Restore Agent workspace and add platform pixel avatars

This commit is contained in:
鲨鱼辣椒 committed 2026-10-12 17:05:24 +08:00
1 parent 7a7d1f7fd8
commit c14d996c05
22 files changed
+1387 -462

No files matched your search

+237 -132
View File
@@ -1,10 +1,10 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import CloudAgents from '@/pages/CloudAgents';
import { EMPTY_CLOUD_CONFIGURATION } from '../../shared/cloud-agents';
const api = vi.hoisted(() => ({ list: vi.fn(), create: vi.fn(), get: vi.fn(), save: vi.fn(), uploadAvatar: vi.fn(), call: vi.fn(), events: vi.fn(), recovery: vi.fn(), remember: vi.fn(), resolvePending: vi.fn() }));
const api = vi.hoisted(() => ({ list: vi.fn(), create: vi.fn(), uploadAvatar: vi.fn(), get: vi.fn(), save: vi.fn(), call: vi.fn(), events: vi.fn(), recovery: vi.fn(), remember: vi.fn(), resolvePending: vi.fn() }));
const auth = vi.hoisted(() => ({ user: { userId: 'creator' } }));
vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api }));
vi.mock('@/stores/auth', () => ({ useAuthStore: (selector: (state: typeof auth) => unknown) => selector(auth) }));
@@ -36,30 +36,6 @@ beforeEach(() => {
});
afterEach(cleanup);
it('loads the Yuxi avatar, saves a replacement as draft data, and persists explicit removal', async () => {
let current = { ...draft, icon: '/old.png' as string | null, icon_url: 'https://agents.test/old.png' as string | null };
api.list.mockImplementation(async () => ({ agents: [current], next_cursor: null }));
api.get.mockImplementation(async () => current);
api.save.mockImplementation(async (_slug, input) => (current = { ...current, ...input,
icon_url: input.icon ? 'https://agents.test' + input.icon : null, draft_revision: current.draft_revision + 1 }));
api.uploadAvatar.mockResolvedValue({ icon: '/new.png', icon_url: 'https://agents.test/new.png' });
open();
expect(await screen.findByRole('img', { name: '写作搭档头像' })).toHaveAttribute('src', 'https://agents.test/old.png');
fireEvent.click(screen.getByText('写作搭档'));
fireEvent.click(await screen.findByRole('button', { name: '更换头像' }));
await waitFor(() => expect(screen.getByRole('button', { name: '保存草稿' })).toBeEnabled());
expect(api.save).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '保存草稿' }));
await waitFor(() => expect(api.save).toHaveBeenCalledWith(draft.slug, expect.objectContaining({ icon: '/new.png', expected_revision: 1 })));
expect(api.save.mock.calls[0][1]).not.toHaveProperty('icon_url');
await screen.findByText('云端已保存');
fireEvent.click(screen.getByRole('button', { name: '移除头像' }));
fireEvent.click(screen.getByRole('button', { name: '保存草稿' }));
await waitFor(() => expect(api.save).toHaveBeenLastCalledWith(draft.slug, expect.objectContaining({ icon: null, expected_revision: 2 })));
await screen.findByText('云端已保存');
expect(screen.getAllByRole('img', { name: '写作搭档默认头像' })).toHaveLength(2);
});
it('does not label disabled or archived assistants as ready to use', async () => {
api.list.mockResolvedValue({ agents: [
{ ...draft, published_version: 1, enabled: false },
@@ -107,16 +83,22 @@ it('shows saved but unapplied changes and applies directly from the editor heade
['publish', expect.objectContaining({ slug: draft.slug, expected_revision: 4 })],
]);
expect(screen.queryByText('修改已保存,尚未应用')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '编辑', exact: true })).toHaveAttribute('aria-current', 'page');
expect(screen.getByRole('button', { name: '配置', exact: true })).toHaveAttribute('aria-current', 'page');
});
it('uses the published draft revision, requires saving edits, and keeps trial inputs when applying', async () => {
it('requires saving before applying and preserves formal chat input across configuration and application', async () => {
publishedAssistant(4);
open();
await screen.findByTestId('cloud-agent-editor');
await waitFor(() => expect(screen.getByRole('button', { name: '已应用', exact: true })).toBeDisabled());
fireEvent.change(screen.getByLabelText('消息'), { target: { value: '保留试用问题' } });
fireEvent.change(screen.getByLabelText('特别要求'), { target: { value: '新的要求' } });
expect(screen.queryByLabelText('消息')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '聊天', exact: true }));
const message = await screen.findByLabelText('消息');
await waitFor(() => expect(message).toBeEnabled());
fireEvent.change(message, { target: { value: '保留正式聊天的问题' } });
fireEvent.click(screen.getByRole('button', { name: '配置', exact: true }));
expect(message).not.toBeVisible();
fireEvent.change(screen.getByLabelText('提示词'), { target: { value: '新的要求' } });
expect(screen.getByText('修改尚未保存和应用')).toBeVisible();
expect(screen.getByRole('button', { name: '应用最新修改', exact: true })).toBeDisabled();
fireEvent.click(screen.getByRole('button', { name: '保存草稿' }));
@@ -124,8 +106,12 @@ it('uses the published draft revision, requires saving edits, and keeps trial in
expect(api.call.mock.calls.some(([operation]) => operation === 'publish')).toBe(false);
fireEvent.click(screen.getByRole('button', { name: '应用最新修改', exact: true }));
await waitFor(() => expect(screen.getByRole('button', { name: '已应用', exact: true })).toBeDisabled());
expect(screen.getByLabelText('消息')).toHaveValue('保留试用问题');
expect(screen.getByRole('button', { name: '配置', exact: true })).toHaveAttribute('aria-current', 'page');
fireEvent.click(screen.getByRole('button', { name: '聊天', exact: true }));
expect(screen.getByLabelText('消息')).toBe(message);
expect(message).toHaveValue('保留正式聊天的问题');
expect(api.call.mock.calls.filter(([operation]) => operation === 'publish')[0][1]).toMatchObject({ expected_revision: 5 });
expect(api.call.mock.calls.some(([operation]) => operation === 'preview' || operation === 'submit')).toBe(false);
});
it('shares the original failed publication between the header and access page', async () => {
@@ -190,53 +176,171 @@ it('keeps a successful application confirmed when its following status refresh f
expect(api.call.mock.calls.filter(([operation]) => operation === 'publish')).toHaveLength(1);
});
it('protects a custom requirement before a name or purpose has been entered', async () => {
it('protects an uploaded avatar before a name has been entered', async () => {
const avatar = { icon: '/api/storage/public/images/avatar.png', icon_url: 'https://images.test/avatar.png' };
api.uploadAvatar.mockResolvedValue(avatar);
const { router } = open();
fireEvent.click(await screen.findByRole('button', { name: '创建智能体', exact: true }));
fireEvent.click(screen.getByRole('button', { name: '我自己想一个' }));
fireEvent.change(screen.getByLabelText('特别要求'), { target: { value: '请先听我说完' } });
fireEvent.click(screen.getByRole('button', { name: '上传头像' }));
await waitFor(() => expect(screen.getByRole('img', { name: '智能体头像' })).toHaveAttribute('src', avatar.icon_url));
expect(screen.getByRole('button', { name: '下一步', exact: true })).toBeDisabled();
await act(async () => { await router.navigate('/module-select'); });
expect(screen.getByRole('dialog', { name: '离开创建' })).toBeVisible();
fireEvent.click(screen.getByRole('button', { name: '继续创建' }));
expect(screen.getByLabelText('特别要求')).toHaveValue('请先听我说完');
expect(screen.getByRole('img', { name: '智能体头像' })).toHaveAttribute('src', avatar.icon_url);
expect(screen.getByLabelText('名称')).toHaveValue('');
expect(api.create).not.toHaveBeenCalled();
});
it('creates from an editable starter, preserves chosen requirements and defaults only a new assistant model', async () => {
it('creates from name and avatar, then configures model and raw prompt without a preview chat', async () => {
const avatar = { icon: '/api/storage/public/images/avatar.png', icon_url: 'https://images.test/avatar.png' };
const base = api.call.getMockImplementation()!;
api.call.mockImplementation(async (operation, input) => operation === 'catalog' ? {
models: [{ id: 'model-first', name: '可用模型甲' }, { id: 'model-second', name: '可用模型乙' }], resources: {}, pricing: null,
models: [{ id: 'model-first', name: '可用模型甲' }, { id: 'model-second', name: '可用模型乙' }], resources: {},
pricing: { unit_size: 1000, points_per_unit: '1.23' },
} : base(operation, input));
api.create.mockImplementation(async input => ({ ...draft, ...input, system_prompt: input.purpose }));
api.save.mockImplementation(async (_slug, input) => ({ ...draft, ...input, draft_revision: 2 }));
api.uploadAvatar.mockResolvedValue(avatar);
api.create.mockImplementation(async input => ({ ...draft, ...input, icon_url: avatar.icon_url, system_prompt: input.purpose }));
api.save.mockImplementation(async (_slug, input) => ({ ...draft, ...input, icon_url: avatar.icon_url, draft_revision: 2 }));
open();
fireEvent.click(await screen.findByRole('button', { name: '创建智能体', exact: true }));
fireEvent.click(screen.getByRole('button', { name: /科学问答助手/ }));
fireEvent.click(screen.getByRole('button', { name: '先给我提示' }));
fireEvent.change(screen.getByLabelText('特别要求'), { target: { value: '一次只解释一个新词。' } });
fireEvent.click(screen.getByRole('button', { name: '下一步:试一试' }));
expect(screen.getByRole('heading', { name: '创建智能体', exact: true })).toBeVisible();
expect(screen.getByLabelText('名称')).toHaveValue('');
expect(screen.getByRole('group', { name: '智能体头像' })).toBeVisible();
expect(screen.queryByLabelText('用途')).not.toBeInTheDocument();
expect(screen.queryByLabelText('提示词')).not.toBeInTheDocument();
expect(screen.queryByLabelText('模型')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /科学问答助手|故事搭档|英语陪练|整理小帮手|先给我提示/ })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '下一步', exact: true })).toBeDisabled();
expect(api.create).not.toHaveBeenCalled();
expect(api.call.mock.calls.some(([operation]) => operation === 'catalog')).toBe(false);
fireEvent.change(screen.getByLabelText('名称'), { target: { value: ' 我的写作助手 ' } });
expect(screen.getByRole('button', { name: '下一步', exact: true })).toBeEnabled();
fireEvent.click(screen.getByRole('button', { name: '上传头像' }));
await screen.findByRole('button', { name: '更换头像' });
fireEvent.click(screen.getByRole('button', { name: '下一步', exact: true }));
await screen.findByTestId('cloud-agent-editor');
expect(screen.getByLabelText('名称')).toHaveValue('我的写作助手');
expect(screen.getByRole('img', { name: '我的写作助手头像' })).toHaveAttribute('src', avatar.icon_url);
expect(api.create).toHaveBeenCalledWith({ operation_id: expect.any(String), name: '我的写作助手', purpose: '按照我的提示词提供帮助。', icon: avatar.icon });
expect(api.create.mock.calls[0][0]).not.toHaveProperty('icon_url');
await waitFor(() => expect(screen.getByLabelText('模型')).toHaveValue('model-first'));
expect(screen.getByLabelText('特别要求')).toHaveValue('一次只解释一个新词。');
expect(screen.getByRole('button', { name: '先给我提示' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.queryByRole('button', { name: '能力' })).not.toBeInTheDocument();
expect(screen.getByLabelText('提示词')).toHaveValue('');
expect(screen.queryByLabelText('用途')).not.toBeInTheDocument();
expect(screen.queryByLabelText('消息')).not.toBeInTheDocument();
expect(screen.queryByRole('separator', { name: '调整配置与试用宽度' })).not.toBeInTheDocument();
expect(screen.queryByText(/这是帮助它思考和回答的 AI|新助手从目录中的第一个可用模型开始|选择模型后,保存即可试用/)).not.toBeInTheDocument();
expect(screen.queryByText(/每 .*个词元约 .*词元点数/)).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /简单易懂|一步一步来|先给我提示/ })).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText('模型'), { target: { value: '' } });
fireEvent.change(screen.getByLabelText('名称'), { target: { value: '我的科学助手' } });
fireEvent.change(screen.getByLabelText('名称'), { target: { value: '我的文章搭档' } });
expect(screen.getByLabelText('模型')).toHaveValue('');
expect(screen.getByRole('button', { name: '保存并开始试用' })).toBeDisabled();
fireEvent.change(screen.getByLabelText('模型'), { target: { value: 'model-second' } });
fireEvent.click(screen.getByRole('button', { name: '保存并开始试用' }));
await waitFor(() => expect(screen.getByLabelText('消息')).toBeEnabled());
const prompt = '一次只解释一个新词。\n\n 保留缩进。\n使用简单易懂的语言,遇到新词先解释。\n';
fireEvent.change(screen.getByLabelText('提示词'), { target: { value: prompt } });
expect(api.save).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '保存草稿' }));
await screen.findByRole('button', { name: '已保存' });
expect(api.save).toHaveBeenCalledWith(draft.slug, expect.objectContaining({
name: '我的科学助手', system_prompt: '一次只解释一个新词。\n先给我提示和思考问题,让我尝试后再补充答案。',
name: '我的文章搭档', system_prompt: prompt, icon: avatar.icon,
configuration: expect.objectContaining({ model: 'model-second' }),
}));
fireEvent.click(screen.getByRole('button', { name: '为什么雨后会出现彩虹?' }));
expect(screen.getByLabelText('消息')).toHaveValue('为什么雨后会出现彩虹?');
expect(api.call.mock.calls.some(([operation]) => operation === 'preview' || operation === 'submit')).toBe(false);
expect(api.save.mock.calls[0][1]).not.toHaveProperty('icon_url');
expect(screen.getByLabelText('提示词')).toHaveValue(prompt);
expect(api.call.mock.calls.some(([operation]) => ['threads', 'preview', 'submit', 'publish'].includes(operation))).toBe(false);
});
it('allows name-only creation after avatar cancellation or failure', async () => {
api.uploadAvatar.mockResolvedValueOnce(null).mockRejectedValueOnce(new Error('头像上传失败'));
api.create.mockImplementation(async input => ({ ...draft, ...input }));
open();
fireEvent.click(await screen.findByRole('button', { name: '创建智能体', exact: true }));
fireEvent.change(screen.getByLabelText('名称'), { target: { value: '写作搭档' } });
fireEvent.click(screen.getByRole('button', { name: '上传头像' }));
await waitFor(() => expect(screen.getByRole('button', { name: '上传头像' })).toBeEnabled());
fireEvent.click(screen.getByRole('button', { name: '上传头像' }));
expect(await screen.findByRole('alert')).toHaveTextContent('头像上传失败');
expect(screen.getByRole('button', { name: '下一步', exact: true })).toBeEnabled();
fireEvent.click(screen.getByRole('button', { name: '下一步', exact: true }));
await screen.findByTestId('cloud-agent-editor');
expect(api.create).toHaveBeenCalledWith(expect.objectContaining({ name: '写作搭档', icon: null }));
expect(screen.getByRole('img', { name: '写作搭档默认头像' })).toBeVisible();
});
it('blocks creation and leaving while an avatar upload is still pending', async () => {
let finish!: (value: null) => void;
api.uploadAvatar.mockReturnValue(new Promise(resolve => { finish = resolve; }));
const { router } = open();
fireEvent.click(await screen.findByRole('button', { name: '创建智能体', exact: true }));
fireEvent.change(screen.getByLabelText('名称'), { target: { value: '写作搭档' } });
fireEvent.click(screen.getByRole('button', { name: '上传头像' }));
expect(screen.getByRole('button', { name: '正在上传…' })).toBeDisabled();
expect(screen.getByLabelText('名称')).toBeDisabled();
expect(screen.getByRole('button', { name: '下一步', exact: true })).toBeDisabled();
expect(screen.getByRole('button', { name: '取消', exact: true })).toBeDisabled();
fireEvent.submit(screen.getByRole('button', { name: '下一步', exact: true }).closest('form')!);
expect(api.create).not.toHaveBeenCalled();
const unload = new Event('beforeunload', { cancelable: true });
window.dispatchEvent(unload);
expect(unload.defaultPrevented).toBe(true);
await act(async () => { await router.navigate('/module-select'); });
expect(screen.getByRole('dialog', { name: '离开创建' })).toBeVisible();
expect(screen.getByRole('button', { name: '离开', exact: true })).toBeDisabled();
await act(async () => finish(null));
fireEvent.click(screen.getByRole('button', { name: '继续创建' }));
expect(screen.getByRole('button', { name: '下一步', exact: true })).toBeEnabled();
expect(screen.getByLabelText('名称')).toHaveValue('写作搭档');
});
it('ignores an avatar upload completed after an account switch', async () => {
let finish!: (value: { icon: string; icon_url: string }) => void;
api.uploadAvatar.mockReturnValue(new Promise(resolve => { finish = resolve; }));
const view = open();
fireEvent.click(await screen.findByRole('button', { name: '创建智能体', exact: true }));
fireEvent.click(screen.getByRole('button', { name: '上传头像' }));
auth.user.userId = 'other';
api.list.mockResolvedValue({ agents: [], next_cursor: null });
view.rerender(<RouterProvider key="other-avatar-account" router={view.router} />);
await screen.findByText('做一个属于你的 AI 助手吧');
await act(async () => finish({ icon: '/old-account.png', icon_url: 'https://images.test/old-account.png' }));
fireEvent.click(screen.getByRole('button', { name: '创建我的第一个智能体' }));
expect(screen.getByRole('img', { name: '智能体默认头像' })).toBeVisible();
expect(screen.queryByRole('button', { name: '更换头像' })).not.toBeInTheDocument();
expect(api.create).not.toHaveBeenCalled();
});
it('treats an avatar change as a manual draft edit and blocks saving during its upload', async () => {
publishedAssistant();
let finish!: (value: { icon: string; icon_url: string }) => void;
const avatar = { icon: '/api/storage/public/images/edit.png', icon_url: 'https://images.test/edit.png' };
api.uploadAvatar.mockReturnValue(new Promise(resolve => { finish = resolve; }));
open();
await screen.findByText('修改已保存,尚未应用');
fireEvent.click(screen.getByRole('button', { name: '修改头像', exact: true }));
fireEvent.click(screen.getByRole('button', { name: '上传头像' }));
const editor = within(screen.getByTestId('cloud-agent-editor'));
expect(editor.getByRole('button', { name: '我的智能体', hidden: true })).toBeDisabled();
expect(editor.getByRole('button', { name: '应用最新修改', hidden: true })).toBeDisabled();
expect(editor.getByRole('button', { name: '已保存', hidden: true })).toBeDisabled();
expect(screen.getByRole('button', { name: '完成', exact: true })).toBeDisabled();
const unload = new Event('beforeunload', { cancelable: true });
window.dispatchEvent(unload);
expect(unload.defaultPrevented).toBe(true);
await act(async () => finish(avatar));
fireEvent.click(screen.getByRole('button', { name: '完成', exact: true }));
expect(screen.getByRole('img', { name: '写作搭档头像' })).toHaveAttribute('src', avatar.icon_url);
expect(screen.getByRole('button', { name: '保存草稿' })).toBeEnabled();
expect(screen.getByRole('button', { name: '应用最新修改' })).toBeDisabled();
expect(api.save).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '保存草稿' }));
await screen.findByText('修改已保存,尚未应用');
expect(api.save).toHaveBeenCalledWith(draft.slug, expect.objectContaining({ icon: avatar.icon }));
expect(api.save.mock.calls[0][1]).not.toHaveProperty('icon_url');
expect(api.call.mock.calls.some(([operation]) => operation === 'publish')).toBe(false);
});
it('saves renamed existing assistants without rewriting arbitrary instructions or selecting a model', async () => {
const original = '自定义标题\n\n 保留缩进。\n简单易懂只是一个例子。\n';
const original = '自定义标题\n\n 保留缩进。\n使用简单易懂的语言,遇到新词先解释。\n把任务拆成小步骤,一次引导我完成一步。\n先给我提示和思考问题,让我尝试后再补充答案。\n';
const existing = { ...draft, system_prompt: original };
api.list.mockResolvedValue({ agents: [existing], next_cursor: null });
const base = api.call.getMockImplementation()!;
@@ -245,10 +349,10 @@ it('saves renamed existing assistants without rewriting arbitrary instructions o
} : base(operation, input));
api.save.mockImplementation(async (_slug, input) => ({ ...existing, ...input, draft_revision: 2 }));
open();
fireEvent.click(await screen.findByRole('button', { name: /写作搭档/ }));
fireEvent.click(await screen.findByRole('button', { name: '继续制作', exact: true }));
await waitFor(() => expect(screen.getByLabelText('模型')).toBeEnabled());
expect(screen.getByLabelText('模型')).toHaveValue('');
expect(screen.getByLabelText('特别要求')).toHaveValue(original);
expect(screen.getByLabelText('提示词')).toHaveValue(original);
fireEvent.change(screen.getByLabelText('名称'), { target: { value: '新名字' } });
fireEvent.click(screen.getByRole('button', { name: '保存草稿' }));
await screen.findByRole('button', { name: '已保存' });
@@ -266,13 +370,13 @@ it('retains requirements when model loading fails and offers a usable retry', as
return base(operation, input);
});
open();
fireEvent.click(await screen.findByRole('button', { name: /写作搭档/ }));
fireEvent.click(await screen.findByRole('button', { name: '继续制作', exact: true }));
await screen.findByText('目录暂不可用');
fireEvent.change(screen.getByLabelText('特别要求'), { target: { value: '保留我的想法' } });
fireEvent.change(screen.getByLabelText('提示词'), { target: { value: '保留我的想法' } });
fail = false;
fireEvent.click(screen.getByRole('button', { name: '重新读取模型' }));
await waitFor(() => expect(screen.getByLabelText('模型')).toBeEnabled());
expect(screen.getByLabelText('特别要求')).toHaveValue('保留我的想法');
expect(screen.getByLabelText('提示词')).toHaveValue('保留我的想法');
expect(screen.getByLabelText('模型')).toHaveValue('');
});
@@ -304,15 +408,13 @@ it('restores the exact previous thread and presents an uncertain operation witho
it('keeps creation input and its uncertain operation when an existing card is clicked', async () => {
api.create.mockRejectedValueOnce(new Error('连接中断')).mockResolvedValueOnce(draft);
open();
const existing = await screen.findByRole('button', { name: /写作搭档/ });
const existing = await screen.findByRole('button', { name: '继续制作', exact: true });
fireEvent.click(screen.getByRole('button', { name: '创建智能体' }));
fireEvent.click(screen.getByRole('button', { name: '我自己想一个' }));
fireEvent.change(screen.getByLabelText('名称'), { target: { value: '新搭档' } });
fireEvent.change(screen.getByLabelText('用途'), { target: { value: '新用途' } });
fireEvent.click(existing);
expect(screen.queryByTestId('cloud-agent-editor')).not.toBeInTheDocument();
expect(screen.getByLabelText('名称')).toHaveValue('新搭档');
fireEvent.click(screen.getByRole('button', { name: '下一步:试一试' }));
fireEvent.click(screen.getByRole('button', { name: '下一步' }));
await screen.findByRole('alert');
fireEvent.click(existing);
expect(screen.queryByTestId('cloud-agent-editor')).not.toBeInTheDocument();
@@ -345,31 +447,40 @@ it('offers only explicit discard for a recovery record whose channel operation w
expect(api.resolvePending).toHaveBeenCalledWith('channel-policy-old', false);
});
it('keeps the same creation operation after a lost response', async () => {
it('freezes the uploaded avatar and creation identity when retrying a lost response', async () => {
const avatar = { icon: '/api/storage/public/images/create.png', icon_url: 'https://images.test/create.png' };
api.list.mockResolvedValue({ agents: [], next_cursor: null });
api.create.mockRejectedValueOnce(new Error('连接中断')).mockResolvedValueOnce(draft);
api.uploadAvatar.mockResolvedValue(avatar);
api.create.mockRejectedValueOnce(new Error('连接中断')).mockResolvedValueOnce({ ...draft, ...avatar });
open();
fireEvent.click(await screen.findByText('创建第一个智能体'));
fireEvent.click(screen.getByRole('button', { name: '我自己想一个' }));
fireEvent.click(await screen.findByText('创建我的第一个智能体'));
fireEvent.change(screen.getByLabelText('名称'), { target: { value: '写作搭档' } });
fireEvent.change(screen.getByLabelText('用途'), { target: { value: '帮助写作' } });
fireEvent.click(screen.getByRole('button', { name: '下一步:试一试' }));
fireEvent.click(screen.getByRole('button', { name: '上传头像' }));
await screen.findByRole('button', { name: '更换头像' });
fireEvent.click(screen.getByRole('button', { name: '下一步', exact: true }));
await screen.findByRole('alert');
expect(api.create).toHaveBeenCalledTimes(1);
expect(screen.getByLabelText('名称')).toBeDisabled();
expect(screen.getByRole('button', { name: '更换头像' })).toBeDisabled();
expect(screen.getByRole('button', { name: '移除头像' })).toBeDisabled();
fireEvent.click(screen.getByRole('button', { name: '重试创建' }));
await screen.findByTestId('cloud-agent-editor');
expect(api.create).toHaveBeenCalledTimes(2);
expect(api.create.mock.calls[1][0]).toEqual(api.create.mock.calls[0][0]);
expect(api.create.mock.calls[0][0]).toEqual({ operation_id: expect.any(String), name: '写作搭档', purpose: '按照我的提示词提供帮助。', icon: avatar.icon });
expect(api.uploadAvatar).toHaveBeenCalledTimes(1);
expect(screen.getByRole('img', { name: '写作搭档头像' })).toHaveAttribute('src', avatar.icon_url);
});
it('preserves local input on conflicts and requires an explicit choice before retry', async () => {
api.save.mockRejectedValueOnce(new Error('草稿已更新'));
api.get.mockResolvedValue({ ...draft, draft_revision: 2, system_prompt: '其他设备内容' });
open();
fireEvent.click(await screen.findByRole('button', { name: /写作搭档/ }));
fireEvent.change(screen.getByLabelText('特别要求'), { target: { value: '我的修改' } });
fireEvent.click(await screen.findByRole('button', { name: '继续制作', exact: true }));
fireEvent.change(screen.getByLabelText('提示词'), { target: { value: '我的修改' } });
fireEvent.click(screen.getByRole('button', { name: '保存草稿' }));
await screen.findByText('云端已有修订 2,本地输入仍保留');
expect(screen.getByLabelText('特别要求')).toHaveValue('我的修改');
expect(screen.getByLabelText('提示词')).toHaveValue('我的修改');
expect(screen.getByRole('button', { name: '保存草稿' })).toBeDisabled();
fireEvent.click(screen.getByText('保留我的内容,继续编辑'));
api.save.mockResolvedValue({ ...draft, draft_revision: 3, system_prompt: '我的修改' });
@@ -380,7 +491,7 @@ it('preserves local input on conflicts and requires an explicit choice before re
it('blocks module navigation with unsaved edits, cancels leaving, then saves before proceeding', async () => {
const { router } = open();
fireEvent.click(await screen.findByRole('button', { name: /写作搭档/ }));
fireEvent.click(await screen.findByRole('button', { name: '继续制作', exact: true }));
fireEvent.change(screen.getByLabelText('名称'), { target: { value: '新的名称' } });
await act(async () => { await router.navigate('/module-select'); });
expect(screen.getByRole('dialog', { name: '未保存的修改' })).toBeVisible();
@@ -401,7 +512,7 @@ it('clears the previous account workspace and ignores its delayed list', async (
const view = open();
auth.user.userId = 'other';
view.rerender(<RouterProvider key="other-account" router={view.router} />);
await screen.findByText('做一个懂你的小助手');
await screen.findByText('做一个属于你的 AI 助手吧');
await act(async () => finish({ agents: [draft], next_cursor: null }));
await waitFor(() => expect(screen.queryByText('写作搭档')).not.toBeInTheDocument());
});
@@ -410,11 +521,9 @@ it('warns before leaving an uncertain creation and keeps its retry intent', asyn
api.list.mockResolvedValue({ agents: [], next_cursor: null });
api.create.mockRejectedValue(new Error('连接中断'));
const { router } = open();
fireEvent.click(await screen.findByText('创建第一个智能体'));
fireEvent.click(screen.getByRole('button', { name: '我自己想一个' }));
fireEvent.click(await screen.findByText('创建我的第一个智能体'));
fireEvent.change(screen.getByLabelText('名称'), { target: { value: '写作搭档' } });
fireEvent.change(screen.getByLabelText('用途'), { target: { value: '帮助写作' } });
fireEvent.click(screen.getByRole('button', { name: '下一步:试一试' }));
fireEvent.click(screen.getByRole('button', { name: '下一步' }));
await screen.findByRole('alert');
await act(async () => { await router.navigate('/module-select'); });
expect(screen.getByRole('dialog', { name: '离开创建' })).toBeVisible();
@@ -425,47 +534,36 @@ it('warns before leaving an uncertain creation and keeps its retry intent', asyn
});
it('keeps unsent preview text when configuration changes and waits before switching saved revision', async () => {
it('saves an existing configured draft without creating or loading a configuration chat', async () => {
const configured = { ...draft, configuration: { ...EMPTY_CLOUD_CONFIGURATION, model: 'test-model' } };
api.list.mockResolvedValue({ agents: [configured], next_cursor: null });
api.save.mockResolvedValue({ ...configured, name: '新的名称', draft_revision: 2 });
open();
fireEvent.click(await screen.findByRole('button', { name: /写作搭档/ }));
const prompt = await screen.findByPlaceholderText('输入消息或任务目标…');
fireEvent.change(prompt, { target: { value: '还未发送的问题' } });
fireEvent.click(await screen.findByRole('button', { name: '继续制作', exact: true }));
await waitFor(() => expect(screen.getByLabelText('模型')).toBeEnabled());
expect(screen.queryByLabelText('消息')).not.toBeInTheDocument();
expect(screen.queryByRole('region', { name: '智能体试用' })).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText('名称'), { target: { value: '新的名称' } });
expect(prompt).toHaveValue('还未发送的问题');
expect(screen.getByText(/要求改好了/)).toBeVisible();
await waitFor(() => expect(screen.getByRole('button', { name: '保存草稿' })).toBeEnabled());
fireEvent.click(screen.getByRole('button', { name: '保存草稿' }));
await screen.findByRole('button', { name: '已保存' });
expect(api.save).toHaveBeenCalledTimes(1);
expect(screen.getByLabelText('消息')).toBe(prompt);
expect(prompt).toHaveValue('还未发送的问题');
expect(screen.getByText('当前试用 · 修订 1')).toBeVisible();
expect(screen.getByRole('button', { name: '发送', exact: true })).toBeDisabled();
fireEvent.click(screen.getByRole('button', { name: '用最新草稿试用' }));
await waitFor(() => expect(screen.getByText('当前试用 · 修订 2')).toBeVisible());
expect(prompt).toHaveValue('还未发送的问题');
expect(screen.getByRole('button', { name: '发送', exact: true })).toBeEnabled();
expect(screen.queryByLabelText('消息')).not.toBeInTheDocument();
expect(api.call.mock.calls.some(([operation]) => ['threads', 'history', 'preview', 'submit', 'publish'].includes(operation))).toBe(false);
});
it('switches peer editor tabs while retaining the advanced category and unfinished form inputs', async () => {
api.list.mockResolvedValue({ agents: [{ ...draft, configuration: { ...EMPTY_CLOUD_CONFIGURATION, model: 'test-model' } }], next_cursor: null });
open();
fireEvent.click(await screen.findByRole('button', { name: /写作搭档/ }));
await waitFor(() => expect(screen.getByLabelText('消息')).toBeEnabled());
const requirements = screen.getByRole('tab', { name: '我的要求', exact: true });
fireEvent.click(await screen.findByRole('button', { name: '继续制作', exact: true }));
await waitFor(() => expect(screen.getByLabelText('模型')).toBeEnabled());
const requirements = screen.getByRole('tab', { name: '模型与提示词', exact: true });
const advanced = screen.getByRole('tab', { name: '高级设置', exact: true });
expect(requirements).toHaveAttribute('aria-selected', 'true');
expect(advanced).toHaveAttribute('aria-selected', 'false');
expect(screen.getByRole('tabpanel', { name: '我的要求' })).toBeVisible();
expect(screen.getByRole('tabpanel', { name: '模型与提示词' })).toBeVisible();
expect(screen.queryByRole('navigation', { name: '配置分类' })).not.toBeInTheDocument();
const instructions = screen.getByLabelText('特别要求');
const preview = screen.getByLabelText('消息');
const instructions = screen.getByLabelText('提示词');
fireEvent.change(instructions, { target: { value: '没有保存的要求' } });
fireEvent.change(preview, { target: { value: '没有发送的问题' } });
fireEvent.mouseDown(advanced, { button: 0, ctrlKey: false });
expect(advanced).toHaveAttribute('aria-selected', 'true');
@@ -473,6 +571,11 @@ it('switches peer editor tabs while retaining the advanced category and unfinish
expect(screen.getByRole('tabpanel', { name: '高级设置' })).toBeVisible();
expect(instructions).not.toBeVisible();
expect(screen.getByRole('button', { name: '能力', exact: true })).toHaveAttribute('aria-current', 'page');
fireEvent.click(screen.getByRole('button', { name: '知识', exact: true }));
fireEvent.click(screen.getByText('管理我的知识文档'));
fireEvent.click(screen.getByRole('button', { name: '新建知识库' }));
const knowledgeName = screen.getByLabelText('知识库名称');
fireEvent.change(knowledgeName, { target: { value: '还没创建的资料' } });
fireEvent.click(screen.getByRole('button', { name: '限制', exact: true }));
const budgetInput = screen.getByLabelText('每日上限(词元点数)');
await waitFor(() => expect(budgetInput).toBeEnabled());
@@ -484,64 +587,66 @@ it('switches peer editor tabs while retaining the advanced category and unfinish
fireEvent.mouseDown(requirements, { button: 0, ctrlKey: false });
expect(screen.queryByRole('navigation', { name: '配置分类' })).not.toBeInTheDocument();
expect(budgetInput).not.toBeVisible();
expect(screen.getByLabelText('特别要求')).toBe(instructions);
expect(screen.getByLabelText('提示词')).toBe(instructions);
expect(instructions).toHaveValue('没有保存的要求');
fireEvent.mouseDown(advanced, { button: 0, ctrlKey: false });
expect(screen.getByRole('button', { name: '限制', exact: true })).toHaveAttribute('aria-current', 'page');
expect(screen.getByLabelText('每日上限(词元点数)')).toBe(budgetInput);
expect(budgetInput).toHaveValue(12.5);
expect(screen.getByLabelText('消息')).toBe(preview);
expect(preview).toHaveValue('没有发送的问题');
fireEvent.click(screen.getByRole('button', { name: '知识', exact: true }));
expect(screen.getByLabelText('知识库名称')).toBe(knowledgeName);
expect(knowledgeName).toHaveValue('还没创建的资料');
expect(screen.queryByLabelText('消息')).not.toBeInTheDocument();
expect(api.save).not.toHaveBeenCalled();
expect(api.call.mock.calls.some(([operation]) => ['saveBudget', 'preview', 'submit'].includes(operation))).toBe(false);
});
it('keeps preview text across mode switches and saves a budget independently of the draft', async () => {
it('retains unsaved instructions while saving a budget independently of the draft', async () => {
const configured = { ...draft, configuration: { ...EMPTY_CLOUD_CONFIGURATION, model: 'test-model' } };
api.list.mockResolvedValue({ agents: [configured], next_cursor: null });
const base = api.call.getMockImplementation()!;
api.call.mockImplementation(async (operation, input) => operation === 'saveBudget' ? { ...input, daily_committed_points: '0.00' } : base(operation, input));
open();
fireEvent.click(await screen.findByRole('button', { name: /写作搭档/ }));
await waitFor(() => expect(screen.getByLabelText('消息')).toBeEnabled());
fireEvent.change(screen.getByLabelText('消息'), { target: { value: '待发送的问题' } });
fireEvent.change(screen.getByLabelText('特别要求'), { target: { value: '未保存的指令' } });
fireEvent.click(screen.getByRole('button', { name: '对话', exact: true }));
fireEvent.click(screen.getByRole('button', { name: '编辑', exact: true }));
expect(screen.getByLabelText('消息')).toHaveValue('待发送的问题');
expect(screen.getByLabelText('特别要求')).toHaveValue('未保存的指令');
fireEvent.click(await screen.findByRole('button', { name: '继续制作', exact: true }));
fireEvent.change(screen.getByLabelText('提示词'), { target: { value: '未保存的指令' } });
fireEvent.click(screen.getByRole('button', { name: '聊天', exact: true }));
expect(screen.queryByLabelText('消息')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '配置', exact: true }));
expect(screen.getByLabelText('提示词')).toHaveValue('未保存的指令');
fireEvent.mouseDown(screen.getByRole('tab', { name: '高级设置' }), { button: 0, ctrlKey: false });
fireEvent.click(screen.getByRole('button', { name: '限制', exact: true }));
fireEvent.change(screen.getByLabelText('每日上限(词元点数)'), { target: { value: '12' } });
const budget = screen.getByLabelText('每日上限(词元点数)');
await waitFor(() => expect(budget).toBeEnabled());
fireEvent.change(budget, { target: { value: '12' } });
fireEvent.click(screen.getByRole('button', { name: '保存费用上限' }));
await waitFor(() => expect(api.call).toHaveBeenCalledWith('saveBudget', expect.objectContaining({ daily_limit_points: '12' })));
expect(api.save).not.toHaveBeenCalled();
fireEvent.mouseDown(screen.getByRole('tab', { name: '我的要求', exact: true }), { button: 0, ctrlKey: false });
expect(screen.getByLabelText('特别要求')).toHaveValue('未保存的指令');
expect(screen.getByLabelText('消息')).toHaveValue('待发送的问题');
fireEvent.mouseDown(screen.getByRole('tab', { name: '模型与提示词', exact: true }), { button: 0, ctrlKey: false });
expect(screen.getByLabelText('提示词')).toHaveValue('未保存的指令');
expect(screen.getByRole('button', { name: '保存草稿' })).toBeEnabled();
expect(api.call.mock.calls.some(([operation]) => ['threads', 'preview', 'submit'].includes(operation))).toBe(false);
});
it('keeps an existing preview when the saved draft clears its model selection', async () => {
it('saves a cleared model selection without choosing another model or starting a preview', async () => {
const configured = { ...draft, configuration: { ...EMPTY_CLOUD_CONFIGURATION, model: 'test-model' } };
api.list.mockResolvedValue({ agents: [configured], next_cursor: null });
const base = api.call.getMockImplementation()!;
api.call.mockImplementation(async (operation, input) => operation === 'catalog'
? { models: [{ id: 'test-model', name: '创作模型' }, { id: 'another-model', name: '其他模型' }], resources: {}, pricing: null }
: base(operation, input));
api.save.mockResolvedValue({ ...draft, draft_revision: 2 });
open();
fireEvent.click(await screen.findByRole('button', { name: /写作搭档/ }));
await waitFor(() => expect(screen.getByLabelText('消息')).toBeEnabled());
const prompt = screen.getByLabelText('消息');
fireEvent.change(prompt, { target: { value: '还没有发送' } });
fireEvent.mouseDown(screen.getByRole('tab', { name: '高级设置' }), { button: 0, ctrlKey: false });
fireEvent.click(screen.getByRole('button', { name: '能力', exact: true }));
fireEvent.click(await screen.findByRole('button', { name: '继续制作', exact: true }));
await waitFor(() => expect(screen.getByLabelText('模型')).toBeEnabled());
fireEvent.change(screen.getByLabelText('模型'), { target: { value: '' } });
fireEvent.click(screen.getByRole('button', { name: '保存草稿' }));
await screen.findByText('云端已保存');
expect(screen.getByLabelText('消息')).toBe(prompt);
expect(prompt).toHaveValue('还没有发送');
expect(screen.getByRole('button', { name: '用最新草稿试用' })).toBeDisabled();
expect(screen.getByLabelText('模型')).toHaveValue('');
expect(api.save).toHaveBeenCalledWith(draft.slug, expect.objectContaining({ configuration: expect.objectContaining({ model: '' }) }));
expect(screen.queryByLabelText('消息')).not.toBeInTheDocument();
expect(api.call.mock.calls.some(([operation]) => ['threads', 'preview', 'submit'].includes(operation))).toBe(false);
});
it('searches capabilities while retaining existing selections outside the results', async () => {
const configured = { ...draft, configuration: { ...EMPTY_CLOUD_CONFIGURATION, model: 'test-model', tools: ['missing-tool'] } };
api.list.mockResolvedValue({ agents: [configured], next_cursor: null });
@@ -551,13 +656,13 @@ it('searches capabilities while retaining existing selections outside the result
} : base(operation, input));
api.save.mockImplementation(async (_slug, input) => ({ ...configured, ...input, draft_revision: 2 }));
open();
fireEvent.click(await screen.findByRole('button', { name: /写作搭档/ }));
fireEvent.click(await screen.findByRole('button', { name: '继续制作', exact: true }));
fireEvent.mouseDown(screen.getByRole('tab', { name: '高级设置' }), { button: 0, ctrlKey: false });
fireEvent.click(screen.getByRole('button', { name: '能力', exact: true }));
expect(await screen.findByRole('checkbox', { name: /missing-tool/ })).toBeChecked();
fireEvent.click(screen.getByRole('button', { name: '选择工具', exact: true }));
fireEvent.change(screen.getByLabelText('搜索工具'), { target: { value: '资料' } });
fireEvent.mouseDown(screen.getByRole('tab', { name: '我的要求' }), { button: 0, ctrlKey: false });
fireEvent.mouseDown(screen.getByRole('tab', { name: '模型与提示词' }), { button: 0, ctrlKey: false });
fireEvent.mouseDown(screen.getByRole('tab', { name: '高级设置' }), { button: 0, ctrlKey: false });
expect(screen.getByLabelText('搜索工具')).toHaveValue('资料');
expect(screen.queryByRole('checkbox', { name: '当前时间' })).not.toBeInTheDocument();