Polish Agent configuration and unify advanced settings
This commit is contained in:
1 parent
a7182ac8fe
commit
1166931a26
13 files changed
+422
-102
No files matched your search
@@ -0,0 +1,76 @@
|
||||
# Task: Polish Agent introduction and unified advanced settings
|
||||
|
||||
## Identity
|
||||
|
||||
- Task ID: 20261012-agent-config-design-74c9a2e1
|
||||
- Mode: Feature
|
||||
- Branch: codex/20261012-agent-config-design-74c9a2e1-agent-config-design
|
||||
- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261012-agent-config-design-74c9a2e1
|
||||
- Base commit: a7182ac8fe8d936a6849e641a77b8720e0827286
|
||||
- Owner: codex
|
||||
- Status: In Progress
|
||||
|
||||
## Scope
|
||||
|
||||
- Polish the introduction/prompt configuration page using the existing MakeLore warm, pastel, rounded visual language.
|
||||
- Replace advanced category switching with one persistent vertically scrolling page; model remains first and required for use.
|
||||
- Present tools as 工具箱(Tool) / 选它做事时能用的工具。 and skills as 做事手册(Skill) / 给它一本手册,照着方法做。
|
||||
- Verify and update the latest temporary application from the new source.
|
||||
|
||||
## Intent And Constraints
|
||||
|
||||
- User explicitly requests this refinement, superseding older advanced category navigation. Preserve identity/avatar editing, optional purpose, raw prompt, selection state, explicit save/apply, independent budget saves and account/request isolation.
|
||||
- No real cloud mutations or paid prompts during verification, no installed-app changes, main merge/push or peer/canonical document writes. Use the exact latest trial commit as base and keep its profile and direct-network startup.
|
||||
|
||||
## Project Context Loaded
|
||||
|
||||
Task context:
|
||||
- Task ID/mode/branch/worktree/base match Identity; bundled start exited 0 and status confirmed ownership/reservation in this isolated checkout.
|
||||
- Read all 33 peer task records' Scope, Intent And Constraints and Promotion Candidates read-only. Agent source/trial/refinement peers are ready; this base includes their latest accepted product behavior. Teacher/consultation/community tasks are separate. The child-language planning record has undefined scope; no identified dependency on this personal-Agent configuration page. Do not modify peer worktrees or primary checkout.
|
||||
|
||||
Read:
|
||||
- AGENTS.md; .project-docs/05-agent-entry/read-before-planning.md, memory-index.md, planning-gate.md; active task; 00-brief/project-positioning.md and success-criteria.md; relevant current-state.md snapshot and current focus; decision-index.md; personal-cloud-agent ADR; system-overview.md; community-collaboration.md; task-relevant business-rules.md; preceding Agent task records.
|
||||
- design and maintain-project-docs skills. Required design-system search completed; existing project colors/components take precedence over generic search suggestions.
|
||||
|
||||
Relevant understanding:
|
||||
- Project goal: accessible creative desktop client; this module centers on building and using one's own Agent.
|
||||
- Current integrated focus: creative tools and cloud consultation, Main-owned authentication/network, independent cloud execution and service policies.
|
||||
- Active constraints: single light design; preserve model/resource contracts, manual saves, drafts and cloud identity. Current optional introduction reuses purpose; companion cloud rollout remains teammate-owned.
|
||||
- Decisions: user's latest instruction replaces historical starter/preview/category-tab descriptions. Basic/advanced top-level switch remains; only categories inside advanced are removed. No billing, permission or architecture changes.
|
||||
- Evidence/commitments: latest trial a7182ac is verified; tests use isolated fixture transport. Real cloud behavior is not inferred from fixtures. No reusable debugging/reflection change required at planning.
|
||||
- Likely files: DraftEditor, ConfigurationFields, AgentRequirements, desktop-workspace styles, resource headings and focused UI/Electron tests/README.
|
||||
- Unknown/stale: canonical UX descriptions predate the accepted October refinements; record promotion only. Cloud optional-purpose deployment is outside this styling task.
|
||||
|
||||
Gate result: Passed.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
1. Add restrained pastel section cards for introduction/prompt and unify advanced rendering in model-first order with the requested tools/skills language.
|
||||
2. Preserve mounted form state and existing save/recovery behavior, adapt focused regression and Electron tests, and inspect responsive screenshots.
|
||||
3. Build, stage the verified temporary app safely, record actual validation and remaining live-login limitations, and hand off a clean committed result.
|
||||
|
||||
## Outcome
|
||||
|
||||
- Implemented two pastel introduction/prompt cards and matching top-level controls, using existing typography, Lucide icons, warm background and rounded forms.
|
||||
- Advanced settings now show model, Tool/Skill resources, MCP/subagents, knowledge and limits on one persistent vertical page. Resource catalogs, missing IDs, selected Skill preloading, management dialogs, independent budget saves and draft/conflict guards are retained.
|
||||
- Requested Tool/Skill names and exact short explanations are visible; personal Skill management actions use the same 做事手册 wording. README and focused tests updated.
|
||||
- Compact screenshot review found the inherited name input collapsing to zero width; increased identity selector specificity and gave it a minimum flex basis so header actions wrap while the name remains visible.
|
||||
- Latest real trial is still on a7182ac: native AX inspection shows unsaved name 英语陪练aron and 有未保存修改. Asked user to save or authorize discarding before replacement; do not terminate that window while the answer is pending.
|
||||
|
||||
## Verification
|
||||
|
||||
- Renderer typecheck and scoped ESLint passed.
|
||||
- Six focused unit files: 139 passed; include page switching preserving prompt/model/search/resources/knowledge/limits/budget inputs and no implicit save or model calls.
|
||||
- Production Renderer/Main/preload/utility build passed; existing Browserslist and chunk-size warnings only.
|
||||
- Five isolated Electron flows passed: identity/configuration/formal chat, three homepage cases and WeChat channel lifecycle. No real cloud mutation/model calls. Basic/advanced Electron screenshots visually inspected.
|
||||
- Independent read-only source/state/accessibility review found no concrete defect.
|
||||
- Nine Chrome fixture cases passed: original five avatar/card cases and four configuration layouts at 375/768/1024/1440. Real element bounds, >=96px name width, accessible focus, all advanced sections, lower-section scrolling and return-to-introduction checked. Desktop and compact screenshots inspected; compact header regression fixed and reverified.
|
||||
- After the header fix, production build and the Electron configuration case passed again, including its 980px 125%/150% content zoom checks. Total 14 distinct E2E cases passed (five Electron and nine browser fixture cases).
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- Finish responsive screenshots and safely replace the latest temporary app after the user resolves its unsaved changes. Restart requires manual login because unpackaged auth is memory-only.
|
||||
|
||||
## Promotion Candidates
|
||||
|
||||
- Target: personal-cloud-Agent ADR and integrated current state during a later serialized integration. Proposal: introduction/prompt uses two readable pastel cards; advanced configuration is one vertical model-first page, with 工具箱(Tool) and 做事手册(Skill) plus short explanations. Evidence: user request, source, focused unit/Electron checks and screenshot review. Future impact: simpler configuration navigation while preserving manual saves, state and service contracts. Supersedes historical internal advanced category switching; user confirmation already provided, canonical documents remain unchanged here.
|
||||
@@ -12,7 +12,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智
|
||||
|
||||
- `Makelore Code|AI 编程`:管理本地项目、项目智能体、对话、文件上下文、代码变更和运行时。
|
||||
会话工作台采用浅蓝项目栏、彩色通用手柄图标与卡片选中态;中间对话/作品使用圆角标签,作品预览的后退、前进、刷新与网址输入统一放在标签右侧;只在作品页显示,窄窗口自动换行。右侧智能体咨询使用奶油色标题区。沿用真实项目和会话入口、侧栏伸缩与账户菜单,样式只作用于 Code 会话工作台。可编辑的空对话在输入区上方的可用区域中央显示童趣欢迎语;「去作品广场逛逛」入口紧贴输入框上方,两者一起位于底部。老师介绍以固定宽度浮在右上角,不占用中间布局;欢迎语和底部输入区始终相对项目栏右侧的整个内容区域居中;小窗口的欢迎内容独立滚动;发送首条消息后收起欢迎区。侧栏保留「作品广场」入口,只有点击后才在系统浏览器打开公共广场,不请求、摘取或内嵌展示广场作品。打开广场保留当前项目、对话、草稿和附件,不自动发送消息;老师求助按钮保留临时应用的最新文案「帮我整体看🧠」。
|
||||
- `Makelore Agents|AI 智能体`:首页围绕自己的智能体和创建入口,显示童趣问候“Hi,今天想创造还是想聊聊?”,不再显示更多、收到的分享、活动或归档筛选。卡片显示名称、可为空的一句话介绍及状态,支持聊天、调整、继续制作和确认删除;删除沿用云端归档机制,移出列表并停用新调用,历史和费用保留。创建先填写名称并选择平台像素头像或上传头像,下一步进入独立配置。顶部名称有编辑图标,头像可点击修改;未设置头像时使用同一张默认像素头像,从创建到配置、改名、保存和重新打开保持一致。基础页为“介绍与提示词”,一句话介绍可留空,完整提示词保留原始文本;模型作为高级设置第一项必选,未选择时不能开始使用,草稿可分步保存,不自动选模型。高级设置保留能力、知识和限制,切换保留输入。配置页移除聊天、自动任务和使用与接入导航,独立聊天仍从卡片进入,微信账号仍在侧栏渠道管理。草稿明确保存(Ctrl/⌘+S);保存只更新草稿,“应用最新修改”使新配置生效,失败重试保持原操作身份;首次确认开始使用创建仅自己可用的发布版本并打开聊天,配置不自动发送消息。头像上传、Main-owned Host API、访问隔离、费用与不可变版本规则保持;介绍留空需要配套 Yuxi DraftCreate/DraftSave 的空字符串契约。
|
||||
- `Makelore Agents|AI 智能体`:首页围绕自己的智能体和创建入口,显示童趣问候“Hi,今天想创造还是想聊聊?”,不再显示更多、收到的分享、活动或归档筛选。卡片显示名称、可为空的一句话介绍及状态,支持聊天、调整、继续制作和确认删除;删除沿用云端归档机制,移出列表并停用新调用,历史和费用保留。创建先填写名称并选择平台像素头像或上传头像,下一步进入独立配置。顶部名称有编辑图标,头像可点击修改;未设置头像时使用同一张默认像素头像,从创建到配置、改名、保存和重新打开保持一致。基础页为“介绍与提示词”,一句话介绍可留空,完整提示词保留原始文本;模型作为高级设置第一项必选,未选择时不能开始使用,草稿可分步保存,不自动选模型。介绍与提示词采用浅蓝介绍卡和浅紫提示词卡;高级设置按模型、工具箱(Tool)、做事手册(Skill)、MCP 服务、子智能体、知识库和限制纵向呈现,不再设内部分类切换。工具箱说明为“选它做事时能用的工具。”,做事手册说明为“给它一本手册,照着方法做。”。顶层页签切换保留搜索、资源选择、知识表单、费用及未保存输入。配置页移除聊天、自动任务和使用与接入导航,独立聊天仍从卡片进入,微信账号仍在侧栏渠道管理。草稿明确保存(Ctrl/⌘+S);保存只更新草稿,“应用最新修改”使新配置生效,失败重试保持原操作身份;首次确认开始使用创建仅自己可用的发布版本并打开聊天,配置不自动发送消息。头像上传、Main-owned Host API、访问隔离、费用与不可变版本规则保持;介绍留空需要配套 Yuxi DraftCreate/DraftSave 的空字符串契约。
|
||||
|
||||
云知识库的 embedding 模型只由平台管理员在 Yuxi 后台维护:在「智能体管理 → 模型供应商」启用供应商,按供应商要求填写有效的服务地址和 API Key,添加并启用 embedding 模型并填写实际向量维度;embedding 向量化费用由平台承担,不计入用户用量、词元点数或智能体费用上限;one-api 不参与 embedding 配置,Works Square 只负责其他应计费模型调用的创建者词元点数账本。客户端区分目录加载中、加载失败与无可用模型,空状态提供管理员配置说明;「刷新知识库与模型」保留未提交名称,未确认的创建请求沿用原操作身份。
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
export function AgentConfigHeading({ title, icon: Icon, note, description }: {
|
||||
title: string; icon: LucideIcon; note?: string; description?: string;
|
||||
}) {
|
||||
return <div className="agent-config-heading">
|
||||
<span className="agent-config-icon" aria-hidden="true"><Icon /></span>
|
||||
<div className="min-w-0">
|
||||
<div className="agent-config-heading-title"><h2>{title}</h2>{note && <span className="agent-config-note">{note}</span>}</div>
|
||||
{description && <p className="agent-config-description">{description}</p>}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
import { useId } from 'react';
|
||||
import { NotebookPen } from 'lucide-react';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { AgentConfigHeading } from './AgentConfigHeading';
|
||||
|
||||
export function AgentRequirements({ value, onChange }: { value: string; onChange: (value: string) => void }) {
|
||||
return <label className="block space-y-2 text-sm"><span className="font-medium">提示词</span>
|
||||
<Textarea aria-label="提示词" className="min-h-64 resize-y leading-7" maxLength={32000}
|
||||
const id = useId();
|
||||
return <section className="agent-config-card agent-config-prompt" data-tone="lilac">
|
||||
<AgentConfigHeading title="提示词" icon={NotebookPen} />
|
||||
<label htmlFor={id} className="sr-only">提示词</label>
|
||||
<Textarea id={id} className="agent-config-prompt-input resize-y leading-7" maxLength={32000}
|
||||
value={value} onChange={event => onChange(event.target.value)}
|
||||
placeholder="填写智能体的任务和要求" />
|
||||
</label>;
|
||||
</section>;
|
||||
}
|
||||
@@ -25,7 +25,7 @@ export function CloudResources({ onChanged }: { onChanged: () => void }) {
|
||||
return <details className="rounded-lg border p-4" onToggle={e => { if (e.currentTarget.open && !data) void act(refresh); }}>
|
||||
<summary className="min-h-8 cursor-pointer text-sm font-medium">管理我的云端能力</summary>
|
||||
<div className="mt-4 space-y-6 text-sm">
|
||||
<p className="text-xs leading-5 text-muted-foreground">连接、Skills 和子智能体属于你的个人账号。创建后在上面的能力目录选用,保存并发布后生效。</p>
|
||||
<p className="text-xs leading-5 text-muted-foreground">连接、做事手册和子智能体属于你的个人账号。创建后在上面的目录选用,保存并发布后生效。</p>
|
||||
{error && <p role="alert" className="text-destructive">{error} <Button variant="ghost" onClick={() => void act(refresh)}>刷新能力</Button></p>}
|
||||
<section className="space-y-3"><div className="flex items-center justify-between"><h3 className="font-medium">MCP 连接</h3><Button type="button" size="sm" variant="outline" disabled={busy || Boolean(mcp)} onClick={() => setMcp('new')}>添加 MCP</Button></div>
|
||||
<p className="text-xs text-muted-foreground">连接由云端访问。凭据只用于你的连接,分享调用可在你已发布的授权范围内使用这些工具。</p>
|
||||
@@ -37,8 +37,8 @@ export function CloudResources({ onChanged }: { onChanged: () => void }) {
|
||||
<Button type="button" size="sm" variant="ghost" disabled={busy} onClick={() => setDeleting({ kind: 'mcp', slug: item.slug, name: item.name })}>删除连接</Button>
|
||||
</div></div>)}
|
||||
</section>
|
||||
<section className="space-y-3"><div className="flex items-center justify-between"><h3 className="font-medium">个人 Skills</h3><Button type="button" size="sm" variant="outline" disabled={busy || Boolean(skill)} onClick={() => void act(async () => setSkill(await cloudAgentsApi.uploadSkill()))}>上传 Skill</Button></div>
|
||||
<p className="text-xs text-muted-foreground">支持 SKILL.md 或单个 Skill 的 ZIP,最大 5 MB。同名 Skill 需先删除个人旧版再安装,并重新核对相关智能体配置。</p>
|
||||
<section className="space-y-3"><div className="flex items-center justify-between"><h3 className="font-medium">我的做事手册</h3><Button type="button" size="sm" variant="outline" disabled={busy || Boolean(skill)} onClick={() => void act(async () => setSkill(await cloudAgentsApi.uploadSkill()))}>上传做事手册</Button></div>
|
||||
<p className="text-xs text-muted-foreground">支持 SKILL.md 或单本手册的 ZIP,最大 5 MB。同名手册需先删除个人旧版再安装,并重新核对相关智能体配置。</p>
|
||||
{skill && <div className="space-y-3 rounded-lg border p-3">{skill.items.map(item => <p key={item.slug}>{item.name || item.slug} · {item.description}{item.error && <span className="text-destructive">{item.error}</span>}</p>)}
|
||||
<div className="flex gap-2"><Button type="button" size="sm" disabled={busy} onClick={() => void act(async () => {
|
||||
const result = await cloudAgentsApi.call('confirmSkill', { draft_id: skill.draft_id });
|
||||
@@ -46,7 +46,7 @@ export function CloudResources({ onChanged }: { onChanged: () => void }) {
|
||||
setSkill(null);
|
||||
})}>确认安装到个人能力</Button><Button type="button" variant="ghost" disabled={busy} onClick={() => setSkill(null)}>取消安装</Button></div></div>}
|
||||
{data?.skills.map(item => <div key={item.slug} className="flex items-center justify-between gap-2 rounded-lg bg-muted/30 p-3"><div><p>{item.name}</p><p className="mt-1 text-xs text-muted-foreground">{item.description}</p></div>
|
||||
<Button type="button" size="sm" variant="ghost" disabled={busy} onClick={() => setDeleting({ kind: 'skill', slug: item.slug, name: item.name })}>删除 Skill</Button></div>)}
|
||||
<Button type="button" size="sm" variant="ghost" disabled={busy} onClick={() => setDeleting({ kind: 'skill', slug: item.slug, name: item.name })}>删除做事手册</Button></div>)}
|
||||
</section>
|
||||
<section className="space-y-3"><div className="flex items-center justify-between"><h3 className="font-medium">个人子智能体</h3><Button type="button" size="sm" variant="outline" disabled={busy || Boolean(child)} onClick={() => setChild('new')}>创建子智能体</Button></div>
|
||||
<p className="text-xs text-muted-foreground">子智能体处理专门任务,继承父智能体发布的模型、能力和费用限制。</p>
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { BookOpen, Brain, Library, Plug, RefreshCw, SlidersHorizontal, Users, Wrench } from 'lucide-react';
|
||||
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { CloudKnowledgePanel } from './CloudKnowledge';
|
||||
import { CloudResources } from './CloudResources';
|
||||
import { CloudBudgetEditor } from './CloudCosts';
|
||||
import { AgentConfigHeading } from './AgentConfigHeading';
|
||||
import type { CloudAgentConfiguration, CloudCatalog } from '../../../shared/cloud-agents';
|
||||
|
||||
const resourceSections = [
|
||||
{ key: 'tools', label: '工具箱', title: '工具箱(Tool)', description: '选它做事时能用的工具。', icon: Wrench, tone: 'blue' },
|
||||
{ key: 'skills', label: '做事手册', title: '做事手册(Skill)', description: '给它一本手册,照着方法做。', icon: BookOpen, tone: 'lilac' },
|
||||
{ key: 'mcps', label: 'MCP 服务', title: 'MCP 服务', icon: Plug, tone: 'neutral' },
|
||||
{ key: 'subagents', label: '子智能体', title: '子智能体', icon: Users, tone: 'neutral' },
|
||||
{ key: 'knowledges', label: '知识库', title: '知识库', icon: Library, tone: 'mint' },
|
||||
] as const;
|
||||
|
||||
export function ConfigurationFields({ slug, value, disabled, onChange, section = 'all' }: {
|
||||
section?: 'all' | 'instructions' | 'model' | 'capabilities' | 'knowledge' | 'limits';
|
||||
section?: 'all' | 'instructions';
|
||||
slug: string; value: CloudAgentConfiguration; disabled: boolean; onChange: (value: CloudAgentConfiguration) => void;
|
||||
}) {
|
||||
const [catalog, setCatalog] = useState<CloudCatalog | null>(null);
|
||||
@@ -23,71 +33,71 @@ export function ConfigurationFields({ slug, value, disabled, onChange, section =
|
||||
return () => { live = false; };
|
||||
}, [refresh]);
|
||||
const change = <K extends keyof CloudAgentConfiguration>(key: K, next: CloudAgentConfiguration[K]) => onChange({ ...value, [key]: next });
|
||||
return <div className="space-y-6">
|
||||
<div hidden={section === 'instructions' || section === 'limits' || section === 'model'} className="flex items-center justify-between"><h2 className="font-medium">{section === 'knowledge' ? '知识库' : '能力'}</h2>
|
||||
<Button type="button" variant="ghost" disabled={disabled} onClick={() => setRefresh(v => v + 1)}>刷新目录</Button></div>
|
||||
{error && <p hidden={section === 'instructions'} role="alert" className="text-sm text-destructive">{error} <Button type="button" variant="outline" disabled={disabled} onClick={() => setRefresh(v => v + 1)}>重新读取模型</Button></p>}
|
||||
<div hidden={section !== 'all' && section !== 'model'} className="space-y-3"><label className="block space-y-2 text-sm"><span>模型 <span className="text-xs text-primary">必选</span></span>
|
||||
<select aria-label="模型" required className="h-10 w-full rounded-md border border-input bg-background px-3" disabled={disabled || !catalog}
|
||||
value={value.model} onChange={e => change('model', e.target.value)}>
|
||||
return <div hidden={section === 'instructions'} className="agent-config-stack agent-advanced-settings">
|
||||
<section className="agent-config-card" data-tone="peach">
|
||||
<div className="agent-config-section-top">
|
||||
<AgentConfigHeading title="模型" icon={Brain} note="必选" />
|
||||
<Button type="button" variant="ghost" disabled={disabled} onClick={() => setRefresh(v => v + 1)}><RefreshCw aria-hidden="true" className="mr-2 h-4 w-4" />刷新目录</Button>
|
||||
</div>
|
||||
{error && <p role="alert" className="mb-3 text-sm text-destructive">{error} <Button type="button" variant="outline" disabled={disabled} onClick={() => setRefresh(v => v + 1)}>重新读取模型</Button></p>}
|
||||
<select aria-label="模型" required disabled={disabled || !catalog} value={value.model} onChange={e => change('model', e.target.value)}>
|
||||
<option value="">选择可用模型</option>
|
||||
{value.model && !catalog?.models.some(m => m.id === value.model) && <option value={value.model}>{value.model}(目录中不可用)</option>}
|
||||
{catalog?.models.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
|
||||
</select></label>
|
||||
{!value.model && <p className="text-sm text-muted-foreground">先选一个模型,再开始使用。</p>}
|
||||
{catalog && catalog.models.length === 0 && <p className="text-sm text-muted-foreground">暂时没有可用模型,请稍后刷新目录或请老师协助。<Button type="button" variant="ghost" disabled={disabled} onClick={() => setRefresh(v => v + 1)}>刷新目录</Button></p>}
|
||||
</div>
|
||||
{(['tools', 'knowledges', 'mcps', 'skills', 'subagents'] as const).map(key => {
|
||||
const labels = { tools: '工具', knowledges: '知识库', mcps: 'MCP 服务', skills: 'Skills', subagents: '子智能体' };
|
||||
</select>
|
||||
{!value.model && <p className="mt-3 text-sm text-muted-foreground">先选一个模型,再开始使用。</p>}
|
||||
{catalog && catalog.models.length === 0 && <p className="mt-3 text-sm text-muted-foreground">暂时没有可用模型,请稍后刷新目录或请老师协助。</p>}
|
||||
</section>
|
||||
{resourceSections.map(item => {
|
||||
const { key, label, title, icon, tone } = item;
|
||||
const options = catalog?.resources[key] ?? [];
|
||||
const missing = value[key].filter(id => !options.some(option => option.key === id));
|
||||
return <fieldset key={key} disabled={disabled} hidden={section !== 'all' && section !== (key === 'knowledges' ? 'knowledge' : 'capabilities')} className="space-y-2">
|
||||
<legend className="mb-2 text-sm font-medium">{labels[key]} <span className="font-normal text-muted-foreground">· 已选 {value[key].length}</span></legend>
|
||||
{!options.length && !missing.length && <p className="text-xs text-muted-foreground">{catalog ? '当前没有可用资源' : '正在读取资源…'}</p>}
|
||||
<Button variant="outline" size="sm" onClick={() => { setChoosing(choosing === key ? null : key); setSearch(''); }}>{choosing === key ? '完成选择' : '选择' + labels[key]}</Button>
|
||||
{choosing === key && <Input aria-label={'搜索' + labels[key]} placeholder={'搜索' + labels[key]} value={search} onChange={e => setSearch(e.target.value)} />}
|
||||
<div className="max-h-64 overflow-auto rounded-lg bg-muted/30">
|
||||
{[...options, ...missing.map(id => ({ key: id, name: id + '(已不可用)', description: '取消选择后重新保存' }))].filter(option => choosing === key ? (option.name + ' ' + (option.description ?? '')).toLowerCase().includes(search.toLowerCase()) : value[key].includes(option.key)).map(option =>
|
||||
<label key={option.key} className="flex min-h-10 cursor-pointer items-start gap-3 px-3 py-2 text-sm hover:bg-muted/50">
|
||||
<input className="mt-1 h-4 w-4 accent-primary" type="checkbox" checked={value[key].includes(option.key)}
|
||||
onChange={e => {
|
||||
const selected = e.target.checked ? [...value[key], option.key] : value[key].filter(id => id !== option.key);
|
||||
onChange({ ...value, [key]: selected, ...(key === 'skills' ? { preload_skills: value.preload_skills.filter(id => selected.includes(id)) } : {}) });
|
||||
}} />
|
||||
<span><span>{option.name}</span>{option.description && <span className="mt-1 block text-xs leading-5 text-muted-foreground">{option.description}</span>}</span>
|
||||
</label>)}
|
||||
const visibleOptions = [...options, ...missing.map(id => ({ key: id, name: id + '(已不可用)', description: '取消选择后重新保存' }))]
|
||||
.filter(option => choosing === key ? (option.name + ' ' + (option.description ?? '')).toLowerCase().includes(search.toLowerCase()) : value[key].includes(option.key));
|
||||
return <fieldset key={key} disabled={disabled} className="agent-config-card agent-resource-card" data-tone={tone}>
|
||||
<legend className="sr-only">{title}</legend>
|
||||
<div className="agent-config-section-top">
|
||||
<AgentConfigHeading title={title} icon={icon} note={'已选 ' + value[key].length} description={'description' in item ? item.description : undefined} />
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => { setChoosing(choosing === key ? null : key); setSearch(''); }}>{choosing === key ? '完成选择' : '选择' + label}</Button>
|
||||
</div>
|
||||
{key === 'skills' && value.skills.length > 0 && <label className="flex min-h-10 items-center gap-2 text-xs">
|
||||
{choosing === key && <Input className="mb-3" aria-label={'搜索' + label} placeholder={'搜索' + label} value={search} onChange={e => setSearch(e.target.value)} />}
|
||||
{visibleOptions.length > 0 ? <div className="agent-resource-options">
|
||||
{visibleOptions.map(option => <label key={option.key} className="agent-resource-option">
|
||||
<input type="checkbox" checked={value[key].includes(option.key)} onChange={e => {
|
||||
const selected = e.target.checked ? [...value[key], option.key] : value[key].filter(id => id !== option.key);
|
||||
onChange({ ...value, [key]: selected, ...(key === 'skills' ? { preload_skills: value.preload_skills.filter(id => selected.includes(id)) } : {}) });
|
||||
}} />
|
||||
<span><span>{option.name}</span>{option.description && <span className="mt-1 block text-xs leading-5 text-muted-foreground">{option.description}</span>}</span>
|
||||
</label>)}
|
||||
</div> : <p className="agent-resource-empty">{!catalog ? '正在读取资源…' : choosing === key && search ? '没有找到匹配的内容' : !options.length && !missing.length ? '当前没有可用资源' : '还没有选择'}</p>}
|
||||
{key === 'skills' && value.skills.length > 0 && <label className="agent-resource-preload">
|
||||
<input type="checkbox" checked={value.preload_skills.length === value.skills.length}
|
||||
onChange={e => change('preload_skills', e.target.checked ? [...value.skills] : [])} />开始运行时预加载已选 Skills</label>}
|
||||
onChange={e => change('preload_skills', e.target.checked ? [...value.skills] : [])} />开始运行时预加载已选做事手册</label>}
|
||||
{key === 'knowledges' && <div className="agent-config-resource-manager"><CloudKnowledgePanel slug={slug}
|
||||
onDeleted={id => { change('knowledges', value.knowledges.filter(selected => selected !== id)); setRefresh(v => v + 1); }}
|
||||
onCreated={kb => { change('knowledges', [...new Set([...value.knowledges, kb.kb_id])]); setRefresh(v => v + 1); }} /></div>}
|
||||
</fieldset>;
|
||||
})}
|
||||
<div hidden={section !== 'all' && section !== 'capabilities'} className="space-y-4">
|
||||
<p className="text-xs leading-5 text-muted-foreground">未选择的资源不会开放给此智能体。通过微信或 API 使用时也遵循本配置。</p>
|
||||
<CloudResources onChanged={() => setRefresh(v => v + 1)} />
|
||||
</div>
|
||||
<div hidden={section !== 'all' && section !== 'knowledge'}>
|
||||
<CloudKnowledgePanel slug={slug} onDeleted={id => { change('knowledges', value.knowledges.filter(key => key !== id)); setRefresh(v => v + 1); }} onCreated={kb => {
|
||||
change('knowledges', [...new Set([...value.knowledges, kb.kb_id])]);
|
||||
setRefresh(v => v + 1);
|
||||
}} />
|
||||
</div>
|
||||
<div hidden={section !== 'all' && section !== 'limits'} className="space-y-6">
|
||||
<CloudBudgetEditor slug={slug} />
|
||||
<h2 className="font-medium">运行限制</h2><p className="text-xs text-muted-foreground">以下设置随草稿保存;费用上限独立保存,对后续调用生效。执行步数会统计模型调用、工具、图节点和中间件步骤。</p>
|
||||
<label className="block space-y-2 text-sm"><span>工具审批</span>
|
||||
<select className="h-10 w-full rounded-md border border-input bg-background px-3" disabled={disabled}
|
||||
value={value.tool_approval_mode} onChange={e => change('tool_approval_mode', e.target.value === 'always_trust' ? 'always_trust' : 'default')}>
|
||||
<option value="default">按工具要求确认</option><option value="always_trust">信任已选工具,自动执行</option>
|
||||
</select></label>
|
||||
<div className="grid gap-4">
|
||||
{([{ key: 'max_execution_steps', label: '最多执行步数', min: 1, max: 300 },
|
||||
{ key: 'max_output_tokens', label: '单次输出词元上限', min: 1, max: 32768 },
|
||||
{ key: 'max_run_seconds', label: '单次运行时限(秒)', min: 10, max: 3600 }] as const).map(item =>
|
||||
<label key={item.key} className="space-y-2 text-xs"><span>{item.label}</span>
|
||||
<Input type="number" min={item.min} max={item.max} value={value[item.key]} disabled={disabled}
|
||||
onChange={e => change(item.key, Number(e.target.value))} /></label>)}
|
||||
</div>
|
||||
<div className="agent-config-card agent-config-resource-manager" data-tone="neutral">
|
||||
<p className="mb-4 text-xs leading-5 text-muted-foreground">未选择的资源不会开放给此智能体。通过微信或 API 使用时也遵循本配置。</p>
|
||||
<CloudResources onChanged={() => setRefresh(v => v + 1)} />
|
||||
</div>
|
||||
<div className="agent-config-budget"><CloudBudgetEditor slug={slug} /></div>
|
||||
<section className="agent-config-card" data-tone="peach">
|
||||
<AgentConfigHeading title="运行限制" icon={SlidersHorizontal} />
|
||||
<p className="mb-4 text-xs leading-6 text-muted-foreground">以下设置随草稿保存;费用上限独立保存,对后续调用生效。执行步数会统计模型调用、工具、图节点和中间件步骤。</p>
|
||||
<label className="block space-y-2 text-sm"><span>工具审批</span>
|
||||
<select disabled={disabled} value={value.tool_approval_mode} onChange={e => change('tool_approval_mode', e.target.value === 'always_trust' ? 'always_trust' : 'default')}>
|
||||
<option value="default">按工具要求确认</option><option value="always_trust">信任已选工具,自动执行</option>
|
||||
</select></label>
|
||||
<div className="agent-config-limit-grid">
|
||||
{([{ key: 'max_execution_steps', label: '最多执行步数', min: 1, max: 300 },
|
||||
{ key: 'max_output_tokens', label: '单次输出词元上限', min: 1, max: 32768 },
|
||||
{ key: 'max_run_seconds', label: '单次运行时限(秒)', min: 10, max: 3600 }] as const).map(item =>
|
||||
<label key={item.key} className="space-y-2 text-xs"><span>{item.label}</span>
|
||||
<Input type="number" min={item.min} max={item.max} value={value[item.key]} disabled={disabled}
|
||||
onChange={e => change(item.key, Number(e.target.value))} /></label>)}
|
||||
</div>
|
||||
</section>
|
||||
</div>;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useBlocker } from 'react-router-dom';
|
||||
import { ArrowLeft, Check, ImagePlus, Loader2, Pencil, Save } from 'lucide-react';
|
||||
import { ArrowLeft, Check, ImagePlus, Loader2, MessageSquareText, Pencil, Save, SlidersHorizontal, Sparkles } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
@@ -15,9 +15,10 @@ import { CloudPendingContext, useCloudPendingState } from './CloudPending';
|
||||
import { AgentRequirements } from './AgentRequirements';
|
||||
import { CloudAvatar, CloudAvatarEditor } from './CloudAvatar';
|
||||
import { useCloudPublication } from './useCloudPublication';
|
||||
import { AgentConfigHeading } from './AgentConfigHeading';
|
||||
import './agent-config.css';
|
||||
|
||||
type Tab = 'configuration' | 'chat' | 'access';
|
||||
type AdvancedCategory = 'model' | 'capabilities' | 'knowledge' | 'limits';
|
||||
const hasDraftChanges = (draft: CloudAgentDraft, saved: CloudAgentDraft) => draft.name !== saved.name || (draft.icon ?? null) !== (saved.icon ?? null) || draft.purpose !== saved.purpose || draft.system_prompt !== saved.system_prompt
|
||||
|| JSON.stringify(draft.configuration) !== JSON.stringify(saved.configuration);
|
||||
const errorMessage = (error: unknown) => error instanceof Error ? error.message : '暂时无法完成操作';
|
||||
@@ -30,7 +31,6 @@ export function DraftEditor({ initial, recent, onBack, onSaved, startingPrompt,
|
||||
const [saved, setSaved] = useState(initial);
|
||||
const [draft, setDraft] = useState(() => startingPrompt === undefined ? initial : { ...initial, system_prompt: startingPrompt });
|
||||
const [editorTab, setEditorTab] = useState('instructions');
|
||||
const [advancedCategory, setAdvancedCategory] = useState<AdvancedCategory>('model');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [editingAvatar, setEditingAvatar] = useState(false);
|
||||
const [remote, setRemote] = useState<CloudAgentDraft | null>(null);
|
||||
@@ -40,7 +40,6 @@ export function DraftEditor({ initial, recent, onBack, onSaved, startingPrompt,
|
||||
const initialTab = entryView ?? (recent?.mode === 'preview' ? 'configuration' : initial.published_version ? 'chat' : 'configuration');
|
||||
const [tab, setTab] = useState<Tab>(initialTab);
|
||||
const [visited, setVisited] = useState<Set<Tab>>(() => new Set([initialTab]));
|
||||
const category = editorTab === 'instructions' ? 'instructions' : advancedCategory;
|
||||
const [conversationSource, setConversationSource] = useState<'desktop' | 'wechat'>('desktop');
|
||||
const [wechatVisited, setWechatVisited] = useState(false);
|
||||
const pending = useCloudPendingState();
|
||||
@@ -141,7 +140,11 @@ export function DraftEditor({ initial, recent, onBack, onSaved, startingPrompt,
|
||||
{publication.busy ? '正在应用…' : publication.pending ? '重试本次应用' : !dirty && publication.appliedRevision === saved.draft_revision ? '已应用' : '应用最新修改'}
|
||||
</Button>}
|
||||
{!saved.published_version && <Button className="min-h-10" variant="outline" disabled={busy} onClick={() => {
|
||||
if (!draft.configuration.model) { setEditorTab('advanced'); setAdvancedCategory('model'); setError('请先在高级设置中选择模型。'); return; }
|
||||
if (!draft.configuration.model) {
|
||||
setEditorTab('advanced');
|
||||
if (configurationScroll.current) configurationScroll.current.scrollTop = 0;
|
||||
setError('请先在高级设置中选择模型。'); return;
|
||||
}
|
||||
setError(''); navigate('access');
|
||||
}}>开始使用</Button>}
|
||||
</header>
|
||||
@@ -166,23 +169,24 @@ export function DraftEditor({ initial, recent, onBack, onSaved, startingPrompt,
|
||||
setEditorTab(next);
|
||||
if (configurationScroll.current) configurationScroll.current.scrollTop = 0;
|
||||
}} className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="agent-config-tabs">
|
||||
<div className="agent-config-tabs agent-editor-tabs">
|
||||
<TabsList aria-label="编辑设置" className="h-auto w-full justify-start">
|
||||
<TabsTrigger value="instructions" className="flex-1 data-[state=active]:text-primary">介绍与提示词</TabsTrigger>
|
||||
<TabsTrigger value="advanced" className="flex-1 data-[state=active]:text-primary">高级设置</TabsTrigger>
|
||||
<TabsTrigger value="instructions" className="flex-1 gap-2 data-[state=active]:text-primary"><Sparkles aria-hidden="true" className="h-4 w-4" />介绍与提示词</TabsTrigger>
|
||||
<TabsTrigger value="advanced" className="flex-1 gap-2 data-[state=active]:text-primary"><SlidersHorizontal aria-hidden="true" className="h-4 w-4" />高级设置</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
{/* Keep the shared form mounted so switching tabs preserves unfinished resource and budget edits. */}
|
||||
<TabsContent value={editorTab} className="m-0 flex min-h-0 flex-1 flex-col">
|
||||
{editorTab === 'advanced' && <nav aria-label="配置分类" className="agent-config-tabs pt-2">{([['model', '模型'], ['capabilities', '能力'], ['knowledge', '知识'], ['limits', '限制']] as const).map(([key, label]) =>
|
||||
<Button key={key} variant={advancedCategory === key ? 'secondary' : 'ghost'} aria-current={advancedCategory === key ? 'page' : undefined} onClick={() => setAdvancedCategory(key)}>{label}</Button>)}</nav>}
|
||||
<div ref={configurationScroll} className="agent-config-scroll"><fieldset disabled={busy} className="agent-configuration-form">
|
||||
<div hidden={category !== 'instructions'} className="space-y-6">
|
||||
<label className="block space-y-2 text-sm"><span>一句话介绍</span><Input aria-label="一句话介绍" maxLength={2000} value={draft.purpose}
|
||||
placeholder="简单介绍一下它,可以不填" onChange={event => setDraft({ ...draft, purpose: event.target.value })} /></label>
|
||||
<div ref={configurationScroll} className="agent-config-scroll agent-editor-scroll"><fieldset disabled={busy} className="agent-configuration-form">
|
||||
<div hidden={editorTab !== 'instructions'} className="agent-config-stack">
|
||||
<section className="agent-config-card" data-tone="blue">
|
||||
<AgentConfigHeading title="一句话介绍" icon={MessageSquareText} note="选填" />
|
||||
<Input aria-label="一句话介绍" maxLength={2000} value={draft.purpose}
|
||||
placeholder="简单介绍一下它,可以不填" onChange={event => setDraft({ ...draft, purpose: event.target.value })} />
|
||||
</section>
|
||||
<AgentRequirements value={draft.system_prompt} onChange={system_prompt => setDraft({ ...draft, system_prompt })} />
|
||||
</div>
|
||||
<ConfigurationFields slug={saved.slug} section={category} value={draft.configuration ?? EMPTY_CLOUD_CONFIGURATION} disabled={busy}
|
||||
<ConfigurationFields slug={saved.slug} section={editorTab === 'instructions' ? 'instructions' : 'all'} value={draft.configuration ?? EMPTY_CLOUD_CONFIGURATION} disabled={busy}
|
||||
onChange={configuration => setDraft({ ...draft, configuration })} />
|
||||
</fieldset></div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
.agent-editor-tabs { padding: 12px 24px; background: #fffdf9; }
|
||||
.agent-editor-tabs [role="tablist"] { max-width: 900px; margin-inline: auto; gap: 6px; padding: 5px; border: 1px solid #e7e3ee; border-radius: 17px; background: #f1eff6; }
|
||||
.agent-editor-tabs [role="tab"] { min-height: 44px; padding: 10px 16px; border-radius: 12px; font-size: 15px; color: #62677b; }
|
||||
.agent-editor-tabs [role="tab"][data-state="active"] { color: #b73d16; box-shadow: 0 2px 8px #2732460a; }
|
||||
.agent-editor-scroll { padding: 28px 32px 40px; background: #fffdf9; color: #273246; }
|
||||
.agent-config-stack { display: flex; flex-direction: column; gap: 22px; min-width: 0; }
|
||||
.agent-config-card, .agent-config-budget > section { min-width: 0; margin: 0; padding: 24px; border: 1px solid #dce5f3; border-radius: 24px; background: #f1f6ff; }
|
||||
.agent-config-card[data-tone="lilac"] { border-color: #e5dcf5; background: #f6f2ff; }
|
||||
.agent-config-card[data-tone="peach"] { border-color: #f0e2ce; background: #fff5e9; }
|
||||
.agent-config-card[data-tone="mint"] { border-color: #dce9e1; background: #f0f7f3; }
|
||||
.agent-config-card[data-tone="neutral"] { border-color: #e6e3de; background: #fff; }
|
||||
.agent-config-budget > section { border-color: #e6e3de; background: #fff; }
|
||||
.agent-config-heading { display: flex; min-width: 0; align-items: center; gap: 13px; margin-bottom: 20px; }
|
||||
.agent-config-icon { display: grid; width: 44px; height: 44px; flex-shrink: 0; place-items: center; border: 2px solid #fff; border-radius: 15px; background: #e0eafb; color: #4a6e9c; }
|
||||
.agent-config-icon svg { width: 23px; height: 23px; stroke-width: 1.8; }
|
||||
[data-tone="lilac"] > .agent-config-heading .agent-config-icon, [data-tone="lilac"] > .agent-config-section-top .agent-config-icon { background: #eae2fa; color: #79629d; }
|
||||
[data-tone="peach"] > .agent-config-heading .agent-config-icon, [data-tone="peach"] > .agent-config-section-top .agent-config-icon { background: #ffe6c8; color: #9e6336; }
|
||||
[data-tone="mint"] > .agent-config-section-top .agent-config-icon { background: #deeee4; color: #4c7a60; }
|
||||
[data-tone="neutral"] > .agent-config-section-top .agent-config-icon { background: #efedf4; color: #726a85; }
|
||||
.agent-config-heading-title { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; }
|
||||
.agent-config-heading h2 { font-size: 18px; font-weight: 650; line-height: 1.5; }
|
||||
.agent-config-note { padding: 3px 9px; border-radius: 20px; background: #fff9; font-size: 12px; line-height: 1.5; color: #62677b; white-space: nowrap; }
|
||||
.agent-config-description { margin-top: 5px; font-size: 14px; line-height: 1.8; color: #59657a; }
|
||||
.agent-config-card input:not([type="checkbox"]), .agent-config-card textarea, .agent-config-card select, .agent-config-budget input {
|
||||
width: 100%; min-width: 0; min-height: 48px; padding: 12px 15px; border: 1px solid #cdd5e3; border-radius: 13px; background: #fff; color: #273246; font-size: 15px; box-shadow: 0 2px 2px #27324603;
|
||||
}
|
||||
.agent-config-card input::placeholder, .agent-config-card textarea::placeholder { color: #6c7588; }
|
||||
.agent-config-card :is(input, textarea, select):focus-visible { outline: 2px solid #9a7bb5; outline-offset: 3px; }
|
||||
.agent-config-card select { cursor: pointer; }
|
||||
.agent-config-card :is(input, textarea, select):disabled { cursor: not-allowed; }
|
||||
.agent-config-card .agent-config-prompt-input { min-height: clamp(280px, 38vh, 420px); padding: 18px; line-height: 1.9; }
|
||||
.agent-config-section-top { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 18px; }
|
||||
.agent-config-section-top .agent-config-heading { margin-bottom: 0; }
|
||||
.agent-config-section-top > button { flex-shrink: 0; border-radius: 12px; }
|
||||
.agent-resource-options { max-height: 280px; overflow: auto; border: 1px solid #dce2ec; border-radius: 14px; background: #fff; }
|
||||
.agent-resource-option { display: flex; min-height: 48px; align-items: flex-start; gap: 12px; padding: 13px 15px; cursor: pointer; font-size: 14px; line-height: 1.6; overflow-wrap: anywhere; transition: background-color 160ms ease; }
|
||||
.agent-resource-option + .agent-resource-option { border-top: 1px solid #edf0f5; }
|
||||
.agent-resource-option:hover, .agent-resource-option:focus-within { background: #f5f3fa; }
|
||||
.agent-resource-option:has(input:checked) { background: #f2eef9; }
|
||||
.agent-resource-option input, .agent-resource-preload input { width: 18px; height: 18px; flex-shrink: 0; margin-top: 2px; accent-color: #79629d; cursor: pointer; }
|
||||
.agent-resource-empty { padding: 14px 16px; border-radius: 13px; background: #ffffff9c; color: #606c7e; font-size: 14px; line-height: 1.7; }
|
||||
.agent-resource-preload { display: flex; min-height: 44px; align-items: center; gap: 9px; margin-top: 12px; cursor: pointer; font-size: 13px; }
|
||||
.agent-config-resource-manager { margin-top: 18px; }
|
||||
.agent-config-resource-manager > details { border-radius: 14px; background: #ffffffa8; }
|
||||
.agent-config-stack > .agent-config-resource-manager { margin-top: 0; }
|
||||
.agent-config-limit-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 200px), 1fr)); gap: 18px; margin-top: 20px; }
|
||||
.agent-config-budget h2 { font-size: 18px; font-weight: 650; }
|
||||
.agent-config-budget form > label { min-width: 0; flex: 1 1 240px; }
|
||||
.agent-config-budget form > button { border-radius: 12px; }
|
||||
@media (max-width: 640px) {
|
||||
.agent-editor-tabs { padding: 10px 12px; }
|
||||
.agent-editor-tabs [role="tab"] { gap: 6px; padding-inline: 8px; font-size: 14px; }
|
||||
.agent-editor-scroll { padding: 18px 12px 28px; }
|
||||
.agent-config-card, .agent-config-budget > section { padding: 18px; border-radius: 20px; }
|
||||
.agent-config-stack { gap: 16px; }
|
||||
.agent-config-heading { gap: 10px; }
|
||||
.agent-config-heading h2 { font-size: 17px; }
|
||||
.agent-config-heading-title { gap: 6px; }
|
||||
.agent-config-icon { width: 38px; height: 38px; border-radius: 13px; }
|
||||
.agent-config-section-top > button { margin-left: 48px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { .agent-resource-option { transition: none; } }
|
||||
@@ -2,7 +2,7 @@
|
||||
.agent-desktop > aside { max-height: 25%; min-height: 0; overflow: auto; }
|
||||
.agent-desktop [hidden] { display: none !important; }
|
||||
.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-header > .agent-workspace-identity { min-width: min(160px, 100%); flex-basis: 160px; }
|
||||
.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; }
|
||||
|
||||
@@ -137,6 +137,86 @@ async function expectImagesLoaded(page: Page, selector = 'img') {
|
||||
}))).toBe(true);
|
||||
}
|
||||
|
||||
async function expectConfigurationBounds(page: Page) {
|
||||
await expectNoOverflow(page);
|
||||
await expect.poll(() => page.getByTestId('cloud-agent-editor').getByLabel('名称', { exact: true })
|
||||
.evaluate(element => element.getBoundingClientRect().width)).toBeGreaterThanOrEqual(96);
|
||||
// Document width alone misses controls clipped by the editor's overflow container.
|
||||
const clipped = await page.getByTestId('cloud-agent-editor').evaluate(editor => {
|
||||
const scroll = editor.querySelector('.agent-config-scroll')!;
|
||||
const scrollBounds = scroll.getBoundingClientRect();
|
||||
return [...editor.querySelectorAll('button, input, textarea, select, h2, summary, .agent-config-card')]
|
||||
.filter(element => element.getClientRects().length > 0 && !element.classList.contains('sr-only'))
|
||||
.flatMap(element => {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
const insideScroll = scroll.contains(element);
|
||||
const left = insideScroll ? scrollBounds.left : 0;
|
||||
const right = insideScroll ? scrollBounds.right : innerWidth;
|
||||
return bounds.left < left - 1 || bounds.right > right + 1
|
||||
? [{ element: element.tagName, label: element.getAttribute('aria-label') ?? element.textContent?.trim(), left: bounds.left, right: bounds.right }]
|
||||
: [];
|
||||
});
|
||||
});
|
||||
expect(clipped).toEqual([]);
|
||||
}
|
||||
|
||||
for (const width of [375, 768, 1024, 1440]) {
|
||||
test(`introduction cards and one-page advanced settings fit and scroll at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 850 });
|
||||
await installFixture(page, [draftAgent]);
|
||||
await page.goto(baseUrl);
|
||||
await page.getByRole('button', { name: '继续制作', exact: true }).click();
|
||||
const editor = page.getByTestId('cloud-agent-editor');
|
||||
const tabs = editor.getByRole('tablist', { name: '编辑设置', exact: true });
|
||||
await expect(tabs.getByRole('tab')).toHaveCount(2);
|
||||
await expect(editor.getByRole('heading', { name: '一句话介绍', exact: true })).toBeVisible();
|
||||
await expect(editor.getByRole('heading', { name: '提示词', exact: true })).toBeVisible();
|
||||
await expect(editor.locator('section.agent-config-card[data-tone="blue"]')).toHaveCSS('background-color', 'rgb(241, 246, 255)');
|
||||
await expect(editor.locator('.agent-config-prompt[data-tone="lilac"]')).toHaveCSS('background-color', 'rgb(246, 242, 255)');
|
||||
await editor.getByLabel('一句话介绍', { exact: true }).focus();
|
||||
await expect(editor.getByLabel('一句话介绍', { exact: true })).toBeFocused();
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(editor.getByLabel('提示词', { exact: true })).toBeFocused();
|
||||
await expectConfigurationBounds(page);
|
||||
if (width === 375 || width === 1440) {
|
||||
await page.screenshot({ path: test.info().outputPath(`agent-introduction-prompt-${width}.png`), animations: 'disabled' });
|
||||
}
|
||||
|
||||
await tabs.getByRole('tab', { name: '高级设置', exact: true }).click();
|
||||
const advanced = editor.locator('.agent-advanced-settings');
|
||||
await expect(tabs.getByRole('tab', { name: '高级设置', exact: true })).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(advanced.getByRole('navigation')).toHaveCount(0);
|
||||
await expect(advanced.getByRole('tablist')).toHaveCount(0);
|
||||
const headings = ['模型', '工具箱(Tool)', '做事手册(Skill)', 'MCP 服务', '子智能体', '知识库', '费用上限', '运行限制'];
|
||||
for (const name of headings) await expect(advanced.getByRole('heading', { name, exact: true })).toBeVisible();
|
||||
await expect(advanced.getByRole('heading').first()).toHaveText('模型');
|
||||
await expect(advanced.getByLabel('模型', { exact: true })).toHaveAttribute('required', '');
|
||||
await expect(advanced.getByText('选它做事时能用的工具。', { exact: true })).toBeVisible();
|
||||
await expect(advanced.getByText('给它一本手册,照着方法做。', { exact: true })).toBeVisible();
|
||||
await expect(advanced.getByLabel('模型', { exact: true })).toBeInViewport();
|
||||
await expectConfigurationBounds(page);
|
||||
if (width === 375 || width === 1440) {
|
||||
await page.screenshot({ path: test.info().outputPath(`agent-advanced-upper-${width}.png`), animations: 'disabled' });
|
||||
}
|
||||
await advanced.getByLabel('单次运行时限(秒)', { exact: true }).scrollIntoViewIfNeeded();
|
||||
await expect(advanced.getByLabel('单次运行时限(秒)', { exact: true })).toBeInViewport();
|
||||
await expect(advanced.getByLabel('每日上限(词元点数)', { exact: true })).toBeVisible();
|
||||
await expectConfigurationBounds(page);
|
||||
if (width === 375 || width === 1440) {
|
||||
await page.screenshot({ path: test.info().outputPath(`agent-advanced-lower-${width}.png`), animations: 'disabled' });
|
||||
}
|
||||
// Returning from the bottom resets the view to the introduction, without changing a draft.
|
||||
await tabs.getByRole('tab', { name: '介绍与提示词', exact: true }).click();
|
||||
await expect(editor.getByLabel('一句话介绍', { exact: true })).toBeInViewport();
|
||||
await expect(editor.getByLabel('提示词', { exact: true })).toHaveValue(draftAgent.system_prompt);
|
||||
const calls = await page.evaluate(() => (window as unknown as FixtureWindow).__agentAvatarFixture.calls);
|
||||
expect(calls.filter(call => call.method === 'PATCH' || call.path === '/api/cloud-agents/avatars/preset'
|
||||
|| (call.method === 'POST' && call.path === '/api/cloud-agents/agents'))).toEqual([]);
|
||||
expect(calls.filter(call => call.path === '/api/cloud-agents/actions')
|
||||
.every(call => ['catalog', 'resources', 'knowledge', 'budget', 'access'].includes(String(call.input?.operation)))).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
for (const width of [375, 1366]) {
|
||||
test(`pastel Agent cards and explicit chat/adjust controls remain usable at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 850 });
|
||||
|
||||
@@ -167,15 +167,19 @@ test('personal cloud Agent identity, configuration and separate published chat i
|
||||
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.getByRole('navigation', { name: '配置分类', exact: true })).toHaveCount(0);
|
||||
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 expect(page.getByText('选它做事时能用的工具。', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('给它一本手册,照着方法做。', { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('group', { name: /^知识库/ })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: '费用上限', exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: '运行限制', exact: true })).toBeVisible();
|
||||
await page.getByRole('button', { name: '选择工具箱', exact: true }).click();
|
||||
await page.getByRole('checkbox', { name: '资料整理工具', exact: true }).check();
|
||||
await instructionsTab.click();
|
||||
await expect(introduction).toHaveValue('一起梳理故事和文章');
|
||||
@@ -202,7 +206,7 @@ test('personal cloud Agent identity, configuration and separate published chat i
|
||||
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.getByRole('navigation', { name: '配置分类', exact: true })).toHaveCount(0);
|
||||
await expect(page.getByLabel('模型', { exact: true })).toHaveValue('reasoning-model');
|
||||
await page.screenshot({ path: testInfo.outputPath('cloud-agent-advanced-model.png') });
|
||||
await instructionsTab.click();
|
||||
|
||||
@@ -52,7 +52,7 @@ it('keeps one child creation intent after a lost response and waits for Skill in
|
||||
await waitFor(() => expect(screen.queryByLabelText('子智能体名称')).not.toBeInTheDocument());
|
||||
const calls = api.call.mock.calls.filter(call => call[0] === 'createChild');
|
||||
expect(calls).toHaveLength(2); expect(calls[0][1]).toEqual(calls[1][1]);
|
||||
fireEvent.click(screen.getByRole('button', { name: '上传 Skill' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '上传做事手册' }));
|
||||
const confirm = await screen.findByRole('button', { name: '确认安装到个人能力' });
|
||||
expect(api.call.mock.calls.some(call => call[0] === 'confirmSkill')).toBe(false);
|
||||
fireEvent.click(confirm);
|
||||
|
||||
@@ -249,7 +249,7 @@ it('creates from name and avatar, then configures an optional introduction, adva
|
||||
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.queryByRole('navigation', { name: '配置分类' })).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText('模型')).toBeVisible();
|
||||
expect(screen.getByLabelText('模型')).toBeRequired();
|
||||
fireEvent.change(screen.getByLabelText('模型'), { target: { value: 'model-second' } });
|
||||
@@ -440,9 +440,7 @@ it('requires an explicit advanced model choice before starting use but permits a
|
||||
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.queryByRole('navigation', { name: '配置分类' })).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText('模型')).toBeVisible();
|
||||
expect(screen.getByLabelText('模型')).toBeRequired();
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('请先在高级设置中选择模型。');
|
||||
@@ -456,6 +454,77 @@ it('requires an explicit advanced model choice before starting use but permits a
|
||||
expect(api.call.mock.calls.some(([operation]) => ['publish', 'preview', 'submit'].includes(operation))).toBe(false);
|
||||
});
|
||||
|
||||
it('shows all advanced settings together and preserves unfinished selections, search and limits when switching pages', async () => {
|
||||
const base = api.call.getMockImplementation()!;
|
||||
api.call.mockImplementation(async (operation, input) => {
|
||||
if (operation === 'catalog') return {
|
||||
models: [{ id: 'model-a', name: '模型甲' }],
|
||||
resources: {
|
||||
tools: [{ key: 'writing-tool', name: '文章整理工具' }],
|
||||
skills: [{ key: 'writing-guide', name: '写作方法' }],
|
||||
}, pricing: null,
|
||||
};
|
||||
return base(operation, input);
|
||||
});
|
||||
open();
|
||||
fireEvent.click(await screen.findByRole('button', { name: '继续制作', exact: true }));
|
||||
await waitFor(() => expect(screen.getByLabelText('模型')).toBeEnabled());
|
||||
fireEvent.mouseDown(screen.getByRole('tab', { name: '高级设置' }), { button: 0, ctrlKey: false });
|
||||
|
||||
const model = screen.getByLabelText('模型');
|
||||
const toolbox = screen.getByRole('group', { name: /^工具箱(Tool)/ });
|
||||
const handbook = screen.getByRole('group', { name: /^做事手册(Skill)/ });
|
||||
expect(model).toBeVisible();
|
||||
expect(model).toBeRequired();
|
||||
expect(toolbox).toBeVisible();
|
||||
expect(handbook).toBeVisible();
|
||||
expect(model.compareDocumentPosition(toolbox) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(screen.getByText('选它做事时能用的工具。', { exact: true })).toBeVisible();
|
||||
expect(screen.getByText('给它一本手册,照着方法做。', { exact: true })).toBeVisible();
|
||||
expect(screen.getByRole('group', { name: /^知识库/ })).toBeVisible();
|
||||
expect(screen.getByRole('heading', { name: '费用上限', exact: true })).toBeVisible();
|
||||
expect(screen.getByRole('heading', { name: '运行限制', exact: true })).toBeVisible();
|
||||
expect(screen.queryByRole('navigation', { name: '配置分类' })).not.toBeInTheDocument();
|
||||
expect(screen.getAllByRole('tab').map(tab => tab.textContent)).toEqual(['介绍与提示词', '高级设置']);
|
||||
|
||||
fireEvent.change(model, { target: { value: 'model-a' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '选择做事手册', exact: true }));
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: '写作方法', exact: true }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '选择工具箱', exact: true }));
|
||||
const search = screen.getByLabelText('搜索工具箱');
|
||||
fireEvent.change(search, { target: { value: '文章' } });
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: '文章整理工具', exact: true }));
|
||||
const steps = screen.getByLabelText('最多执行步数');
|
||||
fireEvent.change(steps, { target: { value: '70' } });
|
||||
const budget = screen.getByLabelText('每次任务上限(词元点数)');
|
||||
await waitFor(() => expect(budget).toBeEnabled());
|
||||
fireEvent.change(budget, { target: { value: '2.50' } });
|
||||
|
||||
fireEvent.mouseDown(screen.getByRole('tab', { name: '介绍与提示词' }), { button: 0, ctrlKey: false });
|
||||
const prompt = ' 保留我的原始写作要求。\n一次讨论一个想法。\n';
|
||||
fireEvent.change(screen.getByLabelText('提示词'), { target: { value: prompt } });
|
||||
expect(model).not.toBeVisible();
|
||||
expect(search).not.toBeVisible();
|
||||
expect(budget).not.toBeVisible();
|
||||
fireEvent.mouseDown(screen.getByRole('tab', { name: '高级设置' }), { button: 0, ctrlKey: false });
|
||||
|
||||
expect(screen.getByLabelText('模型')).toBe(model);
|
||||
expect(model).toHaveValue('model-a');
|
||||
expect(screen.getByLabelText('搜索工具箱')).toBe(search);
|
||||
expect(search).toHaveValue('文章');
|
||||
expect(screen.getByRole('checkbox', { name: '文章整理工具', exact: true })).toBeChecked();
|
||||
expect(screen.getByRole('checkbox', { name: '写作方法', exact: true })).toBeChecked();
|
||||
expect(steps).toHaveValue(70);
|
||||
expect(budget).toHaveValue(2.5);
|
||||
expect(screen.getByRole('button', { name: '保存费用上限', exact: true })).toBeEnabled();
|
||||
expect(screen.getByLabelText('提示词')).toHaveValue(prompt);
|
||||
const unload = new Event('beforeunload', { cancelable: true });
|
||||
window.dispatchEvent(unload);
|
||||
expect(unload.defaultPrevented).toBe(true);
|
||||
expect(api.save).not.toHaveBeenCalled();
|
||||
expect(api.call.mock.calls.some(([operation]) => ['saveBudget', 'publish', 'preview', 'submit'].includes(operation))).toBe(false);
|
||||
});
|
||||
|
||||
it('retains requirements when model loading fails and offers a usable retry', async () => {
|
||||
const base = api.call.getMockImplementation()!;
|
||||
let fail = true;
|
||||
@@ -650,7 +719,7 @@ it('saves an existing configured draft without creating or loading a configurati
|
||||
expect(api.call.mock.calls.some(([operation]) => ['threads', 'history', 'preview', 'submit', 'publish'].includes(operation))).toBe(false);
|
||||
});
|
||||
|
||||
it('switches peer editor tabs while retaining the advanced category and unfinished form inputs', async () => {
|
||||
it('switches editor pages while retaining unfinished knowledge and budget form inputs', async () => {
|
||||
api.list.mockResolvedValue({ agents: [{ ...draft, configuration: { ...EMPTY_CLOUD_CONFIGURATION, model: 'test-model' } }], next_cursor: null });
|
||||
open();
|
||||
fireEvent.click(await screen.findByRole('button', { name: '继续制作', exact: true }));
|
||||
@@ -669,18 +738,17 @@ 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');
|
||||
fireEvent.click(screen.getByRole('button', { name: '知识', exact: true }));
|
||||
expect(screen.getByLabelText('模型')).toBeVisible();
|
||||
expect(screen.queryByRole('navigation', { name: '配置分类' })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('管理我的知识文档'));
|
||||
fireEvent.click(screen.getByRole('button', { name: '新建知识库' }));
|
||||
const knowledgeName = screen.getByLabelText('知识库名称');
|
||||
fireEvent.change(knowledgeName, { target: { value: '还没创建的资料' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '限制', exact: true }));
|
||||
const budgetInput = screen.getByLabelText('每日上限(词元点数)');
|
||||
await waitFor(() => expect(budgetInput).toBeEnabled());
|
||||
fireEvent.change(budgetInput, { target: { value: '12.50' } });
|
||||
fireEvent.mouseDown(advanced, { button: 0, ctrlKey: false });
|
||||
expect(screen.getByRole('button', { name: '限制', exact: true })).toHaveAttribute('aria-current', 'page');
|
||||
expect(knowledgeName).toBeVisible();
|
||||
expect(budgetInput).toBeVisible();
|
||||
|
||||
fireEvent.mouseDown(requirements, { button: 0, ctrlKey: false });
|
||||
@@ -689,10 +757,9 @@ it('switches peer editor tabs while retaining the advanced category and unfinish
|
||||
expect(screen.getByLabelText('提示词')).toBe(instructions);
|
||||
expect(instructions).toHaveValue('没有保存的要求');
|
||||
fireEvent.mouseDown(advanced, { button: 0, ctrlKey: false });
|
||||
expect(screen.getByRole('button', { name: '限制', exact: true })).toHaveAttribute('aria-current', 'page');
|
||||
expect(screen.queryByRole('navigation', { name: '配置分类' })).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText('每日上限(词元点数)')).toBe(budgetInput);
|
||||
expect(budgetInput).toHaveValue(12.5);
|
||||
fireEvent.click(screen.getByRole('button', { name: '知识', exact: true }));
|
||||
expect(screen.getByLabelText('知识库名称')).toBe(knowledgeName);
|
||||
expect(knowledgeName).toHaveValue('还没创建的资料');
|
||||
expect(screen.queryByLabelText('消息')).not.toBeInTheDocument();
|
||||
@@ -711,7 +778,6 @@ it('retains unsaved instructions while saving a budget independently of the draf
|
||||
expect(screen.queryByLabelText('消息')).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText('提示词')).toHaveValue('未保存的指令');
|
||||
fireEvent.mouseDown(screen.getByRole('tab', { name: '高级设置' }), { button: 0, ctrlKey: false });
|
||||
fireEvent.click(screen.getByRole('button', { name: '限制', exact: true }));
|
||||
const budget = screen.getByLabelText('每日上限(词元点数)');
|
||||
await waitFor(() => expect(budget).toBeEnabled());
|
||||
fireEvent.change(budget, { target: { value: '12' } });
|
||||
@@ -755,13 +821,12 @@ it('searches capabilities while retaining existing selections outside the result
|
||||
open();
|
||||
fireEvent.click(await screen.findByRole('button', { name: '继续制作', exact: true }));
|
||||
fireEvent.mouseDown(screen.getByRole('tab', { name: '高级设置' }), { button: 0, ctrlKey: false });
|
||||
fireEvent.click(screen.getByRole('button', { name: '能力', exact: true }));
|
||||
expect(await screen.findByRole('checkbox', { name: /missing-tool/ })).toBeChecked();
|
||||
fireEvent.click(screen.getByRole('button', { name: '选择工具', exact: true }));
|
||||
fireEvent.change(screen.getByLabelText('搜索工具'), { target: { value: '资料' } });
|
||||
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 });
|
||||
expect(screen.getByLabelText('搜索工具')).toHaveValue('资料');
|
||||
expect(screen.getByLabelText('搜索工具箱')).toHaveValue('资料');
|
||||
expect(screen.queryByRole('checkbox', { name: '当前时间' })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: /资料搜索/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '完成选择' }));
|
||||
|
||||
Reference in new issue
Block a user