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

@@ -0,0 +1,57 @@
# Task: Implement Makelore agent avatars using Yuxi data
## Identity
- Task ID: 20261012-agent-avatars-840d7fd7
- Mode: Feature
- Branch: codex/20261012-agent-avatars-840d7fd7-agent-avatars
- Worktree: D:\Datas\OthersProjects\.codex-worktrees\makelore\20261012-agent-avatars-840d7fd7
- Base commit: 0ca744fb9b663a91aae9cdc6e5d3f8c860c551ed
- Owner: codex
- Status: Ready for Integration
## Scope
- Complete cloud Agent avatar upload, replacement, removal and display using Yuxi Agent.icon. Main owns transport; Renderer owns explicit unsaved edits.
## Intent And Constraints
- Merge continuation: user requests local main-branch integration and explicitly authorizes one fresh, read-only Reviewer. Resumed the same source task via official start/status; original worktrees, branch, bases and peer membership are unchanged. Re-read entry, own record and relevant current memory; prior peer-scope assessment still applies, with primary-owner handoff now the concrete integration dependency. Source Concurrent and Planning Gates Passed; no push, release or deployment is requested.
- Concurrent and Planning Gates Passed. Official start/status match this task, feature mode, branch, isolated worktree and base. Loaded entry, own record, positioning, relevant integrated state, decisions, architecture, domain and success criteria. Peer task Scope/Intent/Promotion records were read; historical placeholders remain unknown, with no concrete conflicting dependency. Preserve immutable publication, revision conflict handling, account isolation and creator billing. One fresh read-only Reviewer was explicitly authorized; no other subagents. Feature memory writes remain task-scoped.
- Scope: reuse native image storage/icon; wire Main and UI; verify upload failures, omission/removal, concurrent saves, published snapshots and display. User subsequently requested local main-branch integration; no push or deployment.
- User authorized backing up the damaged Makelore worktree to the same path suffixed .backup-avatar-repair and releasing only its invalid registry entry; both operations completed without deleting files, branches or commits.
## Outcome
- Implemented avatar selection/upload, unsaved preview, replacement/removal, canonical icon saving, and display/fallback for personal lists, draft workspace, shared entries and version comparisons.
- Electron Main uses the existing scoped Yuxi session and native file picker, caps images at 5 MB, and resolves public icon_url separately from the persisted icon. Cancellation/failure preserves the existing value; account changes discard pending results.
- Yuxi companion changes use Agent.icon, existing image validation/public storage and immutable publication snapshots. Works Square requires no code changes.
- Narrow-window layout keeps the avatar beside the name and preserves the visible save/apply controls. README describes the behavior.
- Completed feature result is ready for the separately owned integration task. Fresh read-only Reviewer avatar_review, with no inherited development context, reviewed the full requirement, diff, new files, tests and rules and reported no blocking findings. No push, release or deployment.
## Verification
- Independent review: PASS after including the Agent.icon test-fixture correction; no production changes requested.
- Yuxi full non-slow unit suite: 2270 passed / 1 skipped. Three knowledge-runtime fixture failures were fixed by supplying the real Agent.icon field. A fourth CLI failure was the WSL stdlib test package shadowing the repository namespace; a repository-external PYTHONPATH shim applied the correct namespace to pytest and subprocesses. Production code and dependency manifests were not changed for the environment issue.
- pnpm 10.33.4; pnpm run typecheck: passed (Renderer TypeScript).
- pnpm exec vitest run tests/unit/cloud-agent-avatar.test.tsx tests/unit/cloud-agents-main.test.ts tests/unit/cloud-agents-page.test.tsx tests/unit/cloud-agents-workflows.test.tsx tests/unit/cloud-agents-management.test.tsx tests/unit/cloud-agents-guidance.test.tsx --maxWorkers=2: 98 passed.
- ESLint on the 10 affected production TS/TSX files: passed.
- pnpm run build:vite: passed after final layout changes. pnpm exec playwright test tests/e2e/cloud-agents.spec.ts --reporter=list with task-specific output directory: 1 passed; real Electron, explicit Host API and image fixtures, verified decoded image width, create/replace/remove/save and 1024px layout. Inspected final screenshots.
- Additional Main-wide tsc (--noEmit -p tsconfig.node.json --incremental false --composite false): not passing due to existing repository errors. The unchanged unknown spreads at cloud-agent-operations projectConversation and cloud-agents access projection were confirmed in HEAD; avatar additions introduce no remaining diagnostics in these files. This is not counted as a passing Main typecheck.
- Frozen dependency installation hit D: disk capacity; the task reuses the installed primary node_modules through a local ignored junction. No dependency manifests or locks changed. Build output is redirected to C: using an ignored dist junction. Partially downloaded files were preserved under the task's C: visualization directory; no unknown files were removed.
- git diff --check passed. Task Documentation Gate runs and registry completion are recorded by the official bundled scripts at handoff.
## Follow-ups
- Integrate the client together with Yuxi task 20261012-agent-avatars-840d7fd7, then verify the deployed public storage endpoint and an installed client against real OSS. Local fixtures do not prove production storage or packaging.
- Existing Main typecheck debt is outside this avatar change. Full unrelated client test suites and production rollout were not run.
## Promotion Candidates
- Target: .project-docs/30-worklog/current-state.md and .project-docs/20-architecture/data-flow.md.
Proposal: record that cloud agent avatars are Yuxi Agent.icon references; Main owns uploads and display URL resolution; saves preserve omitted values and publication fixes the displayed version.
Evidence: this task's client changes, 98 focused tests, Electron flow, and the companion Yuxi HTTP/PostgreSQL tests.
Future impact: avoid creating a second client-owned avatar store and require paired deployment.
Semantic conflicts: none identified; existing immutable publication and account boundaries are preserved.
Human confirmation: no new product decision required; canonical promotion belongs to the integration task.
+1
View File
@@ -38,6 +38,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智
- 后台生命周期:Main 统一维护模块活动状态与任务租约。隐藏窗口、离开模块和关闭开发浏览器会释放非必要连接;生成、Code 执行、下载与发布构建持有租约并在完成后释放。已写入 Pi 的 prompt 或上下文整理即使确认超过 10 秒,也会继续持有运行所有权与后台租约,直到迟到响应、权威事件、明确失败或用户清理使其收敛;此时同一 Conversation 不接受重叠 mutation,其他 Conversation 不受影响。各模块的后台连接、轮询和子进程必须通过同一生命周期入口登记。
- 共享开发浏览器:AI 编程右侧提供项目级浏览器,用户与 Agent 查看并调试同一实时页面、Console 和 Network,支持本地与公网开发地址。
- 后端边界:Renderer 通过 Main 所有的 Host API 访问认证、模型、同步、更新、语音、图像与运行时能力。
- 智能体头像:创建和编辑个人云智能体时可上传、更换或移除头像,支持 PNG、JPEG、WebP、GIF,最大 5 MB。复用 Yuxi 的 `Agent.icon` 和公开图片存储;上传后先预览,保存草稿后生效,正式使用的头像随发布版本固定。列表、编辑页、收到的分享、会话入口和版本对比展示对应头像,未设置或加载失败时显示默认图标。Main 负责文件选择、上传和相对地址解析,客户端不维护第二份头像数据;需配套更新 Yuxi 的 Makelore 头像接口。
- 客户端更新:Electron Main 按平台与架构选择更新源并保留原始诊断;设置页只显示一条脱敏后的中文状态。正式源缺少对应安装包时保持错误并允许重试,不会误报为已是最新版。
- 个人资料:姓名、年龄、性别与个人头像按账号同步到云端;首次登录进入模块选择页时会要求先完善姓名,首页欢迎语和主平台左下角账号区优先展示个人资料姓名;头像支持 PNG、JPEG、WebP,保存时自动居中裁剪并以圆形缩略图展示,未设置或加载失败时回退为姓名首字母。
- 会话观察同步:本地编程会话在一轮问答完成并进入空闲后,后台通过个人资料 PUT 上传该会话截至当前的完整问答快照;只保留用户/助手自然语言文本,过滤代码、路径、日志、工具调用、附件与产物。同步只从本地上行,云端不回写、不恢复或删除本地会话;失败数据留在本地等待重试。
+2
View File
@@ -41,6 +41,8 @@ export async function handleCloudAgentsRoutes(req: IncomingMessage, res: ServerR
data = await cloudAgents.rememberRecent(await parseJsonBody(req));
} else if (path === '/recovery/pending' && req.method === 'POST') {
data = await cloudAgents.resolvePending(await parseJsonBody(req));
} else if (path === '/avatars/pick' && req.method === 'POST') {
data = await cloudAgents.uploadAvatar();
} else if (path === '/attachments/pick' && req.method === 'POST') {
data = await cloudAgents.upload();
} else if (path === '/knowledge/pick' && req.method === 'POST') {
+3 -3
View File
@@ -8,9 +8,9 @@ const prompt = ['request_id', 'thread_id', 'query', 'expected_revision', 'attach
const request = ['request_id', 'thread_id', 'run_id', 'status', 'version'];
const schedule = ['name', 'prompt', 'cron_expression', 'timezone', 'enabled', 'result_notification'];
const job = ['id', 'agent_slug', ...schedule, 'next_run_at', 'runs'];
const entry = ['slug', 'name', 'purpose', 'published_version', 'is_creator', 'payer'];
const entry = ['slug', 'name', 'purpose', 'icon', 'published_version', 'is_creator', 'payer'];
const budget = ['agent_slug', 'unit', 'timezone', 'request_limit_points', 'daily_limit_points', 'daily_committed_points', 'resets_at'];
const draft = ['slug', 'name', 'purpose', 'system_prompt', 'draft_revision', 'updated_at', 'configuration', 'published_version', 'enabled', 'archived'];
const draft = ['slug', 'name', 'purpose', 'icon', 'system_prompt', 'draft_revision', 'updated_at', 'configuration', 'published_version', 'enabled', 'archived'];
const mcpInput = ['name', 'description', 'transport', 'url', 'headers'];
const mcpOutput = ['slug', 'name', 'description', 'transport', 'url', 'enabled', 'has_credentials'];
const child = ['name', 'purpose', 'system_prompt'];
@@ -228,7 +228,7 @@ const operations: Record<keyof CloudAgentOperations, OperationSpec> = {
importKnowledgeAttachment: { method: 'POST', path: '/agents/:slug/knowledge/:kb_id/import-attachment', body: ['operation_id', 'thread_id', 'attachment_id'], output: ['file_id', 'name', 'size', 'status', 'error', 'chunk_count', 'available', 'replaces_file_id', 'processing_task'] },
archiveAgent: { method: 'PUT', path: '/agents/:slug/archive', body: ['archived'], output: draft },
archiveThread: { method: 'PUT', path: '/threads/:thread_id/archive', body: ['archived'], output: ['thread_id', 'archived'] },
version: { method: 'GET', path: '/agents/:slug/versions/:version', output: ['version', 'created_at', 'name', 'purpose', 'system_prompt', 'configuration'] },
version: { method: 'GET', path: '/agents/:slug/versions/:version', output: ['version', 'created_at', 'name', 'purpose', 'icon', 'system_prompt', 'configuration'] },
restoreVersion: { method: 'POST', path: '/agents/:slug/versions/:version/restore', body: ['expected_revision'], output: draft },
scheduleContext: { method: 'GET', path: '/agents/:slug/schedule-context', output: ['version', 'enabled', 'configuration', 'result_destination', 'payer'] },
knowledge: { method: 'GET', path: '/agents/:slug/knowledge', output: ['databases', 'models'] },
+39 -8
View File
@@ -43,6 +43,8 @@ const MESSAGES: Record<string, string> = {
knowledge_processing: '知识库有文档正在处理,请完成后再维护',
agent_archived: '请先从归档恢复智能体,再启用',
attachment_too_large: '附件最大支持 5 MB',
avatar_too_large: '头像图片不能超过 5 MB',
avatar_invalid: '请选择有效的 PNG、JPEG、WebP 或 GIF 图片(最大 5 MB)',
download_failed: '文件保存失败,请重试',
channel_not_found: '微信渠道不存在或已解绑',
channel_operation_conflict: '微信渠道正在处理其他操作,请稍后查看状态',
@@ -104,6 +106,7 @@ function projectDraft(value: unknown): CloudAgentDraft {
try { return {
slug: slug(textField(item.slug, 80)),
name: textField(item.name, 100),
icon: item.icon == null ? null : textField(item.icon, 255, false),
purpose: textField(item.purpose, 2000),
system_prompt: textField(item.system_prompt, 32000, false),
draft_revision: revision(item.draft_revision),
@@ -183,11 +186,11 @@ export class CloudAgentsModule {
|| (body.next_cursor !== null && (typeof body.next_cursor !== 'string' || !/^\d{1,16}$/.test(body.next_cursor)))) {
throw new CloudAgentsError(502, 'cloud_service_unavailable');
}
return { agents: body.agents.map(projectDraft), next_cursor: body.next_cursor as string | null };
return { agents: body.agents.map(item => this.withAvatar(projectDraft(item))), next_cursor: body.next_cursor as string | null };
}
async get(agentSlug: string): Promise<CloudAgentDraft> {
return projectDraft(await this.request(`/api/makelore/agents/${slug(agentSlug)}`, 'GET'));
return this.withAvatar(projectDraft(await this.request(`/api/makelore/agents/${slug(agentSlug)}`, 'GET')));
}
async create(value: unknown): Promise<CloudAgentDraft> {
@@ -200,8 +203,9 @@ export class CloudAgentsModule {
operation_id: operationId,
name: textField(input.name, 100).trim(),
purpose: textField(input.purpose, 2000).trim(),
...(input.icon === undefined ? {} : { icon: input.icon === null ? null : textField(input.icon, 255, false) }),
};
return this.withRecovery('createAgent', { ...body }, () => this.request('/api/makelore/agents', 'POST', body).then(projectDraft));
return this.withRecovery('createAgent', { ...body }, () => this.request('/api/makelore/agents', 'POST', body).then(result => this.withAvatar(projectDraft(result))));
}
private async withRecovery<T>(operation: string, input: Record<string, unknown>, execute: () => Promise<T>): Promise<T> {
@@ -294,9 +298,10 @@ export class CloudAgentsModule {
name: textField(input.name, 100).trim(),
purpose: textField(input.purpose, 2000).trim(),
system_prompt: textField(input.system_prompt, 32000, false),
...(input.icon === undefined ? {} : { icon: input.icon === null ? null : textField(input.icon, 255, false) }),
...(input.configuration === undefined ? {} : { configuration: configuration(input.configuration) }),
};
return projectDraft(await this.request(`/api/makelore/agents/${slug(agentSlug)}/draft`, 'PATCH', body));
return this.withAvatar(projectDraft(await this.request(`/api/makelore/agents/${slug(agentSlug)}/draft`, 'PATCH', body)));
}
private async pollActivity(): Promise<void> {
@@ -353,6 +358,11 @@ export class CloudAgentsModule {
} else result = await invoke();
try {
const projected = plan.project(result);
if (plan.operation === 'received') {
const collection = record(projected);
return { ...collection, agents: Array.isArray(collection.agents) ? collection.agents.map(item => this.withAvatar(record(item))) : [] };
}
if (['entry', 'version', 'archiveAgent', 'restoreVersion'].includes(plan.operation)) return this.withAvatar(record(projected));
if (record(value).operation === 'access') {
// The address is public; both exchanged tokens stay in Main.
const binding = getWorksSquareAccountBinding();
@@ -369,6 +379,24 @@ export class CloudAgentsModule {
}
}
private withAvatar<T extends { icon?: unknown }>(value: T): T & { icon_url: string | null } {
let iconUrl: string | null = null;
if (typeof value.icon === 'string' && value.icon) {
try {
const url = new URL(value.icon, this.cached?.apiBaseUrl);
if (['https:', 'http:'].includes(url.protocol) && !url.username && !url.password) iconUrl = url.href;
} catch { /* Invalid legacy icons use the default avatar. */ }
}
return { ...value, icon_url: iconUrl };
}
async uploadAvatar(): Promise<{ icon: string; icon_url: string | null } | null> {
const body = await this.pickUpload(true);
if (!body) return null;
const result = record(await this.request('/api/makelore/avatars', 'POST', body));
return this.withAvatar({ icon: textField(result.icon, 255) });
}
async upload(): Promise<CloudUpload | null> {
const body = await this.pickUpload();
if (!body) return null;
@@ -402,20 +430,23 @@ export class CloudAgentsModule {
return { draft_id: result.draft_id, items: result.items };
}
private async pickUpload(): Promise<FormData | null> {
private async pickUpload(avatar = false): Promise<FormData | null> {
const binding = getWorksSquareAccountBinding();
if (!binding) throw new CloudAgentsError(401, 'session_expired');
const { dialog } = await import('electron');
const { readFile, stat } = await import('node:fs/promises');
const { basename } = await import('node:path');
const picked = await dialog.showOpenDialog({ title: '添加智能体附件(最大 5 MB)', properties: ['openFile'] });
const picked = await dialog.showOpenDialog({
title: avatar ? '选择智能体头像(最大 5 MB)' : '添加智能体附件(最大 5 MB)', properties: ['openFile'],
...(avatar ? { filters: [{ name: '头像图片', extensions: ['png', 'jpg', 'jpeg', 'webp', 'gif'] }] } : {}),
});
this.requireCurrent(binding);
if (picked.canceled || !picked.filePaths[0]) return null;
const path = picked.filePaths[0];
if ((await stat(path)).size > 5 * 1024 * 1024) throw new CloudAgentsError(422, 'attachment_too_large');
if ((await stat(path)).size > 5 * 1024 * 1024) throw new CloudAgentsError(422, avatar ? 'avatar_too_large' : 'attachment_too_large');
const bytes = await readFile(path);
this.requireCurrent(binding);
if (bytes.length > 5 * 1024 * 1024) throw new CloudAgentsError(422, 'attachment_too_large');
if (bytes.length > 5 * 1024 * 1024) throw new CloudAgentsError(422, avatar ? 'avatar_too_large' : 'attachment_too_large');
const body = new FormData();
body.append('file', new Blob([new Uint8Array(bytes)]), basename(path));
return body;
+11 -3
View File
@@ -1,6 +1,12 @@
export const CLOUD_AGENTS_PATH = '/api/cloud-agents';
export interface CloudAgentDraft {
export interface CloudAgentAvatar {
icon?: string | null;
/** Main resolves Yuxi public paths for display; only icon is saved. */
icon_url?: string | null;
}
export interface CloudAgentDraft extends CloudAgentAvatar {
slug: string;
name: string;
purpose: string;
@@ -22,6 +28,7 @@ export interface CreateCloudAgent {
operation_id: string;
name: string;
purpose: string;
icon?: string | null;
}
export interface SaveCloudAgentDraft {
@@ -30,6 +37,7 @@ export interface SaveCloudAgentDraft {
purpose: string;
system_prompt: string;
configuration?: CloudAgentConfiguration;
icon?: string | null;
}
export interface CloudAgentConfiguration {
@@ -51,7 +59,7 @@ export const EMPTY_CLOUD_CONFIGURATION: CloudAgentConfiguration = {
tool_approval_mode: 'default', max_execution_steps: 300, max_output_tokens: 4096, max_run_seconds: 600,
};
export interface CloudAgentEntry {
export interface CloudAgentEntry extends CloudAgentAvatar {
slug: string; name: string; purpose: string; published_version: number;
is_creator?: boolean; payer?: string;
}
@@ -76,7 +84,7 @@ export interface CloudThread {
draft_revision?: number;
archived?: boolean;
}
export interface CloudVersion { version: number; created_at: string; name: string; purpose: string; system_prompt: string; configuration: CloudAgentConfiguration }
export interface CloudVersion extends CloudAgentAvatar { version: number; created_at: string; name: string; purpose: string; system_prompt: string; configuration: CloudAgentConfiguration }
export interface CloudRun {
agent_run_id: string; request_id: string; thread_id: string; agent_slug: string;
status: string; output: string; version: string;
+1
View File
@@ -3,6 +3,7 @@ import type { CloudAgentOperations, CloudUpload, CloudKnowledgeFile, CloudRecove
import { CLOUD_AGENTS_PATH, type CloudAgentDraft, type CloudAgentPage, type CreateCloudAgent, type SaveCloudAgentDraft } from '../../shared/cloud-agents';
export const cloudAgentsApi = {
uploadAvatar: () => hostApiFetch<{ icon: string; icon_url: string | null } | null>(CLOUD_AGENTS_PATH + '/avatars/pick', { method: 'POST' }),
recovery: () => hostApiFetch<CloudRecoveryState>(CLOUD_AGENTS_PATH + '/recovery'),
remember: (recent: CloudRecent | null) => hostApiFetch(CLOUD_AGENTS_PATH + '/recovery/recent', {
method: 'PUT', body: JSON.stringify(recent),
+56
View File
@@ -0,0 +1,56 @@
import { useEffect, useRef, useState } from 'react';
import { ImagePlus, Loader2, Sparkles } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
import type { CloudAgentAvatar } from '../../../shared/cloud-agents';
export function CloudAvatar({ src, name, className = 'h-12 w-12' }: {
src?: string | null; name: string; className?: string;
}) {
const [failed, setFailed] = useState<string | null>(null);
const style = `inline-flex shrink-0 items-center justify-center overflow-hidden rounded-xl bg-violet-50 text-violet-500 ${className}`;
return src && failed !== src
? <img src={src} alt={`${name}头像`} className={style + ' object-cover'} onError={() => setFailed(src)} />
: <span role="img" aria-label={`${name}默认头像`} className={style}><Sparkles className="h-1/2 w-1/2" strokeWidth={1.5} /></span>;
}
export function CloudAvatarEditor({ value, name, disabled, uploading, onUploading, onChange }: {
value: CloudAgentAvatar; name: string; disabled: boolean; uploading: boolean;
onUploading: (busy: boolean) => void; onChange: (avatar: CloudAgentAvatar) => void;
}) {
const [error, setError] = useState('');
const alive = useRef(true);
const pending = useRef(false);
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
const upload = async () => {
if (disabled || pending.current) return;
pending.current = true; onUploading(true); setError('');
try {
const avatar = await cloudAgentsApi.uploadAvatar();
if (alive.current && avatar) onChange(avatar);
} catch (failure) {
if (alive.current) setError(failure instanceof Error ? failure.message : '头像上传失败,请重试');
} finally {
pending.current = false;
if (alive.current) onUploading(false);
}
};
return <div className="space-y-2" role="group" aria-label="智能体头像">
<div className="flex items-center gap-4">
<CloudAvatar src={value.icon_url} name={name || '智能体'} className="h-16 w-16" />
<div className="space-y-2">
<p className="text-sm font-medium">头像</p>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" size="sm" disabled={disabled || uploading} onClick={() => void upload()}>
{uploading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <ImagePlus className="mr-2 h-4 w-4" />}
{uploading ? '正在上传…' : value.icon ? '更换头像' : '上传头像'}
</Button>
{value.icon && <Button type="button" variant="ghost" size="sm" disabled={disabled || uploading}
onClick={() => { setError(''); onChange({ icon: null, icon_url: null }); }}>移除头像</Button>}
</div>
<p className="text-xs text-muted-foreground">PNG、JPEG、WebP 或 GIF,最大 5 MB。保存后生效。</p>
</div>
</div>
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
</div>;
}
+6 -1
View File
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
import { CloudAvatar } from './CloudAvatar';
import type { CloudAgentDraft, CloudVersion } from '../../../shared/cloud-agents';
import { usePendingCloudInput } from './CloudPending';
@@ -38,7 +39,11 @@ export function CloudLifecycle({ slug, revision, versions, onChanged }: {
})}>比较版本 {v}</Button>)}</div>
{version && current && <div className="space-y-3 rounded-xl border border-border p-4 text-sm">
<h3 className="font-medium">当前草稿修订 {current.draft_revision} → 版本 {version.version}</h3>
{!rows.length && <p>配置相同。</p>}
{!rows.length && (current.icon ?? null) === (version.icon ?? null) && <p>配置相同。</p>}
{(current.icon ?? null) !== (version.icon ?? null) && <div className="flex gap-6">
<div><p className="mb-2 text-muted-foreground">当前草稿头像</p><CloudAvatar src={current.icon_url} name={current.name} /></div>
<div><p className="mb-2 text-muted-foreground">旧版本头像</p><CloudAvatar src={version.icon_url} name={version.name} /></div>
</div>}
<div className="max-h-96 overflow-auto"><table className="w-full table-fixed text-left"><thead><tr><th className="w-1/5 p-2">字段</th><th className="p-2">当前草稿</th><th className="p-2">旧版本</th></tr></thead>
<tbody>{rows.map(([key, before, after]) => <tr key={key} className="border-t align-top"><td className="break-words p-2">{key}</td><td className="whitespace-pre-wrap break-words p-2">{before}</td><td className="whitespace-pre-wrap break-words p-2">{after}</td></tr>)}</tbody></table></div>
<Button variant="outline" disabled={busy || current.draft_revision !== revision} onClick={() => void act(async () => {
+2
View File
@@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button';
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
import type { CloudAgentEntry, CloudThread } from '../../../shared/cloud-agents';
import { cloudStatus } from './CloudChat';
import { CloudAvatar } from './CloudAvatar';
export function CloudOverview({ section, onAgent, onThread }: {
section: 'received' | 'activity'; onAgent: (entry: CloudAgentEntry) => void; onThread: (thread: CloudThread) => void;
@@ -51,6 +52,7 @@ export function CloudOverview({ section, onAgent, onThread }: {
{!loading && !agents.length && <p className="py-12 text-center text-sm text-muted-foreground">还没有收到分享</p>}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">{agents.map(agent => <button key={agent.slug} onClick={() => onAgent(agent)}
className="rounded-xl border border-border p-5 text-left transition-colors hover:bg-muted/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<CloudAvatar src={agent.icon_url} name={agent.name} className="mb-4 h-12 w-12" />
<h2 className="font-medium">{agent.name}</h2><p className="mt-2 line-clamp-3 text-sm leading-6 text-muted-foreground">{agent.purpose}</p>
<p className="mt-4 text-xs text-muted-foreground">版本 {agent.published_version} · 打开对话</p>
</button>)}</div></> : <>
+16 -7
View File
@@ -18,10 +18,12 @@ import { DesktopSplit } from './DesktopSplit';
import { AgentRequirements } from './AgentRequirements';
import { agentExamples } from './agent-guidance';
import { useCloudPublication } from './useCloudPublication';
import { CloudAvatar, CloudAvatarEditor } from './CloudAvatar';
type Tab = 'configuration' | 'chat' | 'tasks' | 'access';
type AdvancedCategory = 'capabilities' | 'knowledge' | 'limits';
const hasDraftChanges = (draft: CloudAgentDraft, saved: CloudAgentDraft) => draft.name !== saved.name || draft.purpose !== saved.purpose || draft.system_prompt !== saved.system_prompt
|| (draft.icon ?? null) !== (saved.icon ?? null)
|| JSON.stringify(draft.configuration) !== JSON.stringify(saved.configuration);
const errorMessage = (error: unknown) => error instanceof Error ? error.message : '暂时无法完成操作';
@@ -37,6 +39,7 @@ export function DraftEditor({ initial, recent, onBack, onSaved, startingPrompt }
const [previewCreated, setPreviewCreated] = useState(Boolean(initial.configuration?.model));
const [remote, setRemote] = useState<CloudAgentDraft | null>(null);
const [savingDraft, setBusy] = useState(false);
const [uploadingAvatar, setUploadingAvatar] = useState(false);
const [error, setError] = useState('');
const [leaving, setLeaving] = useState(false);
const initialTab = recent?.mode === 'preview' ? 'configuration' : initial.published_version ? 'chat' : 'configuration';
@@ -61,7 +64,7 @@ export function DraftEditor({ initial, recent, onBack, onSaved, startingPrompt }
onSaved(next);
if (!saved.published_version) navigate('chat');
});
const busy = savingDraft || publication.busy;
const busy = savingDraft || publication.busy || uploadingAvatar;
const hasPending = pending.hasPending || formalPending.hasPending || Boolean(publication.pending);
const canSave = dirty && !busy && !remote && Boolean(draft.name.trim() && draft.purpose.trim());
const needsApply = Boolean(saved.published_version) && publication.appliedRevision !== undefined
@@ -90,10 +93,11 @@ export function DraftEditor({ initial, recent, onBack, onSaved, startingPrompt }
const result = await cloudAgentsApi.save(saved.slug, {
expected_revision: saved.draft_revision, name: draft.name, purpose: draft.purpose, system_prompt: draft.system_prompt,
configuration: draft.configuration ?? EMPTY_CLOUD_CONFIGURATION,
...(draft.icon === undefined ? {} : { icon: draft.icon }),
});
if (!alive.current) return;
setSaved(result); if (result.configuration?.model) setPreviewCreated(true);
setDraft(current => !hasDraftChanges(current, draft) ? result : { ...result, name: current.name, purpose: current.purpose, system_prompt: current.system_prompt, configuration: current.configuration });
setDraft(current => !hasDraftChanges(current, draft) ? result : { ...result, name: current.name, purpose: current.purpose, icon: current.icon, icon_url: current.icon_url, system_prompt: current.system_prompt, configuration: current.configuration });
setRemote(null); onSaved(result);
if (exit) finishLeaving();
return result.draft_revision;
@@ -118,11 +122,14 @@ export function DraftEditor({ initial, recent, onBack, onSaved, startingPrompt }
<header className="agent-workspace-header">
<Button variant="ghost" size="icon" className="h-10 w-10 shrink-0" aria-label="我的智能体" disabled={busy}
onClick={() => dirty || hasPending ? setLeaving(true) : onBack()}><ArrowLeft className="h-4 w-4" /></Button>
<div className="min-w-0 flex-1">
<Input aria-label="名称" maxLength={100} value={draft.name} disabled={busy}
className="h-8 max-w-96 border-transparent bg-transparent px-2 font-semibold shadow-none hover:border-input focus:border-input"
onChange={event => setDraft({ ...draft, name: event.target.value })} />
<p className="px-2 text-xs text-muted-foreground">{saved.published_version ? '我的助手' : startingPrompt !== undefined ? '第 3 步,共 3 步 · 试一试' : '制作中的助手 · 仅自己可见'}</p>
<div className="flex min-w-0 flex-1 items-center gap-2">
<CloudAvatar src={draft.icon_url} name={draft.name} className="h-10 w-10" />
<div className="min-w-0 flex-1">
<Input aria-label="名称" maxLength={100} value={draft.name} disabled={busy}
className="h-8 max-w-96 border-transparent bg-transparent px-2 font-semibold shadow-none hover:border-input focus:border-input"
onChange={event => setDraft({ ...draft, name: event.target.value })} />
<p className="px-2 text-xs text-muted-foreground">{saved.published_version ? '我的助手' : startingPrompt !== undefined ? '第 3 步,共 3 步 · 试一试' : '制作中的助手 · 仅自己可见'}</p>
</div>
</div>
<span role="status" className={`shrink-0 text-xs ${needsApply || dirty ? 'text-amber-800' : 'text-muted-foreground'}`}>{savingDraft ? '正在保存…' : dirty ? '有未保存修改' : saved.published_version ? applicationStatus : '云端已保存'}</span>
<Button className="min-h-10" size="sm" variant={dirty ? 'default' : 'outline'} disabled={!canSave} onClick={() => void save()} title="保存草稿(Ctrl / ⌘ S)">
@@ -171,6 +178,8 @@ export function DraftEditor({ initial, recent, onBack, onSaved, startingPrompt }
<Button key={key} variant={advancedCategory === key ? 'secondary' : 'ghost'} aria-current={advancedCategory === key ? 'page' : undefined} onClick={() => setAdvancedCategory(key)}>{label}</Button>)}</nav>}
<div ref={configurationScroll} className="agent-config-scroll"><fieldset disabled={busy} className="min-w-0">
<div hidden={category !== 'instructions'} className="mb-6 space-y-6">
<CloudAvatarEditor value={draft} name={draft.name} disabled={busy} uploading={uploadingAvatar} onUploading={setUploadingAvatar}
onChange={avatar => setDraft(current => ({ ...current, ...avatar }))} />
<label className="block space-y-2 text-sm"><span className="font-medium">用途</span><Textarea aria-label="用途" className="min-h-20 resize-y" maxLength={2000} value={draft.purpose}
onChange={event => setDraft({ ...draft, purpose: event.target.value })} placeholder="一句话介绍,它可以帮你做什么。" /></label>
<AgentRequirements value={draft.system_prompt} onChange={system_prompt => setDraft({ ...draft, system_prompt })} />
+15 -8
View File
@@ -16,6 +16,8 @@ import { CloudRecovery } from './CloudRecovery';
import { ChannelAccounts } from './ChannelAccounts';
import { AgentRequirements } from './AgentRequirements';
import { agentStarters } from './agent-guidance';
import { CloudAvatar, CloudAvatarEditor } from './CloudAvatar';
import type { CloudAgentAvatar } from '../../../shared/cloud-agents';
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : '暂时无法连接智能体服务,请重试';
@@ -149,7 +151,7 @@ function AgentWorkspace() {
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{agents.map((agent) => <button key={agent.slug} disabled={creating} onClick={() => setSelected(agent)}
className="rounded-xl border border-border bg-background p-5 text-left transition-colors enabled:hover:bg-violet-50/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50">
<span className="mb-4 flex items-center justify-between"><Sparkles className="h-5 w-5 text-violet-500" />
<span className="mb-4 flex items-center justify-between"><CloudAvatar src={agent.icon_url} name={agent.name} />
<span className="text-xs text-muted-foreground">{agent.archived ? '已归档' : !agent.enabled ? '已停用' : agent.published_version ? '可以使用' : '还在制作'}</span></span>
<h2 className="truncate font-medium">{agent.name}</h2>
<p className="mt-2 line-clamp-3 whitespace-pre-wrap text-sm leading-6 text-muted-foreground">{agent.purpose}</p>
@@ -168,22 +170,25 @@ function CreateAgentForm({ onCreated, onCancel }: { onCreated: (agent: CloudAgen
const [purpose, setPurpose] = useState('');
const [prompt, setPrompt] = useState('');
const [intent, setIntent] = useState<CreateCloudAgent | null>(null);
const [busy, setBusy] = useState(false);
const [creatingAgent, setBusy] = useState(false);
const [avatar, setAvatar] = useState<CloudAgentAvatar>({});
const [uploadingAvatar, setUploadingAvatar] = useState(false);
const busy = creatingAgent || uploadingAvatar;
const [error, setError] = useState('');
const alive = useRef(true);
const blocker = useBlocker(busy || intent !== null || Boolean(name || purpose || prompt));
const blocker = useBlocker(busy || intent !== null || Boolean(name || purpose || prompt || avatar.icon));
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
useEffect(() => {
const warn = (event: BeforeUnloadEvent) => {
if (busy || intent || name || purpose || prompt) { event.preventDefault(); event.returnValue = ''; }
if (busy || intent || name || purpose || prompt || avatar.icon) { event.preventDefault(); event.returnValue = ''; }
};
window.addEventListener('beforeunload', warn);
return () => window.removeEventListener('beforeunload', warn);
}, [busy, intent, name, purpose, prompt]);
}, [busy, intent, name, purpose, prompt, avatar.icon]);
const submit = async () => {
if (busy || !name.trim() || !purpose.trim()) return;
const input = intent ?? { operation_id: crypto.randomUUID(), name: name.trim(), purpose: purpose.trim() };
const input = intent ?? { operation_id: crypto.randomUUID(), name: name.trim(), purpose: purpose.trim(), ...(avatar.icon ? { icon: avatar.icon } : {}) };
setIntent(input);
setBusy(true);
setError('');
@@ -210,6 +215,8 @@ function CreateAgentForm({ onCreated, onCancel }: { onCreated: (agent: CloudAgen
<div className="flex gap-2"><Button type="button" variant="outline" onClick={() => setStep(2)}>我自己想一个</Button>
<Button type="button" variant="ghost" onClick={onCancel}>取消</Button></div>
</> : <fieldset disabled={busy || intent !== null} className="space-y-4">
<CloudAvatarEditor value={avatar} name={name} disabled={busy || intent !== null} uploading={uploadingAvatar}
onUploading={setUploadingAvatar} onChange={setAvatar} />
<label className="block space-y-2 text-sm"><span>名称</span><Input autoFocus maxLength={100} value={name}
disabled={busy || intent !== null} onChange={(event) => setName(event.target.value)} placeholder="例如:我的写作搭档" /></label>
<label className="block space-y-2 text-sm"><span>用途</span><Textarea aria-label="用途" maxLength={2000} value={purpose}
@@ -218,7 +225,7 @@ function CreateAgentForm({ onCreated, onCancel }: { onCreated: (agent: CloudAgen
</fieldset>}
{error && <p role="alert" className="text-sm text-destructive">{error}。可重试确认本次创建结果。</p>}
{step === 2 && <div className="flex flex-wrap gap-3">
<Button type="submit" disabled={busy || !name.trim() || !purpose.trim()}>{busy ? '正在创建…' : intent ? '重试创建' : '下一步:试一试'}</Button>
<Button type="submit" disabled={busy || !name.trim() || !purpose.trim()}>{creatingAgent ? '正在创建…' : uploadingAvatar ? '等待头像上传…' : intent ? '重试创建' : '下一步:试一试'}</Button>
{!intent && <Button type="button" variant="outline" disabled={busy} onClick={() => setStep(1)}>上一步</Button>}
<Button type="button" variant="ghost" disabled={busy} onClick={onCancel}>{intent ? '返回列表确认' : '取消'}</Button>
</div>}
@@ -248,7 +255,7 @@ function SharedConversation({ shared, activity, recent, onBack }: {
}, [pending.hasPending]);
return <CloudPendingContext.Provider value={pending.report}><section className="agent-desktop p-6">
<Button variant="ghost" className="mb-5" onClick={() => pending.hasPending ? setLeaving(true) : onBack()}><ArrowLeft className="mr-2 h-4 w-4" />返回智能体</Button>
<h1 className="mb-5 text-xl font-semibold">{shared?.name ?? activity?.title}</h1>
<h1 className="mb-5 flex items-center gap-3 text-xl font-semibold">{shared && <CloudAvatar src={shared.icon_url} name={shared.name} />}{shared?.name ?? activity?.title}</h1>
<CloudChat key={shared?.slug ?? activity?.thread_id} slug={shared?.slug ?? activity!.agent_slug} initialThread={activity ?? undefined}
initialThreadId={recent?.slug === shared?.slug ? recent?.thread_id : undefined}
previewRevision={activity?.mode === 'preview' ? activity.draft_revision : undefined} />
+23 -1
View File
@@ -11,6 +11,8 @@ test('personal cloud Agent creation, draft save and leave protection in Electron
await app.evaluate(({ ipcMain, BrowserWindow }) => {
BrowserWindow.getAllWindows()[0].setSize(1366, 768);
let draft: Record<string, unknown> | null = null;
let avatarUploads = 0;
const iconUrl = (icon: unknown) => icon ? 'https://avatars.example.test' + icon : null;
let recent: unknown = null;
const versions: { version: number; draft_revision: number; created_at: string }[] = [];
let knowledgeCatalogLoads = 0;
@@ -38,6 +40,10 @@ test('personal cloud Agent creation, draft save and leave protection in Electron
});
if (path.startsWith('/api/cloud-agents/')) {
const body = typeof request.body === 'string' ? JSON.parse(request.body) : request.body ?? {};
if (path.endsWith('/avatars/pick')) {
const icon = `/api/storage/public/images/avatar-${++avatarUploads}.png`;
return result({ icon, icon_url: iconUrl(icon) });
}
if (path.endsWith('/knowledge/pick')) {
knowledgeUploaded = true;
return result({ ...knowledgeFile, status: 'uploaded', chunk_count: 0, available: false, processing_task: null });
@@ -86,16 +92,19 @@ test('personal cloud Agent creation, draft save and leave protection in Electron
}
if (method === 'POST') draft = {
slug: 'ml-' + 'a'.repeat(32), name: body.name, purpose: body.purpose, system_prompt: body.purpose,
icon: body.icon ?? null, icon_url: iconUrl(body.icon),
draft_revision: 1, updated_at: '2026-09-10T00:00:00Z',
configuration, published_version: null, enabled: true,
};
if (method === 'PATCH') draft = { ...draft, name: body.name, purpose: body.purpose, system_prompt: body.system_prompt, configuration: body.configuration, draft_revision: Number(draft?.draft_revision ?? 1) + 1 };
if (method === 'PATCH') draft = { ...draft, name: body.name, purpose: body.purpose, icon: body.icon ?? null, icon_url: iconUrl(body.icon), system_prompt: body.system_prompt, configuration: body.configuration, draft_revision: Number(draft?.draft_revision ?? 1) + 1 };
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.route('https://avatars.example.test/**', route => route.fulfill({ contentType: 'image/png',
body: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAIAAAD8GO2jAAAALUlEQVR4nGOssXrLQEvARFPTRy0YtWDUglELRi0YtWDUglELRi0YtWDUAioCAGYEAeMrBAV7AAAAAElFTkSuQmCC', 'base64') }));
await page.goto(applicationUrl);
await page.getByTestId('ai-module-option-cloud_agents').click();
await expect(page.getByTestId('sidebar-cloud-agents-navigation')).toBeVisible();
@@ -106,9 +115,15 @@ test('personal cloud Agent creation, draft save and leave protection in Electron
await page.getByRole('button', { name: '先给我提示', exact: true }).click();
await page.getByLabel('名称', { exact: true }).fill('我的写作搭档');
await page.getByLabel('用途', { exact: true }).fill('整理灵感,把想法写成清晰的文章');
await page.getByRole('button', { name: '上传头像', exact: true }).click();
await expect(page.getByRole('img', { name: '我的写作搭档头像', exact: true })).toHaveAttribute('src', /avatar-1\.png$/);
await page.screenshot({ path: testInfo.outputPath('cloud-agent-create-requirements.png') });
await page.getByRole('button', { name: '下一步:试一试', exact: true }).click();
await expect(page.getByTestId('cloud-agent-editor')).toBeVisible();
await expect(page.getByRole('group', { name: '智能体头像' }).getByRole('img')).toHaveAttribute('src', /avatar-1\.png$/);
await page.getByRole('button', { name: '更换头像', exact: true }).click();
await expect(page.getByRole('group', { name: '智能体头像' }).getByRole('img')).toHaveAttribute('src', /avatar-2\.png$/);
await expect.poll(() => page.getByRole('group', { name: '智能体头像' }).getByRole('img').evaluate(el => (el as HTMLImageElement).naturalWidth)).toBe(32);
await page.getByLabel('特别要求').fill('使用简洁自然的中文。先理解我的目的,再协助梳理结构。');
await expect(page.getByLabel('模型', { exact: true })).toHaveValue('test-model');
await expect(page.getByRole('button', { name: '先给我提示', exact: true })).toHaveAttribute('aria-pressed', 'true');
@@ -117,6 +132,7 @@ test('personal cloud Agent creation, draft save and leave protection in Electron
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 expect(page.getByRole('group', { name: '智能体头像' }).getByRole('img')).toHaveAttribute('src', /avatar-2\.png$/);
await expect(page.getByTestId('titlebar-page-title')).toHaveText('智能体工作区');
const requirementsTab = page.getByRole('tab', { name: '我的要求', exact: true });
const advancedTab = page.getByRole('tab', { name: '高级设置', exact: true });
@@ -313,6 +329,12 @@ test('personal cloud Agent creation, draft save and leave protection in Electron
await expect(page.getByLabel('保存后启用')).not.toBeChecked();
await page.getByRole('button', { name: '取消', exact: true }).click();
await page.screenshot({ path: testInfo.outputPath('cloud-agent-schedules.png') });
await page.getByRole('navigation', { name: '智能体工作区' }).getByRole('button', { name: '编辑', exact: true }).click();
await page.getByRole('tab', { name: '我的要求', exact: true }).click();
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 expect(page.getByRole('group', { name: '智能体头像' }).getByRole('img', { name: '我的新版写作搭档默认头像' })).toBeVisible();
} finally {
await app.evaluate(({ BrowserWindow }) => { for (const win of BrowserWindow.getAllWindows()) win.destroy(); }).catch(() => undefined);
await closeElectronApp(app);
+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 },