Simplify Agent chat with inline attachments and separate thinking

This commit is contained in:
鲨鱼辣椒 committed 2026-10-12 21:57:09 +08:00
1 parent 7af43acebb
commit faebfe9e84
18 files changed
+1558 -195

No files matched your search

+176 -24
View File
@@ -1,13 +1,13 @@
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';
import type { CloudAttachment, 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 real CloudChat, Markdown renderer and attachment composer 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 () => {
@@ -25,15 +25,13 @@ test.beforeAll(async () => {
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>} />
examples={examples} />
</main>
);
`;
@@ -91,31 +89,42 @@ function makeHistory(thread: CloudThread, dense = false, run: CloudRun | null =
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 FixtureStream = EventTarget & { readyState: number; url: string };
type FixtureState = FixtureSeed & { calls: FixtureCall[]; attachments: CloudAttachment[]; streams: FixtureStream[] };
type FixtureWindow = Window & { __cloudChatFixture: FixtureState };
type FixtureKind = 'empty' | 'history' | 'running' | 'approval' | 'thinking';
async function installFixture(page: Page, kind: 'empty' | 'history' | 'running' | 'approval') {
const first = makeThread('story-first', '小狐狸的故事', kind === 'running' ? 'running' : kind === 'approval' ? 'interrupted' : 'completed');
async function installFixture(page: Page, kind: FixtureKind) {
const isRunning = kind === 'running' || kind === 'thinking';
const first = makeThread('story-first', '小狐狸的故事', isRunning ? '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 run = isRunning ? 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),
[first.thread_id]: kind === 'thinking' ? { ...makeHistory(first, false, run), messages: [] } : 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 };
scope.__cloudChatFixture = { ...seed, calls: [], attachments: [{ file_id: 'attachment-story', file_name: '我的故事笔记与新发现.txt', file_size: 4096 }], streams: [] };
const state = scope.__cloudChatFixture;
const persisted = sessionStorage.getItem('cloud-chat-fixture-durable');
if (persisted) {
const { history, thread } = JSON.parse(persisted) as { history: CloudHistory; thread: CloudThread };
state.histories[thread.thread_id] = history;
state.threads[0] = thread;
}
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;
url: string;
onopen: ((event: Event) => void) | null = null;
onerror: ((event: Event) => void) | null = null;
constructor(url: string) { super(); this.url = url; state.streams.push(this); }
close() { this.readyState = 2; }
}
Object.defineProperty(window, 'EventSource', { value: FixtureEventSource });
@@ -130,6 +139,7 @@ async function installFixture(page: Page, kind: 'empty' | 'history' | 'running'
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/attachments/pick') return result({ object_name: 'temporary/狐狸照片.png', file_name: '狐狸照片.png', file_type: 'png', parse_supported: true, parse_methods: ['disable', 'rapid_ocr'] });
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;
@@ -139,8 +149,13 @@ async function installFixture(page: Page, kind: 'empty' | 'history' | 'running'
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 'attachments': return result({ attachments: state.attachments });
case 'confirmAttachment': {
const attachment = { file_id: 'attachment-photo', file_name: '狐狸照片.png', file_size: 8192 };
if (!state.attachments.some(item => item.file_id === attachment.file_id)) state.attachments.push(attachment);
return result({ attachments: [attachment] });
}
case 'files': return result({ files: [{ name: '故事笔记.md', is_dir: false, path: '/故事笔记.md', size: 1024 }] });
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': {
@@ -160,7 +175,7 @@ async function installFixture(page: Page, kind: 'empty' | 'history' | 'running'
});
}
async function openChat(page: Page, kind: 'empty' | 'history' | 'running' | 'approval', width: number, height: number) {
async function openChat(page: Page, kind: FixtureKind, width: number, height: number) {
const errors: string[] = [];
page.on('pageerror', error => errors.push(error.message));
await page.setViewportSize({ width, height });
@@ -169,6 +184,10 @@ async function openChat(page: Page, kind: 'empty' | 'history' | 'running' | 'app
await expect(page.getByRole('region', { name: '智能体对话', exact: true })).toBeVisible();
await expect(page.getByLabel('消息', { exact: true })).toBeEnabled();
await expect(page.getByText('正在读取对话…', { exact: true })).toHaveCount(0);
await expect(page.locator('.agent-chat-toolbar')).toHaveCount(0);
await expect(page.getByRole('button', { name: /^(已保存|已应用|调整智能体|查看历史对话|新对话|客户端对话|微信对话)$/ })).toHaveCount(0);
await expect(page.getByRole('navigation', { name: '对话来源', exact: true })).toHaveCount(0);
await expect(page.getByText(/由创建者支付词元点数|和助手聊一聊/)).toHaveCount(0);
return errors;
}
@@ -202,6 +221,14 @@ async function screenshot(page: Page, name: string) {
await test.info().attach(name, { path, contentType: 'image/png' });
}
async function emitRunEvent(page: Page, type: 'messages' | 'end', id: string, envelope: Record<string, unknown>) {
await page.evaluate(({ type, id, envelope }) => {
const stream = (window as unknown as FixtureWindow).__cloudChatFixture.streams.filter(item => item.readyState === 1).at(-1);
if (!stream) throw new Error('Missing active fixture stream');
stream.dispatchEvent(new MessageEvent(type, { lastEventId: id, data: JSON.stringify(envelope) }));
}, { type, id, envelope });
}
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);
@@ -228,6 +255,13 @@ for (const viewport of [{ width: 375, height: 540 }, { width: 1440, height: 900
const transcript = page.getByTestId('cloud-chat-messages');
const composer = page.getByTestId('cloud-chat-composer');
await expect(transcript.getByRole('article')).toHaveCount(16);
const userBubble = transcript.locator('.agent-chat-message[data-role="user"]').first();
const bubbleStyle = await userBubble.evaluate(element => {
const style = getComputedStyle(element);
return { background: style.backgroundColor, radius: Number.parseFloat(style.borderTopLeftRadius) };
});
expect(bubbleStyle.background).not.toBe('rgba(0, 0, 0, 0)');
expect(bubbleStyle.radius).toBeGreaterThan(0);
await expect(transcript.locator('pre')).toHaveCount(1);
await expect(transcript.locator('table')).toHaveCount(1);
await expectComposerBounds(page);
@@ -244,26 +278,58 @@ for (const viewport of [{ width: 375, height: 540 }, { width: 1440, height: 900
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 expect(page.getByRole('button', { name: '查看历史对话', exact: true })).toHaveCount(0);
await expect(page.getByLabel('历史对话', { exact: true })).toHaveCount(0);
await expect(transcript.getByRole('article')).toHaveCount(16);
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 page.getByRole('button', { name: '对话文件', exact: true }).click();
await expect(page.getByRole('button', { name: '添加文件或图片', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: '保存 故事笔记.md', exact: true })).toBeVisible();
const chosen = page.getByRole('checkbox', { name: '选择附件 我的故事笔记与新发现.txt', exact: true });
await chosen.check();
await expect(composer.getByRole('button', { name: '移除附件 我的故事笔记与新发现.txt', exact: true })).toBeVisible();
await composer.getByRole('button', { name: '移除附件 我的故事笔记与新发现.txt', exact: true }).click();
await expect(chosen).not.toBeChecked();
await expect(composer.locator('.agent-attachment-selected')).toHaveCount(0);
expect((await operations(page)).some(item => item?.operation === 'deleteAttachment')).toBe(false);
await page.getByRole('button', { name: '对话文件', exact: true }).click();
await expectComposerBounds(page);
expect((await operations(page)).some(item => ['submit', 'preview', 'resume'].includes(String(item?.operation)))).toBe(false);
expect(errors).toEqual([]);
});
}
for (const width of [375, 1440]) {
test(`a picked image is added inline and can be deselected without deleting it at ${width}px`, async ({ page }) => {
const errors = await openChat(page, 'history', width, width === 375 ? 640 : 900);
const composer = page.getByTestId('cloud-chat-composer');
const send = composer.getByRole('button', { name: '发送', exact: true });
await expect(send).toBeDisabled();
await composer.getByRole('button', { name: '添加文件或图片', exact: true }).click();
const remove = composer.getByRole('button', { name: '移除附件 狐狸照片.png', exact: true });
await expect(remove).toBeVisible();
await expect(composer.locator('.agent-attachment-card[data-kind="image"]')).toHaveCount(1);
await expect(composer.locator('.agent-attachment-card')).toHaveText(/狐狸照片\.png/);
await expect(send).toBeEnabled();
const confirms = (await operations(page)).filter(item => item?.operation === 'confirmAttachment');
expect(confirms).toHaveLength(1);
expect(confirms[0]?.input).toEqual({ thread_id: 'story-first', attachments: [{ object_name: 'temporary/狐狸照片.png', file_type: 'png' }] });
expect((await operations(page)).some(item => item?.operation === 'parseAttachment')).toBe(false);
await expectComposerBounds(page);
await screenshot(page, `agent-chat-inline-image-${width}`);
await remove.click();
await expect(composer.locator('.agent-attachment-selected')).toHaveCount(0);
await expect(send).toBeDisabled();
await composer.getByRole('button', { name: '对话文件', exact: true }).click();
await expect(composer.getByRole('checkbox', { name: '选择附件 狐狸照片.png', exact: true })).not.toBeChecked();
expect((await operations(page)).some(item => ['deleteAttachment', 'submit', 'preview'].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 });
const stop = page.getByRole('button', { name: '停止回答', exact: true });
await expect(stop).toBeInViewport({ ratio: 1 });
await expectComposerBounds(page);
await screenshot(page, 'agent-chat-running-375');
@@ -273,6 +339,92 @@ test('running conversation keeps Stop reachable on a short narrow screen', async
expect(errors).toEqual([]);
});
for (const viewport of [{ width: 375, height: 540 }, { width: 1440, height: 900 }]) {
test(`reasoning streams separately and becomes collapsed durable history at ${viewport.width}×${viewport.height}`, async ({ page }) => {
const errors = await openChat(page, 'thinking', viewport.width, viewport.height);
const transcript = page.getByTestId('cloud-chat-messages');
const thinking = page.getByTestId('cloud-chat-reasoning');
const envelope = { thread_id: 'story-first', run_id: 'run-story-first' };
const prefix = '先观察故事里的小发现。\n';
const reasoning = prefix + Array.from({ length: 40 }, (_, index) =>
`第 ${index + 1} 个线索:${longWord},再想一想角色会怎样解决。`).join('\n');
const answer = '小狐狸发现了会发光的故事笔记,决定邀请伙伴一起探索。';
const delta = (fields: Record<string, unknown>) => ({ stream_event: {
type: 'message_delta', message_id: 'assistant-story-first', ...fields,
} });
await expect.poll(() => page.evaluate(() => (window as unknown as FixtureWindow).__cloudChatFixture.streams.length)).toBe(1);
await expect(thinking).toHaveCount(0);
// Child Agent and unrelated run events never become visible in this chat.
for (const wrong of [{ thread_id: 'child-story', run_id: envelope.run_id }, { ...envelope, run_id: 'run-other' }]) {
await emitRunEvent(page, 'messages', '1000-0', { ...wrong, payload: { items: [delta({ reasoning_content: '不属于当前对话的思考' })] } });
}
await expect(thinking).toHaveCount(0);
await emitRunEvent(page, 'messages', '1001-0', { ...envelope, payload: { items: [delta({ reasoning_content: prefix })] } });
await expect(thinking).toHaveAttribute('open');
await expect(thinking).toContainText(prefix.trim());
await expect(page.locator('.agent-chat-welcome')).toHaveCount(0);
await expect(transcript.getByText(answer, { exact: true })).toHaveCount(0);
// Replayed SSE IDs must not duplicate the thought or replace the answer.
await emitRunEvent(page, 'messages', '1001-0', { ...envelope, payload: { items: [delta({ reasoning_content: '重复事件不应显示' })] } });
await emitRunEvent(page, 'messages', '1002-0', { ...envelope, payload: { chunk: delta({
additional_reasoning_content: reasoning.slice(prefix.length),
}) } });
await emitRunEvent(page, 'messages', '1003-0', { ...envelope, payload: { items: [delta({ content: answer })] } });
await expect(thinking).toContainText(reasoning);
await expect(transcript).not.toContainText('重复事件不应显示');
await expect(transcript).not.toContainText('不属于当前对话的思考');
await expect(transcript.getByText(answer, { exact: true })).toHaveCount(1);
expect(await thinking.locator('pre, table, a').count()).toBe(0);
await expectComposerBounds(page);
await screenshot(page, `agent-chat-reasoning-live-${viewport.width}`);
const durable = await page.evaluate(({ reasoning, answer, date }) => {
const state = (window as unknown as FixtureWindow).__cloudChatFixture;
const history = state.histories['story-first'];
if (!history.run) throw new Error('Missing reasoning fixture run');
history.run = { ...history.run, status: 'completed', output: answer };
history.messages = [{ id: 1, role: 'assistant', content: answer, reasoning_content: reasoning,
request_id: history.run.request_id, run_id: history.run.agent_run_id, created_at: date }];
state.threads[0] = { ...state.threads[0], status: 'completed' };
return { history, thread: state.threads[0] };
}, { reasoning, answer, date });
// Native terminal events can reuse the last message ID and still finish a run.
await emitRunEvent(page, 'end', '1003-0', { ...envelope, payload: { chunk: { status: 'completed' } } });
await expect(page.getByRole('button', { name: '停止回答', exact: true })).toHaveCount(0);
await expect(thinking).toHaveCount(1);
await expect(thinking).not.toHaveAttribute('open');
await expect(transcript.getByRole('article')).toHaveCount(1);
await expect(transcript.getByText(answer, { exact: true })).toHaveCount(1);
await thinking.locator('summary').scrollIntoViewIfNeeded();
await thinking.locator('summary').focus();
await page.keyboard.press('Enter');
await expect(thinking).toHaveAttribute('open');
await expect(thinking).toContainText(reasoning);
await expectComposerBounds(page);
await screenshot(page, `agent-chat-reasoning-history-${viewport.width}`);
await thinking.locator('summary').click();
await expect(thinking).not.toHaveAttribute('open');
// Reopening restores the durable conversation without bringing back history controls.
await page.evaluate(saved => sessionStorage.setItem('cloud-chat-fixture-durable', JSON.stringify(saved)), durable);
await page.reload();
await expect(thinking).toHaveCount(1);
await expect(thinking).not.toHaveAttribute('open');
await expect(page.getByRole('button', { name: '查看历史对话', exact: true })).toHaveCount(0);
await expect(page.getByLabel('历史对话', { exact: true })).toHaveCount(0);
await thinking.locator('summary').click();
await expect(thinking).toContainText(reasoning);
await expect(transcript.getByText(answer, { exact: true })).toHaveCount(1);
await expect(transcript.getByRole('article')).toHaveCount(1);
await expectComposerBounds(page);
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 });