From dca6deab292028c9f3cfd097a8c52ba0d43b35a9 Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Sat, 12 Sep 2026 17:39:31 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=B9=E9=80=A0=E4=BA=91=E6=99=BA?= =?UTF-8?q?=E8=83=BD=E4=BD=93=E6=A1=8C=E9=9D=A2=E7=BC=96=E8=BE=91=E4=B8=8E?= =?UTF-8?q?=E8=AF=95=E7=94=A8=E5=B7=A5=E4=BD=9C=E5=8C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...raft-design-9d82c9b4__desktop-workspace.md | 2 +- .../20260912-agent-draft-design-9d82c9b4.md | 34 ++-- README.md | 2 +- src/components/layout/MainLayout.tsx | 6 +- src/pages/CloudAgents/CloudAccess.tsx | 3 +- src/pages/CloudAgents/CloudChat.tsx | 94 ++++++---- src/pages/CloudAgents/ConfigurationFields.tsx | 31 +++- src/pages/CloudAgents/DesktopSplit.tsx | 58 ++++++ src/pages/CloudAgents/DraftEditor.tsx | 175 ++++++++++++++++++ src/pages/CloudAgents/desktop-workspace.css | 37 ++++ src/pages/CloudAgents/index.tsx | 152 +-------------- tests/e2e/cloud-agents.spec.ts | 76 +++++++- tests/unit/cloud-agents-page.test.tsx | 89 ++++++++- tests/unit/cloud-agents-workflows.test.tsx | 69 +++++++ tests/unit/main-layout-module-gate.test.tsx | 11 +- tests/unit/module-navigation.test.tsx | 6 +- 16 files changed, 623 insertions(+), 222 deletions(-) create mode 100644 src/pages/CloudAgents/DesktopSplit.tsx create mode 100644 src/pages/CloudAgents/DraftEditor.tsx create mode 100644 src/pages/CloudAgents/desktop-workspace.css diff --git a/.project-docs/10-decisions/proposals/20260912-agent-draft-design-9d82c9b4__desktop-workspace.md b/.project-docs/10-decisions/proposals/20260912-agent-draft-design-9d82c9b4__desktop-workspace.md index ac651c80..1e296a96 100644 --- a/.project-docs/10-decisions/proposals/20260912-agent-draft-design-9d82c9b4__desktop-workspace.md +++ b/.project-docs/10-decisions/proposals/20260912-agent-draft-design-9d82c9b4__desktop-workspace.md @@ -86,7 +86,7 @@ 先做全高布局、紧凑工具栏、可调分栏、固定输入框、配置分类及窗口标题上下文;随后解耦保存与试用状态、恢复编辑位置和输入、补快捷键与错误处理。自动保存和本机耐久草稿在其真实行为明确后实现,不能用视觉上的“已保存”代替。 -后续实施至少验收以下可观察结果;本次没有运行这些产品测试: +以下为产品验收目标;后续本任务已实施首版,实际完成的检查与平台边界见本人任务记录: - 1366×768、1440×900、窄窗口及系统 125%/150% 显示缩放下,长指令/长回答不会把输入框推离工作区;无非预期整页或横向滚动。 - 拖动分隔线、键盘调整、展开/还原和窗口缩放后,两栏内容、输入与滚动位置保持;偏好正确恢复。 diff --git a/.project-docs/30-worklog/tasks/20260912-agent-draft-design-9d82c9b4.md b/.project-docs/30-worklog/tasks/20260912-agent-draft-design-9d82c9b4.md index aa1dd36f..7ae234b2 100644 --- a/.project-docs/30-worklog/tasks/20260912-agent-draft-design-9d82c9b4.md +++ b/.project-docs/30-worklog/tasks/20260912-agent-draft-design-9d82c9b4.md @@ -12,32 +12,42 @@ ## Scope -- 将用户确认的桌面端云智能体编辑方向收敛为可实施的交互规格,并交付独立本地交互草图。只修改本人任务和提案,不改业务代码、主目录或 canonical memory。 +- 用户继续确认交互规格后,在同一隔离任务实施 MakeLore 桌面智能体编辑工作区:全高分栏、独立滚动、配置分类、保存与试用版本分离、快捷键、独立费用上限以及相关单元和 Electron 验证。只修改相关客户端源码、测试、README 和本人任务记录,不修改主目录或 canonical memory。 ## Intent And Constraints - 已加载 maintain-project-docs 与 make-interfaces-feel-better。bundled check/start/status 成功,feature 模式、任务、基线、分支及绝对工作树一致;Concurrent/Planning Gates Passed。主目录由另一个已完成任务持有且有原有未提交文档,本任务在基于 main 的隔离树中工作。 - 已读入口、本人记录、共享快照、个人云平台已接受决策及相关架构/领域/证据/反思/承诺内容;读取 182 个 peer 的 Scope/Intent/Promotion 并核对同模块完整记录。历史占位任务保持未知、只读,无本提案的具体语义依赖。源 AGENTS/部分 shared 文档的三个模块说明早于个人云平台已接受决定,不把它当作禁止现有第四模块的依据。 -- 用户批准的是桌面工作区设计方向。保持个人创作者、Main-owned 网络与凭据、Yuxi 执行/冻结请求及创建者付费;不创建子智能体,不调用云服务,不打包、发布、推送、部署或清理之前的工作树。 +- 用户已批准按设计实施。保持个人创作者、Main-owned 网络与凭据、Yuxi 执行/冻结请求及创建者付费;不创建子智能体,不调用真实云服务,不发布安装包、推送、部署或清理之前的工作树。首次实现采用明确保存,不加入自动保存或离线同步。 +- 续接时 bundled check/start/status 通过,原任务恢复 planning,Planning Gate Passed。沿用已读的 182 个 peer 记录并读取更新的 image-input 任务;后者涉及 Code 模型图片能力,不与本任务云智能体界面冲突。 ## Outcome -- 交互规格已保存到 .project-docs/10-decisions/proposals/20260912-agent-draft-design-9d82c9b4__desktop-workspace.md:全高工作区、紧凑导航、独立滚动和固定输入框、可调分栏、四类配置、独立预算保存、保存与试用版本分离、键盘/窗口/恢复行为及验收场景。 -- 基线源码证明 Agents 未采用 MainLayout 全高分支、DraftEditor 为居中长页面、明确保存成功后通过 revision key 重挂 CloudChat;费用上限是独立即时政策。规格据此明确保留试用内容,不把每次保存当成切换试用的动作。 -- 首次实现优先布局与状态解耦、明确保存和 Ctrl/Cmd+S;自动保存需处理保存期间新增编辑、冲突与未知结果。没有发现现成的未保存配置耐久草稿能力,不能凭端应用身份承诺离线保存。 -- 独立演示文件位于 C:\Users\7brot\.codex\visualizations\2026\09\10\01a0890f-f36d-7f43-b7a0-d3d2232e3ca3\agent-draft-desktop.html。演示有分类切换、分栏调整/展开、长回答、输入、保存与旧试用保留、错误/冲突示意和独立预算操作,所有内容/状态均明确为示例,无外部调用。 +- 已将已确认设计实现到 MakeLore 客户端。MainLayout 为 Agents 使用全高无外层滚动布局,标题改为账号级“智能体工作区”;列表和共享对话保留各自可达入口。 +- DraftEditor 提取为独立组件,工具栏承载名称与保存状态;编辑/对话/自动任务保留已访问面板,发布与访问独立入口。指令/能力/知识/限制分组保留原知识、MCP、Skill、子智能体和运行配置功能;能力可搜索和选择,已不可用资源仍可取消选择。 +- DesktopSplit 支持拖动、方向键调整、双击/Home 复位、展开/Esc 还原、F6 切焦点及仅尺寸偏好的本机记忆。剩余宽度小于 760px 时切换配置/试用面板。聊天区、配置区独立滚动,输入框固定;版本提示和附件区有高度约束。 +- 草稿保存不再以修订 key 重挂 CloudChat。未发送文字、附件、历史、进行中/未知请求保留;新试用明确选择最新保存修订并携带未发送文字。已有请求重试维持原 request_id、expected_revision 和附件;旧修订新请求禁用,旧会话可从历史重开。清空模型并保存也不会卸载已有试用。 +- Ctrl/⌘+S 明确保存,Enter 发送、Shift+Enter 换行及中文输入法保护。保存失败保留输入,冲突继续按用户选择处理;异步保存/版本恢复不会覆盖其间发生的本地内容修改。离开工作区仍保护未完成输入,采用已有 Radix Dialog 管理焦点。 +- 费用上限移至限制分类独立保存,不触发草稿保存或发布。隐藏但继续运行的对话不写最近访问指针,也不提前标记已读。 +- README 同步已实现行为。修正导航单测仍假设三个模块的旧断言,源码和已接受产品决定均已有第四个 Agents 入口。 ## Verification -- 只读核对 CloudAgents/index.tsx、ConfigurationFields.tsx、CloudChat.tsx、CloudCosts.tsx、CloudAccess.tsx、MainLayout.tsx 与 TitleBar.tsx;产品行为结论对应已观察的实际源码,不从截图推断后台状态。 -- 尝试内置浏览器打开本地 file:// 草图被 URL 安全策略拒绝,未使用其他入口或间接执行绕过;没有浏览器视觉和真实交互回读证据。设计稿不冒充已实现或已验收产品。 -- 交付检查通过:Node --check 确认草图 JavaScript 语法有效;HTML 36 个静态元素 ID 无重复、无外部资源链接;bundled check_doc_drift 及 git diff --check 通过。未运行产品 unit/build/Electron 测试,因为没有产品代码变更,所列验收场景供后续实现使用。 +- pnpm 10.33.4 frozen install、typecheck、相关源码和测试 scoped ESLint 通过(CloudChat 两条既有 warning:混合导出及 generation ref cleanup;无 error)。build:vite 成功,保留既有动态/静态混合导入和 chunk 提示,未发布安装包。 +- 全套 pnpm test 在主要改动后通过:229 个普通测试文件,2004 passed / 2 skipped;独立压力测试 1 passed。之后针对模型清空、隐藏对话已读、资源搜索和最后布局修正重跑最终 7 个相关文件,65 tests passed。 +- 真实 Windows Electron E2E 通过:创建/离开保护、保存、恢复、预算独立保存、发布、正式聊天、建议生成自动任务及默认未启用;1366×768 与 980×680 窗口长回答、输入框底部可见、无外层滚动、拖动/键盘分栏、展开/Esc、F6、保存不丢文字/结果和新旧试用版本;Electron 内容缩放 125% / 150% 下输入框仍可见。该缩放测试不是 macOS 或 OS DPI 实机认证。 +- 视觉回读发现并修复遗留 sm:p-6 外边距及小窗口/150% 下提示占高导致输入框裁切。截图来源是本分支构建的 Electron 应用,云端由现有 Host API fixture 提供;未调用真实服务或产生词元费用。 +- 首次失败 E2E 退出时受未发送内容保护阻碍,已仅停止经路径核对的本任务测试进程,并在当前 spec finally 销毁所拥有的测试窗口;后续 E2E 正常完成。未影响用户主客户端。 +- 独立 HTML 草图的原 file:// 浏览器安全拒绝未绕过。本轮通过实际源码 Electron fixture 验收,不是间接打开该草图。 + +- Task Documentation Gate:检查基线以来的已提交设计和未提交实现;bundled check_doc_drift 与 git diff --check 通过。实际截图已另存到 C:\Users\7brot\.codex\visualizations\2026\09\10\01a0890f-f36d-7f43-b7a0-d3d2232e3ca3\agent-draft-implemented.png 和 agent-draft-compact-implemented.png,未将构建/报告/截图提交到仓库。 ## Follow-ups -- 按已交付规格讨论和实施桌面编辑工作区;界面状态、未发送输入、费用独立保存与版本冻结共同验收。 -- 在允许本地页面预览的环境中视觉检查草图,尤其小窗口、长内容、分栏尺寸与键盘焦点。当前浏览器限制不等于草图完成视觉验证。 +- 交付本地功能分支,未合并主分支、推送或替换已安装客户端;按用户后续指示集成。 +- 自动保存和本机耐久草稿仍不在本轮范围,不能把当前的云端明确保存误称为离线同步。 +- 本轮 Windows 实机验证;macOS 原生组合键由 metaKey 路径支持,未进行 macOS 实机验证。 ## Promotion Candidates -- 见本人 desktop-workspace 提案末尾 Promotion Candidate。总体方向已确认;实际实现/证据与 canonical 提升留待后续,不改写 accepted ADR 或其他源任务。 +- Target:个人云智能体客户端 UX 与架构文档。Proposal:将全高桌面编辑工作区、保存与试用版本分离、独立费用政策、隐藏会话未读语义记入 canonical。Evidence:用户已确认规格,本分支源码及 unit/Electron 验收。Future impact:后续客户端迭代不得恢复保存即重建试用的行为。Semantic conflicts:旧三模块描述已早于个人云平台接受决定;本轮未修改 canonical。Human confirmation:方向已获确认,集成时核对最终实现即可。 diff --git a/README.md b/README.md index 91ca8999..217ad04d 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智能体的 AI 桌面工作台。当前版本为 `2.0.0`,源码提供四个模块入口;云智能体需配套配置 WS/Yuxi 服务。模块入口页采用统一的横向卡片视觉,工作区左上角入口点击后返回模块入口页: - `Makelore Code|AI 编程`:管理本地项目、项目智能体、对话、文件上下文、代码变更和运行时。 -- `Makelore Agents|AI 智能体`:配置个人云智能体并试聊,发布后正式对话、按账号分享或创建应用 API Key。支持流式回复、审批、排队请求、附件与产物、自动任务和活动历史;重新进入时恢复最近智能体与对话,未确认操作可按原输入重试。聊天可提出日程建议,由创建者核对能力、时间和费用后启用。知识库支持上传、索引、替换、删除及导入本人会话附件;个人 MCP 凭据、Skills、子智能体可自助管理。支持智能体和会话归档、发布版本比较及恢复为草稿。所有模型调用由创建者个人词元点数支付,可按智能体设置每次任务和每日上限,费用按时间、来源、应用分页统计;分享/API 调用者保有自己的内容空间。Main 管理云会话和本机文件,Renderer 通过 Host API 操作。配套服务接入见 [Yuxi MakeLore 说明](https://xerrors.github.io/Yuxi/advanced/makelore-agents.html)。 +- `Makelore Agents|AI 智能体`:配置个人云智能体并试聊,发布后正式对话、按账号分享或创建应用 API Key。支持流式回复、审批、排队请求、附件与产物、自动任务和活动历史;重新进入时恢复最近智能体与对话,未确认操作可按原输入重试。聊天可提出日程建议,由创建者核对能力、时间和费用后启用。知识库支持上传、索引、替换、删除及导入本人会话附件;个人 MCP 凭据、Skills、子智能体可自助管理。支持智能体和会话归档、发布版本比较及恢复为草稿。所有模型调用由创建者个人词元点数支付,可按智能体设置每次任务和每日上限,费用按时间、来源、应用分页统计;分享/API 调用者保有自己的内容空间。Main 管理云会话和本机文件,Renderer 通过 Host API 操作。配套服务接入见 [Yuxi MakeLore 说明](https://xerrors.github.io/Yuxi/advanced/makelore-agents.html)。 编辑采用全高桌面分栏,支持拖动调整与展开、独立滚动、小窗口配置/试用切换。配置分为指令、能力、知识、限制;草稿明确保存(Ctrl/⌘+S),保存与切换页面保留当前试用及未发送文字,新试用明确使用最新保存版本。费用上限在限制中独立保存。Enter 发送、Shift+Enter 换行,F6 切换面板焦点、Esc 恢复分栏。 - `Makelore Canvas|AI 绘画`:每个设计项目(Workspace)维护一份从创建起就存在的 Living Form。左侧项目栏负责新建、切换和管理 Workspace,并在桌面设计模式下以 256px 宽度常驻展开;中央沿用 AI 编程的安静对话画布、自然消息流和底部悬浮输入器,AI 整理出的制作方案作为对话内的轻量可编辑稿持续更新;桌面端右侧同为 256px 的全高历史作品栏集中展示当前项目的制作记录与生成结果。紧凑窗口通过左侧抽屉访问项目列表,历史记录保留在时间线中。参考图从本地上传后以 `@图片N` 绑定,具体用法只写在创作提示词中。 - `Makelore Robot|AI 机器`:管理机器人智能体、设备激活绑定、智能体配置与设备分配;机器人工作台的智能体位于 Robot 全局侧栏,选中后在内容区先查看绑定设备、再查看基础设置,当前智能体通过 URL 参数保持可分享选择;绑定设备时默认先选择“引导配网”或“已有激活码”。在 Windows 与 macOS 的引导路径中,Makelore 可在弹窗内扫描并连接附近开放的 `Xiaozhi-*` 配网热点,失败时仍可通过系统 Wi-Fi 手动连接;后续继续复用机器人现有热点配网页面,不修改固件,也不由 Makelore 接收 Wi-Fi 密码。 diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx index a7a8619a..b76a7c5d 100644 --- a/src/components/layout/MainLayout.tsx +++ b/src/components/layout/MainLayout.tsx @@ -28,6 +28,7 @@ export function MainLayout() { const isPaintingModule = activeModule === 'painting'; const isCanvasWorkspace = location.pathname === '/image-canvas'; const isChatWorkspace = location.pathname === '/chat'; + const isCloudAgents = activeModule === 'cloud_agents'; const handleSidebarPeekChange = useCallback((open: boolean, source: SidebarPeekSource) => { if (!sidebarCollapsed) return; @@ -77,6 +78,7 @@ export function MainLayout() { {/* Title bar: drag region on macOS, icon + controls on Windows */} diff --git a/src/pages/CloudAgents/CloudAccess.tsx b/src/pages/CloudAgents/CloudAccess.tsx index bdb6ef32..3837a420 100644 --- a/src/pages/CloudAgents/CloudAccess.tsx +++ b/src/pages/CloudAgents/CloudAccess.tsx @@ -5,7 +5,7 @@ import { Input } from '@/components/ui/input'; import { usePendingCloudInput } from './CloudPending'; import { cloudAgentsApi } from '@/lib/cloud-agents-api'; import type { CloudAccess as Access, CloudApplication, CloudKey } from '../../../shared/cloud-agents'; -import { CloudBudgetEditor, CloudCosts } from './CloudCosts'; +import { CloudCosts } from './CloudCosts'; import { CloudLifecycle } from './CloudLifecycle'; import type { CloudAgentDraft } from '../../../shared/cloud-agents'; import type { CloudAgentOperations } from '../../../shared/cloud-agents'; @@ -99,7 +99,6 @@ export function CloudAccessPanel({ slug, revision, onPublished, onChanged }: { s

相同请求标识与输入可重试。通过返回的 request_id 查询排队状态,通过 run_id 读取运行结果或订阅事件;这些请求继续使用同一应用凭据。

} - v.version) ?? []} onChanged={draft => { onChanged?.(draft); void refresh().catch(e => setError(errorText(e))); }} /> {access &&
发布历史({access.versions.length}) diff --git a/src/pages/CloudAgents/CloudChat.tsx b/src/pages/CloudAgents/CloudChat.tsx index 87a68636..5b0f1abf 100644 --- a/src/pages/CloudAgents/CloudChat.tsx +++ b/src/pages/CloudAgents/CloudChat.tsx @@ -1,10 +1,11 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import { MessageSquarePlus, Send, Square } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; +import { History, MessageSquarePlus, Send, Square } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Textarea } from '@/components/ui/textarea'; import { cloudAgentsApi } from '@/lib/cloud-agents-api'; import type { CloudHistory, CloudInterrupt, CloudPrompt, CloudRequest, CloudRun, CloudThread, CloudScheduleProposal } from '../../../shared/cloud-agents'; import { CloudFiles } from './CloudFiles'; +import './desktop-workspace.css'; import ReactMarkdown, { defaultUrlTransform } from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { CloudPendingContext, useCloudPendingState, usePendingCloudInput } from './CloudPending'; @@ -18,7 +19,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, initialThread, initialThreadId, onSchedule }: { slug: string; previewRevision?: number; initialThread?: CloudThread; initialThreadId?: string; onSchedule?: (proposal: CloudScheduleProposal) => void }) { +export function CloudChat({ slug, previewRevision: latestRevision, initialThread, initialThreadId, onSchedule, activeView = true, headerAction, previewReady = true }: { slug: string; headerAction?: ReactNode; previewReady?: boolean; activeView?: boolean; previewRevision?: number; initialThread?: CloudThread; initialThreadId?: string; onSchedule?: (proposal: CloudScheduleProposal) => void }) { + const [previewRevision, setPreviewRevision] = useState(latestRevision === undefined ? undefined : initialThread?.draft_revision ?? latestRevision); + const [showHistory, setShowHistory] = useState(false); const [threads, setThreads] = useState([]); const [thread, setThread] = useState(initialThread ?? null); const [clientId, setClientId] = useState(() => initialThread?.client_thread_id ?? crypto.randomUUID()); @@ -40,8 +43,9 @@ export function CloudChat({ slug, previewRevision, initialThread, initialThreadI usePendingCloudInput(busy || Boolean(intent) || Boolean(query.trim()) || attachmentIds.length > 0 || childrenPending.hasPending); const generation = useRef(0); const alive = useRef(true); - const bottom = useRef(null); - const entry = useRef({ initialThread, initialThreadId }); + const transcript = useRef(null); + const following = useRef(true); + const entry = useRef({ initialThread, initialThreadId, revision: previewRevision }); const mode = previewRevision === undefined ? 'published' : 'preview'; const readHistory = useCallback(async (id: string, expectedGeneration = generation.current) => { let page = await cloudAgentsApi.call('history', { thread_id: id }); @@ -55,7 +59,6 @@ export function CloudChat({ slug, previewRevision, initialThread, initialThreadI setRequest(active(page.run?.status) ? null : page.queued_requests?.[0] ?? null); if (page.run) setThreads(current => current.map(item => item.thread_id === id ? { ...item, run_id: page.run!.agent_run_id, status: !active(page.run!.status) && page.queued_requests?.length ? 'queued' : page.run!.status, unread: false } : item)); - if (page.run) void cloudAgentsApi.call('viewed', { thread_id: id, run_id: page.run.agent_run_id }).catch(() => undefined); }, []); useEffect(() => { alive.current = true; @@ -69,17 +72,15 @@ export function CloudChat({ slug, previewRevision, initialThread, initialThreadI page = { ...more, threads: [...page.threads, ...more.threads] }; } if (!alive.current || g !== generation.current) return; - const items = page.threads.filter(t => t.mode === mode && (mode !== 'preview' || t.draft_revision === previewRevision)); + const items = page.threads.filter(t => t.mode === mode); setThreads(items); - const pointer = recent?.slug === slug && recent.mode === mode - && (mode !== 'preview' || recent.draft_revision === previewRevision) ? recent : null; + const pointer = recent?.slug === slug && recent.mode === mode ? recent : null; const wanted = entry.current.initialThreadId ?? pointer?.thread_id; - let latest = entry.current.initialThread ?? (wanted ? items.find(t => t.thread_id === wanted) : pointer ? undefined : items[0]); + let latest = entry.current.initialThread ?? (wanted ? items.find(t => t.thread_id === wanted) : pointer ? undefined : items.find(t => mode !== 'preview' || t.draft_revision === entry.current.revision)); if (wanted && !latest) { let archived = await cloudAgentsApi.call('threads', { slug, archived: true }); for (;;) { - latest = archived.threads.find(t => t.thread_id === wanted && t.mode === mode - && (mode !== 'preview' || t.draft_revision === previewRevision)); + latest = archived.threads.find(t => t.thread_id === wanted && t.mode === mode); if (latest || archived.next_offset === null) break; archived = await cloudAgentsApi.call('threads', { slug, archived: true, offset: archived.next_offset }); } @@ -88,6 +89,7 @@ export function CloudChat({ slug, previewRevision, initialThread, initialThreadI } if (wanted && !latest) setError('上次对话已不可用,可选择其他对话或开始新对话'); if (latest) { + if (mode === 'preview') setPreviewRevision(latest.draft_revision ?? entry.current.revision); setThread(latest); setClientId(latest.client_thread_id ?? latest.thread_id); await readHistory(latest.thread_id, g); } @@ -96,15 +98,19 @@ export function CloudChat({ slug, previewRevision, initialThread, initialThreadI }; void open(); return () => { alive.current = false; generation.current++; }; - }, [slug, mode, previewRevision, readHistory]); + }, [slug, mode, readHistory]); useEffect(() => { - if (loading) return; + if (loading || !activeView) return; let live = true; void cloudAgentsApi.remember({ slug, mode, thread_id: thread?.thread_id, draft_revision: previewRevision }) .catch(e => { if (live) setError(message(e)); }); return () => { live = false; }; - }, [slug, mode, thread?.thread_id, previewRevision, loading]); + }, [slug, mode, thread?.thread_id, previewRevision, loading, activeView]); + + useEffect(() => { + if (activeView && thread && run) void cloudAgentsApi.call('viewed', { thread_id: thread.thread_id, run_id: run.agent_run_id }).catch(() => undefined); + }, [activeView, thread, run]); useEffect(() => { if (!request || request.run_id || !active(request.status)) return; @@ -186,19 +192,21 @@ export function CloudChat({ slug, previewRevision, initialThread, initialThreadI }, 5000); return () => { live = false; source?.close(); window.clearInterval(timer); }; }, [runId, running, thread?.thread_id, request?.thread_id, readHistory]); - useEffect(() => { bottom.current?.scrollIntoView?.({ block: 'end' }); }, [history?.messages.length, liveText, interrupt]); + useEffect(() => { if (following.current && transcript.current) transcript.current.scrollTop = transcript.current.scrollHeight; }, [history?.messages.length, liveText, interrupt]); + const stalePreview = previewRevision !== undefined && latestRevision !== undefined && previewRevision !== latestRevision; const rememberThread = (next: CloudThread) => { setThread(next); setThreads(current => [next, ...current.filter(item => item.thread_id !== next.thread_id)]); }; const send = async () => { - if (busy || running || thread?.archived || (!intent && !query.trim())) return; + if (busy || running || thread?.archived || (!intent && (stalePreview || !previewReady || !query.trim()))) return; const input = intent ?? { request_id: crypto.randomUUID(), thread_id: thread?.thread_id ?? clientId, query: query.trim(), attachment_file_ids: attachmentIds, ...(previewRevision === undefined ? {} : { expected_revision: previewRevision }), }; + following.current = true; setBusy(true); setIntent(input); setError(''); const g = generation.current; try { @@ -206,15 +214,17 @@ export function CloudChat({ slug, previewRevision, initialThread, initialThreadI if (!alive.current || g !== generation.current) return; setRequest(accepted); setIntent(null); setQuery(''); setLiveText(''); setAttachmentIds([]); rememberThread({ thread_id: accepted.thread_id, client_thread_id: clientId, agent_slug: slug, title: input.query.slice(0, 80), - mode, updated_at: new Date().toISOString(), run_id: accepted.run_id, status: accepted.status, unread: false }); + mode, draft_revision: previewRevision, updated_at: new Date().toISOString(), run_id: accepted.run_id, status: accepted.status, unread: false }); await readHistory(accepted.thread_id, g); } catch (e) { if (alive.current && g === generation.current) setError(message(e)); } finally { if (alive.current && g === generation.current) setBusy(false); } }; - const choose = async (next: CloudThread | null) => { + const choose = async (next: CloudThread | null, keepQuery = false) => { + following.current = true; + if (mode === 'preview') setPreviewRevision(next?.draft_revision ?? latestRevision); const g = ++generation.current; setThread(next); setClientId(next?.client_thread_id ?? crypto.randomUUID()); setHistory(null); - setRequest(null); setQueued([]); setRun(null); setLiveText(''); setInterrupt(null); setError(''); setQuery(''); + setRequest(null); setQueued([]); setRun(null); setLiveText(''); setInterrupt(null); setError(''); if (!keepQuery) setQuery(''); setAttachmentIds([]); if (next) { setLoading(true); try { await readHistory(next.thread_id, g); } catch(e) { setError(message(e)); } finally { setLoading(false); } } }; @@ -225,17 +235,28 @@ export function CloudChat({ slug, previewRevision, initialThread, initialThreadI else if (runId) await cloudAgentsApi.call('cancelRun', { run_id: runId }); } catch (e) { setError(message(e)); } finally { setBusy(false); } }; - return
-
+ return
+
{previewRevision === undefined ? '正式对话' : '草稿预览 · 修订 ' + previewRevision} -

{run?.version && `版本 ${run.version} · `}本次使用由智能体创建者支付词元点数

- +

由创建者支付词元点数{run?.version && ` · 版本 ${run.version}`}

+
+ {headerAction}
- {threads.length > 0 && 0 || childrenPending.hasPending} onChange={e => void choose(threads.find(t => t.thread_id === e.target.value) ?? null)}> - {threads.map(t => )} + {threads.map(t => )} } -
+ {stalePreview &&
+

配置已更新到修订 {latestRevision},当前试用仍保留修订 {previewRevision}。

+ + {(Boolean(intent) || Boolean(interrupt) || attachmentIds.length > 0 || childrenPending.hasPending) &&

请先完成本次重试或确认,并取消附件选择,再开始新试用。

} + {!previewReady &&

当前草稿尚未选择模型。选择模型并保存后,可开始新试用。

} +
} +
+
{ const el = e.currentTarget; following.current = el.scrollHeight - el.scrollTop - el.clientHeight < 64; }}>
{loading ?

正在读取对话…

: !history?.messages.length && !liveText &&
告诉它你想完成什么。
关闭客户端后,已提交的任务仍在云端继续。
} {history?.messages.map(item =>
@@ -256,8 +277,7 @@ export function CloudChat({ slug, previewRevision, initialThread, initialThreadI {interrupt && runId && !thread?.archived && { setInterrupt(null); setLiveText(''); setRun(nextRun); setRequest(null); }} />} -
-
+ {queued.length > 0 &&

还有 {queued.length} 条请求排队中,关闭客户端后仍会继续。

{queued.map((item, index) =>
@@ -278,23 +298,25 @@ export function CloudChat({ slug, previewRevision, initialThread, initialThreadI catch (e) { setError(message(e)); } finally { setBusy(false); } }}>恢复对话
} -
+
{ if (thread) return thread.thread_id; const created = await cloudAgentsApi.call('createThread', { slug, thread_id: clientId, preview: previewRevision !== undefined, expected_revision: previewRevision }); - rememberThread({ thread_id: created.thread_id, client_thread_id: clientId, agent_slug: slug, title: '新对话', mode, + rememberThread({ thread_id: created.thread_id, client_thread_id: clientId, agent_slug: slug, title: '新对话', mode, draft_revision: previewRevision, status: 'idle', updated_at: new Date().toISOString(), run_id: null, unread: false }); return created.thread_id; - }} /> + }} />
{leaving &&
放弃未发送的内容并新建对话?
} -
{ e.preventDefault(); void send(); }}> -