Simplify Agent chat with inline attachments and separate thinking
This commit is contained in:
1 parent
7af43acebb
commit
faebfe9e84
18 files changed
+1558
-195
No files matched your search
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user