diff --git a/.project-docs/30-worklog/tasks/20261013-chat-knowledge-import-explain-ec03b792.md b/.project-docs/30-worklog/tasks/20261013-chat-knowledge-import-explain-ec03b792.md index 1cb27180..11301319 100644 --- a/.project-docs/30-worklog/tasks/20261013-chat-knowledge-import-explain-ec03b792.md +++ b/.project-docs/30-worklog/tasks/20261013-chat-knowledge-import-explain-ec03b792.md @@ -8,7 +8,7 @@ - Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261013-chat-knowledge-import-explain-ec03b792 - Base commit: c634425cc669f1da66e725649a386bf09661a70c - Owner: codex -- Status: Ready for Integration +- Status: Planning ## Scope @@ -19,7 +19,7 @@ - User initially requested explanation; the latest explicit instruction authorizes removing chat-file import and retaining computer upload. This supersedes the previous feature's approved chat-import entry. - Earlier steering placed creation beneath computer upload; the latest explicit layout supersedes that button order: the left creation card contains only the new-library entry/form; owned-library browsing and creation do not bind a library to the Agent. Binding remains an explicit bottom selection, and its 100-library limit does not block management or creation. Delete the old instruction paragraph and 「添加到我的知识库」 label. -- Keep real files/chats/libraries, runtime, Main/backend APIs and old recovery records. No actual imports, deletion of user data, app update, main integration or push. Own only removal-related UI/copy/style/tests and this record. +- Keep real files/chats/libraries, Main/backend APIs and old recovery records. The user's latest instruction explicitly authorizes updating the current temporary application; it supersedes the earlier no-update constraint. No actual imports, deletion of user data, main integration or push. Preserve current deployed homepage/recovery/chat and byte-identical Main by assembling only committed current product changes before the knowledge Renderer overlay. ## Outcome @@ -62,3 +62,12 @@ - Restructure owned-library UI into a top two-column creation/management row and a bottom binding card. Remove creation injection from the document list and automatic bindings on create/browse; preserve pending operations and permission-loss state. - Update README and focused tests. Independently verify creation/browsing with full binding selection and verify 375px/1440px geometry, upload/status, explicit selection/save/reopen. - Run focused knowledge suites, typecheck, scoped lint, build and headless fixture; inspect screenshots and independent source review, verify task document boundaries and commit the source handoff. + +## Temporary Application Follow-up + +- Concurrent Gate: bundled start resumed this same task successfully; status exactly matches feature/task/branch/absolute worktree/base. All peer Scope/Intent/Promotion records read-only. Required project context is retained and startup/positioning/current-state rechecked. Canonical current-state is historical integration context, not the deployed runtime authority. +- Planning Gate: Passed for isolated assembly and staging. Active 「首页更新」 chat is correcting its homepage/account/settings UI and owns concurrent work for the same temporary runtime. Keep its dirty source read-only; bind to the latest committed/verified deployed source and fresh exact current pointer. An active chat need not finish all future steering; serialize actual refresh via inspector exclusivity and pointer/asset identity guards, and rebase if the pointer advances. Unknown legacy scope has no concrete dependency. +- User explicitly authorizes updating the existing trial with finished knowledge source 8e0e52f. Current observed Main is 7c51bb4 / PID9968 / port58003; current Renderer/effective source initially a9fa221 over the home-position overlay. Keep profile/standard login, stable file URL and route, current accepted homepage/recovery/chat, and foreground continuity. No account action, save/discard, real upload/Agent creation/chat, cloud deployment or credential read during verification. +- Plan: assemble the finished knowledge delta over the latest committed deployed product in this owned worktree, keeping canonical/peer task docs read-only. Verify relevant combined source tests/typecheck/lint/build and full Main/resources/package byte equality; prepare a task-owned immutable snapshot with rollback and bound predecessor proof. Activate only after the homepage update settles and a fresh saved/quiet-state check passes; verify actual knowledge layout and original route/session/assets/focus, retaining all rollback artifacts. +- Assembly implemented over deployed a9fa221: imported its committed product/test changes, preserving only this task's knowledge source/README delta. Actual product blobs differ in exactly the four knowledge source/styles files and removed attachment picker; Electron/shared/resources/package/lock match deployed source. Knowledge fixture keeps a9's normalizePath portability fixes; page regression keeps its create/recovery cases and this task's removed old heading click. Independent read-only source review confirms complete current create/journal/onSettled/CloudRecovery/chat DTO/history/stream/message-rendering chains retained. +- Combined verification before staging: 10 focused unit files / 224 tests passed; typecheck and full build passed; 38 changed source/test ESLint files have no errors and retain two existing CloudChat warnings. Knowledge fixture 5/5 passed in 18.2s, including 375/768/1024/1440 geometry and explicit binding/upload-to-ready/save-reopen. All 66 non-Renderer artifacts exactly match immutable running Main/resources/package. The first set comparison detected two unreferenced leftover chunks from this worktree's earlier build; verified that no current JS references them and removed only those generated files, then repeated the strict exact-set comparison successfully. diff --git a/README.md b/README.md index bf64b5ef..fa404db3 100644 --- a/README.md +++ b/README.md @@ -8,17 +8,19 @@ Code 会话保存的模型被下架后,会提示重新选择可用模型;用户可在原会话中切换,恢复时使用新选择并保留会话与历史消息。切换不会自动重发消息或自动选择替代模型。 -Makelore 是一个面向软件、视觉创作、智能机器人与个人云智能体的 AI 桌面工作台。当前版本为 `2.0.0`,源码提供四个模块入口;云智能体需配套配置 WS/Yuxi 服务。模块入口页按 Agents、Code、Canvas、Robot 排列,统一采用横向插画卡片;Agents 标语为“打造你想象中的AI助手”。工作区左上角入口点击后返回模块入口页: +Makelore 是一个面向软件、视觉创作、智能机器人与个人云智能体的 AI 桌面工作台。当前版本为 `2.0.0`,首页展示 Agents、Code、Canvas 三个模块入口,Robot 入口隐藏;云智能体需配套配置 WS/Yuxi 服务。桌面采用左侧问候与标题、右侧三个横向插画卡片竖排的布局,Code 入口中心与“今天,想创作什么?”标题中心对齐,三个入口等高、间距均匀;标题组在桌面向右偏移约 1.5cm,小窗口将标题放在卡片上方;Agents 标语为“打造你想象中的AI助手”。首页左下角保留半尺寸的账户入口,向左偏移约 1.5cm(小窗口保留边距)并靠近底部;展开菜单保持原尺寸,可查看本人词元点数与预占、打开词元点数与充值、个人资料、设置和退出登录;沿用侧栏的账号、钱包和资料规则。工作区左上角入口点击后返回模块入口页: - `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 共享目录,只显示名称与简介;移除仅取消当前智能体选用,不删除原库。知识库区域上方左右分区:左侧「上传自己的文件」仅放「新建知识库」入口及展开的创建表单,右侧「查看我的知识库」通过下拉框查看本人知识库、从电脑追加资料和管理文件;下方独立「选择知识库」,选择智能体使用的「MakeLore知识」和「我的知识库」。小窗口按新建、查看、选用顺序纵向排列。创建成功后在右侧打开新库;新建和查看均不会自动加入智能体配置,选用上限不影响管理与新建。仅提供「从电脑上传」作为添加文件入口,未选择管理目标时上传按钮置灰;聊天文件导入入口已移除。上传后自动解析、建立索引并持续显示真实处理状态;失败可重试且保留已有可用内容。使用说明与费用以折叠说明呈现。暂不可选用的个人库仍可管理文件,已有绑定保留并提示不可用;未确认请求在当前库保留同一操作身份供重试。知识库选择只改草稿,显式保存/应用后生效;个人云端执行已自动接入所选知识库的检索,无需另外勾选 knowledge-base Skill。此交互复用现有接口,无需新增后端补丁。 云知识库的 embedding 模型只由平台管理员在 Yuxi 后台维护:在「智能体管理 → 模型供应商」启用供应商,按供应商要求填写有效的服务地址和 API Key,添加并启用 embedding 模型并填写实际向量维度;embedding 向量化费用由平台承担,不计入用户用量、词元点数或智能体费用上限;one-api 不参与 embedding 配置,Works Square 只负责其他应计费模型调用的创建者词元点数账本。客户端区分目录加载中、加载失败与无可用模型,空状态提供管理员配置说明;「刷新知识库与模型」保留未提交名称,未确认的创建请求沿用原操作身份。 - Agents 侧栏的「渠道」用于把自己的智能体接到微信。每个微信一张卡,展示备注、绑定的智能体头像/名称和是否可用;一个账号同时只有一个目标,多个账号可以使用同一智能体。添加流程为选择自己已开始使用的智能体、扫码连接、明确完成绑定并启用;备注可选,扫码确认后收起二维码与验证码,未确认或启用失败不显示完成。管理已有账号不会重新创建账号;更换目标保留微信登录与原启停状态,解除智能体绑定清空目标并停用但保留微信登录,暂停保留绑定/登录并停止该渠道当前任务,断开微信连接则需要重新扫码。更换、解除和断开有明确确认,历史保留。渠道配置页不展示微信对话或活动,不读取这些列表;聊天在微信中进行,既有云端历史、文件、审批、费用和账号隔离合同不变。页面保留加载失败重试、未确认操作恢复,创建后绑定失败可继续使用原账号,重试沿用原操作身份;仅添加/扫码不自动启用。实际微信身份与重复扫码识别仍由云端负责,客户端不以备注推测同一微信。渠道触发的应计费模型调用仍由智能体创建者支付,Main 持有认证与恢复;用户无需填写服务凭据。应用 API 管理独立于微信渠道。 + Agents 侧栏的「渠道」用于把自己的智能体接到微信。每个微信一张卡,展示备注、绑定的智能体头像/名称和是否可用;一个账号同时只有一个目标,多个账号可以使用同一智能体。没有可连接的智能体时,渠道页和添加窗口说明需先创建、配置并点击“开始使用”,提供“去我的智能体”入口;加载失败不显示该空态,已有微信卡片仍可管理。添加流程为选择自己已开始使用的智能体、扫码连接、明确完成绑定并启用;备注可选,扫码确认后收起二维码与验证码,未确认或启用失败不显示完成。管理已有账号不会重新创建账号;更换目标保留微信登录与原启停状态,解除智能体绑定清空目标并停用但保留微信登录,暂停保留绑定/登录并停止该渠道当前任务,断开微信连接则需要重新扫码。更换、解除和断开有明确确认,历史保留。渠道配置页不展示微信对话或活动,不读取这些列表;聊天在微信中进行,既有云端历史、文件、审批、费用和账号隔离合同不变。页面保留加载失败重试、未确认操作恢复,创建后绑定失败可继续使用原账号,重试沿用原操作身份;仅添加/扫码不自动启用。实际微信身份与重复扫码识别仍由云端负责,客户端不以备注推测同一微信。渠道触发的应计费模型调用仍由智能体创建者支付,Main 持有认证与恢复;用户无需填写服务凭据。应用 API 管理独立于微信渠道。 - `Makelore Canvas|AI 绘画`:每个设计项目(Workspace)维护一份从创建起就存在的 Living Form。左侧项目栏负责新建、切换和管理 Workspace,并在桌面设计模式下以 256px 宽度常驻展开;中央沿用 AI 编程的安静对话画布、自然消息流和底部悬浮输入器,AI 整理出的制作方案作为对话内的轻量可编辑稿持续更新;桌面端右侧同为 256px 的全高历史作品栏集中展示当前项目的制作记录与生成结果。任务中的已生成图片可通过“放大查看图片”按钮直接打开大图,支持适应窗口、原始尺寸及 Esc 关闭,无需先下载。紧凑窗口通过左侧抽屉访问项目列表,历史记录保留在时间线中。参考图从本地上传后以 `@图片N` 绑定,具体用法只写在创作提示词中。 - `Makelore Robot|AI 机器`:管理机器人智能体、设备激活绑定、智能体配置与设备分配;机器人工作台的智能体位于 Robot 全局侧栏,选中后在内容区先查看绑定设备、再查看基础设置,当前智能体通过 URL 参数保持可分享选择;绑定设备时默认先选择“引导配网”或“已有激活码”。在 Windows 与 macOS 的引导路径中,Makelore 可在弹窗内扫描并连接附近开放的 `Xiaozhi-*` 配网热点,失败时仍可通过系统 Wi-Fi 手动连接;后续继续复用机器人现有热点配网页面,不修改固件,也不由 Makelore 接收 Wi-Fi 密码。 @@ -27,6 +29,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 +45,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/components/account/HomeAccountMenu.tsx b/src/components/account/HomeAccountMenu.tsx new file mode 100644 index 00000000..edc0b1ba --- /dev/null +++ b/src/components/account/HomeAccountMenu.tsx @@ -0,0 +1,103 @@ +import { useEffect, useRef, useState } from 'react'; +import { ChevronUp, LogOut, Settings, UserCircle2 } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; +import { UserAvatar } from '@/components/profile/UserAvatar'; +import { getAccountInitial } from '@/components/profile/user-avatar-utils'; +import { DisclosureContent } from '@/components/ui/disclosure'; +import { useAuthStore } from '@/stores/auth'; +import { PointWallet } from './PointWallet'; + +export function HomeAccountMenu({ accountName, avatarUrl, onOpenProfile }: { + accountName: string; + avatarUrl?: string | null; + onOpenProfile: () => void; +}) { + const user = useAuthStore(state => state.user); + const logout = useAuthStore(state => state.logout); + const navigate = useNavigate(); + const [open, setOpen] = useState(false); + const [loggingOut, setLoggingOut] = useState(false); + const [error, setError] = useState(''); + const trigger = useRef(null); + const menu = useRef(null); + const initial = getAccountInitial(accountName); + + useEffect(() => { + if (!open) return; + const outside = (event: MouseEvent) => { + if (event.target instanceof Node && !trigger.current?.contains(event.target) && !menu.current?.contains(event.target)) setOpen(false); + }; + const escape = (event: KeyboardEvent) => { + if (event.key !== 'Escape' || event.defaultPrevented) return; + setOpen(false); + trigger.current?.focus(); + }; + document.addEventListener('mousedown', outside); + document.addEventListener('keydown', escape); + return () => { + document.removeEventListener('mousedown', outside); + document.removeEventListener('keydown', escape); + }; + }, [open]); + + const focusItem = (last = false) => requestAnimationFrame(() => { + if (trigger.current?.getAttribute('aria-expanded') !== 'true') return; + const items = menu.current?.querySelectorAll('[role="menuitem"]:not(:disabled)'); + if (items?.length) items[last ? items.length - 1 : 0].focus(); + }); + + if (!user) return null; + + return
+ + { + if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return; + event.preventDefault(); + const items = [...(menu.current?.querySelectorAll('[role="menuitem"]:not(:disabled)') ?? [])]; + const current = items.indexOf(document.activeElement as HTMLButtonElement); + const index = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 + : (current + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length; + items[index]?.focus(); + }}> +
+ +

{accountName}

词元点数账户

+
+ +
+ + + +
+ {error &&

{error}

} +
+
; +} diff --git a/src/pages/CloudAgents/ChannelAccounts.tsx b/src/pages/CloudAgents/ChannelAccounts.tsx index 95dde3f9..10c3b269 100644 --- a/src/pages/CloudAgents/ChannelAccounts.tsx +++ b/src/pages/CloudAgents/ChannelAccounts.tsx @@ -251,6 +251,11 @@ export function ChannelAccounts() { {flow?.target && !targetAgent && selected?.target_agent_slug === flow.target && } {agents.map(agent => )} ; + const agentRequiredHint =
+

还没有可连接的智能体

+

先创建一个智能体,配置完成后点击“开始使用”,就能连接到微信。

+ +
; return
@@ -266,12 +271,13 @@ export function ChannelAccounts() {
{!flow && messages} + {!loading && loaded && !agents.length && !flow && agentRequiredHint} {loading ?
正在查看你的微信绑定…
: !loaded ?

暂时没能读到微信绑定,请刷新后再试。

- : !accounts.length ?
+ : !accounts.length ? (agents.length > 0 &&

把你的智能体带到微信吧

绑定后,就可以回到微信和它聊聊。每个微信绑定一个智能体,也可以把多个微信交给同一个智能体。

-
:
+
) :
{accounts.map(account => { const agent = agents.find(item => item.slug === account.target_agent_slug); const state = accountState(account); @@ -298,7 +304,7 @@ export function ChannelAccounts() { {recoveryBlocked &&

有一次绑定操作的结果需要确认,请先关闭此窗口并处理页面上的恢复提醒。

} {flow.step === 'choose' && <> {flow.mode === 'manage' && selectedState &&
微信连接与绑定{selectedState.text}
} - {!agents.length ?

先准备一个可以使用的智能体,再把它带到微信里。

+ {!agents.length ? agentRequiredHint : } {flow.mode === 'new' && !flow.accountId && } {flow.mode === 'manage' && selected?.blockers.length ?

{selected.blockers.map(item => blockerText[item] ?? '连接暂时不可用,请刷新后重试').join(';')}

: 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/CloudLifecycle.tsx b/src/pages/CloudAgents/CloudLifecycle.tsx index 69cae026..4241ffd9 100644 --- a/src/pages/CloudAgents/CloudLifecycle.tsx +++ b/src/pages/CloudAgents/CloudLifecycle.tsx @@ -6,7 +6,7 @@ import type { CloudAgentDraft, CloudVersion } from '../../../shared/cloud-agents import { usePendingCloudInput } from './CloudPending'; const configurationLabels: Record = { - model: '模型', tools: '工具', knowledges: '知识库', mcps: 'MCP 连接', skills: 'Skills', preload_skills: '预加载 Skills', + model: '模型', tools: '工具', knowledges: '知识库', mcps: 'MCP 连接', skills: '技能', preload_skills: '提前读好的技能', subagents: '子智能体', tool_approval_mode: '工具确认方式', max_execution_steps: '最多执行步数', max_output_tokens: '每次输出词元上限', max_run_seconds: '最长执行秒数', }; diff --git a/src/pages/CloudAgents/CloudMessageBody.tsx b/src/pages/CloudAgents/CloudMessageBody.tsx new file mode 100644 index 00000000..74b4d964 --- /dev/null +++ b/src/pages/CloudAgents/CloudMessageBody.tsx @@ -0,0 +1,36 @@ +import ReactMarkdown, { defaultUrlTransform } from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import type { CloudToolCall } from '../../../shared/cloud-agents'; + +const display = (value: unknown) => typeof value === 'string' ? value : value == null ? '' : JSON.stringify(value, null, 2); + +export function CloudMessageBody({ content, reasoning_content, tool_calls = [], active = false, onDownload }: { + content: string; reasoning_content?: string; tool_calls?: CloudToolCall[]; active?: boolean; + onDownload?: (path: string) => void; +}) { + return
+ {(reasoning_content || tool_calls.length > 0) &&
+ {reasoning_content ? '思考过程' : '工具调用'}{tool_calls.length > 0 && ' · ' + tool_calls.length + ' 个工具'}{active && !content ? ' · 进行中' : ''} +
+ {reasoning_content &&
{reasoning_content}
} + {tool_calls.map(tool =>
+ {tool.display_name || tool.name} · {tool.status === 'success' ? '已完成' : tool.status === 'error' ? '失败' : active ? tool.status === 'running' ? '执行中' : '准备调用' : '未完成'} +
+ {tool.display_description &&

{tool.display_description}

} + {tool.display_name && tool.display_name !== tool.name &&

工具标识:{tool.name}

} +

输入

{display(tool.args)}
+ {tool.tool_call_result != null && <>

结果

{display(tool.tool_call_result.content)}
} + {tool.error_message &&

{tool.error_message}

} +
+
)} +
+
} + {content &&
+ url.startsWith('sandbox:/') ? url.slice(8) : defaultUrlTransform(url)} remarkPlugins={[remarkGfm]} components={{ + a: ({ href, children }) => href && !/^(https?:|mailto:|#)/i.test(href) + ? onDownload ? : {children} + : {children}, + }}>{content} +
} +
; +} diff --git a/src/pages/CloudAgents/CloudRecovery.tsx b/src/pages/CloudAgents/CloudRecovery.tsx index 0cb1c84e..8a2506fd 100644 --- a/src/pages/CloudAgents/CloudRecovery.tsx +++ b/src/pages/CloudAgents/CloudRecovery.tsx @@ -1,6 +1,10 @@ -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { History, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; import { cloudAgentsApi } from '@/lib/cloud-agents-api'; +import { AppError } from '@/lib/error-model'; import type { CloudPendingOperation } from '../../../shared/cloud-agents'; const labels: Record = { @@ -15,43 +19,92 @@ const labels: Record = { }; const retiredChannelOperations = new Set(['channelPolicy', 'createChannelPairing', 'channelCallers', 'revokeChannelCaller']); -export function CloudRecovery({ pending, onResolved }: { pending: CloudPendingOperation[]; onResolved: () => void }) { +export function CloudRecovery({ pending, onResolved, triggerContainer }: { pending: CloudPendingOperation[]; onResolved: () => void; triggerContainer?: HTMLElement | null }) { + const [open, setOpen] = useState(false); const [busy, setBusy] = useState(''); const [error, setError] = useState(''); const [notice, setNotice] = useState(''); - const [result, setResult] = useState<{ operation: string; value: unknown } | null>(null); + const [result, setResult] = useState<{ operation: string; name?: string; value: unknown } | null>(null); + const inFlight = useRef(false); + const awaitingKeyClose = useRef(false); + const alive = useRef(true); + const creationOnly = pending.length > 0 ? pending.every(item => item.operation === 'createAgent') : result?.operation === 'createAgent'; + const title = creationOnly ? '创建记录' : '待处理记录'; + useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []); + useEffect(() => { if (triggerContainer === null && !busy) setOpen(false); }, [triggerContainer, busy]); + const close = () => { if (!inFlight.current) setOpen(false); }; const resolve = async (item: CloudPendingOperation, discard = false) => { + if (inFlight.current || awaitingKeyClose.current) return; + inFlight.current = true; setBusy(item.id); setError(''); setNotice(''); try { const response = await cloudAgentsApi.resolvePending(item.id, discard); + if (!alive.current) return; if (response.requires_input) { setResult(null); setNotice('这次微信验证还需要验证码。请进入「渠道 → 微信」,选择对应账号后继续连接验证。'); } else if (response.retired) { setResult(null); setNotice('旧版联系人权限操作已移除,本机恢复记录已丢弃,微信账号的其他操作不受影响。'); - } else if (!discard) setResult({ operation: item.operation, value: response.result }); + } else if (!discard) { + awaitingKeyClose.current = item.operation === 'createKey'; + setResult({ operation: item.operation, + name: typeof item.input.name === 'string' ? item.input.name : undefined, value: response.result }); + } onResolved(); - } catch (e) { setError(e instanceof Error ? e.message : '暂时无法确认结果'); } - finally { setBusy(''); } + } catch (e) { + if (!alive.current) return; + const message = e instanceof Error ? e.message : '这次没能完成,请稍后再试。'; + if (e instanceof AppError && e.details?.commandOutcome === 'definitive_failure') { + setError(`${labels[item.operation] ?? '操作'}失败:${message}`); + onResolved(); + } else setError(message); + } + finally { inFlight.current = false; if (alive.current) setBusy(''); } }; - if (!pending.length && !result && !error && !notice) return null; - return ; + {!pending.length && !result && !error && !notice &&

记录已经处理完了。

} + {pending.length > 0 &&

不再提醒只会关闭提醒,已经保存的内容会保留。

} +
+ + + ; } diff --git a/src/pages/CloudAgents/CloudSchedules.tsx b/src/pages/CloudAgents/CloudSchedules.tsx index dcdfc14e..11aa176b 100644 --- a/src/pages/CloudAgents/CloudSchedules.tsx +++ b/src/pages/CloudAgents/CloudSchedules.tsx @@ -113,7 +113,7 @@ function ScheduleEditor({ slug, initial, proposal, onClose, onSaved }: { slug: s

启用前确认

当前发布版本 {context.version} · {context.configuration.model}。触发时使用当时的发布版本。

能力:{['tools', 'knowledges', 'mcps', 'skills', 'subagents'].map(key => { - const labels: Record = { tools: '工具', knowledges: '知识库', mcps: 'MCP', skills: 'Skills', subagents: '子智能体' }; + const labels: Record = { tools: '工具', knowledges: '知识库', mcps: 'MCP', skills: '技能', subagents: '子智能体' }; const selected = context.configuration![key as 'tools']; return selected.length ? labels[key] + ':' + selected.join('、') : ''; }).filter(Boolean).join(';') || '基础对话'}

每次最多 {context.configuration.max_execution_steps} 步、{context.configuration.max_run_seconds} 秒,每次模型输出最多 {context.configuration.max_output_tokens} 词元。

diff --git a/src/pages/CloudAgents/ConfigurationFields.tsx b/src/pages/CloudAgents/ConfigurationFields.tsx index 5979fcb6..8d8c8f63 100644 --- a/src/pages/CloudAgents/ConfigurationFields.tsx +++ b/src/pages/CloudAgents/ConfigurationFields.tsx @@ -76,7 +76,7 @@ export function ConfigurationFields({ slug, value, disabled, onChange, section = } {key === 'skills' && value.skills.length > 0 && } + onChange={e => change('preload_skills', e.target.checked ? [...value.skills] : [])} />开始对话前,先读好已选做事手册的使用方法} ; })}
; diff --git a/src/pages/CloudAgents/agent-home.css b/src/pages/CloudAgents/agent-home.css index d29e0de5..1599c998 100644 --- a/src/pages/CloudAgents/agent-home.css +++ b/src/pages/CloudAgents/agent-home.css @@ -61,6 +61,26 @@ .agent-home-loading svg { width: 38px; height: 38px; color: #8273a9; } .agent-home-error { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 24px; padding: 12px 16px; border: 1px solid #e3dfd6; border-radius: 14px; background: #fff; font-size: 14px; } .agent-home-error p { overflow-wrap: anywhere; } +.agent-recovery-slot:empty { display: none; } +.agent-recovery-trigger { gap: 7px; color: #606c7e; font-size: 13px; } +.agent-recovery-count { display: inline-grid; min-width: 21px; height: 21px; padding-inline: 5px; place-items: center; border-radius: 8px; background: #ede9f6; color: #7161a5; font-size: 11px; } +.agent-recovery-dialog.agent-recovery-modal { display: flex; max-height: calc(100dvh - 32px); flex-direction: column; gap: 20px; border-radius: 24px; } +.agent-recovery-dialog .agent-recovery-heading { flex-shrink: 0; flex-direction: row; align-items: flex-start; justify-content: space-between; gap: 12px; text-align: left; } +.agent-recovery-heading > div { min-width: 0; } +.agent-recovery-heading > button { flex-shrink: 0; margin-top: -8px; margin-right: -8px; } +.agent-recovery-heading p { margin-top: 8px; line-height: 1.7; } +.agent-recovery-content { display: flex; min-height: 0; flex-direction: column; gap: 14px; overflow: auto; overscroll-behavior: contain; font-size: 14px; } +.agent-recovery-record { padding: 16px; border: 1px solid #e5e0ef; border-radius: 16px; background: #f8f6fd; overflow-wrap: anywhere; } +.agent-recovery-record h3 { font-weight: 600; line-height: 1.6; } +.agent-recovery-time { margin-top: 3px; color: #788193; font-size: 12px; } +.agent-recovery-record-help { margin-top: 10px; color: #606c7e; line-height: 1.7; } +.agent-recovery-record-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; } +.agent-recovery-record-actions button { min-height: 40px; border-radius: 10px; } +.agent-recovery-footnote { color: #788193; font-size: 12px; line-height: 1.7; } +.agent-recovery-result { padding: 16px; border: 1px solid #dbe9df; border-radius: 16px; background: #f2f8f3; line-height: 1.7; overflow-wrap: anywhere; } +.agent-recovery-result pre { overflow: auto; } +.agent-recovery-footer { flex-shrink: 0; } +.agent-recovery-footer button { min-height: 44px; } @media (max-width: 1150px) { .agent-home { padding: 32px 28px 40px; } } 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..474b7694 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'; @@ -39,6 +40,7 @@ function AgentWorkspace() { const [deleteTarget, setDeleteTarget] = useState(null); const [deleting, setDeleting] = useState(false); const [deleteError, setDeleteError] = useState(''); + const [recoveryTarget, setRecoveryTarget] = useState(null); const cancelDelete = useRef(null); const deleteTrigger = useRef(null); const createButton = useRef(null); @@ -114,7 +116,6 @@ function AgentWorkspace() { void cloudAgentsApi.remember(null).catch(failure => { if (mounted.current) setError(errorMessage(failure)); }); refreshRecovery(); }; - const recovery = ; const accepted = (agent: CloudAgentDraft) => { if (!mounted.current) return; @@ -155,22 +156,21 @@ function AgentWorkspace() { } }; - if (selected) return
{recovery} { + const renderWorkspace = () => { + if (selected) return
{ setSelected(null); setEntryView(undefined); setStartingPrompt(undefined); clearRecent(); }} onSaved={accepted} />
; - if (shared || activity) return
{recovery} { setShared(null); setActivity(null); clearRecent(); }} />
; + if (shared || activity) return
{ 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(); }} />
; return (
- {recovery}
@@ -183,6 +183,7 @@ function AgentWorkspace() {
}
+
{(section !== 'mine' || loading || error || agents.length > 0) && }
@@ -240,9 +241,13 @@ function AgentWorkspace() {
); + }; + return <>{renderWorkspace()}; } -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 +255,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 +277,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 +301,7 @@ function CreateAgentForm({ onCreated, onCancel }: { onCreated: (agent: CloudAgen - {error &&

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

} + {error &&

{error}

}
diff --git a/src/pages/ModuleSelection/index.tsx b/src/pages/ModuleSelection/index.tsx index 91989e4a..9d39cd6e 100644 --- a/src/pages/ModuleSelection/index.tsx +++ b/src/pages/ModuleSelection/index.tsx @@ -6,6 +6,7 @@ import moduleGameImage from '@/assets/module-game.webp'; import moduleRobotImage from '@/assets/module-robot.webp'; import logoWordmarkSource from '@/assets/makelore-wordmark-source.png'; import { UserProfileDialog } from '@/components/profile/UserProfileDialog'; +import { HomeAccountMenu } from '@/components/account/HomeAccountMenu'; import { useCurrentUserProfile } from '@/hooks/use-current-user-profile'; import { aiModules, isAiModuleAllowed, type AiModuleId } from '@/lib/ai-modules'; import { getAuthUserDisplayName } from '@/lib/auth-user-display'; @@ -29,6 +30,7 @@ export function ModuleSelection({ authRequired = true }: { authRequired?: boolea userProfile, profileRequired, profileSyncError, + syncProfileNow, } = useCurrentUserProfile(authRequired); const [profileDialogOpen, setProfileDialogOpen] = useState(false); const userDisplayName = getUserProfileDisplayName(userProfile) || getAuthUserDisplayName(authUser); @@ -48,8 +50,8 @@ export function ModuleSelection({ authRequired = true }: { authRequired?: boolea }; return ( -
-
+
+
-
-
-

+

+
+

{userDisplayName ? `${userDisplayName},欢迎回来!` : '欢迎回来!'}

-

今天,想创作什么?

+

今天,想创作什么?

- {aiModules.map((module) => { + {aiModules.filter((module) => module.id !== 'robot').map((module) => { const content = moduleSelectionContent[module.id]; const enabledByPolicy = isAiModuleAllowed(module.id, moduleAccess); const enabled = module.enabled && enabledByPolicy; @@ -114,6 +116,13 @@ export function ModuleSelection({ authRequired = true }: { authRequired?: boolea })}
+ {authenticated && authUser &&
+ { + setDismissedSyncError(null); setProfileDialogOpen(true); + void syncProfileNow().catch(() => undefined); + }} /> +
}
{ + 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'); + await app.evaluate(({ ipcMain, BrowserWindow }) => { + BrowserWindow.getAllWindows()[0].setSize(1366, 900); + const calls: { method: string; path: string; operation?: string }[] = []; + Object.assign(globalThis, { cloudChannelEmptyFixtureCalls: calls }); + 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; method?: string; body?: string }) => { + const path = request.path ?? ''; + if (path === '/api/auth/session/sync') return result({ success: true, session: { accessToken: 'ui-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: '创作者', age: null, gender: null, avatar_url: null, share_age_with_agents: false, share_gender_with_agents: false, analysis_enabled: true, completed: true, version: 1, updated_at: '2026-10-13T00:00:00Z' } }); + if (!path.startsWith('/api/cloud-agents/')) return result({ success: true }); + const body = typeof request.body === 'string' ? JSON.parse(request.body) : request.body ?? {}; + calls.push({ method: request.method ?? 'GET', path, operation: body.operation }); + if (path.endsWith('/recovery')) return result({ recent: null, pending: [] }); + if (path.endsWith('/agents') || path.endsWith('/bootstrap')) return result({ agents: [], next_cursor: null }); + if (path.endsWith('/actions') && body.operation === 'channelAccounts') return result({ items: [] }); + throw new Error('Unexpected empty channel fixture request ' + path); + }); + }); + await page.goto(applicationUrl); + await page.getByTestId('ai-module-option-cloud_agents').click(); + const sidebar = page.getByTestId('sidebar-cloud-agents-navigation'); + await sidebar.getByRole('button', { name: '渠道', exact: true }).click(); + await expect(page).toHaveURL(/\/cloud-agents\/channels$/); + const hint = page.getByRole('heading', { name: '还没有可连接的智能体', exact: true }); + await expect(hint).toBeVisible(); + await expect(page.getByText('先创建一个智能体,配置完成后点击“开始使用”,就能连接到微信。', { exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: '添加第一个微信', exact: true })).toHaveCount(0); + await expect(page.getByRole('button', { name: '添加微信', exact: true })).toBeEnabled(); + await page.screenshot({ path: testInfo.outputPath('wechat-no-agents-desktop.png'), fullPage: true }); + + await page.getByRole('button', { name: '添加微信', exact: true }).click(); + const dialog = page.getByRole('dialog', { name: '添加微信', exact: true }); + await expect(dialog.getByRole('heading', { name: '还没有可连接的智能体', exact: true })).toBeVisible(); + await expect(dialog.getByRole('button', { name: '下一步:扫码连接', exact: true })).toBeDisabled(); + await dialog.getByRole('button', { name: '关闭', exact: true }).click(); + + await app.evaluate(({ BrowserWindow }) => { const window = BrowserWindow.getAllWindows()[0]; window.setMinimumSize(800, 600); window.setSize(980, 680); }); + await expect(hint).toBeVisible(); + await expect.poll(() => page.getByTestId('main-content').evaluate(element => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1); + await page.screenshot({ path: testInfo.outputPath('wechat-no-agents-compact.png'), fullPage: true }); + await page.getByRole('button', { name: '去我的智能体', exact: true }).click(); + await expect(page).toHaveURL(/\/cloud-agents\?view=mine$/); + await expect(page.getByTestId('cloud-agents-page')).toBeVisible(); + await expect(page.getByRole('heading', { name: '做一个属于你的 AI 助手吧', exact: true })).toBeVisible(); + await expect(sidebar.getByRole('button', { name: '我的智能体', exact: true })).toHaveAttribute('aria-current', 'page'); + const calls = await app.evaluate(() => (globalThis as typeof globalThis & { cloudChannelEmptyFixtureCalls: { method: string; operation?: string }[] }).cloudChannelEmptyFixtureCalls); + expect(calls.filter(call => call.operation).map(call => call.operation)).toEqual(['channelAccounts']); + expect(calls.every(call => call.method === 'GET' || call.operation === 'channelAccounts')).toBe(true); + } finally { + await app.evaluate(({ BrowserWindow }) => { for (const window of BrowserWindow.getAllWindows()) window.destroy(); }).catch(() => undefined); + await closeElectronApp(app); + } +}); + test('WeChat binding cards connect an account and switch its Agent through the channel workspace', async ({ launchElectronApp }, testInfo) => { const app = await launchElectronApp({ skipSetup: true }); const agentSlug = 'ml-' + 'a'.repeat(32); 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 e72c75d9..b5bc7dec 100644 --- a/tests/e2e/cloud-agent-knowledge.spec.ts +++ b/tests/e2e/cloud-agent-knowledge.spec.ts @@ -1,5 +1,5 @@ import { test, expect, type Page } from '@playwright/test'; -import { build } from 'vite'; +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'; @@ -23,7 +23,7 @@ test.beforeAll(async () => { const html = join(fixtureDir, 'index.html'); const output = join(fixtureDir, 'dist'); await writeFile(html, '
'); - const entry = resolve('tests/e2e/__cloud-agent-knowledge-harness.tsx'); + 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({ @@ -38,8 +38,8 @@ test.beforeAll(async () => { if (id === entry) return ` import { createRoot } from 'react-dom/client'; import { createMemoryRouter, RouterProvider } from 'react-router-dom'; - import CloudAgents from ${JSON.stringify(resolve('src/pages/CloudAgents/index.tsx'))}; - import ${JSON.stringify(resolve('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(
); `; 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-agent-recovery.spec.ts b/tests/e2e/cloud-agent-recovery.spec.ts new file mode 100644 index 00000000..cd8e58b1 --- /dev/null +++ b/tests/e2e/cloud-agent-recovery.spec.ts @@ -0,0 +1,220 @@ +import { test, expect, type Locator, type Page } from '@playwright/test'; +import { build } from 'vite'; +import { createServer, type Server } from 'node:http'; +import { createRequire } from 'node:module'; +import { mkdtemp, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve, sep } from 'node:path'; +import { EMPTY_CLOUD_CONFIGURATION, type CloudAgentDraft, type CloudPendingOperation } from '../../shared/cloud-agents'; + +let server: Server; +let fixtureDir: string; +let baseUrl: string; +const fixtureRequire = createRequire(resolve('tests/e2e/cloud-agent-recovery.spec.ts')); +const viteDependencies = createRequire(fixtureRequire.resolve('vite/package.json')); +test.use({ headless: true, channel: undefined, video: 'off' }); + +// Exercise the real page and styles with Main IPC mocked. The static fixture +// never starts Electron, a native window, a file picker, or a cloud service. +test.beforeAll(async () => { + fixtureDir = await realpath(await mkdtemp(join(tmpdir(), 'makelore-recovery-fixture-'))); + await symlink(resolve('node_modules'), join(fixtureDir, 'node_modules'), 'dir'); + const html = join(fixtureDir, 'index.html'); + const output = join(fixtureDir, 'dist'); + const entry = resolve('tests/e2e/__cloud-agent-recovery-harness.tsx'); + await writeFile(html, '
'); + await build({ + configFile: false, root: fixtureDir, publicDir: false, logLevel: 'warn', + resolve: { alias: { '@': resolve('src') } }, esbuild: { jsx: 'automatic' }, + css: { postcss: resolve('.') }, + build: { outDir: output, emptyOutDir: true, rollupOptions: { input: html } }, + plugins: [{ + name: 'isolated-cloud-agent-recovery', enforce: 'pre', + resolveId(id) { if (id === '/__cloud-agent-recovery-harness.tsx') return entry; }, + load(id) { + if (id === entry) return ` + import { createRoot } from 'react-dom/client'; + import { createMemoryRouter, RouterProvider } from 'react-router-dom'; + import CloudAgents from ${JSON.stringify(resolve('src/pages/CloudAgents/index.tsx'))}; + import ${JSON.stringify(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: "recovery-fixture" } }); }'; + }, + }], + }); + 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 { res.setHeader('Content-Type', mime[file.split('.').pop()!] ?? 'application/octet-stream'); res.end(await readFile(file)); } + 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(); } + } +}); + +const agent: CloudAgentDraft = { + slug: 'ml-' + 'a'.repeat(32), name: '资料小助手', purpose: '一起观察和记录。', system_prompt: '用简单的中文回答。', + draft_revision: 1, configuration: { ...EMPTY_CLOUD_CONFIGURATION, model: 'fixture-model' }, + published_version: 1, enabled: true, updated_at: '2026-10-13T00:00:00Z', +}; +const longName = ('观察小伙伴_' + 'SuperLongUnbrokenAgentName'.repeat(4)).slice(0, 100); +const records: CloudPendingOperation[] = Array.from({ length: 8 }, (_, index) => ({ + id: `e2e-create-record-${index + 1}`, operation: 'createAgent', + input: { operation_id: `e2e-create-record-${index + 1}`, name: index === 0 ? longName : `我的观察伙伴 ${index + 1}`, purpose: '', icon: null }, + created_at: `2026-10-13T0${8 - index}:30:00Z`, +})); +const retryError = '创建结果暂时无法确认,请稍后再试。'; +type FixtureCall = { path: string; method: string; input: Record | null }; +type RecoveryFixture = { agents: CloudAgentDraft[]; pending: CloudPendingOperation[]; retry: 'fail' | 'success'; calls: FixtureCall[] }; +type FixtureWindow = Window & { __recoveryFixture: RecoveryFixture }; + +async function installFixture(page: Page, count = 2) { + page.on('pageerror', error => { throw error; }); + await page.addInitScript(({ agent, records, retryError }) => { + const scope = window as unknown as FixtureWindow; + const state: RecoveryFixture = { agents: [agent], pending: records, retry: 'fail', calls: [] }; + scope.__recoveryFixture = state; + const result = (json: unknown) => ({ ok: true, data: { status: 200, ok: true, json: structuredClone(json) } }); + Object.defineProperty(window, 'electron', { value: { ipcRenderer: { + invoke: async (channel: string, request?: { path?: string; method?: string; body?: string }) => { + if (channel === 'hostapi:token') return 'fixture'; + if (channel === 'hostapi:base-url') return 'http://fixture.invalid'; + if (channel !== 'hostapi:fetch') throw new Error('Unexpected fixture IPC ' + channel); + const path = request?.path ?? ''; const method = request?.method ?? 'GET'; + const input = request?.body ? JSON.parse(request.body) as Record : null; + state.calls.push({ path, method, input }); + if (path === '/api/cloud-agents/recovery') return result({ recent: null, pending: state.pending }); + if (path === '/api/cloud-agents/recovery/recent') return result({ saved: true }); + if (path === '/api/cloud-agents/recovery/pending') { + const record = state.pending.find(item => item.id === input?.id); + if (!record) throw new Error('Recovery must keep the original pending id'); + if (input?.discard) { state.pending = state.pending.filter(item => item.id !== record.id); return result({ discarded: true }); } + if (state.retry === 'fail') return { ok: true, data: { status: 503, ok: false, json: { error: retryError } } }; + const created = { ...agent, slug: 'ml-' + 'b'.repeat(32), name: String(record.input.name), published_version: null }; + state.agents.push(created); state.pending = state.pending.filter(item => item.id !== record.id); + return result({ operation: 'createAgent', result: created }); + } + if (path.startsWith('/api/cloud-agents/agents')) { + if (method !== 'GET') throw new Error('No new Agent may be created by reminder browsing'); + return result(path === '/api/cloud-agents/agents' ? { agents: state.agents, next_cursor: null } : agent); + } + if (path !== '/api/cloud-agents/actions') throw new Error('Unexpected fixture path ' + path); + switch (input?.operation) { + case 'access': return result({ published_version: 1, enabled: true, share_url: 'niancode://agents/' + agent.slug, + api_url: 'http://fixture.invalid', grants: [], applications: [], versions: [{ version: 1, draft_revision: 1 }] }); + case 'catalog': return result({ models: [{ id: 'fixture-model', name: '创作模型' }], resources: {}, pricing: null }); + case 'knowledge': return result({ databases: [], models: [] }); + case 'threads': return result({ threads: [], next_offset: null }); + default: throw new Error('Unexpected fixture operation ' + String(input?.operation)); + } + }, + } } }); + }, { agent, records: records.slice(0, count), retryError }); +} + +async function recoveryCalls(page: Page) { + return page.evaluate(() => (window as unknown as FixtureWindow).__recoveryFixture.calls.filter(call => call.path === '/api/cloud-agents/recovery/pending')); +} +async function expectNoReminderOutsideDialog(page: Page) { + await expect(page.getByRole('complementary', { name: '未确认的云端操作', exact: true })).toHaveCount(0); + await expect(page.getByText('有 2 个操作的结果尚未确认', { exact: true })).toHaveCount(0); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); +} +async function expectModalFits(dialog: Locator) { + const geometry = await dialog.evaluate(element => { + const box = element.getBoundingClientRect(); + return { fits: box.left >= 0 && box.top >= 0 && box.right <= innerWidth && box.bottom <= innerHeight, + horizontalOverflow: element.scrollWidth > element.clientWidth }; + }); + expect(geometry).toEqual({ fits: true, horizontalOverflow: false }); +} + +for (const width of [1440, 375]) { + test(`creation records stay in the header and use a scrollable modal at ${width}px`, async ({ page }) => { + const count = width === 375 ? 8 : 2; + await page.setViewportSize({ width, height: 850 }); await installFixture(page, count); await page.goto(baseUrl); + const home = page.getByTestId('cloud-agents-page'); + const trigger = home.getByRole('button', { name: new RegExp(`^创建记录.*${count}`) }); + await expect(trigger).toBeVisible(); await expect(page.getByRole('dialog')).toHaveCount(0); + expect(await trigger.evaluate(button => Boolean(button.closest('.agent-home-header')))).toBe(true); + await expect(page.getByRole('article', { name: agent.name, exact: true })).toBeVisible(); + await expectNoReminderOutsideDialog(page); expect(await recoveryCalls(page)).toEqual([]); + await page.screenshot({ path: test.info().outputPath(`recovery-home-${width}.png`), animations: 'disabled' }); + await trigger.click(); + const dialog = page.getByRole('dialog', { name: '创建记录', exact: true }); + await expect(dialog).toBeVisible(); await expect(dialog).toContainText(longName); + await expect(dialog.locator('.agent-recovery-record')).toHaveCount(count); + await expect(dialog.locator('.agent-recovery-time').first()).not.toHaveText(''); + const close = dialog.getByRole('button', { name: '返回列表', exact: true }); + await expect(close).toBeInViewport(); await expectModalFits(dialog); + await dialog.locator('.agent-recovery-record').last().scrollIntoViewIfNeeded(); + await expect(dialog.locator('.agent-recovery-record').last().getByRole('button', { name: '再试一次', exact: true })).toBeInViewport(); + await expect(close).toBeInViewport(); await expectModalFits(dialog); + expect(await recoveryCalls(page)).toEqual([]); + await page.screenshot({ path: test.info().outputPath(`recovery-modal-${width}.png`), animations: 'disabled' }); + await close.click(); await expect(dialog).toHaveCount(0); + await expect(page.getByRole('article', { name: agent.name, exact: true })).toBeVisible(); + await trigger.click(); await expect(dialog.locator('.agent-recovery-record')).toHaveCount(count); + await close.click(); expect(await recoveryCalls(page)).toEqual([]); + }); +} + +test('retry keeps the same pending identity and errors remain inside the modal', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 850 }); await installFixture(page); await page.goto(baseUrl); + const trigger = page.getByRole('button', { name: /^创建记录/ }); + await trigger.click(); const dialog = page.getByRole('dialog', { name: '创建记录', exact: true }); + for (let attempt = 0; attempt < 2; attempt++) { + await dialog.getByRole('button', { name: '再试一次', exact: true }).first().click(); + await expect(dialog.getByRole('alert')).toContainText(retryError); + await expect(dialog.getByRole('button', { name: '再试一次', exact: true }).first()).toBeEnabled(); + } + expect((await recoveryCalls(page)).map(call => call.input)).toEqual([{ id: records[0].id, discard: false }, { id: records[0].id, discard: false }]); + await page.screenshot({ path: test.info().outputPath('recovery-retry-error-375.png'), animations: 'disabled' }); + await dialog.getByRole('button', { name: '关闭记录', exact: true }).click(); + await expect(dialog).toHaveCount(0); await expect(page.getByRole('alert')).toHaveCount(0); + await expectNoReminderOutsideDialog(page); await expect(page.getByRole('article', { name: agent.name, exact: true })).toBeVisible(); + await trigger.click(); await expect(dialog.locator('.agent-recovery-record')).toHaveCount(2); + expect(await recoveryCalls(page)).toHaveLength(2); +}); + +test('discard is explicit and reminders do not follow into configuration, chat or creation', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 850 }); await installFixture(page); await page.goto(baseUrl); + const trigger = page.getByRole('button', { name: /^创建记录/ }); + await trigger.click(); const dialog = page.getByRole('dialog', { name: '创建记录', exact: true }); + await dialog.getByRole('button', { name: '不再提醒', exact: true }).first().click(); + await expect(dialog.locator('.agent-recovery-record')).toHaveCount(1); + expect((await recoveryCalls(page)).map(call => call.input)).toEqual([{ id: records[0].id, discard: true }]); + await dialog.getByRole('button', { name: '返回列表', exact: true }).click(); + await expect(trigger).toContainText('1'); + const card = page.getByRole('article', { name: agent.name, exact: true }); + await card.getByRole('button', { name: '调整', exact: true }).click(); + await expect(page.getByTestId('cloud-agent-editor')).toBeVisible(); + await expect(page.getByRole('button', { name: /^创建记录/ })).toHaveCount(0); await expectNoReminderOutsideDialog(page); + await page.getByRole('button', { name: '我的智能体', exact: true }).click(); + await card.getByRole('button', { name: '和它聊聊', exact: true }).click(); + await expect(page.getByRole('button', { name: '返回智能体列表', exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: /^创建记录/ })).toHaveCount(0); await expectNoReminderOutsideDialog(page); + await page.getByRole('button', { name: '返回智能体列表', exact: true }).click(); + await page.getByRole('button', { name: '创建智能体', exact: true }).click(); + await expect(page.getByRole('heading', { name: '创建智能体', exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: /^创建记录/ })).toHaveCount(0); await expectNoReminderOutsideDialog(page); + await page.getByRole('button', { name: '取消', exact: true }).click(); + await expect(trigger).toContainText('1'); expect(await recoveryCalls(page)).toHaveLength(1); + const calls = await page.evaluate(() => (window as unknown as FixtureWindow).__recoveryFixture.calls); + expect(calls.filter(call => call.path.startsWith('/api/cloud-agents/agents') && call.method !== 'GET')).toEqual([]); +}); 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/e2e/main-navigation.spec.ts b/tests/e2e/main-navigation.spec.ts index cf9f9590..608a3c83 100644 --- a/tests/e2e/main-navigation.spec.ts +++ b/tests/e2e/main-navigation.spec.ts @@ -1,6 +1,7 @@ import { mkdir, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron'; +import { billingSummary } from '../fixtures/permanent-points'; async function seedInactiveCodingProjects(resources: { rootDir: string; userDataDir: string }) { const now = '2026-09-06T08:00:00.000Z'; @@ -50,6 +51,139 @@ async function seedInactiveCodingProjects(resources: { rootDir: string; userData } test.describe('Makelore module navigation without setup flow', () => { + test('keeps the home account wallet, profile, settings and logout available without creating a payment', 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'); + await app.evaluate(({ ipcMain, BrowserWindow }, summary) => { + BrowserWindow.getAllWindows()[0].setSize(1440, 900); + const state = { loggedIn: true, requests: [] as { method: string; path: string }[] }; + Object.assign(globalThis, { homeAccountFixture: state }); + 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; method?: string }) => { + const path = request.path ?? ''; + const method = (request.method ?? 'GET').toUpperCase(); + state.requests.push({ method, path }); + if (path === '/api/auth/session/sync') return result({ success: true, session: state.loggedIn ? { accessToken: 'home-account-fixture', tokenType: 'Bearer', expiresAt: Date.now() + 3600000, lastActiveAt: Date.now(), canRefresh: false } : null }); + if (path === '/api/auth/me') return result({ success: true, user: state.loggedIn ? { username: 'child-wallet', userId: 'home-user', tenantId: null, deptId: null, authorities: [] } : null, moduleAccess: { programming: true, design: true, robot: true, cloud_agents: true } }); + if (path === '/api/auth/logout' && method === 'POST') { state.loggedIn = false; return result({ success: true }); } + if (path === '/api/works/user/agent-profile' && method === 'GET') return result({ success: true, profile: { display_name: '小麦', age: null, gender: null, avatar_url: null, share_age_with_agents: false, share_gender_with_agents: false, analysis_enabled: true, completed: true, version: 1, updated_at: '2026-10-13T00:00:00Z' } }); + if (path === '/api/works/billing/me' && method === 'GET') return result({ success: true, data: summary }); + if (path.startsWith('/api/works/billing/recharge/orders?') && method === 'GET') return result({ success: true, data: { items: [] } }); + if (path.startsWith('/api/works/billing/') || path === '/api/works/user/agent-profile') throw new Error('Unexpected home account write ' + method + ' ' + path); + return result({ success: true }); + }); + }, billingSummary()); + // The setup-skipping fixture starts as a guest. Hydrate only this isolated + // Renderer with a fake persisted session before the auth store is created. + await page.addInitScript(() => { + const now = Date.now(); + localStorage.setItem('niancode-auth', JSON.stringify({ version: 4, state: { + authBase: 'https://biz.nianxx.cn/auth/', clientId: 'app', + accessToken: 'home-account-fixture', tokenType: 'Bearer', + expiresAt: now + 3600000, lastActiveAt: now, canRefresh: false, legacyRefreshToken: null, + user: { username: 'child-wallet', userId: 'home-user', tenantId: null, deptId: null, authorities: [] }, + moduleAccess: { programming: true, design: true, robot: true, cloud_agents: true }, + } })); + // Setup-skipping mode also suppresses the home profile sync. Restore the + // isolated account's cached profile in the store's persisted shape. + localStorage.setItem('niancode-user-agent-profiles', JSON.stringify({ version: 1, state: { + profilesByUserId: { 'home-user': { displayName: '小麦', age: null, gender: 'undisclosed', avatarUrl: null, + shareAgeWithAgents: false, shareGenderWithAgents: false, analysisEnabled: true, + pendingSync: false, version: 1, updatedAt: '2026-10-13T00:00:00Z' } }, + } })); + }); + await page.goto(applicationUrl); + const trigger = page.getByTestId('home-account-menu-trigger'); + const menu = page.getByTestId('home-account-menu'); + const surface = page.getByTestId('home-account-trigger-surface'); + const checkAccountGeometry = async (width: number) => { + await expect.poll(async () => (await menu.boundingBox())!.width).toBeCloseTo(288, 1); + const visible = (await surface.boundingBox())!; + const target = (await trigger.boundingBox())!; + const avatar = (await surface.locator('span').first().boundingBox())!; + const panel = (await menu.boundingBox())!; + expect(visible.width).toBeCloseTo(128, 1); + expect(visible.height).toBeCloseTo(25, 1); + expect(avatar.width).toBeCloseTo(16, 1); + expect(avatar.height).toBeCloseTo(16, 1); + expect(target.width).toBeCloseTo(128, 1); + expect(target.height).toBeCloseTo(50, 1); + expect(visible.x).toBeGreaterThanOrEqual(16); + expect(visible.x).toBeCloseTo(width === 1440 ? 80 - 1.5 * 96 / 2.54 : 16, 1); + expect(await page.evaluate(() => innerHeight) - visible.y - visible.height).toBeCloseTo(16, 0); + expect(panel.x).toBeCloseTo(visible.x, 1); + expect(visible.y - panel.y - panel.height).toBeCloseTo(8, 1); + expect(panel.y).toBeGreaterThanOrEqual(0); + expect(panel.x + panel.width).toBeLessThanOrEqual(width); + }; + await expect(trigger).toContainText('小麦'); + await expect(page.locator('[data-testid^="ai-module-option-"]')).toHaveCount(3); + const footer = await trigger.evaluate(element => element.closest('footer')!.getBoundingClientRect().top); + const cards = await page.getByTestId('ai-module-options').boundingBox(); + expect(footer).toBeGreaterThanOrEqual(cards!.y + cards!.height); + await trigger.click(); + await expect(menu.getByTestId('sidebar-total-token-points')).toHaveText('75 点'); + await expect(menu.getByText('永久有效 · 预占 5 点', { exact: true })).toBeVisible(); + await expect(menu.getByRole('menuitem', { name: '词元点数与充值', exact: true })).toBeVisible(); + await checkAccountGeometry(1440); + await page.screenshot({ path: testInfo.outputPath('home-account-menu-desktop.png') }); + await page.keyboard.press('Escape'); + await expect(trigger).toHaveAttribute('aria-expanded', 'false'); + await expect(trigger).toBeFocused(); + await trigger.click(); + await page.getByRole('heading', { name: '今天,想创作什么?', exact: true }).click(); + await expect(trigger).toHaveAttribute('aria-expanded', 'false'); + + await trigger.click(); + await menu.getByRole('menuitem', { name: '词元点数与充值', exact: true }).click(); + const wallet = page.getByTestId('point-wallet-dialog'); + await expect(wallet).toBeVisible(); + await expect(wallet.getByTestId('point-recharge-recipient')).toHaveText('充值到:小麦(child-wallet)'); + await expect(wallet.getByRole('button', { name: /500 点\s*¥10.00/ })).toBeEnabled(); + await wallet.getByText('我的可用点数', { exact: true }).click(); + await expect(trigger).toHaveAttribute('aria-expanded', 'false'); + await expect(wallet).toBeVisible(); + await wallet.getByRole('button', { name: '关闭词元点数', exact: true }).click(); + await expect(wallet).toHaveCount(0); + + await trigger.click(); + await menu.getByRole('menuitem', { name: '个人资料', exact: true }).click(); + const profile = page.getByRole('dialog', { name: '个人资料', exact: true }); + await expect(profile.getByLabel(/名字/)).toHaveValue('小麦'); + await profile.getByRole('button', { name: '关闭个人资料', exact: true }).click(); + await trigger.click(); + await menu.getByRole('menuitem', { name: '设置', exact: true }).click(); + await expect(page).toHaveURL(/\/settings$/); + await page.evaluate(() => { window.location.hash = '#/module-select'; }); + await expect(trigger).toContainText('小麦'); + + await app.evaluate(({ BrowserWindow }) => { const window = BrowserWindow.getAllWindows()[0]; window.setMinimumSize(360, 600); window.setSize(375, 800); }); + await expect.poll(() => page.evaluate(() => innerWidth)).toBe(375); + await trigger.click(); + await expect(menu.getByTestId('sidebar-total-token-points')).toHaveText('75 点'); + await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1); + await checkAccountGeometry(375); + await page.screenshot({ path: testInfo.outputPath('home-account-menu-375.png') }); + await menu.getByRole('menuitem', { name: '退出登录', exact: true }).click(); + await expect(page).toHaveURL(/\/login$/); + await expect(trigger).toHaveCount(0); + const requests = await app.evaluate(() => (globalThis as typeof globalThis & { homeAccountFixture: { requests: { method: string; path: string }[] } }).homeAccountFixture.requests); + const billingReads = requests.filter(request => request.path.startsWith('/api/works/billing/')); + expect(billingReads.length).toBeGreaterThan(0); + expect(billingReads.every(request => request.method === 'GET')).toBe(true); + expect(requests.filter(request => request.path === '/api/works/user/agent-profile').every(request => request.method === 'GET')).toBe(true); + expect(requests.filter(request => request.path === '/api/auth/logout' && request.method === 'POST')).toHaveLength(1); + } finally { + await app.evaluate(({ BrowserWindow }) => { for (const window of BrowserWindow.getAllWindows()) window.destroy(); }).catch(() => undefined); + await closeElectronApp(app); + } + }); + test('opens remembered Code projects and keeps the module return entry in chat instead of project settings', async ({ electronTestResources, launchElectronApp, @@ -200,7 +334,7 @@ test.describe('Makelore module navigation without setup flow', () => { } }); - test('keeps the four module entries in a proportional vertical stack while resizing the window', async ({ launchElectronApp }) => { + test('aligns the desktop home title with the middle of three stacked horizontal module cards', async ({ launchElectronApp }) => { const app = await launchElectronApp({ skipSetup: true }); try { @@ -209,17 +343,18 @@ test.describe('Makelore module navigation without setup flow', () => { const moduleCards = page.locator('[data-testid^="ai-module-option-"]'); await expect(moduleCards.locator('.module-option-card-title')).toHaveText([ - 'Agents 智能体', 'Code 编程', 'Canvas 设计', 'Robot 机器', + 'Agents 智能体', 'Code 编程', 'Canvas 设计', ]); + await expect(page.getByTestId('ai-module-option-robot')).toHaveCount(0); await expect(moduleCards.first().locator('.module-option-card-description')).toHaveText('打造你想象中的AI助手'); - await expect(moduleCards.locator('img')).toHaveCount(4); + await expect(moduleCards.locator('img')).toHaveCount(3); await expect.poll(async () => await moduleCards.locator('img').evaluateAll((images) => images.every((image) => image.complete && image.naturalWidth > 0), )).toBe(true); - await page.screenshot({ path: test.info().outputPath('module-entry-artwork.png') }); const readCardMetrics = async () => await moduleCards.evaluateAll((elements) => elements.map((element) => { const bounds = element.getBoundingClientRect(); const image = element.querySelector('.module-option-card-image')?.getBoundingClientRect(); + const content = element.querySelector('.module-option-card-content')?.getBoundingClientRect(); return { left: bounds.left, top: bounds.top, @@ -227,70 +362,71 @@ test.describe('Makelore module navigation without setup flow', () => { height: bounds.height, imageWidth: image?.width ?? 0, imageHeight: image?.height ?? 0, + imageLeft: image?.left ?? 0, + contentLeft: content?.left ?? 0, }; })); - const initialMetrics = await readCardMetrics(); - expect(initialMetrics).toHaveLength(4); await expect(moduleCards.first().locator('.module-option-card-arrow')).toHaveCount(0); await expect(moduleCards.first().locator('.module-option-artwork')).toHaveCount(0); - await expect.poll(async () => await moduleCards.first().evaluate((element) => { - const surface = element.querySelector('.module-option-card-surface'); - const frame = element.querySelector('.module-option-card-frame'); - if (!surface || !frame) return false; - const surfaceStyle = getComputedStyle(surface); - const frameStyle = getComputedStyle(frame); - const surfaceInsets = [surfaceStyle.top, surfaceStyle.right, surfaceStyle.bottom, surfaceStyle.left] - .map((value) => Number.parseFloat(value)); - return frameStyle.display === 'none' - && surfaceInsets.every((value) => value === 0) - && surfaceStyle.transform === 'none'; - })).toBe(true); - const initialImageStyles = await moduleCards.first().evaluate((element) => { - const image = element.querySelector('.module-option-card-image'); - const cardHeight = element.getBoundingClientRect().height; - if (!image) return null; - const style = getComputedStyle(image); - return { - height: Number.parseFloat(style.height), - cardHeight, - marginTop: Number.parseFloat(style.marginTop), - marginBottom: Number.parseFloat(style.marginBottom), - marginLeft: Number.parseFloat(style.marginLeft), - }; - }); - expect(initialImageStyles).not.toBeNull(); - expect(initialImageStyles!.height).toBeLessThan(initialImageStyles!.cardHeight); - expect(initialImageStyles!.marginTop).toBe(1); - expect(initialImageStyles!.marginBottom).toBe(1); - expect(initialImageStyles!.marginTop).toBe(initialImageStyles!.marginBottom); - expect(initialImageStyles!.marginLeft).toBeGreaterThan(0); - expect(initialImageStyles!.marginLeft).toBe(initialImageStyles!.marginTop); - expect(Math.max(...initialMetrics.map((card) => card.left)) - Math.min(...initialMetrics.map((card) => card.left))).toBeLessThan(1); - expect(initialMetrics.map((card) => card.top)).toEqual( - [...initialMetrics].sort((top, bottom) => top.top - bottom.top).map((card) => card.top), - ); - - const initialWidth = initialMetrics[0]?.width ?? 0; - expect(initialMetrics[0]?.imageWidth ?? 0).toBeGreaterThan(initialWidth * 0.24); - expect(initialMetrics[0]?.imageWidth ?? 0).toBeLessThan(initialWidth / 2); - expect(initialMetrics[0]?.imageHeight ?? 0).toBeGreaterThan((initialMetrics[0]?.height ?? 0) * 0.75); - await app.evaluate(({ BrowserWindow }) => { - BrowserWindow.getAllWindows()[0]?.setSize(1100, 700); + BrowserWindow.getAllWindows()[0]?.setMinimumSize(360, 600); }); - await expect.poll(async () => (await readCardMetrics())[0]?.width ?? 0).toBeLessThan(initialWidth - 1); - - const resizedMetrics = await readCardMetrics(); - expect(resizedMetrics).toHaveLength(4); - expect(Math.max(...resizedMetrics.map((card) => card.left)) - Math.min(...resizedMetrics.map((card) => card.left))).toBeLessThan(1); - expect(resizedMetrics.map((card) => card.top)).toEqual( - [...resizedMetrics].sort((top, bottom) => top.top - bottom.top).map((card) => card.top), - ); - expect(resizedMetrics[0]!.imageWidth).toBeGreaterThan(resizedMetrics[0]!.width * 0.24); - expect(resizedMetrics[0]!.imageWidth).toBeLessThan(resizedMetrics[0]!.width / 2); - expect(resizedMetrics[0]!.imageHeight).toBeGreaterThan(resizedMetrics[0]!.height * 0.75); + for (const width of [1440, 1024, 768, 375]) { + await app.evaluate(({ BrowserWindow }, windowWidth) => BrowserWindow.getAllWindows()[0]?.setSize(windowWidth, windowWidth === 375 ? 800 : 900), width); + await expect.poll(() => page.evaluate(() => innerWidth)).toBe(width); + const cards = await readCardMetrics(); + expect(cards).toHaveLength(3); + expect(Math.max(...cards.map(card => card.left)) - Math.min(...cards.map(card => card.left))).toBeLessThan(1); + expect(Math.max(...cards.map(card => card.width)) - Math.min(...cards.map(card => card.width))).toBeLessThan(1); + expect(Math.max(...cards.map(card => card.height)) - Math.min(...cards.map(card => card.height))).toBeLessThan(1); + const gaps = cards.slice(1).map((card, index) => card.top - cards[index]!.top - cards[index]!.height); + expect(Math.min(...gaps)).toBeGreaterThan(0); + expect(Math.abs(gaps[0]! - gaps[1]!)).toBeLessThan(1); + for (const card of cards) { + expect(card.left).toBeGreaterThanOrEqual(0); + expect(card.left + card.width).toBeLessThanOrEqual(width); + expect(card.imageWidth).toBeLessThan(card.width / 2); + expect(card.imageHeight).toBeGreaterThan(card.height * 0.75); + expect(card.contentLeft).toBeGreaterThanOrEqual(card.imageLeft + card.imageWidth - 1); + } + const title = page.getByRole('heading', { name: '今天,想创作什么?', exact: true }); + const heading = await title.boundingBox(); + expect(heading).not.toBeNull(); + const textWidth = await title.evaluate(element => { + const range = document.createRange(); range.selectNodeContents(element); + return range.getBoundingClientRect().width; + }); + expect(Math.abs(heading!.width - textWidth)).toBeLessThan(1); + if (width >= 1024) { + const shift = await title.evaluate(element => { + const intro = element.parentElement!; + const original = intro.style.transform; + const shiftedLeft = element.getBoundingClientRect().left; + try { + intro.style.transform = 'none'; + return shiftedLeft - element.getBoundingClientRect().left; + } finally { intro.style.transform = original; } + }); + expect(shift).toBeCloseTo(1.5 * 96 / 2.54, 1); + expect(heading!.x + heading!.width).toBeLessThanOrEqual(cards[0]!.left); + expect(Math.abs(heading!.y + heading!.height / 2 - cards[1]!.top - cards[1]!.height / 2)).toBeLessThan(1); + expect(await moduleCards.evaluateAll(elements => elements.every(element => { + const bounds = element.getBoundingClientRect(); + return [[2, 2], [bounds.width - 2, bounds.height - 2], [bounds.width / 2, bounds.height / 2]].every(([x, y]) => + element.contains(document.elementFromPoint(bounds.left + x!, bounds.top + y!)), + ); + }))).toBe(true); + } else { + expect(heading!.y + heading!.height).toBeLessThanOrEqual(cards[0]!.top); + } + expect(await page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1); + expect(await moduleCards.locator('.module-option-card-title, .module-option-card-description').evaluateAll(elements => + elements.every(element => element.scrollWidth <= element.clientWidth), + )).toBe(true); + await page.screenshot({ path: test.info().outputPath(`module-entry-${width}.png`) }); + } await moduleCards.first().hover(); await moduleCards.first().focus(); @@ -323,7 +459,7 @@ test.describe('Makelore module navigation without setup flow', () => { const page = await getStableWindow(app); await expect(page.getByTestId('ai-module-selection-page')).toBeVisible(); - await expect(page.locator('[data-testid^="ai-module-option-"]')).toHaveCount(4); + await expect(page.locator('[data-testid^="ai-module-option-"]')).toHaveCount(3); await expect(page.getByTestId('ai-module-option-learning')).toHaveCount(0); await page.evaluate(() => { window.location.hash = '#/learning'; @@ -385,7 +521,8 @@ test.describe('Makelore module navigation without setup flow', () => { await moduleHomeTrigger.click(); await expect(page).toHaveURL(/\/module-select$/); await expect(page.getByTestId('ai-module-selection-page')).toBeVisible(); - await page.getByTestId('ai-module-option-robot').click(); + await expect(page.getByTestId('ai-module-option-robot')).toHaveCount(0); + await page.evaluate(() => { window.location.hash = '#/ai-hardware'; }); await expect(page).toHaveURL(/\/ai-hardware$/); await expect(page.getByTestId('ai-hardware-page')).toBeVisible(); await expect(page.getByTestId('sidebar-robot-navigation')).toBeVisible(); diff --git a/tests/unit/channel-accounts-page.test.tsx b/tests/unit/channel-accounts-page.test.tsx index e25e3bb9..9e8fe991 100644 --- a/tests/unit/channel-accounts-page.test.tsx +++ b/tests/unit/channel-accounts-page.test.tsx @@ -1,5 +1,5 @@ import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { ChannelAccounts } from '@/pages/CloudAgents/ChannelAccounts'; import { EMPTY_CLOUD_CONFIGURATION, type CloudAgentDraft, type CloudUserChannelAccount, type CloudWechatBindState } from '../../shared/cloud-agents'; @@ -23,7 +23,16 @@ let verifyStatus: CloudWechatBindState['status']; const calls = (operation: string) => api.call.mock.calls.filter(([name]) => name === operation); const writes = () => api.call.mock.calls.filter(([name]) => !['channelAccounts', 'channelAccountWechatBindStatus'].includes(name)); -function renderPage(entry = '/cloud-agents/channels') { return render(); } +function LocationProbe() { + const location = useLocation(); + return {location.pathname + location.search}; +} +function renderPage(entry = '/cloud-agents/channels') { + return render( + } /> + 智能体列表
} /> + ); +} async function addWechat(fakeTimers = false) { fireEvent.click(await screen.findByRole('button', { name: '添加微信', exact: true })); const dialog = screen.getByRole('dialog', { name: '添加微信', exact: true }); @@ -80,6 +89,69 @@ beforeEach(() => { }); afterEach(() => { cleanup(); vi.useRealTimers(); }); +it.each([ + ['no Agents', []], + ['only drafts', [{ ...agentA, published_version: null }]], + ['only disabled Agents', [{ ...agentA, enabled: false }]], +] satisfies [string, CloudAgentDraft[]][])('guides a workspace with %s to My Agents without starting a binding', async (_name, unavailable) => { + agents = unavailable; accounts = []; + renderPage(); + expect(await screen.findByRole('heading', { name: '还没有可连接的智能体', exact: true })).toBeVisible(); + expect(screen.getByText('先创建一个智能体,配置完成后点击“开始使用”,就能连接到微信。', { exact: true })).toBeVisible(); + expect(screen.queryByRole('button', { name: '添加第一个微信', exact: true })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: '添加微信', exact: true })).toBeEnabled(); + fireEvent.click(screen.getByRole('button', { name: '去我的智能体', exact: true })); + expect(await screen.findByText('智能体列表', { exact: true })).toBeVisible(); + expect(screen.getByTestId('location-path')).toHaveTextContent('/cloud-agents?view=mine'); + expect(writes()).toEqual([]); +}); + +it('uses the same preparation hint in the add dialog without duplicating it on the page', async () => { + agents = []; accounts = []; + renderPage(); + await screen.findByRole('heading', { name: '还没有可连接的智能体', exact: true }); + fireEvent.click(screen.getByRole('button', { name: '添加微信', exact: true })); + const dialog = screen.getByRole('dialog', { name: '添加微信', exact: true }); + expect(within(dialog).getByRole('heading', { name: '还没有可连接的智能体', exact: true })).toBeVisible(); + expect(within(dialog).getByText('先创建一个智能体,配置完成后点击“开始使用”,就能连接到微信。', { exact: true })).toBeVisible(); + expect(screen.getAllByRole('heading', { name: '还没有可连接的智能体', exact: true, hidden: true })).toHaveLength(1); + expect(within(dialog).getByRole('button', { name: '下一步:扫码连接', exact: true })).toBeDisabled(); + fireEvent.click(within(dialog).getByRole('button', { name: '去我的智能体', exact: true })); + expect(await screen.findByText('智能体列表', { exact: true })).toBeVisible(); + expect(screen.getByTestId('location-path')).toHaveTextContent('/cloud-agents?view=mine'); + expect(writes()).toEqual([]); +}); + +it('waits for the Agent read before showing a preparation hint', async () => { + accounts = []; + let finish!: (value: { agents: CloudAgentDraft[]; next_cursor: null }) => void; + api.list.mockImplementation(() => new Promise(resolve => { finish = resolve; })); + renderPage(); + expect(screen.getByText('正在查看你的微信绑定…', { exact: true })).toBeVisible(); + expect(screen.queryByRole('heading', { name: '还没有可连接的智能体', exact: true })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '去我的智能体', exact: true })).not.toBeInTheDocument(); + await act(async () => finish({ agents: [], next_cursor: null })); + expect(await screen.findByRole('heading', { name: '还没有可连接的智能体', exact: true })).toBeVisible(); + expect(writes()).toEqual([]); +}); + +it('keeps existing WeChat cards manageable when no connectable Agent remains', async () => { + agents = []; + renderPage(); + expect(await screen.findByRole('heading', { name: '还没有可连接的智能体', exact: true })).toBeVisible(); + expect(screen.getByRole('article', { name: '原有微信微信', exact: true })).toBeVisible(); + expect(screen.getByRole('article', { name: '备用微信微信', exact: true })).toBeVisible(); + const dialog = await manage(); + expect(within(dialog).getByRole('button', { name: '暂停使用', exact: true })).toBeEnabled(); + expect(within(dialog).getByRole('button', { name: '解除绑定', exact: true })).toBeEnabled(); + expect(within(dialog).getByRole('button', { name: '断开微信连接', exact: true })).toBeEnabled(); + fireEvent.click(within(dialog).getByRole('button', { name: '暂停使用', exact: true })); + await waitFor(() => expect(calls('pauseChannelAccount')).toHaveLength(1)); + expect(accounts[0]).toMatchObject({ enabled: false, health: 'connected', target_agent_slug: agentA.slug }); + expect(calls('createChannelAccount')).toHaveLength(0); + expect(calls('routeChannelAccount')).toHaveLength(0); +}); + it('shows binding cards and never fetches or embeds conversation or activity panels', async () => { renderPage(); const card = await screen.findByRole('article', { name: '原有微信微信', exact: true }); @@ -89,6 +161,7 @@ it('shows binding cards and never fetches or embeds conversation or activity pan expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); expect(screen.queryByText('微信对话', { exact: true })).not.toBeInTheDocument(); expect(screen.queryByText('活动记录', { exact: true })).not.toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: '还没有可连接的智能体', exact: true })).not.toBeInTheDocument(); expect(writes()).toEqual([]); expect(api.call.mock.calls.every(([operation]) => operation === 'channelAccounts')).toBe(true); }); @@ -295,13 +368,19 @@ it('does not call a blocked or unconfirmed existing account ready to use', async }); -it('keeps adding unavailable after a failed initial read instead of showing a false empty account list', async () => { - api.call.mockRejectedValue(new Error('微信列表暂时不可用')); +it.each(['accounts', 'agents'])('keeps adding unavailable after a failed initial %s read instead of showing a false empty list', async source => { + agents = []; + const message = source === 'accounts' ? '微信列表暂时不可用' : '智能体列表暂时不可用'; + if (source === 'accounts') api.call.mockRejectedValue(new Error(message)); + else api.list.mockRejectedValue(new Error(message)); renderPage(); - await screen.findByText('微信列表暂时不可用', { exact: true }); + await screen.findByText(message, { exact: true }); expect(screen.getByRole('button', { name: '添加微信', exact: true })).toBeDisabled(); expect(screen.queryByText('还没有微信绑定', { exact: true })).not.toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: '还没有可连接的智能体', exact: true })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '去我的智能体', exact: true })).not.toBeInTheDocument(); installMock(); + api.list.mockImplementation(async () => ({ agents, next_cursor: null })); fireEvent.click(screen.getByRole('button', { name: '刷新微信列表', exact: true })); expect(await screen.findByRole('article', { name: '原有微信微信', exact: true })).toBeVisible(); expect(screen.getByRole('button', { name: '添加微信', exact: true })).toBeEnabled(); 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 86a54615..3e3c86b9 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() })); @@ -574,9 +575,13 @@ it('restores the exact previous thread and presents an uncertain operation witho expect(screen.queryByLabelText('历史对话')).not.toBeInTheDocument(); expect(api.resolvePending).not.toHaveBeenCalled(); expect(api.call.mock.calls.some(call => call[0] === 'submit')).toBe(false); + expect(screen.queryByRole('button', { name: /^待处理记录/ })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '返回智能体列表', exact: true })); + fireEvent.click(await screen.findByRole('button', { name: /^待处理记录/ })); + await screen.findByRole('dialog', { name: '待处理记录' }); api.resolvePending.mockResolvedValue({ result: { request_id: 'same-request', status: 'queued' } }); api.recovery.mockResolvedValue({ recent: null, pending: [] }); - fireEvent.click(screen.getByRole('button', { name: '重试并确认结果' })); + fireEvent.click(screen.getByRole('button', { name: '再试一次' })); await screen.findByText('发送消息的结果已确认。'); expect(api.resolvePending).toHaveBeenCalledWith('submit:pending', false); }); @@ -604,25 +609,130 @@ it('guides an unconfirmed WeChat verification back to its code entry without cla input: { slug: draft.slug, session_key: 'qr-session', operation_id: 'verify-operation' }, created_at: '2026-09-13T01:00:00Z' }] }); api.resolvePending.mockResolvedValue({ requires_input: true, operation: 'wechatBindVerification' }); open(); - fireEvent.click(await screen.findByRole('button', { name: '重试并确认结果' })); + fireEvent.click(await screen.findByRole('button', { name: /^待处理记录/ })); + fireEvent.click(await screen.findByRole('button', { name: '再试一次' })); await screen.findByText(/这次微信验证还需要验证码/); expect(screen.queryByText('微信连接验证的结果已确认。')).not.toBeInTheDocument(); 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: /^创建记录/ })); + 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: /^创建记录/ })); + 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' }] }) .mockResolvedValue({ recent: null, pending: [] }); api.resolvePending.mockResolvedValue({ operation: 'channelPolicy', discarded: true, retired: true }); open(); + fireEvent.click(await screen.findByRole('button', { name: /^待处理记录/ })); expect(await screen.findByText(/旧版联系人权限操作(已移除)/)).toBeVisible(); - expect(screen.queryByRole('button', { name: '重试并确认结果' })).toBeNull(); + expect(screen.queryByRole('button', { name: '再试一次' })).toBeNull(); fireEvent.click(screen.getByRole('button', { name: '丢弃旧记录' })); await screen.findByText(/旧版联系人权限操作已移除/); expect(api.resolvePending).toHaveBeenCalledWith('channel-policy-old', false); }); +it('keeps historic creation records out of the new form while showing the current creation error inline', async () => { + api.recovery.mockResolvedValue({ recent: null, pending: [{ id: 'create:old', operation: 'createAgent', + input: { operation_id: 'original-old', name: '之前的失败创建' }, created_at: '2026-10-13T03:00:00Z' }] }); + api.create.mockRejectedValue(new Error('本次创建失败,请重新检查')); + open(); + expect(await screen.findByRole('button', { name: /^创建记录/ })).toHaveTextContent('1'); + expect(screen.queryByRole('article', { name: '创建「之前的失败创建」' })).not.toBeInTheDocument(); + expect(screen.queryByRole('complementary', { name: '未确认的云端操作' })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '创建智能体', exact: true })); + expect(screen.queryByRole('button', { name: /^创建记录/ })).not.toBeInTheDocument(); + expect(screen.queryByRole('dialog', { name: '创建记录' })).not.toBeInTheDocument(); + fireEvent.change(screen.getByLabelText('名称'), { target: { value: '新的搭档' } }); + fireEvent.click(screen.getByRole('button', { name: '下一步', exact: true })); + expect(await screen.findByRole('alert')).toHaveTextContent('本次创建失败,请重新检查'); + expect(screen.queryByRole('article', { name: '创建「之前的失败创建」' })).not.toBeInTheDocument(); + expect(api.resolvePending).not.toHaveBeenCalled(); +}); + +it('places the records entry only in the list header and preserves it when returning from configuration or chat', async () => { + publishedAssistant(4); + api.recovery.mockResolvedValue({ recent: null, pending: [{ id: 'create:historic', operation: 'createAgent', + input: { operation_id: 'same-historic', name: '旧记录搭档' }, created_at: '2026-10-13T03:00:00Z' }] }); + open(); + const trigger = await screen.findByRole('button', { name: /^创建记录/ }); + expect(trigger.closest('header')).not.toBeNull(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '调整', exact: true })); + await screen.findByTestId('cloud-agent-editor'); + expect(screen.queryByRole('button', { name: /^创建记录/ })).not.toBeInTheDocument(); + expect(screen.queryByRole('complementary', { name: '未确认的云端操作' })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '我的智能体', exact: true })); + expect(await screen.findByRole('button', { name: /^创建记录/ })).toHaveTextContent('1'); + fireEvent.click(screen.getByRole('button', { name: '和它聊聊', exact: true })); + await screen.findByLabelText('消息'); + expect(screen.queryByRole('button', { name: /^创建记录/ })).not.toBeInTheDocument(); + expect(screen.queryByText(/有 1 个操作的结果尚未确认/)).not.toBeInTheDocument(); + expect(api.resolvePending).not.toHaveBeenCalled(); +}); + +it('retains an unclosed key through internal configuration navigation and clears it across account changes', async () => { + publishedAssistant(4); + const secret = 'configuration-navigation-key-fixture'; + const pending = { id: 'key:historical', operation: 'createKey', input: { application_id: 'fixture-application', operation_id: 'original-key' }, created_at: '2026-10-13T03:00:00Z' }; + api.recovery.mockResolvedValue({ recent: null, pending: [pending] }); + api.resolvePending.mockImplementation(async () => { + api.recovery.mockResolvedValue({ recent: null, pending: [] }); + return { result: { secret } }; + }); + const view = open(); + fireEvent.click(await screen.findByRole('button', { name: /^待处理记录/ })); + fireEvent.click(screen.getByRole('button', { name: '再试一次' })); + await screen.findByText(new RegExp(secret)); + fireEvent.click(screen.getByRole('button', { name: '关闭记录' })); + fireEvent.click(await screen.findByRole('button', { name: '调整', exact: true })); + await screen.findByTestId('cloud-agent-editor'); + expect(screen.queryByRole('button', { name: /^待处理记录/ })).not.toBeInTheDocument(); + expect(screen.queryByRole('dialog', { name: '待处理记录' })).not.toBeInTheDocument(); + expect(screen.queryByText(new RegExp(secret))).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '我的智能体', exact: true })); + fireEvent.click(await screen.findByRole('button', { name: /^待处理记录/ })); + expect(await screen.findByText(new RegExp(secret))).toBeVisible(); + expect(api.resolvePending).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole('button', { name: '关闭记录' })); + auth.user.userId = 'different-records-account'; + view.rerender(); + await screen.findByRole('button', { name: '调整', exact: true }); + expect(screen.queryByRole('button', { name: /^待处理记录/ })).not.toBeInTheDocument(); + expect(screen.queryByText(new RegExp(secret))).not.toBeInTheDocument(); +}); + it('freezes the uploaded avatar and creation identity when retrying a lost response', async () => { const avatar = { icon: '/api/storage/public/images/create.png', icon_url: 'https://images.test/create.png' }; api.list.mockResolvedValue({ agents: [], next_cursor: null }); @@ -648,6 +758,112 @@ 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 waitFor(() => expect(api.recovery).toHaveBeenCalledTimes(2)); + expect(retainedPending).toHaveLength(1); + expect(screen.queryByRole('button', { name: /^创建记录/ })).not.toBeInTheDocument(); + expect(screen.queryByText('有 1 个操作的结果尚未确认')).not.toBeInTheDocument(); + 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(api.recovery).toHaveBeenCalledTimes(3)); + expect(await api.recovery.mock.results.at(-1)!.value).toEqual({ recent: null, pending: [] }); + 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.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.findByRole('alert'); + await waitFor(() => expect(api.recovery).toHaveBeenCalledTimes(2)); + expect(retainedPending).toHaveLength(1); + // Clearing the journal independently must not change the still-mounted form's uncertainty. + // The records entry is now confined to the list, and explicit discard is tested there. + retainedPending = []; + expect(screen.queryByRole('button', { name: /^创建记录/ })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '重试创建', exact: true })); + expect(await screen.findByRole('alert')).toHaveTextContent('创建结果暂未确认,可重试确认结果。参数被拒绝'); + await waitFor(() => expect(api.recovery).toHaveBeenCalledTimes(3)); + expect(await api.recovery.mock.results.at(-1)!.value).toEqual({ recent: null, pending: [] }); + expect(api.resolvePending).not.toHaveBeenCalled(); + 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: '其他设备内容' }); @@ -828,7 +1044,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(); @@ -841,9 +1060,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-recovery.test.tsx b/tests/unit/cloud-recovery.test.tsx new file mode 100644 index 00000000..02a7a56b --- /dev/null +++ b/tests/unit/cloud-recovery.test.tsx @@ -0,0 +1,209 @@ +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { CloudRecovery } from '@/pages/CloudAgents/CloudRecovery'; +import { AppError } from '@/lib/error-model'; +import type { CloudPendingOperation } from '../../shared/cloud-agents'; + +const api = vi.hoisted(() => ({ resolvePending: vi.fn() })); +vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api })); +const first: CloudPendingOperation = { id: 'create:first', operation: 'createAgent', input: { operation_id: 'original-first', name: '故事搭档' }, created_at: '2026-10-13T03:54:49Z' }; +const second: CloudPendingOperation = { id: 'create:second', operation: 'createAgent', input: { operation_id: 'original-second', name: '阅读伙伴' }, created_at: '2026-10-13T03:55:18Z' }; +const key: CloudPendingOperation = { id: 'key:original', operation: 'createKey', input: { application_id: 'application', operation_id: 'original-key', name: '应用密钥' }, created_at: '2026-10-13T04:00:00Z' }; +const resolved = vi.fn(); +beforeEach(() => { vi.resetAllMocks(); }); +afterEach(cleanup); +async function openRecords() { + fireEvent.click(screen.getByRole('button', { name: /^(创建记录|待处理记录)/ })); + return await screen.findByRole('dialog'); +} +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(yes => { resolve = yes; }); + return { promise, resolve }; +} + +it('shows only a compact counted creation entry until explicitly opened, without retrying or discarding records', async () => { + render(); + expect(screen.getByRole('button', { name: /^创建记录/ })).toHaveTextContent('2'); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.queryByRole('complementary', { name: '未确认的云端操作' })).not.toBeInTheDocument(); + expect(screen.queryByRole('article', { name: '创建「故事搭档」' })).not.toBeInTheDocument(); + expect(screen.queryByText(/有 2 个操作的结果尚未确认/)).not.toBeInTheDocument(); + const dialog = await openRecords(); + expect(within(dialog).getByRole('article', { name: '创建「故事搭档」' })).toBeVisible(); + expect(within(dialog).getByRole('article', { name: '创建「阅读伙伴」' })).toBeVisible(); + expect(within(dialog).getAllByRole('button', { name: '再试一次', exact: true })).toHaveLength(2); + expect(within(dialog).getAllByRole('button', { name: '不再提醒', exact: true })).toHaveLength(2); + expect(api.resolvePending).not.toHaveBeenCalled(); expect(resolved).not.toHaveBeenCalled(); +}); + +it('uses a general records entry for mixed operations and keeps the original message visible only inside the dialog', async () => { + const message = { ...second, id: 'message:original', operation: 'submit', input: { query: '我的上次问题' } }; + render(); + expect(screen.getByRole('button', { name: /^待处理记录/ })).toHaveTextContent('2'); + expect(screen.queryByText('我的上次问题')).not.toBeInTheDocument(); + const dialog = await openRecords(); + expect(within(dialog).getByText('我的上次问题')).toBeVisible(); + expect(api.resolvePending).not.toHaveBeenCalled(); +}); + +it('keeps failed records and retries the same record ID after closing and reopening the dialog', async () => { + api.resolvePending.mockRejectedValueOnce(new Error('原操作仍未确认')).mockResolvedValueOnce({ result: { slug: 'original-agent' } }); + render(); + const dialog = await openRecords(); + fireEvent.click(within(dialog).getByRole('button', { name: '再试一次' })); + await screen.findByRole('alert'); + expect(screen.getByRole('alert')).toHaveTextContent('原操作仍未确认'); + expect(resolved).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: '关闭记录' })); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + const reopened = await openRecords(); + expect(within(reopened).getByRole('article', { name: '创建「故事搭档」' })).toBeVisible(); + fireEvent.click(within(reopened).getByRole('button', { name: '再试一次' })); + await waitFor(() => expect(resolved).toHaveBeenCalledOnce()); + expect(api.resolvePending.mock.calls).toEqual([[first.id, false], [first.id, false]]); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); +}); + +it('removes only the explicitly chosen local reminder and never implicitly retries it', async () => { + api.resolvePending.mockResolvedValue({ discarded: true }); + const view = render(); + const dialog = await openRecords(); + fireEvent.click(within(dialog).getAllByRole('button', { name: '不再提醒' })[1]); + await waitFor(() => expect(resolved).toHaveBeenCalledOnce()); + expect(api.resolvePending.mock.calls).toEqual([[second.id, true]]); + view.rerender(); + expect(screen.getByRole('article', { name: '创建「故事搭档」' })).toBeVisible(); + expect(screen.queryByRole('article', { name: '创建「阅读伙伴」' })).not.toBeInTheDocument(); +}); + +it('serializes double clicks and other actions and prevents dismissal until a request finishes', async () => { + const response = deferred<{ result: { slug: string } }>(); + api.resolvePending.mockReturnValue(response.promise); + render(); + const dialog = await openRecords(); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); }); + const retry = within(dialog).getAllByRole('button', { name: '再试一次' })[0]; + act(() => { fireEvent.click(retry); fireEvent.click(retry); }); + expect(api.resolvePending).toHaveBeenCalledTimes(1); + expect(within(dialog).getByRole('button', { name: '关闭记录' })).toBeDisabled(); + for (const discard of within(dialog).getAllByRole('button', { name: '不再提醒' })) expect(discard).toBeDisabled(); + fireEvent.keyDown(dialog, { key: 'Escape' }); + fireEvent.pointerDown(document.body, { button: 0, pointerType: 'mouse' }); + expect(screen.getByRole('dialog')).toBeVisible(); + expect(resolved).not.toHaveBeenCalled(); + await act(async () => response.resolve({ result: { slug: 'original-agent' } })); + expect(resolved).toHaveBeenCalledOnce(); + expect(within(dialog).getByRole('button', { name: '关闭记录' })).toBeEnabled(); +}); + +it('keeps a returned key available after modal close and reopen until the result is explicitly closed', async () => { + const secret = 'fixture-key-for-unit-only'; + api.resolvePending.mockResolvedValue({ result: { secret, id: 'created-key' } }); + const view = render(); + const dialog = await openRecords(); + fireEvent.click(within(dialog).getAllByRole('button', { name: '再试一次' })[0]); + await screen.findByText(new RegExp(secret)); + view.rerender(); + fireEvent.click(screen.getByRole('button', { name: '关闭记录' })); + expect(screen.queryByText(new RegExp(secret))).not.toBeInTheDocument(); + await openRecords(); + expect(screen.getByText(new RegExp(secret))).toBeVisible(); + expect(api.resolvePending).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole('button', { name: '关闭结果' })); + expect(screen.queryByText(new RegExp(secret))).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '关闭记录' })); + await openRecords(); + expect(screen.queryByText(new RegExp(secret))).not.toBeInTheDocument(); +}); + +it('keeps a result entry available when the final pending record was acknowledged', async () => { + api.resolvePending.mockResolvedValue({ result: { secret: 'last-key-fixture' } }); + const view = render(); + await openRecords(); fireEvent.click(screen.getByRole('button', { name: '再试一次' })); + await screen.findByText(/last-key-fixture/); + view.rerender(); + fireEvent.click(screen.getByRole('button', { name: '关闭记录' })); + await openRecords(); expect(screen.getByText(/last-key-fixture/)).toBeVisible(); + fireEvent.click(screen.getByRole('button', { name: '关闭结果' })); + expect(screen.queryByText(/last-key-fixture/)).not.toBeInTheDocument(); +}); + +it('guides verification requiring fresh input without claiming that verification completed', async () => { + const verification = { ...first, id: 'wechat:verification', operation: 'wechatBindVerification', input: { session_key: 'fixture-session' } }; + api.resolvePending.mockResolvedValue({ requires_input: true, operation: verification.operation }); + render(); + await openRecords(); fireEvent.click(screen.getByRole('button', { name: '再试一次' })); + expect(await screen.findByText(/还需要验证码/)).toBeVisible(); + expect(screen.queryByText('微信连接验证的结果已确认。')).not.toBeInTheDocument(); + expect(api.resolvePending).toHaveBeenCalledWith(verification.id, false); +}); + +it('offers only explicit discard for retired operations and explains the retired result without auto actions', async () => { + const retired = { ...first, id: 'retired:policy', operation: 'channelPolicy', input: {} }; + api.resolvePending.mockResolvedValue({ retired: true, discarded: true, operation: retired.operation }); + render(); + expect(api.resolvePending).not.toHaveBeenCalled(); + await openRecords(); + expect(screen.queryByRole('button', { name: '再试一次' })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '丢弃旧记录' })); + expect(await screen.findByText(/旧版联系人权限操作已移除/)).toBeVisible(); + expect(api.resolvePending).toHaveBeenCalledWith(retired.id, false); +}); + +it('renders no entry or dialog when there are no pending records or retained results', () => { + render(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(api.resolvePending).not.toHaveBeenCalled(); +}); + +it('does not let another action replace an unclosed key result, and unlocks actions after explicit result closure', async () => { + const verification = { ...second, operation: 'wechatBindVerification' }; + api.resolvePending.mockImplementation(async id => id === key.id + ? { result: { secret: 'protected-key-fixture' } } : { requires_input: true, operation: verification.operation }); + const view = render(); + await openRecords(); + fireEvent.click(within(screen.getByRole('article', { name: '创建密钥' })).getByRole('button', { name: '再试一次' })); + await screen.findByText(/protected-key-fixture/); + view.rerender(); + const row = screen.getByRole('article', { name: '微信连接验证' }); + const retry = within(row).getByRole('button', { name: '再试一次' }); + const discard = within(row).getByRole('button', { name: '不再提醒' }); + expect(retry).toBeDisabled(); expect(discard).toBeDisabled(); + fireEvent.click(retry); fireEvent.click(discard); + expect(api.resolvePending).toHaveBeenCalledTimes(1); + expect(screen.getByText(/protected-key-fixture/)).toBeVisible(); + fireEvent.click(screen.getByRole('button', { name: '关闭结果' })); + expect(retry).toBeEnabled(); + fireEvent.click(retry); + await screen.findByText(/还需要验证码/); + expect(api.resolvePending.mock.calls).toEqual([[key.id, false], [verification.id, false]]); +}); + +it('refreshes pending records after a definite rejection while keeping the error inside the explicit dialog', async () => { + api.resolvePending.mockRejectedValue(new AppError('CONFIG', '这条创建已被拒绝', undefined, { commandOutcome: 'definitive_failure' })); + const view = render(); + await openRecords(); fireEvent.click(screen.getByRole('button', { name: '再试一次' })); + expect(await screen.findByRole('alert')).toHaveTextContent('这条创建已被拒绝'); + expect(resolved).toHaveBeenCalledOnce(); + expect(api.resolvePending).toHaveBeenCalledWith(first.id, false); + view.rerender(); + fireEvent.click(screen.getByRole('button', { name: '关闭记录' })); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + await openRecords(); + expect(screen.queryByRole('article')).not.toBeInTheDocument(); + expect(screen.getByRole('alert')).toHaveTextContent('这条创建已被拒绝'); +}); + +it('keeps an explicitly unknown command outcome recoverable without refreshing it as a definite failure', async () => { + api.resolvePending.mockRejectedValue(new AppError('NETWORK', '请求结果仍未知', undefined, { commandOutcome: 'unknown' })); + render(); + await openRecords(); fireEvent.click(screen.getByRole('button', { name: '再试一次' })); + expect(await screen.findByRole('alert')).toHaveTextContent('请求结果仍未知'); + expect(resolved).not.toHaveBeenCalled(); + expect(screen.getByRole('article', { name: '创建「故事搭档」' })).toBeVisible(); + fireEvent.click(screen.getByRole('button', { name: '再试一次' })); + await waitFor(() => expect(api.resolvePending).toHaveBeenCalledTimes(2)); + expect(api.resolvePending.mock.calls).toEqual([[first.id, false], [first.id, false]]); +}); diff --git a/tests/unit/home-account-menu.test.tsx b/tests/unit/home-account-menu.test.tsx new file mode 100644 index 00000000..8ac090dc --- /dev/null +++ b/tests/unit/home-account-menu.test.tsx @@ -0,0 +1,134 @@ +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { ModuleSelection } from '@/pages/ModuleSelection'; +import { useAuthStore } from '@/stores/auth'; +import { useUserProfileStore } from '@/stores/user-profile'; +import { billingSummary, permanentBalance } from '../fixtures/permanent-points'; + +const api = vi.hoisted(() => ({ fetch: vi.fn() })); +vi.mock('@/lib/host-api', () => ({ hostApiFetch: api.fetch })); + +let profileName: string; +function profileResponse() { + return { success: true, profile: { display_name: profileName, age: null, gender: null, avatar_url: null, + share_age_with_agents: false, share_gender_with_agents: false, analysis_enabled: true, + completed: true, version: 1, updated_at: '2026-10-13T00:00:00Z' } }; +} +function LocationProbe() { + const location = useLocation(); + return {location.pathname}; +} +function renderHome() { + return render( + } /> + } /> + ); +} +const writes = () => api.fetch.mock.calls.filter(([, init]) => init?.method && init.method !== 'GET'); + +beforeEach(() => { + vi.resetAllMocks(); profileName = '小麦'; + useAuthStore.setState({ initialized: true, loading: false, error: null, accessToken: 'home-fixture', tokenType: 'Bearer', + expiresAt: Date.now() + 3600000, lastActiveAt: Date.now(), canRefresh: false, legacyRefreshToken: null, + user: { username: 'child-wallet', userId: 'home-user', tenantId: null, deptId: null, authorities: [] }, + moduleAccess: { programming: true, design: true, robot: true, cloud_agents: true } }); + useUserProfileStore.setState({ profilesByUserId: {}, syncStatesByUserId: {} }); + api.fetch.mockImplementation(async (path: string) => { + if (path === '/api/works/user/agent-profile') return profileResponse(); + if (path === '/api/works/billing/me') return { success: true, data: billingSummary() }; + if (path.startsWith('/api/works/billing/recharge/orders?')) return { success: true, data: { items: [] } }; + if (path === '/api/auth/logout') return { success: true }; + throw new Error('Unexpected home account fixture request ' + path); + }); +}); +afterEach(cleanup); + +it('shows the authenticated account and opens the real personal wallet with read requests only', async () => { + renderHome(); + const trigger = await screen.findByTestId('home-account-menu-trigger'); + await waitFor(() => expect(trigger).toHaveTextContent('小麦')); + expect(screen.getAllByTestId(/^ai-module-option-/)).toHaveLength(3); + fireEvent.click(trigger); + const menu = screen.getByTestId('home-account-menu'); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + expect(await within(menu).findByTestId('sidebar-total-token-points')).toHaveTextContent('75 点'); + expect(within(menu).getByText('永久有效 · 预占 5 点', { exact: true })).toBeInTheDocument(); + fireEvent.click(within(menu).getByRole('menuitem', { name: '词元点数与充值', exact: true })); + const dialog = await screen.findByTestId('point-wallet-dialog'); + await waitFor(() => expect(within(dialog).getByRole('button', { name: /500 点\s*¥10.00/ })).toBeEnabled()); + expect(within(dialog).getByTestId('point-recharge-recipient')).toHaveTextContent('充值到:小麦(child-wallet)'); + expect(api.fetch).toHaveBeenCalledWith('/api/works/billing/recharge/orders?offset=0', undefined); + fireEvent.mouseDown(within(dialog).getByText('我的可用点数', { exact: true })); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + expect(dialog).toBeVisible(); + expect(writes()).toEqual([]); +}); + +it('opens the existing profile dialog and navigates to settings without saving profile changes', async () => { + renderHome(); + await screen.findByText('小麦,欢迎回来!'); + fireEvent.click(screen.getByTestId('home-account-menu-trigger')); + fireEvent.click(within(screen.getByTestId('home-account-menu')).getByRole('menuitem', { name: '个人资料', exact: true })); + const profile = await screen.findByRole('dialog', { name: '个人资料', exact: true }); + expect(within(profile).getByLabelText(/名字/)).toHaveValue('小麦'); + fireEvent.click(within(profile).getByRole('button', { name: '关闭个人资料', exact: true })); + fireEvent.click(screen.getByTestId('home-account-menu-trigger')); + fireEvent.click(within(screen.getByTestId('home-account-menu')).getByRole('menuitem', { name: '设置', exact: true })); + expect(await screen.findByTestId('location-path')).toHaveTextContent('/settings'); + expect(writes()).toEqual([]); +}); + +it('closes the menu on Escape and outside clicks while preserving the wallet instance', async () => { + renderHome(); + const trigger = await screen.findByTestId('home-account-menu-trigger'); + fireEvent.click(trigger); + const menu = screen.getByTestId('home-account-menu'); + const wallet = await within(menu).findByTestId('sidebar-point-balance'); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + expect(menu).toHaveAttribute('aria-hidden', 'true'); + fireEvent.click(trigger); + fireEvent.mouseDown(screen.getByRole('heading', { name: '今天,想创作什么?', exact: true })); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + fireEvent.click(trigger); + expect(within(menu).getByTestId('sidebar-point-balance')).toBe(wallet); + expect(writes()).toEqual([]); +}); + +it('remounts the wallet for another account and ignores the previous delayed balance', async () => { + let finish!: (value: { success: boolean; data: ReturnType }) => void; + const base = api.fetch.getMockImplementation()!; + let firstBalance = true; + api.fetch.mockImplementation((path: string, init) => { + if (path === '/api/works/billing/me') { + if (firstBalance) { firstBalance = false; return new Promise(resolve => { finish = resolve; }); } + return Promise.resolve({ success: true, data: billingSummary({ own_points: permanentBalance({ total_remaining: '2.00' }) }) }); + } + return base(path, init); + }); + renderHome(); + fireEvent.click(await screen.findByTestId('home-account-menu-trigger')); + await waitFor(() => expect(firstBalance).toBe(false)); + profileName = '小红'; + await act(async () => useAuthStore.setState({ user: { username: 'second-wallet', userId: 'second-user', tenantId: null, deptId: null, authorities: [] } })); + const trigger = screen.getByTestId('home-account-menu-trigger'); + await waitFor(() => expect(trigger).toHaveTextContent('小红')); + if (trigger.getAttribute('aria-expanded') !== 'true') fireEvent.click(trigger); + await waitFor(() => expect(within(screen.getByTestId('home-account-menu')).getByTestId('sidebar-total-token-points')).toHaveTextContent('2 点')); + await act(async () => finish({ success: true, data: billingSummary({ own_points: permanentBalance({ total_remaining: '999.00' }) }) })); + expect(screen.queryByText('999 点', { exact: true })).not.toBeInTheDocument(); + expect(writes()).toEqual([]); +}); + +it('logs out only the fixture account and hides the home account entry for signed-out users', async () => { + renderHome(); + fireEvent.click(await screen.findByTestId('home-account-menu-trigger')); + fireEvent.click(within(screen.getByTestId('home-account-menu')).getByRole('menuitem', { name: '退出登录', exact: true })); + expect(await screen.findByTestId('location-path')).toHaveTextContent('/login'); + expect(useAuthStore.getState().user).toBeNull(); + expect(writes()).toEqual([['/api/auth/logout', expect.objectContaining({ method: 'POST' })]]); + cleanup(); + renderHome(); + expect(screen.queryByTestId('home-account-menu-trigger')).not.toBeInTheDocument(); +}); diff --git a/tests/unit/module-navigation.test.tsx b/tests/unit/module-navigation.test.tsx index 357360d2..3128e9b2 100644 --- a/tests/unit/module-navigation.test.tsx +++ b/tests/unit/module-navigation.test.tsx @@ -52,7 +52,7 @@ describe('AI module navigation', () => { }); }); - it('shows the four supported module choices and routes Canvas from the chooser', async () => { + it('shows Agents, Code and Canvas on the home page and routes Canvas from the chooser', async () => { render( @@ -64,18 +64,16 @@ describe('AI module navigation', () => { expect(screen.getByTestId('ai-module-selection-page')).toBeInTheDocument(); expect(screen.getByRole('heading', { name: '今天,想创作什么?' })).toBeInTheDocument(); - expect(screen.getByRole('heading', { name: '今天,想创作什么?' })).toHaveClass('whitespace-nowrap'); expect(screen.getByTestId('ai-module-options')).toHaveClass('module-selection-options'); expect(await screen.findByText('小明,欢迎回来!')).toBeInTheDocument(); - expect(screen.getByTestId('ai-module-option-programming')).toHaveClass('module-option-card', 'module-option-card-horizontal'); - for (const moduleId of ['programming', 'painting', 'robot', 'cloud_agents'] as const) { + expect(screen.getByTestId('ai-module-option-programming')).toHaveClass('module-option-card'); + for (const moduleId of ['programming', 'painting', 'cloud_agents'] as const) { expect(screen.getByTestId(`ai-module-option-${moduleId}`).querySelector('.module-option-artwork')).toBeNull(); } expect(screen.getAllByTestId(/^ai-module-option-/u).map((option) => option.dataset.testid)).toEqual([ 'ai-module-option-cloud_agents', 'ai-module-option-programming', 'ai-module-option-painting', - 'ai-module-option-robot', ]); expect(screen.queryByTestId('ai-module-option-learning')).not.toBeInTheDocument(); expect(screen.queryByText('先选一个入口。进入后,你还可以从左下角随时切换到其他模块。')).not.toBeInTheDocument(); @@ -89,12 +87,7 @@ describe('AI module navigation', () => { expect(screen.getByText('Canvas 设计')).toBeInTheDocument(); expect(screen.getByAltText('数位板设计创作')).toBeInTheDocument(); expect(screen.getByTestId('ai-module-option-painting')).toHaveTextContent('用灵感描绘色彩缤纷的世界'); - const robotOption = screen.getByTestId('ai-module-option-robot'); - expect(robotOption).toBeEnabled(); - expect(robotOption).not.toHaveClass('module-option-card-disabled'); - expect(robotOption).toHaveTextContent('Robot 机器'); - expect(robotOption).toHaveTextContent('制作你的第一个机器人小伙伴'); - expect(screen.getByAltText('青少年管理机器人硬件与设备绑定')).toBeInTheDocument(); + expect(screen.queryByTestId('ai-module-option-robot')).not.toBeInTheDocument(); expect(screen.getByAltText('个人云智能体')).toHaveAttribute('src', expect.stringContaining('module-agents.webp')); expect(screen.getByAltText('个人云智能体')).toHaveClass('module-option-card-image-media', 'object-cover'); @@ -102,7 +95,8 @@ describe('AI module navigation', () => { expect(await screen.findByTestId('location-path')).toHaveTextContent('/image-canvas'); }); - it('routes Robot from the chooser to the AI hardware workspace', async () => { + it('hides Robot from the chooser even when its module permission is enabled', () => { + useAuthStore.setState({ moduleAccess: { programming: true, design: true, robot: true, cloud_agents: true } }); render( @@ -112,8 +106,9 @@ describe('AI module navigation', () => { , ); - fireEvent.click(screen.getByTestId('ai-module-option-robot')); - expect(await screen.findByTestId('location-path')).toHaveTextContent('/ai-hardware'); + expect(screen.queryByTestId('ai-module-option-robot')).not.toBeInTheDocument(); + expect(screen.queryByText('Robot 机器')).not.toBeInTheDocument(); + expect(screen.queryByAltText('青少年管理机器人硬件与设备绑定')).not.toBeInTheDocument(); }); it('greys out a disabled module and does not navigate when it is clicked', async () => {