fix: 对齐个人智能体知识库卡片控件

This commit is contained in:
鲨鱼辣椒 committed 2026-10-13 18:13:10 +08:00
1 parent 2b2f7b96b0
commit f5a93c742a
11 files changed
+338 -31

No files matched your search

@@ -0,0 +1,41 @@
# Task: Align Agent knowledge cards and controls
## Identity
- Task ID: 20261013-knowledge-card-layout-751ac3e5
- Mode: Feature
- Branch: codex/20261013-knowledge-card-layout-751ac3e5-knowledge-card-layout
- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261013-knowledge-card-layout-751ac3e5
- Base commit: 2b2f7b96b0e2c378c706f29099ffb974dc1acdbf
- Owner: codex
- Status: Awaiting Trial Idle
## Scope
- Align the personal Agent knowledge creation and management controls in the existing three-card workspace. Halve the management dropdown, style both refresh actions as outline buttons, place computer upload in the management row, and align the reduced selection dropdown with selected-library cards.
## Intent And Constraints
- Explicit screenshot request authorizes these reversible layout changes and local trial delivery. Preserve all existing creation, selection, upload, retry and permission behavior; no real cloud save/apply/upload or paid model request. Use the current committed Renderer baseline, retain prior subagent-selector removal, and preserve Main/profile when delivering a fresh-pointer-checked renderer payload.
- Project Context Loaded: task20261013-knowledge-card-layout-751ac3e5, feature, codex/20261013-knowledge-card-layout-751ac3e5-knowledge-card-layout, owned isolated worktree at the Identity path, base2b2f7b96. Bundled check/start/status passed. Read AGENTS, entry/memory index/own task, project positioning, integrated current state, decision/architecture/domain/success/evidence context; inherited accepted personal-cloud and light-system constraints remain applicable. Design skill search supports semantic controls and responsive grids; existing Makelore palette/type/layout remains authoritative over generic recommendations.
- Independent reviewer read all43 peer taskrecords only through Scope/Intent/Promotion. Relevant knowledge layout and upload contracts (ec03b792/5ac9e2d), advanced-copy removal (a8369e71), selector removal (4c7e9a21/6d9a8e32), and newer application-message deployment remain intact. Other active chat/draft investigations are read-only. Placeholder child-language scope remains unknown with no concrete dependency. No semantic conflict; overlap handled by isolated source and fresh runtime pointer. Gate result: Passed.
- Plan: shared card control rows with responsive fallback; relocate the existing upload button using a presentation-only portal while keeping all upload state in its current component; add geometric assertions to the existing layout fixture; run existing focused unit/type/lint/default product build and fixture, review screenshots and independent diff, then deliver the prepared renderer without replacing Main or user data.
## Outcome
- Implemented shared subgrid control rows, half-width management dropdown with readable Chinese, outline refresh buttons and relocated the existing upload button through an optional presentation portal. The lower selection dropdown is2/3 of its former width and shares its left/right edges with the selected-library list. Narrow containers stack cards and wrap controls; long upload-retry labels also wrap inside the management card.
- While work was isolated, the current trial advanced from2b2f7b96 to64b4a84a2d07889908cef16656341986c5a8f11b. Copied only that committed chat-time source/test/README update into this owned worktree, preserving the newer behavior and all existing knowledge/subagent contracts. Relative to the current Renderer, the product delta remains exactly the four knowledge layout files.
- Source and fixture verification complete. The actual trial currently shows an English Agent still answering; no live activation, refresh or cancellation has occurred. Immutable file-only renderer delivery is prepared by the same-task delivery agent, pending source proof and a clean actual UI.
## Verification
- Exact pinned pnpm10.33.4. Existing focused knowledge unit31/31, typecheck and scoped ESLint passed; default full build:vite passed with renderer-only preview unset. Updated isolated real-editor browser fixture5/5 passed(23.0s), exercising375/768/1024/1440 widths, button/card midlines, the requested width ratios, selected-list edges, bound/unbound creation and browsing, uploads and identity-preserving failure/retry. No real cloud or native file picker was used by these fixtures.
- Independent source review PASS after fixing a long-retry-label overflow; independent1440/375 screenshot review PASS. git diff whitespace check passed. The first geometry run exposed a9.5px subgrid padding offset; symmetric empty detail track corrected it, followed by all final geometric assertions passing.
## Follow-ups
- Apply the fresh-pointer-checked dist-only payload after the actual running trial is idle and has no unsaved input. Preserve original Main/preload/resources/package/profile; do not interrupt the English Agent without explicit authorization. Then inspect the requested card layout through native CUA and finalize the runtime metadata. No main merge/push requested.
## Promotion Candidates
- None recorded.
+3 -1
View File
@@ -14,9 +14,11 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智
会话工作台采用浅蓝项目栏、彩色通用手柄图标与卡片选中态;中间对话/作品使用圆角标签,作品预览的后退、前进、刷新与网址输入统一放在标签右侧;只在作品页显示,窄窗口自动换行。右侧智能体咨询使用奶油色标题区。沿用真实项目和会话入口、侧栏伸缩与账户菜单,样式只作用于 Code 会话工作台。可编辑的空对话在输入区上方的可用区域中央显示童趣欢迎语;「去作品广场逛逛」入口紧贴输入框上方,两者一起位于底部。老师介绍以固定宽度浮在右上角,不占用中间布局;欢迎语和底部输入区始终相对项目栏右侧的整个内容区域居中;小窗口的欢迎内容独立滚动;发送首条消息后收起欢迎区。侧栏保留「作品广场」入口,只有点击后才在系统浏览器打开公共广场,不请求、摘取或内嵌展示广场作品。打开广场保留当前项目、对话、草稿和附件,不自动发送消息;老师求助按钮保留临时应用的最新文案「帮我整体看🧠」。 会话工作台采用浅蓝项目栏、彩色通用手柄图标与卡片选中态;中间对话/作品使用圆角标签,作品预览的后退、前进、刷新与网址输入统一放在标签右侧;只在作品页显示,窄窗口自动换行。右侧智能体咨询使用奶油色标题区。沿用真实项目和会话入口、侧栏伸缩与账户菜单,样式只作用于 Code 会话工作台。可编辑的空对话在输入区上方的可用区域中央显示童趣欢迎语;「去作品广场逛逛」入口紧贴输入框上方,两者一起位于底部。老师介绍以固定宽度浮在右上角,不占用中间布局;欢迎语和底部输入区始终相对项目栏右侧的整个内容区域居中;小窗口的欢迎内容独立滚动;发送首条消息后收起欢迎区。侧栏保留「作品广场」入口,只有点击后才在系统浏览器打开公共广场,不请求、摘取或内嵌展示广场作品。打开广场保留当前项目、对话、草稿和附件,不自动发送消息;老师求助按钮保留临时应用的最新文案「帮我整体看🧠」。
- `Makelore Agents|AI 智能体`:从模块首页进入时先打开“我的智能体”卡片列表,保留此前对话与操作恢复记录;返回首页箭头与侧栏折叠按钮同排,折叠后仍可返回;智能体模块的侧栏只通过按钮点击或键盘激活展开,鼠标经过左侧、收纳按钮或顶栏不会展开,切换模块不沿用旧悬停预览;其他模块仍保留原预览方式。“我的智能体”使用浅紫对话机器人图标,“渠道”使用浅蓝连接节点图标。macOS 原生窗口按钮留出安全空间。首页围绕自己的智能体和创建入口,显示童趣问候“Hi,今天想创造还是想聊聊?”,不再显示更多、收到的分享、活动或归档筛选。卡片恢复原有尺寸与留白,显示名称、可为空的一句话介绍及状态,支持聊天、调整、继续制作和确认删除;删除沿用云端归档机制,移出列表并停用新调用,历史和费用保留。创建先填写名称并选择平台像素头像或上传头像,下一步进入独立配置。顶部名称有编辑图标,头像可点击修改;未设置头像时使用同一张默认像素头像,从创建到配置、改名、保存和重新打开保持一致。基础页为“介绍与提示词”,一句话介绍可留空,完整提示词保留原始文本;模型作为高级设置第一项必选,未选择时不能开始使用,草稿可分步保存,不自动选模型。介绍与提示词采用浅蓝介绍卡和浅紫提示词卡;高级设置按模型、工具箱(Tool)、做事手册(Skill)、MCP 服务和知识库纵向呈现;移除子智能体选择模块,保存其他配置时保留历史子智能体绑定,不再设内部分类切换;不展示 Tool/Skill 解释、资源开放规则提示、云端能力管理、费用上限或运行限制卡片,保存其他配置时保留既有运行限制,费用规则不变。顶层页签切换保留搜索、资源选择、知识表单及未保存输入。配置页移除聊天、自动任务和使用与接入导航,独立聊天仍从卡片进入,微信账号仍在侧栏渠道管理。草稿明确保存(Ctrl/⌘+S);保存只更新草稿,“应用最新修改”使新配置生效,失败重试保持原操作身份;点击“开始使用”完成必要的私有启用后直接打开聊天,不再展示二次确认页;未保存修改、缺少模型、冲突或尚未确认的请求会留在配置页处理,打开聊天不自动发送消息。独立对话沿用奶油底色和浅蓝、浅紫、薄荷色:顶部展示返回、只读头像和名称;有附件时在同一行右侧显示回形针“附件”入口和文件数量,不展示调整、保存/应用状态、客户端/微信来源、费用/版本说明或历史/新对话图标;配置由列表卡片的“调整”进入。空对话展示当前智能体头像与名称,示例只填入草稿;用户消息使用浅紫圆角气泡,助手 Markdown、表格和代码在消息区内滚动。文件或图片从输入框内添加,上传成功后以待发送卡片呈现,可移除本次携带;移除不会删除云端文件,添加不会自动发送。确认失败沿用同一文件和对话重试,发送前需完成或取消添加;支持仅发送附件,使用明确的附件提问。输入框的“+”添加文件或图片;右上角“附件”打开右侧面板,按“你上传的”和“助手生成的”分组,支持再次选用已上传附件及保存明确交付的生成文件。生成文件仅取当前对话已保存助手回复中的本地文件链接(含图片与引用式链接),核对实际文件后展示和计数;只读取链接所在的文件夹,不遍历全部工作文件。未交付的脚本、缓存和工具记录不展示、不计数,明确交付的代码照常展示;仅写了路径的旧成果可能暂时隐藏,后续接入 Yuxi 的正式交付记录。没有文件时隐藏入口;打开、关闭面板保留聊天与输入,Escape 关闭并返回入口焦点;最近对话自动恢复、审批、停止和未发送内容的离开保护保留。模型返回的思考按消息独立于正文展示;正文到来后或完成时折叠,可手动展开;无思考或工具内容时不显示过程区。重连事件去重并隔离当前运行与主对话,终态历史未到达时保留已收到的片段并有限补读;服务端返回的最终回答思考可在历史中回看,不进入后续提问。历史回看需要配套 Yuxi API 的思考和工具记录投影;实时过程沿用既有事件流。平台工具和内置做事手册使用 Yuxi 下发的通俗名称与说明;逐条消息的思考、工具输入/结果/失败独立于 Markdown 正文,实时与历史工具卡片共用展示文案,展开可查看原始工具标识。个人上传手册保留作者文案,资源选择仍保存原始标识。头像上传、Main-owned Host API、访Line truncated - `Makelore Agents|AI 智能体`:从模块首页进入时先打开“我的智能体”卡片列表,保留此前对话与操作恢复记录;返回首页箭头与侧栏折叠按钮同排,折叠后仍可返回;智能体模块的侧栏只通过按钮点击或键盘激活展开,鼠标经过左侧、收纳按钮或顶栏不会展开,切换模块不沿用旧悬停预览;其他模块仍保留原预览方式。“我的智能体”使用浅紫对话机器人图标,“渠道”使用浅蓝连接节点图标。macOS 原生窗口按钮留出安全空间。首页围绕自己的智能体和创建入口,显示童趣问候“Hi,今天想创造还是想聊聊?”,不再显示更多、收到的分享、活动或归档筛选。卡片恢复原有尺寸与留白,显示名称、可为空的一句话介绍及状态,支持聊天、调整、继续制作和确认删除;删除沿用云端归档机制,移出列表并停用新调用,历史和费用保留。创建先填写名称并选择平台像素头像或上传头像,下一步进入独立配置。顶部名称有编辑图标,头像可点击修改;未设置头像时使用同一张默认像素头像,从创建到配置、改名、保存和重新打开保持一致。基础页为“介绍与提示词”,一句话介绍可留空,完整提示词保留原始文本;模型作为高级设置第一项必选,未选择时不能开始使用,草稿可分步保存,不自动选模型。介绍与提示词采用浅蓝介绍卡和浅紫提示词卡;高级设置按模型、工具箱(Tool)、做事手册(Skill)、MCP 服务和知识库纵向呈现;移除子智能体选择模块,保存其他配置时保留历史子智能体绑定,不再设内部分类切换;不展示 Tool/Skill 解释、资源开放规则提示、云端能力管理、费用上限或运行限制卡片,保存其他配置时保留既有运行限制,费用规则不变。顶层页签切换保留搜索、资源选择、知识表单及未保存输入。配置页移除聊天、自动任务和使用与接入导航,独立聊天仍从卡片进入,微信账号仍在侧栏渠道管理。草稿明确保存(Ctrl/⌘+S);保存只更新草稿,“应用最新修改”使新配置生效,失败重试保持原操作身份;点击“开始使用”完成必要的私有启用后直接打开聊天,不再展示二次确认页;未保存修改、缺少模型、冲突或尚未确认的请求会留在配置页处理,打开聊天不自动发送消息。独立对话沿用奶油底色和浅蓝、浅紫、薄荷色:顶部展示返回、只读头像和名称;有附件时在同一行右侧显示回形针“附件”入口和文件数量,不展示调整、保存/应用状态、客户端/微信来源、费用/版本说明或历史/新对话图标;配置由列表卡片的“调整”进入。空对话展示当前智能体头像与名称,示例只填入草稿;用户消息使用浅紫圆角气泡,助手 Markdown、表格和代码在消息区内滚动。文件或图片从输入框内添加,上传成功后以待发送卡片呈现,可移除本次携带;移除不会删除云端文件,添加不会自动发送。确认失败沿用同一文件和对话重试,发送前需完成或取消添加;支持仅发送附件,使用明确的附件提问。输入框的“+”添加文件或图片;右上角“附件”打开右侧面板,按“你上传的”和“助手生成的”分组,支持再次选用已上传附件及保存明确交付的生成文件。生成文件仅取当前对话已保存助手回复中的本地文件链接(含图片与引用式链接),核对实际文件后展示和计数;只读取链接所在的文件夹,不遍历全部工作文件。未交付的脚本、缓存和工具记录不展示、不计数,明确交付的代码照常展示;仅写了路径的旧成果可能暂时隐藏,后续接入 Yuxi 的正式交付记录。没有文件时隐藏入口;打开、关闭面板保留聊天与输入,Escape 关闭并返回入口焦点;最近对话自动恢复、审批、停止和未发送内容的离开保护保留。模型返回的思考按消息独立于正文展示;正文到来后或完成时折叠,可手动展开;无思考或工具内容时不显示过程区。重连事件去重并隔离当前运行与主对话,终态历史未到达时保留已收到的片段并有限补读;服务端返回的最终回答思考可在历史中回看,不进入后续提问。历史回看需要配套 Yuxi API 的思考和工具记录投影;实时过程沿用既有事件流。平台工具和内置做事手册使用 Yuxi 下发的通俗名称与说明;逐条消息的思考、工具输入/结果/失败独立于 Markdown 正文,实时与历史工具卡片共用展示文案,展开可查看原始工具标识。个人上传手册保留作者文案,资源选择仍保存原始标识。头像上传、Main-owned Host API、访Line truncated
个人智能体聊天在每条提问与回答下方显示云端记录的消息时间:今天显示时分,跨日增加日期,悬停可查看完整日期与秒数;发送时间与回复时间沿用原消息记录,重开对话不改为当前时间。流式回答尚无可靠时间时显示「回复中…」,历史到达后展示原时间;缺失或无效时间显示「时间未记录」。正式对话、草稿试用和上次试用对比使用相同规则,不新增 Yuxi 接口。
历史待确认记录收起在智能体列表标题区的「创建记录/待处理记录」入口,只有点击后才打开记录窗口;配置、聊天和创建页不再置顶这些旧提醒。记录保留智能体名称和原请求身份;应用设置的待确认记录显示“网络可能不稳定,请再点一次「应用」。”,点击「应用」继续原来的提交,成功后显示“设置已应用。”。其他记录仍可明确「再试一次」或「不再提醒」,不会自动重试或清除。当前创建失败继续在创建表单内提示;关闭记录窗口保留已收到的结果和密钥,切换配置、聊天或创建页后可返回列表继续查看。密钥须明确关闭结果后,才可处理其他记录。 历史待确认记录收起在智能体列表标题区的「创建记录/待处理记录」入口,只有点击后才打开记录窗口;配置、聊天和创建页不再置顶这些旧提醒。记录保留智能体名称和原请求身份;应用设置的待确认记录显示“网络可能不稳定,请再点一次「应用」。”,点击「应用」继续原来的提交,成功后显示“设置已应用。”。其他记录仍可明确「再试一次」或「不再提醒」,不会自动重试或清除。当前创建失败继续在创建表单内提示;关闭记录窗口保留已收到的结果和密钥,切换配置、聊天或创建页后可返回列表继续查看。密钥须明确关闭结果后,才可处理其他记录。
高级配置的知识库支持下拉多选,分为「MakeLore知识」和「我的知识库」,已选项可单独移除。MakeLore知识来自当前账号有权读取的 Yuxi 共享目录,只显示名称与简介;移除仅取消当前智能体选用,不删除原库。知识库区域上方左右分区,两张卡片在并排时保持等高:左侧「上传自己的文件」仅放「新建知识库」入口及展开的创建表单,右侧「查看我的知识库」通过下拉框查看本人知识库、从电脑追加资料和管理文件;下方独立「选择知识库」,选择智能体使用的「MakeLore知识」和「我的知识库」。小窗口按新建、查看、选用顺序纵向排列。创建成功后在右侧打开新库;新建和查看均不会自动加入智能体配置,选用上限不影响管理与新建。仅提供「从电脑上传」作为添加文件入口,未选择管理目标时上传按钮置灰;聊天文件导入入口已移除。上传后自动解析、建立索引并持续显示真实处理状态;失败可重试且保留已有可用内容。暂不可选用的个人库仍可管理文件,已有绑定保留并提示不可用;未确认请求在当前库保留同一操作身份供重试。知识库选择只改草稿,显式保存/应用后生效;个人云端执行已自动接入所选知识库的检索,无需另外勾选 knowledge-base Skill。此交互复用现有接口,无需新增后端补丁。 高级配置的知识库支持下拉多选,分为「MakeLore知识」和「我的知识库」,已选项可单独移除。MakeLore知识来自当前账号有权读取的 Yuxi 共享目录,只显示名称与简介;移除仅取消当前智能体选用,不删除原库。知识库区域上方左右分区,两张卡片在并排时保持等高:左侧「上传自己的文件」仅放「新建知识库」入口及展开的创建表单,右侧「查看我的知识库」通过下拉框查看本人知识库、从电脑追加资料和管理文件。宽窗口中新建按钮与右侧选择、刷新、上传控件共用居中的水平线,管理下拉框约为原宽度的一半,两种目录刷新均显示为带边框按钮;下方独立「选择知识库」,选择智能体使用的「MakeLore知识」和「我的知识库」,下拉框缩至原宽度的约2/3,并与已选知识库框左右对齐。小窗口按新建、查看、选用顺序纵向排列,控件允许换行并保持可读。创建成功后在右侧打开新库;新建和查看均不会自动加入智能体配置,选用上限不影响管理与新建。仅提供「从电脑上传」作为添加文件入口,未选择管理目标时上传按钮置灰;聊天文件导入入口已移除。上传后自动解析、建立索引并持续显示真实处理状态;失败可重试且保留已有可用内容。暂不可选用的个人库仍可管理文件,已有绑定保留并提示不可用;未确认请求在当前库保留同一操作身份供重试。知识库选择只改草稿,显式保存/应用后生效;个人云端执行已自动接入所选知识库的检索,无需另外勾选 knowledge-base Skill。此交互复用现有接口,无需新增后端补丁。
云知识库的 embedding 模型只由平台管理员在 Yuxi 后台维护:在「智能体管理 → 模型供应商」启用供应商,按供应商要求填写有效的服务地址和 API Key,添加并启用 embedding 模型并填写实际向量维度;embedding 向量化费用由平台承担,不计入用户用量、词元点数或智能体费用上限;one-api 不参与 embedding 配置,Works Square 只负责其他应计费模型调用的创建者词元点数账本。客户端区分目录加载中、加载失败与无可用模型,空状态提供管理员配置说明;「刷新知识库与模型」保留未提交名称,未确认的创建请求沿用原操作身份。 云知识库的 embedding 模型只由平台管理员在 Yuxi 后台维护:在「智能体管理 → 模型供应商」启用供应商,按供应商要求填写有效的服务地址和 API Key,添加并启用 embedding 模型并填写实际向量维度;embedding 向量化费用由平台承担,不计入用户用量、词元点数或智能体费用上限;one-api 不参与 embedding 配置,Works Square 只负责其他应计费模型调用的创建者词元点数账本。客户端区分目录加载中、加载失败与无可用模型,空状态提供管理员配置说明;「刷新知识库与模型」保留未提交名称,未确认的创建请求沿用原操作身份。
+21 -5
View File
@@ -8,6 +8,8 @@ import { CloudComposerAttachments } from './CloudComposerAttachments';
import { CloudAvatar } from './CloudAvatar'; import { CloudAvatar } from './CloudAvatar';
import './desktop-workspace.css'; import './desktop-workspace.css';
import { CloudMessageBody } from './CloudMessageBody'; import { CloudMessageBody } from './CloudMessageBody';
import { MessageTimestamp } from '@/pages/Chat/MessageTimestamp';
import { formatMessageTimestamp } from '@/lib/message-timestamp';
import './agent-chat.css'; import './agent-chat.css';
import { CloudPendingContext, useCloudPendingState, usePendingCloudInput } from './CloudPending'; import { CloudPendingContext, useCloudPendingState, usePendingCloudInput } from './CloudPending';
import { agentExamples } from './agent-guidance'; import { agentExamples } from './agent-guidance';
@@ -23,6 +25,12 @@ const message = (e: unknown) => e instanceof Error ? e.message : '暂时无法
const object = (v: unknown): Record<string, unknown> => v && typeof v === 'object' ? v as Record<string, unknown> : {}; const object = (v: unknown): Record<string, unknown> => v && typeof v === 'object' ? v as Record<string, unknown> : {};
const savedMessage = (history: CloudHistory, message: CloudLiveMessage) => history.messages.some(saved => 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)); 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, export function CloudChat({ slug, previewRevision: latestRevision, initialThread, initialThreadId, onSchedule, activeView = true, headerAction, attachmentHeader, previewReady = true,
assistantName = '助手', assistantAvatarUrl, examples = agentExamples(), hasUnsavedChanges = false, saveDisabled = false, onSaveForPreview }: { assistantName = '助手', assistantAvatarUrl, examples = agentExamples(), hasUnsavedChanges = false, saveDisabled = false, onSaveForPreview }: {
@@ -351,14 +359,20 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread
{history && <TrialMessages history={{ ...history, messages: history.messages.filter(saved => {history && <TrialMessages history={{ ...history, messages: history.messages.filter(saved =>
!(!liveHistoryReady && liveReply?.messages.some(item => saved.run_id === item.run_id && saved.message_id === item.message_id)), !(!liveHistoryReady && liveReply?.messages.some(item => saved.run_id === item.run_id && saved.message_id === item.message_id)),
) }} onError={setError} assistantName={assistantName} assistantAvatarUrl={assistantAvatarUrl} />} ) }} onError={setError} assistantName={assistantName} assistantAvatarUrl={assistantAvatarUrl} />}
{liveReply?.messages.filter(item => !liveHistoryReady || !history || !savedMessage(history, item)).map(item => <article key={item.run_id + ':' + item.message_id} className="agent-chat-message" data-role="assistant" aria-label="助手正在回复"> {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 <article key={item.run_id + ':' + item.message_id} className="agent-chat-message" data-role="assistant" aria-label="助手正在回复">
<CloudAvatar src={assistantAvatarUrl} name={assistantName} className="agent-chat-message-avatar" /> <CloudAvatar src={assistantAvatarUrl} name={assistantName} className="agent-chat-message-avatar" />
<div className="agent-chat-message-content"><p className="agent-chat-speaker">{assistantName}</p> <div className="agent-chat-message-content"><p className="agent-chat-speaker">{assistantName}</p>
<div className="agent-chat-message-body"><CloudMessageBody {...item} active={running && runId === item.run_id && !liveReply.ended} onDownload={path => { <div className="agent-chat-message-body"><CloudMessageBody {...item} active={replying} onDownload={path => {
const id = thread?.thread_id ?? request?.thread_id; const id = thread?.thread_id ?? request?.thread_id;
if (id) void cloudAgentsApi.download(id, path).catch(e => setError(message(e))); if (id) void cloudAgentsApi.download(id, path).catch(e => setError(message(e)));
}} /></div></div> }} /></div>
</article>)} <MessageTimestamp value={timestamp} pending={!timestamp && replying} label="回复时间" className="agent-chat-message-time" />
</div>
</article>;
})}
{onSchedule && history?.schedule_proposals?.map(proposal => <article key={proposal.id} className="space-y-3 rounded-xl border border-violet-200 bg-violet-50/30 p-4 text-sm"> {onSchedule && history?.schedule_proposals?.map(proposal => <article key={proposal.id} className="space-y-3 rounded-xl border border-violet-200 bg-violet-50/30 p-4 text-sm">
<h3 className="font-medium">自动任务建议 · {proposal.name}</h3><p className="whitespace-pre-wrap">{proposal.prompt}</p> <h3 className="font-medium">自动任务建议 · {proposal.name}</h3><p className="whitespace-pre-wrap">{proposal.prompt}</p>
<p className="text-xs text-muted-foreground">{proposal.cron_expression} · {proposal.timezone} · 尚未启用</p> <p className="text-xs text-muted-foreground">{proposal.cron_expression} · {proposal.timezone} · 尚未启用</p>
@@ -433,7 +447,9 @@ function TrialMessages({ history, onError, assistantName = '助手', assistantAv
<div className="agent-chat-message-body">{item.role === 'user' ? <p className="whitespace-pre-wrap">{item.content}</p> : <div className="agent-chat-message-body">{item.role === 'user' ? <p className="whitespace-pre-wrap">{item.content}</p> :
<CloudMessageBody content={item.content} reasoning_content={item.role === 'assistant' ? item.reasoning_content : undefined} <CloudMessageBody content={item.content} reasoning_content={item.role === 'assistant' ? item.reasoning_content : undefined}
tool_calls={item.role === 'assistant' ? item.tool_calls : undefined} tool_calls={item.role === 'assistant' ? item.tool_calls : undefined}
onDownload={path => void cloudAgentsApi.download(history.thread_id, path).catch(e => onError(message(e)))} />}</div></div> onDownload={path => void cloudAgentsApi.download(history.thread_id, path).catch(e => onError(message(e)))} />}</div>
<MessageTimestamp value={item.created_at} label={item.role === 'user' ? '发送时间' : item.role === 'assistant' ? '回复时间' : '消息时间'} className="agent-chat-message-time" />
</div>
</article>); </article>);
} }
+20 -14
View File
@@ -28,6 +28,7 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted, onCatalog, ref
const [refresh, setRefresh] = useState(0); const [refresh, setRefresh] = useState(0);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [documentPending, setDocumentPending] = useState(false); const [documentPending, setDocumentPending] = useState(false);
const [uploadTarget, setUploadTarget] = useState<HTMLDivElement | null>(null);
const createIntent = useRef<{ operation_id: string; name: string; embedding_model: string } | null>(null); const createIntent = useRef<{ operation_id: string; name: string; embedding_model: string } | null>(null);
usePendingCloudInput(busy || documentPending || Boolean(createIntent.current) || (creating && Boolean(name.trim()))); usePendingCloudInput(busy || documentPending || Boolean(createIntent.current) || (creating && Boolean(name.trim())));
const operationInFlight = useRef(false); const operationInFlight = useRef(false);
@@ -63,8 +64,7 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted, onCatalog, ref
const selectedKnowledge = manageable.find(kb => kb.kb_id === kbId); const selectedKnowledge = manageable.find(kb => kb.kb_id === kbId);
const managementDisabled = disabled || busy || catalogLoading || Boolean(catalogError); const managementDisabled = disabled || busy || catalogLoading || Boolean(catalogError);
const bindable = (id: string) => selectableIds === undefined || Boolean(selectableIds?.includes(id)); const bindable = (id: string) => selectableIds === undefined || Boolean(selectableIds?.includes(id));
const creationControls = <div className="space-y-3"> const creationDetails = <div className="space-y-3">
<Button type="button" variant="outline" disabled={disabled || busy || documentPending || Boolean(createIntent.current)} onClick={() => setCreating(v => !v)}>新建知识库</Button>
{creating && <div className="space-y-3 rounded-md bg-muted/30 p-3"> {creating && <div className="space-y-3 rounded-md bg-muted/30 p-3">
<Input aria-label="知识库名称" placeholder="知识库名称" value={name} disabled={disabled || busy || !!createIntent.current} onChange={e => setName(e.target.value)} /> <Input aria-label="知识库名称" placeholder="知识库名称" value={name} disabled={disabled || busy || !!createIntent.current} onChange={e => setName(e.target.value)} />
<select aria-label="向量模型" className="h-10 w-full rounded-md border bg-background px-3" value={model} <select aria-label="向量模型" className="h-10 w-full rounded-md border bg-background px-3" value={model}
@@ -93,19 +93,20 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted, onCatalog, ref
} }
}, 'creation')}>{createIntent.current ? '重试创建' : '创建知识库'}</Button> }, 'creation')}>{createIntent.current ? '重试创建' : '创建知识库'}</Button>
</div>} </div>}
{errorTarget === 'creation' && error && <p role="alert" className="text-sm text-destructive">{error}</p>}
</div>; </div>;
return <div className="agent-knowledge-owned-workspace"> return <div className="agent-knowledge-owned-workspace">
<section aria-label="上传自己的文件" className="agent-knowledge-upload space-y-4"> <section aria-label="上传自己的文件" className="agent-knowledge-upload">
<div className="agent-knowledge-panel-heading"><Upload aria-hidden="true" className="size-5" /><h3>上传自己的文件</h3></div> <div className="agent-knowledge-panel-heading"><Upload aria-hidden="true" className="size-5" /><h3>上传自己的文件</h3></div>
{creationControls} <div className="agent-knowledge-create-action">
{errorTarget === 'creation' && error && <p role="alert" className="text-sm text-destructive">{error}</p>} <Button type="button" variant="outline" disabled={disabled || busy || documentPending || Boolean(createIntent.current)} onClick={() => setCreating(v => !v)}>新建知识库</Button>
</div>
{creationDetails}
</section> </section>
<section aria-label="查看我的知识库" className="agent-knowledge-management space-y-4 text-sm"> <section aria-label="查看我的知识库" className="agent-knowledge-management text-sm">
<div className="agent-knowledge-panel-heading"><Library aria-hidden="true" className="size-5" /><h3>查看我的知识库</h3></div> <div className="agent-knowledge-panel-heading"><Library aria-hidden="true" className="size-5" /><h3>查看我的知识库</h3></div>
{errorTarget === 'management' && error && <p role="alert" className="text-destructive">{error}</p>} <div className="agent-knowledge-management-controls">
{catalogError && <p role="alert" className="text-destructive">知识库与模型目录加载失败:{catalogError}。请刷新重试。</p>} <select aria-label="管理的知识库" value={kbId} disabled={managementDisabled || documentPending}
<div className="flex flex-wrap gap-2">
<select aria-label="管理的知识库" className="h-10 min-w-0 flex-1 basis-48 rounded-md border bg-background px-3" value={kbId} disabled={managementDisabled || documentPending}
onChange={e => { onChange={e => {
const selected = manageable.find(kb => kb.kb_id === e.target.value); const selected = manageable.find(kb => kb.kb_id === e.target.value);
if (managementDisabled || documentPending) return; if (managementDisabled || documentPending) return;
@@ -117,8 +118,14 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted, onCatalog, ref
{kb.name}{selectableIds === null ? '(选用状态待确认)' : !bindable(kb.kb_id) ? '(当前暂不可选用)' : ''} {kb.name}{selectableIds === null ? '(选用状态待确认)' : !bindable(kb.kb_id) ? '(当前暂不可选用)' : ''}
</option>)} </option>)}
</select> </select>
<Button type="button" variant="ghost" disabled={disabled || busy || catalogLoading} onClick={() => setRefresh(v => v + 1)}>刷新知识库与模型</Button> <Button type="button" variant="outline" disabled={disabled || busy || catalogLoading} onClick={() => setRefresh(v => v + 1)}>刷新知识库与模型</Button>
<div ref={setUploadTarget} className="agent-knowledge-upload-action">
{!selectedKnowledge && <Button type="button" disabled title="请先选择或新建自己的知识库">从电脑上传</Button>}
</div>
</div> </div>
<div className="space-y-4">
{errorTarget === 'management' && error && <p role="alert" className="text-destructive">{error}</p>}
{catalogError && <p role="alert" className="text-destructive">知识库与模型目录加载失败:{catalogError}。请刷新重试。</p>}
{documentPending && selectedKnowledge && <p role="status" className="text-xs leading-5 text-amber-800">有未确认的文件操作,请先在当前知识库重试或确认状态,再切换知识库。</p>} {documentPending && selectedKnowledge && <p role="status" className="text-xs leading-5 text-amber-800">有未确认的文件操作,请先在当前知识库重试或确认状态,再切换知识库。</p>}
{!catalogLoading && !catalogError && !manageable.length && <p className="text-xs leading-5 text-muted-foreground">还没有可管理的知识库,可以新建一个保存自己的文件。MakeLore知识仅供检索。</p>} {!catalogLoading && !catalogError && !manageable.length && <p className="text-xs leading-5 text-muted-foreground">还没有可管理的知识库,可以新建一个保存自己的文件。MakeLore知识仅供检索。</p>}
{selectedKnowledge && selectableIds !== undefined && !bindable(selectedKnowledge.kb_id) && <p role="status" className="text-xs leading-5 text-amber-800"> {selectedKnowledge && selectableIds !== undefined && !bindable(selectedKnowledge.kb_id) && <p role="status" className="text-xs leading-5 text-amber-800">
@@ -131,12 +138,11 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted, onCatalog, ref
</Button> </Button>
{documentPending && <p className="text-xs leading-5 text-muted-foreground">放弃只清除本地重试请求,不会删除已保存的文件。重新进入后可确认服务器状态。</p>} {documentPending && <p className="text-xs leading-5 text-muted-foreground">放弃只清除本地重试请求,不会删除已保存的文件。重新进入后可确认服务器状态。</p>}
</div>} </div>}
{!selectedKnowledge && <div>
<Button type="button" disabled title="请先选择或新建自己的知识库">从电脑上传</Button>
</div>}
{kbId && <div hidden={!selectedKnowledge}><CloudKnowledgeDocuments key={slug + ':' + kbId} slug={slug} kbId={kbId} busy={managementDisabled || !selectedKnowledge} {kbId && <div hidden={!selectedKnowledge}><CloudKnowledgeDocuments key={slug + ':' + kbId} slug={slug} kbId={kbId} busy={managementDisabled || !selectedKnowledge}
uploadTarget={selectedKnowledge ? uploadTarget : null}
canRead={Boolean(selectedKnowledge)} act={act} onPendingChange={setDocumentPending} canRead={Boolean(selectedKnowledge)} act={act} onPendingChange={setDocumentPending}
onDeleteKnowledge={() => { callbacks.current.onDeleted?.(kbId); setKbId(''); setDocumentPending(false); setRefresh(v => v + 1); }} /></div>} onDeleteKnowledge={() => { callbacks.current.onDeleted?.(kbId); setKbId(''); setDocumentPending(false); setRefresh(v => v + 1); }} /></div>}
</div>
</section> </section>
</div>; </div>;
} }
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { CheckCircle2, CircleAlert, FileText, Loader2 } from 'lucide-react'; import { CheckCircle2, CircleAlert, FileText, Loader2 } from 'lucide-react';
import { cloudAgentsApi } from '@/lib/cloud-agents-api'; import { cloudAgentsApi } from '@/lib/cloud-agents-api';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -47,11 +48,12 @@ function documentState(file: CloudKnowledgeFile) {
return { label: '状态待确认', detail: '请刷新状态后再操作。', stage: 0 }; return { label: '状态待确认', detail: '请刷新状态后再操作。', stage: 0 };
} }
export function CloudKnowledgeDocuments({ slug, kbId, busy, act, onDeleteKnowledge, canRead = true, onPendingChange }: { export function CloudKnowledgeDocuments({ slug, kbId, busy, act, onDeleteKnowledge, canRead = true, onPendingChange, uploadTarget }: {
slug: string; kbId: string; busy: boolean; slug: string; kbId: string; busy: boolean;
act: (operation: () => Promise<void>) => Promise<void>; act: (operation: () => Promise<void>) => Promise<void>;
onDeleteKnowledge: () => void; onDeleteKnowledge: () => void;
canRead?: boolean; onPendingChange?: (pending: boolean) => void; canRead?: boolean; onPendingChange?: (pending: boolean) => void;
uploadTarget?: HTMLElement | null;
}) { }) {
const [files, setFiles] = useState<CloudKnowledgeFile[]>([]); const [files, setFiles] = useState<CloudKnowledgeFile[]>([]);
const [pageCount, setPageCount] = useState(1); const [pageCount, setPageCount] = useState(1);
@@ -150,9 +152,10 @@ export function CloudKnowledgeDocuments({ slug, kbId, busy, act, onDeleteKnowled
}); });
const states = files.map(documentState); const states = files.map(documentState);
const active = states.some(state => state.active) || submitting !== null; const active = states.some(state => state.active) || submitting !== null;
const uploadButton = <Button type="button" disabled={busy || !canRead || (unresolved && !uploadIntents.current.new)} onClick={() => void upload()}>{uploadIntents.current.new ? '重新选择同一文件并重试' : '从电脑上传'}</Button>;
return <div className="space-y-4"> return <div className="space-y-4">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Button type="button" disabled={busy || !canRead || (unresolved && !uploadIntents.current.new)} onClick={() => void upload()}>{uploadIntents.current.new ? '重新选择同一文件并重试' : '从电脑上传'}</Button> {uploadTarget ? createPortal(uploadButton, uploadTarget) : uploadButton}
<Button type="button" variant="ghost" disabled={loading || !canRead} onClick={refresh}>刷新状态</Button> <Button type="button" variant="ghost" disabled={loading || !canRead} onClick={refresh}>刷新状态</Button>
</div> </div>
<p className="text-xs leading-5 text-muted-foreground">上传后自动解析并建立索引。最大 5 MB;支持 TXT、Markdown、CSV、JSON、Word、Excel、文本 PDF。</p> <p className="text-xs leading-5 text-muted-foreground">上传后自动解析并建立索引。最大 5 MB;支持 TXT、Markdown、CSV、JSON、Word、Excel、文本 PDF。</p>
@@ -24,21 +24,21 @@ export function CloudKnowledgeSelector({ slug, options, selected, disabled, load
<section aria-label="选择知识库" className="agent-knowledge-selection space-y-4"> <section aria-label="选择知识库" className="agent-knowledge-selection space-y-4">
<div className="agent-knowledge-panel-heading"><Library aria-hidden="true" className="size-5" /><h3>选择知识库</h3></div> <div className="agent-knowledge-panel-heading"><Library aria-hidden="true" className="size-5" /><h3>选择知识库</h3></div>
<p className="text-xs leading-5 text-muted-foreground">从 MakeLore知识和我的知识库中选择,可同时使用多个。</p> <p className="text-xs leading-5 text-muted-foreground">从 MakeLore知识和我的知识库中选择,可同时使用多个。</p>
<div className="flex flex-wrap items-center gap-2"> <div className="agent-knowledge-selection-controls">
<select aria-label="选择知识库" className="min-w-0 flex-1 basis-48" disabled={disabled || loading || Boolean(error) || selectionFull || available.length === 0} value="" <select aria-label="选择知识库" disabled={disabled || loading || Boolean(error) || selectionFull || available.length === 0} value=""
onChange={event => { if (!disabled && !loading && !error && event.target.value && available.some(option => option.key === event.target.value)) add(event.target.value); }}> onChange={event => { if (!disabled && !loading && !error && event.target.value && available.some(option => option.key === event.target.value)) add(event.target.value); }}>
<option value="">{loading ? '正在读取知识库…' : error ? '知识库目录暂不可用' : available.length ? '选择知识库,可选多个' : options.length ? '已选用全部可用知识库' : '暂无可选知识库'}</option> <option value="">{loading ? '正在读取知识库…' : error ? '知识库目录暂不可用' : available.length ? '选择知识库,可选多个' : options.length ? '已选用全部可用知识库' : '暂无可选知识库'}</option>
{shared.length > 0 && <optgroup label="MakeLore知识">{shared.map(option => <option key={option.key} value={option.key}>{option.name}</option>)}</optgroup>} {shared.length > 0 && <optgroup label="MakeLore知识">{shared.map(option => <option key={option.key} value={option.key}>{option.name}</option>)}</optgroup>}
{personal.length > 0 && <optgroup label="我的知识库">{personal.map(option => <option key={option.key} value={option.key}>{option.name}</option>)}</optgroup>} {personal.length > 0 && <optgroup label="我的知识库">{personal.map(option => <option key={option.key} value={option.key}>{option.name}</option>)}</optgroup>}
{unknown.length > 0 && <optgroup label="可选知识库">{unknown.map(option => <option key={option.key} value={option.key}>{option.name}</option>)}</optgroup>} {unknown.length > 0 && <optgroup label="可选知识库">{unknown.map(option => <option key={option.key} value={option.key}>{option.name}</option>)}</optgroup>}
</select> </select>
<Button type="button" variant="ghost" size="sm" disabled={disabled || loading || owned.loading} onClick={onRefresh}>刷新知识库</Button> <Button type="button" variant="outline" disabled={disabled || loading || owned.loading} onClick={onRefresh}>刷新知识库</Button>
</div> </div>
{selectionFull && <p className="text-xs leading-5 text-muted-foreground">已选满100个知识库,请先移除一个再选择。</p>} {selectionFull && <p className="text-xs leading-5 text-muted-foreground">已选满100个知识库,请先移除一个再选择。</p>}
{error ? <p role="alert" className="text-sm text-destructive">知识库目录读取失败,请刷新重试。</p> {error ? <p role="alert" className="text-sm text-destructive">知识库目录读取失败,请刷新重试。</p>
: !loading && ownedIds !== null && options.every(option => ownedIds.includes(option.key)) && <p className="text-xs leading-5 text-muted-foreground">暂无可选的MakeLore知识。平台开放给你读取的知识库会出现在这里。</p>} : !loading && ownedIds !== null && options.every(option => ownedIds.includes(option.key)) && <p className="text-xs leading-5 text-muted-foreground">暂无可选的MakeLore知识。平台开放给你读取的知识库会出现在这里。</p>}
{owned.error && <p className="text-xs leading-5 text-muted-foreground">我的知识库目录暂不可用,仍可选用可读取的知识;上传文件请刷新重试。</p>} {owned.error && <p className="text-xs leading-5 text-muted-foreground">我的知识库目录暂不可用,仍可选用可读取的知识;上传文件请刷新重试。</p>}
{selected.length > 0 ? <ul aria-label="已选知识库" className="space-y-2"> {selected.length > 0 ? <ul aria-label="已选知识库" className="agent-knowledge-selected space-y-2">
{selected.map(id => { {selected.map(id => {
const option = options.find(item => item.key === id); const option = options.find(item => item.key === id);
const ownKnowledge = owned.databases?.find(item => item.kb_id === id); const ownKnowledge = owned.databases?.find(item => item.kb_id === id);
+2
View File
@@ -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"] { 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-message-body p { margin: 0; }
.agent-chat-message[data-role="user"] .agent-chat-speaker { color: #75628e; } .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 :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 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; } .agent-chat-styled .agent-chat-message-body img { max-width: 100%; height: auto; border-radius: 12px; }
+22 -3
View File
@@ -43,16 +43,35 @@
.agent-config-resource-manager > details { border-radius: 14px; background: #ffffffa8; } .agent-config-resource-manager > details { border-radius: 14px; background: #ffffffa8; }
.agent-resource-card:has(.agent-knowledge-workspace) { container-type: inline-size; } .agent-resource-card:has(.agent-knowledge-workspace) { container-type: inline-size; }
.agent-knowledge-workspace { display: grid; gap: 18px; min-width: 0; } .agent-knowledge-workspace { display: grid; gap: 18px; min-width: 0; }
.agent-knowledge-owned-workspace { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr); align-items: stretch; gap: 18px; min-width: 0; } .agent-knowledge-owned-workspace { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr); grid-template-rows: auto minmax(96px, auto) minmax(43px, auto); align-items: stretch; gap: 18px; min-width: 0; }
.agent-knowledge-upload, .agent-knowledge-management, .agent-knowledge-selection { min-width: 0; padding: 18px; border: 1px solid #dce5e0; border-radius: 17px; background: #fffdfb; overflow-wrap: anywhere; } .agent-knowledge-upload, .agent-knowledge-management, .agent-knowledge-selection { min-width: 0; padding: 18px; border: 1px solid #dce5e0; border-radius: 17px; background: #fffdfb; overflow-wrap: anywhere; }
.agent-knowledge-upload { border-color: #dce5f3; } .agent-knowledge-upload { border-color: #dce5f3; }
.agent-knowledge-upload, .agent-knowledge-management { display: grid; grid-template-rows: subgrid; grid-row: span 3; }
.agent-knowledge-create-action { display: flex; align-items: center; justify-content: center; }
.agent-knowledge-management-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; min-width: 0; }
.agent-config-card .agent-knowledge-management-controls select { width: calc((100% - 144px - 8px) / 2); flex: none; padding-inline: 8px; font-size: 14px; }
.agent-knowledge-upload-action { display: flex; min-width: 0; }
.agent-knowledge-create-action button, .agent-knowledge-management-controls button, .agent-knowledge-selection-controls button { min-height: 48px; height: auto; border-radius: 13px; }
.agent-knowledge-management-controls button { flex-shrink: 0; }
.agent-knowledge-selection { --knowledge-choice-width: calc((100% - 102px) * 2 / 3); }
.agent-knowledge-selection-controls { display: flex; align-items: center; gap: 12px; min-width: 0; }
.agent-config-card .agent-knowledge-selection-controls select { width: var(--knowledge-choice-width); flex: none; }
.agent-knowledge-selected { width: var(--knowledge-choice-width); }
.agent-knowledge-panel-heading { display: flex; align-items: center; gap: 9px; color: #33485c; } .agent-knowledge-panel-heading { display: flex; align-items: center; gap: 9px; color: #33485c; }
.agent-knowledge-panel-heading h3 { font-size: 16px; font-weight: 650; line-height: 1.5; } .agent-knowledge-panel-heading h3 { font-size: 16px; font-weight: 650; line-height: 1.5; }
.agent-knowledge-panel-heading svg { flex-shrink: 0; color: #607d72; } .agent-knowledge-panel-heading svg { flex-shrink: 0; color: #607d72; }
.agent-knowledge-upload .agent-knowledge-panel-heading svg { color: #4a6e9c; } .agent-knowledge-upload .agent-knowledge-panel-heading svg { color: #4a6e9c; }
.agent-knowledge-selection ul { max-height: 480px; overflow: auto; overscroll-behavior: contain; } .agent-knowledge-selection ul { max-height: 480px; overflow: auto; overscroll-behavior: contain; }
.agent-knowledge-selection button { flex-shrink: 0; } .agent-knowledge-selection button { flex-shrink: 0; }
@container (max-width: 620px) { .agent-knowledge-owned-workspace { grid-template-columns: minmax(0, 1fr); } } @container (max-width: 840px) {
.agent-knowledge-owned-workspace { grid-template-columns: minmax(0, 1fr); grid-template-rows: none; }
.agent-knowledge-upload, .agent-knowledge-management { display: flex; flex-direction: column; gap: 16px; grid-row: auto; }
.agent-knowledge-create-action { min-height: 80px; }
.agent-knowledge-management-controls { flex-wrap: wrap; }
.agent-config-card .agent-knowledge-management-controls select { width: 100%; }
.agent-knowledge-selection { --knowledge-choice-width: 100%; }
.agent-knowledge-selection-controls { flex-wrap: wrap; }
}
@media (max-width: 640px) { @media (max-width: 640px) {
.agent-editor-tabs { padding: 10px 12px; } .agent-editor-tabs { padding: 10px 12px; }
.agent-editor-tabs [role="tab"] { gap: 6px; padding-inline: 8px; font-size: 14px; } .agent-editor-tabs [role="tab"] { gap: 6px; padding-inline: 8px; font-size: 14px; }
@@ -65,7 +84,7 @@
.agent-config-icon { width: 38px; height: 38px; border-radius: 13px; } .agent-config-icon { width: 38px; height: 38px; border-radius: 13px; }
.agent-config-section-top > button { margin-left: 48px; } .agent-config-section-top > button { margin-left: 48px; }
.agent-knowledge-workspace, .agent-knowledge-owned-workspace { gap: 14px; } .agent-knowledge-workspace, .agent-knowledge-owned-workspace { gap: 14px; }
.agent-knowledge-owned-workspace { grid-template-columns: minmax(0, 1fr); } .agent-knowledge-owned-workspace { grid-template-columns: minmax(0, 1fr); grid-template-rows: none; }
.agent-knowledge-upload, .agent-knowledge-management, .agent-knowledge-selection { padding: 14px; border-radius: 14px; } .agent-knowledge-upload, .agent-knowledge-management, .agent-knowledge-selection { padding: 14px; border-radius: 14px; }
} }
@media (prefers-reduced-motion: reduce) { .agent-resource-option { transition: none; } } @media (prefers-reduced-motion: reduce) { .agent-resource-option { transition: none; } }
+37 -1
View File
@@ -203,12 +203,24 @@ async function expectKnowledgeLayout(page: Page) {
const ownedBounds = await knowledge.locator('.agent-knowledge-owned-workspace').boundingBox(); const ownedBounds = await knowledge.locator('.agent-knowledge-owned-workspace').boundingBox();
expect(creationBounds).not.toBeNull(); expect(managementBounds).not.toBeNull(); expect(selectionBounds).not.toBeNull(); expect(creationBounds).not.toBeNull(); expect(managementBounds).not.toBeNull(); expect(selectionBounds).not.toBeNull();
expect(ownedBounds).not.toBeNull(); expect(ownedBounds).not.toBeNull();
if (ownedBounds!.width > 620) { if (ownedBounds!.width > 840) {
expect(creationBounds!.x + creationBounds!.width).toBeLessThanOrEqual(managementBounds!.x + 1); expect(creationBounds!.x + creationBounds!.width).toBeLessThanOrEqual(managementBounds!.x + 1);
expect(Math.abs(creationBounds!.y - managementBounds!.y)).toBeLessThanOrEqual(1); expect(Math.abs(creationBounds!.y - managementBounds!.y)).toBeLessThanOrEqual(1);
expect(Math.abs(creationBounds!.height - managementBounds!.height)).toBeLessThanOrEqual(1); expect(Math.abs(creationBounds!.height - managementBounds!.height)).toBeLessThanOrEqual(1);
expect(selectionBounds!.y).toBeGreaterThanOrEqual(Math.max(creationBounds!.y + creationBounds!.height, managementBounds!.y + managementBounds!.height)); expect(selectionBounds!.y).toBeGreaterThanOrEqual(Math.max(creationBounds!.y + creationBounds!.height, managementBounds!.y + managementBounds!.height));
expect(Math.abs(selectionBounds!.x + selectionBounds!.width - managementBounds!.x - managementBounds!.width)).toBeLessThanOrEqual(1); expect(Math.abs(selectionBounds!.x + selectionBounds!.width - managementBounds!.x - managementBounds!.width)).toBeLessThanOrEqual(1);
const create = await creation.getByRole('button', { name: '新建知识库', exact: true }).boundingBox();
const manager = await management.getByRole('combobox', { name: '管理的知识库', exact: true }).boundingBox();
const refresh = await management.getByRole('button', { name: '刷新知识库与模型', exact: true }).boundingBox();
const upload = await management.getByRole('button', { name: '从电脑上传', exact: true }).boundingBox();
const controls = await management.locator('.agent-knowledge-management-controls').boundingBox();
for (const control of [manager, refresh, upload]) {
expect(Math.abs(control!.y + control!.height / 2 - create!.y - create!.height / 2)).toBeLessThanOrEqual(1);
}
expect(Math.abs(manager!.width - (controls!.width - 144 - 8) / 2)).toBeLessThanOrEqual(2);
if (await creation.getByRole('textbox').count() === 0 && await management.getByRole('combobox', { name: '管理的知识库', exact: true }).inputValue() === '') {
expect(Math.abs(create!.y + create!.height / 2 - creationBounds!.y - creationBounds!.height / 2)).toBeLessThanOrEqual(2);
}
} else { } else {
expect(creationBounds!.y + creationBounds!.height).toBeLessThanOrEqual(managementBounds!.y + 1); expect(creationBounds!.y + creationBounds!.height).toBeLessThanOrEqual(managementBounds!.y + 1);
expect(managementBounds!.y + managementBounds!.height).toBeLessThanOrEqual(selectionBounds!.y + 1); expect(managementBounds!.y + managementBounds!.height).toBeLessThanOrEqual(selectionBounds!.y + 1);
@@ -217,6 +229,20 @@ async function expectKnowledgeLayout(page: Page) {
expect(Math.abs(creationBounds!.width - selectionBounds!.width)).toBeLessThanOrEqual(1); expect(Math.abs(creationBounds!.width - selectionBounds!.width)).toBeLessThanOrEqual(1);
} }
expect(Math.abs(creationBounds!.x - selectionBounds!.x)).toBeLessThanOrEqual(1); expect(Math.abs(creationBounds!.x - selectionBounds!.x)).toBeLessThanOrEqual(1);
const choice = await selection.getByRole('combobox', { name: '选择知识库', exact: true }).boundingBox();
const choiceRow = await selection.locator('.agent-knowledge-selection-controls').boundingBox();
if (ownedBounds!.width > 840) {
expect(Math.abs(choice!.width - (choiceRow!.width - 102) * 2 / 3)).toBeLessThanOrEqual(2);
}
const selectedList = selection.getByRole('list', { name: '已选知识库', exact: true });
if (await selectedList.count()) {
const selectedBounds = await selectedList.boundingBox();
expect(Math.abs(selectedBounds!.x - choice!.x)).toBeLessThanOrEqual(1);
expect(Math.abs(selectedBounds!.width - choice!.width)).toBeLessThanOrEqual(1);
}
for (const refresh of [management.getByRole('button', { name: '刷新知识库与模型', exact: true }), selection.getByRole('button', { name: '刷新知识库', exact: true })]) {
expect(await refresh.evaluate(element => getComputedStyle(element).borderTopWidth)).toBe('1px');
}
await expectNoClipping(page); await expectNoClipping(page);
} }
async function screenshotKnowledge(page: Page, name: string) { async function screenshotKnowledge(page: Page, name: string) {
@@ -356,6 +382,16 @@ test('cancelled and failed uploads preserve shared choices and the personal draf
// An uncertain upload response keeps its retry attached to the same library. // An uncertain upload response keeps its retry attached to the same library.
await expect(knowledge.getByRole('combobox', { name: '管理的知识库', exact: true })).toBeDisabled(); await expect(knowledge.getByRole('combobox', { name: '管理的知识库', exact: true })).toBeDisabled();
await expect(knowledge.getByRole('button', { name: '新建知识库', exact: true })).toBeDisabled(); await expect(knowledge.getByRole('button', { name: '新建知识库', exact: true })).toBeDisabled();
await page.setViewportSize({ width: 1440, height: 850 });
const managementCard = knowledge.getByRole('region', { name: '查看我的知识库', exact: true });
const managementBounds = await managementCard.boundingBox();
for (const control of await managementCard.locator('.agent-knowledge-management-controls').getByRole('button').all()) {
const bounds = await control.boundingBox();
expect(bounds!.x).toBeGreaterThanOrEqual(managementBounds!.x);
expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(managementBounds!.x + managementBounds!.width);
}
await expectNoClipping(page);
await page.setViewportSize({ width: 375, height: 850 });
const failedUpload = (await calls(page)).filter(call => call.path.endsWith('/knowledge/pick')).at(-1)!; const failedUpload = (await calls(page)).filter(call => call.path.endsWith('/knowledge/pick')).at(-1)!;
await page.evaluate(() => { (window as unknown as FixtureWindow).__knowledgeFixture.upload = 'cancel'; }); await page.evaluate(() => { (window as unknown as FixtureWindow).__knowledgeFixture.upload = 'cancel'; });
await knowledge.getByRole('button', { name: '重新选择同一文件并重试', exact: true }).click(); await knowledge.getByRole('button', { name: '重新选择同一文件并重试', exact: true }).click();
+6 -1
View File
@@ -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 === '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, 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' }], 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' } }); 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 === 'viewed') return result({ viewed: true });
if (body.operation === 'attachments') return result({ attachments: [] }); if (body.operation === 'attachments') return result({ attachments: [] });
@@ -330,6 +330,11 @@ test('personal cloud Agent identity, configuration and separate published chat i
expect(await messageOperations()).toEqual(['submit']); expect(await messageOperations()).toEqual(['submit']);
const userBubble = formalChat.locator('.agent-chat-message[data-role="user"]'); const userBubble = formalChat.locator('.agent-chat-message[data-role="user"]');
await expect(userBubble).toHaveText(/先给我一些创作建议/); 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 bubbleStyle = await userBubble.evaluate(element => {
const style = getComputedStyle(element); const style = getComputedStyle(element);
return { background: style.backgroundColor, radius: Number.parseFloat(style.borderTopLeftRadius) }; return { background: style.backgroundColor, radius: Number.parseFloat(style.borderTopLeftRadius) };
+177
View File
@@ -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> = {}): 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(<CloudChat slug="agent" />);
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(<CloudChat slug="agent" />);
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(<CloudChat slug="agent" />);
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(<CloudChat slug="agent" />);
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(<CloudChat slug="agent" />);
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(<CloudChat slug="agent" />);
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(<CloudChat slug="agent" />);
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(<CloudChat slug="agent" />);
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(<CloudChat slug="agent" />);
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(<CloudChat slug="agent" />);
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();
});