From c634425cc669f1da66e725649a386bf09661a70c 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 11:34:53 +0800 Subject: [PATCH 1/8] Improve knowledge workspace and chat file import --- .../20261013-knowledge-workspace-5ac9e2d.md | 74 ++++ README.md | 2 +- src/pages/CloudAgents/CloudKnowledge.tsx | 26 +- .../CloudKnowledgeAttachmentPicker.tsx | 280 ++++++++++++++ .../CloudAgents/CloudKnowledgeDocuments.tsx | 62 +--- .../CloudAgents/CloudKnowledgeSelector.tsx | 18 +- src/pages/CloudAgents/agent-config.css | 69 +++- tests/e2e/cloud-agent-knowledge.spec.ts | 284 ++++++++++++-- tests/unit/cloud-knowledge-import.test.tsx | 345 ++++++++++++++++++ tests/unit/cloud-knowledge-picker.test.tsx | 18 +- tests/unit/cloud-knowledge-status.test.tsx | 18 +- 11 files changed, 1078 insertions(+), 118 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20261013-knowledge-workspace-5ac9e2d.md create mode 100644 src/pages/CloudAgents/CloudKnowledgeAttachmentPicker.tsx create mode 100644 tests/unit/cloud-knowledge-import.test.tsx diff --git a/.project-docs/30-worklog/tasks/20261013-knowledge-workspace-5ac9e2d.md b/.project-docs/30-worklog/tasks/20261013-knowledge-workspace-5ac9e2d.md new file mode 100644 index 00000000..e598e29b --- /dev/null +++ b/.project-docs/30-worklog/tasks/20261013-knowledge-workspace-5ac9e2d.md @@ -0,0 +1,74 @@ +# Task: Implement split knowledge workspace and multi-file chat import then update trial + +## Identity + +- Task ID: 20261013-knowledge-workspace-5ac9e2d +- Mode: Feature +- Branch: codex/20261013-knowledge-workspace-5ac9e2d-knowledge-workspace +- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261013-knowledge-workspace-5ac9e2d +- Base commit: 23ddce11da5c4c7aa31af81213a5aafd474cdad0 +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Implement the user-approved knowledge workspace: own-file management on the left, multi-select MakeLore/my knowledge on the right, and a clearer multi-file conversation attachment picker. Verify source changes only; the latest user correction explicitly postpones any trial update. + +## Intent And Constraints + +- Current user authorization covers implementation; the latest correction explicitly says not to update the temporary application after changes. Keep current Main/login/profile, prior accepted Agent/chat UI, resource ownership, operation recovery, saved/apply semantics, primary dirty teacher record and remote main. No desktop focus changes, no backend schema/protocol change or generated-file import. Do not prepare or activate a new trial payload, refresh/restart the application, or change its pointer. Existing Main/process/payloads must remain intact. + +## Outcome + +- Implemented stable left own-file management and right grouped multi-select knowledge panels. Small windows stack in the same order; existing draft/save/apply, ownership and selected-library semantics are preserved. +- Replaced the two import dropdowns with a dialog: searchable dated chats with agent names, searchable file metadata, cross-chat selection, explicit target, per-file results and original-operation retry. Confirmed receipts are not replayed; actual ownership loss preserves pending intent while hiding the portal. +- Added read-only catalog refresh and explicit two-step local retry abandonment, retaining server files; in-flight requests cannot be abandoned. Unsupported legacy formats and oversized attachments cannot be selected. +- README reflects current branch behavior. Product source, tests and task record are isolated on this managed feature branch. No Main/shared/backend/API contract changes. +- The latest user correction overrides the initial trial-update request: no temporary application refresh, restart, new deployment payload or pointer change was performed. Retain this checkout for later review/integration/update instructions. + +## Verification + +- Pinned corepack pnpm 10.33.4; focused existing knowledge/catalog/configuration tests: 52/52 passed. New import and adapted status tests: 27/27 passed. No real cloud mutation, native picker or model request was performed. +- Final Renderer typecheck, scoped ESLint, production build:vite and git diff --check passed. Required project docs and own-task drift checks passed. +- Final headless UI fixture: 9/9 passed in 26.6s with exit 0 using the repository-pinned Playwright 1.59 Chromium 1217 Headless Shell; no system browser channel. Covered multi-select/save/reopen, shared read-only access, own uploads, cross-chat selection, original-ID failure retry, reachable footer and explicit abandonment without deletion/replay at 1440px and 375px. The static Vite fixture, HTTP server, temporary directory, esbuild service and test browser all shut down normally; no owned test processes or temporary fixture directories remain. System Chrome had previously passed all assertions but hung on Browser.close because its stdout/stderr pipes stayed open after exit; the pinned test browser completed the same suite normally. +- Inspected generated 1440px and 375px screenshots. Desktop dialog width and layered panels, small-window stacking, scrollable content and reachable footer verified. Preview evidence is under /Users/chillishark/Makelore 麦洛/.codex-evidence/knowledge-workspace-20261013-5ac9e2d. +- Independent read-only review passed for pending intent, authorized in-flight completion, stale scope isolation, confirmed import receipts and two-step local abandonment. +- Final read-only runtime baseline checks passed: PID 7459, Main/Renderer bytes, resources, dependencies, bootstrap, rollback copies, source trees and current renderer be53e44dcf444c9b62c3107b04f079e8ffeac450 are unchanged; runtime pointer SHA256 stays 0a01f8af424b0547c00219564d3f0bb56d0f31eaee40453c52c46ab1601bbacf. Primary stays 10da39341ead9e3db17ea4395d66181f6fe4433d; teacher record SHA256 remains 734f4de9ee0c6308b10ffa614c7678a2814f81d3b1e6d46135f495dbadeac9d3. Remote main advanced externally to bf39f4071fa957704521542e1909d53b0452c5a2: the prior full release verifier now rejects its old remote-equality condition after all runtime checks pass. No remote or primary code was changed by this task; later integration must reconcile the new remote head. + +## Follow-ups + +- The user explicitly deferred temporary application update. Await a later instruction before integration/deployment; retain the feature checkout and preview evidence. +- Test fixtures establish UI behavior; this task does not include a live cloud import/indexing smoke test. + +## Promotion Candidates + +- None recorded. + +## Project Context Loaded + +Task context: +- Identity above matches successful bundled start, feature ownership and task reservation. +- 26 other owners: peer Scope/Intent/Promotion records reread read-only. Teacher consensus occupies primary main; preserve it. Older child-language task has undefined scope but no dependency here. No concrete semantic conflict; current human instruction authorizes this personal knowledge interaction change. + +Read: +- AGENTS.md, new own record, read-before-planning/planning-gate and same-base context retained from the immediately preceding knowledge import review. +- Unchanged positioning, integrated state, decisions, personal cloud Agents ADR, system/module/data flow, domain/success criteria, evidence/reflection/commitment context, README and review findings at 23ddce11. Canonical memory remains read-only. +- Design skill and design-system/React guidance; use existing Makelore cream/blue/lavender and typography rather than generic search palette recommendations. +- Latest runtime durable verifier and pointer: Main7459/Host62864, committed renderer be53e44, full prior rollback/snapshot preserved. + +Relevant understanding: +- Goal: own documents can be indexed and explicitly selected alongside readable shared knowledge for personal Agents. +- Scope: left upload/manage, right grouped multi-select, source/file/target-aware picker, batch independent file operation IDs and error recovery. +- Decisions: selected knowledge alters draft only until explicit save/apply; upload/import stores documents immediately and actual indexing/chunks determine readiness; no payer-derived access. +- Modules: CloudKnowledgeSelector, CloudKnowledge, CloudKnowledgeDocuments/new import component, scoped Agent CSS and focused unit/headless UI tests. Main services/DTO remain unchanged. +- Unknowns: current runtime pointer/hash/process will be checked unchanged at completion; no real cloud write/model smoke is required for this UI update. Backend generated delivery import remains outside this task. + +Gate result: +- Passed before implementation plan. + +## Plan + +1. Split knowledge into stable left upload/manage and right selection panels; keep loading/permission/pending safeguards and responsive stack. +2. Replace chat import dropdowns with searchable chat/file picker, supported-document selection, independent per-file results and same-operation retry. +3. Run focused behavior tests, Renderer type checks, scoped lint, production build and headless size/interaction verification. +4. Commit documented result locally; verify current runtime pointer/files/process unchanged. Trial update is deferred pending a later user instruction. diff --git a/README.md b/README.md index 9447b4ef..2c3b8814 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 思考投影补丁上线,当前未部署;实时思考沿用既有事件流。头像上传、Main-owned Host API、访问隔离、费用与不可变版本规则保持;介绍留空需要配套 Yuxi DraftCreate/DraftSave 的空字符串契约。 - 高级配置的知识库支持下拉多选,分为「MakeLore知识」和「我的知识库」,已选项可单独移除。MakeLore知识来自当前账号有权读取的 Yuxi 共享目录,只显示名称与简介;移除仅取消当前智能体选用,不删除原库。我的知识库以服务端本人管理目录为准,提供「上传自己的文件」入口,可新建库或追加到已有个人库,上传后自动解析、建立索引并持续显示真实处理状态;失败可重试且保留已有可用内容。暂不可选用的个人库仍可管理文件,已有绑定保留并提示不可用;未确认请求在当前库保留同一操作身份供重试。知识库选择只改草稿,显式保存/应用后生效;个人云端执行已自动接入所选知识库的检索,无需另外勾选 knowledge-base Skill。此交互复用现有接口,无需新增后端补丁。 + 高级配置的知识库支持下拉多选,分为「MakeLore知识」和「我的知识库」,已选项可单独移除。MakeLore知识来自当前账号有权读取的 Yuxi 共享目录,只显示名称与简介;移除仅取消当前智能体选用,不删除原库。知识库区域左右分区:左侧「上传自己的文件」管理本人知识库,右侧选择「MakeLore知识」和「我的知识库」,小窗口上下排列。左侧可新建库或追加资料,提供「从电脑上传」和「从聊天选择文件」;聊天文件选择面板显示目标知识库、可搜索的来源聊天和文件名称/类型/大小,支持多选,逐文件导入并保留独立操作身份,部分失败只重试失败项;也可明确确认放弃本页重试,已保存文件仍保留。仅选本人已上传的聊天附件,聊天正文和助手生成的文件不直接入库。上传和导入后自动解析、建立索引并持续显示真实处理状态;失败可重试且保留已有可用内容。使用说明与费用以折叠说明呈现。暂不可选用的个人库仍可管理文件,已有绑定保留并提示不可用;未确认请求在当前库保留同一操作身份供重试。知识库选择只改草稿,显式保存/应用后生效;个人云端执行已自动接入所选知识库的检索,无需另外勾选 knowledge-base Skill。此交互复用现有接口,无需新增后端补丁。 云知识库的 embedding 模型只由平台管理员在 Yuxi 后台维护:在「智能体管理 → 模型供应商」启用供应商,按供应商要求填写有效的服务地址和 API Key,添加并启用 embedding 模型并填写实际向量维度;embedding 向量化费用由平台承担,不计入用户用量、词元点数或智能体费用上限;one-api 不参与 embedding 配置,Works Square 只负责其他应计费模型调用的创建者词元点数账本。客户端区分目录加载中、加载失败与无可用模型,空状态提供管理员配置说明;「刷新知识库与模型」保留未提交名称,未确认的创建请求沿用原操作身份。 diff --git a/src/pages/CloudAgents/CloudKnowledge.tsx b/src/pages/CloudAgents/CloudKnowledge.tsx index 2e95e870..a54ee14b 100644 --- a/src/pages/CloudAgents/CloudKnowledge.tsx +++ b/src/pages/CloudAgents/CloudKnowledge.tsx @@ -1,4 +1,5 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useId, useRef, useState } from 'react'; +import { Upload } from 'lucide-react'; import { cloudAgentsApi } from '@/lib/cloud-agents-api'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -27,6 +28,7 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted, onSelected, on const [refresh, setRefresh] = useState(0); const [creating, setCreating] = useState(false); const [documentPending, setDocumentPending] = useState(false); + const targetId = useId(); 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); @@ -62,15 +64,15 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted, onSelected, on 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)); - return
- 上传自己的文件 -
-

选择或新建自己的知识库,再上传文件。选用只会加入当前配置,保存并应用后生效。

-

上传到知识库的文档可供获准使用此智能体的人检索。解析并建立索引后才会生效;embedding 向量化由平台承担,不计入用户用量、词元点数或智能体费用上限。知识处理如触发对话模型调用,仍按原模型实际用量计费。

+ return
+
+
+

选择或新建自己的知识库,再从电脑或聊天中添加资料。选用会加入右侧配置,保存并应用后生效。

+ {error &&

{error}

} {catalogError &&

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

}
- setThreadQuery(event.target.value)} />
+
+ {threadsLoading ?

正在读取对话…

: threadError ?

对话读取失败:{threadError}

+ : !threads.length ?

还没有个人智能体对话。

: !visibleThreads.length ?

没有匹配的对话。

: visibleThreads.map(thread => )} +
+
+
+

{currentThread?.title || '选择文件'}

+
+
+ {!threadId ?

先选择一段对话,再挑选其中的文件。

: filesLoading ?

正在读取文件…

+ : fileError ?

文件读取失败:{fileError}

+ : !files.items.length ?

这段对话还没有上传文件。

: !visibleFiles.length ?

没有匹配的文件。

: visibleFiles.map(file => { + const details = fileDetails(file), key = JSON.stringify([threadId, file.file_id]); + return ; + })} +
+
+
} + {selected.length > 0 &&

已选 {selected.length} 个文件{locked ? '。' : ',可继续切换对话选择。'}

+
    {selected.map(item =>
  • {item.file.file_name}{item.threadTitle} +
  • )}
+
} + {jobs.length > 0 && } + {abandoning &&
+

这只会结束当前页面的重试。已保存的文件会保留,可刷新知识库确认。

+
+
} + +
+

最大 5 MB;支持文本、Word、Excel、文本 PDF。导入后自动处理,可用于回答后才能检索。

+ {onRefreshKnowledge && } + {unresolved && !running && !abandoning && } + +
+ {completed > 0 &&

已导入 {completed} 个文件,处理状态见知识库。

} + ; +} diff --git a/src/pages/CloudAgents/CloudKnowledgeDocuments.tsx b/src/pages/CloudAgents/CloudKnowledgeDocuments.tsx index 14e0ac64..1a778f84 100644 --- a/src/pages/CloudAgents/CloudKnowledgeDocuments.tsx +++ b/src/pages/CloudAgents/CloudKnowledgeDocuments.tsx @@ -3,7 +3,8 @@ import { CheckCircle2, CircleAlert, FileText, Loader2 } from 'lucide-react'; import { cloudAgentsApi } from '@/lib/cloud-agents-api'; import { Button } from '@/components/ui/button'; import { usePendingCloudInput } from './CloudPending'; -import type { CloudKnowledgeFile, CloudThread, CloudAttachment } from '../../../shared/cloud-agents'; +import { CloudKnowledgeAttachmentPicker } from './CloudKnowledgeAttachmentPicker'; +import type { CloudKnowledgeFile } from '../../../shared/cloud-agents'; function documentState(file: CloudKnowledgeFile) { const task = file.processing_task; @@ -47,11 +48,11 @@ function documentState(file: CloudKnowledgeFile) { return { label: '状态待确认', detail: '请刷新状态后再操作。', stage: 0 }; } -export function CloudKnowledgeDocuments({ slug, kbId, busy, act, onDeleteKnowledge, canRead = true, onPendingChange }: { - slug: string; kbId: string; busy: boolean; +export function CloudKnowledgeDocuments({ slug, kbId, kbName = '当前知识库', busy, act, onDeleteKnowledge, canRead = true, onPendingChange, onRefreshKnowledge }: { + slug: string; kbId: string; kbName?: string; busy: boolean; act: (operation: () => Promise) => Promise; onDeleteKnowledge: () => void; - canRead?: boolean; onPendingChange?: (pending: boolean) => void; + canRead?: boolean; onPendingChange?: (pending: boolean) => void; onRefreshKnowledge?: () => void; }) { const [files, setFiles] = useState([]); const [pageCount, setPageCount] = useState(1); @@ -154,14 +155,14 @@ export function CloudKnowledgeDocuments({ slug, kbId, busy, act, onDeleteKnowled const active = states.some(state => state.active) || submitting !== null; return
- - + +

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

{uploading &&

{uploading}

} - {importing && { setImporting(false); await acceptFile(file); }} />} + {importing && setImporting(false)} onRefreshKnowledge={onRefreshKnowledge} />}
{files.length ? '已显示 ' + files.length + ' 份文档 · ' + states.filter(state => state.ready).length + ' 份可用' : '知识文档'} {active ? '处理中 · 状态自动更新' : '状态自动更新'} @@ -215,52 +216,11 @@ export function CloudKnowledgeDocuments({ slug, kbId, busy, act, onDeleteKnowled {deleting &&

{deleting === 'database' ? '删除整个知识库及其文档、索引?所有使用此库的智能体将无法继续检索这些内容。' : '删除文档“' + deleting.name + '”及其索引?'}

}
; } - -function AttachmentImport({ slug, kbId, busy, act, onImported, onPendingChange }: { - slug: string; kbId: string; busy: boolean; act: (operation: () => Promise) => Promise; - onImported: (file: CloudKnowledgeFile) => Promise; - onPendingChange: (pending: boolean) => void; -}) { - const [threads, setThreads] = useState([]); - const [thread, setThread] = useState(''); - const [files, setFiles] = useState([]); - const [file, setFile] = useState(''); - const [error, setError] = useState(''); - const operation = useRef(null); - const unresolved = Boolean(operation.current); - useEffect(() => { onPendingChange(unresolved); return () => onPendingChange(false); }, [onPendingChange, unresolved]); - usePendingCloudInput(busy || Boolean(operation.current)); - useEffect(() => { - let live = true; - const load = async () => { - let page = await cloudAgentsApi.call('threads', {}); - while (page.next_offset !== null) { const next = await cloudAgentsApi.call('threads', { offset: page.next_offset }); page = { ...next, threads: [...page.threads, ...next.threads] }; } - if (live) setThreads(page.threads); - }; - void load().catch(e => { if (live) setError(e instanceof Error ? e.message : '对话读取失败'); }); - return () => { live = false; }; - }, []); - useEffect(() => { - let live = true; setFiles([]); setFile(''); - if (thread) cloudAgentsApi.call('attachments', { thread_id: thread }).then(page => { if (live) setFiles(page.attachments); }).catch(e => { if (live) setError(e instanceof Error ? e.message : '附件读取失败'); }); - return () => { live = false; }; - }, [thread]); - return
-

只显示你自己的对话附件。导入后自动处理,变为“可用于回答”后才能检索。

- - - - {error &&

{error}

} -
; -} diff --git a/src/pages/CloudAgents/CloudKnowledgeSelector.tsx b/src/pages/CloudAgents/CloudKnowledgeSelector.tsx index 65ce7dd9..9888fb2e 100644 --- a/src/pages/CloudAgents/CloudKnowledgeSelector.tsx +++ b/src/pages/CloudAgents/CloudKnowledgeSelector.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { X } from 'lucide-react'; +import { Library, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { CloudKnowledgePanel, type CloudOwnedKnowledgeCatalog } from './CloudKnowledge'; import type { CloudResource } from '../../../shared/cloud-agents'; @@ -16,7 +16,15 @@ export function CloudKnowledgeSelector({ slug, options, selected, disabled, load const unknown = ownedIds === null ? available : []; const selectionFull = selected.length >= 100; const add = (id: string) => { if (!selectionFull && !selected.includes(id)) onChange([...selected, id]); }; - return
+ return
+ option.key)} + onSelected={kb => { if (!loading && !error && options.some(option => option.key === kb.kb_id)) { add(kb.kb_id); onRefresh(); } }} + onDeleted={id => { onChange(selected.filter(value => value !== id)); onRefresh(); }} + onCreated={kb => { add(kb.kb_id); onRefresh(); }} /> +
+
+

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

setName(e.target.value)} /> + + {!catalogLoading && !catalogError && catalog && !catalog.models.length &&
+

暂时没有可用的向量模型。Embedding 只在 Yuxi 配置,请联系平台管理员启用后刷新;向量化费用由平台承担,不计入用户用量或智能体费用上限。

+
+ 管理员配置说明 +
    +
  1. Yuxi 后台「智能体管理 → 模型供应商」:启用供应商,按供应商要求填写有效的服务地址和 API Key。
  2. +
  3. 在同一后台添加并启用 embedding 模型,填写该模型的实际向量维度。
  4. +
  5. 配置完成后回到这里点击「刷新知识库与模型」。
  6. +
+
+
} + +
} + {documentPending && selectedKnowledge &&

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

} +
; return
-

选择或新建自己的知识库,再从电脑或聊天中添加资料。选用会加入右侧配置,保存并应用后生效。

+

选择或新建自己的知识库,再从电脑上传资料。选用会加入右侧配置,保存并应用后生效。

{error &&

{error}

} {catalogError &&

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

} @@ -85,7 +117,6 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted, onSelected, on {kb.name}{selectableIds === null ? '(选用状态待确认)' : !bindable(kb.kb_id) ? '(当前暂不可选用)' : ''} )} -
@@ -96,35 +127,6 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted, onSelected, on {selectedKnowledge && selectableIds !== undefined && !bindable(selectedKnowledge.kb_id) &&

{selectableIds === null ? '知识库选用状态待确认。' : '这个知识库当前暂不可选用。'}你仍可管理自己的文件,刷新目录后再试。

} - {creating &&
- setName(e.target.value)} /> - - {!catalogLoading && !catalogError && catalog && !catalog.models.length &&
-

暂时没有可用的向量模型。Embedding 只在 Yuxi 配置,请联系平台管理员启用后刷新;向量化费用由平台承担,不计入用户用量或智能体费用上限。

-
- 管理员配置说明 -
    -
  1. Yuxi 后台「智能体管理 → 模型供应商」:启用供应商,按供应商要求填写有效的服务地址和 API Key。
  2. -
  3. 在同一后台添加并启用 embedding 模型,填写该模型的实际向量维度。
  4. -
  5. 配置完成后回到这里点击「刷新知识库与模型」。
  6. -
-
-
} - -
} - {documentPending && selectedKnowledge &&

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

} {kbId && !selectedKnowledge && !catalogLoading && !catalogError &&

这个知识库已无法管理。{documentPending ? '未确认的操作仍保留在当前页面。' : '请重新选择自己的知识库。'}

{documentPending &&

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

}
} - {kbId && } + {!selectedKnowledge &&
+ + {creationControls} +
} + {kbId && }
; } diff --git a/src/pages/CloudAgents/CloudKnowledgeAttachmentPicker.tsx b/src/pages/CloudAgents/CloudKnowledgeAttachmentPicker.tsx deleted file mode 100644 index 88c0509c..00000000 --- a/src/pages/CloudAgents/CloudKnowledgeAttachmentPicker.tsx +++ /dev/null @@ -1,280 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import { CheckCircle2, FileText, Loader2, Search, X } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog'; -import { cloudAgentsApi } from '@/lib/cloud-agents-api'; -import { usePendingCloudInput } from './CloudPending'; -import type { CloudAttachment, CloudKnowledgeFile, CloudThread } from '../../../shared/cloud-agents'; - -type Selection = { key: string; threadId: string; threadTitle: string; file: CloudAttachment }; -type ImportJob = Selection & { - operationId: string; status: 'queued' | 'running' | 'saved' | 'failed' | 'done'; - importedFile?: CloudKnowledgeFile; error?: string; -}; - -function fileDetails(file: CloudAttachment) { - const extension = file.file_name.split('.').pop()?.toLowerCase() ?? ''; - const type = file.file_name.includes('.') ? extension.toUpperCase() : '文件'; - const size = Number.isFinite(file.file_size) - ? file.file_size < 1024 ? `${file.file_size} B` : file.file_size < 1024 * 1024 - ? `${Math.ceil(file.file_size / 1024)} KB` : `${(file.file_size / (1024 * 1024)).toFixed(1)} MB` - : '大小未知'; - const reason = file.file_size > 5 * 1024 * 1024 ? '超过 5 MB,暂不能导入' - : !/^(txt|md|csv|json|docx|xlsx|pdf)$/.test(extension) ? '此格式暂不支持知识库导入' : ''; - return { type, size, reason }; -} - -function updatedDate(value: string) { - const date = new Date(value); - return Number.isFinite(date.getTime()) ? date.toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric', year: 'numeric' }) : '更新时间未知'; -} -function errorMessage(error: unknown) { return error instanceof Error ? error.message : '请稍后重试。'; } - -export function CloudKnowledgeAttachmentPicker({ slug, kbId, kbName, busy, canRead = true, act, onImported, onPendingChange, onClose, onRefreshKnowledge }: { - slug: string; kbId: string; kbName: string; busy: boolean; canRead?: boolean; - act: (operation: () => Promise) => Promise; - onImported: (file: CloudKnowledgeFile) => Promise; - onPendingChange: (pending: boolean) => void; - onClose?: () => void; onRefreshKnowledge?: () => void; -}) { - const scope = slug + ':' + kbId; - const [threads, setThreads] = useState([]); - const [agentNames, setAgentNames] = useState>({}); - const [threadId, setThreadId] = useState(''); - const [threadQuery, setThreadQuery] = useState(''); - const [fileQuery, setFileQuery] = useState(''); - const [files, setFiles] = useState<{ threadId: string; items: CloudAttachment[] }>({ threadId: '', items: [] }); - const [threadsLoading, setThreadsLoading] = useState(true); - const [filesLoading, setFilesLoading] = useState(false); - const [threadError, setThreadError] = useState(''); - const [fileError, setFileError] = useState(''); - const [threadsRevision, setThreadsRevision] = useState(0); - const [filesRevision, setFilesRevision] = useState(0); - const [selected, setSelected] = useState([]); - const [jobs, setJobs] = useState([]); - const [running, setRunning] = useState(false); - const [completed, setCompleted] = useState(0); - const [abandoning, setAbandoning] = useState(false); - const jobsRef = useRef([]); - const runningRef = useRef(false); - const alive = useRef(true); - const activeScope = useRef(scope); - const permitted = useRef(canRead); - const callbacks = useRef({ onImported, onClose }); - useEffect(() => { callbacks.current = { onImported, onClose }; }, [onImported, onClose]); - useEffect(() => { permitted.current = canRead; }, [canRead]); - useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []); - useEffect(() => { - activeScope.current = scope; - jobsRef.current = []; runningRef.current = false; - setJobs([]); setSelected([]); setThreadId(''); setThreadQuery(''); setFileQuery(''); setCompleted(0); setRunning(false); setAbandoning(false); - }, [scope]); - const unresolved = jobs.some(job => job.status !== 'done'); - const pending = running || unresolved || selected.length > 0; - const locked = busy || !canRead || running || unresolved; - usePendingCloudInput(pending); - useEffect(() => { onPendingChange(pending); return () => onPendingChange(false); }, [pending, onPendingChange]); - - useEffect(() => { - let live = true; - setThreads([]); setThreadError(''); setThreadsLoading(canRead); - if (!canRead) return; - const load = async () => { - try { - let page = await cloudAgentsApi.call('threads', {}); - const all = [...page.threads]; - const seen = new Set(); - while (live && page.next_offset !== null) { - if (seen.has(page.next_offset)) throw new Error('对话列表读取未完成,请重试。'); - seen.add(page.next_offset); - page = await cloudAgentsApi.call('threads', { offset: page.next_offset }); - all.push(...page.threads); - } - if (live) setThreads([...new Map(all.map(thread => [thread.thread_id, thread])).values()]); - } catch (error) { if (live) setThreadError(errorMessage(error)); } - finally { if (live) setThreadsLoading(false); } - }; - void load(); - return () => { live = false; }; - }, [scope, canRead, threadsRevision]); - - useEffect(() => { - let live = true; - setAgentNames({}); - if (!canRead) return; - const names: Record = {}; - const publish = () => { if (live) setAgentNames({ ...names }); }; - // Names are supplementary: an unavailable agent directory must not hide chat files. - const ownNames = async () => { - let page = await cloudAgentsApi.list(); - const seen = new Set(); - for (;;) { - for (const agent of page.agents) names[agent.slug] = agent.name; - publish(); - if (!live || !page.next_cursor || seen.has(page.next_cursor)) return; - seen.add(page.next_cursor); - page = await cloudAgentsApi.list(page.next_cursor); - } - }; - void ownNames().catch(() => undefined); - void cloudAgentsApi.call('received', {}).then(page => { - if (!live) return; - for (const agent of page.agents) names[agent.slug] = agent.name; - publish(); - }).catch(() => undefined); - return () => { live = false; }; - }, [scope, canRead, threadsRevision]); - - useEffect(() => { - let live = true; - setFiles({ threadId, items: [] }); setFileError(''); setFilesLoading(Boolean(threadId) && canRead); - if (!threadId || !canRead) return; - cloudAgentsApi.call('attachments', { thread_id: threadId }).then(page => { - if (live) setFiles({ threadId, items: page.attachments }); - }).catch(error => { if (live) setFileError(errorMessage(error)); }) - .finally(() => { if (live) setFilesLoading(false); }); - return () => { live = false; }; - }, [scope, canRead, threadId, filesRevision]); - - const currentThread = threads.find(thread => thread.thread_id === threadId); - const visibleThreads = threads.filter(thread => `${thread.title} ${agentNames[thread.agent_slug] ?? ''}`.toLocaleLowerCase().includes(threadQuery.trim().toLocaleLowerCase())); - const visibleFiles = files.threadId === threadId ? files.items.filter(file => file.file_name.toLocaleLowerCase().includes(fileQuery.trim().toLocaleLowerCase())) : []; - const retryJobs = jobs.filter(job => job.status !== 'done'); - const failureOnly = retryJobs.length > 0 && retryJobs.every(job => job.status === 'failed'); - const selectedKeys = new Set(selected.map(item => item.key)); - - function choose(file: CloudAttachment, checked: boolean) { - if (locked || !currentThread || filesLoading || fileError || fileDetails(file).reason) return; - const key = JSON.stringify([threadId, file.file_id]); - if (jobsRef.current.length && jobsRef.current.every(job => job.status === 'done')) { jobsRef.current = []; setJobs([]); } - setCompleted(0); - setSelected(previous => checked ? [...previous.filter(item => item.key !== key), { - key, threadId, threadTitle: currentThread.title || '未命名对话', file, - }] : previous.filter(item => item.key !== key)); - } - - async function importSelected() { - if (busy || !canRead || runningRef.current) return; - const attemptScope = scope; - const current = () => alive.current && activeScope.current === attemptScope; - const initialJobs: ImportJob[] = jobsRef.current.length ? jobsRef.current : selected.map(item => ({ - ...item, operationId: crypto.randomUUID(), status: 'queued' as const, - })); - const attempts = initialJobs.filter(job => job.status !== 'done'); - if (!attempts.length) return; - const update = (key: string, value: Partial) => { - jobsRef.current = jobsRef.current.map(job => job.key === key ? { ...job, ...value } : job); - setJobs([...jobsRef.current]); - }; - jobsRef.current = initialJobs; setJobs([...initialJobs]); - runningRef.current = true; setRunning(true); setAbandoning(false); - try { - await act(async () => { - for (const job of attempts) { - if (!current() || !permitted.current) return; - update(job.key, { status: 'running', error: '' }); - try { - const file = job.importedFile ?? await cloudAgentsApi.call('importKnowledgeAttachment', { - slug, kb_id: kbId, thread_id: job.threadId, attachment_id: job.file.file_id, operation_id: job.operationId, - }); - if (!current()) return; - // A confirmed import is never repeated, even if permission changes or processing fails. - update(job.key, { status: 'saved', importedFile: file }); - if (!permitted.current) return; - try { - await callbacks.current.onImported(file); - if (!current()) return; - update(job.key, { status: 'done', error: '' }); - } catch (error) { - if (!current()) return; - update(job.key, { status: 'done', error: '文件已导入,处理未全部启动。请在知识库重试处理。' + errorMessage(error) }); - } - } catch (error) { - if (!current()) return; - update(job.key, { status: 'failed', error: '导入结果尚未确认:' + errorMessage(error) }); - } - } - if (current() && jobsRef.current.every(job => job.status === 'done')) { - setSelected([]); setCompleted(jobsRef.current.length); - if (jobsRef.current.every(job => !job.error)) { - jobsRef.current = []; setJobs([]); - callbacks.current.onClose?.(); - } - } - }); - } finally { - if (current()) { runningRef.current = false; setRunning(false); } - } - } - - const close = () => { if (!runningRef.current && !unresolved) onClose?.(); }; - return { if (!open) close(); }}> - { if (runningRef.current || unresolved) event.preventDefault(); }} - onInteractOutside={event => { if (runningRef.current || unresolved) event.preventDefault(); }}> -
-
从聊天选择文件导入到「{kbName}」
- {onClose && } -
-
-

选择你在 MakeLore Agents 聊天中上传的文件。聊天文字和助手生成的文件不会导入。

- {!canRead &&

知识库管理权限待确认,文件操作已暂停;未完成请求仍保留。

} - {canRead &&
-
-

选择对话

-
-
- {threadsLoading ?

正在读取对话…

: threadError ?

对话读取失败:{threadError}

- : !threads.length ?

还没有个人智能体对话。

: !visibleThreads.length ?

没有匹配的对话。

: visibleThreads.map(thread => )} -
-
-
-

{currentThread?.title || '选择文件'}

-
-
- {!threadId ?

先选择一段对话,再挑选其中的文件。

: filesLoading ?

正在读取文件…

- : fileError ?

文件读取失败:{fileError}

- : !files.items.length ?

这段对话还没有上传文件。

: !visibleFiles.length ?

没有匹配的文件。

: visibleFiles.map(file => { - const details = fileDetails(file), key = JSON.stringify([threadId, file.file_id]); - return ; - })} -
-
-
} - {selected.length > 0 &&

已选 {selected.length} 个文件{locked ? '。' : ',可继续切换对话选择。'}

-
    {selected.map(item =>
  • {item.file.file_name}{item.threadTitle} -
  • )}
-
} - {jobs.length > 0 &&
    {jobs.map(job =>
  • - {job.file.file_name}{job.status === 'done' ? <>已导入,处理状态见知识库 : job.status === 'running' ? '正在导入…' : job.status === 'saved' ? '已保存,等待继续处理' : job.status === 'failed' ? '等待重试' : '等待导入'} - {job.error &&

    {job.error}

    } -
  • )}
} - {abandoning &&
-

这只会结束当前页面的重试。已保存的文件会保留,可刷新知识库确认。

-
-
} -
-
-

最大 5 MB;支持文本、Word、Excel、文本 PDF。导入后自动处理,可用于回答后才能检索。

- {onRefreshKnowledge && } - {unresolved && !running && !abandoning && } - -
- {completed > 0 &&

已导入 {completed} 个文件,处理状态见知识库。

} -
; -} diff --git a/src/pages/CloudAgents/CloudKnowledgeDocuments.tsx b/src/pages/CloudAgents/CloudKnowledgeDocuments.tsx index 1a778f84..9a350581 100644 --- a/src/pages/CloudAgents/CloudKnowledgeDocuments.tsx +++ b/src/pages/CloudAgents/CloudKnowledgeDocuments.tsx @@ -1,9 +1,8 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState, type ReactNode } from 'react'; import { CheckCircle2, CircleAlert, FileText, Loader2 } from 'lucide-react'; import { cloudAgentsApi } from '@/lib/cloud-agents-api'; import { Button } from '@/components/ui/button'; import { usePendingCloudInput } from './CloudPending'; -import { CloudKnowledgeAttachmentPicker } from './CloudKnowledgeAttachmentPicker'; import type { CloudKnowledgeFile } from '../../../shared/cloud-agents'; function documentState(file: CloudKnowledgeFile) { @@ -48,11 +47,11 @@ function documentState(file: CloudKnowledgeFile) { return { label: '状态待确认', detail: '请刷新状态后再操作。', stage: 0 }; } -export function CloudKnowledgeDocuments({ slug, kbId, kbName = '当前知识库', busy, act, onDeleteKnowledge, canRead = true, onPendingChange, onRefreshKnowledge }: { - slug: string; kbId: string; kbName?: string; busy: boolean; +export function CloudKnowledgeDocuments({ slug, kbId, busy, act, onDeleteKnowledge, canRead = true, onPendingChange, children }: { + slug: string; kbId: string; busy: boolean; act: (operation: () => Promise) => Promise; onDeleteKnowledge: () => void; - canRead?: boolean; onPendingChange?: (pending: boolean) => void; onRefreshKnowledge?: () => void; + canRead?: boolean; onPendingChange?: (pending: boolean) => void; children?: ReactNode; }) { const [files, setFiles] = useState([]); const [pageCount, setPageCount] = useState(1); @@ -62,8 +61,6 @@ export function CloudKnowledgeDocuments({ slug, kbId, kbName = '当前知识库' const [readError, setReadError] = useState(''); const [uploading, setUploading] = useState(''); const [submitting, setSubmitting] = useState(null); - const [importing, setImporting] = useState(false); - const [importPending, setImportPending] = useState(false); const [deleting, setDeleting] = useState(null); const [processErrors, setProcessErrors] = useState>({}); const uploadIntents = useRef>({}); @@ -72,7 +69,7 @@ export function CloudKnowledgeDocuments({ slug, kbId, kbName = '当前知识库' const permitted = useRef(canRead); useEffect(() => { permitted.current = canRead; }, [canRead]); useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []); - const unresolved = Object.keys(uploadIntents.current).length > 0 || Object.keys(processIntents.current).length > 0 || importPending; + const unresolved = Object.keys(uploadIntents.current).length > 0 || Object.keys(processIntents.current).length > 0; usePendingCloudInput(unresolved); useEffect(() => { onPendingChange?.(unresolved); return () => onPendingChange?.(false); }, [onPendingChange, unresolved]); const refresh = () => setRevision(value => value + 1); @@ -156,13 +153,11 @@ export function CloudKnowledgeDocuments({ slug, kbId, kbName = '当前知识库' return
-
+ {children}

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

{uploading &&

{uploading}

} - {importing && setImporting(false)} onRefreshKnowledge={onRefreshKnowledge} />}
{files.length ? '已显示 ' + files.length + ' 份文档 · ' + states.filter(state => state.ready).length + ' 份可用' : '知识文档'} {active ? '处理中 · 状态自动更新' : '状态自动更新'} diff --git a/src/pages/CloudAgents/agent-config.css b/src/pages/CloudAgents/agent-config.css index 4995f0b9..16b6826b 100644 --- a/src/pages/CloudAgents/agent-config.css +++ b/src/pages/CloudAgents/agent-config.css @@ -55,51 +55,6 @@ .agent-knowledge-help summary:focus-visible { outline: 2px solid #9a7bb5; outline-offset: 3px; border-radius: 4px; } .agent-knowledge-selection ul { max-height: 480px; overflow: auto; overscroll-behavior: contain; } .agent-knowledge-selection button { flex-shrink: 0; } -.agent-knowledge-import.agent-knowledge-import-modal { display: flex; flex-direction: column; width: calc(100vw - 32px); max-width: 860px; max-height: calc(100dvh - 32px); gap: 18px; border-radius: 22px; padding: 24px; background: #fffdf9; color: #273246; } -.agent-knowledge-import-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; flex-shrink: 0; min-width: 0; } -.agent-knowledge-import-header > div { min-width: 0; overflow-wrap: anywhere; } -.agent-knowledge-import-header p { margin-top: 6px; line-height: 1.7; color: #59657a; } -.agent-knowledge-import-content { display: flex; flex-direction: column; gap: 16px; min-height: 0; min-width: 0; overflow: auto; overscroll-behavior: contain; } -.agent-knowledge-import-help { color: #606c7e; font-size: 13px; line-height: 1.7; overflow-wrap: anywhere; } -.agent-knowledge-import-body { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.3fr); gap: 16px; min-height: 0; min-width: 0; overflow: auto; } -.agent-knowledge-import-content > .agent-knowledge-import-body { flex-shrink: 0; } -.agent-knowledge-import-chats, .agent-knowledge-import-files { display: flex; flex-direction: column; min-width: 0; min-height: 0; gap: 10px; } -.agent-knowledge-import-chats h3, .agent-knowledge-import-files h3 { font-size: 14px; font-weight: 600; } -.agent-knowledge-import-search { display: flex; align-items: center; gap: 8px; min-width: 0; color: #606c7e; } -.agent-knowledge-import-search svg, .agent-knowledge-import-file > svg { flex-shrink: 0; } -.agent-knowledge-import :is(input:not([type="checkbox"]), select) { width: 100%; min-width: 0; min-height: 42px; border: 1px solid #cdd5e3; border-radius: 11px; background: #fff; padding: 10px 12px; font-size: 14px; } -.agent-knowledge-import :is(input, button):focus-visible { outline: 2px solid #9a7bb5; outline-offset: 2px; } -.agent-knowledge-import-chat-list, .agent-knowledge-import-file-list { min-width: 0; overflow: auto; max-height: 330px; overscroll-behavior: contain; border: 1px solid #dce2ec; border-radius: 13px; background: #fff; } -.agent-knowledge-import-chat-list > :is(p, div), .agent-knowledge-import-file-list > :is(p, div) { padding: 14px; color: #606c7e; font-size: 13px; line-height: 1.7; } -.agent-knowledge-import-chat { display: flex; flex-direction: column; gap: 5px; width: 100%; min-height: 68px; padding: 12px; text-align: left; font-size: 14px; line-height: 1.5; cursor: pointer; overflow-wrap: anywhere; transition: background-color 160ms ease; } -.agent-knowledge-import-chat + .agent-knowledge-import-chat { border-top: 1px solid #edf0f5; } -.agent-knowledge-import-chat:hover { background: #f6f5fa; } -.agent-knowledge-import-chat[aria-pressed="true"] { background: #f1f6ff; box-shadow: inset 3px 0 #6b87b3; } -.agent-knowledge-import-chat:disabled { cursor: not-allowed; } -.agent-knowledge-import-chat small { color: #606c7e; font-size: 12px; } -.agent-knowledge-import-file { display: flex; align-items: flex-start; gap: 10px; min-width: 0; min-height: 64px; padding: 12px; font-size: 14px; line-height: 1.6; cursor: pointer; overflow-wrap: anywhere; } -.agent-knowledge-import-file + .agent-knowledge-import-file { border-top: 1px solid #edf0f5; } -.agent-knowledge-import-file:has(input:checked) { background: #f6f2ff; } -.agent-knowledge-import-file:has(input:disabled) { cursor: not-allowed; } -.agent-knowledge-import-file input[type="checkbox"] { width: 17px; height: 17px; margin-top: 3px; accent-color: #79629d; flex-shrink: 0; } -.agent-knowledge-import-file > span { min-width: 0; } -.agent-knowledge-import-file small { display: block; font-size: 12px; color: #606c7e; } -.agent-knowledge-import-selection { flex-shrink: 0; min-width: 0; border-radius: 12px; background: #f3f0f8; padding: 12px; font-size: 13px; overflow-wrap: anywhere; } -.agent-knowledge-import-selection ul { max-height: 130px; overflow: auto; } -.agent-knowledge-import-selection li { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding-top: 8px; } -.agent-knowledge-import-selection li > span { min-width: 0; } -.agent-knowledge-import-selection li button { flex-shrink: 0; } -.agent-knowledge-import-selection small { display: block; color: #606c7e; font-size: 12px; } -.agent-knowledge-import-results { max-height: 170px; min-height: 0; flex-shrink: 0; overflow: auto; border: 1px solid #dce5e0; border-radius: 12px; padding: 12px; font-size: 13px; line-height: 1.7; overflow-wrap: anywhere; } -.agent-knowledge-import-results li + li { border-top: 1px solid #e8e6e1; margin-top: 10px; padding-top: 10px; } -.agent-knowledge-import-results li > span { display: flex; align-items: center; gap: 6px; } -.agent-knowledge-import-results li > span + span { color: #526c5f; } -.agent-knowledge-import-results [role="alert"] { color: #b54532; } -.agent-knowledge-import-abandon { flex-shrink: 0; padding: 12px; border: 1px solid #efd9ba; border-radius: 12px; background: #fff5e9; font-size: 13px; line-height: 1.7; } -.agent-knowledge-import-abandon p { margin-bottom: 10px; } -.agent-knowledge-import-abandon button { margin: 0 8px 6px 0; } -.agent-knowledge-import-footer { display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: 9px; flex-shrink: 0; } -.agent-knowledge-import-footer p { flex: 1 1 280px; min-width: 0; font-size: 12px; line-height: 1.7; color: #606c7e; } @container (max-width: 620px) { .agent-knowledge-workspace { grid-template-columns: minmax(0, 1fr); } } @media (max-width: 640px) { .agent-editor-tabs { padding: 10px 12px; } @@ -114,10 +69,5 @@ .agent-config-section-top > button { margin-left: 48px; } .agent-knowledge-workspace { grid-template-columns: minmax(0, 1fr); gap: 14px; } .agent-knowledge-upload, .agent-knowledge-selection { padding: 14px; border-radius: 14px; } - .agent-knowledge-import.agent-knowledge-import-modal { padding: 18px; border-radius: 18px; gap: 14px; } - .agent-knowledge-import-body { grid-template-columns: minmax(0, 1fr); } - .agent-knowledge-import-chat-list { max-height: 160px; } - .agent-knowledge-import-file-list { max-height: 240px; } - .agent-knowledge-import-footer button { width: 100%; } } -@media (prefers-reduced-motion: reduce) { .agent-resource-option, .agent-knowledge-import-chat { transition: none; } } +@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 f1007d28..1e3f83b1 100644 --- a/tests/e2e/cloud-agent-knowledge.spec.ts +++ b/tests/e2e/cloud-agent-knowledge.spec.ts @@ -5,7 +5,7 @@ import { mkdtemp, readFile, realpath, rm, symlink, writeFile } from 'node:fs/pro import { tmpdir } from 'node:os'; import { join, resolve, sep } from 'node:path'; import { createRequire } from 'node:module'; -import { EMPTY_CLOUD_CONFIGURATION, type CloudAgentDraft, type CloudKnowledge, type CloudKnowledgeFile, type CloudThread, type CloudAttachment } from '../../shared/cloud-agents'; +import { EMPTY_CLOUD_CONFIGURATION, type CloudAgentDraft, type CloudKnowledge, type CloudKnowledgeFile } from '../../shared/cloud-agents'; let server: Server; let baseUrl: string; @@ -82,37 +82,20 @@ const libraries: CloudKnowledge[] = [ { kb_id: 'kb_fixturereading', name: '阅读知识', description: '平台开放的阅读资料', embedding_model: 'fixture-embedding' }, { kb_id: 'kb_fixturepersonal', name: '我的观察记录', description: '自己的资料', embedding_model: 'fixture-embedding' }, ]; -const sourceThreads: CloudThread[] = [ - { thread_id: 'thread-spring', client_thread_id: 'client-spring', agent_slug: agent.slug, title: '春天的观察讨论', mode: 'preview', - updated_at: '2026-10-13T01:00:00Z', run_id: null, status: 'completed', unread: false }, - { thread_id: 'thread-reading', client_thread_id: 'client-reading', agent_slug: agent.slug, title: '图书阅读记录', mode: 'preview', - updated_at: '2026-10-13T00:00:00Z', run_id: null, status: 'completed', unread: false }, -]; -const sourceAttachments: Record = { - 'thread-spring': [ - { file_id: 'attachment-observations', file_name: '春天观察报告.md', file_size: 1536, path: 'attachments/spring/report.md', original_path: 'attachments/spring/report.md' }, - { file_id: 'attachment-plants', file_name: '植物清单.csv', file_size: 2048, path: 'attachments/spring/plants.csv', original_path: 'attachments/spring/plants.csv' }, - ], - 'thread-reading': [ - { file_id: 'attachment-reading', file_name: '阅读笔记.pdf', file_size: 2048, path: 'attachments/reading/notes.pdf', original_path: 'attachments/reading/notes.pdf' }, - ], -}; type FixtureCall = { path: string; method: string; input: Record | null }; type KnowledgeFixture = { agent: CloudAgentDraft; libraries: CloudKnowledge[]; files: Record; calls: FixtureCall[]; ownedIds: string[]; upload: 'success' | 'cancel' | 'fail'; processFailure: boolean; - threads: CloudThread[]; attachments: Record; importFailures: string[]; }; type FixtureWindow = Window & { __knowledgeFixture: KnowledgeFixture }; async function installFixture(page: Page) { page.on('pageerror', error => { throw error; }); - await page.addInitScript(({ agent, libraries, sourceThreads, sourceAttachments }) => { + await page.addInitScript(({ agent, libraries }) => { const scope = window as unknown as FixtureWindow; const stored = sessionStorage.getItem('knowledge-fixture'); const state: KnowledgeFixture = stored ? JSON.parse(stored) : { agent, libraries, files: {}, calls: [], ownedIds: ['kb_fixturepersonal'], upload: 'success', processFailure: false, - threads: sourceThreads, attachments: sourceAttachments, importFailures: [], }; scope.__knowledgeFixture = state; const persist = () => sessionStorage.setItem('knowledge-fixture', JSON.stringify(state)); @@ -158,19 +141,7 @@ async function installFixture(page: Page) { case 'knowledgeFiles': if (!managed(args?.kb_id)) throw new Error('Document management reached a read-only library'); return result({ files: state.files[String(args?.kb_id)] ?? [], next_offset: null }); - case 'threads': return result({ threads: state.threads, next_offset: null }); case 'received': return result({ agents: [] }); - case 'attachments': return result({ attachments: state.attachments[String(args?.thread_id)] ?? [] }); - case 'importKnowledgeAttachment': { - if (!managed(args?.kb_id)) throw new Error('Attachment import reached a read-only library'); - const attachment = state.attachments[String(args?.thread_id)]?.find(file => file.file_id === args?.attachment_id); - if (!attachment) throw new Error('Unknown fixture source attachment'); - if (state.importFailures.includes(attachment.file_id)) return failure('植物清单导入暂时失败'); - const file: CloudKnowledgeFile = { file_id: 'imported-' + attachment.file_id, name: attachment.file_name, - size: attachment.file_size, status: 'uploaded', error: null, chunk_count: 0, available: false }; - state.files[String(args?.kb_id)] = [file, ...(state.files[String(args?.kb_id)] ?? []).filter(item => item.file_id !== file.file_id)]; - return result(file); - } case 'processKnowledge': { if (!managed(args?.kb_id)) throw new Error('Processing reached a read-only library'); if (state.processFailure) return failure('处理请求暂不可用'); @@ -183,7 +154,7 @@ async function installFixture(page: Page) { } }, } } }); - }, { agent, libraries, sourceThreads, sourceAttachments }); + }, { agent, libraries }); } async function openKnowledge(page: Page) { @@ -222,22 +193,6 @@ async function expectKnowledgeLayout(page: Page, width: number) { } await expectNoClipping(page); } -async function expectPickerBounds(page: Page) { - expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); - const outside = await page.getByRole('dialog', { name: '从聊天选择文件', exact: true }).evaluate(dialog => { - const bounds = dialog.getBoundingClientRect(); - return { - bounds: { left: bounds.left, top: bounds.top, right: bounds.right, bottom: bounds.bottom }, - viewport: { width: innerWidth, height: innerHeight }, - dialogFits: bounds.left >= 0 && bounds.top >= 0 && bounds.right <= innerWidth && bounds.bottom <= innerHeight, - clipped: [...dialog.querySelectorAll('button, input, label, h2, h4')].filter(element => element.getClientRects().length > 0) - .filter(element => { const box = element.getBoundingClientRect(); return box.left < bounds.left - 1 || box.right > bounds.right + 1; }) - .map(element => element.getAttribute('aria-label') ?? element.textContent?.trim()), - }; - }); - expect(outside.dialogFits, JSON.stringify(outside)).toBe(true); expect(outside.clipped).toEqual([]); - if (outside.viewport.width >= 1024) expect(outside.bounds.right - outside.bounds.left).toBeGreaterThanOrEqual(800); -} for (const width of [375, 1440]) { test(`multiple shared/personal choices stay draft-only and survive save/reopen at ${width}px`, async ({ page }) => { @@ -259,7 +214,7 @@ for (const width of [375, 1440]) { await expect(knowledge.getByRole('heading', { name: '上传自己的文件', exact: true })).toBeVisible(); const management = knowledge.getByRole('combobox', { name: '管理的知识库', exact: true }); await expect(management.locator('option[value="kb_fixturescience"], option[value="kb_fixturereading"]')).toHaveCount(0); - await expect(knowledge.getByRole('button', { name: '从电脑上传', exact: true })).toHaveCount(0); + await expect(knowledge.getByRole('button', { name: '从电脑上传', exact: true })).toBeDisabled(); await expectKnowledgeLayout(page, width); await page.screenshot({ path: test.info().outputPath(`knowledge-shared-${width}.png`), animations: 'disabled' }); await editor.getByRole('button', { name: '保存草稿', exact: true }).click(); await expect(editor.getByRole('button', { name: '已保存', exact: true })).toBeVisible(); @@ -276,12 +231,25 @@ for (const width of [375, 1440]) { await knowledge.getByRole('combobox', { name: '选择知识库', exact: true }).selectOption('kb_fixturescience'); await expect(knowledge.getByRole('heading', { name: '上传自己的文件', exact: true })).toBeVisible(); expect((await calls(page)).filter(call => call.path.endsWith('/knowledge/pick') || ['createKnowledge', 'processKnowledge'].includes(String(call.input?.operation)))).toEqual([]); - await knowledge.getByRole('button', { name: '新建知识库', exact: true }).click(); + const upload = knowledge.getByRole('button', { name: '从电脑上传', exact: true }); + const create = knowledge.getByRole('button', { name: '新建知识库', exact: true }); + await expect(upload).toBeDisabled(); + const uploadBounds = await upload.boundingBox(); + const createBounds = await create.boundingBox(); + expect(uploadBounds).not.toBeNull(); expect(createBounds).not.toBeNull(); + expect(createBounds!.y).toBeGreaterThan(uploadBounds!.y + uploadBounds!.height); + await create.click(); await knowledge.getByRole('textbox', { name: '知识库名称', exact: true }).fill('我的春天资料'); await knowledge.getByRole('combobox', { name: '向量模型', exact: true }).selectOption('fixture-embedding'); await knowledge.getByRole('button', { name: '创建并选用', exact: true }).click(); await expect(knowledge.getByRole('combobox', { name: '管理的知识库', exact: true })).toHaveValue('kb_fixturecreated'); await expect(knowledge.getByRole('list', { name: '已选知识库', exact: true })).toContainText('我的春天资料'); + await expect(upload).toBeEnabled(); + const selectedUploadBounds = await upload.boundingBox(); + const selectedCreateBounds = await create.boundingBox(); + expect(selectedUploadBounds).not.toBeNull(); expect(selectedCreateBounds).not.toBeNull(); + expect(selectedCreateBounds!.y).toBeGreaterThan(selectedUploadBounds!.y + selectedUploadBounds!.height); + await expect(knowledge.getByRole('button', { name: '从聊天选择文件', exact: true })).toHaveCount(0); expect((await calls(page)).filter(call => call.path.endsWith('/knowledge/pick') || call.input?.operation === 'processKnowledge')).toEqual([]); await knowledge.getByRole('button', { name: '从电脑上传', exact: true }).click(); const document = knowledge.getByRole('article', { name: '春天观察记录.md', exact: true }); @@ -306,52 +274,6 @@ for (const width of [375, 1440]) { expect(await page.evaluate(() => (window as unknown as FixtureWindow).__knowledgeFixture.agent.configuration.knowledges)) .toEqual(['kb_fixturescience', 'kb_fixturecreated']); }); - - test(`chat files show their source and metadata and import multiple choices into the named own library at ${width}px`, async ({ page }) => { - await page.setViewportSize({ width, height: 850 }); await installFixture(page); await page.goto(baseUrl); - const { knowledge } = await openKnowledge(page); - await knowledge.getByRole('combobox', { name: '选择知识库', exact: true }).selectOption('kb_fixturescience'); - await knowledge.getByRole('combobox', { name: '管理的知识库', exact: true }).selectOption('kb_fixturepersonal'); - await knowledge.getByRole('button', { name: '从聊天选择文件', exact: true }).click(); - const picker = page.getByRole('dialog', { name: '从聊天选择文件', exact: true }); - await expect(picker).toContainText('导入到「我的观察记录」'); - await picker.getByRole('textbox', { name: '搜索对话', exact: true }).fill('春天'); - await expect(picker.getByRole('button', { name: '选择对话 图书阅读记录', exact: true })).toHaveCount(0); - await picker.getByRole('button', { name: '选择对话 春天的观察讨论', exact: true }).click(); - await expect(picker.getByRole('checkbox', { name: '选择文件 春天观察报告.md', exact: true })).toBeVisible(); - await expect(picker.locator('.agent-knowledge-import-file').filter({ has: page.getByRole('checkbox', { name: '选择文件 春天观察报告.md', exact: true }) })) - .toContainText(/(?:MD|Markdown)\s*·\s*(?:1\.5|2)\s*KB/i); - await picker.getByRole('textbox', { name: '搜索文件', exact: true }).fill('植物'); - await expect(picker.getByRole('checkbox', { name: '选择文件 春天观察报告.md', exact: true })).toHaveCount(0); - await picker.getByRole('checkbox', { name: '选择文件 植物清单.csv', exact: true }).check(); - await expect(picker.locator('.agent-knowledge-import-file').filter({ has: page.getByRole('checkbox', { name: '选择文件 植物清单.csv', exact: true }) })) - .toContainText(/CSV\s*·\s*2\s*KB/); - await picker.getByRole('textbox', { name: '搜索文件', exact: true }).fill(''); - await picker.getByRole('checkbox', { name: '选择文件 春天观察报告.md', exact: true }).check(); - await picker.getByRole('textbox', { name: '搜索对话', exact: true }).fill('图书'); - await picker.getByRole('button', { name: '选择对话 图书阅读记录', exact: true }).click(); - await picker.getByRole('checkbox', { name: '选择文件 阅读笔记.pdf', exact: true }).check(); - const confirm = picker.getByRole('button', { name: '导入 3 个文件', exact: true }); - await expect(confirm).toBeEnabled(); - await expect(confirm).toBeInViewport(); - expect((await calls(page)).filter(call => ['importKnowledgeAttachment', 'processKnowledge'].includes(String(call.input?.operation)))).toEqual([]); - await expectPickerBounds(page); - await page.screenshot({ path: test.info().outputPath(`knowledge-chat-picker-${width}.png`), animations: 'disabled' }); - await confirm.click(); - await expect(picker).toHaveCount(0); - for (const name of ['春天观察报告.md', '植物清单.csv', '阅读笔记.pdf']) { - await expect(knowledge.getByRole('article', { name, exact: true })).toBeVisible(); - } - const imports = (await calls(page)).filter(call => call.input?.operation === 'importKnowledgeAttachment'); - expect(imports).toHaveLength(3); - expect(imports.map(call => (call.input?.input as { attachment_id: string }).attachment_id).sort()) - .toEqual(['attachment-observations', 'attachment-plants', 'attachment-reading']); - expect(imports.every(call => (call.input?.input as { kb_id: string }).kb_id === 'kb_fixturepersonal')).toBe(true); - expect((await calls(page)).filter(call => call.input?.operation === 'processKnowledge')).toHaveLength(3); - expect((await calls(page)).filter(call => call.method === 'PATCH')).toEqual([]); - await expect(knowledge.getByRole('list', { name: '已选知识库', exact: true })).toContainText('科学知识'); - await expectKnowledgeLayout(page, width); - }); } test('cancelled and failed uploads preserve shared choices and the personal draft', async ({ page }) => { @@ -389,93 +311,3 @@ test('cancelled and failed uploads preserve shared choices and the personal draf await editor.getByRole('tab', { name: '介绍与提示词', exact: true }).click(); await expect(editor.getByRole('textbox', { name: '提示词', exact: true })).toHaveValue('这是我还没有保存的提示词。'); }); - -test('a partial chat-file import retries only the failed file with the same operation identity', async ({ page }) => { - await page.setViewportSize({ width: 375, height: 850 }); await installFixture(page); await page.goto(baseUrl); - const { knowledge } = await openKnowledge(page); - await knowledge.getByRole('combobox', { name: '选择知识库', exact: true }).selectOption('kb_fixturescience'); - await knowledge.getByRole('combobox', { name: '管理的知识库', exact: true }).selectOption('kb_fixturepersonal'); - await page.evaluate(() => { (window as unknown as FixtureWindow).__knowledgeFixture.importFailures = ['attachment-plants']; }); - await knowledge.getByRole('button', { name: '从聊天选择文件', exact: true }).click(); - const picker = page.getByRole('dialog', { name: '从聊天选择文件', exact: true }); - await picker.getByRole('button', { name: '选择对话 春天的观察讨论', exact: true }).click(); - await picker.getByRole('checkbox', { name: '选择文件 春天观察报告.md', exact: true }).check(); - await picker.getByRole('checkbox', { name: '选择文件 植物清单.csv', exact: true }).check(); - const confirm = picker.getByRole('button', { name: '导入 2 个文件', exact: true }); - await expect(confirm).toBeInViewport(); - await confirm.click({ timeout: 15_000 }); - await expect(picker).toContainText('植物清单导入暂时失败'); - const retry = picker.getByRole('button', { name: '重试失败的 1 个文件', exact: true }); - await expect(retry).toBeEnabled(); - await expect(retry).toBeInViewport(); - await expect(picker.getByRole('button', { name: '关闭文件选择', exact: true })).toBeDisabled(); - await page.keyboard.press('Escape'); - await expect(picker).toBeVisible(); - const originalImports = (await calls(page)).filter(call => call.input?.operation === 'importKnowledgeAttachment'); - expect(originalImports).toHaveLength(2); - await expect(picker.getByRole('list', { name: '文件导入结果', exact: true }).getByRole('listitem').filter({ hasText: '春天观察报告.md' })) - .toContainText('已导入,处理状态见知识库'); - await expectPickerBounds(page); - await page.screenshot({ path: test.info().outputPath('knowledge-chat-partial-failure-375.png'), animations: 'disabled' }); - await page.evaluate(() => { (window as unknown as FixtureWindow).__knowledgeFixture.importFailures = []; }); - await retry.click(); await expect(picker).toHaveCount(0); - await expect(knowledge.getByRole('article', { name: '春天观察报告.md', exact: true })).toBeVisible(); - await expect(knowledge.getByRole('article', { name: '植物清单.csv', exact: true })).toBeVisible(); - const imported = (await calls(page)).filter(call => call.input?.operation === 'importKnowledgeAttachment') - .map(call => call.input?.input as { attachment_id: string; operation_id: string; kb_id: string }); - expect(imported.filter(input => input.attachment_id === 'attachment-observations')).toHaveLength(1); - const retried = imported.filter(input => input.attachment_id === 'attachment-plants'); - expect(retried).toHaveLength(2); expect(new Set(retried.map(input => input.operation_id)).size).toBe(1); - expect(imported.every(input => input.kb_id === 'kb_fixturepersonal')).toBe(true); - const processed = (await calls(page)).filter(call => call.input?.operation === 'processKnowledge') - .map(call => (call.input?.input as { file_id: string }).file_id); - expect(processed.sort()).toEqual(['imported-attachment-observations', 'imported-attachment-plants']); - expect((await calls(page)).filter(call => call.method === 'PATCH')).toEqual([]); - await expect(knowledge.getByRole('list', { name: '已选知识库', exact: true })).toContainText('科学知识'); - await expectNoClipping(page); -}); - -test('abandoning a failed batch keeps saved files and allows a fresh picker operation without replaying successes', async ({ page }) => { - await page.setViewportSize({ width: 375, height: 850 }); await installFixture(page); await page.goto(baseUrl); - const { knowledge } = await openKnowledge(page); - await knowledge.getByRole('combobox', { name: '选择知识库', exact: true }).selectOption('kb_fixturescience'); - await knowledge.getByRole('combobox', { name: '管理的知识库', exact: true }).selectOption('kb_fixturepersonal'); - await page.evaluate(() => { (window as unknown as FixtureWindow).__knowledgeFixture.importFailures = ['attachment-plants']; }); - await knowledge.getByRole('button', { name: '从聊天选择文件', exact: true }).click(); - const picker = page.getByRole('dialog', { name: '从聊天选择文件', exact: true }); - await picker.getByRole('button', { name: '选择对话 春天的观察讨论', exact: true }).click(); - await picker.getByRole('checkbox', { name: '选择文件 春天观察报告.md', exact: true }).check(); - await picker.getByRole('checkbox', { name: '选择文件 植物清单.csv', exact: true }).check(); - await expect(picker.getByRole('button', { name: '导入 2 个文件', exact: true })).toBeInViewport(); - await picker.getByRole('button', { name: '导入 2 个文件', exact: true }).click({ timeout: 15_000 }); - await expect(picker.getByRole('button', { name: '重试失败的 1 个文件', exact: true })).toBeEnabled(); - const importsBefore = (await calls(page)).filter(call => call.input?.operation === 'importKnowledgeAttachment'); - await picker.getByRole('button', { name: '放弃重试', exact: true }).click(); - await expect(picker).toContainText('已保存的文件会保留'); - const abandon = picker.getByRole('button', { name: '确认放弃重试', exact: true }); - await abandon.scrollIntoViewIfNeeded(); await expect(abandon).toBeInViewport(); - await expectPickerBounds(page); - await page.screenshot({ path: test.info().outputPath('knowledge-chat-abandon-confirm-375.png'), animations: 'disabled' }); - await abandon.click(); await expect(picker).toHaveCount(0); - expect((await calls(page)).filter(call => call.input?.operation === 'importKnowledgeAttachment')).toEqual(importsBefore); - expect((await calls(page)).filter(call => /delete/i.test(String(call.input?.operation)) || call.method === 'DELETE')).toEqual([]); - await expect(knowledge.getByRole('article', { name: '春天观察报告.md', exact: true })).toBeVisible(); - await expect(knowledge.getByRole('combobox', { name: '管理的知识库', exact: true })).toBeEnabled(); - await page.evaluate(() => { (window as unknown as FixtureWindow).__knowledgeFixture.importFailures = []; }); - await knowledge.getByRole('button', { name: '从聊天选择文件', exact: true }).click(); - await picker.getByRole('button', { name: '选择对话 春天的观察讨论', exact: true }).click(); - await expect(picker.getByRole('checkbox', { name: '选择文件 春天观察报告.md', exact: true })).not.toBeChecked(); - await picker.getByRole('checkbox', { name: '选择文件 植物清单.csv', exact: true }).check(); - await picker.getByRole('button', { name: '导入 1 个文件', exact: true }).click(); - await expect(picker).toHaveCount(0); - const imports = (await calls(page)).filter(call => call.input?.operation === 'importKnowledgeAttachment') - .map(call => call.input?.input as { attachment_id: string; operation_id: string }); - expect(imports.filter(input => input.attachment_id === 'attachment-observations')).toHaveLength(1); - const plants = imports.filter(input => input.attachment_id === 'attachment-plants'); - expect(plants).toHaveLength(2); expect(new Set(plants.map(input => input.operation_id)).size).toBe(2); - const processed = (await calls(page)).filter(call => call.input?.operation === 'processKnowledge') - .map(call => (call.input?.input as { file_id: string }).file_id); - expect(processed.sort()).toEqual(['imported-attachment-observations', 'imported-attachment-plants']); - await expect(knowledge.getByRole('list', { name: '已选知识库', exact: true })).toContainText('科学知识'); - await expectNoClipping(page); -}); diff --git a/tests/unit/cloud-knowledge-catalog.test.tsx b/tests/unit/cloud-knowledge-catalog.test.tsx index 0c740867..38c4073a 100644 --- a/tests/unit/cloud-knowledge-catalog.test.tsx +++ b/tests/unit/cloud-knowledge-catalog.test.tsx @@ -20,6 +20,7 @@ function openCreation() { it('explains who configures an empty embedding catalog and refreshes without losing the name', async () => { api.call.mockResolvedValueOnce(empty).mockResolvedValueOnce(available); openCreation(); + expect(screen.getByRole('button', { name: '从电脑上传', exact: true })).toBeDisabled(); expect(await screen.findByText('暂时没有可用的向量模型。Embedding 只在 Yuxi 配置,请联系平台管理员启用后刷新;向量化费用由平台承担,不计入用户用量或智能体费用上限。')).toBeVisible(); expect(screen.getByRole('combobox', { name: '向量模型' })).toBeDisabled(); expect(screen.getByText(/上传到知识库的文档可供/)).toHaveTextContent('embedding 向量化由平台承担,不计入用户用量、词元点数或智能体费用上限'); diff --git a/tests/unit/cloud-knowledge-import.test.tsx b/tests/unit/cloud-knowledge-import.test.tsx deleted file mode 100644 index 8b93b62a..00000000 --- a/tests/unit/cloud-knowledge-import.test.tsx +++ /dev/null @@ -1,345 +0,0 @@ -import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; -import { afterEach, beforeEach, expect, it, vi } from 'vitest'; -import { CloudKnowledgeAttachmentPicker } from '@/pages/CloudAgents/CloudKnowledgeAttachmentPicker'; -import { CloudKnowledgePanel } from '@/pages/CloudAgents/CloudKnowledge'; -import type { CloudAttachment, CloudKnowledgeFile, CloudThread } from '../../shared/cloud-agents'; - -const api = vi.hoisted(() => ({ call: vi.fn(), list: vi.fn() })); -vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api })); -const slug = 'ml-' + 'a'.repeat(32), otherSlug = 'ml-' + 'b'.repeat(32); -const threads: CloudThread[] = [ - { thread_id: 'spring', title: '春天的观察讨论', agent_slug: slug, client_thread_id: null, updated_at: '2026-10-13T00:00:00Z', mode: 'published', run_id: null, status: 'completed', unread: false }, - { thread_id: 'reading', title: '图书阅读记录', agent_slug: otherSlug, client_thread_id: null, updated_at: '2026-10-12T00:00:00Z', mode: 'preview', run_id: null, status: 'completed', unread: false }, -]; -function attachment(id: string, name: string, size = 1536): CloudAttachment { - return { file_id: id, file_name: name, file_size: size, path: '/attachments/' + name, original_path: '/attachments/' + name }; -} -function knowledgeFile(id: string): CloudKnowledgeFile { - return { file_id: 'knowledge-' + id, name: id + '.txt', size: 1536, status: 'uploaded', available: false, chunk_count: 0, error: null }; -} -let sourceFiles: Record; -const imported = vi.fn<(file: CloudKnowledgeFile) => Promise>(); -const pending = vi.fn(); -const close = vi.fn(); -const props = { slug, kbId: 'kb_own', kbName: '我的观察记录', busy: false, act: async (task: () => Promise) => task(), onImported: imported, onPendingChange: pending, onClose: close }; - -beforeEach(() => { - vi.resetAllMocks(); imported.mockResolvedValue(); - sourceFiles = { spring: [attachment('a', '观察报告.md'), attachment('b', '植物资料.pdf')], reading: [attachment('a', '阅读笔记.txt')] }; - api.list.mockResolvedValue({ agents: [{ slug, name: '资料小助手' }], next_cursor: null }); - api.call.mockImplementation(async (operation: string, input: { thread_id?: string; attachment_id?: string }) => { - if (operation === 'threads') return { threads, next_offset: null }; - if (operation === 'received') return { agents: [{ slug: otherSlug, name: '阅读搭档' }] }; - if (operation === 'attachments') return { attachments: sourceFiles[input.thread_id!] }; - if (operation === 'importKnowledgeAttachment') return knowledgeFile(input.attachment_id!); - throw new Error('Unexpected operation ' + operation); - }); -}); -afterEach(cleanup); -async function chooseChat(title = threads[0].title) { - fireEvent.click(await screen.findByRole('button', { name: '选择对话 ' + title })); -} -async function chooseFile(name: string) { fireEvent.click(await screen.findByRole('checkbox', { name: '选择文件 ' + name })); } -function imports() { return api.call.mock.calls.filter(([operation]) => operation === 'importKnowledgeAttachment').map(([, input]) => input); } -function deferred() { - let resolve!: (value: T) => void; - let reject!: (error: Error) => void; - const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); - return { promise, resolve, reject }; -} - -it('searches named and dated chats, keeps selections across chats, and imports only explicitly selected attachments', async () => { - const original = api.call.getMockImplementation()!; - api.call.mockImplementation((operation, input) => operation === 'threads' - ? Promise.resolve(input.offset ? { threads: [threads[1]], next_offset: null } : { threads: [threads[0]], next_offset: 50 }) : original(operation, input)); - render(); - expect(screen.getByRole('dialog', { name: '从聊天选择文件' })).toHaveTextContent('导入到「我的观察记录」'); - await screen.findByText(/阅读搭档 · 2026/); - fireEvent.change(screen.getByRole('textbox', { name: '搜索对话' }), { target: { value: '资料小助手' } }); - expect(screen.queryByRole('button', { name: '选择对话 图书阅读记录' })).not.toBeInTheDocument(); - await chooseChat(); - expect(await screen.findByText('MD · 2 KB')).toBeVisible(); - fireEvent.change(screen.getByRole('textbox', { name: '搜索文件' }), { target: { value: '观察' } }); - expect(screen.queryByRole('checkbox', { name: '选择文件 植物资料.pdf' })).not.toBeInTheDocument(); - await chooseFile('观察报告.md'); - fireEvent.change(screen.getByRole('textbox', { name: '搜索对话' }), { target: { value: '阅读' } }); - await chooseChat(threads[1].title); await chooseFile('阅读笔记.txt'); - expect(within(screen.getByRole('list', { name: '已选聊天文件' })).getAllByRole('listitem')).toHaveLength(2); - expect(imports()).toHaveLength(0); - fireEvent.click(screen.getByRole('button', { name: '导入 2 个文件' })); - await waitFor(() => expect(close).toHaveBeenCalledTimes(1)); - expect(imports()).toEqual([ - expect.objectContaining({ slug, kb_id: 'kb_own', thread_id: 'spring', attachment_id: 'a' }), - expect.objectContaining({ slug, kb_id: 'kb_own', thread_id: 'reading', attachment_id: 'a' }), - ]); - expect(new Set(imports().map(input => input.operation_id)).size).toBe(2); - expect(imported).toHaveBeenCalledTimes(2); - expect(api.call.mock.calls.some(([operation]) => ['history', 'files', 'deleteAttachment'].includes(operation))).toBe(false); - expect(pending).toHaveBeenLastCalledWith(false); -}); - -it('retries only failed files with their original identity, retaining successful files and processing callbacks', async () => { - const original = api.call.getMockImplementation()!; - let failure = true; - api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' && input.attachment_id === 'b' && failure - ? Promise.reject(new Error('连接超时')) : original(operation, input)); - render(); - await chooseChat(); await chooseFile('观察报告.md'); await chooseFile('植物资料.pdf'); - fireEvent.click(screen.getByRole('button', { name: '导入 2 个文件' })); - await screen.findByText(/导入结果尚未确认:连接超时/); - expect(close).not.toHaveBeenCalled(); expect(imported).toHaveBeenCalledTimes(1); - expect(screen.getByRole('button', { name: '关闭文件选择' })).toBeDisabled(); - expect(screen.getByRole('button', { name: '选择对话 图书阅读记录' })).toBeDisabled(); - fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }); - expect(close).not.toHaveBeenCalled(); - const first = imports(); - failure = false; - fireEvent.click(screen.getByRole('button', { name: '重试失败的 1 个文件' })); - await waitFor(() => expect(close).toHaveBeenCalledTimes(1)); - expect(imports()).toHaveLength(3); expect(imports()[2]).toEqual(first[1]); - expect(imported).toHaveBeenCalledTimes(2); -}); - -it('never reimports a confirmed file after a processing callback failure and permits a later independent batch', async () => { - imported.mockRejectedValueOnce(new Error('处理失败')); - render(); - await chooseChat(); await chooseFile('观察报告.md'); - fireEvent.click(screen.getByRole('button', { name: '导入 1 个文件' })); - await screen.findByText(/文件已导入,处理未全部启动/); - expect(imports()).toHaveLength(1); expect(close).not.toHaveBeenCalled(); - expect(screen.getByRole('button', { name: '关闭文件选择' })).toBeEnabled(); - await chooseFile('植物资料.pdf'); - fireEvent.click(screen.getByRole('button', { name: '导入 1 个文件' })); - await waitFor(() => expect(close).toHaveBeenCalledTimes(1)); - expect(imports().map(input => input.attachment_id)).toEqual(['a', 'b']); -}); - -it('shows file types and sizes, disables unsupported legacy formats and oversize files, and accepts supported files at the size boundary', async () => { - sourceFiles.spring = [attachment('legacy-doc', '旧文档.doc'), attachment('legacy-xls', '旧表格.xls'), attachment('markdown', '资料.markdown'), - attachment('image', '图片.png'), attachment('large', '过大的资料.pdf', 5 * 1024 * 1024 + 1), attachment('boundary', '可导入.DOCX', 5 * 1024 * 1024)]; - render(); await chooseChat(); - for (const name of ['旧文档.doc', '旧表格.xls', '资料.markdown', '图片.png', '过大的资料.pdf']) expect(await screen.findByRole('checkbox', { name: '选择文件 ' + name })).toBeDisabled(); - expect(screen.getAllByText('此格式暂不支持知识库导入')).toHaveLength(4); - expect(screen.getByText('超过 5 MB,暂不能导入')).toBeVisible(); - expect(screen.getByText('DOCX · 5.0 MB')).toBeVisible(); - await chooseFile('可导入.DOCX'); - fireEvent.click(screen.getByRole('button', { name: '导入 1 个文件' })); - await waitFor(() => expect(close).toHaveBeenCalledOnce()); - expect(imports()).toEqual([expect.objectContaining({ attachment_id: 'boundary' })]); -}); - -it('distinguishes loading, empty chats, chat read failures and a successful retry', async () => { - const loading = deferred<{ threads: CloudThread[]; next_offset: null }>(); - const original = api.call.getMockImplementation()!; - let mode = 'loading'; - api.call.mockImplementation((operation, input) => { - if (operation !== 'threads') return original(operation, input); - return mode === 'loading' ? loading.promise : mode === 'error' ? Promise.reject(new Error('目录离线')) : Promise.resolve({ threads, next_offset: null }); - }); - const view = render(); - expect(screen.getByText('正在读取对话…')).toBeVisible(); - expect(screen.queryByText('还没有个人智能体对话。')).not.toBeInTheDocument(); - await act(async () => loading.resolve({ threads: [], next_offset: null })); - expect(screen.getByText('还没有个人智能体对话。')).toBeVisible(); - view.unmount(); mode = 'error'; render(); - await screen.findByText('对话读取失败:目录离线'); - expect(screen.queryByText('还没有个人智能体对话。')).not.toBeInTheDocument(); - mode = 'ready'; fireEvent.click(screen.getByRole('button', { name: '重试读取对话' })); - await screen.findByRole('button', { name: '选择对话 春天的观察讨论' }); - expect(screen.queryByRole('alert')).not.toBeInTheDocument(); -}); - -it('distinguishes file loading, failures, empty files and search results while ignoring a late response from a previous chat', async () => { - const late = deferred<{ attachments: CloudAttachment[] }>(); - const original = api.call.getMockImplementation()!; - let readingFails = true; - api.call.mockImplementation((operation, input) => operation === 'attachments' - ? input.thread_id === 'spring' ? late.promise : readingFails ? Promise.reject(new Error('文件离线')) : Promise.resolve({ attachments: [] }) - : original(operation, input)); - render(); await chooseChat(); - expect(screen.getByText('正在读取文件…')).toBeVisible(); - expect(screen.queryByText('这段对话还没有上传文件。')).not.toBeInTheDocument(); - await chooseChat(threads[1].title); await screen.findByText('文件读取失败:文件离线'); - await act(async () => late.resolve({ attachments: sourceFiles.spring })); - expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); - readingFails = false; fireEvent.click(screen.getByRole('button', { name: '重试读取文件' })); - await screen.findByText('这段对话还没有上传文件。'); - expect(screen.queryByRole('alert')).not.toBeInTheDocument(); - fireEvent.change(screen.getByRole('textbox', { name: '搜索对话' }), { target: { value: '不存在' } }); - expect(screen.getByText('没有匹配的对话。')).toBeVisible(); -}); - -it('guards X, escape and selection during an in-flight import, then closes only after all files finish', async () => { - const first = deferred(); - const original = api.call.getMockImplementation()!; - api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' && input.attachment_id === 'a' ? first.promise : original(operation, input)); - render(); await chooseChat(); await chooseFile('观察报告.md'); await chooseFile('植物资料.pdf'); - fireEvent.click(screen.getByRole('button', { name: '导入 2 个文件' })); - expect(imports()).toHaveLength(1); - expect(screen.getByRole('button', { name: '关闭文件选择' })).toBeDisabled(); - expect(screen.getByRole('checkbox', { name: '选择文件 观察报告.md' })).toBeDisabled(); - expect(screen.queryByRole('button', { name: '放弃重试', exact: true })).not.toBeInTheDocument(); - fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }); - expect(close).not.toHaveBeenCalled(); - await act(async () => first.resolve(knowledgeFile('a'))); - await waitFor(() => expect(close).toHaveBeenCalledOnce()); - expect(imports()).toHaveLength(2); expect(imported).toHaveBeenCalledTimes(2); -}); - -it('does not read or import without management permission and resumes a confirmed receipt without importing it twice', async () => { - const receipt = deferred(); - const original = api.call.getMockImplementation()!; - api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' ? receipt.promise : original(operation, input)); - const view = render(); - expect(api.call).not.toHaveBeenCalled(); expect(api.list).not.toHaveBeenCalled(); - view.rerender(); await chooseChat(); await chooseFile('观察报告.md'); - fireEvent.click(screen.getByRole('button', { name: '导入 1 个文件' })); - view.rerender(); - await act(async () => receipt.resolve(knowledgeFile('a'))); - expect(imports()).toHaveLength(1); expect(imported).not.toHaveBeenCalled(); expect(close).not.toHaveBeenCalled(); - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - expect(pending).toHaveBeenLastCalledWith(true); - view.rerender(); - fireEvent.click(screen.getByRole('button', { name: '继续导入 1 个文件' })); - await waitFor(() => expect(close).toHaveBeenCalledOnce()); - expect(imports()).toHaveLength(1); expect(imported).toHaveBeenCalledOnce(); -}); - -it('ignores an old-target response and stops the old batch after a knowledge-base scope change', async () => { - const response = deferred(); - const original = api.call.getMockImplementation()!; - api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' ? response.promise : original(operation, input)); - const view = render(); - await chooseChat(); await chooseFile('观察报告.md'); await chooseFile('植物资料.pdf'); - fireEvent.click(screen.getByRole('button', { name: '导入 2 个文件' })); - view.rerender(); - await act(async () => response.resolve(knowledgeFile('a'))); - expect(imports()).toHaveLength(1); expect(imported).not.toHaveBeenCalled(); expect(close).not.toHaveBeenCalled(); - expect(screen.getByRole('dialog')).toHaveTextContent('导入到「其他资料」'); - expect(screen.queryByRole('list', { name: '文件导入结果' })).not.toBeInTheDocument(); -}); - -it('does not continue a batch or notify the old owner after unmount', async () => { - const response = deferred(); - const original = api.call.getMockImplementation()!; - api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' ? response.promise : original(operation, input)); - const view = render(); - await chooseChat(); await chooseFile('观察报告.md'); await chooseFile('植物资料.pdf'); - fireEvent.click(screen.getByRole('button', { name: '导入 2 个文件' })); view.unmount(); - await act(async () => response.resolve(knowledgeFile('a'))); - expect(imports()).toHaveLength(1); expect(imported).not.toHaveBeenCalled(); expect(close).not.toHaveBeenCalled(); - expect(pending).toHaveBeenLastCalledWith(false); -}); - -it('hides the portal after a confirmed loss of ownership so the parent can discard pending requests without importing again', async () => { - const response = deferred(); - const original = api.call.getMockImplementation()!; - let owned = true; - api.call.mockImplementation((operation, input) => { - if (operation === 'knowledge') return Promise.resolve({ databases: owned ? [{ kb_id: 'kb_own', name: '我的观察记录' }] : [], models: [] }); - if (operation === 'knowledgeFiles') return Promise.resolve({ files: [], next_offset: null }); - if (operation === 'importKnowledgeAttachment') return response.promise; - return original(operation, input); - }); - const created = vi.fn(); - const view = render(); - await screen.findByRole('option', { name: '我的观察记录' }); - fireEvent.change(screen.getByRole('combobox', { name: '管理的知识库' }), { target: { value: 'kb_own' } }); - fireEvent.click(await screen.findByRole('button', { name: '从聊天选择文件' })); - await chooseChat(); await chooseFile('观察报告.md'); await chooseFile('植物资料.pdf'); - fireEvent.click(screen.getByRole('button', { name: '导入 2 个文件' })); - owned = false; - view.rerender(); - await waitFor(() => expect(screen.queryByRole('dialog', { name: '从聊天选择文件' })).not.toBeInTheDocument()); - await act(async () => response.resolve(knowledgeFile('a'))); - const discard = await screen.findByRole('button', { name: '放弃未确认操作并重新选择' }); - await waitFor(() => expect(discard).toBeEnabled()); - expect(imports()).toHaveLength(1); - expect(api.call.mock.calls.some(([operation]) => operation === 'processKnowledge')).toBe(false); - fireEvent.click(discard); - expect(screen.queryByRole('button', { name: '放弃未确认操作并重新选择' })).not.toBeInTheDocument(); - expect(screen.getByRole('combobox', { name: '管理的知识库' })).toHaveValue(''); -}); - -it('allows cancelling unsubmitted selections and does not mutate knowledge merely by choosing files', async () => { - render(); await chooseChat(); await chooseFile('观察报告.md'); - expect(pending).toHaveBeenLastCalledWith(true); - fireEvent.click(screen.getByRole('button', { name: '关闭文件选择' })); - expect(close).toHaveBeenCalledOnce(); expect(imports()).toHaveLength(0); expect(imported).not.toHaveBeenCalled(); -}); - -it.each(['button', 'escape'])('allows %s dismissal during a catalog failure before any import has been sent', async control => { - const view = render(); - await chooseChat(); await chooseFile('观察报告.md'); - view.rerender(); - expect(screen.getByRole('button', { name: '关闭文件选择' })).toBeEnabled(); - if (control === 'button') fireEvent.click(screen.getByRole('button', { name: '关闭文件选择' })); - else fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }); - await waitFor(() => expect(close).toHaveBeenCalledOnce()); - expect(imports()).toHaveLength(0); -}); - -it('offers a read-only catalog refresh while an uncertain import is blocked, then retries only the original operation', async () => { - const original = api.call.getMockImplementation()!; - let fails = true; - api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' && fails - ? Promise.reject(new Error('结果未确认')) : original(operation, input)); - const refresh = vi.fn(); - const view = render(); - await chooseChat(); await chooseFile('观察报告.md'); - fireEvent.click(screen.getByRole('button', { name: '导入 1 个文件' })); - await screen.findByText(/导入结果尚未确认:结果未确认/); - const request = imports()[0]; - view.rerender(); - expect(screen.getByRole('button', { name: '关闭文件选择' })).toBeDisabled(); - expect(screen.getByRole('button', { name: '重试失败的 1 个文件' })).toBeDisabled(); - fireEvent.click(screen.getByRole('button', { name: '刷新知识库', exact: true })); - expect(refresh).toHaveBeenCalledOnce(); expect(imports()).toHaveLength(1); - fails = false; - view.rerender(); - fireEvent.click(screen.getByRole('button', { name: '重试失败的 1 个文件' })); - await waitFor(() => expect(close).toHaveBeenCalledOnce()); - expect(imports()).toEqual([request, request]); expect(imported).toHaveBeenCalledOnce(); -}); - -it('requires explicit confirmation to abandon a retry during a catalog failure, while keeping the request through cancelling that confirmation', async () => { - const original = api.call.getMockImplementation()!; - api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' - ? Promise.reject(new Error('附件已无法读取')) : original(operation, input)); - const view = render(); - await chooseChat(); await chooseFile('观察报告.md'); - fireEvent.click(screen.getByRole('button', { name: '导入 1 个文件' })); - await screen.findByText(/导入结果尚未确认:附件已无法读取/); - view.rerender(); - fireEvent.click(screen.getByRole('button', { name: '放弃重试', exact: true })); - expect(screen.getByText('这只会结束当前页面的重试。已保存的文件会保留,可刷新知识库确认。')).toBeVisible(); - expect(close).not.toHaveBeenCalled(); expect(pending).toHaveBeenLastCalledWith(true); - fireEvent.click(screen.getByRole('button', { name: '继续保留' })); - expect(screen.getByRole('button', { name: '重试失败的 1 个文件' })).toBeDisabled(); - expect(screen.getByRole('list', { name: '文件导入结果' })).toHaveTextContent('观察报告.md'); - fireEvent.click(screen.getByRole('button', { name: '放弃重试', exact: true })); - fireEvent.click(screen.getByRole('button', { name: '确认放弃重试' })); - expect(close).toHaveBeenCalledOnce(); expect(pending).toHaveBeenLastCalledWith(false); - expect(imports()).toHaveLength(1); expect(imported).not.toHaveBeenCalled(); - expect(screen.queryByRole('list', { name: '文件导入结果' })).not.toBeInTheDocument(); - expect(screen.queryByRole('list', { name: '已选聊天文件' })).not.toBeInTheDocument(); -}); - -it('exits a partly successful batch with a permanent source error without deleting or replaying successful imports', async () => { - const original = api.call.getMockImplementation()!; - api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' && input.attachment_id === 'b' - ? Promise.reject(new Error('原附件已经删除')) : original(operation, input)); - render(); - await chooseChat(); await chooseFile('观察报告.md'); await chooseFile('植物资料.pdf'); - fireEvent.click(screen.getByRole('button', { name: '导入 2 个文件' })); - await screen.findByText(/导入结果尚未确认:原附件已经删除/); - expect(imported).toHaveBeenCalledOnce(); - expect(screen.getByRole('button', { name: '关闭文件选择' })).toBeDisabled(); - expect(screen.queryByText(/可继续切换对话选择/)).not.toBeInTheDocument(); - const before = api.call.mock.calls.length; - fireEvent.click(screen.getByRole('button', { name: '放弃重试', exact: true })); - fireEvent.click(screen.getByRole('button', { name: '确认放弃重试' })); - expect(close).toHaveBeenCalledOnce(); expect(pending).toHaveBeenLastCalledWith(false); - expect(imported).toHaveBeenCalledOnce(); expect(imports()).toHaveLength(2); - expect(api.call).toHaveBeenCalledTimes(before); -}); diff --git a/tests/unit/cloud-knowledge-picker.test.tsx b/tests/unit/cloud-knowledge-picker.test.tsx index e83b2689..48284f34 100644 --- a/tests/unit/cloud-knowledge-picker.test.tsx +++ b/tests/unit/cloud-knowledge-picker.test.tsx @@ -57,13 +57,13 @@ it('joins readable IDs to the owned-only catalog and selects multiple libraries expect(changes).toHaveBeenLastCalledWith(expect.objectContaining({ knowledges: ['own', 'other-shared'] })); }); -it('never offers shared libraries to document management or mounts their mutating controls', async () => { +it('never offers shared libraries to document management or enables their upload controls', async () => { databases = []; render(); await screen.findByText('MakeLore知识 · 只读'); openUpload(); const manager = screen.getByRole('combobox', { name: '管理的知识库' }); expect(within(manager).queryByRole('option', { name: '创作指南' })).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: '从电脑上传', exact: true })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: '从电脑上传', exact: true })).toBeDisabled(); expect(screen.queryByRole('button', { name: '删除知识库', exact: true })).not.toBeInTheDocument(); expect(api.call.mock.calls.every(([operation]) => operation !== 'knowledgeFiles')).toBe(true); }); @@ -78,7 +78,7 @@ it('allows readable binding after an owned-list failure while withholding docume expect(screen.queryByRole('group', { name: 'MakeLore知识' })).not.toBeInTheDocument(); choose('shared'); openUpload(); expect(screen.getByRole('combobox', { name: '管理的知识库' })).toBeDisabled(); - expect(screen.queryByRole('button', { name: '从电脑上传', exact: true })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: '从电脑上传', exact: true })).toBeDisabled(); failed = false; fireEvent.click(screen.getByRole('button', { name: '刷新知识库', exact: true })); await screen.findByText('MakeLore知识 · 只读'); diff --git a/tests/unit/cloud-knowledge-status.test.tsx b/tests/unit/cloud-knowledge-status.test.tsx index 2b029716..4b442636 100644 --- a/tests/unit/cloud-knowledge-status.test.tsx +++ b/tests/unit/cloud-knowledge-status.test.tsx @@ -33,6 +33,7 @@ async function tick() { await act(async () => { await vi.advanceTimersByTimeAsyn it('uploads then processes automatically and refreshes through queue, parse, index and readiness', async () => { await open(); await screen.findByText('上传第一份文档,让智能体用你的资料回答。'); + expect(screen.queryByRole('button', { name: '从聊天选择文件' })).not.toBeInTheDocument(); vi.useFakeTimers(); api.uploadKnowledge.mockImplementation(async () => { items = [file]; return file; }); await act(async () => { fireEvent.click(screen.getByRole('button', { name: '从电脑上传', exact: true })); }); @@ -48,6 +49,7 @@ it('uploads then processes automatically and refreshes through queue, parse, ind expect(screen.getByText('可用于回答')).toBeVisible(); expect(screen.getByText(/12 段可检索内容/)).toBeVisible(); expect(api.uploadKnowledge).toHaveBeenCalledTimes(1); + expect(api.call.mock.calls.some(([operation]) => ['threads', 'attachments', 'importKnowledgeAttachment'].includes(operation))).toBe(false); }); it('does not call zero chunks searchable and distinguishes loading from an empty library', async () => { @@ -162,23 +164,6 @@ it('keeps all loaded pages refreshing and ignores a late response from the previ expect(screen.getByText('上传第一份文档,让智能体用你的资料回答。')).toBeVisible(); }); -it('imports an attachment and processes it without claiming immediate readiness', async () => { - const original = api.call.getMockImplementation()!; - api.call.mockImplementation(async (operation, input) => { - if (operation === 'threads') return { threads: [{ thread_id: 'thread', title: '我的对话' }], next_offset: null }; - if (operation === 'attachments') return { attachments: [{ file_id: 'attachment', file_name: '附件.pdf' }] }; - if (operation === 'importKnowledgeAttachment') { items = [file]; return file; } - return original(operation, input); - }); - await open(); - fireEvent.click(screen.getByRole('button', { name: '从聊天选择文件' })); - fireEvent.click(await screen.findByRole('button', { name: '选择对话 我的对话' })); - fireEvent.click(await screen.findByRole('checkbox', { name: '选择文件 附件.pdf' })); - fireEvent.click(screen.getByRole('button', { name: '导入 1 个文件' })); - await screen.findByText('等待处理'); - expect(screen.queryByText('可用于回答')).not.toBeInTheDocument(); -}); - it('keeps indexed content available when replacement cleanup fails and permits retry', async () => { items = [{ ...file, status: 'indexed', chunk_count: 4, available: true, replaces_file_id: 'old', processing_task: { task_id: 'task', status: 'failed', error: '旧文档清理失败' } }]; From 8e0e52f30ea144ab37a8925ff09c31e677fc317c 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 13:57:35 +0800 Subject: [PATCH 4/8] fix(knowledge): separate creation management and selection --- ...-chat-knowledge-import-explain-ec03b792.md | 25 ++-- README.md | 2 +- src/pages/CloudAgents/CloudKnowledge.tsx | 54 ++++---- .../CloudAgents/CloudKnowledgeDocuments.tsx | 7 +- .../CloudAgents/CloudKnowledgeSelector.tsx | 5 +- src/pages/CloudAgents/agent-config.css | 13 +- tests/e2e/cloud-agent-knowledge.spec.ts | 118 +++++++++++++----- tests/unit/cloud-agents-page.test.tsx | 1 - tests/unit/cloud-knowledge-catalog.test.tsx | 11 +- tests/unit/cloud-knowledge-picker.test.tsx | 96 +++++++++++--- tests/unit/cloud-knowledge-status.test.tsx | 3 +- 11 files changed, 223 insertions(+), 112 deletions(-) diff --git a/.project-docs/30-worklog/tasks/20261013-chat-knowledge-import-explain-ec03b792.md b/.project-docs/30-worklog/tasks/20261013-chat-knowledge-import-explain-ec03b792.md index d9292e72..1cb27180 100644 --- a/.project-docs/30-worklog/tasks/20261013-chat-knowledge-import-explain-ec03b792.md +++ b/.project-docs/30-worklog/tasks/20261013-chat-knowledge-import-explain-ec03b792.md @@ -13,28 +13,31 @@ ## Scope - Explain the screenshot's "从聊天选择文件" entry, then remove the chat-file import entry/workflow at the user's explicit instruction. Keep computer upload, current document management and knowledge-library selection/activation. +- Latest follow-up: separate creation, owned-library management and Agent binding. Top left is 「上传自己的文件」 with the new-library entry/form, top right is 「查看我的知识库」 with the managed-library dropdown/upload/files, and 「选择知识库」 spans below both. ## Intent And Constraints - User initially requested explanation; the latest explicit instruction authorizes removing chat-file import and retaining computer upload. This supersedes the previous feature's approved chat-import entry. -- Additional user steering: place the knowledge creation button below the computer upload button. Move the new-library entry/creation form under the upload action row; keep first-library creation available when upload has no selected target. +- Earlier steering placed creation beneath computer upload; the latest explicit layout supersedes that button order: the left creation card contains only the new-library entry/form; owned-library browsing and creation do not bind a library to the Agent. Binding remains an explicit bottom selection, and its 100-library limit does not block management or creation. Delete the old instruction paragraph and 「添加到我的知识库」 label. - Keep real files/chats/libraries, runtime, Main/backend APIs and old recovery records. No actual imports, deletion of user data, app update, main integration or push. Own only removal-related UI/copy/style/tests and this record. ## Outcome - Removed the "从聊天选择文件" entry, its picker component and exclusive styles/state/props/tests. Knowledge adding has only the computer-upload entry. Upload copy and README match. -- Moved "新建知识库" and its expanded creation form below the computer-upload action row. Before a target is selected, the upload button is disabled and first-library creation remains available directly below it. +- Top left 「上传自己的文件」 contains the new-library button and expanded creation form. Top right 「查看我的知识库」 contains the owned-library dropdown, directory refresh, computer upload and file management. Bottom 「选择知识库」 spans both columns; narrow cards stack creation, management and binding in that order. The old instruction paragraph and 「添加到我的知识库」 label are removed. +- New-library creation now says 「创建知识库」 and opens the created library for management without changing Agent draft bindings. Browsing also leaves bindings untouched; bottom selection explicitly binds. Its 100-library limit no longer blocks browsing, uploading or creating. +- Independent review found that using creation-form visibility to place a shared error could show an upload error in the creation card. Errors now follow the operation source, with a regression covering right-side upload failure while the left creation form is open. - Preserved automatic document processing, status polling, pagination, original-operation retries, replacement/deletion, permission-loss pending state, own/shared library distinctions and explicit save/apply of selection. - Main/shared/backend import contracts and recovery handling remain for existing pending operations. No real files/chats/libraries were imported, moved or deleted; current app/runtime was not refreshed. ## Verification - Initial explanation inspected committed client/Yuxi import behavior. Subsequent removal is explicitly authorized and does not change backend contracts. -- Pinned corepack pnpm 10.33.4: 6 focused knowledge/catalog/configuration/page suites, 91 tests passed. Existing upload-to-ready test now also verifies no chat-import entry or chat API calls. Retired picker tests were deleted with the component. +- Pinned corepack pnpm 10.33.4: 6 focused knowledge/catalog/configuration/page suites, 92 tests passed after the layout follow-up. Coverage includes independent creation/browsing/binding, 100-binding-limit management, interleaved draft changes, create result during save, upload-error placement, stable retry identities and permission-loss state. Upload-to-ready also verifies no chat-import entry or chat API calls. Retired picker tests were deleted with the component. - Typecheck, scoped ESLint, full Renderer/Main/preload/utility build and git diff --check passed. -- Headless Vite/Playwright knowledge fixture: 5/5 passed in 18.0s, covering 375px/1440px library selection/save/reopen, first-library creation, upload/auto processing and failed/cancelled upload protection. Both viewport cases assert the new-library button is below upload before and after selecting/creating a target. No real cloud request, native picker or current app operation occurs in the fixture. -- Two prior tests expected no upload button when a target was unavailable; updated explicitly to a disabled placeholder under the new requested layout, preserving the no-document-request assertions. -- Independent review passed for removal and creation-control reorder, including uncertain upload/create and hidden permission-loss state. Inspected generated desktop screenshot confirming the physical button order and removal. +- Latest headless Vite/Playwright fixture: 5/5 passed in 18.2s for 375px/1440px selection/save/reopen, browsing/creation without binding, explicit binding, upload-to-ready and failed/cancelled upload. Intermediate 768px/1024px layout assertions exercise the actual >620px container breakpoint. Ran in this worktree with explicit output directory `/tmp/makelore-knowledge-layout-e2e-20261013`; inspected desktop and mobile complete-card screenshots plus the desktop ready-document state. No real cloud request, native picker or current app operation occurs in the fixture. +- The disabled computer-upload placeholder is now confined to right-side management before a target is selected, preserving no-document-request protection. +- Independent review confirms automatic-binding removal, deletion cleanup, directory refresh, stable uncertain upload/create identities, hidden permission-loss state and no backend change; its error-source finding was fixed, tested and re-reviewed with no remaining actionable findings. - Task-aware documentation drift and whitespace checks run at completion; own record is the only .project-docs edit. ## Follow-ups @@ -43,7 +46,7 @@ ## Promotion Candidates -- Target: personal Agents desktop knowledge UI documentation in a future integration. Proposal: adding knowledge documents uses computer upload only, and new-library creation appears below that upload entry. Evidence: explicit user instructions, source changes, focused regression/viewport checks. No API, storage, privacy or existing-file migration change; human direction already supplied. +- Target: personal Agents desktop knowledge UI documentation in a future integration. Proposal: computer upload is the only document-add entry; top creation and owned-library management are separate from bottom Agent binding. Creation/browsing do not modify bindings, and the binding limit does not restrict management. Evidence: explicit user instructions, source changes, focused regression/viewport checks. No API, storage, privacy or existing-file migration change; human direction already supplied. ## Project Context Loaded @@ -52,10 +55,10 @@ - Peer Scope/Intent/Promotion sections read-only; knowledge-workspace ready source is the explicit verifiable base. Unknown legacy scopes have no concrete dependency for this explanation. - Goal: personal Agent knowledge reuse with Main-owned transport and Yuxi-owned permissions/storage. Relevant files: picker, documents, knowledge selection and Yuxi knowledge/conversation service. - Constraints: no user-file import, live UI operation or app refresh; removal-related source edits are explicitly authorized. Runtime deployment of the peer's newer picker is not inferred from a screenshot. -- Gate result: Passed on resume for the explicitly authorized removal. Ownership and peer scopes rechecked; source base and unchanged canonical context retained. +- Gate result: Passed on resume for the explicitly authorized layout follow-up. Successful start/status confirms the same task/mode/branch/worktree/base. All peer Scope/Intent/Promotion sections read-only; the completed knowledge source is the verifiable base. The combined-trial task may later consume this source but this task owns no live update. Undefined legacy scope has no concrete dependency; canonical context is unchanged. ## Implementation Plan -- Remove the chat-import button, component and its unused local props/state/styles; preserve upload, processing, retries and document management. -- Update explanatory copy and README to computer upload only; remove retired picker tests and adapt existing upload/E2E coverage to confirm the remaining path. -- Run focused knowledge tests, typecheck, scoped lint, build and the headless knowledge fixture; verify task document boundaries and prepare a committed source handoff. +- Restructure owned-library UI into a top two-column creation/management row and a bottom binding card. Remove creation injection from the document list and automatic bindings on create/browse; preserve pending operations and permission-loss state. +- Update README and focused tests. Independently verify creation/browsing with full binding selection and verify 375px/1440px geometry, upload/status, explicit selection/save/reopen. +- Run focused knowledge suites, typecheck, scoped lint, build and headless fixture; inspect screenshots and independent source review, verify task document boundaries and commit the source handoff. diff --git a/README.md b/README.md index 1e457d39..bf64b5ef 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 思考投影补丁上线,当前未部署;实时思考沿用既有事件流。头像上传、Main-owned Host API、访问隔离、费用与不可变版本规则保持;介绍留空需要配套 Yuxi DraftCreate/DraftSave 的空字符串契约。 - 高级配置的知识库支持下拉多选,分为「MakeLore知识」和「我的知识库」,已选项可单独移除。MakeLore知识来自当前账号有权读取的 Yuxi 共享目录,只显示名称与简介;移除仅取消当前智能体选用,不删除原库。知识库区域左右分区:左侧「上传自己的文件」管理本人知识库,右侧选择「MakeLore知识」和「我的知识库」,小窗口上下排列。左侧可新建库或追加资料,仅提供「从电脑上传」作为添加文件入口;「新建知识库」按钮和创建表单位于上传按钮下方,未选择知识库时上传按钮置灰,仍可先新建知识库;聊天文件导入入口已移除。上传后自动解析、建立索引并持续显示真实处理状态;失败可重试且保留已有可用内容。使用说明与费用以折叠说明呈现。暂不可选用的个人库仍可管理文件,已有绑定保留并提示不可用;未确认请求在当前库保留同一操作身份供重试。知识库选择只改草稿,显式保存/应用后生效;个人云端执行已自动接入所选知识库的检索,无需另外勾选 knowledge-base Skill。此交互复用现有接口,无需新增后端补丁。 + 高级配置的知识库支持下拉多选,分为「MakeLore知识」和「我的知识库」,已选项可单独移除。MakeLore知识来自当前账号有权读取的 Yuxi 共享目录,只显示名称与简介;移除仅取消当前智能体选用,不删除原库。知识库区域上方左右分区:左侧「上传自己的文件」仅放「新建知识库」入口及展开的创建表单,右侧「查看我的知识库」通过下拉框查看本人知识库、从电脑追加资料和管理文件;下方独立「选择知识库」,选择智能体使用的「MakeLore知识」和「我的知识库」。小窗口按新建、查看、选用顺序纵向排列。创建成功后在右侧打开新库;新建和查看均不会自动加入智能体配置,选用上限不影响管理与新建。仅提供「从电脑上传」作为添加文件入口,未选择管理目标时上传按钮置灰;聊天文件导入入口已移除。上传后自动解析、建立索引并持续显示真实处理状态;失败可重试且保留已有可用内容。使用说明与费用以折叠说明呈现。暂不可选用的个人库仍可管理文件,已有绑定保留并提示不可用;未确认请求在当前库保留同一操作身份供重试。知识库选择只改草稿,显式保存/应用后生效;个人云端执行已自动接入所选知识库的检索,无需另外勾选 knowledge-base Skill。此交互复用现有接口,无需新增后端补丁。 云知识库的 embedding 模型只由平台管理员在 Yuxi 后台维护:在「智能体管理 → 模型供应商」启用供应商,按供应商要求填写有效的服务地址和 API Key,添加并启用 embedding 模型并填写实际向量维度;embedding 向量化费用由平台承担,不计入用户用量、词元点数或智能体费用上限;one-api 不参与 embedding 配置,Works Square 只负责其他应计费模型调用的创建者词元点数账本。客户端区分目录加载中、加载失败与无可用模型,空状态提供管理员配置说明;「刷新知识库与模型」保留未提交名称,未确认的创建请求沿用原操作身份。 diff --git a/src/pages/CloudAgents/CloudKnowledge.tsx b/src/pages/CloudAgents/CloudKnowledge.tsx index d1b09b30..f3edb6cd 100644 --- a/src/pages/CloudAgents/CloudKnowledge.tsx +++ b/src/pages/CloudAgents/CloudKnowledge.tsx @@ -1,5 +1,5 @@ -import { useEffect, useId, useRef, useState } from 'react'; -import { Upload } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; +import { Library, Upload } from 'lucide-react'; import { cloudAgentsApi } from '@/lib/cloud-agents-api'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -10,11 +10,10 @@ import { CloudKnowledgeDocuments } from './CloudKnowledgeDocuments'; export interface CloudOwnedKnowledgeCatalog { databases: CloudKnowledge[] | null; loading: boolean; error: string } -export function CloudKnowledgePanel({ slug, onCreated, onDeleted, onSelected, onCatalog, refreshToken = 0, disabled = false, selectionFull = false, selectedIds = [], selectableIds }: { +export function CloudKnowledgePanel({ slug, onCreated, onDeleted, onCatalog, refreshToken = 0, disabled = false, selectableIds }: { slug: string; onCreated: (kb: CloudKnowledge) => void; onDeleted?: (id: string) => void; onCatalog?: (value: CloudOwnedKnowledgeCatalog) => void; refreshToken?: number; disabled?: boolean; - selectionFull?: boolean; selectedIds?: string[]; - selectableIds?: string[] | null; onSelected?: (kb: CloudKnowledge) => void; + selectableIds?: string[] | null; }) { type CloudEmbeddingModel = CloudAgentOperations['knowledge']['output']['models'][number]; const [catalog, setCatalog] = useState<{ databases: CloudKnowledge[]; models: CloudEmbeddingModel[] } | null>(null); @@ -22,13 +21,13 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted, onSelected, on const [name, setName] = useState(''); const [model, setModel] = useState(''); const [error, setError] = useState(''); + const [errorTarget, setErrorTarget] = useState<'creation' | 'management'>('management'); const [catalogError, setCatalogError] = useState(''); const [catalogLoading, setCatalogLoading] = useState(true); const [busy, setBusy] = useState(false); const [refresh, setRefresh] = useState(0); const [creating, setCreating] = useState(false); const [documentPending, setDocumentPending] = useState(false); - const targetId = useId(); 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); @@ -51,10 +50,10 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted, onSelected, on .finally(() => { if (current) setCatalogLoading(false); }); return () => { current = false; }; }, [slug, refresh, refreshToken]); - const act = async (operation: () => Promise) => { + const act = async (operation: () => Promise, target: 'creation' | 'management' = 'management') => { if (disabled || operationInFlight.current) return; operationInFlight.current = true; - setBusy(true); setError(''); + setBusy(true); setError(''); setErrorTarget(target); try { await operation(); } catch(e) { if (alive.current) setError(e instanceof Error ? e.message : '知识库操作失败'); } finally { operationInFlight.current = false; if (alive.current) setBusy(false); } @@ -65,7 +64,7 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted, onSelected, on const managementDisabled = disabled || busy || catalogLoading || Boolean(catalogError); const bindable = (id: string) => selectableIds === undefined || Boolean(selectableIds?.includes(id)); const creationControls =
- + {creating &&
setName(e.target.value)} /> { const selected = manageable.find(kb => kb.kb_id === e.target.value); - if (managementDisabled || documentPending || (selected && bindable(selected.kb_id) && selectionFull && !selectedIds.includes(selected.kb_id))) return; + if (managementDisabled || documentPending) return; setKbId(selected?.kb_id ?? ''); setError(''); - if (selected) (onSelected ?? onCreated)(selected); }}> {kbId && !selectedKnowledge && } - {manageable.map(kb => )}
+ {documentPending && selectedKnowledge &&

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

}
使用说明与费用

上传到知识库的文档可供获准使用此智能体的人检索。解析并建立索引后才会生效;embedding 向量化由平台承担,不计入用户用量、词元点数或智能体费用上限。知识处理如触发对话模型调用,仍按原模型实际用量计费。

@@ -134,13 +135,12 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted, onSelected, on {documentPending &&

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

}
} - {!selectedKnowledge &&
+ {!selectedKnowledge &&
- {creationControls}
} {kbId && } -
- ; + onDeleteKnowledge={() => { callbacks.current.onDeleted?.(kbId); setKbId(''); setDocumentPending(false); setRefresh(v => v + 1); }} />
} + +
; } diff --git a/src/pages/CloudAgents/CloudKnowledgeDocuments.tsx b/src/pages/CloudAgents/CloudKnowledgeDocuments.tsx index 9a350581..0cd84b6d 100644 --- a/src/pages/CloudAgents/CloudKnowledgeDocuments.tsx +++ b/src/pages/CloudAgents/CloudKnowledgeDocuments.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, type ReactNode } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { CheckCircle2, CircleAlert, FileText, Loader2 } from 'lucide-react'; import { cloudAgentsApi } from '@/lib/cloud-agents-api'; import { Button } from '@/components/ui/button'; @@ -47,11 +47,11 @@ function documentState(file: CloudKnowledgeFile) { return { label: '状态待确认', detail: '请刷新状态后再操作。', stage: 0 }; } -export function CloudKnowledgeDocuments({ slug, kbId, busy, act, onDeleteKnowledge, canRead = true, onPendingChange, children }: { +export function CloudKnowledgeDocuments({ slug, kbId, busy, act, onDeleteKnowledge, canRead = true, onPendingChange }: { slug: string; kbId: string; busy: boolean; act: (operation: () => Promise) => Promise; onDeleteKnowledge: () => void; - canRead?: boolean; onPendingChange?: (pending: boolean) => void; children?: ReactNode; + canRead?: boolean; onPendingChange?: (pending: boolean) => void; }) { const [files, setFiles] = useState([]); const [pageCount, setPageCount] = useState(1); @@ -155,7 +155,6 @@ export function CloudKnowledgeDocuments({ slug, kbId, busy, act, onDeleteKnowled
- {children}

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

{uploading &&

{uploading}

}
diff --git a/src/pages/CloudAgents/CloudKnowledgeSelector.tsx b/src/pages/CloudAgents/CloudKnowledgeSelector.tsx index 9888fb2e..0c78f04a 100644 --- a/src/pages/CloudAgents/CloudKnowledgeSelector.tsx +++ b/src/pages/CloudAgents/CloudKnowledgeSelector.tsx @@ -18,10 +18,9 @@ export function CloudKnowledgeSelector({ slug, options, selected, disabled, load const add = (id: string) => { if (!selectionFull && !selected.includes(id)) onChange([...selected, id]); }; return
option.key)} - onSelected={kb => { if (!loading && !error && options.some(option => option.key === kb.kb_id)) { add(kb.kb_id); onRefresh(); } }} + selectableIds={loading || error ? null : options.map(option => option.key)} onDeleted={id => { onChange(selected.filter(value => value !== id)); onRefresh(); }} - onCreated={kb => { add(kb.kb_id); onRefresh(); }} /> + onCreated={onRefresh} />

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

diff --git a/src/pages/CloudAgents/agent-config.css b/src/pages/CloudAgents/agent-config.css index 16b6826b..151fa693 100644 --- a/src/pages/CloudAgents/agent-config.css +++ b/src/pages/CloudAgents/agent-config.css @@ -42,20 +42,20 @@ .agent-config-resource-manager { margin-top: 18px; } .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; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); align-items: start; gap: 18px; min-width: 0; } -.agent-knowledge-upload, .agent-knowledge-selection { min-width: 0; padding: 18px; border: 1px solid #dce5e0; border-radius: 17px; background: #fffdfb; overflow-wrap: anywhere; } +.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: start; 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-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-field-label { display: block; font-size: 13px; font-weight: 600; color: #4c586b; } .agent-knowledge-help { border-top: 1px solid #e8e6e1; padding-top: 12px; } .agent-knowledge-help summary { min-height: 28px; width: fit-content; cursor: pointer; color: #606c7e; font-size: 12px; line-height: 1.7; } .agent-knowledge-help summary:focus-visible { outline: 2px solid #9a7bb5; outline-offset: 3px; border-radius: 4px; } .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-workspace { grid-template-columns: minmax(0, 1fr); } } +@container (max-width: 620px) { .agent-knowledge-owned-workspace { grid-template-columns: minmax(0, 1fr); } } @media (max-width: 640px) { .agent-editor-tabs { padding: 10px 12px; } .agent-editor-tabs [role="tab"] { gap: 6px; padding-inline: 8px; font-size: 14px; } @@ -67,7 +67,8 @@ .agent-config-heading-title { gap: 6px; } .agent-config-icon { width: 38px; height: 38px; border-radius: 13px; } .agent-config-section-top > button { margin-left: 48px; } - .agent-knowledge-workspace { grid-template-columns: minmax(0, 1fr); gap: 14px; } - .agent-knowledge-upload, .agent-knowledge-selection { padding: 14px; border-radius: 14px; } + .agent-knowledge-workspace, .agent-knowledge-owned-workspace { gap: 14px; } + .agent-knowledge-owned-workspace { grid-template-columns: minmax(0, 1fr); } + .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 1e3f83b1..e72c75d9 100644 --- a/tests/e2e/cloud-agent-knowledge.spec.ts +++ b/tests/e2e/cloud-agent-knowledge.spec.ts @@ -179,20 +179,50 @@ async function expectNoClipping(page: Page) { }); expect(clipped).toEqual([]); } -async function expectKnowledgeLayout(page: Page, width: number) { - const layout = page.locator('.agent-knowledge-workspace'); - const upload = await layout.locator('.agent-knowledge-upload').boundingBox(); - const selection = await layout.locator('.agent-knowledge-selection').boundingBox(); - expect(upload).not.toBeNull(); expect(selection).not.toBeNull(); - if (width >= 1024) { - expect(upload!.x + upload!.width).toBeLessThanOrEqual(selection!.x + 1); - expect(Math.abs(upload!.y - selection!.y)).toBeLessThanOrEqual(1); +async function expectKnowledgeLayout(page: Page) { + const knowledge = page.getByTestId('cloud-agent-editor').getByRole('group', { name: '知识库', exact: true }); + const creation = knowledge.getByRole('region', { name: '上传自己的文件', exact: true }); + const management = knowledge.getByRole('region', { name: '查看我的知识库', exact: true }); + const selection = knowledge.getByRole('region', { name: '选择知识库', exact: true }); + await expect(creation.getByRole('button', { name: '新建知识库', exact: true })).toBeVisible(); + await expect(creation.getByRole('combobox', { name: '管理的知识库', exact: true })).toHaveCount(0); + await expect(creation.getByRole('button', { name: '从电脑上传', exact: true })).toHaveCount(0); + await expect(management.getByRole('combobox', { name: '管理的知识库', exact: true })).toBeVisible(); + await expect(management.getByRole('button', { name: '从电脑上传', exact: true })).toBeVisible(); + await expect(management.getByRole('button', { name: '新建知识库', exact: true })).toHaveCount(0); + await expect(selection.getByRole('combobox', { name: '选择知识库', exact: true })).toBeVisible(); + await expect(knowledge.getByText('添加到我的知识库', { exact: true })).toHaveCount(0); + await expect(knowledge.getByText('选择或新建自己的知识库,再从电脑上传资料。选用会加入右侧配置,保存并应用后生效。', { exact: true })).toHaveCount(0); + const creationBounds = await creation.boundingBox(); + const managementBounds = await management.boundingBox(); + const selectionBounds = await selection.boundingBox(); + 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) { + expect(creationBounds!.x + creationBounds!.width).toBeLessThanOrEqual(managementBounds!.x + 1); + expect(Math.abs(creationBounds!.y - managementBounds!.y)).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); } else { - expect(upload!.y + upload!.height).toBeLessThanOrEqual(selection!.y + 1); - expect(Math.abs(upload!.x - selection!.x)).toBeLessThanOrEqual(1); + expect(creationBounds!.y + creationBounds!.height).toBeLessThanOrEqual(managementBounds!.y + 1); + expect(managementBounds!.y + managementBounds!.height).toBeLessThanOrEqual(selectionBounds!.y + 1); + expect(Math.abs(creationBounds!.x - managementBounds!.x)).toBeLessThanOrEqual(1); + expect(Math.abs(creationBounds!.width - managementBounds!.width)).toBeLessThanOrEqual(1); + expect(Math.abs(creationBounds!.width - selectionBounds!.width)).toBeLessThanOrEqual(1); } + expect(Math.abs(creationBounds!.x - selectionBounds!.x)).toBeLessThanOrEqual(1); await expectNoClipping(page); } +async function screenshotKnowledge(page: Page, name: string) { + const viewport = page.viewportSize()!; + const knowledge = page.getByTestId('cloud-agent-editor').getByRole('group', { name: '知识库', exact: true }); + const bounds = await knowledge.boundingBox(); + // Fit this scrollable card in the viewport so screenshots show every panel. + await page.setViewportSize({ width: viewport.width, height: Math.ceil(bounds!.height + 260) }); + await knowledge.screenshot({ path: test.info().outputPath(name), animations: 'disabled' }); + await page.setViewportSize(viewport); +} for (const width of [375, 1440]) { test(`multiple shared/personal choices stay draft-only and survive save/reopen at ${width}px`, async ({ page }) => { @@ -215,7 +245,7 @@ for (const width of [375, 1440]) { const management = knowledge.getByRole('combobox', { name: '管理的知识库', exact: true }); await expect(management.locator('option[value="kb_fixturescience"], option[value="kb_fixturereading"]')).toHaveCount(0); await expect(knowledge.getByRole('button', { name: '从电脑上传', exact: true })).toBeDisabled(); - await expectKnowledgeLayout(page, width); await page.screenshot({ path: test.info().outputPath(`knowledge-shared-${width}.png`), animations: 'disabled' }); + await expectKnowledgeLayout(page); await screenshotKnowledge(page, `knowledge-shared-${width}.png`); await editor.getByRole('button', { name: '保存草稿', exact: true }).click(); await expect(editor.getByRole('button', { name: '已保存', exact: true })).toBeVisible(); expect((await calls(page)).filter(call => call.method === 'PATCH').map(call => (call.input?.configuration as { knowledges: string[] }).knowledges)) @@ -223,36 +253,53 @@ for (const width of [375, 1440]) { await page.reload(); const reopened = await openKnowledge(page); await expect(reopened.knowledge.getByRole('list', { name: '已选知识库', exact: true }).getByRole('listitem')).toHaveCount(3); await expectNoClipping(page); + if (width === 1440) { + for (const intermediateWidth of [768, 1024]) { + await page.setViewportSize({ width: intermediateWidth, height: 850 }); + await expectKnowledgeLayout(page); + await screenshotKnowledge(page, `knowledge-shared-${intermediateWidth}.png`); + } + } }); - test(`own library creation and explicit upload process a document to ready at ${width}px`, async ({ page }) => { + test(`own library browsing and creation stay unbound while computer upload processes a document at ${width}px`, async ({ page }) => { await page.setViewportSize({ width, height: 850 }); await installFixture(page); await page.goto(baseUrl); const { editor, knowledge } = await openKnowledge(page); - await knowledge.getByRole('combobox', { name: '选择知识库', exact: true }).selectOption('kb_fixturescience'); + const selection = knowledge.getByRole('region', { name: '选择知识库', exact: true }); + const choose = selection.getByRole('combobox', { name: '选择知识库', exact: true }); + const selected = selection.getByRole('list', { name: '已选知识库', exact: true }); + await choose.selectOption('kb_fixturescience'); await expect(knowledge.getByRole('heading', { name: '上传自己的文件', exact: true })).toBeVisible(); expect((await calls(page)).filter(call => call.path.endsWith('/knowledge/pick') || ['createKnowledge', 'processKnowledge'].includes(String(call.input?.operation)))).toEqual([]); - const upload = knowledge.getByRole('button', { name: '从电脑上传', exact: true }); - const create = knowledge.getByRole('button', { name: '新建知识库', exact: true }); + const creation = knowledge.getByRole('region', { name: '上传自己的文件', exact: true }); + const management = knowledge.getByRole('region', { name: '查看我的知识库', exact: true }); + const manager = management.getByRole('combobox', { name: '管理的知识库', exact: true }); + const upload = management.getByRole('button', { name: '从电脑上传', exact: true }); + const create = creation.getByRole('button', { name: '新建知识库', exact: true }); await expect(upload).toBeDisabled(); - const uploadBounds = await upload.boundingBox(); - const createBounds = await create.boundingBox(); - expect(uploadBounds).not.toBeNull(); expect(createBounds).not.toBeNull(); - expect(createBounds!.y).toBeGreaterThan(uploadBounds!.y + uploadBounds!.height); - await create.click(); - await knowledge.getByRole('textbox', { name: '知识库名称', exact: true }).fill('我的春天资料'); - await knowledge.getByRole('combobox', { name: '向量模型', exact: true }).selectOption('fixture-embedding'); - await knowledge.getByRole('button', { name: '创建并选用', exact: true }).click(); - await expect(knowledge.getByRole('combobox', { name: '管理的知识库', exact: true })).toHaveValue('kb_fixturecreated'); - await expect(knowledge.getByRole('list', { name: '已选知识库', exact: true })).toContainText('我的春天资料'); + await expectKnowledgeLayout(page); + await manager.selectOption('kb_fixturepersonal'); await expect(upload).toBeEnabled(); - const selectedUploadBounds = await upload.boundingBox(); - const selectedCreateBounds = await create.boundingBox(); - expect(selectedUploadBounds).not.toBeNull(); expect(selectedCreateBounds).not.toBeNull(); - expect(selectedCreateBounds!.y).toBeGreaterThan(selectedUploadBounds!.y + selectedUploadBounds!.height); + await expect(selected.getByRole('listitem')).toHaveCount(1); + await expect(selected).not.toContainText('我的观察记录'); + await manager.selectOption(''); + await expect(upload).toBeDisabled(); + await create.click(); + await creation.getByRole('textbox', { name: '知识库名称', exact: true }).fill('我的春天资料'); + await creation.getByRole('combobox', { name: '向量模型', exact: true }).selectOption('fixture-embedding'); + await expectKnowledgeLayout(page); + await creation.getByRole('button', { name: '创建知识库', exact: true }).click(); + await expect(manager).toHaveValue('kb_fixturecreated'); + await expect(selected.getByRole('listitem')).toHaveCount(1); + await expect(selected).not.toContainText('我的春天资料'); + await expect(upload).toBeEnabled(); + await manager.selectOption('kb_fixturepersonal'); + await manager.selectOption('kb_fixturecreated'); + await expect(selected.getByRole('listitem')).toHaveCount(1); await expect(knowledge.getByRole('button', { name: '从聊天选择文件', exact: true })).toHaveCount(0); expect((await calls(page)).filter(call => call.path.endsWith('/knowledge/pick') || call.input?.operation === 'processKnowledge')).toEqual([]); - await knowledge.getByRole('button', { name: '从电脑上传', exact: true }).click(); - const document = knowledge.getByRole('article', { name: '春天观察记录.md', exact: true }); + await upload.click(); + const document = management.getByRole('article', { name: '春天观察记录.md', exact: true }); await expect(document.getByText('等待处理', { exact: true })).toBeVisible(); const mutations = (await calls(page)).filter(call => call.path.endsWith('/knowledge/pick') || call.input?.operation === 'processKnowledge'); expect(mutations).toHaveLength(2); @@ -265,8 +312,12 @@ for (const width of [375, 1440]) { }); await expect(document.getByText('可用于回答', { exact: true })).toBeVisible({ timeout: 12_000 }); await expect(document.getByText('2 KB · 6 段可检索内容', { exact: true })).toBeVisible(); + await expect(selected.getByRole('listitem')).toHaveCount(1); + await choose.selectOption('kb_fixturecreated'); + await expect(selected.getByRole('listitem')).toHaveCount(2); + await expect(selected).toContainText('我的春天资料'); await document.scrollIntoViewIfNeeded(); - await expectKnowledgeLayout(page, width); await page.screenshot({ path: test.info().outputPath(`knowledge-upload-ready-${width}.png`), animations: 'disabled' }); + await expectKnowledgeLayout(page); await screenshotKnowledge(page, `knowledge-upload-ready-${width}.png`); // Document mutations never implicitly save the Agent draft. expect((await calls(page)).filter(call => call.method === 'PATCH')).toEqual([]); await editor.getByRole('button', { name: '保存草稿', exact: true }).click(); @@ -295,7 +346,8 @@ test('cancelled and failed uploads preserve shared choices and the personal draf await expect(knowledge.getByRole('alert')).toContainText('上传服务暂不可用'); await expect(knowledge.getByRole('combobox', { name: '管理的知识库', exact: true })).toHaveValue('kb_fixturepersonal'); await expect(knowledge.getByRole('list', { name: '已选知识库', exact: true })).toContainText('科学知识'); - await expect(knowledge.getByRole('list', { name: '已选知识库', exact: true })).toContainText('我的观察记录'); + await expect(knowledge.getByRole('list', { name: '已选知识库', exact: true })).not.toContainText('我的观察记录'); + await expect(knowledge.getByRole('list', { name: '已选知识库', exact: true }).getByRole('listitem')).toHaveCount(1); // 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(); diff --git a/tests/unit/cloud-agents-page.test.tsx b/tests/unit/cloud-agents-page.test.tsx index fab92a07..86a54615 100644 --- a/tests/unit/cloud-agents-page.test.tsx +++ b/tests/unit/cloud-agents-page.test.tsx @@ -748,7 +748,6 @@ it('switches editor pages while retaining unfinished knowledge form inputs', asy expect(instructions).not.toBeVisible(); expect(screen.getByLabelText('模型')).toBeVisible(); expect(screen.queryByRole('navigation', { name: '配置分类' })).not.toBeInTheDocument(); - fireEvent.click(screen.getByText('上传自己的文件')); fireEvent.click(screen.getByRole('button', { name: '新建知识库' })); const knowledgeName = screen.getByLabelText('知识库名称'); fireEvent.change(knowledgeName, { target: { value: '还没创建的资料' } }); diff --git a/tests/unit/cloud-knowledge-catalog.test.tsx b/tests/unit/cloud-knowledge-catalog.test.tsx index 38c4073a..6fa7a8db 100644 --- a/tests/unit/cloud-knowledge-catalog.test.tsx +++ b/tests/unit/cloud-knowledge-catalog.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { CloudKnowledgePanel } from '@/pages/CloudAgents/CloudKnowledge'; @@ -13,14 +13,13 @@ afterEach(cleanup); function openCreation() { render( undefined} />); - fireEvent.click(screen.getByText('上传自己的文件')); fireEvent.click(screen.getByRole('button', { name: '新建知识库' })); } it('explains who configures an empty embedding catalog and refreshes without losing the name', async () => { api.call.mockResolvedValueOnce(empty).mockResolvedValueOnce(available); openCreation(); - expect(screen.getByRole('button', { name: '从电脑上传', exact: true })).toBeDisabled(); + expect(within(screen.getByRole('region', { name: '查看我的知识库' })).getByRole('button', { name: '从电脑上传', exact: true })).toBeDisabled(); expect(await screen.findByText('暂时没有可用的向量模型。Embedding 只在 Yuxi 配置,请联系平台管理员启用后刷新;向量化费用由平台承担,不计入用户用量或智能体费用上限。')).toBeVisible(); expect(screen.getByRole('combobox', { name: '向量模型' })).toBeDisabled(); expect(screen.getByText(/上传到知识库的文档可供/)).toHaveTextContent('embedding 向量化由平台承担,不计入用户用量、词元点数或智能体费用上限'); @@ -35,7 +34,7 @@ it('explains who configures an empty embedding catalog and refreshes without los expect(screen.getByRole('textbox', { name: '知识库名称' })).toHaveValue('创作资料'); expect(screen.queryByText('管理员配置说明')).not.toBeInTheDocument(); fireEvent.change(screen.getByRole('combobox', { name: '向量模型' }), { target: { value: 'relay:fixture-embedding' } }); - expect(screen.getByRole('button', { name: '创建并选用' })).toBeEnabled(); + expect(screen.getByRole('button', { name: '创建知识库' })).toBeEnabled(); }); it('shows loading instead of suggesting configuration is missing before the catalog arrives', () => { @@ -64,7 +63,7 @@ it('clears a removed selection after refresh so a new create cannot use an unava fireEvent.change(screen.getByRole('combobox', { name: '向量模型' }), { target: { value: 'relay:fixture-embedding' } }); fireEvent.click(screen.getByRole('button', { name: '刷新知识库与模型' })); await waitFor(() => expect(screen.getByRole('combobox', { name: '向量模型' })).toHaveValue('')); - expect(screen.getByRole('button', { name: '创建并选用' })).toBeDisabled(); + expect(screen.getByRole('button', { name: '创建知识库' })).toBeDisabled(); expect(api.call.mock.calls.every(([operation]) => operation === 'knowledge')).toBe(true); }); @@ -78,7 +77,7 @@ it('preserves an uncertain creation intent when its model disappears on refresh' await screen.findByRole('option', { name: '资料向量模型' }); fireEvent.change(screen.getByRole('textbox', { name: '知识库名称' }), { target: { value: '创作资料' } }); fireEvent.change(screen.getByRole('combobox', { name: '向量模型' }), { target: { value: 'relay:fixture-embedding' } }); - fireEvent.click(screen.getByRole('button', { name: '创建并选用' })); + fireEvent.click(screen.getByRole('button', { name: '创建知识库' })); await screen.findByRole('alert'); fireEvent.click(screen.getByRole('button', { name: '刷新知识库与模型' })); await screen.findByText('管理员配置说明'); diff --git a/tests/unit/cloud-knowledge-picker.test.tsx b/tests/unit/cloud-knowledge-picker.test.tsx index 48284f34..d72405ca 100644 --- a/tests/unit/cloud-knowledge-picker.test.tsx +++ b/tests/unit/cloud-knowledge-picker.test.tsx @@ -19,9 +19,7 @@ function Harness({ initial = [], disabled = false, agentSlug = slug }: { initial return { changes(next); setValue(next); }} />; } function choose(id: string) { fireEvent.change(screen.getByRole('combobox', { name: '选择知识库' }), { target: { value: id } }); } -function openUpload() { fireEvent.click(screen.getByText('上传自己的文件')); } async function openOwn(readyText = '上传第一份文档,让智能体用你的资料回答。') { - openUpload(); await within(screen.getByRole('combobox', { name: '管理的知识库' })).findByRole('option', { name: own.name }); fireEvent.change(screen.getByRole('combobox', { name: '管理的知识库' }), { target: { value: own.kb_id } }); await screen.findByText(readyText); @@ -47,6 +45,20 @@ it('joins readable IDs to the owned-only catalog and selects multiple libraries const sharedGroup = await screen.findByRole('group', { name: 'MakeLore知识' }); expect(within(sharedGroup).getByRole('option', { name: '创作指南' })).toBeInTheDocument(); expect(within(screen.getByRole('group', { name: '我的知识库' })).getByRole('option', { name: own.name })).toBeInTheDocument(); + const creation = screen.getByRole('region', { name: '上传自己的文件', exact: true }); + const management = screen.getByRole('region', { name: '查看我的知识库', exact: true }); + const selection = screen.getByRole('region', { name: '选择知识库', exact: true }); + expect(within(creation).getByRole('button', { name: '新建知识库', exact: true })).toBeVisible(); + expect(within(creation).queryByRole('combobox', { name: '管理的知识库' })).not.toBeInTheDocument(); + expect(within(creation).queryByRole('button', { name: '从电脑上传', exact: true })).not.toBeInTheDocument(); + expect(within(management).getByRole('combobox', { name: '管理的知识库' })).toBeVisible(); + expect(within(management).getByRole('button', { name: '从电脑上传', exact: true })).toBeDisabled(); + expect(within(management).queryByRole('button', { name: '新建知识库', exact: true })).not.toBeInTheDocument(); + expect(within(selection).getByRole('combobox', { name: '选择知识库', exact: true })).toBeVisible(); + expect(creation.compareDocumentPosition(management) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(management.compareDocumentPosition(selection) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(screen.queryByText('添加到我的知识库', { exact: true })).not.toBeInTheDocument(); + expect(screen.queryByText('选择或新建自己的知识库,再从电脑上传资料。选用会加入右侧配置,保存并应用后生效。', { exact: true })).not.toBeInTheDocument(); expect(screen.getByText('可多选,每次选择一个知识库加入配置。')).toBeVisible(); choose('shared'); choose(own.kb_id); choose('other-shared'); expect(within(screen.getByRole('list', { name: '已选知识库' })).getAllByRole('listitem')).toHaveLength(3); @@ -60,7 +72,7 @@ it('joins readable IDs to the owned-only catalog and selects multiple libraries it('never offers shared libraries to document management or enables their upload controls', async () => { databases = []; render(); - await screen.findByText('MakeLore知识 · 只读'); openUpload(); + await screen.findByText('MakeLore知识 · 只读'); const manager = screen.getByRole('combobox', { name: '管理的知识库' }); expect(within(manager).queryByRole('option', { name: '创作指南' })).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: '从电脑上传', exact: true })).toBeDisabled(); @@ -76,7 +88,7 @@ it('allows readable binding after an owned-list failure while withholding docume render(); await screen.findByText('我的知识库目录暂不可用,仍可选用可读取的知识;上传文件请刷新重试。'); expect(screen.queryByRole('group', { name: 'MakeLore知识' })).not.toBeInTheDocument(); - choose('shared'); openUpload(); + choose('shared'); expect(screen.getByRole('combobox', { name: '管理的知识库' })).toBeDisabled(); expect(screen.getByRole('button', { name: '从电脑上传', exact: true })).toBeDisabled(); failed = false; @@ -99,48 +111,92 @@ it('retains missing IDs for removal during catalog failure instead of treating t expect(changes).toHaveBeenLastCalledWith(expect.objectContaining({ knowledges: [] })); }); -it('enforces the 100-library limit and restores selection after removal', async () => { +it('limits bindings to 100 libraries without preventing browsing or creation', async () => { const selected = Array.from({ length: 100 }, (_, index) => 'old-' + index); + const original = api.call.getMockImplementation()!; + const created = { kb_id: 'new-own', name: '新资料' }; + api.call.mockImplementation((operation, input) => { + if (operation === 'createKnowledge') { + databases.push(created); + catalog.resources.knowledges!.push({ key: created.kb_id, name: created.name }); + return Promise.resolve(created); + } + return original(operation, input); + }); render(); await screen.findByRole('group', { name: 'MakeLore知识' }); expect(screen.getByRole('combobox', { name: '选择知识库' })).toBeDisabled(); - openUpload(); expect(screen.getByRole('button', { name: '新建知识库' })).toBeDisabled(); + await openOwn(); + expect(screen.getByRole('button', { name: '从电脑上传', exact: true })).toBeEnabled(); + expect(changes).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: '新建知识库' })); + fireEvent.change(screen.getByRole('textbox', { name: '知识库名称' }), { target: { value: created.name } }); + fireEvent.change(screen.getByRole('combobox', { name: '向量模型' }), { target: { value: 'embedding' } }); + expect(screen.getByRole('button', { name: '创建知识库', exact: true })).toBeEnabled(); + fireEvent.click(screen.getByRole('button', { name: '创建知识库', exact: true })); + await waitFor(() => expect(screen.getByRole('combobox', { name: '管理的知识库' })).toHaveValue(created.kb_id)); + expect(changes).not.toHaveBeenCalled(); + expect(screen.getByRole('combobox', { name: '选择知识库' })).toBeDisabled(); fireEvent.click(screen.getByRole('button', { name: '移除知识库 old-0' })); - choose('shared'); + await within(screen.getByRole('combobox', { name: '选择知识库' })).findByRole('option', { name: created.name }); + choose(created.kb_id); expect(changes.mock.calls.at(-1)?.[0].knowledges).toHaveLength(100); - expect(changes.mock.calls.at(-1)?.[0].knowledges).toContain('shared'); + expect(changes.mock.calls.at(-1)?.[0].knowledges).toContain(created.kb_id); }); -it('selects an existing owned library for uploads and cancelling a file picker does not process a file', async () => { +it('browses an owned library without binding it and only explicit selection changes the draft', async () => { render(); await openOwn(); - expect(changes).toHaveBeenLastCalledWith(expect.objectContaining({ knowledges: ['shared', own.kb_id] })); + expect(changes).not.toHaveBeenCalled(); + expect(screen.queryByRole('button', { name: '移除知识库 ' + own.name })).not.toBeInTheDocument(); api.uploadKnowledge.mockResolvedValue(null); fireEvent.click(screen.getByRole('button', { name: '从电脑上传', exact: true })); await waitFor(() => expect(api.uploadKnowledge).toHaveBeenCalledTimes(1)); await waitFor(() => expect(screen.getByRole('button', { name: '从电脑上传', exact: true })).toBeEnabled()); expect(api.call.mock.calls.every(([operation]) => operation !== 'processKnowledge')).toBe(true); expect(screen.getByRole('button', { name: '移除知识库 创作指南' })).toBeVisible(); + expect(changes).not.toHaveBeenCalled(); + choose(own.kb_id); + expect(changes).toHaveBeenLastCalledWith(expect.objectContaining({ knowledges: ['shared', own.kb_id] })); }); -it('appends a late creation result to the latest bindings after interleaved selection and removal', async () => { +it('keeps an upload failure in the management column while the creation form is open', async () => { + api.uploadKnowledge.mockRejectedValue(new Error('上传结果未确认')); + render(); + await openOwn(); + const creation = screen.getByRole('region', { name: '上传自己的文件', exact: true }); + const management = screen.getByRole('region', { name: '查看我的知识库', exact: true }); + fireEvent.click(within(creation).getByRole('button', { name: '新建知识库', exact: true })); + fireEvent.change(within(creation).getByRole('textbox', { name: '知识库名称' }), { target: { value: '还没创建的资料' } }); + fireEvent.click(within(management).getByRole('button', { name: '从电脑上传', exact: true })); + expect(await within(management).findByRole('alert')).toHaveTextContent('上传结果未确认'); + expect(within(creation).queryByRole('alert')).not.toBeInTheDocument(); + expect(within(creation).getByRole('textbox', { name: '知识库名称' })).toHaveValue('还没创建的资料'); + expect(changes).not.toHaveBeenCalled(); +}); + +it('keeps a late creation separate from interleaved draft changes until explicitly selected', async () => { catalog.models.push({ id: 'model2', name: '另一个模型' }); let resolve!: (value: CloudKnowledge) => void; const original = api.call.getMockImplementation()!; api.call.mockImplementation((operation, input) => operation === 'createKnowledge' ? new Promise(value => { resolve = value; }) : original(operation, input)); render(); - await screen.findByRole('group', { name: '我的知识库' }); openUpload(); + await screen.findByRole('group', { name: '我的知识库' }); fireEvent.click(screen.getByRole('button', { name: '新建知识库' })); fireEvent.change(screen.getByRole('textbox', { name: '知识库名称' }), { target: { value: '新资料' } }); fireEvent.change(screen.getByRole('combobox', { name: '向量模型' }), { target: { value: 'embedding' } }); - fireEvent.click(screen.getByRole('button', { name: '创建并选用' })); + fireEvent.click(screen.getByRole('button', { name: '创建知识库' })); choose('other-shared'); fireEvent.change(screen.getByRole('combobox', { name: '模型' }), { target: { value: 'model2' } }); fireEvent.click(screen.getByRole('button', { name: '移除知识库 创作指南' })); const created = { kb_id: 'new-own', name: '新资料' }; databases.push(created); catalog.resources.knowledges!.push({ key: created.kb_id, name: created.name }); await act(async () => resolve(created)); - await screen.findByRole('button', { name: '移除知识库 新资料' }); + await waitFor(() => expect(screen.getByRole('combobox', { name: '管理的知识库' })).toHaveValue(created.kb_id)); + expect(screen.queryByRole('button', { name: '移除知识库 新资料' })).not.toBeInTheDocument(); + expect(changes).toHaveBeenLastCalledWith(expect.objectContaining({ model: 'model2', knowledges: ['other-shared'] })); + await within(screen.getByRole('combobox', { name: '选择知识库' })).findByRole('option', { name: created.name }); + choose(created.kb_id); expect(changes).toHaveBeenLastCalledWith(expect.objectContaining({ model: 'model2', knowledges: ['other-shared', 'new-own'] })); }); @@ -284,22 +340,24 @@ it('finishes an authorized upload through a failed ownership refresh and does no expect(api.call.mock.calls.filter(([operation]) => operation === 'processKnowledge')).toHaveLength(1); }); -it('adds an already authorized creation result while the editor is temporarily disabled by saving', async () => { +it('retains an authorized creation for management while saving without altering draft bindings', async () => { let resolve!: (value: CloudKnowledge) => void; const original = api.call.getMockImplementation()!; api.call.mockImplementation((operation, input) => operation === 'createKnowledge' ? new Promise(value => { resolve = value; }) : original(operation, input)); const view = render(); - await screen.findByRole('group', { name: '我的知识库' }); openUpload(); + await screen.findByRole('group', { name: '我的知识库' }); fireEvent.click(screen.getByRole('button', { name: '新建知识库' })); fireEvent.change(screen.getByRole('textbox', { name: '知识库名称' }), { target: { value: '新资料' } }); fireEvent.change(screen.getByRole('combobox', { name: '向量模型' }), { target: { value: 'embedding' } }); - fireEvent.click(screen.getByRole('button', { name: '创建并选用' })); + fireEvent.click(screen.getByRole('button', { name: '创建知识库' })); view.rerender(); const created = { kb_id: 'new-own', name: '新资料' }; databases.push(created); catalog.resources.knowledges!.push({ key: created.kb_id, name: created.name }); await act(async () => resolve(created)); - expect(changes).toHaveBeenLastCalledWith(expect.objectContaining({ knowledges: ['shared', 'new-own'] })); + await waitFor(() => expect(screen.getByRole('combobox', { name: '管理的知识库' })).toHaveValue(created.kb_id)); + expect(changes).not.toHaveBeenCalled(); + expect(screen.queryByRole('button', { name: '移除知识库 新资料' })).not.toBeInTheDocument(); expect(screen.getByRole('combobox', { name: '选择知识库' })).toBeDisabled(); }); @@ -307,7 +365,7 @@ it('keeps an owned but unusable library manageable without adding an invalid bin const unusable = { kb_id: 'disabled-embedding', name: '旧资料' }; databases.push(unusable); render(); await screen.findByRole('button', { name: '移除知识库 旧资料' }); - expect(screen.getByText('已不可用')).toBeVisible(); openUpload(); + expect(screen.getByText('已不可用')).toBeVisible(); const manager = screen.getByRole('combobox', { name: '管理的知识库' }); expect(within(manager).getByRole('option', { name: '旧资料(当前暂不可选用)' })).toBeEnabled(); fireEvent.click(screen.getByRole('button', { name: '移除知识库 旧资料' })); diff --git a/tests/unit/cloud-knowledge-status.test.tsx b/tests/unit/cloud-knowledge-status.test.tsx index 4b442636..01abc206 100644 --- a/tests/unit/cloud-knowledge-status.test.tsx +++ b/tests/unit/cloud-knowledge-status.test.tsx @@ -24,7 +24,6 @@ beforeEach(() => { afterEach(() => { cleanup(); vi.useRealTimers(); }); async function open() { render( undefined} />); - fireEvent.click(screen.getByText('上传自己的文件')); await screen.findByRole('option', { name: '资料' }); fireEvent.change(screen.getByLabelText('管理的知识库'), { target: { value: 'kb' } }); } @@ -38,6 +37,8 @@ it('uploads then processes automatically and refreshes through queue, parse, ind api.uploadKnowledge.mockImplementation(async () => { items = [file]; return file; }); await act(async () => { fireEvent.click(screen.getByRole('button', { name: '从电脑上传', exact: true })); }); expect(screen.getByText('等待处理')).toBeVisible(); + expect(within(screen.getByRole('region', { name: '查看我的知识库' })).getByRole('article', { name: file.name })).toBeVisible(); + expect(within(screen.getByRole('region', { name: '上传自己的文件' })).queryByRole('article')).not.toBeInTheDocument(); expect(screen.queryByText(/0 个分块/)).not.toBeInTheDocument(); expect(api.call).toHaveBeenCalledWith('processKnowledge', expect.objectContaining({ kb_id: 'kb', file_id: 'file' })); items = [{ ...file, status: 'parsing', processing_task: { task_id: 'task', status: 'running', error: null } }]; From 2d4e44f4a023fc649d9064d4413284fbeb962d59 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 14:29:33 +0800 Subject: [PATCH 5/8] build(trial): preserve deployed source for knowledge refresh --- ...-chat-knowledge-import-explain-ec03b792.md | 13 +- README.md | 11 +- electron/api/routes/cloud-agents.ts | 5 +- electron/services/cloud-agent-journal.ts | 7 +- electron/services/cloud-agent-operations.ts | 19 +- electron/services/cloud-agents.ts | 118 ++++++-- shared/cloud-agents.ts | 8 + src/components/account/HomeAccountMenu.tsx | 103 +++++++ src/pages/CloudAgents/ChannelAccounts.tsx | 12 +- .../CloudAgents/CloudChannelConversations.tsx | 5 +- src/pages/CloudAgents/CloudChat.tsx | 56 ++-- src/pages/CloudAgents/CloudLifecycle.tsx | 2 +- src/pages/CloudAgents/CloudMessageBody.tsx | 36 +++ src/pages/CloudAgents/CloudRecovery.tsx | 91 ++++-- src/pages/CloudAgents/CloudSchedules.tsx | 2 +- src/pages/CloudAgents/ConfigurationFields.tsx | 2 +- src/pages/CloudAgents/agent-home.css | 20 ++ src/pages/CloudAgents/cloud-chat-stream.ts | 133 ++++++--- src/pages/CloudAgents/index.tsx | 36 ++- src/pages/ModuleSelection/index.tsx | 23 +- src/styles/globals.css | 72 ++++- tests/e2e/cloud-agent-channels.spec.ts | 63 +++++ tests/e2e/cloud-agent-chat-layout.spec.ts | 6 +- tests/e2e/cloud-agent-home.spec.ts | 2 +- tests/e2e/cloud-agent-knowledge.spec.ts | 8 +- .../e2e/cloud-agent-navigation-layout.spec.ts | 4 +- .../e2e/cloud-agent-platform-avatars.spec.ts | 4 +- tests/e2e/cloud-agent-recovery.spec.ts | 220 +++++++++++++++ tests/e2e/cloud-agents.spec.ts | 50 +++- tests/e2e/cloud-chat-rendering.spec.ts | 101 +++++++ tests/e2e/main-navigation.spec.ts | 261 +++++++++++++----- tests/unit/channel-accounts-page.test.tsx | 89 +++++- tests/unit/cloud-agent-avatar-routes.test.ts | 25 +- tests/unit/cloud-agents-main.test.ts | 153 ++++++++++ tests/unit/cloud-agents-page.test.tsx | 237 +++++++++++++++- tests/unit/cloud-agents-workflows.test.tsx | 4 +- tests/unit/cloud-chat-reasoning.test.tsx | 38 +-- tests/unit/cloud-chat-rendering.test.tsx | 185 +++++++++++++ tests/unit/cloud-chat-stream.test.ts | 14 +- tests/unit/cloud-recovery.test.tsx | 209 ++++++++++++++ tests/unit/home-account-menu.test.tsx | 134 +++++++++ tests/unit/module-navigation.test.tsx | 23 +- 42 files changed, 2321 insertions(+), 283 deletions(-) create mode 100644 src/components/account/HomeAccountMenu.tsx create mode 100644 src/pages/CloudAgents/CloudMessageBody.tsx create mode 100644 tests/e2e/cloud-agent-recovery.spec.ts create mode 100644 tests/e2e/cloud-chat-rendering.spec.ts create mode 100644 tests/unit/cloud-chat-rendering.test.tsx create mode 100644 tests/unit/cloud-recovery.test.tsx create mode 100644 tests/unit/home-account-menu.test.tsx diff --git a/.project-docs/30-worklog/tasks/20261013-chat-knowledge-import-explain-ec03b792.md b/.project-docs/30-worklog/tasks/20261013-chat-knowledge-import-explain-ec03b792.md index 1cb27180..11301319 100644 --- a/.project-docs/30-worklog/tasks/20261013-chat-knowledge-import-explain-ec03b792.md +++ b/.project-docs/30-worklog/tasks/20261013-chat-knowledge-import-explain-ec03b792.md @@ -8,7 +8,7 @@ - Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261013-chat-knowledge-import-explain-ec03b792 - Base commit: c634425cc669f1da66e725649a386bf09661a70c - Owner: codex -- Status: Ready for Integration +- Status: Planning ## Scope @@ -19,7 +19,7 @@ - User initially requested explanation; the latest explicit instruction authorizes removing chat-file import and retaining computer upload. This supersedes the previous feature's approved chat-import entry. - Earlier steering placed creation beneath computer upload; the latest explicit layout supersedes that button order: the left creation card contains only the new-library entry/form; owned-library browsing and creation do not bind a library to the Agent. Binding remains an explicit bottom selection, and its 100-library limit does not block management or creation. Delete the old instruction paragraph and 「添加到我的知识库」 label. -- Keep real files/chats/libraries, runtime, Main/backend APIs and old recovery records. No actual imports, deletion of user data, app update, main integration or push. Own only removal-related UI/copy/style/tests and this record. +- Keep real files/chats/libraries, Main/backend APIs and old recovery records. The user's latest instruction explicitly authorizes updating the current temporary application; it supersedes the earlier no-update constraint. No actual imports, deletion of user data, main integration or push. Preserve current deployed homepage/recovery/chat and byte-identical Main by assembling only committed current product changes before the knowledge Renderer overlay. ## Outcome @@ -62,3 +62,12 @@ - Restructure owned-library UI into a top two-column creation/management row and a bottom binding card. Remove creation injection from the document list and automatic bindings on create/browse; preserve pending operations and permission-loss state. - Update README and focused tests. Independently verify creation/browsing with full binding selection and verify 375px/1440px geometry, upload/status, explicit selection/save/reopen. - Run focused knowledge suites, typecheck, scoped lint, build and headless fixture; inspect screenshots and independent source review, verify task document boundaries and commit the source handoff. + +## Temporary Application Follow-up + +- Concurrent Gate: bundled start resumed this same task successfully; status exactly matches feature/task/branch/absolute worktree/base. All peer Scope/Intent/Promotion records read-only. Required project context is retained and startup/positioning/current-state rechecked. Canonical current-state is historical integration context, not the deployed runtime authority. +- Planning Gate: Passed for isolated assembly and staging. Active 「首页更新」 chat is correcting its homepage/account/settings UI and owns concurrent work for the same temporary runtime. Keep its dirty source read-only; bind to the latest committed/verified deployed source and fresh exact current pointer. An active chat need not finish all future steering; serialize actual refresh via inspector exclusivity and pointer/asset identity guards, and rebase if the pointer advances. Unknown legacy scope has no concrete dependency. +- User explicitly authorizes updating the existing trial with finished knowledge source 8e0e52f. Current observed Main is 7c51bb4 / PID9968 / port58003; current Renderer/effective source initially a9fa221 over the home-position overlay. Keep profile/standard login, stable file URL and route, current accepted homepage/recovery/chat, and foreground continuity. No account action, save/discard, real upload/Agent creation/chat, cloud deployment or credential read during verification. +- Plan: assemble the finished knowledge delta over the latest committed deployed product in this owned worktree, keeping canonical/peer task docs read-only. Verify relevant combined source tests/typecheck/lint/build and full Main/resources/package byte equality; prepare a task-owned immutable snapshot with rollback and bound predecessor proof. Activate only after the homepage update settles and a fresh saved/quiet-state check passes; verify actual knowledge layout and original route/session/assets/focus, retaining all rollback artifacts. +- Assembly implemented over deployed a9fa221: imported its committed product/test changes, preserving only this task's knowledge source/README delta. Actual product blobs differ in exactly the four knowledge source/styles files and removed attachment picker; Electron/shared/resources/package/lock match deployed source. Knowledge fixture keeps a9's normalizePath portability fixes; page regression keeps its create/recovery cases and this task's removed old heading click. Independent read-only source review confirms complete current create/journal/onSettled/CloudRecovery/chat DTO/history/stream/message-rendering chains retained. +- Combined verification before staging: 10 focused unit files / 224 tests passed; typecheck and full build passed; 38 changed source/test ESLint files have no errors and retain two existing CloudChat warnings. Knowledge fixture 5/5 passed in 18.2s, including 375/768/1024/1440 geometry and explicit binding/upload-to-ready/save-reopen. All 66 non-Renderer artifacts exactly match immutable running Main/resources/package. The first set comparison detected two unreferenced leftover chunks from this worktree's earlier build; verified that no current JS references them and removed only those generated files, then repeated the strict exact-set comparison successfully. diff --git a/README.md b/README.md index bf64b5ef..fa404db3 100644 --- a/README.md +++ b/README.md @@ -8,17 +8,19 @@ Code 会话保存的模型被下架后,会提示重新选择可用模型;用户可在原会话中切换,恢复时使用新选择并保留会话与历史消息。切换不会自动重发消息或自动选择替代模型。 -Makelore 是一个面向软件、视觉创作、智能机器人与个人云智能体的 AI 桌面工作台。当前版本为 `2.0.0`,源码提供四个模块入口;云智能体需配套配置 WS/Yuxi 服务。模块入口页按 Agents、Code、Canvas、Robot 排列,统一采用横向插画卡片;Agents 标语为“打造你想象中的AI助手”。工作区左上角入口点击后返回模块入口页: +Makelore 是一个面向软件、视觉创作、智能机器人与个人云智能体的 AI 桌面工作台。当前版本为 `2.0.0`,首页展示 Agents、Code、Canvas 三个模块入口,Robot 入口隐藏;云智能体需配套配置 WS/Yuxi 服务。桌面采用左侧问候与标题、右侧三个横向插画卡片竖排的布局,Code 入口中心与“今天,想创作什么?”标题中心对齐,三个入口等高、间距均匀;标题组在桌面向右偏移约 1.5cm,小窗口将标题放在卡片上方;Agents 标语为“打造你想象中的AI助手”。首页左下角保留半尺寸的账户入口,向左偏移约 1.5cm(小窗口保留边距)并靠近底部;展开菜单保持原尺寸,可查看本人词元点数与预占、打开词元点数与充值、个人资料、设置和退出登录;沿用侧栏的账号、钱包和资料规则。工作区左上角入口点击后返回模块入口页: - `Makelore Code|AI 编程`:管理本地项目、项目智能体、对话、文件上下文、代码变更和运行时。 会话工作台采用浅蓝项目栏、彩色通用手柄图标与卡片选中态;中间对话/作品使用圆角标签,作品预览的后退、前进、刷新与网址输入统一放在标签右侧;只在作品页显示,窄窗口自动换行。右侧智能体咨询使用奶油色标题区。沿用真实项目和会话入口、侧栏伸缩与账户菜单,样式只作用于 Code 会话工作台。可编辑的空对话在输入区上方的可用区域中央显示童趣欢迎语;「去作品广场逛逛」入口紧贴输入框上方,两者一起位于底部。老师介绍以固定宽度浮在右上角,不占用中间布局;欢迎语和底部输入区始终相对项目栏右侧的整个内容区域居中;小窗口的欢迎内容独立滚动;发送首条消息后收起欢迎区。侧栏保留「作品广场」入口,只有点击后才在系统浏览器打开公共广场,不请求、摘取或内嵌展示广场作品。打开广场保留当前项目、对话、草稿和附件,不自动发送消息;老师求助按钮保留临时应用的最新文案「帮我整体看🧠」。 -- `Makelore Agents|AI 智能体`:从模块首页进入时先打开“我的智能体”卡片列表,保留此前对话与操作恢复记录;返回首页箭头与侧栏折叠按钮同排,折叠后仍可返回;智能体模块的侧栏只通过按钮点击或键盘激活展开,鼠标经过左侧、收纳按钮或顶栏不会展开,切换模块不沿用旧悬停预览;其他模块仍保留原预览方式。“我的智能体”使用浅紫对话机器人图标,“渠道”使用浅蓝连接节点图标。macOS 原生窗口按钮留出安全空间。首页围绕自己的智能体和创建入口,显示童趣问候“Hi,今天想创造还是想聊聊?”,不再显示更多、收到的分享、活动或归档筛选。卡片恢复原有尺寸与留白,显示名称、可为空的一句话介绍及状态,支持聊天、调整、继续制作和确认删除;删除沿用云端归档机制,移出列表并停用新调用,历史和费用保留。创建先填写名称并选择平台像素头像或上传头像,下一步进入独立配置。顶部名称有编辑图标,头像可点击修改;未设置头像时使用同一张默认像素头像,从创建到配置、改名、保存和重新打开保持一致。基础页为“介绍与提示词”,一句话介绍可留空,完整提示词保留原始文本;模型作为高级设置第一项必选,未选择时不能开始使用,草稿可分步保存,不自动选模型。介绍与提示词采用浅蓝介绍卡和浅紫提示词卡;高级设置按模型、工具箱(Tool)、做事手册(Skill)、MCP 服务、子智能体和知识库纵向呈现,不再设内部分类切换;不展示 Tool/Skill 解释、资源开放规则提示、云端能力管理、费用上限或运行限制卡片,保存其他配置时保留既有运行限制,费用规则不变。顶层页签切换保留搜索、资源选择、知识表单及未保存输入。配置页移除聊天、自动任务和使用与接入导航,独立聊天仍从卡片进入,微信账号仍在侧栏渠道管理。草稿明确保存(Ctrl/⌘+S);保存只更新草稿,“应用最新修改”使新配置生效,失败重试保持原操作身份;点击“开始使用”完成必要的私有启用后直接打开聊天,不再展示二次确认页;未保存修改、缺少模型、冲突或尚未确认的请求会留在配置页处理,打开聊天不自动发送消息。独立对话沿用奶油底色和浅蓝、浅紫、薄荷色:顶部展示返回、只读头像和名称;有附件时在同一行右侧显示回形针“附件”入口和文件数量,不展示调整、保存/应用状态、客户端/微信来源、费用/版本说明或历史/新对话图标;配置由列表卡片的“调整”进入。空对话展示当前智能体头像与名称,示例只填入草稿;用户消息使用浅紫圆角气泡,助手 Markdown、表格和代码在消息区内滚动。文件或图片从输入框内添加,上传成功后以待发送卡片呈现,可移除本次携带;移除不会删除云端文件,添加不会自动发送。确认失败沿用同一文件和对话重试,发送前需完成或取消添加;支持仅发送附件,使用明确的附件提问。输入框的“+”添加文件或图片;右上角“附件”打开右侧面板,按“你上传的”和“助手生成的”分组,支持再次选用已上传附件及保存明确交付的生成文件。生成文件仅取当前对话已保存助手回复中的本地文件链接(含图片与引用式链接),核对实际文件后展示和计数;只读取链接所在的文件夹,不遍历全部工作文件。未交付的脚本、缓存和工具记录不展示、不计数,明确交付的代码照常展示;仅写了路径的旧成果可能暂时隐藏,后续接入 Yuxi 的正式交付记录。没有文件时隐藏入口;打开、关闭面板保留聊天与输入,Escape 关闭并返回入口焦点;最近对话自动恢复、审批、停止和未发送内容的离开保护保留。模型返回的思考独立于正文展示,运行时可展开、手动折叠,完成后折叠;无思考内容时不显示。重连事件去重并隔离当前运行与主对话,终态历史未到达时保留已收到的片段并有限补读;服务端返回的最终回答思考可在历史中回看,不进入后续提问。历史回看需要 Yuxi API 思考投影补丁上线,当前未部署;实时思考沿用既有事件流。头像上传、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 的空字符串契约。 + + 历史待确认记录收起在智能体列表标题区的「创建记录/待处理记录」入口,只有点击后才打开记录窗口;配置、聊天和创建页不再置顶这些旧提醒。记录保留智能体名称和原请求身份,用户可明确「再试一次」或「不再提醒」,不会自动重试或清除。当前创建失败继续在创建表单内提示;关闭记录窗口保留已收到的结果和密钥,切换配置、聊天或创建页后可返回列表继续查看。密钥须明确关闭结果后,才可处理其他记录。 高级配置的知识库支持下拉多选,分为「MakeLore知识」和「我的知识库」,已选项可单独移除。MakeLore知识来自当前账号有权读取的 Yuxi 共享目录,只显示名称与简介;移除仅取消当前智能体选用,不删除原库。知识库区域上方左右分区:左侧「上传自己的文件」仅放「新建知识库」入口及展开的创建表单,右侧「查看我的知识库」通过下拉框查看本人知识库、从电脑追加资料和管理文件;下方独立「选择知识库」,选择智能体使用的「MakeLore知识」和「我的知识库」。小窗口按新建、查看、选用顺序纵向排列。创建成功后在右侧打开新库;新建和查看均不会自动加入智能体配置,选用上限不影响管理与新建。仅提供「从电脑上传」作为添加文件入口,未选择管理目标时上传按钮置灰;聊天文件导入入口已移除。上传后自动解析、建立索引并持续显示真实处理状态;失败可重试且保留已有可用内容。使用说明与费用以折叠说明呈现。暂不可选用的个人库仍可管理文件,已有绑定保留并提示不可用;未确认请求在当前库保留同一操作身份供重试。知识库选择只改草稿,显式保存/应用后生效;个人云端执行已自动接入所选知识库的检索,无需另外勾选 knowledge-base Skill。此交互复用现有接口,无需新增后端补丁。 云知识库的 embedding 模型只由平台管理员在 Yuxi 后台维护:在「智能体管理 → 模型供应商」启用供应商,按供应商要求填写有效的服务地址和 API Key,添加并启用 embedding 模型并填写实际向量维度;embedding 向量化费用由平台承担,不计入用户用量、词元点数或智能体费用上限;one-api 不参与 embedding 配置,Works Square 只负责其他应计费模型调用的创建者词元点数账本。客户端区分目录加载中、加载失败与无可用模型,空状态提供管理员配置说明;「刷新知识库与模型」保留未提交名称,未确认的创建请求沿用原操作身份。 - Agents 侧栏的「渠道」用于把自己的智能体接到微信。每个微信一张卡,展示备注、绑定的智能体头像/名称和是否可用;一个账号同时只有一个目标,多个账号可以使用同一智能体。添加流程为选择自己已开始使用的智能体、扫码连接、明确完成绑定并启用;备注可选,扫码确认后收起二维码与验证码,未确认或启用失败不显示完成。管理已有账号不会重新创建账号;更换目标保留微信登录与原启停状态,解除智能体绑定清空目标并停用但保留微信登录,暂停保留绑定/登录并停止该渠道当前任务,断开微信连接则需要重新扫码。更换、解除和断开有明确确认,历史保留。渠道配置页不展示微信对话或活动,不读取这些列表;聊天在微信中进行,既有云端历史、文件、审批、费用和账号隔离合同不变。页面保留加载失败重试、未确认操作恢复,创建后绑定失败可继续使用原账号,重试沿用原操作身份;仅添加/扫码不自动启用。实际微信身份与重复扫码识别仍由云端负责,客户端不以备注推测同一微信。渠道触发的应计费模型调用仍由智能体创建者支付,Main 持有认证与恢复;用户无需填写服务凭据。应用 API 管理独立于微信渠道。 + Agents 侧栏的「渠道」用于把自己的智能体接到微信。每个微信一张卡,展示备注、绑定的智能体头像/名称和是否可用;一个账号同时只有一个目标,多个账号可以使用同一智能体。没有可连接的智能体时,渠道页和添加窗口说明需先创建、配置并点击“开始使用”,提供“去我的智能体”入口;加载失败不显示该空态,已有微信卡片仍可管理。添加流程为选择自己已开始使用的智能体、扫码连接、明确完成绑定并启用;备注可选,扫码确认后收起二维码与验证码,未确认或启用失败不显示完成。管理已有账号不会重新创建账号;更换目标保留微信登录与原启停状态,解除智能体绑定清空目标并停用但保留微信登录,暂停保留绑定/登录并停止该渠道当前任务,断开微信连接则需要重新扫码。更换、解除和断开有明确确认,历史保留。渠道配置页不展示微信对话或活动,不读取这些列表;聊天在微信中进行,既有云端历史、文件、审批、费用和账号隔离合同不变。页面保留加载失败重试、未确认操作恢复,创建后绑定失败可继续使用原账号,重试沿用原操作身份;仅添加/扫码不自动启用。实际微信身份与重复扫码识别仍由云端负责,客户端不以备注推测同一微信。渠道触发的应计费模型调用仍由智能体创建者支付,Main 持有认证与恢复;用户无需填写服务凭据。应用 API 管理独立于微信渠道。 - `Makelore Canvas|AI 绘画`:每个设计项目(Workspace)维护一份从创建起就存在的 Living Form。左侧项目栏负责新建、切换和管理 Workspace,并在桌面设计模式下以 256px 宽度常驻展开;中央沿用 AI 编程的安静对话画布、自然消息流和底部悬浮输入器,AI 整理出的制作方案作为对话内的轻量可编辑稿持续更新;桌面端右侧同为 256px 的全高历史作品栏集中展示当前项目的制作记录与生成结果。任务中的已生成图片可通过“放大查看图片”按钮直接打开大图,支持适应窗口、原始尺寸及 Esc 关闭,无需先下载。紧凑窗口通过左侧抽屉访问项目列表,历史记录保留在时间线中。参考图从本地上传后以 `@图片N` 绑定,具体用法只写在创作提示词中。 - `Makelore Robot|AI 机器`:管理机器人智能体、设备激活绑定、智能体配置与设备分配;机器人工作台的智能体位于 Robot 全局侧栏,选中后在内容区先查看绑定设备、再查看基础设置,当前智能体通过 URL 参数保持可分享选择;绑定设备时默认先选择“引导配网”或“已有激活码”。在 Windows 与 macOS 的引导路径中,Makelore 可在弹窗内扫描并连接附近开放的 `Xiaozhi-*` 配网热点,失败时仍可通过系统 Wi-Fi 手动连接;后续继续复用机器人现有热点配网页面,不修改固件,也不由 Makelore 接收 Wi-Fi 密码。 @@ -27,6 +29,8 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智 作品广场、素材广场、独立发布上传和云部署页面不属于 Makelore 2.0 工作台。新建 Code 项目只要求选择目录:Main 自动生成内部项目 ID,并以内部 `interactive_ai_app` 类型创建 `.makelore/project.json` 与 `knowledge/`,不再让用户选择或查看项目身份、项目类型和模板;缺少项目 ID 的旧项目在读取时由 Main 自动补全。从列表移除项目只取消登记,不删除磁盘文件;在“新建项目”中直接选择已有项目文件夹会重新打开,并保留原有项目身份、类型、智能体、对话和知识文件。“新建下级文件夹”仍拒绝已存在的同名目录。创建成功后直接进入对话工作区,未创建智能体时只显示可选的设置入口,不再用初始化门禁遮挡工作区。已有 `custom` 项目继续受支持;历史 `mini_game` / `mini_program` 配置在读取时归一为交互式 AI 应用,但不会因读取被改写。用户获取并为项目启用官方 bundled `makelore.project-scaffold` 插件后,每个父智能体都可按需明确调用 `makelore-project-scaffold` Skill,无需伙伴分配;它以不覆盖既有路径的方式生成固定六文件 Vite 起步工程,不是创建前置条件,也不安装依赖、不联网、不构建、不上传或提审。交互式 AI 应用的项目配置底部提供“一键提交审核”;Main 自动预检、安全打包并提交,构建通过后进入运营审核,审核通过即直接发布。首次创建必须选择 PNG、JPEG 或 WebP 项目封面,并通过 Main-owned multipart 原子接口同时保存资料与封面;已有 draft/published 只提交新版本并沿用平台现有资料与封面。项目成果预览 `/deliverables` 继续保留。 +创建智能体时,如果本次新操作被服务明确拒绝,会显示失败原因、保留名称和头像,并允许修改后重新提交;不会留下“结果尚未确认”提醒。断网、响应异常或已有待确认操作的重试失败仍保留原创建请求,可重试确认结果,避免重复创建。当前创建表单一旦出现结果不确定,即使移除提醒后重试被拒绝,也继续保留原请求;移除提醒不代表取消创建。创建页面会及时刷新本机恢复记录。服务器的字段校验错误只转换为固定中文提示,不展示内部错误或提交原文。一句话介绍仍可留空;创建与保存需要配套的智能体云端服务支持。 + ## 当前产品状态 - 桌面技术栈:Electron、React 19、Vite、TypeScript、Zustand、Tailwind CSS。 @@ -41,6 +45,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智 - 共享开发浏览器:AI 编程右侧提供项目级浏览器,用户与 Agent 查看并调试同一实时页面、Console 和 Network,支持本地与公网开发地址。 - 后端边界:Renderer 通过 Main 所有的 Host API 访问认证、模型、同步、更新、语音、图像与运行时能力。 - 智能体头像:创建和编辑个人云智能体时可上传、更换或移除头像,支持 PNG、JPEG、WebP、GIF,最大 5 MB。复用 Yuxi 的 `Agent.icon` 和公开图片存储;上传后先预览,保存草稿后生效,正式使用的头像随发布版本固定。列表、编辑页、收到的分享、会话入口和版本对比展示对应头像,未设置或加载失败时显示默认图标。Main 负责文件选择、上传和相对地址解析,客户端不维护第二份头像数据;需配套更新 Yuxi 的 Makelore 头像接口。 +- 智能体对话:试用、正式对话和历史记录使用相同的 Markdown 正文与可折叠过程区,展示模型实际返回的思考、工具输入、结果和失败。实时事件按消息与工具身份累积,重连去重;开始输出正文后过程区自动收起,可手动展开。配套 Yuxi 历史接口投影已有持久思考和工具记录,重新打开及微信历史均可查看;没有思考或工具内容时不显示空面板。 - 客户端更新:Electron Main 按平台与架构选择更新源并保留原始诊断;设置页只显示一条脱敏后的中文状态。正式源缺少对应安装包时保持错误并允许重试,不会误报为已是最新版。 - 个人资料:姓名、年龄、性别与个人头像按账号同步到云端;首次登录进入模块选择页时会要求先完善姓名,首页欢迎语和主平台左下角账号区优先展示个人资料姓名;头像支持 PNG、JPEG、WebP,保存时自动居中裁剪并以圆形缩略图展示,未设置或加载失败时回退为姓名首字母。 - 会话观察同步:本地编程会话在一轮问答完成并进入空闲后,后台通过个人资料 PUT 上传该会话截至当前的完整问答快照;只保留用户/助手自然语言文本,过滤代码、路径、日志、工具调用、附件与产物。同步只从本地上行,云端不回写、不恢复或删除本地会话;失败数据留在本地等待重试。 diff --git a/electron/api/routes/cloud-agents.ts b/electron/api/routes/cloud-agents.ts index b703e62b..1e888bec 100644 --- a/electron/api/routes/cloud-agents.ts +++ b/electron/api/routes/cloud-agents.ts @@ -73,9 +73,10 @@ export async function handleCloudAgentsRoutes(req: IncomingMessage, res: ServerR sendJson(res, 200, data); } catch (error) { const failure = error instanceof CloudAgentsError ? error - : new CloudAgentsError(error instanceof SyntaxError ? 400 : 502, error instanceof SyntaxError ? 'invalid_input' : 'cloud_service_unavailable'); + : new CloudAgentsError(error instanceof SyntaxError ? 400 : 502, error instanceof SyntaxError ? 'invalid_input' : 'cloud_service_unavailable', + error instanceof SyntaxError ? 'definitive_failure' : 'unknown'); if (res.headersSent) res.end(); - else sendJson(res, failure.status, { success: false, error: failure.message, code: failure.code }); + else sendJson(res, failure.status, { success: false, error: failure.message, code: failure.code, commandOutcome: failure.commandOutcome }); } return true; } diff --git a/electron/services/cloud-agent-journal.ts b/electron/services/cloud-agent-journal.ts index 103863a5..0a21ef9c 100644 --- a/electron/services/cloud-agent-journal.ts +++ b/electron/services/cloud-agent-journal.ts @@ -26,14 +26,17 @@ export class CloudAgentJournal { return this.update(accountKey, validate, value => ({ ...value, recent })); } - put(accountKey: string, entry: CloudPendingOperation, validate: () => void) { - return this.update(accountKey, validate, value => { + async put(accountKey: string, entry: CloudPendingOperation, validate: () => void): Promise { + let alreadyPending = false; + await this.update(accountKey, validate, value => { const existing = value.pending.find(item => item.id === entry.id); if (existing && JSON.stringify(existing.input) !== JSON.stringify(entry.input)) { throw new Error('同一次待确认操作不能更改输入'); } + alreadyPending = Boolean(existing); return { ...value, pending: existing ? value.pending : [...value.pending, entry] }; }); + return alreadyPending; } remove(accountKey: string, id: string, validate: () => void) { diff --git a/electron/services/cloud-agent-operations.ts b/electron/services/cloud-agent-operations.ts index e29b7645..22477711 100644 --- a/electron/services/cloud-agent-operations.ts +++ b/electron/services/cloud-agent-operations.ts @@ -77,11 +77,26 @@ function projectRun(value: unknown): unknown { } function projectConversation(value: unknown): unknown { const source = object(value); - return { ...projectSession(source), messages: list(source.messages, item => copy(item, ['id', 'role', 'content', 'request_id', 'run_id', 'created_at'])), + return { ...projectSession(source), messages: list(source.messages, projectMessage), run: source.run == null ? null : projectRun(source.run), queued_requests: list(source.queued_requests, item => copy(item, ['request_id', 'thread_id', 'run_id', 'status', 'version'])), next_offset: source.next_offset ?? null }; } +function projectMessage(value: unknown): unknown { + const source = object(value); + return { ...copy(source, ['id', 'role', 'content', 'request_id', 'run_id', 'created_at', 'message_id', 'reasoning_content']), + ...(Array.isArray(source.tool_calls) ? { tool_calls: source.tool_calls.map(item => { + const tool = object(item); + return { ...copy(tool, ['id', 'name', 'display_name', 'display_description', 'args', 'status', 'error_message']), + ...(tool.tool_call_result == null ? {} : { tool_call_result: copy(tool.tool_call_result, ['content']) }) }; + }) } : {}), + }; +} +function projectHistory(value: unknown): unknown { + const source = object(value); + return { ...copy(source, ['thread_id', 'queued_requests', 'schedule_proposals', 'next_offset']), + messages: list(source.messages, projectMessage), run: source.run == null ? null : projectRun(source.run) }; +} function projectFiles(value: unknown): unknown { const source = object(value); return { session_id: source.session_id, thread_id: source.thread_id ?? null, @@ -253,7 +268,7 @@ const operations: Record = { saveBudget: { method: 'PUT', cloud: 'ws', path: '/agents/:slug/budget', body: ['request_limit_points', 'daily_limit_points'], output: budget }, threads: { method: 'GET', path: '/threads', query: { slug: 'slug', offset: 'offset', archived: 'archived' }, output: ['threads', 'next_offset'] }, createThread: { method: 'POST', path: '/agents/:slug/threads', body: ['thread_id', 'preview', 'expected_revision'], output: ['thread_id', 'client_thread_id'] }, - history: { method: 'GET', path: '/threads/:thread_id', query: { offset: 'offset' }, output: ['thread_id', 'messages', 'run', 'queued_requests', 'schedule_proposals', 'next_offset'] }, + history: { method: 'GET', path: '/threads/:thread_id', query: { offset: 'offset' }, output: ['thread_id', 'messages', 'run', 'queued_requests', 'schedule_proposals', 'next_offset'], project: projectHistory }, viewed: { method: 'POST', path: '/threads/:thread_id/viewed', body: ['run_id'], output: ['viewed'] }, submit: { method: 'POST', path: '/agents/:slug/requests', body: prompt, output: request }, preview: { method: 'POST', path: '/agents/:slug/preview', body: prompt, output: request }, diff --git a/electron/services/cloud-agents.ts b/electron/services/cloud-agents.ts index ee055322..1be67387 100644 --- a/electron/services/cloud-agents.ts +++ b/electron/services/cloud-agents.ts @@ -29,7 +29,12 @@ const MESSAGES: Record = { draft_revision_conflict: '草稿已在其他设备更新,你的输入已保留', operation_conflict: '创建操作的内容发生冲突,请刷新列表确认', cloud_service_unavailable: '智能体服务暂时不可用,请重试', - invalid_input: '请检查名称、用途和配置内容', + invalid_input: '提交的信息未被接受,请检查后再试', + invalid_name: '请填写名称,长度不要超过 100 个字', + invalid_introduction: '一句话介绍不符合要求,长度不要超过 2000 个字', + introduction_too_short: '一句话介绍不符合要求,请修改后再试', + introduction_required: '当前智能体服务尚不支持一句话介绍留空,请等待服务更新后重试', + invalid_icon: '头像设置未被接受,请重新选择头像', model_required: '请先选择模型并保存', model_unavailable: '所选模型当前不可用,请刷新模型目录', resource_unavailable: '所选资源已不可用,请刷新配置', @@ -73,11 +78,39 @@ const MESSAGES: Record = { }; export class CloudAgentsError extends Error { - constructor(readonly status: number, readonly code: string) { + constructor(readonly status: number, readonly code: string, + readonly commandOutcome: 'definitive_failure' | 'unknown' = 'unknown') { super(MESSAGES[code] ?? MESSAGES.cloud_service_unavailable); } } +const validationTypes = new Set(['missing', 'string_type', 'string_too_short', 'string_too_long', + 'string_pattern_mismatch', 'uuid_parsing', 'uuid_type', 'value_error']); + +/** Only structural request-validation metadata is trusted; never expose msg, input or ctx. */ +function requestValidation(detail: unknown): boolean { + return Array.isArray(detail) && detail.length > 0 && detail.length <= 100 && detail.every(value => { + const item = record(value); + return Array.isArray(item.loc) && item.loc[0] === 'body' && item.loc.length >= 2 + && typeof item.type === 'string' && validationTypes.has(item.type); + }); +} + +function validationMessageCode(detail: unknown): string { + if (!requestValidation(detail)) return 'invalid_input'; + const codes = (detail as unknown[]).map(value => { + const item = record(value); + if (!Array.isArray(item.loc) || item.loc.length !== 2) return 'invalid_input'; + switch (item.loc[1]) { + case 'name': return 'invalid_name'; + case 'purpose': return item.type === 'string_too_short' ? 'introduction_too_short' : 'invalid_introduction'; + case 'icon': return 'invalid_icon'; + default: return 'invalid_input'; + } + }); + return codes.every(code => code === codes[0]) ? codes[0] : 'invalid_input'; +} + function record(value: unknown): Record { return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; @@ -154,6 +187,7 @@ export class CloudAgentsModule { private activityTimer: ReturnType; private activityPolling = false; private activitySeen: Map | null = null; + private readonly creationAttempts = new Map }>(); constructor(private readonly fetchImpl: (input: string, init?: RequestInit) => Promise = proxyAwareFetch, private readonly journal = new CloudAgentJournal()) { @@ -196,17 +230,49 @@ export class CloudAgentsModule { async create(value: unknown): Promise { const input = record(value); - const operationId = textField(input.operation_id, 36); - if (!/^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(operationId)) { - throw new CloudAgentsError(422, 'invalid_input'); + let body: CreateCloudAgent; + try { + const operationId = textField(input.operation_id, 36); + if (!/^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(operationId)) { + throw new CloudAgentsError(422, 'invalid_input'); + } + body = { + operation_id: operationId, + name: textField(input.name, 100).trim(), + purpose: textField(input.purpose, 2000, false).trim(), + ...(input.icon === undefined ? {} : { icon: input.icon === null ? null : textField(input.icon, 255, false) }), + }; + } catch (error) { + if (error instanceof CloudAgentsError) { + const binding = getWorksSquareAccountBinding(); + const id = typeof input.operation_id === 'string' ? input.operation_id : ''; + let alreadyPending = Boolean(binding && this.creationAttempts.has(`${binding.accountKey}:${binding.epoch}:${id}`)); + if (binding) { + alreadyPending ||= (await this.journal.read(binding.accountKey)).pending.some(item => item.id === `createAgent::${id}`); + this.requireCurrent(binding); + } + throw new CloudAgentsError(error.status, error.code, alreadyPending ? 'unknown' : 'definitive_failure'); + } + throw error; } - const body: CreateCloudAgent = { - operation_id: operationId, - name: textField(input.name, 100).trim(), - purpose: textField(input.purpose, 2000, false).trim(), - ...(input.icon === undefined ? {} : { icon: input.icon === null ? null : textField(input.icon, 255, false) }), - }; - return this.withRecovery('createAgent', { ...body }, () => this.request('/api/makelore/agents', 'POST', body).then(result => this.withAvatar(projectDraft(result)))); + const binding = getWorksSquareAccountBinding(); + if (!binding) throw new CloudAgentsError(401, 'session_expired'); + const key = `${binding.accountKey}:${binding.epoch}:${body.operation_id}`; + const serialized = JSON.stringify(body); + const existing = this.creationAttempts.get(key); + if (existing) { + if (existing.input !== serialized) throw new CloudAgentsError(409, 'operation_conflict'); + return existing.promise; + } + const promise = this.withRecovery('createAgent', { ...body }, () => this.request('/api/makelore/agents', 'POST', body) + .then(result => this.withAvatar(projectDraft(result))).catch(error => { + if (body.purpose === '' && error instanceof CloudAgentsError && error.code === 'introduction_too_short') { + throw new CloudAgentsError(error.status, 'introduction_required', error.commandOutcome); + } + throw error; + })).finally(() => { this.creationAttempts.delete(key); }); + this.creationAttempts.set(key, { input: serialized, promise }); + return promise; } private async withRecovery(operation: string, input: Record, execute: () => Promise): Promise { @@ -217,9 +283,23 @@ export class CloudAgentsModule { .map(value => encodeURIComponent(typeof value === 'string' ? value : '')).join(':') : input.request_id ?? input.operation_id; const id = [operation, input.slug ?? input.application_id ?? input.run_id ?? '', operationKey].join(':'); - await this.journal.put(binding.accountKey, { id, operation, input, created_at: new Date().toISOString() }, () => this.requireCurrent(binding)); + const alreadyPending = await this.journal.put(binding.accountKey, { id, operation, input, created_at: new Date().toISOString() }, () => this.requireCurrent(binding)); this.requireCurrent(binding); - const result = await execute(); + let result: T; + try { + result = await execute(); + } catch (error) { + this.requireCurrent(binding); + if (error instanceof CloudAgentsError) { + // A retry rejection cannot disprove an earlier acceptance whose response was lost. + if (operation === 'createAgent' && !alreadyPending && error.commandOutcome === 'definitive_failure') { + await this.journal.remove(binding.accountKey, id, () => this.requireCurrent(binding)); + throw error; + } + throw new CloudAgentsError(error.status, error.code, 'unknown'); + } + throw error; + } this.requireCurrent(binding); await this.journal.remove(binding.accountKey, id, () => this.requireCurrent(binding)); return result; @@ -671,11 +751,17 @@ export class CloudAgentsModule { if (raw.length > 4_000_000) throw new CloudAgentsError(502, 'cloud_service_unavailable'); const body: unknown = JSON.parse(raw); if (!response.ok) { - const code = record(record(body).detail).code; + const detail = record(body).detail; + const code = record(detail).code; + const rejected = ([400, 422].includes(response.status) && (code === 'invalid_input' || requestValidation(detail))) + || (response.status === 401 && code === 'session_expired') + || (response.status === 403 && ['access_revoked', 'module_unavailable'].includes(String(code))) + || (response.status === 423 && code === 'account_locked'); throw new CloudAgentsError( response.status, typeof code === 'string' && Object.hasOwn(MESSAGES, code) ? code - : response.status === 422 ? 'invalid_input' : response.status === 401 ? 'session_expired' + : response.status === 422 ? validationMessageCode(detail) : response.status === 401 ? 'session_expired' : response.status === 403 ? 'access_revoked' : 'cloud_service_unavailable', + rejected ? 'definitive_failure' : 'unknown', ); } return body; diff --git a/shared/cloud-agents.ts b/shared/cloud-agents.ts index b9bd84ae..f9266556 100644 --- a/shared/cloud-agents.ts +++ b/shared/cloud-agents.ts @@ -98,7 +98,15 @@ export interface CloudInterrupt { export interface CloudMessage { id: number; role: 'user' | 'assistant' | 'system'; content: string; request_id: string; run_id: string | null; created_at: string; + message_id?: string | null; reasoning_content?: string; + tool_calls?: CloudToolCall[]; +} +export interface CloudToolCall { + id: string; name: string; args: unknown; status: string; + display_name?: string; display_description?: string; + tool_call_result?: { content: unknown } | null; + error_message?: string | null; } export interface CloudHistory { thread_id: string; messages: CloudMessage[]; run: CloudRun | null; next_offset: number | null; diff --git a/src/components/account/HomeAccountMenu.tsx b/src/components/account/HomeAccountMenu.tsx new file mode 100644 index 00000000..edc0b1ba --- /dev/null +++ b/src/components/account/HomeAccountMenu.tsx @@ -0,0 +1,103 @@ +import { useEffect, useRef, useState } from 'react'; +import { ChevronUp, LogOut, Settings, UserCircle2 } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; +import { UserAvatar } from '@/components/profile/UserAvatar'; +import { getAccountInitial } from '@/components/profile/user-avatar-utils'; +import { DisclosureContent } from '@/components/ui/disclosure'; +import { useAuthStore } from '@/stores/auth'; +import { PointWallet } from './PointWallet'; + +export function HomeAccountMenu({ accountName, avatarUrl, onOpenProfile }: { + accountName: string; + avatarUrl?: string | null; + onOpenProfile: () => void; +}) { + const user = useAuthStore(state => state.user); + const logout = useAuthStore(state => state.logout); + const navigate = useNavigate(); + const [open, setOpen] = useState(false); + const [loggingOut, setLoggingOut] = useState(false); + const [error, setError] = useState(''); + const trigger = useRef(null); + const menu = useRef(null); + const initial = getAccountInitial(accountName); + + useEffect(() => { + if (!open) return; + const outside = (event: MouseEvent) => { + if (event.target instanceof Node && !trigger.current?.contains(event.target) && !menu.current?.contains(event.target)) setOpen(false); + }; + const escape = (event: KeyboardEvent) => { + if (event.key !== 'Escape' || event.defaultPrevented) return; + setOpen(false); + trigger.current?.focus(); + }; + document.addEventListener('mousedown', outside); + document.addEventListener('keydown', escape); + return () => { + document.removeEventListener('mousedown', outside); + document.removeEventListener('keydown', escape); + }; + }, [open]); + + const focusItem = (last = false) => requestAnimationFrame(() => { + if (trigger.current?.getAttribute('aria-expanded') !== 'true') return; + const items = menu.current?.querySelectorAll('[role="menuitem"]:not(:disabled)'); + if (items?.length) items[last ? items.length - 1 : 0].focus(); + }); + + if (!user) return null; + + return
+ + { + if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return; + event.preventDefault(); + const items = [...(menu.current?.querySelectorAll('[role="menuitem"]:not(:disabled)') ?? [])]; + const current = items.indexOf(document.activeElement as HTMLButtonElement); + const index = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 + : (current + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length; + items[index]?.focus(); + }}> +
+ +

{accountName}

词元点数账户

+
+ +
+ + + +
+ {error &&

{error}

} +
+
; +} diff --git a/src/pages/CloudAgents/ChannelAccounts.tsx b/src/pages/CloudAgents/ChannelAccounts.tsx index 95dde3f9..10c3b269 100644 --- a/src/pages/CloudAgents/ChannelAccounts.tsx +++ b/src/pages/CloudAgents/ChannelAccounts.tsx @@ -251,6 +251,11 @@ export function ChannelAccounts() { {flow?.target && !targetAgent && selected?.target_agent_slug === flow.target && } {agents.map(agent => )} ; + const agentRequiredHint =
+

还没有可连接的智能体

+

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

+ +
; return
@@ -266,12 +271,13 @@ export function ChannelAccounts() {
{!flow && messages} + {!loading && loaded && !agents.length && !flow && agentRequiredHint} {loading ?
正在查看你的微信绑定…
: !loaded ?

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

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

把你的智能体带到微信吧

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

-
:
+
) :
{accounts.map(account => { const agent = agents.find(item => item.slug === account.target_agent_slug); const state = accountState(account); @@ -298,7 +304,7 @@ export function ChannelAccounts() { {recoveryBlocked &&

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

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

先准备一个可以使用的智能体,再把它带到微信里。

+ {!agents.length ? agentRequiredHint : } {flow.mode === 'new' && !flow.accountId && } {flow.mode === 'manage' && selected?.blockers.length ?

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

: null} diff --git a/src/pages/CloudAgents/CloudChannelConversations.tsx b/src/pages/CloudAgents/CloudChannelConversations.tsx index e5a4bfa5..46fa0566 100644 --- a/src/pages/CloudAgents/CloudChannelConversations.tsx +++ b/src/pages/CloudAgents/CloudChannelConversations.tsx @@ -1,8 +1,7 @@ import { useCallback, useEffect, useState } from 'react'; import { Download, Folder, RefreshCw } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import ReactMarkdown from 'react-markdown'; -import remarkGfm from 'remark-gfm'; +import { CloudMessageBody } from './CloudMessageBody'; import { cloudAgentsApi } from '@/lib/cloud-agents-api'; import type { CloudAgentOperations, CloudChannelSession, CloudChannelConversation, CloudFile, CloudPendingOperation } from '../../../shared/cloud-agents'; import { CloudApproval, cloudStatus } from './CloudChat'; @@ -185,7 +184,7 @@ function ChannelSession({ sessionId, visible, refreshRevision, onBusy, onNewSess {error &&

{error}

}
{data?.messages.filter(message => message.role !== 'system').map(message =>
-

{message.role === 'user' ? '你 · 微信' : '智能体'}

href && /^(https?:|mailto:)/i.test(href) ? {children} : {children} }}>{message.content}
)} +

{message.role === 'user' ? '你 · 微信' : '智能体'}

)} {data && !data.messages.length &&

还没有消息。

}
{!recoveryReady && } diff --git a/src/pages/CloudAgents/CloudChat.tsx b/src/pages/CloudAgents/CloudChat.tsx index 8a65b6d3..cd504ed2 100644 --- a/src/pages/CloudAgents/CloudChat.tsx +++ b/src/pages/CloudAgents/CloudChat.tsx @@ -7,12 +7,11 @@ import type { CloudHistory, CloudInterrupt, CloudPrompt, CloudRequest, CloudRun, import { CloudComposerAttachments } from './CloudComposerAttachments'; import { CloudAvatar } from './CloudAvatar'; import './desktop-workspace.css'; +import { CloudMessageBody } from './CloudMessageBody'; import './agent-chat.css'; -import ReactMarkdown, { defaultUrlTransform } from 'react-markdown'; -import remarkGfm from 'remark-gfm'; import { CloudPendingContext, useCloudPendingState, usePendingCloudInput } from './CloudPending'; import { agentExamples } from './agent-guidance'; -import { consumeCloudChatEvent, type CloudChatStream } from './cloud-chat-stream'; +import { consumeCloudChatEvent, type CloudChatStream, type CloudLiveMessage } from './cloud-chat-stream'; export const cloudStatus = (status: string) => ({ idle: '尚未开始', queued: '等待执行', dispatching: '准备执行', dispatched: '已提交', pending: '准备中', @@ -22,6 +21,8 @@ export const cloudStatus = (status: string) => ({ const active = (status?: string) => Boolean(status && ['queued', 'dispatching', 'dispatched', 'pending', 'running', 'cancel_requested', 'submitted'].includes(status)); 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)); export function CloudChat({ slug, previewRevision: latestRevision, initialThread, initialThreadId, onSchedule, activeView = true, headerAction, attachmentHeader, previewReady = true, assistantName = '助手', assistantAvatarUrl, examples = agentExamples(), hasUnsavedChanges = false, saveDisabled = false, onSaveForPreview }: { @@ -75,8 +76,10 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread // Replace the preview only after authoritative assistant history arrives. // Failed/cancelled Runs without a saved answer retain received fragments. const preview = streamReply.current; - if (preview?.threadId === id && page.run?.agent_run_id === preview.runId && !active(page.run.status) - && page.messages.some(item => item.role === 'assistant' && item.run_id === preview.runId)) updateLiveReply(null); + if (preview?.threadId === id && page.run?.agent_run_id === preview.runId && !active(page.run.status)) { + const remaining = preview.messages.filter(item => !savedMessage(page, item)); + if (remaining.length !== preview.messages.length) updateLiveReply(remaining.length ? { ...preview, messages: remaining } : null); + } setQueued(page.queued_requests ?? []); setRequest(active(page.run?.status) ? null : page.queued_requests?.[0] ?? null); if (page.run) setThreads(current => current.map(item => item.thread_id === id @@ -167,6 +170,9 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread const runId = request && active(request.status) ? request.run_id ?? undefined : run?.agent_run_id; const running = active(run?.status) || active(request?.status); + const liveHistoryReady = history?.run && history.run.agent_run_id === liveReply?.runId + ? !active(history.run.status) + : Boolean(history?.messages.some(item => item.run_id === liveReply?.runId)); useEffect(() => { if (!runId || !running) return; let live = true; @@ -184,7 +190,8 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread if (envelope.run_id !== runId || envelope.thread_id !== targetThread || targetRequest && envelope.request_id && envelope.request_id !== targetRequest) return; const chunk = object(object(envelope.payload).chunk); - if (next?.toolName) setProgress('正在使用 ' + next.toolName); + const latestTool = next?.messages.at(-1)?.tool_calls.at(-1); + if (latestTool && ['pending', 'running'].includes(latestTool.status)) setProgress('正在使用 ' + (latestTool.display_name || latestTool.name)); if (event.type === 'interrupt') setInterrupt(chunk as CloudInterrupt); if (event.type === 'end') { source?.close(); @@ -326,7 +333,7 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread
{ const el = e.currentTarget; following.current = el.scrollHeight - el.scrollTop - el.clientHeight < 64; }}>
{loading ?
- : !history?.messages.length && !liveReply?.content && !liveReply?.reasoning &&
+ : !history?.messages.length && !liveReply?.messages.length &&

{assistantName}

{mode === 'preview' ? '问它一个小问题' : 'Hi,今天想聊些什么?'}

@@ -341,13 +348,17 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread

这是修改前的回答。用同一个问题再问一次,更容易看出变化。

} - {history && } - {liveReply && (liveReply.content || liveReply.reasoning) &&
+ {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 =>

{assistantName}

- {liveReply.reasoning && } - {liveReply.content &&
{liveReply.content}
}
-
} +
{ + 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} · 尚未启用

@@ -419,26 +430,13 @@ function TrialMessages({ history, onError, assistantName = '助手', assistantAv return history.messages.map(item =>
{item.role !== 'user' && }
{item.role !== 'user' &&

{assistantName}

} - {item.role === 'assistant' && typeof item.reasoning_content === 'string' && item.reasoning_content - && } -
{item.role === 'user' ?

{item.content}

: url.startsWith('sandbox:/') ? url.slice(8) : defaultUrlTransform(url)} remarkPlugins={[remarkGfm]} components={{ - a: ({ href, children }) => href && !/^(https?:|mailto:|#)/i.test(href) - ? - : {children}, - }}>{item.content}}
+
{item.role === 'user' ?

{item.content}

: + void cloudAgentsApi.download(history.thread_id, path).catch(e => onError(message(e)))} />}
); } -function CloudReasoning({ content, running }: { content: string; running: boolean }) { - const [expanded, setExpanded] = useState(running); - useEffect(() => { setExpanded(running); }, [running]); - return
setExpanded(event.currentTarget.open)} - className="agent-chat-reasoning" data-testid="cloud-chat-reasoning"> - 思考过程 -
{content}
-
; -} - export function CloudApproval({ interrupt, runId, onResumed, submitDecision }: { interrupt: CloudInterrupt; runId: string; onResumed?: (run: CloudRun) => void; submitDecision?: (input: { operation_id: string; decision: Record }) => Promise; diff --git a/src/pages/CloudAgents/CloudLifecycle.tsx b/src/pages/CloudAgents/CloudLifecycle.tsx index 69cae026..4241ffd9 100644 --- a/src/pages/CloudAgents/CloudLifecycle.tsx +++ b/src/pages/CloudAgents/CloudLifecycle.tsx @@ -6,7 +6,7 @@ import type { CloudAgentDraft, CloudVersion } from '../../../shared/cloud-agents import { usePendingCloudInput } from './CloudPending'; const configurationLabels: Record = { - model: '模型', tools: '工具', knowledges: '知识库', mcps: 'MCP 连接', skills: 'Skills', preload_skills: '预加载 Skills', + model: '模型', tools: '工具', knowledges: '知识库', mcps: 'MCP 连接', skills: '技能', preload_skills: '提前读好的技能', subagents: '子智能体', tool_approval_mode: '工具确认方式', max_execution_steps: '最多执行步数', max_output_tokens: '每次输出词元上限', max_run_seconds: '最长执行秒数', }; diff --git a/src/pages/CloudAgents/CloudMessageBody.tsx b/src/pages/CloudAgents/CloudMessageBody.tsx new file mode 100644 index 00000000..74b4d964 --- /dev/null +++ b/src/pages/CloudAgents/CloudMessageBody.tsx @@ -0,0 +1,36 @@ +import ReactMarkdown, { defaultUrlTransform } from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import type { CloudToolCall } from '../../../shared/cloud-agents'; + +const display = (value: unknown) => typeof value === 'string' ? value : value == null ? '' : JSON.stringify(value, null, 2); + +export function CloudMessageBody({ content, reasoning_content, tool_calls = [], active = false, onDownload }: { + content: string; reasoning_content?: string; tool_calls?: CloudToolCall[]; active?: boolean; + onDownload?: (path: string) => void; +}) { + return
+ {(reasoning_content || tool_calls.length > 0) &&
+ {reasoning_content ? '思考过程' : '工具调用'}{tool_calls.length > 0 && ' · ' + tool_calls.length + ' 个工具'}{active && !content ? ' · 进行中' : ''} +
+ {reasoning_content &&
{reasoning_content}
} + {tool_calls.map(tool =>
+ {tool.display_name || tool.name} · {tool.status === 'success' ? '已完成' : tool.status === 'error' ? '失败' : active ? tool.status === 'running' ? '执行中' : '准备调用' : '未完成'} +
+ {tool.display_description &&

{tool.display_description}

} + {tool.display_name && tool.display_name !== tool.name &&

工具标识:{tool.name}

} +

输入

{display(tool.args)}
+ {tool.tool_call_result != null && <>

结果

{display(tool.tool_call_result.content)}
} + {tool.error_message &&

{tool.error_message}

} +
+
)} +
+
} + {content &&
+ url.startsWith('sandbox:/') ? url.slice(8) : defaultUrlTransform(url)} remarkPlugins={[remarkGfm]} components={{ + a: ({ href, children }) => href && !/^(https?:|mailto:|#)/i.test(href) + ? onDownload ? : {children} + : {children}, + }}>{content} +
} +
; +} diff --git a/src/pages/CloudAgents/CloudRecovery.tsx b/src/pages/CloudAgents/CloudRecovery.tsx index 0cb1c84e..8a2506fd 100644 --- a/src/pages/CloudAgents/CloudRecovery.tsx +++ b/src/pages/CloudAgents/CloudRecovery.tsx @@ -1,6 +1,10 @@ -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { History, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; import { cloudAgentsApi } from '@/lib/cloud-agents-api'; +import { AppError } from '@/lib/error-model'; import type { CloudPendingOperation } from '../../../shared/cloud-agents'; const labels: Record = { @@ -15,43 +19,92 @@ const labels: Record = { }; const retiredChannelOperations = new Set(['channelPolicy', 'createChannelPairing', 'channelCallers', 'revokeChannelCaller']); -export function CloudRecovery({ pending, onResolved }: { pending: CloudPendingOperation[]; onResolved: () => void }) { +export function CloudRecovery({ pending, onResolved, triggerContainer }: { pending: CloudPendingOperation[]; onResolved: () => void; triggerContainer?: HTMLElement | null }) { + const [open, setOpen] = useState(false); const [busy, setBusy] = useState(''); const [error, setError] = useState(''); const [notice, setNotice] = useState(''); - const [result, setResult] = useState<{ operation: string; value: unknown } | null>(null); + const [result, setResult] = useState<{ operation: string; name?: string; value: unknown } | null>(null); + const inFlight = useRef(false); + const awaitingKeyClose = useRef(false); + const alive = useRef(true); + const creationOnly = pending.length > 0 ? pending.every(item => item.operation === 'createAgent') : result?.operation === 'createAgent'; + const title = creationOnly ? '创建记录' : '待处理记录'; + useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []); + useEffect(() => { if (triggerContainer === null && !busy) setOpen(false); }, [triggerContainer, busy]); + const close = () => { if (!inFlight.current) setOpen(false); }; const resolve = async (item: CloudPendingOperation, discard = false) => { + if (inFlight.current || awaitingKeyClose.current) return; + inFlight.current = true; setBusy(item.id); setError(''); setNotice(''); try { const response = await cloudAgentsApi.resolvePending(item.id, discard); + if (!alive.current) return; if (response.requires_input) { setResult(null); setNotice('这次微信验证还需要验证码。请进入「渠道 → 微信」,选择对应账号后继续连接验证。'); } else if (response.retired) { setResult(null); setNotice('旧版联系人权限操作已移除,本机恢复记录已丢弃,微信账号的其他操作不受影响。'); - } else if (!discard) setResult({ operation: item.operation, value: response.result }); + } else if (!discard) { + awaitingKeyClose.current = item.operation === 'createKey'; + setResult({ operation: item.operation, + name: typeof item.input.name === 'string' ? item.input.name : undefined, value: response.result }); + } onResolved(); - } catch (e) { setError(e instanceof Error ? e.message : '暂时无法确认结果'); } - finally { setBusy(''); } + } catch (e) { + if (!alive.current) return; + const message = e instanceof Error ? e.message : '这次没能完成,请稍后再试。'; + if (e instanceof AppError && e.details?.commandOutcome === 'definitive_failure') { + setError(`${labels[item.operation] ?? '操作'}失败:${message}`); + onResolved(); + } else setError(message); + } + finally { inFlight.current = false; if (alive.current) setBusy(''); } }; - if (!pending.length && !result && !error && !notice) return null; - return
)} + {result &&

{result.operation === 'createAgent' + ? `「${result.name || '智能体'}」创建成功,可以回到列表继续设置。` + : `${labels[result.operation] ?? '操作'}的结果已确认。`}{result.operation === 'createKey' && '请保存下面的密钥。关闭结果后将无法再次查看。'}

+ {result.operation !== 'createAgent' && <>
查看结果
{JSON.stringify(result.value, null, 2)}
-
} + } + {result.operation === 'createKey' &&

先保存密钥,再点击「关闭结果」处理其他记录。

} + } {error &&

{error}

} {notice &&

{notice}

} - ; + {!pending.length && !result && !error && !notice &&

记录已经处理完了。

} + {pending.length > 0 &&

不再提醒只会关闭提醒,已经保存的内容会保留。

} + + + + ; } diff --git a/src/pages/CloudAgents/CloudSchedules.tsx b/src/pages/CloudAgents/CloudSchedules.tsx index dcdfc14e..11aa176b 100644 --- a/src/pages/CloudAgents/CloudSchedules.tsx +++ b/src/pages/CloudAgents/CloudSchedules.tsx @@ -113,7 +113,7 @@ function ScheduleEditor({ slug, initial, proposal, onClose, onSaved }: { slug: s

启用前确认

当前发布版本 {context.version} · {context.configuration.model}。触发时使用当时的发布版本。

能力:{['tools', 'knowledges', 'mcps', 'skills', 'subagents'].map(key => { - const labels: Record = { tools: '工具', knowledges: '知识库', mcps: 'MCP', skills: 'Skills', subagents: '子智能体' }; + const labels: Record = { tools: '工具', knowledges: '知识库', mcps: 'MCP', skills: '技能', subagents: '子智能体' }; const selected = context.configuration![key as 'tools']; return selected.length ? labels[key] + ':' + selected.join('、') : ''; }).filter(Boolean).join(';') || '基础对话'}

每次最多 {context.configuration.max_execution_steps} 步、{context.configuration.max_run_seconds} 秒,每次模型输出最多 {context.configuration.max_output_tokens} 词元。

diff --git a/src/pages/CloudAgents/ConfigurationFields.tsx b/src/pages/CloudAgents/ConfigurationFields.tsx index 5979fcb6..8d8c8f63 100644 --- a/src/pages/CloudAgents/ConfigurationFields.tsx +++ b/src/pages/CloudAgents/ConfigurationFields.tsx @@ -76,7 +76,7 @@ export function ConfigurationFields({ slug, value, disabled, onChange, section = } {key === 'skills' && value.skills.length > 0 && } + onChange={e => change('preload_skills', e.target.checked ? [...value.skills] : [])} />开始对话前,先读好已选做事手册的使用方法} ; })} ; diff --git a/src/pages/CloudAgents/agent-home.css b/src/pages/CloudAgents/agent-home.css index d29e0de5..1599c998 100644 --- a/src/pages/CloudAgents/agent-home.css +++ b/src/pages/CloudAgents/agent-home.css @@ -61,6 +61,26 @@ .agent-home-loading svg { width: 38px; height: 38px; color: #8273a9; } .agent-home-error { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 24px; padding: 12px 16px; border: 1px solid #e3dfd6; border-radius: 14px; background: #fff; font-size: 14px; } .agent-home-error p { overflow-wrap: anywhere; } +.agent-recovery-slot:empty { display: none; } +.agent-recovery-trigger { gap: 7px; color: #606c7e; font-size: 13px; } +.agent-recovery-count { display: inline-grid; min-width: 21px; height: 21px; padding-inline: 5px; place-items: center; border-radius: 8px; background: #ede9f6; color: #7161a5; font-size: 11px; } +.agent-recovery-dialog.agent-recovery-modal { display: flex; max-height: calc(100dvh - 32px); flex-direction: column; gap: 20px; border-radius: 24px; } +.agent-recovery-dialog .agent-recovery-heading { flex-shrink: 0; flex-direction: row; align-items: flex-start; justify-content: space-between; gap: 12px; text-align: left; } +.agent-recovery-heading > div { min-width: 0; } +.agent-recovery-heading > button { flex-shrink: 0; margin-top: -8px; margin-right: -8px; } +.agent-recovery-heading p { margin-top: 8px; line-height: 1.7; } +.agent-recovery-content { display: flex; min-height: 0; flex-direction: column; gap: 14px; overflow: auto; overscroll-behavior: contain; font-size: 14px; } +.agent-recovery-record { padding: 16px; border: 1px solid #e5e0ef; border-radius: 16px; background: #f8f6fd; overflow-wrap: anywhere; } +.agent-recovery-record h3 { font-weight: 600; line-height: 1.6; } +.agent-recovery-time { margin-top: 3px; color: #788193; font-size: 12px; } +.agent-recovery-record-help { margin-top: 10px; color: #606c7e; line-height: 1.7; } +.agent-recovery-record-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; } +.agent-recovery-record-actions button { min-height: 40px; border-radius: 10px; } +.agent-recovery-footnote { color: #788193; font-size: 12px; line-height: 1.7; } +.agent-recovery-result { padding: 16px; border: 1px solid #dbe9df; border-radius: 16px; background: #f2f8f3; line-height: 1.7; overflow-wrap: anywhere; } +.agent-recovery-result pre { overflow: auto; } +.agent-recovery-footer { flex-shrink: 0; } +.agent-recovery-footer button { min-height: 44px; } @media (max-width: 1150px) { .agent-home { padding: 32px 28px 40px; } } diff --git a/src/pages/CloudAgents/cloud-chat-stream.ts b/src/pages/CloudAgents/cloud-chat-stream.ts index 79b6f79d..d37cdaa7 100644 --- a/src/pages/CloudAgents/cloud-chat-stream.ts +++ b/src/pages/CloudAgents/cloud-chat-stream.ts @@ -1,58 +1,103 @@ -export interface CloudChatStream { +import type { CloudToolCall } from '../../../shared/cloud-agents'; + +export interface CloudLiveMessage { + message_id: string; + run_id: string; + content: string; + reasoning_content: string; + tool_calls: (CloudToolCall & { index?: number })[]; +} +export interface CloudStream { runId: string; + cursor: string; + messages: CloudLiveMessage[]; + finished?: boolean; +} +export const object = (value: unknown): Record => + value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; + +/** Redis SSE cursors order replayed batches without treating subthread output as an answer. */ +export function reduceCloudStream(state: CloudStream, data: unknown, threadId: string, cursor: string): CloudStream { + if (cursor && /^\d+-\d+$/.test(cursor) && state.cursor) { + const [time, sequence] = cursor.split('-').map(BigInt); + const [previousTime, previousSequence] = state.cursor.split('-').map(BigInt); + if (time < previousTime || (time === previousTime && sequence <= previousSequence)) return state; + } + const envelope = object(data); + if (envelope.run_id && envelope.run_id !== state.runId) return state; + const next = { ...state, cursor: /^\d+-\d+$/.test(cursor) ? cursor : state.cursor }; + if (envelope.thread_id && envelope.thread_id !== threadId) return next; + const payload = object(envelope.payload); + const chunks = Array.isArray(payload.items) ? payload.items : [payload.chunk]; + let messages = state.messages; + for (const value of chunks) { + const chunk = object(value); + const event = object(chunk.stream_event); + if ((chunk.thread_id && chunk.thread_id !== threadId) || (event.thread_id && event.thread_id !== threadId)) continue; + if (['message_delta', 'tool_call', 'tool_call_delta'].includes(String(event.type))) { + const id = typeof event.message_id === 'string' ? event.message_id + : messages.filter(message => message.run_id === state.runId).at(-1)?.message_id ?? state.runId + ':message'; + const current = messages.find(message => message.message_id === id && message.run_id === state.runId) + ?? { message_id: id, run_id: state.runId, content: '', reasoning_content: '', tool_calls: [] }; + const updated = { ...current }; + if (event.type === 'message_delta') { + updated.content += typeof event.content === 'string' ? event.content : ''; + updated.reasoning_content += typeof event.reasoning_content === 'string' ? event.reasoning_content + : typeof event.additional_reasoning_content === 'string' ? event.additional_reasoning_content : ''; + } else { + const index = typeof event.index === 'number' ? event.index : 0; + const existing = current.tool_calls.find(tool => (event.tool_call_id && tool.id === event.tool_call_id) || tool.index === index); + const tool = { ...existing, id: typeof event.tool_call_id === 'string' ? event.tool_call_id : existing?.id ?? id + ':' + index, + name: typeof event.name === 'string' ? event.name : existing?.name ?? '工具', + display_name: typeof event.display_name === 'string' ? event.display_name : existing?.display_name, + display_description: typeof event.display_description === 'string' ? event.display_description : existing?.display_description, + args: existing && existing.status !== 'pending' ? existing.args + : event.type === 'tool_call' ? event.args ?? {} : String(existing?.args ?? '') + String(event.args_delta ?? ''), + status: existing?.status ?? 'pending', index }; + updated.tool_calls = existing ? current.tool_calls.map(item => item === existing ? tool : item) : [...current.tool_calls, tool]; + } + messages = messages.includes(current) + ? messages.map(message => message === current ? updated : message) : [...messages, updated]; + } + const toolEvent = object(chunk.event); + const result = object(toolEvent.data); + if (toolEvent.method === 'tools' && typeof result.tool_call_id === 'string') { + messages = messages.map(message => ({ ...message, tool_calls: message.tool_calls.map(tool => { + if (message.run_id !== state.runId || tool.id !== result.tool_call_id) return tool; + tool = { ...tool, + display_name: typeof result.display_name === 'string' ? result.display_name : tool.display_name, + display_description: typeof result.display_description === 'string' ? result.display_description : tool.display_description }; + if (result.event === 'tool-started') return { ...tool, args: result.input ?? tool.args, status: 'running' }; + if (result.event !== 'tool-finished') return tool; + const output = object(result.output); + const error = result.error || (output.status === 'error' ? output.content : null); + return { ...tool, status: error ? 'error' : 'success', + error_message: error ? (typeof error === 'string' ? error : JSON.stringify(error)) : null, + tool_call_result: { content: output.content ?? result.output ?? '' } }; + }) })); + } + } + return { ...next, messages }; +} + +export interface CloudChatStream extends CloudStream { threadId: string; requestId?: string; - cursor: string; - content: string; - reasoning: string; - messageId: string; - reasoningMessageId: string; ended: boolean; - toolName: string; } -const object = (value: unknown): Record => value && typeof value === 'object' && !Array.isArray(value) - ? value as Record : {}; -const validCursor = (value: string) => /^\d+-\d+$/.test(value); -function newerCursor(next: string, previous: string) { - const [nextTime, nextIndex] = next.split('-').map(BigInt); - const [previousTime, previousIndex] = previous.split('-').map(BigInt); - return nextTime > previousTime || nextTime === previousTime && nextIndex > previousIndex; -} - -/** Project only the subscribed Run's main-thread messages; Redis IDs survive reconnects. */ +/** Keep transport identity and continuation state around the message-level projection. */ export function consumeCloudChatEvent(previous: CloudChatStream | null, value: unknown, type: string, eventId: string, runId: string, threadId: string, requestId?: string): CloudChatStream | null { const envelope = object(value); if (envelope.run_id !== runId || requestId && envelope.request_id && envelope.request_id !== requestId) return previous; const continuation = previous?.threadId === threadId && requestId && previous.requestId === requestId; const current: CloudChatStream = previous?.runId === runId && previous.threadId === threadId ? previous : { - runId, threadId, requestId, cursor: '0-0', content: '', reasoning: continuation ? previous.reasoning : '', - messageId: '', reasoningMessageId: continuation ? previous.reasoningMessageId : '', ended: false, toolName: '', + runId, threadId, requestId, cursor: '0-0', messages: continuation ? previous.messages : [], ended: false, }; - const fresh = validCursor(eventId) && newerCursor(eventId, current.cursor); - // A synthesized terminal event can reuse the last message's Redis ID. - if (type === 'end' && envelope.thread_id === threadId) return { ...current, ended: true, cursor: fresh ? eventId : current.cursor }; - if (validCursor(eventId) && !fresh) return previous; - const next = { ...current, cursor: fresh ? eventId : current.cursor }; - // Child events still advance the replay cursor, but never become visible text. - if (envelope.thread_id !== threadId) return next; - const payload = object(envelope.payload); - for (const item of Array.isArray(payload.items) ? payload.items : payload.chunk ? [payload.chunk] : []) { - const event = object(object(item).stream_event); - if (event.type === 'tool_call' && typeof event.name === 'string') next.toolName = event.name; - if (event.type !== 'message_delta') continue; - const id = typeof event.message_id === 'string' ? event.message_id : next.messageId; - const reasoning = typeof event.reasoning_content === 'string' ? event.reasoning_content - : typeof event.additional_reasoning_content === 'string' ? event.additional_reasoning_content : ''; - if (reasoning) { - next.reasoning += (next.reasoning && next.reasoningMessageId && id !== next.reasoningMessageId ? '\n\n' : '') + reasoning; - next.reasoningMessageId = id; - } - if (typeof event.content === 'string' && event.content) { - next.content += (next.content && next.messageId && id !== next.messageId ? '\n\n' : '') + event.content; - next.messageId = id; - } - } - return next; + // Child events advance the cursor without becoming visible messages. + const projected = reduceCloudStream(current, envelope.thread_id === threadId ? envelope : { ...envelope, payload: {} }, threadId, eventId); + // Terminal events may reuse the last message cursor. + if (type === 'end' && envelope.thread_id === threadId) return { ...current, ...projected, ended: true }; + return projected === current ? previous : { ...current, ...projected }; } diff --git a/src/pages/CloudAgents/index.tsx b/src/pages/CloudAgents/index.tsx index 8bc3a94d..474b7694 100644 --- a/src/pages/CloudAgents/index.tsx +++ b/src/pages/CloudAgents/index.tsx @@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { cloudAgentsApi } from '@/lib/cloud-agents-api'; +import { AppError } from '@/lib/error-model'; import { useAuthStore } from '@/stores/auth'; import { type CloudAgentAvatar, type CloudAgentDraft, type CreateCloudAgent, type CloudAgentEntry, type CloudThread, type CloudRecent, type CloudPendingOperation } from '../../../shared/cloud-agents'; import { DraftEditor } from './DraftEditor'; @@ -39,6 +40,7 @@ function AgentWorkspace() { const [deleteTarget, setDeleteTarget] = useState(null); const [deleting, setDeleting] = useState(false); const [deleteError, setDeleteError] = useState(''); + const [recoveryTarget, setRecoveryTarget] = useState(null); const cancelDelete = useRef(null); const deleteTrigger = useRef(null); const createButton = useRef(null); @@ -114,7 +116,6 @@ function AgentWorkspace() { void cloudAgentsApi.remember(null).catch(failure => { if (mounted.current) setError(errorMessage(failure)); }); refreshRecovery(); }; - const recovery = ; const accepted = (agent: CloudAgentDraft) => { if (!mounted.current) return; @@ -155,22 +156,21 @@ function AgentWorkspace() { } }; - if (selected) return
{recovery} { + const renderWorkspace = () => { + if (selected) return
{ setSelected(null); setEntryView(undefined); setStartingPrompt(undefined); clearRecent(); }} onSaved={accepted} />
; - if (shared || activity) return
{recovery} { setShared(null); setActivity(null); clearRecent(); }} />
; + if (shared || activity) return
{ setShared(null); setActivity(null); clearRecent(); }} />
; if (creating) return
- {recovery} -
{ setStartingPrompt(''); setEntryView('configuration'); accepted(agent); }} +
{ setStartingPrompt(''); setEntryView('configuration'); accepted(agent); }} onSettled={refreshRecovery} onCancel={() => { setCreating(false); void load(); }} />
; return (
- {recovery}
@@ -183,6 +183,7 @@ function AgentWorkspace() {
}
+
{(section !== 'mine' || loading || error || agents.length > 0) && }
@@ -240,9 +241,13 @@ function AgentWorkspace() {
); + }; + return <>{renderWorkspace()}; } -function CreateAgentForm({ onCreated, onCancel }: { onCreated: (agent: CloudAgentDraft) => void; onCancel: () => void }) { +function CreateAgentForm({ onCreated, onCancel, onSettled }: { + onCreated: (agent: CloudAgentDraft) => void; onCancel: () => void; onSettled: () => void; +}) { const [name, setName] = useState(''); const [avatar, setAvatar] = useState({ icon: null, icon_url: null }); const [uploading, setUploading] = useState(false); @@ -250,6 +255,7 @@ function CreateAgentForm({ onCreated, onCancel }: { onCreated: (agent: CloudAgen const [busy, setBusy] = useState(false); const [error, setError] = useState(''); const alive = useRef(true); + const uncertainOperation = useRef(null); const hasInput = Boolean(name || avatar.icon); const blocker = useBlocker(busy || uploading || intent !== null || hasInput); useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []); @@ -271,9 +277,19 @@ function CreateAgentForm({ onCreated, onCancel }: { onCreated: (agent: CloudAgen const agent = await cloudAgentsApi.create(input); if (alive.current) onCreated(agent); } catch (failure) { - if (alive.current) setError(errorMessage(failure)); + if (alive.current) { + if (failure instanceof AppError && failure.details?.commandOutcome === 'definitive_failure' + && uncertainOperation.current !== input.operation_id) { + setIntent(null); + setError('创建失败:' + errorMessage(failure)); + } else { + // Discarding the local reminder cannot rule out acceptance of an earlier attempt. + uncertainOperation.current = input.operation_id; + setError('创建结果暂未确认,可重试确认结果。' + errorMessage(failure)); + } + } } finally { - if (alive.current) setBusy(false); + if (alive.current) { setBusy(false); onSettled(); } } }; @@ -285,7 +301,7 @@ function CreateAgentForm({ onCreated, onCancel }: { onCreated: (agent: CloudAgen - {error &&

{error}。可重试确认本次创建结果。

} + {error &&

{error}

}
diff --git a/src/pages/ModuleSelection/index.tsx b/src/pages/ModuleSelection/index.tsx index 91989e4a..9d39cd6e 100644 --- a/src/pages/ModuleSelection/index.tsx +++ b/src/pages/ModuleSelection/index.tsx @@ -6,6 +6,7 @@ import moduleGameImage from '@/assets/module-game.webp'; import moduleRobotImage from '@/assets/module-robot.webp'; import logoWordmarkSource from '@/assets/makelore-wordmark-source.png'; import { UserProfileDialog } from '@/components/profile/UserProfileDialog'; +import { HomeAccountMenu } from '@/components/account/HomeAccountMenu'; import { useCurrentUserProfile } from '@/hooks/use-current-user-profile'; import { aiModules, isAiModuleAllowed, type AiModuleId } from '@/lib/ai-modules'; import { getAuthUserDisplayName } from '@/lib/auth-user-display'; @@ -29,6 +30,7 @@ export function ModuleSelection({ authRequired = true }: { authRequired?: boolea userProfile, profileRequired, profileSyncError, + syncProfileNow, } = useCurrentUserProfile(authRequired); const [profileDialogOpen, setProfileDialogOpen] = useState(false); const userDisplayName = getUserProfileDisplayName(userProfile) || getAuthUserDisplayName(authUser); @@ -48,8 +50,8 @@ export function ModuleSelection({ authRequired = true }: { authRequired?: boolea }; return ( -
-
+
+
-
-
-

+

+
+

{userDisplayName ? `${userDisplayName},欢迎回来!` : '欢迎回来!'}

-

今天,想创作什么?

+

今天,想创作什么?

- {aiModules.map((module) => { + {aiModules.filter((module) => module.id !== 'robot').map((module) => { const content = moduleSelectionContent[module.id]; const enabledByPolicy = isAiModuleAllowed(module.id, moduleAccess); const enabled = module.enabled && enabledByPolicy; @@ -114,6 +116,13 @@ export function ModuleSelection({ authRequired = true }: { authRequired?: boolea })}
+ {authenticated && authUser &&
+ { + setDismissedSyncError(null); setProfileDialogOpen(true); + void syncProfileNow().catch(() => undefined); + }} /> +
}
{ + const app = await launchElectronApp({ skipSetup: true }); + try { + const page = await getStableWindow(app); + await expect(page.getByTestId('ai-module-selection-page')).toBeVisible(); + const applicationUrl = page.url(); + await page.goto('about:blank'); + await app.evaluate(({ ipcMain, BrowserWindow }) => { + BrowserWindow.getAllWindows()[0].setSize(1366, 900); + const calls: { method: string; path: string; operation?: string }[] = []; + Object.assign(globalThis, { cloudChannelEmptyFixtureCalls: calls }); + 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 }) => { + const path = request.path ?? ''; + if (path === '/api/auth/session/sync') return result({ success: true, session: { accessToken: 'ui-fixture', tokenType: 'Bearer', expiresAt: Date.now() + 3600000, lastActiveAt: Date.now(), canRefresh: false } }); + if (path === '/api/auth/me') return result({ success: true, user: { username: 'creator', userId: 'creator', tenantId: null, deptId: null, authorities: [] }, moduleAccess: { programming: true, design: true, robot: true, cloud_agents: true } }); + if (path === '/api/works/user/agent-profile') return result({ success: true, profile: { display_name: '创作者', age: null, gender: null, avatar_url: null, share_age_with_agents: false, share_gender_with_agents: false, analysis_enabled: true, completed: true, version: 1, updated_at: '2026-10-13T00:00:00Z' } }); + if (!path.startsWith('/api/cloud-agents/')) return result({ success: true }); + const body = typeof request.body === 'string' ? JSON.parse(request.body) : request.body ?? {}; + calls.push({ method: request.method ?? 'GET', path, operation: body.operation }); + if (path.endsWith('/recovery')) return result({ recent: null, pending: [] }); + if (path.endsWith('/agents') || path.endsWith('/bootstrap')) return result({ agents: [], next_cursor: null }); + if (path.endsWith('/actions') && body.operation === 'channelAccounts') return result({ items: [] }); + throw new Error('Unexpected empty channel fixture request ' + path); + }); + }); + await page.goto(applicationUrl); + await page.getByTestId('ai-module-option-cloud_agents').click(); + const sidebar = page.getByTestId('sidebar-cloud-agents-navigation'); + await sidebar.getByRole('button', { name: '渠道', exact: true }).click(); + await expect(page).toHaveURL(/\/cloud-agents\/channels$/); + const hint = page.getByRole('heading', { name: '还没有可连接的智能体', exact: true }); + await expect(hint).toBeVisible(); + await expect(page.getByText('先创建一个智能体,配置完成后点击“开始使用”,就能连接到微信。', { exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: '添加第一个微信', exact: true })).toHaveCount(0); + await expect(page.getByRole('button', { name: '添加微信', exact: true })).toBeEnabled(); + await page.screenshot({ path: testInfo.outputPath('wechat-no-agents-desktop.png'), fullPage: true }); + + await page.getByRole('button', { name: '添加微信', exact: true }).click(); + const dialog = page.getByRole('dialog', { name: '添加微信', exact: true }); + await expect(dialog.getByRole('heading', { name: '还没有可连接的智能体', exact: true })).toBeVisible(); + await expect(dialog.getByRole('button', { name: '下一步:扫码连接', exact: true })).toBeDisabled(); + await dialog.getByRole('button', { name: '关闭', exact: true }).click(); + + await app.evaluate(({ BrowserWindow }) => { const window = BrowserWindow.getAllWindows()[0]; window.setMinimumSize(800, 600); window.setSize(980, 680); }); + await expect(hint).toBeVisible(); + await expect.poll(() => page.getByTestId('main-content').evaluate(element => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1); + await page.screenshot({ path: testInfo.outputPath('wechat-no-agents-compact.png'), fullPage: true }); + await page.getByRole('button', { name: '去我的智能体', exact: true }).click(); + await expect(page).toHaveURL(/\/cloud-agents\?view=mine$/); + await expect(page.getByTestId('cloud-agents-page')).toBeVisible(); + await expect(page.getByRole('heading', { name: '做一个属于你的 AI 助手吧', exact: true })).toBeVisible(); + await expect(sidebar.getByRole('button', { name: '我的智能体', exact: true })).toHaveAttribute('aria-current', 'page'); + const calls = await app.evaluate(() => (globalThis as typeof globalThis & { cloudChannelEmptyFixtureCalls: { method: string; operation?: string }[] }).cloudChannelEmptyFixtureCalls); + expect(calls.filter(call => call.operation).map(call => call.operation)).toEqual(['channelAccounts']); + expect(calls.every(call => call.method === 'GET' || call.operation === 'channelAccounts')).toBe(true); + } finally { + await app.evaluate(({ BrowserWindow }) => { for (const window of BrowserWindow.getAllWindows()) window.destroy(); }).catch(() => undefined); + await closeElectronApp(app); + } +}); + test('WeChat binding cards connect an account and switch its Agent through the channel workspace', async ({ launchElectronApp }, testInfo) => { const app = await launchElectronApp({ skipSetup: true }); const agentSlug = 'ml-' + 'a'.repeat(32); diff --git a/tests/e2e/cloud-agent-chat-layout.spec.ts b/tests/e2e/cloud-agent-chat-layout.spec.ts index 08973620..c55bc298 100644 --- a/tests/e2e/cloud-agent-chat-layout.spec.ts +++ b/tests/e2e/cloud-agent-chat-layout.spec.ts @@ -1,5 +1,5 @@ import { test, expect, type Page } from '@playwright/test'; -import { createServer, type ViteDevServer } from 'vite'; +import { createServer, normalizePath, type ViteDevServer } from 'vite'; import { resolve } from 'node:path'; import type { CloudAttachment, CloudHistory, CloudInterrupt, CloudRun, CloudThread } from '../../shared/cloud-agents'; @@ -11,7 +11,7 @@ test.use({ headless: true, channel: process.env.MAKELORE_LAYOUT_BROWSER_CHANNEL, // the event stream are isolated fixtures; this suite never starts a native app // or contacts an account, cloud service or model. test.beforeAll(async () => { - const entry = resolve('tests/e2e/__cloud-agent-chat-layout-harness.tsx'); + const entry = normalizePath(resolve('tests/e2e/__cloud-agent-chat-layout-harness.tsx')); server = await createServer({ configFile: false, cacheDir: 'node_modules/.vite-cloud-agent-chat-layout', @@ -422,7 +422,7 @@ for (const viewport of [{ width: 375, height: 540 }, { width: 1440, height: 900 test(`reasoning streams separately and becomes collapsed durable history at ${viewport.width}×${viewport.height}`, async ({ page }) => { const errors = await openChat(page, 'thinking', viewport.width, viewport.height); const transcript = page.getByTestId('cloud-chat-messages'); - const thinking = page.getByTestId('cloud-chat-reasoning'); + const thinking = page.getByTestId('cloud-message-process'); const envelope = { thread_id: 'story-first', run_id: 'run-story-first' }; const prefix = '先观察故事里的小发现。\n'; const reasoning = prefix + Array.from({ length: 40 }, (_, index) => diff --git a/tests/e2e/cloud-agent-home.spec.ts b/tests/e2e/cloud-agent-home.spec.ts index 2aa2cc94..b0db44d6 100644 --- a/tests/e2e/cloud-agent-home.spec.ts +++ b/tests/e2e/cloud-agent-home.spec.ts @@ -151,7 +151,7 @@ test('Agent cards open conversation, adjustment and unfinished creation explicit await ready.getByRole('button', { name: '和它聊聊', exact: true }).click(); await expect(page.getByRole('region', { name: '智能体对话', exact: true })).toBeVisible(); await expect(page.getByTestId('cloud-agent-editor').getByRole('button', { name: '聊天', exact: true })).toHaveCount(0); - await page.getByTestId('cloud-agent-editor').getByRole('button', { name: '我的智能体', exact: true }).click(); + await page.getByTestId('cloud-agent-editor').getByRole('button', { name: '返回智能体列表', exact: true }).click(); await expect(ready).toBeVisible(); await ready.getByRole('button', { name: '调整', exact: true }).click(); await expect(page.getByRole('tab', { name: '介绍与提示词', exact: true })).toHaveAttribute('aria-selected', 'true'); diff --git a/tests/e2e/cloud-agent-knowledge.spec.ts b/tests/e2e/cloud-agent-knowledge.spec.ts index e72c75d9..b5bc7dec 100644 --- a/tests/e2e/cloud-agent-knowledge.spec.ts +++ b/tests/e2e/cloud-agent-knowledge.spec.ts @@ -1,5 +1,5 @@ import { test, expect, type Page } from '@playwright/test'; -import { build } from 'vite'; +import { build, normalizePath } from 'vite'; import { createServer, type Server } from 'node:http'; import { mkdtemp, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; @@ -23,7 +23,7 @@ test.beforeAll(async () => { const html = join(fixtureDir, 'index.html'); const output = join(fixtureDir, 'dist'); await writeFile(html, '
'); - const entry = resolve('tests/e2e/__cloud-agent-knowledge-harness.tsx'); + const entry = normalizePath(resolve('tests/e2e/__cloud-agent-knowledge-harness.tsx')); // Build the virtual entry once. A static fixture needs no Vite dev server, // background dependency discovery, file watcher, or HMR connection. await build({ @@ -38,8 +38,8 @@ test.beforeAll(async () => { if (id === entry) return ` import { createRoot } from 'react-dom/client'; import { createMemoryRouter, RouterProvider } from 'react-router-dom'; - import CloudAgents from ${JSON.stringify(resolve('src/pages/CloudAgents/index.tsx'))}; - import ${JSON.stringify(resolve('src/styles/globals.css'))}; + import CloudAgents from ${JSON.stringify(normalizePath(resolve('src/pages/CloudAgents/index.tsx')))}; + import ${JSON.stringify(normalizePath(resolve('src/styles/globals.css')))}; const router = createMemoryRouter([{ path: '/cloud-agents', element: }], { initialEntries: ['/cloud-agents?view=mine'] }); createRoot(document.getElementById('root')).render(
); `; diff --git a/tests/e2e/cloud-agent-navigation-layout.spec.ts b/tests/e2e/cloud-agent-navigation-layout.spec.ts index 733c74b9..4d138f87 100644 --- a/tests/e2e/cloud-agent-navigation-layout.spec.ts +++ b/tests/e2e/cloud-agent-navigation-layout.spec.ts @@ -1,5 +1,5 @@ import { test, expect, type Page } from '@playwright/test'; -import { createServer, type ViteDevServer } from 'vite'; +import { createServer, normalizePath, type ViteDevServer } from 'vite'; import { resolve } from 'node:path'; let server: ViteDevServer; @@ -9,7 +9,7 @@ test.use({ headless: true, channel: process.env.MAKELORE_LAYOUT_BROWSER_CHANNEL, // Exercise actual navigation controls and persisted layout state without // starting Electron or connecting to an account or any cloud service. test.beforeAll(async () => { - const entry = resolve('tests/e2e/__cloud-agent-navigation-layout-harness.tsx'); + const entry = normalizePath(resolve('tests/e2e/__cloud-agent-navigation-layout-harness.tsx')); server = await createServer({ configFile: false, cacheDir: 'node_modules/.vite-cloud-agent-navigation-layout', diff --git a/tests/e2e/cloud-agent-platform-avatars.spec.ts b/tests/e2e/cloud-agent-platform-avatars.spec.ts index 144fd2f7..54865fb6 100644 --- a/tests/e2e/cloud-agent-platform-avatars.spec.ts +++ b/tests/e2e/cloud-agent-platform-avatars.spec.ts @@ -1,5 +1,5 @@ import { test, expect, type Page } from '@playwright/test'; -import { createServer, type ViteDevServer } from 'vite'; +import { createServer, normalizePath, type ViteDevServer } from 'vite'; import { resolve } from 'node:path'; import { EMPTY_CLOUD_CONFIGURATION, type CloudAgentDraft } from '../../shared/cloud-agents'; @@ -10,7 +10,7 @@ test.use({ headless: true, channel: process.env.MAKELORE_LAYOUT_BROWSER_CHANNEL, // Render the real workspace and keep all service calls at the Main IPC boundary. // This suite never starts Electron, opens a native file picker or calls a live service. test.beforeAll(async () => { - const entry = resolve('tests/e2e/__cloud-agent-platform-avatars-harness.tsx'); + const entry = normalizePath(resolve('tests/e2e/__cloud-agent-platform-avatars-harness.tsx')); server = await createServer({ configFile: false, cacheDir: 'node_modules/.vite-cloud-agent-platform-avatars', diff --git a/tests/e2e/cloud-agent-recovery.spec.ts b/tests/e2e/cloud-agent-recovery.spec.ts new file mode 100644 index 00000000..cd8e58b1 --- /dev/null +++ b/tests/e2e/cloud-agent-recovery.spec.ts @@ -0,0 +1,220 @@ +import { test, expect, type Locator, type Page } from '@playwright/test'; +import { build } from 'vite'; +import { createServer, type Server } from 'node:http'; +import { createRequire } from 'node:module'; +import { mkdtemp, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve, sep } from 'node:path'; +import { EMPTY_CLOUD_CONFIGURATION, type CloudAgentDraft, type CloudPendingOperation } from '../../shared/cloud-agents'; + +let server: Server; +let fixtureDir: string; +let baseUrl: string; +const fixtureRequire = createRequire(resolve('tests/e2e/cloud-agent-recovery.spec.ts')); +const viteDependencies = createRequire(fixtureRequire.resolve('vite/package.json')); +test.use({ headless: true, channel: undefined, video: 'off' }); + +// Exercise the real page and styles with Main IPC mocked. The static fixture +// never starts Electron, a native window, a file picker, or a cloud service. +test.beforeAll(async () => { + fixtureDir = await realpath(await mkdtemp(join(tmpdir(), 'makelore-recovery-fixture-'))); + await symlink(resolve('node_modules'), join(fixtureDir, 'node_modules'), 'dir'); + const html = join(fixtureDir, 'index.html'); + const output = join(fixtureDir, 'dist'); + const entry = resolve('tests/e2e/__cloud-agent-recovery-harness.tsx'); + await writeFile(html, '
'); + await build({ + configFile: false, root: fixtureDir, publicDir: false, logLevel: 'warn', + resolve: { alias: { '@': resolve('src') } }, esbuild: { jsx: 'automatic' }, + css: { postcss: resolve('.') }, + build: { outDir: output, emptyOutDir: true, rollupOptions: { input: html } }, + plugins: [{ + name: 'isolated-cloud-agent-recovery', enforce: 'pre', + resolveId(id) { if (id === '/__cloud-agent-recovery-harness.tsx') return entry; }, + load(id) { + if (id === entry) return ` + import { createRoot } from 'react-dom/client'; + import { createMemoryRouter, RouterProvider } from 'react-router-dom'; + import CloudAgents from ${JSON.stringify(resolve('src/pages/CloudAgents/index.tsx'))}; + import ${JSON.stringify(resolve('src/styles/globals.css'))}; + const router = createMemoryRouter([{ path: '/cloud-agents', element: }], { initialEntries: ['/cloud-agents?view=mine'] }); + createRoot(document.getElementById('root')).render(
); + `; + if (id.endsWith('/src/stores/auth.ts')) return 'export function useAuthStore(select) { return select({ user: { userId: "recovery-fixture" } }); }'; + }, + }], + }); + const mime: Record = { html: 'text/html', js: 'text/javascript', css: 'text/css', svg: 'image/svg+xml', + png: 'image/png', woff2: 'font/woff2', woff: 'font/woff' }; + server = createServer(async (req, res) => { + const pathname = decodeURIComponent(new URL(req.url ?? '/', 'http://127.0.0.1').pathname); + const file = resolve(output, '.' + (pathname === '/' ? '/index.html' : pathname)); + if (!file.startsWith(output + sep)) { res.writeHead(403); res.end(); return; } + try { res.setHeader('Content-Type', mime[file.split('.').pop()!] ?? 'application/octet-stream'); res.end(await readFile(file)); } + catch { res.writeHead(404); res.end(); } + }); + await new Promise((done, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', done); }); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Fixture HTTP address is unavailable'); + baseUrl = `http://127.0.0.1:${address.port}`; +}); +test.afterAll(async () => { + try { + if (server?.listening) { server.closeAllConnections(); await new Promise((done, reject) => server.close(error => error ? reject(error) : done())); } + } finally { + try { if (fixtureDir) await rm(fixtureDir, { recursive: true, force: true }); } + finally { (viteDependencies('esbuild') as { stop(): void }).stop(); } + } +}); + +const agent: CloudAgentDraft = { + slug: 'ml-' + 'a'.repeat(32), name: '资料小助手', purpose: '一起观察和记录。', system_prompt: '用简单的中文回答。', + draft_revision: 1, configuration: { ...EMPTY_CLOUD_CONFIGURATION, model: 'fixture-model' }, + published_version: 1, enabled: true, updated_at: '2026-10-13T00:00:00Z', +}; +const longName = ('观察小伙伴_' + 'SuperLongUnbrokenAgentName'.repeat(4)).slice(0, 100); +const records: CloudPendingOperation[] = Array.from({ length: 8 }, (_, index) => ({ + id: `e2e-create-record-${index + 1}`, operation: 'createAgent', + input: { operation_id: `e2e-create-record-${index + 1}`, name: index === 0 ? longName : `我的观察伙伴 ${index + 1}`, purpose: '', icon: null }, + created_at: `2026-10-13T0${8 - index}:30:00Z`, +})); +const retryError = '创建结果暂时无法确认,请稍后再试。'; +type FixtureCall = { path: string; method: string; input: Record | null }; +type RecoveryFixture = { agents: CloudAgentDraft[]; pending: CloudPendingOperation[]; retry: 'fail' | 'success'; calls: FixtureCall[] }; +type FixtureWindow = Window & { __recoveryFixture: RecoveryFixture }; + +async function installFixture(page: Page, count = 2) { + page.on('pageerror', error => { throw error; }); + await page.addInitScript(({ agent, records, retryError }) => { + const scope = window as unknown as FixtureWindow; + const state: RecoveryFixture = { agents: [agent], pending: records, retry: 'fail', calls: [] }; + scope.__recoveryFixture = state; + const result = (json: unknown) => ({ ok: true, data: { status: 200, ok: true, json: structuredClone(json) } }); + Object.defineProperty(window, 'electron', { value: { ipcRenderer: { + invoke: async (channel: string, request?: { path?: string; method?: string; body?: string }) => { + if (channel === 'hostapi:token') return 'fixture'; + if (channel === 'hostapi:base-url') return 'http://fixture.invalid'; + if (channel !== 'hostapi:fetch') throw new Error('Unexpected fixture IPC ' + channel); + const path = request?.path ?? ''; const method = request?.method ?? 'GET'; + const input = request?.body ? JSON.parse(request.body) as Record : null; + state.calls.push({ path, method, input }); + if (path === '/api/cloud-agents/recovery') return result({ recent: null, pending: state.pending }); + if (path === '/api/cloud-agents/recovery/recent') return result({ saved: true }); + if (path === '/api/cloud-agents/recovery/pending') { + 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 } } }; + 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 }); + } + if (path.startsWith('/api/cloud-agents/agents')) { + if (method !== 'GET') throw new Error('No new Agent may be created by reminder browsing'); + return result(path === '/api/cloud-agents/agents' ? { agents: state.agents, next_cursor: null } : agent); + } + if (path !== '/api/cloud-agents/actions') throw new Error('Unexpected fixture path ' + path); + switch (input?.operation) { + case 'access': return result({ published_version: 1, enabled: true, share_url: 'niancode://agents/' + agent.slug, + api_url: 'http://fixture.invalid', grants: [], applications: [], versions: [{ version: 1, draft_revision: 1 }] }); + case 'catalog': return result({ models: [{ id: 'fixture-model', name: '创作模型' }], resources: {}, pricing: null }); + case 'knowledge': return result({ databases: [], models: [] }); + case 'threads': return result({ threads: [], next_offset: null }); + default: throw new Error('Unexpected fixture operation ' + String(input?.operation)); + } + }, + } } }); + }, { agent, records: records.slice(0, count), retryError }); +} + +async function recoveryCalls(page: Page) { + return page.evaluate(() => (window as unknown as FixtureWindow).__recoveryFixture.calls.filter(call => call.path === '/api/cloud-agents/recovery/pending')); +} +async function expectNoReminderOutsideDialog(page: Page) { + await expect(page.getByRole('complementary', { name: '未确认的云端操作', exact: true })).toHaveCount(0); + await expect(page.getByText('有 2 个操作的结果尚未确认', { exact: true })).toHaveCount(0); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); +} +async function expectModalFits(dialog: Locator) { + const geometry = await dialog.evaluate(element => { + const box = element.getBoundingClientRect(); + return { fits: box.left >= 0 && box.top >= 0 && box.right <= innerWidth && box.bottom <= innerHeight, + horizontalOverflow: element.scrollWidth > element.clientWidth }; + }); + expect(geometry).toEqual({ fits: true, horizontalOverflow: false }); +} + +for (const width of [1440, 375]) { + test(`creation records stay in the header and use a scrollable modal at ${width}px`, async ({ page }) => { + const count = width === 375 ? 8 : 2; + await page.setViewportSize({ width, height: 850 }); await installFixture(page, count); await page.goto(baseUrl); + const home = page.getByTestId('cloud-agents-page'); + const trigger = home.getByRole('button', { name: new RegExp(`^创建记录.*${count}`) }); + await expect(trigger).toBeVisible(); await expect(page.getByRole('dialog')).toHaveCount(0); + expect(await trigger.evaluate(button => Boolean(button.closest('.agent-home-header')))).toBe(true); + await expect(page.getByRole('article', { name: agent.name, exact: true })).toBeVisible(); + await expectNoReminderOutsideDialog(page); expect(await recoveryCalls(page)).toEqual([]); + await page.screenshot({ path: test.info().outputPath(`recovery-home-${width}.png`), animations: 'disabled' }); + await trigger.click(); + const dialog = page.getByRole('dialog', { name: '创建记录', exact: true }); + await expect(dialog).toBeVisible(); await expect(dialog).toContainText(longName); + await expect(dialog.locator('.agent-recovery-record')).toHaveCount(count); + await expect(dialog.locator('.agent-recovery-time').first()).not.toHaveText(''); + const close = dialog.getByRole('button', { name: '返回列表', exact: true }); + await expect(close).toBeInViewport(); await expectModalFits(dialog); + await dialog.locator('.agent-recovery-record').last().scrollIntoViewIfNeeded(); + await expect(dialog.locator('.agent-recovery-record').last().getByRole('button', { name: '再试一次', exact: true })).toBeInViewport(); + await expect(close).toBeInViewport(); await expectModalFits(dialog); + expect(await recoveryCalls(page)).toEqual([]); + await page.screenshot({ path: test.info().outputPath(`recovery-modal-${width}.png`), animations: 'disabled' }); + await close.click(); await expect(dialog).toHaveCount(0); + await expect(page.getByRole('article', { name: agent.name, exact: true })).toBeVisible(); + await trigger.click(); await expect(dialog.locator('.agent-recovery-record')).toHaveCount(count); + await close.click(); expect(await recoveryCalls(page)).toEqual([]); + }); +} + +test('retry keeps the same pending identity and errors remain inside the modal', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 850 }); await installFixture(page); await page.goto(baseUrl); + const trigger = page.getByRole('button', { name: /^创建记录/ }); + await trigger.click(); const dialog = page.getByRole('dialog', { name: '创建记录', exact: true }); + for (let attempt = 0; attempt < 2; attempt++) { + await dialog.getByRole('button', { name: '再试一次', exact: true }).first().click(); + await expect(dialog.getByRole('alert')).toContainText(retryError); + await expect(dialog.getByRole('button', { name: '再试一次', exact: true }).first()).toBeEnabled(); + } + expect((await recoveryCalls(page)).map(call => call.input)).toEqual([{ id: records[0].id, discard: false }, { id: records[0].id, discard: false }]); + await page.screenshot({ path: test.info().outputPath('recovery-retry-error-375.png'), animations: 'disabled' }); + await dialog.getByRole('button', { name: '关闭记录', exact: true }).click(); + await expect(dialog).toHaveCount(0); await expect(page.getByRole('alert')).toHaveCount(0); + await expectNoReminderOutsideDialog(page); await expect(page.getByRole('article', { name: agent.name, exact: true })).toBeVisible(); + await trigger.click(); await expect(dialog.locator('.agent-recovery-record')).toHaveCount(2); + expect(await recoveryCalls(page)).toHaveLength(2); +}); + +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: /^创建记录/ }); + await trigger.click(); const dialog = page.getByRole('dialog', { name: '创建记录', exact: true }); + await dialog.getByRole('button', { name: '不再提醒', exact: true }).first().click(); + await expect(dialog.locator('.agent-recovery-record')).toHaveCount(1); + expect((await recoveryCalls(page)).map(call => call.input)).toEqual([{ id: records[0].id, discard: true }]); + await dialog.getByRole('button', { name: '返回列表', exact: true }).click(); + await expect(trigger).toContainText('1'); + const card = page.getByRole('article', { name: agent.name, exact: true }); + await card.getByRole('button', { name: '调整', exact: true }).click(); + await expect(page.getByTestId('cloud-agent-editor')).toBeVisible(); + await expect(page.getByRole('button', { name: /^创建记录/ })).toHaveCount(0); await expectNoReminderOutsideDialog(page); + await page.getByRole('button', { name: '我的智能体', exact: true }).click(); + await card.getByRole('button', { name: '和它聊聊', exact: true }).click(); + await expect(page.getByRole('button', { name: '返回智能体列表', exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: /^创建记录/ })).toHaveCount(0); await expectNoReminderOutsideDialog(page); + await page.getByRole('button', { name: '返回智能体列表', exact: true }).click(); + await page.getByRole('button', { name: '创建智能体', exact: true }).click(); + await expect(page.getByRole('heading', { name: '创建智能体', exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: /^创建记录/ })).toHaveCount(0); await expectNoReminderOutsideDialog(page); + await page.getByRole('button', { name: '取消', exact: true }).click(); + await expect(trigger).toContainText('1'); expect(await recoveryCalls(page)).toHaveLength(1); + const calls = await page.evaluate(() => (window as unknown as FixtureWindow).__recoveryFixture.calls); + expect(calls.filter(call => call.path.startsWith('/api/cloud-agents/agents') && call.method !== 'GET')).toEqual([]); +}); diff --git a/tests/e2e/cloud-agents.spec.ts b/tests/e2e/cloud-agents.spec.ts index bb52dbe4..5b622356 100644 --- a/tests/e2e/cloud-agents.spec.ts +++ b/tests/e2e/cloud-agents.spec.ts @@ -18,8 +18,10 @@ test('personal cloud Agent identity, configuration and separate published chat i let draft: Record | null = null; let recent: unknown = null; let createInput: Record | null = null; + const creationInputs: Record[] = []; + let pending: Record[] = []; const operations: string[] = []; - Reflect.set(globalThis, '__cloudAgentUiFixture', { snapshot: () => ({ draft, createInput, operations }) }); + Reflect.set(globalThis, '__cloudAgentUiFixture', { snapshot: () => ({ draft, createInput, creationInputs, operations }) }); const versions: { version: number; draft_revision: number; created_at: string }[] = []; let knowledgeCatalogLoads = 0; let knowledgeCreated = false; @@ -51,14 +53,15 @@ test('personal cloud Agent identity, configuration and separate published chat i knowledgeUploaded = true; return result({ ...knowledgeFile, status: 'uploaded', chunk_count: 0, available: false, processing_task: null }); } - if (path.endsWith('/recovery')) return result({ recent, pending: [] }); + if (path.endsWith('/recovery')) return result({ recent, pending }); + if (path.endsWith('/recovery/pending') && body.discard === true) { pending = []; return result({ discarded: true }); } if (path.endsWith('/recovery/recent')) { recent = body; return result({ saved: true }); } if (path.endsWith('/actions')) { operations.push(body.operation); const input = body.input ?? {}; if (body.operation === 'catalog') return result({ models: [{ id: 'test-model', name: '创作模型', description: '模型说明不应显示在配置页' }, { id: 'reasoning-model', name: '思考模型' }], - resources: { tools: [{ key: 'fixture-tool', name: '资料整理工具' }], skills: [{ key: 'fixture-skill', name: '文章整理 Skill' }] }, + resources: { tools: [{ key: 'fixture-tool', name: '资料整理工具' }, { key: 'web_search', name: '上网查资料', description: '在网上查找相关信息。' }], skills: [{ key: 'fixture-skill', name: '文章整理 Skill' }, { key: 'deep-research', name: '查资料,做研究', description: '比较不同说法,再整理出结论和出处。' } ] }, pricing: { unit: '词元点数', usage_unit: '词元', unit_size: 1000, points_per_unit: '9.99', version: 1 }, }); if (body.operation === 'knowledge') return result({ databases: knowledgeCreated ? [{ kb_id: 'kb', name: '我的创作资料' }] : [], models: ++knowledgeCatalogLoads === 1 ? [] : [{ id: 'fixture:test-embedding', name: '资料向量模型', dimension: 1024 }] }); @@ -98,7 +101,19 @@ test('personal cloud Agent identity, configuration and separate published chat i if (body.operation === 'files') return result({ files: [] }); throw new Error('Unexpected cloud fixture operation ' + body.operation); } - if (method === 'POST') { createInput = body; draft = { + if (method === 'POST') { + creationInputs.push(body); + if (creationInputs.length === 1) return { ok: true, data: { status: 422, ok: false, + json: { error: '名称没有被接受,请修改后重试', code: 'invalid_name', commandOutcome: 'definitive_failure' } } }; + if (creationInputs.length === 2) { + pending = [{ id: `createAgent::${body.operation_id}`, operation: 'createAgent', input: body, created_at: new Date().toISOString() }]; + return { ok: true, data: { status: 502, ok: false, + json: { error: '暂时无法确认创建结果', code: 'cloud_service_unavailable', commandOutcome: 'unknown' } } }; + } + if (creationInputs.length === 3) return { ok: true, data: { status: 422, ok: false, + json: { error: '名称没有被接受,请稍后重试', code: 'invalid_name', commandOutcome: 'definitive_failure' } } }; + pending = []; + createInput = body; draft = { slug: 'ml-' + 'a'.repeat(32), name: body.name, purpose: body.purpose, system_prompt: body.purpose, icon: body.icon ?? null, icon_url: body.icon === uploadedAvatar.icon ? uploadedAvatar.icon_url : null, draft_revision: 1, updated_at: '2026-09-10T00:00:00Z', @@ -161,11 +176,26 @@ test('personal cloud Agent identity, configuration and separate published chat i await expect(page.getByRole('button', { name: '上一步', exact: true })).toHaveCount(0); await expect(page.getByRole('button', { name: '下一步', exact: true })).toBeDisabled(); await page.screenshot({ path: testInfo.outputPath('cloud-agent-create-empty-form.png') }); - await page.getByLabel('名称', { exact: true }).fill('我的写作搭档'); + await page.getByLabel('名称', { exact: true }).fill('待修改的名字'); await page.getByRole('button', { name: '上传头像', exact: true }).click(); - await expect(page.getByRole('img', { name: '我的写作搭档头像', exact: true })).toHaveAttribute('src', avatar.icon_url); + await expect(page.getByRole('img', { name: '待修改的名字头像', exact: true })).toHaveAttribute('src', avatar.icon_url); await page.screenshot({ path: testInfo.outputPath('cloud-agent-create-identity.png') }); await page.getByRole('button', { name: '下一步', exact: true }).click(); + await expect(page.getByRole('alert')).toContainText('创建失败'); + await expect(page.getByLabel('名称', { exact: true })).toBeEnabled(); + await expect(page.getByRole('button', { name: '更换头像', exact: true })).toBeEnabled(); + await expect(page.getByRole('complementary', { name: '未确认的云端操作' })).toHaveCount(0); + await page.getByLabel('名称', { exact: true }).fill('我的写作搭档'); + await page.getByRole('button', { name: '下一步', exact: true }).click(); + await expect(page.getByLabel('名称', { exact: true })).toBeDisabled(); + await expect(page.getByRole('button', { name: '更换头像', exact: true })).toBeDisabled(); + await expect(page.getByRole('complementary', { name: '未确认的云端操作' })).toBeVisible(); + await page.getByRole('button', { name: '移除提醒', exact: true }).click(); + await expect(page.getByRole('complementary', { name: '未确认的云端操作' })).toHaveCount(0); + await page.getByRole('button', { name: '重试创建', exact: true }).click(); + await expect(page.getByRole('alert')).toContainText('创建结果暂未确认'); + await expect(page.getByLabel('名称', { exact: true })).toBeDisabled(); + await page.getByRole('button', { name: '重试创建', exact: true }).click(); const editor = page.getByTestId('cloud-agent-editor'); const instructionsTab = page.getByRole('tab', { name: '介绍与提示词', exact: true }); @@ -189,6 +219,10 @@ test('personal cloud Agent identity, configuration and separate published chat i const created = await fixtureState(); expect(created.createInput).toMatchObject({ name: '我的写作搭档', icon: avatar.icon, purpose: '' }); expect(created.createInput).not.toHaveProperty('icon_url'); + expect(created.creationInputs).toHaveLength(4); + expect(created.creationInputs[0].operation_id).not.toBe(created.creationInputs[1].operation_id); + expect(created.creationInputs[1]).toEqual(created.creationInputs[2]); + expect(created.creationInputs[1]).toEqual(created.creationInputs[3]); expect(await messageOperations()).toEqual([]); await page.getByRole('button', { name: '修改头像', exact: true }).click(); @@ -214,7 +248,11 @@ test('personal cloud Agent identity, configuration and separate published chat i await expect(page.getByRole('heading', { name: '费用上限', exact: true })).toHaveCount(0); await expect(page.getByRole('heading', { name: '运行限制', exact: true })).toHaveCount(0); await page.getByRole('button', { name: '选择工具箱', exact: true }).click(); + await expect(page.getByRole('checkbox', { name: /上网查资料.*在网上查找相关信息/ })).toBeVisible(); await page.getByRole('checkbox', { name: '资料整理工具', exact: true }).check(); + await page.getByRole('button', { name: '选择做事手册', exact: true }).click(); + await page.getByLabel('搜索做事手册', { exact: true }).fill('出处'); + await expect(page.getByRole('checkbox', { name: /查资料,做研究.*出处/ })).toBeVisible(); await instructionsTab.click(); await expect(introduction).toHaveValue('一起梳理故事和文章'); await expect(prompt).toHaveValue(initialPrompt); diff --git a/tests/e2e/cloud-chat-rendering.spec.ts b/tests/e2e/cloud-chat-rendering.spec.ts new file mode 100644 index 00000000..38f2151a --- /dev/null +++ b/tests/e2e/cloud-chat-rendering.spec.ts @@ -0,0 +1,101 @@ +import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron'; + +test('personal chat displays thinking, tools and Markdown, then restores them after reload', async ({ launchElectronApp }, testInfo) => { + const app = await launchElectronApp({ skipSetup: true }); + try { + const page = await getStableWindow(app); + await expect(page.getByTestId('ai-module-selection-page')).toBeVisible(); + const applicationUrl = page.url(); + await page.goto('about:blank'); + // Cloud provider is deterministic here; the real history HTTP/PG boundary is tested in Yuxi. + await app.evaluate(({ ipcMain, BrowserWindow }) => { + BrowserWindow.getAllWindows()[0].setSize(1180, 820); + let complete = false; + Object.assign(globalThis, { finishRenderRun: () => { complete = true; } }); + const slug = 'ml-' + 'a'.repeat(32); + const configuration = { model: 'test', tools: [], knowledges: [], mcps: [], skills: [], preload_skills: [], subagents: [], tool_approval_mode: 'always_trust', max_execution_steps: 300, max_output_tokens: 4096, max_run_seconds: 600 }; + const draft = { slug, name: '渲染测试助手', purpose: '整理资料', system_prompt: '', configuration, + draft_revision: 1, published_version: 1, enabled: true, updated_at: '' }; + const run = { agent_run_id: 'render-run', request_id: 'render-request', thread_id: 'render-thread', agent_slug: slug, status: 'running', output: '', version: '1' }; + const thread = { thread_id: 'render-thread', client_thread_id: 'client', agent_slug: slug, title: '整理资料', mode: 'published', updated_at: '', run_id: run.agent_run_id, unread: false, status: 'running' }; + 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; body?: string }) => { + const path = request.path ?? ''; + const body = request.body ? JSON.parse(request.body) : {}; + if (path === '/api/auth/session/sync') return result({ success: true, session: { accessToken: 'fixture', tokenType: 'Bearer', expiresAt: Date.now() + 3600000, lastActiveAt: Date.now(), canRefresh: false } }); + if (path === '/api/auth/me') return result({ success: true, user: { username: 'creator', userId: 'creator', tenantId: null, deptId: null, authorities: [] }, moduleAccess: { programming: true, design: true, robot: true, cloud_agents: true } }); + if (path === '/api/works/user/agent-profile') return result({ success: true, profile: { display_name: '创作者', completed: true, version: 1 } }); + if (path.endsWith('/recovery')) return result({ recent: null, pending: [] }); + if (path.endsWith('/recovery/recent')) return result({}); + if (path.endsWith('/agents') || path.endsWith('/bootstrap')) return result({ agents: [draft], next_cursor: null }); + if (path.endsWith('/actions')) { + if (body.operation === 'threads') return result({ threads: [thread], next_offset: null }); + if (body.operation === 'history') return result({ thread_id: thread.thread_id, run: { ...run, status: complete ? 'completed' : 'running' }, next_offset: null, messages: complete ? [{ + id: 1, message_id: 'answer', role: 'assistant', run_id: run.agent_run_id, request_id: run.request_id, created_at: '', + content: '## 整理结果\n\n| 项目 | 状态 |\n| --- | --- |\n| 资料 | 已核对 |\n\n**可以使用**', + reasoning_content: '先核对来源,再整理结果。', + tool_calls: [{ id: 'lookup', name: 'web_search', display_name: '上网查资料', display_description: '在网上查找相关信息。', args: { query: '实际检索资料' }, status: 'success', tool_call_result: { content: '找到两条资料' } }], + }] : [] }); + if (body.operation === 'run') return result({ ...run, status: complete ? 'completed' : 'running' }); + if (body.operation === 'viewed') return result({}); + if (body.operation === 'files') return result({ files: [] }); + if (body.operation === 'attachments') return result({ attachments: [] }); + if (body.operation === 'catalog') return result({ models: [{ id: 'test', name: '测试模型' }], resources: {}, pricing: null }); + if (body.operation === 'access') return result({ published_version: 1, enabled: true, versions: [], grants: [], applications: [], share_url: '', api_url: '' }); + if (body.operation === 'channelBindings') return result({ items: [], available_accounts: [] }); + return result({}); + } + if (path.startsWith('/api/cloud-agents/agents/')) return result(draft); + return result({ success: true }); + }); + }); + await page.addInitScript(() => { + const sources: EventTarget[] = []; + Object.assign(window, { renderSources: sources }); + class FixtureSource extends EventTarget { + onopen = null; onerror = null; close() {} + constructor(url: string) { super(); if (url.includes('/cloud-agents/runs/')) sources.push(this); } + } + Object.defineProperty(window, 'EventSource', { value: FixtureSource }); + }); + await page.goto(applicationUrl); + await page.getByTestId('ai-module-option-cloud_agents').click(); + await page.getByRole('article', { name: '渲染测试助手', exact: true }).getByRole('button', { name: '和它聊聊', exact: true }).click(); + const emit = async (items: unknown[], cursor: string) => page.evaluate(({ items, cursor }) => { + const sources = (window as unknown as { renderSources: EventTarget[] }).renderSources; + sources.at(-1)!.dispatchEvent(new MessageEvent('messages', { lastEventId: cursor, data: JSON.stringify({ run_id: 'render-run', thread_id: 'render-thread', payload: { items } }) })); + }, { items, cursor }); + await expect.poll(() => page.evaluate(() => (window as unknown as { renderSources: EventTarget[] }).renderSources.length)).toBeGreaterThan(0); + await emit([{ stream_event: { type: 'message_delta', message_id: 'answer', reasoning_content: '先核对来源,再整理结果。' } }], '100-1'); + const process = page.getByTestId('cloud-message-process'); + await expect(page.getByLabel('智能体思考过程')).toBeVisible(); + await emit([ + { stream_event: { type: 'tool_call', message_id: 'answer', tool_call_id: 'lookup', name: 'web_search', display_name: '上网查资料', display_description: '在网上查找相关信息。', args: { query: '测试资料' } } }, + { event: { method: 'tools', data: { event: 'tool-started', tool_call_id: 'lookup', input: { query: '实际检索资料' } } } }, + { event: { method: 'tools', data: { event: 'tool-finished', tool_call_id: 'lookup', output: { content: '找到两条资料' } } } }, + { stream_event: { type: 'message_delta', message_id: 'answer', content: '## 整理结果\n\n| 项目 | 状态 |\n| --- | --- |\n| 资料 | 已核对 |\n\n**可以使用**' } }, + ], '100-2'); + await expect(page.getByRole('heading', { name: '整理结果' })).toBeVisible(); + await expect(page.getByRole('table')).toBeVisible(); + await process.locator(':scope > summary').click(); + await process.getByText('上网查资料 · 已完成', { exact: true }).click(); + await expect(process.getByText('找到两条资料', { exact: true })).toBeVisible(); + await expect(process.getByText('在网上查找相关信息。', { exact: true })).toBeVisible(); + await expect(process.getByText('工具标识:web_search', { exact: true })).toBeVisible(); + await expect(process.getByText('"实际检索资料"', { exact: false })).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath('personal-chat-process.png') }); + await app.evaluate(() => (globalThis as unknown as { finishRenderRun: () => void }).finishRenderRun()); + await page.evaluate(() => (window as unknown as { renderSources: EventTarget[] }).renderSources.at(-1)!.dispatchEvent(new MessageEvent('end', { data: JSON.stringify({ run_id: 'render-run', thread_id: 'render-thread', payload: { status: 'completed' } }) }))); + await expect(page.getByRole('heading', { name: '整理结果' })).toHaveCount(1); + await page.reload(); + await page.getByRole('article', { name: '渲染测试助手', exact: true }).getByRole('button', { name: '和它聊聊', exact: true }).click(); + await expect(page.getByRole('heading', { name: '整理结果' })).toBeVisible(); + await page.getByTestId('cloud-message-process').locator(':scope > summary').click(); + await expect(page.getByLabel('智能体思考过程')).toHaveText('先核对来源,再整理结果。'); + await page.getByText('上网查资料 · 已完成', { exact: true }).click(); + await expect(page.getByText('在网上查找相关信息。', { exact: true })).toBeVisible(); + await expect(page.getByText('工具标识:web_search', { exact: true })).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath('personal-chat-reopened.png') }); + } finally { await closeElectronApp(app); } +}); diff --git a/tests/e2e/main-navigation.spec.ts b/tests/e2e/main-navigation.spec.ts index cf9f9590..608a3c83 100644 --- a/tests/e2e/main-navigation.spec.ts +++ b/tests/e2e/main-navigation.spec.ts @@ -1,6 +1,7 @@ import { mkdir, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron'; +import { billingSummary } from '../fixtures/permanent-points'; async function seedInactiveCodingProjects(resources: { rootDir: string; userDataDir: string }) { const now = '2026-09-06T08:00:00.000Z'; @@ -50,6 +51,139 @@ async function seedInactiveCodingProjects(resources: { rootDir: string; userData } test.describe('Makelore module navigation without setup flow', () => { + test('keeps the home account wallet, profile, settings and logout available without creating a payment', async ({ launchElectronApp }, testInfo) => { + const app = await launchElectronApp({ skipSetup: true }); + try { + const page = await getStableWindow(app); + await expect(page.getByTestId('ai-module-selection-page')).toBeVisible(); + const applicationUrl = page.url(); + await page.goto('about:blank'); + await app.evaluate(({ ipcMain, BrowserWindow }, summary) => { + BrowserWindow.getAllWindows()[0].setSize(1440, 900); + const state = { loggedIn: true, requests: [] as { method: string; path: string }[] }; + Object.assign(globalThis, { homeAccountFixture: state }); + 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 }) => { + const path = request.path ?? ''; + const method = (request.method ?? 'GET').toUpperCase(); + state.requests.push({ method, path }); + if (path === '/api/auth/session/sync') return result({ success: true, session: state.loggedIn ? { accessToken: 'home-account-fixture', tokenType: 'Bearer', expiresAt: Date.now() + 3600000, lastActiveAt: Date.now(), canRefresh: false } : null }); + if (path === '/api/auth/me') return result({ success: true, user: state.loggedIn ? { username: 'child-wallet', userId: 'home-user', tenantId: null, deptId: null, authorities: [] } : null, moduleAccess: { programming: true, design: true, robot: true, cloud_agents: true } }); + if (path === '/api/auth/logout' && method === 'POST') { state.loggedIn = false; return result({ success: true }); } + if (path === '/api/works/user/agent-profile' && method === 'GET') return result({ success: true, profile: { display_name: '小麦', age: null, gender: null, avatar_url: null, share_age_with_agents: false, share_gender_with_agents: false, analysis_enabled: true, completed: true, version: 1, updated_at: '2026-10-13T00:00:00Z' } }); + if (path === '/api/works/billing/me' && method === 'GET') return result({ success: true, data: summary }); + if (path.startsWith('/api/works/billing/recharge/orders?') && method === 'GET') return result({ success: true, data: { items: [] } }); + if (path.startsWith('/api/works/billing/') || path === '/api/works/user/agent-profile') throw new Error('Unexpected home account write ' + method + ' ' + path); + return result({ success: true }); + }); + }, billingSummary()); + // The setup-skipping fixture starts as a guest. Hydrate only this isolated + // Renderer with a fake persisted session before the auth store is created. + await page.addInitScript(() => { + const now = Date.now(); + localStorage.setItem('niancode-auth', JSON.stringify({ version: 4, state: { + authBase: 'https://biz.nianxx.cn/auth/', clientId: 'app', + accessToken: 'home-account-fixture', tokenType: 'Bearer', + expiresAt: now + 3600000, lastActiveAt: now, canRefresh: false, legacyRefreshToken: null, + user: { username: 'child-wallet', userId: 'home-user', tenantId: null, deptId: null, authorities: [] }, + moduleAccess: { programming: true, design: true, robot: true, cloud_agents: true }, + } })); + // Setup-skipping mode also suppresses the home profile sync. Restore the + // isolated account's cached profile in the store's persisted shape. + localStorage.setItem('niancode-user-agent-profiles', JSON.stringify({ version: 1, state: { + profilesByUserId: { 'home-user': { displayName: '小麦', age: null, gender: 'undisclosed', avatarUrl: null, + shareAgeWithAgents: false, shareGenderWithAgents: false, analysisEnabled: true, + pendingSync: false, version: 1, updatedAt: '2026-10-13T00:00:00Z' } }, + } })); + }); + await page.goto(applicationUrl); + const trigger = page.getByTestId('home-account-menu-trigger'); + const menu = page.getByTestId('home-account-menu'); + const surface = page.getByTestId('home-account-trigger-surface'); + const checkAccountGeometry = async (width: number) => { + await expect.poll(async () => (await menu.boundingBox())!.width).toBeCloseTo(288, 1); + const visible = (await surface.boundingBox())!; + const target = (await trigger.boundingBox())!; + const avatar = (await surface.locator('span').first().boundingBox())!; + const panel = (await menu.boundingBox())!; + expect(visible.width).toBeCloseTo(128, 1); + expect(visible.height).toBeCloseTo(25, 1); + expect(avatar.width).toBeCloseTo(16, 1); + expect(avatar.height).toBeCloseTo(16, 1); + expect(target.width).toBeCloseTo(128, 1); + expect(target.height).toBeCloseTo(50, 1); + expect(visible.x).toBeGreaterThanOrEqual(16); + expect(visible.x).toBeCloseTo(width === 1440 ? 80 - 1.5 * 96 / 2.54 : 16, 1); + expect(await page.evaluate(() => innerHeight) - visible.y - visible.height).toBeCloseTo(16, 0); + expect(panel.x).toBeCloseTo(visible.x, 1); + expect(visible.y - panel.y - panel.height).toBeCloseTo(8, 1); + expect(panel.y).toBeGreaterThanOrEqual(0); + expect(panel.x + panel.width).toBeLessThanOrEqual(width); + }; + await expect(trigger).toContainText('小麦'); + await expect(page.locator('[data-testid^="ai-module-option-"]')).toHaveCount(3); + const footer = await trigger.evaluate(element => element.closest('footer')!.getBoundingClientRect().top); + const cards = await page.getByTestId('ai-module-options').boundingBox(); + expect(footer).toBeGreaterThanOrEqual(cards!.y + cards!.height); + await trigger.click(); + await expect(menu.getByTestId('sidebar-total-token-points')).toHaveText('75 点'); + await expect(menu.getByText('永久有效 · 预占 5 点', { exact: true })).toBeVisible(); + await expect(menu.getByRole('menuitem', { name: '词元点数与充值', exact: true })).toBeVisible(); + await checkAccountGeometry(1440); + await page.screenshot({ path: testInfo.outputPath('home-account-menu-desktop.png') }); + await page.keyboard.press('Escape'); + await expect(trigger).toHaveAttribute('aria-expanded', 'false'); + await expect(trigger).toBeFocused(); + await trigger.click(); + await page.getByRole('heading', { name: '今天,想创作什么?', exact: true }).click(); + await expect(trigger).toHaveAttribute('aria-expanded', 'false'); + + await trigger.click(); + await menu.getByRole('menuitem', { name: '词元点数与充值', exact: true }).click(); + const wallet = page.getByTestId('point-wallet-dialog'); + await expect(wallet).toBeVisible(); + await expect(wallet.getByTestId('point-recharge-recipient')).toHaveText('充值到:小麦(child-wallet)'); + await expect(wallet.getByRole('button', { name: /500 点\s*¥10.00/ })).toBeEnabled(); + await wallet.getByText('我的可用点数', { exact: true }).click(); + await expect(trigger).toHaveAttribute('aria-expanded', 'false'); + await expect(wallet).toBeVisible(); + await wallet.getByRole('button', { name: '关闭词元点数', exact: true }).click(); + await expect(wallet).toHaveCount(0); + + await trigger.click(); + await menu.getByRole('menuitem', { name: '个人资料', exact: true }).click(); + const profile = page.getByRole('dialog', { name: '个人资料', exact: true }); + await expect(profile.getByLabel(/名字/)).toHaveValue('小麦'); + await profile.getByRole('button', { name: '关闭个人资料', exact: true }).click(); + await trigger.click(); + await menu.getByRole('menuitem', { name: '设置', exact: true }).click(); + await expect(page).toHaveURL(/\/settings$/); + await page.evaluate(() => { window.location.hash = '#/module-select'; }); + await expect(trigger).toContainText('小麦'); + + await app.evaluate(({ BrowserWindow }) => { const window = BrowserWindow.getAllWindows()[0]; window.setMinimumSize(360, 600); window.setSize(375, 800); }); + await expect.poll(() => page.evaluate(() => innerWidth)).toBe(375); + await trigger.click(); + await expect(menu.getByTestId('sidebar-total-token-points')).toHaveText('75 点'); + await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1); + await checkAccountGeometry(375); + await page.screenshot({ path: testInfo.outputPath('home-account-menu-375.png') }); + await menu.getByRole('menuitem', { name: '退出登录', exact: true }).click(); + await expect(page).toHaveURL(/\/login$/); + await expect(trigger).toHaveCount(0); + const requests = await app.evaluate(() => (globalThis as typeof globalThis & { homeAccountFixture: { requests: { method: string; path: string }[] } }).homeAccountFixture.requests); + const billingReads = requests.filter(request => request.path.startsWith('/api/works/billing/')); + expect(billingReads.length).toBeGreaterThan(0); + expect(billingReads.every(request => request.method === 'GET')).toBe(true); + expect(requests.filter(request => request.path === '/api/works/user/agent-profile').every(request => request.method === 'GET')).toBe(true); + expect(requests.filter(request => request.path === '/api/auth/logout' && request.method === 'POST')).toHaveLength(1); + } finally { + await app.evaluate(({ BrowserWindow }) => { for (const window of BrowserWindow.getAllWindows()) window.destroy(); }).catch(() => undefined); + await closeElectronApp(app); + } + }); + test('opens remembered Code projects and keeps the module return entry in chat instead of project settings', async ({ electronTestResources, launchElectronApp, @@ -200,7 +334,7 @@ test.describe('Makelore module navigation without setup flow', () => { } }); - test('keeps the four module entries in a proportional vertical stack while resizing the window', async ({ launchElectronApp }) => { + test('aligns the desktop home title with the middle of three stacked horizontal module cards', async ({ launchElectronApp }) => { const app = await launchElectronApp({ skipSetup: true }); try { @@ -209,17 +343,18 @@ test.describe('Makelore module navigation without setup flow', () => { const moduleCards = page.locator('[data-testid^="ai-module-option-"]'); await expect(moduleCards.locator('.module-option-card-title')).toHaveText([ - 'Agents 智能体', 'Code 编程', 'Canvas 设计', 'Robot 机器', + 'Agents 智能体', 'Code 编程', 'Canvas 设计', ]); + await expect(page.getByTestId('ai-module-option-robot')).toHaveCount(0); await expect(moduleCards.first().locator('.module-option-card-description')).toHaveText('打造你想象中的AI助手'); - await expect(moduleCards.locator('img')).toHaveCount(4); + await expect(moduleCards.locator('img')).toHaveCount(3); await expect.poll(async () => await moduleCards.locator('img').evaluateAll((images) => images.every((image) => image.complete && image.naturalWidth > 0), )).toBe(true); - await page.screenshot({ path: test.info().outputPath('module-entry-artwork.png') }); const readCardMetrics = async () => await moduleCards.evaluateAll((elements) => elements.map((element) => { const bounds = element.getBoundingClientRect(); const image = element.querySelector('.module-option-card-image')?.getBoundingClientRect(); + const content = element.querySelector('.module-option-card-content')?.getBoundingClientRect(); return { left: bounds.left, top: bounds.top, @@ -227,70 +362,71 @@ test.describe('Makelore module navigation without setup flow', () => { height: bounds.height, imageWidth: image?.width ?? 0, imageHeight: image?.height ?? 0, + imageLeft: image?.left ?? 0, + contentLeft: content?.left ?? 0, }; })); - const initialMetrics = await readCardMetrics(); - expect(initialMetrics).toHaveLength(4); await expect(moduleCards.first().locator('.module-option-card-arrow')).toHaveCount(0); await expect(moduleCards.first().locator('.module-option-artwork')).toHaveCount(0); - await expect.poll(async () => await moduleCards.first().evaluate((element) => { - const surface = element.querySelector('.module-option-card-surface'); - const frame = element.querySelector('.module-option-card-frame'); - if (!surface || !frame) return false; - const surfaceStyle = getComputedStyle(surface); - const frameStyle = getComputedStyle(frame); - const surfaceInsets = [surfaceStyle.top, surfaceStyle.right, surfaceStyle.bottom, surfaceStyle.left] - .map((value) => Number.parseFloat(value)); - return frameStyle.display === 'none' - && surfaceInsets.every((value) => value === 0) - && surfaceStyle.transform === 'none'; - })).toBe(true); - const initialImageStyles = await moduleCards.first().evaluate((element) => { - const image = element.querySelector('.module-option-card-image'); - const cardHeight = element.getBoundingClientRect().height; - if (!image) return null; - const style = getComputedStyle(image); - return { - height: Number.parseFloat(style.height), - cardHeight, - marginTop: Number.parseFloat(style.marginTop), - marginBottom: Number.parseFloat(style.marginBottom), - marginLeft: Number.parseFloat(style.marginLeft), - }; - }); - expect(initialImageStyles).not.toBeNull(); - expect(initialImageStyles!.height).toBeLessThan(initialImageStyles!.cardHeight); - expect(initialImageStyles!.marginTop).toBe(1); - expect(initialImageStyles!.marginBottom).toBe(1); - expect(initialImageStyles!.marginTop).toBe(initialImageStyles!.marginBottom); - expect(initialImageStyles!.marginLeft).toBeGreaterThan(0); - expect(initialImageStyles!.marginLeft).toBe(initialImageStyles!.marginTop); - expect(Math.max(...initialMetrics.map((card) => card.left)) - Math.min(...initialMetrics.map((card) => card.left))).toBeLessThan(1); - expect(initialMetrics.map((card) => card.top)).toEqual( - [...initialMetrics].sort((top, bottom) => top.top - bottom.top).map((card) => card.top), - ); - - const initialWidth = initialMetrics[0]?.width ?? 0; - expect(initialMetrics[0]?.imageWidth ?? 0).toBeGreaterThan(initialWidth * 0.24); - expect(initialMetrics[0]?.imageWidth ?? 0).toBeLessThan(initialWidth / 2); - expect(initialMetrics[0]?.imageHeight ?? 0).toBeGreaterThan((initialMetrics[0]?.height ?? 0) * 0.75); - await app.evaluate(({ BrowserWindow }) => { - BrowserWindow.getAllWindows()[0]?.setSize(1100, 700); + BrowserWindow.getAllWindows()[0]?.setMinimumSize(360, 600); }); - await expect.poll(async () => (await readCardMetrics())[0]?.width ?? 0).toBeLessThan(initialWidth - 1); - - const resizedMetrics = await readCardMetrics(); - expect(resizedMetrics).toHaveLength(4); - expect(Math.max(...resizedMetrics.map((card) => card.left)) - Math.min(...resizedMetrics.map((card) => card.left))).toBeLessThan(1); - expect(resizedMetrics.map((card) => card.top)).toEqual( - [...resizedMetrics].sort((top, bottom) => top.top - bottom.top).map((card) => card.top), - ); - expect(resizedMetrics[0]!.imageWidth).toBeGreaterThan(resizedMetrics[0]!.width * 0.24); - expect(resizedMetrics[0]!.imageWidth).toBeLessThan(resizedMetrics[0]!.width / 2); - expect(resizedMetrics[0]!.imageHeight).toBeGreaterThan(resizedMetrics[0]!.height * 0.75); + for (const width of [1440, 1024, 768, 375]) { + await app.evaluate(({ BrowserWindow }, windowWidth) => BrowserWindow.getAllWindows()[0]?.setSize(windowWidth, windowWidth === 375 ? 800 : 900), width); + await expect.poll(() => page.evaluate(() => innerWidth)).toBe(width); + const cards = await readCardMetrics(); + expect(cards).toHaveLength(3); + expect(Math.max(...cards.map(card => card.left)) - Math.min(...cards.map(card => card.left))).toBeLessThan(1); + expect(Math.max(...cards.map(card => card.width)) - Math.min(...cards.map(card => card.width))).toBeLessThan(1); + expect(Math.max(...cards.map(card => card.height)) - Math.min(...cards.map(card => card.height))).toBeLessThan(1); + const gaps = cards.slice(1).map((card, index) => card.top - cards[index]!.top - cards[index]!.height); + expect(Math.min(...gaps)).toBeGreaterThan(0); + expect(Math.abs(gaps[0]! - gaps[1]!)).toBeLessThan(1); + for (const card of cards) { + expect(card.left).toBeGreaterThanOrEqual(0); + expect(card.left + card.width).toBeLessThanOrEqual(width); + expect(card.imageWidth).toBeLessThan(card.width / 2); + expect(card.imageHeight).toBeGreaterThan(card.height * 0.75); + expect(card.contentLeft).toBeGreaterThanOrEqual(card.imageLeft + card.imageWidth - 1); + } + const title = page.getByRole('heading', { name: '今天,想创作什么?', exact: true }); + const heading = await title.boundingBox(); + expect(heading).not.toBeNull(); + const textWidth = await title.evaluate(element => { + const range = document.createRange(); range.selectNodeContents(element); + return range.getBoundingClientRect().width; + }); + expect(Math.abs(heading!.width - textWidth)).toBeLessThan(1); + if (width >= 1024) { + const shift = await title.evaluate(element => { + const intro = element.parentElement!; + const original = intro.style.transform; + const shiftedLeft = element.getBoundingClientRect().left; + try { + intro.style.transform = 'none'; + return shiftedLeft - element.getBoundingClientRect().left; + } finally { intro.style.transform = original; } + }); + expect(shift).toBeCloseTo(1.5 * 96 / 2.54, 1); + expect(heading!.x + heading!.width).toBeLessThanOrEqual(cards[0]!.left); + expect(Math.abs(heading!.y + heading!.height / 2 - cards[1]!.top - cards[1]!.height / 2)).toBeLessThan(1); + expect(await moduleCards.evaluateAll(elements => elements.every(element => { + const bounds = element.getBoundingClientRect(); + return [[2, 2], [bounds.width - 2, bounds.height - 2], [bounds.width / 2, bounds.height / 2]].every(([x, y]) => + element.contains(document.elementFromPoint(bounds.left + x!, bounds.top + y!)), + ); + }))).toBe(true); + } else { + expect(heading!.y + heading!.height).toBeLessThanOrEqual(cards[0]!.top); + } + expect(await page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1); + expect(await moduleCards.locator('.module-option-card-title, .module-option-card-description').evaluateAll(elements => + elements.every(element => element.scrollWidth <= element.clientWidth), + )).toBe(true); + await page.screenshot({ path: test.info().outputPath(`module-entry-${width}.png`) }); + } await moduleCards.first().hover(); await moduleCards.first().focus(); @@ -323,7 +459,7 @@ test.describe('Makelore module navigation without setup flow', () => { const page = await getStableWindow(app); await expect(page.getByTestId('ai-module-selection-page')).toBeVisible(); - await expect(page.locator('[data-testid^="ai-module-option-"]')).toHaveCount(4); + await expect(page.locator('[data-testid^="ai-module-option-"]')).toHaveCount(3); await expect(page.getByTestId('ai-module-option-learning')).toHaveCount(0); await page.evaluate(() => { window.location.hash = '#/learning'; @@ -385,7 +521,8 @@ test.describe('Makelore module navigation without setup flow', () => { await moduleHomeTrigger.click(); await expect(page).toHaveURL(/\/module-select$/); await expect(page.getByTestId('ai-module-selection-page')).toBeVisible(); - await page.getByTestId('ai-module-option-robot').click(); + await expect(page.getByTestId('ai-module-option-robot')).toHaveCount(0); + await page.evaluate(() => { window.location.hash = '#/ai-hardware'; }); await expect(page).toHaveURL(/\/ai-hardware$/); await expect(page.getByTestId('ai-hardware-page')).toBeVisible(); await expect(page.getByTestId('sidebar-robot-navigation')).toBeVisible(); diff --git a/tests/unit/channel-accounts-page.test.tsx b/tests/unit/channel-accounts-page.test.tsx index e25e3bb9..9e8fe991 100644 --- a/tests/unit/channel-accounts-page.test.tsx +++ b/tests/unit/channel-accounts-page.test.tsx @@ -1,5 +1,5 @@ import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { ChannelAccounts } from '@/pages/CloudAgents/ChannelAccounts'; import { EMPTY_CLOUD_CONFIGURATION, type CloudAgentDraft, type CloudUserChannelAccount, type CloudWechatBindState } from '../../shared/cloud-agents'; @@ -23,7 +23,16 @@ let verifyStatus: CloudWechatBindState['status']; const calls = (operation: string) => api.call.mock.calls.filter(([name]) => name === operation); const writes = () => api.call.mock.calls.filter(([name]) => !['channelAccounts', 'channelAccountWechatBindStatus'].includes(name)); -function renderPage(entry = '/cloud-agents/channels') { return render(); } +function LocationProbe() { + const location = useLocation(); + return {location.pathname + location.search}; +} +function renderPage(entry = '/cloud-agents/channels') { + return render( + } /> + 智能体列表
} /> + ); +} async function addWechat(fakeTimers = false) { fireEvent.click(await screen.findByRole('button', { name: '添加微信', exact: true })); const dialog = screen.getByRole('dialog', { name: '添加微信', exact: true }); @@ -80,6 +89,69 @@ beforeEach(() => { }); afterEach(() => { cleanup(); vi.useRealTimers(); }); +it.each([ + ['no Agents', []], + ['only drafts', [{ ...agentA, published_version: null }]], + ['only disabled Agents', [{ ...agentA, enabled: false }]], +] satisfies [string, CloudAgentDraft[]][])('guides a workspace with %s to My Agents without starting a binding', async (_name, unavailable) => { + agents = unavailable; accounts = []; + renderPage(); + expect(await screen.findByRole('heading', { name: '还没有可连接的智能体', exact: true })).toBeVisible(); + expect(screen.getByText('先创建一个智能体,配置完成后点击“开始使用”,就能连接到微信。', { exact: true })).toBeVisible(); + expect(screen.queryByRole('button', { name: '添加第一个微信', exact: true })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: '添加微信', exact: true })).toBeEnabled(); + fireEvent.click(screen.getByRole('button', { name: '去我的智能体', exact: true })); + expect(await screen.findByText('智能体列表', { exact: true })).toBeVisible(); + expect(screen.getByTestId('location-path')).toHaveTextContent('/cloud-agents?view=mine'); + expect(writes()).toEqual([]); +}); + +it('uses the same preparation hint in the add dialog without duplicating it on the page', async () => { + agents = []; accounts = []; + renderPage(); + await screen.findByRole('heading', { name: '还没有可连接的智能体', exact: true }); + fireEvent.click(screen.getByRole('button', { name: '添加微信', exact: true })); + const dialog = screen.getByRole('dialog', { name: '添加微信', exact: true }); + expect(within(dialog).getByRole('heading', { name: '还没有可连接的智能体', exact: true })).toBeVisible(); + expect(within(dialog).getByText('先创建一个智能体,配置完成后点击“开始使用”,就能连接到微信。', { exact: true })).toBeVisible(); + expect(screen.getAllByRole('heading', { name: '还没有可连接的智能体', exact: true, hidden: true })).toHaveLength(1); + expect(within(dialog).getByRole('button', { name: '下一步:扫码连接', exact: true })).toBeDisabled(); + fireEvent.click(within(dialog).getByRole('button', { name: '去我的智能体', exact: true })); + expect(await screen.findByText('智能体列表', { exact: true })).toBeVisible(); + expect(screen.getByTestId('location-path')).toHaveTextContent('/cloud-agents?view=mine'); + expect(writes()).toEqual([]); +}); + +it('waits for the Agent read before showing a preparation hint', async () => { + accounts = []; + let finish!: (value: { agents: CloudAgentDraft[]; next_cursor: null }) => void; + api.list.mockImplementation(() => new Promise(resolve => { finish = resolve; })); + renderPage(); + expect(screen.getByText('正在查看你的微信绑定…', { exact: true })).toBeVisible(); + expect(screen.queryByRole('heading', { name: '还没有可连接的智能体', exact: true })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '去我的智能体', exact: true })).not.toBeInTheDocument(); + await act(async () => finish({ agents: [], next_cursor: null })); + expect(await screen.findByRole('heading', { name: '还没有可连接的智能体', exact: true })).toBeVisible(); + expect(writes()).toEqual([]); +}); + +it('keeps existing WeChat cards manageable when no connectable Agent remains', async () => { + agents = []; + renderPage(); + expect(await screen.findByRole('heading', { name: '还没有可连接的智能体', exact: true })).toBeVisible(); + expect(screen.getByRole('article', { name: '原有微信微信', exact: true })).toBeVisible(); + expect(screen.getByRole('article', { name: '备用微信微信', exact: true })).toBeVisible(); + const dialog = await manage(); + expect(within(dialog).getByRole('button', { name: '暂停使用', exact: true })).toBeEnabled(); + expect(within(dialog).getByRole('button', { name: '解除绑定', exact: true })).toBeEnabled(); + expect(within(dialog).getByRole('button', { name: '断开微信连接', exact: true })).toBeEnabled(); + fireEvent.click(within(dialog).getByRole('button', { name: '暂停使用', exact: true })); + await waitFor(() => expect(calls('pauseChannelAccount')).toHaveLength(1)); + expect(accounts[0]).toMatchObject({ enabled: false, health: 'connected', target_agent_slug: agentA.slug }); + expect(calls('createChannelAccount')).toHaveLength(0); + expect(calls('routeChannelAccount')).toHaveLength(0); +}); + it('shows binding cards and never fetches or embeds conversation or activity panels', async () => { renderPage(); const card = await screen.findByRole('article', { name: '原有微信微信', exact: true }); @@ -89,6 +161,7 @@ it('shows binding cards and never fetches or embeds conversation or activity pan expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); expect(screen.queryByText('微信对话', { exact: true })).not.toBeInTheDocument(); expect(screen.queryByText('活动记录', { exact: true })).not.toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: '还没有可连接的智能体', exact: true })).not.toBeInTheDocument(); expect(writes()).toEqual([]); expect(api.call.mock.calls.every(([operation]) => operation === 'channelAccounts')).toBe(true); }); @@ -295,13 +368,19 @@ it('does not call a blocked or unconfirmed existing account ready to use', async }); -it('keeps adding unavailable after a failed initial read instead of showing a false empty account list', async () => { - api.call.mockRejectedValue(new Error('微信列表暂时不可用')); +it.each(['accounts', 'agents'])('keeps adding unavailable after a failed initial %s read instead of showing a false empty list', async source => { + agents = []; + const message = source === 'accounts' ? '微信列表暂时不可用' : '智能体列表暂时不可用'; + if (source === 'accounts') api.call.mockRejectedValue(new Error(message)); + else api.list.mockRejectedValue(new Error(message)); renderPage(); - await screen.findByText('微信列表暂时不可用', { exact: true }); + await screen.findByText(message, { exact: true }); expect(screen.getByRole('button', { name: '添加微信', exact: true })).toBeDisabled(); expect(screen.queryByText('还没有微信绑定', { exact: true })).not.toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: '还没有可连接的智能体', exact: true })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '去我的智能体', exact: true })).not.toBeInTheDocument(); installMock(); + api.list.mockImplementation(async () => ({ agents, next_cursor: null })); fireEvent.click(screen.getByRole('button', { name: '刷新微信列表', exact: true })); expect(await screen.findByRole('article', { name: '原有微信微信', exact: true })).toBeVisible(); expect(screen.getByRole('button', { name: '添加微信', exact: true })).toBeEnabled(); diff --git a/tests/unit/cloud-agent-avatar-routes.test.ts b/tests/unit/cloud-agent-avatar-routes.test.ts index 7657c94f..3c4cb571 100644 --- a/tests/unit/cloud-agent-avatar-routes.test.ts +++ b/tests/unit/cloud-agent-avatar-routes.test.ts @@ -2,11 +2,13 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; import { Readable } from 'node:stream'; import { beforeEach, expect, it, vi } from 'vitest'; import { handleCloudAgentsRoutes } from '@electron/api/routes/cloud-agents'; +import { CloudAgentsError } from '@electron/services/cloud-agents'; -const service = vi.hoisted(() => ({ useAvatarPreset: vi.fn(), uploadAvatar: vi.fn() })); +const service = vi.hoisted(() => ({ useAvatarPreset: vi.fn(), uploadAvatar: vi.fn(), create: vi.fn() })); vi.mock('@electron/services/cloud-agents', () => ({ - CloudAgentsModule: class { useAvatarPreset = service.useAvatarPreset; uploadAvatar = service.uploadAvatar; }, - CloudAgentsError: class extends Error { constructor(readonly status: number, readonly code: string) { super(code); } }, + CloudAgentsModule: class { useAvatarPreset = service.useAvatarPreset; uploadAvatar = service.uploadAvatar; create = service.create; }, + CloudAgentsError: class extends Error { constructor(readonly status: number, readonly code: string, + readonly commandOutcome = 'unknown') { super(code); } }, })); beforeEach(() => vi.resetAllMocks()); @@ -35,3 +37,20 @@ it('keeps the existing file picker route and rejects malformed preset JSON', asy expect(service.useAvatarPreset).not.toHaveBeenCalled(); expect((await call('/avatars/preset', undefined, 'GET')).status).toBe(404); }); + +it.each(['definitive_failure', 'unknown'] as const)('preserves creation outcome %s across the Host API', async outcome => { + service.create.mockRejectedValue(new CloudAgentsError(422, 'invalid_input', outcome)); + expect(await call('/agents', JSON.stringify({ name: 'test' }))).toEqual({ + handled: true, status: 422, + body: { success: false, error: 'invalid_input', code: 'invalid_input', commandOutcome: outcome }, + }); +}); + +it('marks malformed creation JSON as rejected and unexpected failures as uncertain', async () => { + expect((await call('/agents', '{')).body.commandOutcome).toBe('definitive_failure'); + expect(service.create).not.toHaveBeenCalled(); + service.create.mockRejectedValue(new Error('private internal error')); + const result = await call('/agents', '{}'); + expect(result).toMatchObject({ status: 502, body: { code: 'cloud_service_unavailable', commandOutcome: 'unknown' } }); + expect(result.body.error).not.toContain('private'); +}); diff --git a/tests/unit/cloud-agents-main.test.ts b/tests/unit/cloud-agents-main.test.ts index df61c18c..dc1b8139 100644 --- a/tests/unit/cloud-agents-main.test.ts +++ b/tests/unit/cloud-agents-main.test.ts @@ -44,6 +44,141 @@ function moduleFor(fetchImpl: typeof fetch) { beforeEach(() => { vi.clearAllMocks(); journalRecords.clear(); clearWorksSquareSession(); login(); }); afterEach(() => { instances.splice(0).forEach((module) => module.dispose()); clearWorksSquareSession(); vi.useRealTimers(); }); +describe('creation failure recovery', () => { + const validation = (field = 'purpose', type = 'string_too_short') => ({ detail: [{ + loc: ['body', field], type, msg: 'private upstream text', input: 'private input', ctx: { secret: 'private context' }, + }] }); + + it('clears a first rejected creation and explains the empty-introduction service mismatch safely', async () => { + const transport = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json(validation(), 422)); + const module = moduleFor(transport); + await expect(module.create({ ...input, purpose: '' })).rejects.toMatchObject({ + status: 422, code: 'introduction_required', commandOutcome: 'definitive_failure', + message: '当前智能体服务尚不支持一句话介绍留空,请等待服务更新后重试', + }); + expect((await module.recovery()).pending).toEqual([]); + expect(JSON.parse(transport.mock.calls[1][1].body).purpose).toBe(''); + }); + + it('does not diagnose an empty introduction when a nonempty introduction is rejected as too short', async () => { + const module = moduleFor(vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json(validation(), 422))); + await expect(module.create(input)).rejects.toMatchObject({ code: 'introduction_too_short', commandOutcome: 'definitive_failure' }); + expect((await module.recovery()).pending).toEqual([]); + }); + + it.each([ + ['name', 'string_too_long', 'invalid_name'], ['purpose', 'string_too_long', 'invalid_introduction'], + ['icon', 'string_type', 'invalid_icon'], ['private-field', 'missing', 'invalid_input'], + ])('maps request validation for %s to a fixed product message', async (field, type, code) => { + const transport = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json(validation(field, type), 422)); + const module = moduleFor(transport); + const error = await module.create(input).catch(failure => failure); + expect(error).toMatchObject({ code, commandOutcome: 'definitive_failure' }); + expect(error.message).not.toMatch(/private|secret|upstream/u); + expect((await module.recovery()).pending).toEqual([]); + }); + + it.each([ + [400, 'invalid_input'], [422, 'invalid_input'], [401, 'session_expired'], + [403, 'module_unavailable'], [423, 'account_locked'], + ])('clears a fresh creation only on a recognized rejection (%s %s)', async (status, code) => { + const transport = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json({ detail: { code } }, status)); + const module = moduleFor(transport); + await expect(module.create(input)).rejects.toMatchObject({ code, commandOutcome: 'definitive_failure' }); + expect((await module.recovery()).pending).toEqual([]); + }); + + it.each([408, 409, 429, 500, 502])('keeps ambiguous creation status %s recoverable with its original input', async status => { + const transport = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json({ detail: { code: 'invalid_input' } }, status)); + const module = moduleFor(transport); + await expect(module.create(input)).rejects.toMatchObject({ commandOutcome: 'unknown' }); + expect((await module.recovery()).pending).toMatchObject([{ operation: 'createAgent', input }]); + }); + + it.each([ + new Response('not JSON', { status: 422 }), + json({ detail: [{ loc: ['response', 'purpose'], type: 'string_too_short', msg: 'private' }] }, 422), + json({ detail: [{ loc: ['body', 'purpose'], type: 'unrecognized-validation' }] }, 422), + json({ detail: { code: 'unknown-error', message: 'private' } }, 422), + json({ ...draft, slug: 'invalid-response-slug' }), + ])('keeps malformed errors or successful responses pending rather than allowing another creation', async response => { + const module = moduleFor(vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(response)); + await expect(module.create(input)).rejects.toMatchObject({ commandOutcome: 'unknown' }); + expect((await module.recovery()).pending).toHaveLength(1); + }); + + it('preserves an older uncertain creation after a later validation rejection, then recovers success with the same ID', async () => { + const transport = vi.fn().mockResolvedValueOnce(session()).mockRejectedValueOnce(new Error('lost response')) + .mockResolvedValueOnce(json(validation(), 422)).mockResolvedValueOnce(json({ ...draft, purpose: '' })); + const module = moduleFor(transport); + const original = { ...input, purpose: '' }; + await expect(module.create(original)).rejects.toMatchObject({ commandOutcome: 'unknown' }); + const pending = (await module.recovery()).pending[0]; + await expect(module.resolvePending({ id: pending.id })).rejects.toMatchObject({ commandOutcome: 'unknown' }); + expect((await module.recovery()).pending).toEqual([pending]); + expect(await module.resolvePending({ id: pending.id })).toMatchObject({ result: { slug: draft.slug } }); + expect((await module.recovery()).pending).toEqual([]); + expect(transport.mock.calls.slice(1).map(call => JSON.parse(call[1].body))).toEqual([original, original, original]); + }); + + it('does not treat local validation as proof an older operation failed', async () => { + const transport = vi.fn().mockResolvedValueOnce(session()).mockRejectedValueOnce(new Error('lost response')); + const module = moduleFor(transport); + await expect(module.create(input)).rejects.toMatchObject({ commandOutcome: 'unknown' }); + await expect(module.create({ ...input, name: '' })).rejects.toMatchObject({ commandOutcome: 'unknown' }); + expect((await module.recovery()).pending).toHaveLength(1); + expect(transport).toHaveBeenCalledTimes(2); + }); + + it('shares simultaneous identical creates and rejects changed input without a second POST', async () => { + let finish!: (response: Response) => void; + const transport = vi.fn().mockResolvedValueOnce(session()).mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); + const module = moduleFor(transport); + const first = module.create(input); + const second = module.create(input); + const firstRejected = expect(first).rejects.toMatchObject({ commandOutcome: 'definitive_failure' }); + const secondRejected = expect(second).rejects.toMatchObject({ commandOutcome: 'definitive_failure' }); + await expect(module.create({ ...input, name: 'changed input' })).rejects.toMatchObject({ code: 'operation_conflict', commandOutcome: 'unknown' }); + await vi.waitFor(() => expect(finish).toBeDefined()); + finish(json(validation(), 422)); + await Promise.all([firstRejected, secondRejected]); + expect(transport).toHaveBeenCalledTimes(2); + expect((await module.recovery()).pending).toEqual([]); + }); + + it('keeps the original account record when a rejection arrives after an account switch', async () => { + let finish!: (response: Response) => void; + const transport = vi.fn().mockResolvedValueOnce(session()).mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); + const module = moduleFor(transport); + const rejected = expect(module.create(input)).rejects.toMatchObject({ code: 'account_changed', commandOutcome: 'unknown' }); + await vi.waitFor(() => expect(finish).toBeDefined()); + const pending = (await module.recovery()).pending[0]; + login('b'.repeat(64)); + finish(json(validation(), 422)); + await rejected; + expect((await module.recovery()).pending).toEqual([]); + expect(Array.from(journalRecords.values()).some(state => state.pending.some(item => item.id === pending.id))).toBe(true); + }); + + it('preserves recovery behavior for other operations after a validation rejection', async () => { + const transport = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json(validation(), 422)); + const module = moduleFor(transport); + await expect(module.execute({ operation: 'createSchedule', input: { slug: draft.slug, operation_id: input.operation_id } })).rejects.toMatchObject({ status: 422, commandOutcome: 'unknown' }); + expect((await module.recovery()).pending).toHaveLength(1); + }); + + it('does not mark a rejected retry of another operation as a confirmed failure', async () => { + const transport = vi.fn().mockResolvedValueOnce(session()).mockRejectedValueOnce(new Error('lost response')) + .mockResolvedValueOnce(json(validation(), 422)); + const module = moduleFor(transport); + await expect(module.execute({ operation: 'createSchedule', input: { slug: draft.slug, operation_id: input.operation_id } })) + .rejects.toMatchObject({ commandOutcome: 'unknown' }); + const pending = (await module.recovery()).pending[0]; + await expect(module.resolvePending({ id: pending.id })).rejects.toMatchObject({ status: 422, commandOutcome: 'unknown' }); + expect((await module.recovery()).pending).toEqual([pending]); + }); +}); + it.each(['', ' '])('preserves an empty introduction through create, save and list (%j)', async purpose => { const emptyDraft = { ...draft, purpose: '' }; const transport = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json(emptyDraft)) @@ -65,6 +200,24 @@ it.each([null, 42, 'x'.repeat(2001)])('rejects malformed or overlong introductio }); describe('Main cloud Agents boundary', () => { + it('projects saved reasoning and tool results for both personal and channel history without raw metadata', () => { + const message = { id: 1, role: 'assistant', content: 'answer', reasoning_content: 'thought', message_id: 'model-1', + extra_metadata: { internal: true }, tool_calls: [{ id: 'call-1', name: 'search', args: { query: 'topic' }, + display_name: '上网查资料', display_description: '在网上查找相关信息。', + status: 'success', internal: 'hidden', tool_call_result: { content: 'result', secret: 'hidden' } }] }; + const expected = { id: 1, role: 'assistant', content: 'answer', reasoning_content: 'thought', message_id: 'model-1', + tool_calls: [{ id: 'call-1', name: 'search', display_name: '上网查资料', display_description: '在网上查找相关信息。', + args: { query: 'topic' }, status: 'success', tool_call_result: { content: 'result' } }] }; + for (const input of [ + { operation: 'history', input: { thread_id: 'thread-1' } }, + { operation: 'channelConversation', input: { session_id: 'session-1' } }, + ]) { + expect(operationPlan(input).project({ messages: [message], run: null, next_offset: null })).toMatchObject({ messages: [expected] }); + expect(JSON.stringify(operationPlan(input).project({ messages: [message] }))).not.toContain('hidden'); + expect(JSON.stringify(operationPlan(input).project({ messages: [message] }))).not.toContain('extra_metadata'); + } + }); + it.each(Array.from({ length: 16 }, (_, index) => `avatar-${String(index + 1).padStart(2, '0')}`))('uploads the bundled platform portrait %s through scoped Main transport without a dialog', async id => { const icon = '/api/storage/public/images/creator/preset.png'; const transport = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json({ icon, private: 'hidden' })); diff --git a/tests/unit/cloud-agents-page.test.tsx b/tests/unit/cloud-agents-page.test.tsx index 86a54615..3e3c86b9 100644 --- a/tests/unit/cloud-agents-page.test.tsx +++ b/tests/unit/cloud-agents-page.test.tsx @@ -2,6 +2,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testi import { createMemoryRouter, RouterProvider } from 'react-router-dom'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import CloudAgents from '@/pages/CloudAgents'; +import { AppError } from '@/lib/error-model'; import { EMPTY_CLOUD_CONFIGURATION } from '../../shared/cloud-agents'; const api = vi.hoisted(() => ({ list: vi.fn(), create: vi.fn(), uploadAvatar: vi.fn(), get: vi.fn(), save: vi.fn(), call: vi.fn(), events: vi.fn(), recovery: vi.fn(), remember: vi.fn(), resolvePending: vi.fn() })); @@ -574,9 +575,13 @@ it('restores the exact previous thread and presents an uncertain operation witho expect(screen.queryByLabelText('历史对话')).not.toBeInTheDocument(); expect(api.resolvePending).not.toHaveBeenCalled(); expect(api.call.mock.calls.some(call => call[0] === 'submit')).toBe(false); + expect(screen.queryByRole('button', { name: /^待处理记录/ })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '返回智能体列表', exact: true })); + fireEvent.click(await screen.findByRole('button', { name: /^待处理记录/ })); + await screen.findByRole('dialog', { name: '待处理记录' }); api.resolvePending.mockResolvedValue({ result: { request_id: 'same-request', status: 'queued' } }); api.recovery.mockResolvedValue({ recent: null, pending: [] }); - fireEvent.click(screen.getByRole('button', { name: '重试并确认结果' })); + fireEvent.click(screen.getByRole('button', { name: '再试一次' })); await screen.findByText('发送消息的结果已确认。'); expect(api.resolvePending).toHaveBeenCalledWith('submit:pending', false); }); @@ -604,25 +609,130 @@ it('guides an unconfirmed WeChat verification back to its code entry without cla input: { slug: draft.slug, session_key: 'qr-session', operation_id: 'verify-operation' }, created_at: '2026-09-13T01:00:00Z' }] }); api.resolvePending.mockResolvedValue({ requires_input: true, operation: 'wechatBindVerification' }); open(); - fireEvent.click(await screen.findByRole('button', { name: '重试并确认结果' })); + fireEvent.click(await screen.findByRole('button', { name: /^待处理记录/ })); + fireEvent.click(await screen.findByRole('button', { name: '再试一次' })); await screen.findByText(/这次微信验证还需要验证码/); expect(screen.queryByText('微信连接验证的结果已确认。')).not.toBeInTheDocument(); expect(api.resolvePending).toHaveBeenCalledWith('wechat-code', false); }); +it('refreshes recovery after a definitive rejection without presenting a confirmed result', async () => { + const pending = { id: 'create:pending', operation: 'createAgent', input: { operation_id: 'same-operation', name: '旧搭档', purpose: '' }, created_at: '2026-09-13T01:00:00Z' }; + api.recovery.mockResolvedValueOnce({ recent: null, pending: [pending] }).mockResolvedValue({ recent: null, pending: [] }); + api.resolvePending.mockRejectedValue(new AppError('CONFIG', '名称不可用', undefined, { commandOutcome: 'definitive_failure' })); + open(); + fireEvent.click(await screen.findByRole('button', { name: /^创建记录/ })); + fireEvent.click(await screen.findByRole('button', { name: '再试一次' })); + expect(await screen.findByRole('alert')).toHaveTextContent('创建智能体失败:名称不可用'); + await waitFor(() => expect(api.recovery).toHaveBeenCalledTimes(2)); + expect(screen.queryByRole('button', { name: '再试一次' })).not.toBeInTheDocument(); + expect(screen.queryByText(/创建成功,可以回到列表继续设置/)).not.toBeInTheDocument(); + expect(screen.queryByText('查看结果')).not.toBeInTheDocument(); + expect(api.resolvePending).toHaveBeenCalledWith(pending.id, false); + expect(api.create).not.toHaveBeenCalled(); +}); + +it('keeps an old recovery record retryable when a parameter response cannot determine the original outcome', async () => { + const pending = { id: 'create:old', operation: 'createAgent', input: { operation_id: 'original-operation', name: '旧搭档', purpose: '' }, created_at: '2026-09-13T01:00:00Z' }; + api.recovery.mockResolvedValue({ recent: null, pending: [pending] }); + api.resolvePending.mockRejectedValue(new AppError('CONFIG', '请检查名称', undefined, { commandOutcome: 'unknown', status: 422, code: 'invalid_input' })); + open(); + fireEvent.click(await screen.findByRole('button', { name: /^创建记录/ })); + fireEvent.click(await screen.findByRole('button', { name: '再试一次' })); + await screen.findByRole('alert'); + expect(screen.getByRole('button', { name: '再试一次' })).toBeEnabled(); + expect(screen.queryByText(/创建成功,可以回到列表继续设置/)).not.toBeInTheDocument(); + expect(api.recovery).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole('button', { name: '再试一次' })); + await waitFor(() => expect(api.resolvePending).toHaveBeenCalledTimes(2)); + expect(api.resolvePending.mock.calls).toEqual([[pending.id, false], [pending.id, false]]); + expect(api.create).not.toHaveBeenCalled(); +}); + it('offers only explicit discard for a recovery record whose channel operation was removed', async () => { api.recovery.mockResolvedValueOnce({ recent: null, pending: [{ id: 'channel-policy-old', operation: 'channelPolicy', input: { slug: draft.slug, channel_account_id: 'wechat-1' }, created_at: '2026-09-13T01:00:00Z' }] }) .mockResolvedValue({ recent: null, pending: [] }); api.resolvePending.mockResolvedValue({ operation: 'channelPolicy', discarded: true, retired: true }); open(); + fireEvent.click(await screen.findByRole('button', { name: /^待处理记录/ })); expect(await screen.findByText(/旧版联系人权限操作(已移除)/)).toBeVisible(); - expect(screen.queryByRole('button', { name: '重试并确认结果' })).toBeNull(); + expect(screen.queryByRole('button', { name: '再试一次' })).toBeNull(); fireEvent.click(screen.getByRole('button', { name: '丢弃旧记录' })); await screen.findByText(/旧版联系人权限操作已移除/); expect(api.resolvePending).toHaveBeenCalledWith('channel-policy-old', false); }); +it('keeps historic creation records out of the new form while showing the current creation error inline', async () => { + api.recovery.mockResolvedValue({ recent: null, pending: [{ id: 'create:old', operation: 'createAgent', + input: { operation_id: 'original-old', name: '之前的失败创建' }, created_at: '2026-10-13T03:00:00Z' }] }); + api.create.mockRejectedValue(new Error('本次创建失败,请重新检查')); + open(); + expect(await screen.findByRole('button', { name: /^创建记录/ })).toHaveTextContent('1'); + expect(screen.queryByRole('article', { name: '创建「之前的失败创建」' })).not.toBeInTheDocument(); + expect(screen.queryByRole('complementary', { name: '未确认的云端操作' })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '创建智能体', exact: true })); + expect(screen.queryByRole('button', { name: /^创建记录/ })).not.toBeInTheDocument(); + expect(screen.queryByRole('dialog', { name: '创建记录' })).not.toBeInTheDocument(); + fireEvent.change(screen.getByLabelText('名称'), { target: { value: '新的搭档' } }); + fireEvent.click(screen.getByRole('button', { name: '下一步', exact: true })); + expect(await screen.findByRole('alert')).toHaveTextContent('本次创建失败,请重新检查'); + expect(screen.queryByRole('article', { name: '创建「之前的失败创建」' })).not.toBeInTheDocument(); + expect(api.resolvePending).not.toHaveBeenCalled(); +}); + +it('places the records entry only in the list header and preserves it when returning from configuration or chat', async () => { + publishedAssistant(4); + api.recovery.mockResolvedValue({ recent: null, pending: [{ id: 'create:historic', operation: 'createAgent', + input: { operation_id: 'same-historic', name: '旧记录搭档' }, created_at: '2026-10-13T03:00:00Z' }] }); + open(); + const trigger = await screen.findByRole('button', { name: /^创建记录/ }); + expect(trigger.closest('header')).not.toBeNull(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '调整', exact: true })); + await screen.findByTestId('cloud-agent-editor'); + expect(screen.queryByRole('button', { name: /^创建记录/ })).not.toBeInTheDocument(); + expect(screen.queryByRole('complementary', { name: '未确认的云端操作' })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '我的智能体', exact: true })); + expect(await screen.findByRole('button', { name: /^创建记录/ })).toHaveTextContent('1'); + fireEvent.click(screen.getByRole('button', { name: '和它聊聊', exact: true })); + await screen.findByLabelText('消息'); + expect(screen.queryByRole('button', { name: /^创建记录/ })).not.toBeInTheDocument(); + expect(screen.queryByText(/有 1 个操作的结果尚未确认/)).not.toBeInTheDocument(); + expect(api.resolvePending).not.toHaveBeenCalled(); +}); + +it('retains an unclosed key through internal configuration navigation and clears it across account changes', async () => { + publishedAssistant(4); + const secret = 'configuration-navigation-key-fixture'; + const pending = { id: 'key:historical', operation: 'createKey', input: { application_id: 'fixture-application', operation_id: 'original-key' }, created_at: '2026-10-13T03:00:00Z' }; + api.recovery.mockResolvedValue({ recent: null, pending: [pending] }); + api.resolvePending.mockImplementation(async () => { + api.recovery.mockResolvedValue({ recent: null, pending: [] }); + return { result: { secret } }; + }); + const view = open(); + fireEvent.click(await screen.findByRole('button', { name: /^待处理记录/ })); + fireEvent.click(screen.getByRole('button', { name: '再试一次' })); + await screen.findByText(new RegExp(secret)); + fireEvent.click(screen.getByRole('button', { name: '关闭记录' })); + fireEvent.click(await screen.findByRole('button', { name: '调整', exact: true })); + await screen.findByTestId('cloud-agent-editor'); + expect(screen.queryByRole('button', { name: /^待处理记录/ })).not.toBeInTheDocument(); + expect(screen.queryByRole('dialog', { name: '待处理记录' })).not.toBeInTheDocument(); + expect(screen.queryByText(new RegExp(secret))).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '我的智能体', exact: true })); + fireEvent.click(await screen.findByRole('button', { name: /^待处理记录/ })); + expect(await screen.findByText(new RegExp(secret))).toBeVisible(); + expect(api.resolvePending).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole('button', { name: '关闭记录' })); + auth.user.userId = 'different-records-account'; + view.rerender(); + await screen.findByRole('button', { name: '调整', exact: true }); + expect(screen.queryByRole('button', { name: /^待处理记录/ })).not.toBeInTheDocument(); + expect(screen.queryByText(new RegExp(secret))).not.toBeInTheDocument(); +}); + it('freezes the uploaded avatar and creation identity when retrying a lost response', async () => { const avatar = { icon: '/api/storage/public/images/create.png', icon_url: 'https://images.test/create.png' }; api.list.mockResolvedValue({ agents: [], next_cursor: null }); @@ -648,6 +758,112 @@ it('freezes the uploaded avatar and creation identity when retrying a lost respo expect(screen.getByRole('img', { name: '写作搭档头像' })).toHaveAttribute('src', avatar.icon_url); }); +it('unlocks rejected creation input and uses a new operation identity after editing', async () => { + const avatar = { icon: '/api/storage/public/images/rejected.png', icon_url: 'https://images.test/rejected.png' }; + api.uploadAvatar.mockResolvedValue(avatar); + api.create.mockRejectedValueOnce(new AppError('CONFIG', '名称不可用', undefined, { commandOutcome: 'definitive_failure' })) + .mockImplementationOnce(async input => ({ ...draft, ...input })); + open(); + fireEvent.click(await screen.findByRole('button', { name: '创建智能体', exact: true })); + fireEvent.change(screen.getByLabelText('名称'), { target: { value: '原名称' } }); + fireEvent.click(screen.getByRole('button', { name: '上传头像' })); + await screen.findByRole('button', { name: '更换头像' }); + fireEvent.click(screen.getByRole('button', { name: '下一步', exact: true })); + expect(await screen.findByRole('alert')).toHaveTextContent('创建失败:名称不可用'); + await waitFor(() => expect(api.recovery).toHaveBeenCalledTimes(2)); + expect(screen.getByLabelText('名称')).toBeEnabled(); + expect(screen.getByLabelText('名称')).toHaveValue('原名称'); + expect(screen.getByRole('button', { name: '更换头像' })).toBeEnabled(); + expect(screen.getByRole('button', { name: '移除头像' })).toBeEnabled(); + expect(screen.queryByText(/创建结果暂未确认/)).not.toBeInTheDocument(); + fireEvent.change(screen.getByLabelText('名称'), { target: { value: '新的名称' } }); + fireEvent.click(screen.getByRole('button', { name: '移除头像' })); + fireEvent.click(screen.getByRole('button', { name: '下一步', exact: true })); + await screen.findByTestId('cloud-agent-editor'); + expect(api.create).toHaveBeenCalledTimes(2); + const original = api.create.mock.calls[0][0]; + const corrected = api.create.mock.calls[1][0]; + expect(original).toMatchObject({ name: '原名称', purpose: '', icon: avatar.icon }); + expect(corrected).toMatchObject({ name: '新的名称', purpose: '', icon: null }); + expect(corrected.operation_id).not.toEqual(original.operation_id); + expect(api.recovery).toHaveBeenCalledTimes(3); +}); + +it('preserves the creation identity for unknown command outcomes even when the error says invalid input', async () => { + const avatar = { icon: '/api/storage/public/images/unknown.png', icon_url: 'https://images.test/unknown.png' }; + let retainedPending: unknown[] = []; + api.recovery.mockImplementation(async () => ({ recent: null, pending: retainedPending })); + api.uploadAvatar.mockResolvedValue(avatar); + api.create.mockImplementationOnce(async input => { + retainedPending = [{ id: 'create:unknown', operation: 'createAgent', input, created_at: '2026-09-13T01:00:00Z' }]; + throw new AppError('CONFIG', '请检查名称', undefined, { commandOutcome: 'unknown', status: 422, code: 'invalid_input' }); + }).mockImplementationOnce(async input => { + retainedPending = []; + return { ...draft, ...input, icon_url: avatar.icon_url }; + }); + open(); + fireEvent.click(await screen.findByRole('button', { name: '创建智能体', exact: true })); + fireEvent.change(screen.getByLabelText('名称'), { target: { value: '原搭档' } }); + fireEvent.click(screen.getByRole('button', { name: '上传头像' })); + await screen.findByRole('button', { name: '更换头像' }); + fireEvent.click(screen.getByRole('button', { name: '下一步', exact: true })); + expect(await screen.findByRole('alert')).toHaveTextContent('创建结果暂未确认,可重试确认结果'); + await waitFor(() => expect(api.recovery).toHaveBeenCalledTimes(2)); + expect(retainedPending).toHaveLength(1); + expect(screen.queryByRole('button', { name: /^创建记录/ })).not.toBeInTheDocument(); + expect(screen.queryByText('有 1 个操作的结果尚未确认')).not.toBeInTheDocument(); + expect(screen.getByLabelText('名称')).toBeDisabled(); + expect(screen.getByRole('button', { name: '更换头像' })).toBeDisabled(); + expect(screen.getByRole('button', { name: '移除头像' })).toBeDisabled(); + fireEvent.click(screen.getByRole('button', { name: '重试创建' })); + await screen.findByTestId('cloud-agent-editor'); + expect(api.create.mock.calls[1][0]).toEqual(api.create.mock.calls[0][0]); + await waitFor(() => expect(api.recovery).toHaveBeenCalledTimes(3)); + expect(await api.recovery.mock.results.at(-1)!.value).toEqual({ recent: null, pending: [] }); + expect(api.uploadAvatar).toHaveBeenCalledTimes(1); +}); + +it('keeps an uncertain creation identity after its reminder is removed and a retry is rejected', async () => { + const avatar = { icon: '/api/storage/public/images/discarded.png', icon_url: 'https://images.test/discarded.png' }; + let retainedPending: unknown[] = []; + api.recovery.mockImplementation(async () => ({ recent: null, pending: retainedPending })); + api.uploadAvatar.mockResolvedValue(avatar); + api.create.mockImplementationOnce(async input => { + retainedPending = [{ id: 'create:discarded', operation: 'createAgent', input, created_at: '2026-09-13T01:00:00Z' }]; + throw new AppError('NETWORK', '连接中断', undefined, { commandOutcome: 'unknown' }); + }).mockRejectedValueOnce(new AppError('CONFIG', '参数被拒绝', undefined, { commandOutcome: 'definitive_failure' })) + .mockImplementationOnce(async input => ({ ...draft, ...input, icon_url: avatar.icon_url })); + open(); + fireEvent.click(await screen.findByRole('button', { name: '创建智能体', exact: true })); + fireEvent.change(screen.getByLabelText('名称'), { target: { value: '原搭档' } }); + fireEvent.click(screen.getByRole('button', { name: '上传头像' })); + await screen.findByRole('button', { name: '更换头像' }); + fireEvent.click(screen.getByRole('button', { name: '下一步', exact: true })); + await screen.findByRole('alert'); + await waitFor(() => expect(api.recovery).toHaveBeenCalledTimes(2)); + expect(retainedPending).toHaveLength(1); + // Clearing the journal independently must not change the still-mounted form's uncertainty. + // The records entry is now confined to the list, and explicit discard is tested there. + retainedPending = []; + expect(screen.queryByRole('button', { name: /^创建记录/ })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '重试创建', exact: true })); + expect(await screen.findByRole('alert')).toHaveTextContent('创建结果暂未确认,可重试确认结果。参数被拒绝'); + await waitFor(() => expect(api.recovery).toHaveBeenCalledTimes(3)); + expect(await api.recovery.mock.results.at(-1)!.value).toEqual({ recent: null, pending: [] }); + expect(api.resolvePending).not.toHaveBeenCalled(); + expect(screen.getByLabelText('名称')).toBeDisabled(); + expect(screen.getByLabelText('名称')).toHaveValue('原搭档'); + expect(screen.getByRole('button', { name: '更换头像' })).toBeDisabled(); + expect(screen.getByRole('button', { name: '移除头像' })).toBeDisabled(); + expect(screen.queryByRole('button', { name: '下一步', exact: true })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '重试创建', exact: true })); + await screen.findByTestId('cloud-agent-editor'); + expect(api.create).toHaveBeenCalledTimes(3); + expect(api.create.mock.calls[1][0]).toEqual(api.create.mock.calls[0][0]); + expect(api.create.mock.calls[2][0]).toEqual(api.create.mock.calls[0][0]); + expect(api.uploadAvatar).toHaveBeenCalledTimes(1); +}); + it('preserves local input on conflicts and requires an explicit choice before retry', async () => { api.save.mockRejectedValueOnce(new Error('草稿已更新')); api.get.mockResolvedValue({ ...draft, draft_revision: 2, system_prompt: '其他设备内容' }); @@ -828,7 +1044,10 @@ it('searches capabilities while retaining existing selections outside the result api.list.mockResolvedValue({ agents: [configured], next_cursor: null }); const base = api.call.getMockImplementation()!; api.call.mockImplementation(async (operation, input) => operation === 'catalog' ? { - models: [{ id: 'test-model', name: '写作模型' }], resources: { tools: [{ key: 'search', name: '资料搜索', description: '搜索素材' }, { key: 'clock', name: '当前时间' }] }, pricing: null, + models: [{ id: 'test-model', name: '写作模型' }], resources: { + tools: [{ key: 'web_search', name: '上网查资料', description: '在网上查找相关信息。' }, { key: 'clock', name: '当前时间' }], + skills: [{ key: 'deep-research', name: '查资料,做研究', description: '比较不同说法,再整理出结论和出处。' }], + }, pricing: null, } : base(operation, input)); api.save.mockImplementation(async (_slug, input) => ({ ...configured, ...input, draft_revision: 2 })); open(); @@ -841,9 +1060,15 @@ it('searches capabilities while retaining existing selections outside the result fireEvent.mouseDown(screen.getByRole('tab', { name: '高级设置' }), { button: 0, ctrlKey: false }); expect(screen.getByLabelText('搜索工具箱')).toHaveValue('资料'); expect(screen.queryByRole('checkbox', { name: '当前时间' })).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole('checkbox', { name: /资料搜索/ })); + fireEvent.click(screen.getByRole('checkbox', { name: /上网查资料/ })); fireEvent.click(screen.getByRole('button', { name: '完成选择' })); expect(screen.getByRole('checkbox', { name: /missing-tool/ })).toBeChecked(); + fireEvent.click(screen.getByRole('button', { name: '选择做事手册', exact: true })); + fireEvent.change(screen.getByLabelText('搜索做事手册'), { target: { value: '出处' } }); + fireEvent.click(screen.getByRole('checkbox', { name: /查资料,做研究/ })); + fireEvent.click(screen.getByRole('checkbox', { name: '开始对话前,先读好已选做事手册的使用方法' })); fireEvent.click(screen.getByRole('button', { name: '保存草稿' })); - await waitFor(() => expect(api.save).toHaveBeenCalledWith(draft.slug, expect.objectContaining({ configuration: expect.objectContaining({ tools: ['missing-tool', 'search'] }) }))); + await waitFor(() => expect(api.save).toHaveBeenCalledWith(draft.slug, expect.objectContaining({ + configuration: expect.objectContaining({ tools: ['missing-tool', 'web_search'], skills: ['deep-research'], preload_skills: ['deep-research'] }), + }))); }); diff --git a/tests/unit/cloud-agents-workflows.test.tsx b/tests/unit/cloud-agents-workflows.test.tsx index ff938e83..9c04b5a7 100644 --- a/tests/unit/cloud-agents-workflows.test.tsx +++ b/tests/unit/cloud-agents-workflows.test.tsx @@ -164,8 +164,8 @@ it('retries the same request after a lost response and renders batched native SS expect(submissions[0][1]).toEqual(submissions[1][1]); await act(async () => stream.dispatchEvent(new MessageEvent('messages', { data: JSON.stringify({ run_id: 'run-1', thread_id: 'thread-1', payload: { items: [ - { stream_event: { type: 'message_delta', content: '第一段' } }, - { stream_event: { type: 'message_delta', content: '文字' } }, + { stream_event: { type: 'message_delta', message_id: 'answer', content: '第一段' } }, + { stream_event: { type: 'message_delta', message_id: 'answer', content: '文字' } }, ] } }), }))); expect(screen.getByText('第一段文字')).toBeVisible(); diff --git a/tests/unit/cloud-chat-reasoning.test.tsx b/tests/unit/cloud-chat-reasoning.test.tsx index 66421c66..97e84376 100644 --- a/tests/unit/cloud-chat-reasoning.test.tsx +++ b/tests/unit/cloud-chat-reasoning.test.tsx @@ -41,7 +41,7 @@ async function open(preview = false) { it('shows reasoning without an answer, preserves manual folding during deltas and folds final history once', async () => { await open(); await emit({ reasoning_content: '流式思考' }); - const details = screen.getByTestId('cloud-chat-reasoning'); + const details = screen.getByTestId('cloud-message-process'); expect(details).toHaveAttribute('open'); expect(screen.queryByRole('heading', { name: 'Hi,今天想聊些什么?' })).not.toBeInTheDocument(); act(() => { details.removeAttribute('open'); fireEvent(details, new Event('toggle')); }); await emit({ reasoning_content: '第二段', content: '流式回答' }, '2-0'); @@ -51,8 +51,8 @@ it('shows reasoning without an answer, preserves manual folding during deltas an history = { ...history, run: { ...run, status: 'completed' }, messages: [savedAssistant()] }; await emit({}, '2-0', 'end'); await screen.findByText('完整回答'); - expect(screen.getAllByTestId('cloud-chat-reasoning')).toHaveLength(1); - expect(screen.getByTestId('cloud-chat-reasoning')).not.toHaveAttribute('open'); + expect(screen.getAllByTestId('cloud-message-process')).toHaveLength(1); + expect(screen.getByTestId('cloud-message-process')).not.toHaveAttribute('open'); expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('完整思考'); expect(screen.queryByText('流式回答')).not.toBeInTheDocument(); }); @@ -65,7 +65,7 @@ it('keeps partial thought and answer when terminal history fails and excludes la await screen.findByText('历史暂时离线'); expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('已收思考'); expect(screen.getByText('已收片段')).toBeInTheDocument(); - expect(screen.getByTestId('cloud-chat-reasoning')).not.toHaveAttribute('open'); + expect(screen.getByTestId('cloud-message-process')).not.toHaveAttribute('open'); await emit({ reasoning_content: '另一线程' }, '2-0', 'messages', { thread_id: 'other-thread' }); expect(screen.getByLabelText('智能体思考过程')).not.toHaveTextContent('另一线程'); }); @@ -78,8 +78,8 @@ it('restores only assistant reasoning from completed history and shows no block ] }; render(); await screen.findByText('完整回答'); - expect(screen.getAllByTestId('cloud-chat-reasoning')).toHaveLength(1); - expect(screen.getByTestId('cloud-chat-reasoning')).not.toHaveAttribute('open'); + expect(screen.getAllByTestId('cloud-message-process')).toHaveLength(1); + expect(screen.getByTestId('cloud-message-process')).not.toHaveAttribute('open'); expect(screen.queryByText('不属于用户显示的思考')).not.toBeInTheDocument(); expect(screen.queryByText('系统字段')).not.toBeInTheDocument(); expect(api.events).not.toHaveBeenCalled(); @@ -88,9 +88,9 @@ it('restores only assistant reasoning from completed history and shows no block it('clears live reasoning on a new conversation and ignores its obsolete stream', async () => { await open(true); await emit({ reasoning_content: '旧对话思考' }); fireEvent.click(screen.getByRole('button', { name: '新对话' })); - await waitFor(() => expect(screen.queryByTestId('cloud-chat-reasoning')).not.toBeInTheDocument()); + await waitFor(() => expect(screen.queryByTestId('cloud-message-process')).not.toBeInTheDocument()); await emit({ reasoning_content: '旧事件迟到' }, '2-0'); - expect(screen.queryByTestId('cloud-chat-reasoning')).not.toBeInTheDocument(); + expect(screen.queryByTestId('cloud-message-process')).not.toBeInTheDocument(); expect(api.call.mock.calls.some(call => ['submit', 'preview'].includes(call[0]))).toBe(false); }); @@ -103,7 +103,7 @@ it('hydrates a final message that is saved after its terminal Run stops the runn history = { ...history, messages: [savedAssistant('迟到保存的思考')] }; await act(async () => { await vi.advanceTimersByTimeAsync(2000); }); expect(screen.getByText('完整回答')).toBeInTheDocument(); - expect(screen.getAllByTestId('cloud-chat-reasoning')).toHaveLength(1); + expect(screen.getAllByTestId('cloud-message-process')).toHaveLength(1); expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('迟到保存的思考'); expect(screen.queryByText('还没落盘的回答')).not.toBeInTheDocument(); expect(api.call.mock.calls.some(call => ['submit', 'preview'].includes(call[0]))).toBe(false); @@ -157,8 +157,8 @@ it('ignores a pending history read when approval resumes the same request in a n run_id: 'run-2', thread_id: 'thread-1', request_id: 'request-1', payload: { chunk: { stream_event: { type: 'message_delta', message_id: 'message-2', reasoning_content: '操作后的思考' } } }, }) }))); await act(async () => { resolveOld({ ...history, run: { ...run, status: 'interrupted' } }); }); - expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('操作前的思考'); - expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('操作后的思考'); + expect(screen.getAllByLabelText('智能体思考过程').map(item => item.textContent).join('')).toContain('操作前的思考'); + expect(screen.getAllByLabelText('智能体思考过程').map(item => item.textContent).join('')).toContain('操作后的思考'); expect(screen.queryByRole('button', { name: '允许执行', exact: true })).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: '停止回答', exact: true })).toBeInTheDocument(); }); @@ -176,7 +176,7 @@ it('reopens a resumed same-Run stream and ignores its obsolete interrupted histo return base(operation, input); }); await emit({}, '2-0', 'end'); - expect(screen.getByTestId('cloud-chat-reasoning')).not.toHaveAttribute('open'); + expect(screen.getByTestId('cloud-message-process')).not.toHaveAttribute('open'); const resumedStream = new Stream(); api.events.mockResolvedValue(resumedStream); await act(async () => { fireEvent.click(screen.getByRole('button', { name: '允许执行', exact: true })); }); await waitFor(() => expect(api.events).toHaveBeenLastCalledWith('run-1', '2-0')); @@ -186,13 +186,13 @@ it('reopens a resumed same-Run stream and ignores its obsolete interrupted histo type: 'message_delta', message_id: 'message-2', reasoning_content: '恢复后的思考', content: '恢复后的回答', } } }, }) }))); - expect(screen.getByTestId('cloud-chat-reasoning')).toHaveAttribute('open'); - expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('操作前的思考'); - expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('恢复后的思考'); + expect(screen.getAllByTestId('cloud-message-process').every(item => !item.hasAttribute('open'))).toBe(true); + expect(screen.getAllByLabelText('智能体思考过程').map(item => item.textContent).join('')).toContain('操作前的思考'); + expect(screen.getAllByLabelText('智能体思考过程').map(item => item.textContent).join('')).toContain('恢复后的思考'); expect(screen.getByText(/恢复后的回答/)).toBeInTheDocument(); await act(async () => { resolveOld({ ...history, run: { ...run, status: 'interrupted' }, messages: [savedAssistant()] }); }); - expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('恢复后的思考'); - expect(screen.getByTestId('cloud-chat-reasoning')).toHaveAttribute('open'); + expect(screen.getAllByLabelText('智能体思考过程').map(item => item.textContent).join('')).toContain('恢复后的思考'); + expect(screen.getAllByTestId('cloud-message-process').every(item => !item.hasAttribute('open'))).toBe(true); expect(screen.getByRole('button', { name: '停止回答', exact: true })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: '允许执行', exact: true })).not.toBeInTheDocument(); expect(screen.queryByText('完整回答')).not.toBeInTheDocument(); @@ -215,12 +215,12 @@ it('ignores a late approval receipt after the user discards input and starts a n await waitFor(() => expect(api.call.mock.calls.some(([operation]) => operation === 'resume')).toBe(true)); fireEvent.click(screen.getByRole('button', { name: '新对话', exact: true })); fireEvent.click(await screen.findByRole('button', { name: '放弃内容', exact: true })); - expect(screen.queryByTestId('cloud-chat-reasoning')).not.toBeInTheDocument(); + expect(screen.queryByTestId('cloud-message-process')).not.toBeInTheDocument(); expect(screen.getByRole('heading', { name: '问它一个小问题' })).toBeInTheDocument(); await act(async () => { resolveResume({ run_id: 'resumed-old-run', status: 'running' }); }); expect(screen.queryByRole('button', { name: '停止回答', exact: true })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: '允许执行', exact: true })).not.toBeInTheDocument(); - expect(screen.queryByTestId('cloud-chat-reasoning')).not.toBeInTheDocument(); + expect(screen.queryByTestId('cloud-message-process')).not.toBeInTheDocument(); expect(screen.getByRole('heading', { name: '问它一个小问题' })).toBeInTheDocument(); expect(api.events).not.toHaveBeenCalledWith('resumed-old-run'); expect(api.events).toHaveBeenCalledTimes(1); diff --git a/tests/unit/cloud-chat-rendering.test.tsx b/tests/unit/cloud-chat-rendering.test.tsx new file mode 100644 index 00000000..68b1c60d --- /dev/null +++ b/tests/unit/cloud-chat-rendering.test.tsx @@ -0,0 +1,185 @@ +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { CloudChat } from '@/pages/CloudAgents/CloudChat'; +import { reduceCloudStream, type CloudStream } from '@/pages/CloudAgents/cloud-chat-stream'; +import type { CloudHistory } 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 run = { agent_run_id: 'run', request_id: 'request', thread_id: 'thread', agent_slug: 'agent', status: 'running', output: '', version: '1' }; +const thread = { thread_id: 'thread', client_thread_id: 'client', agent_slug: 'agent', mode: 'published', status: 'running', title: '对话', updated_at: '', unread: false, run_id: 'run' }; +beforeEach(() => { + vi.resetAllMocks(); + stream = new Stream(); + history = { thread_id: 'thread', messages: [], run, next_offset: null }; + api.recovery.mockResolvedValue({ recent: null, pending: [] }); + api.remember.mockResolvedValue({}); + api.download.mockResolvedValue({ saved: true }); + 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); +async function emit(items: unknown[], cursor: string, threadId = 'thread') { + await act(async () => stream.dispatchEvent(new MessageEvent('messages', { + lastEventId: cursor, data: JSON.stringify({ run_id: 'run', thread_id: threadId, payload: { items } }), + }))); +} +const delta = (message_id: string, fields: Record) => ({ stream_event: { type: 'message_delta', message_id, ...fields } }); + +it('renders separate thinking and Markdown messages, ignores replay/subthreads, and restores durable history on reopen', async () => { + const view = render(); + await waitFor(() => expect(api.events).toHaveBeenCalledWith('run')); + await emit([delta('first', { reasoning_content: '先查资料' })], '100-1'); + expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('先查资料'); + expect(screen.getByTestId('cloud-message-process')).toHaveAttribute('open'); + await emit([delta('first', { reasoning_content: '先查资料' })], '100-1'); + await emit([delta('child', { content: '不应出现在主回答', reasoning_content: '子线程思考' })], '100-2', 'child'); + await emit([delta('first', { content: '**核对完成**' }), delta('second', { additional_reasoning_content: '再整理结论' })], '100-3'); + expect(screen.getByText('核对完成').tagName).toBe('STRONG'); + expect(screen.queryByText('不应出现在主回答')).not.toBeInTheDocument(); + expect(screen.getAllByLabelText('智能体思考过程')[0]).toHaveTextContent(/^先查资料$/); + expect(screen.getAllByTestId('cloud-message-process')[0]).not.toHaveAttribute('open'); + await emit([delta('second', { content: '| 项目 | 结果 |\n| --- | --- |\n| 校验 | 通过 |\n\n[报告](sandbox:/report.md)' })], '100-4'); + expect(screen.getByRole('table')).toHaveTextContent('校验通过'); + fireEvent.click(screen.getByRole('button', { name: '报告' })); + expect(api.download).toHaveBeenCalledWith('thread', '/report.md'); + history = { ...history, run: { ...run, status: 'completed' }, messages: [ + { id: 1, role: 'assistant', message_id: 'first', run_id: 'run', request_id: 'request', created_at: '', content: '**核对完成**', reasoning_content: '先查资料' }, + { id: 2, role: 'assistant', message_id: 'second', run_id: 'run', request_id: 'request', created_at: '', content: '最终结论', reasoning_content: '再整理结论' }, + ] }; + await act(async () => stream.dispatchEvent(new MessageEvent('end', { data: JSON.stringify({ run_id: 'run', thread_id: 'thread', payload: { status: 'completed' } }) }))); + await screen.findByText('最终结论'); + expect(screen.getAllByText('核对完成')).toHaveLength(1); + expect(screen.getAllByLabelText('智能体思考过程')).toHaveLength(2); + expect(screen.queryByRole('table')).not.toBeInTheDocument(); + view.unmount(); + render(); + await screen.findByText('最终结论'); + expect(screen.getAllByLabelText('智能体思考过程')[1]).toHaveTextContent('再整理结论'); + expect(screen.getAllByTestId('cloud-message-process').every(element => !element.hasAttribute('open'))).toBe(true); +}); + +it('associates tool arguments and results by call identity, keeps failures visible and clears a switched thread', async () => { + render(); + await waitFor(() => expect(api.events).toHaveBeenCalled()); + await emit([ + { stream_event: { type: 'tool_call_delta', message_id: 'm', tool_call_id: 'search', name: 'web_search', + display_name: '上网查资料', display_description: '在网上查找相关信息。', args_delta: '{"query":', index: 0 } }, + { stream_event: { type: 'tool_call_delta', message_id: 'm', args_delta: '"天气"}', index: 0 } }, + { stream_event: { type: 'tool_call', message_id: 'm', tool_call_id: 'write', name: '写文件', args: { path: '/a' }, index: 1 } }, + { event: { method: 'tools', data: { event: 'tool-finished', tool_call_id: 'write', output: { content: '磁盘不足', status: 'error' } } } }, + { event: { method: 'tools', data: { event: 'tool-finished', tool_call_id: 'search', output: { content: '晴天', status: 'success' } } } }, + ], '101-0'); + const process = screen.getByTestId('cloud-message-process'); + expect(process).toHaveTextContent('上网查资料 · 已完成'); + expect(process).toHaveTextContent('在网上查找相关信息。'); + expect(process).toHaveTextContent('工具标识:web_search'); + expect(process).toHaveTextContent('写文件 · 失败'); + expect(within(process).getByText('{"query":"天气"}')).toBeInTheDocument(); + expect(within(process).getByText('晴天')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '新对话' })); + await waitFor(() => expect(screen.queryByTestId('cloud-message-process')).not.toBeInTheDocument()); + await emit([delta('m', { reasoning_content: '旧连接的迟到内容' })], '102-0'); + expect(screen.queryByText('旧连接的迟到内容')).not.toBeInTheDocument(); +}); + +it('retains partial thinking and answer if final history cannot be read', async () => { + render(); + await waitFor(() => expect(api.events).toHaveBeenCalled()); + await emit([delta('m', { reasoning_content: '已完成分析', content: '已生成部分结果' })], '1-0'); + const implementation = api.call.getMockImplementation()!; + api.call.mockImplementation(async (operation, ...args) => { + if (operation === 'history') throw new Error('历史暂不可用'); + return implementation(operation, ...args); + }); + await act(async () => stream.dispatchEvent(new MessageEvent('end', { data: JSON.stringify({ run_id: 'run', thread_id: 'thread', payload: { status: 'failed' } }) }))); + await screen.findByText('历史暂不可用'); + expect(screen.getByText('已生成部分结果')).toBeVisible(); + expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('已完成分析'); +}); + +it('replaces only persisted messages when terminal history is still incomplete', async () => { + render(); + await waitFor(() => expect(api.events).toHaveBeenCalled()); + await emit([delta('first', { content: '第一条实时消息' }), delta('second', { content: '第二条尚未保存', reasoning_content: '第二条思考' })], '1-0'); + history = { ...history, run: { ...run, status: 'completed' }, messages: [ + { id: 1, message_id: 'first', role: 'assistant', run_id: 'run', request_id: 'request', created_at: '', content: '第一条持久消息' }, + ] }; + await act(async () => stream.dispatchEvent(new MessageEvent('end', { + lastEventId: '1-0', data: JSON.stringify({ run_id: 'run', thread_id: 'thread', payload: { status: 'completed' } }), + }))); + await screen.findByText('第一条持久消息'); + expect(screen.queryByText('第一条实时消息')).not.toBeInTheDocument(); + expect(screen.getByText('第二条尚未保存')).toBeVisible(); + expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('第二条思考'); + expect(screen.getByTestId('cloud-message-process')).not.toHaveAttribute('open'); +}); + +it('leaves ordinary answers without an empty thinking panel', async () => { + history = { ...history, run: { ...run, status: 'completed' }, messages: [ + { id: 1, role: 'assistant', content: '普通回答', request_id: 'request', run_id: 'run', created_at: '' }, + ] }; + render(); + await screen.findByText('普通回答'); + expect(screen.queryByTestId('cloud-message-process')).not.toBeInTheDocument(); +}); + +it('keeps the current full stream while a stale partial history is waiting for terminal refresh', async () => { + history = { ...history, messages: [{ id: 1, message_id: 'm', role: 'assistant', content: '旧片段', + request_id: 'request', run_id: 'run', created_at: '' }] }; + render(); + await waitFor(() => expect(api.events).toHaveBeenCalled()); + await emit([delta('m', { content: '完整的新答案', reasoning_content: '已完成的思考' })], '200-1'); + expect(screen.queryByText('旧片段')).not.toBeInTheDocument(); + const implementation = api.call.getMockImplementation()!; + let finish!: (value: CloudHistory) => void; + api.call.mockImplementation((operation, ...args) => operation === 'history' + ? new Promise(resolve => { finish = resolve; }) : implementation(operation, ...args)); + await act(async () => stream.dispatchEvent(new MessageEvent('end', { data: JSON.stringify({ run_id: 'run', thread_id: 'thread', payload: { status: 'completed' } }) }))); + expect(screen.getByText('完整的新答案')).toBeVisible(); + expect(screen.queryByText('旧片段')).not.toBeInTheDocument(); + await act(async () => finish({ ...history, run: { ...run, status: 'completed' }, messages: [{ + ...history.messages[0], content: '完整的新答案', reasoning_content: '已完成的思考', + }] })); + expect(screen.getAllByText('完整的新答案')).toHaveLength(1); +}); + +it('uses effective execution input once each tool starts, including empty arguments', () => { + let state: CloudStream = { runId: 'run', cursor: '', messages: [] }; + state = reduceCloudStream(state, { payload: { items: [ + { stream_event: { type: 'tool_call', message_id: 'm', tool_call_id: 'search', name: '查资料', args: { query: '模型参数' }, index: 0 } }, + { stream_event: { type: 'tool_call', message_id: 'm', tool_call_id: 'other', name: '其他工具', args: { unused: true }, index: 1 } }, + ] } }, 'thread', '1-0'); + state = reduceCloudStream(state, { payload: { items: [ + { event: { method: 'tools', data: { event: 'tool-started', tool_call_id: 'search', input: { query: '实际执行参数' } } } }, + { event: { method: 'tools', data: { event: 'tool-started', tool_call_id: 'other', input: {} } } }, + ] } }, 'thread', '2-0'); + expect(state.messages[0].tool_calls).toMatchObject([ + { id: 'search', args: { query: '实际执行参数' }, status: 'running' }, + { id: 'other', args: {}, status: 'running' }, + ]); + expect(state.messages[0].tool_calls[1].args).toEqual({}); + state = reduceCloudStream(state, { payload: { chunk: { + stream_event: { type: 'tool_call', message_id: 'm', tool_call_id: 'search', args: { query: '模型参数' }, index: 0 }, + } } }, 'thread', '3-0'); + expect(state.messages[0].tool_calls[0].args).toEqual({ query: '实际执行参数' }); +}); + +it('keeps Redis cursor precision while ignoring other runs', () => { + let state: CloudStream = { runId: 'run', cursor: '', messages: [] }; + const event = { run_id: 'run', thread_id: 'thread', payload: { chunk: delta('m', { content: 'A' }) } }; + state = reduceCloudStream(state, event, 'thread', '9007199254740993-0'); + expect(reduceCloudStream(state, event, 'thread', '9007199254740992-1')).toBe(state); + expect(reduceCloudStream(state, { ...event, run_id: 'other' }, 'thread', '9007199254740994-0')).toBe(state); +}); diff --git a/tests/unit/cloud-chat-stream.test.ts b/tests/unit/cloud-chat-stream.test.ts index e482aa31..afc6f3bc 100644 --- a/tests/unit/cloud-chat-stream.test.ts +++ b/tests/unit/cloud-chat-stream.test.ts @@ -10,7 +10,7 @@ describe('personal Agent main-thread stream projection', () => { it('separates reasoning and answers in batched and single chunks with legacy fallback', () => { let state = consume(null, envelope([delta({ reasoning_content: '思考一', additional_reasoning_content: '不应重复', content: '回答一' })])); state = consume(state, { run_id: 'run-1', thread_id: 'thread-1', payload: { chunk: delta({ additional_reasoning_content: '思考二', content: '回答二' }) } }, '2-0'); - expect(state).toMatchObject({ reasoning: '思考一思考二', content: '回答一回答二', cursor: '2-0' }); + expect(state).toMatchObject({ messages: [{ reasoning_content: '思考一思考二', content: '回答一回答二' }], cursor: '2-0' }); expect(JSON.stringify(state)).not.toContain('不应重复'); }); @@ -20,7 +20,7 @@ describe('personal Agent main-thread stream projection', () => { { stream_event: { type: 'tool_call', name: 'search', arguments: 'private tool args' } }, delta({ message_id: 'message-2', reasoning_content: '核对资料', content: '第二段' }), ])); - expect(state).toMatchObject({ reasoning: '先看资料\n\n核对资料', content: '第一段\n\n第二段', toolName: 'search' }); + expect(state).toMatchObject({ messages: [{ reasoning_content: '先看资料', content: '第一段', tool_calls: [{ name: 'search' }] }, { reasoning_content: '核对资料', content: '第二段' }] }); expect(JSON.stringify(state)).not.toContain('private tool args'); }); @@ -30,7 +30,7 @@ describe('personal Agent main-thread stream projection', () => { expect(consume(state, envelope([delta({ reasoning_content: '另一个请求' })], { request_id: 'request-2' }), '2-0')).toBe(state); const child = consume(state, envelope([delta({ reasoning_content: '子线程' })], { thread_id: 'child-thread' }), '2-0'); const missing = consume(child, envelope([delta({ content: '缺身份' })], { thread_id: undefined }), '3-0'); - expect(missing).toMatchObject({ reasoning: '父线程', content: '', cursor: '3-0', ended: false }); + expect(missing).toMatchObject({ messages: [{ reasoning_content: '父线程', content: '' }], cursor: '3-0', ended: false }); expect(consume(missing, envelope([], { thread_id: 'child-thread' }), '4-0', 'end')).toMatchObject({ ended: false }); }); @@ -39,16 +39,16 @@ describe('personal Agent main-thread stream projection', () => { expect(consume(state, envelope([delta({ reasoning_content: '重放' })]), '99999999999999999999-1')).toBe(state); expect(consume(state, envelope([delta({ reasoning_content: '迟到' })]), '99999999999999999998-99')).toBe(state); const next = consume(state, envelope([delta({ reasoning_content: '二次' })]), '99999999999999999999-2'); - expect(consume(next, envelope([]), '99999999999999999999-2', 'end')).toMatchObject({ reasoning: '一次二次', ended: true }); + expect(consume(next, envelope([]), '99999999999999999999-2', 'end')).toMatchObject({ messages: [{ reasoning_content: '一次二次' }], ended: true }); }); it('keeps missing-ID compatibility and retains same-request reasoning over a resumed Run only', () => { const first = consume(null, envelope([delta({ reasoning_content: '之前', content: '旧预览' })]), ''); - const next = consumeCloudChatEvent(first, envelope([delta({ message_id: 'message-2', reasoning_content: '继续', content: '新预览' })], { run_id: 'run-2' }), + const next = consumeCloudChatEvent(first, envelope([delta({ reasoning_content: '继续', content: '新预览' })], { run_id: 'run-2' }), 'messages', '1-0', 'run-2', 'thread-1', 'request-1'); - expect(next).toMatchObject({ cursor: '1-0', reasoning: '之前\n\n继续', content: '新预览', ended: false }); + expect(next).toMatchObject({ cursor: '1-0', messages: [{ run_id: 'run-1', reasoning_content: '之前', content: '旧预览' }, { run_id: 'run-2', reasoning_content: '继续', content: '新预览' }], ended: false }); const other = consumeCloudChatEvent(next, envelope([delta({ reasoning_content: '新问题' })], { run_id: 'run-3', request_id: 'request-3' }), 'messages', '1-0', 'run-3', 'thread-1', 'request-3'); - expect(other).toMatchObject({ reasoning: '新问题', content: '' }); + expect(other).toMatchObject({ messages: [{ reasoning_content: '新问题', content: '' }] }); }); }); diff --git a/tests/unit/cloud-recovery.test.tsx b/tests/unit/cloud-recovery.test.tsx new file mode 100644 index 00000000..02a7a56b --- /dev/null +++ b/tests/unit/cloud-recovery.test.tsx @@ -0,0 +1,209 @@ +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { CloudRecovery } from '@/pages/CloudAgents/CloudRecovery'; +import { AppError } from '@/lib/error-model'; +import type { CloudPendingOperation } from '../../shared/cloud-agents'; + +const api = vi.hoisted(() => ({ resolvePending: vi.fn() })); +vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api })); +const first: CloudPendingOperation = { id: 'create:first', operation: 'createAgent', input: { operation_id: 'original-first', name: '故事搭档' }, created_at: '2026-10-13T03:54:49Z' }; +const second: CloudPendingOperation = { id: 'create:second', operation: 'createAgent', input: { operation_id: 'original-second', name: '阅读伙伴' }, created_at: '2026-10-13T03:55:18Z' }; +const key: CloudPendingOperation = { id: 'key:original', operation: 'createKey', input: { application_id: 'application', operation_id: 'original-key', name: '应用密钥' }, created_at: '2026-10-13T04:00:00Z' }; +const resolved = vi.fn(); +beforeEach(() => { vi.resetAllMocks(); }); +afterEach(cleanup); +async function openRecords() { + fireEvent.click(screen.getByRole('button', { name: /^(创建记录|待处理记录)/ })); + return await screen.findByRole('dialog'); +} +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(yes => { resolve = yes; }); + return { promise, resolve }; +} + +it('shows only a compact counted creation entry until explicitly opened, without retrying or discarding records', async () => { + render(); + expect(screen.getByRole('button', { name: /^创建记录/ })).toHaveTextContent('2'); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.queryByRole('complementary', { name: '未确认的云端操作' })).not.toBeInTheDocument(); + expect(screen.queryByRole('article', { name: '创建「故事搭档」' })).not.toBeInTheDocument(); + expect(screen.queryByText(/有 2 个操作的结果尚未确认/)).not.toBeInTheDocument(); + const dialog = await openRecords(); + expect(within(dialog).getByRole('article', { name: '创建「故事搭档」' })).toBeVisible(); + expect(within(dialog).getByRole('article', { name: '创建「阅读伙伴」' })).toBeVisible(); + expect(within(dialog).getAllByRole('button', { name: '再试一次', exact: true })).toHaveLength(2); + expect(within(dialog).getAllByRole('button', { name: '不再提醒', exact: true })).toHaveLength(2); + expect(api.resolvePending).not.toHaveBeenCalled(); expect(resolved).not.toHaveBeenCalled(); +}); + +it('uses a general records entry for mixed operations and keeps the original message visible only inside the dialog', async () => { + const message = { ...second, id: 'message:original', operation: 'submit', input: { query: '我的上次问题' } }; + render(); + expect(screen.getByRole('button', { name: /^待处理记录/ })).toHaveTextContent('2'); + expect(screen.queryByText('我的上次问题')).not.toBeInTheDocument(); + const dialog = await openRecords(); + expect(within(dialog).getByText('我的上次问题')).toBeVisible(); + expect(api.resolvePending).not.toHaveBeenCalled(); +}); + +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(); + const dialog = await openRecords(); + fireEvent.click(within(dialog).getByRole('button', { name: '再试一次' })); + await screen.findByRole('alert'); + expect(screen.getByRole('alert')).toHaveTextContent('原操作仍未确认'); + expect(resolved).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: '关闭记录' })); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + const reopened = await openRecords(); + expect(within(reopened).getByRole('article', { name: '创建「故事搭档」' })).toBeVisible(); + fireEvent.click(within(reopened).getByRole('button', { name: '再试一次' })); + await waitFor(() => expect(resolved).toHaveBeenCalledOnce()); + expect(api.resolvePending.mock.calls).toEqual([[first.id, false], [first.id, false]]); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); +}); + +it('removes only the explicitly chosen local reminder and never implicitly retries it', async () => { + api.resolvePending.mockResolvedValue({ discarded: true }); + const view = render(); + const dialog = await openRecords(); + fireEvent.click(within(dialog).getAllByRole('button', { name: '不再提醒' })[1]); + await waitFor(() => expect(resolved).toHaveBeenCalledOnce()); + expect(api.resolvePending.mock.calls).toEqual([[second.id, true]]); + view.rerender(); + expect(screen.getByRole('article', { name: '创建「故事搭档」' })).toBeVisible(); + expect(screen.queryByRole('article', { name: '创建「阅读伙伴」' })).not.toBeInTheDocument(); +}); + +it('serializes double clicks and other actions and prevents dismissal until a request finishes', async () => { + const response = deferred<{ result: { slug: string } }>(); + api.resolvePending.mockReturnValue(response.promise); + render(); + const dialog = await openRecords(); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); }); + const retry = within(dialog).getAllByRole('button', { name: '再试一次' })[0]; + act(() => { fireEvent.click(retry); fireEvent.click(retry); }); + expect(api.resolvePending).toHaveBeenCalledTimes(1); + expect(within(dialog).getByRole('button', { name: '关闭记录' })).toBeDisabled(); + for (const discard of within(dialog).getAllByRole('button', { name: '不再提醒' })) expect(discard).toBeDisabled(); + fireEvent.keyDown(dialog, { key: 'Escape' }); + fireEvent.pointerDown(document.body, { button: 0, pointerType: 'mouse' }); + expect(screen.getByRole('dialog')).toBeVisible(); + expect(resolved).not.toHaveBeenCalled(); + await act(async () => response.resolve({ result: { slug: 'original-agent' } })); + expect(resolved).toHaveBeenCalledOnce(); + expect(within(dialog).getByRole('button', { name: '关闭记录' })).toBeEnabled(); +}); + +it('keeps a returned key available after modal close and reopen until the result is explicitly closed', async () => { + const secret = 'fixture-key-for-unit-only'; + api.resolvePending.mockResolvedValue({ result: { secret, id: 'created-key' } }); + const view = render(); + const dialog = await openRecords(); + fireEvent.click(within(dialog).getAllByRole('button', { name: '再试一次' })[0]); + await screen.findByText(new RegExp(secret)); + view.rerender(); + fireEvent.click(screen.getByRole('button', { name: '关闭记录' })); + expect(screen.queryByText(new RegExp(secret))).not.toBeInTheDocument(); + await openRecords(); + expect(screen.getByText(new RegExp(secret))).toBeVisible(); + expect(api.resolvePending).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole('button', { name: '关闭结果' })); + expect(screen.queryByText(new RegExp(secret))).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '关闭记录' })); + await openRecords(); + expect(screen.queryByText(new RegExp(secret))).not.toBeInTheDocument(); +}); + +it('keeps a result entry available when the final pending record was acknowledged', async () => { + api.resolvePending.mockResolvedValue({ result: { secret: 'last-key-fixture' } }); + const view = render(); + await openRecords(); fireEvent.click(screen.getByRole('button', { name: '再试一次' })); + await screen.findByText(/last-key-fixture/); + view.rerender(); + fireEvent.click(screen.getByRole('button', { name: '关闭记录' })); + await openRecords(); expect(screen.getByText(/last-key-fixture/)).toBeVisible(); + fireEvent.click(screen.getByRole('button', { name: '关闭结果' })); + expect(screen.queryByText(/last-key-fixture/)).not.toBeInTheDocument(); +}); + +it('guides verification requiring fresh input without claiming that verification completed', async () => { + const verification = { ...first, id: 'wechat:verification', operation: 'wechatBindVerification', input: { session_key: 'fixture-session' } }; + api.resolvePending.mockResolvedValue({ requires_input: true, operation: verification.operation }); + render(); + await openRecords(); fireEvent.click(screen.getByRole('button', { name: '再试一次' })); + expect(await screen.findByText(/还需要验证码/)).toBeVisible(); + expect(screen.queryByText('微信连接验证的结果已确认。')).not.toBeInTheDocument(); + expect(api.resolvePending).toHaveBeenCalledWith(verification.id, false); +}); + +it('offers only explicit discard for retired operations and explains the retired result without auto actions', async () => { + const retired = { ...first, id: 'retired:policy', operation: 'channelPolicy', input: {} }; + api.resolvePending.mockResolvedValue({ retired: true, discarded: true, operation: retired.operation }); + render(); + expect(api.resolvePending).not.toHaveBeenCalled(); + await openRecords(); + expect(screen.queryByRole('button', { name: '再试一次' })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '丢弃旧记录' })); + expect(await screen.findByText(/旧版联系人权限操作已移除/)).toBeVisible(); + expect(api.resolvePending).toHaveBeenCalledWith(retired.id, false); +}); + +it('renders no entry or dialog when there are no pending records or retained results', () => { + render(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(api.resolvePending).not.toHaveBeenCalled(); +}); + +it('does not let another action replace an unclosed key result, and unlocks actions after explicit result closure', async () => { + const verification = { ...second, operation: 'wechatBindVerification' }; + api.resolvePending.mockImplementation(async id => id === key.id + ? { result: { secret: 'protected-key-fixture' } } : { requires_input: true, operation: verification.operation }); + const view = render(); + await openRecords(); + fireEvent.click(within(screen.getByRole('article', { name: '创建密钥' })).getByRole('button', { name: '再试一次' })); + await screen.findByText(/protected-key-fixture/); + view.rerender(); + const row = screen.getByRole('article', { name: '微信连接验证' }); + const retry = within(row).getByRole('button', { name: '再试一次' }); + const discard = within(row).getByRole('button', { name: '不再提醒' }); + expect(retry).toBeDisabled(); expect(discard).toBeDisabled(); + fireEvent.click(retry); fireEvent.click(discard); + expect(api.resolvePending).toHaveBeenCalledTimes(1); + expect(screen.getByText(/protected-key-fixture/)).toBeVisible(); + fireEvent.click(screen.getByRole('button', { name: '关闭结果' })); + expect(retry).toBeEnabled(); + fireEvent.click(retry); + await screen.findByText(/还需要验证码/); + expect(api.resolvePending.mock.calls).toEqual([[key.id, false], [verification.id, false]]); +}); + +it('refreshes pending records after a definite rejection while keeping the error inside the explicit dialog', async () => { + api.resolvePending.mockRejectedValue(new AppError('CONFIG', '这条创建已被拒绝', undefined, { commandOutcome: 'definitive_failure' })); + const view = render(); + await openRecords(); fireEvent.click(screen.getByRole('button', { name: '再试一次' })); + expect(await screen.findByRole('alert')).toHaveTextContent('这条创建已被拒绝'); + expect(resolved).toHaveBeenCalledOnce(); + expect(api.resolvePending).toHaveBeenCalledWith(first.id, false); + view.rerender(); + fireEvent.click(screen.getByRole('button', { name: '关闭记录' })); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + await openRecords(); + expect(screen.queryByRole('article')).not.toBeInTheDocument(); + expect(screen.getByRole('alert')).toHaveTextContent('这条创建已被拒绝'); +}); + +it('keeps an explicitly unknown command outcome recoverable without refreshing it as a definite failure', async () => { + api.resolvePending.mockRejectedValue(new AppError('NETWORK', '请求结果仍未知', undefined, { commandOutcome: 'unknown' })); + render(); + await openRecords(); fireEvent.click(screen.getByRole('button', { name: '再试一次' })); + expect(await screen.findByRole('alert')).toHaveTextContent('请求结果仍未知'); + expect(resolved).not.toHaveBeenCalled(); + expect(screen.getByRole('article', { name: '创建「故事搭档」' })).toBeVisible(); + fireEvent.click(screen.getByRole('button', { name: '再试一次' })); + await waitFor(() => expect(api.resolvePending).toHaveBeenCalledTimes(2)); + expect(api.resolvePending.mock.calls).toEqual([[first.id, false], [first.id, false]]); +}); diff --git a/tests/unit/home-account-menu.test.tsx b/tests/unit/home-account-menu.test.tsx new file mode 100644 index 00000000..8ac090dc --- /dev/null +++ b/tests/unit/home-account-menu.test.tsx @@ -0,0 +1,134 @@ +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { ModuleSelection } from '@/pages/ModuleSelection'; +import { useAuthStore } from '@/stores/auth'; +import { useUserProfileStore } from '@/stores/user-profile'; +import { billingSummary, permanentBalance } from '../fixtures/permanent-points'; + +const api = vi.hoisted(() => ({ fetch: vi.fn() })); +vi.mock('@/lib/host-api', () => ({ hostApiFetch: api.fetch })); + +let profileName: string; +function profileResponse() { + return { success: true, profile: { display_name: profileName, age: null, gender: null, avatar_url: null, + share_age_with_agents: false, share_gender_with_agents: false, analysis_enabled: true, + completed: true, version: 1, updated_at: '2026-10-13T00:00:00Z' } }; +} +function LocationProbe() { + const location = useLocation(); + return {location.pathname}; +} +function renderHome() { + return render( + } /> + } /> + ); +} +const writes = () => api.fetch.mock.calls.filter(([, init]) => init?.method && init.method !== 'GET'); + +beforeEach(() => { + vi.resetAllMocks(); profileName = '小麦'; + useAuthStore.setState({ initialized: true, loading: false, error: null, accessToken: 'home-fixture', tokenType: 'Bearer', + expiresAt: Date.now() + 3600000, lastActiveAt: Date.now(), canRefresh: false, legacyRefreshToken: null, + user: { username: 'child-wallet', userId: 'home-user', tenantId: null, deptId: null, authorities: [] }, + moduleAccess: { programming: true, design: true, robot: true, cloud_agents: true } }); + useUserProfileStore.setState({ profilesByUserId: {}, syncStatesByUserId: {} }); + api.fetch.mockImplementation(async (path: string) => { + if (path === '/api/works/user/agent-profile') return profileResponse(); + if (path === '/api/works/billing/me') return { success: true, data: billingSummary() }; + if (path.startsWith('/api/works/billing/recharge/orders?')) return { success: true, data: { items: [] } }; + if (path === '/api/auth/logout') return { success: true }; + throw new Error('Unexpected home account fixture request ' + path); + }); +}); +afterEach(cleanup); + +it('shows the authenticated account and opens the real personal wallet with read requests only', async () => { + renderHome(); + const trigger = await screen.findByTestId('home-account-menu-trigger'); + await waitFor(() => expect(trigger).toHaveTextContent('小麦')); + expect(screen.getAllByTestId(/^ai-module-option-/)).toHaveLength(3); + fireEvent.click(trigger); + const menu = screen.getByTestId('home-account-menu'); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + expect(await within(menu).findByTestId('sidebar-total-token-points')).toHaveTextContent('75 点'); + expect(within(menu).getByText('永久有效 · 预占 5 点', { exact: true })).toBeInTheDocument(); + fireEvent.click(within(menu).getByRole('menuitem', { name: '词元点数与充值', exact: true })); + const dialog = await screen.findByTestId('point-wallet-dialog'); + await waitFor(() => expect(within(dialog).getByRole('button', { name: /500 点\s*¥10.00/ })).toBeEnabled()); + expect(within(dialog).getByTestId('point-recharge-recipient')).toHaveTextContent('充值到:小麦(child-wallet)'); + expect(api.fetch).toHaveBeenCalledWith('/api/works/billing/recharge/orders?offset=0', undefined); + fireEvent.mouseDown(within(dialog).getByText('我的可用点数', { exact: true })); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + expect(dialog).toBeVisible(); + expect(writes()).toEqual([]); +}); + +it('opens the existing profile dialog and navigates to settings without saving profile changes', async () => { + renderHome(); + await screen.findByText('小麦,欢迎回来!'); + fireEvent.click(screen.getByTestId('home-account-menu-trigger')); + fireEvent.click(within(screen.getByTestId('home-account-menu')).getByRole('menuitem', { name: '个人资料', exact: true })); + const profile = await screen.findByRole('dialog', { name: '个人资料', exact: true }); + expect(within(profile).getByLabelText(/名字/)).toHaveValue('小麦'); + fireEvent.click(within(profile).getByRole('button', { name: '关闭个人资料', exact: true })); + fireEvent.click(screen.getByTestId('home-account-menu-trigger')); + fireEvent.click(within(screen.getByTestId('home-account-menu')).getByRole('menuitem', { name: '设置', exact: true })); + expect(await screen.findByTestId('location-path')).toHaveTextContent('/settings'); + expect(writes()).toEqual([]); +}); + +it('closes the menu on Escape and outside clicks while preserving the wallet instance', async () => { + renderHome(); + const trigger = await screen.findByTestId('home-account-menu-trigger'); + fireEvent.click(trigger); + const menu = screen.getByTestId('home-account-menu'); + const wallet = await within(menu).findByTestId('sidebar-point-balance'); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + expect(menu).toHaveAttribute('aria-hidden', 'true'); + fireEvent.click(trigger); + fireEvent.mouseDown(screen.getByRole('heading', { name: '今天,想创作什么?', exact: true })); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + fireEvent.click(trigger); + expect(within(menu).getByTestId('sidebar-point-balance')).toBe(wallet); + expect(writes()).toEqual([]); +}); + +it('remounts the wallet for another account and ignores the previous delayed balance', async () => { + let finish!: (value: { success: boolean; data: ReturnType }) => void; + const base = api.fetch.getMockImplementation()!; + let firstBalance = true; + api.fetch.mockImplementation((path: string, init) => { + if (path === '/api/works/billing/me') { + if (firstBalance) { firstBalance = false; return new Promise(resolve => { finish = resolve; }); } + return Promise.resolve({ success: true, data: billingSummary({ own_points: permanentBalance({ total_remaining: '2.00' }) }) }); + } + return base(path, init); + }); + renderHome(); + fireEvent.click(await screen.findByTestId('home-account-menu-trigger')); + await waitFor(() => expect(firstBalance).toBe(false)); + profileName = '小红'; + await act(async () => useAuthStore.setState({ user: { username: 'second-wallet', userId: 'second-user', tenantId: null, deptId: null, authorities: [] } })); + const trigger = screen.getByTestId('home-account-menu-trigger'); + await waitFor(() => expect(trigger).toHaveTextContent('小红')); + if (trigger.getAttribute('aria-expanded') !== 'true') fireEvent.click(trigger); + await waitFor(() => expect(within(screen.getByTestId('home-account-menu')).getByTestId('sidebar-total-token-points')).toHaveTextContent('2 点')); + await act(async () => finish({ success: true, data: billingSummary({ own_points: permanentBalance({ total_remaining: '999.00' }) }) })); + expect(screen.queryByText('999 点', { exact: true })).not.toBeInTheDocument(); + expect(writes()).toEqual([]); +}); + +it('logs out only the fixture account and hides the home account entry for signed-out users', async () => { + renderHome(); + fireEvent.click(await screen.findByTestId('home-account-menu-trigger')); + fireEvent.click(within(screen.getByTestId('home-account-menu')).getByRole('menuitem', { name: '退出登录', exact: true })); + expect(await screen.findByTestId('location-path')).toHaveTextContent('/login'); + expect(useAuthStore.getState().user).toBeNull(); + expect(writes()).toEqual([['/api/auth/logout', expect.objectContaining({ method: 'POST' })]]); + cleanup(); + renderHome(); + expect(screen.queryByTestId('home-account-menu-trigger')).not.toBeInTheDocument(); +}); diff --git a/tests/unit/module-navigation.test.tsx b/tests/unit/module-navigation.test.tsx index 357360d2..3128e9b2 100644 --- a/tests/unit/module-navigation.test.tsx +++ b/tests/unit/module-navigation.test.tsx @@ -52,7 +52,7 @@ describe('AI module navigation', () => { }); }); - it('shows the four supported module choices and routes Canvas from the chooser', async () => { + it('shows Agents, Code and Canvas on the home page and routes Canvas from the chooser', async () => { render( @@ -64,18 +64,16 @@ describe('AI module navigation', () => { expect(screen.getByTestId('ai-module-selection-page')).toBeInTheDocument(); expect(screen.getByRole('heading', { name: '今天,想创作什么?' })).toBeInTheDocument(); - expect(screen.getByRole('heading', { name: '今天,想创作什么?' })).toHaveClass('whitespace-nowrap'); expect(screen.getByTestId('ai-module-options')).toHaveClass('module-selection-options'); expect(await screen.findByText('小明,欢迎回来!')).toBeInTheDocument(); - expect(screen.getByTestId('ai-module-option-programming')).toHaveClass('module-option-card', 'module-option-card-horizontal'); - for (const moduleId of ['programming', 'painting', 'robot', 'cloud_agents'] as const) { + expect(screen.getByTestId('ai-module-option-programming')).toHaveClass('module-option-card'); + for (const moduleId of ['programming', 'painting', 'cloud_agents'] as const) { expect(screen.getByTestId(`ai-module-option-${moduleId}`).querySelector('.module-option-artwork')).toBeNull(); } expect(screen.getAllByTestId(/^ai-module-option-/u).map((option) => option.dataset.testid)).toEqual([ 'ai-module-option-cloud_agents', 'ai-module-option-programming', 'ai-module-option-painting', - 'ai-module-option-robot', ]); expect(screen.queryByTestId('ai-module-option-learning')).not.toBeInTheDocument(); expect(screen.queryByText('先选一个入口。进入后,你还可以从左下角随时切换到其他模块。')).not.toBeInTheDocument(); @@ -89,12 +87,7 @@ describe('AI module navigation', () => { expect(screen.getByText('Canvas 设计')).toBeInTheDocument(); expect(screen.getByAltText('数位板设计创作')).toBeInTheDocument(); expect(screen.getByTestId('ai-module-option-painting')).toHaveTextContent('用灵感描绘色彩缤纷的世界'); - const robotOption = screen.getByTestId('ai-module-option-robot'); - expect(robotOption).toBeEnabled(); - expect(robotOption).not.toHaveClass('module-option-card-disabled'); - expect(robotOption).toHaveTextContent('Robot 机器'); - expect(robotOption).toHaveTextContent('制作你的第一个机器人小伙伴'); - expect(screen.getByAltText('青少年管理机器人硬件与设备绑定')).toBeInTheDocument(); + expect(screen.queryByTestId('ai-module-option-robot')).not.toBeInTheDocument(); expect(screen.getByAltText('个人云智能体')).toHaveAttribute('src', expect.stringContaining('module-agents.webp')); expect(screen.getByAltText('个人云智能体')).toHaveClass('module-option-card-image-media', 'object-cover'); @@ -102,7 +95,8 @@ describe('AI module navigation', () => { expect(await screen.findByTestId('location-path')).toHaveTextContent('/image-canvas'); }); - it('routes Robot from the chooser to the AI hardware workspace', async () => { + it('hides Robot from the chooser even when its module permission is enabled', () => { + useAuthStore.setState({ moduleAccess: { programming: true, design: true, robot: true, cloud_agents: true } }); render( @@ -112,8 +106,9 @@ describe('AI module navigation', () => { , ); - fireEvent.click(screen.getByTestId('ai-module-option-robot')); - expect(await screen.findByTestId('location-path')).toHaveTextContent('/ai-hardware'); + expect(screen.queryByTestId('ai-module-option-robot')).not.toBeInTheDocument(); + expect(screen.queryByText('Robot 机器')).not.toBeInTheDocument(); + expect(screen.queryByAltText('青少年管理机器人硬件与设备绑定')).not.toBeInTheDocument(); }); it('greys out a disabled module and does not navigate when it is clicked', async () => { From c8ce94b77863e7c75140fe214104b1444fb0698e 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 14:57:44 +0800 Subject: [PATCH 6/8] docs: record verified knowledge trial update --- .../20261013-chat-knowledge-import-explain-ec03b792.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.project-docs/30-worklog/tasks/20261013-chat-knowledge-import-explain-ec03b792.md b/.project-docs/30-worklog/tasks/20261013-chat-knowledge-import-explain-ec03b792.md index 11301319..24bb290b 100644 --- a/.project-docs/30-worklog/tasks/20261013-chat-knowledge-import-explain-ec03b792.md +++ b/.project-docs/30-worklog/tasks/20261013-chat-knowledge-import-explain-ec03b792.md @@ -8,7 +8,7 @@ - Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261013-chat-knowledge-import-explain-ec03b792 - Base commit: c634425cc669f1da66e725649a386bf09661a70c - Owner: codex -- Status: Planning +- Status: Ready for Integration ## Scope @@ -28,7 +28,7 @@ - New-library creation now says 「创建知识库」 and opens the created library for management without changing Agent draft bindings. Browsing also leaves bindings untouched; bottom selection explicitly binds. Its 100-library limit no longer blocks browsing, uploading or creating. - Independent review found that using creation-form visibility to place a shared error could show an upload error in the creation card. Errors now follow the operation source, with a regression covering right-side upload failure while the left creation form is open. - Preserved automatic document processing, status polling, pagination, original-operation retries, replacement/deletion, permission-loss pending state, own/shared library distinctions and explicit save/apply of selection. -- Main/shared/backend import contracts and recovery handling remain for existing pending operations. No real files/chats/libraries were imported, moved or deleted; current app/runtime was not refreshed. +- Main/shared/backend import contracts and recovery handling remain for existing pending operations. No real files/chats/libraries were imported, moved or deleted. The user later explicitly authorized the temporary application update; its verified activation is recorded below. ## Verification @@ -42,7 +42,7 @@ ## Follow-ups -- Source handoff is ready for later integration/application update. The knowledge-workspace source record and current trial remain unchanged; no main integration, push or app update occurred. +- Source and current temporary application update are complete. Primary main, remote and peer/canonical task records are unchanged; no merge/push or cloud deployment occurred. Real cloud file ingestion remains outside this UI update's acceptance scope. ## Promotion Candidates @@ -71,3 +71,7 @@ - Plan: assemble the finished knowledge delta over the latest committed deployed product in this owned worktree, keeping canonical/peer task docs read-only. Verify relevant combined source tests/typecheck/lint/build and full Main/resources/package byte equality; prepare a task-owned immutable snapshot with rollback and bound predecessor proof. Activate only after the homepage update settles and a fresh saved/quiet-state check passes; verify actual knowledge layout and original route/session/assets/focus, retaining all rollback artifacts. - Assembly implemented over deployed a9fa221: imported its committed product/test changes, preserving only this task's knowledge source/README delta. Actual product blobs differ in exactly the four knowledge source/styles files and removed attachment picker; Electron/shared/resources/package/lock match deployed source. Knowledge fixture keeps a9's normalizePath portability fixes; page regression keeps its create/recovery cases and this task's removed old heading click. Independent read-only source review confirms complete current create/journal/onSettled/CloudRecovery/chat DTO/history/stream/message-rendering chains retained. - Combined verification before staging: 10 focused unit files / 224 tests passed; typecheck and full build passed; 38 changed source/test ESLint files have no errors and retain two existing CloudChat warnings. Knowledge fixture 5/5 passed in 18.2s, including 375/768/1024/1440 geometry and explicit binding/upload-to-ready/save-reopen. All 66 non-Renderer artifacts exactly match immutable running Main/resources/package. The first set comparison detected two unreferenced leftover chunks from this worktree's earlier build; verified that no current JS references them and removed only those generated files, then repeated the strict exact-set comparison successfully. +- Source frozen clean as 2d4e44f4a023fc649d9064d4413284fbeb962d59. Task-owned immutable stage `/Users/chillishark/Makelore 麦洛/.trial-runtime/knowledge-layout-20261013-ec03b792` binds exact predecessor a9fa221, its current-pointer SHA and manifest/assets, original Main proof, task helper hashes and rollback snapshot. Root independently checked both helpers and `prepare.py --verify`. A marker cleanup omission on held/preserved exits was fixed before activation; rebind evidence preserves the old/new helper hashes without changing artifacts or pointer. +- First activation held at preflight: route `#/cloud-agents?view=mine`, unsent input and beforeunload protection were true; no unsaved configuration, background lease or trusted interaction. Pointer/compat/Main were unchanged and Inspector was closed. User then explicitly answered 「允许清除这些输入并刷新」. Added a one-run authorization flag for existing inputs and beforeunload, retaining dialog, pending-file, running-work, lease and fresh-user-interaction guards. Electron's pinned declaration confirms the one-time will-prevent-unload handler permits only this authorized reload; no business-data deletion or cloud cancellation occurs. Separate authorization/helper rebind receipt retained. +- Final activation succeeded: current Renderer/effective source is 2d4e44f, Main remains 7c51bb4 / PID9968 / port58003, immutable current-stage and predecessor artifacts retained. Actual loaded JS/CSS match the new snapshot; same stable file URL, current route, profile, login, foreground PID1353 and closed Inspector verified. Application did not restore the cleared local input. `prepare.py --verify` independently reports active-verified. +- Root performed an additional read-only DOM probe: authenticated My Agents list settled with five cards, mounted app, original hash, no login form or visible alert, unchanged foreground and same Main. No names, credentials or input text were read/recorded, no account/Agent/file operation occurred. The original list does not display knowledge configuration; no forced navigation was made. Knowledge layout evidence is the tested source/fixtures at four widths and exact actual loaded Renderer assets, distinct from a live cloud knowledge ingestion test. Durable receipts are overlay-manifest.json, verification-receipt.json, live-dom-probe-receipt.json and retained preflight/helper-authorization evidence. From 2899009215b1de8e28b84fc108e880c6e68a6098 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 15:43:08 +0800 Subject: [PATCH 7/8] fix(knowledge): align cards and remove extra guidance --- ...-chat-knowledge-import-explain-ec03b792.md | 12 +- README.md | 4 +- src/components/account/AccountMenuTrigger.tsx | 38 +++++ src/components/account/HomeAccountMenu.tsx | 34 +++-- .../account/StandaloneAccountDock.tsx | 29 ++++ src/components/layout/MainLayout.tsx | 6 + src/components/layout/SettingsLayout.tsx | 11 ++ src/components/layout/Sidebar.tsx | 57 +++----- src/components/layout/SidebarUpdateButton.tsx | 2 +- src/components/layout/TitleBar.tsx | 1 + src/lib/settings-navigation.ts | 30 ++++ src/pages/Chat/classroom-workspace.css | 3 +- src/pages/CloudAgents/CloudKnowledge.tsx | 4 - .../CloudAgents/CloudKnowledgeSelector.tsx | 3 +- src/pages/CloudAgents/agent-config.css | 5 +- src/pages/ModuleSelection/index.tsx | 2 +- src/pages/Settings/index.tsx | 28 +++- src/styles/globals.css | 14 +- tests/e2e/cloud-agent-knowledge.spec.ts | 5 + tests/e2e/main-navigation.spec.ts | 132 ++++++++++++++++-- tests/e2e/settings-proxy.spec.ts | 12 ++ tests/unit/account-menu-trigger.test.tsx | 108 ++++++++++++++ tests/unit/cloud-knowledge-catalog.test.tsx | 5 +- tests/unit/cloud-knowledge-picker.test.tsx | 5 +- tests/unit/home-account-menu.test.tsx | 23 ++- tests/unit/main-layout-module-gate.test.tsx | 13 ++ tests/unit/settings-navigation.test.ts | 30 ++++ 27 files changed, 516 insertions(+), 100 deletions(-) create mode 100644 src/components/account/AccountMenuTrigger.tsx create mode 100644 src/components/account/StandaloneAccountDock.tsx create mode 100644 src/components/layout/SettingsLayout.tsx create mode 100644 src/lib/settings-navigation.ts create mode 100644 tests/unit/account-menu-trigger.test.tsx create mode 100644 tests/unit/settings-navigation.test.ts diff --git a/.project-docs/30-worklog/tasks/20261013-chat-knowledge-import-explain-ec03b792.md b/.project-docs/30-worklog/tasks/20261013-chat-knowledge-import-explain-ec03b792.md index 24bb290b..612cc332 100644 --- a/.project-docs/30-worklog/tasks/20261013-chat-knowledge-import-explain-ec03b792.md +++ b/.project-docs/30-worklog/tasks/20261013-chat-knowledge-import-explain-ec03b792.md @@ -8,7 +8,7 @@ - Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261013-chat-knowledge-import-explain-ec03b792 - Base commit: c634425cc669f1da66e725649a386bf09661a70c - Owner: codex -- Status: Ready for Integration +- Status: Planning ## Scope @@ -75,3 +75,13 @@ - First activation held at preflight: route `#/cloud-agents?view=mine`, unsent input and beforeunload protection were true; no unsaved configuration, background lease or trusted interaction. Pointer/compat/Main were unchanged and Inspector was closed. User then explicitly answered 「允许清除这些输入并刷新」. Added a one-run authorization flag for existing inputs and beforeunload, retaining dialog, pending-file, running-work, lease and fresh-user-interaction guards. Electron's pinned declaration confirms the one-time will-prevent-unload handler permits only this authorized reload; no business-data deletion or cloud cancellation occurs. Separate authorization/helper rebind receipt retained. - Final activation succeeded: current Renderer/effective source is 2d4e44f, Main remains 7c51bb4 / PID9968 / port58003, immutable current-stage and predecessor artifacts retained. Actual loaded JS/CSS match the new snapshot; same stable file URL, current route, profile, login, foreground PID1353 and closed Inspector verified. Application did not restore the cleared local input. `prepare.py --verify` independently reports active-verified. - Root performed an additional read-only DOM probe: authenticated My Agents list settled with five cards, mounted app, original hash, no login form or visible alert, unchanged foreground and same Main. No names, credentials or input text were read/recorded, no account/Agent/file operation occurred. The original list does not display knowledge configuration; no forced navigation was made. Knowledge layout evidence is the tested source/fixtures at four widths and exact actual loaded Renderer assets, distinct from a live cloud knowledge ingestion test. Durable receipts are overlay-manifest.json, verification-receipt.json, live-dom-probe-receipt.json and retained preflight/helper-authorization evidence. + +## Equal-height / Copy Follow-up + +- Ownership/Planning Gates Passed on same-task resume: successful bundled start and exact task/feature/branch/worktree/base verified; active record and relevant current source/runtime read. Unchanged startup/domain/decisions/evidence context retained. All peer Scope/Intent/Promotion sections read-only. The active draft-conflict task has no defined scope yet; its unknown coordination state does not establish a concrete dependency for these CSS/copy edits. Avoid its dirty source. +- User explicitly requests the top left/right knowledge cards have matching height, deletes the full right-side 「使用说明与费用」 disclosure and content, and deletes both normal multi-select and save/apply/read-only description paragraphs. Keep the actual 100-library-limit message and underlying selection/save/processing behavior. +- Current verified deployed Renderer/effective source is 2cc542f under settings-account overlay, same Main PID9968/port58003. Preserve its latest committed homepage/settings/account changes when assembling the new update; no canonical/peer-doc changes. User's instruction to update the temporary application persists. The previous clear-input authorization was for the completed prior refresh, so fresh user changes remain protected. +- Plan: restore committed current product/test state where changed, keep the owned record; apply grid stretch and remove only requested prose/disclosure/unused exclusive style. Update focused existing tests/README, run knowledge suites/typecheck/lint/build and four-width fixture geometry. Prepare a fresh task-owned overlay using the proven previous helper and fresh current pointer; root activates after quiet-state protection, verifies actual loaded assets and records the result. +- Implementation complete: grid stretches both top cards to the same row height, including creation forms and document rows; narrow cards retain independent vertical flow. The entire right help disclosure and two requested selection paragraphs are removed, while the actual full-selection limit message remains conditional. Imported current 2cc product/test/README state has no knowledge conflict; independent review confirms Main/shared/resources/package identical to 7c. +- Verification: 6 knowledge/page/guidance/workflow unit files / 100 tests, 5 headless scenarios at 375/768/1024/1440, typecheck/full build/scoped 21-file ESLint and whitespace checks passed. Geometry assertions include empty, expanded creation and ready-document equal heights; root inspected the desktop screenshot. All 66 non-Renderer artifacts are byte-identical to immutable Main snapshot. +- Fresh stage knowledge-align-20261013-ec03b792 reuses prior guards with default input-discard authorization false, unique marker/temp names and fresh predecessor binding. It recognizes implicit fieldset knowledge groups, checks equal heights and removed copy, and restores an originally selected Advanced tab without changing any field. Saved configuration TEXTAREA values are excluded from unsent-input detection only when the visible fixed saved indicator and inactive beforeunload prove the configuration is saved; new creation/chat inputs and all real pending/dirty/interaction protections remain. diff --git a/README.md b/README.md index fa404db3..45f3b925 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Code 会话保存的模型被下架后,会提示重新选择可用模型;用户可在原会话中切换,恢复时使用新选择并保留会话与历史消息。切换不会自动重发消息或自动选择替代模型。 -Makelore 是一个面向软件、视觉创作、智能机器人与个人云智能体的 AI 桌面工作台。当前版本为 `2.0.0`,首页展示 Agents、Code、Canvas 三个模块入口,Robot 入口隐藏;云智能体需配套配置 WS/Yuxi 服务。桌面采用左侧问候与标题、右侧三个横向插画卡片竖排的布局,Code 入口中心与“今天,想创作什么?”标题中心对齐,三个入口等高、间距均匀;标题组在桌面向右偏移约 1.5cm,小窗口将标题放在卡片上方;Agents 标语为“打造你想象中的AI助手”。首页左下角保留半尺寸的账户入口,向左偏移约 1.5cm(小窗口保留边距)并靠近底部;展开菜单保持原尺寸,可查看本人词元点数与预占、打开词元点数与充值、个人资料、设置和退出登录;沿用侧栏的账号、钱包和资料规则。工作区左上角入口点击后返回模块入口页: +Makelore 是一个面向软件、视觉创作、智能机器人与个人云智能体的 AI 桌面工作台。当前版本为 `2.0.0`,首页展示 Agents、Code、Canvas 三个模块入口,Robot 入口隐藏;云智能体需配套配置 WS/Yuxi 服务。桌面采用左侧问候与标题、右侧三个横向插画卡片竖排的布局,Code 入口中心与“今天,想创作什么?”标题中心对齐,三个入口等高、间距均匀;标题组在桌面向右偏移约 1.5cm,小窗口将标题放在卡片上方;Agents 标语为“打造你想象中的AI助手”。各页左下角账户按钮统一沿用 Code 的白色圆角卡片、头像、字号和右箭头,展开时跟随 Code 侧栏宽度偏好,首页和设置页与 Code 保持相同的左侧与底部边距;展开菜单保持原尺寸,可查看本人词元点数与预占、打开词元点数与充值、个人资料、设置和退出登录;沿用侧栏的账号、钱包和资料规则。设置使用独立页面,不显示编程项目栏、项目标题或工作栏收起按钮;“返回”回到打开设置前的页面(保留查询参数),直接打开设置时回模块首页,未保存设置先保存或还原。工作区左上角入口点击后返回模块入口页: - `Makelore Code|AI 编程`:管理本地项目、项目智能体、对话、文件上下文、代码变更和运行时。 会话工作台采用浅蓝项目栏、彩色通用手柄图标与卡片选中态;中间对话/作品使用圆角标签,作品预览的后退、前进、刷新与网址输入统一放在标签右侧;只在作品页显示,窄窗口自动换行。右侧智能体咨询使用奶油色标题区。沿用真实项目和会话入口、侧栏伸缩与账户菜单,样式只作用于 Code 会话工作台。可编辑的空对话在输入区上方的可用区域中央显示童趣欢迎语;「去作品广场逛逛」入口紧贴输入框上方,两者一起位于底部。老师介绍以固定宽度浮在右上角,不占用中间布局;欢迎语和底部输入区始终相对项目栏右侧的整个内容区域居中;小窗口的欢迎内容独立滚动;发送首条消息后收起欢迎区。侧栏保留「作品广场」入口,只有点击后才在系统浏览器打开公共广场,不请求、摘取或内嵌展示广场作品。打开广场保留当前项目、对话、草稿和附件,不自动发送消息;老师求助按钮保留临时应用的最新文案「帮我整体看🧠」。 @@ -16,7 +16,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智 历史待确认记录收起在智能体列表标题区的「创建记录/待处理记录」入口,只有点击后才打开记录窗口;配置、聊天和创建页不再置顶这些旧提醒。记录保留智能体名称和原请求身份,用户可明确「再试一次」或「不再提醒」,不会自动重试或清除。当前创建失败继续在创建表单内提示;关闭记录窗口保留已收到的结果和密钥,切换配置、聊天或创建页后可返回列表继续查看。密钥须明确关闭结果后,才可处理其他记录。 - 高级配置的知识库支持下拉多选,分为「MakeLore知识」和「我的知识库」,已选项可单独移除。MakeLore知识来自当前账号有权读取的 Yuxi 共享目录,只显示名称与简介;移除仅取消当前智能体选用,不删除原库。知识库区域上方左右分区:左侧「上传自己的文件」仅放「新建知识库」入口及展开的创建表单,右侧「查看我的知识库」通过下拉框查看本人知识库、从电脑追加资料和管理文件;下方独立「选择知识库」,选择智能体使用的「MakeLore知识」和「我的知识库」。小窗口按新建、查看、选用顺序纵向排列。创建成功后在右侧打开新库;新建和查看均不会自动加入智能体配置,选用上限不影响管理与新建。仅提供「从电脑上传」作为添加文件入口,未选择管理目标时上传按钮置灰;聊天文件导入入口已移除。上传后自动解析、建立索引并持续显示真实处理状态;失败可重试且保留已有可用内容。使用说明与费用以折叠说明呈现。暂不可选用的个人库仍可管理文件,已有绑定保留并提示不可用;未确认请求在当前库保留同一操作身份供重试。知识库选择只改草稿,显式保存/应用后生效;个人云端执行已自动接入所选知识库的检索,无需另外勾选 knowledge-base Skill。此交互复用现有接口,无需新增后端补丁。 + 高级配置的知识库支持下拉多选,分为「MakeLore知识」和「我的知识库」,已选项可单独移除。MakeLore知识来自当前账号有权读取的 Yuxi 共享目录,只显示名称与简介;移除仅取消当前智能体选用,不删除原库。知识库区域上方左右分区,两张卡片在并排时保持等高:左侧「上传自己的文件」仅放「新建知识库」入口及展开的创建表单,右侧「查看我的知识库」通过下拉框查看本人知识库、从电脑追加资料和管理文件;下方独立「选择知识库」,选择智能体使用的「MakeLore知识」和「我的知识库」。小窗口按新建、查看、选用顺序纵向排列。创建成功后在右侧打开新库;新建和查看均不会自动加入智能体配置,选用上限不影响管理与新建。仅提供「从电脑上传」作为添加文件入口,未选择管理目标时上传按钮置灰;聊天文件导入入口已移除。上传后自动解析、建立索引并持续显示真实处理状态;失败可重试且保留已有可用内容。暂不可选用的个人库仍可管理文件,已有绑定保留并提示不可用;未确认请求在当前库保留同一操作身份供重试。知识库选择只改草稿,显式保存/应用后生效;个人云端执行已自动接入所选知识库的检索,无需另外勾选 knowledge-base Skill。此交互复用现有接口,无需新增后端补丁。 云知识库的 embedding 模型只由平台管理员在 Yuxi 后台维护:在「智能体管理 → 模型供应商」启用供应商,按供应商要求填写有效的服务地址和 API Key,添加并启用 embedding 模型并填写实际向量维度;embedding 向量化费用由平台承担,不计入用户用量、词元点数或智能体费用上限;one-api 不参与 embedding 配置,Works Square 只负责其他应计费模型调用的创建者词元点数账本。客户端区分目录加载中、加载失败与无可用模型,空状态提供管理员配置说明;「刷新知识库与模型」保留未提交名称,未确认的创建请求沿用原操作身份。 diff --git a/src/components/account/AccountMenuTrigger.tsx b/src/components/account/AccountMenuTrigger.tsx new file mode 100644 index 00000000..88ca7bd8 --- /dev/null +++ b/src/components/account/AccountMenuTrigger.tsx @@ -0,0 +1,38 @@ +import { forwardRef, type ButtonHTMLAttributes } from 'react'; +import { ChevronRight } from 'lucide-react'; +import { UserAvatar } from '@/components/profile/UserAvatar'; +import { getAccountInitial } from '@/components/profile/user-avatar-utils'; +import { cn } from '@/lib/utils'; + +export interface AccountMenuTriggerProps extends ButtonHTMLAttributes { + accountName: string; + avatarUrl?: string | null; + collapsed?: boolean; + expandedWidth?: number; + open?: boolean; + avatarTestId?: string; + surfaceTestId?: string; +} + +/** Shared Code account appearance; the containing menu owns behavior and width preferences. */ +export const AccountMenuTrigger = forwardRef( + function AccountMenuTrigger({ + accountName, avatarUrl, collapsed = false, expandedWidth = 150, open = false, + avatarTestId, surfaceTestId, className, style, type = 'button', ...props + }, ref) { + return ; + }, +); diff --git a/src/components/account/HomeAccountMenu.tsx b/src/components/account/HomeAccountMenu.tsx index edc0b1ba..e2507b33 100644 --- a/src/components/account/HomeAccountMenu.tsx +++ b/src/components/account/HomeAccountMenu.tsx @@ -1,11 +1,16 @@ import { useEffect, useRef, useState } from 'react'; -import { ChevronUp, LogOut, Settings, UserCircle2 } from 'lucide-react'; -import { useNavigate } from 'react-router-dom'; +import { LogOut, Settings, UserCircle2 } from 'lucide-react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { toast } from 'sonner'; import { UserAvatar } from '@/components/profile/UserAvatar'; import { getAccountInitial } from '@/components/profile/user-avatar-utils'; import { DisclosureContent } from '@/components/ui/disclosure'; import { useAuthStore } from '@/stores/auth'; +import { useSettingsStore } from '@/stores/settings'; +import { normalizeClassroomSidebarWidth } from '@/lib/classroom-layout'; +import { canLeaveCurrentPage, getSettingsReturnState } from '@/lib/settings-navigation'; import { PointWallet } from './PointWallet'; +import { AccountMenuTrigger } from './AccountMenuTrigger'; export function HomeAccountMenu({ accountName, avatarUrl, onOpenProfile }: { accountName: string; @@ -14,7 +19,9 @@ export function HomeAccountMenu({ accountName, avatarUrl, onOpenProfile }: { }) { const user = useAuthStore(state => state.user); const logout = useAuthStore(state => state.logout); + const expandedWidth = useSettingsStore(state => normalizeClassroomSidebarWidth(state.classroomSidebarWidth) - 21); const navigate = useNavigate(); + const location = useLocation(); const [open, setOpen] = useState(false); const [loggingOut, setLoggingOut] = useState(false); const [error, setError] = useState(''); @@ -48,26 +55,20 @@ export function HomeAccountMenu({ accountName, avatarUrl, onOpenProfile }: { if (!user) return null; - return
- + }} /> { if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return; @@ -87,7 +88,10 @@ export function HomeAccountMenu({ accountName, avatarUrl, onOpenProfile }: { + onClick={() => { + if (!canLeaveCurrentPage()) { toast.info('请先保存当前修改,再打开设置。'); return; } + setOpen(false); navigate('/settings', { state: getSettingsReturnState(location) }); + }}>设置
+ {isCanvasWorkspace &&
}
); } diff --git a/src/components/layout/SettingsLayout.tsx b/src/components/layout/SettingsLayout.tsx new file mode 100644 index 00000000..16daa8e7 --- /dev/null +++ b/src/components/layout/SettingsLayout.tsx @@ -0,0 +1,11 @@ +import { Outlet } from 'react-router-dom'; +import { TitleBar } from './TitleBar'; +import { StandaloneAccountDock } from '@/components/account/StandaloneAccountDock'; + +export function SettingsLayout() { + return
+ +
+ +
; +} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index dcd14316..a4cdc38c 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -36,6 +36,8 @@ import { subscribeCodingProjectOpenRequest, } from '@/lib/coding-project-entry'; import { PointWallet } from '@/components/account/PointWallet'; +import { AccountMenuTrigger } from '@/components/account/AccountMenuTrigger'; +import { canLeaveCurrentPage, getSettingsReturnState } from '@/lib/settings-navigation'; import { cn } from '@/lib/utils'; import { useCurrentUserProfile } from '@/hooks/use-current-user-profile'; import type { SidebarPeekSource } from './sidebar-peek'; @@ -361,8 +363,9 @@ export function Sidebar({ }; const navigateFromAccountMenu = (path: string) => { + if (path === '/settings' && !canLeaveCurrentPage()) { toast.info('请先保存当前修改,再打开设置。'); return; } setAccountMenuOpen(false); - navigate(path); + navigate(path, path === '/settings' ? { state: getSettingsReturnState(location) } : undefined); }; const openProfileDialog = () => { @@ -810,42 +813,26 @@ export function Sidebar({ onOpenChange={setProfileDialogOpen} /> -
+
-
- +
+
+ setAccountMenuOpen((open) => !open)} + /> +
void handleClick()} className={cn( - 'motion-press flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-accent/30 bg-accent text-primary-foreground shadow-soft hover:bg-accent-strong disabled:cursor-wait disabled:opacity-80', + 'motion-press flex h-10 w-10 max-w-full shrink-0 items-center justify-center rounded-full border border-accent/30 bg-accent text-primary-foreground shadow-soft hover:bg-accent-strong disabled:cursor-wait disabled:opacity-80', collapsed && 'shadow-none', )} > diff --git a/src/components/layout/TitleBar.tsx b/src/components/layout/TitleBar.tsx index 00a8ad64..043df9b1 100644 --- a/src/components/layout/TitleBar.tsx +++ b/src/components/layout/TitleBar.tsx @@ -212,6 +212,7 @@ function ProductTitleBar({ data-testid="titlebar-main-surface" className={cn( 'relative flex min-w-0 flex-1 bg-background', + nativeTrafficLights && !nativeFullscreen && !showSidebarControls && !overlay && 'pl-[88px]', overlay && 'pointer-events-none border-b-0 bg-transparent', nativeTrafficLights && 'drag-region', !overlay && (workspaceLayout || sidebarTitlebarHidden ? 'border-b-0' : 'border-b border-border/80'), diff --git a/src/lib/settings-navigation.ts b/src/lib/settings-navigation.ts new file mode 100644 index 00000000..cd54837b --- /dev/null +++ b/src/lib/settings-navigation.ts @@ -0,0 +1,30 @@ +import { AI_MODULE_SELECTION_PATH, getGuardedAiModuleForPath } from './ai-modules'; + +type SettingsLocation = { pathname: string; search?: string; hash?: string; state?: unknown }; + +function localReturnPath(value: unknown): string | null { + if (typeof value !== 'string' || !value.startsWith('/') || value.startsWith('//') || value.includes('\\')) return null; + try { + const target = new URL(value, 'https://makelore.local'); + if (target.origin !== 'https://makelore.local') return null; + if (target.pathname !== AI_MODULE_SELECTION_PATH && !getGuardedAiModuleForPath(target.pathname)) return null; + return `${target.pathname}${target.search}${target.hash}`; + } catch { return null; } +} + +export function getSettingsReturnPath(state: unknown): string { + const candidate = state && typeof state === 'object' && 'settingsReturnTo' in state + ? (state as { settingsReturnTo?: unknown }).settingsReturnTo : null; + return localReturnPath(candidate) ?? AI_MODULE_SELECTION_PATH; +} + +export function getSettingsReturnState(location: SettingsLocation): { settingsReturnTo: string } { + const inSettings = location.pathname === '/settings' || location.pathname.startsWith('/settings/'); + return { settingsReturnTo: inSettings ? getSettingsReturnPath(location.state) + : localReturnPath(`${location.pathname}${location.search ?? ''}${location.hash ?? ''}`) ?? AI_MODULE_SELECTION_PATH }; +} + +/** Honor the current page's existing unsaved-input protection before internal navigation. */ +export function canLeaveCurrentPage(): boolean { + return typeof window === 'undefined' || window.dispatchEvent(new Event('beforeunload', { cancelable: true })); +} diff --git a/src/pages/Chat/classroom-workspace.css b/src/pages/Chat/classroom-workspace.css index ec6e0d00..a562e52c 100644 --- a/src/pages/Chat/classroom-workspace.css +++ b/src/pages/Chat/classroom-workspace.css @@ -36,8 +36,7 @@ [data-testid="sidebar"][data-classroom="true"] .classroom-conversation-row[data-selected="true"] { background: #e0ecfa; border-color: #cedef0; color: #304f75; } [data-testid="sidebar"][data-classroom="true"] .classroom-conversation-row > button:first-child { min-height: 35px; gap: 7px; } [data-testid="sidebar"][data-classroom="true"] .classroom-conversation-row > button:first-child > svg { color: #7891af; } -[data-testid="sidebar"][data-classroom="true"] .classroom-account-footer { padding: 10px 10px 12px; border-top: 1px solid #dde5ee; background: #edf3f9; } -[data-testid="sidebar"][data-classroom="true"] [data-testid="sidebar-member-menu-trigger"] { min-height: 42px; border: 1px solid #d9e3ee; border-radius: 11px; background: #fff; font-size: 12px; } +[data-testid="sidebar"][data-classroom="true"] .classroom-account-footer { border-top: 1px solid #dde5ee; background: #edf3f9; } .classroom-workspace [data-testid="coding-conversation-header"] { border-bottom-color: #e8edf3; background: #fff; color: #304968; } .classroom-tabs { display: flex; flex-shrink: 0; height: 46px; gap: 9px; padding: 6px 22px; border-bottom: 1px solid #e8edf3; } .classroom-workspace-toolbar { display: flex; flex-wrap: wrap; flex-shrink: 0; align-items: center; gap: 6px 12px; min-height: 46px; padding: 6px 22px; border-bottom: 1px solid #e8edf3; } diff --git a/src/pages/CloudAgents/CloudKnowledge.tsx b/src/pages/CloudAgents/CloudKnowledge.tsx index f3edb6cd..945cde7e 100644 --- a/src/pages/CloudAgents/CloudKnowledge.tsx +++ b/src/pages/CloudAgents/CloudKnowledge.tsx @@ -120,10 +120,6 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted, onCatalog, ref
{documentPending && selectedKnowledge &&

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

} -
- 使用说明与费用 -

上传到知识库的文档可供获准使用此智能体的人检索。解析并建立索引后才会生效;embedding 向量化由平台承担,不计入用户用量、词元点数或智能体费用上限。知识处理如触发对话模型调用,仍按原模型实际用量计费。

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

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

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

{selectableIds === null ? '知识库选用状态待确认。' : '这个知识库当前暂不可选用。'}你仍可管理自己的文件,刷新目录后再试。 diff --git a/src/pages/CloudAgents/CloudKnowledgeSelector.tsx b/src/pages/CloudAgents/CloudKnowledgeSelector.tsx index 0c78f04a..59764810 100644 --- a/src/pages/CloudAgents/CloudKnowledgeSelector.tsx +++ b/src/pages/CloudAgents/CloudKnowledgeSelector.tsx @@ -34,7 +34,7 @@ export function CloudKnowledgeSelector({ slug, options, selected, disabled, load

-

{selectionFull ? '已选满100个知识库,请先移除一个再选择。' : '可多选,每次选择一个知识库加入配置。'}

+ {selectionFull &&

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

} {error ?

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

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

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

} {owned.error &&

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

} @@ -56,7 +56,6 @@ export function CloudKnowledgeSelector({ slug, options, selected, disabled, load ; })} :

还没有选择知识库

} -

选择后保存并应用配置,智能体即可使用这些知识。MakeLore知识只可检索,无法在这里修改。

; } diff --git a/src/pages/CloudAgents/agent-config.css b/src/pages/CloudAgents/agent-config.css index 151fa693..430b1b65 100644 --- a/src/pages/CloudAgents/agent-config.css +++ b/src/pages/CloudAgents/agent-config.css @@ -43,16 +43,13 @@ .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: start; 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-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-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-help { border-top: 1px solid #e8e6e1; padding-top: 12px; } -.agent-knowledge-help summary { min-height: 28px; width: fit-content; cursor: pointer; color: #606c7e; font-size: 12px; line-height: 1.7; } -.agent-knowledge-help summary:focus-visible { outline: 2px solid #9a7bb5; outline-offset: 3px; border-radius: 4px; } .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); } } diff --git a/src/pages/ModuleSelection/index.tsx b/src/pages/ModuleSelection/index.tsx index 9d39cd6e..1efc7209 100644 --- a/src/pages/ModuleSelection/index.tsx +++ b/src/pages/ModuleSelection/index.tsx @@ -116,7 +116,7 @@ export function ModuleSelection({ authRequired = true }: { authRequired?: boolea })}
- {authenticated && authUser &&