feat(agents): 新增云端智能体入口与草稿编辑

This commit is contained in:
2026-09-10 16:00:37 +08:00
parent 927a85fa22
commit 46fbae9dec
21 changed files with 921 additions and 17 deletions

View File

@@ -0,0 +1,65 @@
import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron';
test('personal cloud Agent creation, draft save and leave protection in Electron', 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');
// This UI test owns the Host API boundary; real cloud HTTP/PG is tested separately.
await app.evaluate(({ ipcMain }) => {
let draft: Record<string, unknown> | null = null;
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; method?: string; body?: string }) => {
const path = request.path ?? '';
const method = request.method ?? 'GET';
if (path === '/api/auth/session/sync') return result({
success: true, session: { accessToken: 'ui-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: '创作者', age: null, gender: null, avatar_url: null, share_age_with_agents: false, share_gender_with_agents: false, analysis_enabled: true, completed: true, version: 1, updated_at: '2026-09-10T00:00:00Z' },
});
if (path.startsWith('/api/cloud-agents/')) {
const body = typeof request.body === 'string' ? JSON.parse(request.body) : request.body ?? {};
if (method === 'POST') draft = {
slug: 'ml-' + 'a'.repeat(32), name: body.name, purpose: body.purpose, system_prompt: body.purpose,
draft_revision: 1, updated_at: '2026-09-10T00:00:00Z',
};
if (method === 'PATCH') draft = { ...draft, name: body.name, purpose: body.purpose, system_prompt: body.system_prompt, draft_revision: 2 };
if (method === 'GET' && (path.endsWith('/agents') || path.endsWith('/bootstrap'))) return result({ agents: draft ? [draft] : [], next_cursor: null });
return result(draft);
}
return result({ success: true });
});
});
await page.goto(applicationUrl);
await page.getByTestId('ai-module-option-cloud_agents').click();
await expect(page.getByTestId('sidebar-cloud-agents-navigation')).toBeVisible();
await expect(page.getByTestId('sidebar-robot-navigation')).toHaveCount(0);
await page.getByRole('button', { name: '创建第一个智能体' }).click();
await page.getByLabel('名称', { exact: true }).fill('我的写作搭档');
await page.getByLabel('用途', { exact: true }).fill('整理灵感,把想法写成清晰的文章');
await page.getByRole('button', { name: '创建草稿', exact: true }).click();
await expect(page.getByTestId('cloud-agent-editor')).toBeVisible();
await page.getByLabel('角色与指令').fill('使用简洁自然的中文。先理解我的目的,再协助梳理结构。');
await page.getByTestId('sidebar-module-switcher-trigger').click();
await expect(page.getByRole('dialog', { name: '未保存的修改' })).toBeVisible();
await page.getByRole('button', { name: '继续编辑', exact: true }).click();
await page.getByRole('button', { name: '保存草稿', exact: true }).click();
await expect(page.getByRole('button', { name: '已保存', exact: true })).toBeVisible();
await page.screenshot({ path: testInfo.outputPath('cloud-agent-editor.png') });
await page.getByTestId('sidebar-module-switcher-trigger').click();
await expect(page.getByTestId('ai-module-selection-page')).toBeVisible();
await page.getByTestId('ai-module-option-cloud_agents').click();
await page.getByRole('button', { name: /我的写作搭档/ }).click();
await expect(page.getByLabel('角色与指令')).toHaveValue('使用简洁自然的中文。先理解我的目的,再协助梳理结构。');
} finally {
await closeElectronApp(app);
}
});

View File

@@ -81,7 +81,7 @@ test.describe('Makelore module navigation without setup flow', () => {
}
});
test('keeps the three module entries in a proportional vertical stack while resizing the window', async ({ launchElectronApp }) => {
test('keeps the four module entries in a proportional vertical stack while resizing the window', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
try {
@@ -103,7 +103,7 @@ test.describe('Makelore module navigation without setup flow', () => {
}));
const initialMetrics = await readCardMetrics();
expect(initialMetrics).toHaveLength(3);
expect(initialMetrics).toHaveLength(4);
await expect(moduleCards.first().locator('.module-option-card-arrow')).toHaveCount(0);
await expect(moduleCards.first().locator('.module-option-artwork')).toHaveCount(0);
await expect.poll(async () => await moduleCards.first().evaluate((element) => {
@@ -155,7 +155,7 @@ test.describe('Makelore module navigation without setup flow', () => {
await expect.poll(async () => (await readCardMetrics())[0]?.width ?? 0).toBeLessThan(initialWidth - 1);
const resizedMetrics = await readCardMetrics();
expect(resizedMetrics).toHaveLength(3);
expect(resizedMetrics).toHaveLength(4);
expect(Math.max(...resizedMetrics.map((card) => card.left)) - Math.min(...resizedMetrics.map((card) => card.left))).toBeLessThan(1);
expect(resizedMetrics.map((card) => card.top)).toEqual(
[...resizedMetrics].sort((top, bottom) => top.top - bottom.top).map((card) => card.top),
@@ -195,7 +195,7 @@ test.describe('Makelore module navigation without setup flow', () => {
const page = await getStableWindow(app);
await expect(page.getByTestId('ai-module-selection-page')).toBeVisible();
await expect(page.locator('[data-testid^="ai-module-option-"]')).toHaveCount(3);
await expect(page.locator('[data-testid^="ai-module-option-"]')).toHaveCount(4);
await expect(page.getByTestId('ai-module-option-learning')).toHaveCount(0);
await page.evaluate(() => {
window.location.hash = '#/learning';

View File

@@ -88,6 +88,7 @@ describe('auth host api routes', () => {
design: true,
learning: false,
robot: true,
cloud_agents: true,
},
}), { status: 200 }),
);
@@ -116,6 +117,7 @@ describe('auth host api routes', () => {
programming: false,
design: true,
robot: true,
cloud_agents: true,
},
});
expect(response.json()).not.toHaveProperty('moduleAccess.learning');
@@ -162,6 +164,7 @@ describe('auth host api routes', () => {
programming: true,
design: true,
robot: true,
cloud_agents: true,
},
});
});

View File

@@ -25,6 +25,7 @@ function resetAuthStore() {
programming: true,
design: true,
robot: true,
cloud_agents: true,
},
});
}
@@ -88,6 +89,7 @@ describe('auth store', () => {
programming: true,
design: false,
robot: false,
cloud_agents: true,
},
});
@@ -128,6 +130,7 @@ describe('auth store', () => {
programming: true,
design: false,
robot: false,
cloud_agents: true,
});
expect(window.localStorage.getItem('niancode-auth')).not.toContain(
'must-not-return-to-renderer-storage',
@@ -177,6 +180,7 @@ describe('auth store', () => {
programming: true,
design: true,
robot: false,
cloud_agents: true,
},
});
});
@@ -214,6 +218,7 @@ describe('auth store', () => {
programming: true,
design: true,
robot: true,
cloud_agents: true,
},
});
});
@@ -257,6 +262,7 @@ describe('auth store', () => {
programming: true,
design: false,
robot: true,
cloud_agents: true,
});
});
@@ -310,6 +316,7 @@ describe('auth store', () => {
programming: true,
design: false,
robot: true,
cloud_agents: true,
},
});
expect(useAuthStore.getState().isAuthenticated()).toBe(true);
@@ -416,6 +423,7 @@ describe('auth store', () => {
programming: true,
design: false,
robot: true,
cloud_agents: true,
},
user: {
username: 'zhangsan',
@@ -438,6 +446,7 @@ describe('auth store', () => {
programming: true,
design: true,
robot: true,
cloud_agents: true,
},
});
});
@@ -480,6 +489,7 @@ describe('auth store', () => {
programming: true,
design: true,
robot: true,
cloud_agents: true,
},
});
});
@@ -943,6 +953,7 @@ describe('auth store', () => {
programming: true,
design: true,
robot: true,
cloud_agents: true,
},
});
});

View File

@@ -0,0 +1,89 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { CloudAgentsModule } from '@electron/services/cloud-agents';
import { clearWorksSquareSession, storeWorksSquareSession } from '@electron/services/works-square-session';
const draft = {
slug: 'ml-' + 'a'.repeat(32), name: '写作搭档', purpose: '帮助写作', system_prompt: '简明表达',
draft_revision: 1, updated_at: '2026-09-10T00:00:00Z',
};
const input = { operation_id: '12345678-1234-1234-1234-123456789012', name: draft.name, purpose: draft.purpose };
const json = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status });
const session = () => json({
access_token: 'yuxi-secret', token_type: 'bearer', scope: 'makelore-agent-drafts',
expires_at: Math.floor(Date.now() / 1000) + 120, api_base_url: 'https://agents.example.test',
});
function login(key = 'a'.repeat(64)) {
storeWorksSquareSession({ accessToken: 'ws-secret', expiresAt: Date.now() + 600000, accountPartitionKey: key });
}
const instances: CloudAgentsModule[] = [];
function moduleFor(fetchImpl: typeof fetch) {
const module = new CloudAgentsModule(fetchImpl); instances.push(module); return module;
}
beforeEach(() => { clearWorksSquareSession(); login(); });
afterEach(() => { instances.splice(0).forEach((module) => module.dispose()); clearWorksSquareSession(); vi.useRealTimers(); });
describe('Main cloud Agents boundary', () => {
it('keeps credentials in Main, projects the DTO and sends only creator-independent inputs', async () => {
const fetchImpl = vi.fn().mockResolvedValueOnce(session())
.mockResolvedValueOnce(json({ ...draft, access_token: 'do-not-project', owner: 'private' }))
.mockResolvedValueOnce(json({ agents: [draft], next_cursor: null }));
const module = moduleFor(fetchImpl);
expect(await module.create({ ...input, payer: 'other', account_id: 'other' })).toEqual(draft);
expect(await module.list()).toEqual({ agents: [draft], next_cursor: null });
expect(fetchImpl).toHaveBeenCalledTimes(3);
expect(fetchImpl.mock.calls[0][1].headers.Authorization).toBe('Bearer ws-secret');
expect(fetchImpl.mock.calls[1][1].headers.Authorization).toBe('Bearer yuxi-secret');
expect(JSON.parse(fetchImpl.mock.calls[1][1].body)).toEqual(input);
expect(fetchImpl.mock.calls[1][1].redirect).toBe('error');
});
it('rejects the old account response after switching accounts', async () => {
let finish!: (response: Response) => void;
const fetchImpl = vi.fn().mockResolvedValueOnce(session()).mockImplementationOnce(() => new Promise<Response>((resolve) => { finish = resolve; }));
const result = moduleFor(fetchImpl).list();
const rejection = expect(result).rejects.toMatchObject({ code: 'account_changed' });
await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(2));
login('b'.repeat(64));
finish(json({ agents: [draft], next_cursor: null }));
await rejection;
});
it('does not automatically repeat a possibly committed create and preserves an explicit retry operation', async () => {
const fetchImpl = vi.fn().mockResolvedValueOnce(session()).mockRejectedValueOnce(new Error('lost response ws-secret'))
.mockResolvedValueOnce(json(draft));
const module = moduleFor(fetchImpl);
await expect(module.create(input)).rejects.toMatchObject({ code: 'cloud_service_unavailable' });
expect(fetchImpl).toHaveBeenCalledTimes(2);
expect(await module.create(input)).toEqual(draft);
expect(fetchImpl.mock.calls[2][1].body).toBe(fetchImpl.mock.calls[1][1].body);
});
it('passes revision conflicts without exposing upstream diagnostics', async () => {
const fetchImpl = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json({
detail: { code: 'draft_revision_conflict', message: 'raw yuxi-secret' },
}, 409));
await expect(moduleFor(fetchImpl).save(draft.slug, {
expected_revision: 1, name: draft.name, purpose: draft.purpose, system_prompt: 'new',
})).rejects.toMatchObject({ status: 409, code: 'draft_revision_conflict', message: '草稿已在其他设备更新,你的输入已保留' });
});
it('rejects malformed draft responses as service failures', async () => {
const fetchImpl = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json({ ...draft, draft_revision: 'oops' }));
await expect(moduleFor(fetchImpl).get(draft.slug)).rejects.toMatchObject({ status: 502 });
});
it('rejects missing login before any network request', async () => {
clearWorksSquareSession();
const fetchImpl = vi.fn();
await expect(moduleFor(fetchImpl).list()).rejects.toMatchObject({ status: 401 });
expect(fetchImpl).not.toHaveBeenCalled();
});
it('times out a stalled response body and projects a safe error', async () => {
vi.useFakeTimers();
const fetchImpl = vi.fn().mockResolvedValueOnce({ text: () => new Promise(() => {}) });
const result = expect(moduleFor(fetchImpl).list()).rejects.toMatchObject({ code: 'cloud_service_unavailable' });
await vi.advanceTimersByTimeAsync(30000);
await result;
});
});

View File

@@ -0,0 +1,122 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import CloudAgents from '@/pages/CloudAgents';
const api = vi.hoisted(() => ({ list: vi.fn(), create: vi.fn(), get: vi.fn(), save: 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) }));
const draft = {
slug: 'ml-' + 'a'.repeat(32), name: '写作搭档', purpose: '帮助写作', system_prompt: '原始指令',
draft_revision: 1, updated_at: '2026-09-10T00:00:00Z',
};
function open() {
const router = createMemoryRouter([
{ path: '/cloud-agents', element: <CloudAgents /> },
{ path: '/module-select', element: <div></div> },
], { initialEntries: ['/cloud-agents'] });
const view = render(<RouterProvider router={router} />);
return { router, ...view };
}
beforeEach(() => {
vi.resetAllMocks(); auth.user.userId = 'creator';
api.list.mockResolvedValue({ agents: [draft], next_cursor: null });
});
afterEach(cleanup);
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: /写作搭档/ });
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: '创建草稿' }));
await screen.findByRole('alert');
fireEvent.click(existing);
expect(screen.queryByTestId('cloud-agent-editor')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '重试创建' }));
await screen.findByTestId('cloud-agent-editor');
expect(api.create.mock.calls[1][0]).toEqual(api.create.mock.calls[0][0]);
});
it('keeps the same creation operation after a lost response', async () => {
api.list.mockResolvedValue({ agents: [], next_cursor: null });
api.create.mockRejectedValueOnce(new Error('连接中断')).mockResolvedValueOnce(draft);
open();
fireEvent.click(await screen.findByText('创建第一个智能体'));
fireEvent.change(screen.getByLabelText('名称'), { target: { value: '写作搭档' } });
fireEvent.change(screen.getByLabelText('用途'), { target: { value: '帮助写作' } });
fireEvent.click(screen.getByRole('button', { name: '创建草稿' }));
await screen.findByRole('alert');
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]);
});
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(screen.getByRole('button', { name: '保存草稿' }));
await screen.findByText('云端已有修订 2本地输入仍保留');
expect(screen.getByLabelText('角色与指令')).toHaveValue('我的修改');
expect(screen.getByRole('button', { name: '保存草稿' })).toBeDisabled();
fireEvent.click(screen.getByText('保留我的内容,继续编辑'));
api.save.mockResolvedValue({ ...draft, draft_revision: 3, system_prompt: '我的修改' });
fireEvent.click(screen.getByRole('button', { name: '保存草稿' }));
await screen.findByRole('button', { name: '已保存' });
expect(api.save.mock.calls[1][1]).toMatchObject({ expected_revision: 2, system_prompt: '我的修改' });
});
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.change(screen.getByLabelText('名称'), { target: { value: '新的名称' } });
await act(async () => { await router.navigate('/module-select'); });
expect(screen.getByRole('dialog', { name: '未保存的修改' })).toBeVisible();
expect(router.state.location.pathname).toBe('/cloud-agents');
fireEvent.click(screen.getByText('继续编辑'));
expect(screen.getByLabelText('名称')).toHaveValue('新的名称');
await act(async () => { await router.navigate('/module-select'); });
api.save.mockResolvedValue({ ...draft, name: '新的名称', draft_revision: 2 });
fireEvent.click(screen.getByText('保存并离开'));
await screen.findByText('模块首页');
expect(api.save).toHaveBeenCalledTimes(1);
});
it('clears the previous account workspace and ignores its delayed list', async () => {
let finish!: (value: unknown) => void;
api.list.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; }))
.mockResolvedValue({ agents: [], next_cursor: null });
const view = open();
auth.user.userId = 'other';
view.rerender(<RouterProvider key="other-account" router={view.router} />);
await screen.findByText('你的第一个智能体,从这里开始');
await act(async () => finish({ agents: [draft], next_cursor: null }));
await waitFor(() => expect(screen.queryByText('写作搭档')).not.toBeInTheDocument());
});
it('warns before leaving an uncertain creation and keeps its retry intent', async () => {
api.list.mockResolvedValue({ agents: [], next_cursor: null });
api.create.mockRejectedValue(new Error('连接中断'));
const { router } = open();
fireEvent.click(await screen.findByText('创建第一个智能体'));
fireEvent.change(screen.getByLabelText('名称'), { target: { value: '写作搭档' } });
fireEvent.change(screen.getByLabelText('用途'), { target: { value: '帮助写作' } });
fireEvent.click(screen.getByRole('button', { name: '创建草稿' }));
await screen.findByRole('alert');
await act(async () => { await router.navigate('/module-select'); });
expect(screen.getByRole('dialog', { name: '离开创建' })).toBeVisible();
fireEvent.click(screen.getByRole('button', { name: '继续创建' }));
fireEvent.click(screen.getByRole('button', { name: '重试创建' }));
await waitFor(() => expect(api.create).toHaveBeenCalledTimes(2));
expect(api.create.mock.calls[1][0]).toEqual(api.create.mock.calls[0][0]);
});

View File

@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest';
import { getGuardedAiModuleForPath, isAiModuleAllowed, isProgrammingProviderRoute } from '@/lib/ai-modules';
import {
DEFAULT_MODULE_ACCESS,
MODULE_ACCESS_KEYS,
@@ -6,12 +7,19 @@ import {
} from '../../shared/module-access';
describe('module access projection', () => {
it('contains only the three supported product modules', () => {
expect(MODULE_ACCESS_KEYS).toEqual(['programming', 'design', 'robot']);
it('honors an explicit cloud Agent denial and does not initialize Code for its route', () => {
const access = normalizeModuleAccess({ cloud_agents: false });
expect(isAiModuleAllowed('cloud_agents', access)).toBe(false);
expect(getGuardedAiModuleForPath('/cloud-agents')).toBe('cloud_agents');
expect(isProgrammingProviderRoute('/cloud-agents')).toBe(false);
});
it('contains only the four supported product modules', () => {
expect(MODULE_ACCESS_KEYS).toEqual(['programming', 'design', 'robot', 'cloud_agents']);
expect(DEFAULT_MODULE_ACCESS).toEqual({
programming: true,
design: true,
robot: true,
cloud_agents: true,
});
});
@@ -27,6 +35,7 @@ describe('module access projection', () => {
programming: false,
design: true,
robot: false,
cloud_agents: true,
});
expect(access).not.toHaveProperty('learning');
});