From 020b36673d9034fed53f7e896b7685aa56e4cecb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Tue, 13 Oct 2026 20:11:01 +0800 Subject: [PATCH] Consolidate approved trial client updates and preserve composer runtime --- .../20261013-consolidate-trial-c0242063.md | 42 + README.md | 8 +- electron/api/routes/cloud-agents.ts | 2 + electron/services/cloud-agent-operations.ts | 7 +- electron/services/cloud-agents.ts | 55 +- shared/cloud-agents.ts | 4 +- src/components/account/PointWallet.tsx | 4 +- src/components/layout/Sidebar.tsx | 2 +- src/hooks/use-works-voice-input.ts | 7 +- src/lib/cloud-agents-api.ts | 11 + src/pages/CloudAgents/ChannelAccounts.tsx | 1211 +++++++++++++---- src/pages/CloudAgents/CloudChat.tsx | 65 +- .../CloudAgents/CloudComposerAttachments.tsx | 183 ++- src/pages/CloudAgents/CloudKnowledge.tsx | 34 +- .../CloudAgents/CloudKnowledgeDocuments.tsx | 7 +- .../CloudAgents/CloudKnowledgeSelector.tsx | 8 +- src/pages/CloudAgents/CloudRecovery.tsx | 4 + src/pages/CloudAgents/agent-chat.css | 50 +- src/pages/CloudAgents/agent-config.css | 25 +- src/pages/CloudAgents/desktop-workspace.css | 4 +- tests/e2e/cloud-agent-channels.spec.ts | 734 ++++++++-- tests/e2e/cloud-agent-chat-layout.spec.ts | 33 +- tests/e2e/cloud-agent-knowledge.spec.ts | 38 +- tests/e2e/cloud-agents.spec.ts | 86 +- tests/e2e/permanent-point-wallet.spec.ts | 78 +- tests/unit/channel-accounts-page.test.tsx | 1086 +++++++++------ tests/unit/cloud-agents-main.test.ts | 62 + tests/unit/cloud-attachment-upload.test.ts | 289 ++++ tests/unit/cloud-chat-reasoning.test.tsx | 8 +- tests/unit/cloud-chat-voice.test.tsx | 441 ++++++ tests/unit/cloud-recovery.test.tsx | 15 + tests/unit/sidebar-token-points.test.tsx | 12 +- 32 files changed, 3668 insertions(+), 947 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20261013-consolidate-trial-c0242063.md create mode 100644 tests/unit/cloud-attachment-upload.test.ts create mode 100644 tests/unit/cloud-chat-voice.test.tsx diff --git a/.project-docs/30-worklog/tasks/20261013-consolidate-trial-c0242063.md b/.project-docs/30-worklog/tasks/20261013-consolidate-trial-c0242063.md new file mode 100644 index 00000000..927c7586 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20261013-consolidate-trial-c0242063.md @@ -0,0 +1,42 @@ +# Task: Consolidate approved thread updates in current trial app + +## Identity + +- Task ID: 20261013-consolidate-trial-c0242063 +- Mode: Feature +- Branch: codex/20261013-consolidate-trial-c0242063-consolidate-trial +- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261013-consolidate-trial-c0242063 +- Base commit: 154a21a295e24bc701af293124cdd7854b6429ec +- Owner: codex +- Status: In Progress + +## Scope + +- Inspect the six unique requested Codex conversations and activate their completed client changes in the existing temporary application: knowledge alignment/subagent selector removal, application-retry copy, hidden wallet entry, chat timestamps, WeChat binding controls and Code-style voice/attachment composer. Preserve all current committed product behavior. + +## Intent And Constraints + +- User explicitly authorizes local trial consolidation and activation. No remote push, cloud deployment, real WeChat binding/unbinding, paid model call, recording or configuration save/apply. Backend-only WS speech ae9be8c5 and Yuxi channel46cdee remain separate undeployed sources, with AgentBus generation protection unverified. +- Concurrent Gate Passed: task20261013-consolidate-trial-c0242063, feature, codex/20261013-consolidate-trial-c0242063-consolidate-trial, owned absolute worktree in Identity, initial metadata base154a21a295e24bc701af293124cdd7854b6429ec. Bundled check/start/status match. Read AGENTS, entry/memory-index/own task, positioning/integrated current-state, personal-cloud ADR/decision-index/system-overview and relevant domain/success/evidence context. Integrated main docs are historical; live trial and committed source evidence take precedence for delivery selection without canonical writes. +- Independent read-only audit checked all peer task scopes through their records. Current temporary app Main and full renderer are actually ddb942f9440db930761feb622336a2065c4a5793, PID87561/Host58003 and composer stage; metadata154a describes an older CSS layer. Preserve full ddb composer/attachment Main changes and its already-retained154a timestamp CSS. Wallet sourcee917120, channel sourced585844/docs e250b01 and knowledge sourcef5a93c7 are completed client deltas to combine. No semantic conflict; old placeholder child-language task remains unknown without dependency. Gate result: Passed. +- Plan: restore only committed actual ddb product/test/README differences into this owned source; apply channel/wallet deltas excluding foreign task records and copy the four committed knowledge-layout files plus its geometry test. Verify relevant merged contracts and layouts, build the full product and stage immutable app bytes. Native CUA alone checks clean/idle state, exits/launches the trial if Main must change, and validates visible features. File helpers never operate UI, read credentials or mutate user data. Preserve existing profile/port/bundle identity and recovery material; do not finalize unseen UI results. + +## Outcome + +- Consolidated approved client deltas from all six requested conversations over the actual ddb942f runtime, preserving voice/attachments and chat timestamp implementation. Source delta is limited to channel UI/Main controls, hidden recharge entry and committed knowledge geometry. Subagent selector removal and application retry copy are retained. No peer project records or canonical memory were modified. +- Full default product build completed; immutable staging and native activation are pending. Original native bundle, Host58003, profile and stable renderer origin will be reused. + +## Verification + +- Default full build:vite passed with NIANCODE_RENDERER_ONLY unset; typecheck and scoped ESLint passed (two pre-existing CloudChat warnings only). +- 21 isolated browser geometry scenes passed, including knowledge card alignment and voice/time composer layouts. Four isolated Electron E2E scenarios passed for channels, wallet and agent configuration/chat. +- Focused unit suite: 308/310 passed initially under concurrent build load; both affected files then passed all58 tests serially. Fixed a genuine test observation race by awaiting React Router deep-link transition; final serial rerun passed58/58 and changed-test ESLint passed. Product behavior was unchanged by this test correction. All310 selected test cases have a passing result. +- Independent source review passed: ddb voice/Main upload and account fences retained, channel delta correct, knowledge geometry and wallet visibility match requested source. No real recording, uploads, model generations, binding/unbinding or configuration writes performed. + +## Follow-ups + +- Native trial activation and visible checks pending. Cloud-only speech ae9be8c5 and Yuxi WeChat46cdee are not deployed by this task; AgentBus generation protection remains unverified. + +## Promotion Candidates + +- None recorded. diff --git a/README.md b/README.md index fbfa9cb6..04a64db9 100644 --- a/README.md +++ b/README.md @@ -12,17 +12,17 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智 - `Makelore Code|AI 编程`:管理本地项目、项目智能体、对话、文件上下文、代码变更和运行时。 会话工作台采用浅蓝项目栏、彩色通用手柄图标与卡片选中态;中间对话/作品使用圆角标签,作品预览的后退、前进、刷新与网址输入统一放在标签右侧;只在作品页显示,窄窗口自动换行。右侧智能体咨询使用奶油色标题区。沿用真实项目和会话入口、侧栏伸缩与账户菜单,样式只作用于 Code 会话工作台。可编辑的空对话在输入区上方的可用区域中央显示童趣欢迎语;「去作品广场逛逛」入口紧贴输入框上方,两者一起位于底部。老师介绍以固定宽度浮在右上角,不占用中间布局;欢迎语和底部输入区始终相对项目栏右侧的整个内容区域居中;小窗口的欢迎内容独立滚动;发送首条消息后收起欢迎区。侧栏保留「作品广场」入口,只有点击后才在系统浏览器打开公共广场,不请求、摘取或内嵌展示广场作品。打开广场保留当前项目、对话、草稿和附件,不自动发送消息;老师求助按钮保留临时应用的最新文案「帮我整体看🧠」。 -- `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 Agents|AI 智能体`:从模块首页进入时先打开“我的智能体”卡片列表,保留此前对话与操作恢复记录;返回首页箭头与侧栏折叠按钮同排,折叠后仍可返回;智能体模块的侧栏只通过按钮点击或键盘激活展开,鼠标经过左侧、收纳按钮或顶栏不会展开,切换模块不沿用旧悬停预览;其他模块仍保留原预览方式。“我的智能体”使用浅紫对话机器人图标,“渠道”使用浅蓝连接节点图标。macOS 原生窗口按钮留出安全空间。首页围绕自己的智能体和创建入口,显示童趣问候“Hi,今天想创造还是想聊聊?”,不再显示更多、收到的分享、活动或归档筛选。卡片恢复原有尺寸与留白,显示名称、可为空的一句话介绍及状态,支持聊天、调整、继续制作和确认删除;删除沿用云端归档机制,移出列表并停用新调用,历史和费用保留。创建先填写名称并选择平台像素头像或上传头像,下一步进入独立配置。顶部名称有编辑图标,头像可点击修改;未设置头像时使用同一张默认像素头像,从创建到配置、改名、保存和重新打开保持一致。基础页为“介绍与提示词”,一句话介绍可留空,完整提示词保留原始文本;模型作为高级设置第一项必选,未选择时不能开始使用,草稿可分步保存,不自动选模型。介绍与提示词采用浅蓝介绍卡和浅紫提示词卡;高级设置按模型、工具箱(Tool)、做事手册(Skill)、MCP 服务和知识库纵向呈现;移除子智能体选择模块,保存其他配置时保留历史子智能体绑定,不再设内部分类切换;不展示 Tool/Skill 解释、资源开放规则提示、云端能力管理、费用上限或运行限制卡片,保存其他配置时保留既有运行限制,费用规则不变。顶层页签切换保留搜索、资源选择、知识表单及未保存输入。配置页移除聊天、自动任务和使用与接入导航,独立聊天仍从卡片进入,微信账号仍在侧栏渠道管理。草稿明确保存(Ctrl/⌘+S);保存只更新草稿,“应用最新修改”使新配置生效,失败重试保持原操作身份;点击“开始使用”完成必要的私有启用后直接打开聊天,不再展示二次确认页;未保存修改、缺少模型、冲突或尚未确认的请求会留在配置页处理,打开聊天不自动发送消息。独立对话沿用奶油底色和浅蓝、浅紫、薄荷色:顶部展示返回、只读头像和名称;有附件时在同一行右侧显示回形针“附件”入口和文件数量,不展示调整、保存/应用状态、客户端/微信来源、费用/版本说明或历史/新对话图标;配置由列表卡片的“调整”进入。空对话展示当前智能体头像与名称,示例只填入草稿;用户消息使用浅紫圆角气泡,助手 Markdown、表格和代码在消息区内滚动。文件或图片从输入框内添加,上传成功后以待发送卡片呈现,可移除本次携带;移除不会删除云端文件,添加不会自动发送。确认失败沿用同一文件和对话重试,发送前需完成或取消添加;支持仅发送附件,使用明确的附件提问。输入框沿用 Code 的浅蓝边框、圆形按钮及一至六行输入规则;Enter 发送、Shift+Enter 换行,保留中文输入法确认。麦克风复用应用级语音输入,识别只追加当前草稿,不自动发送;切换账号、对话或隐藏页面后丢弃过期识别。支持拖入文件、粘贴图片和多文件选择,上传与确认仍由 Main 持有认证并转发;失败重试保留同一批文件身份。输入框的“+”添加文件或图片;右上角“附件”打开右侧面板,按“你上传的”和“助手生成的”分组,支持再次选用已上传附件及保存明确交付的生成文件。生成文件仅取当前对话已保存助手回复中的本地文件链接(含图片与引用式链接),核对实际文件后展示和计数;只读取链接所在的文件夹,不遍历全部工作文件。未交付的脚本、缓存和工具记录不展示、不计数,明确交付的代码照常展示;仅写了路径的旧成果可能暂时隐藏,后续接入 Yuxi 的正式交付记录。没有文件时隐藏入口;打开、关闭面板保留聊天与输入,Escape 关闭并返回入口焦点;最近对话自动恢复、审批、停止和未发送内容的离开保护保留。模型返回的思考按消息独立于正文展示;正文到来后或完成时折叠,可手动展开;无思考或工具内容时不显示过程区。重连事件去重并隔离当前运行与主对话,终态历史未到达时保留已收到的片段并有限补读;服务端返回的最终回答思考可在历史中回看,不进入后续提问。历史回看需要配套 Yuxi API 的思考和工具记录投影;实时过程沿用既有事件流。平台工具和内置做事手册使用 Yuxi 下发的通俗名称与说明;逐条消息的思考、工具输入/结果/失败独立于 Markdown 正文,实时与历史工具卡片共用展示文案,展开可查看原始工具标识。个人上传手册保留作者文案,资源选择仍保存原始标识。头像上传、Main-owned Host API、访问隔离、费用与不可变版本规则保持;介绍留空需要配套 Yuxi DraftCreate/DraftSave 的空字符串契约。 个人智能体聊天在每条提问与回答下方显示云端记录的消息时间;发送时间位于紫色消息气泡外下方并靠右,助手回复时间保持在回答下方:今天显示时分,跨日增加日期,悬停可查看完整日期与秒数;发送时间与回复时间沿用原消息记录,重开对话不改为当前时间。流式回答尚无可靠时间时显示「回复中…」,历史到达后展示原时间;缺失或无效时间显示「时间未记录」。正式对话、草稿试用和上次试用对比使用相同规则,不新增 Yuxi 接口。 历史待确认记录收起在智能体列表标题区的「创建记录/待处理记录」入口,只有点击后才打开记录窗口;配置、聊天和创建页不再置顶这些旧提醒。记录保留智能体名称和原请求身份;应用设置的待确认记录显示“网络可能不稳定,请再点一次「应用」。”,点击「应用」继续原来的提交,成功后显示“设置已应用。”。其他记录仍可明确「再试一次」或「不再提醒」,不会自动重试或清除。当前创建失败继续在创建表单内提示;关闭记录窗口保留已收到的结果和密钥,切换配置、聊天或创建页后可返回列表继续查看。密钥须明确关闭结果后,才可处理其他记录。 - 高级配置的知识库支持下拉多选,分为「MakeLore知识」和「我的知识库」,已选项可单独移除。MakeLore知识来自当前账号有权读取的 Yuxi 共享目录,只显示名称与简介;移除仅取消当前智能体选用,不删除原库。知识库区域上方左右分区,两张卡片在并排时保持等高:左侧「上传自己的文件」仅放「新建知识库」入口及展开的创建表单,右侧「查看我的知识库」通过下拉框查看本人知识库、从电脑追加资料和管理文件;下方独立「选择知识库」,选择智能体使用的「MakeLore知识」和「我的知识库」。小窗口按新建、查看、选用顺序纵向排列。创建成功后在右侧打开新库;新建和查看均不会自动加入智能体配置,选用上限不影响管理与新建。仅提供「从电脑上传」作为添加文件入口,未选择管理目标时上传按钮置灰;聊天文件导入入口已移除。上传后自动解析、建立索引并持续显示真实处理状态;失败可重试且保留已有可用内容。暂不可选用的个人库仍可管理文件,已有绑定保留并提示不可用;未确认请求在当前库保留同一操作身份供重试。知识库选择只改草稿,显式保存/应用后生效;个人云端执行已自动接入所选知识库的检索,无需另外勾选 knowledge-base Skill。此交互复用现有接口,无需新增后端补丁。 + 高级配置的知识库支持下拉多选,分为「MakeLore知识」和「我的知识库」,已选项可单独移除。MakeLore知识来自当前账号有权读取的 Yuxi 共享目录,只显示名称与简介;移除仅取消当前智能体选用,不删除原库。知识库区域上方左右分区,两张卡片在并排时保持等高:左侧「上传自己的文件」仅放「新建知识库」入口及展开的创建表单,右侧「查看我的知识库」通过下拉框查看本人知识库、从电脑追加资料和管理文件。宽窗口中新建按钮与右侧选择、刷新、上传控件共用居中的水平线,管理下拉框约为原宽度的一半,两种目录刷新均显示为带边框按钮;下方独立「选择知识库」,选择智能体使用的「MakeLore知识」和「我的知识库」,下拉框缩至原宽度的约2/3,并与已选知识库框左右对齐。小窗口按新建、查看、选用顺序纵向排列,控件允许换行并保持可读。创建成功后在右侧打开新库;新建和查看均不会自动加入智能体配置,选用上限不影响管理与新建。仅提供「从电脑上传」作为添加文件入口,未选择管理目标时上传按钮置灰;聊天文件导入入口已移除。上传后自动解析、建立索引并持续显示真实处理状态;失败可重试且保留已有可用内容。暂不可选用的个人库仍可管理文件,已有绑定保留并提示不可用;未确认请求在当前库保留同一操作身份供重试。知识库选择只改草稿,显式保存/应用后生效;个人云端执行已自动接入所选知识库的检索,无需另外勾选 knowledge-base Skill。此交互复用现有接口,无需新增后端补丁。 云知识库的 embedding 模型只由平台管理员在 Yuxi 后台维护:在「智能体管理 → 模型供应商」启用供应商,按供应商要求填写有效的服务地址和 API Key,添加并启用 embedding 模型并填写实际向量维度;embedding 向量化费用由平台承担,不计入用户用量、词元点数或智能体费用上限;one-api 不参与 embedding 配置,Works Square 只负责其他应计费模型调用的创建者词元点数账本。客户端区分目录加载中、加载失败与无可用模型,空状态提供管理员配置说明;「刷新知识库与模型」保留未提交名称,未确认的创建请求沿用原操作身份。 - Agents 侧栏的「渠道」用于把自己的智能体接到微信。每个微信一张卡,展示备注、绑定的智能体头像/名称和是否可用;一个账号同时只有一个目标,多个账号可以使用同一智能体。没有可连接的智能体时,渠道页和添加窗口说明需先创建、配置并点击“开始使用”,提供“去我的智能体”入口;加载失败不显示该空态,已有微信卡片仍可管理。添加流程为选择自己已开始使用的智能体、扫码连接、明确完成绑定并启用;备注可选,扫码确认后收起二维码与验证码,未确认或启用失败不显示完成。管理已有账号不会重新创建账号;更换目标保留微信登录与原启停状态,解除智能体绑定清空目标并停用但保留微信登录,暂停保留绑定/登录并停止该渠道当前任务,断开微信连接则需要重新扫码。更换、解除和断开有明确确认,历史保留。渠道配置页不展示微信对话或活动,不读取这些列表;聊天在微信中进行,既有云端历史、文件、审批、费用和账号隔离合同不变。页面保留加载失败重试、未确认操作恢复,创建后绑定失败可继续使用原账号,重试沿用原操作身份;仅添加/扫码不自动启用。实际微信身份与重复扫码识别仍由云端负责,客户端不以备注推测同一微信。渠道触发的应计费模型调用仍由智能体创建者支付,Main 持有认证与恢复;用户无需填写服务凭据。应用 API 管理独立于微信渠道。 + Agents 侧栏的「渠道」按每个微信一张卡展示备注、当前智能体与使用状态;一个微信同时只绑定一个智能体,多个微信可以使用同一智能体。常用操作是“更换智能体”,在同一个选择窗口中确认;暂停自动回复和解除微信绑定放在“更多操作”。暂停停止当前任务并保留登录、绑定和历史;更换保留登录与原启停状态;解除微信绑定是完整操作,停止任务、清除目标并退出微信登录,历史保留,再使用需扫码。掉线显示重新扫码入口,暂停显示继续回复,未知或未同步状态显示正在确认,不宣称可用。添加流程为选择已开始使用的智能体、扫码、自动完成绑定;只有这次用户发起的绑定在扫码确认、目标正确、启用成功且最终状态回读可用后才显示完成,不因页面打开、旧记录或验证码提交自动启用。无可用智能体时说明创建和“开始使用”的前提,加载失败不冒充空态,已有微信仍可管理或解绑。Main 持有凭据与原操作恢复;解除需要配套 Yuxi 用户级 `/channel-accounts/{id}/unbind`,旧后端不会通过改按钮文案或组合未确认操作假装完成。变更后以最新读取状态为准,未完成操作保留原身份供显式恢复;失败在当前窗口可见,结果未知不自动重放。渠道页不展示微信聊天或活动,既有历史、费用、内容隔离与应用 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 密码。 @@ -41,7 +41,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智 - AI 编程核心对话运行时:Electron Main 按需启动一个长驻 Pi Agent Server,同一进程内为每条 Conversation 建立独立的 Runtime、Session、凭证视图、扩展上下文和 JSONL 通道,同时复用 Pi 模块与进程启动成本;Server 异常退出时所有旧通道统一失效,下一次恢复只重启一个 Server。正式包中的 Agent Server 从明确传入的 `resources/pi-runtime` 根定位依赖清单与包入口,不从脚本所在目录或应用 `node_modules` 猜测解析。Renderer 不直接启动或调用 Pi,也不读取其 wire 类型。项目与 Conversation 元数据先从本地读取,选中对话后才按需准备对应逻辑线程;输入框不等待运行时准备完成。同一助手工具批次中,会取得项目写租约的 `bash`、`edit`、`write` 与动态产品工具按顺序执行,避免批次在命令启动前自锁;纯只读工具仍可并行。子 Agent 仍使用独立的短生命周期进程,以保留并发配额、故障和写入边界;macOS 上两类后台进程都使用 Electron 自带的 Helper,不在 Dock 中创建额外应用图标。 - AI 编程默认入口:进入 Code 后保持在 `/chat`。顶部返回箭头与侧栏收起图标同排,与右侧项目/会话名称对齐;折叠时保留返回和展开入口。macOS 为原生窗口按钮保留安全空间,窄侧栏仍可使用。下方麦洛品牌与“操作对话/作品”标签行对齐;更浅底色的“作品广场”与“新建项目”采用相同高度、圆角、边框与阴影,并位于品牌和新建项目之间,上下各留 10px;“我的项目”和项目列表紧接新建项目;项目配置及其资源子页不再重复显示此侧栏入口,保留配置页自身的返回操作。尚未选择本地项目时,页面以麦洛 M 标识和居中引导语建立焦点,下方提供独立的“新增项目”主操作以及可横向浏览、直接进入的已有项目卡片;项目创建和进入仍复用侧栏现有流程。已有项目的新 Conversation 在可编辑 Composer 上方沿用同一品牌引导,第一条消息出现后切换为正常时间线。 - AI 编程历史与配额反馈:Pi 上下文压缩不删除用户可见的 active-branch 历史,压缩摘要保持 Main-private;Renderer 首次渲染最近 120 个节点,向上滚动时按 100 个节点追加更早内容并保持阅读位置。Main 精确识别 `token_point_balance_exhausted` / `词元点数余额不足`,只向 Renderer 投影不可重试的稳定配额错误和安全中文提示,重开会话后仍可见且不暴露上游原始错误或 request id。 -- 账号词元点数:左下角账户菜单显示本人永久余额和预占,提供充值、充值订单与分页收支记录。真正新注册赠送 100 点,1 元充值 50 点,永久有效;旧账户不补送,旧会员/订阅/周额度/重置卡入口已移除。家长为孩子充值时登录孩子的账号,页面显示当前昵称和账号,点数进入该账号自己的钱包;无需将孩子身份改为家长。当前有效账号可给本人充值,青少年可查看自己的精确余额;使用他人共享钱包时仅显示可用状态,不显示付款人的余额或记录。Main 持有凭据并转发充值请求;付款结果未知时沿用原请求并核查原订单,只有服务端确认才显示到账。扫码信息缺失时可到网页账户页继续原订单,付款来源也由网页账户页管理,充值不会自动切换共享/个人来源。需配套部署 Works Square 当前账号充值资格更新(本次无需新迁移,原永久点数基础为 0094);本地模拟验收不代表真实支付通过。 +- 账号词元点数:左下角账户菜单显示本人永久余额和预占;“词元点数与充值”入口暂时隐藏,充值、充值订单与分页收支记录的既有实现保留。真正新注册赠送 100 点,1 元充值 50 点,永久有效;旧账户不补送,旧会员/订阅/周额度/重置卡入口已移除。家长为孩子充值时登录孩子的账号,页面显示当前昵称和账号,点数进入该账号自己的钱包;无需将孩子身份改为家长。当前有效账号可给本人充值,青少年可查看自己的精确余额;使用他人共享钱包时仅显示可用状态,不显示付款人的余额或记录。Main 持有凭据并转发充值请求;付款结果未知时沿用原请求并核查原订单,只有服务端确认才显示到账。扫码信息缺失时可到网页账户页继续原订单,付款来源也由网页账户页管理,充值不会自动切换共享/个人来源。需配套部署 Works Square 当前账号充值资格更新(本次无需新迁移,原永久点数基础为 0094);本地模拟验收不代表真实支付通过。 - 桌面性能策略:应用窗口使用不透明浅色表面并默认保留硬件合成;仅在显式安全模式或短时间内重复 GPU 进程崩溃时启用软件渲染,并把故障原因保存在用户数据目录。启动关键路径只创建本地应用壳,认证、代理、同步、更新和遥测在首帧后延迟初始化;开发版可通过 `app:performance` 快照观察 GPU、进程、WebContents、事件循环与 Renderer Long Task 聚合指标。 - 后台生命周期:Main 统一维护模块活动状态与任务租约。隐藏窗口、离开模块和关闭开发浏览器会释放非必要连接;生成、Code 执行、下载与发布构建持有租约并在完成后释放。已写入 Pi 的 prompt 或上下文整理即使确认超过 10 秒,也会继续持有运行所有权与后台租约,直到迟到响应、权威事件、明确失败或用户清理使其收敛;此时同一 Conversation 不接受重叠 mutation,其他 Conversation 不受影响。各模块的后台连接、轮询和子进程必须通过同一生命周期入口登记。 - 共享开发浏览器:AI 编程右侧提供项目级浏览器,用户与 Agent 查看并调试同一实时页面、Console 和 Network,支持本地与公网开发地址。 diff --git a/electron/api/routes/cloud-agents.ts b/electron/api/routes/cloud-agents.ts index 1e888bec..cbb70905 100644 --- a/electron/api/routes/cloud-agents.ts +++ b/electron/api/routes/cloud-agents.ts @@ -47,6 +47,8 @@ export async function handleCloudAgentsRoutes(req: IncomingMessage, res: ServerR data = await cloudAgents.useAvatarPreset(await parseJsonBody(req)); } else if (path === '/attachments/pick' && req.method === 'POST') { data = await cloudAgents.upload(); + } else if (path === '/attachments/upload' && req.method === 'POST') { + data = await cloudAgents.uploadBytes(await parseJsonBody(req)); } else if (path === '/knowledge/pick' && req.method === 'POST') { data = await cloudAgents.uploadKnowledge(await parseJsonBody(req)); } else if (path === '/skills/pick' && req.method === 'POST') { diff --git a/electron/services/cloud-agent-operations.ts b/electron/services/cloud-agent-operations.ts index 22477711..8562c29b 100644 --- a/electron/services/cloud-agent-operations.ts +++ b/electron/services/cloud-agent-operations.ts @@ -60,6 +60,7 @@ function projectMutation(value: unknown): unknown { ...(source.worker && typeof source.worker === 'object' ? { worker: copy(source.worker, ['account_id', 'address']) } : {}), ...(source.wechat && typeof source.wechat === 'object' ? { wechat: copy(source.wechat, ['status', 'message']) } : {}), ...(source.disconnected === undefined ? {} : { disconnected: source.disconnected === true }), + ...(source.unbound === undefined ? {} : { unbound: source.unbound === true }), }; } function projectWechat(value: unknown): unknown { @@ -187,7 +188,7 @@ function projectSchedules(value: unknown): unknown { return { jobs: list(object( function validateChannelInput(operation: string, args: ObjectRecord): void { const channelOperations = new Set([ 'createChannelAccount', 'routeChannelAccount', 'enableChannelAccount', 'pauseChannelAccount', - 'disconnectChannelAccount', 'channelAccountWechatBindStart', 'channelAccountWechatBindStatus', + 'disconnectChannelAccount', 'unbindChannelAccount', 'channelAccountWechatBindStart', 'channelAccountWechatBindStatus', 'channelAccountWechatBindVerification', 'channelAccountWechatBindAccount', 'channelAccountWechatUnbind', 'channelAccountOperation', 'createChannelBinding', 'switchChannelBindingAgent', 'enableChannelBinding', 'pauseChannelBinding', @@ -211,6 +212,9 @@ function validateChannelInput(operation: string, args: ObjectRecord): void { if (operation === 'routeChannelAccount' && args.target_agent_slug !== null && (typeof args.target_agent_slug !== 'string' || !/^ml-[a-f0-9]{32}$/.test(args.target_agent_slug))) throw new Error('invalid_id'); if (operation === 'routeChannelAccount' && args.target_agent_slug === null && args.enabled !== false) throw new Error('invalid_input'); + if (operation === 'unbindChannelAccount' && (typeof args.channel_account_id !== 'string' + || typeof args.operation_id !== 'string' || !Number.isSafeInteger(args.expected_revision) + || Number(args.expected_revision) < 0)) throw new Error('invalid_input'); if (operation === 'switchChannelBindingAgent' && (typeof args.target_agent_slug !== 'string' || !/^ml-[a-f0-9]{32}$/.test(args.target_agent_slug))) throw new Error('invalid_id'); if (operation === 'wechatBindStart' && typeof args.force !== 'boolean') throw new Error('invalid_input'); if (operation === 'channelAccountWechatBindStart' && typeof args.force !== 'boolean') throw new Error('invalid_input'); @@ -294,6 +298,7 @@ const operations: Record = { enableChannelAccount: { method: 'POST', path: '/channel-accounts/:channel_account_id/enable', body: ['operation_id', 'expected_revision'], output: ['channel'], project: projectMutation }, pauseChannelAccount: { method: 'POST', path: '/channel-accounts/:channel_account_id/pause', body: ['operation_id', 'expected_revision'], output: ['channel'], project: projectMutation }, disconnectChannelAccount: { method: 'POST', path: '/channel-accounts/:channel_account_id/disconnect', body: ['operation_id', 'expected_revision'], output: ['channel', 'wechat', 'disconnected'], project: projectMutation }, + unbindChannelAccount: { method: 'POST', path: '/channel-accounts/:channel_account_id/unbind', body: ['operation_id', 'expected_revision'], output: ['channel', 'wechat', 'disconnected', 'unbound'], project: projectMutation }, channelAccountWechatBindStart: { method: 'POST', path: '/channel-accounts/:channel_account_id/wechat-bind/start', body: ['operation_id', 'force'], output: ['session_key', 'status', 'message', 'qrcode_url'], project: projectWechat }, channelAccountWechatBindStatus: { method: 'GET', path: '/channel-accounts/:channel_account_id/wechat-bind/status', query: { session_key: 'session_key' }, output: ['session_key', 'status', 'message', 'qrcode_url'], project: projectWechat }, channelAccountWechatBindVerification: { method: 'POST', path: '/channel-accounts/:channel_account_id/wechat-bind/verification', body: ['operation_id', 'session_key', 'verify_code'], output: ['session_key', 'status', 'message', 'qrcode_url'], project: projectWechat }, diff --git a/electron/services/cloud-agents.ts b/electron/services/cloud-agents.ts index 1be67387..e9e20aef 100644 --- a/electron/services/cloud-agents.ts +++ b/electron/services/cloud-agents.ts @@ -49,11 +49,13 @@ const MESSAGES: Record = { knowledge_processing: '知识库有文档正在处理,请完成后再维护', agent_archived: '请先从归档恢复智能体,再启用', attachment_too_large: '附件最大支持 5 MB', + attachment_invalid: '附件内容或文件名称无效,请重新选择文件', avatar_too_large: '头像图片不能超过 5 MB', avatar_invalid: '请选择有效的 PNG、JPEG、WebP 或 GIF 图片(最大 5 MB)', download_failed: '文件保存失败,请重试', channel_not_found: '微信渠道不存在或已解绑', channel_operation_conflict: '微信渠道正在处理其他操作,请稍后查看状态', + channel_operation_unconfirmed: '微信绑定的变更尚未确认,请查看待确认操作后重试', channel_request_invalid: '微信渠道请求无效,请刷新后重试', channel_rate_limited: '微信渠道操作过于频繁,请稍后重试', channel_conversation_not_found: '微信会话不存在或已关闭', @@ -423,9 +425,20 @@ export class CloudAgentsModule { let plan; try { plan = operationPlan(value); } catch { throw new CloudAgentsError(422, 'invalid_input'); } - const invoke = async () => plan.project(await this.request(plan.path, plan.method, plan.body, plan.cloud === 'ws')); + const invoke = async () => { + const result = plan.project(await this.request(plan.path, plan.method, plan.body, plan.cloud === 'ws')); + if (plan.operation === 'unbindChannelAccount') { + const value = record(result), channel = record(value.channel); + if (value.unbound !== true || value.disconnected !== true || channel.id !== plan.input.channel_account_id + || channel.enabled !== false || channel.target_agent_slug !== null || channel.health !== 'login_required') { + // Keep the original operation in recovery until both routing and login are confirmed removed. + throw new CloudAgentsError(503, 'channel_operation_unconfirmed'); + } + } + return result; + }; const recoverable = ['submit', 'preview', 'resume', 'publish', 'createApplication', 'createKey', 'createKnowledge', 'processKnowledge', 'createSchedule', 'runSchedule', 'importKnowledgeAttachment', 'createChild', - 'createChannelAccount', 'routeChannelAccount', 'enableChannelAccount', 'pauseChannelAccount', 'disconnectChannelAccount', + 'createChannelAccount', 'routeChannelAccount', 'enableChannelAccount', 'pauseChannelAccount', 'disconnectChannelAccount', 'unbindChannelAccount', 'channelAccountWechatBindStart', 'channelAccountWechatBindVerification', 'channelAccountWechatUnbind', 'createChannelBinding', 'switchChannelBindingAgent', 'enableChannelBinding', 'pauseChannelBinding', 'disconnectChannelBinding', 'wechatBindStart', 'wechatBindVerification', 'wechatUnbind', @@ -489,8 +502,46 @@ export class CloudAgentsModule { } async upload(): Promise { + const binding = getWorksSquareAccountBinding(); + if (!binding) throw new CloudAgentsError(401, 'session_expired'); const body = await this.pickUpload(); + this.requireCurrent(binding); if (!body) return null; + const result = await this.uploadTemporaryAttachment(body); + this.requireCurrent(binding); + return result; + } + + async uploadBytes(value: unknown): Promise { + const binding = getWorksSquareAccountBinding(); + if (!binding) throw new CloudAgentsError(401, 'session_expired'); + const input = record(value); + const limit = 5 * 1024 * 1024; + if (Object.keys(input).some(key => !['bytesBase64', 'fileName', 'mimeType'].includes(key)) + || typeof input.fileName !== 'string' || !input.fileName.trim() || input.fileName.length > 255 + || /[\\/]/.test(input.fileName) || Array.from(input.fileName).some(char => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127) + || ['.', '..'].includes(input.fileName) + || typeof input.mimeType !== 'string' || input.mimeType.length > 127 + || !/^[a-zA-Z0-9][a-zA-Z0-9!#$&^_.+-]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&^_.+-]*$/.test(input.mimeType) + || typeof input.bytesBase64 !== 'string' || !input.bytesBase64) { + throw new CloudAgentsError(422, 'attachment_invalid', 'definitive_failure'); + } + if (input.bytesBase64.length > Math.ceil(limit / 3) * 4) throw new CloudAgentsError(422, 'attachment_too_large', 'definitive_failure'); + if (input.bytesBase64.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(input.bytesBase64)) { + throw new CloudAgentsError(422, 'attachment_invalid', 'definitive_failure'); + } + const bytes = Buffer.from(input.bytesBase64, 'base64'); + if (!bytes.length || bytes.toString('base64') !== input.bytesBase64) throw new CloudAgentsError(422, 'attachment_invalid', 'definitive_failure'); + if (bytes.length > limit) throw new CloudAgentsError(422, 'attachment_too_large', 'definitive_failure'); + const body = new FormData(); + body.append('file', new Blob([new Uint8Array(bytes)], { type: input.mimeType }), input.fileName); + this.requireCurrent(binding); + const result = await this.uploadTemporaryAttachment(body); + this.requireCurrent(binding); + return result; + } + + private async uploadTemporaryAttachment(body: FormData): Promise { const result = record(await this.request('/api/makelore/attachments/tmp', 'POST', body)); return { object_name: textField(result.object_name, 2048), file_name: textField(result.file_name, 255), diff --git a/shared/cloud-agents.ts b/shared/cloud-agents.ts index f9266556..aef4e919 100644 --- a/shared/cloud-agents.ts +++ b/shared/cloud-agents.ts @@ -222,10 +222,11 @@ export interface CloudUserChannelAccount { target_agent_published_version: number | null; provider_generation?: string; desired_state?: string; + sync_state?: string; last_confirmed_at?: string | null; } export interface CloudChannelAccountPage { items: CloudUserChannelAccount[] } -export interface CloudChannelAccountMutation { channel: CloudUserChannelAccount; target_agent?: string | null; wechat?: Record; disconnected?: boolean } +export interface CloudChannelAccountMutation { channel: CloudUserChannelAccount; target_agent?: string | null; wechat?: Record; disconnected?: boolean; unbound?: boolean } export interface CloudChannelOperation { operation_id: string; status: 'pending' | 'completed' | 'failed' | 'unknown' | string; @@ -424,6 +425,7 @@ export interface CloudAgentOperations { enableChannelAccount: Op<{ channel_account_id: string; operation_id: string; expected_revision: number }, CloudChannelAccountMutation>; pauseChannelAccount: Op<{ channel_account_id: string; operation_id: string; expected_revision: number }, CloudChannelAccountMutation>; disconnectChannelAccount: Op<{ channel_account_id: string; operation_id: string; expected_revision: number }, CloudChannelAccountMutation>; + unbindChannelAccount: Op<{ channel_account_id: string; operation_id: string; expected_revision: number }, CloudChannelAccountMutation>; channelAccountWechatBindStart: Op<{ channel_account_id: string; operation_id: string; force: boolean }, CloudWechatBindState>; channelAccountWechatBindStatus: Op<{ channel_account_id: string; session_key: string }, CloudWechatBindState>; channelAccountWechatBindVerification: Op<{ channel_account_id: string; operation_id: string; session_key: string; verify_code: string }, CloudWechatBindState>; diff --git a/src/components/account/PointWallet.tsx b/src/components/account/PointWallet.tsx index f4a31b27..7fd21814 100644 --- a/src/components/account/PointWallet.tsx +++ b/src/components/account/PointWallet.tsx @@ -21,7 +21,7 @@ const money = (cents: number) => `¥${(cents / 100).toFixed(2)}`; const time = (value: string) => new Date(value).toLocaleString('zh-CN'); const unresolved = (order: WorksRechargeOrder) => order.status === 'pending' || order.status === 'manual_review'; -export function PointWallet({ active = true, accountName = '当前登录账号', accountUsername }: { active?: boolean; accountName?: string; accountUsername?: string }) { +export function PointWallet({ active = true, accountName = '当前登录账号', accountUsername, showRechargeEntry = true }: { active?: boolean; accountName?: string; accountUsername?: string; showRechargeEntry?: boolean }) { const [summary, setSummary] = useState(null); const [summaryError, setSummaryError] = useState(false); const [open, setOpen] = useState(false); @@ -169,7 +169,7 @@ export function PointWallet({ active = true, accountName = '当前登录账号', {isWorksTokenPointBalanceExhausted(own) && !usingShared &&

{summary?.can_recharge ? '点数已用尽,请由家长完成当前账号充值。' : '点数已用尽,当前账号暂不能充值,请联系支持。'}

} {summaryError && } - + {showRechargeEntry && } diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index a4cdc38c..84f86bf1 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -864,7 +864,7 @@ export function Sidebar({
- {authUser && } + {authUser && } -
; - - return
-
- void refresh().catch(failure => setError(errorText(failure)))} /> -
-
- -

连接到微信

选一个智能体,把它带到微信里。

-
-
- - -
-
- {!flow && messages} - {!loading && loaded && !agents.length && !flow && agentRequiredHint} - {loading ?
正在查看你的微信绑定…
- : !loaded ?

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

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

把你的智能体带到微信吧

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

- -
) :
- {accounts.map(account => { - const agent = agents.find(item => item.slug === account.target_agent_slug); - const state = accountState(account); - return
-

微信

{account.display_name}

{state.text}
-
- {account.target_agent_slug ? <>

绑定的智能体

{account.target_agent_name ?? agent?.name ?? '智能体暂不可用'}

- : <>

还没有绑定智能体

} -
- -
; - })} -
} + const feedback = ( + <> + {error && ( +

+ {error} +

+ )} + {agentsError && ( +

+ {agentsError}。已有微信仍可以暂停或解除绑定。 +

+ )} + {notice && ( +

+ {notice} +

+ )} + + ); + const agentHint = ( +
+

还没有可连接的智能体

+

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

+
- { if (!open) close(); }}> - event.preventDefault()} onEscapeKeyDown={event => { if (busy) event.preventDefault(); }}> - {flow?.mode === 'new' ? '添加微信' : '管理微信绑定'}{flow?.mode === 'new' ? '给微信选一个智能体,再扫码连接。' : selected?.display_name ?? '微信绑定'} - {flow && <> - {(flow.mode === 'new' || flow.step !== 'choose') &&
    {['选择智能体', '扫码连接', '完成绑定'].map((text, index) => { - const current = ['choose', 'connect', 'result'].indexOf(flow.step); - return
  1. {index < current ? : index + 1}{text}
  2. ; - })}
} - {messages} - {recoveryBlocked &&

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

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

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

: null} - {flow.mode === 'new' || !selected?.target_agent_slug ? - :
- {flow.target !== selected.target_agent_slug && } -
- {!connectionReady && } - - + ); + return ( +
+
+ void refresh().catch((failure) => setError(errorText(failure)))} + /> +
+
+ + + +
+

+ 连接到微信 +

+

一个微信,一个智能体。

+
+
+
+ + +
+
+ {!flow && feedback} + {!loading && + accountsLoaded && + agentsLoaded && + !agentsError && + !agents.length && + !flow && + agentHint} + {loading ? ( +
+ + 正在查看你的微信绑定… +
+ ) : !accountsLoaded ? ( +
+ 暂时没能读到微信绑定,请刷新后再试。 +
+ ) : !accounts.length ? ( + agentsLoaded && + !agentsError && + agents.length > 0 && ( +
+ +

把你的智能体带到微信吧

+

选一个智能体,再用微信扫码,就可以开始聊天。

+ +
+ ) + ) : ( +
+ {accounts.map((account) => { + const agent = agents.find((item) => item.slug === account.target_agent_slug); + const uncertain = !recoveryLoaded || checkingIds.includes(account.id) || pendingFor(account.id); + const state = accountState(account, uncertain || busyAccount === account.id); + const needsScan = + account.target_agent_slug && + ['login_required', 'verification_required'].includes(account.health); + const blocked = disabled || uncertain; + const changeBlocked = + blocked || unsettled(account) || !agentsLoaded || Boolean(agentsError); + const canResume = + !unsettled(account) && + account.health === 'connected' && + Boolean(account.target_agent_slug) && + !usableBlockers(account).length; + return ( +
+
+
+

微信

+

+ {account.display_name} +

+
+ + {state.text} + +
+
+ {account.target_agent_slug ? ( + <> + +
+

当前智能体

+

+ {account.target_agent_name ?? agent?.name ?? '智能体暂不可用'} +

+
+ + ) : ( + <> + +

还没有绑定智能体

+ + )} +
+ {usableBlockers(account) + .filter((item) => blockerText[item]) + .map((item) => ( +

+ {blockerText[item]} +

+ ))} +
+ + +
+ {more === account.id && ( +
{ + if (event.key === 'Escape') setMore(''); + }} + > + {needsScan && ( + + )} + {account.target_agent_slug && ( + + )} + +
+ )} + {uncertain && ( + + )} +
+ ); + })} +
+ )} +
+ { + if (!value) close(); + }} + > + event.preventDefault()} + onEscapeKeyDown={(event) => { + if (busy) event.preventDefault(); + }} + > + + + {flow?.mode === 'unbind' + ? '解除微信绑定' + : flow?.mode === 'reconnect' + ? '重新连接微信' + : flow?.mode === 'switch' + ? '更换智能体' + : '添加微信'} + + + {flow?.mode === 'new' + ? '选一个智能体,再用微信扫码。' + : (selected?.display_name ?? '微信绑定')} + + + {feedback} + {recoveryBlocked && ( +

+ 上次操作的结果还需要确认。请关闭窗口,处理页面上的恢复提醒。 +

+ )} + {flow?.mode === 'unbind' ? ( + <> +

+ 解除后,这个微信将停止自动回复,当前任务会停止,并断开微信登录。智能体聊天历史仍保留,再使用需要重新扫码。 +

+ + + + ) : flow?.step === 'choose' ? ( + <> + {!agentsError && agentsLoaded && !agents.length ? ( + agentHint + ) : ( +
+ {agents.map((agent) => ( + + ))}
-

暂停会停止这个微信正在进行的任务,连接和绑定仍保留。

-
} - {flow.mode === 'manage' && selected && } - - } - {flow.step === 'connect' && selected && <> -

{selected.display_name}将使用

{targetAgent?.name ?? selected.target_agent_name}

- {connectionReady ?

微信已连接

完成绑定后,就可以回微信使用。

- :
-
{qr?.qrcode_url ? : busy === 'qr' ? : }
-

{qr ? qrLabels[qr.status] ?? '请使用微信扫码' : '请使用微信扫码'}

- {qr?.message &&

{qr.message}

} - {(!qr || ['expired', 'failed'].includes(qr.status)) && } -
} - {qr?.status === 'verification_required' &&
} - -
- } - {flow.step === 'result' && selected && <> -

绑定完成啦

{selected.display_name}已绑定{selected.target_agent_name ?? targetAgent?.name ?? '智能体'},现在可以回微信和它聊聊。

- - } - } - -
- setConfirmation(null)} /> -
; + )} + {flow.mode === 'switch' && selected?.target_agent_slug && ( +

+ 更换会停止当前任务,微信连接和历史保留。{!selected.enabled && '这个微信仍保持暂停。'} +

+ )} + + + + ) : flow?.step === 'connect' ? ( + <> + {qr?.status === 'confirmed' || selected?.health === 'connected' ? ( +
+ +

{busy ? '正在完成微信绑定…' : '微信登录已确认'}

+

+ {connectionFailed + ? '还没有完成绑定,请查看上方提示。' + : '正在确认智能体和自动回复状态。'} +

+
+ ) : ( +
+
+ {qr?.qrcode_url ? ( + + ) : busy === 'qr' ? ( + + ) : ( + + )} +
+

+ {qr ? (qrLabels[qr.status] ?? '请使用微信扫码') : '请使用微信扫码'} +

+ {qr?.message &&

{qr.message}

} + {(!qr || ['expired', 'failed'].includes(qr.status)) && ( + + )} +
+ )} + {qr?.status === 'verification_required' && ( +
+ + +
+ )} + {connectionFailed && ( + + )} + {checkingIds.includes(flow.accountId) && ( + + )} +
+ + +
+ + ) : flow?.step === 'result' ? ( + <> +
+ +

+ {flow.mode === 'reconnect' ? '微信重新连接成功' : '微信绑定成功'} +

+

+ {selected?.enabled + ? `现在可以回微信和${selected.target_agent_name ?? '智能体'}聊聊。` + : '微信已连接,自动回复仍保持暂停。'} +

+
+ + + ) : null} +
+
+ + ); } diff --git a/src/pages/CloudAgents/CloudChat.tsx b/src/pages/CloudAgents/CloudChat.tsx index 30ccdee0..a0008fc7 100644 --- a/src/pages/CloudAgents/CloudChat.tsx +++ b/src/pages/CloudAgents/CloudChat.tsx @@ -1,7 +1,9 @@ import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; -import { ArrowUpRight, History, MessageCircle, MessageSquarePlus, Send, Sparkles, Square } from 'lucide-react'; +import { ArrowUp, ArrowUpRight, History, LoaderCircle, MessageCircle, MessageSquarePlus, Sparkles, Square } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Textarea } from '@/components/ui/textarea'; +import { useWorksVoiceInput } from '@/hooks/use-works-voice-input'; +import { ComposerVoiceButton } from '@/pages/Chat/ComposerVoiceButton'; import { cloudAgentsApi } from '@/lib/cloud-agents-api'; import type { CloudHistory, CloudInterrupt, CloudPrompt, CloudRequest, CloudRun, CloudThread, CloudScheduleProposal } from '../../../shared/cloud-agents'; import { CloudComposerAttachments } from './CloudComposerAttachments'; @@ -66,13 +68,32 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread const [localAttachmentHeader, setLocalAttachmentHeader] = useState(null); const [executionEpoch, setExecutionEpoch] = useState(0); const childrenPending = useCloudPendingState(); - usePendingCloudInput(busy || Boolean(intent) || Boolean(query.trim()) || attachmentIds.length > 0 || childrenPending.hasPending); + const queryRef = useRef(query); + queryRef.current = query; const generation = useRef(0); const alive = useRef(true); const transcript = useRef(null); const following = useRef(true); const entry = useRef({ initialThread, initialThreadId, revision: previewRevision }); const mode = previewRevision === undefined ? 'published' : 'preview'; + const voiceScopeKey = `${slug}:${mode}:${clientId}:${previewRevision ?? 'published'}`; + const editable = activeView && !busy && !intent && !loading && !thread?.archived; + const voiceInput = useWorksVoiceInput({ + enabled: editable && !attachmentBusy && !childrenPending.hasPending, + scopeKey: voiceScopeKey, + onTranscript: text => { + const current = queryRef.current; + const next = current.trim() ? `${current.trimEnd()}\n${text}` : text; + if (next.length > 32000) { + setError('语音内容加入后超过 32000 字,请先精简草稿再试。'); + return; + } + queryRef.current = next; + setQuery(next); + }, + }); + const voicePending = voiceInput.state !== 'idle'; + usePendingCloudInput(busy || Boolean(intent) || Boolean(query.trim()) || attachmentIds.length > 0 || childrenPending.hasPending || voicePending); const readHistory = useCallback(async (id: string, expectedGeneration = generation.current) => { let page = await cloudAgentsApi.call('history', { thread_id: id }); while (page.next_offset !== null) { @@ -257,13 +278,16 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread }, [previewRunId, previewThreadId, previewEnded, currentRunId, currentRunStatus, running, readHistory, executionEpoch]); useEffect(() => { if (following.current && transcript.current) transcript.current.scrollTop = transcript.current.scrollHeight; }, [history?.messages.length, liveReply, interrupt]); const stalePreview = previewRevision !== undefined && latestRevision !== undefined && previewRevision !== latestRevision; + const canSend = activeView && !busy && !attachmentBusy && !childrenPending.hasPending && !voicePending + && !running && !loading && !thread?.archived + && (Boolean(intent) || (!hasUnsavedChanges && !stalePreview && previewReady && Boolean(query.trim() || attachmentIds.length))); const rememberThread = (next: CloudThread) => { setThread(next); setThreads(current => [next, ...current.filter(item => item.thread_id !== next.thread_id)]); }; const send = async () => { - if (busy || attachmentBusy || childrenPending.hasPending || running || thread?.archived || (!intent && (hasUnsavedChanges || stalePreview || !previewReady || (!query.trim() && !attachmentIds.length)))) return; + if (!canSend) return; const input = intent ?? { request_id: crypto.randomUUID(), thread_id: thread?.thread_id ?? clientId, query: query.trim() || '请看看这些附件。', attachment_file_ids: [...attachmentIds], @@ -285,6 +309,7 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread finally { if (alive.current && g === generation.current) setBusy(false); } }; const choose = async (next: CloudThread | null, keepQuery = false, revision = latestRevision) => { + voiceInput.cancel(); following.current = true; if (next) setPreviousTrial(null); if (mode === 'preview') setPreviewRevision(next?.draft_revision ?? revision); @@ -294,7 +319,7 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread setAttachmentIds([]); if (next) { setLoading(true); try { await readHistory(next.thread_id, g); } catch(e) { setError(message(e)); } finally { setLoading(false); } } }; - const trialPending = running || queued.length > 0 || Boolean(intent) || Boolean(interrupt) || attachmentIds.length > 0 || childrenPending.hasPending; + const trialPending = running || queued.length > 0 || Boolean(intent) || Boolean(interrupt) || attachmentIds.length > 0 || childrenPending.hasPending || voicePending; const restartTrial = async (saveFirst: boolean) => { if (busy || loading || trialPending || (saveFirst ? saveDisabled || !onSaveForPreview : !previewReady)) return; setBusy(true); @@ -323,11 +348,11 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread {previewRevision !== undefined &&

当前试用 · 修订 {previewRevision}

}

由创建者支付词元点数{run?.version && ` · 版本 ${run.version}`}

- {headerAction}
+ {headerAction} {showHistory && threads.length === 0 &&

还没有历史对话

} {showHistory && threads.length > 0 && } {(stalePreview || hasUnsavedChanges) &&
@@ -348,7 +373,7 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread

{mode === 'preview' ? '看看它的回答是否符合你的要求。不满意就改一改,再试一次。' : '从下面选一句,或写下你自己的想法。'}

{examples.map((example, index) => )}
+ disabled={busy || Boolean(intent) || Boolean(query.trim()) || Boolean(thread?.archived) || voicePending} onClick={() => setQuery(example)}>{example}

点选只会填入输入框,由你决定何时发送。

} {previousTrial &&
@@ -376,7 +401,7 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread {onSchedule && history?.schedule_proposals?.map(proposal =>

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

{proposal.prompt}

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

- +
)} {run?.error && run.status === 'failed' &&

{run.error.message}

} {interrupt && runId && !thread?.archived && { @@ -402,7 +427,7 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread {error &&

{error}

} {thread?.archived &&
此对话已归档,恢复后可继续发送消息。 -
}
{ e.preventDefault(); void send(); }}> - { + onBusy={setAttachmentBusy} disabled={!editable || running || voicePending} ensureThread={async () => { if (thread) return thread.thread_id; const expectedGeneration = generation.current; const created = await cloudAgentsApi.call('createThread', { slug, thread_id: clientId, preview: previewRevision !== undefined, expected_revision: previewRevision }); - if (!alive.current || expectedGeneration !== generation.current) return created.thread_id; + if (!alive.current || expectedGeneration !== generation.current) throw new Error('对话已切换,请在当前对话重新添加附件。'); rememberThread({ thread_id: created.thread_id, client_thread_id: clientId, agent_slug: slug, title: '新对话', mode, draft_revision: previewRevision, status: 'idle', updated_at: new Date().toISOString(), run_id: null, unread: false }); return created.thread_id; }} footer={<> Enter 发送 · Shift + Enter 换行 - {running ? - : } +
+ + {running ? + : } +
}> -