feat(knowledge): 展示文档处理进度并自动衔接导入索引
This commit is contained in:
@@ -0,0 +1,56 @@
|
|||||||
|
# Task: 优化知识文档处理进度和可用性展示
|
||||||
|
|
||||||
|
## Identity
|
||||||
|
|
||||||
|
- Task ID: 20260915-knowledge-status-ml-3065956f
|
||||||
|
- Mode: Feature
|
||||||
|
- Branch: codex/20260915-knowledge-status-ml-3065956f-knowledge-status
|
||||||
|
- Worktree: D:\Datas\OthersProjects\.codex-worktrees\makelore\20260915-knowledge-status-ml-3065956f
|
||||||
|
- Base commit: b0bf7a49eb5d8e7e8ee141ddb4b992fddf2e410c
|
||||||
|
- Owner: codex
|
||||||
|
- Status: Ready for Integration
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- MakeLore 知识文档自动处理、阶段状态、持续刷新、失败恢复、分页和知识库切换,相关 DOM/Electron 测试与 README。
|
||||||
|
|
||||||
|
## Intent And Constraints
|
||||||
|
|
||||||
|
- Concurrent / Planning Gates Passed;官方登记匹配本任务、分支、工作区和基线。已读取必需项目记忆与相关个人云智能体/平台 embedding 决策并评估 peer 作用域;旧模板保持未知,无影响本任务的具体语义冲突。
|
||||||
|
- 保持 Electron Main 传输边界、Yuxi 所有权和持久 Task;embedding 由平台承担,不计算用户用量或预算。不包含主分支合并、部署、生产迁移或工作区清理。
|
||||||
|
- 用户已授权独立审查及通过后提交,并补充授权替换无响应的原 Reviewer。原审查者已停止,knowledge_status_review_replacement 已完成最终两仓全新只读审查,Standards PASS / Spec PASS;未创建实现子智能体。
|
||||||
|
- 上传 API 仍负责登记文件,客户端使用既有稳定操作 ID 自动衔接 process;以持久状态和实际分块为准,不新增状态表或估算百分比。
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
- 上传、对话附件导入、替换后自动处理,显示等待处理、正在解析、建立索引、可用于回答。
|
||||||
|
- 空索引明确显示没有可用内容,提供扫描 PDF 提取文字说明;任务失败展示原因和重试,新内容可用前保留旧文档。
|
||||||
|
- 每 5 秒在前次读取完成后刷新已加载页面,刷新失败保留旧结果并说明,知识库切换隔离请求;卡片显示大小、可检索段数和替换关系。
|
||||||
|
- Main 上传/导入 allowlist 保留可用性、替换关系与处理 Task。处理请求超时后保持原意图,看到新持久 Task 后清除本地不确定提示;新 Task 失败后的重试使用新操作 ID,避免反复命中旧失败。
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- pnpm exec vitest run tests/unit/cloud-agents-main.test.ts tests/unit/cloud-knowledge-status.test.tsx tests/unit/cloud-knowledge-catalog.test.tsx:49 passed;覆盖真实 Main 上传/导入投影、各阶段、初始读取/空内容、稳定意图重试、异步确认后清除本地不确定状态/失败后新操作、任务失败、断网恢复、分页、库切换、导入和替换。
|
||||||
|
- pnpm typecheck、scoped ESLint、pnpm run build:vite 通过(Renderer/Main/Preload/utility)。
|
||||||
|
- pnpm exec playwright test tests/e2e/cloud-agents.spec.ts --workers=1:真实 Electron 1 passed (23.2s),Host API fixture。最终 build:vite 后验证状态推进、空内容、卡片无横向溢出与后续原有编辑/试用/缩放/发布流程通过;新增知识库的草稿先保存并重新试用,相应 revision oracle 显式递增。
|
||||||
|
- pnpm test -- --run:2081 passed / 1 failed / 2 skipped,237 files passed / 1 failed。唯一失败 tests/unit/pi-managed-worker-opener.test.ts:292 期待 makelore-runtime-v6.mjs;git show 基线确认同一断言,基线 electron/coding-runtime/pi/extensions/makelore-runtime.ts 已为版本 7,两域均无本任务改动。
|
||||||
|
- 全量第一阶段失败未进入压力测试,单独 pnpm exec vitest run tests/unit/coding-chat-pressure.test.tsx --maxWorkers=1:1 passed。
|
||||||
|
- Not run:真实登录/上传、生产 OSS/Milvus、安装包和部署。
|
||||||
|
- 独立审查发现并已修正:cleanup 重试的 pending/running Task 不得覆盖 indexed+available+真实分块事实;重试期间仍计为可用并保留段数,另示后续处理进度。修订的 DOM case 覆盖 failed→pending→running→success,全程可用计数/段数保持;Reviewer 已确认原阻断解除。
|
||||||
|
- 复验调用 pnpm build 时 Vite 编译完成后进入本任务不需要的 NSIS 打包,已停止该构建进程;未发布、未验证安装包,生成物不提交。
|
||||||
|
- 独立审查 Standards PASS / Spec PASS,无未解决阻断。审查者已逐文件审阅最终固定基线 diff,并确认本轮发现与修复、测试和未验证边界;最终 task-aware drift 与 diff --check 通过。审查记录任务:20260915-knowledge-status-ml-review-4e6a1d83。
|
||||||
|
|
||||||
|
## Follow-ups
|
||||||
|
|
||||||
|
- 配套 Yuxi 与 MakeLore 变更需一起合入/部署,再用实际文档验收;本任务尚未获合并或部署授权。
|
||||||
|
- Pi runtime 的 v6 旧测试断言交该域单独处理,本任务未修改。
|
||||||
|
- 用户已补充确认注销缺失登记 20260816-explore-account-lifecycle-4f8a,官方 release-missing --force 已返回 RELEASED_MISSING;此前批准的 44 条亦已完成。仅注销登记,未删除目录、分支或提交。
|
||||||
|
|
||||||
|
## Promotion Candidates
|
||||||
|
|
||||||
|
- Target: .project-docs/current-state.md 与个人云智能体知识资源相关当前说明。
|
||||||
|
- Proposal: 文档上传/导入/替换自动处理,文件列表读取最新持久 Task 与实际分块;有内容的已索引文件才可用于回答,失败有恢复入口。
|
||||||
|
- Evidence: 本任务实现、HTTP/PostgreSQL/worker 和 Main/DOM/Electron 回归;独立审查 Standards PASS / Spec PASS。
|
||||||
|
- Future impact: 操作说明需区分上传与可用;embedding 平台承担规则保持。
|
||||||
|
- Semantic conflicts: 先前手动索引说明按本次用户明确要求更新,无费用/权限/凭据边界变化。
|
||||||
|
- Human confirmation: 功能优化和审查后提交已授权;canonical promotion 在后续 integration 任务执行。
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
Makelore 是一个面向软件、视觉创作、智能机器人与个人云智能体的 AI 桌面工作台。当前版本为 `2.0.0`,源码提供四个模块入口;云智能体需配套配置 WS/Yuxi 服务。模块入口页按 Agents、Code、Canvas、Robot 排列,统一采用横向插画卡片;Agents 标语为“打造你想象中的AI助手”。工作区左上角入口点击后返回模块入口页:
|
Makelore 是一个面向软件、视觉创作、智能机器人与个人云智能体的 AI 桌面工作台。当前版本为 `2.0.0`,源码提供四个模块入口;云智能体需配套配置 WS/Yuxi 服务。模块入口页按 Agents、Code、Canvas、Robot 排列,统一采用横向插画卡片;Agents 标语为“打造你想象中的AI助手”。工作区左上角入口点击后返回模块入口页:
|
||||||
|
|
||||||
- `Makelore Code|AI 编程`:管理本地项目、项目智能体、对话、文件上下文、代码变更和运行时。
|
- `Makelore Code|AI 编程`:管理本地项目、项目智能体、对话、文件上下文、代码变更和运行时。
|
||||||
- `Makelore Agents|AI 智能体`:配置个人云智能体并试聊,发布后正式对话、按账号分享或创建应用 API Key。支持流式回复、审批、排队请求、附件与产物、自动任务和活动历史;重新进入时恢复最近智能体与对话,未确认操作可按原输入重试。聊天可提出日程建议,由创建者核对能力、时间和费用后启用。知识库支持上传、索引、替换、删除及导入本人会话附件;个人 MCP 凭据、Skills、子智能体可自助管理。支持智能体和会话归档、发布版本比较及恢复为草稿。除知识库 embedding 向量化外,模型调用(包括知识处理触发的对话模型调用)仍按原规则由创建者个人词元点数支付,可按智能体设置每次任务和每日上限,费用按时间、来源、应用分页统计;embedding 向量化由平台承担,不计入用户用量、词元点数或智能体费用上限;分享/API 调用者保有自己的内容空间。Main 管理云会话和本机文件,Renderer 通过 Host API 操作。配套服务接入见 [Yuxi MakeLore 说明](https://xerrors.github.io/Yuxi/advanced/makelore-agents.html)。 编辑采用全高桌面分栏,支持拖动调整与展开、独立滚动、小窗口配置/试用切换。首次创建按选用途、说要求、试一试引导,提供科学问答、故事、英语和笔记整理起点;回答方式可多选,特别要求直接编辑,仍以草稿的 system_prompt 保存。新助手预选云端目录的第一个可用模型并明确显示,可自行更换;已有助手不自动换模型。能力、知识与限制折叠到高级设置。草稿明确保存(Ctrl/⌘+S),保存与切换页面保留当前试用及未发送文字;“保存并重新试用”使用最新保存的要求,保留上次试用供对比,并可复用上次问题,不自动发送。示例问题仅填入输入框;有运行、排队、审批或未确认操作时先处理当前试用。工具确认先展示实际操作说明与参数,完整技术详情可展开。确认“开始使用”会创建仅自己可用的发布版本并进入独立对话,分享与应用仍单独开启。费用上限在限制中独立保存。Enter 发送、Shift+Enter 换行,F6 切换面板焦点、Esc 恢复分栏。
|
- `Makelore Agents|AI 智能体`:配置个人云智能体并试聊,发布后正式对话、按账号分享或创建应用 API Key。支持流式回复、审批、排队请求、附件与产物、自动任务和活动历史;重新进入时恢复最近智能体与对话,未确认操作可按原输入重试。聊天可提出日程建议,由创建者核对能力、时间和费用后启用。知识库上传、替换及导入本人会话附件后自动解析并建立索引;文档卡片区分排队、解析、索引、可用和失败,持续刷新已加载文档,失败可原地重试;零文本内容不会显示为可用,新文档可用后才移除被替换的旧文档。支持删除知识内容;个人 MCP 凭据、Skills、子智能体可自助管理。支持智能体和会话归档、发布版本比较及恢复为草稿。除知识库 embedding 向量化外,模型调用(包括知识处理触发的对话模型调用)仍按原规则由创建者个人词元点数支付,可按智能体设置每次任务和每日上限,费用按时间、来源、应用分页统计;embedding 向量化由平台承担,不计入用户用量、词元点数或智能体费用上限;分享/API 调用者保有自己的内容空间。Main 管理云会话和本机文件,Renderer 通过 Host API 操作。配套服务接入见 [Yuxi MakeLore 说明](https://xerrors.github.io/Yuxi/advanced/makelore-agents.html)。 编辑采用全高桌面分栏,支持拖动调整与展开、独立滚动、小窗口配置/试用切换。首次创建按选用途、说要求、试一试引导,提供科学问答、故事、英语和笔记整理起点;回答方式可多选,特别要求直接编辑,仍以草稿的 system_prompt 保存。新助手预选云端目录的第一个可用模型并明确显示,可自行更换;已有助手不自动换模型。能力、知识与限制折叠到高级设置。草稿明确保存(Ctrl/⌘+S),保存与切换页面保留当前试用及未发送文字;“保存并重新试用”使用最新保存的要求,保留上次试用供对比,并可复用上次问题,不自动发送。示例问题仅填入输入框;有运行、排队、审批或未确认操作时先处理当前试用。工具确认先展示实际操作说明与参数,完整技术详情可展开。确认“开始使用”会创建仅自己可用的发布版本并进入独立对话,分享与应用仍单独开启。费用上限在限制中独立保存。Enter 发送、Shift+Enter 换行,F6 切换面板焦点、Esc 恢复分栏。
|
||||||
|
|
||||||
云知识库的 embedding 模型只由平台管理员在 Yuxi 后台维护:在「智能体管理 → 模型供应商」启用供应商,按供应商要求填写有效的服务地址和 API Key,添加并启用 embedding 模型并填写实际向量维度;embedding 向量化费用由平台承担,不计入用户用量、词元点数或智能体费用上限;one-api 不参与 embedding 配置,Works Square 只负责其他应计费模型调用的创建者词元点数账本。客户端区分目录加载中、加载失败与无可用模型,空状态提供管理员配置说明;「刷新知识库与模型」保留未提交名称,未确认的创建请求沿用原操作身份。
|
云知识库的 embedding 模型只由平台管理员在 Yuxi 后台维护:在「智能体管理 → 模型供应商」启用供应商,按供应商要求填写有效的服务地址和 API Key,添加并启用 embedding 模型并填写实际向量维度;embedding 向量化费用由平台承担,不计入用户用量、词元点数或智能体费用上限;one-api 不参与 embedding 配置,Works Square 只负责其他应计费模型调用的创建者词元点数账本。客户端区分目录加载中、加载失败与无可用模型,空状态提供管理员配置说明;「刷新知识库与模型」保留未提交名称,未确认的创建请求沿用原操作身份。
|
||||||
|
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ const operations: Record<keyof CloudAgentOperations, OperationSpec> = {
|
|||||||
deleteSkill: { method: 'DELETE', path: '/resources/skills/:key', output: ['deleted'] },
|
deleteSkill: { method: 'DELETE', path: '/resources/skills/:key', output: ['deleted'] },
|
||||||
deleteKnowledge: { method: 'DELETE', path: '/agents/:slug/knowledge/:kb_id', output: ['deleted'] },
|
deleteKnowledge: { method: 'DELETE', path: '/agents/:slug/knowledge/:kb_id', output: ['deleted'] },
|
||||||
deleteKnowledgeFile: { method: 'DELETE', path: '/agents/:slug/knowledge/:kb_id/files/:file_id', output: ['deleted'] },
|
deleteKnowledgeFile: { method: 'DELETE', path: '/agents/:slug/knowledge/:kb_id/files/:file_id', output: ['deleted'] },
|
||||||
importKnowledgeAttachment: { method: 'POST', path: '/agents/:slug/knowledge/:kb_id/import-attachment', body: ['operation_id', 'thread_id', 'attachment_id'], output: ['file_id', 'name', 'size', 'status', 'error', 'chunk_count'] },
|
importKnowledgeAttachment: { method: 'POST', path: '/agents/:slug/knowledge/:kb_id/import-attachment', body: ['operation_id', 'thread_id', 'attachment_id'], output: ['file_id', 'name', 'size', 'status', 'error', 'chunk_count', 'available', 'replaces_file_id', 'processing_task'] },
|
||||||
archiveAgent: { method: 'PUT', path: '/agents/:slug/archive', body: ['archived'], output: draft },
|
archiveAgent: { method: 'PUT', path: '/agents/:slug/archive', body: ['archived'], output: draft },
|
||||||
archiveThread: { method: 'PUT', path: '/threads/:thread_id/archive', body: ['archived'], output: ['thread_id', 'archived'] },
|
archiveThread: { method: 'PUT', path: '/threads/:thread_id/archive', body: ['archived'], output: ['thread_id', 'archived'] },
|
||||||
version: { method: 'GET', path: '/agents/:slug/versions/:version', output: ['version', 'created_at', 'name', 'purpose', 'system_prompt', 'configuration'] },
|
version: { method: 'GET', path: '/agents/:slug/versions/:version', output: ['version', 'created_at', 'name', 'purpose', 'system_prompt', 'configuration'] },
|
||||||
|
|||||||
@@ -392,7 +392,7 @@ export class CloudAgentsModule {
|
|||||||
const body = await this.pickUpload();
|
const body = await this.pickUpload();
|
||||||
if (!body) return null;
|
if (!body) return null;
|
||||||
const result = record(await this.request(`/api/makelore/agents/${slug}/knowledge/${kbId}/files?operation_id=${operationId}` + (replacement ? '&replaces_file_id=' + replacement : ''), 'POST', body));
|
const result = record(await this.request(`/api/makelore/agents/${slug}/knowledge/${kbId}/files?operation_id=${operationId}` + (replacement ? '&replaces_file_id=' + replacement : ''), 'POST', body));
|
||||||
return Object.fromEntries(['file_id', 'name', 'size', 'status', 'error', 'chunk_count'].map(key => [key, result[key]]));
|
return Object.fromEntries(['file_id', 'name', 'size', 'status', 'error', 'chunk_count', 'available', 'replaces_file_id', 'processing_task'].map(key => [key, result[key]]));
|
||||||
}
|
}
|
||||||
|
|
||||||
async uploadSkill(): Promise<unknown> {
|
async uploadSkill(): Promise<unknown> {
|
||||||
|
|||||||
@@ -319,7 +319,15 @@ export interface CloudChannelControl {
|
|||||||
}
|
}
|
||||||
export interface CloudChannelAccountStatus { status: string; message?: string }
|
export interface CloudChannelAccountStatus { status: string; message?: string }
|
||||||
export interface CloudKnowledge { kb_id: string; name: string; description: string; embedding_model: string }
|
export interface CloudKnowledge { kb_id: string; name: string; description: string; embedding_model: string }
|
||||||
export interface CloudKnowledgeFile { file_id: string; name: string; size: number; status: string; error: string | null; chunk_count: number }
|
export interface CloudKnowledgeFile {
|
||||||
|
file_id: string; name: string; size: number; status: string; error: string | null; chunk_count: number;
|
||||||
|
available: boolean;
|
||||||
|
replaces_file_id?: string | null;
|
||||||
|
processing_task?: {
|
||||||
|
task_id: string; status: string; error: string | null;
|
||||||
|
created_at?: string | null; started_at?: string | null; completed_at?: string | null;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
export interface CloudMcp { slug: string; name: string; description: string; transport: 'sse' | 'streamable_http'; url: string; enabled: boolean; has_credentials: boolean }
|
export interface CloudMcp { slug: string; name: string; description: string; transport: 'sse' | 'streamable_http'; url: string; enabled: boolean; has_credentials: boolean }
|
||||||
export interface CloudMcpInput { name: string; description: string; transport: 'sse' | 'streamable_http'; url: string; headers?: Record<string, string> | null }
|
export interface CloudMcpInput { name: string; description: string; transport: 'sse' | 'streamable_http'; url: string; headers?: Record<string, string> | null }
|
||||||
export interface CloudChild { slug: string; name: string; purpose: string; system_prompt: string }
|
export interface CloudChild { slug: string; name: string; purpose: string; system_prompt: string }
|
||||||
|
|||||||
@@ -3,18 +3,14 @@ import { cloudAgentsApi } from '@/lib/cloud-agents-api';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { usePendingCloudInput } from './CloudPending';
|
import { usePendingCloudInput } from './CloudPending';
|
||||||
import type { CloudAgentOperations, CloudKnowledge, CloudKnowledgeFile, CloudThread, CloudAttachment } from '../../../shared/cloud-agents';
|
import type { CloudAgentOperations, CloudKnowledge } from '../../../shared/cloud-agents';
|
||||||
|
|
||||||
|
import { CloudKnowledgeDocuments } from './CloudKnowledgeDocuments';
|
||||||
|
|
||||||
const statusName: Record<string, string> = {
|
|
||||||
uploaded: '待解析', parsing: '解析中', parsed: '待索引', indexing: '索引中', indexed: '可检索',
|
|
||||||
error_parsing: '解析失败', error_indexing: '索引失败',
|
|
||||||
};
|
|
||||||
export function CloudKnowledgePanel({ slug, onCreated, onDeleted }: { slug: string; onCreated: (kb: CloudKnowledge) => void; onDeleted?: (id: string) => void }) {
|
export function CloudKnowledgePanel({ slug, onCreated, onDeleted }: { slug: string; onCreated: (kb: CloudKnowledge) => void; onDeleted?: (id: string) => void }) {
|
||||||
type CloudEmbeddingModel = CloudAgentOperations['knowledge']['output']['models'][number];
|
type CloudEmbeddingModel = CloudAgentOperations['knowledge']['output']['models'][number];
|
||||||
const [catalog, setCatalog] = useState<{ databases: CloudKnowledge[]; models: CloudEmbeddingModel[] } | null>(null);
|
const [catalog, setCatalog] = useState<{ databases: CloudKnowledge[]; models: CloudEmbeddingModel[] } | null>(null);
|
||||||
const [kbId, setKbId] = useState('');
|
const [kbId, setKbId] = useState('');
|
||||||
const [files, setFiles] = useState<CloudKnowledgeFile[]>([]);
|
|
||||||
const [next, setNext] = useState<number | null>(null);
|
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [model, setModel] = useState('');
|
const [model, setModel] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -23,15 +19,9 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted }: { slug: stri
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [refresh, setRefresh] = useState(0);
|
const [refresh, setRefresh] = useState(0);
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [deleting, setDeleting] = useState<CloudKnowledgeFile | 'database' | null>(null);
|
|
||||||
const [importing, setImporting] = useState(false);
|
|
||||||
const replacements = useRef<Record<string, string>>({});
|
|
||||||
usePendingCloudInput(busy || (creating && Boolean(name.trim())));
|
usePendingCloudInput(busy || (creating && Boolean(name.trim())));
|
||||||
const createIntent = useRef<{ operation_id: string; name: string; embedding_model: string } | null>(null);
|
const createIntent = useRef<{ operation_id: string; name: string; embedding_model: string } | null>(null);
|
||||||
const uploadIntent = useRef<string | null>(null);
|
|
||||||
const processIntents = useRef<Record<string, string>>({});
|
|
||||||
const alive = useRef(true);
|
const alive = useRef(true);
|
||||||
const paged = useRef(false);
|
|
||||||
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
|
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let current = true;
|
let current = true;
|
||||||
@@ -45,17 +35,6 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted }: { slug: stri
|
|||||||
.finally(() => { if (current) setCatalogLoading(false); });
|
.finally(() => { if (current) setCatalogLoading(false); });
|
||||||
return () => { current = false; };
|
return () => { current = false; };
|
||||||
}, [slug, refresh]);
|
}, [slug, refresh]);
|
||||||
useEffect(() => {
|
|
||||||
if (!kbId) { setFiles([]); return; }
|
|
||||||
paged.current = false;
|
|
||||||
let current = true;
|
|
||||||
const load = () => cloudAgentsApi.call('knowledgeFiles', { slug, kb_id: kbId })
|
|
||||||
.then(v => { if (current) { setFiles(v.files); setNext(v.next_offset); } })
|
|
||||||
.catch(e => { if (current) setError(String(e.message || e)); });
|
|
||||||
void load();
|
|
||||||
const timer = setInterval(() => { if (!paged.current) void load(); }, 5000);
|
|
||||||
return () => { current = false; clearInterval(timer); };
|
|
||||||
}, [slug, kbId, refresh]); // A refreshed first page replaces pagination only after an explicit operation.
|
|
||||||
const act = async (operation: () => Promise<void>) => {
|
const act = async (operation: () => Promise<void>) => {
|
||||||
setBusy(true); setError('');
|
setBusy(true); setError('');
|
||||||
try { await operation(); }
|
try { await operation(); }
|
||||||
@@ -70,7 +49,7 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted }: { slug: stri
|
|||||||
{catalogError && <p role="alert" className="text-destructive">知识库与模型目录加载失败:{catalogError}。请刷新重试。</p>}
|
{catalogError && <p role="alert" className="text-destructive">知识库与模型目录加载失败:{catalogError}。请刷新重试。</p>}
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<select aria-label="管理的知识库" className="h-10 min-w-0 flex-1 basis-48 rounded-md border bg-background px-3" value={kbId} disabled={busy}
|
<select aria-label="管理的知识库" className="h-10 min-w-0 flex-1 basis-48 rounded-md border bg-background px-3" value={kbId} disabled={busy}
|
||||||
onChange={e => { setKbId(e.target.value); uploadIntent.current = null; setNext(null); }}>
|
onChange={e => { setKbId(e.target.value); setError(''); }}>
|
||||||
<option value="">选择我的知识库</option>
|
<option value="">选择我的知识库</option>
|
||||||
{catalog?.databases.map(kb => <option key={kb.kb_id} value={kb.kb_id}>{kb.name}</option>)}
|
{catalog?.databases.map(kb => <option key={kb.kb_id} value={kb.kb_id}>{kb.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
@@ -102,94 +81,8 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted }: { slug: stri
|
|||||||
if (alive.current) { setKbId(kb.kb_id); setCreating(false); setName(''); setRefresh(v => v + 1); onCreated(kb); }
|
if (alive.current) { setKbId(kb.kb_id); setCreating(false); setName(''); setRefresh(v => v + 1); onCreated(kb); }
|
||||||
})}>{createIntent.current ? '重试创建' : '创建并选用'}</Button>
|
})}>{createIntent.current ? '重试创建' : '创建并选用'}</Button>
|
||||||
</div>}
|
</div>}
|
||||||
{kbId && <>
|
{kbId && <CloudKnowledgeDocuments key={slug + ':' + kbId} slug={slug} kbId={kbId} busy={busy} act={act}
|
||||||
<div className="flex flex-wrap gap-2">
|
onDeleteKnowledge={() => { onDeleted?.(kbId); setKbId(''); setRefresh(v => v + 1); }} />}
|
||||||
<Button type="button" disabled={busy} onClick={() => void act(async () => {
|
|
||||||
uploadIntent.current ??= crypto.randomUUID();
|
|
||||||
const uploaded = await cloudAgentsApi.uploadKnowledge(slug, kbId, uploadIntent.current);
|
|
||||||
if (!uploaded) return;
|
|
||||||
uploadIntent.current = null;
|
|
||||||
if (alive.current) setRefresh(v => v + 1);
|
|
||||||
})}>{uploadIntent.current ? '重新选择同一文件并重试' : '上传文档'}</Button>
|
|
||||||
<Button type="button" variant="ghost" disabled={busy} onClick={() => setRefresh(v => v + 1)}>刷新状态</Button>
|
|
||||||
<Button type="button" variant="outline" disabled={busy} onClick={() => setImporting(v => !v)}>从对话附件导入</Button>
|
|
||||||
<Button type="button" variant="ghost" disabled={busy} onClick={() => setDeleting('database')}>删除知识库</Button>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">最大 5 MB;支持 TXT、Markdown、CSV、JSON、Word、Excel、文本 PDF。</p>
|
|
||||||
{importing && <AttachmentImport key={kbId} slug={slug} kbId={kbId} onImported={() => { setImporting(false); setRefresh(v => v + 1); }} />}
|
|
||||||
{deleting && <div role="dialog" aria-label="删除知识内容" className="space-y-3 rounded-lg border border-amber-200 p-3">
|
|
||||||
<p>{deleting === 'database' ? '删除整个知识库及其文档、索引?所有使用此库的智能体将无法继续检索这些内容。' : `删除文档“${deleting.name}”及其索引?`}</p>
|
|
||||||
<div className="flex gap-2"><Button type="button" variant="outline" disabled={busy} onClick={() => void act(async () => {
|
|
||||||
if (deleting === 'database') { await cloudAgentsApi.call('deleteKnowledge', { slug, kb_id: kbId }); onDeleted?.(kbId); setKbId(''); }
|
|
||||||
else await cloudAgentsApi.call('deleteKnowledgeFile', { slug, kb_id: kbId, file_id: deleting.file_id });
|
|
||||||
setDeleting(null); setRefresh(v => v + 1);
|
|
||||||
})}>确认删除知识内容</Button><Button type="button" variant="ghost" disabled={busy} onClick={() => setDeleting(null)}>取消</Button></div>
|
|
||||||
</div>}
|
|
||||||
<div className="divide-y">
|
|
||||||
{files.map(file => <div key={file.file_id} className="flex items-start justify-between gap-3 py-3">
|
|
||||||
<div className="min-w-0"><p className="break-all">{file.name}</p><p className="mt-1 text-xs text-muted-foreground">{statusName[file.status] || file.status} · {file.chunk_count || 0} 个分块</p>
|
|
||||||
{file.error && <p className="mt-1 text-xs text-destructive">{file.error}</p>}</div>
|
|
||||||
<div className="flex shrink-0 flex-wrap gap-1">{!['parsing', 'indexing', 'indexed'].includes(file.status) && <Button type="button" size="sm" variant="outline" disabled={busy}
|
|
||||||
onClick={() => void act(async () => {
|
|
||||||
processIntents.current[file.file_id] ??= crypto.randomUUID();
|
|
||||||
await cloudAgentsApi.call('processKnowledge', { slug, kb_id: kbId, file_id: file.file_id, operation_id: processIntents.current[file.file_id] });
|
|
||||||
delete processIntents.current[file.file_id];
|
|
||||||
if (alive.current) setRefresh(v => v + 1);
|
|
||||||
})}>{file.status.startsWith('error') ? '重试处理' : '解析并索引'}</Button>}
|
|
||||||
<Button type="button" size="sm" variant="ghost" disabled={busy || ['parsing', 'indexing'].includes(file.status)} onClick={() => void act(async () => {
|
|
||||||
replacements.current[file.file_id] ??= crypto.randomUUID();
|
|
||||||
const result = await cloudAgentsApi.uploadKnowledge(slug, kbId, replacements.current[file.file_id], file.file_id);
|
|
||||||
if (result) { delete replacements.current[file.file_id]; setRefresh(v => v + 1); }
|
|
||||||
})}>{replacements.current[file.file_id] ? '重试替换' : '上传替换文档'}</Button>
|
|
||||||
<Button type="button" size="sm" variant="ghost" disabled={busy || ['parsing', 'indexing'].includes(file.status)} onClick={() => setDeleting(file)}>删除文档</Button>
|
|
||||||
</div>
|
|
||||||
</div>)}
|
|
||||||
{!files.length && <p className="py-4 text-xs text-muted-foreground">尚未上传文档。</p>}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs leading-5 text-muted-foreground">替换文档上传后,需对新文档点击“解析并索引”。新索引成功后才移除旧文档;失败时旧文档仍可检索。</p>
|
|
||||||
{next !== null && <Button type="button" variant="ghost" disabled={busy} onClick={() => void act(async () => {
|
|
||||||
paged.current = true;
|
|
||||||
const page = await cloudAgentsApi.call('knowledgeFiles', { slug, kb_id: kbId, offset: next });
|
|
||||||
if (alive.current) { setFiles(v => [...v, ...page.files]); setNext(page.next_offset); }
|
|
||||||
})}>加载更多文档</Button>}
|
|
||||||
</>}
|
|
||||||
</div>
|
</div>
|
||||||
</details>;
|
</details>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function AttachmentImport({ slug, kbId, onImported }: { slug: string; kbId: string; onImported: () => void }) {
|
|
||||||
const [threads, setThreads] = useState<CloudThread[]>([]);
|
|
||||||
const [thread, setThread] = useState('');
|
|
||||||
const [files, setFiles] = useState<CloudAttachment[]>([]);
|
|
||||||
const [file, setFile] = useState('');
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const operation = useRef<string | null>(null);
|
|
||||||
usePendingCloudInput(busy || Boolean(operation.current));
|
|
||||||
useEffect(() => {
|
|
||||||
let live = true;
|
|
||||||
const load = async () => {
|
|
||||||
let page = await cloudAgentsApi.call('threads', {});
|
|
||||||
while (page.next_offset !== null) { const next = await cloudAgentsApi.call('threads', { offset: page.next_offset }); page = { ...next, threads: [...page.threads, ...next.threads] }; }
|
|
||||||
if (live) setThreads(page.threads);
|
|
||||||
};
|
|
||||||
void load().catch(e => { if (live) setError(e instanceof Error ? e.message : '对话读取失败'); });
|
|
||||||
return () => { live = false; };
|
|
||||||
}, []);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!thread) return;
|
|
||||||
let live = true; setFiles([]); setFile('');
|
|
||||||
cloudAgentsApi.call('attachments', { thread_id: thread }).then(page => { if (live) setFiles(page.attachments); }).catch(e => { if (live) setError(e instanceof Error ? e.message : '附件读取失败'); });
|
|
||||||
return () => { live = false; };
|
|
||||||
}, [thread]);
|
|
||||||
return <div className="space-y-3 rounded-lg border p-3"><p className="text-xs text-muted-foreground">只显示你自己的对话附件。导入后,该文档可以被使用此知识库的智能体检索。</p>
|
|
||||||
<select aria-label="附件来源对话" className="h-10 w-full rounded-md border bg-background px-3" value={thread} disabled={busy || Boolean(operation.current)} onChange={e => setThread(e.target.value)}><option value="">选择对话</option>{threads.map(item => <option key={item.thread_id} value={item.thread_id}>{item.title || '未命名对话'}</option>)}</select>
|
|
||||||
<select aria-label="入库附件" className="h-10 w-full rounded-md border bg-background px-3" value={file} disabled={busy || Boolean(operation.current)} onChange={e => setFile(e.target.value)}><option value="">选择附件</option>{files.map(item => <option key={item.file_id} value={item.file_id}>{item.file_name}</option>)}</select>
|
|
||||||
<Button type="button" disabled={busy || !thread || !file} onClick={async () => {
|
|
||||||
operation.current ??= crypto.randomUUID(); setBusy(true); setError('');
|
|
||||||
try { await cloudAgentsApi.call('importKnowledgeAttachment', { slug, kb_id: kbId, thread_id: thread, attachment_id: file, operation_id: operation.current }); onImported(); }
|
|
||||||
catch (e) { setError(e instanceof Error ? e.message : '导入失败'); } finally { setBusy(false); }
|
|
||||||
}}>{operation.current ? '重试本次导入' : '确认导入知识库'}</Button>
|
|
||||||
{error && <p role="alert" className="text-destructive">{error}</p>}
|
|
||||||
</div>;
|
|
||||||
}
|
|
||||||
|
|||||||
252
src/pages/CloudAgents/CloudKnowledgeDocuments.tsx
Normal file
252
src/pages/CloudAgents/CloudKnowledgeDocuments.tsx
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { CheckCircle2, CircleAlert, FileText, Loader2 } from 'lucide-react';
|
||||||
|
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { usePendingCloudInput } from './CloudPending';
|
||||||
|
import type { CloudKnowledgeFile, CloudThread, CloudAttachment } from '../../../shared/cloud-agents';
|
||||||
|
|
||||||
|
function documentState(file: CloudKnowledgeFile) {
|
||||||
|
const task = file.processing_task;
|
||||||
|
if (file.status === 'indexed' && file.available && file.chunk_count > 0) {
|
||||||
|
if (task && ['pending', 'running'].includes(task.status)) return {
|
||||||
|
label: '内容已可用,正在完成处理',
|
||||||
|
detail: '索引已建立,可以用于回答。后续处理完成后会自动更新。',
|
||||||
|
stage: 3, ready: true, active: true,
|
||||||
|
};
|
||||||
|
if (task && ['failed', 'cancelled'].includes(task.status)) return {
|
||||||
|
label: '内容已可用,处理未全部完成',
|
||||||
|
detail: '索引已建立,可以用于回答。' + (task.error || file.error || '后续处理未完成,请重试。'),
|
||||||
|
stage: 3, ready: true, failed: true,
|
||||||
|
};
|
||||||
|
return { label: '可用于回答', detail: '已解析并建立索引。选用此知识库后,智能体即可检索这些内容。', stage: 3, ready: true };
|
||||||
|
}
|
||||||
|
if (task?.status === 'pending') return { label: '等待处理', detail: '文档已上传,正在排队。完成后会自动更新。', stage: 1, active: true };
|
||||||
|
if (task?.status === 'running' || ['parsing', 'indexing'].includes(file.status)) {
|
||||||
|
const indexing = ['parsed', 'indexing', 'indexed'].includes(file.status);
|
||||||
|
// Terminal Task errors take precedence over a file left in an intermediate stage.
|
||||||
|
if (!task || !['failed', 'cancelled'].includes(task.status)) return {
|
||||||
|
label: indexing ? '正在建立索引' : '正在解析文档',
|
||||||
|
detail: indexing ? '正在整理可供智能体检索的内容。' : '正在读取文档文字,暂时还不能用于回答。',
|
||||||
|
stage: indexing ? 2 : 1, active: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (task && ['failed', 'cancelled'].includes(task.status)) return {
|
||||||
|
label: task.status === 'cancelled' ? '处理已取消' : '处理失败',
|
||||||
|
detail: file.error || task.error || '处理未完成,可以重试;仍失败时请替换文档。',
|
||||||
|
stage: ['parsed', 'indexing', 'error_indexing', 'indexed'].includes(file.status) ? 2 : 1, failed: true,
|
||||||
|
};
|
||||||
|
if (file.status === 'indexed') return { label: '没有可用内容', detail: '未提取到可检索的文本。请替换为包含可复制文字的文档;扫描 PDF 需先识别文字。', stage: 2, empty: true };
|
||||||
|
if (['error_parsing', 'error_indexing', 'failed'].includes(file.status)) return {
|
||||||
|
label: file.status === 'error_indexing' ? '索引失败' : '解析失败',
|
||||||
|
detail: file.error || '处理未完成,请重试或替换文档。', stage: file.status === 'error_indexing' ? 2 : 1, failed: true,
|
||||||
|
};
|
||||||
|
if (file.status === 'uploaded' || file.status === 'parsed') return {
|
||||||
|
label: file.status === 'parsed' ? '等待建立索引' : '已上传,待处理',
|
||||||
|
detail: '文件已保存,完成处理后才能用于回答。', stage: file.status === 'parsed' ? 2 : 1, pending: true,
|
||||||
|
};
|
||||||
|
return { label: '状态待确认', detail: '请刷新状态后再操作。', stage: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CloudKnowledgeDocuments({ slug, kbId, busy, act, onDeleteKnowledge }: {
|
||||||
|
slug: string; kbId: string; busy: boolean;
|
||||||
|
act: (operation: () => Promise<void>) => Promise<void>;
|
||||||
|
onDeleteKnowledge: () => void;
|
||||||
|
}) {
|
||||||
|
const [files, setFiles] = useState<CloudKnowledgeFile[]>([]);
|
||||||
|
const [pageCount, setPageCount] = useState(1);
|
||||||
|
const [next, setNext] = useState<number | null>(null);
|
||||||
|
const [revision, setRevision] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [readError, setReadError] = useState('');
|
||||||
|
const [uploading, setUploading] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState<string | null>(null);
|
||||||
|
const [importing, setImporting] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState<CloudKnowledgeFile | 'database' | null>(null);
|
||||||
|
const [processErrors, setProcessErrors] = useState<Record<string, string>>({});
|
||||||
|
const uploadIntents = useRef<Record<string, string>>({});
|
||||||
|
const processIntents = useRef<Record<string, { operationId: string; previousTaskId?: string }>>({});
|
||||||
|
const alive = useRef(true);
|
||||||
|
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
|
||||||
|
const refresh = () => setRevision(value => value + 1);
|
||||||
|
useEffect(() => {
|
||||||
|
let current = true;
|
||||||
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const loaded: CloudKnowledgeFile[] = [];
|
||||||
|
let offset: number | null = 0;
|
||||||
|
for (let page = 0; page < pageCount && offset !== null; page++) {
|
||||||
|
const result: { files: CloudKnowledgeFile[]; next_offset: number | null } = await cloudAgentsApi.call('knowledgeFiles', { slug, kb_id: kbId, offset });
|
||||||
|
if (!current) return;
|
||||||
|
loaded.push(...result.files);
|
||||||
|
offset = result.next_offset;
|
||||||
|
}
|
||||||
|
if (current) {
|
||||||
|
for (const file of loaded) {
|
||||||
|
const intent = processIntents.current[file.file_id];
|
||||||
|
if (intent && file.processing_task && file.processing_task.task_id !== intent.previousTaskId) {
|
||||||
|
delete processIntents.current[file.file_id];
|
||||||
|
setProcessErrors(previous => { const value = { ...previous }; delete value[file.file_id]; return value; });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setFiles([...new Map(loaded.map(file => [file.file_id, file])).values()]);
|
||||||
|
setNext(offset); setReadError('');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (current) setReadError(error instanceof Error ? error.message : '状态读取失败');
|
||||||
|
} finally {
|
||||||
|
if (current) { setLoading(false); timer = setTimeout(() => void load(), 5000); }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void load();
|
||||||
|
return () => { current = false; clearTimeout(timer); };
|
||||||
|
}, [slug, kbId, revision, pageCount]);
|
||||||
|
|
||||||
|
const process = async (file: CloudKnowledgeFile) => {
|
||||||
|
setSubmitting(file.file_id);
|
||||||
|
setProcessErrors(previous => { const value = { ...previous }; delete value[file.file_id]; return value; });
|
||||||
|
const intent = processIntents.current[file.file_id] ??= {
|
||||||
|
operationId: crypto.randomUUID(), previousTaskId: file.processing_task?.task_id,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const task = await cloudAgentsApi.call('processKnowledge', {
|
||||||
|
slug, kb_id: kbId, file_id: file.file_id, operation_id: intent.operationId,
|
||||||
|
});
|
||||||
|
delete processIntents.current[file.file_id];
|
||||||
|
if (alive.current) setFiles(previous => previous.map(value => value.file_id === file.file_id
|
||||||
|
? { ...value, processing_task: { task_id: task.task_id, status: 'pending', error: null } } : value));
|
||||||
|
} catch (error) {
|
||||||
|
if (alive.current && processIntents.current[file.file_id] === intent) setProcessErrors(previous => ({
|
||||||
|
...previous, [file.file_id]: '文件已保存,处理请求尚未确认。' + (error instanceof Error ? error.message : '请重试处理。'),
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
if (alive.current) { setSubmitting(null); refresh(); }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const acceptFile = async (file: CloudKnowledgeFile) => {
|
||||||
|
if (!alive.current) return;
|
||||||
|
setFiles(previous => [file, ...previous.filter(value => value.file_id !== file.file_id)]);
|
||||||
|
await process(file);
|
||||||
|
};
|
||||||
|
const upload = (replaces?: CloudKnowledgeFile) => act(async () => {
|
||||||
|
const key = replaces?.file_id ?? 'new';
|
||||||
|
uploadIntents.current[key] ??= crypto.randomUUID();
|
||||||
|
setUploading(replaces ? '正在选择并上传替换文档…' : '正在选择并上传文档…');
|
||||||
|
try {
|
||||||
|
const file = await cloudAgentsApi.uploadKnowledge(slug, kbId, uploadIntents.current[key], replaces?.file_id);
|
||||||
|
delete uploadIntents.current[key];
|
||||||
|
if (alive.current) setUploading('');
|
||||||
|
if (file) await acceptFile(file);
|
||||||
|
} finally { if (alive.current) setUploading(''); }
|
||||||
|
});
|
||||||
|
const states = files.map(documentState);
|
||||||
|
const active = states.some(state => state.active) || submitting !== null;
|
||||||
|
return <div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button type="button" disabled={busy} onClick={() => void upload()}>{uploadIntents.current.new ? '重新选择同一文件并重试' : '上传文档'}</Button>
|
||||||
|
<Button type="button" variant="outline" disabled={busy} onClick={() => setImporting(value => !value)}>从对话附件导入</Button>
|
||||||
|
<Button type="button" variant="ghost" disabled={loading} onClick={refresh}>刷新状态</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs leading-5 text-muted-foreground">上传后自动解析并建立索引。最大 5 MB;支持 TXT、Markdown、CSV、JSON、Word、Excel、文本 PDF。</p>
|
||||||
|
{uploading && <p role="status" className="flex items-center gap-2 text-sm"><Loader2 className="size-4 animate-spin" />{uploading}</p>}
|
||||||
|
{importing && <AttachmentImport slug={slug} kbId={kbId} busy={busy} act={act} onImported={async file => { setImporting(false); await acceptFile(file); }} />}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 border-b pb-2 text-xs text-muted-foreground" aria-live="polite">
|
||||||
|
<span>{files.length ? '已显示 ' + files.length + ' 份文档 · ' + states.filter(state => state.ready).length + ' 份可用' : '知识文档'}</span>
|
||||||
|
<span>{active ? '处理中 · 状态自动更新' : '状态自动更新'}</span>
|
||||||
|
</div>
|
||||||
|
{readError && <div role="alert" className="rounded-md bg-amber-50 p-3 text-xs leading-5 text-amber-900">
|
||||||
|
状态刷新失败,以下保留上次结果,暂不能确认最新进度。{readError}
|
||||||
|
<Button type="button" size="sm" variant="ghost" disabled={loading} onClick={refresh}>重试刷新</Button>
|
||||||
|
</div>}
|
||||||
|
{!files.length && <p role="status" className="py-5 text-center text-sm text-muted-foreground">
|
||||||
|
{loading ? '正在读取文档状态…' : readError ? '暂时无法读取文档列表。' : '上传第一份文档,让智能体用你的资料回答。'}
|
||||||
|
</p>}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{files.map((file, index) => {
|
||||||
|
const state = states[index];
|
||||||
|
const error = processErrors[file.file_id];
|
||||||
|
const isSubmitting = submitting === file.file_id;
|
||||||
|
return <article key={file.file_id} aria-label={file.name} className="min-w-0 space-y-3 rounded-lg border bg-background p-3">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<FileText className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<div className="min-w-0 flex-1"><p className="break-words font-medium [overflow-wrap:anywhere]">{file.name}</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{file.size > 0 ? (file.size < 1024 * 1024 ? Math.ceil(file.size / 1024) + ' KB' : (file.size / 1024 / 1024).toFixed(1) + ' MB') : '大小未知'}{state.ready ? ' · ' + file.chunk_count + ' 段可检索内容' : ''}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={state.ready ? 'text-emerald-700' : state.failed || state.empty ? 'text-amber-800' : 'text-foreground'}>
|
||||||
|
<p className="flex items-center gap-2 text-sm font-medium" aria-live="polite">
|
||||||
|
{state.active || isSubmitting ? <Loader2 className="size-4 animate-spin" /> : state.ready ? <CheckCircle2 className="size-4" /> : state.failed || state.empty ? <CircleAlert className="size-4" /> : null}
|
||||||
|
{isSubmitting ? state.ready ? '内容已可用,正在提交处理…' : '正在提交处理…' : state.label}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ol aria-label="处理阶段" className="flex gap-3 text-xs text-muted-foreground">
|
||||||
|
{['已上传', '解析', '索引', '可用'].map((label, step) => <li key={label} aria-current={step === state.stage ? 'step' : undefined}
|
||||||
|
className={step === state.stage ? 'font-medium text-foreground' : ''}>{step < state.stage ? '✓ ' : ''}{label}</li>)}
|
||||||
|
</ol>
|
||||||
|
<p className="text-xs leading-5 text-muted-foreground">{state.detail}</p>
|
||||||
|
{file.replaces_file_id && <p className="text-xs leading-5 text-muted-foreground">{state.ready ? '这是替换文档,新内容已可用。处理未完成时,重试可继续清理旧文档。' : '这是替换文档,新内容可用后才移除旧文档。'}</p>}
|
||||||
|
{error && !state.active && <p role="alert" className="text-xs leading-5 text-destructive">{error}</p>}
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{(state.pending || state.failed || error) && !state.active && <Button type="button" size="sm" variant="outline" disabled={busy || !!readError} onClick={() => void act(() => process(file))}>
|
||||||
|
{state.failed || error ? '重试处理' : '开始处理'}
|
||||||
|
</Button>}
|
||||||
|
<Button type="button" size="sm" variant="ghost" disabled={busy || active || !!readError} onClick={() => void upload(file)}>
|
||||||
|
{uploadIntents.current[file.file_id] ? '重试替换' : '替换文档'}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" size="sm" variant="ghost" disabled={busy || active || !!readError} onClick={() => setDeleting(file)}>删除</Button>
|
||||||
|
</div>
|
||||||
|
</article>;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{next !== null && <Button type="button" variant="ghost" disabled={loading || busy} onClick={() => setPageCount(value => value + 1)}>加载更多文档</Button>}
|
||||||
|
<div className="border-t pt-2"><Button type="button" variant="ghost" size="sm" disabled={busy || active || !!readError} onClick={() => setDeleting('database')}>删除知识库</Button></div>
|
||||||
|
{deleting && <div role="dialog" aria-label="删除知识内容" className="space-y-3 rounded-lg border border-amber-200 p-3">
|
||||||
|
<p>{deleting === 'database' ? '删除整个知识库及其文档、索引?所有使用此库的智能体将无法继续检索这些内容。' : '删除文档“' + deleting.name + '”及其索引?'}</p>
|
||||||
|
<div className="flex gap-2"><Button type="button" variant="outline" disabled={busy} onClick={() => void act(async () => {
|
||||||
|
if (deleting === 'database') { await cloudAgentsApi.call('deleteKnowledge', { slug, kb_id: kbId }); onDeleteKnowledge(); }
|
||||||
|
else await cloudAgentsApi.call('deleteKnowledgeFile', { slug, kb_id: kbId, file_id: deleting.file_id });
|
||||||
|
if (alive.current) { setDeleting(null); refresh(); }
|
||||||
|
})}>确认删除知识内容</Button><Button type="button" variant="ghost" disabled={busy} onClick={() => setDeleting(null)}>取消</Button></div>
|
||||||
|
</div>}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AttachmentImport({ slug, kbId, busy, act, onImported }: {
|
||||||
|
slug: string; kbId: string; busy: boolean; act: (operation: () => Promise<void>) => Promise<void>;
|
||||||
|
onImported: (file: CloudKnowledgeFile) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [threads, setThreads] = useState<CloudThread[]>([]);
|
||||||
|
const [thread, setThread] = useState('');
|
||||||
|
const [files, setFiles] = useState<CloudAttachment[]>([]);
|
||||||
|
const [file, setFile] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const operation = useRef<string | null>(null);
|
||||||
|
usePendingCloudInput(busy || Boolean(operation.current));
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true;
|
||||||
|
const load = async () => {
|
||||||
|
let page = await cloudAgentsApi.call('threads', {});
|
||||||
|
while (page.next_offset !== null) { const next = await cloudAgentsApi.call('threads', { offset: page.next_offset }); page = { ...next, threads: [...page.threads, ...next.threads] }; }
|
||||||
|
if (live) setThreads(page.threads);
|
||||||
|
};
|
||||||
|
void load().catch(e => { if (live) setError(e instanceof Error ? e.message : '对话读取失败'); });
|
||||||
|
return () => { live = false; };
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true; setFiles([]); setFile('');
|
||||||
|
if (thread) cloudAgentsApi.call('attachments', { thread_id: thread }).then(page => { if (live) setFiles(page.attachments); }).catch(e => { if (live) setError(e instanceof Error ? e.message : '附件读取失败'); });
|
||||||
|
return () => { live = false; };
|
||||||
|
}, [thread]);
|
||||||
|
return <div className="space-y-3 rounded-lg border p-3">
|
||||||
|
<p className="text-xs text-muted-foreground">只显示你自己的对话附件。导入后自动处理,变为“可用于回答”后才能检索。</p>
|
||||||
|
<select aria-label="附件来源对话" className="h-10 w-full rounded-md border bg-background px-3" value={thread} disabled={busy || Boolean(operation.current)} onChange={e => setThread(e.target.value)}><option value="">选择对话</option>{threads.map(item => <option key={item.thread_id} value={item.thread_id}>{item.title || '未命名对话'}</option>)}</select>
|
||||||
|
<select aria-label="入库附件" className="h-10 w-full rounded-md border bg-background px-3" value={file} disabled={busy || Boolean(operation.current)} onChange={e => setFile(e.target.value)}><option value="">选择附件</option>{files.map(item => <option key={item.file_id} value={item.file_id}>{item.file_name}</option>)}</select>
|
||||||
|
<Button type="button" disabled={busy || !thread || !file} onClick={() => void act(async () => {
|
||||||
|
operation.current ??= crypto.randomUUID();
|
||||||
|
const result = await cloudAgentsApi.call('importKnowledgeAttachment', { slug, kb_id: kbId, thread_id: thread, attachment_id: file, operation_id: operation.current });
|
||||||
|
operation.current = null; await onImported(result);
|
||||||
|
})}>{busy ? '正在导入…' : operation.current ? '重试本次导入' : '确认导入知识库'}</Button>
|
||||||
|
{error && <p role="alert" className="text-destructive">{error}</p>}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -13,6 +13,10 @@ test('personal cloud Agent creation, draft save and leave protection in Electron
|
|||||||
let draft: Record<string, unknown> | null = null;
|
let draft: Record<string, unknown> | null = null;
|
||||||
let recent: unknown = null;
|
let recent: unknown = null;
|
||||||
let knowledgeCatalogLoads = 0;
|
let knowledgeCatalogLoads = 0;
|
||||||
|
let knowledgeCreated = false;
|
||||||
|
let knowledgeUploaded = false;
|
||||||
|
let knowledgeStage = -1;
|
||||||
|
const knowledgeFile = { file_id: 'document', name: '本体(Ontology)学习路线图.pdf', size: 34000, error: null };
|
||||||
let limits: { request_limit_points: string | null; daily_limit_points: string | null } = { request_limit_points: null, daily_limit_points: null };
|
let limits: { request_limit_points: string | null; daily_limit_points: string | null } = { request_limit_points: null, daily_limit_points: null };
|
||||||
const jobs: Record<string, unknown>[] = [];
|
const jobs: Record<string, unknown>[] = [];
|
||||||
const configuration = { model: '', tools: [], knowledges: [], mcps: [], skills: [], preload_skills: [], subagents: [], tool_approval_mode: 'default', max_execution_steps: 40, max_output_tokens: 4096, max_run_seconds: 600 };
|
const configuration = { model: '', tools: [], knowledges: [], mcps: [], skills: [], preload_skills: [], subagents: [], tool_approval_mode: 'default', max_execution_steps: 40, max_output_tokens: 4096, max_run_seconds: 600 };
|
||||||
@@ -33,12 +37,28 @@ test('personal cloud Agent creation, draft save and leave protection in Electron
|
|||||||
});
|
});
|
||||||
if (path.startsWith('/api/cloud-agents/')) {
|
if (path.startsWith('/api/cloud-agents/')) {
|
||||||
const body = typeof request.body === 'string' ? JSON.parse(request.body) : request.body ?? {};
|
const body = typeof request.body === 'string' ? JSON.parse(request.body) : request.body ?? {};
|
||||||
|
if (path.endsWith('/knowledge/pick')) {
|
||||||
|
knowledgeUploaded = true;
|
||||||
|
return result({ ...knowledgeFile, status: 'uploaded', chunk_count: 0, available: false, processing_task: null });
|
||||||
|
}
|
||||||
if (path.endsWith('/recovery')) return result({ recent, pending: [] });
|
if (path.endsWith('/recovery')) return result({ recent, pending: [] });
|
||||||
if (path.endsWith('/recovery/recent')) { recent = body; return result({ saved: true }); }
|
if (path.endsWith('/recovery/recent')) { recent = body; return result({ saved: true }); }
|
||||||
if (path.endsWith('/actions')) {
|
if (path.endsWith('/actions')) {
|
||||||
const input = body.input ?? {};
|
const input = body.input ?? {};
|
||||||
if (body.operation === 'catalog') return result({ models: [{ id: 'test-model', name: '创作模型' }], resources: {}, pricing: null });
|
if (body.operation === 'catalog') return result({ models: [{ id: 'test-model', name: '创作模型' }], resources: {}, pricing: null });
|
||||||
if (body.operation === 'knowledge') return result({ databases: [], models: ++knowledgeCatalogLoads === 1 ? [] : [{ id: 'fixture:test-embedding', name: '资料向量模型', dimension: 1024 }] });
|
if (body.operation === 'knowledge') return result({ databases: knowledgeCreated ? [{ kb_id: 'kb', name: '我的创作资料' }] : [], models: ++knowledgeCatalogLoads === 1 ? [] : [{ id: 'fixture:test-embedding', name: '资料向量模型', dimension: 1024 }] });
|
||||||
|
if (body.operation === 'createKnowledge') { knowledgeCreated = true; return result({ kb_id: 'kb', name: '我的创作资料' }); }
|
||||||
|
if (body.operation === 'processKnowledge') { knowledgeStage = 0; return result({ task_id: 'process-document' }); }
|
||||||
|
if (body.operation === 'knowledgeFiles') {
|
||||||
|
const stage = knowledgeStage;
|
||||||
|
if (knowledgeStage >= 0) knowledgeStage++;
|
||||||
|
return result({ next_offset: null, files: knowledgeUploaded ? [
|
||||||
|
{ ...knowledgeFile, status: stage < 1 ? 'uploaded' : stage === 1 ? 'parsing' : stage === 2 ? 'indexing' : 'indexed',
|
||||||
|
available: stage >= 3, chunk_count: stage >= 3 ? 18 : 0,
|
||||||
|
processing_task: stage < 0 ? null : { task_id: 'process-document', status: stage === 0 ? 'pending' : stage < 3 ? 'running' : 'success', error: null } },
|
||||||
|
{ file_id: 'empty', name: '扫描文档.pdf', size: 2000, status: 'indexed', available: false, chunk_count: 0, error: null },
|
||||||
|
] : [] });
|
||||||
|
}
|
||||||
if (body.operation === 'resources') return result({ mcps: [], skills: [], subagents: [] });
|
if (body.operation === 'resources') return result({ mcps: [], skills: [], subagents: [] });
|
||||||
if (body.operation === 'budget' || body.operation === 'saveBudget') {
|
if (body.operation === 'budget' || body.operation === 'saveBudget') {
|
||||||
if (body.operation === 'saveBudget') limits = input;
|
if (body.operation === 'saveBudget') limits = input;
|
||||||
@@ -117,10 +137,25 @@ test('personal cloud Agent creation, draft save and leave protection in Electron
|
|||||||
await expect(page.getByLabel('知识库名称', { exact: true })).toHaveValue('我的创作资料');
|
await expect(page.getByLabel('知识库名称', { exact: true })).toHaveValue('我的创作资料');
|
||||||
await expect(page.getByRole('button', { name: '创建并选用', exact: true })).toBeEnabled();
|
await expect(page.getByRole('button', { name: '创建并选用', exact: true })).toBeEnabled();
|
||||||
await expect(page.getByText('管理员配置说明', { exact: true })).toHaveCount(0);
|
await expect(page.getByText('管理员配置说明', { exact: true })).toHaveCount(0);
|
||||||
await page.getByLabel('知识库名称', { exact: true }).fill('');
|
await page.getByRole('button', { name: '创建并选用', exact: true }).click();
|
||||||
await page.getByRole('button', { name: '新建知识库', exact: true }).click();
|
await expect(page.getByText('上传第一份文档,让智能体用你的资料回答。')).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: '上传文档', exact: true }).click();
|
||||||
|
const document = page.getByRole('article', { name: '本体(Ontology)学习路线图.pdf' });
|
||||||
|
await expect(document.getByText('等待处理', { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText('没有可用内容', { exact: true })).toBeVisible();
|
||||||
|
await document.scrollIntoViewIfNeeded();
|
||||||
|
await page.screenshot({ path: testInfo.outputPath('knowledge-queued.png') });
|
||||||
|
await expect(document.getByText('正在解析文档', { exact: true })).toBeVisible();
|
||||||
|
await expect(document.getByText('正在建立索引', { exact: true })).toBeVisible();
|
||||||
|
await expect(document.getByText('可用于回答', { exact: true })).toBeVisible();
|
||||||
|
await expect(document.getByText(/18 段可检索内容/)).toBeVisible();
|
||||||
|
await document.scrollIntoViewIfNeeded();
|
||||||
|
expect(await document.evaluate(el => el.scrollWidth - el.clientWidth)).toBeLessThanOrEqual(1);
|
||||||
|
await page.screenshot({ path: testInfo.outputPath('knowledge-ready.png') });
|
||||||
await page.getByRole('button', { name: '我的要求', exact: true }).click();
|
await page.getByRole('button', { name: '我的要求', exact: true }).click();
|
||||||
const preview = page.getByRole('region', { name: '草稿预览', exact: true });
|
const preview = page.getByRole('region', { name: '草稿预览', exact: true });
|
||||||
|
await preview.getByRole('button', { name: '保存并重新试用', exact: true }).click();
|
||||||
|
await expect(preview.getByText('当前试用 · 修订 3')).toBeVisible();
|
||||||
await preview.getByRole('button', { name: '你可以怎样帮助我?请举一个例子。', exact: true }).click();
|
await preview.getByRole('button', { name: '你可以怎样帮助我?请举一个例子。', exact: true }).click();
|
||||||
await expect(preview.getByLabel('消息')).toHaveValue('你可以怎样帮助我?请举一个例子。');
|
await expect(preview.getByLabel('消息')).toHaveValue('你可以怎样帮助我?请举一个例子。');
|
||||||
await expect(preview.getByRole('heading', { name: '创作建议' })).toHaveCount(0);
|
await expect(preview.getByRole('heading', { name: '创作建议' })).toHaveCount(0);
|
||||||
@@ -141,7 +176,7 @@ test('personal cloud Agent creation, draft save and leave protection in Electron
|
|||||||
await expect(page.getByText('云端已保存', { exact: true })).toBeVisible();
|
await expect(page.getByText('云端已保存', { exact: true })).toBeVisible();
|
||||||
await expect(preview.getByLabel('消息')).toHaveValue('保存后继续的问题');
|
await expect(preview.getByLabel('消息')).toHaveValue('保存后继续的问题');
|
||||||
await expect(preview.getByRole('heading', { name: '创作建议' })).toBeAttached();
|
await expect(preview.getByRole('heading', { name: '创作建议' })).toBeAttached();
|
||||||
await expect(preview.getByText('当前试用 · 修订 2')).toBeVisible();
|
await expect(preview.getByText('当前试用 · 修订 3')).toBeVisible();
|
||||||
const divider = page.getByRole('separator', { name: '调整配置与试用宽度' });
|
const divider = page.getByRole('separator', { name: '调整配置与试用宽度' });
|
||||||
await divider.focus();
|
await divider.focus();
|
||||||
const previousRatio = Number(await divider.getAttribute('aria-valuenow'));
|
const previousRatio = Number(await divider.getAttribute('aria-valuenow'));
|
||||||
@@ -175,14 +210,14 @@ test('personal cloud Agent creation, draft save and leave protection in Electron
|
|||||||
}
|
}
|
||||||
await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].webContents.setZoomFactor(1));
|
await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].webContents.setZoomFactor(1));
|
||||||
await preview.getByRole('button', { name: '用最新草稿试用' }).click();
|
await preview.getByRole('button', { name: '用最新草稿试用' }).click();
|
||||||
await expect(preview.getByText('当前试用 · 修订 3')).toBeVisible();
|
await expect(preview.getByText('当前试用 · 修订 4')).toBeVisible();
|
||||||
await expect(preview.getByLabel('消息')).toHaveValue('保存后继续的问题');
|
await expect(preview.getByLabel('消息')).toHaveValue('保存后继续的问题');
|
||||||
await preview.getByText('对比上一次试用 · 修订 2', { exact: true }).click();
|
await preview.getByText('对比上一次试用 · 修订 3', { exact: true }).click();
|
||||||
await expect(preview.getByRole('heading', { name: '创作建议' })).toBeVisible();
|
await expect(preview.getByRole('heading', { name: '创作建议' })).toBeVisible();
|
||||||
await preview.getByText('对比上一次试用 · 修订 2', { exact: true }).click();
|
await preview.getByText('对比上一次试用 · 修订 3', { exact: true }).click();
|
||||||
await page.getByRole('button', { name: '简单易懂', exact: true }).click();
|
await page.getByRole('button', { name: '简单易懂', exact: true }).click();
|
||||||
await preview.getByRole('button', { name: '保存并重新试用', exact: true }).click();
|
await preview.getByRole('button', { name: '保存并重新试用', exact: true }).click();
|
||||||
await expect(preview.getByText('当前试用 · 修订 4')).toBeVisible();
|
await expect(preview.getByText('当前试用 · 修订 5')).toBeVisible();
|
||||||
await expect(preview.getByLabel('消息')).toHaveValue('保存后继续的问题');
|
await expect(preview.getByLabel('消息')).toHaveValue('保存后继续的问题');
|
||||||
await preview.getByLabel('消息').fill('');
|
await preview.getByLabel('消息').fill('');
|
||||||
await page.getByTestId('sidebar-module-switcher-trigger').click();
|
await page.getByTestId('sidebar-module-switcher-trigger').click();
|
||||||
|
|||||||
@@ -151,6 +151,28 @@ describe('Main cloud Agents boundary', () => {
|
|||||||
expect(transport).not.toHaveBeenCalled();
|
expect(transport).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves knowledge readiness and processing fields through Main upload and attachment import', async () => {
|
||||||
|
const directory = await mkdtemp(join(tmpdir(), 'makelore-knowledge-status-'));
|
||||||
|
try {
|
||||||
|
const source = join(directory, 'replacement.txt');
|
||||||
|
await writeFile(source, 'knowledge content');
|
||||||
|
desktop.pick.mockResolvedValue({ canceled: false, filePaths: [source] });
|
||||||
|
const file = { file_id: 'new-file', name: 'replacement.txt', size: 17, status: 'indexed', error: null,
|
||||||
|
chunk_count: 2, available: true, replaces_file_id: 'old-file',
|
||||||
|
processing_task: { task_id: 'task', status: 'success', error: null } };
|
||||||
|
const transport = vi.fn().mockResolvedValueOnce(session())
|
||||||
|
.mockResolvedValueOnce(json({ ...file, access_token: 'never-render' }))
|
||||||
|
.mockResolvedValueOnce(json({ ...file, access_token: 'never-render' }));
|
||||||
|
const module = moduleFor(transport);
|
||||||
|
const request = { slug: draft.slug, kb_id: 'kb_test', operation_id: input.operation_id };
|
||||||
|
expect(await module.uploadKnowledge({ ...request, replaces_file_id: 'old-file' })).toEqual(file);
|
||||||
|
expect(await module.execute({ operation: 'importKnowledgeAttachment', input: {
|
||||||
|
...request, thread_id: 'thread', attachment_id: 'attachment',
|
||||||
|
} })).toEqual(file);
|
||||||
|
expect(new URL(transport.mock.calls[1][0]).searchParams.get('replaces_file_id')).toBe('old-file');
|
||||||
|
} finally { await rm(directory, { recursive: true, force: true }); }
|
||||||
|
});
|
||||||
|
|
||||||
it('notifies only a new unread terminal state and resets its baseline on account switch', async () => {
|
it('notifies only a new unread terminal state and resets its baseline on account switch', async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const transport = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json({ agents: [], next_cursor: null }))
|
const transport = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json({ agents: [], next_cursor: null }))
|
||||||
|
|||||||
225
tests/unit/cloud-knowledge-status.test.tsx
Normal file
225
tests/unit/cloud-knowledge-status.test.tsx
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react';
|
||||||
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||||
|
import { CloudKnowledgePanel } from '@/pages/CloudAgents/CloudKnowledge';
|
||||||
|
import type { CloudKnowledgeFile } from '../../shared/cloud-agents';
|
||||||
|
|
||||||
|
const api = vi.hoisted(() => ({ call: vi.fn(), uploadKnowledge: vi.fn() }));
|
||||||
|
vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api }));
|
||||||
|
const file: CloudKnowledgeFile = { file_id: 'file', name: '学习路线图.pdf', size: 3000, status: 'uploaded', chunk_count: 0, error: null, available: false };
|
||||||
|
let items: CloudKnowledgeFile[];
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks(); items = [];
|
||||||
|
api.call.mockImplementation(async operation => {
|
||||||
|
if (operation === 'knowledge') return { databases: [{ kb_id: 'kb', name: '资料' }, { kb_id: 'other', name: '其他库' }], models: [] };
|
||||||
|
if (operation === 'knowledgeFiles') return { files: items, next_offset: null };
|
||||||
|
if (operation === 'processKnowledge') {
|
||||||
|
items = items.map(value => ({ ...value, processing_task: { task_id: 'task', status: 'pending', error: null } }));
|
||||||
|
return { task_id: 'task' };
|
||||||
|
}
|
||||||
|
throw new Error('unexpected ' + operation);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
afterEach(() => { cleanup(); vi.useRealTimers(); });
|
||||||
|
async function open() {
|
||||||
|
render(<CloudKnowledgePanel slug="ml-agent" onCreated={() => undefined} />);
|
||||||
|
fireEvent.click(screen.getByText('管理我的知识文档'));
|
||||||
|
await screen.findByRole('option', { name: '资料' });
|
||||||
|
fireEvent.change(screen.getByLabelText('管理的知识库'), { target: { value: 'kb' } });
|
||||||
|
}
|
||||||
|
async function tick() { await act(async () => { await vi.advanceTimersByTimeAsync(5000); }); }
|
||||||
|
|
||||||
|
it('uploads then processes automatically and refreshes through queue, parse, index and readiness', async () => {
|
||||||
|
await open();
|
||||||
|
await screen.findByText('上传第一份文档,让智能体用你的资料回答。');
|
||||||
|
vi.useFakeTimers();
|
||||||
|
api.uploadKnowledge.mockImplementation(async () => { items = [file]; return file; });
|
||||||
|
await act(async () => { fireEvent.click(screen.getByRole('button', { name: '上传文档', exact: true })); });
|
||||||
|
expect(screen.getByText('等待处理')).toBeVisible();
|
||||||
|
expect(screen.queryByText(/0 个分块/)).not.toBeInTheDocument();
|
||||||
|
expect(api.call).toHaveBeenCalledWith('processKnowledge', expect.objectContaining({ kb_id: 'kb', file_id: 'file' }));
|
||||||
|
items = [{ ...file, status: 'parsing', processing_task: { task_id: 'task', status: 'running', error: null } }];
|
||||||
|
await tick(); expect(screen.getByText('正在解析文档')).toBeVisible();
|
||||||
|
items = [{ ...items[0], status: 'indexing' }];
|
||||||
|
await tick(); expect(screen.getByText('正在建立索引')).toBeVisible();
|
||||||
|
items = [{ ...file, status: 'indexed', available: true, chunk_count: 12, processing_task: { task_id: 'task', status: 'success', error: null } }];
|
||||||
|
await tick();
|
||||||
|
expect(screen.getByText('可用于回答')).toBeVisible();
|
||||||
|
expect(screen.getByText(/12 段可检索内容/)).toBeVisible();
|
||||||
|
expect(api.uploadKnowledge).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not call zero chunks searchable and distinguishes loading from an empty library', async () => {
|
||||||
|
let resolve!: (value: unknown) => void;
|
||||||
|
const original = api.call.getMockImplementation()!;
|
||||||
|
api.call.mockImplementation((operation, input) => operation === 'knowledgeFiles'
|
||||||
|
? new Promise(value => { resolve = value; }) : original(operation, input));
|
||||||
|
await open();
|
||||||
|
expect(screen.getByText('正在读取文档状态…')).toBeVisible();
|
||||||
|
expect(screen.queryByText(/上传第一份/)).not.toBeInTheDocument();
|
||||||
|
await act(async () => resolve({ files: [{ ...file, status: 'indexed' }], next_offset: null }));
|
||||||
|
expect(screen.getByText('没有可用内容')).toBeVisible();
|
||||||
|
expect(screen.queryByText('可用于回答')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/扫描 PDF 需先识别文字/)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retains the saved upload and processing identity after an uncertain submission', async () => {
|
||||||
|
const original = api.call.getMockImplementation()!;
|
||||||
|
const attempts: unknown[] = [];
|
||||||
|
api.call.mockImplementation(async (operation, input) => {
|
||||||
|
if (operation === 'processKnowledge') {
|
||||||
|
attempts.push(input);
|
||||||
|
if (attempts.length === 1) throw new Error('连接超时');
|
||||||
|
}
|
||||||
|
return original(operation, input);
|
||||||
|
});
|
||||||
|
api.uploadKnowledge.mockImplementation(async () => { items = [file]; return file; });
|
||||||
|
await open();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '上传文档', exact: true }));
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent('文件已保存,处理请求尚未确认');
|
||||||
|
expect(screen.getByRole('article', { name: file.name })).toBeVisible();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '重试处理' }));
|
||||||
|
await screen.findByText('等待处理');
|
||||||
|
expect(attempts).toHaveLength(2);
|
||||||
|
expect(attempts[0]).toEqual(attempts[1]);
|
||||||
|
expect(api.uploadKnowledge).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores terminal task errors on entry and clears stale read errors after recovery', async () => {
|
||||||
|
items = [{ ...file, processing_task: { task_id: 'task', status: 'failed', error: '文档读取失败' } }];
|
||||||
|
await open();
|
||||||
|
expect(await screen.findByText('处理失败')).toBeVisible();
|
||||||
|
expect(screen.getByText('文档读取失败')).toBeVisible();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
api.call.mockRejectedValueOnce(new Error('读取超时'));
|
||||||
|
await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新状态' })));
|
||||||
|
expect(screen.getByRole('alert')).toHaveTextContent('以下保留上次结果');
|
||||||
|
expect(screen.getByRole('article')).toBeVisible();
|
||||||
|
await tick();
|
||||||
|
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['success', 'failed'] as const)('uses a newly observed %s Task to resolve an uncertain submission', async status => {
|
||||||
|
items = [{ ...file, processing_task: { task_id: 'old-task', status: 'failed', error: '上次失败' } }];
|
||||||
|
const attempts: { operation_id: string }[] = [];
|
||||||
|
const original = api.call.getMockImplementation()!;
|
||||||
|
api.call.mockImplementation(async (operation, input) => {
|
||||||
|
if (operation === 'processKnowledge') {
|
||||||
|
attempts.push(input);
|
||||||
|
if (attempts.length < 3) throw new Error('连接超时');
|
||||||
|
}
|
||||||
|
return original(operation, input);
|
||||||
|
});
|
||||||
|
await open();
|
||||||
|
await screen.findByText('处理失败');
|
||||||
|
vi.useFakeTimers();
|
||||||
|
await act(async () => fireEvent.click(screen.getByRole('button', { name: '重试处理' })));
|
||||||
|
await tick(); // 旧 Task 仍可见时,保留同一请求身份。
|
||||||
|
expect(screen.getByRole('alert')).toHaveTextContent('处理请求尚未确认');
|
||||||
|
await act(async () => fireEvent.click(screen.getByRole('button', { name: '重试处理' })));
|
||||||
|
expect(attempts[1].operation_id).toBe(attempts[0].operation_id);
|
||||||
|
items = [{ ...file, status: status === 'success' ? 'indexed' : 'uploaded',
|
||||||
|
available: status === 'success', chunk_count: status === 'success' ? 3 : 0,
|
||||||
|
processing_task: { task_id: 'new-task', status, error: status === 'failed' ? '新处理失败' : null } }];
|
||||||
|
await tick();
|
||||||
|
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||||
|
if (status === 'success') {
|
||||||
|
expect(screen.getByText('可用于回答')).toBeVisible();
|
||||||
|
expect(screen.queryByRole('button', { name: '重试处理' })).not.toBeInTheDocument();
|
||||||
|
} else {
|
||||||
|
expect(screen.getByText('新处理失败')).toBeVisible();
|
||||||
|
await act(async () => fireEvent.click(screen.getByRole('button', { name: '重试处理' })));
|
||||||
|
expect(attempts[2].operation_id).not.toBe(attempts[0].operation_id);
|
||||||
|
expect(screen.getByText('等待处理')).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps all loaded pages refreshing and ignores a late response from the previous knowledge base', async () => {
|
||||||
|
let late!: (value: unknown) => void;
|
||||||
|
let useLate = false;
|
||||||
|
const original = api.call.getMockImplementation()!;
|
||||||
|
const second = { ...file, file_id: 'second', name: '第二页.txt' };
|
||||||
|
api.call.mockImplementation(async (operation, input) => {
|
||||||
|
if (operation !== 'knowledgeFiles') return original(operation, input);
|
||||||
|
if (input.kb_id === 'other') return { files: [], next_offset: null };
|
||||||
|
if (useLate) return new Promise(resolve => { late = resolve; });
|
||||||
|
return input.offset ? { files: [second], next_offset: null } : { files: [file], next_offset: 100 };
|
||||||
|
});
|
||||||
|
await open();
|
||||||
|
const more = await screen.findByRole('button', { name: '加载更多文档' });
|
||||||
|
vi.useFakeTimers();
|
||||||
|
await act(async () => fireEvent.click(more));
|
||||||
|
expect(screen.getByRole('article', { name: '第二页.txt' })).toBeVisible();
|
||||||
|
second.status = 'indexed'; second.available = true; second.chunk_count = 6;
|
||||||
|
await tick();
|
||||||
|
expect(within(screen.getByRole('article', { name: '第二页.txt' })).getByText('可用于回答')).toBeVisible();
|
||||||
|
useLate = true;
|
||||||
|
await tick();
|
||||||
|
await act(async () => fireEvent.change(screen.getByLabelText('管理的知识库'), { target: { value: 'other' } }));
|
||||||
|
await act(async () => late({ files: [file], next_offset: null }));
|
||||||
|
expect(screen.queryByRole('article')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText('上传第一份文档,让智能体用你的资料回答。')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('imports an attachment and processes it without claiming immediate readiness', async () => {
|
||||||
|
const original = api.call.getMockImplementation()!;
|
||||||
|
api.call.mockImplementation(async (operation, input) => {
|
||||||
|
if (operation === 'threads') return { threads: [{ thread_id: 'thread', title: '我的对话' }], next_offset: null };
|
||||||
|
if (operation === 'attachments') return { attachments: [{ file_id: 'attachment', file_name: '附件.pdf' }] };
|
||||||
|
if (operation === 'importKnowledgeAttachment') { items = [file]; return file; }
|
||||||
|
return original(operation, input);
|
||||||
|
});
|
||||||
|
await open();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '从对话附件导入' }));
|
||||||
|
await screen.findByRole('option', { name: '我的对话' });
|
||||||
|
fireEvent.change(screen.getByLabelText('附件来源对话'), { target: { value: 'thread' } });
|
||||||
|
await screen.findByRole('option', { name: '附件.pdf' });
|
||||||
|
fireEvent.change(screen.getByLabelText('入库附件'), { target: { value: 'attachment' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '确认导入知识库' }));
|
||||||
|
await screen.findByText('等待处理');
|
||||||
|
expect(screen.queryByText('可用于回答')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps indexed content available when replacement cleanup fails and permits retry', async () => {
|
||||||
|
items = [{ ...file, status: 'indexed', chunk_count: 4, available: true, replaces_file_id: 'old',
|
||||||
|
processing_task: { task_id: 'task', status: 'failed', error: '旧文档清理失败' } }];
|
||||||
|
await open();
|
||||||
|
expect(await screen.findByText('内容已可用,处理未全部完成')).toBeVisible();
|
||||||
|
expect(screen.getByText(/已显示 1 份文档 · 1 份可用/)).toBeVisible();
|
||||||
|
expect(screen.getByText(/4 段可检索内容/)).toBeVisible();
|
||||||
|
expect(screen.getByText(/旧文档清理失败/)).toBeVisible();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
await act(async () => fireEvent.click(screen.getByRole('button', { name: '重试处理' })));
|
||||||
|
expect(screen.getByText('内容已可用,正在完成处理')).toBeVisible();
|
||||||
|
expect(screen.getByText(/已显示 1 份文档 · 1 份可用/)).toBeVisible();
|
||||||
|
expect(screen.getByText(/4 段可检索内容/)).toBeVisible();
|
||||||
|
items = [{ ...items[0], processing_task: { task_id: 'task', status: 'running', error: null } }];
|
||||||
|
await tick();
|
||||||
|
expect(screen.getByText('内容已可用,正在完成处理')).toBeVisible();
|
||||||
|
expect(screen.getByText(/已显示 1 份文档 · 1 份可用/)).toBeVisible();
|
||||||
|
expect(screen.getByText(/4 段可检索内容/)).toBeVisible();
|
||||||
|
items = [{ ...items[0], processing_task: { task_id: 'task', status: 'success', error: null } }];
|
||||||
|
await tick();
|
||||||
|
expect(screen.getByText('可用于回答')).toBeVisible();
|
||||||
|
expect(api.call).toHaveBeenCalledWith('processKnowledge', expect.objectContaining({ file_id: 'file' }));
|
||||||
|
expect(api.uploadKnowledge).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('automatically processes a replacement while retaining the usable old document on failure', async () => {
|
||||||
|
items = [{ ...file, status: 'indexed', chunk_count: 4, available: true }];
|
||||||
|
const replacement = { ...file, file_id: 'new', name: '新版.pdf', replaces_file_id: 'file' };
|
||||||
|
api.uploadKnowledge.mockImplementation(async () => { items = [...items, replacement]; return replacement; });
|
||||||
|
const original = api.call.getMockImplementation()!;
|
||||||
|
api.call.mockImplementation(async (operation, input) => {
|
||||||
|
if (operation === 'processKnowledge') {
|
||||||
|
items = items.map(value => value.file_id === 'new' ? { ...replacement, status: 'error_parsing', processing_task: { task_id: 'task', status: 'failed', error: '文字读取失败' } } : value);
|
||||||
|
return { task_id: 'task' };
|
||||||
|
}
|
||||||
|
return original(operation, input);
|
||||||
|
});
|
||||||
|
await open();
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: '替换文档' }));
|
||||||
|
await screen.findByText('处理失败');
|
||||||
|
expect(within(screen.getByRole('article', { name: file.name })).getByText('可用于回答')).toBeVisible();
|
||||||
|
expect(screen.getByText(/这是替换文档/)).toBeVisible();
|
||||||
|
expect(api.uploadKnowledge).toHaveBeenCalledWith('ml-agent', 'kb', expect.any(String), 'file');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user