diff --git a/.project-docs/30-worklog/tasks/20261013-chat-knowledge-import-explain-ec03b792.md b/.project-docs/30-worklog/tasks/20261013-chat-knowledge-import-explain-ec03b792.md index 4474b2a1..d9292e72 100644 --- a/.project-docs/30-worklog/tasks/20261013-chat-knowledge-import-explain-ec03b792.md +++ b/.project-docs/30-worklog/tasks/20261013-chat-knowledge-import-explain-ec03b792.md @@ -12,35 +12,38 @@ ## Scope -- Explain the screenshot's "从聊天选择文件" entry, source-chat/file range, destination, processing and activation. Inspect committed client knowledge-workspace source c634425 and committed Yuxi reference source; no product edits. +- Explain the screenshot's "从聊天选择文件" entry, then remove the chat-file import entry/workflow at the user's explicit instruction. Keep computer upload, current document management and knowledge-library selection/activation. ## Intent And Constraints -- User requests a product-language explanation, not an import, redesign or app update. Preserve real files/chats/libraries, runtime, peer worktrees and source. Only this owned record changes. +- User initially requested explanation; the latest explicit instruction authorizes removing chat-file import and retaining computer upload. This supersedes the previous feature's approved chat-import entry. +- Additional user steering: place the knowledge creation button below the computer upload button. Move the new-library entry/creation form under the upload action row; keep first-library creation available when upload has no selected target. +- Keep real files/chats/libraries, runtime, Main/backend APIs and old recovery records. No actual imports, deletion of user data, app update, main integration or push. Own only removal-related UI/copy/style/tests and this record. ## Outcome -- This entry copies already uploaded/confirmed attachments from the user's personal Agents chats into the own knowledge library selected on the left ("试试" in the screenshot). Source chat attachments remain in place. -- Threads are loaded without a target-agent filter, so the user's eligible chats from other personal/shared Agents can be sources. Caller ownership is enforced by Yuxi; the target creator cannot import someone else's attachments. -- Chat text and assistant-generated workdir files are not imported. The picker fetches attachments, not history or generated-file listings. -- Latest committed picker supports searching chats/files, cross-chat selection, supported-document limits (5 MB; txt/md/csv/json/docx/xlsx/pdf), independent import receipts and same-operation failure retries. -- Client onImported invokes processKnowledge; status polling confirms actual indexed chunks/availability before displaying "可用于回答". Selecting a library for an Agent changes configuration and requires explicit save/apply; import is not itself final activation. -- Import reuses the knowledge upload/storage path, making a new knowledge copy without moving/deleting the chat original. Other eligible personal Agents can select the same own library. +- Removed the "从聊天选择文件" entry, its picker component and exclusive styles/state/props/tests. Knowledge adding has only the computer-upload entry. Upload copy and README match. +- Moved "新建知识库" and its expanded creation form below the computer-upload action row. Before a target is selected, the upload button is disabled and first-library creation remains available directly below it. +- Preserved automatic document processing, status polling, pagination, original-operation retries, replacement/deletion, permission-loss pending state, own/shared library distinctions and explicit save/apply of selection. +- Main/shared/backend import contracts and recovery handling remain for existing pending operations. No real files/chats/libraries were imported, moved or deleted; current app/runtime was not refreshed. ## Verification -- Read source: CloudKnowledgeAttachmentPicker thread/attachment/selection/import flow, CloudKnowledgeDocuments onImported/processing callback and CloudKnowledge target selection; existing focused import test shows cross-chat attachment selection and no history/files/deleteAttachment calls. -- Independent read-only Yuxi review confirms owned personal thread checking, confirmed attachment lookup, original file read/new upload, limits and separate assistant-generated file directory. -- No application tests/build required for explanation-only source inspection; no real import, cloud mutation, file movement or runtime update performed. -- Task-aware documentation drift and whitespace checks run at completion. +- Initial explanation inspected committed client/Yuxi import behavior. Subsequent removal is explicitly authorized and does not change backend contracts. +- Pinned corepack pnpm 10.33.4: 6 focused knowledge/catalog/configuration/page suites, 91 tests passed. Existing upload-to-ready test now also verifies no chat-import entry or chat API calls. Retired picker tests were deleted with the component. +- Typecheck, scoped ESLint, full Renderer/Main/preload/utility build and git diff --check passed. +- Headless Vite/Playwright knowledge fixture: 5/5 passed in 18.0s, covering 375px/1440px library selection/save/reopen, first-library creation, upload/auto processing and failed/cancelled upload protection. Both viewport cases assert the new-library button is below upload before and after selecting/creating a target. No real cloud request, native picker or current app operation occurs in the fixture. +- Two prior tests expected no upload button when a target was unavailable; updated explicitly to a disabled placeholder under the new requested layout, preserving the no-document-request assertions. +- Independent review passed for removal and creation-control reorder, including uncertain upload/create and hidden permission-loss state. Inspected generated desktop screenshot confirming the physical button order and removal. +- Task-aware documentation drift and whitespace checks run at completion; own record is the only .project-docs edit. ## Follow-ups -- Knowledge workspace peer source remains a separately owned completed feature; its recorded app update is deferred. This explanation does not authorize applying it or importing user files. +- Source handoff is ready for later integration/application update. The knowledge-workspace source record and current trial remain unchanged; no main integration, push or app update occurred. ## Promotion Candidates -- None; current behavior explanation does not change accepted product semantics. +- Target: personal Agents desktop knowledge UI documentation in a future integration. Proposal: adding knowledge documents uses computer upload only, and new-library creation appears below that upload entry. Evidence: explicit user instructions, source changes, focused regression/viewport checks. No API, storage, privacy or existing-file migration change; human direction already supplied. ## Project Context Loaded @@ -48,5 +51,11 @@ - Current context: prior entry/positioning/current-state/decision/architecture/domain/success-criteria knowledge rules rechecked; personal-cloud-agents ADR confirms own attachments, independent caller privacy, explicit configuration activation and processing readiness. - Peer Scope/Intent/Promotion sections read-only; knowledge-workspace ready source is the explicit verifiable base. Unknown legacy scopes have no concrete dependency for this explanation. - Goal: personal Agent knowledge reuse with Main-owned transport and Yuxi-owned permissions/storage. Relevant files: picker, documents, knowledge selection and Yuxi knowledge/conversation service. -- Constraints: no user-file import, live UI operation, app refresh or product changes. Runtime deployment of the peer's newer picker is not inferred from a screenshot. -- Gate result: Passed for read-only explanation; no coding plan or implementation needed. +- Constraints: no user-file import, live UI operation or app refresh; removal-related source edits are explicitly authorized. Runtime deployment of the peer's newer picker is not inferred from a screenshot. +- Gate result: Passed on resume for the explicitly authorized removal. Ownership and peer scopes rechecked; source base and unchanged canonical context retained. + +## Implementation Plan + +- Remove the chat-import button, component and its unused local props/state/styles; preserve upload, processing, retries and document management. +- Update explanatory copy and README to computer upload only; remove retired picker tests and adapt existing upload/E2E coverage to confirm the remaining path. +- Run focused knowledge tests, typecheck, scoped lint, build and the headless knowledge fixture; verify task document boundaries and prepare a committed source handoff. diff --git a/README.md b/README.md index 2c3b8814..1e457d39 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智 会话工作台采用浅蓝项目栏、彩色通用手柄图标与卡片选中态;中间对话/作品使用圆角标签,作品预览的后退、前进、刷新与网址输入统一放在标签右侧;只在作品页显示,窄窗口自动换行。右侧智能体咨询使用奶油色标题区。沿用真实项目和会话入口、侧栏伸缩与账户菜单,样式只作用于 Code 会话工作台。可编辑的空对话在输入区上方的可用区域中央显示童趣欢迎语;「去作品广场逛逛」入口紧贴输入框上方,两者一起位于底部。老师介绍以固定宽度浮在右上角,不占用中间布局;欢迎语和底部输入区始终相对项目栏右侧的整个内容区域居中;小窗口的欢迎内容独立滚动;发送首条消息后收起欢迎区。侧栏保留「作品广场」入口,只有点击后才在系统浏览器打开公共广场,不请求、摘取或内嵌展示广场作品。打开广场保留当前项目、对话、草稿和附件,不自动发送消息;老师求助按钮保留临时应用的最新文案「帮我整体看🧠」。 - `Makelore Agents|AI 智能体`:从模块首页进入时先打开“我的智能体”卡片列表,保留此前对话与操作恢复记录;返回首页箭头与侧栏折叠按钮同排,折叠后仍可返回;智能体模块的侧栏只通过按钮点击或键盘激活展开,鼠标经过左侧、收纳按钮或顶栏不会展开,切换模块不沿用旧悬停预览;其他模块仍保留原预览方式。“我的智能体”使用浅紫对话机器人图标,“渠道”使用浅蓝连接节点图标。macOS 原生窗口按钮留出安全空间。首页围绕自己的智能体和创建入口,显示童趣问候“Hi,今天想创造还是想聊聊?”,不再显示更多、收到的分享、活动或归档筛选。卡片恢复原有尺寸与留白,显示名称、可为空的一句话介绍及状态,支持聊天、调整、继续制作和确认删除;删除沿用云端归档机制,移出列表并停用新调用,历史和费用保留。创建先填写名称并选择平台像素头像或上传头像,下一步进入独立配置。顶部名称有编辑图标,头像可点击修改;未设置头像时使用同一张默认像素头像,从创建到配置、改名、保存和重新打开保持一致。基础页为“介绍与提示词”,一句话介绍可留空,完整提示词保留原始文本;模型作为高级设置第一项必选,未选择时不能开始使用,草稿可分步保存,不自动选模型。介绍与提示词采用浅蓝介绍卡和浅紫提示词卡;高级设置按模型、工具箱(Tool)、做事手册(Skill)、MCP 服务、子智能体和知识库纵向呈现,不再设内部分类切换;不展示 Tool/Skill 解释、资源开放规则提示、云端能力管理、费用上限或运行限制卡片,保存其他配置时保留既有运行限制,费用规则不变。顶层页签切换保留搜索、资源选择、知识表单及未保存输入。配置页移除聊天、自动任务和使用与接入导航,独立聊天仍从卡片进入,微信账号仍在侧栏渠道管理。草稿明确保存(Ctrl/⌘+S);保存只更新草稿,“应用最新修改”使新配置生效,失败重试保持原操作身份;点击“开始使用”完成必要的私有启用后直接打开聊天,不再展示二次确认页;未保存修改、缺少模型、冲突或尚未确认的请求会留在配置页处理,打开聊天不自动发送消息。独立对话沿用奶油底色和浅蓝、浅紫、薄荷色:顶部展示返回、只读头像和名称;有附件时在同一行右侧显示回形针“附件”入口和文件数量,不展示调整、保存/应用状态、客户端/微信来源、费用/版本说明或历史/新对话图标;配置由列表卡片的“调整”进入。空对话展示当前智能体头像与名称,示例只填入草稿;用户消息使用浅紫圆角气泡,助手 Markdown、表格和代码在消息区内滚动。文件或图片从输入框内添加,上传成功后以待发送卡片呈现,可移除本次携带;移除不会删除云端文件,添加不会自动发送。确认失败沿用同一文件和对话重试,发送前需完成或取消添加;支持仅发送附件,使用明确的附件提问。输入框的“+”添加文件或图片;右上角“附件”打开右侧面板,按“你上传的”和“助手生成的”分组,支持再次选用已上传附件及保存明确交付的生成文件。生成文件仅取当前对话已保存助手回复中的本地文件链接(含图片与引用式链接),核对实际文件后展示和计数;只读取链接所在的文件夹,不遍历全部工作文件。未交付的脚本、缓存和工具记录不展示、不计数,明确交付的代码照常展示;仅写了路径的旧成果可能暂时隐藏,后续接入 Yuxi 的正式交付记录。没有文件时隐藏入口;打开、关闭面板保留聊天与输入,Escape 关闭并返回入口焦点;最近对话自动恢复、审批、停止和未发送内容的离开保护保留。模型返回的思考独立于正文展示,运行时可展开、手动折叠,完成后折叠;无思考内容时不显示。重连事件去重并隔离当前运行与主对话,终态历史未到达时保留已收到的片段并有限补读;服务端返回的最终回答思考可在历史中回看,不进入后续提问。历史回看需要 Yuxi API 思考投影补丁上线,当前未部署;实时思考沿用既有事件流。头像上传、Main-owned Host API、访问隔离、费用与不可变版本规则保持;介绍留空需要配套 Yuxi DraftCreate/DraftSave 的空字符串契约。 - 高级配置的知识库支持下拉多选,分为「MakeLore知识」和「我的知识库」,已选项可单独移除。MakeLore知识来自当前账号有权读取的 Yuxi 共享目录,只显示名称与简介;移除仅取消当前智能体选用,不删除原库。知识库区域左右分区:左侧「上传自己的文件」管理本人知识库,右侧选择「MakeLore知识」和「我的知识库」,小窗口上下排列。左侧可新建库或追加资料,提供「从电脑上传」和「从聊天选择文件」;聊天文件选择面板显示目标知识库、可搜索的来源聊天和文件名称/类型/大小,支持多选,逐文件导入并保留独立操作身份,部分失败只重试失败项;也可明确确认放弃本页重试,已保存文件仍保留。仅选本人已上传的聊天附件,聊天正文和助手生成的文件不直接入库。上传和导入后自动解析、建立索引并持续显示真实处理状态;失败可重试且保留已有可用内容。使用说明与费用以折叠说明呈现。暂不可选用的个人库仍可管理文件,已有绑定保留并提示不可用;未确认请求在当前库保留同一操作身份供重试。知识库选择只改草稿,显式保存/应用后生效;个人云端执行已自动接入所选知识库的检索,无需另外勾选 knowledge-base Skill。此交互复用现有接口,无需新增后端补丁。 + 高级配置的知识库支持下拉多选,分为「MakeLore知识」和「我的知识库」,已选项可单独移除。MakeLore知识来自当前账号有权读取的 Yuxi 共享目录,只显示名称与简介;移除仅取消当前智能体选用,不删除原库。知识库区域左右分区:左侧「上传自己的文件」管理本人知识库,右侧选择「MakeLore知识」和「我的知识库」,小窗口上下排列。左侧可新建库或追加资料,仅提供「从电脑上传」作为添加文件入口;「新建知识库」按钮和创建表单位于上传按钮下方,未选择知识库时上传按钮置灰,仍可先新建知识库;聊天文件导入入口已移除。上传后自动解析、建立索引并持续显示真实处理状态;失败可重试且保留已有可用内容。使用说明与费用以折叠说明呈现。暂不可选用的个人库仍可管理文件,已有绑定保留并提示不可用;未确认请求在当前库保留同一操作身份供重试。知识库选择只改草稿,显式保存/应用后生效;个人云端执行已自动接入所选知识库的检索,无需另外勾选 knowledge-base Skill。此交互复用现有接口,无需新增后端补丁。 云知识库的 embedding 模型只由平台管理员在 Yuxi 后台维护:在「智能体管理 → 模型供应商」启用供应商,按供应商要求填写有效的服务地址和 API Key,添加并启用 embedding 模型并填写实际向量维度;embedding 向量化费用由平台承担,不计入用户用量、词元点数或智能体费用上限;one-api 不参与 embedding 配置,Works Square 只负责其他应计费模型调用的创建者词元点数账本。客户端区分目录加载中、加载失败与无可用模型,空状态提供管理员配置说明;「刷新知识库与模型」保留未提交名称,未确认的创建请求沿用原操作身份。 diff --git a/src/pages/CloudAgents/CloudKnowledge.tsx b/src/pages/CloudAgents/CloudKnowledge.tsx index a54ee14b..d1b09b30 100644 --- a/src/pages/CloudAgents/CloudKnowledge.tsx +++ b/src/pages/CloudAgents/CloudKnowledge.tsx @@ -64,10 +64,42 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted, onSelected, on const selectedKnowledge = manageable.find(kb => kb.kb_id === kbId); const managementDisabled = disabled || busy || catalogLoading || Boolean(catalogError); const bindable = (id: string) => selectableIds === undefined || Boolean(selectableIds?.includes(id)); + const creationControls =
+ + {creating &&
+ setName(e.target.value)} /> + + {!catalogLoading && !catalogError && catalog && !catalog.models.length &&
+

暂时没有可用的向量模型。Embedding 只在 Yuxi 配置,请联系平台管理员启用后刷新;向量化费用由平台承担,不计入用户用量或智能体费用上限。

+
+ 管理员配置说明 +
    +
  1. Yuxi 后台「智能体管理 → 模型供应商」:启用供应商,按供应商要求填写有效的服务地址和 API Key。
  2. +
  3. 在同一后台添加并启用 embedding 模型,填写该模型的实际向量维度。
  4. +
  5. 配置完成后回到这里点击「刷新知识库与模型」。
  6. +
+
+
} + +
} + {documentPending && selectedKnowledge &&

有未确认的文件操作,请先在当前知识库重试或确认状态,再切换知识库。

} +
; return
-

选择或新建自己的知识库,再从电脑或聊天中添加资料。选用会加入右侧配置,保存并应用后生效。

+

选择或新建自己的知识库,再从电脑上传资料。选用会加入右侧配置,保存并应用后生效。

{error &&

{error}

} {catalogError &&

知识库与模型目录加载失败:{catalogError}。请刷新重试。

} @@ -85,7 +117,6 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted, onSelected, on {kb.name}{selectableIds === null ? '(选用状态待确认)' : !bindable(kb.kb_id) ? '(当前暂不可选用)' : ''} )} -
@@ -96,35 +127,6 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted, onSelected, on {selectedKnowledge && selectableIds !== undefined && !bindable(selectedKnowledge.kb_id) &&

{selectableIds === null ? '知识库选用状态待确认。' : '这个知识库当前暂不可选用。'}你仍可管理自己的文件,刷新目录后再试。

} - {creating &&
- setName(e.target.value)} /> - - {!catalogLoading && !catalogError && catalog && !catalog.models.length &&
-

暂时没有可用的向量模型。Embedding 只在 Yuxi 配置,请联系平台管理员启用后刷新;向量化费用由平台承担,不计入用户用量或智能体费用上限。

-
- 管理员配置说明 -
    -
  1. Yuxi 后台「智能体管理 → 模型供应商」:启用供应商,按供应商要求填写有效的服务地址和 API Key。
  2. -
  3. 在同一后台添加并启用 embedding 模型,填写该模型的实际向量维度。
  4. -
  5. 配置完成后回到这里点击「刷新知识库与模型」。
  6. -
-
-
} - -
} - {documentPending && selectedKnowledge &&

有未确认的文件操作,请先在当前知识库重试或确认状态,再切换知识库。

} {kbId && !selectedKnowledge && !catalogLoading && !catalogError &&

这个知识库已无法管理。{documentPending ? '未确认的操作仍保留在当前页面。' : '请重新选择自己的知识库。'}

{documentPending &&

放弃只清除本地重试请求,不会删除已保存的文件。重新进入后可确认服务器状态。

}
} - {kbId && } + {!selectedKnowledge &&
+ + {creationControls} +
} + {kbId && }
; } diff --git a/src/pages/CloudAgents/CloudKnowledgeAttachmentPicker.tsx b/src/pages/CloudAgents/CloudKnowledgeAttachmentPicker.tsx deleted file mode 100644 index 88c0509c..00000000 --- a/src/pages/CloudAgents/CloudKnowledgeAttachmentPicker.tsx +++ /dev/null @@ -1,280 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import { CheckCircle2, FileText, Loader2, Search, X } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog'; -import { cloudAgentsApi } from '@/lib/cloud-agents-api'; -import { usePendingCloudInput } from './CloudPending'; -import type { CloudAttachment, CloudKnowledgeFile, CloudThread } from '../../../shared/cloud-agents'; - -type Selection = { key: string; threadId: string; threadTitle: string; file: CloudAttachment }; -type ImportJob = Selection & { - operationId: string; status: 'queued' | 'running' | 'saved' | 'failed' | 'done'; - importedFile?: CloudKnowledgeFile; error?: string; -}; - -function fileDetails(file: CloudAttachment) { - const extension = file.file_name.split('.').pop()?.toLowerCase() ?? ''; - const type = file.file_name.includes('.') ? extension.toUpperCase() : '文件'; - const size = Number.isFinite(file.file_size) - ? file.file_size < 1024 ? `${file.file_size} B` : file.file_size < 1024 * 1024 - ? `${Math.ceil(file.file_size / 1024)} KB` : `${(file.file_size / (1024 * 1024)).toFixed(1)} MB` - : '大小未知'; - const reason = file.file_size > 5 * 1024 * 1024 ? '超过 5 MB,暂不能导入' - : !/^(txt|md|csv|json|docx|xlsx|pdf)$/.test(extension) ? '此格式暂不支持知识库导入' : ''; - return { type, size, reason }; -} - -function updatedDate(value: string) { - const date = new Date(value); - return Number.isFinite(date.getTime()) ? date.toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric', year: 'numeric' }) : '更新时间未知'; -} -function errorMessage(error: unknown) { return error instanceof Error ? error.message : '请稍后重试。'; } - -export function CloudKnowledgeAttachmentPicker({ slug, kbId, kbName, busy, canRead = true, act, onImported, onPendingChange, onClose, onRefreshKnowledge }: { - slug: string; kbId: string; kbName: string; busy: boolean; canRead?: boolean; - act: (operation: () => Promise) => Promise; - onImported: (file: CloudKnowledgeFile) => Promise; - onPendingChange: (pending: boolean) => void; - onClose?: () => void; onRefreshKnowledge?: () => void; -}) { - const scope = slug + ':' + kbId; - const [threads, setThreads] = useState([]); - const [agentNames, setAgentNames] = useState>({}); - const [threadId, setThreadId] = useState(''); - const [threadQuery, setThreadQuery] = useState(''); - const [fileQuery, setFileQuery] = useState(''); - const [files, setFiles] = useState<{ threadId: string; items: CloudAttachment[] }>({ threadId: '', items: [] }); - const [threadsLoading, setThreadsLoading] = useState(true); - const [filesLoading, setFilesLoading] = useState(false); - const [threadError, setThreadError] = useState(''); - const [fileError, setFileError] = useState(''); - const [threadsRevision, setThreadsRevision] = useState(0); - const [filesRevision, setFilesRevision] = useState(0); - const [selected, setSelected] = useState([]); - const [jobs, setJobs] = useState([]); - const [running, setRunning] = useState(false); - const [completed, setCompleted] = useState(0); - const [abandoning, setAbandoning] = useState(false); - const jobsRef = useRef([]); - const runningRef = useRef(false); - const alive = useRef(true); - const activeScope = useRef(scope); - const permitted = useRef(canRead); - const callbacks = useRef({ onImported, onClose }); - useEffect(() => { callbacks.current = { onImported, onClose }; }, [onImported, onClose]); - useEffect(() => { permitted.current = canRead; }, [canRead]); - useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []); - useEffect(() => { - activeScope.current = scope; - jobsRef.current = []; runningRef.current = false; - setJobs([]); setSelected([]); setThreadId(''); setThreadQuery(''); setFileQuery(''); setCompleted(0); setRunning(false); setAbandoning(false); - }, [scope]); - const unresolved = jobs.some(job => job.status !== 'done'); - const pending = running || unresolved || selected.length > 0; - const locked = busy || !canRead || running || unresolved; - usePendingCloudInput(pending); - useEffect(() => { onPendingChange(pending); return () => onPendingChange(false); }, [pending, onPendingChange]); - - useEffect(() => { - let live = true; - setThreads([]); setThreadError(''); setThreadsLoading(canRead); - if (!canRead) return; - const load = async () => { - try { - let page = await cloudAgentsApi.call('threads', {}); - const all = [...page.threads]; - const seen = new Set(); - while (live && page.next_offset !== null) { - if (seen.has(page.next_offset)) throw new Error('对话列表读取未完成,请重试。'); - seen.add(page.next_offset); - page = await cloudAgentsApi.call('threads', { offset: page.next_offset }); - all.push(...page.threads); - } - if (live) setThreads([...new Map(all.map(thread => [thread.thread_id, thread])).values()]); - } catch (error) { if (live) setThreadError(errorMessage(error)); } - finally { if (live) setThreadsLoading(false); } - }; - void load(); - return () => { live = false; }; - }, [scope, canRead, threadsRevision]); - - useEffect(() => { - let live = true; - setAgentNames({}); - if (!canRead) return; - const names: Record = {}; - const publish = () => { if (live) setAgentNames({ ...names }); }; - // Names are supplementary: an unavailable agent directory must not hide chat files. - const ownNames = async () => { - let page = await cloudAgentsApi.list(); - const seen = new Set(); - for (;;) { - for (const agent of page.agents) names[agent.slug] = agent.name; - publish(); - if (!live || !page.next_cursor || seen.has(page.next_cursor)) return; - seen.add(page.next_cursor); - page = await cloudAgentsApi.list(page.next_cursor); - } - }; - void ownNames().catch(() => undefined); - void cloudAgentsApi.call('received', {}).then(page => { - if (!live) return; - for (const agent of page.agents) names[agent.slug] = agent.name; - publish(); - }).catch(() => undefined); - return () => { live = false; }; - }, [scope, canRead, threadsRevision]); - - useEffect(() => { - let live = true; - setFiles({ threadId, items: [] }); setFileError(''); setFilesLoading(Boolean(threadId) && canRead); - if (!threadId || !canRead) return; - cloudAgentsApi.call('attachments', { thread_id: threadId }).then(page => { - if (live) setFiles({ threadId, items: page.attachments }); - }).catch(error => { if (live) setFileError(errorMessage(error)); }) - .finally(() => { if (live) setFilesLoading(false); }); - return () => { live = false; }; - }, [scope, canRead, threadId, filesRevision]); - - const currentThread = threads.find(thread => thread.thread_id === threadId); - const visibleThreads = threads.filter(thread => `${thread.title} ${agentNames[thread.agent_slug] ?? ''}`.toLocaleLowerCase().includes(threadQuery.trim().toLocaleLowerCase())); - const visibleFiles = files.threadId === threadId ? files.items.filter(file => file.file_name.toLocaleLowerCase().includes(fileQuery.trim().toLocaleLowerCase())) : []; - const retryJobs = jobs.filter(job => job.status !== 'done'); - const failureOnly = retryJobs.length > 0 && retryJobs.every(job => job.status === 'failed'); - const selectedKeys = new Set(selected.map(item => item.key)); - - function choose(file: CloudAttachment, checked: boolean) { - if (locked || !currentThread || filesLoading || fileError || fileDetails(file).reason) return; - const key = JSON.stringify([threadId, file.file_id]); - if (jobsRef.current.length && jobsRef.current.every(job => job.status === 'done')) { jobsRef.current = []; setJobs([]); } - setCompleted(0); - setSelected(previous => checked ? [...previous.filter(item => item.key !== key), { - key, threadId, threadTitle: currentThread.title || '未命名对话', file, - }] : previous.filter(item => item.key !== key)); - } - - async function importSelected() { - if (busy || !canRead || runningRef.current) return; - const attemptScope = scope; - const current = () => alive.current && activeScope.current === attemptScope; - const initialJobs: ImportJob[] = jobsRef.current.length ? jobsRef.current : selected.map(item => ({ - ...item, operationId: crypto.randomUUID(), status: 'queued' as const, - })); - const attempts = initialJobs.filter(job => job.status !== 'done'); - if (!attempts.length) return; - const update = (key: string, value: Partial) => { - jobsRef.current = jobsRef.current.map(job => job.key === key ? { ...job, ...value } : job); - setJobs([...jobsRef.current]); - }; - jobsRef.current = initialJobs; setJobs([...initialJobs]); - runningRef.current = true; setRunning(true); setAbandoning(false); - try { - await act(async () => { - for (const job of attempts) { - if (!current() || !permitted.current) return; - update(job.key, { status: 'running', error: '' }); - try { - const file = job.importedFile ?? await cloudAgentsApi.call('importKnowledgeAttachment', { - slug, kb_id: kbId, thread_id: job.threadId, attachment_id: job.file.file_id, operation_id: job.operationId, - }); - if (!current()) return; - // A confirmed import is never repeated, even if permission changes or processing fails. - update(job.key, { status: 'saved', importedFile: file }); - if (!permitted.current) return; - try { - await callbacks.current.onImported(file); - if (!current()) return; - update(job.key, { status: 'done', error: '' }); - } catch (error) { - if (!current()) return; - update(job.key, { status: 'done', error: '文件已导入,处理未全部启动。请在知识库重试处理。' + errorMessage(error) }); - } - } catch (error) { - if (!current()) return; - update(job.key, { status: 'failed', error: '导入结果尚未确认:' + errorMessage(error) }); - } - } - if (current() && jobsRef.current.every(job => job.status === 'done')) { - setSelected([]); setCompleted(jobsRef.current.length); - if (jobsRef.current.every(job => !job.error)) { - jobsRef.current = []; setJobs([]); - callbacks.current.onClose?.(); - } - } - }); - } finally { - if (current()) { runningRef.current = false; setRunning(false); } - } - } - - const close = () => { if (!runningRef.current && !unresolved) onClose?.(); }; - return { if (!open) close(); }}> - { if (runningRef.current || unresolved) event.preventDefault(); }} - onInteractOutside={event => { if (runningRef.current || unresolved) event.preventDefault(); }}> -
-
从聊天选择文件导入到「{kbName}」
- {onClose && } -
-
-

选择你在 MakeLore Agents 聊天中上传的文件。聊天文字和助手生成的文件不会导入。

- {!canRead &&

知识库管理权限待确认,文件操作已暂停;未完成请求仍保留。

} - {canRead &&
-
-

选择对话

-
-
- {threadsLoading ?

正在读取对话…

: threadError ?

对话读取失败:{threadError}

- : !threads.length ?

还没有个人智能体对话。

: !visibleThreads.length ?

没有匹配的对话。

: visibleThreads.map(thread => )} -
-
-
-

{currentThread?.title || '选择文件'}

-
-
- {!threadId ?

先选择一段对话,再挑选其中的文件。

: filesLoading ?

正在读取文件…

- : fileError ?

文件读取失败:{fileError}

- : !files.items.length ?

这段对话还没有上传文件。

: !visibleFiles.length ?

没有匹配的文件。

: visibleFiles.map(file => { - const details = fileDetails(file), key = JSON.stringify([threadId, file.file_id]); - return ; - })} -
-
-
} - {selected.length > 0 &&

已选 {selected.length} 个文件{locked ? '。' : ',可继续切换对话选择。'}

-
    {selected.map(item =>
  • {item.file.file_name}{item.threadTitle} -
  • )}
-
} - {jobs.length > 0 &&
    {jobs.map(job =>
  • - {job.file.file_name}{job.status === 'done' ? <>已导入,处理状态见知识库 : job.status === 'running' ? '正在导入…' : job.status === 'saved' ? '已保存,等待继续处理' : job.status === 'failed' ? '等待重试' : '等待导入'} - {job.error &&

    {job.error}

    } -
  • )}
} - {abandoning &&
-

这只会结束当前页面的重试。已保存的文件会保留,可刷新知识库确认。

-
-
} -
-
-

最大 5 MB;支持文本、Word、Excel、文本 PDF。导入后自动处理,可用于回答后才能检索。

- {onRefreshKnowledge && } - {unresolved && !running && !abandoning && } - -
- {completed > 0 &&

已导入 {completed} 个文件,处理状态见知识库。

} -
; -} diff --git a/src/pages/CloudAgents/CloudKnowledgeDocuments.tsx b/src/pages/CloudAgents/CloudKnowledgeDocuments.tsx index 1a778f84..9a350581 100644 --- a/src/pages/CloudAgents/CloudKnowledgeDocuments.tsx +++ b/src/pages/CloudAgents/CloudKnowledgeDocuments.tsx @@ -1,9 +1,8 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState, type ReactNode } 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 { CloudKnowledgeAttachmentPicker } from './CloudKnowledgeAttachmentPicker'; import type { CloudKnowledgeFile } from '../../../shared/cloud-agents'; function documentState(file: CloudKnowledgeFile) { @@ -48,11 +47,11 @@ function documentState(file: CloudKnowledgeFile) { return { label: '状态待确认', detail: '请刷新状态后再操作。', stage: 0 }; } -export function CloudKnowledgeDocuments({ slug, kbId, kbName = '当前知识库', busy, act, onDeleteKnowledge, canRead = true, onPendingChange, onRefreshKnowledge }: { - slug: string; kbId: string; kbName?: string; busy: boolean; +export function CloudKnowledgeDocuments({ slug, kbId, busy, act, onDeleteKnowledge, canRead = true, onPendingChange, children }: { + slug: string; kbId: string; busy: boolean; act: (operation: () => Promise) => Promise; onDeleteKnowledge: () => void; - canRead?: boolean; onPendingChange?: (pending: boolean) => void; onRefreshKnowledge?: () => void; + canRead?: boolean; onPendingChange?: (pending: boolean) => void; children?: ReactNode; }) { const [files, setFiles] = useState([]); const [pageCount, setPageCount] = useState(1); @@ -62,8 +61,6 @@ export function CloudKnowledgeDocuments({ slug, kbId, kbName = '当前知识库' const [readError, setReadError] = useState(''); const [uploading, setUploading] = useState(''); const [submitting, setSubmitting] = useState(null); - const [importing, setImporting] = useState(false); - const [importPending, setImportPending] = useState(false); const [deleting, setDeleting] = useState(null); const [processErrors, setProcessErrors] = useState>({}); const uploadIntents = useRef>({}); @@ -72,7 +69,7 @@ export function CloudKnowledgeDocuments({ slug, kbId, kbName = '当前知识库' const permitted = useRef(canRead); useEffect(() => { permitted.current = canRead; }, [canRead]); useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []); - const unresolved = Object.keys(uploadIntents.current).length > 0 || Object.keys(processIntents.current).length > 0 || importPending; + const unresolved = Object.keys(uploadIntents.current).length > 0 || Object.keys(processIntents.current).length > 0; usePendingCloudInput(unresolved); useEffect(() => { onPendingChange?.(unresolved); return () => onPendingChange?.(false); }, [onPendingChange, unresolved]); const refresh = () => setRevision(value => value + 1); @@ -156,13 +153,11 @@ export function CloudKnowledgeDocuments({ slug, kbId, kbName = '当前知识库' return
-
+ {children}

上传后自动解析并建立索引。最大 5 MB;支持 TXT、Markdown、CSV、JSON、Word、Excel、文本 PDF。

{uploading &&

{uploading}

} - {importing && setImporting(false)} onRefreshKnowledge={onRefreshKnowledge} />}
{files.length ? '已显示 ' + files.length + ' 份文档 · ' + states.filter(state => state.ready).length + ' 份可用' : '知识文档'} {active ? '处理中 · 状态自动更新' : '状态自动更新'} diff --git a/src/pages/CloudAgents/agent-config.css b/src/pages/CloudAgents/agent-config.css index 4995f0b9..16b6826b 100644 --- a/src/pages/CloudAgents/agent-config.css +++ b/src/pages/CloudAgents/agent-config.css @@ -55,51 +55,6 @@ .agent-knowledge-help summary:focus-visible { outline: 2px solid #9a7bb5; outline-offset: 3px; border-radius: 4px; } .agent-knowledge-selection ul { max-height: 480px; overflow: auto; overscroll-behavior: contain; } .agent-knowledge-selection button { flex-shrink: 0; } -.agent-knowledge-import.agent-knowledge-import-modal { display: flex; flex-direction: column; width: calc(100vw - 32px); max-width: 860px; max-height: calc(100dvh - 32px); gap: 18px; border-radius: 22px; padding: 24px; background: #fffdf9; color: #273246; } -.agent-knowledge-import-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; flex-shrink: 0; min-width: 0; } -.agent-knowledge-import-header > div { min-width: 0; overflow-wrap: anywhere; } -.agent-knowledge-import-header p { margin-top: 6px; line-height: 1.7; color: #59657a; } -.agent-knowledge-import-content { display: flex; flex-direction: column; gap: 16px; min-height: 0; min-width: 0; overflow: auto; overscroll-behavior: contain; } -.agent-knowledge-import-help { color: #606c7e; font-size: 13px; line-height: 1.7; overflow-wrap: anywhere; } -.agent-knowledge-import-body { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.3fr); gap: 16px; min-height: 0; min-width: 0; overflow: auto; } -.agent-knowledge-import-content > .agent-knowledge-import-body { flex-shrink: 0; } -.agent-knowledge-import-chats, .agent-knowledge-import-files { display: flex; flex-direction: column; min-width: 0; min-height: 0; gap: 10px; } -.agent-knowledge-import-chats h3, .agent-knowledge-import-files h3 { font-size: 14px; font-weight: 600; } -.agent-knowledge-import-search { display: flex; align-items: center; gap: 8px; min-width: 0; color: #606c7e; } -.agent-knowledge-import-search svg, .agent-knowledge-import-file > svg { flex-shrink: 0; } -.agent-knowledge-import :is(input:not([type="checkbox"]), select) { width: 100%; min-width: 0; min-height: 42px; border: 1px solid #cdd5e3; border-radius: 11px; background: #fff; padding: 10px 12px; font-size: 14px; } -.agent-knowledge-import :is(input, button):focus-visible { outline: 2px solid #9a7bb5; outline-offset: 2px; } -.agent-knowledge-import-chat-list, .agent-knowledge-import-file-list { min-width: 0; overflow: auto; max-height: 330px; overscroll-behavior: contain; border: 1px solid #dce2ec; border-radius: 13px; background: #fff; } -.agent-knowledge-import-chat-list > :is(p, div), .agent-knowledge-import-file-list > :is(p, div) { padding: 14px; color: #606c7e; font-size: 13px; line-height: 1.7; } -.agent-knowledge-import-chat { display: flex; flex-direction: column; gap: 5px; width: 100%; min-height: 68px; padding: 12px; text-align: left; font-size: 14px; line-height: 1.5; cursor: pointer; overflow-wrap: anywhere; transition: background-color 160ms ease; } -.agent-knowledge-import-chat + .agent-knowledge-import-chat { border-top: 1px solid #edf0f5; } -.agent-knowledge-import-chat:hover { background: #f6f5fa; } -.agent-knowledge-import-chat[aria-pressed="true"] { background: #f1f6ff; box-shadow: inset 3px 0 #6b87b3; } -.agent-knowledge-import-chat:disabled { cursor: not-allowed; } -.agent-knowledge-import-chat small { color: #606c7e; font-size: 12px; } -.agent-knowledge-import-file { display: flex; align-items: flex-start; gap: 10px; min-width: 0; min-height: 64px; padding: 12px; font-size: 14px; line-height: 1.6; cursor: pointer; overflow-wrap: anywhere; } -.agent-knowledge-import-file + .agent-knowledge-import-file { border-top: 1px solid #edf0f5; } -.agent-knowledge-import-file:has(input:checked) { background: #f6f2ff; } -.agent-knowledge-import-file:has(input:disabled) { cursor: not-allowed; } -.agent-knowledge-import-file input[type="checkbox"] { width: 17px; height: 17px; margin-top: 3px; accent-color: #79629d; flex-shrink: 0; } -.agent-knowledge-import-file > span { min-width: 0; } -.agent-knowledge-import-file small { display: block; font-size: 12px; color: #606c7e; } -.agent-knowledge-import-selection { flex-shrink: 0; min-width: 0; border-radius: 12px; background: #f3f0f8; padding: 12px; font-size: 13px; overflow-wrap: anywhere; } -.agent-knowledge-import-selection ul { max-height: 130px; overflow: auto; } -.agent-knowledge-import-selection li { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding-top: 8px; } -.agent-knowledge-import-selection li > span { min-width: 0; } -.agent-knowledge-import-selection li button { flex-shrink: 0; } -.agent-knowledge-import-selection small { display: block; color: #606c7e; font-size: 12px; } -.agent-knowledge-import-results { max-height: 170px; min-height: 0; flex-shrink: 0; overflow: auto; border: 1px solid #dce5e0; border-radius: 12px; padding: 12px; font-size: 13px; line-height: 1.7; overflow-wrap: anywhere; } -.agent-knowledge-import-results li + li { border-top: 1px solid #e8e6e1; margin-top: 10px; padding-top: 10px; } -.agent-knowledge-import-results li > span { display: flex; align-items: center; gap: 6px; } -.agent-knowledge-import-results li > span + span { color: #526c5f; } -.agent-knowledge-import-results [role="alert"] { color: #b54532; } -.agent-knowledge-import-abandon { flex-shrink: 0; padding: 12px; border: 1px solid #efd9ba; border-radius: 12px; background: #fff5e9; font-size: 13px; line-height: 1.7; } -.agent-knowledge-import-abandon p { margin-bottom: 10px; } -.agent-knowledge-import-abandon button { margin: 0 8px 6px 0; } -.agent-knowledge-import-footer { display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: 9px; flex-shrink: 0; } -.agent-knowledge-import-footer p { flex: 1 1 280px; min-width: 0; font-size: 12px; line-height: 1.7; color: #606c7e; } @container (max-width: 620px) { .agent-knowledge-workspace { grid-template-columns: minmax(0, 1fr); } } @media (max-width: 640px) { .agent-editor-tabs { padding: 10px 12px; } @@ -114,10 +69,5 @@ .agent-config-section-top > button { margin-left: 48px; } .agent-knowledge-workspace { grid-template-columns: minmax(0, 1fr); gap: 14px; } .agent-knowledge-upload, .agent-knowledge-selection { padding: 14px; border-radius: 14px; } - .agent-knowledge-import.agent-knowledge-import-modal { padding: 18px; border-radius: 18px; gap: 14px; } - .agent-knowledge-import-body { grid-template-columns: minmax(0, 1fr); } - .agent-knowledge-import-chat-list { max-height: 160px; } - .agent-knowledge-import-file-list { max-height: 240px; } - .agent-knowledge-import-footer button { width: 100%; } } -@media (prefers-reduced-motion: reduce) { .agent-resource-option, .agent-knowledge-import-chat { transition: none; } } +@media (prefers-reduced-motion: reduce) { .agent-resource-option { transition: none; } } diff --git a/tests/e2e/cloud-agent-knowledge.spec.ts b/tests/e2e/cloud-agent-knowledge.spec.ts index f1007d28..1e3f83b1 100644 --- a/tests/e2e/cloud-agent-knowledge.spec.ts +++ b/tests/e2e/cloud-agent-knowledge.spec.ts @@ -5,7 +5,7 @@ import { mkdtemp, readFile, realpath, rm, symlink, writeFile } from 'node:fs/pro import { tmpdir } from 'node:os'; import { join, resolve, sep } from 'node:path'; import { createRequire } from 'node:module'; -import { EMPTY_CLOUD_CONFIGURATION, type CloudAgentDraft, type CloudKnowledge, type CloudKnowledgeFile, type CloudThread, type CloudAttachment } from '../../shared/cloud-agents'; +import { EMPTY_CLOUD_CONFIGURATION, type CloudAgentDraft, type CloudKnowledge, type CloudKnowledgeFile } from '../../shared/cloud-agents'; let server: Server; let baseUrl: string; @@ -82,37 +82,20 @@ const libraries: CloudKnowledge[] = [ { kb_id: 'kb_fixturereading', name: '阅读知识', description: '平台开放的阅读资料', embedding_model: 'fixture-embedding' }, { kb_id: 'kb_fixturepersonal', name: '我的观察记录', description: '自己的资料', embedding_model: 'fixture-embedding' }, ]; -const sourceThreads: CloudThread[] = [ - { thread_id: 'thread-spring', client_thread_id: 'client-spring', agent_slug: agent.slug, title: '春天的观察讨论', mode: 'preview', - updated_at: '2026-10-13T01:00:00Z', run_id: null, status: 'completed', unread: false }, - { thread_id: 'thread-reading', client_thread_id: 'client-reading', agent_slug: agent.slug, title: '图书阅读记录', mode: 'preview', - updated_at: '2026-10-13T00:00:00Z', run_id: null, status: 'completed', unread: false }, -]; -const sourceAttachments: Record = { - 'thread-spring': [ - { file_id: 'attachment-observations', file_name: '春天观察报告.md', file_size: 1536, path: 'attachments/spring/report.md', original_path: 'attachments/spring/report.md' }, - { file_id: 'attachment-plants', file_name: '植物清单.csv', file_size: 2048, path: 'attachments/spring/plants.csv', original_path: 'attachments/spring/plants.csv' }, - ], - 'thread-reading': [ - { file_id: 'attachment-reading', file_name: '阅读笔记.pdf', file_size: 2048, path: 'attachments/reading/notes.pdf', original_path: 'attachments/reading/notes.pdf' }, - ], -}; type FixtureCall = { path: string; method: string; input: Record | null }; type KnowledgeFixture = { agent: CloudAgentDraft; libraries: CloudKnowledge[]; files: Record; calls: FixtureCall[]; ownedIds: string[]; upload: 'success' | 'cancel' | 'fail'; processFailure: boolean; - threads: CloudThread[]; attachments: Record; importFailures: string[]; }; type FixtureWindow = Window & { __knowledgeFixture: KnowledgeFixture }; async function installFixture(page: Page) { page.on('pageerror', error => { throw error; }); - await page.addInitScript(({ agent, libraries, sourceThreads, sourceAttachments }) => { + await page.addInitScript(({ agent, libraries }) => { const scope = window as unknown as FixtureWindow; const stored = sessionStorage.getItem('knowledge-fixture'); const state: KnowledgeFixture = stored ? JSON.parse(stored) : { agent, libraries, files: {}, calls: [], ownedIds: ['kb_fixturepersonal'], upload: 'success', processFailure: false, - threads: sourceThreads, attachments: sourceAttachments, importFailures: [], }; scope.__knowledgeFixture = state; const persist = () => sessionStorage.setItem('knowledge-fixture', JSON.stringify(state)); @@ -158,19 +141,7 @@ async function installFixture(page: Page) { case 'knowledgeFiles': if (!managed(args?.kb_id)) throw new Error('Document management reached a read-only library'); return result({ files: state.files[String(args?.kb_id)] ?? [], next_offset: null }); - case 'threads': return result({ threads: state.threads, next_offset: null }); case 'received': return result({ agents: [] }); - case 'attachments': return result({ attachments: state.attachments[String(args?.thread_id)] ?? [] }); - case 'importKnowledgeAttachment': { - if (!managed(args?.kb_id)) throw new Error('Attachment import reached a read-only library'); - const attachment = state.attachments[String(args?.thread_id)]?.find(file => file.file_id === args?.attachment_id); - if (!attachment) throw new Error('Unknown fixture source attachment'); - if (state.importFailures.includes(attachment.file_id)) return failure('植物清单导入暂时失败'); - const file: CloudKnowledgeFile = { file_id: 'imported-' + attachment.file_id, name: attachment.file_name, - size: attachment.file_size, status: 'uploaded', error: null, chunk_count: 0, available: false }; - state.files[String(args?.kb_id)] = [file, ...(state.files[String(args?.kb_id)] ?? []).filter(item => item.file_id !== file.file_id)]; - return result(file); - } case 'processKnowledge': { if (!managed(args?.kb_id)) throw new Error('Processing reached a read-only library'); if (state.processFailure) return failure('处理请求暂不可用'); @@ -183,7 +154,7 @@ async function installFixture(page: Page) { } }, } } }); - }, { agent, libraries, sourceThreads, sourceAttachments }); + }, { agent, libraries }); } async function openKnowledge(page: Page) { @@ -222,22 +193,6 @@ async function expectKnowledgeLayout(page: Page, width: number) { } await expectNoClipping(page); } -async function expectPickerBounds(page: Page) { - expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); - const outside = await page.getByRole('dialog', { name: '从聊天选择文件', exact: true }).evaluate(dialog => { - const bounds = dialog.getBoundingClientRect(); - return { - bounds: { left: bounds.left, top: bounds.top, right: bounds.right, bottom: bounds.bottom }, - viewport: { width: innerWidth, height: innerHeight }, - dialogFits: bounds.left >= 0 && bounds.top >= 0 && bounds.right <= innerWidth && bounds.bottom <= innerHeight, - clipped: [...dialog.querySelectorAll('button, input, label, h2, h4')].filter(element => element.getClientRects().length > 0) - .filter(element => { const box = element.getBoundingClientRect(); return box.left < bounds.left - 1 || box.right > bounds.right + 1; }) - .map(element => element.getAttribute('aria-label') ?? element.textContent?.trim()), - }; - }); - expect(outside.dialogFits, JSON.stringify(outside)).toBe(true); expect(outside.clipped).toEqual([]); - if (outside.viewport.width >= 1024) expect(outside.bounds.right - outside.bounds.left).toBeGreaterThanOrEqual(800); -} for (const width of [375, 1440]) { test(`multiple shared/personal choices stay draft-only and survive save/reopen at ${width}px`, async ({ page }) => { @@ -259,7 +214,7 @@ for (const width of [375, 1440]) { await expect(knowledge.getByRole('heading', { name: '上传自己的文件', exact: true })).toBeVisible(); const management = knowledge.getByRole('combobox', { name: '管理的知识库', exact: true }); await expect(management.locator('option[value="kb_fixturescience"], option[value="kb_fixturereading"]')).toHaveCount(0); - await expect(knowledge.getByRole('button', { name: '从电脑上传', exact: true })).toHaveCount(0); + await expect(knowledge.getByRole('button', { name: '从电脑上传', exact: true })).toBeDisabled(); await expectKnowledgeLayout(page, width); await page.screenshot({ path: test.info().outputPath(`knowledge-shared-${width}.png`), animations: 'disabled' }); await editor.getByRole('button', { name: '保存草稿', exact: true }).click(); await expect(editor.getByRole('button', { name: '已保存', exact: true })).toBeVisible(); @@ -276,12 +231,25 @@ for (const width of [375, 1440]) { await knowledge.getByRole('combobox', { name: '选择知识库', exact: true }).selectOption('kb_fixturescience'); await expect(knowledge.getByRole('heading', { name: '上传自己的文件', exact: true })).toBeVisible(); expect((await calls(page)).filter(call => call.path.endsWith('/knowledge/pick') || ['createKnowledge', 'processKnowledge'].includes(String(call.input?.operation)))).toEqual([]); - await knowledge.getByRole('button', { name: '新建知识库', exact: true }).click(); + const upload = knowledge.getByRole('button', { name: '从电脑上传', exact: true }); + const create = knowledge.getByRole('button', { name: '新建知识库', exact: true }); + await expect(upload).toBeDisabled(); + const uploadBounds = await upload.boundingBox(); + const createBounds = await create.boundingBox(); + expect(uploadBounds).not.toBeNull(); expect(createBounds).not.toBeNull(); + expect(createBounds!.y).toBeGreaterThan(uploadBounds!.y + uploadBounds!.height); + await create.click(); await knowledge.getByRole('textbox', { name: '知识库名称', exact: true }).fill('我的春天资料'); await knowledge.getByRole('combobox', { name: '向量模型', exact: true }).selectOption('fixture-embedding'); await knowledge.getByRole('button', { name: '创建并选用', exact: true }).click(); await expect(knowledge.getByRole('combobox', { name: '管理的知识库', exact: true })).toHaveValue('kb_fixturecreated'); await expect(knowledge.getByRole('list', { name: '已选知识库', exact: true })).toContainText('我的春天资料'); + await expect(upload).toBeEnabled(); + const selectedUploadBounds = await upload.boundingBox(); + const selectedCreateBounds = await create.boundingBox(); + expect(selectedUploadBounds).not.toBeNull(); expect(selectedCreateBounds).not.toBeNull(); + expect(selectedCreateBounds!.y).toBeGreaterThan(selectedUploadBounds!.y + selectedUploadBounds!.height); + await expect(knowledge.getByRole('button', { name: '从聊天选择文件', exact: true })).toHaveCount(0); expect((await calls(page)).filter(call => call.path.endsWith('/knowledge/pick') || call.input?.operation === 'processKnowledge')).toEqual([]); await knowledge.getByRole('button', { name: '从电脑上传', exact: true }).click(); const document = knowledge.getByRole('article', { name: '春天观察记录.md', exact: true }); @@ -306,52 +274,6 @@ for (const width of [375, 1440]) { expect(await page.evaluate(() => (window as unknown as FixtureWindow).__knowledgeFixture.agent.configuration.knowledges)) .toEqual(['kb_fixturescience', 'kb_fixturecreated']); }); - - test(`chat files show their source and metadata and import multiple choices into the named own library at ${width}px`, async ({ page }) => { - await page.setViewportSize({ width, height: 850 }); await installFixture(page); await page.goto(baseUrl); - const { knowledge } = await openKnowledge(page); - await knowledge.getByRole('combobox', { name: '选择知识库', exact: true }).selectOption('kb_fixturescience'); - await knowledge.getByRole('combobox', { name: '管理的知识库', exact: true }).selectOption('kb_fixturepersonal'); - await knowledge.getByRole('button', { name: '从聊天选择文件', exact: true }).click(); - const picker = page.getByRole('dialog', { name: '从聊天选择文件', exact: true }); - await expect(picker).toContainText('导入到「我的观察记录」'); - await picker.getByRole('textbox', { name: '搜索对话', exact: true }).fill('春天'); - await expect(picker.getByRole('button', { name: '选择对话 图书阅读记录', exact: true })).toHaveCount(0); - await picker.getByRole('button', { name: '选择对话 春天的观察讨论', exact: true }).click(); - await expect(picker.getByRole('checkbox', { name: '选择文件 春天观察报告.md', exact: true })).toBeVisible(); - await expect(picker.locator('.agent-knowledge-import-file').filter({ has: page.getByRole('checkbox', { name: '选择文件 春天观察报告.md', exact: true }) })) - .toContainText(/(?:MD|Markdown)\s*·\s*(?:1\.5|2)\s*KB/i); - await picker.getByRole('textbox', { name: '搜索文件', exact: true }).fill('植物'); - await expect(picker.getByRole('checkbox', { name: '选择文件 春天观察报告.md', exact: true })).toHaveCount(0); - await picker.getByRole('checkbox', { name: '选择文件 植物清单.csv', exact: true }).check(); - await expect(picker.locator('.agent-knowledge-import-file').filter({ has: page.getByRole('checkbox', { name: '选择文件 植物清单.csv', exact: true }) })) - .toContainText(/CSV\s*·\s*2\s*KB/); - await picker.getByRole('textbox', { name: '搜索文件', exact: true }).fill(''); - await picker.getByRole('checkbox', { name: '选择文件 春天观察报告.md', exact: true }).check(); - await picker.getByRole('textbox', { name: '搜索对话', exact: true }).fill('图书'); - await picker.getByRole('button', { name: '选择对话 图书阅读记录', exact: true }).click(); - await picker.getByRole('checkbox', { name: '选择文件 阅读笔记.pdf', exact: true }).check(); - const confirm = picker.getByRole('button', { name: '导入 3 个文件', exact: true }); - await expect(confirm).toBeEnabled(); - await expect(confirm).toBeInViewport(); - expect((await calls(page)).filter(call => ['importKnowledgeAttachment', 'processKnowledge'].includes(String(call.input?.operation)))).toEqual([]); - await expectPickerBounds(page); - await page.screenshot({ path: test.info().outputPath(`knowledge-chat-picker-${width}.png`), animations: 'disabled' }); - await confirm.click(); - await expect(picker).toHaveCount(0); - for (const name of ['春天观察报告.md', '植物清单.csv', '阅读笔记.pdf']) { - await expect(knowledge.getByRole('article', { name, exact: true })).toBeVisible(); - } - const imports = (await calls(page)).filter(call => call.input?.operation === 'importKnowledgeAttachment'); - expect(imports).toHaveLength(3); - expect(imports.map(call => (call.input?.input as { attachment_id: string }).attachment_id).sort()) - .toEqual(['attachment-observations', 'attachment-plants', 'attachment-reading']); - expect(imports.every(call => (call.input?.input as { kb_id: string }).kb_id === 'kb_fixturepersonal')).toBe(true); - expect((await calls(page)).filter(call => call.input?.operation === 'processKnowledge')).toHaveLength(3); - expect((await calls(page)).filter(call => call.method === 'PATCH')).toEqual([]); - await expect(knowledge.getByRole('list', { name: '已选知识库', exact: true })).toContainText('科学知识'); - await expectKnowledgeLayout(page, width); - }); } test('cancelled and failed uploads preserve shared choices and the personal draft', async ({ page }) => { @@ -389,93 +311,3 @@ test('cancelled and failed uploads preserve shared choices and the personal draf await editor.getByRole('tab', { name: '介绍与提示词', exact: true }).click(); await expect(editor.getByRole('textbox', { name: '提示词', exact: true })).toHaveValue('这是我还没有保存的提示词。'); }); - -test('a partial chat-file import retries only the failed file with the same operation identity', async ({ page }) => { - await page.setViewportSize({ width: 375, height: 850 }); await installFixture(page); await page.goto(baseUrl); - const { knowledge } = await openKnowledge(page); - await knowledge.getByRole('combobox', { name: '选择知识库', exact: true }).selectOption('kb_fixturescience'); - await knowledge.getByRole('combobox', { name: '管理的知识库', exact: true }).selectOption('kb_fixturepersonal'); - await page.evaluate(() => { (window as unknown as FixtureWindow).__knowledgeFixture.importFailures = ['attachment-plants']; }); - await knowledge.getByRole('button', { name: '从聊天选择文件', exact: true }).click(); - const picker = page.getByRole('dialog', { name: '从聊天选择文件', exact: true }); - await picker.getByRole('button', { name: '选择对话 春天的观察讨论', exact: true }).click(); - await picker.getByRole('checkbox', { name: '选择文件 春天观察报告.md', exact: true }).check(); - await picker.getByRole('checkbox', { name: '选择文件 植物清单.csv', exact: true }).check(); - const confirm = picker.getByRole('button', { name: '导入 2 个文件', exact: true }); - await expect(confirm).toBeInViewport(); - await confirm.click({ timeout: 15_000 }); - await expect(picker).toContainText('植物清单导入暂时失败'); - const retry = picker.getByRole('button', { name: '重试失败的 1 个文件', exact: true }); - await expect(retry).toBeEnabled(); - await expect(retry).toBeInViewport(); - await expect(picker.getByRole('button', { name: '关闭文件选择', exact: true })).toBeDisabled(); - await page.keyboard.press('Escape'); - await expect(picker).toBeVisible(); - const originalImports = (await calls(page)).filter(call => call.input?.operation === 'importKnowledgeAttachment'); - expect(originalImports).toHaveLength(2); - await expect(picker.getByRole('list', { name: '文件导入结果', exact: true }).getByRole('listitem').filter({ hasText: '春天观察报告.md' })) - .toContainText('已导入,处理状态见知识库'); - await expectPickerBounds(page); - await page.screenshot({ path: test.info().outputPath('knowledge-chat-partial-failure-375.png'), animations: 'disabled' }); - await page.evaluate(() => { (window as unknown as FixtureWindow).__knowledgeFixture.importFailures = []; }); - await retry.click(); await expect(picker).toHaveCount(0); - await expect(knowledge.getByRole('article', { name: '春天观察报告.md', exact: true })).toBeVisible(); - await expect(knowledge.getByRole('article', { name: '植物清单.csv', exact: true })).toBeVisible(); - const imported = (await calls(page)).filter(call => call.input?.operation === 'importKnowledgeAttachment') - .map(call => call.input?.input as { attachment_id: string; operation_id: string; kb_id: string }); - expect(imported.filter(input => input.attachment_id === 'attachment-observations')).toHaveLength(1); - const retried = imported.filter(input => input.attachment_id === 'attachment-plants'); - expect(retried).toHaveLength(2); expect(new Set(retried.map(input => input.operation_id)).size).toBe(1); - expect(imported.every(input => input.kb_id === 'kb_fixturepersonal')).toBe(true); - const processed = (await calls(page)).filter(call => call.input?.operation === 'processKnowledge') - .map(call => (call.input?.input as { file_id: string }).file_id); - expect(processed.sort()).toEqual(['imported-attachment-observations', 'imported-attachment-plants']); - expect((await calls(page)).filter(call => call.method === 'PATCH')).toEqual([]); - await expect(knowledge.getByRole('list', { name: '已选知识库', exact: true })).toContainText('科学知识'); - await expectNoClipping(page); -}); - -test('abandoning a failed batch keeps saved files and allows a fresh picker operation without replaying successes', async ({ page }) => { - await page.setViewportSize({ width: 375, height: 850 }); await installFixture(page); await page.goto(baseUrl); - const { knowledge } = await openKnowledge(page); - await knowledge.getByRole('combobox', { name: '选择知识库', exact: true }).selectOption('kb_fixturescience'); - await knowledge.getByRole('combobox', { name: '管理的知识库', exact: true }).selectOption('kb_fixturepersonal'); - await page.evaluate(() => { (window as unknown as FixtureWindow).__knowledgeFixture.importFailures = ['attachment-plants']; }); - await knowledge.getByRole('button', { name: '从聊天选择文件', exact: true }).click(); - const picker = page.getByRole('dialog', { name: '从聊天选择文件', exact: true }); - await picker.getByRole('button', { name: '选择对话 春天的观察讨论', exact: true }).click(); - await picker.getByRole('checkbox', { name: '选择文件 春天观察报告.md', exact: true }).check(); - await picker.getByRole('checkbox', { name: '选择文件 植物清单.csv', exact: true }).check(); - await expect(picker.getByRole('button', { name: '导入 2 个文件', exact: true })).toBeInViewport(); - await picker.getByRole('button', { name: '导入 2 个文件', exact: true }).click({ timeout: 15_000 }); - await expect(picker.getByRole('button', { name: '重试失败的 1 个文件', exact: true })).toBeEnabled(); - const importsBefore = (await calls(page)).filter(call => call.input?.operation === 'importKnowledgeAttachment'); - await picker.getByRole('button', { name: '放弃重试', exact: true }).click(); - await expect(picker).toContainText('已保存的文件会保留'); - const abandon = picker.getByRole('button', { name: '确认放弃重试', exact: true }); - await abandon.scrollIntoViewIfNeeded(); await expect(abandon).toBeInViewport(); - await expectPickerBounds(page); - await page.screenshot({ path: test.info().outputPath('knowledge-chat-abandon-confirm-375.png'), animations: 'disabled' }); - await abandon.click(); await expect(picker).toHaveCount(0); - expect((await calls(page)).filter(call => call.input?.operation === 'importKnowledgeAttachment')).toEqual(importsBefore); - expect((await calls(page)).filter(call => /delete/i.test(String(call.input?.operation)) || call.method === 'DELETE')).toEqual([]); - await expect(knowledge.getByRole('article', { name: '春天观察报告.md', exact: true })).toBeVisible(); - await expect(knowledge.getByRole('combobox', { name: '管理的知识库', exact: true })).toBeEnabled(); - await page.evaluate(() => { (window as unknown as FixtureWindow).__knowledgeFixture.importFailures = []; }); - await knowledge.getByRole('button', { name: '从聊天选择文件', exact: true }).click(); - await picker.getByRole('button', { name: '选择对话 春天的观察讨论', exact: true }).click(); - await expect(picker.getByRole('checkbox', { name: '选择文件 春天观察报告.md', exact: true })).not.toBeChecked(); - await picker.getByRole('checkbox', { name: '选择文件 植物清单.csv', exact: true }).check(); - await picker.getByRole('button', { name: '导入 1 个文件', exact: true }).click(); - await expect(picker).toHaveCount(0); - const imports = (await calls(page)).filter(call => call.input?.operation === 'importKnowledgeAttachment') - .map(call => call.input?.input as { attachment_id: string; operation_id: string }); - expect(imports.filter(input => input.attachment_id === 'attachment-observations')).toHaveLength(1); - const plants = imports.filter(input => input.attachment_id === 'attachment-plants'); - expect(plants).toHaveLength(2); expect(new Set(plants.map(input => input.operation_id)).size).toBe(2); - const processed = (await calls(page)).filter(call => call.input?.operation === 'processKnowledge') - .map(call => (call.input?.input as { file_id: string }).file_id); - expect(processed.sort()).toEqual(['imported-attachment-observations', 'imported-attachment-plants']); - await expect(knowledge.getByRole('list', { name: '已选知识库', exact: true })).toContainText('科学知识'); - await expectNoClipping(page); -}); diff --git a/tests/unit/cloud-knowledge-catalog.test.tsx b/tests/unit/cloud-knowledge-catalog.test.tsx index 0c740867..38c4073a 100644 --- a/tests/unit/cloud-knowledge-catalog.test.tsx +++ b/tests/unit/cloud-knowledge-catalog.test.tsx @@ -20,6 +20,7 @@ function openCreation() { it('explains who configures an empty embedding catalog and refreshes without losing the name', async () => { api.call.mockResolvedValueOnce(empty).mockResolvedValueOnce(available); openCreation(); + expect(screen.getByRole('button', { name: '从电脑上传', exact: true })).toBeDisabled(); expect(await screen.findByText('暂时没有可用的向量模型。Embedding 只在 Yuxi 配置,请联系平台管理员启用后刷新;向量化费用由平台承担,不计入用户用量或智能体费用上限。')).toBeVisible(); expect(screen.getByRole('combobox', { name: '向量模型' })).toBeDisabled(); expect(screen.getByText(/上传到知识库的文档可供/)).toHaveTextContent('embedding 向量化由平台承担,不计入用户用量、词元点数或智能体费用上限'); diff --git a/tests/unit/cloud-knowledge-import.test.tsx b/tests/unit/cloud-knowledge-import.test.tsx deleted file mode 100644 index 8b93b62a..00000000 --- a/tests/unit/cloud-knowledge-import.test.tsx +++ /dev/null @@ -1,345 +0,0 @@ -import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; -import { afterEach, beforeEach, expect, it, vi } from 'vitest'; -import { CloudKnowledgeAttachmentPicker } from '@/pages/CloudAgents/CloudKnowledgeAttachmentPicker'; -import { CloudKnowledgePanel } from '@/pages/CloudAgents/CloudKnowledge'; -import type { CloudAttachment, CloudKnowledgeFile, CloudThread } from '../../shared/cloud-agents'; - -const api = vi.hoisted(() => ({ call: vi.fn(), list: vi.fn() })); -vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api })); -const slug = 'ml-' + 'a'.repeat(32), otherSlug = 'ml-' + 'b'.repeat(32); -const threads: CloudThread[] = [ - { thread_id: 'spring', title: '春天的观察讨论', agent_slug: slug, client_thread_id: null, updated_at: '2026-10-13T00:00:00Z', mode: 'published', run_id: null, status: 'completed', unread: false }, - { thread_id: 'reading', title: '图书阅读记录', agent_slug: otherSlug, client_thread_id: null, updated_at: '2026-10-12T00:00:00Z', mode: 'preview', run_id: null, status: 'completed', unread: false }, -]; -function attachment(id: string, name: string, size = 1536): CloudAttachment { - return { file_id: id, file_name: name, file_size: size, path: '/attachments/' + name, original_path: '/attachments/' + name }; -} -function knowledgeFile(id: string): CloudKnowledgeFile { - return { file_id: 'knowledge-' + id, name: id + '.txt', size: 1536, status: 'uploaded', available: false, chunk_count: 0, error: null }; -} -let sourceFiles: Record; -const imported = vi.fn<(file: CloudKnowledgeFile) => Promise>(); -const pending = vi.fn(); -const close = vi.fn(); -const props = { slug, kbId: 'kb_own', kbName: '我的观察记录', busy: false, act: async (task: () => Promise) => task(), onImported: imported, onPendingChange: pending, onClose: close }; - -beforeEach(() => { - vi.resetAllMocks(); imported.mockResolvedValue(); - sourceFiles = { spring: [attachment('a', '观察报告.md'), attachment('b', '植物资料.pdf')], reading: [attachment('a', '阅读笔记.txt')] }; - api.list.mockResolvedValue({ agents: [{ slug, name: '资料小助手' }], next_cursor: null }); - api.call.mockImplementation(async (operation: string, input: { thread_id?: string; attachment_id?: string }) => { - if (operation === 'threads') return { threads, next_offset: null }; - if (operation === 'received') return { agents: [{ slug: otherSlug, name: '阅读搭档' }] }; - if (operation === 'attachments') return { attachments: sourceFiles[input.thread_id!] }; - if (operation === 'importKnowledgeAttachment') return knowledgeFile(input.attachment_id!); - throw new Error('Unexpected operation ' + operation); - }); -}); -afterEach(cleanup); -async function chooseChat(title = threads[0].title) { - fireEvent.click(await screen.findByRole('button', { name: '选择对话 ' + title })); -} -async function chooseFile(name: string) { fireEvent.click(await screen.findByRole('checkbox', { name: '选择文件 ' + name })); } -function imports() { return api.call.mock.calls.filter(([operation]) => operation === 'importKnowledgeAttachment').map(([, input]) => input); } -function deferred() { - let resolve!: (value: T) => void; - let reject!: (error: Error) => void; - const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); - return { promise, resolve, reject }; -} - -it('searches named and dated chats, keeps selections across chats, and imports only explicitly selected attachments', async () => { - const original = api.call.getMockImplementation()!; - api.call.mockImplementation((operation, input) => operation === 'threads' - ? Promise.resolve(input.offset ? { threads: [threads[1]], next_offset: null } : { threads: [threads[0]], next_offset: 50 }) : original(operation, input)); - render(); - expect(screen.getByRole('dialog', { name: '从聊天选择文件' })).toHaveTextContent('导入到「我的观察记录」'); - await screen.findByText(/阅读搭档 · 2026/); - fireEvent.change(screen.getByRole('textbox', { name: '搜索对话' }), { target: { value: '资料小助手' } }); - expect(screen.queryByRole('button', { name: '选择对话 图书阅读记录' })).not.toBeInTheDocument(); - await chooseChat(); - expect(await screen.findByText('MD · 2 KB')).toBeVisible(); - fireEvent.change(screen.getByRole('textbox', { name: '搜索文件' }), { target: { value: '观察' } }); - expect(screen.queryByRole('checkbox', { name: '选择文件 植物资料.pdf' })).not.toBeInTheDocument(); - await chooseFile('观察报告.md'); - fireEvent.change(screen.getByRole('textbox', { name: '搜索对话' }), { target: { value: '阅读' } }); - await chooseChat(threads[1].title); await chooseFile('阅读笔记.txt'); - expect(within(screen.getByRole('list', { name: '已选聊天文件' })).getAllByRole('listitem')).toHaveLength(2); - expect(imports()).toHaveLength(0); - fireEvent.click(screen.getByRole('button', { name: '导入 2 个文件' })); - await waitFor(() => expect(close).toHaveBeenCalledTimes(1)); - expect(imports()).toEqual([ - expect.objectContaining({ slug, kb_id: 'kb_own', thread_id: 'spring', attachment_id: 'a' }), - expect.objectContaining({ slug, kb_id: 'kb_own', thread_id: 'reading', attachment_id: 'a' }), - ]); - expect(new Set(imports().map(input => input.operation_id)).size).toBe(2); - expect(imported).toHaveBeenCalledTimes(2); - expect(api.call.mock.calls.some(([operation]) => ['history', 'files', 'deleteAttachment'].includes(operation))).toBe(false); - expect(pending).toHaveBeenLastCalledWith(false); -}); - -it('retries only failed files with their original identity, retaining successful files and processing callbacks', async () => { - const original = api.call.getMockImplementation()!; - let failure = true; - api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' && input.attachment_id === 'b' && failure - ? Promise.reject(new Error('连接超时')) : original(operation, input)); - render(); - await chooseChat(); await chooseFile('观察报告.md'); await chooseFile('植物资料.pdf'); - fireEvent.click(screen.getByRole('button', { name: '导入 2 个文件' })); - await screen.findByText(/导入结果尚未确认:连接超时/); - expect(close).not.toHaveBeenCalled(); expect(imported).toHaveBeenCalledTimes(1); - expect(screen.getByRole('button', { name: '关闭文件选择' })).toBeDisabled(); - expect(screen.getByRole('button', { name: '选择对话 图书阅读记录' })).toBeDisabled(); - fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }); - expect(close).not.toHaveBeenCalled(); - const first = imports(); - failure = false; - fireEvent.click(screen.getByRole('button', { name: '重试失败的 1 个文件' })); - await waitFor(() => expect(close).toHaveBeenCalledTimes(1)); - expect(imports()).toHaveLength(3); expect(imports()[2]).toEqual(first[1]); - expect(imported).toHaveBeenCalledTimes(2); -}); - -it('never reimports a confirmed file after a processing callback failure and permits a later independent batch', async () => { - imported.mockRejectedValueOnce(new Error('处理失败')); - render(); - await chooseChat(); await chooseFile('观察报告.md'); - fireEvent.click(screen.getByRole('button', { name: '导入 1 个文件' })); - await screen.findByText(/文件已导入,处理未全部启动/); - expect(imports()).toHaveLength(1); expect(close).not.toHaveBeenCalled(); - expect(screen.getByRole('button', { name: '关闭文件选择' })).toBeEnabled(); - await chooseFile('植物资料.pdf'); - fireEvent.click(screen.getByRole('button', { name: '导入 1 个文件' })); - await waitFor(() => expect(close).toHaveBeenCalledTimes(1)); - expect(imports().map(input => input.attachment_id)).toEqual(['a', 'b']); -}); - -it('shows file types and sizes, disables unsupported legacy formats and oversize files, and accepts supported files at the size boundary', async () => { - sourceFiles.spring = [attachment('legacy-doc', '旧文档.doc'), attachment('legacy-xls', '旧表格.xls'), attachment('markdown', '资料.markdown'), - attachment('image', '图片.png'), attachment('large', '过大的资料.pdf', 5 * 1024 * 1024 + 1), attachment('boundary', '可导入.DOCX', 5 * 1024 * 1024)]; - render(); await chooseChat(); - for (const name of ['旧文档.doc', '旧表格.xls', '资料.markdown', '图片.png', '过大的资料.pdf']) expect(await screen.findByRole('checkbox', { name: '选择文件 ' + name })).toBeDisabled(); - expect(screen.getAllByText('此格式暂不支持知识库导入')).toHaveLength(4); - expect(screen.getByText('超过 5 MB,暂不能导入')).toBeVisible(); - expect(screen.getByText('DOCX · 5.0 MB')).toBeVisible(); - await chooseFile('可导入.DOCX'); - fireEvent.click(screen.getByRole('button', { name: '导入 1 个文件' })); - await waitFor(() => expect(close).toHaveBeenCalledOnce()); - expect(imports()).toEqual([expect.objectContaining({ attachment_id: 'boundary' })]); -}); - -it('distinguishes loading, empty chats, chat read failures and a successful retry', async () => { - const loading = deferred<{ threads: CloudThread[]; next_offset: null }>(); - const original = api.call.getMockImplementation()!; - let mode = 'loading'; - api.call.mockImplementation((operation, input) => { - if (operation !== 'threads') return original(operation, input); - return mode === 'loading' ? loading.promise : mode === 'error' ? Promise.reject(new Error('目录离线')) : Promise.resolve({ threads, next_offset: null }); - }); - const view = render(); - expect(screen.getByText('正在读取对话…')).toBeVisible(); - expect(screen.queryByText('还没有个人智能体对话。')).not.toBeInTheDocument(); - await act(async () => loading.resolve({ threads: [], next_offset: null })); - expect(screen.getByText('还没有个人智能体对话。')).toBeVisible(); - view.unmount(); mode = 'error'; render(); - await screen.findByText('对话读取失败:目录离线'); - expect(screen.queryByText('还没有个人智能体对话。')).not.toBeInTheDocument(); - mode = 'ready'; fireEvent.click(screen.getByRole('button', { name: '重试读取对话' })); - await screen.findByRole('button', { name: '选择对话 春天的观察讨论' }); - expect(screen.queryByRole('alert')).not.toBeInTheDocument(); -}); - -it('distinguishes file loading, failures, empty files and search results while ignoring a late response from a previous chat', async () => { - const late = deferred<{ attachments: CloudAttachment[] }>(); - const original = api.call.getMockImplementation()!; - let readingFails = true; - api.call.mockImplementation((operation, input) => operation === 'attachments' - ? input.thread_id === 'spring' ? late.promise : readingFails ? Promise.reject(new Error('文件离线')) : Promise.resolve({ attachments: [] }) - : original(operation, input)); - render(); await chooseChat(); - expect(screen.getByText('正在读取文件…')).toBeVisible(); - expect(screen.queryByText('这段对话还没有上传文件。')).not.toBeInTheDocument(); - await chooseChat(threads[1].title); await screen.findByText('文件读取失败:文件离线'); - await act(async () => late.resolve({ attachments: sourceFiles.spring })); - expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); - readingFails = false; fireEvent.click(screen.getByRole('button', { name: '重试读取文件' })); - await screen.findByText('这段对话还没有上传文件。'); - expect(screen.queryByRole('alert')).not.toBeInTheDocument(); - fireEvent.change(screen.getByRole('textbox', { name: '搜索对话' }), { target: { value: '不存在' } }); - expect(screen.getByText('没有匹配的对话。')).toBeVisible(); -}); - -it('guards X, escape and selection during an in-flight import, then closes only after all files finish', async () => { - const first = deferred(); - const original = api.call.getMockImplementation()!; - api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' && input.attachment_id === 'a' ? first.promise : original(operation, input)); - render(); await chooseChat(); await chooseFile('观察报告.md'); await chooseFile('植物资料.pdf'); - fireEvent.click(screen.getByRole('button', { name: '导入 2 个文件' })); - expect(imports()).toHaveLength(1); - expect(screen.getByRole('button', { name: '关闭文件选择' })).toBeDisabled(); - expect(screen.getByRole('checkbox', { name: '选择文件 观察报告.md' })).toBeDisabled(); - expect(screen.queryByRole('button', { name: '放弃重试', exact: true })).not.toBeInTheDocument(); - fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }); - expect(close).not.toHaveBeenCalled(); - await act(async () => first.resolve(knowledgeFile('a'))); - await waitFor(() => expect(close).toHaveBeenCalledOnce()); - expect(imports()).toHaveLength(2); expect(imported).toHaveBeenCalledTimes(2); -}); - -it('does not read or import without management permission and resumes a confirmed receipt without importing it twice', async () => { - const receipt = deferred(); - const original = api.call.getMockImplementation()!; - api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' ? receipt.promise : original(operation, input)); - const view = render(); - expect(api.call).not.toHaveBeenCalled(); expect(api.list).not.toHaveBeenCalled(); - view.rerender(); await chooseChat(); await chooseFile('观察报告.md'); - fireEvent.click(screen.getByRole('button', { name: '导入 1 个文件' })); - view.rerender(); - await act(async () => receipt.resolve(knowledgeFile('a'))); - expect(imports()).toHaveLength(1); expect(imported).not.toHaveBeenCalled(); expect(close).not.toHaveBeenCalled(); - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - expect(pending).toHaveBeenLastCalledWith(true); - view.rerender(); - fireEvent.click(screen.getByRole('button', { name: '继续导入 1 个文件' })); - await waitFor(() => expect(close).toHaveBeenCalledOnce()); - expect(imports()).toHaveLength(1); expect(imported).toHaveBeenCalledOnce(); -}); - -it('ignores an old-target response and stops the old batch after a knowledge-base scope change', async () => { - const response = deferred(); - const original = api.call.getMockImplementation()!; - api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' ? response.promise : original(operation, input)); - const view = render(); - await chooseChat(); await chooseFile('观察报告.md'); await chooseFile('植物资料.pdf'); - fireEvent.click(screen.getByRole('button', { name: '导入 2 个文件' })); - view.rerender(); - await act(async () => response.resolve(knowledgeFile('a'))); - expect(imports()).toHaveLength(1); expect(imported).not.toHaveBeenCalled(); expect(close).not.toHaveBeenCalled(); - expect(screen.getByRole('dialog')).toHaveTextContent('导入到「其他资料」'); - expect(screen.queryByRole('list', { name: '文件导入结果' })).not.toBeInTheDocument(); -}); - -it('does not continue a batch or notify the old owner after unmount', async () => { - const response = deferred(); - const original = api.call.getMockImplementation()!; - api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' ? response.promise : original(operation, input)); - const view = render(); - await chooseChat(); await chooseFile('观察报告.md'); await chooseFile('植物资料.pdf'); - fireEvent.click(screen.getByRole('button', { name: '导入 2 个文件' })); view.unmount(); - await act(async () => response.resolve(knowledgeFile('a'))); - expect(imports()).toHaveLength(1); expect(imported).not.toHaveBeenCalled(); expect(close).not.toHaveBeenCalled(); - expect(pending).toHaveBeenLastCalledWith(false); -}); - -it('hides the portal after a confirmed loss of ownership so the parent can discard pending requests without importing again', async () => { - const response = deferred(); - const original = api.call.getMockImplementation()!; - let owned = true; - api.call.mockImplementation((operation, input) => { - if (operation === 'knowledge') return Promise.resolve({ databases: owned ? [{ kb_id: 'kb_own', name: '我的观察记录' }] : [], models: [] }); - if (operation === 'knowledgeFiles') return Promise.resolve({ files: [], next_offset: null }); - if (operation === 'importKnowledgeAttachment') return response.promise; - return original(operation, input); - }); - const created = vi.fn(); - const view = render(); - await screen.findByRole('option', { name: '我的观察记录' }); - fireEvent.change(screen.getByRole('combobox', { name: '管理的知识库' }), { target: { value: 'kb_own' } }); - fireEvent.click(await screen.findByRole('button', { name: '从聊天选择文件' })); - await chooseChat(); await chooseFile('观察报告.md'); await chooseFile('植物资料.pdf'); - fireEvent.click(screen.getByRole('button', { name: '导入 2 个文件' })); - owned = false; - view.rerender(); - await waitFor(() => expect(screen.queryByRole('dialog', { name: '从聊天选择文件' })).not.toBeInTheDocument()); - await act(async () => response.resolve(knowledgeFile('a'))); - const discard = await screen.findByRole('button', { name: '放弃未确认操作并重新选择' }); - await waitFor(() => expect(discard).toBeEnabled()); - expect(imports()).toHaveLength(1); - expect(api.call.mock.calls.some(([operation]) => operation === 'processKnowledge')).toBe(false); - fireEvent.click(discard); - expect(screen.queryByRole('button', { name: '放弃未确认操作并重新选择' })).not.toBeInTheDocument(); - expect(screen.getByRole('combobox', { name: '管理的知识库' })).toHaveValue(''); -}); - -it('allows cancelling unsubmitted selections and does not mutate knowledge merely by choosing files', async () => { - render(); await chooseChat(); await chooseFile('观察报告.md'); - expect(pending).toHaveBeenLastCalledWith(true); - fireEvent.click(screen.getByRole('button', { name: '关闭文件选择' })); - expect(close).toHaveBeenCalledOnce(); expect(imports()).toHaveLength(0); expect(imported).not.toHaveBeenCalled(); -}); - -it.each(['button', 'escape'])('allows %s dismissal during a catalog failure before any import has been sent', async control => { - const view = render(); - await chooseChat(); await chooseFile('观察报告.md'); - view.rerender(); - expect(screen.getByRole('button', { name: '关闭文件选择' })).toBeEnabled(); - if (control === 'button') fireEvent.click(screen.getByRole('button', { name: '关闭文件选择' })); - else fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }); - await waitFor(() => expect(close).toHaveBeenCalledOnce()); - expect(imports()).toHaveLength(0); -}); - -it('offers a read-only catalog refresh while an uncertain import is blocked, then retries only the original operation', async () => { - const original = api.call.getMockImplementation()!; - let fails = true; - api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' && fails - ? Promise.reject(new Error('结果未确认')) : original(operation, input)); - const refresh = vi.fn(); - const view = render(); - await chooseChat(); await chooseFile('观察报告.md'); - fireEvent.click(screen.getByRole('button', { name: '导入 1 个文件' })); - await screen.findByText(/导入结果尚未确认:结果未确认/); - const request = imports()[0]; - view.rerender(); - expect(screen.getByRole('button', { name: '关闭文件选择' })).toBeDisabled(); - expect(screen.getByRole('button', { name: '重试失败的 1 个文件' })).toBeDisabled(); - fireEvent.click(screen.getByRole('button', { name: '刷新知识库', exact: true })); - expect(refresh).toHaveBeenCalledOnce(); expect(imports()).toHaveLength(1); - fails = false; - view.rerender(); - fireEvent.click(screen.getByRole('button', { name: '重试失败的 1 个文件' })); - await waitFor(() => expect(close).toHaveBeenCalledOnce()); - expect(imports()).toEqual([request, request]); expect(imported).toHaveBeenCalledOnce(); -}); - -it('requires explicit confirmation to abandon a retry during a catalog failure, while keeping the request through cancelling that confirmation', async () => { - const original = api.call.getMockImplementation()!; - api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' - ? Promise.reject(new Error('附件已无法读取')) : original(operation, input)); - const view = render(); - await chooseChat(); await chooseFile('观察报告.md'); - fireEvent.click(screen.getByRole('button', { name: '导入 1 个文件' })); - await screen.findByText(/导入结果尚未确认:附件已无法读取/); - view.rerender(); - fireEvent.click(screen.getByRole('button', { name: '放弃重试', exact: true })); - expect(screen.getByText('这只会结束当前页面的重试。已保存的文件会保留,可刷新知识库确认。')).toBeVisible(); - expect(close).not.toHaveBeenCalled(); expect(pending).toHaveBeenLastCalledWith(true); - fireEvent.click(screen.getByRole('button', { name: '继续保留' })); - expect(screen.getByRole('button', { name: '重试失败的 1 个文件' })).toBeDisabled(); - expect(screen.getByRole('list', { name: '文件导入结果' })).toHaveTextContent('观察报告.md'); - fireEvent.click(screen.getByRole('button', { name: '放弃重试', exact: true })); - fireEvent.click(screen.getByRole('button', { name: '确认放弃重试' })); - expect(close).toHaveBeenCalledOnce(); expect(pending).toHaveBeenLastCalledWith(false); - expect(imports()).toHaveLength(1); expect(imported).not.toHaveBeenCalled(); - expect(screen.queryByRole('list', { name: '文件导入结果' })).not.toBeInTheDocument(); - expect(screen.queryByRole('list', { name: '已选聊天文件' })).not.toBeInTheDocument(); -}); - -it('exits a partly successful batch with a permanent source error without deleting or replaying successful imports', async () => { - const original = api.call.getMockImplementation()!; - api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' && input.attachment_id === 'b' - ? Promise.reject(new Error('原附件已经删除')) : original(operation, input)); - render(); - await chooseChat(); await chooseFile('观察报告.md'); await chooseFile('植物资料.pdf'); - fireEvent.click(screen.getByRole('button', { name: '导入 2 个文件' })); - await screen.findByText(/导入结果尚未确认:原附件已经删除/); - expect(imported).toHaveBeenCalledOnce(); - expect(screen.getByRole('button', { name: '关闭文件选择' })).toBeDisabled(); - expect(screen.queryByText(/可继续切换对话选择/)).not.toBeInTheDocument(); - const before = api.call.mock.calls.length; - fireEvent.click(screen.getByRole('button', { name: '放弃重试', exact: true })); - fireEvent.click(screen.getByRole('button', { name: '确认放弃重试' })); - expect(close).toHaveBeenCalledOnce(); expect(pending).toHaveBeenLastCalledWith(false); - expect(imported).toHaveBeenCalledOnce(); expect(imports()).toHaveLength(2); - expect(api.call).toHaveBeenCalledTimes(before); -}); diff --git a/tests/unit/cloud-knowledge-picker.test.tsx b/tests/unit/cloud-knowledge-picker.test.tsx index e83b2689..48284f34 100644 --- a/tests/unit/cloud-knowledge-picker.test.tsx +++ b/tests/unit/cloud-knowledge-picker.test.tsx @@ -57,13 +57,13 @@ it('joins readable IDs to the owned-only catalog and selects multiple libraries expect(changes).toHaveBeenLastCalledWith(expect.objectContaining({ knowledges: ['own', 'other-shared'] })); }); -it('never offers shared libraries to document management or mounts their mutating controls', async () => { +it('never offers shared libraries to document management or enables their upload controls', async () => { databases = []; render(); await screen.findByText('MakeLore知识 · 只读'); openUpload(); const manager = screen.getByRole('combobox', { name: '管理的知识库' }); expect(within(manager).queryByRole('option', { name: '创作指南' })).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: '从电脑上传', exact: true })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: '从电脑上传', exact: true })).toBeDisabled(); expect(screen.queryByRole('button', { name: '删除知识库', exact: true })).not.toBeInTheDocument(); expect(api.call.mock.calls.every(([operation]) => operation !== 'knowledgeFiles')).toBe(true); }); @@ -78,7 +78,7 @@ it('allows readable binding after an owned-list failure while withholding docume expect(screen.queryByRole('group', { name: 'MakeLore知识' })).not.toBeInTheDocument(); choose('shared'); openUpload(); expect(screen.getByRole('combobox', { name: '管理的知识库' })).toBeDisabled(); - expect(screen.queryByRole('button', { name: '从电脑上传', exact: true })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: '从电脑上传', exact: true })).toBeDisabled(); failed = false; fireEvent.click(screen.getByRole('button', { name: '刷新知识库', exact: true })); await screen.findByText('MakeLore知识 · 只读'); diff --git a/tests/unit/cloud-knowledge-status.test.tsx b/tests/unit/cloud-knowledge-status.test.tsx index 2b029716..4b442636 100644 --- a/tests/unit/cloud-knowledge-status.test.tsx +++ b/tests/unit/cloud-knowledge-status.test.tsx @@ -33,6 +33,7 @@ async function tick() { await act(async () => { await vi.advanceTimersByTimeAsyn it('uploads then processes automatically and refreshes through queue, parse, index and readiness', async () => { await open(); await screen.findByText('上传第一份文档,让智能体用你的资料回答。'); + expect(screen.queryByRole('button', { name: '从聊天选择文件' })).not.toBeInTheDocument(); vi.useFakeTimers(); api.uploadKnowledge.mockImplementation(async () => { items = [file]; return file; }); await act(async () => { fireEvent.click(screen.getByRole('button', { name: '从电脑上传', exact: true })); }); @@ -48,6 +49,7 @@ it('uploads then processes automatically and refreshes through queue, parse, ind expect(screen.getByText('可用于回答')).toBeVisible(); expect(screen.getByText(/12 段可检索内容/)).toBeVisible(); expect(api.uploadKnowledge).toHaveBeenCalledTimes(1); + expect(api.call.mock.calls.some(([operation]) => ['threads', 'attachments', 'importKnowledgeAttachment'].includes(operation))).toBe(false); }); it('does not call zero chunks searchable and distinguishes loading from an empty library', async () => { @@ -162,23 +164,6 @@ it('keeps all loaded pages refreshing and ignores a late response from the previ 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: '从聊天选择文件' })); - fireEvent.click(await screen.findByRole('button', { name: '选择对话 我的对话' })); - fireEvent.click(await screen.findByRole('checkbox', { name: '选择文件 附件.pdf' })); - fireEvent.click(screen.getByRole('button', { name: '导入 1 个文件' })); - 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: '旧文档清理失败' } }];