feat(agents): 复用 Yuxi 头像上传与展示

This commit is contained in:
brother7 committed 2026-10-12 13:48:38 +08:00
1 parent 0ca744fb9b
commit 235af24d6c
16 files changed
+381 -32

No files matched your search

+56
View File
@@ -0,0 +1,56 @@
import { useState } from 'react';
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { CloudAvatar, CloudAvatarEditor } from '@/pages/CloudAgents/CloudAvatar';
import type { CloudAgentAvatar } from '../../shared/cloud-agents';
const api = vi.hoisted(() => ({ uploadAvatar: vi.fn() }));
vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api }));
beforeEach(() => vi.resetAllMocks());
afterEach(cleanup);
function Editor() {
const [value, setValue] = useState<CloudAgentAvatar>({ icon: '/old.png', icon_url: 'https://images.test/old.png' });
const [uploading, setUploading] = useState(false);
return <CloudAvatarEditor name="写作搭档" value={value} disabled={false} uploading={uploading} onUploading={setUploading} onChange={setValue} />;
}
it('shows a default avatar for absent or broken images and recovers when the image changes', () => {
const view = render(<CloudAvatar name="写作搭档" />);
expect(screen.getByRole('img', { name: '写作搭档默认头像' })).toBeVisible();
view.rerender(<CloudAvatar name="写作搭档" src="https://images.test/broken.png" />);
fireEvent.error(screen.getByRole('img', { name: '写作搭档头像' }));
expect(screen.getByRole('img', { name: '写作搭档默认头像' })).toBeVisible();
view.rerender(<CloudAvatar name="写作搭档" src="https://images.test/new.png" />);
expect(screen.getByRole('img', { name: '写作搭档头像' })).toHaveAttribute('src', 'https://images.test/new.png');
});
it('retains the avatar after cancellation or failure, then supports replacing and removing it', async () => {
api.uploadAvatar.mockResolvedValueOnce(null).mockRejectedValueOnce(new Error('上传失败'))
.mockResolvedValueOnce({ icon: '/new.png', icon_url: 'https://images.test/new.png' });
render(<Editor />);
fireEvent.click(screen.getByRole('button', { name: '更换头像' }));
await waitFor(() => expect(screen.getByRole('button', { name: '更换头像' })).toBeEnabled());
expect(screen.getByRole('img', { name: '写作搭档头像' })).toHaveAttribute('src', 'https://images.test/old.png');
fireEvent.click(screen.getByRole('button', { name: '更换头像' }));
expect(await screen.findByRole('alert')).toHaveTextContent('上传失败');
expect(screen.getByRole('img', { name: '写作搭档头像' })).toHaveAttribute('src', 'https://images.test/old.png');
fireEvent.click(screen.getByRole('button', { name: '更换头像' }));
await waitFor(() => expect(screen.getByRole('img', { name: '写作搭档头像' })).toHaveAttribute('src', 'https://images.test/new.png'));
fireEvent.click(screen.getByRole('button', { name: '移除头像' }));
expect(screen.getByRole('img', { name: '写作搭档默认头像' })).toBeVisible();
});
it('disables edits during upload and drops results after leaving the account or editor', async () => {
let resolve!: (value: CloudAgentAvatar) => void;
api.uploadAvatar.mockReturnValue(new Promise(r => { resolve = r; }));
const changed = vi.fn(), busy = vi.fn();
const view = render(<CloudAvatarEditor name="写作搭档" value={{}} disabled={false} uploading={false} onChange={changed} onUploading={busy} />);
fireEvent.click(screen.getByRole('button', { name: '上传头像' }));
expect(busy).toHaveBeenCalledWith(true);
view.rerender(<CloudAvatarEditor name="写作搭档" value={{}} disabled={false} uploading onChange={changed} onUploading={busy} />);
expect(screen.getByRole('button', { name: '正在上传…' })).toBeDisabled();
view.unmount();
await act(async () => resolve({ icon: '/other-account.png', icon_url: 'https://images.test/other.png' }));
expect(changed).not.toHaveBeenCalled();
});
+68
View File
@@ -19,6 +19,7 @@ vi.mock('electron', () => { const electron = {
const draft = {
slug: 'ml-' + 'a'.repeat(32), name: '写作搭档', purpose: '帮助写作', system_prompt: '简明表达',
icon: null, icon_url: null,
draft_revision: 1, updated_at: '2026-09-10T00:00:00Z',
configuration: EMPTY_CLOUD_CONFIGURATION, published_version: null, enabled: true, archived: false,
};
@@ -44,6 +45,73 @@ beforeEach(() => { vi.clearAllMocks(); journalRecords.clear(); clearWorksSquareS
afterEach(() => { instances.splice(0).forEach((module) => module.dispose()); clearWorksSquareSession(); vi.useRealTimers(); });
describe('Main cloud Agents boundary', () => {
it('uploads avatar bytes through Main and resolves Yuxi public URLs without exposing credentials', async () => {
const directory = await mkdtemp(join(tmpdir(), 'makelore-avatar-'));
try {
const source = join(directory, 'avatar.png');
const bytes = Buffer.from('avatar bytes validated by Yuxi');
await writeFile(source, bytes);
desktop.pick.mockResolvedValue({ canceled: false, filePaths: [source] });
const icon = '/api/storage/public/images/creator/avatar.png';
const transport = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json({ icon, private: 'hidden' }));
expect(await moduleFor(transport).uploadAvatar()).toEqual({ icon, icon_url: 'https://agents.example.test' + icon });
expect(desktop.pick.mock.calls[0][0].filters[0].extensions).toContain('png');
expect(transport.mock.calls[1][0]).toBe('https://agents.example.test/api/makelore/avatars');
const body = transport.mock.calls[1][1].body as FormData;
expect(Buffer.from(await (body.get('file') as File).arrayBuffer())).toEqual(bytes);
} finally { await rm(directory, { recursive: true, force: true }); }
});
it('cancels avatar selection without a request and rejects selection from a previous account', async () => {
const transport = vi.fn();
desktop.pick.mockResolvedValueOnce({ canceled: true, filePaths: [] });
const module = moduleFor(transport);
expect(await module.uploadAvatar()).toBeNull();
desktop.pick.mockImplementationOnce(async () => { login('b'.repeat(64)); return { canceled: false, filePaths: ['old.png'] }; });
await expect(module.uploadAvatar()).rejects.toMatchObject({ code: 'account_changed' });
expect(transport).not.toHaveBeenCalled();
});
it('rejects oversized avatars before reading or transmitting them', async () => {
const directory = await mkdtemp(join(tmpdir(), 'makelore-avatar-limit-'));
try {
const source = join(directory, 'large.png');
await writeFile(source, Buffer.alloc(5 * 1024 * 1024 + 1));
desktop.pick.mockResolvedValue({ canceled: false, filePaths: [source] });
const transport = vi.fn();
await expect(moduleFor(transport).uploadAvatar()).rejects.toMatchObject({ code: 'avatar_too_large' });
expect(transport).not.toHaveBeenCalled();
} finally { await rm(directory, { recursive: true, force: true }); }
});
it('preserves canonical avatar paths through create/save and projects published and received avatars', async () => {
const icon = '/api/storage/public/images/creator/avatar.png';
const avatar = { icon, icon_url: 'https://agents.example.test' + icon };
const transport = vi.fn(async (url: string) => {
if (url.endsWith('/session')) return session();
if (url.includes('/received')) return json({ agents: [{ ...draft, icon, private: 'hidden' }], next_offset: null });
return json({ ...draft, icon, private: 'hidden' });
});
const module = moduleFor(transport);
expect(await module.create({ ...input, ...avatar })).toMatchObject(avatar);
expect(JSON.parse(transport.mock.calls[1][1].body as string)).toEqual({ ...input, icon });
await module.save(draft.slug, { ...draft, ...avatar, expected_revision: 1 });
expect(JSON.parse(transport.mock.calls[2][1].body as string)).toMatchObject({ icon });
expect(transport.mock.calls[2][1].body).not.toContain('icon_url');
await module.save(draft.slug, { ...draft, expected_revision: 1, icon: null });
expect(JSON.parse(transport.mock.calls[3][1].body as string).icon).toBeNull();
const entry = await module.execute({ operation: 'entry', input: { slug: draft.slug } });
expect(entry).toMatchObject(avatar); expect(entry).not.toHaveProperty('private');
expect(await module.execute({ operation: 'received', input: {} })).toMatchObject({ agents: [avatar] });
expect(await module.execute({ operation: 'version', input: { slug: draft.slug, version: '1' } })).toMatchObject(avatar);
});
it.each(['file:///private/avatar.png', 'javascript:alert(1)', 'https://secret@images.example.test/avatar.png'])
('falls back for an unusable legacy avatar URL %s', async icon => {
const transport = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json({ ...draft, icon }));
expect(await moduleFor(transport).get(draft.slug)).toMatchObject({ icon, icon_url: null });
});
it('accepts a five-minute session from a clock one second ahead and caps local caching', async () => {
vi.useFakeTimers();
const now = Math.floor(Date.now() / 1000) * 1000;
+25 -1
View File
@@ -4,7 +4,7 @@ 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(), 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(), 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 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,6 +36,30 @@ 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 },