From 235af24d6cf29b7e13b6be29fec9426fd4c6600c Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Mon, 12 Oct 2026 13:48:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(agents):=20=E5=A4=8D=E7=94=A8=20Yuxi=20?= =?UTF-8?q?=E5=A4=B4=E5=83=8F=E4=B8=8A=E4=BC=A0=E4=B8=8E=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tasks/20261012-agent-avatars-840d7fd7.md | 57 ++++++++++++++++ README.md | 1 + electron/api/routes/cloud-agents.ts | 2 + electron/services/cloud-agent-operations.ts | 6 +- electron/services/cloud-agents.ts | 47 ++++++++++--- shared/cloud-agents.ts | 14 +++- src/lib/cloud-agents-api.ts | 1 + src/pages/CloudAgents/CloudAvatar.tsx | 56 +++++++++++++++ src/pages/CloudAgents/CloudLifecycle.tsx | 7 +- src/pages/CloudAgents/CloudOverview.tsx | 2 + src/pages/CloudAgents/DraftEditor.tsx | 23 +++++-- src/pages/CloudAgents/index.tsx | 23 ++++--- tests/e2e/cloud-agents.spec.ts | 24 ++++++- tests/unit/cloud-agent-avatar.test.tsx | 56 +++++++++++++++ tests/unit/cloud-agents-main.test.ts | 68 +++++++++++++++++++ tests/unit/cloud-agents-page.test.tsx | 26 ++++++- 16 files changed, 381 insertions(+), 32 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20261012-agent-avatars-840d7fd7.md create mode 100644 src/pages/CloudAgents/CloudAvatar.tsx create mode 100644 tests/unit/cloud-agent-avatar.test.tsx diff --git a/.project-docs/30-worklog/tasks/20261012-agent-avatars-840d7fd7.md b/.project-docs/30-worklog/tasks/20261012-agent-avatars-840d7fd7.md new file mode 100644 index 00000000..4edd3a33 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20261012-agent-avatars-840d7fd7.md @@ -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. diff --git a/README.md b/README.md index aeef282b..ae86d761 100644 --- a/README.md +++ b/README.md @@ -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 上传该会话截至当前的完整问答快照;只保留用户/助手自然语言文本,过滤代码、路径、日志、工具调用、附件与产物。同步只从本地上行,云端不回写、不恢复或删除本地会话;失败数据留在本地等待重试。 diff --git a/electron/api/routes/cloud-agents.ts b/electron/api/routes/cloud-agents.ts index 87e78120..aee2ddae 100644 --- a/electron/api/routes/cloud-agents.ts +++ b/electron/api/routes/cloud-agents.ts @@ -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') { diff --git a/electron/services/cloud-agent-operations.ts b/electron/services/cloud-agent-operations.ts index 9ebf8c10..e29b7645 100644 --- a/electron/services/cloud-agent-operations.ts +++ b/electron/services/cloud-agent-operations.ts @@ -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 = { 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'] }, diff --git a/electron/services/cloud-agents.ts b/electron/services/cloud-agents.ts index 2cf27e45..25533ab5 100644 --- a/electron/services/cloud-agents.ts +++ b/electron/services/cloud-agents.ts @@ -43,6 +43,8 @@ const MESSAGES: Record = { 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 { - 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 { @@ -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(operation: string, input: Record, execute: () => Promise): Promise { @@ -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 { @@ -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(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 { 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 { + private async pickUpload(avatar = false): Promise { 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; diff --git a/shared/cloud-agents.ts b/shared/cloud-agents.ts index 8e3a50aa..52d50e7d 100644 --- a/shared/cloud-agents.ts +++ b/shared/cloud-agents.ts @@ -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; diff --git a/src/lib/cloud-agents-api.ts b/src/lib/cloud-agents-api.ts index 06da271a..5c6f78bc 100644 --- a/src/lib/cloud-agents-api.ts +++ b/src/lib/cloud-agents-api.ts @@ -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(CLOUD_AGENTS_PATH + '/recovery'), remember: (recent: CloudRecent | null) => hostApiFetch(CLOUD_AGENTS_PATH + '/recovery/recent', { method: 'PUT', body: JSON.stringify(recent), diff --git a/src/pages/CloudAgents/CloudAvatar.tsx b/src/pages/CloudAgents/CloudAvatar.tsx new file mode 100644 index 00000000..8fe397be --- /dev/null +++ b/src/pages/CloudAgents/CloudAvatar.tsx @@ -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(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 + ? {`${name}头像`} setFailed(src)} /> + : ; +} + +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
+
+ +
+

头像

+
+ + {value.icon && } +
+

PNG、JPEG、WebP 或 GIF,最大 5 MB。保存后生效。

+
+
+ {error &&

{error}

} +
; +} diff --git a/src/pages/CloudAgents/CloudLifecycle.tsx b/src/pages/CloudAgents/CloudLifecycle.tsx index 7d650f26..69cae026 100644 --- a/src/pages/CloudAgents/CloudLifecycle.tsx +++ b/src/pages/CloudAgents/CloudLifecycle.tsx @@ -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})} {version && current &&

当前草稿修订 {current.draft_revision} → 版本 {version.version}

- {!rows.length &&

配置相同。

} + {!rows.length && (current.icon ?? null) === (version.icon ?? null) &&

配置相同。

} + {(current.icon ?? null) !== (version.icon ?? null) &&
+

当前草稿头像

+

旧版本头像

+
}
{rows.map(([key, before, after]) => )}
字段当前草稿旧版本
{key}{before}{after}
)}
: <> diff --git a/src/pages/CloudAgents/DraftEditor.tsx b/src/pages/CloudAgents/DraftEditor.tsx index 4fd8dd37..bd244db8 100644 --- a/src/pages/CloudAgents/DraftEditor.tsx +++ b/src/pages/CloudAgents/DraftEditor.tsx @@ -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(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 }
-
- setDraft({ ...draft, name: event.target.value })} /> -

{saved.published_version ? '我的助手' : startingPrompt !== undefined ? '第 3 步,共 3 步 · 试一试' : '制作中的助手 · 仅自己可见'}

+
+ +
+ setDraft({ ...draft, name: event.target.value })} /> +

{saved.published_version ? '我的助手' : startingPrompt !== undefined ? '第 3 步,共 3 步 · 试一试' : '制作中的助手 · 仅自己可见'}

+
{savingDraft ? '正在保存…' : dirty ? '有未保存修改' : saved.published_version ? applicationStatus : '云端已保存'} )}}