From 8b40463a9ffa5a5ed7b2493a1b021478639b6c09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Mon, 12 Oct 2026 17:45:02 +0800 Subject: [PATCH] Refine personal Agent cards and configuration --- ...261012-agent-config-refinement-a73e4b92.md | 61 ++++ README.md | 4 +- electron/services/cloud-agents.ts | 6 +- src/components/layout/MainLayout.tsx | 2 +- src/pages/CloudAgents/CloudAvatar.tsx | 20 +- src/pages/CloudAgents/ConfigurationFields.tsx | 22 +- src/pages/CloudAgents/DraftEditor.tsx | 69 +++-- src/pages/CloudAgents/agent-home.css | 40 +-- src/pages/CloudAgents/desktop-workspace.css | 3 +- src/pages/CloudAgents/index.tsx | 108 +++++-- tests/e2e/cloud-agent-channels.spec.ts | 9 +- tests/e2e/cloud-agent-home.spec.ts | 80 +++-- .../e2e/cloud-agent-platform-avatars.spec.ts | 4 +- tests/e2e/cloud-agents.spec.ts | 284 ++++-------------- tests/unit/cloud-agent-avatar.test.tsx | 74 +++++ tests/unit/cloud-agents-main.test.ts | 20 ++ tests/unit/cloud-agents-page.test.tsx | 161 ++++++++-- 17 files changed, 555 insertions(+), 412 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20261012-agent-config-refinement-a73e4b92.md diff --git a/.project-docs/30-worklog/tasks/20261012-agent-config-refinement-a73e4b92.md b/.project-docs/30-worklog/tasks/20261012-agent-config-refinement-a73e4b92.md new file mode 100644 index 00000000..1c567516 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20261012-agent-config-refinement-a73e4b92.md @@ -0,0 +1,61 @@ +# Task: Refine personal Agent cards and configuration + +## Identity + +- Task ID: 20261012-agent-config-refinement-a73e4b92 +- Mode: Feature +- Branch: codex/20261012-agent-config-refinement-a73e4b92-agent-config-refinement +- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261012-agent-config-refinement-a73e4b92 +- Base commit: c14d996c054d1b5b8596b5d2af7c476e271d1540 +- Owner: codex +- Status: In Progress + +## Scope + +- Implement the user's 13 accepted refinements: simplified homepage/navigation, card deletion, visible name editing, stable and editable avatars, optional introduction and prompt, required model first in advanced settings, then update and show the latest temporary app. + +## Intent And Constraints + +- User explicitly authorizes code changes and trial update. Preserve platform pixel avatars, identity-first creation, independent formal chat, explicit save/apply, Main-owned credentials, account isolation and existing service lifecycle. No real Agent deletion/upload/config save/publication or paid calls during verification, no main merge/push, installed-app changes or peer/canonical-document writes. + +## Outcome + +- All 13 frontend/source refinements implemented. Homepage now has the exact playful greeting and a single global title, no More/share/activity/archive entry; cards render optional introduction and have confirmed recoverable deletion with failure retry, focus restoration and stale/account guards. +- Configuration header supports name editing with a pencil and avatar editing; one fixed platform fallback portrait is consistent through creation, rename, card and reopen. Explicit preset/upload takes precedence. Configuration only exposes introduction/prompt and advanced settings; model is advanced's first required item and no longer auto-selected. Draft save may precede model selection; starting/applying requires a model. Formal chat remains a card entry, with pending-input protection before entering configuration. +- Main accepts empty introduction through create/save/read without weakening type/length or account guards. Yuxi companion patch 86602a3d2d784a50f4e6e720a4109b8eeb5eb1a3 in task 20261012-agent-optional-intro-yx-91fd76a2 keeps the existing purpose field and relaxes only personal create/save; no child-resource weakening or migration. User confirmed the Yuxi original purpose is the intended source. +- New separate trial shell and startup probe prepared successfully. Actual latest-trial replacement/showing remains pending at this checkpoint. Cloud deployment remains unavailable: no known target/configuration, Docker or kubectl, and generic repository examples do not identify the real service. Do not claim real-account empty creation/save works until the Yuxi patch is rolled out. + +## Verification + +- Pinned pnpm 10.33.4: TypeScript check, scoped ESLint and production renderer/main/preload build passed. +- Six focused unit files: 138 passed (Main boundary, page flows, avatar, workflow, management, guidance). Includes empty/whitespace intro normalization, invalid types/length rejection, raw prompt preservation, explicit model requirement, stable creation/rename/reopen avatar, unsent-chat accessibility, publication/conflict/account/recovery regressions. +- Ten distinct UI checks passed across the focused Electron/home/channel and browser-avatar suites. Final affected workflow/avatar rerun: 6/6 passed. Checked 375/1366 widths plus 980 desktop and 125%/150% zoom. Initial failures identified heading accessible-name spacing, broad duplicate button locators, unavailable default Chromium binary and 13px zoom header overflow; fixed the real accessibility/layout issues, scoped locators, used installed headless Chrome, then reran affected flows. No live account mutations or model calls. +- Visually inspected actual screenshot outputs for homepage and introduction/prompt/model layouts. Isolated-profile hidden startup probe confirms correct built source, unfocused/invisible window and DIRECT public health 200 without changing current app/profile or injecting test data. +- Yuxi companion: 20 schema and 5 real HTTP/PostgreSQL checks plus defect-restoration negative controls passed, independent review passed. Backend full unit suite was not passed: 545 passed, two baseline execution-bit failures, then Redis wait interruption. See companion task for exact evidence. + +## Follow-ups + +- Update only the confirmed c14d996 latest trial, retain profile and direct network, then show the new app. Never replace the installed application. +- Real cloud service must integrate/deploy companion 86602a3 before personal creation with blank introduction or saving cleared introduction can be accepted; the current desktop refresh cannot update Yuxi. Keep this dependency explicit in the product handoff. + +## Promotion Candidates + +- Optional introduction reuses purpose end to end; required model moves to first advanced category and gates use rather than draft save. +- Homepage is own creation/use only, card delete archives/disables while preserving history, configuration removes chat/task/access navigation, and default avatar changes only upon deliberate choice/removal. +- No canonical/peer records were edited. Promote these decisions in a later authorized integration task with the Yuxi companion and cloud rollout evidence. + +## Project Context Loaded + +- Task ID 20261012-agent-config-refinement-a73e4b92; feature mode; branch codex/20261012-agent-config-refinement-a73e4b92-agent-config-refinement; owned worktree /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261012-agent-config-refinement-a73e4b92; base c14d996c054d1b5b8596b5d2af7c476e271d1540. Bundled start exited 0; registry identity and reservation match. +- Read planning entry/memory-index/gate, active record, positioning/current-state/decisions/system architecture and relevant personal-Agent ADR/domain/success/evidence/commitment/stale sections. Large historical canonical entries are unchanged context, not live peer state. All peer records' Scope/Intent/Promotion were examined read-only; teacher tasks are unrelated, older undefined child-language scope remains unknown. +- Latest trial was replaced by the completed pixel-avatar source c14d996 after our earlier combined update. Current runtime metadata points to that exact source, shell and original profile; use it as the new base and preserve its 16 allowlisted avatars and background startup. Previous source peers are complete and read-only. No product conflict remains because this user's 13 changes explicitly supersede old More/navigation/model placement rules. +- Goal: own-Agent creation and use for young creators, within the existing light playful interface and typed Host API. Canonical personal-Agent ADR remains historical for removed entry points; this feature records proposals only. +- Relevant files: CloudAgents index/home CSS, DraftEditor/ConfigurationFields/CloudAvatar, MainLayout, Main cloud validation, unit/Electron regressions, README. Existing deletion capability is recoverable archive/disable; no hard-delete contract is available. Optional introduction needs verification through Main and cloud schema. +- Gates: Passed. + +## Implementation Plan + +- Preserve latest trial base; split homepage/deletion and avatar fixes into disjoint edits. Inspect optional-description service compatibility before selecting its wire representation. +- Simplify configuration navigation, add name edit cue, move required model into the first advanced category, replace main form model with optional introduction, and keep explicit save/publication and independent chat. +- Adapt meaningful unit/Electron coverage for deletion, empty introduction, model placement and stable/editable avatars; run pinned typecheck/lint/build and focused flows. +- Update only the confirmed current latest trial after source verification, retain original profile/direct-network mode and rollback artifact, then show the updated page and complete this record. diff --git a/README.md b/README.md index 8cef7387..1547f36f 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,11 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智 - `Makelore Code|AI 编程`:管理本地项目、项目智能体、对话、文件上下文、代码变更和运行时。 会话工作台采用浅蓝项目栏、彩色通用手柄图标与卡片选中态;中间对话/作品使用圆角标签,作品预览的后退、前进、刷新与网址输入统一放在标签右侧;只在作品页显示,窄窗口自动换行。右侧智能体咨询使用奶油色标题区。沿用真实项目和会话入口、侧栏伸缩与账户菜单,样式只作用于 Code 会话工作台。可编辑的空对话在输入区上方的可用区域中央显示童趣欢迎语;「去作品广场逛逛」入口紧贴输入框上方,两者一起位于底部。老师介绍以固定宽度浮在右上角,不占用中间布局;欢迎语和底部输入区始终相对项目栏右侧的整个内容区域居中;小窗口的欢迎内容独立滚动;发送首条消息后收起欢迎区。侧栏保留「作品广场」入口,只有点击后才在系统浏览器打开公共广场,不请求、摘取或内嵌展示广场作品。打开广场保留当前项目、对话、草稿和附件,不自动发送消息;老师求助按钮保留临时应用的最新文案「帮我整体看🧠」。 -- `Makelore Agents|AI 智能体`:首页以“我的智能体”和创建入口为核心;空状态引导创建第一个助手,不在首页展示推荐起点。浅色卡片展示自己的助手、已保存头像、用途及状态;可用助手直接“和它聊聊”或“调整”,草稿“继续制作”,停用/归档助手“查看与调整”。收到的分享、活动和包含已归档的筛选收进“更多”,保留原数据和入口。创建第一步填写名称,并可选择平台16张像素头像或上传自己的头像,点击“下一步”进入配置;创建结果不确定时保留原操作和头像,明确重试。独立配置页顶部显示当前名称和头像,基础页选择模型并直接编辑完整提示词;不包含用途选择、回答方式预设、模型介绍、模型价格提示或并排试聊。新助手预选真实目录中的首个可用模型,可自行更换;已有助手不会自动换模型。高级设置保留能力、知识与限制,切换保留未保存输入;微信与应用接入仍单独配置。未设置头像或图片加载失败时显示内置像素伙伴。平台头像支持预览与更换,保存后生效;选择失败保留原头像。头像复用 Yuxi 上传合同,Main 持有文件选择、固定平台头像资源与上传,保存 canonical icon,公开 icon_url 只用于显示。草稿明确保存(Ctrl/⌘+S);保存只更新草稿,顶部“应用最新修改”使新配置生效,失败重试保留原操作身份。确认“开始使用”创建仅自己可用的发布版本并打开独立聊天,配置页不会自动发送消息。聊天支持流式回复、审批、排队、附件、产物和历史;聊天与配置切换保留未发送内容。历史试用记录仍可从活动查看,不自动删除。自动任务建议须创建者核对后启用。知识上传、替换及本人附件导入后自动解析索引,持续显示处理/可用/失败状态,零内容不会显示可用,新内容可用后才移除旧资料。费用上限独立保存;收费模型调用仍由创建者个人词元点数支付,知识 embedding 由平台承担。访问隔离、版本恢复、原输入重试及 Main-owned Host API 边界保持。配套服务见 [Yuxi MakeLore 说明](https://xerrors.github.io/Yuxi/advanced/makelore-agents.html)。 +- `Makelore Agents|AI 智能体`:首页围绕自己的智能体和创建入口,显示童趣问候“Hi,今天想创造还是想聊聊?”,不再显示更多、收到的分享、活动或归档筛选。卡片显示名称、可为空的一句话介绍及状态,支持聊天、调整、继续制作和确认删除;删除沿用云端归档机制,移出列表并停用新调用,历史和费用保留。创建先填写名称并选择平台像素头像或上传头像,下一步进入独立配置。顶部名称有编辑图标,头像可点击修改;未设置头像时使用同一张默认像素头像,从创建到配置、改名、保存和重新打开保持一致。基础页为“介绍与提示词”,一句话介绍可留空,完整提示词保留原始文本;模型作为高级设置第一项必选,未选择时不能开始使用,草稿可分步保存,不自动选模型。高级设置保留能力、知识和限制,切换保留输入。配置页移除聊天、自动任务和使用与接入导航,独立聊天仍从卡片进入,微信账号仍在侧栏渠道管理。草稿明确保存(Ctrl/⌘+S);保存只更新草稿,“应用最新修改”使新配置生效,失败重试保持原操作身份;首次确认开始使用创建仅自己可用的发布版本并打开聊天,配置不自动发送消息。头像上传、Main-owned Host API、访问隔离、费用与不可变版本规则保持;介绍留空需要配套 Yuxi DraftCreate/DraftSave 的空字符串契约。 云知识库的 embedding 模型只由平台管理员在 Yuxi 后台维护:在「智能体管理 → 模型供应商」启用供应商,按供应商要求填写有效的服务地址和 API Key,添加并启用 embedding 模型并填写实际向量维度;embedding 向量化费用由平台承担,不计入用户用量、词元点数或智能体费用上限;one-api 不参与 embedding 配置,Works Square 只负责其他应计费模型调用的创建者词元点数账本。客户端区分目录加载中、加载失败与无可用模型,空状态提供管理员配置说明;「刷新知识库与模型」保留未提交名称,未确认的创建请求沿用原操作身份。 - Agents 侧栏的「渠道」统一管理个人微信账号。每个账号由唯一扫码微信身份连接,可以在发布智能体前先扫码,之后选择或更换目标智能体;多个微信账号可以使用同一个已发布智能体,切换目标不需要重新扫码。账号可单独启用、暂停、重新连接或断开,不提供联系人邀请或授权调用者管理。扫码或验证码流程确认连接成功后,二维码与验证码输入自动收起,显示连接成功及选择目标、启用渠道或返回微信对话的下一步提示;仅提交验证码不视为已连接。Agent 的「使用与接入」展示已关联渠道并提供定位入口,保留应用 API 管理,不再提供指定用户授权和分享链接。选中账号的二级详情保留运行/投递活动、失败文件补发和微信对话;对话可审批、停止、新开一段并保存实际产物,不同账号的会话相互隔离。自动任务可明确选择把结果发送到已经开始对话的微信账号,暂停保留目标;渠道触发的聊天等应计费模型调用仍由智能体创建者支付,费用统计包含微信渠道。渠道需要配套版本的 AgentBus Core/微信 Adapter、Yuxi 和 Works Square,桌面用户无需填写服务凭据。Main 保管操作恢复记录,重开界面不会自动重发绑定或执行请求;验证码不写入恢复记录,已移除的联系人权限操作记录只能丢弃。应用 API 管理独立于微信渠道账号管理。 + Agents 侧栏的「渠道」统一管理个人微信账号。每个账号由唯一扫码微信身份连接,可以在发布智能体前先扫码,之后选择或更换目标智能体;多个微信账号可以使用同一个已发布智能体,切换目标不需要重新扫码。账号可单独启用、暂停、重新连接或断开,不提供联系人邀请或授权调用者管理。扫码或验证码流程确认连接成功后,二维码与验证码输入自动收起,显示连接成功及选择目标、启用渠道或返回微信对话的下一步提示;仅提交验证码不视为已连接。智能体配置页不再展示「使用与接入」导航;侧栏「渠道」承担微信账号管理,不再提供指定用户授权和分享链接。选中账号的二级详情保留运行/投递活动、失败文件补发和微信对话;对话可审批、停止、新开一段并保存实际产物,不同账号的会话相互隔离。渠道触发的聊天等应计费模型调用仍由智能体创建者支付,费用统计包含微信渠道。渠道需要配套版本的 AgentBus Core/微信 Adapter、Yuxi 和 Works Square,桌面用户无需填写服务凭据。Main 保管操作恢复记录,重开界面不会自动重发绑定或执行请求;验证码不写入恢复记录,已移除的联系人权限操作记录只能丢弃。应用 API 管理独立于微信渠道账号管理。 - `Makelore Canvas|AI 绘画`:每个设计项目(Workspace)维护一份从创建起就存在的 Living Form。左侧项目栏负责新建、切换和管理 Workspace,并在桌面设计模式下以 256px 宽度常驻展开;中央沿用 AI 编程的安静对话画布、自然消息流和底部悬浮输入器,AI 整理出的制作方案作为对话内的轻量可编辑稿持续更新;桌面端右侧同为 256px 的全高历史作品栏集中展示当前项目的制作记录与生成结果。任务中的已生成图片可通过“放大查看图片”按钮直接打开大图,支持适应窗口、原始尺寸及 Esc 关闭,无需先下载。紧凑窗口通过左侧抽屉访问项目列表,历史记录保留在时间线中。参考图从本地上传后以 `@图片N` 绑定,具体用法只写在创作提示词中。 - `Makelore Robot|AI 机器`:管理机器人智能体、设备激活绑定、智能体配置与设备分配;机器人工作台的智能体位于 Robot 全局侧栏,选中后在内容区先查看绑定设备、再查看基础设置,当前智能体通过 URL 参数保持可分享选择;绑定设备时默认先选择“引导配网”或“已有激活码”。在 Windows 与 macOS 的引导路径中,Makelore 可在弹窗内扫描并连接附近开放的 `Xiaozhi-*` 配网热点,失败时仍可通过系统 Wi-Fi 手动连接;后续继续复用机器人现有热点配网页面,不修改固件,也不由 Makelore 接收 Wi-Fi 密码。 diff --git a/electron/services/cloud-agents.ts b/electron/services/cloud-agents.ts index 38517709..ee055322 100644 --- a/electron/services/cloud-agents.ts +++ b/electron/services/cloud-agents.ts @@ -108,7 +108,7 @@ function projectDraft(value: unknown): CloudAgentDraft { slug: slug(textField(item.slug, 80)), name: textField(item.name, 100), icon: item.icon == null ? null : textField(item.icon, 255, false), - purpose: textField(item.purpose, 2000), + purpose: textField(item.purpose, 2000, false), system_prompt: textField(item.system_prompt, 32000, false), draft_revision: revision(item.draft_revision), updated_at: textField(item.updated_at, 40), @@ -203,7 +203,7 @@ export class CloudAgentsModule { const body: CreateCloudAgent = { operation_id: operationId, name: textField(input.name, 100).trim(), - purpose: textField(input.purpose, 2000).trim(), + purpose: textField(input.purpose, 2000, false).trim(), ...(input.icon === undefined ? {} : { icon: input.icon === null ? null : textField(input.icon, 255, false) }), }; return this.withRecovery('createAgent', { ...body }, () => this.request('/api/makelore/agents', 'POST', body).then(result => this.withAvatar(projectDraft(result)))); @@ -297,7 +297,7 @@ export class CloudAgentsModule { const body: SaveCloudAgentDraft = { expected_revision: revision(input.expected_revision), name: textField(input.name, 100).trim(), - purpose: textField(input.purpose, 2000).trim(), + purpose: textField(input.purpose, 2000, false).trim(), system_prompt: textField(input.system_prompt, 32000, false), ...(input.icon === undefined ? {} : { icon: input.icon === null ? null : textField(input.icon, 255, false) }), ...(input.configuration === undefined ? {} : { configuration: configuration(input.configuration) }), diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx index a02128e7..b27e0b9f 100644 --- a/src/components/layout/MainLayout.tsx +++ b/src/components/layout/MainLayout.tsx @@ -78,7 +78,7 @@ export function MainLayout() { {/* Title bar: drag region on macOS, icon + controls on Windows */} >> 0; - return agentAvatarOptions[seed % agentAvatarOptions.length]!; -} +// The default portrait is the same before and after an assistant is created. +// Only an explicitly selected or uploaded avatar changes its appearance. +const defaultAvatar = agentAvatarOptions[0]!; export function CloudAvatar({ src, name, className = 'h-12 w-12' }: { - src?: string | null; name: string; className?: string; + src?: string | null; name: string; identity?: string; className?: string; }) { const [failed, setFailed] = useState(null); const style = `inline-flex shrink-0 items-center justify-center overflow-hidden rounded-xl bg-violet-50 text-violet-500 ${className}`; return src && failed !== src ? {`${name}头像`} setFailed(src)} /> - : {`${name}默认头像`}; + : {`${name}默认头像`}; } -export function CloudAvatarEditor({ value, name, disabled, uploading, onUploading, onChange }: { - value: CloudAgentAvatar; name: string; disabled: boolean; uploading: boolean; +export function CloudAvatarEditor({ value, name, identity, disabled, uploading, onUploading, onChange }: { + value: CloudAgentAvatar; name: string; identity?: string; disabled: boolean; uploading: boolean; onUploading: (busy: boolean) => void; onChange: (avatar: CloudAgentAvatar) => void; }) { const [error, setError] = useState(''); @@ -47,10 +45,10 @@ export function CloudAvatarEditor({ value, name, disabled, uploading, onUploadin if (alive.current) { setPendingPreset(null); onUploading(false); } } }; - const selectedId = value.icon ? (selection?.icon === value.icon ? selection.id : null) : defaultAvatar(name || '智能体').id; + const selectedId = value.icon ? (selection?.icon === value.icon ? selection.id : null) : defaultAvatar.id; return
- +

头像

diff --git a/src/pages/CloudAgents/ConfigurationFields.tsx b/src/pages/CloudAgents/ConfigurationFields.tsx index 85027601..643b1789 100644 --- a/src/pages/CloudAgents/ConfigurationFields.tsx +++ b/src/pages/CloudAgents/ConfigurationFields.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; import { cloudAgentsApi } from '@/lib/cloud-agents-api'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -7,9 +7,8 @@ import { CloudResources } from './CloudResources'; import { CloudBudgetEditor } from './CloudCosts'; import type { CloudAgentConfiguration, CloudCatalog } from '../../../shared/cloud-agents'; -export function ConfigurationFields({ slug, value, disabled, onChange, section = 'all', chooseInitialModel = false }: { - chooseInitialModel?: boolean; - section?: 'all' | 'instructions' | 'capabilities' | 'knowledge' | 'limits'; +export function ConfigurationFields({ slug, value, disabled, onChange, section = 'all' }: { + section?: 'all' | 'instructions' | 'model' | 'capabilities' | 'knowledge' | 'limits'; slug: string; value: CloudAgentConfiguration; disabled: boolean; onChange: (value: CloudAgentConfiguration) => void; }) { const [catalog, setCatalog] = useState(null); @@ -17,30 +16,25 @@ export function ConfigurationFields({ slug, value, disabled, onChange, section = const [refresh, setRefresh] = useState(0); const [search, setSearch] = useState(''); const [choosing, setChoosing] = useState(null); - const initialModelChosen = useRef(false); useEffect(() => { let live = true; cloudAgentsApi.call('catalog', {}).then(v => { if (live) { setCatalog(v); setError(''); } }) .catch(e => { if (live) setError(e instanceof Error ? e.message : '配置目录暂不可用'); }); return () => { live = false; }; }, [refresh]); - useEffect(() => { - if (!chooseInitialModel || initialModelChosen.current || !catalog?.models.length || disabled) return; - initialModelChosen.current = true; - if (!value.model) onChange({ ...value, model: catalog.models[0].id }); - }, [catalog, chooseInitialModel, disabled, onChange, value]); const change = (key: K, next: CloudAgentConfiguration[K]) => onChange({ ...value, [key]: next }); return
- - { if (!uploading) setEditingAvatar(open); }}> 修改头像选择这个智能体的头像。 - setDraft(current => ({ ...current, ...avatar }))} /> diff --git a/src/pages/CloudAgents/agent-home.css b/src/pages/CloudAgents/agent-home.css index 1033d6f8..d29e0de5 100644 --- a/src/pages/CloudAgents/agent-home.css +++ b/src/pages/CloudAgents/agent-home.css @@ -10,14 +10,20 @@ .agent-home-header { display: flex; align-items: center; justify-content: space-between; gap: 24px; margin-bottom: 36px; } .agent-home-intro { min-width: 0; } .agent-home-intro h1 { margin: 0; font-size: 30px; font-weight: 700; line-height: 1.3; letter-spacing: -.5px; } -.agent-home-intro p { margin-top: 12px; font-size: 15px; line-height: 1.8; color: #606c7e; } -.agent-home-intro-break { display: none; } +.agent-home-greeting { position: relative; display: flex; align-items: center; gap: 14px; padding-block: 12px; } +.agent-home-greeting h1 { font-size: clamp(24px, 2.4vw, 32px); line-height: 1.7; letter-spacing: -.6px; } +.agent-home-hello { display: inline-block; margin-right: 5px; color: #7263a6; transform: rotate(-6deg); } +.agent-home-create-word, .agent-home-chat-word { position: relative; isolation: isolate; display: inline-block; padding-inline: 4px; white-space: nowrap; } +.agent-home-create-word::before, .agent-home-chat-word::before { position: absolute; z-index: -1; inset: 12px 0 4px; border-radius: 8px; transform: rotate(-3deg); background: #ffe7bb; content: ''; } +.agent-home-chat-word::before { background: #e3eee8; transform: rotate(3deg); } +.agent-home-greeting-spark { display: grid; flex-shrink: 0; width: 42px; height: 42px; place-items: center; border-radius: 14px; background: #f0eafc; color: #8a72ba; transform: rotate(-8deg); } +.agent-home-greeting-spark svg { width: 27px; height: 27px; } +.agent-home-greeting-doodle { flex-shrink: 0; width: 38px; height: 38px; color: #729785; animation: agent-home-doodle-bob 5s ease-in-out infinite; } +@keyframes agent-home-doodle-bob { 0%, 100% { transform: translateY(0) rotate(5deg); } 50% { transform: translateY(-3px) rotate(-3deg); } } .agent-home-header-actions { display: flex; flex-shrink: 0; align-items: center; gap: 8px; } .agent-home button { min-height: 44px; } .agent-home-create { gap: 8px; min-height: 48px !important; padding-inline: 20px; border-radius: 14px; font-size: 15px; box-shadow: 0 3px 0 #af340d; } .agent-home-create svg { width: 19px; height: 19px; } -.agent-home-more { gap: 5px; padding-inline: 10px; color: #606c7e; font-weight: 500; } -.agent-home-more svg { width: 15px; height: 15px; } .agent-home-back { gap: 6px; margin: -12px 0 16px -12px; color: #606c7e; } .agent-home-back svg { width: 17px; height: 17px; } .agent-home-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 292px), 1fr)); gap: 20px; } @@ -37,11 +43,15 @@ .agent-home-card[data-state="inactive"] .agent-home-status::before { background: #778294; } .agent-home-card h2 { overflow: hidden; font-size: 21px; font-weight: 650; line-height: 1.5; text-overflow: ellipsis; white-space: nowrap; } .agent-home-purpose { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; min-height: 78px; overflow: hidden; margin-top: 8px; font-size: 15px; line-height: 26px; color: #606c7e; white-space: pre-wrap; overflow-wrap: anywhere; } -.agent-home-card-actions { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: auto; padding-top: 20px; } +.agent-home-card-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: auto; padding-top: 20px; } .agent-home-card-primary { gap: 7px; border-radius: 12px; font-size: 14px; } .agent-home-card-primary svg { width: 17px; height: 17px; } .agent-home-card-primary.border { border-color: #c6ccda; background: #ffffffb3; color: #35435b; } -.agent-home-adjust { padding-inline: 12px; border-radius: 12px; color: #546176; } +.agent-home-adjust { padding-inline: 9px; border-radius: 12px; color: #546176; } +.agent-home-delete { gap: 5px; margin-left: auto; padding-inline: 8px; border-radius: 12px; color: #836b73; } +.agent-home-delete svg { width: 16px; height: 16px; } +.agent-home-delete:hover { color: #ac3d49; background: #fce9e9; } +.agent-home-delete-dialog { border-radius: 24px; } .agent-home-empty { display: flex; min-height: 410px; align-items: center; justify-content: center; flex-direction: column; padding: 48px 24px; border: 1px solid #e7e3f1; border-radius: 28px; background: #f7f5fd; text-align: center; } .agent-home-empty-icon { display: grid; width: 96px; height: 96px; place-items: center; margin-bottom: 24px; border-radius: 30px; background: #e8e5fa; color: #7161a5; } .agent-home-empty-icon svg { width: 52px; height: 52px; } @@ -49,31 +59,25 @@ .agent-home-empty p { margin: 12px 0 28px; font-size: 16px; line-height: 1.9; color: #606c7e; } .agent-home-loading { display: flex; min-height: 280px; align-items: center; justify-content: center; flex-direction: column; gap: 16px; color: #606c7e; } .agent-home-loading svg { width: 38px; height: 38px; color: #8273a9; } -.agent-home-error, .agent-home-filter { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 24px; padding: 12px 16px; border: 1px solid #e3dfd6; border-radius: 14px; background: #fff; font-size: 14px; } +.agent-home-error { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 24px; padding: 12px 16px; border: 1px solid #e3dfd6; border-radius: 14px; background: #fff; font-size: 14px; } .agent-home-error p { overflow-wrap: anywhere; } -.agent-home-filter { background: #f0eee9; border: 0; color: #606c7e; } -.agent-home-filter span { display: flex; align-items: center; gap: 8px; } -.agent-home-filter svg { width: 17px; height: 17px; flex-shrink: 0; } -.agent-home-menu { z-index: 60; min-width: 226px; padding: 6px; border: 1px solid #e5e4e0; border-radius: 14px; background: #fffdf9; box-shadow: 0 8px 28px #2732461a; color: #35435b; } -.agent-home-menu-item { display: flex; min-height: 44px; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 8px; outline: none; font-size: 14px; cursor: pointer; user-select: none; } -.agent-home-menu-item[data-highlighted] { background: #f0eef9; } -.agent-home-menu-item svg { width: 17px; height: 17px; flex-shrink: 0; color: #716785; } -.agent-home-menu-separator { height: 1px; margin: 5px 8px; background: #e9e6e0; } -.agent-home-menu-check { margin-left: auto; } @media (max-width: 1150px) { .agent-home { padding: 32px 28px 40px; } - .agent-home-intro-break { display: block; } } @media (max-width: 760px) { .agent-home { padding: 24px 20px 32px; } .agent-home-header { align-items: flex-start; flex-wrap: wrap; gap: 18px; margin-bottom: 28px; } .agent-home-intro h1 { font-size: 27px; } + .agent-home-greeting { gap: 10px; } + .agent-home-greeting h1 { font-size: 26px; } + .agent-home-greeting-doodle { display: none; } .agent-home-header-actions { flex-wrap: wrap; } .agent-home-card { padding: 22px; } .agent-home-empty { min-height: 360px; padding-inline: 20px; } .agent-home-empty h2 { font-size: 22px; } - .agent-home-filter, .agent-home-error { align-items: flex-start; flex-wrap: wrap; } + .agent-home-error { align-items: flex-start; flex-wrap: wrap; } } @media (prefers-reduced-motion: reduce) { .agent-home-card { transition: none; } + .agent-home-greeting-doodle { animation: none; } } diff --git a/src/pages/CloudAgents/desktop-workspace.css b/src/pages/CloudAgents/desktop-workspace.css index 118cf236..e1f79489 100644 --- a/src/pages/CloudAgents/desktop-workspace.css +++ b/src/pages/CloudAgents/desktop-workspace.css @@ -1,7 +1,8 @@ .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-header { display: flex; flex-wrap: wrap; flex-shrink: 0; align-items: center; gap: 12px; min-height: 64px; padding: 8px 20px; border-bottom: 1px solid hsl(var(--border)); } +.agent-workspace-identity { min-width: min(160px, 100%); } .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; } diff --git a/src/pages/CloudAgents/index.tsx b/src/pages/CloudAgents/index.tsx index e263a3c1..4a32d9f9 100644 --- a/src/pages/CloudAgents/index.tsx +++ b/src/pages/CloudAgents/index.tsx @@ -1,9 +1,9 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useBlocker, useLocation } from 'react-router-dom'; -import { Archive, ArrowLeft, Bot, Check, ChevronDown, History, MessageCircle, Pencil, Plus, Share2 } from 'lucide-react'; -import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; +import { ArrowLeft, Bot, MessageCircle, Pencil, Plus, Sparkles, Trash2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { cloudAgentsApi } from '@/lib/cloud-agents-api'; import { useAuthStore } from '@/stores/auth'; import { type CloudAgentAvatar, type CloudAgentDraft, type CreateCloudAgent, type CloudAgentEntry, type CloudThread, type CloudRecent, type CloudPendingOperation } from '../../../shared/cloud-agents'; @@ -36,7 +36,14 @@ function AgentWorkspace() { const [activity, setActivity] = useState(null); const [recent, setRecent] = useState(null); const [pending, setPending] = useState([]); - const [archived, setArchived] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleting, setDeleting] = useState(false); + const [deleteError, setDeleteError] = useState(''); + const cancelDelete = useRef(null); + const deleteTrigger = useRef(null); + const createButton = useRef(null); + const deletion = useRef(null); + const deletedSlugs = useRef(new Set()); const mounted = useRef(true); const request = useRef(0); @@ -45,16 +52,17 @@ function AgentWorkspace() { setLoading(true); setError(''); try { - const page = await cloudAgentsApi.list(next, archived); + const page = await cloudAgentsApi.list(next); if (!mounted.current || generation !== request.current) return; - setAgents((previous) => next ? [...previous, ...page.agents] : page.agents); + const visible = page.agents.filter(agent => !agent.archived && !deletedSlugs.current.has(agent.slug)); + setAgents((previous) => next ? [...previous, ...visible] : visible); setCursor(page.next_cursor); } catch (failure) { if (mounted.current && generation === request.current) setError(errorMessage(failure)); } finally { if (mounted.current && generation === request.current) setLoading(false); } - }, [archived]); + }, []); useEffect(() => { mounted.current = true; @@ -83,6 +91,12 @@ function AgentWorkspace() { try { draft = await cloudAgentsApi.get(pointer.slug); } catch { entry = await cloudAgentsApi.call('entry', { slug: pointer.slug }); } if (!live) return; + // A deleted/archived Agent must not reopen through the last-workspace pointer. + if (draft?.archived || deletedSlugs.current.has(pointer.slug)) { + void cloudAgentsApi.remember(null).catch(() => undefined); + return; + } + if (deletion.current === pointer.slug) return; setRecent(pointer); if (draft) setSelected(draft); else setShared(entry); }).catch(failure => { if (live) setError('上次工作区未能恢复:' + errorMessage(failure)); }); @@ -112,6 +126,33 @@ function AgentWorkspace() { setEntryView(view); setSelected(agent); }; + const removeAgent = async () => { + if (!deleteTarget || deletion.current || !mounted.current) return; + const slug = deleteTarget.slug; + deletion.current = slug; + setDeleting(true); + setDeleteError(''); + try { + await cloudAgentsApi.call('archiveAgent', { slug, archived: true }); + if (!mounted.current) return; + deletedSlugs.current.add(slug); + // Invalidate an older list request so it cannot put the card back. + request.current += 1; + setLoading(false); + setAgents(previous => previous.filter(agent => agent.slug !== slug)); + setDeleteTarget(null); + const state = await cloudAgentsApi.recovery(); + if (mounted.current && state.recent?.slug === slug) { + await cloudAgentsApi.remember(null); + } + } catch (failure) { + // Once archived successfully, a failed pointer cleanup must not undo removal. + if (mounted.current && !deletedSlugs.current.has(slug)) setDeleteError(errorMessage(failure)); + } finally { + deletion.current = null; + if (mounted.current) setDeleting(false); + } + }; if (selected) return
{recovery} { setSelected(null); @@ -133,40 +174,28 @@ function AgentWorkspace() {
{section !== 'mine' && } -

{section === 'mine' ? '我的智能体' : section === 'received' ? '收到的分享' : '活动'}

- {section === 'mine' &&

创建自己的 AI 助手,告诉它怎样帮助你。
也可以继续和已有的助手聊天。

} + {section !== 'mine' &&

{section === 'received' ? '收到的分享' : '活动'}

} + {section === 'mine' &&
+ +

Hi,今天想创造还是想聊聊?

+ +
}
- {(section !== 'mine' || loading || error || agents.length > 0 || archived) && - } - - - - - - - setSection('received')}> - setSection('activity')}> - - { setArchived(value); setSection('mine'); }}> - - - - + {(section !== 'mine' || loading || error || agents.length > 0) && + }
{error &&

{error}

} {section !== 'mine' ? : <> - {archived &&
} {loading && agents.length === 0 ?
: !error && agents.length === 0 ?

做一个属于你的 AI 助手吧

给它起个名字、选好头像,
再设置它怎样帮助你。

- +
: null}
{agents.map(agent => { @@ -175,16 +204,17 @@ function AgentWorkspace() { const unavailable = Boolean(agent.archived || !agent.enabled); return
-
+
{agent.archived ? '已归档' : !agent.enabled ? '已停用' : ready ? '可以使用' : '还在制作'}

{agent.name}

-

{agent.purpose}

+ {agent.purpose.trim() &&

{agent.purpose}

}
{ready && } +
; })} @@ -192,6 +222,21 @@ function AgentWorkspace() { {cursor && } }
+ { if (!open && !deletion.current) { setDeleteTarget(null); setDeleteError(''); } }}> + { event.preventDefault(); cancelDelete.current?.focus(); }} + onCloseAutoFocus={event => { event.preventDefault(); (deleteTrigger.current?.isConnected ? deleteTrigger.current : createButton.current)?.focus(); }} + onEscapeKeyDown={event => { if (deletion.current) event.preventDefault(); }} onInteractOutside={event => event.preventDefault()}> + + 删除「{deleteTarget?.name}」? + 它会从「我的智能体」中移除,并停止后续使用。已有的聊天记录会保留。 + + {deleteError &&

{deleteError}。智能体仍在列表中,你可以重试。

} + + + + +
+
); } @@ -217,8 +262,7 @@ function CreateAgentForm({ onCreated, onCancel }: { onCreated: (agent: CloudAgen const submit = async () => { if (busy || uploading || !name.trim()) return; - // The existing service requires a purpose; actual instructions are configured on the next page. - const input = intent ?? { operation_id: crypto.randomUUID(), name: name.trim(), purpose: '按照我的提示词提供帮助。', icon: avatar.icon ?? null }; + const input = intent ?? { operation_id: crypto.randomUUID(), name: name.trim(), purpose: '', icon: avatar.icon ?? null }; setIntent(input); setBusy(true); setError(''); @@ -271,7 +315,7 @@ function SharedConversation({ shared, activity, recent, onBack }: { }, [pending.hasPending]); return
-

{shared && }{shared?.name ?? activity?.title}

+

{shared && }{shared?.name ?? activity?.title}

diff --git a/tests/e2e/cloud-agent-channels.spec.ts b/tests/e2e/cloud-agent-channels.spec.ts index 0adf45c6..60a0191e 100644 --- a/tests/e2e/cloud-agent-channels.spec.ts +++ b/tests/e2e/cloud-agent-channels.spec.ts @@ -95,13 +95,8 @@ test('own WeChat conversation approval and generated PPT delivery in the desktop expect(calls.find(call => call.operation === 'channelConversationControl')?.input).toMatchObject({ session_id: 'self-session', run_id: 'channel-run', action: 'resume', decision: { decisions: [{ type: 'approve' }] } }); expect(calls.find(call => call.operation === 'downloadChannelArtifact')?.input).toEqual({ session_id: 'self-session', path: '/outputs/创作建议.pptx' }); expect(calls.some(call => call.operation === 'resume' || call.operation === 'submit')).toBe(false); - await page.getByRole('button', { name: '使用与接入', exact: true }).click(); - const linked = page.getByRole('region', { name: '已关联渠道', exact: true }); - await expect(linked.getByText('创作微信', { exact: true })).toBeVisible(); - await expect(page.getByRole('region', { name: '个人微信渠道', exact: true })).toHaveCount(0); - await linked.scrollIntoViewIfNeeded(); - await page.screenshot({ path: testInfo.outputPath('agent-linked-channels-shortcut.png') }); - await linked.getByRole('button', { name: /创作微信/ }).click(); + await page.getByTestId('cloud-agent-editor').getByRole('button', { name: '我的智能体', exact: true }).click(); + await page.getByRole('button', { name: '渠道', exact: true }).click(); await expect(page.getByRole('heading', { name: '微信', exact: true })).toBeVisible(); await expect(page.getByRole('button', { name: /创作微信.*我的创作助手/ })).toBeVisible(); await expect(page.getByRole('button', { name: /备用微信.*未分配智能体/ })).toBeVisible(); diff --git a/tests/e2e/cloud-agent-home.spec.ts b/tests/e2e/cloud-agent-home.spec.ts index 91e28ffa..2aa2cc94 100644 --- a/tests/e2e/cloud-agent-home.spec.ts +++ b/tests/e2e/cloud-agent-home.spec.ts @@ -11,19 +11,20 @@ const agent = (character: string, name: string, published: boolean, extra: Parti }); const readyAgent = agent('a', '我的故事搭档', true); -const draftAgent = agent('b', '我的英语陪练', false); +const draftAgent = agent('b', '我的英语陪练', false, { purpose: '' }); const disabledAgent = agent('c', '暂停使用的助手', true, { enabled: false }); const archivedAgent = agent('d', '以前的学习助手', true, { archived: true }); -async function openHome(app: ElectronApplication, agents: CloudAgentDraft[]): Promise { +async function openHome(app: ElectronApplication, agents: CloudAgentDraft[], failArchiveOnce = false): Promise { const page = await getStableWindow(app); await expect(page.getByTestId('ai-module-selection-page')).toBeVisible(); const applicationUrl = page.url(); await page.goto('about:blank'); // The UI fixture owns the Host API boundary. These tests never send a real message or publish an Agent. - await app.evaluate(({ ipcMain, BrowserWindow }, items) => { + await app.evaluate(({ ipcMain, BrowserWindow }, { items, failArchiveOnce }) => { BrowserWindow.getAllWindows()[0].setSize(1366, 768); let recent: unknown = null; + let archiveAttempts = 0; const result = (json: unknown) => ({ ok: true, data: { status: 200, ok: true, json } }); ipcMain.removeHandler('hostapi:fetch'); ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string; method?: string; body?: string }) => { @@ -57,6 +58,13 @@ async function openHome(app: ElectronApplication, agents: CloudAgentDraft[]): Pr const body = JSON.parse(request.body ?? '{}'); const selected = items.find(item => item.slug === body.input?.slug) ?? items[0]; switch (body.operation) { + case 'archiveAgent': { + archiveAttempts += 1; + if (failArchiveOnce && archiveAttempts === 1) throw new Error('暂时无法删除,请稍后重试'); + if (!selected) throw new Error('Unknown fixture Agent'); + selected.archived = body.input.archived; + return result(selected); + } case 'catalog': return result({ models: [{ id: 'fixture-model', name: '创作模型' }], resources: {}, pricing: null }); case 'resources': return result({ mcps: [], skills: [], subagents: [] }); case 'knowledge': return result({ databases: [], models: [] }); @@ -75,7 +83,7 @@ async function openHome(app: ElectronApplication, agents: CloudAgentDraft[]): Pr } throw new Error('Unexpected homepage fixture request ' + path); }); - }, agents); + }, { items: agents, failArchiveOnce }); await page.goto(applicationUrl); await page.getByTestId('ai-module-option-cloud_agents').click(); await expect(page.getByTestId('cloud-agents-page')).toBeVisible(); @@ -87,7 +95,10 @@ test('Agent homepage guides the first creation without starter tiles', async ({ try { const page = await openHome(app, []); const home = page.getByTestId('cloud-agents-page'); - await expect(home.getByRole('heading', { name: '我的智能体', exact: true })).toBeVisible(); + await expect(home.getByRole('heading', { name: 'Hi,今天想创造还是想聊聊?', exact: true })).toBeVisible(); + await expect(home.getByRole('heading', { name: '我的智能体', exact: true })).toHaveCount(0); + await expect(page.getByText('智能体工作区', { exact: true })).toHaveCount(0); + await expect(home.getByRole('button', { name: '更多', exact: true })).toHaveCount(0); await expect(home.getByRole('heading', { name: '做一个属于你的 AI 助手吧', exact: true })).toBeVisible(); await expect(home.getByRole('article')).toHaveCount(0); await expect(home.getByRole('button', { name: /故事搭档|英语陪练|学习Friend/ })).toHaveCount(0); @@ -125,6 +136,8 @@ test('Agent cards open conversation, adjustment and unfinished creation explicit await expect(ready.getByRole('button', { name: '调整', exact: true })).toBeVisible(); await expect(draft.getByRole('button', { name: '继续制作', exact: true })).toBeVisible(); await expect(draft.getByRole('button', { name: '和它聊聊', exact: true })).toHaveCount(0); + await expect(draft.locator('.agent-home-purpose')).toHaveCount(0); + await expect(draft.getByRole('button', { name: `删除${draftAgent.name}`, exact: true })).toBeVisible(); await expect(disabled.getByRole('button', { name: '查看与调整', exact: true })).toBeVisible(); await expect(disabled.getByRole('button', { name: '和它聊聊', exact: true })).toHaveCount(0); await page.screenshot({ path: testInfo.outputPath('cloud-agent-home-populated.png') }); @@ -137,46 +150,55 @@ test('Agent cards open conversation, adjustment and unfinished creation explicit await ready.getByRole('button', { name: '和它聊聊', exact: true }).click(); await expect(page.getByRole('region', { name: '智能体对话', exact: true })).toBeVisible(); - await expect(page.getByRole('navigation', { name: '智能体工作区' }).getByRole('button', { name: '聊天', exact: true })).toHaveAttribute('aria-current', 'page'); + await expect(page.getByTestId('cloud-agent-editor').getByRole('button', { name: '聊天', exact: true })).toHaveCount(0); await page.getByTestId('cloud-agent-editor').getByRole('button', { name: '我的智能体', exact: true }).click(); await expect(ready).toBeVisible(); await ready.getByRole('button', { name: '调整', exact: true }).click(); - await expect(page.getByRole('navigation', { name: '智能体工作区' }).getByRole('button', { name: '配置', exact: true })).toHaveAttribute('aria-current', 'page'); - await expect(page.getByRole('tab', { name: '模型与提示词', exact: true })).toHaveAttribute('aria-selected', 'true'); + await expect(page.getByRole('tab', { name: '介绍与提示词', exact: true })).toHaveAttribute('aria-selected', 'true'); + await expect(page.getByTestId('cloud-agent-editor').getByRole('button', { name: /^(聊天|自动任务|使用与接入)$/ })).toHaveCount(0); await expect(page.getByTestId('cloud-agent-editor').getByRole('button', { name: '修改头像', exact: true })).toBeVisible(); await expect(page.getByRole('img', { name: readyAgent.name + '默认头像', exact: true })).toBeVisible(); await expect(page.getByLabel('提示词', { exact: true })).toHaveValue(readyAgent.system_prompt); await page.getByTestId('cloud-agent-editor').getByRole('button', { name: '我的智能体', exact: true }).click(); await draft.getByRole('button', { name: '继续制作', exact: true }).click(); - await expect(page.getByRole('navigation', { name: '智能体工作区' }).getByRole('button', { name: '配置', exact: true })).toHaveAttribute('aria-current', 'page'); - await expect(page.getByRole('tab', { name: '模型与提示词', exact: true })).toHaveAttribute('aria-selected', 'true'); + await expect(page.getByRole('tab', { name: '介绍与提示词', exact: true })).toHaveAttribute('aria-selected', 'true'); + await expect(page.getByLabel('一句话介绍', { exact: true })).toHaveValue(''); await expect(page.getByLabel('名称', { exact: true })).toHaveValue(draftAgent.name); await expect(page.getByLabel('提示词', { exact: true })).toHaveValue(draftAgent.system_prompt); } finally { await closeElectronApp(app); } }); -test('Secondary sharing, activity and archived Agents remain reachable from More', async ({ launchElectronApp }) => { +test('Agent deletion confirms, preserves a failed card for retry and keeps other cards', async ({ launchElectronApp }, testInfo) => { const app = await launchElectronApp({ skipSetup: true }); try { - const page = await openHome(app, [readyAgent, archivedAgent]); + const page = await openHome(app, [readyAgent, draftAgent, archivedAgent], true); const home = page.getByTestId('cloud-agents-page'); await expect(home.getByRole('article', { name: archivedAgent.name, exact: true })).toHaveCount(0); - await home.getByRole('button', { name: '更多', exact: true }).click(); - await page.getByRole('menuitemcheckbox', { name: '查看已归档智能体', exact: true }).click(); - const archived = home.getByRole('article', { name: archivedAgent.name, exact: true }); - await expect(archived).toBeVisible(); - await expect(home.getByRole('article', { name: readyAgent.name, exact: true })).toBeVisible(); - await expect(archived.getByRole('button', { name: '查看与调整', exact: true })).toBeVisible(); - await expect(archived.getByRole('button', { name: '和它聊聊', exact: true })).toHaveCount(0); - await home.getByRole('button', { name: '更多', exact: true }).click(); - await page.getByRole('menuitem', { name: '收到的分享', exact: true }).click(); - await expect(page.getByText('还没有收到分享', { exact: true })).toBeVisible(); - await page.getByRole('button', { name: '返回我的智能体', exact: true }).click(); - await expect(home.getByRole('heading', { name: '我的智能体', exact: true })).toBeVisible(); - await home.getByRole('button', { name: '更多', exact: true }).click(); - await page.getByRole('menuitem', { name: '活动', exact: true }).click(); - await expect(page.getByText('还没有运行记录', { exact: true })).toBeVisible(); - await page.getByRole('button', { name: '返回我的智能体', exact: true }).click(); - await expect(home.getByRole('heading', { name: '我的智能体', exact: true })).toBeVisible(); + await expect(home.getByRole('button', { name: '更多', exact: true })).toHaveCount(0); + await expect(home.getByText('收到的分享', { exact: true })).toHaveCount(0); + await expect(home.getByText('活动', { exact: true })).toHaveCount(0); + await expect(home.getByText('查看已归档智能体', { exact: true })).toHaveCount(0); + const ready = home.getByRole('article', { name: readyAgent.name, exact: true }); + const draft = home.getByRole('article', { name: draftAgent.name, exact: true }); + await ready.getByRole('button', { name: `删除${readyAgent.name}`, exact: true }).click(); + const dialog = page.getByRole('dialog', { name: `删除「${readyAgent.name}」?`, exact: true }); + await expect(dialog.getByText('它会从「我的智能体」中移除,并停止后续使用。已有的聊天记录会保留。', { exact: true })).toBeVisible(); + await expect(dialog.getByRole('button', { name: '取消', exact: true })).toBeFocused(); + await dialog.getByRole('button', { name: '取消', exact: true }).click(); + await expect(dialog).toBeHidden(); + await expect(ready).toBeVisible(); + await ready.getByRole('button', { name: `删除${readyAgent.name}`, exact: true }).click(); + await dialog.getByRole('button', { name: '删除', exact: true }).click(); + await expect(dialog.getByRole('alert')).toContainText('智能体仍在列表中,你可以重试。'); + await page.screenshot({ path: testInfo.outputPath('cloud-agent-home-delete-retry.png') }); + await dialog.getByRole('button', { name: '重试删除', exact: true }).click(); + await expect(dialog).toBeHidden(); + await expect(ready).toHaveCount(0); + await expect(draft).toBeVisible(); + await expect(home.getByRole('article')).toHaveCount(1); + await page.reload(); + await expect(home.getByRole('heading', { name: 'Hi,今天想创造还是想聊聊?', exact: true })).toBeVisible(); + await expect(ready).toHaveCount(0); + await expect(draft).toBeVisible(); } finally { await closeElectronApp(app); } }); diff --git a/tests/e2e/cloud-agent-platform-avatars.spec.ts b/tests/e2e/cloud-agent-platform-avatars.spec.ts index 91b95685..e078d593 100644 --- a/tests/e2e/cloud-agent-platform-avatars.spec.ts +++ b/tests/e2e/cloud-agent-platform-avatars.spec.ts @@ -156,10 +156,10 @@ for (const width of [375, 1366]) { await page.screenshot({ path: test.info().outputPath(`agent-cards-${width}.png`), fullPage: true, animations: 'disabled' }); await ready.getByRole('button', { name: '和它聊聊', exact: true }).click(); await expect(page.getByRole('region', { name: '智能体对话', exact: true })).toBeVisible(); - await expect(page.getByRole('navigation', { name: '智能体工作区' }).getByRole('button', { name: '聊天', exact: true })).toHaveAttribute('aria-current', 'page'); + await expect(page.getByRole('button', { name: /^(聊天|自动任务|使用与接入)$/ })).toHaveCount(0); await page.getByRole('button', { name: '我的智能体', exact: true }).click(); await ready.getByRole('button', { name: '调整', exact: true }).click(); - await expect(page.getByRole('tab', { name: '模型与提示词', exact: true })).toHaveAttribute('aria-selected', 'true'); + await expect(page.getByRole('tab', { name: '介绍与提示词', exact: true })).toHaveAttribute('aria-selected', 'true'); await expect(page.getByLabel('提示词', { exact: true })).toHaveValue(readyAgent.system_prompt); await expectNoOverflow(page); }); diff --git a/tests/e2e/cloud-agents.spec.ts b/tests/e2e/cloud-agents.spec.ts index fab55bd2..e9aaf090 100644 --- a/tests/e2e/cloud-agents.spec.ts +++ b/tests/e2e/cloud-agents.spec.ts @@ -136,280 +136,114 @@ test('personal cloud Agent identity, configuration and separate published chat i await page.getByRole('button', { name: '下一步', exact: true }).click(); const editor = page.getByTestId('cloud-agent-editor'); - const workspaceNavigation = page.getByRole('navigation', { name: '智能体工作区' }); - const configurationButton = workspaceNavigation.getByRole('button', { name: '配置', exact: true }); - const chatButton = workspaceNavigation.getByRole('button', { name: '聊天', exact: true }); - const instructionsTab = page.getByRole('tab', { name: '模型与提示词', exact: true }); + const instructionsTab = page.getByRole('tab', { name: '介绍与提示词', exact: true }); const advancedTab = page.getByRole('tab', { name: '高级设置', exact: true }); const prompt = page.getByLabel('提示词', { exact: true }); - const initialPrompt = '使用简洁自然的中文。先理解我的目的,再协助梳理结构。'; - const revisedPrompt = initialPrompt + '补充具体例子。'; + const introduction = page.getByLabel('一句话介绍', { exact: true }); + const initialPrompt = ' 使用简洁自然的中文。\n先理解我的目的,再协助梳理结构。 '; await expect(editor).toBeVisible(); - await expect(page.getByTestId('titlebar-page-title')).toHaveText('智能体工作区'); + await expect(page.getByTestId('titlebar-page-title')).toHaveText('我的智能体'); await expect(page.getByLabel('名称', { exact: true })).toHaveValue('我的写作搭档'); + await page.getByRole('button', { name: '修改名称', exact: true }).click(); + await expect(page.getByLabel('名称', { exact: true })).toBeFocused(); await expect(page.getByRole('button', { name: '修改头像', exact: true }).getByRole('img')).toHaveAttribute('src', avatar.icon_url); - await expect(configurationButton).toHaveAttribute('aria-current', 'page'); + await expect(introduction).toHaveValue(''); await expect(prompt).toHaveValue(''); - await expect(page.getByLabel('模型', { exact: true })).toHaveValue('test-model'); - expect(await page.getByLabel('模型', { exact: true }).evaluate(el => Boolean(el.compareDocumentPosition(window.document.querySelector('textarea[aria-label="提示词"]')!) & Node.DOCUMENT_POSITION_FOLLOWING))).toBe(true); - await expect(page.getByLabel('用途', { exact: true })).toHaveCount(0); - await expect(page.getByLabel('特别要求')).toHaveCount(0); - await expect(page.getByText('模型说明不应显示在配置页', { exact: true })).toHaveCount(0); - await expect(page.getByText(/9\.99/)).toHaveCount(0); - await expect(page.getByRole('button', { name: /先给我提示|简单易懂|条理清楚|详细深入/ })).toHaveCount(0); + await expect(page.getByLabel('模型', { exact: true })).toBeHidden(); + await expect(page.getByRole('button', { name: /^(聊天|自动任务|使用与接入)$/ })).toHaveCount(0); await expect(page.getByRole('region', { name: '草稿预览', exact: true })).toHaveCount(0); - await expect(page.getByRole('separator', { name: '调整配置与试用宽度' })).toHaveCount(0); await expect(page.getByLabel('消息', { exact: true })).toHaveCount(0); + await expect(page.getByText(/9\.99|模型说明不应显示在配置页/)).toHaveCount(0); const created = await fixtureState(); - expect(created.createInput).toMatchObject({ name: '我的写作搭档', icon: avatar.icon }); + expect(created.createInput).toMatchObject({ name: '我的写作搭档', icon: avatar.icon, purpose: '' }); expect(created.createInput).not.toHaveProperty('icon_url'); - expect(created.createInput.purpose).toBeTruthy(); - expect(created.draft).toMatchObject({ name: '我的写作搭档', icon: avatar.icon, icon_url: avatar.icon_url, published_version: null }); expect(await messageOperations()).toEqual([]); + await page.getByRole('button', { name: '修改头像', exact: true }).click(); const avatarDialog = page.getByRole('dialog', { name: '修改头像', exact: true }); await expect(avatarDialog.getByRole('img', { name: '我的写作搭档头像', exact: true })).toHaveAttribute('src', avatar.icon_url); await avatarDialog.getByRole('button', { name: '完成', exact: true }).click(); + await introduction.fill('一起梳理故事和文章'); await prompt.fill(initialPrompt); + await page.getByRole('button', { name: '开始使用', exact: true }).click(); + await expect(advancedTab).toHaveAttribute('aria-selected', 'true'); + await expect(page.getByRole('button', { name: '模型', exact: true })).toHaveAttribute('aria-current', 'page'); + await expect(page.getByLabel('模型', { exact: true })).toBeVisible(); + await expect(page.getByLabel('模型', { exact: true })).toHaveValue(''); + await expect(page.getByLabel('模型', { exact: true })).toHaveAttribute('required', ''); + await expect(page.getByRole('button', { name: '确认开始使用', exact: true })).toHaveCount(0); + expect((await fixtureState()).operations).not.toContain('publish'); await page.getByLabel('模型', { exact: true }).selectOption('reasoning-model'); + await page.getByRole('button', { name: '能力', exact: true }).click(); + await page.getByRole('button', { name: '选择工具', exact: true }).click(); + await page.getByRole('checkbox', { name: '资料整理工具', exact: true }).check(); + await instructionsTab.click(); + await expect(introduction).toHaveValue('一起梳理故事和文章'); + await expect(prompt).toHaveValue(initialPrompt); + await expect(page.getByLabel('模型', { exact: true })).toBeHidden(); await page.getByTestId('sidebar-module-switcher-trigger').click(); await expect(page.getByRole('dialog', { name: '未保存的修改' })).toBeVisible(); await page.getByRole('button', { name: '继续编辑', exact: true }).click(); await expect(prompt).toHaveValue(initialPrompt); - await expect(page.getByLabel('模型', { exact: true })).toHaveValue('reasoning-model'); - - await expect(instructionsTab).toHaveAttribute('aria-selected', 'true'); - await expect(page.getByRole('navigation', { name: '配置分类' })).toHaveCount(0); - await instructionsTab.focus(); - await instructionsTab.press('ArrowRight'); - await expect(advancedTab).toBeFocused(); - await expect(advancedTab).toHaveAttribute('aria-selected', 'true'); - await expect(page.getByRole('tabpanel', { name: '高级设置' })).toBeVisible(); - await expect(prompt).toBeHidden(); - await expect(page.getByRole('button', { name: '能力', exact: true })).toHaveAttribute('aria-current', 'page'); - await page.getByRole('button', { name: '选择工具', exact: true }).click(); - await page.getByRole('checkbox', { name: '资料整理工具', exact: true }).check(); - await page.getByRole('button', { name: '完成选择', exact: true }).click(); - await page.getByText('管理我的云端能力', { exact: true }).click(); - await page.getByRole('button', { name: '添加 MCP', exact: true }).click(); - await page.getByLabel('连接名称', { exact: true }).fill('尚未保存的资料连接'); - await page.getByLabel('连接地址', { exact: true }).fill('https://example.com/mcp'); - await instructionsTab.click(); - await expect(prompt).toHaveValue(initialPrompt); - await advancedTab.click(); - await expect(page.getByLabel('连接名称', { exact: true })).toHaveValue('尚未保存的资料连接'); - await expect(page.getByLabel('连接地址', { exact: true })).toHaveValue('https://example.com/mcp'); - await expect(page.getByRole('checkbox', { name: '资料整理工具', exact: true })).toBeChecked(); - await chatButton.click(); - await expect(page.getByText('保存配置后,开始使用这个智能体。', { exact: true })).toBeVisible(); - await expect(page.getByLabel('消息', { exact: true })).toHaveCount(0); - await configurationButton.click(); - await expect(page.getByLabel('连接名称', { exact: true })).toHaveValue('尚未保存的资料连接'); - await page.getByRole('button', { name: '返回能力列表', exact: true }).click(); - await page.screenshot({ path: testInfo.outputPath('cloud-agent-advanced-tab.png') }); - const configurationScroll = page.getByRole('tabpanel').locator('.agent-config-scroll'); - await configurationScroll.evaluate(el => { el.scrollTop = el.scrollHeight; }); - await expect.poll(() => configurationScroll.evaluate(el => el.scrollTop)).toBeGreaterThan(0); - await instructionsTab.click(); - await expect.poll(() => configurationScroll.evaluate(el => el.scrollTop)).toBe(0); - await expect(page.getByLabel('模型', { exact: true })).toBeInViewport(); - await expect(prompt).toBeInViewport(); - await page.screenshot({ path: testInfo.outputPath('cloud-agent-model-prompt-tab.png') }); - await advancedTab.click(); - await expect.poll(() => configurationScroll.evaluate(el => el.scrollTop)).toBe(0); - await page.getByRole('button', { name: '知识', exact: true }).click(); - await page.getByText('管理我的知识文档', { exact: true }).click(); - const knowledgeDescription = page.getByText(/上传到知识库的文档可供/); - await expect(knowledgeDescription).toContainText('embedding 向量化由平台承担,不计入用户用量、词元点数或智能体费用上限'); - await expect(knowledgeDescription).toContainText('知识处理如触发对话模型调用,仍按原模型实际用量计费'); - await page.getByRole('button', { name: '新建知识库', exact: true }).click(); - await expect(page.getByLabel('向量模型', { exact: true })).toBeDisabled(); - await page.getByText('管理员配置说明', { exact: true }).click(); - await expect(page.getByText(/Embedding 只在 Yuxi 配置/)).toBeVisible(); - await expect(page.getByText(/Yuxi 后台/)).toContainText('智能体管理 → 模型供应商'); - await expect(page.getByText(/Yuxi 后台/)).toContainText('服务地址和 API Key'); - await expect(page.getByText(/Works Square 运营后台|one-api/)).toHaveCount(0); - await page.getByLabel('知识库名称', { exact: true }).fill('我的创作资料'); - await instructionsTab.click(); - await expect(page.getByRole('navigation', { name: '配置分类' })).toHaveCount(0); - await expect(prompt).toHaveValue(initialPrompt); - await advancedTab.click(); - await expect(page.getByRole('button', { name: '知识', exact: true })).toHaveAttribute('aria-current', 'page'); - await expect(page.getByLabel('知识库名称', { exact: true })).toHaveValue('我的创作资料'); - await page.screenshot({ path: testInfo.outputPath('knowledge-empty.png') }); - await page.getByRole('button', { name: '刷新知识库与模型', exact: true }).click(); - await page.getByLabel('向量模型', { exact: true }).selectOption('fixture:test-embedding'); - await expect(page.getByLabel('知识库名称', { exact: true })).toHaveValue('我的创作资料'); - await expect(page.getByRole('button', { name: '创建并选用', exact: true })).toBeEnabled(); - await expect(page.getByText('管理员配置说明', { exact: true })).toHaveCount(0); - await page.getByRole('button', { name: '创建并选用', exact: true }).click(); - await expect(page.getByText('上传第一份文档,让智能体用你的资料回答。')).toBeVisible(); - await page.getByRole('button', { name: '上传文档', exact: true }).click(); - const document = page.getByRole('article', { name: '本体(Ontology)学习路线图.pdf' }); - await expect(document.getByText('等待处理', { exact: true })).toBeVisible(); - await expect(page.getByText('没有可用内容', { exact: true })).toBeVisible(); - await document.scrollIntoViewIfNeeded(); - await page.screenshot({ path: testInfo.outputPath('knowledge-queued.png') }); - await expect(document.getByText('正在解析文档', { exact: true })).toBeVisible(); - await expect(document.getByText('正在建立索引', { exact: true })).toBeVisible(); - await expect(document.getByText('可用于回答', { exact: true })).toBeVisible(); - await expect(document.getByText(/18 段可检索内容/)).toBeVisible(); - await document.scrollIntoViewIfNeeded(); - expect(await document.evaluate(el => el.scrollWidth - el.clientWidth)).toBeLessThanOrEqual(1); - await page.screenshot({ path: testInfo.outputPath('knowledge-ready.png') }); - - await page.getByRole('button', { name: '限制', exact: true }).click(); - await page.getByLabel('每次任务上限(词元点数)').fill('2.50'); - await page.getByLabel('每日上限(词元点数)').fill('10.00'); - await page.getByLabel('最多执行步数', { exact: true }).fill('60'); - await advancedTab.focus(); - await advancedTab.press('ArrowLeft'); - await expect(instructionsTab).toBeFocused(); - await expect(instructionsTab).toHaveAttribute('aria-selected', 'true'); - await expect(page.getByLabel('每日上限(词元点数)')).toBeHidden(); - await expect(prompt).toHaveValue(initialPrompt); - await instructionsTab.press('ArrowRight'); - await expect(page.getByRole('button', { name: '限制', exact: true })).toHaveAttribute('aria-current', 'page'); - await expect(page.getByLabel('每次任务上限(词元点数)')).toHaveValue('2.50'); - await expect(page.getByLabel('每日上限(词元点数)')).toHaveValue('10.00'); - await expect(page.getByLabel('最多执行步数', { exact: true })).toHaveValue('60'); - await chatButton.click(); - await configurationButton.click(); - await expect(page.getByLabel('每日上限(词元点数)')).toHaveValue('10.00'); - await page.getByRole('button', { name: '保存费用上限' }).click(); - await expect(page.getByRole('button', { name: '保存费用上限' })).toBeDisabled(); - await instructionsTab.click(); await page.getByRole('button', { name: '保存草稿', exact: true }).click(); await expect(page.getByRole('button', { name: '已保存', exact: true })).toBeDisabled(); - expect((await fixtureState()).draft).toMatchObject({ - name: '我的写作搭档', icon: avatar.icon, icon_url: avatar.icon_url, system_prompt: initialPrompt, published_version: null, - configuration: { model: 'reasoning-model', tools: ['fixture-tool'], knowledges: ['kb'], max_execution_steps: 60 }, - }); - expect(await messageOperations()).toEqual([]); - await prompt.fill(revisedPrompt); - await prompt.press('Control+s'); - await expect(page.getByText('云端已保存', { exact: true })).toBeVisible(); - expect((await fixtureState()).draft.system_prompt).toBe(revisedPrompt); - expect(await messageOperations()).toEqual([]); - 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(editor).toBeVisible(); - await expect(configurationButton).toHaveAttribute('aria-current', 'page'); - await expect(prompt).toHaveValue(revisedPrompt); + expect((await fixtureState()).draft).toMatchObject({ purpose: '一起梳理故事和文章', system_prompt: initialPrompt, configuration: { model: 'reasoning-model', tools: ['fixture-tool'] } }); + await editor.getByRole('button', { name: '我的智能体', exact: true }).click(); + const card = page.getByRole('article', { name: '我的写作搭档', exact: true }); + await expect(card.getByText('一起梳理故事和文章', { exact: true })).toBeVisible(); + await card.getByRole('button', { name: '继续制作', exact: true }).click(); + await introduction.fill(''); + await page.getByRole('button', { name: '保存草稿', exact: true }).click(); + await expect(page.getByRole('button', { name: '已保存', exact: true })).toBeDisabled(); + await editor.getByRole('button', { name: '我的智能体', exact: true }).click(); + await expect(card.getByText('一起梳理故事和文章', { exact: true })).toHaveCount(0); + await expect(card.locator('.agent-home-purpose')).toHaveCount(0); + await card.getByRole('button', { name: '继续制作', exact: true }).click(); + await expect(introduction).toHaveValue(''); + await expect(prompt).toHaveValue(initialPrompt); + await advancedTab.click(); + await expect(page.getByRole('button', { name: '模型', exact: true })).toHaveAttribute('aria-current', 'page'); await expect(page.getByLabel('模型', { exact: true })).toHaveValue('reasoning-model'); - await expect(page.getByRole('button', { name: '修改头像', exact: true }).getByRole('img')).toHaveAttribute('src', avatar.icon_url); + await page.screenshot({ path: testInfo.outputPath('cloud-agent-advanced-model.png') }); + await instructionsTab.click(); await app.evaluate(({ BrowserWindow }) => { const win = BrowserWindow.getAllWindows()[0]; win.setMinimumSize(800, 600); win.setSize(980, 680); }); + await expect(introduction).toBeInViewport(); await expect(prompt).toBeInViewport(); - await expect(configurationButton).toBeInViewport(); - await expect(page.getByRole('button', { name: '已保存', exact: true })).toBeInViewport(); - await expect(page.getByRole('region', { name: '草稿预览', exact: true })).toHaveCount(0); - await expect(page.getByLabel('消息', { exact: true })).toHaveCount(0); await expect.poll(() => editor.evaluate(el => el.scrollWidth - el.clientWidth)).toBeLessThanOrEqual(1); - await page.screenshot({ path: testInfo.outputPath('cloud-agent-config-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); - await expect(configurationButton).toBeInViewport(); - await expect(page.getByLabel('模型', { exact: true })).toBeInViewport(); + await expect(introduction).toBeInViewport(); await expect(page.getByRole('button', { name: '已保存', exact: true })).toBeInViewport(); await expect.poll(() => editor.evaluate(el => el.scrollWidth - el.clientWidth)).toBeLessThanOrEqual(1); } - await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].webContents.setZoomFactor(1)); - await page.screenshot({ path: testInfo.outputPath('cloud-agent-config.png') }); + await app.evaluate(({ BrowserWindow }) => { const win = BrowserWindow.getAllWindows()[0]; win.webContents.setZoomFactor(1); win.setSize(1366, 768); }); + await page.screenshot({ path: testInfo.outputPath('cloud-agent-introduction-prompt.png') }); expect(await messageOperations()).toEqual([]); - - await workspaceNavigation.getByRole('button', { name: '使用与接入', exact: true }).click(); - await expect(page.getByLabel('应用名称')).toHaveCount(0); - await page.screenshot({ path: testInfo.outputPath('cloud-agent-start-private-use.png') }); + await page.getByRole('button', { name: '开始使用', exact: true }).click(); await page.getByRole('button', { name: '确认开始使用', exact: true }).click(); const formalChat = page.getByRole('region', { name: '智能体对话', exact: true }); await expect(formalChat).toBeVisible(); - await expect(chatButton).toHaveAttribute('aria-current', 'page'); - await expect(prompt).toBeHidden(); - await expect(formalChat.getByRole('heading', { name: '创作建议' })).toHaveCount(0); expect((await fixtureState()).operations.filter((operation: string) => operation === 'publish')).toHaveLength(1); expect(await messageOperations()).toEqual([]); - await formalChat.getByRole('button', { name: '你可以怎样帮助我?请举一个例子。', exact: true }).click(); - await expect(formalChat.getByLabel('消息', { exact: true })).toHaveValue('你可以怎样帮助我?请举一个例子。'); - expect(await messageOperations()).toEqual([]); await formalChat.getByLabel('消息', { exact: true }).fill('先给我一些创作建议'); await formalChat.getByRole('button', { name: '发送', exact: true }).click(); await expect(formalChat.getByRole('heading', { name: '创作建议' })).toBeAttached(); expect(await messageOperations()).toEqual(['submit']); - const composer = formalChat.getByTestId('cloud-chat-composer'); - const beforeScroll = await composer.boundingBox(); - await formalChat.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 formalChat.getByLabel('消息', { exact: true }).fill('保存后继续的问题'); - await configurationButton.click(); - await expect(prompt).toHaveValue(revisedPrompt); - await expect(formalChat).toBeHidden(); - await expect(page.getByLabel('消息', { exact: true })).toBeHidden(); - await expect(page.getByRole('region', { name: '草稿预览', exact: true })).toHaveCount(0); + await page.getByRole('button', { name: '调整智能体', exact: true }).click(); + await expect(prompt).toHaveValue(initialPrompt); await page.getByLabel('名称', { exact: true }).fill('我的新版写作搭档'); await expect(page.getByRole('button', { name: '应用最新修改', exact: true })).toBeDisabled(); await page.getByRole('button', { name: '保存草稿', exact: true }).click(); await expect(page.getByText('修改已保存,尚未应用', { exact: true })).toBeVisible(); - const applyButton = page.getByRole('button', { name: '应用最新修改', exact: true }); - await expect(applyButton).toBeInViewport(); - await page.screenshot({ path: testInfo.outputPath('cloud-agent-apply-visible.png') }); - await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(1024, 768)); - await expect(applyButton).toBeInViewport(); - await page.screenshot({ path: testInfo.outputPath('cloud-agent-apply-compact.png') }); - await applyButton.click(); + await page.getByRole('button', { name: '应用最新修改', exact: true }).click(); await expect(page.getByRole('button', { name: '已应用', exact: true })).toBeDisabled(); - await expect(page.getByText('修改已保存,尚未应用', { exact: true })).toHaveCount(0); - await chatButton.click(); - await expect(formalChat.getByLabel('消息', { exact: true })).toHaveValue('保存后继续的问题'); - await expect(formalChat.getByRole('heading', { name: '创作建议' })).toBeAttached(); + await editor.getByRole('button', { name: '我的智能体', exact: true }).click(); + await page.getByRole('article', { name: '我的新版写作搭档', exact: true }).getByRole('button', { name: '和它聊聊', exact: true }).click(); + await expect(formalChat).toBeVisible(); + await expect(page.getByRole('button', { name: /^(聊天|自动任务|使用与接入)$/ })).toHaveCount(0); expect(await messageOperations()).toEqual(['submit']); - await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(980, 680)); - 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-chat-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); - await expect.poll(async () => { const bounds = await composer.boundingBox(); return bounds!.y + bounds!.height - await page.evaluate(() => innerHeight); }).toBeLessThanOrEqual(0); - await expect(formalChat.getByLabel('消息', { exact: true })).toHaveValue('保存后继续的问题'); - } - await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].webContents.setZoomFactor(1)); - await workspaceNavigation.getByRole('button', { name: '使用与接入', exact: true }).click(); - await expect(page.getByRole('heading', { name: '应用与 API', exact: true })).toBeVisible(); - await expect(page.getByRole('button', { name: '打开渠道', exact: true })).toBeVisible(); - await expect(page.getByLabel('应用名称')).toBeVisible(); - await expect(page.getByRole('heading', { name: '分享给指定用户', exact: true })).toHaveCount(0); - await expect(page.getByLabel('查找分享用户')).toHaveCount(0); - await expect(page.getByRole('button', { name: '复制分享链接', exact: true })).toHaveCount(0); - await page.screenshot({ path: testInfo.outputPath('cloud-agent-access-without-user-sharing.png') }); - await chatButton.click(); - await expect(formalChat.getByLabel('消息', { exact: true })).toHaveValue('保存后继续的问题'); - await formalChat.getByLabel('消息', { exact: true }).fill('每天早上八点半给我三个创作方向'); - await formalChat.getByRole('button', { name: '发送', exact: true }).click(); - await expect(formalChat.getByLabel('消息', { exact: true })).toHaveValue(''); - await expect(formalChat.getByRole('heading', { name: '创作建议' })).toBeAttached(); - expect(await messageOperations()).toEqual(['submit', 'submit']); - await page.getByRole('button', { name: '查看并配置自动任务' }).click(); - await expect(workspaceNavigation.getByRole('button', { name: '自动任务', exact: true })).toHaveAttribute('aria-current', 'page'); - await expect(page.getByLabel('任务名称')).toHaveValue('每天整理选题'); - await expect(page.getByLabel('执行时间')).toHaveValue('08:30'); - await expect(page.getByLabel('保存后启用')).not.toBeChecked(); - await page.getByLabel('保存后启用').check(); - await expect(page.getByText('启用前确认')).toBeVisible(); - await page.getByRole('button', { name: '保存并启用' }).click(); - await expect(page.getByRole('heading', { name: '每天整理选题' })).toBeVisible(); - await expect(page.getByText(/每天 08:30/)).toBeVisible(); - await page.getByRole('button', { name: '添加任务', exact: true }).click(); - await expect(page.getByLabel('保存后启用')).not.toBeChecked(); - await page.getByRole('button', { name: '取消', exact: true }).click(); - await page.screenshot({ path: testInfo.outputPath('cloud-agent-schedules.png') }); - expect(await messageOperations()).toEqual(['submit', 'submit']); + await page.screenshot({ path: testInfo.outputPath('cloud-agent-separate-chat.png') }); } finally { await app.evaluate(({ BrowserWindow }) => { for (const win of BrowserWindow.getAllWindows()) win.destroy(); }).catch(() => undefined); await closeElectronApp(app); diff --git a/tests/unit/cloud-agent-avatar.test.tsx b/tests/unit/cloud-agent-avatar.test.tsx index cf0a2c88..29db5073 100644 --- a/tests/unit/cloud-agent-avatar.test.tsx +++ b/tests/unit/cloud-agent-avatar.test.tsx @@ -27,6 +27,80 @@ it('shows a default avatar for absent or broken images and recovers when the ima expect(screen.getByRole('img', { name: '写作搭档头像' })).toHaveAttribute('src', 'https://images.test/new.png'); }); +it('keeps a new assistant portrait while its name is typed or cleared', () => { + const view = render(); + const portrait = screen.getByRole('img').getAttribute('src'); + for (const name of ['学', '学习', '学习 Friend', '学习 Friend 🚀', '']) { + view.rerender(); + expect(screen.getByRole('img')).toHaveAttribute('src', portrait); + expect(screen.getByRole('img')).toHaveAttribute('alt', `${name}默认头像`); + } +}); + +it('keeps an assistant portrait across renaming, reopening and a failed custom image', () => { + const view = render(); + const portrait = screen.getByRole('img').getAttribute('src'); + for (const name of ['英', '英语', '英语 Friend', '英语 Friend 🌟']) { + view.rerender(); + expect(screen.getByRole('img')).toHaveAttribute('src', portrait); + } + view.unmount(); + const reopened = render(); + expect(screen.getByRole('img')).toHaveAttribute('src', 'https://images.test/saved.png'); + fireEvent.error(screen.getByRole('img')); + expect(screen.getByRole('img')).toHaveAttribute('src', portrait); + reopened.rerender(); + expect(screen.getByRole('img')).toHaveAttribute('src', 'https://images.test/replaced.png'); +}); + +it('uses one default portrait across creation, persisted identities and reopening', () => { + const creation = render(); + const portrait = screen.getByRole('img').getAttribute('src'); + creation.rerender(); + expect(screen.getByRole('img')).toHaveAttribute('src', portrait); + creation.unmount(); + const view = render(); + expect(screen.getByRole('img')).toHaveAttribute('src', portrait); + view.rerender(); + expect(screen.getByRole('img')).toHaveAttribute('src', portrait); + view.rerender(); + expect(screen.getByRole('img')).toHaveAttribute('src', portrait); + view.unmount(); + render(); + expect(screen.getByRole('img')).toHaveAttribute('src', portrait); +}); + +it('keeps the default selection and chosen portrait stable while the name is edited', async () => { + api.useAvatarPreset.mockResolvedValue({ icon: '/chosen.png', icon_url: 'https://images.test/chosen.png' }); + function RenameEditor() { + const [name, setName] = useState('英语陪练'); + const [value, setValue] = useState({}); + const [uploading, setUploading] = useState(false); + return <> setName(event.target.value)} /> + ; + } + render(); + const portraits = () => screen.getAllByRole('button', { name: /^像素伙伴 / }); + const initialSelection = portraits().find(button => button.getAttribute('aria-pressed') === 'true')!; + const portrait = screen.getByRole('img', { name: '英语陪练默认头像' }).getAttribute('src'); + expect(initialSelection.querySelector('img')).toHaveAttribute('src', portrait); + for (const name of ['英', '英语', '英语 Friend']) { + fireEvent.change(screen.getByRole('textbox', { name: '助手名称' }), { target: { value: name } }); + expect(screen.getByRole('img', { name: `${name}默认头像` })).toHaveAttribute('src', portrait); + expect(initialSelection).toHaveAttribute('aria-pressed', 'true'); + expect(portraits().filter(button => button.getAttribute('aria-pressed') === 'true')).toHaveLength(1); + } + fireEvent.click(screen.getByRole('button', { name: '像素伙伴 7' })); + await waitFor(() => expect(screen.getByRole('img', { name: '英语 Friend头像' })).toHaveAttribute('src', 'https://images.test/chosen.png')); + fireEvent.change(screen.getByRole('textbox', { name: '助手名称' }), { target: { value: '新的伙伴' } }); + expect(screen.getByRole('img', { name: '新的伙伴头像' })).toHaveAttribute('src', 'https://images.test/chosen.png'); + expect(screen.getByRole('button', { name: '像素伙伴 7' })).toHaveAttribute('aria-pressed', 'true'); + fireEvent.click(screen.getByRole('button', { name: '移除头像' })); + expect(screen.getByRole('img', { name: '新的伙伴默认头像' })).toHaveAttribute('src', portrait); + expect(initialSelection).toHaveAttribute('aria-pressed', 'true'); + expect(api.useAvatarPreset).toHaveBeenCalledExactlyOnceWith('avatar-07'); +}); + it('offers sixteen platform portraits and uses the chosen portrait without a file picker', async () => { api.useAvatarPreset.mockResolvedValue({ icon: '/chosen.png', icon_url: 'https://images.test/chosen.png' }); render(); diff --git a/tests/unit/cloud-agents-main.test.ts b/tests/unit/cloud-agents-main.test.ts index d6d1fe6c..df61c18c 100644 --- a/tests/unit/cloud-agents-main.test.ts +++ b/tests/unit/cloud-agents-main.test.ts @@ -44,6 +44,26 @@ function moduleFor(fetchImpl: typeof fetch) { beforeEach(() => { vi.clearAllMocks(); journalRecords.clear(); clearWorksSquareSession(); login(); }); afterEach(() => { instances.splice(0).forEach((module) => module.dispose()); clearWorksSquareSession(); vi.useRealTimers(); }); +it.each(['', ' '])('preserves an empty introduction through create, save and list (%j)', async purpose => { + const emptyDraft = { ...draft, purpose: '' }; + const transport = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json(emptyDraft)) + .mockResolvedValueOnce(json({ ...emptyDraft, draft_revision: 2 })).mockResolvedValueOnce(json({ agents: [emptyDraft], next_cursor: null })); + const module = moduleFor(transport); + expect(await module.create({ ...input, purpose })).toMatchObject({ purpose: '' }); + expect(await module.save(draft.slug, { expected_revision: 1, name: draft.name, purpose, system_prompt: draft.system_prompt })).toMatchObject({ purpose: '' }); + expect((await module.list()).agents[0].purpose).toBe(''); + expect(JSON.parse(transport.mock.calls[1][1].body).purpose).toBe(''); + expect(JSON.parse(transport.mock.calls[2][1].body).purpose).toBe(''); +}); + +it.each([null, 42, 'x'.repeat(2001)])('rejects malformed or overlong introductions before network access', async purpose => { + const transport = vi.fn(); + const module = moduleFor(transport); + await expect(module.create({ ...input, purpose })).rejects.toMatchObject({ code: 'invalid_input' }); + await expect(module.save(draft.slug, { expected_revision: 1, name: draft.name, purpose, system_prompt: draft.system_prompt })).rejects.toMatchObject({ code: 'invalid_input' }); + expect(transport).not.toHaveBeenCalled(); +}); + describe('Main cloud Agents boundary', () => { it.each(Array.from({ length: 16 }, (_, index) => `avatar-${String(index + 1).padStart(2, '0')}`))('uploads the bundled platform portrait %s through scoped Main transport without a dialog', async id => { const icon = '/api/storage/public/images/creator/preset.png'; diff --git a/tests/unit/cloud-agents-page.test.tsx b/tests/unit/cloud-agents-page.test.tsx index 83242135..4da31896 100644 --- a/tests/unit/cloud-agents-page.test.tsx +++ b/tests/unit/cloud-agents-page.test.tsx @@ -36,14 +36,15 @@ beforeEach(() => { }); afterEach(cleanup); -it('does not label disabled or archived assistants as ready to use', async () => { +it('does not label disabled assistants as ready to use or show archived assistants', async () => { api.list.mockResolvedValue({ agents: [ { ...draft, published_version: 1, enabled: false }, { ...draft, slug: 'ml-' + 'b'.repeat(32), name: '归档助手', published_version: 1, archived: true }, ], next_cursor: null }); open(); expect(await screen.findByText('已停用')).toBeVisible(); - expect(screen.getByText('已归档')).toBeVisible(); + expect(screen.queryByText('已归档')).not.toBeInTheDocument(); + expect(screen.queryByRole('article', { name: '归档助手' })).not.toBeInTheDocument(); expect(screen.queryByText('可以使用')).not.toBeInTheDocument(); }); @@ -83,20 +84,25 @@ it('shows saved but unapplied changes and applies directly from the editor heade ['publish', expect.objectContaining({ slug: draft.slug, expected_revision: 4 })], ]); expect(screen.queryByText('修改已保存,尚未应用')).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: '配置', exact: true })).toHaveAttribute('aria-current', 'page'); + expect(screen.getByRole('tab', { name: '介绍与提示词' })).toHaveAttribute('aria-selected', 'true'); }); -it('requires saving before applying and preserves formal chat input across configuration and application', async () => { +it('keeps unsent input accessible before adjusting and applying configuration', async () => { publishedAssistant(4); + api.recovery.mockResolvedValue({ recent: null, pending: [] }); open(); + fireEvent.click(await screen.findByRole('button', { name: '和它聊聊', exact: true })); await screen.findByTestId('cloud-agent-editor'); await waitFor(() => expect(screen.getByRole('button', { name: '已应用', exact: true })).toBeDisabled()); - expect(screen.queryByLabelText('消息')).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: '聊天', exact: true })); const message = await screen.findByLabelText('消息'); await waitFor(() => expect(message).toBeEnabled()); fireEvent.change(message, { target: { value: '保留正式聊天的问题' } }); - fireEvent.click(screen.getByRole('button', { name: '配置', exact: true })); + fireEvent.click(screen.getByRole('button', { name: '调整智能体', exact: true })); + expect(message).toBeVisible(); + expect(message).toHaveValue('保留正式聊天的问题'); + expect(screen.getByText('对话中还有未完成的内容,请先发送或处理后再调整智能体。')).toBeVisible(); + fireEvent.change(message, { target: { value: '' } }); + fireEvent.click(screen.getByRole('button', { name: '调整智能体', exact: true })); expect(message).not.toBeVisible(); fireEvent.change(screen.getByLabelText('提示词'), { target: { value: '新的要求' } }); expect(screen.getByText('修改尚未保存和应用')).toBeVisible(); @@ -106,15 +112,16 @@ it('requires saving before applying and preserves formal chat input across confi expect(api.call.mock.calls.some(([operation]) => operation === 'publish')).toBe(false); fireEvent.click(screen.getByRole('button', { name: '应用最新修改', exact: true })); await waitFor(() => expect(screen.getByRole('button', { name: '已应用', exact: true })).toBeDisabled()); - expect(screen.getByRole('button', { name: '配置', exact: true })).toHaveAttribute('aria-current', 'page'); - fireEvent.click(screen.getByRole('button', { name: '聊天', exact: true })); + expect(screen.getByRole('tab', { name: '介绍与提示词' })).toHaveAttribute('aria-selected', 'true'); expect(screen.getByLabelText('消息')).toBe(message); - expect(message).toHaveValue('保留正式聊天的问题'); + expect(message).not.toBeVisible(); + expect(message).toHaveValue(''); + expect(screen.queryByRole('button', { name: '聊天', exact: true })).not.toBeInTheDocument(); expect(api.call.mock.calls.filter(([operation]) => operation === 'publish')[0][1]).toMatchObject({ expected_revision: 5 }); expect(api.call.mock.calls.some(([operation]) => operation === 'preview' || operation === 'submit')).toBe(false); }); -it('shares the original failed publication between the header and access page', async () => { +it('retries a failed application from the header using the original publication intent', async () => { publishedAssistant(); const base = api.call.getMockImplementation()!; let attempts = 0; @@ -128,8 +135,7 @@ it('shares the original failed publication between the header and access page', fireEvent.click(screen.getByRole('button', { name: '应用最新修改', exact: true })); await screen.findByText(/连接中断/); expect(screen.getByRole('button', { name: '重试本次应用' })).toBeEnabled(); - fireEvent.click(screen.getByRole('button', { name: '使用与接入' })); - fireEvent.click(await screen.findByRole('button', { name: '重试本次发布' })); + fireEvent.click(screen.getByRole('button', { name: '重试本次应用' })); await waitFor(() => expect(screen.getByRole('button', { name: '已应用', exact: true })).toBeDisabled()); const calls = api.call.mock.calls.filter(([operation]) => operation === 'publish'); expect(calls).toHaveLength(2); @@ -192,7 +198,7 @@ it('protects an uploaded avatar before a name has been entered', async () => { expect(api.create).not.toHaveBeenCalled(); }); -it('creates from name and avatar, then configures model and raw prompt without a preview chat', async () => { +it('creates from name and avatar, then configures an optional introduction, advanced model and raw prompt', async () => { const avatar = { icon: '/api/storage/public/images/avatar.png', icon_url: 'https://images.test/avatar.png' }; const base = api.call.getMockImplementation()!; api.call.mockImplementation(async (operation, input) => operation === 'catalog' ? { @@ -222,9 +228,14 @@ it('creates from name and avatar, then configures model and raw prompt without a await screen.findByTestId('cloud-agent-editor'); expect(screen.getByLabelText('名称')).toHaveValue('我的写作助手'); expect(screen.getByRole('img', { name: '我的写作助手头像' })).toHaveAttribute('src', avatar.icon_url); - expect(api.create).toHaveBeenCalledWith({ operation_id: expect.any(String), name: '我的写作助手', purpose: '按照我的提示词提供帮助。', icon: avatar.icon }); + expect(api.create).toHaveBeenCalledWith({ operation_id: expect.any(String), name: '我的写作助手', purpose: '', icon: avatar.icon }); expect(api.create.mock.calls[0][0]).not.toHaveProperty('icon_url'); - await waitFor(() => expect(screen.getByLabelText('模型')).toHaveValue('model-first')); + await waitFor(() => expect(screen.getByLabelText('模型')).toBeEnabled()); + expect(screen.getByLabelText('一句话介绍')).toHaveValue(''); + expect(screen.getByLabelText('一句话介绍')).toBeVisible(); + expect(screen.getByLabelText('模型')).toHaveValue(''); + expect(screen.getByLabelText('模型')).not.toBeVisible(); + expect(screen.getByRole('tab', { name: '介绍与提示词' })).toHaveAttribute('aria-selected', 'true'); expect(screen.getByLabelText('提示词')).toHaveValue(''); expect(screen.queryByLabelText('用途')).not.toBeInTheDocument(); expect(screen.queryByLabelText('消息')).not.toBeInTheDocument(); @@ -232,17 +243,24 @@ it('creates from name and avatar, then configures model and raw prompt without a expect(screen.queryByText(/这是帮助它思考和回答的 AI|新助手从目录中的第一个可用模型开始|选择模型后,保存即可试用/)).not.toBeInTheDocument(); expect(screen.queryByText(/每 .*个词元约 .*词元点数/)).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /简单易懂|一步一步来|先给我提示/ })).not.toBeInTheDocument(); - fireEvent.change(screen.getByLabelText('模型'), { target: { value: '' } }); + for (const name of ['聊天', '自动任务', '使用与接入']) { + expect(screen.queryByRole('button', { name, exact: true })).not.toBeInTheDocument(); + } fireEvent.change(screen.getByLabelText('名称'), { target: { value: '我的文章搭档' } }); expect(screen.getByLabelText('模型')).toHaveValue(''); + fireEvent.mouseDown(screen.getByRole('tab', { name: '高级设置' }), { button: 0, ctrlKey: false }); + expect(screen.getByRole('button', { name: '模型', exact: true })).toHaveAttribute('aria-current', 'page'); + expect(screen.getByLabelText('模型')).toBeVisible(); + expect(screen.getByLabelText('模型')).toBeRequired(); fireEvent.change(screen.getByLabelText('模型'), { target: { value: 'model-second' } }); + fireEvent.mouseDown(screen.getByRole('tab', { name: '介绍与提示词' }), { button: 0, ctrlKey: false }); const prompt = '一次只解释一个新词。\n\n 保留缩进。\n使用简单易懂的语言,遇到新词先解释。\n'; fireEvent.change(screen.getByLabelText('提示词'), { target: { value: prompt } }); expect(api.save).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole('button', { name: '保存草稿' })); await screen.findByRole('button', { name: '已保存' }); expect(api.save).toHaveBeenCalledWith(draft.slug, expect.objectContaining({ - name: '我的文章搭档', system_prompt: prompt, icon: avatar.icon, + name: '我的文章搭档', purpose: '', system_prompt: prompt, icon: avatar.icon, configuration: expect.objectContaining({ model: 'model-second' }), })); expect(api.save.mock.calls[0][1]).not.toHaveProperty('icon_url'); @@ -341,22 +359,101 @@ it('treats an avatar change as a manual draft edit and blocks saving during its it('saves renamed existing assistants without rewriting arbitrary instructions or selecting a model', async () => { const original = '自定义标题\n\n 保留缩进。\n使用简单易懂的语言,遇到新词先解释。\n把任务拆成小步骤,一次引导我完成一步。\n先给我提示和思考问题,让我尝试后再补充答案。\n'; - const existing = { ...draft, system_prompt: original }; - api.list.mockResolvedValue({ agents: [existing], next_cursor: null }); + let existing = { ...draft, system_prompt: original }; + api.list.mockImplementation(async () => ({ agents: [existing], next_cursor: null })); const base = api.call.getMockImplementation()!; api.call.mockImplementation(async (operation, input) => operation === 'catalog' ? { models: [{ id: 'model-a', name: '模型甲' }], resources: {}, pricing: null, } : base(operation, input)); - api.save.mockImplementation(async (_slug, input) => ({ ...existing, ...input, draft_revision: 2 })); + api.save.mockImplementation(async (_slug, input) => (existing = { ...existing, ...input, draft_revision: 2 })); + open(); + const cardAvatar = await screen.findByRole('img', { name: '写作搭档默认头像' }); + const portrait = cardAvatar.getAttribute('src'); + fireEvent.click(await screen.findByRole('button', { name: '继续制作', exact: true })); + await waitFor(() => expect(screen.getByLabelText('模型')).toBeEnabled()); + expect(screen.getByLabelText('模型')).toHaveValue(''); + expect(screen.getByLabelText('模型')).not.toBeVisible(); + expect(screen.getByLabelText('提示词')).toHaveValue(original); + fireEvent.click(screen.getByRole('button', { name: '修改名称' })); + expect(screen.getByLabelText('名称')).toHaveFocus(); + for (const name of ['新', '新名', '新名字']) { + fireEvent.change(screen.getByLabelText('名称'), { target: { value: name } }); + expect(screen.getByRole('img', { name: `${name}默认头像` })).toHaveAttribute('src', portrait); + } + fireEvent.click(screen.getByRole('button', { name: '保存草稿' })); + await screen.findByRole('button', { name: '已保存' }); + expect(api.save).toHaveBeenCalledWith(draft.slug, expect.objectContaining({ system_prompt: original, configuration: expect.objectContaining({ model: '' }) })); + fireEvent.click(screen.getByRole('button', { name: '我的智能体', exact: true })); + expect(await screen.findByRole('article', { name: '新名字' })).toBeVisible(); + expect(screen.getByRole('img', { name: '新名字默认头像' })).toHaveAttribute('src', portrait); + fireEvent.click(screen.getByRole('button', { name: '继续制作', exact: true })); + expect(screen.getByRole('img', { name: '新名字默认头像' })).toHaveAttribute('src', portrait); + expect(screen.getByLabelText('提示词')).toHaveValue(original); +}); + +it('uses the saved introduction on the assistant card and permits clearing it on reopening', async () => { + let current = { ...draft }; + api.list.mockImplementation(async () => ({ agents: [current], next_cursor: null })); + api.save.mockImplementation(async (_slug, input) => (current = { ...current, ...input, draft_revision: current.draft_revision + 1 })); + open(); + fireEvent.click(await screen.findByRole('button', { name: '继续制作', exact: true })); + const introduction = '和我一起把小想法变成故事'; + fireEvent.change(screen.getByLabelText('一句话介绍'), { target: { value: introduction } }); + expect(api.save).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: '保存草稿' })); + await screen.findByRole('button', { name: '已保存' }); + expect(api.save).toHaveBeenCalledWith(draft.slug, expect.objectContaining({ purpose: introduction, system_prompt: '原始指令' })); + fireEvent.click(screen.getByRole('button', { name: '我的智能体', exact: true })); + expect(await screen.findByText(introduction)).toBeVisible(); + fireEvent.click(screen.getByRole('button', { name: '继续制作', exact: true })); + expect(screen.getByLabelText('一句话介绍')).toHaveValue(introduction); + fireEvent.change(screen.getByLabelText('一句话介绍'), { target: { value: '' } }); + fireEvent.click(screen.getByRole('button', { name: '保存草稿' })); + await screen.findByRole('button', { name: '已保存' }); + expect(api.save.mock.calls[1][1]).toMatchObject({ purpose: '', system_prompt: '原始指令' }); + fireEvent.click(screen.getByRole('button', { name: '我的智能体', exact: true })); + const card = await screen.findByRole('article', { name: '写作搭档' }); + expect(within(card).queryByText(introduction)).not.toBeInTheDocument(); + expect(card.querySelector('.agent-home-purpose')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '继续制作', exact: true })); + expect(screen.getByLabelText('一句话介绍')).toHaveValue(''); +}); + +it('requires an explicit advanced model choice before starting use but permits an unfinished draft save', async () => { + let current = { ...draft }; + const base = api.call.getMockImplementation()!; + api.call.mockImplementation(async (operation, input) => { + if (operation === 'catalog') return { models: [{ id: 'model-a', name: '模型甲' }], resources: {}, pricing: null }; + if (operation === 'access') return { published_version: null, enabled: true, versions: [], grants: [], applications: [] }; + return base(operation, input); + }); + api.save.mockImplementation(async (_slug, input) => (current = { ...current, ...input, draft_revision: current.draft_revision + 1 })); open(); fireEvent.click(await screen.findByRole('button', { name: '继续制作', exact: true })); await waitFor(() => expect(screen.getByLabelText('模型')).toBeEnabled()); expect(screen.getByLabelText('模型')).toHaveValue(''); - expect(screen.getByLabelText('提示词')).toHaveValue(original); - fireEvent.change(screen.getByLabelText('名称'), { target: { value: '新名字' } }); + expect(screen.getByLabelText('模型')).not.toBeVisible(); + expect(api.save).not.toHaveBeenCalled(); + fireEvent.change(screen.getByLabelText('提示词'), { target: { value: '还没配置模型的想法' } }); fireEvent.click(screen.getByRole('button', { name: '保存草稿' })); await screen.findByRole('button', { name: '已保存' }); - expect(api.save).toHaveBeenCalledWith(draft.slug, expect.objectContaining({ system_prompt: original, configuration: expect.objectContaining({ model: '' }) })); + expect(api.save).toHaveBeenCalledWith(draft.slug, expect.objectContaining({ configuration: expect.objectContaining({ model: '' }) })); + fireEvent.click(screen.getByRole('button', { name: '开始使用', exact: true })); + expect(screen.getByRole('tab', { name: '高级设置' })).toHaveAttribute('aria-selected', 'true'); + const categories = within(screen.getByRole('navigation', { name: '配置分类' })).getAllByRole('button'); + expect(categories[0]).toHaveAccessibleName('模型'); + expect(categories[0]).toHaveAttribute('aria-current', 'page'); + expect(screen.getByLabelText('模型')).toBeVisible(); + expect(screen.getByLabelText('模型')).toBeRequired(); + expect(screen.getByRole('alert')).toHaveTextContent('请先在高级设置中选择模型。'); + expect(screen.queryByRole('button', { name: '确认开始使用' })).not.toBeInTheDocument(); + expect(api.call.mock.calls.some(([operation]) => operation === 'publish')).toBe(false); + fireEvent.change(screen.getByLabelText('模型'), { target: { value: 'model-a' } }); + fireEvent.click(screen.getByRole('button', { name: '保存草稿' })); + await screen.findByRole('button', { name: '已保存' }); + fireEvent.click(screen.getByRole('button', { name: '开始使用', exact: true })); + expect(await screen.findByRole('button', { name: '确认开始使用' })).toBeEnabled(); + expect(api.call.mock.calls.some(([operation]) => ['publish', 'preview', 'submit'].includes(operation))).toBe(false); }); it('retains requirements when model loading fails and offers a usable retry', async () => { @@ -373,6 +470,8 @@ it('retains requirements when model loading fails and offers a usable retry', as fireEvent.click(await screen.findByRole('button', { name: '继续制作', exact: true })); await screen.findByText('目录暂不可用'); fireEvent.change(screen.getByLabelText('提示词'), { target: { value: '保留我的想法' } }); + fireEvent.mouseDown(screen.getByRole('tab', { name: '高级设置' }), { button: 0, ctrlKey: false }); + expect(screen.getByText('目录暂不可用')).toBeVisible(); fail = false; fireEvent.click(screen.getByRole('button', { name: '重新读取模型' })); await waitFor(() => expect(screen.getByLabelText('模型')).toBeEnabled()); @@ -467,7 +566,7 @@ it('freezes the uploaded avatar and creation identity when retrying a lost respo await screen.findByTestId('cloud-agent-editor'); expect(api.create).toHaveBeenCalledTimes(2); expect(api.create.mock.calls[1][0]).toEqual(api.create.mock.calls[0][0]); - expect(api.create.mock.calls[0][0]).toEqual({ operation_id: expect.any(String), name: '写作搭档', purpose: '按照我的提示词提供帮助。', icon: avatar.icon }); + expect(api.create.mock.calls[0][0]).toEqual({ operation_id: expect.any(String), name: '写作搭档', purpose: '', icon: avatar.icon }); expect(api.uploadAvatar).toHaveBeenCalledTimes(1); expect(screen.getByRole('img', { name: '写作搭档头像' })).toHaveAttribute('src', avatar.icon_url); }); @@ -556,11 +655,11 @@ it('switches peer editor tabs while retaining the advanced category and unfinish open(); fireEvent.click(await screen.findByRole('button', { name: '继续制作', exact: true })); await waitFor(() => expect(screen.getByLabelText('模型')).toBeEnabled()); - const requirements = screen.getByRole('tab', { name: '模型与提示词', exact: true }); + const requirements = screen.getByRole('tab', { name: '介绍与提示词', exact: true }); const advanced = screen.getByRole('tab', { name: '高级设置', exact: true }); expect(requirements).toHaveAttribute('aria-selected', 'true'); expect(advanced).toHaveAttribute('aria-selected', 'false'); - expect(screen.getByRole('tabpanel', { name: '模型与提示词' })).toBeVisible(); + expect(screen.getByRole('tabpanel', { name: '介绍与提示词' })).toBeVisible(); expect(screen.queryByRole('navigation', { name: '配置分类' })).not.toBeInTheDocument(); const instructions = screen.getByLabelText('提示词'); fireEvent.change(instructions, { target: { value: '没有保存的要求' } }); @@ -570,7 +669,7 @@ it('switches peer editor tabs while retaining the advanced category and unfinish expect(requirements).toHaveAttribute('aria-selected', 'false'); expect(screen.getByRole('tabpanel', { name: '高级设置' })).toBeVisible(); expect(instructions).not.toBeVisible(); - expect(screen.getByRole('button', { name: '能力', exact: true })).toHaveAttribute('aria-current', 'page'); + expect(screen.getByRole('button', { name: '模型', exact: true })).toHaveAttribute('aria-current', 'page'); fireEvent.click(screen.getByRole('button', { name: '知识', exact: true })); fireEvent.click(screen.getByText('管理我的知识文档')); fireEvent.click(screen.getByRole('button', { name: '新建知识库' })); @@ -609,9 +708,7 @@ it('retains unsaved instructions while saving a budget independently of the draf open(); fireEvent.click(await screen.findByRole('button', { name: '继续制作', exact: true })); fireEvent.change(screen.getByLabelText('提示词'), { target: { value: '未保存的指令' } }); - fireEvent.click(screen.getByRole('button', { name: '聊天', exact: true })); expect(screen.queryByLabelText('消息')).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: '配置', exact: true })); expect(screen.getByLabelText('提示词')).toHaveValue('未保存的指令'); fireEvent.mouseDown(screen.getByRole('tab', { name: '高级设置' }), { button: 0, ctrlKey: false }); fireEvent.click(screen.getByRole('button', { name: '限制', exact: true })); @@ -621,7 +718,7 @@ it('retains unsaved instructions while saving a budget independently of the draf 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.mouseDown(screen.getByRole('tab', { name: '模型与提示词', exact: true }), { button: 0, ctrlKey: false }); + fireEvent.mouseDown(screen.getByRole('tab', { name: '介绍与提示词', exact: true }), { button: 0, ctrlKey: false }); expect(screen.getByLabelText('提示词')).toHaveValue('未保存的指令'); expect(screen.getByRole('button', { name: '保存草稿' })).toBeEnabled(); expect(api.call.mock.calls.some(([operation]) => ['threads', 'preview', 'submit'].includes(operation))).toBe(false); @@ -662,7 +759,7 @@ it('searches capabilities while retaining existing selections outside the result expect(await screen.findByRole('checkbox', { name: /missing-tool/ })).toBeChecked(); fireEvent.click(screen.getByRole('button', { name: '选择工具', exact: true })); fireEvent.change(screen.getByLabelText('搜索工具'), { target: { value: '资料' } }); - fireEvent.mouseDown(screen.getByRole('tab', { name: '模型与提示词' }), { button: 0, ctrlKey: false }); + fireEvent.mouseDown(screen.getByRole('tab', { name: '介绍与提示词' }), { button: 0, ctrlKey: false }); fireEvent.mouseDown(screen.getByRole('tab', { name: '高级设置' }), { button: 0, ctrlKey: false }); expect(screen.getByLabelText('搜索工具')).toHaveValue('资料'); expect(screen.queryByRole('checkbox', { name: '当前时间' })).not.toBeInTheDocument();