From f61efaf046cd6c15584609cead646c275068cc2e 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 19:12:35 +0800 Subject: [PATCH] feat: add Code-style voice composer to current Agents trial --- .../20261013-agent-composer-trial-d76b48c2.md | 37 ++ README.md | 10 +- electron/api/routes/cloud-agents.ts | 2 + electron/services/cloud-agents.ts | 39 ++ src/components/account/AccountMenuTrigger.tsx | 38 ++ src/components/account/HomeAccountMenu.tsx | 107 +++++ .../account/StandaloneAccountDock.tsx | 29 ++ src/components/layout/MainLayout.tsx | 6 + src/components/layout/SettingsLayout.tsx | 11 + src/components/layout/Sidebar.tsx | 57 +-- src/components/layout/SidebarUpdateButton.tsx | 2 +- src/components/layout/TitleBar.tsx | 1 + src/hooks/use-works-voice-input.ts | 7 +- src/lib/cloud-agents-api.ts | 11 + src/lib/settings-navigation.ts | 30 ++ src/pages/Chat/classroom-workspace.css | 3 +- src/pages/CloudAgents/CloudAccess.tsx | 2 +- src/pages/CloudAgents/CloudChat.tsx | 89 +++- .../CloudAgents/CloudComposerAttachments.tsx | 183 ++++++-- src/pages/CloudAgents/CloudKnowledge.tsx | 114 ++--- .../CloudKnowledgeAttachmentPicker.tsx | 280 ----------- .../CloudAgents/CloudKnowledgeDocuments.tsx | 14 +- .../CloudAgents/CloudKnowledgeSelector.tsx | 8 +- src/pages/CloudAgents/CloudRecovery.tsx | 95 +++- src/pages/CloudAgents/CloudResources.tsx | 2 +- src/pages/CloudAgents/ConfigurationFields.tsx | 3 +- src/pages/CloudAgents/DraftEditor.tsx | 4 +- src/pages/CloudAgents/agent-chat.css | 57 ++- src/pages/CloudAgents/agent-config.css | 68 +-- src/pages/CloudAgents/agent-home.css | 20 + src/pages/CloudAgents/application-message.ts | 16 + src/pages/CloudAgents/desktop-workspace.css | 4 +- src/pages/CloudAgents/index.tsx | 12 +- src/pages/CloudAgents/useCloudPublication.ts | 3 +- src/pages/ModuleSelection/index.tsx | 21 +- src/pages/Settings/index.tsx | 28 +- src/styles/globals.css | 79 +++- tests/e2e/cloud-agent-chat-layout.spec.ts | 33 +- tests/e2e/cloud-agent-knowledge.spec.ts | 303 ++++-------- tests/e2e/cloud-agent-recovery.spec.ts | 248 ++++++++++ tests/e2e/cloud-agents.spec.ts | 96 +++- tests/e2e/main-navigation.spec.ts | 310 ++++++++++-- tests/e2e/settings-proxy.spec.ts | 12 + tests/unit/account-menu-trigger.test.tsx | 108 +++++ tests/unit/cloud-agent-start-use.test.tsx | 15 +- .../cloud-agent-subagent-selector.test.tsx | 52 +++ tests/unit/cloud-agents-guidance.test.tsx | 25 +- tests/unit/cloud-agents-page.test.tsx | 133 ++++-- tests/unit/cloud-attachment-upload.test.ts | 289 ++++++++++++ tests/unit/cloud-chat-reasoning.test.tsx | 8 +- tests/unit/cloud-chat-timestamps.test.tsx | 177 +++++++ tests/unit/cloud-chat-voice.test.tsx | 441 ++++++++++++++++++ tests/unit/cloud-knowledge-catalog.test.tsx | 15 +- tests/unit/cloud-knowledge-import.test.tsx | 345 -------------- tests/unit/cloud-knowledge-picker.test.tsx | 107 ++++- tests/unit/cloud-knowledge-status.test.tsx | 22 +- tests/unit/cloud-recovery.test.tsx | 242 ++++++++++ tests/unit/home-account-menu.test.tsx | 153 ++++++ tests/unit/main-layout-module-gate.test.tsx | 13 + tests/unit/settings-navigation.test.ts | 30 ++ 60 files changed, 3383 insertions(+), 1286 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20261013-agent-composer-trial-d76b48c2.md create mode 100644 src/components/account/AccountMenuTrigger.tsx create mode 100644 src/components/account/HomeAccountMenu.tsx create mode 100644 src/components/account/StandaloneAccountDock.tsx create mode 100644 src/components/layout/SettingsLayout.tsx create mode 100644 src/lib/settings-navigation.ts delete mode 100644 src/pages/CloudAgents/CloudKnowledgeAttachmentPicker.tsx create mode 100644 src/pages/CloudAgents/application-message.ts create mode 100644 tests/e2e/cloud-agent-recovery.spec.ts create mode 100644 tests/unit/account-menu-trigger.test.tsx create mode 100644 tests/unit/cloud-agent-subagent-selector.test.tsx create mode 100644 tests/unit/cloud-attachment-upload.test.ts create mode 100644 tests/unit/cloud-chat-timestamps.test.tsx create mode 100644 tests/unit/cloud-chat-voice.test.tsx delete mode 100644 tests/unit/cloud-knowledge-import.test.tsx create mode 100644 tests/unit/cloud-recovery.test.tsx create mode 100644 tests/unit/home-account-menu.test.tsx create mode 100644 tests/unit/settings-navigation.test.ts diff --git a/.project-docs/30-worklog/tasks/20261013-agent-composer-trial-d76b48c2.md b/.project-docs/30-worklog/tasks/20261013-agent-composer-trial-d76b48c2.md new file mode 100644 index 00000000..7e2d1d62 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20261013-agent-composer-trial-d76b48c2.md @@ -0,0 +1,37 @@ +# Task: Adapt Agents Code-style voice composer to current trial + +## Identity + +- Task ID: 20261013-agent-composer-trial-d76b48c2 +- Mode: Feature +- Branch: codex/20261013-agent-composer-trial-d76b48c2-agent-composer-trial +- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261013-agent-composer-trial-d76b48c2 +- Base commit: 7c51bb4a008e388071ec37156adeae80eff24167 +- Owner: codex-root +- Status: Implementing + +## Scope + +- Adapt the Agents composer onto running trial source: Main 7c51bb4a plus its exact current Renderer overlay 154a21a (JS 64b4a84 and timestamp CSS 154a21a). Preserve stream replies, avatar, attachment sheet, delivered-file privacy, newer account/settings, recovery and knowledge layout. Voice recognition, draft-only appending, Code sizing/keyboard behavior, multi-file/paste/drop upload are in scope. + +## Intent And Constraints + +- User requests Code-style Agents chat input with voice; speech is application-level, independent of Learning. Main owns network/auth/files. Preserve newer trial UI and peer work; no provider/model configuration or automatic submission. +- Concurrent Task Gate: isolated owned feature worktree registered and verified. Peer registry scopes were reviewed in preceding candidate task; edits here partitioned explicitly among root and three subagents. Primary foreign document remains untouched. +- Planning Gate: passed after project entry, positioning, current-state, decision and architecture review. Main-baseline candidate 48c592f remains retained; this adaptation starts from exact current trial 7c51bb4a rather than removing newer product features. Only this task record and README are written as documentation. + +## Outcome + +- Source implementation complete. Preserved peer-committed Renderer delta 7c51bb4a → 154a21a incrementally, including matching tests and README; only composer and typed upload changes differ in product tree from actual predecessor. Shared voice hook now resets mounted state during React StrictMode effect setup. No cloud model contract or automatic prompt submission changes. Current running app is not yet updated. + +## Verification + +- Current-baseline 253 related regression tests passed (11 files), 28 voice tests including StrictMode passed, and 31 new upload/lifecycle tests passed: 312 disjoint cases. Final typecheck, focused lint (0 errors; two existing CloudChat warnings), full Renderer/Main/Preload/utility Vite build and project-docs/diff gates passed. Independent review has no open blocker. 17 disjoint Electron scenarios passed: 12 in first run plus 5 targeted reruns after updating inherited timestamp/recovery assertions to the exact current trial contract. Published voice fixture validates WAV transcription, latest-draft append, no automatic prompts, 32px buttons and multi-line growth; layout covers narrow/wide windows, long messages, attachments and stream/stop. Voice screenshot visually inspected by root and QA. Fixture audio never accesses the real microphone or paid provider. Real microphone/provider transcription remains distinct from fixture validation. + +## Follow-ups + +- Task-owned compiled runtime is staged at .trial-runtime/agent-composer-20261013-d76b48c2; retains predecessor native shell, profile and Renderer origin. Before activation compare current pointer/compatibility link, inspect live UI for unsaved input, quit gracefully through CUA, then replace and verify visible composer. No profile contents read or copied by this update. The independent speech service fix ae9be8c5 remains undeployed. + +## Promotion Candidates + +- Agents composer follows Code interaction and style; application-level speech deployment remains a server integration follow-up. diff --git a/README.md b/README.md index cf0dfc14..ac7cb08e 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,17 @@ Code 会话保存的模型被下架后,会提示重新选择可用模型;用户可在原会话中切换,恢复时使用新选择并保留会话与历史消息。切换不会自动重发消息或自动选择替代模型。 -Makelore 是一个面向软件、视觉创作、智能机器人与个人云智能体的 AI 桌面工作台。当前版本为 `2.0.0`,首页展示 Agents、Code、Canvas 三个模块入口,Robot 入口隐藏;云智能体需配套配置 WS/Yuxi 服务。三个入口在桌面等宽并排、插画在上文字在下,小窗口改为纵向排列的横向插画卡片;Agents 标语为“打造你想象中的AI助手”。工作区左上角入口点击后返回模块入口页: +Makelore 是一个面向软件、视觉创作、智能机器人与个人云智能体的 AI 桌面工作台。当前版本为 `2.0.0`,首页展示 Agents、Code、Canvas 三个模块入口,Robot 入口隐藏;云智能体需配套配置 WS/Yuxi 服务。桌面采用左侧问候与标题、右侧三个横向插画卡片竖排的布局,Code 入口中心与“今天,想创作什么?”标题中心对齐,三个入口等高、间距均匀;标题组在桌面向右偏移约 1.5cm,小窗口将标题放在卡片上方;Agents 标语为“打造你想象中的AI助手”。各页左下角账户按钮统一沿用 Code 的白色圆角卡片、头像、字号和右箭头,展开时跟随 Code 侧栏宽度偏好,首页和设置页与 Code 保持相同的左侧与底部边距;展开菜单保持原尺寸,可查看本人词元点数与预占、打开词元点数与充值、个人资料、设置和退出登录;沿用侧栏的账号、钱包和资料规则。设置使用独立页面,不显示编程项目栏、项目标题或工作栏收起按钮;“返回”回到打开设置前的页面(保留查询参数),直接打开设置时回模块首页,未保存设置先保存或还原。工作区左上角入口点击后返回模块入口页: - `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 的空字符串契约。 - 高级配置的知识库支持下拉多选,分为「MakeLore知识」和「我的知识库」,已选项可单独移除。MakeLore知识来自当前账号有权读取的 Yuxi 共享目录,只显示名称与简介;移除仅取消当前智能体选用,不删除原库。知识库区域左右分区:左侧「上传自己的文件」管理本人知识库,右侧选择「MakeLore知识」和「我的知识库」,小窗口上下排列。左侧可新建库或追加资料,提供「从电脑上传」和「从聊天选择文件」;聊天文件选择面板显示目标知识库、可搜索的来源聊天和文件名称/类型/大小,支持多选,逐文件导入并保留独立操作身份,部分失败只重试失败项;也可明确确认放弃本页重试,已保存文件仍保留。仅选本人已上传的聊天附件,聊天正文和助手生成的文件不直接入库。上传和导入后自动解析、建立索引并持续显示真实处理状态;失败可重试且保留已有可用内容。使用说明与费用以折叠说明呈现。暂不可选用的个人库仍可管理文件,已有绑定保留并提示不可用;未确认请求在当前库保留同一操作身份供重试。知识库选择只改草稿,显式保存/应用后生效;个人云端执行已自动接入所选知识库的检索,无需另外勾选 knowledge-base Skill。此交互复用现有接口,无需新增后端补丁。 + 个人智能体聊天在每条提问与回答下方显示云端记录的消息时间;发送时间位于紫色消息气泡外下方并靠右,助手回复时间保持在回答下方:今天显示时分,跨日增加日期,悬停可查看完整日期与秒数;发送时间与回复时间沿用原消息记录,重开对话不改为当前时间。流式回答尚无可靠时间时显示「回复中…」,历史到达后展示原时间;缺失或无效时间显示「时间未记录」。正式对话、草稿试用和上次试用对比使用相同规则,不新增 Yuxi 接口。 + + 历史待确认记录收起在智能体列表标题区的「创建记录/待处理记录」入口,只有点击后才打开记录窗口;配置、聊天和创建页不再置顶这些旧提醒。记录保留智能体名称和原请求身份;应用设置的待确认记录显示“网络可能不稳定,请再点一次「应用」。”,点击「应用」继续原来的提交,成功后显示“设置已应用。”。其他记录仍可明确「再试一次」或「不再提醒」,不会自动重试或清除。当前创建失败继续在创建表单内提示;关闭记录窗口保留已收到的结果和密钥,切换配置、聊天或创建页后可返回列表继续查看。密钥须明确关闭结果后,才可处理其他记录。 + + 高级配置的知识库支持下拉多选,分为「MakeLore知识」和「我的知识库」,已选项可单独移除。MakeLore知识来自当前账号有权读取的 Yuxi 共享目录,只显示名称与简介;移除仅取消当前智能体选用,不删除原库。知识库区域上方左右分区,两张卡片在并排时保持等高:左侧「上传自己的文件」仅放「新建知识库」入口及展开的创建表单,右侧「查看我的知识库」通过下拉框查看本人知识库、从电脑追加资料和管理文件;下方独立「选择知识库」,选择智能体使用的「MakeLore知识」和「我的知识库」。小窗口按新建、查看、选用顺序纵向排列。创建成功后在右侧打开新库;新建和查看均不会自动加入智能体配置,选用上限不影响管理与新建。仅提供「从电脑上传」作为添加文件入口,未选择管理目标时上传按钮置灰;聊天文件导入入口已移除。上传后自动解析、建立索引并持续显示真实处理状态;失败可重试且保留已有可用内容。暂不可选用的个人库仍可管理文件,已有绑定保留并提示不可用;未确认请求在当前库保留同一操作身份供重试。知识库选择只改草稿,显式保存/应用后生效;个人云端执行已自动接入所选知识库的检索,无需另外勾选 knowledge-base Skill。此交互复用现有接口,无需新增后端补丁。 云知识库的 embedding 模型只由平台管理员在 Yuxi 后台维护:在「智能体管理 → 模型供应商」启用供应商,按供应商要求填写有效的服务地址和 API Key,添加并启用 embedding 模型并填写实际向量维度;embedding 向量化费用由平台承担,不计入用户用量、词元点数或智能体费用上限;one-api 不参与 embedding 配置,Works Square 只负责其他应计费模型调用的创建者词元点数账本。客户端区分目录加载中、加载失败与无可用模型,空状态提供管理员配置说明;「刷新知识库与模型」保留未提交名称,未确认的创建请求沿用原操作身份。 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-agents.ts b/electron/services/cloud-agents.ts index 1be67387..81865c4c 100644 --- a/electron/services/cloud-agents.ts +++ b/electron/services/cloud-agents.ts @@ -49,6 +49,7 @@ 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: '文件保存失败,请重试', @@ -489,8 +490,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/src/components/account/AccountMenuTrigger.tsx b/src/components/account/AccountMenuTrigger.tsx new file mode 100644 index 00000000..88ca7bd8 --- /dev/null +++ b/src/components/account/AccountMenuTrigger.tsx @@ -0,0 +1,38 @@ +import { forwardRef, type ButtonHTMLAttributes } from 'react'; +import { ChevronRight } from 'lucide-react'; +import { UserAvatar } from '@/components/profile/UserAvatar'; +import { getAccountInitial } from '@/components/profile/user-avatar-utils'; +import { cn } from '@/lib/utils'; + +export interface AccountMenuTriggerProps extends ButtonHTMLAttributes { + accountName: string; + avatarUrl?: string | null; + collapsed?: boolean; + expandedWidth?: number; + open?: boolean; + avatarTestId?: string; + surfaceTestId?: string; +} + +/** Shared Code account appearance; the containing menu owns behavior and width preferences. */ +export const AccountMenuTrigger = forwardRef( + function AccountMenuTrigger({ + accountName, avatarUrl, collapsed = false, expandedWidth = 150, open = false, + avatarTestId, surfaceTestId, className, style, type = 'button', ...props + }, ref) { + return ; + }, +); diff --git a/src/components/account/HomeAccountMenu.tsx b/src/components/account/HomeAccountMenu.tsx new file mode 100644 index 00000000..e2507b33 --- /dev/null +++ b/src/components/account/HomeAccountMenu.tsx @@ -0,0 +1,107 @@ +import { useEffect, useRef, useState } from 'react'; +import { LogOut, Settings, UserCircle2 } from 'lucide-react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { toast } from 'sonner'; +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 { useSettingsStore } from '@/stores/settings'; +import { normalizeClassroomSidebarWidth } from '@/lib/classroom-layout'; +import { canLeaveCurrentPage, getSettingsReturnState } from '@/lib/settings-navigation'; +import { PointWallet } from './PointWallet'; +import { AccountMenuTrigger } from './AccountMenuTrigger'; + +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 expandedWidth = useSettingsStore(state => normalizeClassroomSidebarWidth(state.classroomSidebarWidth) - 21); + const navigate = useNavigate(); + const location = useLocation(); + 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
+ setOpen(value => !value)} + onKeyDown={event => { + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault(); setOpen(true); focusItem(event.key === 'ArrowUp'); + } + }} /> + { + 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/components/account/StandaloneAccountDock.tsx b/src/components/account/StandaloneAccountDock.tsx new file mode 100644 index 00000000..9f584029 --- /dev/null +++ b/src/components/account/StandaloneAccountDock.tsx @@ -0,0 +1,29 @@ +import { useState } from 'react'; +import { HomeAccountMenu } from './HomeAccountMenu'; +import { UserProfileDialog } from '@/components/profile/UserProfileDialog'; +import { useCurrentUserProfile } from '@/hooks/use-current-user-profile'; +import { getAuthUserDisplayName } from '@/lib/auth-user-display'; +import { useAuthStore } from '@/stores/auth'; +import { getUserProfileDisplayName } from '@/stores/user-profile'; + +export function StandaloneAccountDock({ enforceProfileCompletion = true }: { enforceProfileCompletion?: boolean }) { + const user = useAuthStore(state => state.user); + const authenticated = useAuthStore(state => state.isAuthenticated()); + const { userProfile, profileRequired, profileSyncError, syncProfileNow } = useCurrentUserProfile(authenticated); + const [profileOpen, setProfileOpen] = useState(false); + const [dismissedError, setDismissedError] = useState(null); + if (!user || !authenticated) return null; + const name = getUserProfileDisplayName(userProfile) || getAuthUserDisplayName(user) || user.username; + return <> +
+ { setDismissedError(null); setProfileOpen(true); void syncProfileNow().catch(() => undefined); }} /> +
+ { + setProfileOpen(open); + if (open) setDismissedError(null); + else if (profileSyncError) setDismissedError(profileSyncError); + }} /> + ; +} diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx index 94712efb..7d1a5def 100644 --- a/src/components/layout/MainLayout.tsx +++ b/src/components/layout/MainLayout.tsx @@ -6,6 +6,8 @@ import { Outlet, useLocation } from 'react-router-dom'; import { useCallback, useEffect, useRef, useState } from 'react'; import { Sidebar } from './Sidebar'; import { TitleBar } from './TitleBar'; +import { SettingsLayout } from './SettingsLayout'; +import { StandaloneAccountDock } from '@/components/account/StandaloneAccountDock'; import { getAiModuleForPath } from '@/lib/ai-modules'; import { useSettingsStore } from '@/stores/settings'; import { cn } from '@/lib/utils'; @@ -74,6 +76,8 @@ export function MainLayout() { if (sidebarPeekCloseTimerRef.current) clearTimeout(sidebarPeekCloseTimerRef.current); }, []); + if (location.pathname === '/settings' || location.pathname.startsWith('/settings/')) return ; + return (
{/* Title bar: drag region on macOS, icon + controls on Windows */} @@ -103,11 +107,13 @@ export function MainLayout() { 'relative min-h-0 min-w-0 flex-1 overflow-auto bg-background', (isPaintingModule || isCloudAgents || isChatWorkspace) ? 'basis-0 h-full overflow-hidden p-0' : 'p-5 sm:p-6', (isChatWorkspace || isCloudAgents) && !isPaintingModule && 'basis-0 overflow-hidden p-0', + isCanvasWorkspace && 'pb-16 lg:pb-0', )} >
+ {isCanvasWorkspace &&
} ); } diff --git a/src/components/layout/SettingsLayout.tsx b/src/components/layout/SettingsLayout.tsx new file mode 100644 index 00000000..16daa8e7 --- /dev/null +++ b/src/components/layout/SettingsLayout.tsx @@ -0,0 +1,11 @@ +import { Outlet } from 'react-router-dom'; +import { TitleBar } from './TitleBar'; +import { StandaloneAccountDock } from '@/components/account/StandaloneAccountDock'; + +export function SettingsLayout() { + return
+ +
+ +
; +} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index dcd14316..a4cdc38c 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -36,6 +36,8 @@ import { subscribeCodingProjectOpenRequest, } from '@/lib/coding-project-entry'; import { PointWallet } from '@/components/account/PointWallet'; +import { AccountMenuTrigger } from '@/components/account/AccountMenuTrigger'; +import { canLeaveCurrentPage, getSettingsReturnState } from '@/lib/settings-navigation'; import { cn } from '@/lib/utils'; import { useCurrentUserProfile } from '@/hooks/use-current-user-profile'; import type { SidebarPeekSource } from './sidebar-peek'; @@ -361,8 +363,9 @@ export function Sidebar({ }; const navigateFromAccountMenu = (path: string) => { + if (path === '/settings' && !canLeaveCurrentPage()) { toast.info('请先保存当前修改,再打开设置。'); return; } setAccountMenuOpen(false); - navigate(path); + navigate(path, path === '/settings' ? { state: getSettingsReturnState(location) } : undefined); }; const openProfileDialog = () => { @@ -810,42 +813,26 @@ export function Sidebar({ onOpenChange={setProfileDialogOpen} /> -
+
-
- +
+
+ setAccountMenuOpen((open) => !open)} + /> +
void handleClick()} className={cn( - 'motion-press flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-accent/30 bg-accent text-primary-foreground shadow-soft hover:bg-accent-strong disabled:cursor-wait disabled:opacity-80', + 'motion-press flex h-10 w-10 max-w-full shrink-0 items-center justify-center rounded-full border border-accent/30 bg-accent text-primary-foreground shadow-soft hover:bg-accent-strong disabled:cursor-wait disabled:opacity-80', collapsed && 'shadow-none', )} > diff --git a/src/components/layout/TitleBar.tsx b/src/components/layout/TitleBar.tsx index 00a8ad64..043df9b1 100644 --- a/src/components/layout/TitleBar.tsx +++ b/src/components/layout/TitleBar.tsx @@ -212,6 +212,7 @@ function ProductTitleBar({ data-testid="titlebar-main-surface" className={cn( 'relative flex min-w-0 flex-1 bg-background', + nativeTrafficLights && !nativeFullscreen && !showSidebarControls && !overlay && 'pl-[88px]', overlay && 'pointer-events-none border-b-0 bg-transparent', nativeTrafficLights && 'drag-region', !overlay && (workspaceLayout || sidebarTitlebarHidden ? 'border-b-0' : 'border-b border-border/80'), diff --git a/src/hooks/use-works-voice-input.ts b/src/hooks/use-works-voice-input.ts index 7773ebed..28b83e0d 100644 --- a/src/hooks/use-works-voice-input.ts +++ b/src/hooks/use-works-voice-input.ts @@ -76,8 +76,9 @@ export function useWorksVoiceInput({ if (mountedRef.current) setState('idle'); }, [stopStream]); - useEffect(() => () => { - mountedRef.current = false; + useEffect(() => { + mountedRef.current = true; + return () => { mountedRef.current = false; }; }, []); useEffect(() => () => { @@ -235,5 +236,5 @@ export function useWorksVoiceInput({ && Boolean(navigator.mediaDevices?.getUserMedia); const canToggle = state === 'recording' || (enabled && Boolean(scopeKey) && state === 'idle'); - return { canToggle, state, supported, toggle }; + return { canToggle, cancel, state, supported, toggle }; } diff --git a/src/lib/cloud-agents-api.ts b/src/lib/cloud-agents-api.ts index db095d69..6a9b54d7 100644 --- a/src/lib/cloud-agents-api.ts +++ b/src/lib/cloud-agents-api.ts @@ -1,4 +1,5 @@ import { hostApiFetch, createHostEventSource, ensureHostApiToken } from './host-api'; +import { blobToBase64 } from './voice-recording'; import type { CloudAgentOperations, CloudUpload, CloudKnowledgeFile, CloudRecoveryState, CloudRecent, CloudSkillDraft } from '../../shared/cloud-agents'; import { CLOUD_AGENTS_PATH, type CloudAgentDraft, type CloudAgentPage, type CreateCloudAgent, type SaveCloudAgentDraft } from '../../shared/cloud-agents'; @@ -19,6 +20,16 @@ export const cloudAgentsApi = { method: 'POST', body: JSON.stringify({ slug, kb_id, operation_id, replaces_file_id }), }), upload: () => hostApiFetch(CLOUD_AGENTS_PATH + '/attachments/pick', { method: 'POST' }), + uploadFile: async (file: File, isActive: () => boolean = () => true) => { + if (!isActive()) throw new Error('对话已切换,请重新添加附件'); + if (!file.size) throw new Error('附件不能为空'); + if (file.size > 5 * 1024 * 1024) throw new Error('附件最大支持 5 MB'); + const bytesBase64 = await blobToBase64(file); + if (!isActive()) throw new Error('对话已切换,请重新添加附件'); + return hostApiFetch(CLOUD_AGENTS_PATH + '/attachments/upload', { + method: 'POST', body: JSON.stringify({ bytesBase64, fileName: file.name, mimeType: file.type || 'application/octet-stream' }), + }); + }, download: (thread_id: string, path: string) => hostApiFetch<{ saved: boolean }>(CLOUD_AGENTS_PATH + '/files/save', { method: 'POST', body: JSON.stringify({ thread_id, path }), }), diff --git a/src/lib/settings-navigation.ts b/src/lib/settings-navigation.ts new file mode 100644 index 00000000..cd54837b --- /dev/null +++ b/src/lib/settings-navigation.ts @@ -0,0 +1,30 @@ +import { AI_MODULE_SELECTION_PATH, getGuardedAiModuleForPath } from './ai-modules'; + +type SettingsLocation = { pathname: string; search?: string; hash?: string; state?: unknown }; + +function localReturnPath(value: unknown): string | null { + if (typeof value !== 'string' || !value.startsWith('/') || value.startsWith('//') || value.includes('\\')) return null; + try { + const target = new URL(value, 'https://makelore.local'); + if (target.origin !== 'https://makelore.local') return null; + if (target.pathname !== AI_MODULE_SELECTION_PATH && !getGuardedAiModuleForPath(target.pathname)) return null; + return `${target.pathname}${target.search}${target.hash}`; + } catch { return null; } +} + +export function getSettingsReturnPath(state: unknown): string { + const candidate = state && typeof state === 'object' && 'settingsReturnTo' in state + ? (state as { settingsReturnTo?: unknown }).settingsReturnTo : null; + return localReturnPath(candidate) ?? AI_MODULE_SELECTION_PATH; +} + +export function getSettingsReturnState(location: SettingsLocation): { settingsReturnTo: string } { + const inSettings = location.pathname === '/settings' || location.pathname.startsWith('/settings/'); + return { settingsReturnTo: inSettings ? getSettingsReturnPath(location.state) + : localReturnPath(`${location.pathname}${location.search ?? ''}${location.hash ?? ''}`) ?? AI_MODULE_SELECTION_PATH }; +} + +/** Honor the current page's existing unsaved-input protection before internal navigation. */ +export function canLeaveCurrentPage(): boolean { + return typeof window === 'undefined' || window.dispatchEvent(new Event('beforeunload', { cancelable: true })); +} diff --git a/src/pages/Chat/classroom-workspace.css b/src/pages/Chat/classroom-workspace.css index ec6e0d00..a562e52c 100644 --- a/src/pages/Chat/classroom-workspace.css +++ b/src/pages/Chat/classroom-workspace.css @@ -36,8 +36,7 @@ [data-testid="sidebar"][data-classroom="true"] .classroom-conversation-row[data-selected="true"] { background: #e0ecfa; border-color: #cedef0; color: #304f75; } [data-testid="sidebar"][data-classroom="true"] .classroom-conversation-row > button:first-child { min-height: 35px; gap: 7px; } [data-testid="sidebar"][data-classroom="true"] .classroom-conversation-row > button:first-child > svg { color: #7891af; } -[data-testid="sidebar"][data-classroom="true"] .classroom-account-footer { padding: 10px 10px 12px; border-top: 1px solid #dde5ee; background: #edf3f9; } -[data-testid="sidebar"][data-classroom="true"] [data-testid="sidebar-member-menu-trigger"] { min-height: 42px; border: 1px solid #d9e3ee; border-radius: 11px; background: #fff; font-size: 12px; } +[data-testid="sidebar"][data-classroom="true"] .classroom-account-footer { border-top: 1px solid #dde5ee; background: #edf3f9; } .classroom-workspace [data-testid="coding-conversation-header"] { border-bottom-color: #e8edf3; background: #fff; color: #304968; } .classroom-tabs { display: flex; flex-shrink: 0; height: 46px; gap: 9px; padding: 6px 22px; border-bottom: 1px solid #e8edf3; } .classroom-workspace-toolbar { display: flex; flex-wrap: wrap; flex-shrink: 0; align-items: center; gap: 6px 12px; min-height: 46px; padding: 6px 22px; border-bottom: 1px solid #e8edf3; } diff --git a/src/pages/CloudAgents/CloudAccess.tsx b/src/pages/CloudAgents/CloudAccess.tsx index 4e09309e..5a2909ac 100644 --- a/src/pages/CloudAgents/CloudAccess.tsx +++ b/src/pages/CloudAgents/CloudAccess.tsx @@ -47,7 +47,7 @@ export function CloudAccessPanel({ slug, revision, publication, onChanged, activ

{access?.published_version ? '助手已准备好' : '准备好使用自己的助手了吗?'}

{access?.published_version ? '应用最新修改后,新的对话会使用这次保存的要求。已有对话保留原来的版本。' : '开始使用后,只有你自己能用。以后还可以回来修改要求。'}

微信渠道和应用接入需要单独配置。

-
+

自己使用、API 调用、自动任务和个人微信渠道产生的费用,均从你的个人词元点数扣除。你可以随时停用智能体或撤销应用凭据。

{access?.published_version &&
{access.enabled ? '智能体已启用' : '智能体已停用'}
- {headerAction}
+ {headerAction} {showHistory && threads.length === 0 &&

还没有历史对话

} {showHistory && threads.length > 0 && } {(stalePreview || hasUnsavedChanges) &&
@@ -340,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 &&
@@ -351,18 +384,24 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread {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 =>
+ {liveReply?.messages.filter(item => !liveHistoryReady || !history || !savedMessage(history, item)).map(item => { + const timestamp = recordedReplyTime(history, liveReply, item); + const replying = running && runId === item.run_id && !liveReply.ended; + return

{assistantName}

-
{ +
{ 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} · 尚未启用

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

{run.error.message}

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

{error}

} {thread?.archived &&
此对话已归档,恢复后可继续发送消息。 -
}
{ e.preventDefault(); void send(); }}> - { + actions={} + 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 ? + : } }> -