feat: 入库改为独立页面,更新 API 说明文档与 Agent skill
- 新增 Ingest.vue 独立入库页(文本/文件/批量 + 任务进度列表),移除入库 Drawer - 导航栏去掉「入库进度」,新增「文档入库」入口;知识库页「入库」跳转独立页 - API 说明页接口清单与后端对齐(补齐用户管理/上传批量/任务重试删除/reingest 等,修正鉴权标注) - 更新 qmdsearch-agent skill 与 api-examples(鉴权说明、会话 token、新端点示例)
This commit is contained in:
@@ -13,19 +13,16 @@ import {
|
|||||||
DatabaseOutlined,
|
DatabaseOutlined,
|
||||||
ApiOutlined,
|
ApiOutlined,
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
ClockCircleOutlined
|
UploadOutlined
|
||||||
} from '@ant-design/icons-vue'
|
} from '@ant-design/icons-vue'
|
||||||
import { useAuthStore } from '@/stores/useAuthStore'
|
import { useAuthStore } from '@/stores/useAuthStore'
|
||||||
import { changeMyPassword } from '@/api/auth'
|
import { changeMyPassword } from '@/api/auth'
|
||||||
import { callApi } from '@/api/client'
|
import { callApi } from '@/api/client'
|
||||||
import TasksDrawer from '@/views/TasksDrawer.vue'
|
|
||||||
import { useTasksDrawer } from '@/composables/useTasksDrawer'
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const { user, displayName } = storeToRefs(authStore)
|
const { user, displayName } = storeToRefs(authStore)
|
||||||
const tasksDrawer = useTasksDrawer()
|
|
||||||
|
|
||||||
const collapsed = ref(false)
|
const collapsed = ref(false)
|
||||||
|
|
||||||
@@ -39,7 +36,7 @@ const menuItems = computed(() => {
|
|||||||
const items = [
|
const items = [
|
||||||
{ key: 'overview', icon: () => h(DashboardOutlined), label: '概览与检索' },
|
{ key: 'overview', icon: () => h(DashboardOutlined), label: '概览与检索' },
|
||||||
{ key: 'library', icon: () => h(AppstoreOutlined), 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: 'settings', icon: () => h(SettingOutlined), label: '设置' },
|
||||||
{ key: 'api-docs', icon: () => h(ApiOutlined), label: 'API 说明' }
|
{ key: 'api-docs', icon: () => h(ApiOutlined), label: 'API 说明' }
|
||||||
]
|
]
|
||||||
@@ -50,10 +47,6 @@ const menuItems = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function handleMenuClick({ key }) {
|
function handleMenuClick({ key }) {
|
||||||
if (key === 'tasks') {
|
|
||||||
tasksDrawer.open()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (key && key !== route.name) {
|
if (key && key !== route.name) {
|
||||||
router.push({ name: key })
|
router.push({ name: key })
|
||||||
}
|
}
|
||||||
@@ -195,9 +188,6 @@ async function handleSubmitPassword() {
|
|||||||
</a-layout-footer>
|
</a-layout-footer>
|
||||||
</a-layout>
|
</a-layout>
|
||||||
|
|
||||||
<!-- 入库进度右侧 Drawer -->
|
|
||||||
<TasksDrawer />
|
|
||||||
|
|
||||||
<!-- 强制改密弹窗(must_change_password 用户,不可关闭) -->
|
<!-- 强制改密弹窗(must_change_password 用户,不可关闭) -->
|
||||||
<a-modal
|
<a-modal
|
||||||
v-model:open="passwordModalVisible"
|
v-model:open="passwordModalVisible"
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ const routes = [
|
|||||||
component: () => import('@/views/Library.vue'),
|
component: () => import('@/views/Library.vue'),
|
||||||
meta: { title: '知识库', requiresAuth: true }
|
meta: { title: '知识库', requiresAuth: true }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'ingest',
|
||||||
|
name: 'ingest',
|
||||||
|
component: () => import('@/views/Ingest.vue'),
|
||||||
|
meta: { title: '文档入库', requiresAuth: true }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'settings',
|
path: 'settings',
|
||||||
name: 'settings',
|
name: 'settings',
|
||||||
|
|||||||
@@ -2,21 +2,33 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { ApiOutlined, SafetyCertificateOutlined, CodeOutlined, DownloadOutlined } from '@ant-design/icons-vue'
|
import { ApiOutlined, SafetyCertificateOutlined, CodeOutlined, DownloadOutlined } from '@ant-design/icons-vue'
|
||||||
|
|
||||||
// 以下内容整理自项目 README.md 的「API 文档」章节
|
// 以下内容与后端 app/api/v1 的路由实现保持一致
|
||||||
const apiList = [
|
const apiList = [
|
||||||
{ method: 'GET', path: '/api/v1/health', desc: '健康检查', auth: false },
|
{ 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: '用户登录,签发 session token(TTL 12h)', auth: false },
|
||||||
{ method: 'POST', path: '/api/v1/auth/login', desc: '用户登录,返回 JWT token', auth: false },
|
{ method: 'POST', path: '/api/v1/auth/logout', desc: '退出登录(删除当前会话)', auth: true },
|
||||||
{ method: 'GET', path: '/api/v1/auth/me', desc: '获取当前用户信息', auth: true },
|
{ method: 'POST', path: '/api/v1/auth/password', desc: '修改自己的密码', auth: true },
|
||||||
{ method: 'POST', path: '/api/v1/search', 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', desc: '文档入库(JSON 文本,202 异步入库)', auth: true },
|
||||||
{ method: 'POST', path: '/api/v1/documents/upload', desc: '文件上传入库(multipart,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: 'POST', path: '/api/v1/documents/upload-batch', desc: '批量文件上传入库(多文件,202 异步)', auth: true },
|
||||||
{ method: 'GET', path: '/api/v1/documents', desc: '文档列表(分页)', auth: true },
|
{ method: 'GET', path: '/api/v1/documents/tasks', desc: '入库任务列表(按时间降序)', auth: true },
|
||||||
{ method: 'GET', path: '/api/v1/documents/{doc_id}', 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: '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/categories', desc: '知识分类类目集', auth: false },
|
||||||
{ method: 'GET', path: '/api/v1/knowledge/stats', desc: '统计(四层点数 + 类目分布)', auth: true },
|
{ method: 'GET', path: '/api/v1/knowledge/stats', desc: '统计(四层点数 + 类目分布)', auth: false },
|
||||||
{ method: 'GET', path: '/admin', desc: '管理后台(本页)', auth: false }
|
{ method: 'GET', path: '/admin', desc: '管理后台(本页)', auth: false }
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -24,7 +36,18 @@ const methodColor = {
|
|||||||
GET: 'green',
|
GET: 'green',
|
||||||
POST: 'blue',
|
POST: 'blue',
|
||||||
DELETE: 'red',
|
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\/$/, ''))
|
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 \\
|
const loginExample = `curl -X POST ${baseUrl.value}/api/v1/auth/login \\
|
||||||
-H "Content-Type: application/json" \\
|
-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 \\
|
const searchExample = `curl -X POST ${baseUrl.value}/api/v1/search \\
|
||||||
-H "Content-Type: application/json" \\
|
-H "Content-Type: application/json" \\
|
||||||
-H "Authorization: Bearer <token>" \\
|
-d '{"query": "如何配置 Redis 缓存", "top_k": 5, "summarize": false}'`
|
||||||
-d '{"query": "如何配置 Redis 缓存", "top_k": 5}'`
|
|
||||||
|
|
||||||
const uploadExample = `curl -X POST ${baseUrl.value}/api/v1/documents/upload \\
|
const uploadExample = `curl -X POST ${baseUrl.value}/api/v1/documents/upload \\
|
||||||
-H "Authorization: Bearer <token>" \\
|
-H "Authorization: Bearer <token>" \\
|
||||||
-F "file=@document.pdf"`
|
-F "file=@document.pdf" \\
|
||||||
|
-F "source=manual"`
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -58,7 +82,7 @@ const uploadExample = `curl -X POST ${baseUrl.value}/api/v1/documents/upload \\
|
|||||||
class="apidocs__tip"
|
class="apidocs__tip"
|
||||||
type="info"
|
type="info"
|
||||||
show-icon
|
show-icon
|
||||||
message="需要鉴权的接口请在请求头携带 Authorization: Bearer <token>,token 通过 /auth/login 获取。"
|
message="需要鉴权的接口请在请求头携带 Authorization: Bearer <token>,token 通过 /auth/login 获取;用户管理类接口(/auth/users*)仅 admin 角色可用。查询类接口(检索 / 文档列表 / 详情 / 下载 / 类目 / 统计 / 任务状态)免登录。"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- AI Agent Skill 下载 -->
|
<!-- AI Agent Skill 下载 -->
|
||||||
@@ -117,8 +141,8 @@ const uploadExample = `curl -X POST ${baseUrl.value}/api/v1/documents/upload \\
|
|||||||
<code class="api-path">{{ text }}</code>
|
<code class="api-path">{{ text }}</code>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'auth'">
|
<template v-else-if="column.key === 'auth'">
|
||||||
<a-tag :color="record.auth ? 'volcano' : 'default'">
|
<a-tag :color="authColor(record.auth)">
|
||||||
{{ record.auth ? '需鉴权' : '否' }}
|
{{ authLabel(record.auth) }}
|
||||||
</a-tag>
|
</a-tag>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
@@ -130,7 +154,7 @@ const uploadExample = `curl -X POST ${baseUrl.value}/api/v1/documents/upload \\
|
|||||||
<h3 class="section-subtitle">
|
<h3 class="section-subtitle">
|
||||||
<SafetyCertificateOutlined /> 获取 Token
|
<SafetyCertificateOutlined /> 获取 Token
|
||||||
</h3>
|
</h3>
|
||||||
<p class="apidocs__p">先调用登录接口获取 JWT,再在后续请求的 Header 中携带:</p>
|
<p class="apidocs__p">先调用登录接口获取 session token,再在后续需要鉴权的请求 Header 中携带:</p>
|
||||||
<pre class="code-block">{{ loginExample }}</pre>
|
<pre class="code-block">{{ loginExample }}</pre>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -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,12 +4,11 @@ import { message, Modal } from 'ant-design-vue'
|
|||||||
import {
|
import {
|
||||||
FolderOutlined,
|
FolderOutlined,
|
||||||
FileTextOutlined,
|
FileTextOutlined,
|
||||||
UploadOutlined,
|
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
ClockCircleOutlined,
|
|
||||||
TagsOutlined
|
TagsOutlined
|
||||||
} from '@ant-design/icons-vue'
|
} from '@ant-design/icons-vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import {
|
import {
|
||||||
categories as fetchCategories,
|
categories as fetchCategories,
|
||||||
stats as fetchStats
|
stats as fetchStats
|
||||||
@@ -18,16 +17,10 @@ import {
|
|||||||
list as fetchDocuments,
|
list as fetchDocuments,
|
||||||
detail as fetchDocumentDetail,
|
detail as fetchDocumentDetail,
|
||||||
remove as deleteDocument,
|
remove as deleteDocument,
|
||||||
reingest as reingestDocument,
|
reingest as reingestDocument
|
||||||
ingest as ingestDocument,
|
|
||||||
upload as uploadDocument,
|
|
||||||
uploadBatch
|
|
||||||
} from '@/api/documents'
|
} 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)
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -391,11 +243,7 @@ const failedColumns = [
|
|||||||
<template #icon><ReloadOutlined /></template>
|
<template #icon><ReloadOutlined /></template>
|
||||||
刷新
|
刷新
|
||||||
</a-button>
|
</a-button>
|
||||||
<a-button @click="tasksDrawer.open()">
|
<a-button type="primary" @click="router.push({ name: 'ingest' })">
|
||||||
<template #icon><ClockCircleOutlined /></template>
|
|
||||||
入库进度
|
|
||||||
</a-button>
|
|
||||||
<a-button type="primary" @click="openIngest">
|
|
||||||
<template #icon><UploadOutlined /></template>
|
<template #icon><UploadOutlined /></template>
|
||||||
入库
|
入库
|
||||||
</a-button>
|
</a-button>
|
||||||
@@ -498,156 +346,6 @@ const failedColumns = [
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -789,31 +487,6 @@ const failedColumns = [
|
|||||||
margin-bottom: 4px;
|
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) {
|
@media (max-width: 768px) {
|
||||||
.library__body {
|
.library__body {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ metadata: {"clawdbot":{"emoji":"📚"}}
|
|||||||
- `QMDSEARCH_BASE_URL`:服务基地址,例如 `http://localhost:8000` 或 NAS 地址 `http://<nas-ip>:8000`。
|
- `QMDSEARCH_BASE_URL`:服务基地址,例如 `http://localhost:8000` 或 NAS 地址 `http://<nas-ip>:8000`。
|
||||||
- `QMDSEARCH_TOKEN`:Bearer Token。通过 `POST {BASE}/api/v1/auth/login`(用户名 + 密码)获取;session TTL 12h,失效后重新登录。
|
- `QMDSEARCH_TOKEN`:Bearer Token。通过 `POST {BASE}/api/v1/auth/login`(用户名 + 密码)获取;session TTL 12h,失效后重新登录。
|
||||||
|
|
||||||
> 所有变更类请求(上传 / 删除)与检索请求都必须在 Header 携带 `Authorization: Bearer <token>`。
|
> 变更类请求(上传 / 删除 / 重试 / 用户管理)必须携带 `Authorization: Bearer <token>`。
|
||||||
> 当前代码实现中,查询类只读接口(文档列表 / 详情 / 类目 / 统计 / 健康)同样要求 Bearer(与登录态绑定),请始终携带 token 以避免 `1005` 未认证。
|
> 查询类只读接口(检索 / 文档列表 / 详情 / 下载 / 类目 / 统计 / 健康 / 任务状态)免登录,无需携带 token。
|
||||||
|
|
||||||
## 统一约定
|
## 统一约定
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ metadata: {"clawdbot":{"emoji":"📚"}}
|
|||||||
curl -X POST {BASE}/api/v1/auth/login \
|
curl -X POST {BASE}/api/v1/auth/login \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"username":"admin","password":"<password>"}'
|
-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` 改密。
|
`must_change_password=true` 时,登录成功但调用其他接口会返回 `1006`,需先 `POST /api/v1/auth/password` 改密。
|
||||||
@@ -96,7 +96,6 @@ curl {BASE}/api/v1/documents/tasks/<task_id>
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST {BASE}/api/v1/search \
|
curl -X POST {BASE}/api/v1/search \
|
||||||
-H "Authorization: Bearer <token>" \
|
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"query":"如何配置 Redis 缓存","top_k":5,"summarize":false}'
|
-d '{"query":"如何配置 Redis 缓存","top_k":5,"summarize":false}'
|
||||||
```
|
```
|
||||||
@@ -111,18 +110,31 @@ curl -X POST {BASE}/api/v1/search \
|
|||||||
|
|
||||||
## 6. 其他管理接口(按需)
|
## 6. 其他管理接口(按需)
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
| 方法 | 路径 | 说明 | 鉴权 |
|
||||||
|------|------|------|
|
|------|------|------|------|
|
||||||
| POST | `/documents` | 文本入库(202 + task_id) |
|
| POST | `/documents` | 文本入库(202 + task_id) | Bearer |
|
||||||
| POST | `/documents/upload` | 文件入库(202 + task_id) |
|
| POST | `/documents/upload` | 文件入库(202 + task_id) | Bearer |
|
||||||
| GET | `/documents/tasks/{task_id}` | 任务状态(done / failed) |
|
| POST | `/documents/upload-batch` | 批量文件入库(202,逐文件建任务) | Bearer |
|
||||||
| GET | `/documents?limit=&offset=` | 文档列表(分页游标) |
|
| GET | `/documents/tasks` | 入库任务列表(按时间降序) | Bearer |
|
||||||
| GET | `/documents/{doc_id}` | 文档详情(L1 + L2/L3 + chunks 数) |
|
| GET | `/documents/tasks/{task_id}` | 任务状态(done / failed) | 免登录 |
|
||||||
| GET | `/documents/{doc_id}/file` | 下载原始文件 |
|
| POST | `/documents/tasks/{task_id}/retry` | 重试入库任务(返回新 task_id) | Bearer |
|
||||||
| DELETE | `/documents/{doc_id}` | 删除文档(幂等) |
|
| DELETE | `/documents/tasks/{task_id}` | 删除入库任务(幂等) | Bearer |
|
||||||
| GET | `/knowledge/categories` | 知识分类类目集 |
|
| GET | `/documents?limit=&offset=` | 文档列表(分页游标) | 免登录 |
|
||||||
| GET | `/knowledge/stats` | 统计(四层点数 + 类目分布) |
|
| GET | `/documents/{doc_id}` | 文档详情(L1 + L2/L3 + chunks 数) | 免登录 |
|
||||||
| GET | `/health` | 健康检查 |
|
| 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>
|
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
|
```bash
|
||||||
curl -s -X POST {BASE}/api/v1/search \
|
curl -s -X POST {BASE}/api/v1/search \
|
||||||
-H "Authorization: Bearer <token>" \
|
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"query":"如何配置 Redis 缓存","top_k":5,"summarize":false}'
|
-d '{"query":"如何配置 Redis 缓存","top_k":5,"summarize":false}'
|
||||||
```
|
```
|
||||||
@@ -120,6 +141,13 @@ class QMDSearchClient:
|
|||||||
headers=self._headers(), files=files, data=data, timeout=60)
|
headers=self._headers(), files=files, data=data, timeout=60)
|
||||||
return self._ok(resp)["task_id"]
|
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:
|
def wait_task(self, task_id: str) -> dict:
|
||||||
deadline = time.time() + self.poll_timeout
|
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}`
|
- 任务 `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?}`
|
- 检索 → `data = {query, hits:[{text,doc_id,title,section_path,score,doc_summary}], routed_categories, fallback, extracted_info, summary?}`
|
||||||
|
|||||||
Reference in New Issue
Block a user