feat: 入库改为独立页面,更新 API 说明文档与 Agent skill

- 新增 Ingest.vue 独立入库页(文本/文件/批量 + 任务进度列表),移除入库 Drawer
- 导航栏去掉「入库进度」,新增「文档入库」入口;知识库页「入库」跳转独立页
- API 说明页接口清单与后端对齐(补齐用户管理/上传批量/任务重试删除/reingest 等,修正鉴权标注)
- 更新 qmdsearch-agent skill 与 api-examples(鉴权说明、会话 token、新端点示例)
This commit is contained in:
2026-08-04 20:50:53 +08:00
parent 224eac0048
commit db568740c5
7 changed files with 843 additions and 381 deletions
+2 -12
View File
@@ -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() {
</a-layout-footer>
</a-layout>
<!-- 入库进度右侧 Drawer -->
<TasksDrawer />
<!-- 强制改密弹窗must_change_password 用户不可关闭 -->
<a-modal
v-model:open="passwordModalVisible"
+6
View File
@@ -26,6 +26,12 @@ const routes = [
component: () => 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',
+43 -19
View File
@@ -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 tokenTTL 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: '文件上传入库(multipart202 异步)', 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":"<session-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 <token>" \\
-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 <token>" \\
-F "file=@document.pdf"`
-F "file=@document.pdf" \\
-F "source=manual"`
</script>
<template>
@@ -58,7 +82,7 @@ const uploadExample = `curl -X POST ${baseUrl.value}/api/v1/documents/upload \\
class="apidocs__tip"
type="info"
show-icon
message="需要鉴权的接口请在请求头携带 Authorization: Bearer &lt;token&gt;token 通过 /auth/login 获取。"
message="需要鉴权的接口请在请求头携带 Authorization: Bearer &lt;token&gt;token 通过 /auth/login 获取;用户管理类接口(/auth/users*)仅 admin 角色可用。查询类接口(检索 / 文档列表 / 详情 / 下载 / 类目 / 统计 / 任务状态)免登录。"
/>
<!-- AI Agent Skill 下载 -->
@@ -117,8 +141,8 @@ const uploadExample = `curl -X POST ${baseUrl.value}/api/v1/documents/upload \\
<code class="api-path">{{ text }}</code>
</template>
<template v-else-if="column.key === 'auth'">
<a-tag :color="record.auth ? 'volcano' : 'default'">
{{ record.auth ? '需鉴权' : '否' }}
<a-tag :color="authColor(record.auth)">
{{ authLabel(record.auth) }}
</a-tag>
</template>
</template>
@@ -130,7 +154,7 @@ const uploadExample = `curl -X POST ${baseUrl.value}/api/v1/documents/upload \\
<h3 class="section-subtitle">
<SafetyCertificateOutlined /> 获取 Token
</h3>
<p class="apidocs__p">先调用登录接口获取 JWT再在后续请求 Header 中携带</p>
<p class="apidocs__p">先调用登录接口获取 session token再在后续需要鉴权的请求 Header 中携带</p>
<pre class="code-block">{{ loginExample }}</pre>
</section>
+729
View File
@@ -0,0 +1,729 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { message, Modal } from 'ant-design-vue'
import {
UploadOutlined,
ReloadOutlined,
EyeOutlined,
ArrowLeftOutlined,
UndoOutlined,
DeleteOutlined
} from '@ant-design/icons-vue'
import {
ingest as ingestDocument,
upload as uploadDocument,
uploadBatch,
taskList,
taskStatus,
retryTask,
deleteTask
} from '@/api/documents'
import { useIngestPolling } from '@/composables/useIngestPolling'
import { INGEST_STATUS_TEXT, INGEST_STATUS_COLOR } from '@/constants/ingest'
import { formatSize } from '@/utils/format'
/* ---------- 入库表单 ---------- */
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 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)
await loadTasks(false)
} 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)
await loadTasks(false)
} 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)
}
await loadTasks(false)
} 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' }
]
/* ---------- 入库任务列表 ---------- */
const tasks = ref([])
const total = ref(0)
const isLoading = ref(false)
const isPolling = ref(false)
const selectedTask = ref(null)
const isLoadingDetail = ref(false)
const isRetrying = ref(false)
const isDeleting = ref(false)
let timer = null
function formatTime(iso) {
if (!iso) return '-'
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
const pad = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
function statusText(s) {
return INGEST_STATUS_TEXT[s] || s || '-'
}
function statusColor(s) {
return INGEST_STATUS_COLOR[s] || 'default'
}
async function loadTasks(showSpinner = false) {
if (showSpinner) isLoading.value = true
try {
const data = await taskList(50)
tasks.value = data.items || []
total.value = data.total ?? tasks.value.length
} catch (err) {
message.error(err?.message || '加载入库任务失败')
} finally {
if (showSpinner) isLoading.value = false
}
}
function startListPolling() {
if (timer) return
isPolling.value = true
timer = setInterval(async () => {
await loadTasks(false)
}, 2000)
}
function stopListPolling() {
if (timer) {
clearInterval(timer)
timer = null
}
isPolling.value = false
}
function toggleListPolling() {
if (isPolling.value) stopListPolling()
else startListPolling()
}
async function openDetail(task) {
selectedTask.value = null
isLoadingDetail.value = true
try {
selectedTask.value = await taskStatus(task.task_id)
} catch (err) {
message.error(err?.message || '加载任务详情失败')
} finally {
isLoadingDetail.value = false
}
}
function backToList() {
selectedTask.value = null
}
const detailIsTerminal = computed(() => ['done', 'failed'].includes(selectedTask.value?.status))
const detailResult = computed(() => selectedTask.value?.result || null)
const detailError = computed(() => selectedTask.value?.error || null)
const detailSummary = computed(() => detailResult.value?.summary || {})
const canRetry = computed(() => ['failed', 'done'].includes(selectedTask.value?.status))
const canDelete = computed(() => ['failed', 'done'].includes(selectedTask.value?.status))
const detailComputed = computed(() => {
const task = selectedTask.value
if (!task) return null
const source = task.source || {}
const metadata = source.metadata || {}
return {
title: task.title || source.title || '-',
source: task.source_text || source.source || '-',
size: task.size_bytes != null ? formatSize(task.size_bytes) : formatSize(metadata.original_size_bytes)
}
})
async function handleRetry(task) {
const taskId = task?.task_id || selectedTask.value?.task_id
if (!taskId) return
isRetrying.value = true
try {
const data = await retryTask(taskId)
message.success(`已提交新任务:${data.task_id}`)
selectedTask.value = null
await loadTasks(false)
} catch (err) {
message.error(err?.message || '重试失败')
} finally {
isRetrying.value = false
}
}
function handleDelete(task) {
const taskId = task?.task_id || selectedTask.value?.task_id
if (!taskId) return
Modal.confirm({
title: '删除入库任务',
content: `确定删除任务「${taskId}」吗?删除后不可恢复。`,
okText: '删除',
okType: 'danger',
cancelText: '取消',
async onOk() {
isDeleting.value = true
try {
await deleteTask(taskId)
message.success('任务已删除')
selectedTask.value = null
await loadTasks(false)
} catch (err) {
message.error(err?.message || '删除任务失败')
} finally {
isDeleting.value = false
}
}
})
}
onMounted(() => {
loadTasks(true)
startListPolling()
})
onBeforeUnmount(() => {
stopListPolling()
stopPolling()
})
const columns = [
{ title: '状态', dataIndex: 'status', key: 'status', width: 90 },
{ title: '文件名', dataIndex: 'filename', key: 'filename', ellipsis: true },
{ title: 'task_id', dataIndex: 'task_id', key: 'task_id', width: 200 },
{ title: 'doc_id', dataIndex: 'doc_id', key: 'doc_id', width: 200 },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 160 },
{ title: '操作', dataIndex: 'action', key: 'action', width: 130 }
]
</script>
<template>
<div class="ingest page-section">
<div class="ingest__header">
<h2 class="ingest__title"><UploadOutlined /> 文档入库</h2>
</div>
<div class="ingest__card">
<a-tabs v-model:activeKey="activeTab">
<a-tab-pane key="text" tab="文本入库">
<a-form ref="textFormRef" :model="textForm" :rules="textRules" layout="vertical">
<a-form-item label="标题" name="title">
<a-input v-model:value="textForm.title" placeholder="请输入标题" allow-clear />
</a-form-item>
<a-form-item label="来源" name="source">
<a-input v-model:value="textForm.source" placeholder="例如:manual / web / file" allow-clear />
</a-form-item>
<a-form-item label="正文" name="text">
<a-textarea v-model:value="textForm.text" placeholder="请输入文档正文" :auto-size="{ minRows: 8, maxRows: 18 }" />
</a-form-item>
<a-form-item>
<a-button type="primary" :loading="isSubmittingText || pollState.isPolling" @click="handleSubmitText">
提交入库
</a-button>
</a-form-item>
</a-form>
</a-tab-pane>
<a-tab-pane key="file" tab="文件上传">
<a-form :model="fileForm" layout="vertical">
<a-form-item label="选择文件">
<a-upload :file-list="fileList" :accept="ACCEPTED_EXTENSIONS" :max-count="1" :before-upload="handleFileChange" @remove="handleFileRemove">
<a-button :disabled="fileList.length >= 1">选择文件</a-button>
</a-upload>
<div class="text-muted" style="margin-top: 4px">支持 .txt/.md/.html/.htm/.pdf/.docx</div>
</a-form-item>
<a-form-item label="标题(可选,默认取文件名)" name="title">
<a-input v-model:value="fileForm.title" placeholder="留空则使用文件名去扩展" allow-clear />
</a-form-item>
<a-form-item label="来源(可选,默认 file:原文件名)" name="source">
<a-input v-model:value="fileForm.source" placeholder="例如:manual / web" allow-clear />
</a-form-item>
<a-form-item>
<a-button type="primary" :loading="isSubmittingFile || pollState.isPolling" @click="handleSubmitFile">
上传入库
</a-button>
</a-form-item>
</a-form>
</a-tab-pane>
<a-tab-pane key="batch" tab="批量上传">
<a-form :model="fileForm" layout="vertical">
<a-form-item label="选择文件(可多选)">
<a-upload multiple :file-list="fileList" :accept="ACCEPTED_EXTENSIONS" :before-upload="handleBatchFileChange" @remove="handleBatchRemove">
<a-button>选择文件</a-button>
</a-upload>
<div class="text-muted" style="margin-top: 4px">支持 .txt/.md/.html/.htm/.pdf/.docx可多选逐文件异步入库</div>
</a-form-item>
<a-form-item v-if="fileList.length">
<a-button type="primary" :loading="isSubmittingBatch" @click="handleSubmitBatch">
批量上传入库{{ fileList.length }} 个文件
</a-button>
<a-button style="margin-left: 8px" :disabled="isSubmittingBatch" @click="handleBatchClear">清空</a-button>
</a-form-item>
</a-form>
<div v-if="batchResult" class="ingest__batch">
<a-alert
v-if="batchResult.failed && batchResult.failed.length"
class="ingest__alert"
type="error"
show-icon
:message="`${batchResult.failed.length} 个文件上传失败`"
/>
<a-table
v-if="batchResult.tasks && batchResult.tasks.length"
:data-source="batchResult.tasks"
:columns="batchColumns"
:pagination="false"
size="small"
row-key="task_id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'task_id'">
<a-typography-text copyable :style="{ fontSize: '12px' }">{{ record.task_id }}</a-typography-text>
</template>
</template>
</a-table>
<a-table
v-if="batchResult.failed && batchResult.failed.length"
:data-source="batchResult.failed"
:columns="failedColumns"
:pagination="false"
size="small"
row-key="filename"
style="margin-top: 12px"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'error'">
<span class="text-break">{{ record.error }}</span>
</template>
</template>
</a-table>
</div>
</a-tab-pane>
</a-tabs>
<div v-if="pollState.taskId" class="ingest__result">
<div class="ingest__result-header">
<span>任务已提交{{ pollState.taskId }}</span>
<a-tag :color="statusBadgeColor">{{ statusBadgeText }}</a-tag>
<a-button v-if="pollState.isPolling" type="link" size="small" @click="handleCancelPolling">停止轮询</a-button>
</div>
<a-alert
v-if="pollState.isTimeout"
class="ingest__alert"
type="warning"
show-icon
:message="`任务仍在进行,可稍后凭 task_id 查询:${pollState.taskId}`"
/>
<div v-if="isTerminal && pollState.status === 'done' && resultData" class="ingest__done">
<a-descriptions :column="1" size="small" bordered>
<a-descriptions-item label="document_id">{{ resultData.document_id || '-' }}</a-descriptions-item>
<a-descriptions-item label="类目">{{ resultData.category || '-' }}置信度 {{ resultData.category_confidence ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="标签">
<template v-if="resultData.tags && resultData.tags.length">
<a-tag v-for="tag in resultData.tags" :key="tag">{{ tag }}</a-tag>
</template>
<span v-else class="text-muted">-</span>
</a-descriptions-item>
<a-descriptions-item label="总结层级">{{ summaryData.level ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="chunks_count">{{ resultData.chunks_count ?? 0 }}</a-descriptions-item>
</a-descriptions>
<h3 class="ingest__section-title">L1 总结</h3>
<pre class="ingest__pre">{{ summaryData.l1_summary || '' }}</pre>
</div>
<div v-if="isTerminal && pollState.status === 'failed'" class="ingest__failed">
<a-descriptions :column="1" size="small" bordered>
<a-descriptions-item label="失败阶段">{{ errorData?.stage || '-' }}</a-descriptions-item>
<a-descriptions-item label="错误信息"><span class="text-break">{{ errorData?.message || '-' }}</span></a-descriptions-item>
</a-descriptions>
<template v-if="partialSummary && partialSummary.l1_summary">
<h3 class="ingest__section-title">L1 总结</h3>
<pre class="ingest__pre">{{ partialSummary.l1_summary }}</pre>
</template>
</div>
</div>
</div>
<div class="ingest__card">
<div class="ingest__tasks-header">
<h3 class="ingest__tasks-title"><ClockCircleOutlined /> 入库任务 {{ total }} </h3>
<div class="ingest__tasks-actions">
<a-button size="small" :loading="isLoading" @click="loadTasks(true)">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
<a-button size="small" :type="isPolling ? 'default' : 'primary'" @click="toggleListPolling">
{{ isPolling ? '停止轮询' : '开始轮询' }}
</a-button>
</div>
</div>
<a-alert
v-if="isPolling"
type="info"
show-icon
message="每 2 秒自动刷新近期入库任务。"
style="margin-bottom: 12px"
/>
<!-- 列表视图 -->
<template v-if="!selectedTask">
<a-table
:columns="columns"
:data-source="tasks"
:pagination="false"
:loading="isLoading"
row-key="task_id"
size="small"
:scroll="{ x: 720 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="statusColor(record.status)">{{ statusText(record.status) }}</a-tag>
</template>
<template v-else-if="column.key === 'filename'">
<span :title="record.filename">{{ record.filename || '-' }}</span>
</template>
<template v-else-if="column.key === 'task_id'">
<a-typography-text copyable :style="{ fontSize: '12px' }">{{ record.task_id }}</a-typography-text>
</template>
<template v-else-if="column.key === 'doc_id'">
<a-typography-text
v-if="record.doc_id"
copyable
:style="{ fontSize: '12px' }"
>
{{ record.doc_id }}
</a-typography-text>
<span v-else class="text-muted">-</span>
</template>
<template v-else-if="column.key === 'created_at'">
{{ formatTime(record.created_at) }}
</template>
<template v-else-if="column.key === 'action'">
<a-button type="link" size="small" @click="openDetail(record)">
<template #icon><EyeOutlined /></template>
详情
</a-button>
<a-button
v-if="['failed', 'done'].includes(record.status)"
type="link"
size="small"
@click="handleRetry(record)"
>
<template #icon><UndoOutlined /></template>
重试
</a-button>
<a-button
v-if="['done', 'failed'].includes(record.status)"
type="link"
size="small"
danger
@click="handleDelete(record)"
>
<template #icon><DeleteOutlined /></template>
删除
</a-button>
</template>
</template>
</a-table>
</template>
<!-- 详情视图 -->
<template v-else>
<a-button size="small" type="link" style="margin-bottom: 12px; padding-left: 0" @click="backToList">
<template #icon><ArrowLeftOutlined /></template>
返回列表
</a-button>
<a-spin :spinning="isLoadingDetail">
<template v-if="selectedTask">
<a-descriptions :column="1" size="small" bordered>
<a-descriptions-item label="状态">
<a-tag :color="statusColor(selectedTask.status)">{{ statusText(selectedTask.status) }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="task_id">{{ selectedTask.task_id || '-' }}</a-descriptions-item>
<a-descriptions-item label="文件名">{{ selectedTask.filename || '-' }}</a-descriptions-item>
<a-descriptions-item label="标题">{{ detailComputed?.title || '-' }}</a-descriptions-item>
<a-descriptions-item label="来源">{{ detailComputed?.source || '-' }}</a-descriptions-item>
<a-descriptions-item label="文件大小">{{ detailComputed?.size || '-' }}</a-descriptions-item>
<a-descriptions-item label="doc_id">{{ selectedTask.doc_id || '-' }}</a-descriptions-item>
<a-descriptions-item label="创建时间">{{ formatTime(selectedTask.created_at) }}</a-descriptions-item>
<a-descriptions-item label="更新时间">{{ formatTime(selectedTask.updated_at) }}</a-descriptions-item>
</a-descriptions>
<div v-if="detailIsTerminal && selectedTask.status === 'done' && detailResult" class="ingest__block">
<h4 class="ingest__block-title">入库结果</h4>
<a-descriptions :column="1" size="small" bordered>
<a-descriptions-item label="document_id">{{ detailResult.document_id || '-' }}</a-descriptions-item>
<a-descriptions-item label="类目">{{ detailResult.category || '-' }}置信度 {{ detailResult.category_confidence ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="标签">
<template v-if="detailResult.tags && detailResult.tags.length">
<a-tag v-for="tag in detailResult.tags" :key="tag">{{ tag }}</a-tag>
</template>
<span v-else class="text-muted">-</span>
</a-descriptions-item>
<a-descriptions-item label="总结层级">{{ detailSummary.level ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="chunks_count">{{ detailResult.chunks_count ?? 0 }}</a-descriptions-item>
<a-descriptions-item v-if="detailResult.deduplicated" label="去重">复用既有文档</a-descriptions-item>
</a-descriptions>
<h4 class="ingest__block-title">L1 总结</h4>
<pre class="ingest__pre">{{ detailSummary.l1_summary || '' }}</pre>
</div>
<div v-if="detailIsTerminal && selectedTask.status === 'failed' && detailError" class="ingest__block">
<a-alert
type="error"
show-icon
:message="`失败阶段:${detailError.stage || '-'}`"
:description="detailError.message || '-'"
/>
<template v-if="detailError.partial_summary && detailError.partial_summary.l1_summary">
<h4 class="ingest__block-title">L1 总结部分</h4>
<pre class="ingest__pre">{{ detailError.partial_summary.l1_summary }}</pre>
</template>
</div>
<div class="ingest__actions">
<a-button v-if="canRetry" :loading="isRetrying" @click="handleRetry(selectedTask)">
<template #icon><UndoOutlined /></template>
重试
</a-button>
<a-button v-if="canDelete" danger :loading="isDeleting" @click="handleDelete(selectedTask)">
<template #icon><DeleteOutlined /></template>
删除
</a-button>
</div>
</template>
</a-spin>
</template>
</div>
</div>
</template>
<style scoped>
.ingest__header {
margin-bottom: 12px;
}
.ingest__title {
font-size: 16px;
margin: 0;
display: flex;
align-items: center;
gap: 6px;
}
.ingest__card {
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
.ingest__batch {
margin-top: 8px;
}
.ingest__alert {
margin: 8px 0;
}
.ingest__result {
margin-top: 16px;
border: 1px solid #e5e7eb;
border-radius: 6px;
background: #fafafa;
padding: 12px 16px;
}
.ingest__result-header {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
font-size: 13px;
margin-bottom: 8px;
}
.ingest__section-title {
font-size: 14px;
margin: 16px 0 8px;
color: #374151;
}
.ingest__pre {
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 4px;
padding: 8px;
white-space: pre-wrap;
word-break: break-word;
font-size: 12px;
max-height: 260px;
overflow: auto;
margin: 0;
}
.ingest__tasks-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
flex-wrap: wrap;
gap: 8px;
}
.ingest__tasks-title {
font-size: 15px;
margin: 0;
display: flex;
align-items: center;
gap: 6px;
}
.ingest__tasks-actions {
display: flex;
align-items: center;
gap: 8px;
}
.ingest__block {
margin-top: 16px;
}
.ingest__block-title {
font-size: 13px;
margin: 12px 0 6px;
color: #374151;
}
.ingest__actions {
margin-top: 16px;
display: flex;
gap: 8px;
}
</style>
+4 -331
View File
@@ -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' }
]
</script>
<template>
@@ -391,11 +243,7 @@ const failedColumns = [
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
<a-button @click="tasksDrawer.open()">
<template #icon><ClockCircleOutlined /></template>
入库进度
</a-button>
<a-button type="primary" @click="openIngest">
<a-button type="primary" @click="router.push({ name: 'ingest' })">
<template #icon><UploadOutlined /></template>
入库
</a-button>
@@ -498,156 +346,6 @@ const failedColumns = [
</div>
</div>
</div>
<!-- 入库抽屉 -->
<a-drawer
:open="ingestVisible"
title="文档入库"
placement="right"
width="560"
:destroy-on-close="true"
@close="() => (ingestVisible = false)"
>
<a-tabs v-model:activeKey="activeTab">
<a-tab-pane key="text" tab="文本入库">
<a-form ref="textFormRef" :model="textForm" :rules="textRules" layout="vertical">
<a-form-item label="标题" name="title">
<a-input v-model:value="textForm.title" placeholder="请输入标题" allow-clear />
</a-form-item>
<a-form-item label="来源" name="source">
<a-input v-model:value="textForm.source" placeholder="例如:manual / web / file" allow-clear />
</a-form-item>
<a-form-item label="正文" name="text">
<a-textarea v-model:value="textForm.text" placeholder="请输入文档正文" :auto-size="{ minRows: 8, maxRows: 18 }" />
</a-form-item>
<a-form-item>
<a-button type="primary" :loading="isSubmittingText || pollState.isPolling" @click="handleSubmitText">
提交入库
</a-button>
</a-form-item>
</a-form>
</a-tab-pane>
<a-tab-pane key="file" tab="文件上传">
<a-form :model="fileForm" layout="vertical">
<a-form-item label="选择文件">
<a-upload :file-list="fileList" :accept="ACCEPTED_EXTENSIONS" :max-count="1" :before-upload="handleFileChange" @remove="handleFileRemove">
<a-button :disabled="fileList.length >= 1">选择文件</a-button>
</a-upload>
<div class="text-muted" style="margin-top: 4px">支持 .txt/.md/.html/.htm/.pdf/.docx</div>
</a-form-item>
<a-form-item label="标题(可选,默认取文件名)" name="title">
<a-input v-model:value="fileForm.title" placeholder="留空则使用文件名去扩展" allow-clear />
</a-form-item>
<a-form-item label="来源(可选,默认 file:原文件名)" name="source">
<a-input v-model:value="fileForm.source" placeholder="例如:manual / web" allow-clear />
</a-form-item>
<a-form-item>
<a-button type="primary" :loading="isSubmittingFile || pollState.isPolling" @click="handleSubmitFile">
上传入库
</a-button>
</a-form-item>
</a-form>
</a-tab-pane>
<a-tab-pane key="batch" tab="批量上传">
<a-form :model="fileForm" layout="vertical">
<a-form-item label="选择文件(可多选)">
<a-upload multiple :file-list="fileList" :accept="ACCEPTED_EXTENSIONS" :before-upload="handleBatchFileChange" @remove="handleBatchRemove">
<a-button>选择文件</a-button>
</a-upload>
<div class="text-muted" style="margin-top: 4px">支持 .txt/.md/.html/.htm/.pdf/.docx可多选逐文件异步入库</div>
</a-form-item>
<a-form-item v-if="fileList.length">
<a-button type="primary" :loading="isSubmittingBatch" @click="handleSubmitBatch">
批量上传入库{{ fileList.length }} 个文件
</a-button>
<a-button style="margin-left: 8px" :disabled="isSubmittingBatch" @click="handleBatchClear">清空</a-button>
</a-form-item>
</a-form>
<div v-if="batchResult" class="library__batch">
<a-alert
v-if="batchResult.failed && batchResult.failed.length"
class="library__alert"
type="error"
show-icon
:message="`${batchResult.failed.length} 个文件上传失败`"
/>
<a-table
v-if="batchResult.tasks && batchResult.tasks.length"
:data-source="batchResult.tasks"
:columns="batchColumns"
:pagination="false"
size="small"
row-key="task_id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'task_id'">
<a-typography-text copyable :style="{ fontSize: '12px' }">{{ record.task_id }}</a-typography-text>
</template>
</template>
</a-table>
<a-table
v-if="batchResult.failed && batchResult.failed.length"
:data-source="batchResult.failed"
:columns="failedColumns"
:pagination="false"
size="small"
row-key="filename"
style="margin-top: 12px"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'error'">
<span class="text-break">{{ record.error }}</span>
</template>
</template>
</a-table>
</div>
</a-tab-pane>
</a-tabs>
<div v-if="pollState.taskId" class="library__result">
<div class="library__result-header">
<span>任务已提交{{ pollState.taskId }}</span>
<a-tag :color="statusBadgeColor">{{ statusBadgeText }}</a-tag>
<a-button v-if="pollState.isPolling" type="link" size="small" @click="handleCancelPolling">停止轮询</a-button>
</div>
<a-alert
v-if="pollState.isTimeout"
class="library__alert"
type="warning"
show-icon
:message="`任务仍在进行,可稍后凭 task_id 查询:${pollState.taskId}`"
/>
<div v-if="isTerminal && pollState.status === 'done' && resultData" class="library__done">
<a-descriptions :column="1" size="small" bordered>
<a-descriptions-item label="document_id">{{ resultData.document_id || '-' }}</a-descriptions-item>
<a-descriptions-item label="类目">{{ resultData.category || '-' }}置信度 {{ resultData.category_confidence ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="标签">
<template v-if="resultData.tags && resultData.tags.length">
<a-tag v-for="tag in resultData.tags" :key="tag">{{ tag }}</a-tag>
</template>
<span v-else class="text-muted">-</span>
</a-descriptions-item>
<a-descriptions-item label="总结层级">{{ summaryData.level ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="chunks_count">{{ resultData.chunks_count ?? 0 }}</a-descriptions-item>
</a-descriptions>
<h3 class="library__section-title">L1 总结</h3>
<pre class="library__pre">{{ summaryData.l1_summary || '' }}</pre>
</div>
<div v-if="isTerminal && pollState.status === 'failed'" class="library__failed">
<a-descriptions :column="1" size="small" bordered>
<a-descriptions-item label="失败阶段">{{ errorData?.stage || '-' }}</a-descriptions-item>
<a-descriptions-item label="错误信息"><span class="text-break">{{ errorData?.message || '-' }}</span></a-descriptions-item>
</a-descriptions>
<template v-if="partialSummary && partialSummary.l1_summary">
<h3 class="library__section-title">L1 总结</h3>
<pre class="library__pre">{{ partialSummary.l1_summary }}</pre>
</template>
</div>
</div>
</a-drawer>
</div>
</template>
@@ -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;
+28 -16
View File
@@ -25,8 +25,8 @@ metadata: {"clawdbot":{"emoji":"📚"}}
- `QMDSEARCH_BASE_URL`:服务基地址,例如 `http://localhost:8000` 或 NAS 地址 `http://<nas-ip>:8000`
- `QMDSEARCH_TOKEN`Bearer Token。通过 `POST {BASE}/api/v1/auth/login`(用户名 + 密码)获取;session TTL 12h,失效后重新登录。
> 所有变更类请求(上传 / 删除)与检索请求都必须在 Header 携带 `Authorization: Bearer <token>`。
> 当前代码实现中,查询类只读接口(文档列表 / 详情 / 类目 / 统计 / 健康)同样要求 Bearer(与登录态绑定),请始终携带 token 以避免 `1005` 未认证
> 变更类请求(上传 / 删除 / 重试 / 用户管理)必须携带 `Authorization: Bearer <token>`。
> 查询类只读接口(检索 / 文档列表 / 详情 / 下载 / 类目 / 统计 / 健康 / 任务状态)免登录,无需携带 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":"<password>"}'
# → {"code":0,"data":{"token":"<JWT>","username":"admin","role":"admin","must_change_password":false},"message":"ok"}
# → {"code":0,"data":{"token":"<session-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/<task_id>
```bash
curl -X POST {BASE}/api/v1/search \
-H "Authorization: Bearer <token>" \
-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 |
## 错误码速查
@@ -36,11 +36,32 @@ curl -s -X POST {BASE}/api/v1/documents/upload \
curl -s {BASE}/api/v1/documents/tasks/<task_id>
```
### 检索
### 批量文件上传(202,逐文件建任务)
```bash
curl -s -X POST {BASE}/api/v1/documents/upload-batch \
-H "Authorization: Bearer <token>" \
-F "files=@a.pdf" -F "files=@b.md"
```
### 列出入库任务 / 重试 / 删除任务
```bash
curl -s -H "Authorization: Bearer <token>" "{BASE}/api/v1/documents/tasks?limit=20"
curl -s -X POST -H "Authorization: Bearer <token>" {BASE}/api/v1/documents/tasks/<task_id>/retry
curl -s -X DELETE -H "Authorization: Bearer <token>" {BASE}/api/v1/documents/tasks/<task_id>
```
### 重新摘要入库(读原文件 → 删旧 → 重跑)
```bash
curl -s -X POST -H "Authorization: Bearer <token>" {BASE}/api/v1/documents/<doc_id>/reingest
```
### 检索(免登录)
```bash
curl -s -X POST {BASE}/api/v1/search \
-H "Authorization: Bearer <token>" \
-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?}`