diff --git a/.project-docs/30-worklog/tasks/20261013-chat-message-time-64ebac32.md b/.project-docs/30-worklog/tasks/20261013-chat-message-time-64ebac32.md
index e66cc509..d0ad2242 100644
--- a/.project-docs/30-worklog/tasks/20261013-chat-message-time-64ebac32.md
+++ b/.project-docs/30-worklog/tasks/20261013-chat-message-time-64ebac32.md
@@ -8,7 +8,7 @@
- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261013-chat-message-time-64ebac32
- Base commit: 2899009215b1de8e28b84fc108e880c6e68a6098
- Owner: codex
-- Status: Ready for Integration
+- Status: Planning
## Scope
@@ -58,3 +58,30 @@
- Model audit begins an assistant row at start and publishes it later with the same created_at; finish updates actualfinished_at/content. Legacy creation may occur at final storage. Accurate reply-completion presentation therefore needs messagefinished_at or the associated completed Runfinished_at rather than barecreated_at. Historical personal pages include only the latest Run and cannot stamp every earlier reply with that one finish time.
- Current-page wait fallback may correlate queue request_id with the user message's servercreated_at, but durable queue/requestcreated_at anddispatched_at should be exposed for complete pagination/restart recovery. Columns already exist; no database migration or made-up local time is needed. Same local request retries retain first send identity/time. Group multi-message answer timing by actual request/run, preserving each message's provenance.
+
+## Authorized Existing-Time Implementation
+
+- User clarifies that ordinary sent/reply times are wanted, then explicitly authorizes direct implementation. This supersedes the earlier proposal's completion-time precision work: use the existing authoritative history.messages.created_at as each message's recorded time and label it sending/reply time; do not call it finished_at. No new Yuxi endpoint/field, clock estimate or waiting-duration contract is needed for this scope.
+- Bundled check/start/status reverified the same owner/task/branch/base. All current peer Scope/Intent/Promotion sections reread; canonical/AGENTS context is unchanged and reused. No source semantics conflict; earlier publication repair remains cancelled and chat replay repair is separate. Current deployed Renderer remains2899009, matching own product baseline. Planning Gate: Passed.
+- Reuse established local-time/date/tooltip behavior, preserving message identity and server timestamps across refresh/reopen. Streaming content without authoritative time should not be stamped with now; display a pending label until the recorded history arrives. Any saved time found by stable message identity remains its original time, including known old replay identity. Preserve existing styling, history/draft/account behavior and Main/backend protocols.
+- Scope is ordinary chat timestamps and meaningful Renderer regression coverage/typecheck/scoped lint/build. No automatic question submission, paid calls, backend deployment, merge/push or cleanup. Temporary-app activation will only be considered as an explicit requested update; this instruction directly authorizes source implementation.
+
+## Implementation Plan
+
+1. Add sending/reply timestamps to persisted and live personal Agent messages using existing formatter/component and authoritative history data; align quiet footer with each bubble.
+2. Verify saved/live/hydration/reopen and missing-time behavior with focused UI tests and production compilation.
+3. Record result and hand off committed source, keeping the running app unchanged unless separately authorized to update it.
+
+## Current Runtime Assembly And Background Update
+
+- During implementation another authorized UI task updated the running Renderer to2b2f7b96b0e2c378c706f29099ffb974dc1acdbf (application-message-v3), while Main PID9968/source7c51 and Host API58003 remain. The actual current product/test/README delta2899009→2b2f7b9 is assembled before time activation to preserve its application-retry wording, staged configuration checks and subagent selection changes. Foreign task/canonical docs are not copied. No semantic conflict with timestamp UI; README combines its current paragraphs with the own time paragraph, and E2E copy retains both changes.
+- Source timestamp implementation is authorized directly. In this ongoing temporary-app workflow, root treats reversible background Renderer refresh as part of delivering the visible requested change, with fresh immutable source/binding checks and strict input/task/receipt guards. This supersedes the earlier source-only handoff plan, not the cancelled publication repair. No Main restart, backend request/publication/cancellation or focus activation is included.
+- Background stage uses actual predecessor current pointer/manifest chain and original stable Renderer URL/profile; no discard authorization is reused. If quiet-state or writer binding differs, activation stops without overwriting inputs/runtime.
+
+## Implementation Verification Before Runtime Binding
+
+- Added small aligned timestamps to saved user/assistant messages via existing MessageTimestamp formatter/semantictime component. TodayHH:mm, older dates/date-year and full second-level tooltip follow existing app rules. Both formal chat and preview/history comparison reuse TrialMessages. Explicit labels are 发送时间/回复时间, never claiming completion.
+- Live messages use authoritative same-thread assistant source identity to find created_at; known old source IDs retain original date across Run replay, while unknown active/terminal times show 回复中…/时间未记录. No clock-now fallback, Main/backend endpoint, request timing field or queued-status behavior changed. Existing repeat-message defect is not repaired by this timestamp change.
+- Added9 meaningful UI regressions for persisted dates, invalid values, next-day reopening, live hydration, partial history, oldRun source identity, user-ID isolation and terminal-history failure. Updated existing Electron UI fixture with authoritative created_at and semantictime/label assertions. Full native Electron E2E is not run because it creates visible windows; focused Renderer boundary tests and later read-only actual-app DOM verification provide this request's coverage without desktop activation.
+- Current assembled source tests:120/120 across10 focused unit suites; typecheck and production Vite/Main/Preload/utility build passed. Scoped ESLint passed with only the existing two CloudChat warnings (component/constants fast-refresh export and generation ref cleanup). Independent source review found no blocking defect. Diff/drift checks pass. Earlier43focused/9new tests are included in120, not additional distinct coverage.
+- Preserved actual2b2f7b9 product deltas and approved timestamp code only, excluded foreign project docs. Stage helpers are preparation only at this point; source commit will be bound before any activation. No live questions/cancellations/config mutations or application refresh occurred yet.
diff --git a/README.md b/README.md
index 45f3b925..1797c32c 100644
--- a/README.md
+++ b/README.md
@@ -12,9 +12,11 @@ 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、表格和代码在消息区内滚动。文件或图片从输入框内添加,上传成功后以待发送卡片呈现,可移除本次携带;移除不会删除云端文件,添加不会自动发送。确认失败沿用同一文件和对话重试,发送前需完成或取消添加;支持仅发送附件,使用明确的附件提问。输入框的“+”添加文件或图片;右上角“附件”打开右侧面板,按“你上传的”和“助手生成的”分组,支持再次选用已上传附件及保存明确交付的生成文件。生成文件仅取当前对话已保存助手回复中的本地文件链接(含图片与引用式链接),核对实际文件后展示和计数;只读取链接所在的文件夹,不遍历全部工作文件。未交付的脚本、缓存和工具记录不展示、不计数,明确交付的代码照常展示;仅写了路径的旧成果可能暂时隐藏,后续接入 Yuxi 的正式交付记录。没有文件时隐藏入口;打开、关闭面板保留聊天与输入,Escape 关闭并返回入口焦点;最近对话自动恢复、审批、停止和未发送内容的离开保护保留。模型返回的思考按消息独立于正文展示;正文到来后或完成时折叠,可手动展开;无思考或工具内容时不显示过程区。重连事件去重并隔离当前运行与主对话,终态历史未到达时保留已收到的片段并有限补读;服务端返回的最终回答思考可在历史中回看,不进入后续提问。历史回看需要配套 Yuxi API 的思考和工具记录投影;实时过程沿用既有事件流。平台工具和内置做事手册使用 Yuxi 下发的通俗名称与说明;逐条消息的思考、工具输入/结果/失败独立于 Markdown 正文,实时与历史工具卡片共用展示文案,展开可查看原始工具标识。个人上传手册保留作者文案,资源选择仍保存原始标识。头像上传、Main-owned Host API、访问隔离、费用与不可变版本规则保持;介绍留空需要配套 Yuxi DraftCreate/DraftSave 的空字符串契约。
- 历史待确认记录收起在智能体列表标题区的「创建记录/待处理记录」入口,只有点击后才打开记录窗口;配置、聊天和创建页不再置顶这些旧提醒。记录保留智能体名称和原请求身份,用户可明确「再试一次」或「不再提醒」,不会自动重试或清除。当前创建失败继续在创建表单内提示;关闭记录窗口保留已收到的结果和密钥,切换配置、聊天或创建页后可返回列表继续查看。密钥须明确关闭结果后,才可处理其他记录。
+ 个人智能体聊天在每条提问与回答下方显示云端记录的消息时间:今天显示时分,跨日增加日期,悬停可查看完整日期与秒数;发送时间与回复时间沿用原消息记录,重开对话不改为当前时间。流式回答尚无可靠时间时显示「回复中…」,历史到达后展示原时间;缺失或无效时间显示「时间未记录」。正式对话、草稿试用和上次试用对比使用相同规则,不新增 Yuxi 接口。
+
+ 历史待确认记录收起在智能体列表标题区的「创建记录/待处理记录」入口,只有点击后才打开记录窗口;配置、聊天和创建页不再置顶这些旧提醒。记录保留智能体名称和原请求身份;应用设置的待确认记录显示“网络可能不稳定,请再点一次「应用」。”,点击「应用」继续原来的提交,成功后显示“设置已应用。”。其他记录仍可明确「再试一次」或「不再提醒」,不会自动重试或清除。当前创建失败继续在创建表单内提示;关闭记录窗口保留已收到的结果和密钥,切换配置、聊天或创建页后可返回列表继续查看。密钥须明确关闭结果后,才可处理其他记录。
高级配置的知识库支持下拉多选,分为「MakeLore知识」和「我的知识库」,已选项可单独移除。MakeLore知识来自当前账号有权读取的 Yuxi 共享目录,只显示名称与简介;移除仅取消当前智能体选用,不删除原库。知识库区域上方左右分区,两张卡片在并排时保持等高:左侧「上传自己的文件」仅放「新建知识库」入口及展开的创建表单,右侧「查看我的知识库」通过下拉框查看本人知识库、从电脑追加资料和管理文件;下方独立「选择知识库」,选择智能体使用的「MakeLore知识」和「我的知识库」。小窗口按新建、查看、选用顺序纵向排列。创建成功后在右侧打开新库;新建和查看均不会自动加入智能体配置,选用上限不影响管理与新建。仅提供「从电脑上传」作为添加文件入口,未选择管理目标时上传按钮置灰;聊天文件导入入口已移除。上传后自动解析、建立索引并持续显示真实处理状态;失败可重试且保留已有可用内容。暂不可选用的个人库仍可管理文件,已有绑定保留并提示不可用;未确认请求在当前库保留同一操作身份供重试。知识库选择只改草稿,显式保存/应用后生效;个人云端执行已自动接入所选知识库的检索,无需另外勾选 knowledge-base Skill。此交互复用现有接口,无需新增后端补丁。
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 ? '应用最新修改后,新的对话会使用这次保存的要求。已有对话保留原来的版本。' : '开始使用后,只有你自己能用。以后还可以回来修改要求。'}
微信渠道和应用接入需要单独配置。
- void publish()}>{publication.busy ? '正在应用…' : publication.pending ? '重试本次发布' : publication.appliedRevision === revision ? '已应用最新修改' : access?.published_version ? '应用最新修改' : '确认开始使用'}
+ void publish()}>{publication.busy ? '正在应用…' : publication.pending ? '应用' : publication.appliedRevision === revision ? '已应用最新修改' : access?.published_version ? '应用最新修改' : '确认开始使用'}
自己使用、API 调用、自动任务和个人微信渠道产生的费用,均从你的个人词元点数扣除。你可以随时停用智能体或撤销应用凭据。
{access?.published_version && {access.enabled ? '智能体已启用' : '智能体已停用'}
void act(() => cloudAgentsApi.call('setEnabled', { slug, enabled: !access.enabled }))}>
diff --git a/src/pages/CloudAgents/CloudChat.tsx b/src/pages/CloudAgents/CloudChat.tsx
index cd504ed2..30ccdee0 100644
--- a/src/pages/CloudAgents/CloudChat.tsx
+++ b/src/pages/CloudAgents/CloudChat.tsx
@@ -8,6 +8,8 @@ import { CloudComposerAttachments } from './CloudComposerAttachments';
import { CloudAvatar } from './CloudAvatar';
import './desktop-workspace.css';
import { CloudMessageBody } from './CloudMessageBody';
+import { MessageTimestamp } from '@/pages/Chat/MessageTimestamp';
+import { formatMessageTimestamp } from '@/lib/message-timestamp';
import './agent-chat.css';
import { CloudPendingContext, useCloudPendingState, usePendingCloudInput } from './CloudPending';
import { agentExamples } from './agent-guidance';
@@ -23,6 +25,12 @@ 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));
+const recordedReplyTime = (history: CloudHistory | null, stream: CloudChatStream | null, message: CloudLiveMessage) => {
+ if (!history || history.thread_id !== stream?.threadId) return undefined;
+ const saved = history.messages.find(item => item.role === 'assistant' && item.message_id === message.message_id);
+ // Source IDs retain their recorded time even if an old message is replayed in a newer Run.
+ return formatMessageTimestamp(saved?.created_at) ? saved?.created_at : undefined;
+};
export function CloudChat({ slug, previewRevision: latestRevision, initialThread, initialThreadId, onSchedule, activeView = true, headerAction, attachmentHeader, previewReady = true,
assistantName = '助手', assistantAvatarUrl, examples = agentExamples(), hasUnsavedChanges = false, saveDisabled = false, onSaveForPreview }: {
@@ -351,14 +359,20 @@ 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} · 尚未启用
@@ -433,7 +447,9 @@ function TrialMessages({ history, onError, assistantName = '助手', assistantAv
{item.role === 'user' ?
{item.content}
:
void cloudAgentsApi.download(history.thread_id, path).catch(e => onError(message(e)))} />}
+ onDownload={path => void cloudAgentsApi.download(history.thread_id, path).catch(e => onError(message(e)))} />}
+
+
);
}
diff --git a/src/pages/CloudAgents/CloudRecovery.tsx b/src/pages/CloudAgents/CloudRecovery.tsx
index 8a2506fd..91adc25e 100644
--- a/src/pages/CloudAgents/CloudRecovery.tsx
+++ b/src/pages/CloudAgents/CloudRecovery.tsx
@@ -6,9 +6,10 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
import { AppError } from '@/lib/error-model';
import type { CloudPendingOperation } from '../../../shared/cloud-agents';
+import { APPLICATION_RETRY_MESSAGE, applicationErrorMessage } from './application-message';
const labels: Record = {
- createAgent: '创建智能体', submit: '发送消息', preview: '预览草稿', resume: '继续任务', publish: '发布版本',
+ createAgent: '创建智能体', submit: '发送消息', preview: '预览草稿', resume: '继续任务', publish: '应用设置',
createApplication: '创建应用', createKey: '创建密钥', createKnowledge: '创建知识库', processKnowledge: '处理文档',
createSchedule: '创建自动任务', runSchedule: '立即运行任务',
importKnowledgeAttachment: '导入附件到知识库', createChild: '创建子智能体',
@@ -23,6 +24,7 @@ export function CloudRecovery({ pending, onResolved, triggerContainer }: { pendi
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState('');
const [error, setError] = useState('');
+ const [errorRecord, setErrorRecord] = useState('');
const [notice, setNotice] = useState('');
const [result, setResult] = useState<{ operation: string; name?: string; value: unknown } | null>(null);
const inFlight = useRef(false);
@@ -36,7 +38,7 @@ export function CloudRecovery({ pending, onResolved, triggerContainer }: { pendi
const resolve = async (item: CloudPendingOperation, discard = false) => {
if (inFlight.current || awaitingKeyClose.current) return;
inFlight.current = true;
- setBusy(item.id); setError(''); setNotice('');
+ setBusy(item.id); setError(''); setErrorRecord(''); setNotice('');
try {
const response = await cloudAgentsApi.resolvePending(item.id, discard);
if (!alive.current) return;
@@ -54,7 +56,9 @@ export function CloudRecovery({ pending, onResolved, triggerContainer }: { pendi
onResolved();
} catch (e) {
if (!alive.current) return;
- const message = e instanceof Error ? e.message : '这次没能完成,请稍后再试。';
+ setErrorRecord(item.id);
+ const message = item.operation === 'publish' && !discard ? applicationErrorMessage(e)
+ : e instanceof Error ? e.message : '这次没能完成,请稍后再试。';
if (e instanceof AppError && e.details?.commandOutcome === 'definitive_failure') {
setError(`${labels[item.operation] ?? '操作'}失败:${message}`);
onResolved();
@@ -84,16 +88,19 @@ export function CloudRecovery({ pending, onResolved, triggerContainer }: { pendi
: retiredChannelOperations.has(item.operation) ? '旧版联系人权限操作(已移除)' : labels[item.operation] ?? '待处理记录'}
{new Date(item.created_at).toLocaleString()}
{item.operation === 'createAgent' && 还不确定有没有创建成功,先看看列表里有没有它。
}
+ {item.operation === 'publish' && errorRecord !== item.id && {APPLICATION_RETRY_MESSAGE}
}
{typeof item.input.query === 'string' && {item.input.query}
}
{retiredChannelOperations.has(item.operation)
? void resolve(item)}>{busy === item.id ? '正在丢弃…' : '丢弃旧记录'}
- : void resolve(item)}>{busy === item.id ? '正在处理…' : '再试一次'}
+ :
void resolve(item)}>{item.operation === 'publish'
+ ? busy === item.id ? '正在应用…' : '应用' : busy === item.id ? '正在处理…' : '再试一次'}
void resolve(item, true)}>不再提醒
}
)}
{result &&
{result.operation === 'createAgent'
? `「${result.name || '智能体'}」创建成功,可以回到列表继续设置。`
+ : result.operation === 'publish' ? '设置已应用。'
: `${labels[result.operation] ?? '操作'}的结果已确认。`}{result.operation === 'createKey' && '请保存下面的密钥。关闭结果后将无法再次查看。'}
- {result.operation !== 'createAgent' && <>
+ {result.operation !== 'createAgent' && result.operation !== 'publish' && <>
查看结果
{JSON.stringify(result.value, null, 2)}
>}
diff --git a/src/pages/CloudAgents/CloudResources.tsx b/src/pages/CloudAgents/CloudResources.tsx
index 174c702e..af7aa4bc 100644
--- a/src/pages/CloudAgents/CloudResources.tsx
+++ b/src/pages/CloudAgents/CloudResources.tsx
@@ -25,7 +25,7 @@ export function CloudResources({ onChanged }: { onChanged: () => void }) {
return
{ if (e.currentTarget.open && !data) void act(refresh); }}>
管理我的云端能力
-
连接、做事手册和子智能体属于你的个人账号。创建后在上面的目录选用,保存并发布后生效。
+
连接与做事手册属于你的个人账号。创建后在目录选用,保存并发布后生效。
{error &&
{error} void act(refresh)}>刷新能力
}
MCP 连接 setMcp('new')}>添加 MCP
连接由云端访问。凭据只用于你的连接,分享调用可在你已发布的授权范围内使用这些工具。
diff --git a/src/pages/CloudAgents/ConfigurationFields.tsx b/src/pages/CloudAgents/ConfigurationFields.tsx
index 8d8c8f63..74667e34 100644
--- a/src/pages/CloudAgents/ConfigurationFields.tsx
+++ b/src/pages/CloudAgents/ConfigurationFields.tsx
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
-import { BookOpen, Brain, Library, Plug, RefreshCw, Users, Wrench } from 'lucide-react';
+import { BookOpen, Brain, Library, Plug, RefreshCw, Wrench } from 'lucide-react';
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -11,7 +11,6 @@ const resourceSections = [
{ key: 'tools', label: '工具箱', title: '工具箱(Tool)', icon: Wrench, tone: 'blue' },
{ key: 'skills', label: '做事手册', title: '做事手册(Skill)', icon: BookOpen, tone: 'lilac' },
{ key: 'mcps', label: 'MCP 服务', title: 'MCP 服务', icon: Plug, tone: 'neutral' },
- { key: 'subagents', label: '子智能体', title: '子智能体', icon: Users, tone: 'neutral' },
{ key: 'knowledges', label: '知识库', title: '知识库', icon: Library, tone: 'mint' },
] as const;
diff --git a/src/pages/CloudAgents/DraftEditor.tsx b/src/pages/CloudAgents/DraftEditor.tsx
index c8134f1a..64dccb6b 100644
--- a/src/pages/CloudAgents/DraftEditor.tsx
+++ b/src/pages/CloudAgents/DraftEditor.tsx
@@ -154,11 +154,11 @@ export function DraftEditor({ initial, recent, onBack, onSaved, startingPrompt,
title={dirty ? '请先保存草稿,再应用修改' : '将已保存的修改应用到正在使用的助手'}
onClick={() => void publication.publish()}>
{publication.busy && }
- {publication.busy ? '正在应用…' : publication.pending ? '重试本次应用' : !dirty && publication.appliedRevision === saved.draft_revision ? '已应用' : '应用最新修改'}
+ {publication.busy ? '正在应用…' : publication.pending ? '应用' : !dirty && publication.appliedRevision === saved.draft_revision ? '已应用' : '应用最新修改'}
}
{!saved.published_version &&
{publication.busy && }
- {publication.busy ? '正在开始…' : publication.pending ? '重试开始使用' : '开始使用'}
+ {publication.busy ? '正在开始…' : publication.pending ? '应用' : '开始使用'}
}
>}
diff --git a/src/pages/CloudAgents/agent-chat.css b/src/pages/CloudAgents/agent-chat.css
index d07a5aac..1de7ee92 100644
--- a/src/pages/CloudAgents/agent-chat.css
+++ b/src/pages/CloudAgents/agent-chat.css
@@ -99,6 +99,8 @@
.agent-chat-message[data-role="user"] { align-self: flex-end; width: fit-content; max-width: 85%; padding: 14px 20px; border: 1px solid #e3d9f0; border-radius: 22px 22px 7px 22px; background: #f0e9f9; box-shadow: 0 2px 0 #e9e1f0; }
.agent-chat-message[data-role="user"] .agent-chat-message-body p { margin: 0; }
.agent-chat-message[data-role="user"] .agent-chat-speaker { color: #75628e; }
+.agent-chat-message-time { margin-top: 6px; font-size: 12px; }
+.agent-chat-message[data-role="user"] .agent-chat-message-time { text-align: right; }
.agent-chat-styled .agent-chat-message-body :is(pre, table) { display: block; max-width: 100%; overflow-x: auto; }
.agent-chat-styled .agent-chat-message-body pre { padding: 14px 16px; border: 1px solid #e4e2dc; border-radius: 13px; background: #f3f2ef; font-size: 13px; line-height: 1.7; overflow-wrap: normal; }
.agent-chat-styled .agent-chat-message-body img { max-width: 100%; height: auto; border-radius: 12px; }
diff --git a/src/pages/CloudAgents/application-message.ts b/src/pages/CloudAgents/application-message.ts
new file mode 100644
index 00000000..1d59aa8a
--- /dev/null
+++ b/src/pages/CloudAgents/application-message.ts
@@ -0,0 +1,16 @@
+import { AppError } from '@/lib/error-model';
+
+export const APPLICATION_RETRY_MESSAGE = '网络可能不稳定,请再点一次「应用」。';
+
+export function applicationErrorMessage(error: unknown): string {
+ if (error instanceof AppError) {
+ const status = error.details?.status;
+ const rejected = error.details?.commandOutcome === 'definitive_failure'
+ || (typeof status === 'number' && status >= 400 && status < 500);
+ if (!rejected && (error.code === 'NETWORK' || error.code === 'TIMEOUT'
+ || error.details?.backendCode === 'cloud_service_unavailable')) {
+ return APPLICATION_RETRY_MESSAGE;
+ }
+ }
+ return error instanceof Error ? error.message : '操作失败,请重试';
+}
diff --git a/src/pages/CloudAgents/useCloudPublication.ts b/src/pages/CloudAgents/useCloudPublication.ts
index 5fd01346..ade81003 100644
--- a/src/pages/CloudAgents/useCloudPublication.ts
+++ b/src/pages/CloudAgents/useCloudPublication.ts
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
import type { CloudAccess, CloudAgentOperations } from '../../../shared/cloud-agents';
+import { applicationErrorMessage } from './application-message';
type Publication = CloudAgentOperations['publish']['output'];
type PublishInput = { operation_id: string; expected_revision: number };
@@ -52,7 +53,7 @@ export function useCloudPublication(slug: string, revision: number, onPublished:
onPublished(result);
await refresh();
} catch (failure) {
- if (alive.current) setError(errorText(failure));
+ if (alive.current) setError(applicationErrorMessage(failure));
} finally {
publishing.current = false;
if (alive.current) setBusy(false);
diff --git a/tests/e2e/cloud-agent-recovery.spec.ts b/tests/e2e/cloud-agent-recovery.spec.ts
index cd8e58b1..5dc0a5ac 100644
--- a/tests/e2e/cloud-agent-recovery.spec.ts
+++ b/tests/e2e/cloud-agent-recovery.spec.ts
@@ -83,7 +83,7 @@ type FixtureCall = { path: string; method: string; input: Record { throw error; });
await page.addInitScript(({ agent, records, retryError }) => {
const scope = window as unknown as FixtureWindow;
@@ -104,7 +104,12 @@ async function installFixture(page: Page, count = 2) {
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 } } };
+ if (state.retry === 'fail') return { ok: true, data: { status: 503, ok: false, json: { error: retryError,
+ ...(record.operation === 'publish' ? { code: 'cloud_service_unavailable', commandOutcome: 'unknown' } : {}) } } };
+ if (record.operation === 'publish') {
+ state.pending = state.pending.filter(item => item.id !== record.id);
+ return result({ operation: 'publish', result: { version: 1, draft_revision: record.input.expected_revision } });
+ }
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 });
@@ -124,7 +129,9 @@ async function installFixture(page: Page, count = 2) {
}
},
} } });
- }, { agent, records: records.slice(0, count), retryError });
+ }, { agent, records: publication ? [{ id: 'e2e-publish-original', operation: 'publish',
+ input: { slug: agent.slug, operation_id: 'e2e-publish-original', expected_revision: 1 }, created_at: records[0].created_at }]
+ : records.slice(0, count), retryError });
}
async function recoveryCalls(page: Page) {
@@ -192,6 +199,27 @@ test('retry keeps the same pending identity and errors remain inside the modal',
expect(await recoveryCalls(page)).toHaveLength(2);
});
+test('an application reminder gives a simple action and reuses its record after a network failure', async ({ page }) => {
+ await page.setViewportSize({ width: 375, height: 850 }); await installFixture(page, 1, true); await page.goto(baseUrl);
+ await page.getByRole('button', { name: /^待处理记录/ }).click();
+ const dialog = page.getByRole('dialog', { name: '待处理记录', exact: true });
+ await expect(dialog.getByRole('heading', { name: '应用设置', exact: true })).toBeVisible();
+ await expect(dialog.getByText('网络可能不稳定,请再点一次「应用」。', { exact: true })).toBeVisible();
+ expect(await recoveryCalls(page)).toEqual([]);
+ const apply = dialog.getByRole('button', { name: '应用', exact: true });
+ await apply.click();
+ await expect(dialog.getByRole('alert')).toHaveText('网络可能不稳定,请再点一次「应用」。');
+ await expect(apply).toBeEnabled(); await expectModalFits(dialog);
+ await page.screenshot({ path: test.info().outputPath('application-message-375.png'), animations: 'disabled' });
+ await page.evaluate(() => { (window as unknown as FixtureWindow).__recoveryFixture.retry = 'success'; });
+ await apply.click();
+ await expect(dialog.getByText('设置已应用。', { exact: true })).toBeVisible();
+ await expect(dialog.getByText('查看结果', { exact: true })).toHaveCount(0);
+ expect((await recoveryCalls(page)).map(call => call.input)).toEqual([
+ { id: 'e2e-publish-original', discard: false }, { id: 'e2e-publish-original', discard: false },
+ ]);
+});
+
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: /^创建记录/ });
diff --git a/tests/e2e/cloud-agents.spec.ts b/tests/e2e/cloud-agents.spec.ts
index 5b622356..35298a3c 100644
--- a/tests/e2e/cloud-agents.spec.ts
+++ b/tests/e2e/cloud-agents.spec.ts
@@ -30,7 +30,7 @@ test('personal cloud Agent identity, configuration and separate published chat i
const knowledgeFile = { file_id: 'document', name: '本体(Ontology)学习路线图.pdf', size: 34000, error: null };
let limits: { request_limit_points: string | null; daily_limit_points: string | null } = { request_limit_points: null, daily_limit_points: null };
const jobs: Record[] = [];
- const configuration = { model: '', tools: [], knowledges: [], mcps: [], skills: [], preload_skills: [], subagents: [], tool_approval_mode: 'default', max_execution_steps: 40, max_output_tokens: 4096, max_run_seconds: 600 };
+ const configuration = { model: '', tools: [], knowledges: [], mcps: [], skills: [], preload_skills: [], subagents: ['historical-child'], tool_approval_mode: 'default', max_execution_steps: 40, max_output_tokens: 4096, max_run_seconds: 600 };
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 }) => {
@@ -94,7 +94,7 @@ test('personal cloud Agent identity, configuration and separate published chat i
if (body.operation === 'submit' || body.operation === 'preview') return result({ request_id: input.request_id, thread_id: 'thread-1', run_id: 'run-1', version: '1', status: 'dispatched' });
if (body.operation === 'history') return result({ thread_id: 'thread-1', next_offset: null,
queued_requests: [], schedule_proposals: [{ id: 'proposal', name: '每天整理选题', prompt: '每天给我三个适合个人创作的选题', cron_expression: '30 8 * * *', timezone: 'Asia/Shanghai' }],
- messages: [{ id: 1, role: 'user', content: '先给我一些创作建议' }, { id: 2, role: 'assistant', content: '## 创作建议\n先从一个真实的观察开始。\n\n' + '记录今天的一次观察,把具体场景、你的感受和想分享的发现写下来。\n\n'.repeat(22) }],
+ messages: [{ id: 1, role: 'user', content: '先给我一些创作建议', created_at: '2026-10-13T09:24:30Z' }, { id: 2, role: 'assistant', created_at: '2026-10-13T09:25:40Z', content: '## 创作建议\n先从一个真实的观察开始。\n\n' + '记录今天的一次观察,把具体场景、你的感受和想分享的发现写下来。\n\n'.repeat(22) }],
run: { agent_run_id: 'run-1', request_id: 'request-1', thread_id: 'thread-1', status: 'completed', version: '1' } });
if (body.operation === 'viewed') return result({ viewed: true });
if (body.operation === 'attachments') return result({ attachments: [] });
@@ -244,6 +244,8 @@ test('personal cloud Agent identity, configuration and separate published chat i
await expect(page.getByText('给它一本手册,照着方法做。', { exact: true })).toHaveCount(0);
await expect(page.getByText('管理我的云端能力', { exact: true })).toHaveCount(0);
await expect(page.getByText('未选择的资源不会开放给此智能体。通过微信或 API 使用时也遵循本配置。', { exact: true })).toHaveCount(0);
+ await expect(page.getByRole('group', { name: '子智能体', exact: true })).toHaveCount(0);
+ await expect(page.getByRole('button', { name: '选择子智能体', exact: true })).toHaveCount(0);
await expect(page.getByRole('group', { name: /^知识库/ })).toBeVisible();
await expect(page.getByRole('heading', { name: '费用上限', exact: true })).toHaveCount(0);
await expect(page.getByRole('heading', { name: '运行限制', exact: true })).toHaveCount(0);
@@ -263,7 +265,7 @@ test('personal cloud Agent identity, configuration and separate published chat i
await expect(prompt).toHaveValue(initialPrompt);
await page.getByRole('button', { name: '保存草稿', exact: true }).click();
await expect(page.getByRole('button', { name: '已保存', exact: true })).toBeDisabled();
- expect((await fixtureState()).draft).toMatchObject({ purpose: '一起梳理故事和文章', system_prompt: initialPrompt, configuration: { model: 'reasoning-model', tools: ['fixture-tool'] } });
+ expect((await fixtureState()).draft).toMatchObject({ purpose: '一起梳理故事和文章', system_prompt: initialPrompt, configuration: { model: 'reasoning-model', tools: ['fixture-tool'], subagents: ['historical-child'] } });
await editor.getByRole('button', { name: '我的智能体', exact: true }).click();
const card = page.getByRole('article', { name: '我的写作搭档', exact: true });
await expect(card.getByText('一起梳理故事和文章', { exact: true })).toBeVisible();
@@ -328,6 +330,11 @@ test('personal cloud Agent identity, configuration and separate published chat i
expect(await messageOperations()).toEqual(['submit']);
const userBubble = formalChat.locator('.agent-chat-message[data-role="user"]');
await expect(userBubble).toHaveText(/先给我一些创作建议/);
+ await expect(userBubble.locator('time')).toHaveAttribute('datetime', '2026-10-13T09:24:30.000Z');
+ await expect(userBubble.locator('time')).toHaveAccessibleName(/^发送时间:/);
+ const answerTime = formalChat.locator('.agent-chat-message[data-role="assistant"] time');
+ await expect(answerTime).toHaveAttribute('datetime', '2026-10-13T09:25:40.000Z');
+ await expect(answerTime).toHaveAccessibleName(/^回复时间:/);
const bubbleStyle = await userBubble.evaluate(element => {
const style = getComputedStyle(element);
return { background: style.backgroundColor, radius: Number.parseFloat(style.borderTopLeftRadius) };
diff --git a/tests/unit/cloud-agent-start-use.test.tsx b/tests/unit/cloud-agent-start-use.test.tsx
index 6965682f..136dbbec 100644
--- a/tests/unit/cloud-agent-start-use.test.tsx
+++ b/tests/unit/cloud-agent-start-use.test.tsx
@@ -1,6 +1,7 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
+import { AppError } from '@/lib/error-model';
import CloudAgents from '@/pages/CloudAgents';
import { EMPTY_CLOUD_CONFIGURATION } from '../../shared/cloud-agents';
@@ -149,17 +150,17 @@ it('retries an uncertain activation with the original operation and keeps pendin
const base = api.call.getMockImplementation()!;
let attempts = 0;
api.call.mockImplementation(async (operation, input) => {
- if (operation === 'publish' && ++attempts === 1) throw new Error('连接中断');
+ if (operation === 'publish' && ++attempts === 1) throw new AppError('NETWORK', '连接中断', undefined, { commandOutcome: 'unknown' });
return base(operation, input);
});
await openConfiguration();
fireEvent.click(screen.getByRole('button', { name: '开始使用', exact: true }));
- await screen.findByText(/连接中断/);
+ await screen.findByText(/网络可能不稳定,请再点一次「应用」。/);
expect(screen.queryByRole('region', { name: '智能体对话' })).not.toBeInTheDocument();
const unload = new Event('beforeunload', { cancelable: true });
window.dispatchEvent(unload);
expect(unload.defaultPrevented).toBe(true);
- fireEvent.click(screen.getByRole('button', { name: '重试开始使用', exact: true }));
+ fireEvent.click(screen.getByRole('button', { name: '应用', exact: true }));
expect(await screen.findByRole('region', { name: '智能体对话' })).toBeVisible();
expect(publications()).toHaveLength(2);
expect(publications()[1][1]).toEqual(publications()[0][1]);
@@ -180,7 +181,7 @@ it('keeps a newer saved revision unapplied when retry resolves the original unce
versions: activated ? [{ version: 1, draft_revision: 3, created_at: '' }] : [],
};
if (operation === 'publish') {
- if (++attempts === 1) throw new Error('连接中断');
+ if (++attempts === 1) throw new AppError('NETWORK', '连接中断', undefined, { commandOutcome: 'unknown' });
activated = true;
current = { ...current, published_version: 1 };
return { version: 1, draft_revision: input.expected_revision };
@@ -189,11 +190,11 @@ it('keeps a newer saved revision unapplied when retry resolves the original unce
});
await openConfiguration();
fireEvent.click(screen.getByRole('button', { name: '开始使用', exact: true }));
- await screen.findByText(/连接中断/);
+ await screen.findByText(/网络可能不稳定,请再点一次「应用」。/);
fireEvent.change(screen.getByLabelText('提示词'), { target: { value: '这次新保存的要求' } });
fireEvent.click(screen.getByRole('button', { name: '保存草稿', exact: true }));
await waitFor(() => expect(screen.getByRole('button', { name: '已保存', exact: true })).toBeDisabled());
- fireEvent.click(screen.getByRole('button', { name: '重试开始使用', exact: true }));
+ fireEvent.click(screen.getByRole('button', { name: '应用', exact: true }));
expect(await screen.findByRole('region', { name: '智能体对话' })).toBeVisible();
expect(screen.queryByRole('button', { name: '应用最新修改', exact: true })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '返回智能体列表', exact: true }));
@@ -225,7 +226,7 @@ it('keeps successful activation and conversation when the following status read
expect(await screen.findByRole('region', { name: '智能体对话' })).toBeVisible();
await waitFor(() => expect(api.call.mock.calls.filter(([operation]) => operation === 'access')).toHaveLength(2));
expect(screen.queryByRole('button', { name: '已应用', exact: true })).not.toBeInTheDocument();
- expect(screen.queryByRole('button', { name: '重试开始使用', exact: true })).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: '应用', exact: true })).not.toBeInTheDocument();
expect(screen.queryByText(/状态刷新失败/)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '返回智能体列表', exact: true }));
fireEvent.click(await screen.findByRole('button', { name: '调整', exact: true }));
diff --git a/tests/unit/cloud-agent-subagent-selector.test.tsx b/tests/unit/cloud-agent-subagent-selector.test.tsx
new file mode 100644
index 00000000..22b5f085
--- /dev/null
+++ b/tests/unit/cloud-agent-subagent-selector.test.tsx
@@ -0,0 +1,52 @@
+import { useState } from 'react';
+import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
+import { afterEach, expect, it, vi } from 'vitest';
+import { ConfigurationFields } from '@/pages/CloudAgents/ConfigurationFields';
+import { EMPTY_CLOUD_CONFIGURATION, type CloudAgentConfiguration } from '../../shared/cloud-agents';
+
+const api = vi.hoisted(() => ({ call: vi.fn() }));
+vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api }));
+afterEach(() => { cleanup(); vi.resetAllMocks(); });
+
+it('removes subagent selection while other capability edits preserve historical bindings', async () => {
+ api.call.mockImplementation(async operation => {
+ if (operation === 'catalog') return {
+ models: [{ id: 'old-model', name: '原模型' }, { id: 'new-model', name: '新模型' }],
+ resources: {
+ tools: [{ key: 'tool', name: '查询工具' }], skills: [{ key: 'skill', name: '研究手册' }],
+ mcps: [{ key: 'mcp', name: '资料连接' }], knowledges: [{ key: 'kb', name: '已有资料' }],
+ subagents: [{ key: 'historical-child', name: '调研探索员' }],
+ },
+ };
+ if (operation === 'knowledge') return { databases: [], models: [] };
+ throw new Error('Unexpected operation ' + operation);
+ });
+ const initial: CloudAgentConfiguration = {
+ ...EMPTY_CLOUD_CONFIGURATION, model: 'old-model', subagents: ['historical-child', 'missing-child'],
+ max_execution_steps: 30, max_output_tokens: 2048, max_run_seconds: 300,
+ };
+ const changed = vi.fn();
+ function Harness() {
+ const [value, setValue] = useState(initial);
+ return { changed(next); setValue(next); }} />;
+ }
+ render( );
+ await screen.findByRole('option', { name: '新模型' });
+ expect(screen.queryByRole('group', { name: '子智能体', exact: true })).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: '选择子智能体', exact: true })).not.toBeInTheDocument();
+ expect(screen.queryByText('调研探索员')).not.toBeInTheDocument();
+ expect(screen.queryByText(/missing-child/)).not.toBeInTheDocument();
+ expect(changed).not.toHaveBeenCalled();
+ fireEvent.change(screen.getByRole('combobox', { name: '模型' }), { target: { value: 'new-model' } });
+ for (const [button, checkbox] of [['选择工具箱', '查询工具'], ['选择做事手册', '研究手册'], ['选择MCP 服务', '资料连接']]) {
+ fireEvent.click(screen.getByRole('button', { name: button, exact: true }));
+ fireEvent.click(screen.getByRole('checkbox', { name: checkbox, exact: true }));
+ }
+ await within(screen.getByRole('combobox', { name: '选择知识库' })).findByRole('option', { name: '已有资料' });
+ fireEvent.change(screen.getByRole('combobox', { name: '选择知识库' }), { target: { value: 'kb' } });
+ expect(changed).toHaveBeenLastCalledWith({
+ ...initial, model: 'new-model', tools: ['tool'], skills: ['skill'], mcps: ['mcp'], knowledges: ['kb'],
+ });
+ expect(api.call.mock.calls.every(([operation]) => ['catalog', 'knowledge'].includes(operation))).toBe(true);
+});
diff --git a/tests/unit/cloud-agents-guidance.test.tsx b/tests/unit/cloud-agents-guidance.test.tsx
index 0bd942c8..2d0d56dc 100644
--- a/tests/unit/cloud-agents-guidance.test.tsx
+++ b/tests/unit/cloud-agents-guidance.test.tsx
@@ -5,6 +5,7 @@ import { CloudApproval, CloudChat } from '@/pages/CloudAgents/CloudChat';
import { AgentRequirements } from '@/pages/CloudAgents/AgentRequirements';
import { CloudAccessPanel as AccessPanel } from '@/pages/CloudAgents/CloudAccess';
+import { AppError } from '@/lib/error-model';
import { useCloudPublication } from '@/pages/CloudAgents/useCloudPublication';
function CloudAccessPanel(props: { slug: string; revision: number; onPublished: () => void }) {
@@ -43,7 +44,7 @@ it('starts private use through the existing publication action and retains its i
let attempts = 0;
api.call.mockImplementation(async (operation, input) => {
if (operation === 'access') return { published_version: published ? 1 : null, enabled: true, versions: [], grants: [], applications: [] };
- if (operation === 'publish') { if (++attempts === 1) throw new Error('连接中断'); published = true; return { version: 1, draft_revision: input.expected_revision }; }
+ if (operation === 'publish') { if (++attempts === 1) throw new AppError('NETWORK', '连接中断', undefined, { commandOutcome: 'unknown' }); published = true; return { version: 1, draft_revision: input.expected_revision }; }
if (operation === 'costs') return { items: [], next_offset: null, summary: {} };
if (operation === 'channelBindings') return { items: [] };
return base(operation, input);
@@ -53,9 +54,9 @@ it('starts private use through the existing publication action and retains its i
await waitFor(() => expect(screen.getByRole('button', { name: '确认开始使用' })).toBeEnabled());
expect(screen.queryByLabelText('应用名称')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '确认开始使用' }));
- await screen.findByText(/连接中断/);
+ await screen.findByText(/网络可能不稳定,请再点一次「应用」。/);
view.rerender( );
- fireEvent.click(screen.getByRole('button', { name: '重试本次发布' }));
+ fireEvent.click(screen.getByRole('button', { name: '应用' }));
await screen.findByText('助手已准备好');
const publications = api.call.mock.calls.filter(([operation]) => operation === 'publish');
expect(publications).toHaveLength(2);
@@ -65,6 +66,24 @@ it('starts private use through the existing publication action and retains its i
expect(api.call.mock.calls.some(([operation]) => operation === 'share' || operation === 'createApplication')).toBe(false);
});
+it.each([
+ { status: 401, code: 'session_expired', message: '请重新登录' },
+ { status: 403, code: 'access_revoked', message: '无权使用这个智能体' },
+ { status: 409, code: 'draft_revision_conflict', message: '云端草稿已更新' },
+ { status: 422, code: 'invalid_input', message: '请选择模型' },
+ { status: 429, code: 'cloud_service_unavailable', message: '请求过于频繁,请稍后再试' },
+])('keeps the actual application rejection for HTTP $status', async ({ status, code, message }) => {
+ api.call.mockImplementation(async operation => {
+ if (operation === 'access') return { published_version: null, enabled: true, versions: [], grants: [], applications: [] };
+ throw new AppError('UNKNOWN', message, undefined, { status, backendCode: code, commandOutcome: 'unknown' });
+ });
+ render( );
+ await waitFor(() => expect(screen.getByRole('button', { name: '确认开始使用' })).toBeEnabled());
+ fireEvent.click(screen.getByRole('button', { name: '确认开始使用' }));
+ expect(await screen.findByRole('alert')).toHaveTextContent(message);
+ expect(screen.getByRole('alert')).not.toHaveTextContent('网络可能不稳定');
+});
+
it('preserves the entire prompt when displaying and editing former style instruction lines', () => {
const initial = '我的规则\n\n 保留缩进和解释。\n使用简单易懂的语言,遇到新词先解释。\n把任务拆成小步骤,一次引导我完成一步。\n先给我提示和思考问题,让我尝试后再补充答案。\n';
const edited = '先给我提示和思考问题,让我尝试后再补充答案。\n\n 我的新规则\n使用简单易懂的语言,遇到新词先解释。\n把任务拆成小步骤,一次引导我完成一步。\n\n';
diff --git a/tests/unit/cloud-agents-page.test.tsx b/tests/unit/cloud-agents-page.test.tsx
index 3e3c86b9..0ab04ff7 100644
--- a/tests/unit/cloud-agents-page.test.tsx
+++ b/tests/unit/cloud-agents-page.test.tsx
@@ -145,8 +145,8 @@ it('retries a failed application from the header using the original publication
await screen.findByText('修改已保存,尚未应用');
fireEvent.click(screen.getByRole('button', { name: '应用最新修改', exact: true }));
await screen.findByText(/连接中断/);
- expect(screen.getByRole('button', { name: '重试本次应用' })).toBeEnabled();
- fireEvent.click(screen.getByRole('button', { name: '重试本次应用' }));
+ expect(screen.getByRole('button', { name: '应用' })).toBeEnabled();
+ fireEvent.click(screen.getByRole('button', { name: '应用' }));
await waitFor(() => expect(screen.getByRole('button', { name: '已应用', exact: true })).toBeDisabled());
const calls = api.call.mock.calls.filter(([operation]) => operation === 'publish');
expect(calls).toHaveLength(2);
@@ -187,7 +187,7 @@ it('keeps a successful application confirmed when its following status refresh f
fireEvent.click(screen.getByRole('button', { name: '应用最新修改', exact: true }));
await screen.findByText(/状态刷新失败/);
expect(screen.getByRole('button', { name: '已应用', exact: true })).toBeDisabled();
- expect(screen.queryByRole('button', { name: '重试本次应用' })).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: '应用' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '刷新应用状态' }));
await waitFor(() => expect(api.call.mock.calls.filter(([operation]) => operation === 'access')).toHaveLength(3));
expect(api.call.mock.calls.filter(([operation]) => operation === 'publish')).toHaveLength(1);
diff --git a/tests/unit/cloud-chat-timestamps.test.tsx b/tests/unit/cloud-chat-timestamps.test.tsx
new file mode 100644
index 00000000..d581f340
--- /dev/null
+++ b/tests/unit/cloud-chat-timestamps.test.tsx
@@ -0,0 +1,177 @@
+import { act, cleanup, render, screen, waitFor, within } from '@testing-library/react';
+import { afterEach, beforeEach, expect, it, vi } from 'vitest';
+import { CloudChat } from '@/pages/CloudAgents/CloudChat';
+import type { CloudHistory, CloudMessage, CloudRun, CloudThread } 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 now = new Date(2026, 9, 13, 17, 30, 40);
+const sent = new Date(2026, 9, 13, 17, 24, 12).toISOString();
+const replied = new Date(2026, 9, 13, 17, 25, 34).toISOString();
+const run: CloudRun = { agent_run_id: 'run', request_id: 'request', thread_id: 'thread', agent_slug: 'agent', status: 'running', output: '', version: '1' };
+const thread: CloudThread = { thread_id: 'thread', client_thread_id: 'client', agent_slug: 'agent', mode: 'published', status: 'running', title: '对话', updated_at: '', unread: false, run_id: 'run' };
+const saved = (fields: Partial = {}): CloudMessage => ({
+ id: 1, role: 'assistant', message_id: 'answer', run_id: 'run', request_id: 'request', created_at: replied, content: '已经保存的回答', ...fields,
+});
+const articleFor = (text: string) => {
+ const article = screen.getByText(text).closest('article');
+ expect(article).not.toBeNull();
+ return article!;
+};
+async function emitAnswer(id = 'answer', content = '正在写的回答') {
+ await act(async () => stream.dispatchEvent(new MessageEvent('messages', {
+ lastEventId: '100-1', data: JSON.stringify({ run_id: 'run', thread_id: 'thread', payload: {
+ items: [{ stream_event: { type: 'message_delta', message_id: id, content } }],
+ } }),
+ })));
+}
+async function finish(status = 'completed') {
+ await act(async () => stream.dispatchEvent(new MessageEvent('end', {
+ lastEventId: '100-1', data: JSON.stringify({ run_id: 'run', thread_id: 'thread', payload: { status } }),
+ })));
+}
+beforeEach(() => {
+ vi.resetAllMocks();
+ vi.useFakeTimers({ toFake: ['Date'] });
+ vi.setSystemTime(now);
+ stream = new Stream();
+ history = { thread_id: 'thread', messages: [], run, next_offset: null };
+ api.recovery.mockResolvedValue({ recent: null, pending: [] });
+ api.remember.mockResolvedValue({});
+ 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(); vi.useRealTimers(); });
+
+it('displays cloud creation times for sent questions and replies with semantic time and full precision', async () => {
+ history = { ...history, run: { ...run, status: 'completed' }, messages: [
+ saved({ id: 1, role: 'user', message_id: 'question', created_at: sent, content: '已经发送的问题' }),
+ saved({ id: 2 }),
+ ] };
+ const view = render( );
+ await screen.findByText('已经保存的回答');
+ const questionTime = within(articleFor('已经发送的问题')).getByLabelText('发送时间:2026年10月13日 17:24:12');
+ expect(questionTime.tagName).toBe('TIME');
+ expect(questionTime).toHaveTextContent(/^17:24$/);
+ expect(questionTime).toHaveAttribute('datetime', sent);
+ expect(questionTime).toHaveAttribute('title', '2026年10月13日 17:24:12');
+ const replyTime = within(articleFor('已经保存的回答')).getByLabelText('回复时间:2026年10月13日 17:25:34');
+ expect(replyTime).toHaveTextContent(/^17:25$/);
+ expect(replyTime).toHaveAttribute('datetime', replied);
+ expect(view.container.querySelectorAll('time')).toHaveLength(2);
+ expect(api.events).not.toHaveBeenCalled();
+});
+
+it('includes the date for older messages and the year when needed', async () => {
+ const yesterday = new Date(2026, 9, 12, 23, 59, 58).toISOString();
+ const lastYear = new Date(2025, 9, 12, 9, 5, 6).toISOString();
+ history = { ...history, run: { ...run, status: 'completed' }, messages: [
+ saved({ id: 1, role: 'user', created_at: yesterday, content: '昨天的问题' }),
+ saved({ id: 2, created_at: lastYear, content: '去年的回答' }),
+ ] };
+ render( );
+ await screen.findByText('去年的回答');
+ expect(within(articleFor('昨天的问题')).getByText('10月12日 23:59')).toHaveAttribute('datetime', yesterday);
+ expect(within(articleFor('去年的回答')).getByText('2025年10月12日 09:05')).toHaveAttribute('title', '2025年10月12日 09:05:06');
+});
+
+it('does not fabricate timestamps for legacy or invalid cloud records', async () => {
+ history = { ...history, run: { ...run, status: 'completed' }, messages: [
+ saved({ id: 1, role: 'user', created_at: '', content: '没有记录时间的问题' }),
+ saved({ id: 2, created_at: 'invalid-date', content: '时间无效的回答' }),
+ ] };
+ const view = render( );
+ await screen.findByText('时间无效的回答');
+ expect(within(articleFor('没有记录时间的问题')).getByText('时间未记录')).toHaveAttribute('data-message-time', 'unknown');
+ expect(within(articleFor('时间无效的回答')).getByText('时间未记录')).toHaveAttribute('data-message-time', 'unknown');
+ expect(view.container.querySelector('time')).toBeNull();
+});
+
+it('preserves the original cloud timestamp when a conversation is read again on a later day', async () => {
+ history = { ...history, run: { ...run, status: 'completed' }, messages: [saved()] };
+ const view = render( );
+ await screen.findByText('已经保存的回答');
+ expect(within(articleFor('已经保存的回答')).getByText('17:25')).toHaveAttribute('datetime', replied);
+ view.unmount();
+ vi.setSystemTime(new Date(2026, 9, 14, 10, 0, 0));
+ render( );
+ await screen.findByText('已经保存的回答');
+ expect(within(articleFor('已经保存的回答')).getByText('10月13日 17:25')).toHaveAttribute('datetime', replied);
+ expect(api.call.mock.calls.filter(([operation]) => operation === 'history')).toHaveLength(2);
+});
+
+it('replaces the pending reply marker with its authoritative cloud timestamp once, after final history arrives', async () => {
+ const view = render( );
+ await waitFor(() => expect(api.events).toHaveBeenCalledWith('run'));
+ await emitAnswer();
+ expect(within(articleFor('正在写的回答')).getByText('回复中…')).toHaveAttribute('data-message-time', 'pending');
+ expect(view.container.querySelector('time')).toBeNull();
+ history = { ...history, run: { ...run, status: 'completed' }, messages: [saved({ content: '完整的已保存回答' })] };
+ await finish();
+ await screen.findByText('完整的已保存回答');
+ expect(screen.queryByText('正在写的回答')).not.toBeInTheDocument();
+ expect(screen.queryByText('回复中…')).not.toBeInTheDocument();
+ expect(view.container.querySelectorAll('time')).toHaveLength(1);
+ expect(within(articleFor('完整的已保存回答')).getByLabelText('回复时间:2026年10月13日 17:25:34')).toHaveAttribute('datetime', replied);
+});
+
+it('keeps a known source timestamp when live content replaces its partial saved history', async () => {
+ history = { ...history, messages: [saved({ content: '已保存的片段' })] };
+ const view = render( );
+ await waitFor(() => expect(api.events).toHaveBeenCalled());
+ await emitAnswer('answer', '实时补全的回答');
+ expect(screen.queryByText('已保存的片段')).not.toBeInTheDocument();
+ expect(within(articleFor('实时补全的回答')).getByLabelText('回复时间:2026年10月13日 17:25:34')).toHaveAttribute('datetime', replied);
+ expect(view.container.querySelectorAll('time')).toHaveLength(1);
+ expect(screen.queryByText('回复中…')).not.toBeInTheDocument();
+});
+
+it('retains the original date if an older source message is replayed by a newer run', async () => {
+ const oldTime = new Date(2026, 9, 11, 8, 15, 3).toISOString();
+ history = { ...history, messages: [saved({ run_id: 'old-run', request_id: 'old-request', created_at: oldTime, content: '旧回答的历史记录' })] };
+ render( );
+ await waitFor(() => expect(api.events).toHaveBeenCalled());
+ await emitAnswer('answer', '旧回答再次推送的内容');
+ const replay = articleFor('旧回答再次推送的内容');
+ expect(within(replay).getByLabelText('回复时间:2026年10月11日 08:15:03')).toHaveTextContent('10月11日 08:15');
+ expect(replay.querySelector('time')).toHaveAttribute('datetime', oldTime);
+ expect(replay.querySelector('time')).not.toHaveAttribute('datetime', now.toISOString());
+});
+
+it('does not borrow a question timestamp for a streamed reply with the same source id', async () => {
+ history = { ...history, messages: [saved({ role: 'user', created_at: sent, content: '历史中的问题' })] };
+ render( );
+ await waitFor(() => expect(api.events).toHaveBeenCalled());
+ await emitAnswer();
+ const reply = articleFor('正在写的回答');
+ expect(within(reply).getByText('回复中…')).toHaveAttribute('data-message-time', 'pending');
+ expect(reply.querySelector('time')).toBeNull();
+});
+
+it('marks a terminal unsaved reply as unrecorded if history refresh fails instead of using the local clock', async () => {
+ render( );
+ await waitFor(() => expect(api.events).toHaveBeenCalled());
+ await emitAnswer();
+ const implementation = api.call.getMockImplementation()!;
+ api.call.mockImplementation(async (operation, ...args) => {
+ if (operation === 'history') throw new Error('历史暂不可用');
+ return implementation(operation, ...args);
+ });
+ await finish('failed');
+ await screen.findByText('历史暂不可用');
+ const reply = articleFor('正在写的回答');
+ expect(within(reply).getByText('时间未记录')).toHaveAttribute('data-message-time', 'unknown');
+ expect(reply.querySelector('time')).toBeNull();
+ expect(screen.queryByText('回复中…')).not.toBeInTheDocument();
+});
diff --git a/tests/unit/cloud-recovery.test.tsx b/tests/unit/cloud-recovery.test.tsx
index 02a7a56b..97e1c188 100644
--- a/tests/unit/cloud-recovery.test.tsx
+++ b/tests/unit/cloud-recovery.test.tsx
@@ -47,6 +47,39 @@ it('uses a general records entry for mixed operations and keeps the original mes
expect(api.resolvePending).not.toHaveBeenCalled();
});
+it('explains a pending application in simple language and retries only its original record', async () => {
+ const publication: CloudPendingOperation = { ...first, id: 'publish:original', operation: 'publish',
+ input: { slug: 'ml-' + 'a'.repeat(32), operation_id: 'original-publication', expected_revision: 3 } };
+ api.resolvePending.mockRejectedValueOnce(new AppError('UNKNOWN', '智能体服务暂时不可用,请重试', undefined,
+ { status: 502, backendCode: 'cloud_service_unavailable', commandOutcome: 'unknown' }))
+ .mockResolvedValueOnce({ result: { version: 2, draft_revision: 3 } });
+ render( );
+ await openRecords();
+ const row = screen.getByRole('article', { name: '应用设置' });
+ expect(within(row).getByText('网络可能不稳定,请再点一次「应用」。')).toBeVisible();
+ expect(screen.queryByText('发布版本')).not.toBeInTheDocument();
+ expect(api.resolvePending).not.toHaveBeenCalled();
+ fireEvent.click(within(row).getByRole('button', { name: '应用', exact: true }));
+ expect(await screen.findByRole('alert')).toHaveTextContent('网络可能不稳定,请再点一次「应用」。');
+ expect(resolved).not.toHaveBeenCalled();
+ fireEvent.click(within(row).getByRole('button', { name: '应用', exact: true }));
+ expect(await screen.findByText('设置已应用。')).toBeVisible();
+ expect(api.resolvePending.mock.calls).toEqual([[publication.id, false], [publication.id, false]]);
+ expect(screen.queryByText('查看结果')).not.toBeInTheDocument();
+});
+
+it('keeps a publication conflict explicit instead of calling it a network failure', async () => {
+ const publication: CloudPendingOperation = { ...first, id: 'publish:conflict', operation: 'publish' };
+ api.resolvePending.mockRejectedValue(new AppError('UNKNOWN', '云端草稿已更新,请重新读取后再保存', undefined,
+ { status: 409, backendCode: 'draft_revision_conflict', commandOutcome: 'unknown' }));
+ render( );
+ await openRecords(); fireEvent.click(screen.getByRole('button', { name: '应用', exact: true }));
+ expect(await screen.findByRole('alert')).toHaveTextContent('云端草稿已更新,请重新读取后再保存');
+ expect(screen.getByRole('alert')).not.toHaveTextContent('网络可能不稳定');
+ expect(screen.queryByText('网络可能不稳定,请再点一次「应用」。')).not.toBeInTheDocument();
+ expect(api.resolvePending).toHaveBeenCalledWith(publication.id, false);
+});
+
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( );