feat(agents): 复用 Yuxi 头像上传与展示
This commit is contained in:
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.
|
||||
@@ -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 上传该会话截至当前的完整问答快照;只保留用户/助手自然语言文本,过滤代码、路径、日志、工具调用、附件与产物。同步只从本地上行,云端不回写、不恢复或删除本地会话;失败数据留在本地等待重试。
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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'] },
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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></> : <>
|
||||
|
||||
@@ -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 })} />
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in new issue
Block a user