From 05d5182178e50271b4d0447d5c9da4464e848bf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Tue, 13 Oct 2026 16:40:29 +0800 Subject: [PATCH 1/6] docs: explain stale publication recovery record --- ...261013-publish-pending-explain-5dd70a31.md | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 .project-docs/30-worklog/tasks/20261013-publish-pending-explain-5dd70a31.md diff --git a/.project-docs/30-worklog/tasks/20261013-publish-pending-explain-5dd70a31.md b/.project-docs/30-worklog/tasks/20261013-publish-pending-explain-5dd70a31.md new file mode 100644 index 00000000..aecc2b25 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20261013-publish-pending-explain-5dd70a31.md @@ -0,0 +1,50 @@ +# Task: Explain pending publication record on the channel page + +## Identity + +- Task ID: 20261013-publish-pending-explain-5dd70a31 +- Mode: Feature +- Branch: codex/20261013-publish-pending-explain-5dd70a31-publish-pending-explain +- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261013-publish-pending-explain-5dd70a31 +- Base commit: 2899009215b1de8e28b84fc108e880c6e68a6098 +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Explain the screenshot's pending publication record, its appearance on the WeChat page, and retry/dismiss semantics. Read current recovery metadata and revision state if available; no business operation or source fix. + +## Intent And Constraints + +- User requests an explanation. Preserve the pending record, publication state, bindings, current UI/route/focus/runtime and credentials. Do not retry/discard/publish or change product source. + +## Project Context Loaded + +- Concurrent Gate passed: bundled start isolated this exact feature task/branch/worktree at deployed Renderer 2899009; status verified matching ownership/base. Main is unchanged 7c51bb4 / PID9968 / port58003. +- Read active record, startup and retained unchanged positioning/current-state/decision/architecture/domain/evidence context from this same project; read relevant personal-cloud Agent/recovery source and accepted domain constraints. Canonical integrated state is historical; runtime current-pointer is authoritative for actual deployment. +- Peer Scope/Intent/Promotion sections read-only. Draft-conflict, chat-repeat and WeChat UI tasks are active and have separate source/behavior scopes; their uncommitted work stays untouched. No semantic conflict for this read-only diagnosis; unknown legacy scopes have no dependency. +- Goal: explain uncertain result recovery in product language. Main owns account-scoped journal/transport and Yuxi owns immutable publication/idempotency. Relevant modules: CloudRecovery, ChannelAccounts, cloud-agents service/journal, host-api and Yuxi publish service. +- Gate result: Passed. Plan: trace receipt retention/replay/dismiss and global recovery display, then safely project only pending operation/revision/status metadata through the existing Main Host API; record findings and limitations without any publication/recovery mutation. + +## Outcome + +- The account has one pending publish record created at 2026-10-13 15:32:12 Asia/Shanghai. A safe existing Host API read maps it to 「整理小帮手」; its original expected draft revision is 5, while current draft revision is 7 and published version is 4, with the Agent enabled. It is an old publication intent, distinct from the WeChat card behind the dialog. +- Main records publication before sending and removes it only after execution/response processing succeeds. Unlike fresh Agent creation, publication retains definite rejections such as 409/422 as unknown too. This conflates known failure with uncertain result and explains why a rejected attempt can remain under generic pending copy; the journal does not store the historical error/outcome. +- Retry reuses the original operation ID and expected draft revision. Yuxi returns the same version if that operation was already accepted; otherwise it attempts that original draft and rejects a stale revision. It does not publish newer edits under the old request. Dismiss removes only the local reminder/record and does not undo saved cloud content. +- ChannelAccounts reads the entire current-account recovery journal, not only channel operations, so publication records appear on the WeChat page. The publish entry itself does not set channel recovery-blocked state. +- A read-only attempt to fetch publication/access history returned 502 cloud_service_unavailable; therefore the original request's terminal outcome and historical failure cause are not established. Do not label the actual old attempt definitively failed or successful based only on current draft/version numbers or a failing metadata read. + +## Verification + +- Inspected exact deployed client source 2899009 / immutable Main7c, current pointer and client/Yuxi journal/retry/idempotency source. Independent read-only agent review agrees with retry/dismiss/global-display semantics. +- Existing Main-owned Host API recovery and Agent GET returned success with only operation/time/revision/name/status projected. No prompt/configuration text, credential, secret, raw journal file or auth token was read or recorded. Access action maps to an upstream GET; it returned safe 502 code, with no publication/recovery/channel mutation. +- Actual Main PID9968 and current renderer pointer, route/UI and foreground were unchanged; Inspector closed after both brief read-only probes. No retry, discard, publication, real message or app update. +- No tests/build required: product source is unchanged, only this task record is added. Task-aware document drift and whitespace checks run at completion. + +## Follow-ups + +- An outcome-aware publication recovery fix should distinguish a fresh definite rejection from a previously accepted/uncertain operation, and label its Agent. Any implementation must preserve original-ID deduplication and must not automatically replay or discard this existing unknown request. It is outside the user's explanation-only request. + +## Promotion Candidates + +- None recorded. From 1a496dd4fbb70fbb6a3e2d552aca940d640eec3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Tue, 13 Oct 2026 17:01:41 +0800 Subject: [PATCH 2/6] docs: clarify publication query error attribution --- .../tasks/20261013-publish-pending-explain-5dd70a31.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.project-docs/30-worklog/tasks/20261013-publish-pending-explain-5dd70a31.md b/.project-docs/30-worklog/tasks/20261013-publish-pending-explain-5dd70a31.md index aecc2b25..287cb5be 100644 --- a/.project-docs/30-worklog/tasks/20261013-publish-pending-explain-5dd70a31.md +++ b/.project-docs/30-worklog/tasks/20261013-publish-pending-explain-5dd70a31.md @@ -48,3 +48,11 @@ ## Promotion Candidates - None recorded. + +## Follow-up: 502 attribution and repair ownership + +- Same-task ownership resumed and verified. Startup/context and current active peer Scope/Intent/Promotion sections re-read; this remains read-only diagnosis. Gate Passed. No product, cloud state, pending record or runtime update. +- Corrected earlier wording: the actual evidence is a 502 cloud_service_unavailable response from the running desktop Main Host API. It is not a verbal report from a teammate and does not by itself establish an upstream HTTP 502. +- The Main route maps non-CloudAgentsError exceptions to 502. The service also synthesizes 502 for invalid/oversized upstream data, session metadata or projection failures, while preserving status when fetchJson receives a non-success JSON response. Thus transport, parsing/projection, service response and gateway causes require further correlation; no single-party backend fault is established. +- Client repair ownership: known-failure/uncertain publication classification and reminder context are client behavior. Query availability/contract is an integration investigation involving the desktop client and Yuxi backend; the exact failing layer is still unverified. The supplied backend chat is titled YUXI-智能体后端 and was only read, not messaged. +- Verification: re-inspected route/service operation sources and the existing read-only diagnostic result. No live action retried, no raw errors or credentials inspected, and no source edits/tests/build required. From 5a8b8d75e5e3676902b305b931564493bad356ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Tue, 13 Oct 2026 17:19:36 +0800 Subject: [PATCH 3/6] fix: clarify application retry prompts --- ...261013-publish-pending-explain-5dd70a31.md | 22 ++++++++++-- README.md | 2 +- src/pages/CloudAgents/CloudAccess.tsx | 2 +- src/pages/CloudAgents/CloudRecovery.tsx | 17 +++++++--- src/pages/CloudAgents/DraftEditor.tsx | 4 +-- src/pages/CloudAgents/application-message.ts | 16 +++++++++ src/pages/CloudAgents/useCloudPublication.ts | 3 +- tests/e2e/cloud-agent-recovery.spec.ts | 34 +++++++++++++++++-- tests/unit/cloud-agent-start-use.test.tsx | 15 ++++---- tests/unit/cloud-agents-guidance.test.tsx | 25 ++++++++++++-- tests/unit/cloud-agents-page.test.tsx | 6 ++-- tests/unit/cloud-recovery.test.tsx | 33 ++++++++++++++++++ 12 files changed, 150 insertions(+), 29 deletions(-) create mode 100644 src/pages/CloudAgents/application-message.ts diff --git a/.project-docs/30-worklog/tasks/20261013-publish-pending-explain-5dd70a31.md b/.project-docs/30-worklog/tasks/20261013-publish-pending-explain-5dd70a31.md index 287cb5be..66325b22 100644 --- a/.project-docs/30-worklog/tasks/20261013-publish-pending-explain-5dd70a31.md +++ b/.project-docs/30-worklog/tasks/20261013-publish-pending-explain-5dd70a31.md @@ -8,15 +8,15 @@ - Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261013-publish-pending-explain-5dd70a31 - Base commit: 2899009215b1de8e28b84fc108e880c6e68a6098 - Owner: codex -- Status: Ready for Integration +- Status: Planning ## Scope -- Explain the screenshot's pending publication record, its appearance on the WeChat page, and retry/dismiss semantics. Read current recovery metadata and revision state if available; no business operation or source fix. +- Diagnose publication reminders and implement the latest explicitly approved simple application/retry copy. Own only relevant Renderer publication/recovery messages and focused verification. ## Intent And Constraints -- User requests an explanation. Preserve the pending record, publication state, bindings, current UI/route/focus/runtime and credentials. Do not retry/discard/publish or change product source. +- User now explicitly authorizes changing the prompt to 「网络可能不稳定,请再点一次「应用」。」. Preserve original-operation retry identity, other recovery types, field/conflict messages, real pending records, cloud data, runtime, credentials and peer source. No real apply/retry/discard/publish, backend release, merge or push. ## Project Context Loaded @@ -56,3 +56,19 @@ - The Main route maps non-CloudAgentsError exceptions to 502. The service also synthesizes 502 for invalid/oversized upstream data, session metadata or projection failures, while preserving status when fetchJson receives a non-success JSON response. Thus transport, parsing/projection, service response and gateway causes require further correlation; no single-party backend fault is established. - Client repair ownership: known-failure/uncertain publication classification and reminder context are client behavior. Query availability/contract is an integration investigation involving the desktop client and Yuxi backend; the exact failing layer is still unverified. The supplied backend chat is titled YUXI-智能体后端 and was only read, not messaged. - Verification: re-inspected route/service operation sources and the existing read-only diagnostic result. No live action retried, no raw errors or credentials inspected, and no source edits/tests/build required. + +## Implementation planning: approved simple application copy + +- Same task resumes; task ID/mode/worktree/branch/base verified. Latest user approval narrows the change to understandable application prompt/action wording; the earlier multi-part recovery redesign is not included. +- Reused unchanged project positioning, integrated snapshot, accepted personal-cloud ADR, architecture/domain and evidence/reflection/commitment context read on prior same-task turns; re-read current entry/own record and all peer Scope/Intent/Promotion sections. Integrated docs remain historical. Current runtime still Renderer289/Main7c; no fresh runtime operation is necessary for source editing. +- Active draft-conflict diagnosis is read-only; subagent-selector owns another configuration component; completed channel/recovery tasks have no semantic conflict for publish-only copy. Unknown legacy scopes have no concrete dependency. +- Project goal is a understandable Chinese personal-Agent UI; Main owns credential/transport/journal, Yuxi owns immutable published configuration. Relevant files are CloudRecovery and publication entry callbacks. Network remains a possible cause, not a confirmed diagnosis. +- Planning Gate Passed. Plan: inspect all user-visible publish errors/recovery wording and existing button retry; change only unknown publication prompt/action to the approved wording while preserving original ID/revision and explicit error causes; run existing relevant regression/type/lint/build checks, independent review, then document and commit. + +## Approved copy implementation outcome + +- Publication recovery now uses 「应用设置」, the approved message 「网络可能不稳定,请再点一次「应用」。」 and a matching 「应用」 action. In-editor/access-panel retries also say 「应用」. Successful recovered application says 「设置已应用。」 without exposing internal version JSON. +- The application hook translates only network/timeout/generic unavailable errors; explicit 4xx and definitive rejections retain their actual safe causes. Initial unknown-history publication records use the user's possible-network wording; after a record returns a concrete error, its initial speculative help is hidden and the actual error is shown. Access-state reads and other recovery types retain their current messages. +- Original operation IDs, expected revisions, journal state, explicit retry/discard and duplicate-click guards are unchanged. There are no Main/shared/backend changes or real cloud apply/retry/discard. README reflects current application copy. +- Pinned corepack pnpm10.33.4 verification: 98 unit tests across 5 focused suites passed; Renderer typecheck passed; 10 changed source/test files ESLint passed; build:vite passed. Updated browser Host-IPC fixture E2E passed (1); the 375px screenshot was reviewed and the modal fits. Tests verify original ID reuse after network failure and preserve 401/403/409/422/429 explanations. +- Compared all 66 Main/preload/utility/resources/package artifact files to immutable live Main7c: exact hash equality, with no missing/extra files. This enables a Renderer-only temporary-app update. That update is the next authorized step; current inputs and UI remain protected, with no reuse of previous one-time input-discard approvals. diff --git a/README.md b/README.md index 45f3b925..7f96cadd 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智 会话工作台采用浅蓝项目栏、彩色通用手柄图标与卡片选中态;中间对话/作品使用圆角标签,作品预览的后退、前进、刷新与网址输入统一放在标签右侧;只在作品页显示,窄窗口自动换行。右侧智能体咨询使用奶油色标题区。沿用真实项目和会话入口、侧栏伸缩与账户菜单,样式只作用于 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知识」和「我的知识库」,已选项可单独移除。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 ? '应用最新修改后,新的对话会使用这次保存的要求。已有对话保留原来的版本。' : '开始使用后,只有你自己能用。以后还可以回来修改要求。'}

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

- +

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

{access?.published_version &&
{access.enabled ? '智能体已启用' : '智能体已停用'}
{retiredChannelOperations.has(item.operation) ? - :
+ :
} )} {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/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 && } } 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/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-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-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(); From 2b2f7b96b0e2c378c706f29099ffb974dc1acdbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Tue, 13 Oct 2026 17:32:31 +0800 Subject: [PATCH 4/6] fix: preserve staged Agent configuration update --- ...261013-publish-pending-explain-5dd70a31.md | 8 +++ README.md | 2 +- src/pages/CloudAgents/CloudResources.tsx | 2 +- src/pages/CloudAgents/ConfigurationFields.tsx | 3 +- tests/e2e/cloud-agents.spec.ts | 6 ++- .../cloud-agent-subagent-selector.test.tsx | 52 +++++++++++++++++++ 6 files changed, 67 insertions(+), 6 deletions(-) create mode 100644 tests/unit/cloud-agent-subagent-selector.test.tsx diff --git a/.project-docs/30-worklog/tasks/20261013-publish-pending-explain-5dd70a31.md b/.project-docs/30-worklog/tasks/20261013-publish-pending-explain-5dd70a31.md index 66325b22..ce430adb 100644 --- a/.project-docs/30-worklog/tasks/20261013-publish-pending-explain-5dd70a31.md +++ b/.project-docs/30-worklog/tasks/20261013-publish-pending-explain-5dd70a31.md @@ -72,3 +72,11 @@ - Original operation IDs, expected revisions, journal state, explicit retry/discard and duplicate-click guards are unchanged. There are no Main/shared/backend changes or real cloud apply/retry/discard. README reflects current application copy. - Pinned corepack pnpm10.33.4 verification: 98 unit tests across 5 focused suites passed; Renderer typecheck passed; 10 changed source/test files ESLint passed; build:vite passed. Updated browser Host-IPC fixture E2E passed (1); the 375px screenshot was reviewed and the modal fits. Tests verify original ID reuse after network failure and preserve 401/403/409/422/429 explanations. - Compared all 66 Main/preload/utility/resources/package artifact files to immutable live Main7c: exact hash equality, with no missing/extra files. This enables a Renderer-only temporary-app update. That update is the next authorized step; current inputs and UI remain protected, with no reuse of previous one-time input-discard approvals. + +## Concurrent trial update reconciliation + +- Initial immutable stage application-message-20261013-5dd70a31 was prepared for clean source5a8b8d7. Activation was safely held first by a visible dialog, then a nonempty input; no symlink/pointer/runtime mutation was performed by this task. Later the exact predecessor guard refused because another authorized task switched the compatible file payload. +- The separate remove-subagent trial task6d9a8e32 staged source2e5411c, containing only the subagent-selector removal and supporting committed product/test/README updates; its peer Scope/Intent/Promotion were read-only and compatible. The human explicitly approved immediate refresh in that chat, but the root could not use its CUA window and left the stage as files-activated-awaiting-cua-refresh, with global pointer still289. No foreign task source/docs/stage were changed here. +- Imported only source2e5411c's committed product/test/README diff into this owned worktree, retaining our application copy and all prior knowledge/home/account updates. Relative to staged2e, owned product delta remains exactly the five application files. No copied foreign task records. +- Combined verification:99 unit tests/6 suites, typecheck, changed-imported-file ESLint, full build:vite passed. All66 Main/resources/package files again match immutable7c exactly. Initial application fixture/screenshot evidence remains applicable to unchanged application paths. +- Runtime source reconciliation will bind a fresh task-owned overlay to both old current-pointer289 and the exact staged-but-not-acknowledged2e predecessor. All input/lease/dialog/user-interaction/focus guards remain, with no input-discard or business-action authorization inferred. Peer stage remains intact; successful activation must establish new own compound-build proof rather than claiming the peer's activation was verified. diff --git a/README.md b/README.md index 7f96cadd..c3ab3c20 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ 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 的空字符串契约。 历史待确认记录收起在智能体列表标题区的「创建记录/待处理记录」入口,只有点击后才打开记录窗口;配置、聊天和创建页不再置顶这些旧提醒。记录保留智能体名称和原请求身份;应用设置的待确认记录显示“网络可能不稳定,请再点一次「应用」。”,点击「应用」继续原来的提交,成功后显示“设置已应用。”。其他记录仍可明确「再试一次」或「不再提醒」,不会自动重试或清除。当前创建失败继续在创建表单内提示;关闭记录窗口保留已收到的结果和密钥,切换配置、聊天或创建页后可返回列表继续查看。密钥须明确关闭结果后,才可处理其他记录。 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}

}

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/tests/e2e/cloud-agents.spec.ts b/tests/e2e/cloud-agents.spec.ts index 5b622356..6936d46c 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 }) => { @@ -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(); 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); +}); From f5a93c742a53a072c4ae6033c2d9a232496b5283 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Tue, 13 Oct 2026 18:13:10 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20=E5=AF=B9=E9=BD=90=E4=B8=AA=E4=BA=BA?= =?UTF-8?q?=E6=99=BA=E8=83=BD=E4=BD=93=E7=9F=A5=E8=AF=86=E5=BA=93=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E6=8E=A7=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...20261013-knowledge-card-layout-751ac3e5.md | 41 ++++ README.md | 4 +- src/pages/CloudAgents/CloudChat.tsx | 26 ++- src/pages/CloudAgents/CloudKnowledge.tsx | 34 ++-- .../CloudAgents/CloudKnowledgeDocuments.tsx | 7 +- .../CloudAgents/CloudKnowledgeSelector.tsx | 8 +- src/pages/CloudAgents/agent-chat.css | 2 + src/pages/CloudAgents/agent-config.css | 25 ++- tests/e2e/cloud-agent-knowledge.spec.ts | 38 +++- tests/e2e/cloud-agents.spec.ts | 7 +- tests/unit/cloud-chat-timestamps.test.tsx | 177 ++++++++++++++++++ 11 files changed, 338 insertions(+), 31 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20261013-knowledge-card-layout-751ac3e5.md create mode 100644 tests/unit/cloud-chat-timestamps.test.tsx diff --git a/.project-docs/30-worklog/tasks/20261013-knowledge-card-layout-751ac3e5.md b/.project-docs/30-worklog/tasks/20261013-knowledge-card-layout-751ac3e5.md new file mode 100644 index 00000000..bcdf524a --- /dev/null +++ b/.project-docs/30-worklog/tasks/20261013-knowledge-card-layout-751ac3e5.md @@ -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. diff --git a/README.md b/README.md index c3ab3c20..6d0069a8 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,11 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智 会话工作台采用浅蓝项目栏、彩色通用手柄图标与卡片选中态;中间对话/作品使用圆角标签,作品预览的后退、前进、刷新与网址输入统一放在标签右侧;只在作品页显示,窄窗口自动换行。右侧智能体咨询使用奶油色标题区。沿用真实项目和会话入口、侧栏伸缩与账户菜单,样式只作用于 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 的空字符串契约。 + 个人智能体聊天在每条提问与回答下方显示云端记录的消息时间:今天显示时分,跨日增加日期,悬停可查看完整日期与秒数;发送时间与回复时间沿用原消息记录,重开对话不改为当前时间。流式回答尚无可靠时间时显示「回复中…」,历史到达后展示原时间;缺失或无效时间显示「时间未记录」。正式对话、草稿试用和上次试用对比使用相同规则,不新增 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 只负责其他应计费模型调用的创建者词元点数账本。客户端区分目录加载中、加载失败与无可用模型,空状态提供管理员配置说明;「刷新知识库与模型」保留未提交名称,未确认的创建请求沿用原操作身份。 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/CloudKnowledge.tsx b/src/pages/CloudAgents/CloudKnowledge.tsx index 945cde7e..c07d0b5e 100644 --- a/src/pages/CloudAgents/CloudKnowledge.tsx +++ b/src/pages/CloudAgents/CloudKnowledge.tsx @@ -28,6 +28,7 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted, onCatalog, ref const [refresh, setRefresh] = useState(0); const [creating, setCreating] = useState(false); const [documentPending, setDocumentPending] = useState(false); + const [uploadTarget, setUploadTarget] = useState(null); const createIntent = useRef<{ operation_id: string; name: string; embedding_model: string } | null>(null); usePendingCloudInput(busy || documentPending || Boolean(createIntent.current) || (creating && Boolean(name.trim()))); 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 managementDisabled = disabled || busy || catalogLoading || Boolean(catalogError); const bindable = (id: string) => selectableIds === undefined || Boolean(selectableIds?.includes(id)); - const creationControls =
- + const creationDetails =
{creating &&
setName(e.target.value)} /> + - + +
+ {!selectedKnowledge && } +
+
+ {errorTarget === 'management' && error &&

{error}

} + {catalogError &&

知识库与模型目录加载失败:{catalogError}。请刷新重试。

} {documentPending && selectedKnowledge &&

有未确认的文件操作,请先在当前知识库重试或确认状态,再切换知识库。

} {!catalogLoading && !catalogError && !manageable.length &&

还没有可管理的知识库,可以新建一个保存自己的文件。MakeLore知识仅供检索。

} {selectedKnowledge && selectableIds !== undefined && !bindable(selectedKnowledge.kb_id) &&

@@ -131,12 +138,11 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted, onCatalog, ref {documentPending &&

放弃只清除本地重试请求,不会删除已保存的文件。重新进入后可确认服务器状态。

}
} - {!selectedKnowledge &&
- -
} {kbId && } +
; } diff --git a/src/pages/CloudAgents/CloudKnowledgeDocuments.tsx b/src/pages/CloudAgents/CloudKnowledgeDocuments.tsx index 0cd84b6d..48d4ad95 100644 --- a/src/pages/CloudAgents/CloudKnowledgeDocuments.tsx +++ b/src/pages/CloudAgents/CloudKnowledgeDocuments.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; import { CheckCircle2, CircleAlert, FileText, Loader2 } from 'lucide-react'; import { cloudAgentsApi } from '@/lib/cloud-agents-api'; import { Button } from '@/components/ui/button'; @@ -47,11 +48,12 @@ function documentState(file: CloudKnowledgeFile) { 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; act: (operation: () => Promise) => Promise; onDeleteKnowledge: () => void; canRead?: boolean; onPendingChange?: (pending: boolean) => void; + uploadTarget?: HTMLElement | null; }) { const [files, setFiles] = useState([]); const [pageCount, setPageCount] = useState(1); @@ -150,9 +152,10 @@ export function CloudKnowledgeDocuments({ slug, kbId, busy, act, onDeleteKnowled }); const states = files.map(documentState); const active = states.some(state => state.active) || submitting !== null; + const uploadButton = ; return
- + {uploadTarget ? createPortal(uploadButton, uploadTarget) : uploadButton}

上传后自动解析并建立索引。最大 5 MB;支持 TXT、Markdown、CSV、JSON、Word、Excel、文本 PDF。

diff --git a/src/pages/CloudAgents/CloudKnowledgeSelector.tsx b/src/pages/CloudAgents/CloudKnowledgeSelector.tsx index 59764810..74502a71 100644 --- a/src/pages/CloudAgents/CloudKnowledgeSelector.tsx +++ b/src/pages/CloudAgents/CloudKnowledgeSelector.tsx @@ -24,21 +24,21 @@ export function CloudKnowledgeSelector({ slug, options, selected, disabled, load

从 MakeLore知识和我的知识库中选择,可同时使用多个。

-
- { if (!disabled && !loading && !error && event.target.value && available.some(option => option.key === event.target.value)) add(event.target.value); }}> {shared.length > 0 && {shared.map(option => )}} {personal.length > 0 && {personal.map(option => )}} {unknown.length > 0 && {unknown.map(option => )}} - +
{selectionFull &&

已选满100个知识库,请先移除一个再选择。

} {error ?

知识库目录读取失败,请刷新重试。

: !loading && ownedIds !== null && options.every(option => ownedIds.includes(option.key)) &&

暂无可选的MakeLore知识。平台开放给你读取的知识库会出现在这里。

} {owned.error &&

我的知识库目录暂不可用,仍可选用可读取的知识;上传文件请刷新重试。

} - {selected.length > 0 ?
    + {selected.length > 0 ?
      {selected.map(id => { const option = options.find(item => item.key === id); const ownKnowledge = owned.databases?.find(item => item.kb_id === id); 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/agent-config.css b/src/pages/CloudAgents/agent-config.css index 430b1b65..41601706 100644 --- a/src/pages/CloudAgents/agent-config.css +++ b/src/pages/CloudAgents/agent-config.css @@ -43,16 +43,35 @@ .agent-config-resource-manager > details { border-radius: 14px; background: #ffffffa8; } .agent-resource-card:has(.agent-knowledge-workspace) { container-type: inline-size; } .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 { 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 h3 { font-size: 16px; font-weight: 650; line-height: 1.5; } .agent-knowledge-panel-heading svg { flex-shrink: 0; color: #607d72; } .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 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) { .agent-editor-tabs { padding: 10px 12px; } .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-section-top > button { margin-left: 48px; } .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; } } @media (prefers-reduced-motion: reduce) { .agent-resource-option { transition: none; } } diff --git a/tests/e2e/cloud-agent-knowledge.spec.ts b/tests/e2e/cloud-agent-knowledge.spec.ts index 473b07bf..5368ca7c 100644 --- a/tests/e2e/cloud-agent-knowledge.spec.ts +++ b/tests/e2e/cloud-agent-knowledge.spec.ts @@ -203,12 +203,24 @@ async function expectKnowledgeLayout(page: Page) { const ownedBounds = await knowledge.locator('.agent-knowledge-owned-workspace').boundingBox(); expect(creationBounds).not.toBeNull(); expect(managementBounds).not.toBeNull(); expect(selectionBounds).not.toBeNull(); expect(ownedBounds).not.toBeNull(); - if (ownedBounds!.width > 620) { + if (ownedBounds!.width > 840) { expect(creationBounds!.x + creationBounds!.width).toBeLessThanOrEqual(managementBounds!.x + 1); expect(Math.abs(creationBounds!.y - managementBounds!.y)).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(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 { expect(creationBounds!.y + creationBounds!.height).toBeLessThanOrEqual(managementBounds!.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!.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); } 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. await expect(knowledge.getByRole('combobox', { 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)!; await page.evaluate(() => { (window as unknown as FixtureWindow).__knowledgeFixture.upload = 'cancel'; }); await knowledge.getByRole('button', { name: '重新选择同一文件并重试', exact: true }).click(); diff --git a/tests/e2e/cloud-agents.spec.ts b/tests/e2e/cloud-agents.spec.ts index 6936d46c..35298a3c 100644 --- a/tests/e2e/cloud-agents.spec.ts +++ b/tests/e2e/cloud-agents.spec.ts @@ -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: [] }); @@ -330,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-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(); +}); From 7d5f8a500b7d1bd6d9138ed8cfa638465565bcd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Tue, 13 Oct 2026 18:16:22 +0800 Subject: [PATCH 6/6] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=E7=9F=A5?= =?UTF-8?q?=E8=AF=86=E5=BA=93=E5=B8=83=E5=B1=80=E9=AA=8C=E8=AF=81=E5=92=8C?= =?UTF-8?q?=E5=BE=85=E7=A9=BA=E9=97=B2=E5=88=B7=E6=96=B0=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tasks/20261013-knowledge-card-layout-751ac3e5.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.project-docs/30-worklog/tasks/20261013-knowledge-card-layout-751ac3e5.md b/.project-docs/30-worklog/tasks/20261013-knowledge-card-layout-751ac3e5.md index bcdf524a..34a45435 100644 --- a/.project-docs/30-worklog/tasks/20261013-knowledge-card-layout-751ac3e5.md +++ b/.project-docs/30-worklog/tasks/20261013-knowledge-card-layout-751ac3e5.md @@ -25,7 +25,8 @@ - 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. +- Source and fixture verification complete, committed asf5a93c742a53a072c4ae6033c2d9a232496b5283. Immutable file-only payload prepared and verified at`.trial-runtime/knowledge-controls-20261013-751ac3e5/overlay-manifest.json`, state`prepared`; sourcef5a93c7/predecessor64b4/source product delta and compiled normal-product gate checked. Main/preload/resources/package are byte-identical to original7c51 bootstrap, PID9968/Host58003 unchanged. +- The actual trial still shows the English Agent answering, with empty input and a stop control. No live activation, refresh or cancellation has occurred. Asked the user to stop and return to the list for updating, or retain the answer and accept source-only delivery; response pending. A silence or expired guard never authorizes interrupting this run. ## Verification