feat: 改造云智能体桌面编辑与试用工作区

This commit is contained in:
brother7 committed 2026-09-12 17:39:31 +08:00
1 parent 6b92591129
commit dca6deab29
16 files changed
+623 -222

No files matched your search

@@ -86,7 +86,7 @@
先做全高布局、紧凑工具栏、可调分栏、固定输入框、配置分类及窗口标题上下文;随后解耦保存与试用状态、恢复编辑位置和输入、补快捷键与错误处理。自动保存和本机耐久草稿在其真实行为明确后实现,不能用视觉上的“已保存”代替。
后续实施至少验收以下可观察结果;本次没有运行这些产品测试:
以下为产品验收目标;后续本任务已实施首版,实际完成的检查与平台边界见本人任务记录:
- 1366×768、1440×900、窄窗口及系统 125%/150% 显示缩放下,长指令/长回答不会把输入框推离工作区;无非预期整页或横向滚动。
- 拖动分隔线、键盘调整、展开/还原和窗口缩放后,两栏内容、输入与滚动位置保持;偏好正确恢复。
@@ -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:方向已获确认,集成时核对最终实现即可。
+1 -1
View File
@@ -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 密码。
+4 -2
View File
@@ -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 */}
<TitleBar
integrated
pageTitle={isCloudAgents ? '智能体工作区' : undefined}
workspaceLayout={isChatWorkspace}
overlay={isPaintingModule}
showSidebarControls={!isCanvasWorkspace}
@@ -97,8 +99,8 @@ export function MainLayout() {
data-testid="main-content"
className={cn(
'relative min-h-0 min-w-0 flex-1 overflow-auto bg-background',
isPaintingModule ? 'basis-0 h-full overflow-hidden p-0' : 'p-5 sm:p-6',
isChatWorkspace && !isPaintingModule && 'basis-0 overflow-hidden p-0',
(isPaintingModule || isCloudAgents) ? 'basis-0 h-full overflow-hidden p-0' : 'p-5 sm:p-6',
(isChatWorkspace || isCloudAgents) && !isPaintingModule && 'basis-0 overflow-hidden p-0',
)}
>
<Outlet />
+1 -2
View File
@@ -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
<p className="mt-3 text-xs leading-6 text-muted-foreground">相同请求标识与输入可重试。通过返回的 request_id 查询排队状态,通过 run_id 读取运行结果或订阅事件;这些请求继续使用同一应用凭据。</p>
</details>}
</section>
<CloudBudgetEditor slug={slug} />
<CloudCosts slug={slug} applications={access?.applications ?? []} />
<CloudLifecycle slug={slug} revision={revision} versions={access?.versions.map(v => v.version) ?? []} onChanged={draft => { onChanged?.(draft); void refresh().catch(e => setError(errorText(e))); }} />
{access && <details className="text-sm"><summary className="cursor-pointer">发布历史({access.versions.length})</summary>
+58 -36
View File
@@ -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<string, unknown> => v && typeof v === 'object' ? v as Record<string, unknown> : {};
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<CloudThread[]>([]);
const [thread, setThread] = useState<CloudThread | null>(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<HTMLDivElement>(null);
const entry = useRef({ initialThread, initialThreadId });
const transcript = useRef<HTMLDivElement>(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 <CloudPendingContext.Provider value={childrenPending.report}><section className="flex min-h-[480px] flex-col rounded-xl bg-background" aria-label={previewRevision === undefined ? '智能体对话' : '草稿预览'}>
<div className="flex min-h-12 items-center justify-between gap-3 border-b border-border pb-3">
return <CloudPendingContext.Provider value={childrenPending.report}><section className="agent-chat bg-background" aria-label={previewRevision === undefined ? '智能体对话' : '草稿预览'}>
<div className="agent-chat-toolbar"><div className="flex min-h-12 shrink-0 items-center justify-between gap-3 border-b border-border py-2">
<div className="min-w-0 text-sm">{previewRevision === undefined ? '正式对话' : '草稿预览 · 修订 ' + previewRevision}
<p className="mt-1 text-xs text-muted-foreground">{run?.version && `版本 ${run.version} · `}本次使用由智能体创建者支付词元点数</p></div>
<Button variant="ghost" disabled={busy || Boolean(intent) || loading} onClick={() => (query.trim() || attachmentIds.length > 0 || childrenPending.hasPending) ? setLeaving(true) : void choose(null)} aria-label="新对话"><MessageSquarePlus className="h-4 w-4" /></Button>
<p className="mt-1 text-xs text-muted-foreground">由创建者支付词元点数{run?.version && ` · 版本 ${run.version}`}</p></div>
<div className="flex shrink-0"><Button variant="ghost" size="icon" aria-label="查看历史对话" aria-expanded={showHistory} onClick={() => setShowHistory(v => !v)}><History className="h-4 w-4" /></Button>
<Button variant="ghost" disabled={busy || Boolean(intent) || loading} onClick={() => (query.trim() || attachmentIds.length > 0 || childrenPending.hasPending) ? setLeaving(true) : void choose(null)} aria-label="新对话"><MessageSquarePlus className="h-4 w-4" /></Button>{headerAction}</div>
</div>
{threads.length > 0 && <select aria-label="历史对话" className="mt-3 h-10 rounded-md border border-input bg-background px-3 text-sm"
{showHistory && threads.length === 0 && <p className="py-2 text-xs text-muted-foreground">还没有历史对话</p>}
{showHistory && threads.length > 0 && <select aria-label="历史对话" className="mt-3 h-10 rounded-md border border-input bg-background px-3 text-sm"
value={thread?.thread_id ?? ''} disabled={busy || Boolean(intent) || Boolean(query.trim()) || attachmentIds.length > 0 || childrenPending.hasPending} onChange={e => void choose(threads.find(t => t.thread_id === e.target.value) ?? null)}>
<option value="">新对话</option>{threads.map(t => <option key={t.thread_id} value={t.thread_id}>{t.title || '未命名对话'} · {cloudStatus(t.status)}</option>)}
<option value="">新对话</option>{threads.map(t => <option key={t.thread_id} value={t.thread_id}>{t.mode === 'preview' ? `修订 ${t.draft_revision ?? previewRevision} · ` : ''}{t.title || '未命名对话'} · {cloudStatus(t.status)}</option>)}
</select>}
<div className="my-4 max-h-[55vh] min-h-56 flex-1 space-y-5 overflow-y-auto pr-2">
{stalePreview && <div className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b border-border py-2 text-xs leading-5">
<p>配置已更新到修订 {latestRevision},当前试用仍保留修订 {previewRevision}。</p>
<Button title="保留未发送文字,已有对话可从历史中查看" size="sm" variant="outline" disabled={!previewReady || busy || loading || Boolean(intent) || Boolean(interrupt) || attachmentIds.length > 0 || childrenPending.hasPending}
onClick={() => void choose(null, true)}>用最新草稿试用</Button>
{(Boolean(intent) || Boolean(interrupt) || attachmentIds.length > 0 || childrenPending.hasPending) && <p className="mt-1 text-muted-foreground">请先完成本次重试或确认,并取消附件选择,再开始新试用。</p>}
{!previewReady && <p className="text-muted-foreground">当前草稿尚未选择模型。选择模型并保存后,可开始新试用。</p>}
</div>}
</div>
<div ref={transcript} data-testid="cloud-chat-messages" className="agent-chat-scroll"
onScroll={e => { const el = e.currentTarget; following.current = el.scrollHeight - el.scrollTop - el.clientHeight < 64; }}><div className="space-y-5 py-5 pr-2">
{loading ? <p role="status" className="text-sm text-muted-foreground">正在读取对话…</p>
: !history?.messages.length && !liveText && <div className="py-12 text-center text-sm leading-7 text-muted-foreground">告诉它你想完成什么。<br />关闭客户端后,已提交的任务仍在云端继续。</div>}
{history?.messages.map(item => <article key={item.id} className={item.role === 'user' ? 'ml-8 rounded-xl bg-muted/60 p-4' : 'p-2'}>
@@ -256,8 +277,7 @@ export function CloudChat({ slug, previewRevision, initialThread, initialThreadI
{interrupt && runId && !thread?.archived && <CloudApproval key={runId} interrupt={interrupt} runId={runId} onResumed={nextRun => {
setInterrupt(null); setLiveText(''); setRun(nextRun); setRequest(null);
}} />}
<div ref={bottom} />
</div>
{queued.length > 0 && <div className="mb-3 space-y-2 rounded-lg bg-muted/40 p-3 text-sm">
<p>还有 {queued.length} 条请求排队中,关闭客户端后仍会继续。</p>
{queued.map((item, index) => <div key={item.request_id} className="flex items-center justify-between">
@@ -278,23 +298,25 @@ export function CloudChat({ slug, previewRevision, initialThread, initialThreadI
catch (e) { setError(message(e)); } finally { setBusy(false); }
}}>恢复对话</Button>
</div>}
<CloudFiles key={clientId} threadId={thread?.thread_id} selected={attachmentIds} onSelected={setAttachmentIds}
</div></div>
<div className="agent-chat-files"><CloudFiles key={clientId} threadId={thread?.thread_id} selected={attachmentIds} onSelected={setAttachmentIds}
onBusy={setBusy} disabled={busy || running || Boolean(intent) || loading || Boolean(thread?.archived)} ensureThread={async () => {
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;
}} />
}} /></div>
{leaving && <div className="mb-3 flex flex-wrap items-center gap-2 text-sm"><span>放弃未发送的内容并新建对话?</span>
<Button variant="outline" onClick={() => { setLeaving(false); void choose(null); }}>放弃内容</Button><Button variant="ghost" onClick={() => setLeaving(false)}>继续编辑</Button></div>}
<form className="rounded-xl border border-input p-3 shadow-sm" onSubmit={e => { e.preventDefault(); void send(); }}>
<Textarea aria-label="消息" className="min-h-20 resize-none border-0 p-1 shadow-none focus-visible:ring-0" maxLength={32000}
value={query} disabled={busy || Boolean(intent) || loading || thread?.archived} placeholder="输入消息或任务目标…" onChange={e => setQuery(e.target.value)} />
<form data-testid="cloud-chat-composer" className="agent-chat-composer rounded-xl border border-input p-3 shadow-sm" onSubmit={e => { e.preventDefault(); void send(); }}>
<Textarea aria-label="消息" className="h-20 min-h-20 max-h-40 resize-y border-0 p-1 shadow-none focus-visible:ring-0" maxLength={32000}
value={query} disabled={busy || Boolean(intent) || loading || thread?.archived} placeholder="输入消息或任务目标…" onChange={e => setQuery(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing && e.keyCode !== 229) { e.preventDefault(); void send(); } }} />
<div className="mt-2 flex items-center justify-between gap-3">
<p role="status" className="text-xs text-muted-foreground">{progress || (running ? cloudStatus(run?.status ?? request?.status ?? '') : run ? cloudStatus(run.status) : '就绪')}</p>
{running ? <Button size="sm" variant="outline" disabled={busy} onClick={() => void cancel()} type="button"><Square className="mr-2 h-3 w-3" />停止</Button>
: <Button size="sm" type="submit" disabled={busy || loading || thread?.archived || (!intent && !query.trim())}><Send className="mr-2 h-3 w-3" />{busy ? '正在提交…' : intent ? '重试本次发送' : '发送'}</Button>}
: <Button size="sm" type="submit" disabled={busy || loading || thread?.archived || (!intent && (stalePreview || !previewReady || !query.trim()))}><Send className="mr-2 h-3 w-3" />{busy ? '正在提交…' : intent ? '重试本次发送' : '发送'}</Button>}
</div>
</form>
</section></CloudPendingContext.Provider>;
+23 -8
View File
@@ -4,14 +4,18 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { CloudKnowledgePanel } from './CloudKnowledge';
import { CloudResources } from './CloudResources';
import { CloudBudgetEditor } from './CloudCosts';
import type { CloudAgentConfiguration, CloudCatalog } from '../../../shared/cloud-agents';
export function ConfigurationFields({ slug, value, disabled, onChange }: {
export function ConfigurationFields({ slug, value, disabled, onChange, section = 'all' }: {
section?: 'all' | 'instructions' | 'capabilities' | 'knowledge' | 'limits';
slug: string; value: CloudAgentConfiguration; disabled: boolean; onChange: (value: CloudAgentConfiguration) => void;
}) {
const [catalog, setCatalog] = useState<CloudCatalog | null>(null);
const [error, setError] = useState('');
const [refresh, setRefresh] = useState(0);
const [search, setSearch] = useState('');
const [choosing, setChoosing] = useState<string | null>(null);
useEffect(() => {
let live = true;
cloudAgentsApi.call('catalog', {}).then(v => { if (live) { setCatalog(v); setError(''); } })
@@ -20,10 +24,10 @@ export function ConfigurationFields({ slug, value, disabled, onChange }: {
}, [refresh]);
const change = <K extends keyof CloudAgentConfiguration>(key: K, next: CloudAgentConfiguration[K]) => onChange({ ...value, [key]: next });
return <div className="space-y-6">
<div className="flex items-center justify-between"><h2 className="font-medium">能力与运行限制</h2>
<div hidden={section === 'instructions' || section === 'limits'} className="flex items-center justify-between"><h2 className="font-medium">{section === 'knowledge' ? '知识库' : '能力'}</h2>
<Button type="button" variant="ghost" disabled={disabled} onClick={() => setRefresh(v => v + 1)}>刷新目录</Button></div>
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
<label className="block space-y-2 text-sm"><span>模型</span>
<div hidden={section !== 'all' && section !== 'capabilities'} className="space-y-4"><label className="block space-y-2 text-sm"><span>模型</span>
<select aria-label="模型" className="h-10 w-full rounded-md border border-input bg-background px-3" disabled={disabled || !catalog}
value={value.model} onChange={e => change('model', e.target.value)}>
<option value="">选择可用模型</option>
@@ -33,17 +37,20 @@ export function ConfigurationFields({ slug, value, disabled, onChange }: {
{catalog?.pricing && <p className="text-xs leading-5 text-muted-foreground tabular-nums">
每 {catalog.pricing.unit_size.toLocaleString()} 个词元约 {catalog.pricing.points_per_unit} 词元点数,按实际模型调用结算。
</p>}
</div>
{(['tools', 'knowledges', 'mcps', 'skills', 'subagents'] as const).map(key => {
const labels = { tools: '工具', knowledges: '知识库', mcps: 'MCP 服务', skills: 'Skills', subagents: '子智能体' };
const options = catalog?.resources[key] ?? [];
const missing = value[key].filter(id => !options.some(option => option.key === id));
return <fieldset key={key} disabled={disabled} className="space-y-2">
return <fieldset key={key} disabled={disabled} hidden={section !== 'all' && section !== (key === 'knowledges' ? 'knowledge' : 'capabilities')} className="space-y-2">
<legend className="mb-2 text-sm font-medium">{labels[key]} <span className="font-normal text-muted-foreground">· 已选 {value[key].length}</span></legend>
{!options.length && !missing.length && <p className="text-xs text-muted-foreground">{catalog ? '当前没有可用资源' : '正在读取资源…'}</p>}
<div className="max-h-48 overflow-auto rounded-lg bg-muted/30">
{[...options, ...missing.map(id => ({ key: id, name: id + '(已不可用)', description: '取消选择后重新保存' }))].map(option =>
<Button variant="outline" size="sm" onClick={() => { setChoosing(choosing === key ? null : key); setSearch(''); }}>{choosing === key ? '完成选择' : '选择' + labels[key]}</Button>
{choosing === key && <Input aria-label={'搜索' + labels[key]} placeholder={'搜索' + labels[key]} value={search} onChange={e => setSearch(e.target.value)} />}
<div className="max-h-64 overflow-auto rounded-lg bg-muted/30">
{[...options, ...missing.map(id => ({ key: id, name: id + '(已不可用)', description: '取消选择后重新保存' }))].filter(option => choosing === key ? (option.name + ' ' + (option.description ?? '')).toLowerCase().includes(search.toLowerCase()) : value[key].includes(option.key)).map(option =>
<label key={option.key} className="flex min-h-10 cursor-pointer items-start gap-3 px-3 py-2 text-sm hover:bg-muted/50">
<input className="mt-1 h-4 w-4 accent-violet-600" type="checkbox" checked={value[key].includes(option.key)}
<input className="mt-1 h-4 w-4 accent-primary" type="checkbox" checked={value[key].includes(option.key)}
onChange={e => {
const selected = e.target.checked ? [...value[key], option.key] : value[key].filter(id => id !== option.key);
onChange({ ...value, [key]: selected, ...(key === 'skills' ? { preload_skills: value.preload_skills.filter(id => selected.includes(id)) } : {}) });
@@ -56,18 +63,25 @@ export function ConfigurationFields({ slug, value, disabled, onChange }: {
onChange={e => change('preload_skills', e.target.checked ? [...value.skills] : [])} />开始运行时预加载已选 Skills</label>}
</fieldset>;
})}
<div hidden={section !== 'all' && section !== 'capabilities'} className="space-y-4">
<p className="text-xs leading-5 text-muted-foreground">未选择的资源不会开放给此智能体。分享使用时仍按本配置限制能力。</p>
<CloudResources onChanged={() => setRefresh(v => v + 1)} />
</div>
<div hidden={section !== 'all' && section !== 'knowledge'}>
<CloudKnowledgePanel slug={slug} onDeleted={id => { change('knowledges', value.knowledges.filter(key => key !== id)); setRefresh(v => v + 1); }} onCreated={kb => {
change('knowledges', [...new Set([...value.knowledges, kb.kb_id])]);
setRefresh(v => v + 1);
}} />
</div>
<div hidden={section !== 'all' && section !== 'limits'} className="space-y-6">
<CloudBudgetEditor slug={slug} />
<h2 className="font-medium">运行限制</h2><p className="text-xs text-muted-foreground">以下设置随草稿保存;费用上限独立保存,对后续调用生效。</p>
<label className="block space-y-2 text-sm"><span>工具审批</span>
<select className="h-10 w-full rounded-md border border-input bg-background px-3" disabled={disabled}
value={value.tool_approval_mode} onChange={e => change('tool_approval_mode', e.target.value === 'always_trust' ? 'always_trust' : 'default')}>
<option value="default">按工具要求确认</option><option value="always_trust">信任已选工具,自动执行</option>
</select></label>
<div className="grid gap-3 sm:grid-cols-3">
<div className="grid gap-4">
{([{ key: 'max_execution_steps', label: '最多执行步数', min: 1, max: 300 },
{ key: 'max_output_tokens', label: '单次输出词元上限', min: 1, max: 32768 },
{ key: 'max_run_seconds', label: '单次运行时限(秒)', min: 10, max: 3600 }] as const).map(item =>
@@ -75,5 +89,6 @@ export function ConfigurationFields({ slug, value, disabled, onChange }: {
<Input type="number" min={item.min} max={item.max} value={value[item.key]} disabled={disabled}
onChange={e => change(item.key, Number(e.target.value))} /></label>)}
</div>
</div>
</div>;
}
+58
View File
@@ -0,0 +1,58 @@
import { useRef, useState, type ReactNode } from 'react';
import { Columns2, Maximize2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import './desktop-workspace.css';
const preferenceKey = 'makelore.cloud-agent-editor-ratio';
export function DesktopSplit({ configuration, preview }: { configuration: ReactNode; preview: (action: ReactNode) => ReactNode }) {
const root = useRef<HTMLDivElement>(null);
const [ratio, setRatio] = useState(() => {
try { const saved = Number(localStorage.getItem(preferenceKey)); return saved >= 25 && saved <= 75 ? saved : 46; }
catch { return 46; }
});
const [focus, setFocus] = useState<'configuration' | 'preview' | null>(null);
const [compactPanel, setCompactPanel] = useState<'configuration' | 'preview'>('configuration');
const resize = (next: number) => {
const width = (root.current?.clientWidth ?? 0) - 8;
if (width < 720) return;
const clamped = Math.max(25, 340 / width * 100, Math.min(75, 100 - 380 / width * 100, next));
setRatio(clamped);
try { localStorage.setItem(preferenceKey, String(clamped)); } catch { /* Layout remains usable without persistence. */ }
};
const panelToggle = (panel: 'configuration' | 'preview') => <Button variant="ghost" size="icon" className="h-10 w-10"
aria-label={focus === panel ? '恢复分栏' : panel === 'configuration' ? '展开配置' : '展开试用'}
onClick={() => setFocus(focus === panel ? null : panel)}>{focus === panel ? <Columns2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}</Button>;
return <div className="agent-split-container" onKeyDown={e => {
if (e.key === 'Escape') setFocus(null);
if (e.key === 'F6' && root.current) {
const panels = [...root.current.querySelectorAll<HTMLElement>('.agent-config-pane, .agent-preview-pane')].filter(panel => panel.offsetParent !== null);
const current = panels.findIndex(panel => panel.contains(document.activeElement));
const next = panels[(current + 1) % panels.length];
const target = [...(next?.querySelectorAll<HTMLElement>('textarea:not(:disabled), input:not(:disabled), button:not(:disabled)') ?? [])].find(el => el.offsetParent !== null);
if (target) { e.preventDefault(); target.focus(); }
}
}}>
<div className="agent-compact-switch" aria-label="工作区面板">
<Button variant={compactPanel === 'configuration' ? 'secondary' : 'ghost'} onClick={() => { setFocus(null); setCompactPanel('configuration'); }}>配置</Button>
<Button variant={compactPanel === 'preview' ? 'secondary' : 'ghost'} onClick={() => { setFocus(null); setCompactPanel('preview'); }}>试用</Button>
</div>
<div ref={root} className="agent-split" data-focus={focus ?? ''} data-compact-panel={compactPanel}
style={{ gridTemplateColumns: focus ? 'minmax(0, 1fr)' : `minmax(340px, ${ratio}fr) 8px minmax(380px, ${100 - ratio}fr)` }}>
<section className="agent-config-pane" aria-label="草稿配置">
<div className="agent-pane-heading"><span>配置智能体</span>{panelToggle('configuration')}</div>
{configuration}
</section>
<div role="separator" aria-label="调整配置与试用宽度" aria-orientation="vertical" tabIndex={0}
aria-valuemin={25} aria-valuemax={75} aria-valuenow={Math.round(ratio)} className="agent-divider"
onDoubleClick={() => resize(46)}
onPointerDown={e => { e.preventDefault(); e.currentTarget.focus(); e.currentTarget.setPointerCapture(e.pointerId); }}
onPointerMove={e => { if (e.currentTarget.hasPointerCapture(e.pointerId) && root.current) resize((e.clientX - root.current.getBoundingClientRect().left) / (root.current.clientWidth - 8) * 100); }}
onPointerUp={e => e.currentTarget.releasePointerCapture(e.pointerId)}
onKeyDown={e => { if (['ArrowLeft', 'ArrowRight', 'Home'].includes(e.key)) { e.preventDefault(); resize(e.key === 'Home' ? 46 : ratio + (e.key === 'ArrowLeft' ? -2 : 2)); } }} />
<section className="agent-preview-pane" aria-label="试用工作区">
{preview(panelToggle('preview'))}
</section>
</div>
</div>;
}
+175
View File
@@ -0,0 +1,175 @@
import { useEffect, useRef, useState } from 'react';
import { useBlocker } from 'react-router-dom';
import { ArrowLeft, Check, Loader2, Save } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog';
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
import { EMPTY_CLOUD_CONFIGURATION, type CloudAgentDraft, type CloudRecent, type CloudThread, type CloudScheduleProposal } from '../../../shared/cloud-agents';
import { ConfigurationFields } from './ConfigurationFields';
import { CloudChat } from './CloudChat';
import { CloudAccessPanel } from './CloudAccess';
import { CloudSchedules } from './CloudSchedules';
import { CloudPendingContext, useCloudPendingState } from './CloudPending';
import { DesktopSplit } from './DesktopSplit';
type Tab = 'configuration' | 'chat' | 'tasks' | 'access';
type Category = 'instructions' | 'capabilities' | 'knowledge' | 'limits';
const hasDraftChanges = (draft: CloudAgentDraft, saved: CloudAgentDraft) => draft.name !== saved.name || draft.purpose !== saved.purpose || draft.system_prompt !== saved.system_prompt
|| JSON.stringify(draft.configuration) !== JSON.stringify(saved.configuration);
const errorMessage = (error: unknown) => error instanceof Error ? error.message : '暂时无法完成操作';
export function DraftEditor({ initial, recent, onBack, onSaved }: {
initial: CloudAgentDraft; recent: CloudRecent | null; onBack: () => void; onSaved: (agent: CloudAgentDraft) => void;
}) {
const [saved, setSaved] = useState(initial);
const [draft, setDraft] = useState(initial);
const [previewCreated, setPreviewCreated] = useState(Boolean(initial.configuration?.model));
const [remote, setRemote] = useState<CloudAgentDraft | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [leaving, setLeaving] = useState(false);
const initialTab = recent?.mode === 'preview' ? 'configuration' : initial.published_version ? 'chat' : 'configuration';
const [tab, setTab] = useState<Tab>(initialTab);
const [visited, setVisited] = useState<Set<Tab>>(() => new Set([initialTab]));
const [category, setCategory] = useState<Category>('instructions');
const [openedThread, setOpenedThread] = useState<CloudThread>();
const [scheduleProposal, setScheduleProposal] = useState<CloudScheduleProposal>();
const pending = useCloudPendingState();
const formalPending = useCloudPendingState();
const hasPending = pending.hasPending || formalPending.hasPending;
const alive = useRef(true);
const saving = useRef(false);
const dirty = hasDraftChanges(draft, saved);
const canSave = dirty && !busy && !remote && Boolean(draft.name.trim() && draft.purpose.trim());
const navigate = (next: Tab) => { setVisited(current => new Set([...current, next])); setTab(next); };
const blocker = useBlocker(dirty || busy || hasPending);
const finishLeaving = () => { if (blocker.state === 'blocked') blocker.proceed(); else onBack(); };
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
useEffect(() => {
if (tab === 'configuration' && !saved.configuration?.model) {
void cloudAgentsApi.remember({ slug: saved.slug, mode: 'preview', draft_revision: saved.draft_revision })
.catch(e => { if (alive.current) setError(errorMessage(e)); });
}
}, [tab, saved.slug, saved.draft_revision, saved.configuration?.model]);
useEffect(() => {
const warn = (event: BeforeUnloadEvent) => { if (dirty || busy || hasPending) { event.preventDefault(); event.returnValue = ''; } };
window.addEventListener('beforeunload', warn);
return () => window.removeEventListener('beforeunload', warn);
}, [dirty, busy, hasPending]);
const save = async (exit = false) => {
if (!canSave || saving.current) return;
saving.current = true; setBusy(true); setError('');
try {
const result = await cloudAgentsApi.save(saved.slug, {
expected_revision: saved.draft_revision, name: draft.name, purpose: draft.purpose, system_prompt: draft.system_prompt,
configuration: draft.configuration ?? EMPTY_CLOUD_CONFIGURATION,
});
if (!alive.current) return;
setSaved(result); if (result.configuration?.model) setPreviewCreated(true);
setDraft(current => !hasDraftChanges(current, draft) ? result : { ...result, name: current.name, purpose: current.purpose, system_prompt: current.system_prompt, configuration: current.configuration });
setRemote(null); onSaved(result);
if (exit) finishLeaving();
} catch (failure) {
if (!alive.current) return;
setError(errorMessage(failure));
try {
const latest = await cloudAgentsApi.get(saved.slug);
if (alive.current && latest.draft_revision !== saved.draft_revision) setRemote(latest);
} catch { /* Keep the failed save and local input available for retry. */ }
} finally { saving.current = false; if (alive.current) setBusy(false); }
};
useEffect(() => {
const shortcut = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') { event.preventDefault(); void save(); }
};
window.addEventListener('keydown', shortcut);
return () => window.removeEventListener('keydown', shortcut);
});
return <CloudPendingContext.Provider value={pending.report}><section className="agent-desktop" data-testid="cloud-agent-editor">
<header className="agent-workspace-header">
<Button variant="ghost" size="icon" className="h-10 w-10 shrink-0" aria-label="我的智能体" disabled={busy}
onClick={() => dirty || hasPending ? setLeaving(true) : onBack()}><ArrowLeft className="h-4 w-4" /></Button>
<div className="min-w-0 flex-1">
<Input aria-label="名称" maxLength={100} value={draft.name} disabled={busy}
className="h-8 max-w-96 border-transparent bg-transparent px-2 font-semibold shadow-none hover:border-input focus:border-input"
onChange={event => setDraft({ ...draft, name: event.target.value })} />
<p className="px-2 text-xs text-muted-foreground">{saved.published_version ? `已发布 v${saved.published_version}` : '私人草稿'} · 修订 {saved.draft_revision}</p>
</div>
<span role="status" className="shrink-0 text-xs text-muted-foreground">{busy ? '正在保存…' : dirty ? '有未保存修改' : '云端已保存'}</span>
<Button size="sm" variant={dirty ? 'default' : 'outline'} disabled={!canSave} onClick={() => void save()} title="保存草稿(Ctrl / ⌘ S)">
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : dirty ? <Save className="mr-2 h-4 w-4" /> : <Check className="mr-2 h-4 w-4" />}
{busy ? '保存中…' : dirty ? '保存草稿' : '已保存'}
</Button>
</header>
<nav aria-label="智能体工作区" className="agent-workspace-nav">
{([['configuration', '编辑'], ['chat', '对话'], ['tasks', '自动任务']] as const).map(([key, label]) =>
<Button key={key} variant={tab === key ? 'secondary' : 'ghost'} aria-current={tab === key ? 'page' : undefined} onClick={() => navigate(key)}>{label}</Button>)}
<Button className="ml-auto" variant={tab === 'access' ? 'secondary' : 'ghost'} aria-current={tab === 'access' ? 'page' : undefined} onClick={() => navigate('access')}>发布与访问</Button>
</nav>
{error && <p role="alert" className="shrink-0 px-6 py-2 text-sm text-destructive">{error}</p>}
{remote && <div className="max-h-52 shrink-0 space-y-2 overflow-auto border-b border-amber-200 bg-amber-50 px-6 py-3 text-sm">
<p className="font-medium">云端已有修订 {remote.draft_revision},本地输入仍保留</p>
<details><summary className="cursor-pointer">查看最新草稿</summary><p>{remote.name} · {remote.purpose}</p><pre className="whitespace-pre-wrap font-sans">{remote.system_prompt}</pre></details>
<div className="flex flex-wrap gap-2"><Button variant="outline" onClick={() => { setSaved(remote); if (remote.configuration?.model) setPreviewCreated(true); setDraft(remote); setRemote(null); setError(''); }}>使用最新草稿</Button>
<Button variant="outline" onClick={() => { setSaved(remote); if (remote.configuration?.model) setPreviewCreated(true); setRemote(null); setError(''); }}>保留我的内容,继续编辑</Button></div>
</div>}
<div hidden={tab !== 'configuration'} className="agent-workspace-view">
{visited.has('configuration') && <DesktopSplit configuration={<>
<nav aria-label="配置分类" className="agent-config-tabs">{([['instructions', '指令'], ['capabilities', '能力'], ['knowledge', '知识'], ['limits', '限制']] as const).map(([key, label]) =>
<Button key={key} variant={category === key ? 'secondary' : 'ghost'} aria-current={category === key ? 'page' : undefined} onClick={() => setCategory(key)}>{label}</Button>)}</nav>
<div className="agent-config-scroll"><fieldset disabled={busy} className="min-w-0">
<div hidden={category !== 'instructions'} className="space-y-6">
<label className="block space-y-2 text-sm"><span className="font-medium">用途</span><Textarea className="min-h-20 resize-y" maxLength={2000} value={draft.purpose}
onChange={event => setDraft({ ...draft, purpose: event.target.value })} placeholder="一句话介绍,它可以帮你做什么。" /></label>
<label className="block space-y-2 text-sm"><span className="font-medium">角色与指令</span><Textarea className="min-h-[min(400px,45vh)] resize-y leading-7" maxLength={32000}
value={draft.system_prompt} onChange={event => setDraft({ ...draft, system_prompt: event.target.value })} placeholder="描述它的工作方式、回答风格,以及需要遵守的要求。" /></label>
<p className="text-xs leading-6 text-muted-foreground">先描述你希望它怎样工作,再通过右侧试用调整。草稿只有你可见。</p>
</div>
<ConfigurationFields slug={saved.slug} section={category} value={draft.configuration ?? EMPTY_CLOUD_CONFIGURATION} disabled={busy}
onChange={configuration => setDraft({ ...draft, configuration })} />
</fieldset></div>
</>} preview={expandAction => <>
{dirty && <p role="status" className="shrink-0 rounded-md bg-muted/50 px-3 py-2 text-xs leading-5 text-muted-foreground">配置有未保存修改。保存不会清空当前试用。</p>}
{!saved.configuration?.model && !previewCreated ? <div className="my-auto p-6 text-sm leading-7">{expandAction}<h2 className="font-medium">试试它是否懂你</h2>
<p className="mt-2 text-muted-foreground">选择模型并保存后,在这里开始试用。</p><Button variant="outline" className="mt-4" onClick={() => setCategory('capabilities')}>选择模型</Button>
<p className="mt-4 text-xs text-muted-foreground">保存免费;发送消息按模型实际用量扣除你的词元点数。</p></div>
: <CloudChat slug={saved.slug} previewRevision={saved.draft_revision} activeView={tab === 'configuration'} headerAction={expandAction} previewReady={Boolean(saved.configuration?.model)} />}
</>} />}
</div>
<div hidden={tab !== 'chat'} className="agent-workspace-view p-6">
{visited.has('chat') && (saved.published_version ? <CloudPendingContext.Provider value={formalPending.report}>
<CloudChat key={openedThread?.thread_id ?? 'published'} slug={saved.slug} initialThread={openedThread} activeView={tab === 'chat'}
onSchedule={proposal => { setScheduleProposal(proposal); navigate('tasks'); }} /></CloudPendingContext.Provider>
: <p className="m-auto text-sm text-muted-foreground">先在编辑中试用,再发布智能体,即可开始正式对话。</p>)}
</div>
<div hidden={tab !== 'tasks'} className="agent-management-view">
{visited.has('tasks') && <CloudSchedules slug={saved.slug} proposal={scheduleProposal} onProposalClosed={() => setScheduleProposal(undefined)} onOpen={thread => {
if (formalPending.hasPending) { setError('正式对话还有未完成的输入,请先处理后再打开任务对话。'); navigate('chat'); return; }
setOpenedThread(thread); navigate('chat');
}} />}
</div>
<div hidden={tab !== 'access'} className="agent-management-view">
{dirty && <p className="mb-4 text-sm text-muted-foreground">先保存草稿,再发布或调整访问。编辑中的内容仍保留。</p>}
<fieldset disabled={dirty || busy || remote !== null}>
{visited.has('access') && <CloudAccessPanel slug={saved.slug} revision={saved.draft_revision} onChanged={result => { setSaved(result); if (result.configuration?.model) setPreviewCreated(true); setDraft(current => !hasDraftChanges(current, saved) ? result : { ...current, draft_revision: result.draft_revision, published_version: result.published_version }); onSaved(result); }} onPublished={() => {
void cloudAgentsApi.get(saved.slug).then(result => { if (alive.current) {
const publication = { published_version: result.published_version, enabled: result.enabled };
setSaved(current => ({ ...current, ...publication })); setDraft(current => ({ ...current, ...publication })); onSaved({ ...saved, ...publication });
} }).catch(e => { if (alive.current) setError(errorMessage(e)); });
}} />}
</fieldset>
</div>
<Dialog open={leaving || blocker.state === 'blocked'} onOpenChange={open => { if (!open && !busy) { setLeaving(false); if (blocker.state === 'blocked') blocker.reset(); } }}>
<DialogContent><DialogTitle>未保存的修改</DialogTitle>
<DialogDescription>{hasPending ? '还有未发送的内容或未确认的操作。离开会丢弃当前输入;已被云端接受的任务仍会继续,可在活动中查看。' : '保存后离开,或放弃这次修改。'}</DialogDescription>
<div className="flex flex-wrap gap-2">{!hasPending && <Button disabled={!canSave} onClick={() => void save(true)}>保存并离开</Button>}
<Button variant="outline" disabled={busy} onClick={finishLeaving}>放弃修改</Button>
<Button variant="ghost" onClick={() => { setLeaving(false); if (blocker.state === 'blocked') blocker.reset(); }}>继续编辑</Button></div>
</DialogContent>
</Dialog>
</section></CloudPendingContext.Provider>;
}
@@ -0,0 +1,37 @@
.agent-desktop { display: flex; flex: 1; flex-direction: column; height: 100%; min-height: 0; min-width: 0; overflow: hidden; }
.agent-desktop > aside { max-height: 25%; min-height: 0; overflow: auto; }
.agent-desktop [hidden] { display: none !important; }
.agent-workspace-header { display: flex; flex-shrink: 0; align-items: center; gap: 12px; min-height: 64px; padding: 8px 20px; border-bottom: 1px solid hsl(var(--border)); }
.agent-workspace-nav { display: flex; flex-shrink: 0; align-items: center; gap: 4px; padding: 6px 20px; border-bottom: 1px solid hsl(var(--border)); }
.agent-workspace-view { display: flex; flex: 1; flex-direction: column; min-height: 0; min-width: 0; overflow: hidden; }
.agent-management-view { flex: 1; min-height: 0; overflow: auto; padding: 24px; }
.agent-management-view > * { max-width: 1000px; margin-inline: auto; }
.agent-split-container { display: flex; flex: 1; flex-direction: column; container-type: inline-size; min-height: 0; overflow: hidden; }
.agent-split { display: grid; flex: 1; min-height: 0; overflow: hidden; }
.agent-config-pane, .agent-preview-pane { display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; }
.agent-pane-heading { display: flex; flex-shrink: 0; align-items: center; justify-content: space-between; height: 44px; padding: 0 16px 0 24px; font-size: 13px; font-weight: 500; }
.agent-config-tabs { display: flex; flex-shrink: 0; gap: 4px; padding: 0 20px 10px; border-bottom: 1px solid hsl(var(--border)); }
.agent-config-scroll { flex: 1; min-height: 0; overflow: auto; overscroll-behavior: contain; padding: 24px; }
.agent-preview-pane { padding: 8px 20px 16px 12px; }
.agent-preview-pane > .agent-pane-heading { padding-inline: 0; }
.agent-divider { touch-action: none; cursor: col-resize; background: hsl(var(--border) / .45); border-inline: 3px solid hsl(var(--background)); }
.agent-divider:hover, .agent-divider:focus-visible { background: hsl(var(--primary) / .6); outline: none; }
.agent-split[data-focus="configuration"] > .agent-preview-pane,
.agent-split[data-focus="preview"] > .agent-config-pane,
.agent-split:not([data-focus=""]) > .agent-divider { display: none; }
.agent-compact-switch { display: none; }
.agent-chat { display: flex; flex: 1; flex-direction: column; min-height: 0; min-width: 0; overflow: hidden; color: hsl(var(--foreground)); }
.agent-chat-scroll { flex: 1; min-height: 0; overflow: auto; overscroll-behavior: contain; scrollbar-gutter: stable; }
.agent-chat-toolbar { flex: 0 1 auto; min-height: 0; max-height: 35%; overflow: auto; overscroll-behavior: contain; }
.agent-chat-files { flex: 0 1 auto; min-height: 0; max-height: 28%; overflow: auto; overscroll-behavior: contain; }
.agent-chat-files details { margin: 8px 0; padding: 0 8px; border: 0; }
.agent-chat-composer { flex-shrink: 0; }
.agent-chat-composer textarea { height: clamp(48px, 12vh, 80px); min-height: 48px; max-height: min(160px, 25vh); }
@container (max-width: 759px) {
.agent-compact-switch { display: flex; flex-shrink: 0; gap: 4px; padding: 8px 20px; }
.agent-split { grid-template-columns: minmax(0, 1fr) !important; }
.agent-divider { display: none; }
.agent-split[data-focus=""][data-compact-panel="configuration"] > .agent-preview-pane,
.agent-split[data-focus=""][data-compact-panel="preview"] > .agent-config-pane { display: none; }
.agent-preview-pane { padding-inline: 24px; }
}
+9 -143
View File
@@ -1,17 +1,15 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useBlocker, useLocation } from 'react-router-dom';
import { ArrowLeft, Check, Loader2, Plus, Save, Sparkles } from 'lucide-react';
import { ArrowLeft, Plus, Sparkles } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
import { useAuthStore } from '@/stores/auth';
import { EMPTY_CLOUD_CONFIGURATION, type CloudAgentDraft, type CreateCloudAgent, type CloudAgentEntry, type CloudThread, type CloudRecent, type CloudPendingOperation } from '../../../shared/cloud-agents';
import { ConfigurationFields } from './ConfigurationFields';
import { type CloudAgentDraft, type CreateCloudAgent, type CloudAgentEntry, type CloudThread, type CloudRecent, type CloudPendingOperation } from '../../../shared/cloud-agents';
import { DraftEditor } from './DraftEditor';
import './desktop-workspace.css';
import { CloudChat } from './CloudChat';
import { CloudAccessPanel } from './CloudAccess';
import { CloudSchedules } from './CloudSchedules';
import type { CloudScheduleProposal } from '../../../shared/cloud-agents';
import { CloudOverview } from './CloudOverview';
import { CloudPendingContext, useCloudPendingState } from './CloudPending';
import { CloudRecovery } from './CloudRecovery';
@@ -105,14 +103,14 @@ function AgentWorkspace() {
setCreating(false);
};
if (selected) return <>{recovery}<DraftEditor key={selected.slug} initial={selected} recent={recent} onBack={() => {
if (selected) return <div className="agent-desktop">{recovery}<DraftEditor key={selected.slug} initial={selected} recent={recent} onBack={() => {
setSelected(null);
clearRecent();
}} onSaved={accepted} /></>;
if (shared || activity) return <>{recovery}<SharedConversation shared={shared} activity={activity} recent={recent} onBack={() => { setShared(null); setActivity(null); clearRecent(); }} /></>;
}} onSaved={accepted} /></div>;
if (shared || activity) return <div className="agent-desktop">{recovery}<SharedConversation shared={shared} activity={activity} recent={recent} onBack={() => { setShared(null); setActivity(null); clearRecent(); }} /></div>;
return (
<section className="mx-auto max-w-5xl pb-10" data-testid="cloud-agents-page">
<section className="h-full overflow-auto p-6 pb-10" data-testid="cloud-agents-page">
{recovery}
<header className="mb-8 flex flex-wrap items-end justify-between gap-4 border-b border-border pb-6">
<div>
@@ -223,7 +221,7 @@ function SharedConversation({ shared, activity, recent, onBack }: {
window.addEventListener('beforeunload', warn);
return () => window.removeEventListener('beforeunload', warn);
}, [pending.hasPending]);
return <CloudPendingContext.Provider value={pending.report}><section className="mx-auto max-w-4xl pb-10">
return <CloudPendingContext.Provider value={pending.report}><section className="agent-desktop p-6">
<Button variant="ghost" className="mb-5" onClick={() => pending.hasPending ? setLeaving(true) : onBack()}><ArrowLeft className="mr-2 h-4 w-4" />返回智能体</Button>
<h1 className="mb-5 text-xl font-semibold">{shared?.name ?? activity?.title}</h1>
<CloudChat key={shared?.slug ?? activity?.thread_id} slug={shared?.slug ?? activity!.agent_slug} initialThread={activity ?? undefined}
@@ -240,138 +238,6 @@ function SharedConversation({ shared, activity, recent, onBack }: {
}
function DraftEditor({ initial, recent, onBack, onSaved }: {
initial: CloudAgentDraft; recent: CloudRecent | null; onBack: () => void; onSaved: (agent: CloudAgentDraft) => void;
}) {
const [saved, setSaved] = useState(initial);
const [draft, setDraft] = useState(initial);
const [remote, setRemote] = useState<CloudAgentDraft | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [leaving, setLeaving] = useState(false);
type Tab = 'chat' | 'tasks' | 'configuration' | 'access';
const [tab, setTab] = useState<Tab>(recent?.mode === 'preview' ? 'configuration' : initial.published_version ? 'chat' : 'configuration');
const [nextTab, setNextTab] = useState<Tab | null>(null);
const [openedThread, setOpenedThread] = useState<CloudThread | undefined>(undefined);
const [scheduleProposal, setScheduleProposal] = useState<CloudScheduleProposal | undefined>();
const pending = useCloudPendingState();
const alive = useRef(true);
const dirty = draft.name !== saved.name || draft.purpose !== saved.purpose || draft.system_prompt !== saved.system_prompt
|| JSON.stringify(draft.configuration) !== JSON.stringify(saved.configuration);
useEffect(() => {
if (!saved.configuration?.model && !saved.published_version) {
void cloudAgentsApi.remember({ slug: saved.slug, mode: 'preview', draft_revision: saved.draft_revision })
.catch(e => { if (alive.current) setError(errorMessage(e)); });
}
}, [saved.slug, saved.draft_revision, saved.configuration?.model, saved.published_version]);
const blocker = useBlocker(dirty || busy || pending.hasPending);
const finishLeaving = () => {
if (blocker.state === 'blocked') blocker.proceed();
else if (nextTab) { setOpenedThread(undefined); setTab(nextTab); setNextTab(null); setLeaving(false); setDraft(saved); }
else onBack();
};
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
useEffect(() => {
const warn = (event: BeforeUnloadEvent) => { if (dirty || pending.hasPending) { event.preventDefault(); event.returnValue = ''; } };
window.addEventListener('beforeunload', warn);
return () => window.removeEventListener('beforeunload', warn);
}, [dirty, pending.hasPending]);
const save = async (exit = false) => {
if (busy || pending.hasPending) return;
setBusy(true);
setError('');
try {
const result = await cloudAgentsApi.save(saved.slug, {
expected_revision: saved.draft_revision, name: draft.name, purpose: draft.purpose, system_prompt: draft.system_prompt,
configuration: draft.configuration ?? EMPTY_CLOUD_CONFIGURATION,
});
if (!alive.current) return;
setSaved(result); setDraft(result); setRemote(null); onSaved(result);
if (exit) {
if (nextTab && blocker.state !== 'blocked') { setTab(nextTab); setNextTab(null); setLeaving(false); }
else finishLeaving();
}
} catch (failure) {
if (!alive.current) return;
setError(errorMessage(failure));
// A readback also resolves an uncertain save without silently replacing local edits.
try {
const latest = await cloudAgentsApi.get(saved.slug);
if (alive.current && latest.draft_revision !== saved.draft_revision) setRemote(latest);
} catch { /* Keep the original save failure and the user's input. */ }
} finally { if (alive.current) setBusy(false); }
};
return <CloudPendingContext.Provider value={pending.report}><section className="mx-auto max-w-6xl pb-10" data-testid="cloud-agent-editor">
<Button variant="ghost" className="mb-5 -ml-3" disabled={busy} onClick={() => dirty || pending.hasPending ? setLeaving(true) : onBack()}>
<ArrowLeft className="mr-2 h-4 w-4" />我的智能体
</Button>
<header className="mb-6 flex items-center justify-between gap-4 border-b border-border pb-5">
<div><p className="mb-1 text-xs text-muted-foreground">私人草稿 · 修订 {saved.draft_revision}</p>
<h1 className="text-xl font-semibold">{saved.name}</h1></div>
{tab === 'configuration' && <Button disabled={busy || pending.hasPending || !dirty || !draft.name.trim() || !draft.purpose.trim() || remote !== null} onClick={() => void save()}>
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : dirty ? <Save className="mr-2 h-4 w-4" /> : <Check className="mr-2 h-4 w-4" />}
{busy ? '保存中…' : dirty ? '保存草稿' : '已保存'}
</Button>}
</header>
<nav aria-label="智能体工作区" className="mb-6 flex flex-wrap gap-2">
{([['chat', '对话'], ['tasks', '自动任务'], ['configuration', '配置'], ['access', '发布与访问']] as const).map(([key, label]) =>
<Button key={key} variant={tab === key ? 'secondary' : 'ghost'} aria-current={tab === key ? 'page' : undefined} disabled={busy}
onClick={() => { if (key === tab) return; if (dirty || pending.hasPending) { setNextTab(key); setLeaving(true); } else { setOpenedThread(undefined); setTab(key); } }}>{label}</Button>)}
</nav>
{error && <p role="alert" className="mb-4 text-sm text-destructive">{error}</p>}
{remote && <div className="mb-5 space-y-3 rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm">
<p className="font-medium">云端已有修订 {remote.draft_revision},本地输入仍保留</p>
<details><summary className="cursor-pointer">查看最新草稿</summary><p className="mt-2">{remote.name} · {remote.purpose}</p>
<pre className="mt-2 max-h-48 overflow-auto whitespace-pre-wrap font-sans">{remote.system_prompt}</pre></details>
<div className="flex flex-wrap gap-2"><Button variant="outline" onClick={() => { setSaved(remote); setDraft(remote); setRemote(null); setError(''); }}>使用最新草稿</Button>
<Button variant="outline" onClick={() => { setSaved(remote); setRemote(null); setError(''); }}>保留我的内容,继续编辑</Button></div>
</div>}
{tab === 'chat' && (saved.published_version ? <CloudChat slug={saved.slug} initialThread={openedThread}
onSchedule={proposal => { setScheduleProposal(proposal); setTab('tasks'); }} />
: <p className="rounded-xl bg-muted/30 p-8 text-sm text-muted-foreground">先在配置中测试,再发布智能体,即可开始正式对话。</p>)}
{tab === 'tasks' && <CloudSchedules slug={saved.slug} proposal={scheduleProposal} onProposalClosed={() => setScheduleProposal(undefined)} onOpen={thread => { setOpenedThread(thread); setTab('chat'); }} />}
{tab === 'access' && <CloudAccessPanel slug={saved.slug} revision={saved.draft_revision} onChanged={result => { setSaved(result); setDraft(result); onSaved(result); }} onPublished={() => {
void cloudAgentsApi.get(saved.slug).then(result => { if (alive.current) {
const publication = { published_version: result.published_version, enabled: result.enabled };
setSaved(current => ({ ...current, ...publication }));
setDraft(current => ({ ...current, ...publication }));
onSaved({ ...saved, ...publication });
} }).catch(e => { if (alive.current) setError(errorMessage(e)); });
}} />}
{tab === 'configuration' && <div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(320px,0.8fr)]">
<div className="space-y-5">
<label className="block space-y-2 text-sm"><span>名称</span><Input maxLength={100} value={draft.name} disabled={busy}
onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></label>
<label className="block space-y-2 text-sm"><span>用途</span><Textarea maxLength={2000} value={draft.purpose} disabled={busy}
onChange={(event) => setDraft({ ...draft, purpose: event.target.value })} /></label>
<label className="block space-y-2 text-sm"><span>角色与指令</span><Textarea className="min-h-64 leading-7" maxLength={32000}
value={draft.system_prompt} disabled={busy} onChange={(event) => setDraft({ ...draft, system_prompt: event.target.value })}
placeholder="描述它的工作方式、回答风格,以及需要遵守的要求。" /></label>
<ConfigurationFields slug={saved.slug} value={draft.configuration ?? EMPTY_CLOUD_CONFIGURATION} disabled={busy}
onChange={configuration => setDraft({ ...draft, configuration })} />
</div>
<aside className="space-y-3 text-sm leading-6 text-muted-foreground">
{dirty && <p role="status" className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-amber-900">
预览仍使用已保存的修订 {saved.draft_revision}。{pending.hasPending ? '还有未完成的输入或操作,完成或清空后再保存。' : '保存后将开始新一轮预览。'}
</p>}
{!saved.configuration?.model ? <div className="rounded-xl bg-muted/30 p-6"><h2 className="font-medium text-foreground">测试当前配置</h2>
<p className="mt-3">选择模型并保存后,在这里测试智能体。</p>
<p className="mt-3">草稿仅你可见。保存不会触发模型调用,发送预览消息会消耗你的词元点数。</p></div>
: <CloudChat key={saved.draft_revision} slug={saved.slug} previewRevision={saved.draft_revision} />}
</aside>
</div>}
{(leaving || blocker.state === 'blocked') && <div role="dialog" aria-modal="true" aria-label="未保存的修改" className="fixed inset-0 z-50 flex items-center justify-center bg-black/20 p-6">
<div className="max-w-md rounded-xl bg-background p-6 shadow-lg"><h2 className="font-medium">还有未保存的修改</h2>
<p className="my-4 text-sm text-muted-foreground">{pending.hasPending ? '还有未发送的内容或未确认的操作。离开会丢弃当前输入;已被云端接受的任务仍会继续,可在活动中查看。' : '保存后离开,或放弃这次修改。'}</p>
<div className="flex flex-wrap gap-2">{!pending.hasPending && <Button disabled={busy || remote !== null || !draft.name.trim() || !draft.purpose.trim()} onClick={() => void save(true)}>保存并离开</Button>}
<Button variant="outline" disabled={busy} onClick={finishLeaving}>放弃修改</Button>
<Button variant="ghost" onClick={() => { setLeaving(false); setNextTab(null); if (blocker.state === 'blocked') blocker.reset(); }}>继续编辑</Button></div></div>
</div>}
</section></CloudPendingContext.Provider>;
}
export default function CloudAgents() {
const accountId = useAuthStore((state) => state.user?.userId);
const location = useLocation();
+68 -8
View File
@@ -8,7 +8,8 @@ test('personal cloud Agent creation, draft save and leave protection in Electron
const applicationUrl = page.url();
await page.goto('about:blank');
// This UI test owns the Host API boundary; real cloud HTTP/PG is tested separately.
await app.evaluate(({ ipcMain }) => {
await app.evaluate(({ ipcMain, BrowserWindow }) => {
BrowserWindow.getAllWindows()[0].setSize(1366, 768);
let draft: Record<string, unknown> | null = null;
let recent: unknown = null;
let limits: { request_limit_points: string | null; daily_limit_points: string | null } = { request_limit_points: null, daily_limit_points: null };
@@ -52,7 +53,7 @@ test('personal cloud Agent creation, draft save and leave protection in Electron
if (body.operation === 'submit' || body.operation === 'preview') return result({ request_id: input.request_id, thread_id: 'thread-1', run_id: 'run-1', version: '1', status: 'dispatched' });
if (body.operation === 'history') return result({ thread_id: 'thread-1', next_offset: null,
queued_requests: [], schedule_proposals: [{ id: 'proposal', name: '每天整理选题', prompt: '每天给我三个适合个人创作的选题', cron_expression: '30 8 * * *', timezone: 'Asia/Shanghai' }],
messages: [{ id: 1, role: 'assistant', content: '## 创作建议\n先从一个真实的观察开始。' }],
messages: [{ id: 1, role: 'assistant', content: '## 创作建议\n先从一个真实的观察开始。\n\n' + '记录今天的一次观察,把具体场景、你的感受和想分享的发现写下来。\n\n'.repeat(22) }],
run: { agent_run_id: 'run-1', request_id: 'request-1', thread_id: 'thread-1', status: 'completed', version: '1' } });
if (body.operation === 'viewed') return result({ viewed: true });
if (body.operation === 'attachments') return result({ attachments: [] });
@@ -64,7 +65,7 @@ test('personal cloud Agent creation, draft save and leave protection in Electron
draft_revision: 1, updated_at: '2026-09-10T00:00:00Z',
configuration, published_version: null, enabled: true,
};
if (method === 'PATCH') draft = { ...draft, name: body.name, purpose: body.purpose, system_prompt: body.system_prompt, configuration: body.configuration, draft_revision: 2 };
if (method === 'PATCH') draft = { ...draft, name: body.name, purpose: body.purpose, system_prompt: body.system_prompt, configuration: body.configuration, draft_revision: Number(draft?.draft_revision ?? 1) + 1 };
if (method === 'GET' && (path.endsWith('/agents') || path.endsWith('/bootstrap'))) return result({ agents: draft ? [draft] : [], next_cursor: null });
return result(draft);
}
@@ -81,29 +82,87 @@ test('personal cloud Agent creation, draft save and leave protection in Electron
await page.getByRole('button', { name: '创建草稿', exact: true }).click();
await expect(page.getByTestId('cloud-agent-editor')).toBeVisible();
await page.getByLabel('角色与指令').fill('使用简洁自然的中文。先理解我的目的,再协助梳理结构。');
await page.getByRole('button', { name: '能力', exact: true }).click();
await page.getByLabel('模型', { exact: true }).selectOption('test-model');
await page.getByTestId('sidebar-module-switcher-trigger').click();
await expect(page.getByRole('dialog', { name: '未保存的修改' })).toBeVisible();
await page.getByRole('button', { name: '继续编辑', exact: true }).click();
await page.getByRole('button', { name: '保存草稿', exact: true }).click();
await expect(page.getByRole('button', { name: '已保存', exact: true })).toBeVisible();
await expect(page.getByTestId('titlebar-page-title')).toHaveText('智能体工作区');
await page.getByRole('button', { name: '指令', exact: true }).click();
const preview = page.getByRole('region', { name: '草稿预览', exact: true });
await preview.getByLabel('消息').fill('先给我一些创作建议');
await preview.getByLabel('消息').press('Enter');
await expect(preview.getByRole('heading', { name: '创作建议' })).toBeAttached();
const composer = preview.getByTestId('cloud-chat-composer');
const beforeScroll = await composer.boundingBox();
await preview.getByTestId('cloud-chat-messages').evaluate(el => { el.scrollTop = 0; });
await expect.poll(async () => (await composer.boundingBox())?.y).toBe(beforeScroll?.y);
await expect.poll(() => page.getByTestId('main-content').evaluate(el => el.scrollHeight - el.clientHeight)).toBeLessThanOrEqual(1);
expect(beforeScroll!.y + beforeScroll!.height).toBeLessThanOrEqual(await page.evaluate(() => innerHeight));
await preview.getByLabel('消息').fill('保存后继续的问题');
await preview.getByLabel('消息').press('F6');
await expect(page.getByRole('region', { name: '草稿配置', exact: true }).getByRole('button', { name: '展开配置' })).toBeFocused();
await page.getByLabel('角色与指令').fill('使用简洁自然的中文。先理解我的目的,再协助梳理结构。补充具体例子。');
await page.getByLabel('角色与指令').press('Control+s');
await expect(page.getByText('云端已保存', { exact: true })).toBeVisible();
await expect(preview.getByLabel('消息')).toHaveValue('保存后继续的问题');
await expect(preview.getByRole('heading', { name: '创作建议' })).toBeAttached();
await expect(preview.getByText('草稿预览 · 修订 2')).toBeVisible();
const divider = page.getByRole('separator', { name: '调整配置与试用宽度' });
await divider.focus();
const previousRatio = Number(await divider.getAttribute('aria-valuenow'));
await divider.press('ArrowRight');
await expect(divider).toHaveAttribute('aria-valuenow', String(previousRatio + 2));
const handle = await divider.boundingBox();
await page.mouse.move(handle!.x + 4, handle!.y + 80);
await page.mouse.down();
await page.mouse.move(handle!.x - 35, handle!.y + 80);
await page.mouse.up();
expect(Number(await divider.getAttribute('aria-valuenow'))).toBeLessThan(previousRatio + 2);
await page.getByRole('button', { name: '展开试用', exact: true }).click();
await expect(page.getByRole('region', { name: '草稿配置', exact: true })).toBeHidden();
await page.keyboard.press('Escape');
await expect(page.getByRole('region', { name: '草稿配置', exact: true })).toBeVisible();
await page.screenshot({ path: testInfo.outputPath('cloud-agent-editor.png') });
await app.evaluate(({ BrowserWindow }) => { const win = BrowserWindow.getAllWindows()[0]; win.setMinimumSize(800, 600); win.setSize(980, 680); });
await expect(divider).toBeHidden();
await page.getByRole('button', { name: '试用', exact: true }).click();
await expect(preview.getByLabel('消息')).toHaveValue('保存后继续的问题');
await expect.poll(async () => {
const bounds = await composer.boundingBox();
return bounds!.y + bounds!.height - await page.evaluate(() => innerHeight);
}).toBeLessThanOrEqual(0);
await page.screenshot({ path: testInfo.outputPath('cloud-agent-compact.png') });
await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(1366, 768));
for (const factor of [1.25, 1.5]) {
await app.evaluate(({ BrowserWindow }, zoom) => BrowserWindow.getAllWindows()[0].webContents.setZoomFactor(zoom), factor);
if (await page.getByRole('button', { name: '试用', exact: true }).isVisible()) await page.getByRole('button', { name: '试用', exact: true }).click();
await expect.poll(async () => { const bounds = await composer.boundingBox(); return bounds!.y + bounds!.height - await page.evaluate(() => innerHeight); }).toBeLessThanOrEqual(0);
}
await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].webContents.setZoomFactor(1));
await preview.getByRole('button', { name: '用最新草稿试用' }).click();
await expect(preview.getByText('草稿预览 · 修订 3')).toBeVisible();
await expect(preview.getByLabel('消息')).toHaveValue('保存后继续的问题');
await preview.getByLabel('消息').fill('');
await page.getByTestId('sidebar-module-switcher-trigger').click();
await expect(page.getByTestId('ai-module-selection-page')).toBeVisible();
await page.getByTestId('ai-module-option-cloud_agents').click();
await expect(page.getByTestId('cloud-agent-editor')).toBeVisible();
await expect(page.getByLabel('角色与指令')).toHaveValue('使用简洁自然的中文。先理解我的目的,再协助梳理结构。');
await page.getByRole('button', { name: '发布与访问', exact: true }).click();
await expect(page.getByLabel('角色与指令')).toHaveValue('使用简洁自然的中文。先理解我的目的,再协助梳理结构。补充具体例子。');
await page.getByRole('button', { name: '限制', exact: true }).click();
await page.getByLabel('每次任务上限(词元点数)').fill('2.50');
await page.getByLabel('每日上限(词元点数)').fill('10.00');
await page.getByRole('button', { name: '保存费用上限' }).click();
await expect(page.getByRole('button', { name: '保存费用上限' })).toBeDisabled();
await page.getByRole('button', { name: '发布与访问', exact: true }).click();
await page.getByRole('button', { name: '发布当前草稿' }).click();
await expect(page.getByText('当前发布版本 1')).toBeVisible();
await page.getByRole('button', { name: '对话', exact: true }).click();
await page.getByLabel('消息', { exact: true }).fill('每天早上八点半给我三个创作方向');
await page.getByRole('button', { name: '发送', exact: true }).click();
await expect(page.getByRole('heading', { name: '创作建议' })).toBeVisible();
await page.getByRole('region', { name: '智能体对话', exact: true }).getByLabel('消息', { exact: true }).fill('每天早上八点半给我三个创作方向');
await page.getByRole('region', { name: '智能体对话', exact: true }).getByRole('button', { name: '发送', exact: true }).click();
await expect(page.getByRole('region', { name: '智能体对话', exact: true }).getByRole('heading', { name: '创作建议' })).toBeAttached();
await page.getByRole('button', { name: '查看并配置自动任务' }).click();
await expect(page.getByLabel('任务名称')).toHaveValue('每天整理选题');
await expect(page.getByLabel('执行时间')).toHaveValue('08:30');
@@ -118,6 +177,7 @@ test('personal cloud Agent creation, draft save and leave protection in Electron
await page.getByRole('button', { name: '取消', exact: true }).click();
await page.screenshot({ path: testInfo.outputPath('cloud-agent-schedules.png') });
} finally {
await app.evaluate(({ BrowserWindow }) => { for (const win of BrowserWindow.getAllWindows()) win.destroy(); }).catch(() => undefined);
await closeElectronApp(app);
}
});
+84 -5
View File
@@ -26,8 +26,13 @@ beforeEach(() => {
api.recovery.mockResolvedValue({ recent: null, pending: [] });
api.remember.mockResolvedValue({ saved: true });
api.list.mockResolvedValue({ agents: [draft], next_cursor: null });
api.call.mockImplementation(async (operation: string) => operation === 'knowledge'
? { databases: [], models: [] } : { models: [], resources: {}, pricing: null });
api.call.mockImplementation(async (operation: string) => {
if (operation === 'knowledge') return { databases: [], models: [] };
if (operation === 'resources') return { mcps: [], skills: [], subagents: [] };
if (operation === 'threads') return { threads: [], next_offset: null };
if (operation === 'budget') return { request_limit_points: null, daily_limit_points: null, daily_committed_points: '0.00' };
return { models: [], resources: {}, pricing: null };
});
});
afterEach(cleanup);
@@ -45,6 +50,7 @@ it('restores the exact previous thread and presents an uncertain operation witho
});
open();
await screen.findByText('恢复了旧对话');
fireEvent.click(screen.getByRole('button', { name: '查看历史对话' }));
expect(screen.getByLabelText('历史对话')).toHaveValue('older-thread');
expect(api.resolvePending).not.toHaveBeenCalled();
expect(api.call.mock.calls.some(call => call[0] === 'submit')).toBe(false);
@@ -162,11 +168,84 @@ it('keeps unsent preview text when configuration changes and waits before switch
fireEvent.change(prompt, { target: { value: '还未发送的问题' } });
fireEvent.change(screen.getByLabelText('名称'), { target: { value: '新的名称' } });
expect(prompt).toHaveValue('还未发送的问题');
expect(screen.getByText(/预览仍使用已保存的修订 1/)).toBeVisible();
expect(screen.getByRole('button', { name: '保存草稿' })).toBeDisabled();
fireEvent.change(prompt, { target: { value: '' } });
expect(screen.getByText(/保存不会清空当前试用/)).toBeVisible();
await waitFor(() => expect(screen.getByRole('button', { name: '保存草稿' })).toBeEnabled());
fireEvent.click(screen.getByRole('button', { name: '保存草稿' }));
await screen.findByRole('button', { name: '已保存' });
expect(api.save).toHaveBeenCalledTimes(1);
expect(screen.getByLabelText('消息')).toBe(prompt);
expect(prompt).toHaveValue('还未发送的问题');
expect(screen.getByText('草稿预览 · 修订 1')).toBeVisible();
expect(screen.getByRole('button', { name: '发送', exact: true })).toBeDisabled();
fireEvent.click(screen.getByRole('button', { name: '用最新草稿试用' }));
expect(screen.getByText('草稿预览 · 修订 2')).toBeVisible();
expect(prompt).toHaveValue('还未发送的问题');
expect(screen.getByRole('button', { name: '发送', exact: true })).toBeEnabled();
});
it('keeps preview text across mode switches and saves a budget independently of the draft', async () => {
const configured = { ...draft, configuration: { ...EMPTY_CLOUD_CONFIGURATION, model: 'test-model' } };
api.list.mockResolvedValue({ agents: [configured], next_cursor: null });
const base = api.call.getMockImplementation()!;
api.call.mockImplementation(async (operation, input) => operation === 'saveBudget' ? { ...input, daily_committed_points: '0.00' } : base(operation, input));
open();
fireEvent.click(await screen.findByRole('button', { name: /写作搭档/ }));
await waitFor(() => expect(screen.getByLabelText('消息')).toBeEnabled());
fireEvent.change(screen.getByLabelText('消息'), { target: { value: '待发送的问题' } });
fireEvent.change(screen.getByLabelText('角色与指令'), { target: { value: '未保存的指令' } });
fireEvent.click(screen.getByRole('button', { name: '对话', exact: true }));
fireEvent.click(screen.getByRole('button', { name: '编辑', exact: true }));
expect(screen.getByLabelText('消息')).toHaveValue('待发送的问题');
expect(screen.getByLabelText('角色与指令')).toHaveValue('未保存的指令');
fireEvent.click(screen.getByRole('button', { name: '限制', exact: true }));
fireEvent.change(screen.getByLabelText('每日上限(词元点数)'), { target: { value: '12' } });
fireEvent.click(screen.getByRole('button', { name: '保存费用上限' }));
await waitFor(() => expect(api.call).toHaveBeenCalledWith('saveBudget', expect.objectContaining({ daily_limit_points: '12' })));
expect(api.save).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '指令', exact: true }));
expect(screen.getByLabelText('角色与指令')).toHaveValue('未保存的指令');
expect(screen.getByLabelText('消息')).toHaveValue('待发送的问题');
});
it('keeps an existing preview when the saved draft clears its model selection', async () => {
const configured = { ...draft, configuration: { ...EMPTY_CLOUD_CONFIGURATION, model: 'test-model' } };
api.list.mockResolvedValue({ agents: [configured], next_cursor: null });
api.save.mockResolvedValue({ ...draft, draft_revision: 2 });
open();
fireEvent.click(await screen.findByRole('button', { name: /写作搭档/ }));
await waitFor(() => expect(screen.getByLabelText('消息')).toBeEnabled());
const prompt = screen.getByLabelText('消息');
fireEvent.change(prompt, { target: { value: '还没有发送' } });
fireEvent.click(screen.getByRole('button', { name: '能力', exact: true }));
fireEvent.change(screen.getByLabelText('模型'), { target: { value: '' } });
fireEvent.click(screen.getByRole('button', { name: '保存草稿' }));
await screen.findByText('云端已保存');
expect(screen.getByLabelText('消息')).toBe(prompt);
expect(prompt).toHaveValue('还没有发送');
expect(screen.getByRole('button', { name: '用最新草稿试用' })).toBeDisabled();
});
it('searches capabilities while retaining existing selections outside the results', async () => {
const configured = { ...draft, configuration: { ...EMPTY_CLOUD_CONFIGURATION, model: 'test-model', tools: ['missing-tool'] } };
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,
} : base(operation, input));
api.save.mockImplementation(async (_slug, input) => ({ ...configured, ...input, draft_revision: 2 }));
open();
fireEvent.click(await screen.findByRole('button', { name: /写作搭档/ }));
fireEvent.click(screen.getByRole('button', { name: '能力', exact: true }));
expect(await screen.findByRole('checkbox', { name: /missing-tool/ })).toBeChecked();
fireEvent.click(screen.getByRole('button', { name: '选择工具', exact: true }));
fireEvent.change(screen.getByLabelText('搜索工具'), { target: { value: '资料' } });
expect(screen.queryByRole('checkbox', { name: '当前时间' })).not.toBeInTheDocument();
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: '保存草稿' }));
await waitFor(() => expect(api.save).toHaveBeenCalledWith(draft.slug, expect.objectContaining({ configuration: expect.objectContaining({ tools: ['missing-tool', 'search'] }) })));
});
@@ -63,11 +63,13 @@ it('returns to the last selected thread on remount and requires restoring an arc
return base(operation, input);
});
const first = render(<CloudChat slug={slug} />);
fireEvent.click(screen.getByRole('button', { name: '查看历史对话' }));
await waitFor(() => expect(screen.getByLabelText('历史对话')).toHaveValue('older'));
fireEvent.change(screen.getByLabelText('历史对话'), { target: { value: 'newest' } });
await waitFor(() => expect(recent.thread_id).toBe('newest'));
first.unmount();
const second = render(<CloudChat slug={slug} />);
fireEvent.click(screen.getByRole('button', { name: '查看历史对话' }));
await waitFor(() => expect(screen.getByLabelText('历史对话')).toHaveValue('newest'));
second.unmount();
recent = { ...recent, thread_id: 'older' };
@@ -241,6 +243,7 @@ it('returns to a newly created conversation from history without remounting the
await waitFor(() => expect(screen.getByLabelText('消息')).not.toBeDisabled());
fireEvent.change(screen.getByLabelText('消息'), { target: { value: '新对话主题' } });
fireEvent.click(screen.getByRole('button', { name: '发送', exact: true }));
fireEvent.click(screen.getByRole('button', { name: '查看历史对话' }));
await waitFor(() => expect(screen.getByLabelText('历史对话')).toBeEnabled());
expect(screen.getByRole('option', { name: '新对话主题 · 已完成' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '新对话' }));
@@ -280,3 +283,69 @@ it('creates an attachment conversation only when the selected file is confirmed'
expect(api.call.mock.calls.filter(call => call[0] === 'createThread')).toHaveLength(1);
await waitFor(() => expect(screen.getByText('附件与产物 · 本条消息附带 1 个文件')).toBeVisible());
});
it('pins uncertain preview retries to the original request and revision after a draft save', async () => {
const base = api.call.getMockImplementation()!;
let attempts = 0;
api.call.mockImplementation(async (operation, input) => {
if (operation === 'preview') {
if (++attempts === 1) throw new Error('响应丢失');
return { request_id: input.request_id, thread_id: 'thread-1', run_id: 'run-1', status: 'completed', version: 'draft:1' };
}
if (operation === 'history') return { ...history, run: { ...run, status: 'completed', version: 'draft:1' }, messages: [{ id: 1, role: 'assistant', content: '第一版回答' }] };
return base(operation, input);
});
const view = render(<CloudChat slug={slug} previewRevision={1} />);
await waitFor(() => expect(screen.getByLabelText('消息')).toBeEnabled());
fireEvent.change(screen.getByLabelText('消息'), { target: { value: '原来的问题' } });
fireEvent.click(screen.getByRole('button', { name: '发送', exact: true }));
await screen.findByRole('button', { name: '重试本次发送' });
view.rerender(<CloudChat slug={slug} previewRevision={2} />);
expect(screen.getByRole('button', { name: '用最新草稿试用' })).toBeDisabled();
fireEvent.click(screen.getByRole('button', { name: '重试本次发送' }));
await screen.findByText('第一版回答');
const submissions = api.call.mock.calls.filter(call => call[0] === 'preview');
expect(submissions).toHaveLength(2);
expect(submissions[0][1]).toEqual(submissions[1][1]);
expect(submissions[1][1]).toMatchObject({ expected_revision: 1, query: '原来的问题' });
fireEvent.click(screen.getByRole('button', { name: '用最新草稿试用' }));
expect(screen.queryByText('第一版回答')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '查看历史对话' }));
fireEvent.change(screen.getByLabelText('历史对话'), { target: { value: 'thread-1' } });
await screen.findByText('第一版回答');
expect(screen.getByText('草稿预览 · 修订 1')).toBeVisible();
});
it('uses Enter to send while preserving Shift+Enter and Chinese IME composition', async () => {
const base = api.call.getMockImplementation()!;
api.call.mockImplementation(async (operation, input) => operation === 'preview'
? { request_id: input.request_id, thread_id: 'thread-1', status: 'completed' } : base(operation, input));
render(<CloudChat slug={slug} previewRevision={3} />);
const input = screen.getByLabelText('消息');
await waitFor(() => expect(input).toBeEnabled());
fireEvent.change(input, { target: { value: '整理我的思路' } });
fireEvent.keyDown(input, { key: 'Enter', shiftKey: true });
fireEvent.keyDown(input, { key: 'Enter', isComposing: true });
fireEvent.keyDown(input, { key: 'Enter', keyCode: 229 });
expect(api.call.mock.calls.some(call => call[0] === 'preview')).toBe(false);
fireEvent.keyDown(input, { key: 'Enter' });
await waitFor(() => expect(api.call).toHaveBeenCalledWith('preview', expect.objectContaining({ expected_revision: 3, query: '整理我的思路' })));
});
it('does not mark a retained hidden conversation as read until it is shown', async () => {
const base = api.call.getMockImplementation()!;
api.call.mockImplementation(async (operation, input) => {
if (operation === 'threads') return { threads: [{ thread_id: 'thread-1', mode: 'published' }], next_offset: null };
if (operation === 'history') return { ...history, run: { ...run, status: 'completed' }, messages: [{ id: 1, role: 'assistant', content: '后台的新回答' }] };
if (operation === 'viewed') return { viewed: true };
return base(operation, input);
});
const view = render(<CloudChat slug={slug} activeView={false} />);
await screen.findByText('后台的新回答');
expect(api.call.mock.calls.some(call => call[0] === 'viewed')).toBe(false);
expect(api.remember).not.toHaveBeenCalled();
view.rerender(<CloudChat slug={slug} activeView />);
await waitFor(() => expect(api.call).toHaveBeenCalledWith('viewed', { thread_id: 'thread-1', run_id: 'run-1' }));
});
+10 -1
View File
@@ -89,7 +89,16 @@ describe('MainLayout module isolation', () => {
expect(screen.getByTestId('sidebar-stub')).toHaveAttribute('data-has-peek-handler', 'false');
});
it('does not overlay the local project initialization gate on AI hardware', () => {
it('gives cloud Agents the full desktop content area and an account workspace title', () => {
renderLayout('/cloud-agents');
expect(screen.getByTestId('main-content')).toHaveClass('h-full', 'overflow-hidden', 'p-0');
expect(screen.getByTestId('main-content')).not.toHaveClass('p-5', 'sm:p-6');
expect(screen.getByTestId('titlebar-stub')).toHaveAttribute('data-page-title', '智能体工作区');
expect(screen.getByTestId('titlebar-stub')).toHaveAttribute('data-overlay', 'false');
expect(screen.getByTestId('sidebar-stub')).toHaveAttribute('data-hide-on-compact', 'false');
});
it('does not overlay the local project initialization gate on AI hardware' , () => {
renderLayout('/ai-hardware');
expect(screen.getByTestId('route-content')).toBeVisible();
+3 -3
View File
@@ -52,7 +52,7 @@ describe('AI module navigation', () => {
});
});
it('shows only the three supported module choices and routes Canvas from the chooser', async () => {
it('shows the four supported module choices and routes Canvas from the chooser', async () => {
render(
<MemoryRouter initialEntries={['/module-select']}>
<Routes>
@@ -68,10 +68,10 @@ describe('AI module navigation', () => {
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'] as const) {
for (const moduleId of ['programming', 'painting', 'robot', 'cloud_agents'] as const) {
expect(screen.getByTestId(`ai-module-option-${moduleId}`).querySelector('.module-option-artwork')).toBeNull();
}
expect(screen.getAllByTestId(/^ai-module-option-/u)).toHaveLength(3);
expect(screen.getAllByTestId(/^ai-module-option-/u)).toHaveLength(4);
expect(screen.queryByTestId('ai-module-option-learning')).not.toBeInTheDocument();
expect(screen.queryByText('先选一个入口。进入后,你还可以从左下角随时切换到其他模块。')).not.toBeInTheDocument();
expect(screen.queryByText('选择创作空间。你可以随时从侧边栏切换。')).not.toBeInTheDocument();