feat: 补齐智能体对话思考和工具过程渲染

This commit is contained in:
brother7 committed 2026-10-12 23:21:54 +08:00
1 parent 7a7d1f7fd8
commit 70a3212bce
12 files changed
+517 -31

No files matched your search

@@ -0,0 +1,69 @@
# Task: Trace personal agent reasoning output
## Identity
- Task ID: 20261012-agent-reasoning-39afbc21
- Mode: Feature
- Branch: codex/20261012-agent-reasoning-39afbc21-agent-reasoning
- Worktree: C:\Users\7brot\.codex\worktrees\makelore-agent-reasoning-39afbc21
- Base commit: 7a7d1f7fd89fff3b77f2fd8f1986da65fb6223ea
- Owner: codex
- Status: Ready for Integration
## Scope
- Continue the diagnosed gap with user-authorized personal cloud chat rendering: separate thinking and tools from Markdown answers, retain per-message boundaries, and project existing saved process data for history/reopen.
- Reuse Yuxi native conversation semantics; no new persistence, model behavior, credentials or deployment. User now authorizes committing and merging the paired change and one fresh read-only Reviewer.
## Intent And Constraints
- Concurrent and Planning Gates Passed through official check/start/status; task ID, feature mode, isolated absolute worktree, branch and base match Identity.
- Project Context Loaded: entry and own task record, positioning, integrated current state, decisions/personal-cloud ADR, system architecture, domain and evidence context; unchanged prior session context reused. Yuxi has 64 peer records and Makelore 148. Peer task Scope/Intent/Promotion records are read-only; historical template scopes remain unknown without a concrete conflicting dependency.
- Yuxi owns cloud execution; Makelore Main owns credentials and transport; personal Agents and Code teacher consultation are separate consumers.
- Existing avatar integration and optional-purpose work remain separate. No claims about installed-client versions, deployed services, or the actual provider payload of a particular run.
- Same-task resume check/start/status passed; owner, branch, base and all peer registry fields unchanged. Reused already loaded context and read-only peer scopes. Planning Gate Passed for this implementation continuation.
- Plan: (1) reuse native history projection and prove ownership/paging with HTTP/PostgreSQL; (2) implement message-scoped SSE accumulation, expandable process and shared Markdown rendering; (3) verify stream replay, terminal/history replacement and thread isolation with focused unit and Electron scenarios, then type/lint/build and documentation gates.
- Merge continuation: official same-task start/status passed; unchanged bases and all 64/148 peer registry entries permit reuse of prior read-only context and scope assessment. Planning Gate Passed. User approved fresh read-only reasoning_merge_review; full backend non-slow unit gate and independent review precede commit. User also approved primary ownership handoff: official release/adopt completed under dedicated integration tasks, preserving all five foreign records.
## Outcome
- Confirmed Yuxi already emits reasoning in compact personal SSE, but Makelore CloudChat and the personal history DTO omitted its display.
- Added message-scoped live reasoning, Markdown answers, collapsible tool inputs/results/errors and Run/thread isolation. Redis numeric cursors suppress replay duplicates; terminal history replaces matching live messages, while stale/failed refresh retains the full live answer.
- Formal, preview, shared and saved channel conversations use CloudMessageBody. Thinking starts expanded, collapses as answer text arrives and remains manually expandable. No invented or empty thinking sections; artifact downloads remain supported.
- Yuxi projects existing saved reasoning and tools through native parsers and visibility rules. Only state-confirmed terminal tool messages enter ordinary history; ownership and filtering-before-pagination remain enforced. No new schema, model requests or provider behavior.
- Main limits personal/channel history to display fields and omits raw internal metadata. Updated client README, Yuxi user reference and the implemented decision (created as a proposal before implementation).
- Review correction: compact SSE retains tool-started.input; the renderer adopts effective execution arguments by tool_call_id and prevents later model argument fragments from overriding them. Empty effective input is retained, and tool-error remains provisional until native Run reconciliation.
## Verification
- Client TypeScript passed. Affected ESLint: 0 errors, 2 pre-existing CloudChat warnings (Fast Refresh export / cleanup ref). Fixed a pre-existing line-break lint error in the touched it.each test.
- Client Vitest: cloud-chat-rendering, cloud-agents-main, cloud-agents-workflows and cloud-channel-conversations passed 80 tests; cloud-agents-page, cloud-agents-management and cloud-agents-guidance passed 36 tests (116 total / 7 files).
- Seven rendering tests cover replay (including large cursors), separate messages, wrong Run/thread, tool chunks/results/errors, Markdown/artifact links, history failure/staleness, reopening and absent thinking. Main tests cover personal/channel field projection.
- pnpm run build:vite passed with final production code. Electron cloud-chat-rendering.spec.ts passed 1 test against the built real window with test IPC/SSE fixtures. Screenshots were visually checked for thought/tool details, Markdown tables and reopened history.
- Yuxi Ruff check/format passed for the six changed Python files. WSL pytest: conversation message audits, queue history and provider reasoning passed 47 tests; repository memory history/workdir passed 10 (57 total).
- Two real uvicorn HTTP/PostgreSQL tests in test_makelore_agents.py passed (28 deselected): saved/legacy reasoning, literal user think tags, tool success/error, running-vs-terminal visibility, hidden metadata, other-user 404, 104 unique paged messages, and shared/application authorization.
- The first integration run found a detached fixture object read after DB commit; retaining its scalar conversation_id fixed the fixture and the rerun passed.
- verify_engineering_contracts.py passed (119 decisions / 5 workflows / 4 agents files / 172 docs / 34 routers / 248 web sources). Its unittest module passed 62 tests in WSL; Windows symlink privilege and path-separator failures were environmental, with no production workaround.
- VitePress build passed. Ignored dependency junctions reuse existing pinned client/docs dependencies; no dependency lockfiles changed.
- Task artifact directory: C:/Users/7brot/.codex/visualizations/2026/10/12/01a12e4f-9365-74c2-9e57-3f900a028618/reasoning-local (client-build.log, docs-build.log and rendered docs).
- Electron images: Makelore worktree test-results/cloud-chat-rendering-perso-31b4f--restores-them-after-reload/personal-chat-process.png and personal-chat-reopened.png.
- Temporary task PostgreSQL: WSL /tmp/makelore-reasoning-39afbc21/data, loopback 58413; stopped during final cleanup. No primary service database changed.
- Submission gate: WSL Python 3.12 non-slow unit suite passed 2270 tests / 1 skipped. Command: PYTHONPATH=<task-artifacts>/test-shim:package:. python -m pytest test/unit -m 'not slow' -q. The external namespace shim points the installed stdlib test package at this checkout for pytest and subprocess imports; production code and test assertions are unchanged. Full log: reasoning-local/backend-unit-all.log. Initial collection without the shim failed on seven stdlib-shadowed imports.
- Fresh independent reasoning_merge_review reviewed full requirements, tracked/untracked diffs, native protocol, persistence, tests and rules, and returned PASS with no open material findings. Its one effective-input finding was fixed and verified; both new regression assertions failed for the expected reason before production correction.
- After that correction: 80 affected client tests passed (plus 36 unchanged page tests), tsc/scoped lint, final production build, updated real Electron-window fixture, docs build and engineering checks passed. Backend non-slow suite again passed 2270 / 1 skipped. Earlier real HTTP/PostgreSQL history tests remain valid because their code path is unchanged.
- git diff --check passed; official task-aware drift and completion commands are the final documentation gates.
## Follow-ups
- User authorized committing the independently reviewed result and local main-branch integration. Both source task records must be imported unchanged. No push, installer packaging, deployment or real provider call is authorized.
- A reviewer test attempt created an ignored Yuxi .venv before being stopped; it is not staged and may be removed with the source worktree only after explicit cleanup consent.
- Release the paired history API and client for saved process display. Reasoning is limited to what the selected model actually returns and stores; older records without reasoning remain without it.
- Preserve the separate optional-purpose worktree and earlier avatar work. No unrelated task files changed.
## Promotion Candidates
- Target: canonical personal-cloud architecture/current state in both repositories.
- Proposal: record shared message presentation, separate live/saved process fields, native state-confirmed tool visibility and paired release dependency.
- Evidence: 116 client tests, 1 Electron scenario, 57 backend unit tests, 2 real HTTP/PostgreSQL tests and successful builds. Future impact: align formal/preview/shared/channel messages and avoid reconstructing durable history from short-lived SSE.
- Semantic conflicts: none found against integrated context; peers remain read-only. Human confirmation: no additional product decision needed; promotion belongs to an authorized integration task.
+1
View File
@@ -39,6 +39,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智
- 共享开发浏览器:AI 编程右侧提供项目级浏览器,用户与 Agent 查看并调试同一实时页面、Console 和 Network,支持本地与公网开发地址。 - 共享开发浏览器:AI 编程右侧提供项目级浏览器,用户与 Agent 查看并调试同一实时页面、Console 和 Network,支持本地与公网开发地址。
- 后端边界:Renderer 通过 Main 所有的 Host API 访问认证、模型、同步、更新、语音、图像与运行时能力。 - 后端边界:Renderer 通过 Main 所有的 Host API 访问认证、模型、同步、更新、语音、图像与运行时能力。
- 智能体头像:创建和编辑个人云智能体时可上传、更换或移除头像,支持 PNG、JPEG、WebP、GIF,最大 5 MB。复用 Yuxi 的 `Agent.icon` 和公开图片存储;上传后先预览,保存草稿后生效,正式使用的头像随发布版本固定。列表、编辑页、收到的分享、会话入口和版本对比展示对应头像,未设置或加载失败时显示默认图标。Main 负责文件选择、上传和相对地址解析,客户端不维护第二份头像数据;需配套更新 Yuxi 的 Makelore 头像接口。 - 智能体头像:创建和编辑个人云智能体时可上传、更换或移除头像,支持 PNG、JPEG、WebP、GIF,最大 5 MB。复用 Yuxi 的 `Agent.icon` 和公开图片存储;上传后先预览,保存草稿后生效,正式使用的头像随发布版本固定。列表、编辑页、收到的分享、会话入口和版本对比展示对应头像,未设置或加载失败时显示默认图标。Main 负责文件选择、上传和相对地址解析,客户端不维护第二份头像数据;需配套更新 Yuxi 的 Makelore 头像接口。
- 智能体对话:试用、正式对话和历史记录使用相同的 Markdown 正文与可折叠过程区,展示模型实际返回的思考、工具输入、结果和失败。实时事件按消息与工具身份累积,重连去重;开始输出正文后过程区自动收起,可手动展开。配套 Yuxi 历史接口投影已有持久思考和工具记录,重新打开及微信历史均可查看;没有思考内容时不显示空面板。
- 客户端更新:Electron Main 按平台与架构选择更新源并保留原始诊断;设置页只显示一条脱敏后的中文状态。正式源缺少对应安装包时保持错误并允许重试,不会误报为已是最新版。 - 客户端更新:Electron Main 按平台与架构选择更新源并保留原始诊断;设置页只显示一条脱敏后的中文状态。正式源缺少对应安装包时保持错误并允许重试,不会误报为已是最新版。
- 个人资料:姓名、年龄、性别与个人头像按账号同步到云端;首次登录进入模块选择页时会要求先完善姓名,首页欢迎语和主平台左下角账号区优先展示个人资料姓名;头像支持 PNG、JPEG、WebP,保存时自动居中裁剪并以圆形缩略图展示,未设置或加载失败时回退为姓名首字母。 - 个人资料:姓名、年龄、性别与个人头像按账号同步到云端;首次登录进入模块选择页时会要求先完善姓名,首页欢迎语和主平台左下角账号区优先展示个人资料姓名;头像支持 PNG、JPEG、WebP,保存时自动居中裁剪并以圆形缩略图展示,未设置或加载失败时回退为姓名首字母。
- 会话观察同步:本地编程会话在一轮问答完成并进入空闲后,后台通过个人资料 PUT 上传该会话截至当前的完整问答快照;只保留用户/助手自然语言文本,过滤代码、路径、日志、工具调用、附件与产物。同步只从本地上行,云端不回写、不恢复或删除本地会话;失败数据留在本地等待重试。 - 会话观察同步:本地编程会话在一轮问答完成并进入空闲后,后台通过个人资料 PUT 上传该会话截至当前的完整问答快照;只保留用户/助手自然语言文本,过滤代码、路径、日志、工具调用、附件与产物。同步只从本地上行,云端不回写、不恢复或删除本地会话;失败数据留在本地等待重试。
+17 -2
View File
@@ -77,11 +77,26 @@ function projectRun(value: unknown): unknown {
} }
function projectConversation(value: unknown): unknown { function projectConversation(value: unknown): unknown {
const source = object(value); const source = object(value);
return { ...projectSession(source), messages: list(source.messages, item => copy(item, ['id', 'role', 'content', 'request_id', 'run_id', 'created_at'])), return { ...projectSession(source), messages: list(source.messages, projectMessage),
run: source.run == null ? null : projectRun(source.run), run: source.run == null ? null : projectRun(source.run),
queued_requests: list(source.queued_requests, item => copy(item, ['request_id', 'thread_id', 'run_id', 'status', 'version'])), queued_requests: list(source.queued_requests, item => copy(item, ['request_id', 'thread_id', 'run_id', 'status', 'version'])),
next_offset: source.next_offset ?? null }; next_offset: source.next_offset ?? null };
} }
function projectMessage(value: unknown): unknown {
const source = object(value);
return { ...copy(source, ['id', 'role', 'content', 'request_id', 'run_id', 'created_at', 'message_id', 'reasoning_content']),
...(Array.isArray(source.tool_calls) ? { tool_calls: source.tool_calls.map(item => {
const tool = object(item);
return { ...copy(tool, ['id', 'name', 'args', 'status', 'error_message']),
...(tool.tool_call_result == null ? {} : { tool_call_result: copy(tool.tool_call_result, ['content']) }) };
}) } : {}),
};
}
function projectHistory(value: unknown): unknown {
const source = object(value);
return { ...copy(source, ['thread_id', 'queued_requests', 'schedule_proposals', 'next_offset']),
messages: list(source.messages, projectMessage), run: source.run == null ? null : projectRun(source.run) };
}
function projectFiles(value: unknown): unknown { function projectFiles(value: unknown): unknown {
const source = object(value); const source = object(value);
return { session_id: source.session_id, thread_id: source.thread_id ?? null, return { session_id: source.session_id, thread_id: source.thread_id ?? null,
@@ -253,7 +268,7 @@ const operations: Record<keyof CloudAgentOperations, OperationSpec> = {
saveBudget: { method: 'PUT', cloud: 'ws', path: '/agents/:slug/budget', body: ['request_limit_points', 'daily_limit_points'], output: budget }, saveBudget: { method: 'PUT', cloud: 'ws', path: '/agents/:slug/budget', body: ['request_limit_points', 'daily_limit_points'], output: budget },
threads: { method: 'GET', path: '/threads', query: { slug: 'slug', offset: 'offset', archived: 'archived' }, output: ['threads', 'next_offset'] }, threads: { method: 'GET', path: '/threads', query: { slug: 'slug', offset: 'offset', archived: 'archived' }, output: ['threads', 'next_offset'] },
createThread: { method: 'POST', path: '/agents/:slug/threads', body: ['thread_id', 'preview', 'expected_revision'], output: ['thread_id', 'client_thread_id'] }, createThread: { method: 'POST', path: '/agents/:slug/threads', body: ['thread_id', 'preview', 'expected_revision'], output: ['thread_id', 'client_thread_id'] },
history: { method: 'GET', path: '/threads/:thread_id', query: { offset: 'offset' }, output: ['thread_id', 'messages', 'run', 'queued_requests', 'schedule_proposals', 'next_offset'] }, history: { method: 'GET', path: '/threads/:thread_id', query: { offset: 'offset' }, output: ['thread_id', 'messages', 'run', 'queued_requests', 'schedule_proposals', 'next_offset'], project: projectHistory },
viewed: { method: 'POST', path: '/threads/:thread_id/viewed', body: ['run_id'], output: ['viewed'] }, viewed: { method: 'POST', path: '/threads/:thread_id/viewed', body: ['run_id'], output: ['viewed'] },
submit: { method: 'POST', path: '/agents/:slug/requests', body: prompt, output: request }, submit: { method: 'POST', path: '/agents/:slug/requests', body: prompt, output: request },
preview: { method: 'POST', path: '/agents/:slug/preview', body: prompt, output: request }, preview: { method: 'POST', path: '/agents/:slug/preview', body: prompt, output: request },
+8
View File
@@ -98,6 +98,14 @@ export interface CloudInterrupt {
export interface CloudMessage { export interface CloudMessage {
id: number; role: 'user' | 'assistant' | 'system'; content: string; request_id: string; id: number; role: 'user' | 'assistant' | 'system'; content: string; request_id: string;
run_id: string | null; created_at: string; run_id: string | null; created_at: string;
message_id?: string | null;
reasoning_content?: string;
tool_calls?: CloudToolCall[];
}
export interface CloudToolCall {
id: string; name: string; args: unknown; status: string;
tool_call_result?: { content: unknown } | null;
error_message?: string | null;
} }
export interface CloudHistory { export interface CloudHistory {
thread_id: string; messages: CloudMessage[]; run: CloudRun | null; next_offset: number | null; thread_id: string; messages: CloudMessage[]; run: CloudRun | null; next_offset: number | null;
@@ -1,8 +1,7 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Download, Folder, RefreshCw } from 'lucide-react'; import { Download, Folder, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import ReactMarkdown from 'react-markdown'; import { CloudMessageBody } from './CloudMessageBody';
import remarkGfm from 'remark-gfm';
import { cloudAgentsApi } from '@/lib/cloud-agents-api'; import { cloudAgentsApi } from '@/lib/cloud-agents-api';
import type { CloudAgentOperations, CloudChannelSession, CloudChannelConversation, CloudFile, CloudPendingOperation } from '../../../shared/cloud-agents'; import type { CloudAgentOperations, CloudChannelSession, CloudChannelConversation, CloudFile, CloudPendingOperation } from '../../../shared/cloud-agents';
import { CloudApproval, cloudStatus } from './CloudChat'; import { CloudApproval, cloudStatus } from './CloudChat';
@@ -185,7 +184,7 @@ function ChannelSession({ sessionId, visible, refreshRevision, onBusy, onNewSess
{error && <p role="alert" className="text-sm text-destructive">{error}</p>} {error && <p role="alert" className="text-sm text-destructive">{error}</p>}
<div className="space-y-4 rounded-xl border border-border p-4" aria-label="微信消息"> <div className="space-y-4 rounded-xl border border-border p-4" aria-label="微信消息">
{data?.messages.filter(message => message.role !== 'system').map(message => <article key={message.id} className={message.role === 'user' ? 'ml-6 rounded-xl bg-muted/40 p-3' : 'p-1'}> {data?.messages.filter(message => message.role !== 'system').map(message => <article key={message.id} className={message.role === 'user' ? 'ml-6 rounded-xl bg-muted/40 p-3' : 'p-1'}>
<p className="mb-2 text-xs text-muted-foreground">{message.role === 'user' ? '你 · 微信' : '智能体'}</p><div className="prose prose-sm max-w-none break-words"><ReactMarkdown remarkPlugins={[remarkGfm]} components={{ a: ({ href, children }) => href && /^(https?:|mailto:)/i.test(href) ? <a href={href} target="_blank" rel="noreferrer">{children}</a> : <span>{children}</span> }}>{message.content}</ReactMarkdown></div></article>)} <p className="mb-2 text-xs text-muted-foreground">{message.role === 'user' ? '你 · 微信' : '智能体'}</p><CloudMessageBody {...message} /></article>)}
{data && !data.messages.length && <p className="text-sm text-muted-foreground">还没有消息。</p>} {data && !data.messages.length && <p className="text-sm text-muted-foreground">还没有消息。</p>}
</div> </div>
{!recoveryReady && <Button variant="outline" size="sm" onClick={() => void loadRecovery().catch(error => setError(failureText(error)))}>重新读取操作记录</Button>} {!recoveryReady && <Button variant="outline" size="sm" onClick={() => void loadRecovery().catch(error => setError(failureText(error)))}>重新读取操作记录</Button>}
+31 -22
View File
@@ -6,8 +6,8 @@ import { cloudAgentsApi } from '@/lib/cloud-agents-api';
import type { CloudHistory, CloudInterrupt, CloudPrompt, CloudRequest, CloudRun, CloudThread, CloudScheduleProposal } from '../../../shared/cloud-agents'; import type { CloudHistory, CloudInterrupt, CloudPrompt, CloudRequest, CloudRun, CloudThread, CloudScheduleProposal } from '../../../shared/cloud-agents';
import { CloudFiles } from './CloudFiles'; import { CloudFiles } from './CloudFiles';
import './desktop-workspace.css'; import './desktop-workspace.css';
import ReactMarkdown, { defaultUrlTransform } from 'react-markdown'; import { CloudMessageBody } from './CloudMessageBody';
import remarkGfm from 'remark-gfm'; import { reduceCloudStream, type CloudStream } from './cloud-chat-stream';
import { CloudPendingContext, useCloudPendingState, usePendingCloudInput } from './CloudPending'; import { CloudPendingContext, useCloudPendingState, usePendingCloudInput } from './CloudPending';
import { agentExamples } from './agent-guidance'; import { agentExamples } from './agent-guidance';
@@ -39,7 +39,7 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread
const [intent, setIntent] = useState<CloudPrompt | null>(null); const [intent, setIntent] = useState<CloudPrompt | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [liveText, setLiveText] = useState(''); const [liveStream, setLiveStream] = useState<CloudStream>({ runId: '', cursor: '', messages: [] });
const [progress, setProgress] = useState(''); const [progress, setProgress] = useState('');
const [interrupt, setInterrupt] = useState<CloudInterrupt | null>(null); const [interrupt, setInterrupt] = useState<CloudInterrupt | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -151,6 +151,9 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread
const runId = request && active(request.status) ? request.run_id ?? undefined : run?.agent_run_id; const runId = request && active(request.status) ? request.run_id ?? undefined : run?.agent_run_id;
const running = active(run?.status) || active(request?.status); const running = active(run?.status) || active(request?.status);
const liveHistoryReady = history?.run?.agent_run_id === liveStream.runId
? !active(history.run.status)
: Boolean(history?.messages.some(item => item.run_id === liveStream.runId));
useEffect(() => { useEffect(() => {
if (!runId || !running) return; if (!runId || !running) return;
let live = true; let live = true;
@@ -163,16 +166,17 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread
const payload = object(envelope.payload); const payload = object(envelope.payload);
const chunk = object(payload.chunk); const chunk = object(payload.chunk);
const targetThread = thread?.thread_id ?? request?.thread_id; const targetThread = thread?.thread_id ?? request?.thread_id;
if (envelope.run_id && envelope.run_id !== runId) return;
if (envelope.thread_id && targetThread && envelope.thread_id !== targetThread) return; if (envelope.thread_id && targetThread && envelope.thread_id !== targetThread) return;
for (const item of Array.isArray(payload.items) ? payload.items : [chunk]) { setLiveStream(current => reduceCloudStream(
const semantic = object(object(item).stream_event); current.runId === runId ? current : { runId, cursor: '', messages: [] },
if (semantic.type === 'message_delta' && typeof semantic.content === 'string') setLiveText(v => v + semantic.content); envelope, targetThread ?? '', event.lastEventId,
if (semantic.type === 'tool_call' && typeof semantic.name === 'string') setProgress('正在使用 ' + semantic.name); ));
}
if (event.type === 'interrupt') setInterrupt(chunk as CloudInterrupt); if (event.type === 'interrupt') setInterrupt(chunk as CloudInterrupt);
if (event.type === 'end') { if (event.type === 'end') {
source?.close(); source?.close();
setLiveText(''); setProgress(''); setLiveStream(current => ({ ...current, finished: true }));
setProgress('');
const id = thread?.thread_id ?? request?.thread_id; const id = thread?.thread_id ?? request?.thread_id;
if (id) void readHistory(id, g).catch(e => { if (live) setError(message(e)); }); if (id) void readHistory(id, g).catch(e => { if (live) setError(message(e)); });
} }
@@ -191,14 +195,14 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread
if (!live || g !== generation.current) return; if (!live || g !== generation.current) return;
setRun(next); setRun(next);
if (!active(next.status)) { if (!active(next.status)) {
source?.close(); setLiveText(''); setProgress(''); setInterrupt(next.interrupt ?? null); source?.close(); setProgress(''); setInterrupt(next.interrupt ?? null);
void readHistory(next.thread_id, g).catch(e => { if (live) setError(message(e)); }); void readHistory(next.thread_id, g).catch(e => { if (live) setError(message(e)); });
} }
}).catch(e => { if (live) setError(message(e)); }); }).catch(e => { if (live) setError(message(e)); });
}, 5000); }, 5000);
return () => { live = false; source?.close(); window.clearInterval(timer); }; return () => { live = false; source?.close(); window.clearInterval(timer); };
}, [runId, running, thread?.thread_id, request?.thread_id, readHistory]); }, [runId, running, thread?.thread_id, request?.thread_id, readHistory]);
useEffect(() => { if (following.current && transcript.current) transcript.current.scrollTop = transcript.current.scrollHeight; }, [history?.messages.length, liveText, interrupt]); useEffect(() => { if (following.current && transcript.current) transcript.current.scrollTop = transcript.current.scrollHeight; }, [history?.messages.length, liveStream, interrupt]);
const stalePreview = previewRevision !== undefined && latestRevision !== undefined && previewRevision !== latestRevision; const stalePreview = previewRevision !== undefined && latestRevision !== undefined && previewRevision !== latestRevision;
const rememberThread = (next: CloudThread) => { const rememberThread = (next: CloudThread) => {
@@ -218,7 +222,7 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread
try { try {
const accepted = await cloudAgentsApi.call(previewRevision === undefined ? 'submit' : 'preview', { slug, ...input }); const accepted = await cloudAgentsApi.call(previewRevision === undefined ? 'submit' : 'preview', { slug, ...input });
if (!alive.current || g !== generation.current) return; if (!alive.current || g !== generation.current) return;
setRequest(accepted); setIntent(null); setQuery(''); setLiveText(''); setAttachmentIds([]); setRequest(accepted); setIntent(null); setQuery(''); setLiveStream({ runId: '', cursor: '', messages: [] }); setAttachmentIds([]);
rememberThread({ thread_id: accepted.thread_id, client_thread_id: clientId, agent_slug: slug, title: input.query.slice(0, 80), rememberThread({ thread_id: accepted.thread_id, client_thread_id: clientId, agent_slug: slug, title: input.query.slice(0, 80),
mode, draft_revision: previewRevision, updated_at: new Date().toISOString(), run_id: accepted.run_id, status: accepted.status, unread: false }); mode, draft_revision: previewRevision, updated_at: new Date().toISOString(), run_id: accepted.run_id, status: accepted.status, unread: false });
await readHistory(accepted.thread_id, g); await readHistory(accepted.thread_id, g);
@@ -231,7 +235,7 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread
if (mode === 'preview') setPreviewRevision(next?.draft_revision ?? revision); if (mode === 'preview') setPreviewRevision(next?.draft_revision ?? revision);
const g = ++generation.current; const g = ++generation.current;
setThread(next); setClientId(next?.client_thread_id ?? crypto.randomUUID()); setHistory(null); setThread(next); setClientId(next?.client_thread_id ?? crypto.randomUUID()); setHistory(null);
setRequest(null); setQueued([]); setRun(null); setLiveText(''); setInterrupt(null); setError(''); if (!keepQuery) setQuery(''); setRequest(null); setQueued([]); setRun(null); setLiveStream({ runId: '', cursor: '', messages: [] }); setInterrupt(null); setError(''); if (!keepQuery) setQuery('');
setAttachmentIds([]); setAttachmentIds([]);
if (next) { setLoading(true); try { await readHistory(next.thread_id, g); } catch(e) { setError(message(e)); } finally { setLoading(false); } } if (next) { setLoading(true); try { await readHistory(next.thread_id, g); } catch(e) { setError(message(e)); } finally { setLoading(false); } }
}; };
@@ -280,7 +284,7 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread
<div ref={transcript} data-testid="cloud-chat-messages" className="agent-chat-scroll" <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"> 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> {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"> : !history?.messages.length && !liveStream.messages.length && <div className="space-y-4 py-6 text-sm leading-7">
<h2 className="font-medium">{mode === 'preview' ? '问它一个小问题' : '今天想让它帮你做什么?'}</h2> <h2 className="font-medium">{mode === 'preview' ? '问它一个小问题' : '今天想让它帮你做什么?'}</h2>
<p className="text-muted-foreground">{mode === 'preview' ? '看看它的回答是否符合你的要求。不满意就改一改,再试一次。' : '从下面选一句,或写下你自己的想法。'}</p> <p className="text-muted-foreground">{mode === 'preview' ? '看看它的回答是否符合你的要求。不满意就改一改,再试一次。' : '从下面选一句,或写下你自己的想法。'}</p>
<div className="grid gap-2">{examples.map(example => <Button key={example} type="button" variant="outline" <div className="grid gap-2">{examples.map(example => <Button key={example} type="button" variant="outline"
@@ -293,8 +297,17 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread
<p className="my-2 text-xs text-muted-foreground">这是修改前的回答。用同一个问题再问一次,更容易看出变化。</p> <p className="my-2 text-xs text-muted-foreground">这是修改前的回答。用同一个问题再问一次,更容易看出变化。</p>
<TrialMessages history={previousTrial.history} onError={setError} /> <TrialMessages history={previousTrial.history} onError={setError} />
</details>} </details>}
{history && <TrialMessages history={history} onError={setError} />} {history && <TrialMessages history={{ ...history, messages: history.messages.filter(saved =>
{liveText && <div className="whitespace-pre-wrap break-words p-2 text-sm leading-7">{liveText}</div>} !(!liveHistoryReady && saved.run_id === liveStream.runId && liveStream.messages.some(item => item.message_id === saved.message_id)),
) }} onError={setError} />}
{liveStream.messages.filter(item => !liveHistoryReady || !history?.messages.some(saved => saved.run_id === liveStream.runId && saved.message_id === item.message_id)).map(item =>
<article key={liveStream.runId + ':' + item.message_id} className="min-w-0 p-2">
<p className="mb-2 text-xs text-muted-foreground">助手</p>
<CloudMessageBody {...item} active={running && !liveStream.finished && runId === liveStream.runId} onDownload={path => {
const id = thread?.thread_id ?? request?.thread_id;
if (id) void cloudAgentsApi.download(id, path).catch(e => setError(message(e)));
}} />
</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"> {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> <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> <p className="text-xs text-muted-foreground">{proposal.cron_expression} · {proposal.timezone} · 尚未启用</p>
@@ -302,7 +315,7 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread
</article>)} </article>)}
{run?.error && run.status === 'failed' && <p role="alert" className="text-sm text-destructive">{run.error.message}</p>} {run?.error && run.status === 'failed' && <p role="alert" className="text-sm text-destructive">{run.error.message}</p>}
{interrupt && runId && !thread?.archived && <CloudApproval key={runId} interrupt={interrupt} runId={runId} onResumed={nextRun => { {interrupt && runId && !thread?.archived && <CloudApproval key={runId} interrupt={interrupt} runId={runId} onResumed={nextRun => {
setInterrupt(null); setLiveText(''); setRun(nextRun); setRequest(null); setInterrupt(null); setLiveStream({ runId: '', cursor: '', messages: [] }); setRun(nextRun); setRequest(null);
}} />} }} />}
{queued.length > 0 && <div className="mb-3 space-y-2 rounded-lg bg-muted/40 p-3 text-sm"> {queued.length > 0 && <div className="mb-3 space-y-2 rounded-lg bg-muted/40 p-3 text-sm">
@@ -352,11 +365,7 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread
function TrialMessages({ history, onError }: { history: CloudHistory; onError: (error: string) => void }) { 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'}> 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> <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={{ <CloudMessageBody {...item} onDownload={path => void cloudAgentsApi.download(history.thread_id, path).catch(e => onError(message(e)))} />
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>
</article>); </article>);
} }
@@ -0,0 +1,34 @@
import ReactMarkdown, { defaultUrlTransform } from 'react-markdown';
import remarkGfm from 'remark-gfm';
import type { CloudToolCall } from '../../../shared/cloud-agents';
const display = (value: unknown) => typeof value === 'string' ? value : value == null ? '' : JSON.stringify(value, null, 2);
export function CloudMessageBody({ content, reasoning_content, tool_calls = [], active = false, onDownload }: {
content: string; reasoning_content?: string; tool_calls?: CloudToolCall[]; active?: boolean;
onDownload?: (path: string) => void;
}) {
return <div className="min-w-0 space-y-3">
{(reasoning_content || tool_calls.length > 0) && <details open={active && !content} className="rounded-lg border border-border bg-muted/20 p-3 text-xs text-muted-foreground" data-testid="cloud-message-process">
<summary className="cursor-pointer font-medium">{reasoning_content ? '思考过程' : '工具调用'}{tool_calls.length > 0 && ' · ' + tool_calls.length + ' 个工具'}{active && !content ? ' · 进行中' : ''}</summary>
<div className="mt-3 space-y-3">
{reasoning_content && <div aria-label="智能体思考过程" className="max-h-72 overflow-auto whitespace-pre-wrap break-words border-l-2 pl-3 leading-6">{reasoning_content}</div>}
{tool_calls.map(tool => <details key={tool.id} className="rounded-md border border-border p-2">
<summary className="cursor-pointer break-words">{tool.name} · {tool.status === 'success' ? '已完成' : tool.status === 'error' ? '失败' : active ? tool.status === 'running' ? '执行中' : '准备调用' : '未完成'}</summary>
<div className="mt-2 space-y-2">
<p>输入</p><pre className="max-h-48 overflow-auto whitespace-pre-wrap break-words">{display(tool.args)}</pre>
{tool.tool_call_result != null && <><p>结果</p><pre className="max-h-60 overflow-auto whitespace-pre-wrap break-words">{display(tool.tool_call_result.content)}</pre></>}
{tool.error_message && <p className="whitespace-pre-wrap break-words text-destructive">{tool.error_message}</p>}
</div>
</details>)}
</div>
</details>}
{content && <div className="prose prose-sm max-w-none break-words [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_table]:block [&_table]:overflow-x-auto">
<ReactMarkdown urlTransform={url => url.startsWith('sandbox:/') ? url.slice(8) : defaultUrlTransform(url)} remarkPlugins={[remarkGfm]} components={{
a: ({ href, children }) => href && !/^(https?:|mailto:|#)/i.test(href)
? onDownload ? <button className="text-primary underline" onClick={() => onDownload(href.replace(/^\/api\/chat\/thread\/[^/]+\/artifacts\//, '/'))}>{children}</button> : <span>{children}</span>
: <a href={href} target="_blank" rel="noreferrer">{children}</a>,
}}>{content}</ReactMarkdown>
</div>}
</div>;
}
@@ -0,0 +1,74 @@
import type { CloudToolCall } from '../../../shared/cloud-agents';
export interface CloudLiveMessage {
message_id: string;
content: string;
reasoning_content: string;
tool_calls: (CloudToolCall & { index?: number })[];
}
export interface CloudStream {
runId: string;
cursor: string;
messages: CloudLiveMessage[];
finished?: boolean;
}
export const object = (value: unknown): Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
/** Redis SSE cursors order replayed batches without treating subthread output as an answer. */
export function reduceCloudStream(state: CloudStream, data: unknown, threadId: string, cursor: string): CloudStream {
if (cursor && /^\d+-\d+$/.test(cursor) && state.cursor) {
const [time, sequence] = cursor.split('-').map(BigInt);
const [previousTime, previousSequence] = state.cursor.split('-').map(BigInt);
if (time < previousTime || (time === previousTime && sequence <= previousSequence)) return state;
}
const envelope = object(data);
if (envelope.run_id && envelope.run_id !== state.runId) return state;
const next = { ...state, cursor: /^\d+-\d+$/.test(cursor) ? cursor : state.cursor };
if (envelope.thread_id && envelope.thread_id !== threadId) return next;
const payload = object(envelope.payload);
const chunks = Array.isArray(payload.items) ? payload.items : [payload.chunk];
let messages = state.messages;
for (const value of chunks) {
const chunk = object(value);
const event = object(chunk.stream_event);
if ((chunk.thread_id && chunk.thread_id !== threadId) || (event.thread_id && event.thread_id !== threadId)) continue;
if (['message_delta', 'tool_call', 'tool_call_delta'].includes(String(event.type)) && typeof event.message_id === 'string') {
const id = event.message_id;
const current = messages.find(message => message.message_id === id)
?? { message_id: id, content: '', reasoning_content: '', tool_calls: [] };
const updated = { ...current };
if (event.type === 'message_delta') {
updated.content += typeof event.content === 'string' ? event.content : '';
updated.reasoning_content += typeof event.reasoning_content === 'string' ? event.reasoning_content
: typeof event.additional_reasoning_content === 'string' ? event.additional_reasoning_content : '';
} else {
const index = typeof event.index === 'number' ? event.index : 0;
const existing = current.tool_calls.find(tool => (event.tool_call_id && tool.id === event.tool_call_id) || tool.index === index);
const tool = { ...existing, id: typeof event.tool_call_id === 'string' ? event.tool_call_id : existing?.id ?? id + ':' + index,
name: typeof event.name === 'string' ? event.name : existing?.name ?? '工具',
args: existing && existing.status !== 'pending' ? existing.args
: event.type === 'tool_call' ? event.args ?? {} : String(existing?.args ?? '') + String(event.args_delta ?? ''),
status: existing?.status ?? 'pending', index };
updated.tool_calls = existing ? current.tool_calls.map(item => item === existing ? tool : item) : [...current.tool_calls, tool];
}
messages = messages.some(message => message.message_id === id)
? messages.map(message => message.message_id === id ? updated : message) : [...messages, updated];
}
const toolEvent = object(chunk.event);
const result = object(toolEvent.data);
if (toolEvent.method === 'tools' && typeof result.tool_call_id === 'string') {
messages = messages.map(message => ({ ...message, tool_calls: message.tool_calls.map(tool => {
if (tool.id !== result.tool_call_id) return tool;
if (result.event === 'tool-started') return { ...tool, args: result.input ?? tool.args, status: 'running' };
if (result.event !== 'tool-finished') return tool;
const output = object(result.output);
const error = result.error || (output.status === 'error' ? output.content : null);
return { ...tool, status: error ? 'error' : 'success',
error_message: error ? (typeof error === 'string' ? error : JSON.stringify(error)) : null,
tool_call_result: { content: output.content ?? result.output ?? '' } };
}) }));
}
}
return { ...next, messages };
}
+97
View File
@@ -0,0 +1,97 @@
import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron';
test('personal chat displays thinking, tools and Markdown, then restores them after reload', async ({ launchElectronApp }, testInfo) => {
const app = await launchElectronApp({ skipSetup: true });
try {
const page = await getStableWindow(app);
await expect(page.getByTestId('ai-module-selection-page')).toBeVisible();
const applicationUrl = page.url();
await page.goto('about:blank');
// Cloud provider is deterministic here; the real history HTTP/PG boundary is tested in Yuxi.
await app.evaluate(({ ipcMain, BrowserWindow }) => {
BrowserWindow.getAllWindows()[0].setSize(1180, 820);
let complete = false;
Object.assign(globalThis, { finishRenderRun: () => { complete = true; } });
const slug = 'ml-' + 'a'.repeat(32);
const configuration = { model: 'test', tools: [], knowledges: [], mcps: [], skills: [], preload_skills: [], subagents: [], tool_approval_mode: 'always_trust', max_execution_steps: 300, max_output_tokens: 4096, max_run_seconds: 600 };
const draft = { slug, name: '渲染测试助手', purpose: '整理资料', system_prompt: '', configuration,
draft_revision: 1, published_version: 1, enabled: true, updated_at: '' };
const run = { agent_run_id: 'render-run', request_id: 'render-request', thread_id: 'render-thread', agent_slug: slug, status: 'running', output: '', version: '1' };
const thread = { thread_id: 'render-thread', client_thread_id: 'client', agent_slug: slug, title: '整理资料', mode: 'published', updated_at: '', run_id: run.agent_run_id, unread: false, status: 'running' };
const result = (json: unknown) => ({ ok: true, data: { status: 200, ok: true, json } });
ipcMain.removeHandler('hostapi:fetch');
ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string; body?: string }) => {
const path = request.path ?? '';
const body = request.body ? JSON.parse(request.body) : {};
if (path === '/api/auth/session/sync') return result({ success: true, session: { accessToken: 'fixture', tokenType: 'Bearer', expiresAt: Date.now() + 3600000, lastActiveAt: Date.now(), canRefresh: false } });
if (path === '/api/auth/me') return result({ success: true, user: { username: 'creator', userId: 'creator', tenantId: null, deptId: null, authorities: [] }, moduleAccess: { programming: true, design: true, robot: true, cloud_agents: true } });
if (path === '/api/works/user/agent-profile') return result({ success: true, profile: { display_name: '创作者', completed: true, version: 1 } });
if (path.endsWith('/recovery')) return result({ recent: null, pending: [] });
if (path.endsWith('/recovery/recent')) return result({});
if (path.endsWith('/agents') || path.endsWith('/bootstrap')) return result({ agents: [draft], next_cursor: null });
if (path.endsWith('/actions')) {
if (body.operation === 'threads') return result({ threads: [thread], next_offset: null });
if (body.operation === 'history') return result({ thread_id: thread.thread_id, run: { ...run, status: complete ? 'completed' : 'running' }, next_offset: null, messages: complete ? [{
id: 1, message_id: 'answer', role: 'assistant', run_id: run.agent_run_id, request_id: run.request_id, created_at: '',
content: '## 整理结果\n\n| 项目 | 状态 |\n| --- | --- |\n| 资料 | 已核对 |\n\n**可以使用**',
reasoning_content: '先核对来源,再整理结果。',
tool_calls: [{ id: 'lookup', name: '资料检索', args: { query: '实际检索资料' }, status: 'success', tool_call_result: { content: '找到两条资料' } }],
}] : [] });
if (body.operation === 'run') return result({ ...run, status: complete ? 'completed' : 'running' });
if (body.operation === 'viewed') return result({});
if (body.operation === 'files') return result({ files: [] });
if (body.operation === 'attachments') return result({ attachments: [] });
if (body.operation === 'catalog') return result({ models: [{ id: 'test', name: '测试模型' }], resources: {}, pricing: null });
if (body.operation === 'access') return result({ published_version: 1, enabled: true, versions: [], grants: [], applications: [], share_url: '', api_url: '' });
if (body.operation === 'channelBindings') return result({ items: [], available_accounts: [] });
return result({});
}
if (path.startsWith('/api/cloud-agents/agents/')) return result(draft);
return result({ success: true });
});
});
await page.addInitScript(() => {
const sources: EventTarget[] = [];
Object.assign(window, { renderSources: sources });
class FixtureSource extends EventTarget {
onopen = null; onerror = null; close() {}
constructor(url: string) { super(); if (url.includes('/cloud-agents/runs/')) sources.push(this); }
}
Object.defineProperty(window, 'EventSource', { value: FixtureSource });
});
await page.goto(applicationUrl);
await page.getByTestId('ai-module-option-cloud_agents').click();
await page.getByRole('button', { name: /渲染测试助手.*打开对话/ }).click();
const emit = async (items: unknown[], cursor: string) => page.evaluate(({ items, cursor }) => {
const sources = (window as unknown as { renderSources: EventTarget[] }).renderSources;
sources.at(-1)!.dispatchEvent(new MessageEvent('messages', { lastEventId: cursor, data: JSON.stringify({ run_id: 'render-run', thread_id: 'render-thread', payload: { items } }) }));
}, { items, cursor });
await expect.poll(() => page.evaluate(() => (window as unknown as { renderSources: EventTarget[] }).renderSources.length)).toBeGreaterThan(0);
await emit([{ stream_event: { type: 'message_delta', message_id: 'answer', reasoning_content: '先核对来源,再整理结果。' } }], '100-1');
const process = page.getByTestId('cloud-message-process');
await expect(page.getByLabel('智能体思考过程')).toBeVisible();
await emit([
{ stream_event: { type: 'tool_call', message_id: 'answer', tool_call_id: 'lookup', name: '资料检索', args: { query: '测试资料' } } },
{ event: { method: 'tools', data: { event: 'tool-started', tool_call_id: 'lookup', input: { query: '实际检索资料' } } } },
{ event: { method: 'tools', data: { event: 'tool-finished', tool_call_id: 'lookup', output: { content: '找到两条资料' } } } },
{ stream_event: { type: 'message_delta', message_id: 'answer', content: '## 整理结果\n\n| 项目 | 状态 |\n| --- | --- |\n| 资料 | 已核对 |\n\n**可以使用**' } },
], '100-2');
await expect(page.getByRole('heading', { name: '整理结果' })).toBeVisible();
await expect(page.getByRole('table')).toBeVisible();
await process.locator(':scope > summary').click();
await process.getByText('资料检索 · 已完成', { exact: true }).click();
await expect(process.getByText('找到两条资料', { exact: true })).toBeVisible();
await expect(process.getByText('"实际检索资料"', { exact: false })).toBeVisible();
await page.screenshot({ path: testInfo.outputPath('personal-chat-process.png') });
await app.evaluate(() => (globalThis as unknown as { finishRenderRun: () => void }).finishRenderRun());
await page.evaluate(() => (window as unknown as { renderSources: EventTarget[] }).renderSources.at(-1)!.dispatchEvent(new MessageEvent('end', { data: JSON.stringify({ thread_id: 'render-thread', payload: { status: 'completed' } }) })));
await expect(page.getByRole('heading', { name: '整理结果' })).toHaveCount(1);
await page.reload();
await page.getByRole('button', { name: /渲染测试助手.*打开对话/ }).click();
await expect(page.getByRole('heading', { name: '整理结果' })).toBeVisible();
await page.getByTestId('cloud-message-process').locator(':scope > summary').click();
await expect(page.getByLabel('智能体思考过程')).toHaveText('先核对来源,再整理结果。');
await expect(page.getByText('资料检索 · 已完成', { exact: true })).toBeVisible();
await page.screenshot({ path: testInfo.outputPath('personal-chat-reopened.png') });
} finally { await closeElectronApp(app); }
});
+17 -2
View File
@@ -45,6 +45,22 @@ beforeEach(() => { vi.clearAllMocks(); journalRecords.clear(); clearWorksSquareS
afterEach(() => { instances.splice(0).forEach((module) => module.dispose()); clearWorksSquareSession(); vi.useRealTimers(); }); afterEach(() => { instances.splice(0).forEach((module) => module.dispose()); clearWorksSquareSession(); vi.useRealTimers(); });
describe('Main cloud Agents boundary', () => { describe('Main cloud Agents boundary', () => {
it('projects saved reasoning and tool results for both personal and channel history without raw metadata', () => {
const message = { id: 1, role: 'assistant', content: 'answer', reasoning_content: 'thought', message_id: 'model-1',
extra_metadata: { internal: true }, tool_calls: [{ id: 'call-1', name: 'search', args: { query: 'topic' },
status: 'success', internal: 'hidden', tool_call_result: { content: 'result', secret: 'hidden' } }] };
const expected = { id: 1, role: 'assistant', content: 'answer', reasoning_content: 'thought', message_id: 'model-1',
tool_calls: [{ id: 'call-1', name: 'search', args: { query: 'topic' }, status: 'success', tool_call_result: { content: 'result' } }] };
for (const input of [
{ operation: 'history', input: { thread_id: 'thread-1' } },
{ operation: 'channelConversation', input: { session_id: 'session-1' } },
]) {
expect(operationPlan(input).project({ messages: [message], run: null, next_offset: null })).toMatchObject({ messages: [expected] });
expect(JSON.stringify(operationPlan(input).project({ messages: [message] }))).not.toContain('hidden');
expect(JSON.stringify(operationPlan(input).project({ messages: [message] }))).not.toContain('extra_metadata');
}
});
it('uploads avatar bytes through Main and resolves Yuxi public URLs without exposing credentials', async () => { it('uploads avatar bytes through Main and resolves Yuxi public URLs without exposing credentials', async () => {
const directory = await mkdtemp(join(tmpdir(), 'makelore-avatar-')); const directory = await mkdtemp(join(tmpdir(), 'makelore-avatar-'));
try { try {
@@ -106,8 +122,7 @@ describe('Main cloud Agents boundary', () => {
expect(await module.execute({ operation: 'version', input: { slug: draft.slug, version: '1' } })).toMatchObject(avatar); expect(await module.execute({ operation: 'version', input: { slug: draft.slug, version: '1' } })).toMatchObject(avatar);
}); });
it.each(['file:///private/avatar.png', 'javascript:alert(1)', 'https://secret@images.example.test/avatar.png']) it.each(['file:///private/avatar.png', 'javascript:alert(1)', 'https://secret@images.example.test/avatar.png'])('falls back for an unusable legacy avatar URL %s', async icon => {
('falls back for an unusable legacy avatar URL %s', async icon => {
const transport = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json({ ...draft, icon })); const transport = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json({ ...draft, icon }));
expect(await moduleFor(transport).get(draft.slug)).toMatchObject({ icon, icon_url: null }); expect(await moduleFor(transport).get(draft.slug)).toMatchObject({ icon, icon_url: null });
}); });
+2 -2
View File
@@ -158,8 +158,8 @@ it('retries the same request after a lost response and renders batched native SS
expect(submissions[0][1]).toEqual(submissions[1][1]); expect(submissions[0][1]).toEqual(submissions[1][1]);
await act(async () => stream.dispatchEvent(new MessageEvent('messages', { await act(async () => stream.dispatchEvent(new MessageEvent('messages', {
data: JSON.stringify({ thread_id: 'thread-1', payload: { items: [ data: JSON.stringify({ thread_id: 'thread-1', payload: { items: [
{ stream_event: { type: 'message_delta', content: '第一段' } }, { stream_event: { type: 'message_delta', message_id: 'answer', content: '第一段' } },
{ stream_event: { type: 'message_delta', content: '文字' } }, { stream_event: { type: 'message_delta', message_id: 'answer', content: '文字' } },
] } }), ] } }),
}))); })));
expect(screen.getByText('第一段文字')).toBeVisible(); expect(screen.getByText('第一段文字')).toBeVisible();
+165
View File
@@ -0,0 +1,165 @@
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { CloudChat } from '@/pages/CloudAgents/CloudChat';
import { reduceCloudStream, type CloudStream } from '@/pages/CloudAgents/cloud-chat-stream';
import type { CloudHistory } from '../../shared/cloud-agents';
const api = vi.hoisted(() => ({ call: vi.fn(), events: vi.fn(), recovery: vi.fn(), remember: vi.fn(), download: vi.fn() }));
vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api }));
class Stream extends EventTarget { close = vi.fn(); onopen = null; onerror = null; }
let stream: Stream;
let history: CloudHistory;
const run = { agent_run_id: 'run', request_id: 'request', thread_id: 'thread', agent_slug: 'agent', status: 'running', output: '', version: '1' };
const thread = { thread_id: 'thread', client_thread_id: 'client', agent_slug: 'agent', mode: 'published', status: 'running', title: '对话', updated_at: '', unread: false, run_id: 'run' };
beforeEach(() => {
vi.resetAllMocks();
stream = new Stream();
history = { thread_id: 'thread', messages: [], run, next_offset: null };
api.recovery.mockResolvedValue({ recent: null, pending: [] });
api.remember.mockResolvedValue({});
api.download.mockResolvedValue({ saved: true });
api.events.mockResolvedValue(stream);
api.call.mockImplementation(async (operation: string) => {
if (operation === 'threads') return { threads: [thread], next_offset: null };
if (operation === 'history') return history;
if (operation === 'run') return history.run;
if (operation === 'files') return { files: [] };
if (operation === 'attachments') return { attachments: [] };
if (operation === 'viewed') return {};
throw new Error(operation);
});
});
afterEach(cleanup);
async function emit(items: unknown[], cursor: string, threadId = 'thread') {
await act(async () => stream.dispatchEvent(new MessageEvent('messages', {
lastEventId: cursor, data: JSON.stringify({ run_id: 'run', thread_id: threadId, payload: { items } }),
})));
}
const delta = (message_id: string, fields: Record<string, unknown>) => ({ stream_event: { type: 'message_delta', message_id, ...fields } });
it('renders separate thinking and Markdown messages, ignores replay/subthreads, and restores durable history on reopen', async () => {
const view = render(<CloudChat slug="agent" />);
await waitFor(() => expect(api.events).toHaveBeenCalledWith('run'));
await emit([delta('first', { reasoning_content: '先查资料' })], '100-1');
expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('先查资料');
expect(screen.getByTestId('cloud-message-process')).toHaveAttribute('open');
await emit([delta('first', { reasoning_content: '先查资料' })], '100-1');
await emit([delta('child', { content: '不应出现在主回答', reasoning_content: '子线程思考' })], '100-2', 'child');
await emit([delta('first', { content: '**核对完成**' }), delta('second', { additional_reasoning_content: '再整理结论' })], '100-3');
expect(screen.getByText('核对完成').tagName).toBe('STRONG');
expect(screen.queryByText('不应出现在主回答')).not.toBeInTheDocument();
expect(screen.getAllByLabelText('智能体思考过程')[0]).toHaveTextContent(/^先查资料$/);
expect(screen.getAllByTestId('cloud-message-process')[0]).not.toHaveAttribute('open');
await emit([delta('second', { content: '| 项目 | 结果 |\n| --- | --- |\n| 校验 | 通过 |\n\n[报告](sandbox:/report.md)' })], '100-4');
expect(screen.getByRole('table')).toHaveTextContent('校验通过');
fireEvent.click(screen.getByRole('button', { name: '报告' }));
expect(api.download).toHaveBeenCalledWith('thread', '/report.md');
history = { ...history, run: { ...run, status: 'completed' }, messages: [
{ id: 1, role: 'assistant', message_id: 'first', run_id: 'run', request_id: 'request', created_at: '', content: '**核对完成**', reasoning_content: '先查资料' },
{ id: 2, role: 'assistant', message_id: 'second', run_id: 'run', request_id: 'request', created_at: '', content: '最终结论', reasoning_content: '再整理结论' },
] };
await act(async () => stream.dispatchEvent(new MessageEvent('end', { data: JSON.stringify({ thread_id: 'thread', payload: { status: 'completed' } }) })));
await screen.findByText('最终结论');
expect(screen.getAllByText('核对完成')).toHaveLength(1);
expect(screen.getAllByLabelText('智能体思考过程')).toHaveLength(2);
expect(screen.queryByRole('table')).not.toBeInTheDocument();
view.unmount();
render(<CloudChat slug="agent" />);
await screen.findByText('最终结论');
expect(screen.getAllByLabelText('智能体思考过程')[1]).toHaveTextContent('再整理结论');
expect(screen.getAllByTestId('cloud-message-process').every(element => !element.hasAttribute('open'))).toBe(true);
});
it('associates tool arguments and results by call identity, keeps failures visible and clears a switched thread', async () => {
render(<CloudChat slug="agent" />);
await waitFor(() => expect(api.events).toHaveBeenCalled());
await emit([
{ stream_event: { type: 'tool_call_delta', message_id: 'm', tool_call_id: 'search', name: '查资料', args_delta: '{"query":', index: 0 } },
{ stream_event: { type: 'tool_call_delta', message_id: 'm', args_delta: '"天气"}', index: 0 } },
{ stream_event: { type: 'tool_call', message_id: 'm', tool_call_id: 'write', name: '写文件', args: { path: '/a' }, index: 1 } },
{ event: { method: 'tools', data: { event: 'tool-finished', tool_call_id: 'write', output: { content: '磁盘不足', status: 'error' } } } },
{ event: { method: 'tools', data: { event: 'tool-finished', tool_call_id: 'search', output: { content: '晴天', status: 'success' } } } },
], '101-0');
const process = screen.getByTestId('cloud-message-process');
expect(process).toHaveTextContent('查资料 · 已完成');
expect(process).toHaveTextContent('写文件 · 失败');
expect(within(process).getByText('{"query":"天气"}')).toBeInTheDocument();
expect(within(process).getByText('晴天')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '新对话' }));
await waitFor(() => expect(screen.queryByTestId('cloud-message-process')).not.toBeInTheDocument());
await emit([delta('m', { reasoning_content: '旧连接的迟到内容' })], '102-0');
expect(screen.queryByText('旧连接的迟到内容')).not.toBeInTheDocument();
});
it('retains partial thinking and answer if final history cannot be read', async () => {
render(<CloudChat slug="agent" />);
await waitFor(() => expect(api.events).toHaveBeenCalled());
await emit([delta('m', { reasoning_content: '已完成分析', content: '已生成部分结果' })], '1-0');
const implementation = api.call.getMockImplementation()!;
api.call.mockImplementation(async (operation, ...args) => {
if (operation === 'history') throw new Error('历史暂不可用');
return implementation(operation, ...args);
});
await act(async () => stream.dispatchEvent(new MessageEvent('end', { data: JSON.stringify({ payload: { status: 'failed' } }) })));
await screen.findByText('历史暂不可用');
expect(screen.getByText('已生成部分结果')).toBeVisible();
expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('已完成分析');
});
it('leaves ordinary answers without an empty thinking panel', async () => {
history = { ...history, run: { ...run, status: 'completed' }, messages: [
{ id: 1, role: 'assistant', content: '普通回答', request_id: 'request', run_id: 'run', created_at: '' },
] };
render(<CloudChat slug="agent" />);
await screen.findByText('普通回答');
expect(screen.queryByTestId('cloud-message-process')).not.toBeInTheDocument();
});
it('keeps the current full stream while a stale partial history is waiting for terminal refresh', async () => {
history = { ...history, messages: [{ id: 1, message_id: 'm', role: 'assistant', content: '旧片段',
request_id: 'request', run_id: 'run', created_at: '' }] };
render(<CloudChat slug="agent" />);
await waitFor(() => expect(api.events).toHaveBeenCalled());
await emit([delta('m', { content: '完整的新答案', reasoning_content: '已完成的思考' })], '200-1');
expect(screen.queryByText('旧片段')).not.toBeInTheDocument();
const implementation = api.call.getMockImplementation()!;
let finish!: (value: CloudHistory) => void;
api.call.mockImplementation((operation, ...args) => operation === 'history'
? new Promise<CloudHistory>(resolve => { finish = resolve; }) : implementation(operation, ...args));
await act(async () => stream.dispatchEvent(new MessageEvent('end', { data: JSON.stringify({ payload: { status: 'completed' } }) })));
expect(screen.getByText('完整的新答案')).toBeVisible();
expect(screen.queryByText('旧片段')).not.toBeInTheDocument();
await act(async () => finish({ ...history, run: { ...run, status: 'completed' }, messages: [{
...history.messages[0], content: '完整的新答案', reasoning_content: '已完成的思考',
}] }));
expect(screen.getAllByText('完整的新答案')).toHaveLength(1);
});
it('uses effective execution input once each tool starts, including empty arguments', () => {
let state: CloudStream = { runId: 'run', cursor: '', messages: [] };
state = reduceCloudStream(state, { payload: { items: [
{ stream_event: { type: 'tool_call', message_id: 'm', tool_call_id: 'search', name: '查资料', args: { query: '模型参数' }, index: 0 } },
{ stream_event: { type: 'tool_call', message_id: 'm', tool_call_id: 'other', name: '其他工具', args: { unused: true }, index: 1 } },
] } }, 'thread', '1-0');
state = reduceCloudStream(state, { payload: { items: [
{ event: { method: 'tools', data: { event: 'tool-started', tool_call_id: 'search', input: { query: '实际执行参数' } } } },
{ event: { method: 'tools', data: { event: 'tool-started', tool_call_id: 'other', input: {} } } },
] } }, 'thread', '2-0');
expect(state.messages[0].tool_calls).toMatchObject([
{ id: 'search', args: { query: '实际执行参数' }, status: 'running' },
{ id: 'other', args: {}, status: 'running' },
]);
expect(state.messages[0].tool_calls[1].args).toEqual({});
state = reduceCloudStream(state, { payload: { chunk: {
stream_event: { type: 'tool_call', message_id: 'm', tool_call_id: 'search', args: { query: '模型参数' }, index: 0 },
} } }, 'thread', '3-0');
expect(state.messages[0].tool_calls[0].args).toEqual({ query: '实际执行参数' });
});
it('keeps Redis cursor precision while ignoring other runs', () => {
let state: CloudStream = { runId: 'run', cursor: '', messages: [] };
const event = { run_id: 'run', thread_id: 'thread', payload: { chunk: delta('m', { content: 'A' }) } };
state = reduceCloudStream(state, event, 'thread', '9007199254740993-0');
expect(reduceCloudStream(state, event, 'thread', '9007199254740992-1')).toBe(state);
expect(reduceCloudStream(state, { ...event, run_id: 'other' }, 'thread', '9007199254740994-0')).toBe(state);
});