diff --git a/.project-docs/30-worklog/tasks/20261012-agent-chat-files-review-e87b41c6.md b/.project-docs/30-worklog/tasks/20261012-agent-chat-files-review-e87b41c6.md new file mode 100644 index 00000000..d7bfba01 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20261012-agent-chat-files-review-e87b41c6.md @@ -0,0 +1,74 @@ +# Task: Review Agent conversation file interaction + +## Identity + +- Task ID: 20261012-agent-chat-files-review-e87b41c6 +- Mode: Feature +- Branch: codex/20261012-agent-chat-files-review-e87b41c6-agent-chat-files-review +- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261012-agent-chat-files-review-e87b41c6 +- Base commit: bcb1b61564b05c92fb51d8791ce15db3ab8e8b2c +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Review and implement the approved personal Agent attachment interaction: counted entry in the top-right identity header, a right-side file panel grouped by uploaded/generated files, composer plus action, and trial Renderer update. +- Local follow-up: hide internal process files using conservative saved-assistant reply links and current-conversation file verification. The user explicitly forbids updating the temporary app for this follow-up. + +## Intent And Constraints + +- For the original header/panel change, discussion completed and the user explicitly approved implementation and updating the temporary app. The later local filtering follow-up supersedes that deployment instruction. Keep Chinese product communication and existing Makelore light visual language. +- Preserve Main-owned upload/confirmation/download, per-conversation access, attachment retry identity and chat history. Removing an attachment from the pending message only deselects it; it does not delete the cloud file. +- Limit source changes to this attachment UI, its focused tests and README. Do not change cloud contracts, shared canonical project memory or push remote branches. Stage Renderer updates in an isolated ignored runtime directory; root alone activates and verifies the live app. +- Latest instruction: implement the client-side compatibility filter locally only. No live application actions, build activation, runtime pointer/manifest changes or cloud calls. Yuxi's formal delivery mechanism is assigned to the user's colleague and is not assumed available. + +## Outcome + +- The previously vague inline “对话文件” area is now a counted “附件” entry beside the Agent identity at the top right. It is hidden when both uploaded and generated collections are successfully known to be empty. Initial failures expose a retryable entry without claiming a precise count. +- Opening the entry shows a right-side Sheet with “你上传的” and “助手生成的” groups. Uploaded files can be selected for reuse or saved; explicitly delivered generated files can be saved. Linked files in output subfolders are verified through the current Main-owned file API, without showing folder navigation. Uploaded paths are excluded from the generated group to avoid double counting. This local filtering follow-up has not been deployed to the temporary app, which still shows the original broader collection. +- The composer’s plus menu adds files/images and retains the optional reading-method flow. Pending cards, confirmation retries, cancellation and removal retain their existing semantics; browsing or closing the panel never sends a message or deletes a file. Conversation changes cancel stale list reads. Authoritative history/run updates refresh generated-file availability. +- The panel follows the existing cream, lilac and mint UI. It preserves chat/input state, uses accessible dialog/menu behavior and restores focus on close. Long Agent names are limited to two visible lines with the full name available on hover, preserving the header and composer in narrow short windows. +- Independent review identified and resolved uploaded download loss, initial-error misclassification/counting, inaccessible initial retry and long-name overflow. The existing attachment suite was migrated without dropping its 14 boundary cases. +- Concurrent and Planning Gates passed. 42 peer scopes checked read-only on same-task resumption; no concrete dependency blocks this implementation. Existing composer/history tasks supply preservation constraints. Earlier proposal details are historical context; the user’s approved header/panel design and simplification decisions govern the delivered change. +- The production Renderer was committed at `0d54690dd63de39e007f2e1f54f9aea157670f61`, staged and activated in the existing temporary app. Main PID 7459 and Host port 62864 were preserved, with all eight Main/preload/utility artifacts byte-identical to the previous confirmed stage; login remained active after native refresh. The original runtime and prior Renderer layers remain available for rollback. +- Native UI verification saw the “整理小帮手” header showing 55 files and its right-side panel with uploaded/generated groups, readable file cards, save actions and return-to-chat footer. The existing “英语陪练aron” conversation subsequently showed 3 files, including its uploaded spreadsheet and generated summary, with independent grouping and unchecked reuse selection. The user navigated during verification; subsequent observations were read-only and left their chosen conversation in place. + +### File Visibility Discussion Follow-up + +- At the discussion stage, the user questioned the 55-file collection and agreed that internal process files should be hidden. The findings below preceded the later approved local implementation; no filtering was deployed during that discussion. +- Same-task Concurrent/Planning Gates passed again: current ownership matches; 43 peers and 129 required sections read-only, including the new knowledge-picker discussion which explicitly isolates chat attachments. No concrete semantic conflict. The historical child-language peer remains undefined without a dependency. +- Code review confirms `CloudFile` only has name/path/directory/size fields (`shared/cloud-agents.ts:163`) and messages/history have no structured delivered-file relationship (`shared/cloud-agents.ts:98–107`). Current UI recursively collects all files and calls every non-uploaded path generated. That includes scripts, caches and tool-call outputs. A downloadable path or an `outputs` directory does not establish delivery intent. +- Recommendation: retain confirmed uploaded attachments; show Agent files only after explicit delivery to the user. Yuxi should validate and persist a stable delivered-file identity linked to the current conversation, run and assistant reply, plus display name and availability. The client should show/count that authoritative collection. Process files stay usable internally and are not deleted. Code files remain visible when explicitly delivered; no extension/name/directory heuristic should decide purpose. +- A conservative interim client filter could use valid file links in persisted assistant replies, normalized and matched to actual files of the same conversation. Exclude reasoning, tool/system content, code examples and plain path mentions; unknown files stay hidden. This is only a compatibility bridge and can miss valid old deliverables lacking links. Formal delivery records and any legacy backfill require Yuxi assessment and must not be claimed implemented from this discussion. +- No source, tests, cloud content or live app interaction changed in this follow-up. Existing build and runtime remain the delivered header/panel version; a docs-only handoff may update its source record without changing product payloads. + +### Local File Visibility Implementation + +- The user approved the conservative client filter and explicitly requested no temporary-app update. Same-task ownership and Planning Gates passed again; 43 peer records / 129 required sections reread with no concrete conflict. The now-approved knowledge-picker task continues on separate configuration surfaces. +- A pure Markdown AST helper extracts local links, image links and reference links only from saved assistant reply content of the current conversation. User/system messages, reasoning, code examples, raw HTML, plain path mentions, external URLs, unsafe paths and other conversation artifact links are excluded. File extensions and directory names do not classify purpose. +- The composer reads only the parent directories of these explicit links through Main and includes exact existing leaf-file matches. It no longer recursively enumerates all workspace folders. Uploads remain independent and parsed/original copies are excluded from generated output counts. Collection state is keyed by conversation and normalized linked paths, so an old collection cannot be shown or counted under new history. Equivalent history refreshes reuse the same target set; explicit refresh and authoritative revision changes still recheck availability. +- The locked transitive Markdown parser `mdast-util-from-markdown@2.0.3` is declared as an exact direct frontend dependency; the existing lock graph is unchanged and frozen lockfile-only validation passed. No package download or runtime installation is needed for this task's existing local dependencies. +- Local implementation and independent review are complete: 45 helper checks, 53 focused UI/workflow checks, and 16 isolated Chrome layout checks passed. Typecheck, production build, scoped ESLint and frozen lockfile validation passed. The existing temporary application remains bound to the previously verified Renderer build `0d54690dd63de39e007f2e1f54f9aea157670f61` and source handoff `922b09bda02299528478617ef5c47789ea39ac39`; this local implementation is not deployed there. No runtime pointer, payload, manifest, profile, cloud data or live app interaction was changed. + +## Verification + +- Local visibility follow-up: helper suite 45/45 passed; focused attachment/composer/workflow suites 53/53 passed, including the 15-case composer suite rerun after stabilizing the normalized target set. Isolated Chrome layout suite 16/16 passed, including narrow-window internal-file filtering and native saving of an explicitly delivered Python file. The prior upload/confirmation/parse/retry/deselection/thread-isolation boundaries remain covered. +- Local follow-up typecheck and production Renderer/Main/preload/utility build passed. Scoped ESLint has no errors and only the two pre-existing CloudChat warnings. Frozen lockfile-only offline validation passed without changing the resolved package graph; final whitespace and task-document drift checks passed. Validation uses fixtures and local build outputs only; the live temporary app was neither refreshed nor updated. +- Typecheck and production Renderer/Main/preload/utility build passed. Scoped ESLint passed with only the two existing CloudChat warnings (export/refresh and ref cleanup); no errors. +- `cloud-agent-attachments`, `cloud-agents-workflows` and the migrated `cloud-composer-attachments` suites: 47/47 passed. They cover empty/generated-only counts, list failure/retry, uploaded/generated downloads, recursive output paths, reuse/deselection, disabled actions, immutable confirmation/parse retries, late responses and thread isolation. +- Related start-use, guidance and page suites: 48/48 passed sequentially with one worker. An earlier concurrent run hit a single existing five-second test timeout while build/layout checks ran; the isolated run passed without changing the test timeout or product code. +- Installed Chrome layout suite: all 14 chat/panel cases passed at 375/768/1024/1440 widths; the additional long-name short-window case passed separately after fixing the test harness query routing. Coverage includes sheet bounds, Escape/focus, chat scrolling, selection return, native-save arguments and thinking/approval/stop layout. +- Before refresh the real trial was logged in on saved Agent configuration with no active unsaved form; afterward it retained login and loaded the card list. Native AX and screenshots verified the new header entry and actual generated-file panel. Existing uploaded/generated files in a second conversation verified the groups and count. No cloud upload, deletion, model request or save dialog was initiated by this task; native Escape/focus behavior was covered by the browser checks, not claimed as separately verified in the live app. +- Deployment stage: `/Users/chillishark/Makelore 麦洛/.trial-runtime/agent-chat-files-20261012-e87b41c6`. The staged updater’s atomic swap/rollback self-test, preparation, binding verification, activation and visible-UI mark passed. Runtime state is `refreshed-and-visually-verified`; the unchanged profile was not read. Documentation drift and whitespace checks passed. +- Required project context and relevant personal-cloud-agent decision read; peer review verified all 42 records were readable. One old peer scope remains undefined without any concrete dependency. + +## Follow-ups + +- Inline file previews and association of generated files with individual assistant replies remain future work requiring cloud contract assessment; they are not part of this approved header/panel change. +- The current file-list contract does not distinguish final deliverables from intermediate Agent files. The local reply-link compatibility filter is conservative: old deliverables with only plain paths may remain hidden. Yuxi's stable reply-linked delivery records are still needed for complete classification; no future fields are invented or assumed in the client. +- Local task commits and the confirmed trial Renderer are ready for later integration. No remote push, merge or worktree removal was requested or performed. + +## Promotion Candidates + +- Target: personal-cloud-agent client interaction specification and integrated current state. Proposal: replace the vague inline file browser with the approved counted header entry and right panel grouped by uploaded/generated files, while keeping adding files in the composer’s plus menu. Evidence: explicit user approval, 47 focused + 48 related unit checks and 15 browser layout scenarios. Future impact: file access stays visible without crowding the composer, and generated output subfolders no longer require navigation. Semantic conflicts: earlier proposal’s folder-browser and trial-chat layouts are superseded by the user’s later simplification decisions. Human confirmation: already supplied for this UI behavior; canonical promotion still requires a serialized integration task. +- Target: personal-cloud-agent client/API specification. Proposal: count/display only confirmed user uploads and explicitly delivered results; the now-approved local compatibility filter uses verified saved-assistant file links while Yuxi's formal reply-linked delivery records remain assigned to the colleague. Unknown/internal files stay hidden without deletion; explicitly delivered source files remain visible. Evidence: user approval, 45 helper checks, 53 focused UI/workflow checks and 16 browser scenarios. Future impact: clear attachment counts and less repeated directory reading; old plain-path deliverables may remain hidden until formal records/backfill are available. Semantic conflict: supersedes treating all non-uploaded workspace files as generated results. Human confirmation: already supplied for local compatibility behavior and withholding temporary-app deployment; Yuxi registration/backfill details are not implemented or assumed and need their own assessment. diff --git a/README.md b/README.md index b1de8905..9447b4ef 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智 - `Makelore Code|AI 编程`:管理本地项目、项目智能体、对话、文件上下文、代码变更和运行时。 会话工作台采用浅蓝项目栏、彩色通用手柄图标与卡片选中态;中间对话/作品使用圆角标签,作品预览的后退、前进、刷新与网址输入统一放在标签右侧;只在作品页显示,窄窗口自动换行。右侧智能体咨询使用奶油色标题区。沿用真实项目和会话入口、侧栏伸缩与账户菜单,样式只作用于 Code 会话工作台。可编辑的空对话在输入区上方的可用区域中央显示童趣欢迎语;「去作品广场逛逛」入口紧贴输入框上方,两者一起位于底部。老师介绍以固定宽度浮在右上角,不占用中间布局;欢迎语和底部输入区始终相对项目栏右侧的整个内容区域居中;小窗口的欢迎内容独立滚动;发送首条消息后收起欢迎区。侧栏保留「作品广场」入口,只有点击后才在系统浏览器打开公共广场,不请求、摘取或内嵌展示广场作品。打开广场保留当前项目、对话、草稿和附件,不自动发送消息;老师求助按钮保留临时应用的最新文案「帮我整体看🧠」。 -- `Makelore Agents|AI 智能体`:从模块首页进入时先打开“我的智能体”卡片列表,保留此前对话与操作恢复记录;返回首页箭头与侧栏折叠按钮同排,折叠后仍可返回;智能体模块的侧栏只通过按钮点击或键盘激活展开,鼠标经过左侧、收纳按钮或顶栏不会展开,切换模块不沿用旧悬停预览;其他模块仍保留原预览方式。“我的智能体”使用浅紫对话机器人图标,“渠道”使用浅蓝连接节点图标。macOS 原生窗口按钮留出安全空间。首页围绕自己的智能体和创建入口,显示童趣问候“Hi,今天想创造还是想聊聊?”,不再显示更多、收到的分享、活动或归档筛选。卡片恢复原有尺寸与留白,显示名称、可为空的一句话介绍及状态,支持聊天、调整、继续制作和确认删除;删除沿用云端归档机制,移出列表并停用新调用,历史和费用保留。创建先填写名称并选择平台像素头像或上传头像,下一步进入独立配置。顶部名称有编辑图标,头像可点击修改;未设置头像时使用同一张默认像素头像,从创建到配置、改名、保存和重新打开保持一致。基础页为“介绍与提示词”,一句话介绍可留空,完整提示词保留原始文本;模型作为高级设置第一项必选,未选择时不能开始使用,草稿可分步保存,不自动选模型。介绍与提示词采用浅蓝介绍卡和浅紫提示词卡;高级设置按模型、工具箱(Tool)、做事手册(Skill)、MCP 服务、子智能体和知识库纵向呈现,不再设内部分类切换;不展示 Tool/Skill 解释、资源开放规则提示、云端能力管理、费用上限或运行限制卡片,保存其他配置时保留既有运行限制,费用规则不变。顶层页签切换保留搜索、资源选择、知识表单及未保存输入。配置页移除聊天、自动任务和使用与接入导航,独立聊天仍从卡片进入,微信账号仍在侧栏渠道管理。草稿明确保存(Ctrl/⌘+S);保存只更新草稿,“应用最新修改”使新配置生效,失败重试保持原操作身份;点击“开始使用”完成必要的私有启用后直接打开聊天,不再展示二次确认页;未保存修改、缺少模型、冲突或尚未确认的请求会留在配置页处理,打开聊天不自动发送消息。独立对话沿用奶油底色和浅蓝、浅紫、薄荷色:顶部只展示返回、只读头像和名称,不展示调整、保存/应用状态、客户端/微信来源、费用/版本说明或历史/新对话图标;配置由列表卡片的“调整”进入。空对话展示当前智能体头像与名称,示例只填入草稿;用户消息使用浅紫圆角气泡,助手 Markdown、表格和代码在消息区内滚动。文件或图片从输入框内添加,上传成功后以待发送卡片呈现,可移除本次携带;移除不会删除云端文件,添加不会自动发送。确认失败沿用同一文件和对话重试,发送前需完成或取消添加;支持仅发送附件,使用明确的附件提问。对话文件按需展开查看和选择,生成文件仍可下载;最近对话自动恢复、审批、停止和未发送内容的离开保护保留。模型返回的思考独立于正文展示,运行时可展开、手动折叠,完成后折叠;无思考内容时不显示。重连事件去重并隔离当前运行与主对话,终态历史未到达时保留已收到的片段并有限补读;服务端返回的最终回答思考可在历史中回看,不进入后续提问。历史回看需要 Yuxi 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 思考投影补丁上线,当前未部署;实时思考沿用既有事件流。头像上传、Main-owned Host API、访问隔离、费用与不可变版本规则保持;介绍留空需要配套 Yuxi DraftCreate/DraftSave 的空字符串契约。 高级配置的知识库支持下拉多选,分为「MakeLore知识」和「我的知识库」,已选项可单独移除。MakeLore知识来自当前账号有权读取的 Yuxi 共享目录,只显示名称与简介;移除仅取消当前智能体选用,不删除原库。我的知识库以服务端本人管理目录为准,提供「上传自己的文件」入口,可新建库或追加到已有个人库,上传后自动解析、建立索引并持续显示真实处理状态;失败可重试且保留已有可用内容。暂不可选用的个人库仍可管理文件,已有绑定保留并提示不可用;未确认请求在当前库保留同一操作身份供重试。知识库选择只改草稿,显式保存/应用后生效;个人云端执行已自动接入所选知识库的检索,无需另外勾选 knowledge-base Skill。此交互复用现有接口,无需新增后端补丁。 diff --git a/package.json b/package.json index ad370a43..9078ab0d 100644 --- a/package.json +++ b/package.json @@ -173,6 +173,7 @@ "i18next": "^25.8.11", "jsdom": "^28.1.0", "lucide-react": "^0.563.0", + "mdast-util-from-markdown": "2.0.3", "mpg123-decoder": "^1.0.3", "opusscript": "^0.1.1", "playwright-core": "1.59.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b57e139..63d41a35 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -219,6 +219,9 @@ importers: lucide-react: specifier: ^0.563.0 version: 0.563.0(react@19.2.4) + mdast-util-from-markdown: + specifier: 2.0.3 + version: 2.0.3 mpg123-decoder: specifier: ^1.0.3 version: 1.0.3 diff --git a/src/pages/CloudAgents/CloudChat.tsx b/src/pages/CloudAgents/CloudChat.tsx index 028dc000..8a65b6d3 100644 --- a/src/pages/CloudAgents/CloudChat.tsx +++ b/src/pages/CloudAgents/CloudChat.tsx @@ -23,9 +23,9 @@ const active = (status?: string) => Boolean(status && ['queued', 'dispatching', const message = (e: unknown) => e instanceof Error ? e.message : '暂时无法完成操作'; const object = (v: unknown): Record => v && typeof v === 'object' ? v as Record : {}; -export function CloudChat({ slug, previewRevision: latestRevision, initialThread, initialThreadId, onSchedule, activeView = true, headerAction, previewReady = true, +export function CloudChat({ slug, previewRevision: latestRevision, initialThread, initialThreadId, onSchedule, activeView = true, headerAction, attachmentHeader, previewReady = true, assistantName = '助手', assistantAvatarUrl, examples = agentExamples(), hasUnsavedChanges = false, saveDisabled = false, onSaveForPreview }: { - slug: string; assistantName?: string; assistantAvatarUrl?: string | null; headerAction?: ReactNode; previewReady?: boolean; activeView?: boolean; previewRevision?: number; initialThread?: CloudThread; initialThreadId?: string; onSchedule?: (proposal: CloudScheduleProposal) => void; + slug: string; assistantName?: string; assistantAvatarUrl?: string | null; headerAction?: ReactNode; attachmentHeader?: HTMLElement | null; previewReady?: boolean; activeView?: boolean; previewRevision?: number; initialThread?: CloudThread; initialThreadId?: string; onSchedule?: (proposal: CloudScheduleProposal) => void; examples?: readonly string[]; hasUnsavedChanges?: boolean; saveDisabled?: boolean; onSaveForPreview?: () => Promise; }) { const [previewRevision, setPreviewRevision] = useState(latestRevision === undefined ? undefined : initialThread?.draft_revision ?? latestRevision); @@ -54,6 +54,7 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread const [leaving, setLeaving] = useState(false); const [attachmentIds, setAttachmentIds] = useState([]); const [attachmentBusy, setAttachmentBusy] = useState(false); + const [localAttachmentHeader, setLocalAttachmentHeader] = useState(null); const [executionEpoch, setExecutionEpoch] = useState(0); const childrenPending = useCloudPendingState(); usePendingCloudInput(busy || Boolean(intent) || Boolean(query.trim()) || attachmentIds.length > 0 || childrenPending.hasPending); @@ -301,6 +302,7 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread }; const approvalGeneration = generation.current; return
+ {attachmentHeader === undefined &&
} {mode === 'preview' &&

{previewRevision === undefined ? '和助手聊一聊' : '试一试'}

{previewRevision !== undefined &&

当前试用 · 修订 {previewRevision}

} @@ -386,6 +388,9 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread
}
{ e.preventDefault(); void send(); }}> { if (thread) return thread.thread_id; const expectedGeneration = generation.current; diff --git a/src/pages/CloudAgents/CloudComposerAttachments.tsx b/src/pages/CloudAgents/CloudComposerAttachments.tsx index aaef07ad..a736f462 100644 --- a/src/pages/CloudAgents/CloudComposerAttachments.tsx +++ b/src/pages/CloudAgents/CloudComposerAttachments.tsx @@ -1,9 +1,13 @@ -import { useEffect, useId, useRef, useState, type ReactNode } from 'react'; -import { ChevronDown, Download, FileText, Folder, Image, Plus, X } from 'lucide-react'; +import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; +import { createPortal } from 'react-dom'; +import { Download, FileText, Image, Paperclip, Plus, RefreshCw, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; +import { Sheet, SheetClose, SheetContent, SheetDescription, SheetTitle, SheetTrigger } from '@/components/ui/sheet'; import { cloudAgentsApi } from '@/lib/cloud-agents-api'; import { usePendingCloudInput } from './CloudPending'; -import type { CloudAttachment, CloudFile, CloudUpload } from '../../../shared/cloud-agents'; +import { deliveredFilePaths } from './cloud-delivered-files'; +import type { CloudAttachment, CloudFile, CloudHistory, CloudUpload } from '../../../shared/cloud-agents'; type PendingUpload = { upload: CloudUpload; @@ -14,8 +18,24 @@ type PendingUpload = { locked: boolean; }; -type AttachmentList = { threadId?: string; items: CloudAttachment[]; error: string }; -type FileList = { threadId?: string; path: string; items: CloudFile[]; error: string }; +type AttachmentList = { threadId?: string; loaded?: boolean; items: CloudAttachment[]; error: string }; +type FileList = { threadId?: string; deliveryKey: string; loaded?: boolean; items: CloudFile[]; error: string }; + +// Verify only the folders of explicitly linked reply files through Main. +// Internal folders and unrelated files never enter the attachment library. +async function conversationFiles(threadId: string, deliveredPaths: ReadonlySet, live: () => boolean) { + const directories = new Set([...deliveredPaths].map(path => path.slice(0, path.lastIndexOf('/')) || '/')); + const files = new Map(); + for (const path of directories) { + if (!live()) return []; + const result = await cloudAgentsApi.call('files', { thread_id: threadId, path }); + if (!live()) return []; + for (const file of result.files) { + if (!file.is_dir && deliveredPaths.has(file.path)) files.set(file.path, file); + } + } + return [...files.values()]; +} function message(error: unknown) { return error instanceof Error ? error.message : '文件操作失败,请重试。'; @@ -35,37 +55,47 @@ function mergeAttachments(...lists: CloudAttachment[][]) { return [...new Map(lists.flat().map(file => [file.file_id, file])).values()]; } -export function CloudComposerAttachments({ threadId, ensureThread, selected, onSelected, onBusy, disabled, children, footer }: { +export function CloudComposerAttachments({ threadId, ensureThread, selected, onSelected, onBusy, disabled, headerTarget, deliveryHistory, activeView = true, filesRevision = '', children, footer }: { threadId?: string; ensureThread: () => Promise; selected: string[]; onSelected: (ids: string[]) => void; onBusy: (busy: boolean) => void; disabled: boolean; + headerTarget?: HTMLElement | null; + deliveryHistory?: CloudHistory | null; + activeView?: boolean; + filesRevision?: string; children?: ReactNode; footer?: ReactNode; }) { const [attachmentList, setAttachmentList] = useState({ items: [], error: '' }); - const [fileList, setFileList] = useState({ path: '/', items: [], error: '' }); + const [fileList, setFileList] = useState({ deliveryKey: '', items: [], error: '' }); const [localThreadId, setLocalThreadId] = useState(); const [pending, setPending] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(''); const [pickerFailed, setPickerFailed] = useState(false); const [open, setOpen] = useState(false); - const [path, setPath] = useState('/'); const [refresh, setRefresh] = useState(0); const mounted = useRef(false); const busyRef = useRef(false); const confirmed = useRef(new Map()); const callbacks = useRef({ onBusy, onSelected, selected }); - const libraryId = useId(); const currentThreadId = threadId ?? localThreadId; + const deliveryKey = useMemo(() => [...deliveredFilePaths(deliveryHistory, currentThreadId ?? '')].sort().join('\0'), [deliveryHistory, currentThreadId]); + const deliveredPaths = useMemo(() => new Set(deliveryKey ? deliveryKey.split('\0') : []), [deliveryKey]); + const matchingFiles = fileList.threadId === currentThreadId && fileList.deliveryKey === deliveryKey; const attachments = attachmentList.threadId === currentThreadId ? attachmentList.items : []; const knownFiles = attachments; const blocked = disabled || busy; const attachmentsLoading = Boolean(currentThreadId) && attachmentList.threadId !== currentThreadId; - const filesLoading = Boolean(currentThreadId) && (fileList.threadId !== currentThreadId || fileList.path !== path); + const filesLoading = Boolean(currentThreadId) && !matchingFiles; + const attachmentsKnown = attachmentList.threadId === currentThreadId && attachmentList.loaded; + const generatedFiles = attachmentsKnown && matchingFiles ? fileList.items.filter(file => deliveredPaths.has(file.path) && !knownFiles.some(attachment => file.path === attachment.path || file.path === attachment.original_path)) : []; + const count = knownFiles.length + generatedFiles.length; + const countReady = attachmentsKnown && matchingFiles && fileList.loaded; + const libraryError = attachmentList.threadId === currentThreadId && attachmentList.error || matchingFiles && fileList.error; usePendingCloudInput(busy || Boolean(pending)); useEffect(() => { callbacks.current = { onBusy, onSelected, selected }; }, [onBusy, onSelected, selected]); @@ -84,23 +114,28 @@ export function CloudComposerAttachments({ threadId, ensureThread, selected, onS if (!currentThreadId) return; let live = true; cloudAgentsApi.call('attachments', { thread_id: currentThreadId }).then(result => { - if (live) setAttachmentList({ threadId: currentThreadId, items: mergeAttachments(result.attachments, confirmed.current.get(currentThreadId) ?? []), error: '' }); + if (live) setAttachmentList({ threadId: currentThreadId, loaded: true, items: mergeAttachments(result.attachments, confirmed.current.get(currentThreadId) ?? []), error: '' }); }).catch(error => { - if (live) setAttachmentList({ threadId: currentThreadId, items: confirmed.current.get(currentThreadId) ?? [], error: message(error) }); + if (live) setAttachmentList(previous => ({ threadId: currentThreadId, loaded: previous.threadId === currentThreadId && previous.loaded, items: mergeAttachments(previous.threadId === currentThreadId ? previous.items : [], confirmed.current.get(currentThreadId) ?? []), error: message(error) })); }); return () => { live = false; }; - }, [currentThreadId, refresh]); + }, [currentThreadId, refresh, filesRevision]); useEffect(() => { - if (!open || !currentThreadId) return; + if (!currentThreadId) return; let live = true; - cloudAgentsApi.call('files', { thread_id: currentThreadId, path }).then(result => { - if (live) setFileList({ threadId: currentThreadId, path, items: result.files, error: '' }); + conversationFiles(currentThreadId, deliveredPaths, () => live).then(items => { + if (live) setFileList({ threadId: currentThreadId, deliveryKey, loaded: true, items, error: '' }); }).catch(error => { - if (live) setFileList({ threadId: currentThreadId, path, items: [], error: message(error) }); + if (live) setFileList(previous => { + const sameCollection = previous.threadId === currentThreadId && previous.deliveryKey === deliveryKey; + return { threadId: currentThreadId, deliveryKey, loaded: sameCollection && previous.loaded, items: sameCollection ? previous.items : [], error: message(error) }; + }); }); return () => { live = false; }; - }, [open, currentThreadId, path, refresh]); + }, [currentThreadId, refresh, filesRevision, deliveredPaths, deliveryKey]); + + useEffect(() => { if (!activeView) setOpen(false); }, [activeView]); async function act(task: () => Promise) { if (disabled || busyRef.current || !mounted.current) return; @@ -143,7 +178,7 @@ export function CloudComposerAttachments({ threadId, ensureThread, selected, onS if (!result.attachments.length) throw new Error('文件暂未加入,请重试。'); const all = mergeAttachments(confirmed.current.get(upload.threadId) ?? [], result.attachments); confirmed.current.set(upload.threadId, all); - setAttachmentList(previous => ({ threadId: upload.threadId, items: mergeAttachments(previous.threadId === upload.threadId ? previous.items : [], all), error: previous.threadId === upload.threadId ? previous.error : '' })); + setAttachmentList(previous => ({ threadId: upload.threadId, loaded: previous.threadId === upload.threadId && previous.loaded, items: mergeAttachments(previous.threadId === upload.threadId ? previous.items : [], all), error: previous.threadId === upload.threadId ? previous.error : '' })); callbacks.current.onSelected([...new Set([...callbacks.current.selected, ...result.attachments.map(file => file.file_id)])]); setPending(null); setRefresh(value => value + 1); } @@ -168,7 +203,12 @@ export function CloudComposerAttachments({ threadId, ensureThread, selected, onS callbacks.current.onSelected(value ? [...new Set([...current, fileId])] : current.filter(id => id !== fileId)); } - return
+ const entry = (count > 0 || libraryError) && ; + + return { setOpen(next); if (next) setRefresh(value => value + 1); }}>
+ {headerTarget ? createPortal(entry, headerTarget) : headerTarget === undefined && entry} {selected.length > 0 &&
{selected.map(id => { const file = knownFiles.find(file => file.file_id === id); @@ -193,32 +233,49 @@ export function CloudComposerAttachments({ threadId, ensureThread, selected, onS }
} - {error &&

{error}

{pickerFailed && <> + {error && !open &&

{error}

{pickerFailed && <> }
}
- - + + + pick()}>添加文件或图片 + pick(true)}>添加并选择读取方式 + +
{footer}
- {open &&
-
已上传的文件
+ { + // A conversation switch may have removed its now-empty trigger. + if (!count && !libraryError) { event.preventDefault(); headerTarget?.closest('section')?.querySelector('[aria-label="消息"]')?.focus(); } + }}> +
本次对话的附件{count && countReady ? `${count} 个文件,随时找回来。` : '这次聊天用到的文件都在这里。'}
+
+
+
+

你上传的

{attachmentsLoading ?

正在读取文件…

: attachmentList.threadId === currentThreadId && attachmentList.error ?

{attachmentList.error}

: knownFiles.length === 0 &&

还没有上传文件。

} {knownFiles.map(file => { const Icon = isImage(file.file_name) ? Image : FileText; - return ; + return ; })} - - {currentThreadId &&
-
对话产物 · {path}{path !== '/' && }
- {filesLoading ?

正在读取产物…

: fileList.error ?

{fileList.error}

: fileList.items.length === 0 &&

还没有生成文件。

} - {!filesLoading && fileList.items.map(file =>
- {file.is_dir ? : <>{file.name}{fileSize(file.size)}} +
+ {currentThreadId &&
+

助手生成的

+ {filesLoading || attachmentsLoading ?

正在读取文件…

: !attachmentsKnown ?

附件还没读完整,请刷新再试。

: fileList.error ?

{fileList.error}

: generatedFiles.length === 0 &&

还没有生成文件。

} + {!filesLoading && generatedFiles.map(file =>
+ {isImage(file.name) ? : }{file.name}{fileSize(file.size)}
)} -
} -
} -
; +
} + {error &&

{error}

} + +
+
+ + ; } diff --git a/src/pages/CloudAgents/DraftEditor.tsx b/src/pages/CloudAgents/DraftEditor.tsx index 45524875..c8134f1a 100644 --- a/src/pages/CloudAgents/DraftEditor.tsx +++ b/src/pages/CloudAgents/DraftEditor.tsx @@ -38,6 +38,7 @@ export function DraftEditor({ initial, recent, onBack, onSaved, startingPrompt, const initialTab = entryView ?? (recent?.mode === 'preview' ? 'configuration' : initial.published_version ? 'chat' : 'configuration'); const [tab, setTab] = useState(initialTab); const [visited, setVisited] = useState>(() => new Set([initialTab])); + const [attachmentHeader, setAttachmentHeader] = useState(null); const pending = useCloudPendingState(); const formalPending = useCloudPendingState(); const alive = useRef(true); @@ -128,7 +129,8 @@ export function DraftEditor({ initial, recent, onBack, onSaved, startingPrompt, onClick={() => dirty || hasPending ? setLeaving(true) : onBack()}> {tab === 'chat' ? <> -

{saved.name}

+

{saved.name}

+
: <>
diff --git a/src/pages/CloudAgents/agent-chat.css b/src/pages/CloudAgents/agent-chat.css index ac33d490..d07a5aac 100644 --- a/src/pages/CloudAgents/agent-chat.css +++ b/src/pages/CloudAgents/agent-chat.css @@ -5,7 +5,53 @@ .agent-chat-workspace-header > button { border-radius: 13px; } .agent-chat-workspace-header { flex-wrap: nowrap; min-height: 76px; padding: 12px 24px; } .agent-chat-header-avatar { width: 44px; height: 44px; flex-shrink: 0; border: 2px solid #fff; border-radius: 15px; box-shadow: 0 2px 0 #ebe3d6; } -.agent-chat-header-name { min-width: 0; margin: 0; overflow-wrap: anywhere; color: #303747; font-size: 18px; font-weight: 650; line-height: 1.5; } +.agent-chat-header-name { display: -webkit-box; min-width: 0; max-height: 3em; margin: 0; overflow: hidden; overflow-wrap: anywhere; -webkit-box-orient: vertical; -webkit-line-clamp: 2; color: #303747; font-size: 18px; font-weight: 650; line-height: 1.5; } +.agent-chat-header-files { margin-left: auto; flex-shrink: 0; } +.agent-chat-local-files-header { display: flex; justify-content: flex-end; padding: 12px 24px 0; } +.agent-chat-local-files-header:empty { display: none; } +.agent-attachment-entry { display: flex; height: 42px; min-width: 88px; gap: 7px; border: 1px solid #dfd5ed; border-radius: 14px; padding: 0 12px; background: #f5f0fc; color: #655080; cursor: pointer; } +.agent-attachment-entry:hover, .agent-attachment-entry[data-state="open"] { border-color: #c6b3df; background: #ede4f8; } +.agent-attachment-count { display: grid; min-width: 22px; height: 22px; place-items: center; border-radius: 8px; padding: 0 5px; background: #e7daf4; font-size: 12px; font-weight: 650; } +.agent-attachment-sheet { display: flex; width: min(420px, 100vw); max-width: 420px; flex-direction: column; gap: 0; border-color: #e3dcec; border-radius: 24px 0 0 24px; padding: 24px; background: #fffdf9; color: #303747; box-shadow: -12px 0 48px #50406012; backdrop-filter: none; } +.agent-attachment-sheet-heading { display: flex; align-items: center; gap: 12px; padding-bottom: 24px; } +.agent-attachment-sheet-heading > div { min-width: 0; flex: 1; } +.agent-attachment-sheet-heading h2 { margin: 0; font-size: 19px; font-weight: 650; } +.agent-attachment-sheet-heading p { margin-top: 6px; color: #677286; font-size: 12px; line-height: 1.7; } +.agent-attachment-sheet-symbol { display: grid; width: 44px; height: 48px; flex-shrink: 0; place-items: center; border: 1px solid #e0d3ef; border-radius: 16px; background: #f0e8fa; color: #8062a3; } +.agent-attachment-sheet-heading button { flex-shrink: 0; border-radius: 12px; color: #657084; } +.agent-attachment-sheet-body { min-height: 0; flex: 1; overflow-y: auto; overscroll-behavior: contain; padding: 2px; } +.agent-attachment-group { margin-bottom: 18px; border: 1px solid #dfd8ed; border-radius: 20px; padding: 12px; background: #f7f3fc; } +.agent-attachment-group[data-kind="generated"] { border-color: #dae6db; background: #f2f7ef; } +.agent-attachment-sheet .agent-attachment-library-header { min-height: 40px; margin-bottom: 8px; color: #68547f; } +.agent-attachment-sheet .agent-attachment-library-header h3 { font-size: 14px; font-weight: 650; } +.agent-attachment-group[data-kind="generated"] .agent-attachment-library-header { color: #587263; } +.agent-attachment-sheet .agent-attachment-library-file { min-height: 70px; gap: 10px; margin-top: 8px; border: 1px solid #e7e1ef; border-radius: 14px; padding: 10px; background: #fff; } +.agent-attachment-sheet label.agent-attachment-library-file { cursor: pointer; } +.agent-attachment-sheet .agent-attachment-library-file[data-selected="true"] { border-color: #bc9edc; background: #faf7ff; } +.agent-attachment-sheet .agent-attachment-library-file input { width: 16px; height: 16px; accent-color: #8062a3; } +.agent-attachment-file-icon { display: grid; width: 34px; height: 40px; flex-shrink: 0; place-items: center; border-radius: 10px; background: #f0eaf8; color: #8062a3; } +.agent-attachment-group[data-kind="generated"] .agent-attachment-file-icon { background: #e9f1e5; color: #63806b; } +.agent-attachment-sheet .agent-attachment-card-info > span { display: block; } +.agent-attachment-sheet .agent-attachment-card-info .agent-attachment-name { white-space: normal; overflow-wrap: anywhere; line-height: 1.6; } +.agent-attachment-sheet .agent-attachment-library-file button { flex-shrink: 0; border-radius: 11px; color: #587263; } +.agent-attachment-sheet .agent-attachment-empty { margin: 10px 0 14px; font-size: 12px; line-height: 1.8; } +.agent-attachment-sheet [role="alert"] { color: #a34424; font-size: 13px; line-height: 1.8; overflow-wrap: anywhere; } +.agent-attachment-sheet [role="status"] { color: #677286; font-size: 13px; } +.agent-attachment-sheet-footer { padding-top: 16px; } +.agent-attachment-sheet-footer > button { width: 100%; min-height: 44px; border-color: #ddd3e9; border-radius: 13px; background: #fff; font-size: 13px; } +.agent-attachment-menu { z-index: 120; min-width: 210px; max-width: calc(100vw - 24px); border: 1px solid #e0d7ec; border-radius: 15px; padding: 6px; background: #fffdf9; box-shadow: 0 8px 24px #50406014; } +.agent-attachment-menu [role="menuitem"] { display: flex; align-items: center; min-height: 42px; gap: 9px; border-radius: 10px; padding: 8px 10px; color: #5f4e78; font-size: 13px; cursor: pointer; outline: none; } +.agent-attachment-menu [role="menuitem"]:focus { background: #f1eafa; } +.agent-attachment-sheet :is(button, input):focus-visible, .agent-attachment-entry:focus-visible { outline: 2px solid #9275b4; outline-offset: 3px; } +.agent-attachment-sheet button { cursor: pointer; } +.agent-attachment-sheet button:disabled { cursor: not-allowed; } +@media (max-width: 480px) { + .agent-attachment-sheet { padding: 20px 16px; border-radius: 0; } + .agent-attachment-sheet-heading { gap: 9px; } + .agent-attachment-sheet-heading h2 { font-size: 17px; } + .agent-attachment-entry { min-width: 82px; padding-inline: 9px; } + .agent-chat-local-files-header { padding-inline: 14px; } +} .agent-conversation-workspace { padding: 0; background: #fffdf9; container-type: inline-size; } .agent-conversation-sources { display: flex; flex-shrink: 0; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 16px; padding: 10px 24px; border-bottom: 1px solid #e9e5de; } .agent-conversation-source-options { display: flex; gap: 4px; padding: 4px; border: 1px solid #e5e2ee; border-radius: 15px; background: #f2eff7; } diff --git a/src/pages/CloudAgents/cloud-delivered-files.ts b/src/pages/CloudAgents/cloud-delivered-files.ts new file mode 100644 index 00000000..c36105e0 --- /dev/null +++ b/src/pages/CloudAgents/cloud-delivered-files.ts @@ -0,0 +1,81 @@ +import { fromMarkdown } from 'mdast-util-from-markdown'; +import type { CloudHistory } from '../../../shared/cloud-agents'; + +type MarkdownNode = { + type: string; + url?: string; + identifier?: string; + children?: MarkdownNode[]; +}; + +const scheme = /^[a-z][a-z\d+.-]*:/i; +const nestedPathEscape = /%(?:2e|2f|5c|00|3f|23)/i; + +function invalidPath(path: string): boolean { + return /[\\\ufffd?#]/.test(path) || [...path].some(character => { + const code = character.charCodeAt(0); + return code < 32 || code === 127; + }); +} + +function filePath(url: string, threadId: string): string | null { + if (!url || invalidPath(url)) return null; + let path = url; + if (/^sandbox:\//i.test(path)) path = path.slice('sandbox:'.length); + else if (scheme.test(path)) return null; + + try { path = decodeURIComponent(path); } + catch { return null; } + + if (!path || invalidPath(path) || scheme.test(path) + || path.startsWith('//') || nestedPathEscape.test(path)) return null; + if (!path.startsWith('/')) path = '/' + path; + + const artifact = path.match(/^\/api\/(?:chat\/thread|makelore\/threads)\/([^/]+)\/artifacts\/(.+)$/); + if (artifact) { + if (artifact[1] !== threadId) return null; + path = '/' + artifact[2]; + } else if (/^\/api\/(?:chat\/thread|makelore\/threads)(?:\/|$)/.test(path)) return null; + + const segments = path.slice(1).split('/'); + if (segments.some(segment => !segment || segment === '.' || segment === '..')) return null; + return path; +} + +function referenceId(identifier: string): string { + return identifier.trim().replace(/\s+/g, ' ').toUpperCase(); +} + +function walk(node: MarkdownNode, visit: (node: MarkdownNode) => void): void { + if (node.type === 'code' || node.type === 'inlineCode' || node.type === 'html') return; + visit(node); + node.children?.forEach(child => walk(child, visit)); +} + +/** Legacy delivery evidence comes from saved assistant replies, never workspace contents. */ +export function deliveredFilePaths(history: CloudHistory | null | undefined, threadId: string): Set { + const paths = new Set(); + if (!threadId || history?.thread_id !== threadId) return paths; + + for (const message of history.messages) { + if (message.role !== 'assistant') continue; + const tree = fromMarkdown(message.content); + const definitions = new Map(); + walk(tree, node => { + if (node.type === 'definition' && node.identifier && node.url) { + const id = referenceId(node.identifier); + if (!definitions.has(id)) definitions.set(id, node.url); + } + }); + walk(tree, node => { + const url = node.type === 'link' || node.type === 'image' ? node.url + : (node.type === 'linkReference' || node.type === 'imageReference') && node.identifier + ? definitions.get(referenceId(node.identifier)) : undefined; + if (url) { + const path = filePath(url, threadId); + if (path) paths.add(path); + } + }); + } + return paths; +} diff --git a/tests/e2e/cloud-agent-chat-layout.spec.ts b/tests/e2e/cloud-agent-chat-layout.spec.ts index a7e640d4..08973620 100644 --- a/tests/e2e/cloud-agent-chat-layout.spec.ts +++ b/tests/e2e/cloud-agent-chat-layout.spec.ts @@ -24,21 +24,31 @@ test.beforeAll(async () => { load(id) { if (id === entry) return ` import { createRoot } from 'react-dom/client'; + import { useState } from 'react'; import { CloudChat } from '/src/pages/CloudAgents/CloudChat.tsx'; + import { CloudAvatar } from '/src/pages/CloudAgents/CloudAvatar.tsx'; import avatar from '/src/assets/agent-avatars/avatar-05.png'; import '/src/styles/globals.css'; const examples = ['我想一起想一个有趣的故事。', '请帮我把今天的新发现整理一下。']; - createRoot(document.getElementById('root')).render( -
+ const name = new URLSearchParams(location.search).has('longName') ? '我的故事搭档'.repeat(16) : '我的故事搭档'; + function Harness() { + const [header, setHeader] = useState(null); + return
+
+ +

{name}

+
+
-
- ); + examples={examples} attachmentHeader={header} /> +
; + } + createRoot(document.getElementById('root')).render(); `; }, configureServer(vite) { vite.middlewares.use((req, res, next) => { - if (req.url !== '/') return next(); + if (req.url?.split('?')[0] !== '/') return next(); res.setHeader('Content-Type', 'text/html'); res.end('
'); }); @@ -57,6 +67,7 @@ const longWord = 'an-unbroken-story-notebook-'.repeat(24); const richReply = [ '## 一起把小发现变成故事', '你可以先选一个最喜欢的发现,再让角色去试一试。', + '[下载故事笔记](sandbox:/故事笔记.md)', `这个很长的名字也应该完整保留:${longWord}`, `[查看完整故事线索](https://example.invalid/story/${longWord})`, ['| 今天的线索 | 可以接着想什么 |', '| --- | --- |', @@ -92,7 +103,7 @@ type FixtureSeed = { threads: CloudThread[]; histories: Record | undefined; @@ -155,7 +174,8 @@ async function installFixture(page: Page, kind: FixtureKind) { if (!state.attachments.some(item => item.file_id === attachment.file_id)) state.attachments.push(attachment); return result({ attachments: [attachment] }); } - case 'files': return result({ files: [{ name: '故事笔记.md', is_dir: false, path: '/故事笔记.md', size: 1024 }] }); + case 'files': return result({ files: ['故事笔记.md', 'deliverable.py', 'internal.py', 'call.txt', 'cache.json', 'plain.txt', 'inline.py', 'example.py', 'user.txt', 'thought.txt'] + .map(name => ({ name, is_dir: false, path: '/' + name, directory_path: '/', size: 1024 })) }); case 'run': return result(Object.values(state.histories).find(item => item.run?.agent_run_id === args?.run_id)?.run); case 'cancelRun': return result({ status: 'cancel_requested' }); case 'resume': { @@ -175,12 +195,12 @@ async function installFixture(page: Page, kind: FixtureKind) { }); } -async function openChat(page: Page, kind: FixtureKind, width: number, height: number) { +async function openChat(page: Page, kind: FixtureKind, width: number, height: number, longName = false) { const errors: string[] = []; page.on('pageerror', error => errors.push(error.message)); await page.setViewportSize({ width, height }); await installFixture(page, kind); - await page.goto(baseUrl); + await page.goto(baseUrl + (longName ? '?longName=1' : '')); await expect(page.getByRole('region', { name: '智能体对话', exact: true })).toBeVisible(); await expect(page.getByLabel('消息', { exact: true })).toBeEnabled(); await expect(page.getByText('正在读取对话…', { exact: true })).toHaveCount(0); @@ -229,6 +249,46 @@ async function emitRunEvent(page: Page, type: 'messages' | 'end', id: string, en }, { type, id, envelope }); } +test('long Agent identity and attachment entry fit on a short narrow window', async ({ page }) => { + const errors = await openChat(page, 'history', 375, 540, true); + const entry = page.getByRole('button', { name: '附件,共 2 个文件', exact: true }); + await expect(entry).toBeVisible(); + const header = page.locator('.agent-chat-workspace-header'); + expect((await header.boundingBox())!.height).toBeLessThanOrEqual(104); + await expect(header.locator('.agent-chat-header-name')).toHaveAttribute('title', '我的故事搭档'.repeat(16)); + await expect(header.locator('.agent-attachment-entry')).toHaveCount(1); + await expectComposerBounds(page); + await entry.click(); + await expect(page.getByRole('dialog', { name: '本次对话的附件' })).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(entry).toBeFocused(); + await expectComposerBounds(page); + await screenshot(page, 'agent-chat-long-name-attachment-header'); + expect(errors).toEqual([]); +}); + +test('the file Sheet counts only uploads and files explicitly delivered in the assistant answer', async ({ page }) => { + const errors = await openChat(page, 'delivery', 375, 640); + const entry = page.getByRole('button', { name: '附件,共 2 个文件', exact: true }); + await expect(entry).toBeVisible(); + await entry.click(); + const panel = page.getByRole('dialog', { name: '本次对话的附件' }); + const generated = panel.getByRole('region', { name: '助手生成的' }); + await expect(generated.getByRole('button', { name: /^保存 / })).toHaveCount(1); + await expect(generated.getByRole('button', { name: '保存 deliverable.py', exact: true })).toBeVisible(); + for (const name of ['故事笔记.md', 'internal.py', 'call.txt', 'cache.json', 'plain.txt', 'inline.py', 'example.py', 'user.txt', 'thought.txt']) { + await expect(generated.getByText(name, { exact: true })).toHaveCount(0); + } + await generated.getByRole('button', { name: '保存 deliverable.py', exact: true }).click(); + await expect.poll(() => page.evaluate(() => (window as unknown as FixtureWindow).__cloudChatFixture.calls + .filter(call => call.path === '/api/cloud-agents/files/save').map(call => call.input))).toEqual([{ thread_id: 'story-first', path: '/deliverable.py' }]); + await page.keyboard.press('Escape'); + await expect(entry).toBeFocused(); + await expectComposerBounds(page); + expect((await operations(page)).some(item => ['submit', 'preview', 'resume', 'deleteAttachment'].includes(String(item?.operation)))).toBe(false); + expect(errors).toEqual([]); +}); + for (const width of [375, 768, 1024, 1440]) { test(`friendly empty conversation keeps the composer reachable at ${width}px`, async ({ page }) => { const errors = await openChat(page, 'empty', width, width === 375 ? 640 : 760); @@ -249,7 +309,7 @@ for (const width of [375, 768, 1024, 1440]) { }); } -for (const viewport of [{ width: 375, height: 540 }, { width: 1440, height: 900 }]) { +for (const viewport of [{ width: 375, height: 540 }, { width: 768, height: 760 }, { width: 1024, height: 760 }, { width: 1440, height: 900 }]) { test(`long Markdown history scrolls independently at ${viewport.width}×${viewport.height}`, async ({ page }) => { const errors = await openChat(page, 'history', viewport.width, viewport.height); const transcript = page.getByTestId('cloud-chat-messages'); @@ -282,17 +342,34 @@ for (const viewport of [{ width: 375, height: 540 }, { width: 1440, height: 900 await expect(page.getByLabel('历史对话', { exact: true })).toHaveCount(0); await expect(transcript.getByRole('article')).toHaveCount(16); await input.fill(''); - await page.getByRole('button', { name: '对话文件', exact: true }).click(); - await expect(page.getByRole('button', { name: '添加文件或图片', exact: true })).toBeVisible(); + const entry = page.getByRole('button', { name: /^附件,共 2 个文件$/ }); + await expect(composer.getByRole('button', { name: /附件,共/ })).toHaveCount(0); + await entry.click(); + const panel = page.getByRole('dialog', { name: '本次对话的附件' }); + await expect(panel).toBeVisible(); + const bounds = await panel.boundingBox(); + expect(bounds!.x).toBeGreaterThanOrEqual(0); + expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(viewport.width + 1); + expect(Math.abs(bounds!.x + bounds!.width - viewport.width)).toBeLessThanOrEqual(1); + expect(await panel.evaluate(element => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1); await expect(page.getByRole('button', { name: '保存 故事笔记.md', exact: true })).toBeVisible(); + await page.getByRole('button', { name: '保存 故事笔记.md', exact: true }).click(); + await expect.poll(() => page.evaluate(() => (window as unknown as FixtureWindow).__cloudChatFixture.calls + .filter(call => call.path === '/api/cloud-agents/files/save').map(call => call.input))).toEqual([{ thread_id: 'story-first', path: '/故事笔记.md' }]); const chosen = page.getByRole('checkbox', { name: '选择附件 我的故事笔记与新发现.txt', exact: true }); await chosen.check(); + await screenshot(page, `agent-chat-attachment-panel-${viewport.width}`); + await page.getByRole('button', { name: /返回聊天/ }).click(); + await expect(panel).toHaveCount(0); await expect(composer.getByRole('button', { name: '移除附件 我的故事笔记与新发现.txt', exact: true })).toBeVisible(); await composer.getByRole('button', { name: '移除附件 我的故事笔记与新发现.txt', exact: true }).click(); + await entry.click(); await expect(chosen).not.toBeChecked(); await expect(composer.locator('.agent-attachment-selected')).toHaveCount(0); expect((await operations(page)).some(item => item?.operation === 'deleteAttachment')).toBe(false); - await page.getByRole('button', { name: '对话文件', exact: true }).click(); + await page.keyboard.press('Escape'); + await expect(panel).toHaveCount(0); + await expect(entry).toBeFocused(); await expectComposerBounds(page); expect((await operations(page)).some(item => ['submit', 'preview', 'resume'].includes(String(item?.operation)))).toBe(false); expect(errors).toEqual([]); @@ -306,6 +383,7 @@ for (const width of [375, 1440]) { const send = composer.getByRole('button', { name: '发送', exact: true }); await expect(send).toBeDisabled(); await composer.getByRole('button', { name: '添加文件或图片', exact: true }).click(); + await page.getByRole('menuitem', { name: '添加文件或图片', exact: true }).click(); const remove = composer.getByRole('button', { name: '移除附件 狐狸照片.png', exact: true }); await expect(remove).toBeVisible(); await expect(composer.locator('.agent-attachment-card[data-kind="image"]')).toHaveCount(1); @@ -320,8 +398,9 @@ for (const width of [375, 1440]) { await remove.click(); await expect(composer.locator('.agent-attachment-selected')).toHaveCount(0); await expect(send).toBeDisabled(); - await composer.getByRole('button', { name: '对话文件', exact: true }).click(); - await expect(composer.getByRole('checkbox', { name: '选择附件 狐狸照片.png', exact: true })).not.toBeChecked(); + await page.getByRole('button', { name: /^附件,共 3 个文件$/ }).click(); + await expect(page.getByRole('checkbox', { name: '选择附件 狐狸照片.png', exact: true })).not.toBeChecked(); + await page.getByRole('button', { name: '关闭附件面板' }).click(); expect((await operations(page)).some(item => ['deleteAttachment', 'submit', 'preview'].includes(String(item?.operation)))).toBe(false); expect(errors).toEqual([]); }); diff --git a/tests/unit/cloud-agent-attachments.test.tsx b/tests/unit/cloud-agent-attachments.test.tsx new file mode 100644 index 00000000..16ce018a --- /dev/null +++ b/tests/unit/cloud-agent-attachments.test.tsx @@ -0,0 +1,324 @@ +import { useState, type ComponentProps } from 'react'; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { CloudComposerAttachments } from '@/pages/CloudAgents/CloudComposerAttachments'; +import type { CloudAttachment, CloudFile, CloudHistory } from '../../shared/cloud-agents'; + +const api = vi.hoisted(() => ({ call: vi.fn(), upload: vi.fn(), download: vi.fn() })); +vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api })); +const ensureThread = vi.fn(); +const onBusy = vi.fn(); + +const upload: CloudAttachment = { + file_id: 'upload-1', file_name: 'notes.txt', file_size: 1024, + path: '/notes.md', original_path: '/notes.txt', +}; +function file(path: string): CloudFile { + return { name: path.split('/').at(-1)!, path, directory_path: path.split('/').slice(0, -1).join('/') || '/', is_dir: false, size: 2048 }; +} +function folder(path: string): CloudFile { + return { name: path.split('/').at(-1)!, path, directory_path: path, is_dir: true, size: 0 }; +} +function delivered(threadId: string, ...paths: string[]): CloudHistory { + return { thread_id: threadId, next_offset: null, run: null, messages: [{ + id: 1, role: 'assistant', request_id: 'delivery-request', run_id: null, created_at: '', + content: paths.map(path => `[${path.split('/').at(-1)}](sandbox:${path})`).join('\n'), + }] }; +} +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +} + +type HarnessProps = Pick, 'threadId' | 'headerTarget' | 'filesRevision' | 'activeView' | 'deliveryHistory'> & { initialSelected?: string[] }; +function Harness({ initialSelected = [], ...props }: HarnessProps) { + const [selected, setSelected] = useState(initialSelected); + return +