diff --git a/frontend/src/layouts/MainLayout.vue b/frontend/src/layouts/MainLayout.vue index d6c2947..a96fe52 100644 --- a/frontend/src/layouts/MainLayout.vue +++ b/frontend/src/layouts/MainLayout.vue @@ -13,19 +13,16 @@ import { DatabaseOutlined, ApiOutlined, TeamOutlined, - ClockCircleOutlined + UploadOutlined } from '@ant-design/icons-vue' import { useAuthStore } from '@/stores/useAuthStore' import { changeMyPassword } from '@/api/auth' import { callApi } from '@/api/client' -import TasksDrawer from '@/views/TasksDrawer.vue' -import { useTasksDrawer } from '@/composables/useTasksDrawer' const route = useRoute() const router = useRouter() const authStore = useAuthStore() const { user, displayName } = storeToRefs(authStore) -const tasksDrawer = useTasksDrawer() const collapsed = ref(false) @@ -39,7 +36,7 @@ const menuItems = computed(() => { const items = [ { key: 'overview', icon: () => h(DashboardOutlined), label: '概览与检索' }, { key: 'library', icon: () => h(AppstoreOutlined), label: '知识库' }, - { key: 'tasks', icon: () => h(ClockCircleOutlined), label: '入库进度' }, + { key: 'ingest', icon: () => h(UploadOutlined), label: '文档入库' }, { key: 'settings', icon: () => h(SettingOutlined), label: '设置' }, { key: 'api-docs', icon: () => h(ApiOutlined), label: 'API 说明' } ] @@ -50,10 +47,6 @@ const menuItems = computed(() => { }) function handleMenuClick({ key }) { - if (key === 'tasks') { - tasksDrawer.open() - return - } if (key && key !== route.name) { router.push({ name: key }) } @@ -195,9 +188,6 @@ async function handleSubmitPassword() { - - - import('@/views/Library.vue'), meta: { title: '知识库', requiresAuth: true } }, + { + path: 'ingest', + name: 'ingest', + component: () => import('@/views/Ingest.vue'), + meta: { title: '文档入库', requiresAuth: true } + }, { path: 'settings', name: 'settings', diff --git a/frontend/src/views/ApiDocs.vue b/frontend/src/views/ApiDocs.vue index b94948c..f457ab9 100644 --- a/frontend/src/views/ApiDocs.vue +++ b/frontend/src/views/ApiDocs.vue @@ -2,21 +2,33 @@ import { computed } from 'vue' import { ApiOutlined, SafetyCertificateOutlined, CodeOutlined, DownloadOutlined } from '@ant-design/icons-vue' -// 以下内容整理自项目 README.md 的「API 文档」章节 +// 以下内容与后端 app/api/v1 的路由实现保持一致 const apiList = [ { method: 'GET', path: '/api/v1/health', desc: '健康检查', auth: false }, - { method: 'POST', path: '/api/v1/auth/register', desc: '用户注册(可关闭)', auth: false }, - { method: 'POST', path: '/api/v1/auth/login', desc: '用户登录,返回 JWT token', auth: false }, - { method: 'GET', path: '/api/v1/auth/me', desc: '获取当前用户信息', auth: true }, - { method: 'POST', path: '/api/v1/search', desc: '分层检索', auth: true }, + { method: 'POST', path: '/api/v1/auth/login', desc: '用户登录,签发 session token(TTL 12h)', auth: false }, + { method: 'POST', path: '/api/v1/auth/logout', desc: '退出登录(删除当前会话)', auth: true }, + { method: 'POST', path: '/api/v1/auth/password', desc: '修改自己的密码', auth: true }, + { method: 'GET', path: '/api/v1/auth/me', desc: '当前登录用户信息(脱敏)', auth: true }, + { method: 'GET', path: '/api/v1/auth/users', desc: '用户列表(脱敏)', auth: 'admin' }, + { method: 'POST', path: '/api/v1/auth/users', desc: '创建用户', auth: 'admin' }, + { method: 'PATCH', path: '/api/v1/auth/users/{username}', desc: '更新用户角色 / 启用状态', auth: 'admin' }, + { method: 'POST', path: '/api/v1/auth/users/{username}/password', desc: '重置指定用户密码', auth: 'admin' }, + { method: 'DELETE', path: '/api/v1/auth/users/{username}', desc: '删除用户', auth: 'admin' }, + { method: 'POST', path: '/api/v1/search', desc: '分层检索(L1→L2→L3→chunk)', auth: false }, { method: 'POST', path: '/api/v1/documents', desc: '文档入库(JSON 文本,202 异步入库)', auth: true }, { method: 'POST', path: '/api/v1/documents/upload', desc: '文件上传入库(multipart,202 异步)', auth: true }, - { method: 'GET', path: '/api/v1/documents/tasks/{task_id}', desc: '入库任务状态查询', auth: true }, - { method: 'GET', path: '/api/v1/documents', desc: '文档列表(分页)', auth: true }, - { method: 'GET', path: '/api/v1/documents/{doc_id}', desc: '文档详情', auth: true }, + { method: 'POST', path: '/api/v1/documents/upload-batch', desc: '批量文件上传入库(多文件,202 异步)', auth: true }, + { method: 'GET', path: '/api/v1/documents/tasks', desc: '入库任务列表(按时间降序)', auth: true }, + { method: 'GET', path: '/api/v1/documents/tasks/{task_id}', desc: '入库任务状态查询', auth: false }, + { method: 'POST', path: '/api/v1/documents/tasks/{task_id}/retry', desc: '重试入库任务(返回新 task_id)', auth: true }, + { method: 'DELETE', path: '/api/v1/documents/tasks/{task_id}', desc: '删除入库任务(幂等)', auth: true }, + { method: 'GET', path: '/api/v1/documents', desc: '文档列表(limit/offset 分页)', auth: false }, + { method: 'GET', path: '/api/v1/documents/{doc_id}', desc: '文档详情', auth: false }, + { method: 'GET', path: '/api/v1/documents/{doc_id}/file', desc: '下载关联的原始文件', auth: false }, + { method: 'POST', path: '/api/v1/documents/{doc_id}/reingest', desc: '重新摘要入库(读原文件→删旧→重跑)', auth: true }, { method: 'DELETE', path: '/api/v1/documents/{doc_id}', desc: '删除文档(幂等)', auth: true }, - { method: 'GET', path: '/api/v1/knowledge/categories', desc: '知识分类类目集', auth: true }, - { method: 'GET', path: '/api/v1/knowledge/stats', desc: '统计(四层点数 + 类目分布)', auth: true }, + { method: 'GET', path: '/api/v1/knowledge/categories', desc: '知识分类类目集', auth: false }, + { method: 'GET', path: '/api/v1/knowledge/stats', desc: '统计(四层点数 + 类目分布)', auth: false }, { method: 'GET', path: '/admin', desc: '管理后台(本页)', auth: false } ] @@ -24,7 +36,18 @@ const methodColor = { GET: 'green', POST: 'blue', DELETE: 'red', - PUT: 'orange' + PUT: 'orange', + PATCH: 'purple' +} + +function authLabel(auth) { + if (auth === 'admin') return '仅 admin' + return auth ? '需鉴权' : '否' +} + +function authColor(auth) { + if (auth === 'admin') return 'gold' + return auth ? 'volcano' : 'default' } const baseUrl = computed(() => `${window.location.origin}/admin/`.replace(/\/admin\/$/, '')) @@ -33,16 +56,17 @@ const agentSkillUrl = computed(() => `${window.location.origin}/agent-skill`) const loginExample = `curl -X POST ${baseUrl.value}/api/v1/auth/login \\ -H "Content-Type: application/json" \\ - -d '{"username": "admin", "password": "your-password"}'` + -d '{"username": "admin", "password": "your-password"}' +# → {"code":0,"data":{"token":"","username":"admin","role":"admin","must_change_password":false},"message":"ok"}` const searchExample = `curl -X POST ${baseUrl.value}/api/v1/search \\ -H "Content-Type: application/json" \\ - -H "Authorization: Bearer " \\ - -d '{"query": "如何配置 Redis 缓存", "top_k": 5}'` + -d '{"query": "如何配置 Redis 缓存", "top_k": 5, "summarize": false}'` const uploadExample = `curl -X POST ${baseUrl.value}/api/v1/documents/upload \\ -H "Authorization: Bearer " \\ - -F "file=@document.pdf"` + -F "file=@document.pdf" \\ + -F "source=manual"` @@ -130,7 +154,7 @@ const uploadExample = `curl -X POST ${baseUrl.value}/api/v1/documents/upload \\

获取 Token

-

先调用登录接口获取 JWT,再在后续请求的 Header 中携带:

+

先调用登录接口获取 session token,再在后续需要鉴权的请求 Header 中携带:

{{ loginExample }}
diff --git a/frontend/src/views/Ingest.vue b/frontend/src/views/Ingest.vue new file mode 100644 index 0000000..818c10f --- /dev/null +++ b/frontend/src/views/Ingest.vue @@ -0,0 +1,729 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/Library.vue b/frontend/src/views/Library.vue index fd557e7..4827b8a 100644 --- a/frontend/src/views/Library.vue +++ b/frontend/src/views/Library.vue @@ -4,12 +4,11 @@ import { message, Modal } from 'ant-design-vue' import { FolderOutlined, FileTextOutlined, - UploadOutlined, ReloadOutlined, SearchOutlined, - ClockCircleOutlined, TagsOutlined } from '@ant-design/icons-vue' +import { useRouter } from 'vue-router' import { categories as fetchCategories, stats as fetchStats @@ -18,16 +17,10 @@ import { list as fetchDocuments, detail as fetchDocumentDetail, remove as deleteDocument, - reingest as reingestDocument, - ingest as ingestDocument, - upload as uploadDocument, - uploadBatch + reingest as reingestDocument } from '@/api/documents' -import { useIngestPolling } from '@/composables/useIngestPolling' -import { useTasksDrawer } from '@/composables/useTasksDrawer' -import { INGEST_STATUS_TEXT, INGEST_STATUS_COLOR } from '@/constants/ingest' -const tasksDrawer = useTasksDrawer() +const router = useRouter() /* ---------- 树形目录 ---------- */ const isLoading = ref(false) @@ -231,147 +224,6 @@ function handleReingest(doc) { } }) } - -/* ---------- 入库抽屉 ---------- */ -const ingestVisible = ref(false) -const activeTab = ref('text') -const { state: pollState, startPolling, stopPolling } = useIngestPolling() - -const textFormRef = ref(null) -const textForm = reactive({ title: '', source: '', text: '' }) -const textRules = { - title: [{ required: true, message: '请输入标题', trigger: 'blur' }], - text: [{ required: true, message: '请输入正文', trigger: 'blur' }] -} -const isSubmittingText = ref(false) -const isSubmittingFile = ref(false) -const isSubmittingBatch = ref(false) - -const fileForm = reactive({ title: '', source: '' }) -const fileList = ref([]) -const rawFile = ref(null) -const batchResult = ref(null) -const ACCEPTED_EXTENSIONS = '.txt,.md,.html,.htm,.pdf,.docx' - -const statusBadgeColor = computed(() => INGEST_STATUS_COLOR[pollState.status] || 'default') -const statusBadgeText = computed(() => INGEST_STATUS_TEXT[pollState.status] || pollState.status || '-') -const isTerminal = computed(() => ['done', 'failed'].includes(pollState.status)) -const resultData = computed(() => pollState.task?.result || null) -const errorData = computed(() => pollState.task?.error || pollState.error || null) -const summaryData = computed(() => resultData.value?.summary || {}) -const partialSummary = computed(() => errorData.value?.partial_summary || null) - -function openIngest() { - ingestVisible.value = true -} - -function handleFileChange(file) { - rawFile.value = file - fileList.value = [file] - return false -} - -function handleFileRemove() { - rawFile.value = null - fileList.value = [] -} - -function handleBatchFileChange(_file, fileListArg) { - fileList.value = fileListArg.map((f) => f.originFileObj || f) - batchResult.value = null - return false -} - -function handleBatchRemove(file) { - fileList.value = fileList.value.filter((f) => f !== file && f.name !== file.name) - batchResult.value = null -} - -function handleBatchClear() { - fileList.value = [] - batchResult.value = null -} - -async function handleSubmitText() { - try { - await textFormRef.value.validate() - } catch { - return - } - isSubmittingText.value = true - try { - const payload = { title: textForm.title, text: textForm.text } - if (textForm.source) payload.source = textForm.source - const data = await ingestDocument(payload) - message.success('任务已提交') - startPolling(data.task_id) - } catch (err) { - message.error(err?.message || '提交入库失败') - } finally { - isSubmittingText.value = false - } -} - -async function handleSubmitFile() { - if (!rawFile.value) { - message.warning('请选择文件') - return - } - isSubmittingFile.value = true - try { - const formData = new FormData() - formData.append('file', rawFile.value) - if (fileForm.title) formData.append('title', fileForm.title) - if (fileForm.source) formData.append('source', fileForm.source) - const data = await uploadDocument(formData) - message.success('任务已提交') - startPolling(data.task_id) - } catch (err) { - message.error(err?.message || '上传入库失败') - } finally { - isSubmittingFile.value = false - } -} - -async function handleSubmitBatch() { - if (!fileList.value.length) { - message.warning('请选择文件') - return - } - isSubmittingBatch.value = true - try { - const data = await uploadBatch(fileList.value) - batchResult.value = data - const okCount = data.tasks?.length || 0 - const failCount = data.failed?.length || 0 - if (okCount > 0) { - message.success(`已提交 ${okCount} 个任务${failCount > 0 ? `,${failCount} 个失败` : ''}`) - } else if (failCount > 0) { - message.error(`全部 ${failCount} 个文件上传失败`) - } - if (data.tasks?.length === 1 && failCount === 0) { - startPolling(data.tasks[0].task_id) - } - } catch (err) { - message.error(err?.message || '批量上传失败') - } finally { - isSubmittingBatch.value = false - } -} - -function handleCancelPolling() { - stopPolling() - message.info('已停止轮询') -} - -const batchColumns = [ - { title: '文件名', dataIndex: 'filename', key: 'filename', ellipsis: true }, - { title: 'task_id', dataIndex: 'task_id', key: 'task_id', width: 240 } -] -const failedColumns = [ - { title: '文件名', dataIndex: 'filename', key: 'filename', ellipsis: true }, - { title: '错误', dataIndex: 'error', key: 'error' } -] @@ -789,31 +487,6 @@ const failedColumns = [ margin-bottom: 4px; } -.library__result { - margin-top: 16px; - border: 1px solid #e5e7eb; - border-radius: 6px; - background: #fafafa; - padding: 12px 16px; -} - -.library__result-header { - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; - font-size: 13px; - margin-bottom: 8px; -} - -.library__alert { - margin: 8px 0; -} - -.library__batch { - margin-top: 8px; -} - @media (max-width: 768px) { .library__body { flex-direction: column; diff --git a/skills/qmdsearch-agent/SKILL.md b/skills/qmdsearch-agent/SKILL.md index 84b290c..aee8e8e 100644 --- a/skills/qmdsearch-agent/SKILL.md +++ b/skills/qmdsearch-agent/SKILL.md @@ -25,8 +25,8 @@ metadata: {"clawdbot":{"emoji":"📚"}} - `QMDSEARCH_BASE_URL`:服务基地址,例如 `http://localhost:8000` 或 NAS 地址 `http://:8000`。 - `QMDSEARCH_TOKEN`:Bearer Token。通过 `POST {BASE}/api/v1/auth/login`(用户名 + 密码)获取;session TTL 12h,失效后重新登录。 -> 所有变更类请求(上传 / 删除)与检索请求都必须在 Header 携带 `Authorization: Bearer `。 -> 当前代码实现中,查询类只读接口(文档列表 / 详情 / 类目 / 统计 / 健康)同样要求 Bearer(与登录态绑定),请始终携带 token 以避免 `1005` 未认证。 +> 变更类请求(上传 / 删除 / 重试 / 用户管理)必须携带 `Authorization: Bearer `。 +> 查询类只读接口(检索 / 文档列表 / 详情 / 下载 / 类目 / 统计 / 健康 / 任务状态)免登录,无需携带 token。 ## 统一约定 @@ -40,7 +40,7 @@ metadata: {"clawdbot":{"emoji":"📚"}} curl -X POST {BASE}/api/v1/auth/login \ -H "Content-Type: application/json" \ -d '{"username":"admin","password":""}' -# → {"code":0,"data":{"token":"","username":"admin","role":"admin","must_change_password":false},"message":"ok"} +# → {"code":0,"data":{"token":"","username":"admin","role":"admin","must_change_password":false},"message":"ok"} ``` `must_change_password=true` 时,登录成功但调用其他接口会返回 `1006`,需先 `POST /api/v1/auth/password` 改密。 @@ -96,7 +96,6 @@ curl {BASE}/api/v1/documents/tasks/ ```bash curl -X POST {BASE}/api/v1/search \ - -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"query":"如何配置 Redis 缓存","top_k":5,"summarize":false}' ``` @@ -111,18 +110,31 @@ curl -X POST {BASE}/api/v1/search \ ## 6. 其他管理接口(按需) -| 方法 | 路径 | 说明 | -|------|------|------| -| POST | `/documents` | 文本入库(202 + task_id) | -| POST | `/documents/upload` | 文件入库(202 + task_id) | -| GET | `/documents/tasks/{task_id}` | 任务状态(done / failed) | -| GET | `/documents?limit=&offset=` | 文档列表(分页游标) | -| GET | `/documents/{doc_id}` | 文档详情(L1 + L2/L3 + chunks 数) | -| GET | `/documents/{doc_id}/file` | 下载原始文件 | -| DELETE | `/documents/{doc_id}` | 删除文档(幂等) | -| GET | `/knowledge/categories` | 知识分类类目集 | -| GET | `/knowledge/stats` | 统计(四层点数 + 类目分布) | -| GET | `/health` | 健康检查 | +| 方法 | 路径 | 说明 | 鉴权 | +|------|------|------|------| +| POST | `/documents` | 文本入库(202 + task_id) | Bearer | +| POST | `/documents/upload` | 文件入库(202 + task_id) | Bearer | +| POST | `/documents/upload-batch` | 批量文件入库(202,逐文件建任务) | Bearer | +| GET | `/documents/tasks` | 入库任务列表(按时间降序) | Bearer | +| GET | `/documents/tasks/{task_id}` | 任务状态(done / failed) | 免登录 | +| POST | `/documents/tasks/{task_id}/retry` | 重试入库任务(返回新 task_id) | Bearer | +| DELETE | `/documents/tasks/{task_id}` | 删除入库任务(幂等) | Bearer | +| GET | `/documents?limit=&offset=` | 文档列表(分页游标) | 免登录 | +| GET | `/documents/{doc_id}` | 文档详情(L1 + L2/L3 + chunks 数) | 免登录 | +| GET | `/documents/{doc_id}/file` | 下载原始文件 | 免登录 | +| POST | `/documents/{doc_id}/reingest` | 重新摘要入库(读原文件→删旧→重跑) | Bearer | +| DELETE | `/documents/{doc_id}` | 删除文档(幂等) | Bearer | +| GET | `/knowledge/categories` | 知识分类类目集 | 免登录 | +| GET | `/knowledge/stats` | 统计(四层点数 + 类目分布) | 免登录 | +| GET | `/health` | 健康检查 | 免登录 | +| POST | `/auth/logout` | 退出登录 | Bearer | +| POST | `/auth/password` | 修改自己的密码 | Bearer | +| GET | `/auth/me` | 当前用户信息(脱敏) | Bearer | +| GET | `/auth/users` | 用户列表(脱敏) | Bearer + admin | +| POST | `/auth/users` | 创建用户 | Bearer + admin | +| PATCH | `/auth/users/{username}` | 更新用户角色 / 启用状态 | Bearer + admin | +| POST | `/auth/users/{username}/password` | 重置指定用户密码 | Bearer + admin | +| DELETE | `/auth/users/{username}` | 删除用户 | Bearer + admin | ## 错误码速查 diff --git a/skills/qmdsearch-agent/references/api-examples.md b/skills/qmdsearch-agent/references/api-examples.md index 228a156..5340aa1 100644 --- a/skills/qmdsearch-agent/references/api-examples.md +++ b/skills/qmdsearch-agent/references/api-examples.md @@ -36,11 +36,32 @@ curl -s -X POST {BASE}/api/v1/documents/upload \ curl -s {BASE}/api/v1/documents/tasks/ ``` -### 检索 +### 批量文件上传(202,逐文件建任务) + +```bash +curl -s -X POST {BASE}/api/v1/documents/upload-batch \ + -H "Authorization: Bearer " \ + -F "files=@a.pdf" -F "files=@b.md" +``` + +### 列出入库任务 / 重试 / 删除任务 + +```bash +curl -s -H "Authorization: Bearer " "{BASE}/api/v1/documents/tasks?limit=20" +curl -s -X POST -H "Authorization: Bearer " {BASE}/api/v1/documents/tasks//retry +curl -s -X DELETE -H "Authorization: Bearer " {BASE}/api/v1/documents/tasks/ +``` + +### 重新摘要入库(读原文件 → 删旧 → 重跑) + +```bash +curl -s -X POST -H "Authorization: Bearer " {BASE}/api/v1/documents//reingest +``` + +### 检索(免登录) ```bash curl -s -X POST {BASE}/api/v1/search \ - -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"query":"如何配置 Redis 缓存","top_k":5,"summarize":false}' ``` @@ -120,6 +141,13 @@ class QMDSearchClient: headers=self._headers(), files=files, data=data, timeout=60) return self._ok(resp)["task_id"] + def upload_batch(self, paths: list[str]) -> dict: + """批量上传多个文件:返回 {tasks:[{filename,task_id}], failed:[{filename,error}]}""" + files = [("files", open(p, "rb")) for p in paths] + resp = requests.post(f"{self.base}/api/v1/documents/upload-batch", + headers=self._headers(), files=files, timeout=60) + return self._ok(resp) + # ---- 轮询 ---- def wait_task(self, task_id: str) -> dict: deadline = time.time() + self.poll_timeout @@ -165,6 +193,6 @@ if __name__ == "__main__": ## 三、响应结构速记 -- 文本 / 文件入库 `202` → `data = {task_id, status:"pending"[, saved_path]}` +- 文本 / 文件 / 批量入库 `202` → `data = {task_id, status:"pending"[, saved_path]}`;批量返回 `{tasks, failed}` - 任务 `done` → `data.result = {document_id, summary:{l1_summary,l2_outline,l3_content_outline,level}, category, chunks_count, tags, category_confidence, deduplicated}` - 检索 → `data = {query, hits:[{text,doc_id,title,section_path,score,doc_summary}], routed_categories, fallback, extracted_info, summary?}`