From 7c51bb4a008e388071ec37156adeae80eff24167 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Tue, 13 Oct 2026 12:11:56 +0800 Subject: [PATCH] feat(trial): assemble completed Agent workspace updates --- ...20261013-combined-trial-update-c42d19a7.md | 65 ++++ README.md | 7 +- electron/api/routes/cloud-agents.ts | 5 +- electron/services/cloud-agent-journal.ts | 7 +- electron/services/cloud-agent-operations.ts | 19 +- electron/services/cloud-agents.ts | 118 +++++- shared/cloud-agents.ts | 8 + .../CloudAgents/CloudChannelConversations.tsx | 5 +- src/pages/CloudAgents/CloudChat.tsx | 56 ++- src/pages/CloudAgents/CloudKnowledge.tsx | 26 +- .../CloudKnowledgeAttachmentPicker.tsx | 280 ++++++++++++++ .../CloudAgents/CloudKnowledgeDocuments.tsx | 62 +--- .../CloudAgents/CloudKnowledgeSelector.tsx | 18 +- src/pages/CloudAgents/CloudLifecycle.tsx | 2 +- src/pages/CloudAgents/CloudMessageBody.tsx | 36 ++ src/pages/CloudAgents/CloudRecovery.tsx | 11 +- src/pages/CloudAgents/CloudSchedules.tsx | 2 +- src/pages/CloudAgents/ConfigurationFields.tsx | 2 +- src/pages/CloudAgents/agent-config.css | 69 +++- src/pages/CloudAgents/cloud-chat-stream.ts | 133 ++++--- src/pages/CloudAgents/index.tsx | 24 +- tests/e2e/cloud-agent-chat-layout.spec.ts | 6 +- tests/e2e/cloud-agent-home.spec.ts | 2 +- tests/e2e/cloud-agent-knowledge.spec.ts | 286 +++++++++++++-- .../e2e/cloud-agent-navigation-layout.spec.ts | 4 +- .../e2e/cloud-agent-platform-avatars.spec.ts | 4 +- tests/e2e/cloud-agents.spec.ts | 50 ++- tests/e2e/cloud-chat-rendering.spec.ts | 101 +++++ tests/unit/cloud-agent-avatar-routes.test.ts | 25 +- tests/unit/cloud-agents-main.test.ts | 153 ++++++++ tests/unit/cloud-agents-page.test.tsx | 149 +++++++- tests/unit/cloud-agents-workflows.test.tsx | 4 +- tests/unit/cloud-chat-reasoning.test.tsx | 38 +- tests/unit/cloud-chat-rendering.test.tsx | 185 ++++++++++ tests/unit/cloud-chat-stream.test.ts | 14 +- tests/unit/cloud-knowledge-import.test.tsx | 345 ++++++++++++++++++ tests/unit/cloud-knowledge-picker.test.tsx | 18 +- tests/unit/cloud-knowledge-status.test.tsx | 18 +- 38 files changed, 2081 insertions(+), 276 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20261013-combined-trial-update-c42d19a7.md create mode 100644 src/pages/CloudAgents/CloudKnowledgeAttachmentPicker.tsx create mode 100644 src/pages/CloudAgents/CloudMessageBody.tsx create mode 100644 tests/e2e/cloud-chat-rendering.spec.ts create mode 100644 tests/unit/cloud-chat-rendering.test.tsx create mode 100644 tests/unit/cloud-knowledge-import.test.tsx diff --git a/.project-docs/30-worklog/tasks/20261013-combined-trial-update-c42d19a7.md b/.project-docs/30-worklog/tasks/20261013-combined-trial-update-c42d19a7.md new file mode 100644 index 00000000..0c825756 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20261013-combined-trial-update-c42d19a7.md @@ -0,0 +1,65 @@ +# Task: Update trial application with completed Agent and homepage changes + +## Identity + +- Task ID: 20261013-combined-trial-update-c42d19a7 +- Mode: Feature +- Branch: codex/20261013-combined-trial-update-c42d19a7-combined-trial-update +- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261013-combined-trial-update-c42d19a7 +- Base commit: 8289f27f3e53ef455b656f1c43d2620d235d5014 +- Owner: codex +- Status: Planning + +## Scope + +- Assemble and validate completed homepage and Agent changes, then update the existing local temporary application with both Main and Renderer while retaining its profile and login. +- Keep the current temporary application as the target; no production backend deployment, repository push, primary checkout change or worktree cleanup. + +## Project Context Loaded + +- Task ID / mode: 20261013-combined-trial-update-c42d19a7 / feature; exact registry match verified after successful isolated start. +- Branch / worktree / base: codex/20261013-combined-trial-update-c42d19a7-combined-trial-update; this Identity path; 8289f27f3e53ef455b656f1c43d2620d235d5014. +- Concurrent and Planning Gates: Passed. Primary main is occupied by teacher integration and has an unrelated modified record; leave it untouched. Feature mode assembles product code and owns only this task record; do not promote canonical docs or edit source task records. +- Read: startup instructions, planning gate, this task record; retained previously read memory-index, positioning, current-state, decision-index, system-overview, business rules and success criteria at this base. Read fetched main's ADR/current-state/data-flow/decision-index delta, evidence index, commitments/stale items, and linked chat Agent-待更新. +- Peer scopes: independent read-only review checked all 31 peer records. Ready knowledge c634425 and create-recovery 21852fb are compatible. Old teacher/Alice changes are already present or superseded; undefined old release/child-language tasks and Canvas investigation have no releasable product code. +- Product goal/current focus: single light Electron workspace, independent personal Agent creation/configuration/chat, explicit channel binding, selected knowledge and safe conversation process rendering. +- Sources: base retains Robot-home hiding 13336cf and channel hint 8289f27; fetched origin/main bf39f40 includes message/tool rendering 70a3212 and capability copy 821fd6f; knowledge c634425; create recovery 21852fb with compatibility-only record 66c49c9. +- Decisions: latest accepted personal-cloud-Agent ADR supersedes purpose wizard/embedded trial/access navigation. Main owns credentials and safe history projection; explicit publish/channel enablement, request identities, owner isolation and billing remain. +- Evidence/unknowns: current Main PID 7459, port 62864, original entry-nav stage and profile .trial-runtime/user-data; active Renderer be53e44, product Main predates fetched updates. History/capability labels require matching Yuxi; compatibility audit says deployed-source purpose still requires nonempty text. Client update does not deploy that backend or resolve the empty-introduction contract. No paid-call validation. +- Likely files: CloudChat/message stream, knowledge workspace/picker, creation/recovery, cloud-agent Main projection/service/journal, README, relevant tests and runtime manifests. README and shared tests require semantic combination. + +## Intent And Constraints + +- User now explicitly authorizes updating the temporary application and combining eligible completed changes, revoking the prior no-update instruction for this task. +- Retain login/profile and avoid switching desktop focus; do not send messages, create real Agents, import real files or use paid generation during verification. +- Stage and verify a complete build before replacing the runtime. Main changed, so a Renderer-only refresh is insufficient. Preserve a rollback path and check current activity before a bounded background restart. +- Import source product patches and their tests; keep upstream/source project-doc records read-only. Record provenance here instead of canonical reconciliation. + +## Plan + +1. Combine latest main product changes, knowledge workspace and create recovery with the base's homepage/channel improvements; resolve only verified compatible conflicts. +2. Verify combined unit/type/lint/build and fixture Electron scenarios, covering the changed screens and Main contracts. +3. Prepare a durable runtime stage, preserve the current profile/window state and replace Main/Renderer together in the background; verify loaded source/bytes, local health and visible entries without real writes. +4. Commit source and this handoff record, save verified runtime provenance, and report updated scope plus material backend limits. + +## Outcome + +- Product assembly complete: retained base homepage/channel changes, applied bf39f40 product delta, knowledge c634425 and creation 21852fb without modifying their source task/canonical records. Runtime deployment pending. +- 22 changed product files match exact accepted source commit blobs; README combines all current behaviors. The only test conflict combined the knowledge static harness with main's normalized cross-platform import paths. + +## Verification + +- Pinned pnpm 10.33.4: 24 focused unit files / 406 tests passed; typecheck and complete build:vite passed. +- Scoped ESLint 35 TypeScript files passed with 0 errors; 2 existing CloudChat warnings retained. +- Combined UI suite: 50 passed, 1 historical Code navigation assertion failed. Targeted retry reproduces only the same mismatch (brand 46px vs inner tablist 32px). Independent review verified d787267 introduced the inner-tab layout on Sept 30 without updating this assertion; Code CSS/page/Sidebar/test blobs are identical to base. All updated Agent and homepage scenarios passed. Do not weaken the test or change unrelated product code in this task. +- Source composition and `git diff --check` passed; task documentation ownership drift passed. +- Runtime snapshot, hidden probe and actual application switch remain pending. + +## Follow-ups + +- Historical Code navigation E2E compares brand against inner tablist height; review the assertion against the existing toolbar contract in a separate scoped task. +- A client update does not deploy Yuxi history/capability/empty-introduction support; preserve these known backend limits. + +## Promotion Candidates + +- None recorded. diff --git a/README.md b/README.md index e56e421e..cf0dfc14 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,9 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智 - `Makelore Code|AI 编程`:管理本地项目、项目智能体、对话、文件上下文、代码变更和运行时。 会话工作台采用浅蓝项目栏、彩色通用手柄图标与卡片选中态;中间对话/作品使用圆角标签,作品预览的后退、前进、刷新与网址输入统一放在标签右侧;只在作品页显示,窄窗口自动换行。右侧智能体咨询使用奶油色标题区。沿用真实项目和会话入口、侧栏伸缩与账户菜单,样式只作用于 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 Agents|AI 智能体`:从模块首页进入时先打开“我的智能体”卡片列表,保留此前对话与操作恢复记录;返回首页箭头与侧栏折叠按钮同排,折叠后仍可返回;智能体模块的侧栏只通过按钮点击或键盘激活展开,鼠标经过左侧、收纳按钮或顶栏不会展开,切换模块不沿用旧悬停预览;其他模块仍保留原预览方式。“我的智能体”使用浅紫对话机器人图标,“渠道”使用浅蓝连接节点图标。macOS 原生窗口按钮留出安全空间。首页围绕自己的智能体和创建入口,显示童趣问候“Hi,今天想创造还是想聊聊?”,不再显示更多、收到的分享、活动或归档筛选。卡片恢复原有尺寸与留白,显示名称、可为空的一句话介绍及状态,支持聊天、调整、继续制作和确认删除;删除沿用云端归档机制,移出列表并停用新调用,历史和费用保留。创建先填写名称并选择平台像素头像或上传头像,下一步进入独立配置。顶部名称有编辑图标,头像可点击修改;未设置头像时使用同一张默认像素头像,从创建到配置、改名、保存和重新打开保持一致。基础页为“介绍与提示词”,一句话介绍可留空,完整提示词保留原始文本;模型作为高级设置第一项必选,未选择时不能开始使用,草稿可分步保存,不自动选模型。介绍与提示词采用浅蓝介绍卡和浅紫提示词卡;高级设置按模型、工具箱(Tool)、做事手册(Skill)、MCP 服务、子智能体和知识库纵向呈现,不再设内部分类切换;不展示 Tool/Skill 解释、资源开放规则提示、云端能力管理、费用上限或运行限制卡片,保存其他配置时保留既有运行限制,费用规则不变。顶层页签切换保留搜索、资源选择、知识表单及未保存输入。配置页移除聊天、自动任务和使用与接入导航,独立聊天仍从卡片进入,微信账号仍在侧栏渠道管理。草稿明确保存(Ctrl/⌘+S);保存只更新草稿,“应用最新修改”使新配置生效,失败重试保持原操作身份;点击“开始使用”完成必要的私有启用后直接打开聊天,不再展示二次确认页;未保存修改、缺少模型、冲突或尚未确认的请求会留在配置页处理,打开聊天不自动发送消息。独立对话沿用奶油底色和浅蓝、浅紫、薄荷色:顶部展示返回、只读头像和名称;有附件时在同一行右侧显示回形针“附件”入口和文件数量,不展示调整、保存/应用状态、客户端/微信来源、费用/版本说明或历史/新对话图标;配置由列表卡片的“调整”进入。空对话展示当前智能体头像与名称,示例只填入草稿;用户消息使用浅紫圆角气泡,助手 Markdown、表格和代码在消息区内滚动。文件或图片从输入框内添加,上传成功后以待发送卡片呈现,可移除本次携带;移除不会删除云端文件,添加不会自动发送。确认失败沿用同一文件和对话重试,发送前需完成或取消添加;支持仅发送附件,使用明确的附件提问。输入框的“+”添加文件或图片;右上角“附件”打开右侧面板,按“你上传的”和“助手生成的”分组,支持再次选用已上传附件及保存明确交付的生成文件。生成文件仅取当前对话已保存助手回复中的本地文件链接(含图片与引用式链接),核对实际文件后展示和计数;只读取链接所在的文件夹,不遍历全部工作文件。未交付的脚本、缓存和工具记录不展示、不计数,明确交付的代码照常展示;仅写了路径的旧成果可能暂时隐藏,后续接入 Yuxi 的正式交付记录。没有文件时隐藏入口;打开、关闭面板保留聊天与输入,Escape 关闭并返回入口焦点;最近对话自动恢复、审批、停止和未发送内容的离开保护保留。模型返回的思考按消息独立于正文展示;正文到来后或完成时折叠,可手动展开;无思考或工具内容时不显示过程区。重连事件去重并隔离当前运行与主对话,终态历史未到达时保留已收到的片段并有限补读;服务端返回的最终回答思考可在历史中回看,不进入后续提问。历史回看需要配套 Yuxi API 的思考和工具记录投影;实时过程沿用既有事件流。平台工具和内置做事手册使用 Yuxi 下发的通俗名称与说明;逐条消息的思考、工具输入/结果/失败独立于 Markdown 正文,实时与历史工具卡片共用展示文案,展开可查看原始工具标识。个人上传手册保留作者文案,资源选择仍保存原始标识。头像上传、Main-owned Host API、访问隔离、费用与不可变版本规则保持;介绍留空需要配套 Yuxi DraftCreate/DraftSave 的空字符串契约。 - 高级配置的知识库支持下拉多选,分为「MakeLore知识」和「我的知识库」,已选项可单独移除。MakeLore知识来自当前账号有权读取的 Yuxi 共享目录,只显示名称与简介;移除仅取消当前智能体选用,不删除原库。我的知识库以服务端本人管理目录为准,提供「上传自己的文件」入口,可新建库或追加到已有个人库,上传后自动解析、建立索引并持续显示真实处理状态;失败可重试且保留已有可用内容。暂不可选用的个人库仍可管理文件,已有绑定保留并提示不可用;未确认请求在当前库保留同一操作身份供重试。知识库选择只改草稿,显式保存/应用后生效;个人云端执行已自动接入所选知识库的检索,无需另外勾选 knowledge-base Skill。此交互复用现有接口,无需新增后端补丁。 + 高级配置的知识库支持下拉多选,分为「MakeLore知识」和「我的知识库」,已选项可单独移除。MakeLore知识来自当前账号有权读取的 Yuxi 共享目录,只显示名称与简介;移除仅取消当前智能体选用,不删除原库。知识库区域左右分区:左侧「上传自己的文件」管理本人知识库,右侧选择「MakeLore知识」和「我的知识库」,小窗口上下排列。左侧可新建库或追加资料,提供「从电脑上传」和「从聊天选择文件」;聊天文件选择面板显示目标知识库、可搜索的来源聊天和文件名称/类型/大小,支持多选,逐文件导入并保留独立操作身份,部分失败只重试失败项;也可明确确认放弃本页重试,已保存文件仍保留。仅选本人已上传的聊天附件,聊天正文和助手生成的文件不直接入库。上传和导入后自动解析、建立索引并持续显示真实处理状态;失败可重试且保留已有可用内容。使用说明与费用以折叠说明呈现。暂不可选用的个人库仍可管理文件,已有绑定保留并提示不可用;未确认请求在当前库保留同一操作身份供重试。知识库选择只改草稿,显式保存/应用后生效;个人云端执行已自动接入所选知识库的检索,无需另外勾选 knowledge-base Skill。此交互复用现有接口,无需新增后端补丁。 云知识库的 embedding 模型只由平台管理员在 Yuxi 后台维护:在「智能体管理 → 模型供应商」启用供应商,按供应商要求填写有效的服务地址和 API Key,添加并启用 embedding 模型并填写实际向量维度;embedding 向量化费用由平台承担,不计入用户用量、词元点数或智能体费用上限;one-api 不参与 embedding 配置,Works Square 只负责其他应计费模型调用的创建者词元点数账本。客户端区分目录加载中、加载失败与无可用模型,空状态提供管理员配置说明;「刷新知识库与模型」保留未提交名称,未确认的创建请求沿用原操作身份。 @@ -27,6 +27,8 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智 作品广场、素材广场、独立发布上传和云部署页面不属于 Makelore 2.0 工作台。新建 Code 项目只要求选择目录:Main 自动生成内部项目 ID,并以内部 `interactive_ai_app` 类型创建 `.makelore/project.json` 与 `knowledge/`,不再让用户选择或查看项目身份、项目类型和模板;缺少项目 ID 的旧项目在读取时由 Main 自动补全。从列表移除项目只取消登记,不删除磁盘文件;在“新建项目”中直接选择已有项目文件夹会重新打开,并保留原有项目身份、类型、智能体、对话和知识文件。“新建下级文件夹”仍拒绝已存在的同名目录。创建成功后直接进入对话工作区,未创建智能体时只显示可选的设置入口,不再用初始化门禁遮挡工作区。已有 `custom` 项目继续受支持;历史 `mini_game` / `mini_program` 配置在读取时归一为交互式 AI 应用,但不会因读取被改写。用户获取并为项目启用官方 bundled `makelore.project-scaffold` 插件后,每个父智能体都可按需明确调用 `makelore-project-scaffold` Skill,无需伙伴分配;它以不覆盖既有路径的方式生成固定六文件 Vite 起步工程,不是创建前置条件,也不安装依赖、不联网、不构建、不上传或提审。交互式 AI 应用的项目配置底部提供“一键提交审核”;Main 自动预检、安全打包并提交,构建通过后进入运营审核,审核通过即直接发布。首次创建必须选择 PNG、JPEG 或 WebP 项目封面,并通过 Main-owned multipart 原子接口同时保存资料与封面;已有 draft/published 只提交新版本并沿用平台现有资料与封面。项目成果预览 `/deliverables` 继续保留。 +创建智能体时,如果本次新操作被服务明确拒绝,会显示失败原因、保留名称和头像,并允许修改后重新提交;不会留下“结果尚未确认”提醒。断网、响应异常或已有待确认操作的重试失败仍保留原创建请求,可重试确认结果,避免重复创建。当前创建表单一旦出现结果不确定,即使移除提醒后重试被拒绝,也继续保留原请求;移除提醒不代表取消创建。创建页面会及时刷新本机恢复记录。服务器的字段校验错误只转换为固定中文提示,不展示内部错误或提交原文。一句话介绍仍可留空;创建与保存需要配套的智能体云端服务支持。 + ## 当前产品状态 - 桌面技术栈:Electron、React 19、Vite、TypeScript、Zustand、Tailwind CSS。 @@ -41,6 +43,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智 - 共享开发浏览器:AI 编程右侧提供项目级浏览器,用户与 Agent 查看并调试同一实时页面、Console 和 Network,支持本地与公网开发地址。 - 后端边界:Renderer 通过 Main 所有的 Host API 访问认证、模型、同步、更新、语音、图像与运行时能力。 - 智能体头像:创建和编辑个人云智能体时可上传、更换或移除头像,支持 PNG、JPEG、WebP、GIF,最大 5 MB。复用 Yuxi 的 `Agent.icon` 和公开图片存储;上传后先预览,保存草稿后生效,正式使用的头像随发布版本固定。列表、编辑页、收到的分享、会话入口和版本对比展示对应头像,未设置或加载失败时显示默认图标。Main 负责文件选择、上传和相对地址解析,客户端不维护第二份头像数据;需配套更新 Yuxi 的 Makelore 头像接口。 +- 智能体对话:试用、正式对话和历史记录使用相同的 Markdown 正文与可折叠过程区,展示模型实际返回的思考、工具输入、结果和失败。实时事件按消息与工具身份累积,重连去重;开始输出正文后过程区自动收起,可手动展开。配套 Yuxi 历史接口投影已有持久思考和工具记录,重新打开及微信历史均可查看;没有思考或工具内容时不显示空面板。 - 客户端更新:Electron Main 按平台与架构选择更新源并保留原始诊断;设置页只显示一条脱敏后的中文状态。正式源缺少对应安装包时保持错误并允许重试,不会误报为已是最新版。 - 个人资料:姓名、年龄、性别与个人头像按账号同步到云端;首次登录进入模块选择页时会要求先完善姓名,首页欢迎语和主平台左下角账号区优先展示个人资料姓名;头像支持 PNG、JPEG、WebP,保存时自动居中裁剪并以圆形缩略图展示,未设置或加载失败时回退为姓名首字母。 - 会话观察同步:本地编程会话在一轮问答完成并进入空闲后,后台通过个人资料 PUT 上传该会话截至当前的完整问答快照;只保留用户/助手自然语言文本,过滤代码、路径、日志、工具调用、附件与产物。同步只从本地上行,云端不回写、不恢复或删除本地会话;失败数据留在本地等待重试。 diff --git a/electron/api/routes/cloud-agents.ts b/electron/api/routes/cloud-agents.ts index b703e62b..1e888bec 100644 --- a/electron/api/routes/cloud-agents.ts +++ b/electron/api/routes/cloud-agents.ts @@ -73,9 +73,10 @@ export async function handleCloudAgentsRoutes(req: IncomingMessage, res: ServerR sendJson(res, 200, data); } catch (error) { const failure = error instanceof CloudAgentsError ? error - : new CloudAgentsError(error instanceof SyntaxError ? 400 : 502, error instanceof SyntaxError ? 'invalid_input' : 'cloud_service_unavailable'); + : new CloudAgentsError(error instanceof SyntaxError ? 400 : 502, error instanceof SyntaxError ? 'invalid_input' : 'cloud_service_unavailable', + error instanceof SyntaxError ? 'definitive_failure' : 'unknown'); if (res.headersSent) res.end(); - else sendJson(res, failure.status, { success: false, error: failure.message, code: failure.code }); + else sendJson(res, failure.status, { success: false, error: failure.message, code: failure.code, commandOutcome: failure.commandOutcome }); } return true; } diff --git a/electron/services/cloud-agent-journal.ts b/electron/services/cloud-agent-journal.ts index 103863a5..0a21ef9c 100644 --- a/electron/services/cloud-agent-journal.ts +++ b/electron/services/cloud-agent-journal.ts @@ -26,14 +26,17 @@ export class CloudAgentJournal { return this.update(accountKey, validate, value => ({ ...value, recent })); } - put(accountKey: string, entry: CloudPendingOperation, validate: () => void) { - return this.update(accountKey, validate, value => { + async put(accountKey: string, entry: CloudPendingOperation, validate: () => void): Promise { + let alreadyPending = false; + await this.update(accountKey, validate, value => { const existing = value.pending.find(item => item.id === entry.id); if (existing && JSON.stringify(existing.input) !== JSON.stringify(entry.input)) { throw new Error('同一次待确认操作不能更改输入'); } + alreadyPending = Boolean(existing); return { ...value, pending: existing ? value.pending : [...value.pending, entry] }; }); + return alreadyPending; } remove(accountKey: string, id: string, validate: () => void) { diff --git a/electron/services/cloud-agent-operations.ts b/electron/services/cloud-agent-operations.ts index e29b7645..22477711 100644 --- a/electron/services/cloud-agent-operations.ts +++ b/electron/services/cloud-agent-operations.ts @@ -77,11 +77,26 @@ function projectRun(value: unknown): unknown { } function projectConversation(value: unknown): unknown { const source = object(value); - return { ...projectSession(source), messages: list(source.messages, item => copy(item, ['id', 'role', 'content', 'request_id', 'run_id', 'created_at'])), + return { ...projectSession(source), messages: list(source.messages, projectMessage), run: source.run == null ? null : projectRun(source.run), queued_requests: list(source.queued_requests, item => copy(item, ['request_id', 'thread_id', 'run_id', 'status', 'version'])), next_offset: source.next_offset ?? null }; } +function projectMessage(value: unknown): unknown { + const source = object(value); + return { ...copy(source, ['id', 'role', 'content', 'request_id', 'run_id', 'created_at', 'message_id', 'reasoning_content']), + ...(Array.isArray(source.tool_calls) ? { tool_calls: source.tool_calls.map(item => { + const tool = object(item); + return { ...copy(tool, ['id', 'name', 'display_name', 'display_description', 'args', 'status', 'error_message']), + ...(tool.tool_call_result == null ? {} : { tool_call_result: copy(tool.tool_call_result, ['content']) }) }; + }) } : {}), + }; +} +function projectHistory(value: unknown): unknown { + const source = object(value); + return { ...copy(source, ['thread_id', 'queued_requests', 'schedule_proposals', 'next_offset']), + messages: list(source.messages, projectMessage), run: source.run == null ? null : projectRun(source.run) }; +} function projectFiles(value: unknown): unknown { const source = object(value); return { session_id: source.session_id, thread_id: source.thread_id ?? null, @@ -253,7 +268,7 @@ const operations: Record = { saveBudget: { method: 'PUT', cloud: 'ws', path: '/agents/:slug/budget', body: ['request_limit_points', 'daily_limit_points'], output: budget }, threads: { method: 'GET', path: '/threads', query: { slug: 'slug', offset: 'offset', archived: 'archived' }, output: ['threads', 'next_offset'] }, createThread: { method: 'POST', path: '/agents/:slug/threads', body: ['thread_id', 'preview', 'expected_revision'], output: ['thread_id', 'client_thread_id'] }, - history: { method: 'GET', path: '/threads/:thread_id', query: { offset: 'offset' }, output: ['thread_id', 'messages', 'run', 'queued_requests', 'schedule_proposals', 'next_offset'] }, + history: { method: 'GET', path: '/threads/:thread_id', query: { offset: 'offset' }, output: ['thread_id', 'messages', 'run', 'queued_requests', 'schedule_proposals', 'next_offset'], project: projectHistory }, viewed: { method: 'POST', path: '/threads/:thread_id/viewed', body: ['run_id'], output: ['viewed'] }, submit: { method: 'POST', path: '/agents/:slug/requests', body: prompt, output: request }, preview: { method: 'POST', path: '/agents/:slug/preview', body: prompt, output: request }, diff --git a/electron/services/cloud-agents.ts b/electron/services/cloud-agents.ts index ee055322..1be67387 100644 --- a/electron/services/cloud-agents.ts +++ b/electron/services/cloud-agents.ts @@ -29,7 +29,12 @@ const MESSAGES: Record = { draft_revision_conflict: '草稿已在其他设备更新,你的输入已保留', operation_conflict: '创建操作的内容发生冲突,请刷新列表确认', cloud_service_unavailable: '智能体服务暂时不可用,请重试', - invalid_input: '请检查名称、用途和配置内容', + invalid_input: '提交的信息未被接受,请检查后再试', + invalid_name: '请填写名称,长度不要超过 100 个字', + invalid_introduction: '一句话介绍不符合要求,长度不要超过 2000 个字', + introduction_too_short: '一句话介绍不符合要求,请修改后再试', + introduction_required: '当前智能体服务尚不支持一句话介绍留空,请等待服务更新后重试', + invalid_icon: '头像设置未被接受,请重新选择头像', model_required: '请先选择模型并保存', model_unavailable: '所选模型当前不可用,请刷新模型目录', resource_unavailable: '所选资源已不可用,请刷新配置', @@ -73,11 +78,39 @@ const MESSAGES: Record = { }; export class CloudAgentsError extends Error { - constructor(readonly status: number, readonly code: string) { + constructor(readonly status: number, readonly code: string, + readonly commandOutcome: 'definitive_failure' | 'unknown' = 'unknown') { super(MESSAGES[code] ?? MESSAGES.cloud_service_unavailable); } } +const validationTypes = new Set(['missing', 'string_type', 'string_too_short', 'string_too_long', + 'string_pattern_mismatch', 'uuid_parsing', 'uuid_type', 'value_error']); + +/** Only structural request-validation metadata is trusted; never expose msg, input or ctx. */ +function requestValidation(detail: unknown): boolean { + return Array.isArray(detail) && detail.length > 0 && detail.length <= 100 && detail.every(value => { + const item = record(value); + return Array.isArray(item.loc) && item.loc[0] === 'body' && item.loc.length >= 2 + && typeof item.type === 'string' && validationTypes.has(item.type); + }); +} + +function validationMessageCode(detail: unknown): string { + if (!requestValidation(detail)) return 'invalid_input'; + const codes = (detail as unknown[]).map(value => { + const item = record(value); + if (!Array.isArray(item.loc) || item.loc.length !== 2) return 'invalid_input'; + switch (item.loc[1]) { + case 'name': return 'invalid_name'; + case 'purpose': return item.type === 'string_too_short' ? 'introduction_too_short' : 'invalid_introduction'; + case 'icon': return 'invalid_icon'; + default: return 'invalid_input'; + } + }); + return codes.every(code => code === codes[0]) ? codes[0] : 'invalid_input'; +} + function record(value: unknown): Record { return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; @@ -154,6 +187,7 @@ export class CloudAgentsModule { private activityTimer: ReturnType; private activityPolling = false; private activitySeen: Map | null = null; + private readonly creationAttempts = new Map }>(); constructor(private readonly fetchImpl: (input: string, init?: RequestInit) => Promise = proxyAwareFetch, private readonly journal = new CloudAgentJournal()) { @@ -196,17 +230,49 @@ export class CloudAgentsModule { async create(value: unknown): Promise { const input = record(value); - const operationId = textField(input.operation_id, 36); - if (!/^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(operationId)) { - throw new CloudAgentsError(422, 'invalid_input'); + let body: CreateCloudAgent; + try { + const operationId = textField(input.operation_id, 36); + if (!/^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(operationId)) { + throw new CloudAgentsError(422, 'invalid_input'); + } + body = { + operation_id: operationId, + name: textField(input.name, 100).trim(), + purpose: textField(input.purpose, 2000, false).trim(), + ...(input.icon === undefined ? {} : { icon: input.icon === null ? null : textField(input.icon, 255, false) }), + }; + } catch (error) { + if (error instanceof CloudAgentsError) { + const binding = getWorksSquareAccountBinding(); + const id = typeof input.operation_id === 'string' ? input.operation_id : ''; + let alreadyPending = Boolean(binding && this.creationAttempts.has(`${binding.accountKey}:${binding.epoch}:${id}`)); + if (binding) { + alreadyPending ||= (await this.journal.read(binding.accountKey)).pending.some(item => item.id === `createAgent::${id}`); + this.requireCurrent(binding); + } + throw new CloudAgentsError(error.status, error.code, alreadyPending ? 'unknown' : 'definitive_failure'); + } + throw error; } - const body: CreateCloudAgent = { - operation_id: operationId, - name: textField(input.name, 100).trim(), - purpose: textField(input.purpose, 2000, false).trim(), - ...(input.icon === undefined ? {} : { icon: input.icon === null ? null : textField(input.icon, 255, false) }), - }; - return this.withRecovery('createAgent', { ...body }, () => this.request('/api/makelore/agents', 'POST', body).then(result => this.withAvatar(projectDraft(result)))); + const binding = getWorksSquareAccountBinding(); + if (!binding) throw new CloudAgentsError(401, 'session_expired'); + const key = `${binding.accountKey}:${binding.epoch}:${body.operation_id}`; + const serialized = JSON.stringify(body); + const existing = this.creationAttempts.get(key); + if (existing) { + if (existing.input !== serialized) throw new CloudAgentsError(409, 'operation_conflict'); + return existing.promise; + } + const promise = this.withRecovery('createAgent', { ...body }, () => this.request('/api/makelore/agents', 'POST', body) + .then(result => this.withAvatar(projectDraft(result))).catch(error => { + if (body.purpose === '' && error instanceof CloudAgentsError && error.code === 'introduction_too_short') { + throw new CloudAgentsError(error.status, 'introduction_required', error.commandOutcome); + } + throw error; + })).finally(() => { this.creationAttempts.delete(key); }); + this.creationAttempts.set(key, { input: serialized, promise }); + return promise; } private async withRecovery(operation: string, input: Record, execute: () => Promise): Promise { @@ -217,9 +283,23 @@ export class CloudAgentsModule { .map(value => encodeURIComponent(typeof value === 'string' ? value : '')).join(':') : input.request_id ?? input.operation_id; const id = [operation, input.slug ?? input.application_id ?? input.run_id ?? '', operationKey].join(':'); - await this.journal.put(binding.accountKey, { id, operation, input, created_at: new Date().toISOString() }, () => this.requireCurrent(binding)); + const alreadyPending = await this.journal.put(binding.accountKey, { id, operation, input, created_at: new Date().toISOString() }, () => this.requireCurrent(binding)); this.requireCurrent(binding); - const result = await execute(); + let result: T; + try { + result = await execute(); + } catch (error) { + this.requireCurrent(binding); + if (error instanceof CloudAgentsError) { + // A retry rejection cannot disprove an earlier acceptance whose response was lost. + if (operation === 'createAgent' && !alreadyPending && error.commandOutcome === 'definitive_failure') { + await this.journal.remove(binding.accountKey, id, () => this.requireCurrent(binding)); + throw error; + } + throw new CloudAgentsError(error.status, error.code, 'unknown'); + } + throw error; + } this.requireCurrent(binding); await this.journal.remove(binding.accountKey, id, () => this.requireCurrent(binding)); return result; @@ -671,11 +751,17 @@ export class CloudAgentsModule { if (raw.length > 4_000_000) throw new CloudAgentsError(502, 'cloud_service_unavailable'); const body: unknown = JSON.parse(raw); if (!response.ok) { - const code = record(record(body).detail).code; + const detail = record(body).detail; + const code = record(detail).code; + const rejected = ([400, 422].includes(response.status) && (code === 'invalid_input' || requestValidation(detail))) + || (response.status === 401 && code === 'session_expired') + || (response.status === 403 && ['access_revoked', 'module_unavailable'].includes(String(code))) + || (response.status === 423 && code === 'account_locked'); throw new CloudAgentsError( response.status, typeof code === 'string' && Object.hasOwn(MESSAGES, code) ? code - : response.status === 422 ? 'invalid_input' : response.status === 401 ? 'session_expired' + : response.status === 422 ? validationMessageCode(detail) : response.status === 401 ? 'session_expired' : response.status === 403 ? 'access_revoked' : 'cloud_service_unavailable', + rejected ? 'definitive_failure' : 'unknown', ); } return body; diff --git a/shared/cloud-agents.ts b/shared/cloud-agents.ts index b9bd84ae..f9266556 100644 --- a/shared/cloud-agents.ts +++ b/shared/cloud-agents.ts @@ -98,7 +98,15 @@ export interface CloudInterrupt { export interface CloudMessage { id: number; role: 'user' | 'assistant' | 'system'; content: string; request_id: string; run_id: string | null; created_at: string; + message_id?: string | null; reasoning_content?: string; + tool_calls?: CloudToolCall[]; +} +export interface CloudToolCall { + id: string; name: string; args: unknown; status: string; + display_name?: string; display_description?: string; + tool_call_result?: { content: unknown } | null; + error_message?: string | null; } export interface CloudHistory { thread_id: string; messages: CloudMessage[]; run: CloudRun | null; next_offset: number | null; diff --git a/src/pages/CloudAgents/CloudChannelConversations.tsx b/src/pages/CloudAgents/CloudChannelConversations.tsx index e5a4bfa5..46fa0566 100644 --- a/src/pages/CloudAgents/CloudChannelConversations.tsx +++ b/src/pages/CloudAgents/CloudChannelConversations.tsx @@ -1,8 +1,7 @@ import { useCallback, useEffect, useState } from 'react'; import { Download, Folder, RefreshCw } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import ReactMarkdown from 'react-markdown'; -import remarkGfm from 'remark-gfm'; +import { CloudMessageBody } from './CloudMessageBody'; import { cloudAgentsApi } from '@/lib/cloud-agents-api'; import type { CloudAgentOperations, CloudChannelSession, CloudChannelConversation, CloudFile, CloudPendingOperation } from '../../../shared/cloud-agents'; import { CloudApproval, cloudStatus } from './CloudChat'; @@ -185,7 +184,7 @@ function ChannelSession({ sessionId, visible, refreshRevision, onBusy, onNewSess {error &&

{error}

}
{data?.messages.filter(message => message.role !== 'system').map(message =>
-

{message.role === 'user' ? '你 · 微信' : '智能体'}

href && /^(https?:|mailto:)/i.test(href) ? {children} : {children} }}>{message.content}
)} +

{message.role === 'user' ? '你 · 微信' : '智能体'}

)} {data && !data.messages.length &&

还没有消息。

}
{!recoveryReady && } diff --git a/src/pages/CloudAgents/CloudChat.tsx b/src/pages/CloudAgents/CloudChat.tsx index 8a65b6d3..cd504ed2 100644 --- a/src/pages/CloudAgents/CloudChat.tsx +++ b/src/pages/CloudAgents/CloudChat.tsx @@ -7,12 +7,11 @@ import type { CloudHistory, CloudInterrupt, CloudPrompt, CloudRequest, CloudRun, import { CloudComposerAttachments } from './CloudComposerAttachments'; import { CloudAvatar } from './CloudAvatar'; import './desktop-workspace.css'; +import { CloudMessageBody } from './CloudMessageBody'; import './agent-chat.css'; -import ReactMarkdown, { defaultUrlTransform } from 'react-markdown'; -import remarkGfm from 'remark-gfm'; import { CloudPendingContext, useCloudPendingState, usePendingCloudInput } from './CloudPending'; import { agentExamples } from './agent-guidance'; -import { consumeCloudChatEvent, type CloudChatStream } from './cloud-chat-stream'; +import { consumeCloudChatEvent, type CloudChatStream, type CloudLiveMessage } from './cloud-chat-stream'; export const cloudStatus = (status: string) => ({ idle: '尚未开始', queued: '等待执行', dispatching: '准备执行', dispatched: '已提交', pending: '准备中', @@ -22,6 +21,8 @@ export const cloudStatus = (status: string) => ({ const active = (status?: string) => Boolean(status && ['queued', 'dispatching', 'dispatched', 'pending', 'running', 'cancel_requested', 'submitted'].includes(status)); const message = (e: unknown) => e instanceof Error ? e.message : '暂时无法完成操作'; const object = (v: unknown): Record => v && typeof v === 'object' ? v as Record : {}; +const savedMessage = (history: CloudHistory, message: CloudLiveMessage) => history.messages.some(saved => + saved.role === 'assistant' && saved.run_id === message.run_id && (!saved.message_id || saved.message_id === message.message_id)); export function CloudChat({ slug, previewRevision: latestRevision, initialThread, initialThreadId, onSchedule, activeView = true, headerAction, attachmentHeader, previewReady = true, assistantName = '助手', assistantAvatarUrl, examples = agentExamples(), hasUnsavedChanges = false, saveDisabled = false, onSaveForPreview }: { @@ -75,8 +76,10 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread // Replace the preview only after authoritative assistant history arrives. // Failed/cancelled Runs without a saved answer retain received fragments. const preview = streamReply.current; - if (preview?.threadId === id && page.run?.agent_run_id === preview.runId && !active(page.run.status) - && page.messages.some(item => item.role === 'assistant' && item.run_id === preview.runId)) updateLiveReply(null); + if (preview?.threadId === id && page.run?.agent_run_id === preview.runId && !active(page.run.status)) { + const remaining = preview.messages.filter(item => !savedMessage(page, item)); + if (remaining.length !== preview.messages.length) updateLiveReply(remaining.length ? { ...preview, messages: remaining } : null); + } setQueued(page.queued_requests ?? []); setRequest(active(page.run?.status) ? null : page.queued_requests?.[0] ?? null); if (page.run) setThreads(current => current.map(item => item.thread_id === id @@ -167,6 +170,9 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread const runId = request && active(request.status) ? request.run_id ?? undefined : run?.agent_run_id; const running = active(run?.status) || active(request?.status); + const liveHistoryReady = history?.run && history.run.agent_run_id === liveReply?.runId + ? !active(history.run.status) + : Boolean(history?.messages.some(item => item.run_id === liveReply?.runId)); useEffect(() => { if (!runId || !running) return; let live = true; @@ -184,7 +190,8 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread if (envelope.run_id !== runId || envelope.thread_id !== targetThread || targetRequest && envelope.request_id && envelope.request_id !== targetRequest) return; const chunk = object(object(envelope.payload).chunk); - if (next?.toolName) setProgress('正在使用 ' + next.toolName); + const latestTool = next?.messages.at(-1)?.tool_calls.at(-1); + if (latestTool && ['pending', 'running'].includes(latestTool.status)) setProgress('正在使用 ' + (latestTool.display_name || latestTool.name)); if (event.type === 'interrupt') setInterrupt(chunk as CloudInterrupt); if (event.type === 'end') { source?.close(); @@ -326,7 +333,7 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread
{ const el = e.currentTarget; following.current = el.scrollHeight - el.scrollTop - el.clientHeight < 64; }}>
{loading ?
- : !history?.messages.length && !liveReply?.content && !liveReply?.reasoning &&
+ : !history?.messages.length && !liveReply?.messages.length &&

{assistantName}

{mode === 'preview' ? '问它一个小问题' : 'Hi,今天想聊些什么?'}

@@ -341,13 +348,17 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread

这是修改前的回答。用同一个问题再问一次,更容易看出变化。

} - {history && } - {liveReply && (liveReply.content || liveReply.reasoning) &&
+ {history && + !(!liveHistoryReady && liveReply?.messages.some(item => saved.run_id === item.run_id && saved.message_id === item.message_id)), + ) }} onError={setError} assistantName={assistantName} assistantAvatarUrl={assistantAvatarUrl} />} + {liveReply?.messages.filter(item => !liveHistoryReady || !history || !savedMessage(history, item)).map(item =>

{assistantName}

- {liveReply.reasoning && } - {liveReply.content &&
{liveReply.content}
}
-
} +
{ + const id = thread?.thread_id ?? request?.thread_id; + if (id) void cloudAgentsApi.download(id, path).catch(e => setError(message(e))); + }} />
+ )} {onSchedule && history?.schedule_proposals?.map(proposal =>

自动任务建议 · {proposal.name}

{proposal.prompt}

{proposal.cron_expression} · {proposal.timezone} · 尚未启用

@@ -419,26 +430,13 @@ function TrialMessages({ history, onError, assistantName = '助手', assistantAv return history.messages.map(item =>
{item.role !== 'user' && }
{item.role !== 'user' &&

{assistantName}

} - {item.role === 'assistant' && typeof item.reasoning_content === 'string' && item.reasoning_content - && } -
{item.role === 'user' ?

{item.content}

: url.startsWith('sandbox:/') ? url.slice(8) : defaultUrlTransform(url)} remarkPlugins={[remarkGfm]} components={{ - a: ({ href, children }) => href && !/^(https?:|mailto:|#)/i.test(href) - ? - : {children}, - }}>{item.content}}
+
{item.role === 'user' ?

{item.content}

: + void cloudAgentsApi.download(history.thread_id, path).catch(e => onError(message(e)))} />}
); } -function CloudReasoning({ content, running }: { content: string; running: boolean }) { - const [expanded, setExpanded] = useState(running); - useEffect(() => { setExpanded(running); }, [running]); - return
setExpanded(event.currentTarget.open)} - className="agent-chat-reasoning" data-testid="cloud-chat-reasoning"> - 思考过程 -
{content}
-
; -} - export function CloudApproval({ interrupt, runId, onResumed, submitDecision }: { interrupt: CloudInterrupt; runId: string; onResumed?: (run: CloudRun) => void; submitDecision?: (input: { operation_id: string; decision: Record }) => Promise; diff --git a/src/pages/CloudAgents/CloudKnowledge.tsx b/src/pages/CloudAgents/CloudKnowledge.tsx index 2e95e870..a54ee14b 100644 --- a/src/pages/CloudAgents/CloudKnowledge.tsx +++ b/src/pages/CloudAgents/CloudKnowledge.tsx @@ -1,4 +1,5 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useId, useRef, useState } from 'react'; +import { Upload } from 'lucide-react'; import { cloudAgentsApi } from '@/lib/cloud-agents-api'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -27,6 +28,7 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted, onSelected, on const [refresh, setRefresh] = useState(0); const [creating, setCreating] = useState(false); const [documentPending, setDocumentPending] = useState(false); + const targetId = useId(); const createIntent = useRef<{ operation_id: string; name: string; embedding_model: string } | null>(null); usePendingCloudInput(busy || documentPending || Boolean(createIntent.current) || (creating && Boolean(name.trim()))); const operationInFlight = useRef(false); @@ -62,15 +64,15 @@ 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)); - return
- 上传自己的文件 -
-

选择或新建自己的知识库,再上传文件。选用只会加入当前配置,保存并应用后生效。

-

上传到知识库的文档可供获准使用此智能体的人检索。解析并建立索引后才会生效;embedding 向量化由平台承担,不计入用户用量、词元点数或智能体费用上限。知识处理如触发对话模型调用,仍按原模型实际用量计费。

+ return
+
+
+

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

+ {error &&

{error}

} {catalogError &&

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

}
- setThreadQuery(event.target.value)} />
+
+ {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 14e0ac64..1a778f84 100644 --- a/src/pages/CloudAgents/CloudKnowledgeDocuments.tsx +++ b/src/pages/CloudAgents/CloudKnowledgeDocuments.tsx @@ -3,7 +3,8 @@ 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'; +import { CloudKnowledgeAttachmentPicker } from './CloudKnowledgeAttachmentPicker'; +import type { CloudKnowledgeFile } from '../../../shared/cloud-agents'; function documentState(file: CloudKnowledgeFile) { const task = file.processing_task; @@ -47,11 +48,11 @@ function documentState(file: CloudKnowledgeFile) { return { label: '状态待确认', detail: '请刷新状态后再操作。', stage: 0 }; } -export function CloudKnowledgeDocuments({ slug, kbId, busy, act, onDeleteKnowledge, canRead = true, onPendingChange }: { - slug: string; kbId: string; busy: boolean; +export function CloudKnowledgeDocuments({ slug, kbId, kbName = '当前知识库', busy, act, onDeleteKnowledge, canRead = true, onPendingChange, onRefreshKnowledge }: { + slug: string; kbId: string; kbName?: string; busy: boolean; act: (operation: () => Promise) => Promise; onDeleteKnowledge: () => void; - canRead?: boolean; onPendingChange?: (pending: boolean) => void; + canRead?: boolean; onPendingChange?: (pending: boolean) => void; onRefreshKnowledge?: () => void; }) { const [files, setFiles] = useState([]); const [pageCount, setPageCount] = useState(1); @@ -154,14 +155,14 @@ export function CloudKnowledgeDocuments({ slug, kbId, busy, act, onDeleteKnowled const active = states.some(state => state.active) || submitting !== null; return
- - + +

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

{uploading &&

{uploading}

} - {importing && { setImporting(false); await acceptFile(file); }} />} + {importing && setImporting(false)} onRefreshKnowledge={onRefreshKnowledge} />}
{files.length ? '已显示 ' + files.length + ' 份文档 · ' + states.filter(state => state.ready).length + ' 份可用' : '知识文档'} {active ? '处理中 · 状态自动更新' : '状态自动更新'} @@ -215,52 +216,11 @@ export function CloudKnowledgeDocuments({ slug, kbId, busy, act, onDeleteKnowled {deleting &&

{deleting === 'database' ? '删除整个知识库及其文档、索引?所有使用此库的智能体将无法继续检索这些内容。' : '删除文档“' + deleting.name + '”及其索引?'}

}
; } - -function AttachmentImport({ slug, kbId, busy, act, onImported, onPendingChange }: { - slug: string; kbId: string; busy: boolean; act: (operation: () => Promise) => Promise; - onImported: (file: CloudKnowledgeFile) => Promise; - onPendingChange: (pending: boolean) => void; -}) { - const [threads, setThreads] = useState([]); - const [thread, setThread] = useState(''); - const [files, setFiles] = useState([]); - const [file, setFile] = useState(''); - const [error, setError] = useState(''); - const operation = useRef(null); - const unresolved = Boolean(operation.current); - useEffect(() => { onPendingChange(unresolved); return () => onPendingChange(false); }, [onPendingChange, unresolved]); - 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
-

只显示你自己的对话附件。导入后自动处理,变为“可用于回答”后才能检索。

- - - - {error &&

{error}

} -
; -} diff --git a/src/pages/CloudAgents/CloudKnowledgeSelector.tsx b/src/pages/CloudAgents/CloudKnowledgeSelector.tsx index 65ce7dd9..9888fb2e 100644 --- a/src/pages/CloudAgents/CloudKnowledgeSelector.tsx +++ b/src/pages/CloudAgents/CloudKnowledgeSelector.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { X } from 'lucide-react'; +import { Library, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { CloudKnowledgePanel, type CloudOwnedKnowledgeCatalog } from './CloudKnowledge'; import type { CloudResource } from '../../../shared/cloud-agents'; @@ -16,7 +16,15 @@ export function CloudKnowledgeSelector({ slug, options, selected, disabled, load const unknown = ownedIds === null ? available : []; const selectionFull = selected.length >= 100; const add = (id: string) => { if (!selectionFull && !selected.includes(id)) onChange([...selected, id]); }; - return
+ return
+ option.key)} + onSelected={kb => { if (!loading && !error && options.some(option => option.key === kb.kb_id)) { add(kb.kb_id); onRefresh(); } }} + onDeleted={id => { onChange(selected.filter(value => value !== id)); onRefresh(); }} + onCreated={kb => { add(kb.kb_id); onRefresh(); }} /> +
+
+

从 MakeLore知识和我的知识库中选择,可同时使用多个。

change('preload_skills', e.target.checked ? [...value.skills] : [])} />开始运行时预加载已选做事手册} + onChange={e => change('preload_skills', e.target.checked ? [...value.skills] : [])} />开始对话前,先读好已选做事手册的使用方法} ; })}
; diff --git a/src/pages/CloudAgents/agent-config.css b/src/pages/CloudAgents/agent-config.css index 7798a365..4995f0b9 100644 --- a/src/pages/CloudAgents/agent-config.css +++ b/src/pages/CloudAgents/agent-config.css @@ -41,6 +41,66 @@ .agent-resource-preload { display: flex; min-height: 44px; align-items: center; gap: 9px; margin-top: 12px; cursor: pointer; font-size: 13px; } .agent-config-resource-manager { margin-top: 18px; } .agent-config-resource-manager > details { border-radius: 14px; background: #ffffffa8; } +.agent-resource-card:has(.agent-knowledge-workspace) { container-type: inline-size; } +.agent-knowledge-workspace { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); align-items: start; gap: 18px; min-width: 0; } +.agent-knowledge-upload, .agent-knowledge-selection { min-width: 0; padding: 18px; border: 1px solid #dce5e0; border-radius: 17px; background: #fffdfb; overflow-wrap: anywhere; } +.agent-knowledge-upload { border-color: #dce5f3; } +.agent-knowledge-panel-heading { display: flex; align-items: center; gap: 9px; color: #33485c; } +.agent-knowledge-panel-heading h3 { font-size: 16px; font-weight: 650; line-height: 1.5; } +.agent-knowledge-panel-heading svg { flex-shrink: 0; color: #607d72; } +.agent-knowledge-upload .agent-knowledge-panel-heading svg { color: #4a6e9c; } +.agent-knowledge-field-label { display: block; font-size: 13px; font-weight: 600; color: #4c586b; } +.agent-knowledge-help { border-top: 1px solid #e8e6e1; padding-top: 12px; } +.agent-knowledge-help summary { min-height: 28px; width: fit-content; cursor: pointer; color: #606c7e; font-size: 12px; line-height: 1.7; } +.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; } .agent-editor-tabs [role="tab"] { gap: 6px; padding-inline: 8px; font-size: 14px; } @@ -52,5 +112,12 @@ .agent-config-heading-title { gap: 6px; } .agent-config-icon { width: 38px; height: 38px; border-radius: 13px; } .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 { transition: none; } } +@media (prefers-reduced-motion: reduce) { .agent-resource-option, .agent-knowledge-import-chat { transition: none; } } diff --git a/src/pages/CloudAgents/cloud-chat-stream.ts b/src/pages/CloudAgents/cloud-chat-stream.ts index 79b6f79d..d37cdaa7 100644 --- a/src/pages/CloudAgents/cloud-chat-stream.ts +++ b/src/pages/CloudAgents/cloud-chat-stream.ts @@ -1,58 +1,103 @@ -export interface CloudChatStream { +import type { CloudToolCall } from '../../../shared/cloud-agents'; + +export interface CloudLiveMessage { + message_id: string; + run_id: string; + content: string; + reasoning_content: string; + tool_calls: (CloudToolCall & { index?: number })[]; +} +export interface CloudStream { runId: string; + cursor: string; + messages: CloudLiveMessage[]; + finished?: boolean; +} +export const object = (value: unknown): Record => + value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; + +/** Redis SSE cursors order replayed batches without treating subthread output as an answer. */ +export function reduceCloudStream(state: CloudStream, data: unknown, threadId: string, cursor: string): CloudStream { + if (cursor && /^\d+-\d+$/.test(cursor) && state.cursor) { + const [time, sequence] = cursor.split('-').map(BigInt); + const [previousTime, previousSequence] = state.cursor.split('-').map(BigInt); + if (time < previousTime || (time === previousTime && sequence <= previousSequence)) return state; + } + const envelope = object(data); + if (envelope.run_id && envelope.run_id !== state.runId) return state; + const next = { ...state, cursor: /^\d+-\d+$/.test(cursor) ? cursor : state.cursor }; + if (envelope.thread_id && envelope.thread_id !== threadId) return next; + const payload = object(envelope.payload); + const chunks = Array.isArray(payload.items) ? payload.items : [payload.chunk]; + let messages = state.messages; + for (const value of chunks) { + const chunk = object(value); + const event = object(chunk.stream_event); + if ((chunk.thread_id && chunk.thread_id !== threadId) || (event.thread_id && event.thread_id !== threadId)) continue; + if (['message_delta', 'tool_call', 'tool_call_delta'].includes(String(event.type))) { + const id = typeof event.message_id === 'string' ? event.message_id + : messages.filter(message => message.run_id === state.runId).at(-1)?.message_id ?? state.runId + ':message'; + const current = messages.find(message => message.message_id === id && message.run_id === state.runId) + ?? { message_id: id, run_id: state.runId, content: '', reasoning_content: '', tool_calls: [] }; + const updated = { ...current }; + if (event.type === 'message_delta') { + updated.content += typeof event.content === 'string' ? event.content : ''; + updated.reasoning_content += typeof event.reasoning_content === 'string' ? event.reasoning_content + : typeof event.additional_reasoning_content === 'string' ? event.additional_reasoning_content : ''; + } else { + const index = typeof event.index === 'number' ? event.index : 0; + const existing = current.tool_calls.find(tool => (event.tool_call_id && tool.id === event.tool_call_id) || tool.index === index); + const tool = { ...existing, id: typeof event.tool_call_id === 'string' ? event.tool_call_id : existing?.id ?? id + ':' + index, + name: typeof event.name === 'string' ? event.name : existing?.name ?? '工具', + display_name: typeof event.display_name === 'string' ? event.display_name : existing?.display_name, + display_description: typeof event.display_description === 'string' ? event.display_description : existing?.display_description, + args: existing && existing.status !== 'pending' ? existing.args + : event.type === 'tool_call' ? event.args ?? {} : String(existing?.args ?? '') + String(event.args_delta ?? ''), + status: existing?.status ?? 'pending', index }; + updated.tool_calls = existing ? current.tool_calls.map(item => item === existing ? tool : item) : [...current.tool_calls, tool]; + } + messages = messages.includes(current) + ? messages.map(message => message === current ? updated : message) : [...messages, updated]; + } + const toolEvent = object(chunk.event); + const result = object(toolEvent.data); + if (toolEvent.method === 'tools' && typeof result.tool_call_id === 'string') { + messages = messages.map(message => ({ ...message, tool_calls: message.tool_calls.map(tool => { + if (message.run_id !== state.runId || tool.id !== result.tool_call_id) return tool; + tool = { ...tool, + display_name: typeof result.display_name === 'string' ? result.display_name : tool.display_name, + display_description: typeof result.display_description === 'string' ? result.display_description : tool.display_description }; + if (result.event === 'tool-started') return { ...tool, args: result.input ?? tool.args, status: 'running' }; + if (result.event !== 'tool-finished') return tool; + const output = object(result.output); + const error = result.error || (output.status === 'error' ? output.content : null); + return { ...tool, status: error ? 'error' : 'success', + error_message: error ? (typeof error === 'string' ? error : JSON.stringify(error)) : null, + tool_call_result: { content: output.content ?? result.output ?? '' } }; + }) })); + } + } + return { ...next, messages }; +} + +export interface CloudChatStream extends CloudStream { threadId: string; requestId?: string; - cursor: string; - content: string; - reasoning: string; - messageId: string; - reasoningMessageId: string; ended: boolean; - toolName: string; } -const object = (value: unknown): Record => value && typeof value === 'object' && !Array.isArray(value) - ? value as Record : {}; -const validCursor = (value: string) => /^\d+-\d+$/.test(value); -function newerCursor(next: string, previous: string) { - const [nextTime, nextIndex] = next.split('-').map(BigInt); - const [previousTime, previousIndex] = previous.split('-').map(BigInt); - return nextTime > previousTime || nextTime === previousTime && nextIndex > previousIndex; -} - -/** Project only the subscribed Run's main-thread messages; Redis IDs survive reconnects. */ +/** Keep transport identity and continuation state around the message-level projection. */ export function consumeCloudChatEvent(previous: CloudChatStream | null, value: unknown, type: string, eventId: string, runId: string, threadId: string, requestId?: string): CloudChatStream | null { const envelope = object(value); if (envelope.run_id !== runId || requestId && envelope.request_id && envelope.request_id !== requestId) return previous; const continuation = previous?.threadId === threadId && requestId && previous.requestId === requestId; const current: CloudChatStream = previous?.runId === runId && previous.threadId === threadId ? previous : { - runId, threadId, requestId, cursor: '0-0', content: '', reasoning: continuation ? previous.reasoning : '', - messageId: '', reasoningMessageId: continuation ? previous.reasoningMessageId : '', ended: false, toolName: '', + runId, threadId, requestId, cursor: '0-0', messages: continuation ? previous.messages : [], ended: false, }; - const fresh = validCursor(eventId) && newerCursor(eventId, current.cursor); - // A synthesized terminal event can reuse the last message's Redis ID. - if (type === 'end' && envelope.thread_id === threadId) return { ...current, ended: true, cursor: fresh ? eventId : current.cursor }; - if (validCursor(eventId) && !fresh) return previous; - const next = { ...current, cursor: fresh ? eventId : current.cursor }; - // Child events still advance the replay cursor, but never become visible text. - if (envelope.thread_id !== threadId) return next; - const payload = object(envelope.payload); - for (const item of Array.isArray(payload.items) ? payload.items : payload.chunk ? [payload.chunk] : []) { - const event = object(object(item).stream_event); - if (event.type === 'tool_call' && typeof event.name === 'string') next.toolName = event.name; - if (event.type !== 'message_delta') continue; - const id = typeof event.message_id === 'string' ? event.message_id : next.messageId; - const reasoning = typeof event.reasoning_content === 'string' ? event.reasoning_content - : typeof event.additional_reasoning_content === 'string' ? event.additional_reasoning_content : ''; - if (reasoning) { - next.reasoning += (next.reasoning && next.reasoningMessageId && id !== next.reasoningMessageId ? '\n\n' : '') + reasoning; - next.reasoningMessageId = id; - } - if (typeof event.content === 'string' && event.content) { - next.content += (next.content && next.messageId && id !== next.messageId ? '\n\n' : '') + event.content; - next.messageId = id; - } - } - return next; + // Child events advance the cursor without becoming visible messages. + const projected = reduceCloudStream(current, envelope.thread_id === threadId ? envelope : { ...envelope, payload: {} }, threadId, eventId); + // Terminal events may reuse the last message cursor. + if (type === 'end' && envelope.thread_id === threadId) return { ...current, ...projected, ended: true }; + return projected === current ? previous : { ...current, ...projected }; } diff --git a/src/pages/CloudAgents/index.tsx b/src/pages/CloudAgents/index.tsx index 8bc3a94d..72b217bd 100644 --- a/src/pages/CloudAgents/index.tsx +++ b/src/pages/CloudAgents/index.tsx @@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { cloudAgentsApi } from '@/lib/cloud-agents-api'; +import { AppError } from '@/lib/error-model'; import { useAuthStore } from '@/stores/auth'; import { type CloudAgentAvatar, type CloudAgentDraft, type CreateCloudAgent, type CloudAgentEntry, type CloudThread, type CloudRecent, type CloudPendingOperation } from '../../../shared/cloud-agents'; import { DraftEditor } from './DraftEditor'; @@ -164,7 +165,7 @@ function AgentWorkspace() { if (shared || activity) return
{recovery} { setShared(null); setActivity(null); clearRecent(); }} />
; if (creating) return
{recovery} -
{ setStartingPrompt(''); setEntryView('configuration'); accepted(agent); }} +
{ setStartingPrompt(''); setEntryView('configuration'); accepted(agent); }} onSettled={refreshRecovery} onCancel={() => { setCreating(false); void load(); }} />
; @@ -242,7 +243,9 @@ function AgentWorkspace() { ); } -function CreateAgentForm({ onCreated, onCancel }: { onCreated: (agent: CloudAgentDraft) => void; onCancel: () => void }) { +function CreateAgentForm({ onCreated, onCancel, onSettled }: { + onCreated: (agent: CloudAgentDraft) => void; onCancel: () => void; onSettled: () => void; +}) { const [name, setName] = useState(''); const [avatar, setAvatar] = useState({ icon: null, icon_url: null }); const [uploading, setUploading] = useState(false); @@ -250,6 +253,7 @@ function CreateAgentForm({ onCreated, onCancel }: { onCreated: (agent: CloudAgen const [busy, setBusy] = useState(false); const [error, setError] = useState(''); const alive = useRef(true); + const uncertainOperation = useRef(null); const hasInput = Boolean(name || avatar.icon); const blocker = useBlocker(busy || uploading || intent !== null || hasInput); useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []); @@ -271,9 +275,19 @@ function CreateAgentForm({ onCreated, onCancel }: { onCreated: (agent: CloudAgen const agent = await cloudAgentsApi.create(input); if (alive.current) onCreated(agent); } catch (failure) { - if (alive.current) setError(errorMessage(failure)); + if (alive.current) { + if (failure instanceof AppError && failure.details?.commandOutcome === 'definitive_failure' + && uncertainOperation.current !== input.operation_id) { + setIntent(null); + setError('创建失败:' + errorMessage(failure)); + } else { + // Discarding the local reminder cannot rule out acceptance of an earlier attempt. + uncertainOperation.current = input.operation_id; + setError('创建结果暂未确认,可重试确认结果。' + errorMessage(failure)); + } + } } finally { - if (alive.current) setBusy(false); + if (alive.current) { setBusy(false); onSettled(); } } }; @@ -285,7 +299,7 @@ function CreateAgentForm({ onCreated, onCancel }: { onCreated: (agent: CloudAgen - {error &&

{error}。可重试确认本次创建结果。

} + {error &&

{error}

}
diff --git a/tests/e2e/cloud-agent-chat-layout.spec.ts b/tests/e2e/cloud-agent-chat-layout.spec.ts index 08973620..c55bc298 100644 --- a/tests/e2e/cloud-agent-chat-layout.spec.ts +++ b/tests/e2e/cloud-agent-chat-layout.spec.ts @@ -1,5 +1,5 @@ import { test, expect, type Page } from '@playwright/test'; -import { createServer, type ViteDevServer } from 'vite'; +import { createServer, normalizePath, type ViteDevServer } from 'vite'; import { resolve } from 'node:path'; import type { CloudAttachment, CloudHistory, CloudInterrupt, CloudRun, CloudThread } from '../../shared/cloud-agents'; @@ -11,7 +11,7 @@ test.use({ headless: true, channel: process.env.MAKELORE_LAYOUT_BROWSER_CHANNEL, // the event stream are isolated fixtures; this suite never starts a native app // or contacts an account, cloud service or model. test.beforeAll(async () => { - const entry = resolve('tests/e2e/__cloud-agent-chat-layout-harness.tsx'); + const entry = normalizePath(resolve('tests/e2e/__cloud-agent-chat-layout-harness.tsx')); server = await createServer({ configFile: false, cacheDir: 'node_modules/.vite-cloud-agent-chat-layout', @@ -422,7 +422,7 @@ for (const viewport of [{ width: 375, height: 540 }, { width: 1440, height: 900 test(`reasoning streams separately and becomes collapsed durable history at ${viewport.width}×${viewport.height}`, async ({ page }) => { const errors = await openChat(page, 'thinking', viewport.width, viewport.height); const transcript = page.getByTestId('cloud-chat-messages'); - const thinking = page.getByTestId('cloud-chat-reasoning'); + const thinking = page.getByTestId('cloud-message-process'); const envelope = { thread_id: 'story-first', run_id: 'run-story-first' }; const prefix = '先观察故事里的小发现。\n'; const reasoning = prefix + Array.from({ length: 40 }, (_, index) => diff --git a/tests/e2e/cloud-agent-home.spec.ts b/tests/e2e/cloud-agent-home.spec.ts index 2aa2cc94..b0db44d6 100644 --- a/tests/e2e/cloud-agent-home.spec.ts +++ b/tests/e2e/cloud-agent-home.spec.ts @@ -151,7 +151,7 @@ test('Agent cards open conversation, adjustment and unfinished creation explicit await ready.getByRole('button', { name: '和它聊聊', exact: true }).click(); await expect(page.getByRole('region', { name: '智能体对话', exact: true })).toBeVisible(); await expect(page.getByTestId('cloud-agent-editor').getByRole('button', { name: '聊天', exact: true })).toHaveCount(0); - await page.getByTestId('cloud-agent-editor').getByRole('button', { name: '我的智能体', exact: true }).click(); + await page.getByTestId('cloud-agent-editor').getByRole('button', { name: '返回智能体列表', exact: true }).click(); await expect(ready).toBeVisible(); await ready.getByRole('button', { name: '调整', exact: true }).click(); await expect(page.getByRole('tab', { name: '介绍与提示词', exact: true })).toHaveAttribute('aria-selected', 'true'); diff --git a/tests/e2e/cloud-agent-knowledge.spec.ts b/tests/e2e/cloud-agent-knowledge.spec.ts index 9389f2b0..31eee198 100644 --- a/tests/e2e/cloud-agent-knowledge.spec.ts +++ b/tests/e2e/cloud-agent-knowledge.spec.ts @@ -1,20 +1,36 @@ import { test, expect, type Page } from '@playwright/test'; -import { createServer, type ViteDevServer } from 'vite'; -import { resolve } from 'node:path'; -import { EMPTY_CLOUD_CONFIGURATION, type CloudAgentDraft, type CloudKnowledge, type CloudKnowledgeFile } from '../../shared/cloud-agents'; +import { build, normalizePath } from 'vite'; +import { createServer, type Server } from 'node:http'; +import { mkdtemp, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +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'; -let server: ViteDevServer; +let server: Server; let baseUrl: string; +let fixtureDir: string; +const fixtureRequire = createRequire(resolve('tests/e2e/cloud-agent-knowledge.spec.ts')); +const viteDependencies = createRequire(fixtureRequire.resolve('vite/package.json')); test.use({ headless: true, channel: process.env.MAKELORE_LAYOUT_BROWSER_CHANNEL, video: 'off' }); // Render the real editor while replacing only the Main IPC boundary. No Electron, // native picker, real upload or cloud request is started by this suite. test.beforeAll(async () => { - const entry = resolve('tests/e2e/__cloud-agent-knowledge-harness.tsx'); - server = await createServer({ - configFile: false, cacheDir: 'node_modules/.vite-cloud-agent-knowledge', + test.setTimeout(90_000); + fixtureDir = await realpath(await mkdtemp(join(tmpdir(), 'makelore-knowledge-fixture-'))); + await symlink(resolve('node_modules'), join(fixtureDir, 'node_modules'), 'dir'); + const html = join(fixtureDir, 'index.html'); + const output = join(fixtureDir, 'dist'); + await writeFile(html, '
'); + const entry = normalizePath(resolve('tests/e2e/__cloud-agent-knowledge-harness.tsx')); + // Build the virtual entry once. A static fixture needs no Vite dev server, + // background dependency discovery, file watcher, or HMR connection. + await build({ + configFile: false, root: fixtureDir, publicDir: false, logLevel: 'warn', resolve: { alias: { '@': resolve('src') } }, esbuild: { jsx: 'automatic' }, - server: { host: '127.0.0.1', port: 0 }, + css: { postcss: resolve('.') }, + build: { outDir: output, emptyOutDir: true, rollupOptions: { input: html } }, plugins: [{ name: 'isolated-cloud-agent-knowledge', enforce: 'pre', resolveId(id) { if (id === '/__cloud-agent-knowledge-harness.tsx') return entry; }, @@ -22,25 +38,37 @@ test.beforeAll(async () => { if (id === entry) return ` import { createRoot } from 'react-dom/client'; import { createMemoryRouter, RouterProvider } from 'react-router-dom'; - import CloudAgents from '/src/pages/CloudAgents/index.tsx'; - import '/src/styles/globals.css'; + import CloudAgents from ${JSON.stringify(normalizePath(resolve('src/pages/CloudAgents/index.tsx')))}; + import ${JSON.stringify(normalizePath(resolve('src/styles/globals.css')))}; const router = createMemoryRouter([{ path: '/cloud-agents', element: }], { initialEntries: ['/cloud-agents?view=mine'] }); createRoot(document.getElementById('root')).render(
); `; if (id.endsWith('/src/stores/auth.ts')) return 'export function useAuthStore(select) { return select({ user: { userId: "knowledge-fixture" } }); }'; }, - configureServer(vite) { - vite.middlewares.use((req, res, next) => { - if (req.url !== '/' && !req.url?.startsWith('/?')) return next(); - res.setHeader('Content-Type', 'text/html'); - res.end('
'); - }); - }, }], }); - await server.listen(); baseUrl = server.resolvedUrls!.local[0]; + const mime: Record = { html: 'text/html', js: 'text/javascript', css: 'text/css', + svg: 'image/svg+xml', png: 'image/png', woff2: 'font/woff2', woff: 'font/woff' }; + server = createServer(async (req, res) => { + const pathname = decodeURIComponent(new URL(req.url ?? '/', 'http://127.0.0.1').pathname); + const file = resolve(output, '.' + (pathname === '/' ? '/index.html' : pathname)); + if (!file.startsWith(output + sep)) { res.writeHead(403); res.end(); return; } + try { const bytes = await readFile(file); res.setHeader('Content-Type', mime[file.split('.').pop()!] ?? 'application/octet-stream'); res.end(bytes); } + catch { res.writeHead(404); res.end(); } + }); + await new Promise((done, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', done); }); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Fixture HTTP address is unavailable'); + baseUrl = `http://127.0.0.1:${address.port}`; +}); +test.afterAll(async () => { + try { + if (server?.listening) { server.closeAllConnections(); await new Promise((done, reject) => server.close(error => error ? reject(error) : done())); } + } finally { + try { if (fixtureDir) await rm(fixtureDir, { recursive: true, force: true }); } + finally { (viteDependencies('esbuild') as { stop(): void }).stop(); } + } }); -test.afterAll(async () => { await server?.close(); }); const agent: CloudAgentDraft = { slug: 'ml-' + 'a'.repeat(32), name: '资料小助手', purpose: '用我的资料回答问题。', @@ -54,20 +82,37 @@ 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 }) => { + await page.addInitScript(({ agent, libraries, sourceThreads, sourceAttachments }) => { 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)); @@ -113,6 +158,19 @@ 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('处理请求暂不可用'); @@ -125,7 +183,7 @@ async function installFixture(page: Page) { } }, } } }); - }, { agent, libraries }); + }, { agent, libraries, sourceThreads, sourceAttachments }); } async function openKnowledge(page: Page) { @@ -150,6 +208,36 @@ async function expectNoClipping(page: Page) { }); expect(clipped).toEqual([]); } +async function expectKnowledgeLayout(page: Page, width: number) { + const layout = page.locator('.agent-knowledge-workspace'); + const upload = await layout.locator('.agent-knowledge-upload').boundingBox(); + const selection = await layout.locator('.agent-knowledge-selection').boundingBox(); + expect(upload).not.toBeNull(); expect(selection).not.toBeNull(); + if (width >= 1024) { + expect(upload!.x + upload!.width).toBeLessThanOrEqual(selection!.x + 1); + expect(Math.abs(upload!.y - selection!.y)).toBeLessThanOrEqual(1); + } else { + expect(upload!.y + upload!.height).toBeLessThanOrEqual(selection!.y + 1); + expect(Math.abs(upload!.x - selection!.x)).toBeLessThanOrEqual(1); + } + 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 }) => { @@ -168,11 +256,11 @@ for (const width of [375, 1440]) { await expect(selected.getByRole('button', { name: '移除知识库 科学知识', exact: true })).toBeVisible(); await expect(selected.getByRole('button', { name: /上传|删除|处理|替换/ })).toHaveCount(0); expect((await calls(page)).filter(call => call.method === 'PATCH' || call.path.endsWith('/knowledge/pick'))).toEqual([]); - await knowledge.getByText('上传自己的文件', { exact: true }).click(); + 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 expectNoClipping(page); await page.screenshot({ path: test.info().outputPath(`knowledge-shared-${width}.png`), animations: 'disabled' }); + await expect(knowledge.getByRole('button', { name: '从电脑上传', exact: true })).toHaveCount(0); + 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(); expect((await calls(page)).filter(call => call.method === 'PATCH').map(call => (call.input?.configuration as { knowledges: string[] }).knowledges)) @@ -186,7 +274,7 @@ for (const width of [375, 1440]) { await page.setViewportSize({ width, height: 850 }); await installFixture(page); await page.goto(baseUrl); const { editor, knowledge } = await openKnowledge(page); await knowledge.getByRole('combobox', { name: '选择知识库', exact: true }).selectOption('kb_fixturescience'); - await knowledge.getByText('上传自己的文件', { exact: true }).click(); + 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(); await knowledge.getByRole('textbox', { name: '知识库名称', exact: true }).fill('我的春天资料'); @@ -195,7 +283,7 @@ for (const width of [375, 1440]) { await expect(knowledge.getByRole('combobox', { name: '管理的知识库', exact: true })).toHaveValue('kb_fixturecreated'); await expect(knowledge.getByRole('list', { name: '已选知识库', exact: true })).toContainText('我的春天资料'); expect((await calls(page)).filter(call => call.path.endsWith('/knowledge/pick') || call.input?.operation === 'processKnowledge')).toEqual([]); - await knowledge.getByRole('button', { name: '上传文档', exact: true }).click(); + await knowledge.getByRole('button', { name: '从电脑上传', exact: true }).click(); const document = knowledge.getByRole('article', { name: '春天观察记录.md', exact: true }); await expect(document.getByText('等待处理', { exact: true })).toBeVisible(); const mutations = (await calls(page)).filter(call => call.path.endsWith('/knowledge/pick') || call.input?.operation === 'processKnowledge'); @@ -210,7 +298,7 @@ for (const width of [375, 1440]) { await expect(document.getByText('可用于回答', { exact: true })).toBeVisible({ timeout: 12_000 }); await expect(document.getByText('2 KB · 6 段可检索内容', { exact: true })).toBeVisible(); await document.scrollIntoViewIfNeeded(); - await expectNoClipping(page); await page.screenshot({ path: test.info().outputPath(`knowledge-upload-ready-${width}.png`), animations: 'disabled' }); + await expectKnowledgeLayout(page, width); await page.screenshot({ path: test.info().outputPath(`knowledge-upload-ready-${width}.png`), animations: 'disabled' }); // Document mutations never implicitly save the Agent draft. expect((await calls(page)).filter(call => call.method === 'PATCH')).toEqual([]); await editor.getByRole('button', { name: '保存草稿', exact: true }).click(); @@ -218,6 +306,52 @@ 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 }) => { @@ -227,15 +361,15 @@ test('cancelled and failed uploads preserve shared choices and the personal draf await editor.getByRole('textbox', { name: '提示词', exact: true }).fill('这是我还没有保存的提示词。'); await editor.getByRole('tab', { name: '高级设置', exact: true }).click(); await knowledge.getByRole('combobox', { name: '选择知识库', exact: true }).selectOption('kb_fixturescience'); - await knowledge.getByText('上传自己的文件', { exact: true }).click(); + await expect(knowledge.getByRole('heading', { name: '上传自己的文件', exact: true })).toBeVisible(); await knowledge.getByRole('combobox', { name: '管理的知识库', exact: true }).selectOption('kb_fixturepersonal'); await page.evaluate(() => { (window as unknown as FixtureWindow).__knowledgeFixture.upload = 'cancel'; }); - await knowledge.getByRole('button', { name: '上传文档', exact: true }).click(); - await expect(knowledge.getByRole('button', { name: '上传文档', exact: true })).toBeEnabled(); + await knowledge.getByRole('button', { name: '从电脑上传', exact: true }).click(); + await expect(knowledge.getByRole('button', { name: '从电脑上传', exact: true })).toBeEnabled(); await expect(knowledge.getByRole('article')).toHaveCount(0); expect((await calls(page)).filter(call => call.input?.operation === 'processKnowledge')).toEqual([]); await page.evaluate(() => { (window as unknown as FixtureWindow).__knowledgeFixture.upload = 'fail'; }); - await knowledge.getByRole('button', { name: '上传文档', exact: true }).click(); + await knowledge.getByRole('button', { name: '从电脑上传', exact: true }).click(); await expect(knowledge.getByRole('alert')).toContainText('上传服务暂不可用'); await expect(knowledge.getByRole('combobox', { name: '管理的知识库', exact: true })).toHaveValue('kb_fixturepersonal'); await expect(knowledge.getByRole('list', { name: '已选知识库', exact: true })).toContainText('科学知识'); @@ -255,3 +389,93 @@ 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/e2e/cloud-agent-navigation-layout.spec.ts b/tests/e2e/cloud-agent-navigation-layout.spec.ts index 733c74b9..4d138f87 100644 --- a/tests/e2e/cloud-agent-navigation-layout.spec.ts +++ b/tests/e2e/cloud-agent-navigation-layout.spec.ts @@ -1,5 +1,5 @@ import { test, expect, type Page } from '@playwright/test'; -import { createServer, type ViteDevServer } from 'vite'; +import { createServer, normalizePath, type ViteDevServer } from 'vite'; import { resolve } from 'node:path'; let server: ViteDevServer; @@ -9,7 +9,7 @@ test.use({ headless: true, channel: process.env.MAKELORE_LAYOUT_BROWSER_CHANNEL, // Exercise actual navigation controls and persisted layout state without // starting Electron or connecting to an account or any cloud service. test.beforeAll(async () => { - const entry = resolve('tests/e2e/__cloud-agent-navigation-layout-harness.tsx'); + const entry = normalizePath(resolve('tests/e2e/__cloud-agent-navigation-layout-harness.tsx')); server = await createServer({ configFile: false, cacheDir: 'node_modules/.vite-cloud-agent-navigation-layout', diff --git a/tests/e2e/cloud-agent-platform-avatars.spec.ts b/tests/e2e/cloud-agent-platform-avatars.spec.ts index 144fd2f7..54865fb6 100644 --- a/tests/e2e/cloud-agent-platform-avatars.spec.ts +++ b/tests/e2e/cloud-agent-platform-avatars.spec.ts @@ -1,5 +1,5 @@ import { test, expect, type Page } from '@playwright/test'; -import { createServer, type ViteDevServer } from 'vite'; +import { createServer, normalizePath, type ViteDevServer } from 'vite'; import { resolve } from 'node:path'; import { EMPTY_CLOUD_CONFIGURATION, type CloudAgentDraft } from '../../shared/cloud-agents'; @@ -10,7 +10,7 @@ test.use({ headless: true, channel: process.env.MAKELORE_LAYOUT_BROWSER_CHANNEL, // Render the real workspace and keep all service calls at the Main IPC boundary. // This suite never starts Electron, opens a native file picker or calls a live service. test.beforeAll(async () => { - const entry = resolve('tests/e2e/__cloud-agent-platform-avatars-harness.tsx'); + const entry = normalizePath(resolve('tests/e2e/__cloud-agent-platform-avatars-harness.tsx')); server = await createServer({ configFile: false, cacheDir: 'node_modules/.vite-cloud-agent-platform-avatars', diff --git a/tests/e2e/cloud-agents.spec.ts b/tests/e2e/cloud-agents.spec.ts index bb52dbe4..5b622356 100644 --- a/tests/e2e/cloud-agents.spec.ts +++ b/tests/e2e/cloud-agents.spec.ts @@ -18,8 +18,10 @@ test('personal cloud Agent identity, configuration and separate published chat i let draft: Record | null = null; let recent: unknown = null; let createInput: Record | null = null; + const creationInputs: Record[] = []; + let pending: Record[] = []; const operations: string[] = []; - Reflect.set(globalThis, '__cloudAgentUiFixture', { snapshot: () => ({ draft, createInput, operations }) }); + Reflect.set(globalThis, '__cloudAgentUiFixture', { snapshot: () => ({ draft, createInput, creationInputs, operations }) }); const versions: { version: number; draft_revision: number; created_at: string }[] = []; let knowledgeCatalogLoads = 0; let knowledgeCreated = false; @@ -51,14 +53,15 @@ test('personal cloud Agent identity, configuration and separate published chat i 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/pending') && body.discard === true) { pending = []; return result({ discarded: true }); } if (path.endsWith('/recovery/recent')) { recent = body; return result({ saved: true }); } if (path.endsWith('/actions')) { operations.push(body.operation); const input = body.input ?? {}; if (body.operation === 'catalog') return result({ models: [{ id: 'test-model', name: '创作模型', description: '模型说明不应显示在配置页' }, { id: 'reasoning-model', name: '思考模型' }], - resources: { tools: [{ key: 'fixture-tool', name: '资料整理工具' }], skills: [{ key: 'fixture-skill', name: '文章整理 Skill' }] }, + resources: { tools: [{ key: 'fixture-tool', name: '资料整理工具' }, { key: 'web_search', name: '上网查资料', description: '在网上查找相关信息。' }], skills: [{ key: 'fixture-skill', name: '文章整理 Skill' }, { key: 'deep-research', name: '查资料,做研究', description: '比较不同说法,再整理出结论和出处。' } ] }, pricing: { unit: '词元点数', usage_unit: '词元', unit_size: 1000, points_per_unit: '9.99', version: 1 }, }); if (body.operation === 'knowledge') return result({ databases: knowledgeCreated ? [{ kb_id: 'kb', name: '我的创作资料' }] : [], models: ++knowledgeCatalogLoads === 1 ? [] : [{ id: 'fixture:test-embedding', name: '资料向量模型', dimension: 1024 }] }); @@ -98,7 +101,19 @@ test('personal cloud Agent identity, configuration and separate published chat i if (body.operation === 'files') return result({ files: [] }); throw new Error('Unexpected cloud fixture operation ' + body.operation); } - if (method === 'POST') { createInput = body; draft = { + if (method === 'POST') { + creationInputs.push(body); + if (creationInputs.length === 1) return { ok: true, data: { status: 422, ok: false, + json: { error: '名称没有被接受,请修改后重试', code: 'invalid_name', commandOutcome: 'definitive_failure' } } }; + if (creationInputs.length === 2) { + pending = [{ id: `createAgent::${body.operation_id}`, operation: 'createAgent', input: body, created_at: new Date().toISOString() }]; + return { ok: true, data: { status: 502, ok: false, + json: { error: '暂时无法确认创建结果', code: 'cloud_service_unavailable', commandOutcome: 'unknown' } } }; + } + if (creationInputs.length === 3) return { ok: true, data: { status: 422, ok: false, + json: { error: '名称没有被接受,请稍后重试', code: 'invalid_name', commandOutcome: 'definitive_failure' } } }; + pending = []; + createInput = body; draft = { slug: 'ml-' + 'a'.repeat(32), name: body.name, purpose: body.purpose, system_prompt: body.purpose, icon: body.icon ?? null, icon_url: body.icon === uploadedAvatar.icon ? uploadedAvatar.icon_url : null, draft_revision: 1, updated_at: '2026-09-10T00:00:00Z', @@ -161,11 +176,26 @@ test('personal cloud Agent identity, configuration and separate published chat i await expect(page.getByRole('button', { name: '上一步', exact: true })).toHaveCount(0); await expect(page.getByRole('button', { name: '下一步', exact: true })).toBeDisabled(); await page.screenshot({ path: testInfo.outputPath('cloud-agent-create-empty-form.png') }); - await page.getByLabel('名称', { exact: true }).fill('我的写作搭档'); + await page.getByLabel('名称', { exact: true }).fill('待修改的名字'); await page.getByRole('button', { name: '上传头像', exact: true }).click(); - await expect(page.getByRole('img', { name: '我的写作搭档头像', exact: true })).toHaveAttribute('src', avatar.icon_url); + await expect(page.getByRole('img', { name: '待修改的名字头像', exact: true })).toHaveAttribute('src', avatar.icon_url); await page.screenshot({ path: testInfo.outputPath('cloud-agent-create-identity.png') }); await page.getByRole('button', { name: '下一步', exact: true }).click(); + await expect(page.getByRole('alert')).toContainText('创建失败'); + await expect(page.getByLabel('名称', { exact: true })).toBeEnabled(); + await expect(page.getByRole('button', { name: '更换头像', exact: true })).toBeEnabled(); + await expect(page.getByRole('complementary', { name: '未确认的云端操作' })).toHaveCount(0); + await page.getByLabel('名称', { exact: true }).fill('我的写作搭档'); + await page.getByRole('button', { name: '下一步', exact: true }).click(); + await expect(page.getByLabel('名称', { exact: true })).toBeDisabled(); + await expect(page.getByRole('button', { name: '更换头像', exact: true })).toBeDisabled(); + await expect(page.getByRole('complementary', { name: '未确认的云端操作' })).toBeVisible(); + await page.getByRole('button', { name: '移除提醒', exact: true }).click(); + await expect(page.getByRole('complementary', { name: '未确认的云端操作' })).toHaveCount(0); + await page.getByRole('button', { name: '重试创建', exact: true }).click(); + await expect(page.getByRole('alert')).toContainText('创建结果暂未确认'); + await expect(page.getByLabel('名称', { exact: true })).toBeDisabled(); + await page.getByRole('button', { name: '重试创建', exact: true }).click(); const editor = page.getByTestId('cloud-agent-editor'); const instructionsTab = page.getByRole('tab', { name: '介绍与提示词', exact: true }); @@ -189,6 +219,10 @@ test('personal cloud Agent identity, configuration and separate published chat i const created = await fixtureState(); expect(created.createInput).toMatchObject({ name: '我的写作搭档', icon: avatar.icon, purpose: '' }); expect(created.createInput).not.toHaveProperty('icon_url'); + expect(created.creationInputs).toHaveLength(4); + expect(created.creationInputs[0].operation_id).not.toBe(created.creationInputs[1].operation_id); + expect(created.creationInputs[1]).toEqual(created.creationInputs[2]); + expect(created.creationInputs[1]).toEqual(created.creationInputs[3]); expect(await messageOperations()).toEqual([]); await page.getByRole('button', { name: '修改头像', exact: true }).click(); @@ -214,7 +248,11 @@ test('personal cloud Agent identity, configuration and separate published chat i await expect(page.getByRole('heading', { name: '费用上限', exact: true })).toHaveCount(0); await expect(page.getByRole('heading', { name: '运行限制', exact: true })).toHaveCount(0); await page.getByRole('button', { name: '选择工具箱', exact: true }).click(); + await expect(page.getByRole('checkbox', { name: /上网查资料.*在网上查找相关信息/ })).toBeVisible(); await page.getByRole('checkbox', { name: '资料整理工具', exact: true }).check(); + await page.getByRole('button', { name: '选择做事手册', exact: true }).click(); + await page.getByLabel('搜索做事手册', { exact: true }).fill('出处'); + await expect(page.getByRole('checkbox', { name: /查资料,做研究.*出处/ })).toBeVisible(); await instructionsTab.click(); await expect(introduction).toHaveValue('一起梳理故事和文章'); await expect(prompt).toHaveValue(initialPrompt); diff --git a/tests/e2e/cloud-chat-rendering.spec.ts b/tests/e2e/cloud-chat-rendering.spec.ts new file mode 100644 index 00000000..38f2151a --- /dev/null +++ b/tests/e2e/cloud-chat-rendering.spec.ts @@ -0,0 +1,101 @@ +import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron'; + +test('personal chat displays thinking, tools and Markdown, then restores them after reload', async ({ launchElectronApp }, testInfo) => { + const app = await launchElectronApp({ skipSetup: true }); + try { + const page = await getStableWindow(app); + await expect(page.getByTestId('ai-module-selection-page')).toBeVisible(); + const applicationUrl = page.url(); + await page.goto('about:blank'); + // Cloud provider is deterministic here; the real history HTTP/PG boundary is tested in Yuxi. + await app.evaluate(({ ipcMain, BrowserWindow }) => { + BrowserWindow.getAllWindows()[0].setSize(1180, 820); + let complete = false; + Object.assign(globalThis, { finishRenderRun: () => { complete = true; } }); + const slug = 'ml-' + 'a'.repeat(32); + const configuration = { model: 'test', tools: [], knowledges: [], mcps: [], skills: [], preload_skills: [], subagents: [], tool_approval_mode: 'always_trust', max_execution_steps: 300, max_output_tokens: 4096, max_run_seconds: 600 }; + const draft = { slug, name: '渲染测试助手', purpose: '整理资料', system_prompt: '', configuration, + draft_revision: 1, published_version: 1, enabled: true, updated_at: '' }; + const run = { agent_run_id: 'render-run', request_id: 'render-request', thread_id: 'render-thread', agent_slug: slug, status: 'running', output: '', version: '1' }; + const thread = { thread_id: 'render-thread', client_thread_id: 'client', agent_slug: slug, title: '整理资料', mode: 'published', updated_at: '', run_id: run.agent_run_id, unread: false, status: 'running' }; + const result = (json: unknown) => ({ ok: true, data: { status: 200, ok: true, json } }); + ipcMain.removeHandler('hostapi:fetch'); + ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string; body?: string }) => { + const path = request.path ?? ''; + const body = request.body ? JSON.parse(request.body) : {}; + if (path === '/api/auth/session/sync') return result({ success: true, session: { accessToken: 'fixture', tokenType: 'Bearer', expiresAt: Date.now() + 3600000, lastActiveAt: Date.now(), canRefresh: false } }); + if (path === '/api/auth/me') return result({ success: true, user: { username: 'creator', userId: 'creator', tenantId: null, deptId: null, authorities: [] }, moduleAccess: { programming: true, design: true, robot: true, cloud_agents: true } }); + if (path === '/api/works/user/agent-profile') return result({ success: true, profile: { display_name: '创作者', completed: true, version: 1 } }); + if (path.endsWith('/recovery')) return result({ recent: null, pending: [] }); + if (path.endsWith('/recovery/recent')) return result({}); + if (path.endsWith('/agents') || path.endsWith('/bootstrap')) return result({ agents: [draft], next_cursor: null }); + if (path.endsWith('/actions')) { + if (body.operation === 'threads') return result({ threads: [thread], next_offset: null }); + if (body.operation === 'history') return result({ thread_id: thread.thread_id, run: { ...run, status: complete ? 'completed' : 'running' }, next_offset: null, messages: complete ? [{ + id: 1, message_id: 'answer', role: 'assistant', run_id: run.agent_run_id, request_id: run.request_id, created_at: '', + content: '## 整理结果\n\n| 项目 | 状态 |\n| --- | --- |\n| 资料 | 已核对 |\n\n**可以使用**', + reasoning_content: '先核对来源,再整理结果。', + tool_calls: [{ id: 'lookup', name: 'web_search', display_name: '上网查资料', display_description: '在网上查找相关信息。', args: { query: '实际检索资料' }, status: 'success', tool_call_result: { content: '找到两条资料' } }], + }] : [] }); + if (body.operation === 'run') return result({ ...run, status: complete ? 'completed' : 'running' }); + if (body.operation === 'viewed') return result({}); + if (body.operation === 'files') return result({ files: [] }); + if (body.operation === 'attachments') return result({ attachments: [] }); + if (body.operation === 'catalog') return result({ models: [{ id: 'test', name: '测试模型' }], resources: {}, pricing: null }); + if (body.operation === 'access') return result({ published_version: 1, enabled: true, versions: [], grants: [], applications: [], share_url: '', api_url: '' }); + if (body.operation === 'channelBindings') return result({ items: [], available_accounts: [] }); + return result({}); + } + if (path.startsWith('/api/cloud-agents/agents/')) return result(draft); + return result({ success: true }); + }); + }); + await page.addInitScript(() => { + const sources: EventTarget[] = []; + Object.assign(window, { renderSources: sources }); + class FixtureSource extends EventTarget { + onopen = null; onerror = null; close() {} + constructor(url: string) { super(); if (url.includes('/cloud-agents/runs/')) sources.push(this); } + } + Object.defineProperty(window, 'EventSource', { value: FixtureSource }); + }); + await page.goto(applicationUrl); + await page.getByTestId('ai-module-option-cloud_agents').click(); + await page.getByRole('article', { name: '渲染测试助手', exact: true }).getByRole('button', { name: '和它聊聊', exact: true }).click(); + const emit = async (items: unknown[], cursor: string) => page.evaluate(({ items, cursor }) => { + const sources = (window as unknown as { renderSources: EventTarget[] }).renderSources; + sources.at(-1)!.dispatchEvent(new MessageEvent('messages', { lastEventId: cursor, data: JSON.stringify({ run_id: 'render-run', thread_id: 'render-thread', payload: { items } }) })); + }, { items, cursor }); + await expect.poll(() => page.evaluate(() => (window as unknown as { renderSources: EventTarget[] }).renderSources.length)).toBeGreaterThan(0); + await emit([{ stream_event: { type: 'message_delta', message_id: 'answer', reasoning_content: '先核对来源,再整理结果。' } }], '100-1'); + const process = page.getByTestId('cloud-message-process'); + await expect(page.getByLabel('智能体思考过程')).toBeVisible(); + await emit([ + { stream_event: { type: 'tool_call', message_id: 'answer', tool_call_id: 'lookup', name: 'web_search', display_name: '上网查资料', display_description: '在网上查找相关信息。', args: { query: '测试资料' } } }, + { event: { method: 'tools', data: { event: 'tool-started', tool_call_id: 'lookup', input: { query: '实际检索资料' } } } }, + { event: { method: 'tools', data: { event: 'tool-finished', tool_call_id: 'lookup', output: { content: '找到两条资料' } } } }, + { stream_event: { type: 'message_delta', message_id: 'answer', content: '## 整理结果\n\n| 项目 | 状态 |\n| --- | --- |\n| 资料 | 已核对 |\n\n**可以使用**' } }, + ], '100-2'); + await expect(page.getByRole('heading', { name: '整理结果' })).toBeVisible(); + await expect(page.getByRole('table')).toBeVisible(); + await process.locator(':scope > summary').click(); + await process.getByText('上网查资料 · 已完成', { exact: true }).click(); + await expect(process.getByText('找到两条资料', { exact: true })).toBeVisible(); + await expect(process.getByText('在网上查找相关信息。', { exact: true })).toBeVisible(); + await expect(process.getByText('工具标识:web_search', { exact: true })).toBeVisible(); + await expect(process.getByText('"实际检索资料"', { exact: false })).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath('personal-chat-process.png') }); + await app.evaluate(() => (globalThis as unknown as { finishRenderRun: () => void }).finishRenderRun()); + await page.evaluate(() => (window as unknown as { renderSources: EventTarget[] }).renderSources.at(-1)!.dispatchEvent(new MessageEvent('end', { data: JSON.stringify({ run_id: 'render-run', thread_id: 'render-thread', payload: { status: 'completed' } }) }))); + await expect(page.getByRole('heading', { name: '整理结果' })).toHaveCount(1); + await page.reload(); + await page.getByRole('article', { name: '渲染测试助手', exact: true }).getByRole('button', { name: '和它聊聊', exact: true }).click(); + await expect(page.getByRole('heading', { name: '整理结果' })).toBeVisible(); + await page.getByTestId('cloud-message-process').locator(':scope > summary').click(); + await expect(page.getByLabel('智能体思考过程')).toHaveText('先核对来源,再整理结果。'); + await page.getByText('上网查资料 · 已完成', { exact: true }).click(); + await expect(page.getByText('在网上查找相关信息。', { exact: true })).toBeVisible(); + await expect(page.getByText('工具标识:web_search', { exact: true })).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath('personal-chat-reopened.png') }); + } finally { await closeElectronApp(app); } +}); diff --git a/tests/unit/cloud-agent-avatar-routes.test.ts b/tests/unit/cloud-agent-avatar-routes.test.ts index 7657c94f..3c4cb571 100644 --- a/tests/unit/cloud-agent-avatar-routes.test.ts +++ b/tests/unit/cloud-agent-avatar-routes.test.ts @@ -2,11 +2,13 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; import { Readable } from 'node:stream'; import { beforeEach, expect, it, vi } from 'vitest'; import { handleCloudAgentsRoutes } from '@electron/api/routes/cloud-agents'; +import { CloudAgentsError } from '@electron/services/cloud-agents'; -const service = vi.hoisted(() => ({ useAvatarPreset: vi.fn(), uploadAvatar: vi.fn() })); +const service = vi.hoisted(() => ({ useAvatarPreset: vi.fn(), uploadAvatar: vi.fn(), create: vi.fn() })); vi.mock('@electron/services/cloud-agents', () => ({ - CloudAgentsModule: class { useAvatarPreset = service.useAvatarPreset; uploadAvatar = service.uploadAvatar; }, - CloudAgentsError: class extends Error { constructor(readonly status: number, readonly code: string) { super(code); } }, + CloudAgentsModule: class { useAvatarPreset = service.useAvatarPreset; uploadAvatar = service.uploadAvatar; create = service.create; }, + CloudAgentsError: class extends Error { constructor(readonly status: number, readonly code: string, + readonly commandOutcome = 'unknown') { super(code); } }, })); beforeEach(() => vi.resetAllMocks()); @@ -35,3 +37,20 @@ it('keeps the existing file picker route and rejects malformed preset JSON', asy expect(service.useAvatarPreset).not.toHaveBeenCalled(); expect((await call('/avatars/preset', undefined, 'GET')).status).toBe(404); }); + +it.each(['definitive_failure', 'unknown'] as const)('preserves creation outcome %s across the Host API', async outcome => { + service.create.mockRejectedValue(new CloudAgentsError(422, 'invalid_input', outcome)); + expect(await call('/agents', JSON.stringify({ name: 'test' }))).toEqual({ + handled: true, status: 422, + body: { success: false, error: 'invalid_input', code: 'invalid_input', commandOutcome: outcome }, + }); +}); + +it('marks malformed creation JSON as rejected and unexpected failures as uncertain', async () => { + expect((await call('/agents', '{')).body.commandOutcome).toBe('definitive_failure'); + expect(service.create).not.toHaveBeenCalled(); + service.create.mockRejectedValue(new Error('private internal error')); + const result = await call('/agents', '{}'); + expect(result).toMatchObject({ status: 502, body: { code: 'cloud_service_unavailable', commandOutcome: 'unknown' } }); + expect(result.body.error).not.toContain('private'); +}); diff --git a/tests/unit/cloud-agents-main.test.ts b/tests/unit/cloud-agents-main.test.ts index df61c18c..dc1b8139 100644 --- a/tests/unit/cloud-agents-main.test.ts +++ b/tests/unit/cloud-agents-main.test.ts @@ -44,6 +44,141 @@ function moduleFor(fetchImpl: typeof fetch) { beforeEach(() => { vi.clearAllMocks(); journalRecords.clear(); clearWorksSquareSession(); login(); }); afterEach(() => { instances.splice(0).forEach((module) => module.dispose()); clearWorksSquareSession(); vi.useRealTimers(); }); +describe('creation failure recovery', () => { + const validation = (field = 'purpose', type = 'string_too_short') => ({ detail: [{ + loc: ['body', field], type, msg: 'private upstream text', input: 'private input', ctx: { secret: 'private context' }, + }] }); + + it('clears a first rejected creation and explains the empty-introduction service mismatch safely', async () => { + const transport = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json(validation(), 422)); + const module = moduleFor(transport); + await expect(module.create({ ...input, purpose: '' })).rejects.toMatchObject({ + status: 422, code: 'introduction_required', commandOutcome: 'definitive_failure', + message: '当前智能体服务尚不支持一句话介绍留空,请等待服务更新后重试', + }); + expect((await module.recovery()).pending).toEqual([]); + expect(JSON.parse(transport.mock.calls[1][1].body).purpose).toBe(''); + }); + + it('does not diagnose an empty introduction when a nonempty introduction is rejected as too short', async () => { + const module = moduleFor(vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json(validation(), 422))); + await expect(module.create(input)).rejects.toMatchObject({ code: 'introduction_too_short', commandOutcome: 'definitive_failure' }); + expect((await module.recovery()).pending).toEqual([]); + }); + + it.each([ + ['name', 'string_too_long', 'invalid_name'], ['purpose', 'string_too_long', 'invalid_introduction'], + ['icon', 'string_type', 'invalid_icon'], ['private-field', 'missing', 'invalid_input'], + ])('maps request validation for %s to a fixed product message', async (field, type, code) => { + const transport = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json(validation(field, type), 422)); + const module = moduleFor(transport); + const error = await module.create(input).catch(failure => failure); + expect(error).toMatchObject({ code, commandOutcome: 'definitive_failure' }); + expect(error.message).not.toMatch(/private|secret|upstream/u); + expect((await module.recovery()).pending).toEqual([]); + }); + + it.each([ + [400, 'invalid_input'], [422, 'invalid_input'], [401, 'session_expired'], + [403, 'module_unavailable'], [423, 'account_locked'], + ])('clears a fresh creation only on a recognized rejection (%s %s)', async (status, code) => { + const transport = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json({ detail: { code } }, status)); + const module = moduleFor(transport); + await expect(module.create(input)).rejects.toMatchObject({ code, commandOutcome: 'definitive_failure' }); + expect((await module.recovery()).pending).toEqual([]); + }); + + it.each([408, 409, 429, 500, 502])('keeps ambiguous creation status %s recoverable with its original input', async status => { + const transport = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json({ detail: { code: 'invalid_input' } }, status)); + const module = moduleFor(transport); + await expect(module.create(input)).rejects.toMatchObject({ commandOutcome: 'unknown' }); + expect((await module.recovery()).pending).toMatchObject([{ operation: 'createAgent', input }]); + }); + + it.each([ + new Response('not JSON', { status: 422 }), + json({ detail: [{ loc: ['response', 'purpose'], type: 'string_too_short', msg: 'private' }] }, 422), + json({ detail: [{ loc: ['body', 'purpose'], type: 'unrecognized-validation' }] }, 422), + json({ detail: { code: 'unknown-error', message: 'private' } }, 422), + json({ ...draft, slug: 'invalid-response-slug' }), + ])('keeps malformed errors or successful responses pending rather than allowing another creation', async response => { + const module = moduleFor(vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(response)); + await expect(module.create(input)).rejects.toMatchObject({ commandOutcome: 'unknown' }); + expect((await module.recovery()).pending).toHaveLength(1); + }); + + it('preserves an older uncertain creation after a later validation rejection, then recovers success with the same ID', async () => { + const transport = vi.fn().mockResolvedValueOnce(session()).mockRejectedValueOnce(new Error('lost response')) + .mockResolvedValueOnce(json(validation(), 422)).mockResolvedValueOnce(json({ ...draft, purpose: '' })); + const module = moduleFor(transport); + const original = { ...input, purpose: '' }; + await expect(module.create(original)).rejects.toMatchObject({ commandOutcome: 'unknown' }); + const pending = (await module.recovery()).pending[0]; + await expect(module.resolvePending({ id: pending.id })).rejects.toMatchObject({ commandOutcome: 'unknown' }); + expect((await module.recovery()).pending).toEqual([pending]); + expect(await module.resolvePending({ id: pending.id })).toMatchObject({ result: { slug: draft.slug } }); + expect((await module.recovery()).pending).toEqual([]); + expect(transport.mock.calls.slice(1).map(call => JSON.parse(call[1].body))).toEqual([original, original, original]); + }); + + it('does not treat local validation as proof an older operation failed', async () => { + const transport = vi.fn().mockResolvedValueOnce(session()).mockRejectedValueOnce(new Error('lost response')); + const module = moduleFor(transport); + await expect(module.create(input)).rejects.toMatchObject({ commandOutcome: 'unknown' }); + await expect(module.create({ ...input, name: '' })).rejects.toMatchObject({ commandOutcome: 'unknown' }); + expect((await module.recovery()).pending).toHaveLength(1); + expect(transport).toHaveBeenCalledTimes(2); + }); + + it('shares simultaneous identical creates and rejects changed input without a second POST', async () => { + let finish!: (response: Response) => void; + const transport = vi.fn().mockResolvedValueOnce(session()).mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); + const module = moduleFor(transport); + const first = module.create(input); + const second = module.create(input); + const firstRejected = expect(first).rejects.toMatchObject({ commandOutcome: 'definitive_failure' }); + const secondRejected = expect(second).rejects.toMatchObject({ commandOutcome: 'definitive_failure' }); + await expect(module.create({ ...input, name: 'changed input' })).rejects.toMatchObject({ code: 'operation_conflict', commandOutcome: 'unknown' }); + await vi.waitFor(() => expect(finish).toBeDefined()); + finish(json(validation(), 422)); + await Promise.all([firstRejected, secondRejected]); + expect(transport).toHaveBeenCalledTimes(2); + expect((await module.recovery()).pending).toEqual([]); + }); + + it('keeps the original account record when a rejection arrives after an account switch', async () => { + let finish!: (response: Response) => void; + const transport = vi.fn().mockResolvedValueOnce(session()).mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); + const module = moduleFor(transport); + const rejected = expect(module.create(input)).rejects.toMatchObject({ code: 'account_changed', commandOutcome: 'unknown' }); + await vi.waitFor(() => expect(finish).toBeDefined()); + const pending = (await module.recovery()).pending[0]; + login('b'.repeat(64)); + finish(json(validation(), 422)); + await rejected; + expect((await module.recovery()).pending).toEqual([]); + expect(Array.from(journalRecords.values()).some(state => state.pending.some(item => item.id === pending.id))).toBe(true); + }); + + it('preserves recovery behavior for other operations after a validation rejection', async () => { + const transport = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json(validation(), 422)); + const module = moduleFor(transport); + await expect(module.execute({ operation: 'createSchedule', input: { slug: draft.slug, operation_id: input.operation_id } })).rejects.toMatchObject({ status: 422, commandOutcome: 'unknown' }); + expect((await module.recovery()).pending).toHaveLength(1); + }); + + it('does not mark a rejected retry of another operation as a confirmed failure', async () => { + const transport = vi.fn().mockResolvedValueOnce(session()).mockRejectedValueOnce(new Error('lost response')) + .mockResolvedValueOnce(json(validation(), 422)); + const module = moduleFor(transport); + await expect(module.execute({ operation: 'createSchedule', input: { slug: draft.slug, operation_id: input.operation_id } })) + .rejects.toMatchObject({ commandOutcome: 'unknown' }); + const pending = (await module.recovery()).pending[0]; + await expect(module.resolvePending({ id: pending.id })).rejects.toMatchObject({ status: 422, commandOutcome: 'unknown' }); + expect((await module.recovery()).pending).toEqual([pending]); + }); +}); + it.each(['', ' '])('preserves an empty introduction through create, save and list (%j)', async purpose => { const emptyDraft = { ...draft, purpose: '' }; const transport = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json(emptyDraft)) @@ -65,6 +200,24 @@ it.each([null, 42, 'x'.repeat(2001)])('rejects malformed or overlong introductio }); describe('Main cloud Agents boundary', () => { + it('projects saved reasoning and tool results for both personal and channel history without raw metadata', () => { + const message = { id: 1, role: 'assistant', content: 'answer', reasoning_content: 'thought', message_id: 'model-1', + extra_metadata: { internal: true }, tool_calls: [{ id: 'call-1', name: 'search', args: { query: 'topic' }, + display_name: '上网查资料', display_description: '在网上查找相关信息。', + status: 'success', internal: 'hidden', tool_call_result: { content: 'result', secret: 'hidden' } }] }; + const expected = { id: 1, role: 'assistant', content: 'answer', reasoning_content: 'thought', message_id: 'model-1', + tool_calls: [{ id: 'call-1', name: 'search', display_name: '上网查资料', display_description: '在网上查找相关信息。', + args: { query: 'topic' }, status: 'success', tool_call_result: { content: 'result' } }] }; + for (const input of [ + { operation: 'history', input: { thread_id: 'thread-1' } }, + { operation: 'channelConversation', input: { session_id: 'session-1' } }, + ]) { + expect(operationPlan(input).project({ messages: [message], run: null, next_offset: null })).toMatchObject({ messages: [expected] }); + expect(JSON.stringify(operationPlan(input).project({ messages: [message] }))).not.toContain('hidden'); + expect(JSON.stringify(operationPlan(input).project({ messages: [message] }))).not.toContain('extra_metadata'); + } + }); + it.each(Array.from({ length: 16 }, (_, index) => `avatar-${String(index + 1).padStart(2, '0')}`))('uploads the bundled platform portrait %s through scoped Main transport without a dialog', async id => { const icon = '/api/storage/public/images/creator/preset.png'; const transport = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json({ icon, private: 'hidden' })); diff --git a/tests/unit/cloud-agents-page.test.tsx b/tests/unit/cloud-agents-page.test.tsx index fab92a07..bd0289b7 100644 --- a/tests/unit/cloud-agents-page.test.tsx +++ b/tests/unit/cloud-agents-page.test.tsx @@ -2,6 +2,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testi import { createMemoryRouter, RouterProvider } from 'react-router-dom'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import CloudAgents from '@/pages/CloudAgents'; +import { AppError } from '@/lib/error-model'; import { EMPTY_CLOUD_CONFIGURATION } from '../../shared/cloud-agents'; const api = vi.hoisted(() => ({ list: vi.fn(), create: vi.fn(), uploadAvatar: vi.fn(), get: vi.fn(), save: vi.fn(), call: vi.fn(), events: vi.fn(), recovery: vi.fn(), remember: vi.fn(), resolvePending: vi.fn() })); @@ -610,6 +611,37 @@ it('guides an unconfirmed WeChat verification back to its code entry without cla expect(api.resolvePending).toHaveBeenCalledWith('wechat-code', false); }); +it('refreshes recovery after a definitive rejection without presenting a confirmed result', async () => { + const pending = { id: 'create:pending', operation: 'createAgent', input: { operation_id: 'same-operation', name: '旧搭档', purpose: '' }, created_at: '2026-09-13T01:00:00Z' }; + api.recovery.mockResolvedValueOnce({ recent: null, pending: [pending] }).mockResolvedValue({ recent: null, pending: [] }); + api.resolvePending.mockRejectedValue(new AppError('CONFIG', '名称不可用', undefined, { commandOutcome: 'definitive_failure' })); + open(); + fireEvent.click(await screen.findByRole('button', { name: '重试并确认结果' })); + expect(await screen.findByRole('alert')).toHaveTextContent('创建智能体失败:名称不可用'); + await waitFor(() => expect(api.recovery).toHaveBeenCalledTimes(2)); + expect(screen.queryByRole('button', { name: '重试并确认结果' })).not.toBeInTheDocument(); + expect(screen.queryByText('创建智能体的结果已确认。')).not.toBeInTheDocument(); + expect(screen.queryByText('查看结果')).not.toBeInTheDocument(); + expect(api.resolvePending).toHaveBeenCalledWith(pending.id, false); + expect(api.create).not.toHaveBeenCalled(); +}); + +it('keeps an old recovery record retryable when a parameter response cannot determine the original outcome', async () => { + const pending = { id: 'create:old', operation: 'createAgent', input: { operation_id: 'original-operation', name: '旧搭档', purpose: '' }, created_at: '2026-09-13T01:00:00Z' }; + api.recovery.mockResolvedValue({ recent: null, pending: [pending] }); + api.resolvePending.mockRejectedValue(new AppError('CONFIG', '请检查名称', undefined, { commandOutcome: 'unknown', status: 422, code: 'invalid_input' })); + open(); + fireEvent.click(await screen.findByRole('button', { name: '重试并确认结果' })); + await screen.findByRole('alert'); + expect(screen.getByRole('button', { name: '重试并确认结果' })).toBeEnabled(); + expect(screen.queryByText('创建智能体的结果已确认。')).not.toBeInTheDocument(); + expect(api.recovery).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole('button', { name: '重试并确认结果' })); + await waitFor(() => expect(api.resolvePending).toHaveBeenCalledTimes(2)); + expect(api.resolvePending.mock.calls).toEqual([[pending.id, false], [pending.id, false]]); + expect(api.create).not.toHaveBeenCalled(); +}); + it('offers only explicit discard for a recovery record whose channel operation was removed', async () => { api.recovery.mockResolvedValueOnce({ recent: null, pending: [{ id: 'channel-policy-old', operation: 'channelPolicy', input: { slug: draft.slug, channel_account_id: 'wechat-1' }, created_at: '2026-09-13T01:00:00Z' }] }) @@ -648,6 +680,108 @@ it('freezes the uploaded avatar and creation identity when retrying a lost respo expect(screen.getByRole('img', { name: '写作搭档头像' })).toHaveAttribute('src', avatar.icon_url); }); +it('unlocks rejected creation input and uses a new operation identity after editing', async () => { + const avatar = { icon: '/api/storage/public/images/rejected.png', icon_url: 'https://images.test/rejected.png' }; + api.uploadAvatar.mockResolvedValue(avatar); + api.create.mockRejectedValueOnce(new AppError('CONFIG', '名称不可用', undefined, { commandOutcome: 'definitive_failure' })) + .mockImplementationOnce(async input => ({ ...draft, ...input })); + open(); + fireEvent.click(await screen.findByRole('button', { name: '创建智能体', exact: true })); + fireEvent.change(screen.getByLabelText('名称'), { target: { value: '原名称' } }); + fireEvent.click(screen.getByRole('button', { name: '上传头像' })); + await screen.findByRole('button', { name: '更换头像' }); + fireEvent.click(screen.getByRole('button', { name: '下一步', exact: true })); + expect(await screen.findByRole('alert')).toHaveTextContent('创建失败:名称不可用'); + await waitFor(() => expect(api.recovery).toHaveBeenCalledTimes(2)); + expect(screen.getByLabelText('名称')).toBeEnabled(); + expect(screen.getByLabelText('名称')).toHaveValue('原名称'); + expect(screen.getByRole('button', { name: '更换头像' })).toBeEnabled(); + expect(screen.getByRole('button', { name: '移除头像' })).toBeEnabled(); + expect(screen.queryByText(/创建结果暂未确认/)).not.toBeInTheDocument(); + fireEvent.change(screen.getByLabelText('名称'), { target: { value: '新的名称' } }); + fireEvent.click(screen.getByRole('button', { name: '移除头像' })); + fireEvent.click(screen.getByRole('button', { name: '下一步', exact: true })); + await screen.findByTestId('cloud-agent-editor'); + expect(api.create).toHaveBeenCalledTimes(2); + const original = api.create.mock.calls[0][0]; + const corrected = api.create.mock.calls[1][0]; + expect(original).toMatchObject({ name: '原名称', purpose: '', icon: avatar.icon }); + expect(corrected).toMatchObject({ name: '新的名称', purpose: '', icon: null }); + expect(corrected.operation_id).not.toEqual(original.operation_id); + expect(api.recovery).toHaveBeenCalledTimes(3); +}); + +it('preserves the creation identity for unknown command outcomes even when the error says invalid input', async () => { + const avatar = { icon: '/api/storage/public/images/unknown.png', icon_url: 'https://images.test/unknown.png' }; + let retainedPending: unknown[] = []; + api.recovery.mockImplementation(async () => ({ recent: null, pending: retainedPending })); + api.uploadAvatar.mockResolvedValue(avatar); + api.create.mockImplementationOnce(async input => { + retainedPending = [{ id: 'create:unknown', operation: 'createAgent', input, created_at: '2026-09-13T01:00:00Z' }]; + throw new AppError('CONFIG', '请检查名称', undefined, { commandOutcome: 'unknown', status: 422, code: 'invalid_input' }); + }).mockImplementationOnce(async input => { + retainedPending = []; + return { ...draft, ...input, icon_url: avatar.icon_url }; + }); + open(); + fireEvent.click(await screen.findByRole('button', { name: '创建智能体', exact: true })); + fireEvent.change(screen.getByLabelText('名称'), { target: { value: '原搭档' } }); + fireEvent.click(screen.getByRole('button', { name: '上传头像' })); + await screen.findByRole('button', { name: '更换头像' }); + fireEvent.click(screen.getByRole('button', { name: '下一步', exact: true })); + expect(await screen.findByRole('alert')).toHaveTextContent('创建结果暂未确认,可重试确认结果'); + await screen.findByText('有 1 个操作的结果尚未确认'); + expect(screen.getByLabelText('名称')).toBeDisabled(); + expect(screen.getByRole('button', { name: '更换头像' })).toBeDisabled(); + expect(screen.getByRole('button', { name: '移除头像' })).toBeDisabled(); + fireEvent.click(screen.getByRole('button', { name: '重试创建' })); + await screen.findByTestId('cloud-agent-editor'); + expect(api.create.mock.calls[1][0]).toEqual(api.create.mock.calls[0][0]); + await waitFor(() => expect(screen.queryByText('有 1 个操作的结果尚未确认')).not.toBeInTheDocument()); + expect(api.recovery).toHaveBeenCalledTimes(3); + expect(api.uploadAvatar).toHaveBeenCalledTimes(1); +}); + +it('keeps an uncertain creation identity after its reminder is removed and a retry is rejected', async () => { + const avatar = { icon: '/api/storage/public/images/discarded.png', icon_url: 'https://images.test/discarded.png' }; + let retainedPending: unknown[] = []; + api.recovery.mockImplementation(async () => ({ recent: null, pending: retainedPending })); + api.uploadAvatar.mockResolvedValue(avatar); + api.resolvePending.mockImplementation(async (_id, discard) => { + expect(discard).toBe(true); + retainedPending = []; + return { discarded: true }; + }); + api.create.mockImplementationOnce(async input => { + retainedPending = [{ id: 'create:discarded', operation: 'createAgent', input, created_at: '2026-09-13T01:00:00Z' }]; + throw new AppError('NETWORK', '连接中断', undefined, { commandOutcome: 'unknown' }); + }).mockRejectedValueOnce(new AppError('CONFIG', '参数被拒绝', undefined, { commandOutcome: 'definitive_failure' })) + .mockImplementationOnce(async input => ({ ...draft, ...input, icon_url: avatar.icon_url })); + open(); + fireEvent.click(await screen.findByRole('button', { name: '创建智能体', exact: true })); + fireEvent.change(screen.getByLabelText('名称'), { target: { value: '原搭档' } }); + fireEvent.click(screen.getByRole('button', { name: '上传头像' })); + await screen.findByRole('button', { name: '更换头像' }); + fireEvent.click(screen.getByRole('button', { name: '下一步', exact: true })); + await screen.findByText('有 1 个操作的结果尚未确认'); + fireEvent.click(screen.getByRole('button', { name: '移除提醒', exact: true })); + await waitFor(() => expect(screen.queryByText('有 1 个操作的结果尚未确认')).not.toBeInTheDocument()); + expect(api.resolvePending).toHaveBeenCalledWith('create:discarded', true); + fireEvent.click(screen.getByRole('button', { name: '重试创建', exact: true })); + expect(await screen.findByRole('alert')).toHaveTextContent('创建结果暂未确认,可重试确认结果。参数被拒绝'); + expect(screen.getByLabelText('名称')).toBeDisabled(); + expect(screen.getByLabelText('名称')).toHaveValue('原搭档'); + expect(screen.getByRole('button', { name: '更换头像' })).toBeDisabled(); + expect(screen.getByRole('button', { name: '移除头像' })).toBeDisabled(); + expect(screen.queryByRole('button', { name: '下一步', exact: true })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '重试创建', exact: true })); + await screen.findByTestId('cloud-agent-editor'); + expect(api.create).toHaveBeenCalledTimes(3); + expect(api.create.mock.calls[1][0]).toEqual(api.create.mock.calls[0][0]); + expect(api.create.mock.calls[2][0]).toEqual(api.create.mock.calls[0][0]); + expect(api.uploadAvatar).toHaveBeenCalledTimes(1); +}); + it('preserves local input on conflicts and requires an explicit choice before retry', async () => { api.save.mockRejectedValueOnce(new Error('草稿已更新')); api.get.mockResolvedValue({ ...draft, draft_revision: 2, system_prompt: '其他设备内容' }); @@ -829,7 +963,10 @@ it('searches capabilities while retaining existing selections outside the result api.list.mockResolvedValue({ agents: [configured], next_cursor: null }); const base = api.call.getMockImplementation()!; api.call.mockImplementation(async (operation, input) => operation === 'catalog' ? { - models: [{ id: 'test-model', name: '写作模型' }], resources: { tools: [{ key: 'search', name: '资料搜索', description: '搜索素材' }, { key: 'clock', name: '当前时间' }] }, pricing: null, + models: [{ id: 'test-model', name: '写作模型' }], resources: { + tools: [{ key: 'web_search', name: '上网查资料', description: '在网上查找相关信息。' }, { key: 'clock', name: '当前时间' }], + skills: [{ key: 'deep-research', name: '查资料,做研究', description: '比较不同说法,再整理出结论和出处。' }], + }, pricing: null, } : base(operation, input)); api.save.mockImplementation(async (_slug, input) => ({ ...configured, ...input, draft_revision: 2 })); open(); @@ -842,9 +979,15 @@ it('searches capabilities while retaining existing selections outside the result fireEvent.mouseDown(screen.getByRole('tab', { name: '高级设置' }), { button: 0, ctrlKey: false }); expect(screen.getByLabelText('搜索工具箱')).toHaveValue('资料'); expect(screen.queryByRole('checkbox', { name: '当前时间' })).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole('checkbox', { name: /资料搜索/ })); + fireEvent.click(screen.getByRole('checkbox', { name: /上网查资料/ })); fireEvent.click(screen.getByRole('button', { name: '完成选择' })); expect(screen.getByRole('checkbox', { name: /missing-tool/ })).toBeChecked(); + fireEvent.click(screen.getByRole('button', { name: '选择做事手册', exact: true })); + fireEvent.change(screen.getByLabelText('搜索做事手册'), { target: { value: '出处' } }); + fireEvent.click(screen.getByRole('checkbox', { name: /查资料,做研究/ })); + fireEvent.click(screen.getByRole('checkbox', { name: '开始对话前,先读好已选做事手册的使用方法' })); fireEvent.click(screen.getByRole('button', { name: '保存草稿' })); - await waitFor(() => expect(api.save).toHaveBeenCalledWith(draft.slug, expect.objectContaining({ configuration: expect.objectContaining({ tools: ['missing-tool', 'search'] }) }))); + await waitFor(() => expect(api.save).toHaveBeenCalledWith(draft.slug, expect.objectContaining({ + configuration: expect.objectContaining({ tools: ['missing-tool', 'web_search'], skills: ['deep-research'], preload_skills: ['deep-research'] }), + }))); }); diff --git a/tests/unit/cloud-agents-workflows.test.tsx b/tests/unit/cloud-agents-workflows.test.tsx index ff938e83..9c04b5a7 100644 --- a/tests/unit/cloud-agents-workflows.test.tsx +++ b/tests/unit/cloud-agents-workflows.test.tsx @@ -164,8 +164,8 @@ it('retries the same request after a lost response and renders batched native SS expect(submissions[0][1]).toEqual(submissions[1][1]); await act(async () => stream.dispatchEvent(new MessageEvent('messages', { data: JSON.stringify({ run_id: 'run-1', thread_id: 'thread-1', payload: { items: [ - { stream_event: { type: 'message_delta', content: '第一段' } }, - { stream_event: { type: 'message_delta', content: '文字' } }, + { stream_event: { type: 'message_delta', message_id: 'answer', content: '第一段' } }, + { stream_event: { type: 'message_delta', message_id: 'answer', content: '文字' } }, ] } }), }))); expect(screen.getByText('第一段文字')).toBeVisible(); diff --git a/tests/unit/cloud-chat-reasoning.test.tsx b/tests/unit/cloud-chat-reasoning.test.tsx index 66421c66..97e84376 100644 --- a/tests/unit/cloud-chat-reasoning.test.tsx +++ b/tests/unit/cloud-chat-reasoning.test.tsx @@ -41,7 +41,7 @@ async function open(preview = false) { it('shows reasoning without an answer, preserves manual folding during deltas and folds final history once', async () => { await open(); await emit({ reasoning_content: '流式思考' }); - const details = screen.getByTestId('cloud-chat-reasoning'); + const details = screen.getByTestId('cloud-message-process'); expect(details).toHaveAttribute('open'); expect(screen.queryByRole('heading', { name: 'Hi,今天想聊些什么?' })).not.toBeInTheDocument(); act(() => { details.removeAttribute('open'); fireEvent(details, new Event('toggle')); }); await emit({ reasoning_content: '第二段', content: '流式回答' }, '2-0'); @@ -51,8 +51,8 @@ it('shows reasoning without an answer, preserves manual folding during deltas an history = { ...history, run: { ...run, status: 'completed' }, messages: [savedAssistant()] }; await emit({}, '2-0', 'end'); await screen.findByText('完整回答'); - expect(screen.getAllByTestId('cloud-chat-reasoning')).toHaveLength(1); - expect(screen.getByTestId('cloud-chat-reasoning')).not.toHaveAttribute('open'); + expect(screen.getAllByTestId('cloud-message-process')).toHaveLength(1); + expect(screen.getByTestId('cloud-message-process')).not.toHaveAttribute('open'); expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('完整思考'); expect(screen.queryByText('流式回答')).not.toBeInTheDocument(); }); @@ -65,7 +65,7 @@ it('keeps partial thought and answer when terminal history fails and excludes la await screen.findByText('历史暂时离线'); expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('已收思考'); expect(screen.getByText('已收片段')).toBeInTheDocument(); - expect(screen.getByTestId('cloud-chat-reasoning')).not.toHaveAttribute('open'); + expect(screen.getByTestId('cloud-message-process')).not.toHaveAttribute('open'); await emit({ reasoning_content: '另一线程' }, '2-0', 'messages', { thread_id: 'other-thread' }); expect(screen.getByLabelText('智能体思考过程')).not.toHaveTextContent('另一线程'); }); @@ -78,8 +78,8 @@ it('restores only assistant reasoning from completed history and shows no block ] }; render(); await screen.findByText('完整回答'); - expect(screen.getAllByTestId('cloud-chat-reasoning')).toHaveLength(1); - expect(screen.getByTestId('cloud-chat-reasoning')).not.toHaveAttribute('open'); + expect(screen.getAllByTestId('cloud-message-process')).toHaveLength(1); + expect(screen.getByTestId('cloud-message-process')).not.toHaveAttribute('open'); expect(screen.queryByText('不属于用户显示的思考')).not.toBeInTheDocument(); expect(screen.queryByText('系统字段')).not.toBeInTheDocument(); expect(api.events).not.toHaveBeenCalled(); @@ -88,9 +88,9 @@ it('restores only assistant reasoning from completed history and shows no block it('clears live reasoning on a new conversation and ignores its obsolete stream', async () => { await open(true); await emit({ reasoning_content: '旧对话思考' }); fireEvent.click(screen.getByRole('button', { name: '新对话' })); - await waitFor(() => expect(screen.queryByTestId('cloud-chat-reasoning')).not.toBeInTheDocument()); + await waitFor(() => expect(screen.queryByTestId('cloud-message-process')).not.toBeInTheDocument()); await emit({ reasoning_content: '旧事件迟到' }, '2-0'); - expect(screen.queryByTestId('cloud-chat-reasoning')).not.toBeInTheDocument(); + expect(screen.queryByTestId('cloud-message-process')).not.toBeInTheDocument(); expect(api.call.mock.calls.some(call => ['submit', 'preview'].includes(call[0]))).toBe(false); }); @@ -103,7 +103,7 @@ it('hydrates a final message that is saved after its terminal Run stops the runn history = { ...history, messages: [savedAssistant('迟到保存的思考')] }; await act(async () => { await vi.advanceTimersByTimeAsync(2000); }); expect(screen.getByText('完整回答')).toBeInTheDocument(); - expect(screen.getAllByTestId('cloud-chat-reasoning')).toHaveLength(1); + expect(screen.getAllByTestId('cloud-message-process')).toHaveLength(1); expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('迟到保存的思考'); expect(screen.queryByText('还没落盘的回答')).not.toBeInTheDocument(); expect(api.call.mock.calls.some(call => ['submit', 'preview'].includes(call[0]))).toBe(false); @@ -157,8 +157,8 @@ it('ignores a pending history read when approval resumes the same request in a n run_id: 'run-2', thread_id: 'thread-1', request_id: 'request-1', payload: { chunk: { stream_event: { type: 'message_delta', message_id: 'message-2', reasoning_content: '操作后的思考' } } }, }) }))); await act(async () => { resolveOld({ ...history, run: { ...run, status: 'interrupted' } }); }); - expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('操作前的思考'); - expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('操作后的思考'); + expect(screen.getAllByLabelText('智能体思考过程').map(item => item.textContent).join('')).toContain('操作前的思考'); + expect(screen.getAllByLabelText('智能体思考过程').map(item => item.textContent).join('')).toContain('操作后的思考'); expect(screen.queryByRole('button', { name: '允许执行', exact: true })).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: '停止回答', exact: true })).toBeInTheDocument(); }); @@ -176,7 +176,7 @@ it('reopens a resumed same-Run stream and ignores its obsolete interrupted histo return base(operation, input); }); await emit({}, '2-0', 'end'); - expect(screen.getByTestId('cloud-chat-reasoning')).not.toHaveAttribute('open'); + expect(screen.getByTestId('cloud-message-process')).not.toHaveAttribute('open'); const resumedStream = new Stream(); api.events.mockResolvedValue(resumedStream); await act(async () => { fireEvent.click(screen.getByRole('button', { name: '允许执行', exact: true })); }); await waitFor(() => expect(api.events).toHaveBeenLastCalledWith('run-1', '2-0')); @@ -186,13 +186,13 @@ it('reopens a resumed same-Run stream and ignores its obsolete interrupted histo type: 'message_delta', message_id: 'message-2', reasoning_content: '恢复后的思考', content: '恢复后的回答', } } }, }) }))); - expect(screen.getByTestId('cloud-chat-reasoning')).toHaveAttribute('open'); - expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('操作前的思考'); - expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('恢复后的思考'); + expect(screen.getAllByTestId('cloud-message-process').every(item => !item.hasAttribute('open'))).toBe(true); + expect(screen.getAllByLabelText('智能体思考过程').map(item => item.textContent).join('')).toContain('操作前的思考'); + expect(screen.getAllByLabelText('智能体思考过程').map(item => item.textContent).join('')).toContain('恢复后的思考'); expect(screen.getByText(/恢复后的回答/)).toBeInTheDocument(); await act(async () => { resolveOld({ ...history, run: { ...run, status: 'interrupted' }, messages: [savedAssistant()] }); }); - expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('恢复后的思考'); - expect(screen.getByTestId('cloud-chat-reasoning')).toHaveAttribute('open'); + expect(screen.getAllByLabelText('智能体思考过程').map(item => item.textContent).join('')).toContain('恢复后的思考'); + expect(screen.getAllByTestId('cloud-message-process').every(item => !item.hasAttribute('open'))).toBe(true); expect(screen.getByRole('button', { name: '停止回答', exact: true })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: '允许执行', exact: true })).not.toBeInTheDocument(); expect(screen.queryByText('完整回答')).not.toBeInTheDocument(); @@ -215,12 +215,12 @@ it('ignores a late approval receipt after the user discards input and starts a n await waitFor(() => expect(api.call.mock.calls.some(([operation]) => operation === 'resume')).toBe(true)); fireEvent.click(screen.getByRole('button', { name: '新对话', exact: true })); fireEvent.click(await screen.findByRole('button', { name: '放弃内容', exact: true })); - expect(screen.queryByTestId('cloud-chat-reasoning')).not.toBeInTheDocument(); + expect(screen.queryByTestId('cloud-message-process')).not.toBeInTheDocument(); expect(screen.getByRole('heading', { name: '问它一个小问题' })).toBeInTheDocument(); await act(async () => { resolveResume({ run_id: 'resumed-old-run', status: 'running' }); }); expect(screen.queryByRole('button', { name: '停止回答', exact: true })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: '允许执行', exact: true })).not.toBeInTheDocument(); - expect(screen.queryByTestId('cloud-chat-reasoning')).not.toBeInTheDocument(); + expect(screen.queryByTestId('cloud-message-process')).not.toBeInTheDocument(); expect(screen.getByRole('heading', { name: '问它一个小问题' })).toBeInTheDocument(); expect(api.events).not.toHaveBeenCalledWith('resumed-old-run'); expect(api.events).toHaveBeenCalledTimes(1); diff --git a/tests/unit/cloud-chat-rendering.test.tsx b/tests/unit/cloud-chat-rendering.test.tsx new file mode 100644 index 00000000..68b1c60d --- /dev/null +++ b/tests/unit/cloud-chat-rendering.test.tsx @@ -0,0 +1,185 @@ +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { CloudChat } from '@/pages/CloudAgents/CloudChat'; +import { reduceCloudStream, type CloudStream } from '@/pages/CloudAgents/cloud-chat-stream'; +import type { CloudHistory } from '../../shared/cloud-agents'; + +const api = vi.hoisted(() => ({ call: vi.fn(), events: vi.fn(), recovery: vi.fn(), remember: vi.fn(), download: vi.fn() })); +vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api })); +class Stream extends EventTarget { close = vi.fn(); onopen = null; onerror = null; } +let stream: Stream; +let history: CloudHistory; +const run = { agent_run_id: 'run', request_id: 'request', thread_id: 'thread', agent_slug: 'agent', status: 'running', output: '', version: '1' }; +const thread = { thread_id: 'thread', client_thread_id: 'client', agent_slug: 'agent', mode: 'published', status: 'running', title: '对话', updated_at: '', unread: false, run_id: 'run' }; +beforeEach(() => { + vi.resetAllMocks(); + stream = new Stream(); + history = { thread_id: 'thread', messages: [], run, next_offset: null }; + api.recovery.mockResolvedValue({ recent: null, pending: [] }); + api.remember.mockResolvedValue({}); + api.download.mockResolvedValue({ saved: true }); + api.events.mockResolvedValue(stream); + api.call.mockImplementation(async (operation: string) => { + if (operation === 'threads') return { threads: [thread], next_offset: null }; + if (operation === 'history') return history; + if (operation === 'run') return history.run; + if (operation === 'files') return { files: [] }; + if (operation === 'attachments') return { attachments: [] }; + if (operation === 'viewed') return {}; + throw new Error(operation); + }); +}); +afterEach(cleanup); +async function emit(items: unknown[], cursor: string, threadId = 'thread') { + await act(async () => stream.dispatchEvent(new MessageEvent('messages', { + lastEventId: cursor, data: JSON.stringify({ run_id: 'run', thread_id: threadId, payload: { items } }), + }))); +} +const delta = (message_id: string, fields: Record) => ({ stream_event: { type: 'message_delta', message_id, ...fields } }); + +it('renders separate thinking and Markdown messages, ignores replay/subthreads, and restores durable history on reopen', async () => { + const view = render(); + await waitFor(() => expect(api.events).toHaveBeenCalledWith('run')); + await emit([delta('first', { reasoning_content: '先查资料' })], '100-1'); + expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('先查资料'); + expect(screen.getByTestId('cloud-message-process')).toHaveAttribute('open'); + await emit([delta('first', { reasoning_content: '先查资料' })], '100-1'); + await emit([delta('child', { content: '不应出现在主回答', reasoning_content: '子线程思考' })], '100-2', 'child'); + await emit([delta('first', { content: '**核对完成**' }), delta('second', { additional_reasoning_content: '再整理结论' })], '100-3'); + expect(screen.getByText('核对完成').tagName).toBe('STRONG'); + expect(screen.queryByText('不应出现在主回答')).not.toBeInTheDocument(); + expect(screen.getAllByLabelText('智能体思考过程')[0]).toHaveTextContent(/^先查资料$/); + expect(screen.getAllByTestId('cloud-message-process')[0]).not.toHaveAttribute('open'); + await emit([delta('second', { content: '| 项目 | 结果 |\n| --- | --- |\n| 校验 | 通过 |\n\n[报告](sandbox:/report.md)' })], '100-4'); + expect(screen.getByRole('table')).toHaveTextContent('校验通过'); + fireEvent.click(screen.getByRole('button', { name: '报告' })); + expect(api.download).toHaveBeenCalledWith('thread', '/report.md'); + history = { ...history, run: { ...run, status: 'completed' }, messages: [ + { id: 1, role: 'assistant', message_id: 'first', run_id: 'run', request_id: 'request', created_at: '', content: '**核对完成**', reasoning_content: '先查资料' }, + { id: 2, role: 'assistant', message_id: 'second', run_id: 'run', request_id: 'request', created_at: '', content: '最终结论', reasoning_content: '再整理结论' }, + ] }; + await act(async () => stream.dispatchEvent(new MessageEvent('end', { data: JSON.stringify({ run_id: 'run', thread_id: 'thread', payload: { status: 'completed' } }) }))); + await screen.findByText('最终结论'); + expect(screen.getAllByText('核对完成')).toHaveLength(1); + expect(screen.getAllByLabelText('智能体思考过程')).toHaveLength(2); + expect(screen.queryByRole('table')).not.toBeInTheDocument(); + view.unmount(); + render(); + await screen.findByText('最终结论'); + expect(screen.getAllByLabelText('智能体思考过程')[1]).toHaveTextContent('再整理结论'); + expect(screen.getAllByTestId('cloud-message-process').every(element => !element.hasAttribute('open'))).toBe(true); +}); + +it('associates tool arguments and results by call identity, keeps failures visible and clears a switched thread', async () => { + render(); + await waitFor(() => expect(api.events).toHaveBeenCalled()); + await emit([ + { stream_event: { type: 'tool_call_delta', message_id: 'm', tool_call_id: 'search', name: 'web_search', + display_name: '上网查资料', display_description: '在网上查找相关信息。', args_delta: '{"query":', index: 0 } }, + { stream_event: { type: 'tool_call_delta', message_id: 'm', args_delta: '"天气"}', index: 0 } }, + { stream_event: { type: 'tool_call', message_id: 'm', tool_call_id: 'write', name: '写文件', args: { path: '/a' }, index: 1 } }, + { event: { method: 'tools', data: { event: 'tool-finished', tool_call_id: 'write', output: { content: '磁盘不足', status: 'error' } } } }, + { event: { method: 'tools', data: { event: 'tool-finished', tool_call_id: 'search', output: { content: '晴天', status: 'success' } } } }, + ], '101-0'); + const process = screen.getByTestId('cloud-message-process'); + expect(process).toHaveTextContent('上网查资料 · 已完成'); + expect(process).toHaveTextContent('在网上查找相关信息。'); + expect(process).toHaveTextContent('工具标识:web_search'); + expect(process).toHaveTextContent('写文件 · 失败'); + expect(within(process).getByText('{"query":"天气"}')).toBeInTheDocument(); + expect(within(process).getByText('晴天')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '新对话' })); + await waitFor(() => expect(screen.queryByTestId('cloud-message-process')).not.toBeInTheDocument()); + await emit([delta('m', { reasoning_content: '旧连接的迟到内容' })], '102-0'); + expect(screen.queryByText('旧连接的迟到内容')).not.toBeInTheDocument(); +}); + +it('retains partial thinking and answer if final history cannot be read', async () => { + render(); + await waitFor(() => expect(api.events).toHaveBeenCalled()); + await emit([delta('m', { reasoning_content: '已完成分析', content: '已生成部分结果' })], '1-0'); + const implementation = api.call.getMockImplementation()!; + api.call.mockImplementation(async (operation, ...args) => { + if (operation === 'history') throw new Error('历史暂不可用'); + return implementation(operation, ...args); + }); + await act(async () => stream.dispatchEvent(new MessageEvent('end', { data: JSON.stringify({ run_id: 'run', thread_id: 'thread', payload: { status: 'failed' } }) }))); + await screen.findByText('历史暂不可用'); + expect(screen.getByText('已生成部分结果')).toBeVisible(); + expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('已完成分析'); +}); + +it('replaces only persisted messages when terminal history is still incomplete', async () => { + render(); + await waitFor(() => expect(api.events).toHaveBeenCalled()); + await emit([delta('first', { content: '第一条实时消息' }), delta('second', { content: '第二条尚未保存', reasoning_content: '第二条思考' })], '1-0'); + history = { ...history, run: { ...run, status: 'completed' }, messages: [ + { id: 1, message_id: 'first', role: 'assistant', run_id: 'run', request_id: 'request', created_at: '', content: '第一条持久消息' }, + ] }; + await act(async () => stream.dispatchEvent(new MessageEvent('end', { + lastEventId: '1-0', data: JSON.stringify({ run_id: 'run', thread_id: 'thread', payload: { status: 'completed' } }), + }))); + await screen.findByText('第一条持久消息'); + expect(screen.queryByText('第一条实时消息')).not.toBeInTheDocument(); + expect(screen.getByText('第二条尚未保存')).toBeVisible(); + expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('第二条思考'); + expect(screen.getByTestId('cloud-message-process')).not.toHaveAttribute('open'); +}); + +it('leaves ordinary answers without an empty thinking panel', async () => { + history = { ...history, run: { ...run, status: 'completed' }, messages: [ + { id: 1, role: 'assistant', content: '普通回答', request_id: 'request', run_id: 'run', created_at: '' }, + ] }; + render(); + await screen.findByText('普通回答'); + expect(screen.queryByTestId('cloud-message-process')).not.toBeInTheDocument(); +}); + +it('keeps the current full stream while a stale partial history is waiting for terminal refresh', async () => { + history = { ...history, messages: [{ id: 1, message_id: 'm', role: 'assistant', content: '旧片段', + request_id: 'request', run_id: 'run', created_at: '' }] }; + render(); + await waitFor(() => expect(api.events).toHaveBeenCalled()); + await emit([delta('m', { content: '完整的新答案', reasoning_content: '已完成的思考' })], '200-1'); + expect(screen.queryByText('旧片段')).not.toBeInTheDocument(); + const implementation = api.call.getMockImplementation()!; + let finish!: (value: CloudHistory) => void; + api.call.mockImplementation((operation, ...args) => operation === 'history' + ? new Promise(resolve => { finish = resolve; }) : implementation(operation, ...args)); + await act(async () => stream.dispatchEvent(new MessageEvent('end', { data: JSON.stringify({ run_id: 'run', thread_id: 'thread', payload: { status: 'completed' } }) }))); + expect(screen.getByText('完整的新答案')).toBeVisible(); + expect(screen.queryByText('旧片段')).not.toBeInTheDocument(); + await act(async () => finish({ ...history, run: { ...run, status: 'completed' }, messages: [{ + ...history.messages[0], content: '完整的新答案', reasoning_content: '已完成的思考', + }] })); + expect(screen.getAllByText('完整的新答案')).toHaveLength(1); +}); + +it('uses effective execution input once each tool starts, including empty arguments', () => { + let state: CloudStream = { runId: 'run', cursor: '', messages: [] }; + state = reduceCloudStream(state, { payload: { items: [ + { stream_event: { type: 'tool_call', message_id: 'm', tool_call_id: 'search', name: '查资料', args: { query: '模型参数' }, index: 0 } }, + { stream_event: { type: 'tool_call', message_id: 'm', tool_call_id: 'other', name: '其他工具', args: { unused: true }, index: 1 } }, + ] } }, 'thread', '1-0'); + state = reduceCloudStream(state, { payload: { items: [ + { event: { method: 'tools', data: { event: 'tool-started', tool_call_id: 'search', input: { query: '实际执行参数' } } } }, + { event: { method: 'tools', data: { event: 'tool-started', tool_call_id: 'other', input: {} } } }, + ] } }, 'thread', '2-0'); + expect(state.messages[0].tool_calls).toMatchObject([ + { id: 'search', args: { query: '实际执行参数' }, status: 'running' }, + { id: 'other', args: {}, status: 'running' }, + ]); + expect(state.messages[0].tool_calls[1].args).toEqual({}); + state = reduceCloudStream(state, { payload: { chunk: { + stream_event: { type: 'tool_call', message_id: 'm', tool_call_id: 'search', args: { query: '模型参数' }, index: 0 }, + } } }, 'thread', '3-0'); + expect(state.messages[0].tool_calls[0].args).toEqual({ query: '实际执行参数' }); +}); + +it('keeps Redis cursor precision while ignoring other runs', () => { + let state: CloudStream = { runId: 'run', cursor: '', messages: [] }; + const event = { run_id: 'run', thread_id: 'thread', payload: { chunk: delta('m', { content: 'A' }) } }; + state = reduceCloudStream(state, event, 'thread', '9007199254740993-0'); + expect(reduceCloudStream(state, event, 'thread', '9007199254740992-1')).toBe(state); + expect(reduceCloudStream(state, { ...event, run_id: 'other' }, 'thread', '9007199254740994-0')).toBe(state); +}); diff --git a/tests/unit/cloud-chat-stream.test.ts b/tests/unit/cloud-chat-stream.test.ts index e482aa31..afc6f3bc 100644 --- a/tests/unit/cloud-chat-stream.test.ts +++ b/tests/unit/cloud-chat-stream.test.ts @@ -10,7 +10,7 @@ describe('personal Agent main-thread stream projection', () => { it('separates reasoning and answers in batched and single chunks with legacy fallback', () => { let state = consume(null, envelope([delta({ reasoning_content: '思考一', additional_reasoning_content: '不应重复', content: '回答一' })])); state = consume(state, { run_id: 'run-1', thread_id: 'thread-1', payload: { chunk: delta({ additional_reasoning_content: '思考二', content: '回答二' }) } }, '2-0'); - expect(state).toMatchObject({ reasoning: '思考一思考二', content: '回答一回答二', cursor: '2-0' }); + expect(state).toMatchObject({ messages: [{ reasoning_content: '思考一思考二', content: '回答一回答二' }], cursor: '2-0' }); expect(JSON.stringify(state)).not.toContain('不应重复'); }); @@ -20,7 +20,7 @@ describe('personal Agent main-thread stream projection', () => { { stream_event: { type: 'tool_call', name: 'search', arguments: 'private tool args' } }, delta({ message_id: 'message-2', reasoning_content: '核对资料', content: '第二段' }), ])); - expect(state).toMatchObject({ reasoning: '先看资料\n\n核对资料', content: '第一段\n\n第二段', toolName: 'search' }); + expect(state).toMatchObject({ messages: [{ reasoning_content: '先看资料', content: '第一段', tool_calls: [{ name: 'search' }] }, { reasoning_content: '核对资料', content: '第二段' }] }); expect(JSON.stringify(state)).not.toContain('private tool args'); }); @@ -30,7 +30,7 @@ describe('personal Agent main-thread stream projection', () => { expect(consume(state, envelope([delta({ reasoning_content: '另一个请求' })], { request_id: 'request-2' }), '2-0')).toBe(state); const child = consume(state, envelope([delta({ reasoning_content: '子线程' })], { thread_id: 'child-thread' }), '2-0'); const missing = consume(child, envelope([delta({ content: '缺身份' })], { thread_id: undefined }), '3-0'); - expect(missing).toMatchObject({ reasoning: '父线程', content: '', cursor: '3-0', ended: false }); + expect(missing).toMatchObject({ messages: [{ reasoning_content: '父线程', content: '' }], cursor: '3-0', ended: false }); expect(consume(missing, envelope([], { thread_id: 'child-thread' }), '4-0', 'end')).toMatchObject({ ended: false }); }); @@ -39,16 +39,16 @@ describe('personal Agent main-thread stream projection', () => { expect(consume(state, envelope([delta({ reasoning_content: '重放' })]), '99999999999999999999-1')).toBe(state); expect(consume(state, envelope([delta({ reasoning_content: '迟到' })]), '99999999999999999998-99')).toBe(state); const next = consume(state, envelope([delta({ reasoning_content: '二次' })]), '99999999999999999999-2'); - expect(consume(next, envelope([]), '99999999999999999999-2', 'end')).toMatchObject({ reasoning: '一次二次', ended: true }); + expect(consume(next, envelope([]), '99999999999999999999-2', 'end')).toMatchObject({ messages: [{ reasoning_content: '一次二次' }], ended: true }); }); it('keeps missing-ID compatibility and retains same-request reasoning over a resumed Run only', () => { const first = consume(null, envelope([delta({ reasoning_content: '之前', content: '旧预览' })]), ''); - const next = consumeCloudChatEvent(first, envelope([delta({ message_id: 'message-2', reasoning_content: '继续', content: '新预览' })], { run_id: 'run-2' }), + const next = consumeCloudChatEvent(first, envelope([delta({ reasoning_content: '继续', content: '新预览' })], { run_id: 'run-2' }), 'messages', '1-0', 'run-2', 'thread-1', 'request-1'); - expect(next).toMatchObject({ cursor: '1-0', reasoning: '之前\n\n继续', content: '新预览', ended: false }); + expect(next).toMatchObject({ cursor: '1-0', messages: [{ run_id: 'run-1', reasoning_content: '之前', content: '旧预览' }, { run_id: 'run-2', reasoning_content: '继续', content: '新预览' }], ended: false }); const other = consumeCloudChatEvent(next, envelope([delta({ reasoning_content: '新问题' })], { run_id: 'run-3', request_id: 'request-3' }), 'messages', '1-0', 'run-3', 'thread-1', 'request-3'); - expect(other).toMatchObject({ reasoning: '新问题', content: '' }); + expect(other).toMatchObject({ messages: [{ reasoning_content: '新问题', content: '' }] }); }); }); diff --git a/tests/unit/cloud-knowledge-import.test.tsx b/tests/unit/cloud-knowledge-import.test.tsx new file mode 100644 index 00000000..8b93b62a --- /dev/null +++ b/tests/unit/cloud-knowledge-import.test.tsx @@ -0,0 +1,345 @@ +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 66b880a7..e83b2689 100644 --- a/tests/unit/cloud-knowledge-picker.test.tsx +++ b/tests/unit/cloud-knowledge-picker.test.tsx @@ -63,7 +63,7 @@ it('never offers shared libraries to document management or mounts their mutatin 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.queryByRole('button', { name: '从电脑上传', exact: true })).not.toBeInTheDocument(); 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.queryByRole('button', { name: '从电脑上传', exact: true })).not.toBeInTheDocument(); failed = false; fireEvent.click(screen.getByRole('button', { name: '刷新知识库', exact: true })); await screen.findByText('MakeLore知识 · 只读'); @@ -116,9 +116,9 @@ it('selects an existing owned library for uploads and cancelling a file picker d await openOwn(); expect(changes).toHaveBeenLastCalledWith(expect.objectContaining({ knowledges: ['shared', own.kb_id] })); api.uploadKnowledge.mockResolvedValue(null); - fireEvent.click(screen.getByRole('button', { name: '上传文档', exact: true })); + fireEvent.click(screen.getByRole('button', { name: '从电脑上传', exact: true })); await waitFor(() => expect(api.uploadKnowledge).toHaveBeenCalledTimes(1)); - await waitFor(() => expect(screen.getByRole('button', { name: '上传文档', exact: true })).toBeEnabled()); + await waitFor(() => expect(screen.getByRole('button', { name: '从电脑上传', exact: true })).toBeEnabled()); expect(api.call.mock.calls.every(([operation]) => operation !== 'processKnowledge')).toBe(true); expect(screen.getByRole('button', { name: '移除知识库 创作指南' })).toBeVisible(); }); @@ -180,7 +180,7 @@ it('keeps an uncertain upload scoped to its library through retry cancellation a const pending: Record = {}; render( { pending[id] = value; }}>); await openOwn(); - fireEvent.click(screen.getByRole('button', { name: '上传文档', exact: true })); + fireEvent.click(screen.getByRole('button', { name: '从电脑上传', exact: true })); await screen.findByText('上传结果未确认'); expect(Object.values(pending).some(Boolean)).toBe(true); const manager = screen.getByRole('combobox', { name: '管理的知识库' }); @@ -224,7 +224,7 @@ it('preserves uncertain processing while refreshing and releases it when the ser it('retains unconfirmed operations in a disabled manager if its owned library disappears and requires explicit discard', async () => { api.uploadKnowledge.mockRejectedValue(new Error('上传结果未确认')); render(); await openOwn(); - fireEvent.click(screen.getByRole('button', { name: '上传文档', exact: true })); + fireEvent.click(screen.getByRole('button', { name: '从电脑上传', exact: true })); await screen.findByText('上传结果未确认'); const before = api.call.mock.calls.filter(([operation]) => operation === 'knowledgeFiles').length; databases = []; fireEvent.click(screen.getByRole('button', { name: '刷新知识库与模型' })); @@ -249,7 +249,7 @@ it('automatically processes a confirmed upload that finishes while both knowledg }); api.uploadKnowledge.mockImplementation(() => new Promise(value => { finishUpload = value; })); render(); await openOwn(); - fireEvent.click(screen.getByRole('button', { name: '上传文档', exact: true })); + fireEvent.click(screen.getByRole('button', { name: '从电脑上传', exact: true })); holdRefresh = true; fireEvent.click(screen.getByRole('button', { name: '刷新目录' })); await screen.findByRole('option', { name: '正在读取我的知识库…' }); await act(async () => finishUpload({ file_id: 'confirmed', name: '资料.txt', size: 10, status: 'uploaded', chunk_count: 0, available: false, error: null })); @@ -270,7 +270,7 @@ it('finishes an authorized upload through a failed ownership refresh and does no }); api.uploadKnowledge.mockImplementation(() => new Promise(value => { finishUpload = value; })); render(); await openOwn(); - fireEvent.click(screen.getByRole('button', { name: '上传文档', exact: true })); + fireEvent.click(screen.getByRole('button', { name: '从电脑上传', exact: true })); holdRefresh = true; fireEvent.click(screen.getByRole('button', { name: '刷新知识库', exact: true })); await screen.findByRole('option', { name: '正在读取我的知识库…' }); await act(async () => rejectRefresh(new Error('目录连接失败'))); @@ -316,6 +316,6 @@ it('keeps an owned but unusable library manageable without adding an invalid bin await screen.findByText('上传第一份文档,让智能体用你的资料回答。'); expect(screen.getByText('这个知识库当前暂不可选用。你仍可管理自己的文件,刷新目录后再试。')).toBeVisible(); expect(changes).toHaveBeenCalledTimes(before); - expect(screen.getByRole('button', { name: '上传文档', exact: true })).toBeEnabled(); + expect(screen.getByRole('button', { name: '从电脑上传', exact: true })).toBeEnabled(); expect(changes.mock.calls.at(-1)?.[0].knowledges).toEqual(['shared']); }); diff --git a/tests/unit/cloud-knowledge-status.test.tsx b/tests/unit/cloud-knowledge-status.test.tsx index e6814b60..2b029716 100644 --- a/tests/unit/cloud-knowledge-status.test.tsx +++ b/tests/unit/cloud-knowledge-status.test.tsx @@ -3,13 +3,15 @@ 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() })); +const api = vi.hoisted(() => ({ call: vi.fn(), uploadKnowledge: vi.fn(), list: 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.list.mockResolvedValue({ agents: [], next_cursor: null }); api.call.mockImplementation(async operation => { + if (operation === 'received') return { agents: [] }; 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') { @@ -33,7 +35,7 @@ it('uploads then processes automatically and refreshes through queue, parse, ind await screen.findByText('上传第一份文档,让智能体用你的资料回答。'); vi.useFakeTimers(); api.uploadKnowledge.mockImplementation(async () => { items = [file]; return file; }); - await act(async () => { fireEvent.click(screen.getByRole('button', { name: '上传文档', exact: true })); }); + 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' })); @@ -74,7 +76,7 @@ it('retains the saved upload and processing identity after an uncertain submissi }); api.uploadKnowledge.mockImplementation(async () => { items = [file]; return file; }); await open(); - fireEvent.click(screen.getByRole('button', { name: '上传文档', exact: true })); + 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: '重试处理' })); @@ -169,12 +171,10 @@ it('imports an attachment and processes it without claiming immediate readiness' 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: '确认导入知识库' })); + 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(); });