Polish personal Agent conversation workspace
This commit is contained in:
1 parent
1aef423df0
commit
a340c0fd94
7 files changed
+599
-53
No files matched your search
@@ -0,0 +1,78 @@
|
||||
# Task: Polish personal Agent conversations and update combined trial
|
||||
|
||||
## Identity
|
||||
|
||||
- Task ID: 20261012-agent-chat-polish-9f43c8a2
|
||||
- Mode: Feature
|
||||
- Branch: codex/20261012-agent-chat-polish-9f43c8a2-agent-chat-polish
|
||||
- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261012-agent-chat-polish-9f43c8a2
|
||||
- Base commit: 1aef423df0320cf946600af8ff44fa469f637c2b
|
||||
- Owner: codex
|
||||
- Status: Ready for Integration
|
||||
|
||||
## Scope
|
||||
|
||||
- Polish personal Agent conversation layout, welcome, message roles, toolbar, files and composer using the existing Makelore light/cream/pastel visual system. Combine chat-only adjustment action into the conversation-source row to reduce duplicate navigation; include completed entry-nav refinements from base. Latest user steering cancels the trial update; finish code and checks only. Current trial stays unchanged.
|
||||
|
||||
## Intent And Constraints
|
||||
|
||||
- User requests product-language communication and chat polish. Initially requested a combined trial update, then explicitly said “不用更新临时应用”; that newer constraint is authoritative. Preserve all message text, Markdown, attachment, history, approval, new-thread, stop, queue, replay/recovery and draft-leave guards. Main/WS/Yuxi identity, billing and execution remain authoritative. Styling is scoped to personal CloudChat and its workspace shell; Code/teacher and WeChat protocols unchanged. Opening or example selection does not submit. No real cloud mutation/model call during verification; manual login after restart remains with user.
|
||||
|
||||
## Project Context Loaded
|
||||
|
||||
Task context:
|
||||
- Task ID: 20261012-agent-chat-polish-9f43c8a2; mode Feature; owner codex.
|
||||
- Branch: codex/20261012-agent-chat-polish-9f43c8a2-agent-chat-polish.
|
||||
- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261012-agent-chat-polish-9f43c8a2.
|
||||
- Base: 1aef423df0320cf946600af8ff44fa469f637c2b, verified current trial source including completed entry navigation.
|
||||
- Successful bundled start/status matched identity, absolute worktree, mode, branch and base. 36 other owners inspected through peer task records only; 35 concrete scopes, one undefined old child-language task with no dependency; no blocking semantic conflict.
|
||||
|
||||
Read:
|
||||
- AGENTS.md; 05-agent-entry/read-before-planning.md, memory-index.md, planning-gate.md and own task record.
|
||||
- 00-brief/project-positioning.md, success-criteria.md; 20-architecture/community-collaboration.md; current-state integrated snapshot.
|
||||
- 10-decisions/decision-index.md and full personal-cloud-agents ADR; task-relevant system-overview, business-rules and evidence/reflection/commitments indexes (unchanged canonical context retained from prior navigation task).
|
||||
- Existing CloudChat/DraftEditor/files and scoped visual CSS. Design skill system search and Airbnb warm/soft consumer reference; adapt to existing project fonts/colors, no external fonts/assets or dark theme.
|
||||
|
||||
Relevant understanding:
|
||||
- Goal: approachable AI creation/use for students and creators; no course management or cloud-auth/billing authority in Renderer.
|
||||
- Integrated current-state predates this conversation's simplified config and entry UI; current user-approved behavior in base is authoritative.
|
||||
- New task is presentation polish only, plus reducing redundant chat action row. Pending operations/retries retain original identity and draft/attachment guards, server Run state remains authoritative, no decorative invented status.
|
||||
- Evidence commitments distinguish fixture/local runtime from paid-provider, cloud deployment and signed installed acceptance.
|
||||
- Likely modules: CloudChat, scoped agent-chat CSS, DraftEditor chat shell; focused workflow/unit/browser/Electron tests and README.
|
||||
- Unknown: native trial currently awaits login; CUA clicks/screenshots have been intermittently unreliable. Real final page observation may need user navigation. Prior discard authorization does not cover new edits.
|
||||
|
||||
Gate result:
|
||||
- Passed.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Apply existing cream/pastel language to personal chat welcome, message roles, readable transcript and prominent composer.
|
||||
2. Combine chat-only adjustment/source controls and pass presentation identity without changing save/apply/navigation safeguards.
|
||||
3. Verify focused workflows and responsive empty/history/running/approval states, build and review.
|
||||
4. Record final verification and complete task ownership; do not bind, probe, launch or replace the temporary app after the latest cancellation.
|
||||
|
||||
## Outcome
|
||||
|
||||
- Personal chat now shares the existing cream, pastel blue/lilac/mint palette and rounded controls. Empty conversations show the current assistant portrait/name, a friendly greeting and existing example prompts; examples still only fill a draft.
|
||||
- User and assistant messages have distinct presentation, assistant portraits and readable text. Long Markdown/code/tables remain complete and scroll inside their message bounds; files, history, new conversation, status, approvals, stop and send retain their behavior.
|
||||
- Chat-only “调整智能体” moved into the source-control row. Saved persona data is passed as presentation props; save/apply, pending input, hidden mounts and source-switch guards are unchanged. Narrow containers wrap controls and stack examples; the composer stays visible separately from the transcript. Short chat windows hide only duplicate settled configuration status and already-disabled save/apply controls; dirty, busy, pending, conflict, error or unknown publication status restores them. Name/avatar/back/adjust/first-start remain available.
|
||||
- README updated. Meaningful browser and Electron fixtures cover responsive bounds, full workspace shell, example no-submit, actual persona display, history/files/stop/approval actions and homepage re-entry.
|
||||
- User cancelled trial updating before any build binding, probe, launch or replacement. A prepared, unbound app directory remains at /Users/chillishark/Makelore 麦洛/.trial-runtime/agent-chat-polish-20261012-9f43c8a2; it is not a deliverable runtime and must be rebound/rechecked if requested later. Current pointer remains Entry Trial source 1aef423df0320cf946600af8ff44fa469f637c2b, PID 63739. No real cloud draft, publish or model operation was used.
|
||||
|
||||
## Verification
|
||||
|
||||
- TypeScript typecheck passed. Scoped ESLint passed with zero errors and two existing CloudChat warnings (mixed component export and ref cleanup); no new warning introduced. Whitespace check passed.
|
||||
- 31 Cloud Agents page unit cases passed; 26 workflow/guidance cases passed, including history, pending retries, previews, queue, approval and recovery behaviors.
|
||||
- All 8 isolated real-CloudChat Chrome layout cases passed: empty at 375/768/1024/1440; long Markdown at 375×540/1440×900; narrow running Stop and short approval. Fixtures mock only Main IPC/event streams and block external browser requests. No account or model involved.
|
||||
- Electron fixture passed the personal-agent create/config/save/apply/direct-chat flow, same-row source/adjust controls, persona rendering, example draft/no-submit, long transcript/composer bounds and whole-editor 980×680 at 1.5 zoom, plus homepage re-entry. The first complete native fixture screenshot exposed a zero-height transcript despite a visible composer; settled-header and compact spacing refinements restore at least 80 px of transcript in the final check. A subsequent 76 px result was corrected by reducing short-window composer/section spacing; the assertion was not weakened. Native fixture capture is used for the zoomed visual check because Chromium screenshots crop a zoomed Electron surface.
|
||||
- build:vite passed for Renderer/Main/preload/utility; existing Browserslist, chunk-size and mixed-import notices only. Production-code layout reviewed in isolated fixtures, not represented as real signed installed/cloud acceptance.
|
||||
- Independent read-only review found no functional regression. Its request for a complete-shell narrow/short check was addressed in Electron, in addition to CloudChat-only browser coverage. A second review confirmed the settled-header condition only hides actions that are already disabled and does not affect configuration pages or pending operations.
|
||||
- Fixture screenshots retained in ignored .tmp/chat-layout-verified and .tmp/chat-electron-verified, inside this worktree. Runtime pointer observed read-only and unchanged; no live-login/navigation/visual acceptance claimed.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- No outstanding implementation work. Updating the temporary app requires a new user request; any future launch still needs fresh unsaved-input observation and user login.
|
||||
|
||||
## Promotion Candidates
|
||||
|
||||
- Integrate the scoped chat presentation and its checks alongside the completed entry-navigation base; canonical project documents were intentionally not edited in Feature mode.
|
||||
@@ -12,7 +12,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智
|
||||
|
||||
- `Makelore Code|AI 编程`:管理本地项目、项目智能体、对话、文件上下文、代码变更和运行时。
|
||||
会话工作台采用浅蓝项目栏、彩色通用手柄图标与卡片选中态;中间对话/作品使用圆角标签,作品预览的后退、前进、刷新与网址输入统一放在标签右侧;只在作品页显示,窄窗口自动换行。右侧智能体咨询使用奶油色标题区。沿用真实项目和会话入口、侧栏伸缩与账户菜单,样式只作用于 Code 会话工作台。可编辑的空对话在输入区上方的可用区域中央显示童趣欢迎语;「去作品广场逛逛」入口紧贴输入框上方,两者一起位于底部。老师介绍以固定宽度浮在右上角,不占用中间布局;欢迎语和底部输入区始终相对项目栏右侧的整个内容区域居中;小窗口的欢迎内容独立滚动;发送首条消息后收起欢迎区。侧栏保留「作品广场」入口,只有点击后才在系统浏览器打开公共广场,不请求、摘取或内嵌展示广场作品。打开广场保留当前项目、对话、草稿和附件,不自动发送消息;老师求助按钮保留临时应用的最新文案「帮我整体看🧠」。
|
||||
- `Makelore Agents|AI 智能体`:从模块首页进入时先打开“我的智能体”卡片列表,保留此前对话与操作恢复记录;返回首页箭头与侧栏折叠按钮同排,折叠后仍可返回或展开,macOS 原生窗口按钮留出安全空间。首页围绕自己的智能体和创建入口,显示童趣问候“Hi,今天想创造还是想聊聊?”,不再显示更多、收到的分享、活动或归档筛选。卡片显示名称、可为空的一句话介绍及状态,支持聊天、调整、继续制作和确认删除;删除沿用云端归档机制,移出列表并停用新调用,历史和费用保留。创建先填写名称并选择平台像素头像或上传头像,下一步进入独立配置。顶部名称有编辑图标,头像可点击修改;未设置头像时使用同一张默认像素头像,从创建到配置、改名、保存和重新打开保持一致。基础页为“介绍与提示词”,一句话介绍可留空,完整提示词保留原始文本;模型作为高级设置第一项必选,未选择时不能开始使用,草稿可分步保存,不自动选模型。介绍与提示词采用浅蓝介绍卡和浅紫提示词卡;高级设置按模型、工具箱(Tool)、做事手册(Skill)、MCP 服务、子智能体和知识库纵向呈现,不再设内部分类切换;不展示 Tool/Skill 解释、云端能力管理、费用上限或运行限制卡片,保存其他配置时保留既有运行限制,费用规则不变。顶层页签切换保留搜索、资源选择、知识表单及未保存输入。配置页移除聊天、自动任务和使用与接入导航,独立聊天仍从卡片进入,微信账号仍在侧栏渠道管理。草稿明确保存(Ctrl/⌘+S);保存只更新草稿,“应用最新修改”使新配置生效,失败重试保持原操作身份;点击“开始使用”完成必要的私有启用后直接打开聊天,不再展示二次确认页;未保存修改、缺少模型、冲突或尚未确认的请求会留在配置页处理,打开聊天不自动发送消息。头像上传、Main-owned Host API、访问隔离、费用与不可变版本规则保持;介绍留空需要配套 Yuxi DraftCreate/DraftSave 的空字符串契约。
|
||||
- `Makelore Agents|AI 智能体`:从模块首页进入时先打开“我的智能体”卡片列表,保留此前对话与操作恢复记录;返回首页箭头与侧栏折叠按钮同排,折叠后仍可返回或展开,macOS 原生窗口按钮留出安全空间。首页围绕自己的智能体和创建入口,显示童趣问候“Hi,今天想创造还是想聊聊?”,不再显示更多、收到的分享、活动或归档筛选。卡片显示名称、可为空的一句话介绍及状态,支持聊天、调整、继续制作和确认删除;删除沿用云端归档机制,移出列表并停用新调用,历史和费用保留。创建先填写名称并选择平台像素头像或上传头像,下一步进入独立配置。顶部名称有编辑图标,头像可点击修改;未设置头像时使用同一张默认像素头像,从创建到配置、改名、保存和重新打开保持一致。基础页为“介绍与提示词”,一句话介绍可留空,完整提示词保留原始文本;模型作为高级设置第一项必选,未选择时不能开始使用,草稿可分步保存,不自动选模型。介绍与提示词采用浅蓝介绍卡和浅紫提示词卡;高级设置按模型、工具箱(Tool)、做事手册(Skill)、MCP 服务、子智能体和知识库纵向呈现,不再设内部分类切换;不展示 Tool/Skill 解释、云端能力管理、费用上限或运行限制卡片,保存其他配置时保留既有运行限制,费用规则不变。顶层页签切换保留搜索、资源选择、知识表单及未保存输入。配置页移除聊天、自动任务和使用与接入导航,独立聊天仍从卡片进入,微信账号仍在侧栏渠道管理。草稿明确保存(Ctrl/⌘+S);保存只更新草稿,“应用最新修改”使新配置生效,失败重试保持原操作身份;点击“开始使用”完成必要的私有启用后直接打开聊天,不再展示二次确认页;未保存修改、缺少模型、冲突或尚未确认的请求会留在配置页处理,打开聊天不自动发送消息。独立对话沿用奶油底色和浅蓝、浅紫、薄荷色:空对话展示当前智能体头像与名称,示例只填入草稿;用户消息和智能体回复区分呈现,长 Markdown、表格和代码在消息区内滚动,底部输入框、附件、停止和历史入口保留;短窗口中,配置完全生效且没有待处理问题时收起重复的已保存/已应用提示和禁用按钮,修改或待确认时恢复;“调整智能体”与客户端/微信来源同排,窄窗口会换行,未发送内容的离开保护不变。头像上传、Main-owned Host API、访问隔离、费用与不可变版本规则保持;介绍留空需要配套 Yuxi DraftCreate/DraftSave 的空字符串契约。
|
||||
|
||||
云知识库的 embedding 模型只由平台管理员在 Yuxi 后台维护:在「智能体管理 → 模型供应商」启用供应商,按供应商要求填写有效的服务地址和 API Key,添加并启用 embedding 模型并填写实际向量维度;embedding 向量化费用由平台承担,不计入用户用量、词元点数或智能体费用上限;one-api 不参与 embedding 配置,Works Square 只负责其他应计费模型调用的创建者词元点数账本。客户端区分目录加载中、加载失败与无可用模型,空状态提供管理员配置说明;「刷新知识库与模型」保留未提交名称,未确认的创建请求沿用原操作身份。
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { History, MessageSquarePlus, Send, Square } from 'lucide-react';
|
||||
import { ArrowUpRight, History, MessageCircle, MessageSquarePlus, Send, Sparkles, Square } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
|
||||
import type { CloudHistory, CloudInterrupt, CloudPrompt, CloudRequest, CloudRun, CloudThread, CloudScheduleProposal } from '../../../shared/cloud-agents';
|
||||
import { CloudFiles } from './CloudFiles';
|
||||
import { CloudAvatar } from './CloudAvatar';
|
||||
import './desktop-workspace.css';
|
||||
import './agent-chat.css';
|
||||
import ReactMarkdown, { defaultUrlTransform } from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { CloudPendingContext, useCloudPendingState, usePendingCloudInput } from './CloudPending';
|
||||
@@ -21,8 +23,8 @@ const message = (e: unknown) => e instanceof Error ? e.message : '暂时无法
|
||||
const object = (v: unknown): Record<string, unknown> => v && typeof v === 'object' ? v as Record<string, unknown> : {};
|
||||
|
||||
export function CloudChat({ slug, previewRevision: latestRevision, initialThread, initialThreadId, onSchedule, activeView = true, headerAction, previewReady = true,
|
||||
examples = agentExamples(), hasUnsavedChanges = false, saveDisabled = false, onSaveForPreview }: {
|
||||
slug: string; headerAction?: ReactNode; previewReady?: boolean; activeView?: boolean; previewRevision?: number; initialThread?: CloudThread; initialThreadId?: string; onSchedule?: (proposal: CloudScheduleProposal) => void;
|
||||
assistantName = '助手', assistantAvatarUrl, examples = agentExamples(), hasUnsavedChanges = false, saveDisabled = false, onSaveForPreview }: {
|
||||
slug: string; assistantName?: string; assistantAvatarUrl?: string | null; headerAction?: ReactNode; previewReady?: boolean; activeView?: boolean; previewRevision?: number; initialThread?: CloudThread; initialThreadId?: string; onSchedule?: (proposal: CloudScheduleProposal) => void;
|
||||
examples?: readonly string[]; hasUnsavedChanges?: boolean; saveDisabled?: boolean; onSaveForPreview?: () => Promise<number | undefined>;
|
||||
}) {
|
||||
const [previewRevision, setPreviewRevision] = useState(latestRevision === undefined ? undefined : initialThread?.draft_revision ?? latestRevision);
|
||||
@@ -256,16 +258,16 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread
|
||||
else if (runId) await cloudAgentsApi.call('cancelRun', { run_id: runId });
|
||||
} catch (e) { setError(message(e)); } finally { setBusy(false); }
|
||||
};
|
||||
return <CloudPendingContext.Provider value={childrenPending.report}><section className="agent-chat bg-background" aria-label={previewRevision === undefined ? '智能体对话' : '草稿预览'}>
|
||||
<div className="agent-chat-toolbar"><div className="flex min-h-12 shrink-0 items-center justify-between gap-3 border-b border-border py-2">
|
||||
<div className="min-w-0 text-sm">{previewRevision === undefined ? '和助手聊一聊' : '试一试'}
|
||||
return <CloudPendingContext.Provider value={childrenPending.report}><section className="agent-chat agent-chat-styled" aria-label={previewRevision === undefined ? '智能体对话' : '草稿预览'}>
|
||||
<div className="agent-chat-toolbar"><div className="agent-chat-toolbar-row">
|
||||
<div className="agent-chat-toolbar-heading"><span className="agent-chat-toolbar-icon" aria-hidden="true"><MessageCircle /></span><div className="min-w-0"><p className="agent-chat-toolbar-title">{previewRevision === undefined ? '和助手聊一聊' : '试一试'}</p>
|
||||
{previewRevision !== undefined && <p className="mt-1 text-xs text-muted-foreground">当前试用 · 修订 {previewRevision}</p>}
|
||||
<p className="mt-1 text-xs text-muted-foreground">由创建者支付词元点数{run?.version && ` · 版本 ${run.version}`}</p></div>
|
||||
<div className="flex shrink-0"><Button variant="ghost" size="icon" aria-label="查看历史对话" aria-expanded={showHistory} onClick={() => setShowHistory(v => !v)}><History className="h-4 w-4" /></Button>
|
||||
<Button variant="ghost" disabled={busy || Boolean(intent) || loading} onClick={() => (query.trim() || attachmentIds.length > 0 || childrenPending.hasPending) ? setLeaving(true) : void choose(null)} aria-label="新对话"><MessageSquarePlus className="h-4 w-4" /></Button>{headerAction}</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">由创建者支付词元点数{run?.version && ` · 版本 ${run.version}`}</p></div></div>
|
||||
<div className="agent-chat-toolbar-actions"><Button variant="ghost" aria-label="查看历史对话" title="查看历史对话" aria-expanded={showHistory} onClick={() => setShowHistory(v => !v)}><History aria-hidden="true" className="h-4 w-4" /><span>历史对话</span></Button>
|
||||
<Button variant="ghost" disabled={busy || Boolean(intent) || loading} onClick={() => (query.trim() || attachmentIds.length > 0 || childrenPending.hasPending) ? setLeaving(true) : void choose(null)} aria-label="新对话"><MessageSquarePlus aria-hidden="true" className="h-4 w-4" /><span>新对话</span></Button>{headerAction}</div>
|
||||
</div>
|
||||
{showHistory && threads.length === 0 && <p className="py-2 text-xs text-muted-foreground">还没有历史对话</p>}
|
||||
{showHistory && threads.length > 0 && <select aria-label="历史对话" className="mt-3 h-10 rounded-md border border-input bg-background px-3 text-sm"
|
||||
{showHistory && threads.length > 0 && <select aria-label="历史对话" className="agent-chat-history-select"
|
||||
value={thread?.thread_id ?? ''} disabled={busy || Boolean(intent) || Boolean(query.trim()) || attachmentIds.length > 0 || childrenPending.hasPending} onChange={e => void choose(threads.find(t => t.thread_id === e.target.value) ?? null)}>
|
||||
<option value="">新对话</option>{threads.map(t => <option key={t.thread_id} value={t.thread_id}>{t.mode === 'preview' ? `修订 ${t.draft_revision ?? previewRevision} · ` : ''}{t.title || '未命名对话'} · {cloudStatus(t.status)}</option>)}
|
||||
</select>}
|
||||
@@ -278,23 +280,28 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread
|
||||
</div>}
|
||||
</div>
|
||||
<div ref={transcript} data-testid="cloud-chat-messages" className="agent-chat-scroll"
|
||||
onScroll={e => { const el = e.currentTarget; following.current = el.scrollHeight - el.scrollTop - el.clientHeight < 64; }}><div className="space-y-5 py-5 pr-2">
|
||||
{loading ? <p role="status" className="text-sm text-muted-foreground">正在读取对话…</p>
|
||||
: !history?.messages.length && !liveText && <div className="space-y-4 py-6 text-sm leading-7">
|
||||
<h2 className="font-medium">{mode === 'preview' ? '问它一个小问题' : '今天想让它帮你做什么?'}</h2>
|
||||
<p className="text-muted-foreground">{mode === 'preview' ? '看看它的回答是否符合你的要求。不满意就改一改,再试一次。' : '从下面选一句,或写下你自己的想法。'}</p>
|
||||
<div className="grid gap-2">{examples.map(example => <Button key={example} type="button" variant="outline"
|
||||
className="h-auto min-h-10 justify-start whitespace-normal py-2 text-left font-normal leading-6"
|
||||
disabled={busy || Boolean(intent) || Boolean(query.trim()) || Boolean(thread?.archived)} onClick={() => setQuery(example)}>{example}</Button>)}</div>
|
||||
<p className="text-xs text-muted-foreground">点选只会填入输入框,由你决定何时发送。</p>
|
||||
onScroll={e => { const el = e.currentTarget; following.current = el.scrollHeight - el.scrollTop - el.clientHeight < 64; }}><div className="agent-chat-transcript">
|
||||
{loading ? <div role="status" className="agent-chat-loading"><MessageCircle aria-hidden="true" /><p>正在读取对话…</p></div>
|
||||
: !history?.messages.length && !liveText && <div className="agent-chat-welcome">
|
||||
<div className="agent-chat-welcome-portrait"><CloudAvatar src={assistantAvatarUrl} name={assistantName} className="agent-chat-welcome-avatar" /><Sparkles aria-hidden="true" className="agent-chat-welcome-spark" /></div>
|
||||
<p className="agent-chat-welcome-name">{assistantName}</p>
|
||||
<h2>{mode === 'preview' ? '问它一个小问题' : 'Hi,今天想聊些什么?'}</h2>
|
||||
<p className="agent-chat-welcome-description">{mode === 'preview' ? '看看它的回答是否符合你的要求。不满意就改一改,再试一次。' : '从下面选一句,或写下你自己的想法。'}</p>
|
||||
<div className="agent-chat-examples">{examples.map((example, index) => <Button key={example} type="button" variant="outline"
|
||||
className="agent-chat-example" data-tone={index % 3}
|
||||
disabled={busy || Boolean(intent) || Boolean(query.trim()) || Boolean(thread?.archived)} onClick={() => setQuery(example)}><span>{example}</span><ArrowUpRight aria-hidden="true" /></Button>)}</div>
|
||||
<p className="agent-chat-example-note">点选只会填入输入框,由你决定何时发送。</p>
|
||||
</div>}
|
||||
{previousTrial && <details className="rounded-lg border border-border p-3 text-sm">
|
||||
<summary className="min-h-10 cursor-pointer content-center font-medium">对比上一次试用 · 修订 {previousTrial.revision}</summary>
|
||||
<p className="my-2 text-xs text-muted-foreground">这是修改前的回答。用同一个问题再问一次,更容易看出变化。</p>
|
||||
<TrialMessages history={previousTrial.history} onError={setError} />
|
||||
<TrialMessages history={previousTrial.history} onError={setError} assistantName={assistantName} assistantAvatarUrl={assistantAvatarUrl} />
|
||||
</details>}
|
||||
{history && <TrialMessages history={history} onError={setError} />}
|
||||
{liveText && <div className="whitespace-pre-wrap break-words p-2 text-sm leading-7">{liveText}</div>}
|
||||
{history && <TrialMessages history={history} onError={setError} assistantName={assistantName} assistantAvatarUrl={assistantAvatarUrl} />}
|
||||
{liveText && <article className="agent-chat-message" data-role="assistant" aria-label="助手正在回复">
|
||||
<CloudAvatar src={assistantAvatarUrl} name={assistantName} className="agent-chat-message-avatar" />
|
||||
<div className="agent-chat-message-content"><p className="agent-chat-speaker">{assistantName}</p><div className="agent-chat-message-body whitespace-pre-wrap">{liveText}</div></div>
|
||||
</article>}
|
||||
{onSchedule && history?.schedule_proposals?.map(proposal => <article key={proposal.id} className="space-y-3 rounded-xl border border-violet-200 bg-violet-50/30 p-4 text-sm">
|
||||
<h3 className="font-medium">自动任务建议 · {proposal.name}</h3><p className="whitespace-pre-wrap">{proposal.prompt}</p>
|
||||
<p className="text-xs text-muted-foreground">{proposal.cron_expression} · {proposal.timezone} · 尚未启用</p>
|
||||
@@ -336,27 +343,30 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread
|
||||
}} /></div>
|
||||
{leaving && <div className="mb-3 flex flex-wrap items-center gap-2 text-sm"><span>放弃未发送的内容并新建对话?</span>
|
||||
<Button variant="outline" onClick={() => { setLeaving(false); void choose(null); }}>放弃内容</Button><Button variant="ghost" onClick={() => setLeaving(false)}>继续编辑</Button></div>}
|
||||
<form data-testid="cloud-chat-composer" className="agent-chat-composer rounded-xl border border-input p-3 shadow-sm" onSubmit={e => { e.preventDefault(); void send(); }}>
|
||||
<Textarea aria-label="消息" className="h-20 min-h-20 max-h-40 resize-y border-0 p-1 shadow-none focus-visible:ring-0" maxLength={32000}
|
||||
value={query} disabled={busy || Boolean(intent) || loading || thread?.archived} placeholder="输入消息或任务目标…" onChange={e => setQuery(e.target.value)}
|
||||
<form data-testid="cloud-chat-composer" className="agent-chat-composer" onSubmit={e => { e.preventDefault(); void send(); }}>
|
||||
<Textarea aria-label="消息" className="agent-chat-input resize-y border-0 shadow-none focus-visible:ring-0" maxLength={32000}
|
||||
value={query} disabled={busy || Boolean(intent) || loading || thread?.archived} placeholder="说说你的想法,或问一个问题…" onChange={e => setQuery(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing && e.keyCode !== 229) { e.preventDefault(); void send(); } }} />
|
||||
<div className="mt-2 flex items-center justify-between gap-3">
|
||||
<p role="status" className="text-xs text-muted-foreground">{progress || (running ? cloudStatus(run?.status ?? request?.status ?? '') : run ? cloudStatus(run.status) : '就绪')}</p>
|
||||
{running ? <Button size="sm" variant="outline" disabled={busy} onClick={() => void cancel()} type="button"><Square className="mr-2 h-3 w-3" />停止</Button>
|
||||
: <Button size="sm" type="submit" disabled={busy || loading || thread?.archived || (!intent && (hasUnsavedChanges || stalePreview || !previewReady || !query.trim()))}><Send className="mr-2 h-3 w-3" />{busy ? '正在提交…' : intent ? '重试本次发送' : '发送'}</Button>}
|
||||
<div className="agent-chat-composer-footer">
|
||||
<div className="agent-chat-composer-meta"><p role="status" className="agent-chat-status" data-active={running}>{progress || (running ? cloudStatus(run?.status ?? request?.status ?? '') : run ? cloudStatus(run.status) : '就绪')}</p><span className="agent-chat-keyboard-hint">Enter 发送 · Shift + Enter 换行</span></div>
|
||||
{running ? <Button className="agent-chat-stop" size="sm" variant="outline" disabled={busy} onClick={() => void cancel()} type="button"><Square className="mr-2 h-3 w-3" />停止</Button>
|
||||
: <Button className="agent-chat-send" size="sm" type="submit" disabled={busy || loading || thread?.archived || (!intent && (hasUnsavedChanges || stalePreview || !previewReady || !query.trim()))}><Send className="mr-2 h-3 w-3" />{busy ? '正在提交…' : intent ? '重试本次发送' : '发送'}</Button>}
|
||||
</div>
|
||||
</form>
|
||||
</section></CloudPendingContext.Provider>;
|
||||
}
|
||||
|
||||
function TrialMessages({ history, onError }: { history: CloudHistory; onError: (error: string) => void }) {
|
||||
return history.messages.map(item => <article key={item.id} className={item.role === 'user' ? 'ml-8 rounded-xl bg-muted/60 p-4' : 'p-2'}>
|
||||
<p className="mb-2 text-xs text-muted-foreground">{item.role === 'user' ? '你' : '助手'}</p>
|
||||
<div className="prose prose-sm max-w-none break-words"><ReactMarkdown urlTransform={url => url.startsWith('sandbox:/') ? url.slice(8) : defaultUrlTransform(url)} remarkPlugins={[remarkGfm]} components={{
|
||||
function TrialMessages({ history, onError, assistantName = '助手', assistantAvatarUrl }: {
|
||||
history: CloudHistory; onError: (error: string) => void; assistantName?: string; assistantAvatarUrl?: string | null;
|
||||
}) {
|
||||
return history.messages.map(item => <article key={item.id} className="agent-chat-message" data-role={item.role === 'user' ? 'user' : 'assistant'}>
|
||||
{item.role !== 'user' && <CloudAvatar src={assistantAvatarUrl} name={assistantName} className="agent-chat-message-avatar" />}
|
||||
<div className="agent-chat-message-content"><p className="agent-chat-speaker">{item.role === 'user' ? '你' : assistantName}</p>
|
||||
<div className="agent-chat-message-body prose prose-sm max-w-none break-words"><ReactMarkdown urlTransform={url => url.startsWith('sandbox:/') ? url.slice(8) : defaultUrlTransform(url)} remarkPlugins={[remarkGfm]} components={{
|
||||
a: ({ href, children }) => href && !/^(https?:|mailto:|#)/i.test(href)
|
||||
? <button className="text-primary underline" onClick={() => void cloudAgentsApi.download(history.thread_id, href.replace(/^\/api\/chat\/thread\/[^/]+\/artifacts\//, '/')).catch(e => onError(message(e)))}>{children}</button>
|
||||
: <a href={href} target="_blank" rel="noreferrer">{children}</a>,
|
||||
}}>{item.content}</ReactMarkdown></div>
|
||||
}}>{item.content}</ReactMarkdown></div></div>
|
||||
</article>);
|
||||
}
|
||||
|
||||
|
||||
@@ -124,11 +124,12 @@ export function DraftEditor({ initial, recent, onBack, onSaved, startingPrompt,
|
||||
return () => window.removeEventListener('keydown', shortcut);
|
||||
});
|
||||
|
||||
return <CloudPendingContext.Provider value={pending.report}><section className="agent-desktop" data-testid="cloud-agent-editor">
|
||||
<header className="agent-workspace-header">
|
||||
return <CloudPendingContext.Provider value={pending.report}><section className={`agent-desktop${tab === 'chat' ? ' agent-chat-editor' : ''}`} data-testid="cloud-agent-editor"
|
||||
data-configuration-settled={!dirty && !busy && !publication.pending && !publication.error && !remote && publication.appliedRevision === saved.draft_revision}>
|
||||
<header className={`agent-workspace-header${tab === 'chat' ? ' agent-chat-workspace-header' : ''}`}>
|
||||
<Button variant="ghost" size="icon" className="h-10 w-10 shrink-0" aria-label="我的智能体" disabled={busy}
|
||||
onClick={() => dirty || hasPending ? setLeaving(true) : onBack()}><ArrowLeft className="h-4 w-4" /></Button>
|
||||
<Button variant="ghost" className="relative h-12 w-12 shrink-0 p-0" aria-label="修改头像" title="修改头像" disabled={busy} onClick={() => setEditingAvatar(true)}>
|
||||
<Button variant="ghost" className="agent-chat-identity-avatar relative h-12 w-12 shrink-0 p-0" aria-label="修改头像" title="修改头像" disabled={busy} onClick={() => setEditingAvatar(true)}>
|
||||
<CloudAvatar src={draft.icon_url} name={draft.name} identity={saved.slug} />
|
||||
<span className="absolute -bottom-1 -right-1 rounded-full border-2 border-background bg-primary p-1 text-white"><ImagePlus aria-hidden="true" className="h-3 w-3" /></span>
|
||||
</Button>
|
||||
@@ -141,12 +142,12 @@ export function DraftEditor({ initial, recent, onBack, onSaved, startingPrompt,
|
||||
</div>
|
||||
<p className="px-2 text-xs text-muted-foreground">{saved.published_version ? '我的智能体' : '配置智能体'}</p>
|
||||
</div>
|
||||
<span role="status" className={`shrink-0 text-xs ${needsApply || dirty ? 'text-amber-800' : 'text-muted-foreground'}`}>{savingDraft ? '正在保存…' : dirty ? '有未保存修改' : saved.published_version ? applicationStatus : '云端已保存'}</span>
|
||||
<Button className="min-h-10" size="sm" variant={dirty ? 'default' : 'outline'} disabled={!canSave} onClick={() => void save()} title="保存草稿(Ctrl / ⌘ S)">
|
||||
<span role="status" className={`agent-chat-config-status shrink-0 text-xs ${needsApply || dirty ? 'text-amber-800' : 'text-muted-foreground'}`}>{savingDraft ? '正在保存…' : dirty ? '有未保存修改' : saved.published_version ? applicationStatus : '云端已保存'}</span>
|
||||
<Button className="agent-chat-save-action min-h-10" size="sm" variant={dirty ? 'default' : 'outline'} disabled={!canSave} onClick={() => void save()} title="保存草稿(Ctrl / ⌘ S)">
|
||||
{savingDraft ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : dirty ? <Save className="mr-2 h-4 w-4" /> : <Check className="mr-2 h-4 w-4" />}
|
||||
{savingDraft ? '保存中…' : dirty ? '保存草稿' : '已保存'}
|
||||
</Button>
|
||||
{saved.published_version && <Button className="min-h-10 shrink-0" disabled={busy || dirty || remote !== null || !saved.configuration.model || !publication.access || (!publication.pending && publication.appliedRevision === saved.draft_revision)}
|
||||
{saved.published_version && <Button className="agent-chat-apply-action min-h-10 shrink-0" disabled={busy || dirty || remote !== null || !saved.configuration.model || !publication.access || (!publication.pending && publication.appliedRevision === saved.draft_revision)}
|
||||
title={dirty ? '请先保存草稿,再应用修改' : '将已保存的修改应用到正在使用的助手'}
|
||||
onClick={() => void publication.publish()}>
|
||||
{publication.busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
@@ -157,10 +158,6 @@ export function DraftEditor({ initial, recent, onBack, onSaved, startingPrompt,
|
||||
{publication.busy ? '正在开始…' : publication.pending ? '重试开始使用' : '开始使用'}
|
||||
</Button>}
|
||||
</header>
|
||||
{tab !== 'configuration' && <nav aria-label="智能体操作" className="agent-workspace-nav"><Button variant="ghost" onClick={() => {
|
||||
if (formalPending.hasPending) { setError('对话中还有未完成的内容,请先发送或处理后再调整智能体。'); return; }
|
||||
setError(''); navigate('configuration');
|
||||
}}><Pencil aria-hidden="true" className="mr-2 h-4 w-4" />调整智能体</Button></nav>}
|
||||
{saved.published_version && (needsApply || dirty) && <div className="shrink-0 border-b border-amber-200 bg-amber-50 px-6 py-3 text-sm text-amber-900">
|
||||
<p className="font-medium">{dirty ? '修改尚未保存和应用' : '这次修改还没有应用到正在使用的助手'}</p>
|
||||
<p className="mt-1 text-xs leading-5">保存只更新草稿。请点击顶部“应用最新修改”,再到微信等渠道使用。已开始的任务保持原配置。</p>
|
||||
@@ -201,17 +198,25 @@ export function DraftEditor({ initial, recent, onBack, onSaved, startingPrompt,
|
||||
</TabsContent>
|
||||
</Tabs>}
|
||||
</div>
|
||||
<div hidden={tab !== 'chat'} className="agent-workspace-view p-6">
|
||||
{visited.has('chat') && (saved.published_version ? <CloudPendingContext.Provider value={formalPending.report}>
|
||||
<nav aria-label="对话来源" className="mb-3 flex shrink-0 gap-2">
|
||||
<Button variant={conversationSource === 'desktop' ? 'secondary' : 'ghost'} disabled={formalPending.hasPending && conversationSource !== 'desktop'} onClick={() => setConversationSource('desktop')}>客户端对话</Button>
|
||||
<Button variant={conversationSource === 'wechat' ? 'secondary' : 'ghost'} disabled={formalPending.hasPending && conversationSource !== 'wechat'} onClick={() => { setWechatVisited(true); setConversationSource('wechat'); }}>微信对话</Button>
|
||||
<div hidden={tab !== 'chat'} className="agent-workspace-view agent-conversation-workspace">
|
||||
{visited.has('chat') && <CloudPendingContext.Provider value={formalPending.report}>
|
||||
<nav aria-label="对话来源" className="agent-conversation-sources">
|
||||
{saved.published_version && <div className="agent-conversation-source-options">
|
||||
<Button variant={conversationSource === 'desktop' ? 'secondary' : 'ghost'} aria-pressed={conversationSource === 'desktop'} disabled={formalPending.hasPending && conversationSource !== 'desktop'} onClick={() => setConversationSource('desktop')}>客户端对话</Button>
|
||||
<Button variant={conversationSource === 'wechat' ? 'secondary' : 'ghost'} aria-pressed={conversationSource === 'wechat'} disabled={formalPending.hasPending && conversationSource !== 'wechat'} onClick={() => { setWechatVisited(true); setConversationSource('wechat'); }}>微信对话</Button>
|
||||
</div>}
|
||||
<Button variant="ghost" className="agent-conversation-adjust" onClick={() => {
|
||||
if (formalPending.hasPending) { setError('对话中还有未完成的内容,请先发送或处理后再调整智能体。'); return; }
|
||||
setError(''); navigate('configuration');
|
||||
}}><Pencil aria-hidden="true" className="mr-2 h-4 w-4" />调整智能体</Button>
|
||||
</nav>
|
||||
{saved.published_version ? <>
|
||||
<div hidden={conversationSource !== 'desktop'} className="flex min-h-0 flex-1 flex-col">
|
||||
<CloudChat key="published" slug={saved.slug} activeView={tab === 'chat' && conversationSource === 'desktop'} /></div>
|
||||
<CloudChat key="published" slug={saved.slug} assistantName={saved.name} assistantAvatarUrl={saved.icon_url} activeView={tab === 'chat' && conversationSource === 'desktop'} /></div>
|
||||
<div hidden={conversationSource !== 'wechat'} className="min-h-0 flex-1 overflow-auto">{wechatVisited && <CloudChannelConversations slug={saved.slug} activeView={tab === 'chat' && conversationSource === 'wechat'} />}</div>
|
||||
</CloudPendingContext.Provider>
|
||||
: <div className="m-auto space-y-4 text-center text-sm"><p>保存配置后,开始使用这个智能体。</p><Button disabled={busy || remote !== null || !publication.access} onClick={startUsing}>{publication.pending ? '重试开始使用' : '开始使用'}</Button></div>)}
|
||||
</>
|
||||
: <div className="m-auto space-y-4 text-center text-sm"><p>保存配置后,开始使用这个智能体。</p><Button disabled={busy || remote !== null || !publication.access} onClick={startUsing}>{publication.pending ? '重试开始使用' : '开始使用'}</Button></div>}
|
||||
</CloudPendingContext.Provider>}
|
||||
</div>
|
||||
<Dialog open={editingAvatar} onOpenChange={open => { if (!uploading) setEditingAvatar(open); }}>
|
||||
<DialogContent><DialogTitle>修改头像</DialogTitle><DialogDescription>选择这个智能体的头像。</DialogDescription>
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/* Personal Agent chat shares the homepage/configuration palette. Keep these
|
||||
* rules local so Code, teacher and channel conversation renderers stay intact. */
|
||||
.agent-chat-editor { background: #fffdf9; }
|
||||
.agent-chat-workspace-header { background: #fffdf9; border-color: #e9e5de; }
|
||||
.agent-chat-workspace-header > button { border-radius: 13px; }
|
||||
.agent-conversation-workspace { padding: 0; background: #fffdf9; container-type: inline-size; }
|
||||
.agent-conversation-sources { display: flex; flex-shrink: 0; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 16px; padding: 10px 24px; border-bottom: 1px solid #e9e5de; }
|
||||
.agent-conversation-source-options { display: flex; gap: 4px; padding: 4px; border: 1px solid #e5e2ee; border-radius: 15px; background: #f2eff7; }
|
||||
.agent-conversation-source-options button { min-height: 36px; height: auto; border-radius: 10px; padding: 8px 14px; color: #62677b; box-shadow: none; }
|
||||
.agent-conversation-source-options button[aria-pressed="true"] { background: #fff; color: #775fa2; box-shadow: 0 1px 4px #27324608; }
|
||||
.agent-conversation-adjust { gap: 7px; border-radius: 12px; color: #606c7e; }
|
||||
.agent-conversation-adjust svg { margin: 0; }
|
||||
.agent-chat-styled { container-type: inline-size; background: #fffdf9; color: #273246; }
|
||||
.agent-chat-styled .agent-chat-toolbar { max-height: 32%; padding: 0 24px; }
|
||||
.agent-chat-toolbar-row { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px 18px; min-height: 72px; padding: 12px 0; }
|
||||
.agent-chat-toolbar-heading { display: flex; min-width: 0; align-items: center; gap: 11px; }
|
||||
.agent-chat-toolbar-icon { display: grid; width: 38px; height: 38px; flex-shrink: 0; place-items: center; border-radius: 13px; background: #e7f0eb; color: #5c8170; }
|
||||
.agent-chat-toolbar-icon svg { width: 21px; height: 21px; stroke-width: 1.8; }
|
||||
.agent-chat-toolbar-title { font-size: 15px; font-weight: 600; line-height: 1.5; }
|
||||
.agent-chat-toolbar-actions { display: flex; flex-shrink: 0; gap: 6px; }
|
||||
.agent-chat-toolbar-actions button { display: flex; min-height: 40px; align-items: center; gap: 7px; border: 1px solid #e7e3dd; border-radius: 12px; padding: 8px 12px; background: #fff; color: #59657a; }
|
||||
.agent-chat-toolbar-actions button[aria-expanded="true"] { border-color: #cbbce4; background: #f3eefb; color: #70568e; }
|
||||
.agent-chat-history-select { display: block; width: 100%; min-width: 0; min-height: 44px; margin: 0 0 12px; padding: 10px 12px; border: 1px solid #d6cee3; border-radius: 12px; background: #fff; color: #35435b; font-size: 14px; }
|
||||
.agent-chat-styled .agent-chat-scroll { display: flex; flex-direction: column; }
|
||||
.agent-chat-transcript { display: flex; width: calc(100% - 48px); max-width: 860px; min-height: min-content; flex: 1; flex-direction: column; gap: 26px; margin-inline: auto; padding: 16px 0 24px; }
|
||||
.agent-chat-loading { display: flex; min-height: 160px; align-items: center; justify-content: center; gap: 10px; color: #606c7e; font-size: 14px; }
|
||||
.agent-chat-loading svg { width: 22px; height: 22px; color: #8a72ba; }
|
||||
.agent-chat-welcome { width: 100%; max-width: 780px; margin: auto; padding: 24px 0; text-align: center; }
|
||||
.agent-chat-welcome-portrait { position: relative; width: 92px; margin: 0 auto 12px; }
|
||||
.agent-chat-styled .agent-chat-welcome-avatar { width: 92px; height: 92px; border: 4px solid #fff; border-radius: 28px; background: #fff0d4; box-shadow: 0 3px 0 #e8dfd2; }
|
||||
.agent-chat-welcome-spark { position: absolute; top: -7px; right: -18px; width: 30px; height: 30px; color: #9175b9; transform: rotate(10deg); }
|
||||
.agent-chat-welcome-name { overflow-wrap: anywhere; color: #7263a6; font-size: 14px; font-weight: 600; line-height: 1.6; }
|
||||
.agent-chat-welcome h2 { margin: 10px 0 8px; font-size: clamp(23px, 2.2vw, 30px); font-weight: 650; letter-spacing: -.5px; line-height: 1.5; }
|
||||
.agent-chat-welcome-description { margin-inline: auto; max-width: 540px; font-size: 14px; line-height: 1.8; color: #606c7e; }
|
||||
.agent-chat-examples { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); gap: 12px; margin-top: 26px; text-align: left; }
|
||||
.agent-chat-styled .agent-chat-example { display: flex; width: 100%; min-height: 82px; height: auto; justify-content: space-between; align-items: flex-start; gap: 12px; padding: 16px; border: 1px solid #dce5f3; border-radius: 19px; background: #f1f6ff; color: #35435b; font-size: 14px; font-weight: 400; line-height: 1.7; white-space: normal; text-align: left; box-shadow: none; }
|
||||
.agent-chat-styled .agent-chat-example span { min-width: 0; overflow-wrap: anywhere; }
|
||||
.agent-chat-styled .agent-chat-example svg { width: 17px; height: 17px; flex-shrink: 0; margin-top: 3px; color: #6a7e9a; }
|
||||
.agent-chat-styled .agent-chat-example[data-tone="1"] { border-color: #e5dcf5; background: #f6f2ff; }
|
||||
.agent-chat-styled .agent-chat-example[data-tone="2"] { border-color: #f0e2ce; background: #fff5e9; }
|
||||
.agent-chat-styled .agent-chat-example:hover { border-color: #b8c8de; background: #eaf1fb; }
|
||||
.agent-chat-styled .agent-chat-example[data-tone="1"]:hover { border-color: #ccbbe8; background: #efe8fa; }
|
||||
.agent-chat-styled .agent-chat-example[data-tone="2"]:hover { border-color: #dfc49e; background: #fff0db; }
|
||||
.agent-chat-example-note { margin-top: 13px; color: #6a7384; font-size: 12px; line-height: 1.7; }
|
||||
.agent-chat-message { display: flex; min-width: 0; align-items: flex-start; gap: 13px; }
|
||||
.agent-chat-styled .agent-chat-message-avatar { width: 38px; height: 38px; flex-shrink: 0; border: 2px solid #fff; border-radius: 13px; background: #fff0d4; }
|
||||
.agent-chat-message-content { min-width: 0; flex: 1; }
|
||||
.agent-chat-speaker { margin-bottom: 7px; color: #606c7e; font-size: 12px; font-weight: 500; line-height: 1.6; overflow-wrap: anywhere; }
|
||||
.agent-chat-styled .agent-chat-message-body { color: #273246; font-size: 15px; line-height: 1.9; overflow-wrap: anywhere; }
|
||||
.agent-chat-message[data-role="user"] { align-self: flex-end; width: fit-content; max-width: 85%; padding: 14px 18px; border: 1px solid #e4dbf3; border-radius: 20px 20px 6px 20px; background: #f1ebfb; }
|
||||
.agent-chat-message[data-role="user"] .agent-chat-speaker { color: #75628e; }
|
||||
.agent-chat-styled .agent-chat-message-body :is(pre, table) { display: block; max-width: 100%; overflow-x: auto; }
|
||||
.agent-chat-styled .agent-chat-message-body pre { padding: 14px 16px; border: 1px solid #e4e2dc; border-radius: 13px; background: #f3f2ef; font-size: 13px; line-height: 1.7; overflow-wrap: normal; }
|
||||
.agent-chat-styled .agent-chat-message-body img { max-width: 100%; height: auto; border-radius: 12px; }
|
||||
.agent-chat-styled .agent-chat-message-body :is(a, button) { color: #b43e17; text-decoration: none; overflow-wrap: anywhere; }
|
||||
.agent-chat-styled .agent-chat-message-body :is(a, button):hover { text-decoration: underline; }
|
||||
.agent-chat-styled .agent-chat-message-body blockquote { padding: 8px 14px; border-left: 3px solid #c8b8e0; border-radius: 0 10px 10px 0; background: #f4f0fa; color: #5b6173; }
|
||||
.agent-chat-styled .agent-chat-files { width: calc(100% - 48px); max-width: 860px; margin: 0 auto; }
|
||||
.agent-chat-styled .agent-chat-files details { margin: 4px 0 8px; padding: 0 12px; border: 1px solid #e5e7e2; border-radius: 13px; background: #f3f6f1; }
|
||||
.agent-chat-styled .agent-chat-files summary { min-height: 38px; color: #5e7162; font-size: 13px; line-height: 1.8; }
|
||||
.agent-chat-styled .agent-chat-files details > div { padding-bottom: 12px; }
|
||||
.agent-chat-styled .agent-chat-composer { width: calc(100% - 48px); max-width: 860px; margin: 0 auto 20px; padding: 14px 16px 12px; border: 1px solid #ddd5e8; border-radius: 23px; background: #fff; box-shadow: 0 4px 16px #7161820b, 0 3px 0 #eee8f3; }
|
||||
.agent-chat-styled .agent-chat-composer:focus-within { border-color: #b49dce; box-shadow: 0 0 0 3px #eee7f6; }
|
||||
.agent-chat-styled .agent-chat-input { width: 100%; min-height: 64px; height: clamp(64px, 10vh, 92px); max-height: min(152px, 22vh); padding: 2px; background: transparent; color: #273246; font-size: 15px; line-height: 1.8; }
|
||||
.agent-chat-input::placeholder { color: #727c8c; }
|
||||
.agent-chat-composer-footer { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; margin-top: 8px; }
|
||||
.agent-chat-composer-meta { display: flex; min-width: 0; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding-bottom: 5px; }
|
||||
.agent-chat-status { display: flex; min-width: 0; align-items: center; gap: 6px; color: #626f7e; font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
|
||||
.agent-chat-status::before { width: 6px; height: 6px; flex-shrink: 0; border-radius: 50%; background: #7d9f8b; content: ''; }
|
||||
.agent-chat-status[data-active="true"]::before { background: #bf8b49; }
|
||||
.agent-chat-keyboard-hint { color: #747c8a; font-size: 11px; line-height: 1.6; }
|
||||
.agent-chat-styled .agent-chat-send, .agent-chat-styled .agent-chat-stop { min-height: 44px; height: auto; flex-shrink: 0; border-radius: 13px; padding: 10px 16px; font-size: 14px; }
|
||||
.agent-chat-styled .agent-chat-send { box-shadow: 0 3px 0 #ad3812; }
|
||||
.agent-chat-styled :is(button, summary, select) { cursor: pointer; }
|
||||
.agent-chat-styled :is(button, textarea, select):focus-visible { outline: 2px solid #9275b4; outline-offset: 3px; }
|
||||
.agent-chat-styled :is(button, textarea, select):disabled { cursor: not-allowed; }
|
||||
@container (max-width: 620px) {
|
||||
.agent-chat-styled .agent-chat-toolbar { padding-inline: 14px; }
|
||||
.agent-chat-toolbar-row { min-height: 64px; }
|
||||
.agent-chat-toolbar-actions button { padding-inline: 10px; }
|
||||
.agent-chat-toolbar-actions button span { display: none; }
|
||||
.agent-chat-examples { grid-template-columns: minmax(0, 1fr); gap: 8px; margin-top: 20px; }
|
||||
.agent-chat-styled .agent-chat-example { min-height: 48px; padding: 12px 14px; border-radius: 15px; }
|
||||
.agent-chat-transcript { width: calc(100% - 28px); gap: 22px; }
|
||||
.agent-chat-welcome { padding-block: 16px; }
|
||||
.agent-chat-welcome-portrait { width: 74px; }
|
||||
.agent-chat-styled .agent-chat-welcome-avatar { width: 74px; height: 74px; border-radius: 23px; }
|
||||
.agent-chat-welcome h2 { font-size: 23px; }
|
||||
.agent-chat-message { gap: 9px; }
|
||||
.agent-chat-styled .agent-chat-message-avatar { width: 30px; height: 30px; border-radius: 10px; }
|
||||
.agent-chat-styled .agent-chat-message-body { font-size: 14px; }
|
||||
.agent-chat-message[data-role="user"] { max-width: 91%; padding: 12px 14px; }
|
||||
.agent-chat-styled .agent-chat-files, .agent-chat-styled .agent-chat-composer { width: calc(100% - 28px); }
|
||||
.agent-chat-styled .agent-chat-composer { margin-bottom: 14px; padding: 12px 13px; border-radius: 20px; }
|
||||
.agent-chat-keyboard-hint { display: none; }
|
||||
}
|
||||
@container (max-width: 440px) {
|
||||
.agent-conversation-sources { padding-inline: 12px; gap: 8px; }
|
||||
.agent-conversation-source-options button { padding-inline: 9px; font-size: 13px; }
|
||||
.agent-conversation-adjust { padding-inline: 8px; font-size: 13px; }
|
||||
}
|
||||
@media (max-height: 640px) {
|
||||
.agent-chat-workspace-header { min-height: 56px; gap: 8px; padding: 6px 12px; }
|
||||
.agent-chat-workspace-header > .agent-workspace-identity { min-width: min(120px, 100%); flex-basis: 120px; }
|
||||
.agent-chat-workspace-header .agent-workspace-identity > p { display: none; }
|
||||
.agent-chat-workspace-header > .agent-chat-identity-avatar,
|
||||
.agent-chat-workspace-header > .agent-chat-identity-avatar img { width: 40px; height: 40px; }
|
||||
.agent-chat-editor[data-configuration-settled="true"] .agent-chat-workspace-header > :is(.agent-chat-config-status, .agent-chat-save-action, .agent-chat-apply-action) { display: none; }
|
||||
.agent-conversation-sources { padding: 6px 12px; }
|
||||
.agent-conversation-source-options { padding: 2px; }
|
||||
.agent-chat-styled .agent-chat-files summary { min-height: 32px; }
|
||||
.agent-chat-styled .agent-chat-files details { margin: 3px 0 5px; }
|
||||
.agent-chat-styled .agent-chat-input { min-height: 40px; height: 40px; }
|
||||
.agent-chat-styled .agent-chat-composer { margin-bottom: 10px; padding-block: 10px; }
|
||||
.agent-chat-styled .agent-chat-composer-footer { margin-top: 4px; }
|
||||
.agent-chat-transcript { padding-block: 8px 12px; }
|
||||
.agent-chat-toolbar-row { min-height: 50px; padding-block: 5px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.agent-chat-styled :is(button, textarea, select) { transition: none; }
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import { createServer, type ViteDevServer } from 'vite';
|
||||
import { resolve } from 'node:path';
|
||||
import type { CloudHistory, CloudInterrupt, CloudRun, CloudThread } from '../../shared/cloud-agents';
|
||||
|
||||
let server: ViteDevServer;
|
||||
let baseUrl: string;
|
||||
test.use({ headless: true, channel: process.env.MAKELORE_LAYOUT_BROWSER_CHANNEL, video: 'off' });
|
||||
|
||||
// The real CloudChat, Markdown renderer and CloudFiles are used. Main IPC and
|
||||
// the event stream are isolated fixtures; this suite never starts a native app
|
||||
// or contacts an account, cloud service or model.
|
||||
test.beforeAll(async () => {
|
||||
const entry = resolve('tests/e2e/__cloud-agent-chat-layout-harness.tsx');
|
||||
server = await createServer({
|
||||
configFile: false,
|
||||
cacheDir: 'node_modules/.vite-cloud-agent-chat-layout',
|
||||
resolve: { alias: { '@': resolve('src') } },
|
||||
esbuild: { jsx: 'automatic' },
|
||||
server: { host: '127.0.0.1', port: 0 },
|
||||
plugins: [{
|
||||
name: 'isolated-cloud-agent-chat-layout', enforce: 'pre',
|
||||
resolveId(id) { if (id === '/__cloud-agent-chat-layout-harness.tsx') return entry; },
|
||||
load(id) {
|
||||
if (id === entry) return `
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { CloudChat } from '/src/pages/CloudAgents/CloudChat.tsx';
|
||||
import { Button } from '/src/components/ui/button.tsx';
|
||||
import avatar from '/src/assets/agent-avatars/avatar-05.png';
|
||||
import '/src/styles/globals.css';
|
||||
const examples = ['我想一起想一个有趣的故事。', '请帮我把今天的新发现整理一下。'];
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<main className="flex h-screen min-h-0 min-w-0 flex-col">
|
||||
<CloudChat slug="ml-chat-layout-fixture" assistantName="我的故事搭档" assistantAvatarUrl={avatar}
|
||||
examples={examples} headerAction={<Button variant="outline" data-testid="fixture-adjust"
|
||||
onClick={() => window.__cloudChatFixture.adjustments++}>调整智能体</Button>} />
|
||||
</main>
|
||||
);
|
||||
`;
|
||||
},
|
||||
configureServer(vite) {
|
||||
vite.middlewares.use((req, res, next) => {
|
||||
if (req.url !== '/') return next();
|
||||
res.setHeader('Content-Type', 'text/html');
|
||||
res.end('<!doctype html><html><head><meta charset="utf-8"></head><body><div id="root"></div><script type="module" src="/__cloud-agent-chat-layout-harness.tsx"></script></body></html>');
|
||||
});
|
||||
},
|
||||
}],
|
||||
});
|
||||
await server.listen();
|
||||
baseUrl = server.resolvedUrls!.local[0];
|
||||
});
|
||||
test.afterAll(async () => { await server?.close(); });
|
||||
|
||||
const slug = 'ml-chat-layout-fixture';
|
||||
const date = '2026-10-12T12:00:00Z';
|
||||
const examples = ['我想一起想一个有趣的故事。', '请帮我把今天的新发现整理一下。'];
|
||||
const longWord = 'an-unbroken-story-notebook-'.repeat(24);
|
||||
const richReply = [
|
||||
'## 一起把小发现变成故事',
|
||||
'你可以先选一个最喜欢的发现,再让角色去试一试。',
|
||||
`这个很长的名字也应该完整保留:${longWord}`,
|
||||
`[查看完整故事线索](https://example.invalid/story/${longWord})`,
|
||||
['| 今天的线索 | 可以接着想什么 |', '| --- | --- |',
|
||||
...Array.from({ length: 8 }, (_, index) => `| 第 ${index + 1} 个小发现 | ${longWord.slice(0, 120)} |`),
|
||||
].join('\n'),
|
||||
['```javascript', `const completeStoryTitle = "${longWord}";`,
|
||||
...Array.from({ length: 6 }, (_, index) => `console.log("第 ${index + 1} 个新想法");`),
|
||||
'```',
|
||||
].join('\n'),
|
||||
].join('\n\n');
|
||||
|
||||
function makeThread(id: string, title: string, status = 'completed'): CloudThread {
|
||||
return { thread_id: id, client_thread_id: id, agent_slug: slug, title, mode: 'published',
|
||||
updated_at: date, run_id: null, status, unread: false };
|
||||
}
|
||||
function makeRun(thread: CloudThread, status: string, interrupt?: CloudInterrupt): CloudRun {
|
||||
return { agent_run_id: 'run-' + thread.thread_id, request_id: 'request-' + thread.thread_id,
|
||||
thread_id: thread.thread_id, agent_slug: slug, status, output: '', version: '1', interrupt };
|
||||
}
|
||||
function makeHistory(thread: CloudThread, dense = false, run: CloudRun | null = null): CloudHistory {
|
||||
return { thread_id: thread.thread_id, next_offset: null, queued_requests: [], run,
|
||||
messages: dense ? Array.from({ length: 16 }, (_, index) => ({
|
||||
id: index + 1, role: index % 2 ? 'assistant' as const : 'user' as const,
|
||||
content: index === 15 ? richReply : index % 2 ? `第 ${index + 1} 次回答:我们一起想一想,角色还可以试着做什么?` : `第 ${index + 1} 个想法:我想写一只会寻找新发现的小狐狸。`,
|
||||
request_id: 'fixture-request-' + index, run_id: null, created_at: date,
|
||||
})) : [{ id: 1, role: 'assistant', content: '这是另一段保留下来的故事对话。',
|
||||
request_id: 'fixture-request-short', run_id: null, created_at: date }],
|
||||
};
|
||||
}
|
||||
|
||||
type FixtureCall = { path: string; method: string; input: Record<string, unknown> | null };
|
||||
type FixtureSeed = { threads: CloudThread[]; histories: Record<string, CloudHistory> };
|
||||
type FixtureState = FixtureSeed & { calls: FixtureCall[]; adjustments: number };
|
||||
type FixtureWindow = Window & { __cloudChatFixture: FixtureState };
|
||||
|
||||
async function installFixture(page: Page, kind: 'empty' | 'history' | 'running' | 'approval') {
|
||||
const first = makeThread('story-first', '小狐狸的故事', kind === 'running' ? 'running' : kind === 'approval' ? 'interrupted' : 'completed');
|
||||
const second = makeThread('story-second', '另一段故事');
|
||||
const interrupt: CloudInterrupt = { approval: { action_requests: [{ name: 'execute',
|
||||
description: '把刚才确认的故事保存到我的故事笔记', args: { path: '/stories/' + longWord, content: '保留我们的故事想法。' } }] } };
|
||||
const run = kind === 'running' ? makeRun(first, 'running') : kind === 'approval' ? makeRun(first, 'interrupted', interrupt) : null;
|
||||
const seed: FixtureSeed = kind === 'empty' ? { threads: [], histories: {} } : {
|
||||
threads: [first, second], histories: {
|
||||
[first.thread_id]: makeHistory(first, kind !== 'approval', run),
|
||||
[second.thread_id]: makeHistory(second),
|
||||
},
|
||||
};
|
||||
await page.addInitScript(({ seed }) => {
|
||||
const scope = window as unknown as FixtureWindow;
|
||||
scope.__cloudChatFixture = { ...seed, calls: [], adjustments: 0 };
|
||||
const state = scope.__cloudChatFixture;
|
||||
const result = (json: unknown) => ({ ok: true, data: { status: 200, ok: true, json: structuredClone(json) } });
|
||||
// Keep durable run polling real, but prevent EventSource from reaching a network.
|
||||
class FixtureEventSource extends EventTarget {
|
||||
readyState = 1;
|
||||
onopen: ((event: Event) => void) | null = null;
|
||||
onerror: ((event: Event) => void) | null = null;
|
||||
close() { this.readyState = 2; }
|
||||
}
|
||||
Object.defineProperty(window, 'EventSource', { value: FixtureEventSource });
|
||||
Object.defineProperty(window, 'electron', { value: { ipcRenderer: {
|
||||
invoke: async (channel: string, request?: { path?: string; method?: string; body?: string }) => {
|
||||
if (channel === 'hostapi:token') return 'fixture-token';
|
||||
if (channel === 'hostapi:base-url') return 'http://fixture.invalid';
|
||||
if (channel !== 'hostapi:fetch') throw new Error('Unexpected fixture IPC: ' + channel);
|
||||
const path = request?.path ?? '';
|
||||
const method = request?.method ?? 'GET';
|
||||
const input = request?.body ? JSON.parse(request.body) as Record<string, unknown> : null;
|
||||
state.calls.push({ path, method, input });
|
||||
if (path === '/api/cloud-agents/recovery') return result({ recent: null, pending: [] });
|
||||
if (path === '/api/cloud-agents/recovery/recent') return result({ saved: true });
|
||||
if (path !== '/api/cloud-agents/actions') throw new Error('Unexpected fixture path: ' + path);
|
||||
const operation = String(input?.operation);
|
||||
const args = input?.input as Record<string, unknown> | undefined;
|
||||
const thread = state.threads.find(item => item.thread_id === args?.thread_id) ?? state.threads[0];
|
||||
const history = thread ? state.histories[thread.thread_id] : null;
|
||||
switch (operation) {
|
||||
case 'threads': return result({ threads: state.threads, next_offset: null });
|
||||
case 'history': return result(history);
|
||||
case 'viewed': return result({ viewed: true });
|
||||
case 'attachments': return result({ attachments: [{ file_id: 'attachment-story', file_name: '我的故事笔记与新发现.txt', file_size: 4096 }] });
|
||||
case 'files': return result({ files: [{ name: '故事笔记.md', is_dir: false, path: '/故事笔记.md' }] });
|
||||
case 'run': return result(Object.values(state.histories).find(item => item.run?.agent_run_id === args?.run_id)?.run);
|
||||
case 'cancelRun': return result({ status: 'cancel_requested' });
|
||||
case 'resume': {
|
||||
if (!history?.run) throw new Error('Missing approval fixture run');
|
||||
history.run = { ...history.run, status: 'completed', interrupt: undefined };
|
||||
return result({ run_id: history.run.agent_run_id, status: 'completed' });
|
||||
}
|
||||
default: throw new Error('Unexpected fixture operation: ' + operation);
|
||||
}
|
||||
},
|
||||
} } });
|
||||
}, { seed });
|
||||
await page.route('**/*', route => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.hostname === '127.0.0.1') return route.continue();
|
||||
return route.abort('blockedbyclient');
|
||||
});
|
||||
}
|
||||
|
||||
async function openChat(page: Page, kind: 'empty' | 'history' | 'running' | 'approval', width: number, height: number) {
|
||||
const errors: string[] = [];
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
await page.setViewportSize({ width, height });
|
||||
await installFixture(page, kind);
|
||||
await page.goto(baseUrl);
|
||||
await expect(page.getByRole('region', { name: '智能体对话', exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel('消息', { exact: true })).toBeEnabled();
|
||||
await expect(page.getByText('正在读取对话…', { exact: true })).toHaveCount(0);
|
||||
return errors;
|
||||
}
|
||||
|
||||
async function expectComposerBounds(page: Page) {
|
||||
const composer = page.getByTestId('cloud-chat-composer');
|
||||
await expect(composer).toBeInViewport({ ratio: 1 });
|
||||
const overflow = await page.evaluate(() => ({
|
||||
document: document.documentElement.scrollWidth - innerWidth,
|
||||
body: document.body.scrollWidth - innerWidth,
|
||||
elements: [...document.querySelectorAll('.agent-chat, [data-testid="cloud-chat-messages"], [data-testid="cloud-chat-composer"]')]
|
||||
.map(element => ({ width: element.scrollWidth - element.clientWidth, name: element.getAttribute('data-testid') ?? element.className })),
|
||||
composer: (() => {
|
||||
const bounds = document.querySelector('[data-testid="cloud-chat-composer"]')!.getBoundingClientRect();
|
||||
return { left: bounds.left, right: bounds.right, bottom: bounds.bottom };
|
||||
})(),
|
||||
}));
|
||||
expect(overflow.document).toBeLessThanOrEqual(1);
|
||||
expect(overflow.body).toBeLessThanOrEqual(1);
|
||||
expect(overflow.elements.filter(item => item.width > 1)).toEqual([]);
|
||||
expect(overflow.composer.left).toBeGreaterThanOrEqual(0);
|
||||
expect(overflow.composer.right).toBeLessThanOrEqual(page.viewportSize()!.width);
|
||||
expect(overflow.composer.bottom).toBeLessThanOrEqual(page.viewportSize()!.height);
|
||||
}
|
||||
async function operations(page: Page) {
|
||||
return page.evaluate(() => (window as unknown as FixtureWindow).__cloudChatFixture.calls
|
||||
.filter(call => call.path === '/api/cloud-agents/actions').map(call => call.input));
|
||||
}
|
||||
async function screenshot(page: Page, name: string) {
|
||||
const path = test.info().outputPath(name + '.png');
|
||||
await page.screenshot({ path, animations: 'disabled' });
|
||||
await test.info().attach(name, { path, contentType: 'image/png' });
|
||||
}
|
||||
|
||||
for (const width of [375, 768, 1024, 1440]) {
|
||||
test(`friendly empty conversation keeps the composer reachable at ${width}px`, async ({ page }) => {
|
||||
const errors = await openChat(page, 'empty', width, width === 375 ? 640 : 760);
|
||||
await expect(page.locator('.agent-chat-welcome')).toBeVisible();
|
||||
await expect(page.locator('img').first()).toBeVisible();
|
||||
await expect.poll(() => page.locator('img').evaluateAll(images => images.every(image => {
|
||||
const element = image as HTMLImageElement;
|
||||
return element.complete && element.naturalWidth > 0;
|
||||
}))).toBe(true);
|
||||
await expectComposerBounds(page);
|
||||
await screenshot(page, `agent-chat-empty-${width}`);
|
||||
await page.getByRole('button', { name: examples[0], exact: true }).click();
|
||||
await expect(page.getByLabel('消息', { exact: true })).toHaveValue(examples[0]);
|
||||
await expect(page.getByRole('button', { name: '发送', exact: true })).toBeEnabled();
|
||||
expect((await operations(page)).some(item => ['submit', 'preview', 'createThread', 'resume'].includes(String(item?.operation)))).toBe(false);
|
||||
await expectComposerBounds(page);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
for (const viewport of [{ width: 375, height: 540 }, { width: 1440, height: 900 }]) {
|
||||
test(`long Markdown history scrolls independently at ${viewport.width}×${viewport.height}`, async ({ page }) => {
|
||||
const errors = await openChat(page, 'history', viewport.width, viewport.height);
|
||||
const transcript = page.getByTestId('cloud-chat-messages');
|
||||
const composer = page.getByTestId('cloud-chat-composer');
|
||||
await expect(transcript.getByRole('article')).toHaveCount(16);
|
||||
await expect(transcript.locator('pre')).toHaveCount(1);
|
||||
await expect(transcript.locator('table')).toHaveCount(1);
|
||||
await expectComposerBounds(page);
|
||||
expect(await transcript.evaluate(element => element.scrollHeight > element.clientHeight)).toBe(true);
|
||||
const before = await composer.boundingBox();
|
||||
await transcript.evaluate(element => { element.scrollTop = 0; });
|
||||
await expect.poll(() => transcript.evaluate(element => element.scrollTop)).toBe(0);
|
||||
await screenshot(page, `agent-chat-message-roles-${viewport.width}`);
|
||||
const after = await composer.boundingBox();
|
||||
expect(Math.abs(before!.y - after!.y)).toBeLessThanOrEqual(1);
|
||||
expect(await page.evaluate(() => scrollY)).toBe(0);
|
||||
await transcript.evaluate(element => { element.scrollTop = element.scrollHeight; });
|
||||
await screenshot(page, `agent-chat-long-history-${viewport.width}`);
|
||||
const input = page.getByLabel('消息', { exact: true });
|
||||
await input.fill(Array.from({ length: 6 }, (_, index) => `第 ${index + 1} 行未发送的故事想法`).join('\n'));
|
||||
await expectComposerBounds(page);
|
||||
await page.getByRole('button', { name: '查看历史对话', exact: true }).click();
|
||||
const history = page.getByLabel('历史对话', { exact: true });
|
||||
await expect(history).toBeDisabled();
|
||||
await input.fill('');
|
||||
await expect(history).toBeEnabled();
|
||||
await history.selectOption('story-second');
|
||||
await expect(transcript.getByText('这是另一段保留下来的故事对话。', { exact: true })).toBeVisible();
|
||||
await expect(transcript.getByRole('article')).toHaveCount(1);
|
||||
await page.locator('.agent-chat-files summary').click();
|
||||
await expect(page.getByRole('button', { name: '添加附件', exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '保存 故事笔记.md', exact: true })).toBeVisible();
|
||||
await expectComposerBounds(page);
|
||||
expect((await operations(page)).some(item => ['submit', 'preview', 'resume'].includes(String(item?.operation)))).toBe(false);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test('running conversation keeps Stop reachable on a short narrow screen', async ({ page }) => {
|
||||
const errors = await openChat(page, 'running', 375, 540);
|
||||
const stop = page.getByRole('button', { name: '停止', exact: true });
|
||||
await expect(stop).toBeInViewport({ ratio: 1 });
|
||||
await expectComposerBounds(page);
|
||||
await screenshot(page, 'agent-chat-running-375');
|
||||
await stop.click();
|
||||
await expect.poll(async () => (await operations(page)).filter(item => item?.operation === 'cancelRun').length).toBe(1);
|
||||
expect((await operations(page)).some(item => ['submit', 'preview', 'resume'].includes(String(item?.operation)))).toBe(false);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test('approval details and decisions remain reachable without covering the composer', async ({ page }) => {
|
||||
const errors = await openChat(page, 'approval', 768, 480);
|
||||
const approval = page.getByRole('heading', { name: '需要你的确认', exact: true });
|
||||
await expect(approval).toBeVisible();
|
||||
const reject = page.getByRole('button', { name: '拒绝', exact: true });
|
||||
await reject.scrollIntoViewIfNeeded();
|
||||
await expect(reject).toBeInViewport({ ratio: 1 });
|
||||
await expect(page.getByRole('button', { name: '允许执行', exact: true })).toBeInViewport({ ratio: 1 });
|
||||
await expectComposerBounds(page);
|
||||
await screenshot(page, 'agent-chat-approval-768');
|
||||
expect((await operations(page)).some(item => ['submit', 'preview', 'resume'].includes(String(item?.operation)))).toBe(false);
|
||||
await reject.click();
|
||||
await expect(approval).toHaveCount(0);
|
||||
const resumes = (await operations(page)).filter(item => item?.operation === 'resume');
|
||||
expect(resumes).toHaveLength(1);
|
||||
expect((resumes[0]?.input as Record<string, unknown>).decision).toEqual({ decisions: [{ type: 'reject', message: '用户拒绝执行' }] });
|
||||
await expectComposerBounds(page);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
|
||||
test('personal cloud Agent identity, configuration and separate published chat in Electron', async ({ launchElectronApp }, testInfo) => {
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
@@ -245,10 +246,47 @@ test('personal cloud Agent identity, configuration and separate published chat i
|
||||
await expect(formalChat).toBeVisible();
|
||||
expect((await fixtureState()).operations.filter((operation: string) => operation === 'publish')).toHaveLength(1);
|
||||
expect(await messageOperations()).toEqual([]);
|
||||
await expect(formalChat.getByRole('heading', { name: 'Hi,今天想聊些什么?', exact: true })).toBeVisible();
|
||||
await expect(formalChat.locator('.agent-chat-welcome').getByRole('img', { name: '我的写作搭档头像', exact: true })).toHaveAttribute('src', avatar.icon_url);
|
||||
const conversationSources = page.getByRole('navigation', { name: '对话来源', exact: true });
|
||||
const sourceBounds = await conversationSources.getByRole('button', { name: '客户端对话', exact: true }).boundingBox();
|
||||
const adjustBounds = await conversationSources.getByRole('button', { name: '调整智能体', exact: true }).boundingBox();
|
||||
expect(sourceBounds).not.toBeNull();
|
||||
expect(adjustBounds).not.toBeNull();
|
||||
expect(Math.abs(sourceBounds!.y + sourceBounds!.height / 2 - adjustBounds!.y - adjustBounds!.height / 2)).toBeLessThanOrEqual(1);
|
||||
const example = formalChat.locator('.agent-chat-example').first();
|
||||
const exampleText = await example.innerText();
|
||||
await example.click();
|
||||
await expect(formalChat.getByLabel('消息', { exact: true })).toHaveValue(exampleText);
|
||||
expect(await messageOperations()).toEqual([]);
|
||||
await formalChat.getByLabel('消息', { exact: true }).fill('');
|
||||
await page.screenshot({ path: testInfo.outputPath('cloud-agent-chat-welcome.png') });
|
||||
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']);
|
||||
await expect(formalChat.locator('.agent-chat-message[data-role="assistant"]').getByRole('img', { name: '我的写作搭档头像', exact: true })).toHaveAttribute('src', avatar.icon_url);
|
||||
await expect(formalChat.getByLabel('消息', { exact: true })).toBeInViewport();
|
||||
const transcriptBounds = await formalChat.getByTestId('cloud-chat-messages').boundingBox();
|
||||
const composerBounds = await formalChat.getByTestId('cloud-chat-composer').boundingBox();
|
||||
expect(transcriptBounds).not.toBeNull();
|
||||
expect(composerBounds).not.toBeNull();
|
||||
expect(transcriptBounds!.y + transcriptBounds!.height).toBeLessThanOrEqual(composerBounds!.y + 1);
|
||||
await page.screenshot({ path: testInfo.outputPath('cloud-agent-chat-long-reply.png') });
|
||||
await app.evaluate(({ BrowserWindow }) => { const win = BrowserWindow.getAllWindows()[0]; win.setSize(980, 680); win.webContents.setZoomFactor(1.5); });
|
||||
await expect(formalChat.getByTestId('cloud-chat-composer')).toBeInViewport({ ratio: 1 });
|
||||
await expect(formalChat.getByRole('button', { name: '发送', exact: true })).toBeInViewport({ ratio: 1 });
|
||||
await expect(conversationSources.getByRole('button', { name: '调整智能体', exact: true })).toBeInViewport({ ratio: 1 });
|
||||
await expect.poll(() => editor.evaluate(el => el.scrollWidth - el.clientWidth)).toBeLessThanOrEqual(1);
|
||||
await expect.poll(() => formalChat.getByTestId('cloud-chat-messages').evaluate(el => el.clientHeight)).toBeGreaterThanOrEqual(80);
|
||||
// Chromium screenshots can crop a zoomed Electron surface. Capture the
|
||||
// isolated native fixture's complete window for the responsive visual check.
|
||||
const compactWindowImage = await app.evaluate(async ({ BrowserWindow }) => {
|
||||
const image = await BrowserWindow.getAllWindows()[0].capturePage();
|
||||
return image.toPNG().toString('base64');
|
||||
});
|
||||
await writeFile(testInfo.outputPath('cloud-agent-chat-compact-window.png'), Buffer.from(compactWindowImage, 'base64'));
|
||||
await app.evaluate(({ BrowserWindow }) => { const win = BrowserWindow.getAllWindows()[0]; win.webContents.setZoomFactor(1); win.setSize(1366, 768); });
|
||||
await page.getByRole('button', { name: '调整智能体', exact: true }).click();
|
||||
await expect(prompt).toHaveValue(initialPrompt);
|
||||
await page.getByLabel('名称', { exact: true }).fill('我的新版写作搭档');
|
||||
|
||||
Reference in new issue
Block a user