From 49844ee42a703fe5ea94c5feca856d28116ee3b6 Mon Sep 17 00:00:00 2001 From: Jason Carter Date: Wed, 23 Sep 2026 22:20:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20Agent=20=E5=A4=B4=E5=83=8F/?= =?UTF-8?q?=E5=A4=B4=E8=A1=94=E5=8F=AF=E7=BC=96=E8=BE=91=E3=80=81=E5=9B=9E?= =?UTF-8?q?=E8=B0=83=E4=BC=9A=E8=AF=9D=E8=B7=AF=E7=94=B1=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E3=80=81=E6=B6=88=E6=81=AF=E6=B0=94=E6=B3=A1=E6=8A=98=E5=8F=A0?= =?UTF-8?q?=E4=B8=8E=20i18n=20=E5=9B=9E=E9=80=80=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 头像(Team Chat 顶部): - 顶部 Agent 头像改为可点击上传(复用既有 AvatarUpload,targetType=agent) - 上传成功后乐观写入本地 agents,顶部/L1/L2 同帧换图,不再等 GET /api/agents 全量刷新 - 上传中强制常显遮罩+转圈(原 opacity-0 group-hover 会把反馈藏掉) - 点击外部 / Esc 关闭浮层;补齐 zh-CN/en/es 文案 头像缓存: - 上传返回带 ?v=<时间戳> 的版本化 URL,避免同名文件命中一年期 immutable 缓存 - 头像响应改为 no-cache(serveStaticFile 支持覆盖),存量无版本号 URL 也能即时刷新 - 带哈希的静态资源仍保持 immutable Agent 头衔: - 新增 AgentProfile.displayTitle,持久化到本 Agent 的 profile JSON - PATCH /api/agents/:id/config 接受 title,两个 GET 读取覆盖 - 前端保存目标仅改一处;不写共享角色模板,避免串改其他 Agent 回调会话路由(callback_result): - TurnSessionHint 补第五种 kind:'memory'(显式表达继续某内存会话) - deliverCallback 在 in_session 投递时写入 extra.sessionHint,不再让下游猜 - resolveTurnSession 新增 memory 分支;解析失败则告警并保持当前会话 - 消费端删除 sys_${id}_${ts} 自造兜底,只在 origin 已知时传 sessionId 消息气泡渲染: - 工具调用失败不再把图标变警告(收敛为两态 running/done),失败次数保留在文字中 - 流式结束后,最终正文之前的过程块(中间正文/思考/工具行)折叠为一行「已工作 N 秒 / N 分 N 秒」,可展开收起 - 流式期间不折叠;单正文简单回复不折叠;无正文块不折叠 i18n: - react.i18n nsMode 设为 'fallback',修复 useTranslation([...]) 只绑 namespaces[0] 导致 team/common 回退链失效、渲染出 execution.workedForMinutes 这类 key - formatWorkedFor 改用显式 common: 前缀,不依赖调用方绑定的 ns - 清理 AgentProfile 未使用的 AvatarUpload 导入及死键 processRun.state.error 验证: - pnpm typecheck 通过 - 全量测试 5718 passed;唯一失败 packages/cli/test/commands-start-integration.test.ts > auto-runs quickInit(60s 超时),已在 HEAD a004a3da 干净基线上复现同样失败,确认与本批改动无关 - 新增回归测试:回调投递必须表态 memory、无 origin 时绝不出现 sys_*、二次上传 URL 必须变化、头像响应不得含 immutable、上传挂起时遮罩常显、耗时文案三语可译 --- packages/core/src/agent.ts | 39 ++- packages/core/src/session-hint.ts | 11 +- packages/core/test/callback-scenario.test.ts | 47 ++++ packages/org-manager/src/api-server.ts | 49 +++- .../test/api-server-extended.test.ts | 40 +++ packages/shared/src/types/agent.ts | 7 + packages/storage/src/sqlite-storage.ts | 3 +- packages/web-ui/src/components/Avatar.tsx | 14 +- packages/web-ui/src/i18n/index.ts | 7 + packages/web-ui/src/locales/en/common.json | 4 +- packages/web-ui/src/locales/en/team.json | 1 + packages/web-ui/src/locales/es/common.json | 4 +- packages/web-ui/src/locales/es/team.json | 1 + packages/web-ui/src/locales/zh-CN/common.json | 4 +- packages/web-ui/src/locales/zh-CN/team.json | 1 + packages/web-ui/src/pages/AgentProfile.tsx | 2 +- .../web-ui/src/pages/ChatComponents.test.ts | 232 ++++++++++++++---- packages/web-ui/src/pages/ChatComponents.tsx | 153 +++++++++--- packages/web-ui/src/pages/Team.tsx | 77 +++++- .../web-ui/test/avatarUploadFeedback.test.tsx | 113 +++++++++ packages/web-ui/test/workedLabelI18n.test.ts | 130 ++++++++++ 21 files changed, 833 insertions(+), 106 deletions(-) create mode 100644 packages/web-ui/test/avatarUploadFeedback.test.tsx create mode 100644 packages/web-ui/test/workedLabelI18n.test.ts diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 59988137d..aefb60bc3 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -2282,9 +2282,13 @@ export class Agent { } case 'callback_result': { - // Route to the originating session if known, otherwise a fresh system session + // 会话身份由契约解析(`resolveTurnSession` 读 `extra.sessionHint`, + // 见 `deliverCallback`——它把发起轮的 `originSessionId` 表态为 `memory`)。 + // 旧实现写的是 `originSessionId ?? `sys_${this.id}_${ts}``:**一旦 origin + // 缺失,每完成一次就新建一个会话**,把后台结果从原会话里孤立出去(磁盘上 + // 实测累计 200+ 个 `sys_*` 空壳会话)。契约规定「推不出来就保持当前会话, + // 绝不静默新建」,所以这里不再兜底造 id。 const originSessionId = extra.originSessionId as string | undefined; - const cbSessionId = originSessionId ?? `sys_${this.id}_${ts}`; // Replay the scenario the callback was registered from (`deliverCallback`), // defaulting to `heartbeat`. Hardcoding `heartbeat` for callbacks gave the // turn the **reflex** pack — no `file_write` / `file_edit` / `shell_execute` / @@ -2293,13 +2297,17 @@ export class Agent { // therefore only *notify* on completion, never continue the task, which // contradicts `background_exec`'s documented contract. const cbScenario = asAgentScenario(extra.scenario) ?? 'heartbeat'; + const cbOpts = buildHandleOpts({ scenario: cbScenario }); + // origin 已知 → 显式回到发起轮;未知 → 不传,交由 resolveTurnSession + // 的「保持当前会话」纪律处理。 + if (originSessionId) cbOpts.sessionId = originSessionId; let reply = await this.handleMessage( item.payload.content + markerSuffix, undefined, undefined, - buildHandleOpts({ sessionId: cbSessionId, scenario: cbScenario }), + cbOpts, ); - if (needsMarker) reply = await this.ensureCompletionMarker(reply, cbSessionId); + if (needsMarker) reply = await this.ensureCompletionMarker(reply, cbOpts.sessionId ?? this.currentSessionId); resolveResponse(reply); return reply; } @@ -2428,6 +2436,7 @@ export class Agent { * - new :显式新对话(用户点了「新对话」) * - system :系统/内部会话(heartbeat / task / report / announce / a2a / channel)—— 保持既有语义, * 不动当前会话(各分支自己决定系统、任务、频道的会话 id) + * - memory :继续指定的**内存会话**(异步回调回到发起它的那一轮) * - unknown :入口没表态 → **告警**并保持当前会话(不新建、不静默) * * 另外:只要 hint 带 DB 身份,就顺手在**处理该 item 的工作区**里写 DB→内存绑定。 @@ -2447,6 +2456,22 @@ export class Agent { case 'system': // 系统/内部会话由各自分支决定 sessionId,这里不干预当前会话。 break; + case 'memory': + // 继续指定的内存会话(`sess_*`)——异步回调回到发起它的那一轮。 + // MemoryStore 会按 id 惰性从磁盘加载,所以会话冷掉也能重接。 + // 解析不到(已被清理)则告警并保持当前会话:与 `unknown` 同一条纪律, + // **绝不静默新建**(旧行为就是在这里上游造 `sys_*` 空壳会话)。 + if (this.memory.getSession(hint.memorySessionId)) { + this.currentSessionId = hint.memorySessionId; + } else { + log.warn('Turn names a memory session that is no longer resolvable — keeping the current session', { + agentId: this.id, + itemId: item.id, + sourceType: item.sourceType, + memorySessionId: hint.memorySessionId, + }); + } + break; case 'unknown': log.warn('Turn has NO session identity — keeping the current session (never silently starting a new one)', { agentId: this.id, @@ -7035,6 +7060,12 @@ export class Agent { extra: { callbackId: cb.callbackId, originSessionId: cb.originSessionId, + // 会话身份契约:回调属于**发起它的那一轮**。显式表态为 `memory`, + // 否则该轮会落到 `unknown`(每条回调都告警、且不写 DB→内存绑定), + // 消费端只能自己拼兜底会话 id——后台结果因此被孤立到独立会话。 + sessionHint: cb.originSessionId + ? { kind: 'memory' as const, memorySessionId: cb.originSessionId } + : undefined, callbackType: cb.type, correlationId: cb.correlationId, exitCode: cb.exitCode, diff --git a/packages/core/src/session-hint.ts b/packages/core/src/session-hint.ts index 4b20ecbac..fb0013bc5 100644 --- a/packages/core/src/session-hint.ts +++ b/packages/core/src/session-hint.ts @@ -6,9 +6,10 @@ * channelKey / 什么都不传)决定的;一旦某个入口漏传,agent 就会静默开一个新会话 * ——「同一会话后续请求看不到历史」反复出现,根因就在这里。 * - * 契约:**每个入口都必须显式表态这一轮是什么会话**,四种可能: + * 契约:**每个入口都必须显式表态这一轮是什么会话**,五种可能: * - new 显式新对话(用户点了「新对话」) * - existing 继续一个已存在的 DB 会话(带 DB 身份,可带绑定内存会话与历史) + * - memory 继续一个**内存会话**(`sess_*`)——异步回调回到发起它的那一轮 * - system 系统/内部会话(heartbeat / task / report / announce / a2a / channel) * - unknown 入口没表态 → **必须告警**,并按「不改变当前会话」处理,绝不静默新建 * @@ -31,6 +32,13 @@ export type TurnSessionHint = messages?: Array<{ role: string; content: string }>; isRetry?: boolean; } + /** + * 继续一个**内存会话**(`sess_*`)。用于「本会话里发出的东西必须由本会话处理」 + * 的异步场景:`background_exec` / `a2a_reply` 等回调携带发起轮的 `originSessionId`, + * 就是这一种。没有它,回调会落到 `unknown`(告警且不写 DB→内存绑定),消费端 + * 只能自己拼一个兜底会话 id —— 那正是「后台结果被孤立到独立会话」的根因。 + */ + | { kind: 'memory'; memorySessionId: string } | { kind: 'system'; role: 'heartbeat' | 'task' | 'report' | 'announce' | 'a2a' | 'channel' | 'workflow' | 'federation'; @@ -124,6 +132,7 @@ export function describeTurnSessionHint(hint: TurnSessionHint): string { switch (hint.kind) { case 'new': return `new(db=${hint.dbSessionId ?? '-'})`; case 'existing': return `existing(db=${hint.dbSessionId}, preferred=${hint.preferredMemorySessionId ?? '-'})`; + case 'memory': return `memory(${hint.memorySessionId})`; case 'system': return `system(${hint.role}${hint.key ? `,key=${hint.key}` : ''})`; case 'unknown': return `unknown(${hint.reason})`; } diff --git a/packages/core/test/callback-scenario.test.ts b/packages/core/test/callback-scenario.test.ts index d312000d4..10c894304 100644 --- a/packages/core/test/callback-scenario.test.ts +++ b/packages/core/test/callback-scenario.test.ts @@ -219,4 +219,51 @@ describe('异步回调的场景回放', () => { expect(scen).toEqual(['task_execution', 'heartbeat']); }); + + // ── 会话身份契约:本会话里发出的东西,必须由本会话处理 ──────────────────── + // 旧实现消费端写的是 `originSessionId ?? `sys_${this.id}_${ts}``:一旦 origin + // 缺失就**每完成一次新建一个会话**,把后台结果从原会话里孤立出去(磁盘上实测 + // 累计 200+ 个 `sys_*` 空壳会话)。契约规定「推不出来就保持当前会话」。 + it('会话身份:deliverCallback 把发起会话表态为 memory 契约(否则落到 unknown:告警且不绑定)', () => { + const agent = createTestAgent(); + const seen: Array> = []; + vi.spyOn(agent as unknown as { + enqueueToMailbox: (type: string, p: { extra?: Record }) => void; + }, 'enqueueToMailbox').mockImplementation((_t, p) => { seen.push(p.extra ?? {}); }); + + agent.deliverCallback({ + callbackId: 'cb1', type: 'background_exec', deliveryMode: 'in_session', + originSessionId: 'sess_origin_1', summary: 's', content: 'c', + }); + // mailbox 分支是「新注意力周期」,没有发起会话 —— 不得表态 memory。 + agent.deliverCallback({ + callbackId: 'cb2', type: 'wakeup', deliveryMode: 'mailbox', summary: 's', content: 'c', + }); + + expect(seen[0]!.sessionHint).toEqual({ kind: 'memory', memorySessionId: 'sess_origin_1' }); + expect(seen[1]!.sessionHint).toBeUndefined(); + }); + + it('会话身份:回调回到发起会话;无 origin 时保持当前会话,绝不新建 sys_* 空壳', async () => { + const agent = createTestAgent(); + const seen: Array = []; + vi.spyOn(agent as unknown as { + handleMessage: (c: string, s?: string, si?: unknown, o?: { sessionId?: string }) => Promise; + }, 'handleMessage').mockImplementation(async (_c, _s, _si, o) => { seen.push(o?.sessionId); return 'done'; }); + + const core = agent as unknown as { processMailboxItemCore: (i: MailboxItem) => Promise }; + + // ① 契约表态 memory → 回到发起轮 + await core.processMailboxItemCore(cbItem({ + originSessionId: 'sess_origin_1', + sessionHint: { kind: 'memory', memorySessionId: 'sess_origin_1' }, + callbackType: 'background_exec', + })); + // ② origin 缺失 → 保持当前会话(不传 sessionId),不得兜底造 id + await core.processMailboxItemCore(cbItem({ callbackType: 'background_exec' })); + + expect(seen[0]).toBe('sess_origin_1'); + expect(seen[1]).toBeUndefined(); + for (const sid of seen) expect(String(sid ?? '')).not.toMatch(/^sys_/); + }); }); diff --git a/packages/org-manager/src/api-server.ts b/packages/org-manager/src/api-server.ts index 16627d86f..54dd83d50 100644 --- a/packages/org-manager/src/api-server.ts +++ b/packages/org-manager/src/api-server.ts @@ -3697,8 +3697,11 @@ export class APIServer { const avatarDir = join(homedir(), '.markus', 'avatars'); mkdirSync(avatarDir, { recursive: true }); const filename = `${targetType}_${targetId}.${ext}`; - writeFileSync(join(avatarDir, filename), buf); - const avatarUrl = `/api/avatars/${filename}`; + const savedPath = join(avatarDir, filename); + writeFileSync(savedPath, buf); + // 文件名是稳定的(agent_.),换了图 URL 却不变 → 浏览器会命中旧缓存。 + // 追加版本号,保证每次上传都得到一个新 URL(GET 路由按 pathname 匹配,忽略 query)。 + const avatarUrl = `/api/avatars/${filename}?v=${Date.now()}`; if (targetType === 'user' && this.storage) { this.storage.userRepo.updateAvatarUrl(targetId, avatarUrl); const human = this.orgService.getHumanUser(targetId); @@ -3718,7 +3721,9 @@ export class APIServer { } const filePath = join(homedir(), '.markus', 'avatars', filename); if (existsSync(filePath) && statSync(filePath).isFile()) { - this.serveStaticFile(res, filePath); + // 头像可被覆盖,绝不能进 immutable 长缓存(否则换头像后前端仍显示旧图)。 + // 兼容历史未带 ?v= 的存量 URL,统一走重验证。 + this.serveStaticFile(res, filePath, req, { cacheControl: 'no-cache' }); } else { this.json(res, 404, { error: 'Avatar not found' }); } @@ -4342,10 +4347,18 @@ export class APIServer { if (this.storage) { const dbAgents = this.storage.agentRepo.listAll(); const avatarMap = new Map(dbAgents.filter((a: any) => a.avatarUrl).map((a: any) => [a.id, a.avatarUrl])); - if (avatarMap.size > 0) { + // 显示用头衔覆盖(与 avatarUrl 同一套合并方式):Team Chat 头部点改的那一行。 + // 列表接口的 role 原本直接来自角色模板,这里用本 Agent 的 profile 覆盖它。 + const titleMap = new Map(); + for (const row of dbAgents as Array<{ id: string; profile?: { displayTitle?: string } | null }>) { + const t = row.profile?.displayTitle; + if (t) titleMap.set(row.id, t); + } + if (avatarMap.size > 0 || titleMap.size > 0) { agents = agents.map(a => { const av = avatarMap.get(a.id as string); - return av ? { ...a, avatarUrl: av } : a; + const t = titleMap.get(a.id as string); + return av || t ? { ...a, ...(av ? { avatarUrl: av } : {}), ...(t ? { role: t } : {}) } : a; }); } } @@ -5662,6 +5675,18 @@ export class APIServer { effectiveHeartbeatMs = agent.setHeartbeatInterval(Number(body['heartbeatIntervalMs'])); } + // 显示用头衔(Team Chat 头部点改的那一行)。写入本 Agent 的 profile JSON, + // 不改角色模板 —— 模板由多个 Agent 共享,改了会串到别的 Agent。 + const titleProvided = body['title'] !== undefined; + if (titleProvided) { + const title = stripHtmlTags(String(body['title'])).trim(); + if (title.length > 60) { + this.json(res, 400, { error: 'title must be 60 characters or fewer' }); + return; + } + cfg.profile = { ...((cfg.profile as Record) ?? {}), displayTitle: title || undefined }; + } + // Persist config changes to DB if (this.storage) { try { @@ -5671,6 +5696,7 @@ export class APIServer { skills: body['skills'] as unknown, llmConfig: cfg.llmConfig, heartbeatIntervalMs: effectiveHeartbeatMs, + profile: titleProvided ? cfg.profile : undefined, }); } catch (persistErr) { log.warn('Failed to persist agent config to DB', { agentId, error: String(persistErr) }); @@ -6348,7 +6374,7 @@ EXPLANATION_END`; this.json(res, 200, { id: agent.id, name: agent.config.name, - role: agent.role.name, + role: (storedAgent?.profile as { displayTitle?: string } | null | undefined)?.displayTitle || agent.role.name, roleDescription: agent.role.description, agentRole: agent.config.agentRole, avatarUrl: storedAgent?.avatarUrl ?? undefined, @@ -13246,7 +13272,12 @@ EXPLANATION_END`; return null; } - private serveStaticFile(res: ServerResponse, filePath: string, req?: IncomingMessage): void { + private serveStaticFile( + res: ServerResponse, + filePath: string, + req?: IncomingMessage, + opts?: { cacheControl?: string }, + ): void { const ext = filePath.split('.').pop()?.toLowerCase() ?? ''; const MIME: Record = { html: 'text/html; charset=utf-8', @@ -13267,7 +13298,9 @@ EXPLANATION_END`; }; const contentType = MIME[ext] ?? 'application/octet-stream'; const body = readFileSync(filePath); - const cacheControl = ext === 'html' ? 'no-cache' : 'public, max-age=31536000, immutable'; + // 调用方可覆盖缓存策略:内容会变的资源(如用户/Agent 头像)文件路径稳定, + // 沿用 immutable 长缓存会导致浏览器一直显示旧图,必须改为可重验证。 + const cacheControl = opts?.cacheControl ?? (ext === 'html' ? 'no-cache' : 'public, max-age=31536000, immutable'); const COMPRESSIBLE = new Set(['html', 'js', 'mjs', 'css', 'json', 'svg', 'map']); const acceptEncoding = req?.headers?.['accept-encoding'] ?? ''; diff --git a/packages/org-manager/test/api-server-extended.test.ts b/packages/org-manager/test/api-server-extended.test.ts index 69b4892a5..36feddc78 100644 --- a/packages/org-manager/test/api-server-extended.test.ts +++ b/packages/org-manager/test/api-server-extended.test.ts @@ -259,6 +259,46 @@ describe('APIServer extended route coverage', () => { const res = await request(ctx.server, 'GET', avatarUrl); expect([200, 404]).toContain(res.status); }); + + // 回归:头像文件名是稳定的(agent_.),若 URL 不随内容变化, + // 浏览器会长期命中旧缓存 → 换头像后顶部头像与 L1/L2 列表仍是旧图。 + it('avatar upload returns a versioned URL so a re-upload is not masked by cache', async () => { + const first = await request(ctx.server, 'POST', '/api/avatars/upload', { + type: 'agent', + id: AGENT_A, + image: 'data:image/png;base64,iVBORw0KGgo=', + }); + expect(first.status).toBe(200); + const firstUrl = String(first.json.avatarUrl); + expect(firstUrl).toMatch(/^\/api\/avatars\/[^?]+\?v=\d+$/); + + await new Promise(resolve => setTimeout(resolve, 3)); + const second = await request(ctx.server, 'POST', '/api/avatars/upload', { + type: 'agent', + id: AGENT_A, + image: 'data:image/png;base64,iVBORw0KGgo=', + }); + const secondUrl = String(second.json.avatarUrl); + // 文件路径不变(稳定文件名),但 URL 必须变,否则前端拿不到新图。 + expect(secondUrl.split('?')[0]).toBe(firstUrl.split('?')[0]); + expect(secondUrl).not.toBe(firstUrl); + }); + + // 回归:头像绝不能走静态资源的 immutable 长缓存(max-age=1y)。 + it('avatar responses are revalidated instead of immutably cached', async () => { + const upload = await request(ctx.server, 'POST', '/api/avatars/upload', { + type: 'agent', + id: AGENT_A, + image: 'data:image/png;base64,iVBORw0KGgo=', + }); + // 带 ?v= 的新 URL 与历史遗留的不带版本号 URL,都必须可重验证。 + for (const url of [String(upload.json.avatarUrl), '/api/avatars/agent_legacy.png']) { + const res = await request(ctx.server, 'GET', url); + expect(res.status).toBe(200); + expect(res.headers['Cache-Control']).toBe('no-cache'); + expect(res.headers['Cache-Control']).not.toContain('immutable'); + } + }); }); describe('Uploads and sessions', () => { diff --git a/packages/shared/src/types/agent.ts b/packages/shared/src/types/agent.ts index 9558c178b..7db53aad1 100644 --- a/packages/shared/src/types/agent.ts +++ b/packages/shared/src/types/agent.ts @@ -119,6 +119,13 @@ export interface AgentProfile { requireApprovalFor?: string[]; /** Working directory constraint — agent tools restricted to this path */ workspacePath?: string; + /** + * 显示用头衔覆盖 —— 用户在 Team Chat 头部点改的那一行。 + * + * 留空时用角色模板自带的 `role.name`。**不写回角色模板**:模板是多个 Agent + * 共享的,改模板会串到其它 Agent,所以只在本 Agent 的 `agents.profile` JSON 里存。 + */ + displayTitle?: string; } /** 并发处理配置(智能体设置 → 并发处理)。默认开启 = 智能体并发处理消息。 */ diff --git a/packages/storage/src/sqlite-storage.ts b/packages/storage/src/sqlite-storage.ts index 8d953e502..52bb57e79 100644 --- a/packages/storage/src/sqlite-storage.ts +++ b/packages/storage/src/sqlite-storage.ts @@ -1169,7 +1169,7 @@ export class SqliteAgentRepo { this.db.prepare('UPDATE agents SET avatar_url = ?, updated_at = ? WHERE id = ?').run(avatarUrl, now(), id); } - updateConfig(id: string, data: { name?: string; agentRole?: string; skills?: unknown; llmConfig?: unknown; computeConfig?: unknown; heartbeatIntervalMs?: number }) { + updateConfig(id: string, data: { name?: string; agentRole?: string; skills?: unknown; llmConfig?: unknown; computeConfig?: unknown; heartbeatIntervalMs?: number; profile?: unknown }) { const sets: string[] = ['updated_at = ?']; const vals: SqlParams = [now()]; if (data.name !== undefined) { sets.push('name = ?'); vals.push(data.name); } @@ -1178,6 +1178,7 @@ export class SqliteAgentRepo { if (data.llmConfig !== undefined) { sets.push('llm_config = ?'); vals.push(toJson(data.llmConfig)); } if (data.computeConfig !== undefined) { sets.push('compute_config = ?'); vals.push(toJson(data.computeConfig)); } if (data.heartbeatIntervalMs !== undefined) { sets.push('heartbeat_interval_ms = ?'); vals.push(data.heartbeatIntervalMs); } + if (data.profile !== undefined) { sets.push('profile = ?'); vals.push(toJson(data.profile)); } vals.push(id); this.db.prepare(`UPDATE agents SET ${sets.join(', ')} WHERE id = ?`).run(...vals); } diff --git a/packages/web-ui/src/components/Avatar.tsx b/packages/web-ui/src/components/Avatar.tsx index 50757aa48..575df11b3 100644 --- a/packages/web-ui/src/components/Avatar.tsx +++ b/packages/web-ui/src/components/Avatar.tsx @@ -159,7 +159,8 @@ export function AvatarUpload({ currentUrl, name, size = 64, targetType = 'user', onClick={() => fileRef.current?.click()} className="relative rounded-full overflow-hidden focus:outline-none focus:ring-2 focus:ring-brand-500/50" style={{ width: size, height: size }} - title="Click to set avatar" + title={t('clickToSetAvatar', { defaultValue: 'Click to set avatar' })} + aria-busy={uploading} > {showImage ? ( )} -
+ {/* + 上传中必须「常显」转圈,而不是只在 hover 时出现。 + 上面那张 用的是 FileReader 的本地预览,文件一选就换了 —— + 若不强制盖一层遮罩,用户会以为已经传完,而请求其实还在飞。 + */} +
{uploading ? (
) : ( diff --git a/packages/web-ui/src/i18n/index.ts b/packages/web-ui/src/i18n/index.ts index c76588af9..513a2e7c6 100644 --- a/packages/web-ui/src/i18n/index.ts +++ b/packages/web-ui/src/i18n/index.ts @@ -117,6 +117,13 @@ void i18n fallbackLng: 'en', defaultNS: DEFAULT_NS, ns: [...NAMESPACES], + // react-i18next 把 `t` 绑定到 namespaces[0],除非 nsMode==='fallback': + // useTranslation.js: getFixedT(lng, nsMode === 'fallback' ? namespaces : namespaces[0]) + // 也就是说 useTranslation(['team','common']) 里的 t **只能**查到 team 的 key, + // 第二个 ns 里的 key 会原样渲染成 key 字符串(曾把「已工作 N 秒」显示成 + // execution.workedForMinutes)。打开 fallback 后 ns 数组才真正成为回退链: + // ns[0] 命中即用 ns[0],找不到才继续往下找 —— 纯增量,不影响已命中的文案。 + react: { nsMode: 'fallback' }, interpolation: { escapeValue: false, }, diff --git a/packages/web-ui/src/locales/en/common.json b/packages/web-ui/src/locales/en/common.json index 19492af90..b62dd52cd 100644 --- a/packages/web-ui/src/locales/en/common.json +++ b/packages/web-ui/src/locales/en/common.json @@ -10,6 +10,7 @@ "ok": "OK", "error": "Error", "imageTooLarge": "Image must be under 2MB", + "clickToSetAvatar": "Click to set avatar", "delete": "Delete", "remove": "Remove", "edit": "Edit", @@ -180,6 +181,8 @@ "rejectionReasonPlaceholder": "Rejection reason...", "idPrefix": "ID: {{id}}...", "errorPrefix": "Error:", + "workedForSeconds": "Worked for {{seconds}}s", + "workedForMinutes": "Worked for {{minutes}}m {{seconds}}s", "processRun": { "label": "Process", "thinking": "Thinking", @@ -192,7 +195,6 @@ "runningTool": "Running {{tool}}...", "state": { "running": "In progress", - "error": "Has failures", "done": "Completed" } }, diff --git a/packages/web-ui/src/locales/en/team.json b/packages/web-ui/src/locales/en/team.json index 3e6d965df..32155cc2f 100644 --- a/packages/web-ui/src/locales/en/team.json +++ b/packages/web-ui/src/locales/en/team.json @@ -365,6 +365,7 @@ "agentThinking": "Agent is thinking…", "scrollToBottom": "Scroll to bottom", "toggleSidebar": "Toggle sidebar", + "editAgentAvatar": "Set avatar", "newMessages": "{{count}} new message(s)", "mentionAgent": "Mention an agent", "slashSkill": "Skills", diff --git a/packages/web-ui/src/locales/es/common.json b/packages/web-ui/src/locales/es/common.json index ca9ee2f5e..7705520fc 100644 --- a/packages/web-ui/src/locales/es/common.json +++ b/packages/web-ui/src/locales/es/common.json @@ -10,6 +10,7 @@ "ok": "OK", "error": "Error: {{message}}", "imageTooLarge": "Imagen debe ser inferior a 2 MB", + "clickToSetAvatar": "Haz clic para definir el avatar", "delete": "Eliminar", "remove": "Retirar", "edit": "Editar", @@ -373,6 +374,8 @@ "execution_finished": "Completado" }, "toolsSummary": "{{count}} llamada(s) a herramienta · clic para ver la ejecución", + "workedForSeconds": "Trabajó {{seconds}} s", + "workedForMinutes": "Trabajó {{minutes}} min {{seconds}} s", "processRun": { "label": "Proceso", "thinking": "Pensando", @@ -385,7 +388,6 @@ "runningTool": "Ejecutando {{tool}}…", "state": { "running": "En curso", - "error": "Tiene fallos", "done": "Completado" } } diff --git a/packages/web-ui/src/locales/es/team.json b/packages/web-ui/src/locales/es/team.json index a31dd6b64..29b303db6 100644 --- a/packages/web-ui/src/locales/es/team.json +++ b/packages/web-ui/src/locales/es/team.json @@ -353,6 +353,7 @@ "agentThinking": "El agente está pensando…", "scrollToBottom": "Pergamino a fondo", "toggleSidebar": "Toggle sidebar", + "editAgentAvatar": "Establecer avatar", "newMessages": "{{count}} nuevo mensaje(s))", "mentionAgent": "Mención a un agente", "slashSkill": "Habilidades", diff --git a/packages/web-ui/src/locales/zh-CN/common.json b/packages/web-ui/src/locales/zh-CN/common.json index 9ce52ad2c..387967be1 100644 --- a/packages/web-ui/src/locales/zh-CN/common.json +++ b/packages/web-ui/src/locales/zh-CN/common.json @@ -10,6 +10,7 @@ "ok": "确定", "error": "错误", "imageTooLarge": "图片大小不能超过 2MB", + "clickToSetAvatar": "点击设置头像", "delete": "删除", "remove": "移除", "edit": "编辑", @@ -180,6 +181,8 @@ "rejectionReasonPlaceholder": "拒绝原因…", "idPrefix": "ID:{{id}}…", "errorPrefix": "错误:", + "workedForSeconds": "已工作 {{seconds}} 秒", + "workedForMinutes": "已工作 {{minutes}} 分 {{seconds}} 秒", "processRun": { "label": "执行过程", "thinking": "思考", @@ -192,7 +195,6 @@ "runningTool": "正在运行 {{tool}}…", "state": { "running": "执行中", - "error": "有失败", "done": "已完成" } }, diff --git a/packages/web-ui/src/locales/zh-CN/team.json b/packages/web-ui/src/locales/zh-CN/team.json index 2c104635a..d25c75e7c 100644 --- a/packages/web-ui/src/locales/zh-CN/team.json +++ b/packages/web-ui/src/locales/zh-CN/team.json @@ -365,6 +365,7 @@ "agentThinking": "智能体思考中…", "scrollToBottom": "回到底部", "toggleSidebar": "展开侧边栏", + "editAgentAvatar": "设置头像", "newMessages": "{{count}} 条新消息", "mentionAgent": "提及智能体", "slashSkill": "技能", diff --git a/packages/web-ui/src/pages/AgentProfile.tsx b/packages/web-ui/src/pages/AgentProfile.tsx index 5631296fe..1425e43b4 100644 --- a/packages/web-ui/src/pages/AgentProfile.tsx +++ b/packages/web-ui/src/pages/AgentProfile.tsx @@ -11,7 +11,7 @@ import { resolveTokensToday, visibleStorageBuckets, storageBucketLabelKey, agent import { MarkdownMessage } from '../components/MarkdownMessage.tsx'; import { useSwipeTabs } from '../hooks/useSwipeTabs.ts'; import { useIsMobile } from '../hooks/useIsMobile.ts'; -import { Avatar, AvatarUpload } from '../components/Avatar.tsx'; +import { Avatar } from '../components/Avatar.tsx'; import { ConfirmModal } from '../components/ConfirmModal.tsx'; import { friendlyAgentError } from './ChatComponents.tsx'; import { DELIVERABLE_TYPE_META, DELIVERABLE_STATUS_META } from '../components/DeliverableDetailModal.tsx'; diff --git a/packages/web-ui/src/pages/ChatComponents.test.ts b/packages/web-ui/src/pages/ChatComponents.test.ts index 919cdd3aa..4dd5a5ff0 100644 --- a/packages/web-ui/src/pages/ChatComponents.test.ts +++ b/packages/web-ui/src/pages/ChatComponents.test.ts @@ -1,13 +1,31 @@ import { describe, expect, it, vi } from 'vitest'; // ChatComponents.tsx transitively imports api.ts which reads `window` at module -// load. jsdom is not installed in this repo, so provide a minimal window stub -// BEFORE the component module is imported. vi.hoisted runs before imports. +// load. Provide the properties it needs BEFORE the component module is imported. +// vi.hoisted runs before imports. +// +// 注意:这段桩是**就地补属性**,不是把 window 整个换掉 —— 早期这里是 +// `window = { … }` 直接覆盖,服务端渲染(不跑 effect)看不出问题,一旦用 +// RTL 做客户端渲染就会炸在 `window.matchMedia is not a function` +// (MarkdownMessage 的窄屏监听要用它)。补全比覆盖更安全。 const _stub = vi.hoisted(() => { - (globalThis as unknown as { window: unknown }).window = { - __MARKUS_HUB_BASE_URL__: '', - location: { origin: 'http://localhost' }, - } as unknown as Window & typeof globalThis; + const win = ((globalThis as unknown as { window?: Record }).window + ?? {}) as Record; + win['__MARKUS_HUB_BASE_URL__'] = ''; + win['location'] = { origin: 'http://localhost' }; + if (typeof win['innerWidth'] !== 'number') win['innerWidth'] = 1024; + if (typeof win['matchMedia'] !== 'function') { + win['matchMedia'] = (query: string) => ({ + matches: false, + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + }); + } + (globalThis as unknown as { window: unknown }).window = win; return true; }); @@ -117,23 +135,21 @@ describe('AgentMessageBody live streaming bubble', () => { }); // ─── ProcessRun 折叠行的状态图标 ───────────────────────────────────────────── -// 收起状态下,开头那个小图标是用户唯一的进度线索:还在跑?跑完了?还是跑挂了? -// 所以三个状态必须给三种**形状不同**的图标 —— 只换颜色在 11px 高的一行里等于没换, -// 对色觉障碍用户更是完全无感。 +// 收起状态下,开头那个小图标是用户唯一的进度线索:还在跑?还是跑完了? +// 两种形态必须**形状不同** —— 只换颜色在 11px 高的一行里等于没换,对色觉障碍 +// 用户更是完全无感。 -describe('ProcessRunIcon — 三态图标', () => { - const render = async (state: 'running' | 'error' | 'done') => { +describe('ProcessRunIcon — 两态图标', () => { + const render = async (state: string) => { const { ProcessRunIcon } = await import('./ChatComponents.tsx'); const { renderToStaticMarkup } = await import('react-dom/server'); const React = await import('react'); return renderToStaticMarkup(React.createElement(ProcessRunIcon, { state } as never)); }; - it('三种状态两两不同(不是只换颜色的同一个图形)', async () => { - const [running, done, error] = await Promise.all([ - render('running'), render('done'), render('error'), - ]); - expect(new Set([running, done, error]).size).toBe(3); + it('两种状态形状不同(不是只换颜色的同一个图形)', async () => { + const [running, done] = await Promise.all([render('running'), render('done')]); + expect(new Set([running, done]).size).toBe(2); }); it('执行中 = 转圈的弧线,走 tick 驱动的 animate-spin(不额外产帧)', async () => { @@ -148,10 +164,14 @@ describe('ProcessRunIcon — 三态图标', () => { expect(done).not.toContain('animate-spin'); }); - it('有失败 = 三角形惊叹号,绝不显示成表示完成的勾', async () => { - const error = await render('error'); - expect(error).toContain('10.29 3.86'); - expect(error).not.toContain('M20 6.5'); + it('工具失败不再有专属的警告图标(老板 2026-09-23 明确要求)', async () => { + // 状态类型里已经删掉 'error' 了。这里故意硬塞一个进去,钉死「就算有人把它 + // 加回来,也只画勾、绝不画三角」——失败信息改由文字(N 个失败)承载。 + const forced = await render('error'); + const done = await render('done'); + expect(forced).toBe(done); + // 三角形惊叹号的路径必须彻底消失。 + expect(forced).not.toContain('10.29 3.86'); }); }); @@ -189,40 +209,29 @@ describe('ProcessRun — 折叠行的状态判定', () => { expect(html).toContain('execution.processRun.state.running'); }); - it('这轮结束、工具成功 → done', async () => { + it('这轮结束后整条时间线折进顶部一行,气泡里不再有过程行', async () => { const html = await render(completedTurn('done'), false); - expect(html).toContain('data-process-state="done"'); - expect(html).toContain('execution.processRun.state.done'); - // 跑完了就没理由报警,收起行里不该出现任何红色。 - expect(html).not.toContain('text-red-500'); + // 顶部一行在(可点开),过程行已被折走 —— 这正是「降低气泡高度」。 + expect(html).toContain('data-worked-summary="true"'); + expect(html).not.toContain('data-process-state'); + // 最终结论照常显示。 + expect(html).toContain('答案'); + // 跑完了就没理由报警,收起状态里不该出现任何红色。 + expect(html).not.toContain('text-red-'); }); - it('这轮结束、工具有失败 → error,并在收起行里直接报出失败次数', async () => { + it('这轮结束时同样不因为工具有失败而报警 —— 顶部一行照旧是对勾', async () => { const html = await render(completedTurn('error'), false); - expect(html).toContain('data-process-state="error"'); - expect(html).toContain('execution.processRun.state.error'); - // 失败次数写进标签:收起状态也必须看得见「这段里有东西挂了」。 - expect(html).toContain('execution.processRun.errors'); - // 失败态**不用红色**(老板 2026-09-20 明确要求):这一行三种状态一律灰, - // 可辨性靠形状 + 文字。红色会把高频出现的收起行变成警报墙。 - expect(html).not.toContain('text-red-500'); + expect(html).toContain('data-worked-summary="true"'); + // 失败不再产生警告图标/红色;次数改由文字承载(展开后才看得到)。 + expect(html).not.toContain('10.29 3.86'); expect(html).not.toContain('text-red-'); - // 但「有失败」必须仍然可辨:用三角形,绝不是表示完成的勾。 - expect(html).toContain('10.29 3.86'); - expect(html).not.toContain('M20 6.5'); }); - it('三种状态的收起行都不用红色 —— 只有「执行中」有颜色(品牌色)', async () => { - for (const [status, streaming] of [ - ['running', true], - ['done', false], - ['error', false], - ] as const) { - const html = await render(completedTurn(status), streaming); - expect(html, `${status} 态不该出现红色`).not.toContain('text-red-'); - } + it('「执行中」是这一行唯一的颜色(品牌色)', async () => { const runningHtml = await render(completedTurn('running'), true); expect(runningHtml).toContain('text-brand-400'); + expect(runningHtml).not.toContain('text-red-'); const doneHtml = await render(completedTurn('done'), false); expect(doneHtml).not.toContain('text-brand-400'); }); @@ -282,4 +291,137 @@ describe('summarizeProcessRun — errorCount', () => { expect(s.running).toBe(false); expect(s.tailIsThinking).toBe(true); }); +}); + +// ─── 完成后整体折叠成一行 ───────────────────────────────────────────────────── +// 老板 2026-09-23:「流式输出完成后,非最终结果的正文和思考及工具调用行,整体 +// 折叠成一行……顶部有一行,显示已工作 xx 秒,点击可以展开……这样是为了减少气泡 +// 的高度。」下面是这条要求的护栏。 + +describe('lastTextBlockIndex / collapsibleBlockCount — 折叠边界', () => { + const block = (kind: 'text' | 'process', key: string) => kind === 'text' + ? { kind, key, entry: { content: 'x' } } + : { kind, key, entries: [], summary: {} }; + + it('「最终结果」= 最后一个正文块,不是第一个', async () => { + const { lastTextBlockIndex } = await import('./ChatComponents.tsx'); + expect(lastTextBlockIndex([ + block('text', 'a'), block('process', 'p'), block('text', 'b'), + ] as never)).toBe(2); + }); + + it('没有正文块 → -1(无处可折,也不该硬折成空气泡)', async () => { + const { lastTextBlockIndex } = await import('./ChatComponents.tsx'); + expect(lastTextBlockIndex([block('process', 'p')] as never)).toBe(-1); + }); + + it('流式期间绝不折叠 —— 正在产出的内容不能被收走', async () => { + const { collapsibleBlockCount } = await import('./ChatComponents.tsx'); + expect(collapsibleBlockCount([ + block('process', 'p'), block('text', 'a'), + ] as never, true)).toBe(0); + }); + + it('只有一条正文的简单回复不折叠(不给它平白加一行)', async () => { + const { collapsibleBlockCount } = await import('./ChatComponents.tsx'); + expect(collapsibleBlockCount([block('text', 'a')] as never, false)).toBe(0); + }); + + it('思考/工具 + 最终结论 → 折 1 块,气泡只剩结论', async () => { + const { collapsibleBlockCount } = await import('./ChatComponents.tsx'); + expect(collapsibleBlockCount([ + block('process', 'p'), block('text', 'a'), + ] as never, false)).toBe(1); + }); +}); + +describe('formatWorkedFor — 「已工作」文案', () => { + // 回显 key + 参数的假 t:只用来断言「选了哪个分支 / 传了哪些参数」。 + // 真正的「能不能翻译出来」由 test/workedLabelI18n.test.ts 用真实语言包验证 —— + // 这里若也用假 t 去断言 key,就永远发现不了 key 渲染不出来。 + const t = ((key: string, opts?: Record) => `${key}|${JSON.stringify(opts ?? {})}`) as never; + + it('不足 1 分钟 → 只写秒,不出现「0 分」', async () => { + const { formatWorkedFor } = await import('./ChatComponents.tsx'); + const out = formatWorkedFor(45_400, t); + expect(out).toContain('common:execution.workedForSeconds'); + expect(out).not.toContain('workedForMinutes'); + expect(out).toContain('45'); + }); + + it('超过 1 分钟 → 分 + 余秒(「2 分 13 秒」比「2 分钟」说明力强得多)', async () => { + const { formatWorkedFor } = await import('./ChatComponents.tsx'); + const out = formatWorkedFor(133_000, t); + expect(out).toContain('common:execution.workedForMinutes'); + expect(out).toContain('"minutes":2'); + expect(out).toContain('"seconds":13'); + }); +}); + +describe('AgentMessageBody — 顶部一行的展开 / 收起', () => { + const finishedTurn = { + text: '最终结论', + segments: [ + // 中间正文必须是**完整句子**(以句号收尾):否则会被 healSentenceSplits + // 判定成「被工具行从中间切开的一句话」而拼进最终结论,夹具就失真了 + // —— 那样「最终结果」会落在 index 0,压根没有可折叠的内容。 + { type: 'text', content: '先说说思路。', createdAt: '2026-09-11T00:00:00.000Z' }, + { type: 'text', content: '', thinking: '内心独白', createdAt: '2026-09-11T00:00:05.000Z' }, + { type: 'tool', key: 't1', tool: 'file_edit', status: 'error', createdAt: '2026-09-11T00:00:10.000Z' }, + { type: 'text', content: '最终结论', createdAt: '2026-09-11T00:00:20.000Z' }, + ], + }; + + const renderBody = async (msg: Record, isStreaming = false) => { + const React = await import('react'); + const { render } = await import('@testing-library/react'); + const { AgentMessageBody } = await import('./ChatComponents.tsx'); + return render( + React.createElement(AgentMessageBody, { + msg: { + id: 'm', sender: 'agent', time: '', + rawCreatedAt: '2026-09-11T00:00:00.000Z', + isStreaming, + ...msg, + }, + isStreaming, + liveActivities: [], + } as never), + ); + }; + + it('默认收起:只剩顶部一行 + 最终结论,中间正文/思考/工具全部不见', async () => { + const { container } = await renderBody(finishedTurn); + expect(container.querySelector('[data-worked-summary]')).toBeTruthy(); + expect(container.textContent).toContain('最终结论'); + expect(container.textContent).not.toContain('先说说思路'); + expect(container.textContent).not.toContain('内心独白'); + }); + + it('点开 → 完整时间线回来(含失败计数),再点 → 收起', async () => { + const { container } = await renderBody(finishedTurn); + const { fireEvent } = await import('@testing-library/react'); + const btn = () => container.querySelector('[data-worked-summary]')!; + + expect(btn().getAttribute('aria-expanded')).toBe('false'); + + fireEvent.click(btn()); + expect(container.textContent).toContain('先说说思路'); + expect(btn().getAttribute('aria-expanded')).toBe('true'); + // 展开后过程行回来,失败次数写在文字里;但**绝不出现警告三角**。 + expect(container.textContent).toContain('execution.processRun.errors'); + expect(container.innerHTML).not.toContain('10.29 3.86'); + // 两级展开:展开顶部行返回的是过程行的**收起态**,思考正文要再点一次过程行 + // 才出来。这里钉死「展开顶部行不等于把所有明细一次性铺满」。 + expect(container.textContent).not.toContain('内心独白'); + + fireEvent.click(btn()); + expect(container.textContent).not.toContain('先说说思路'); + }); + + it('流式进行中不折叠 —— 不把用户正盯着的内容收走', async () => { + const { container } = await renderBody(finishedTurn, true); + expect(container.querySelector('[data-worked-summary]')).toBeNull(); + expect(container.textContent).toContain('先说说思路'); + }); }); \ No newline at end of file diff --git a/packages/web-ui/src/pages/ChatComponents.tsx b/packages/web-ui/src/pages/ChatComponents.tsx index 9db4815e0..0081992d4 100644 --- a/packages/web-ui/src/pages/ChatComponents.tsx +++ b/packages/web-ui/src/pages/ChatComponents.tsx @@ -505,6 +505,13 @@ export function isProcessEntry(entry: ExecutionStreamEntryUI): boolean { return entry.type !== 'text' || entry.metadata?.isThinking === true; } +/** 首末 entry 的真实时间跨度;拿不到真实时间戳时为 0。过程块与顶部「已工作」行共用。 */ +export function entriesElapsedMs(entries: ExecutionStreamEntryUI[]): number { + const first = Date.parse(entries[0]?.createdAt ?? ''); + const last = Date.parse(entries[entries.length - 1]?.createdAt ?? ''); + return Number.isFinite(first) && Number.isFinite(last) && last > first ? last - first : 0; +} + export function summarizeProcessRun(entries: ExecutionStreamEntryUI[]): ProcessRunSummary { let thinkingCount = 0; let toolCount = 0; @@ -529,9 +536,7 @@ export function summarizeProcessRun(entries: ExecutionStreamEntryUI[]): ProcessR if (e.type === 'error') errorCount++; } - const first = Date.parse(entries[0]?.createdAt ?? ''); - const last = Date.parse(entries[entries.length - 1]?.createdAt ?? ''); - const elapsedMs = Number.isFinite(first) && Number.isFinite(last) && last > first ? last - first : 0; + const elapsedMs = entriesElapsedMs(entries); return { thinkingCount, @@ -579,18 +584,68 @@ export function groupProcessRuns(entries: ExecutionStreamEntryUI[]): ChatTimelin return blocks; } +/** + * 最后一个正文块的索引;没有正文块 → -1。**「最终结果」= 最后一个正文块。** + * + * 为什么是「最后一个」而不是「唯一一个」:一轮里正文会被工具切开好几段 + * (每个工具开始就 flush 一次 textBuf),中间那些是过程旁白,只有末尾那段 + * 才是真正交付给用户的结论。 + */ +export function lastTextBlockIndex(blocks: ChatTimelineBlock[]): number { + for (let i = blocks.length - 1; i >= 0; i--) { + if (blocks[i]!.kind === 'text') return i; + } + return -1; +} + +/** + * 完成后要折进顶部一行的块数(= 最终结果之前的全部块)。0 = 没什么可折, + * 此时不显示顶部行 —— 一条纯正文的简单回复不该平白多出一行。 + * + * 只在**这轮已经结束**时才折:流式期间逐段展开正是「生成中与生成完气泡等高」的 + * 前提;中途把已经产出的内容收走,会让用户正在盯的东西突然消失。 + */ +export function collapsibleBlockCount(blocks: ChatTimelineBlock[], isStreaming: boolean): number { + if (isStreaming) return 0; + const idx = lastTextBlockIndex(blocks); + return idx > 0 ? idx : 0; +} + +/** + * 「已工作 N 秒 / N 分 N 秒」。秒级不写「0 分」;分钟级保留余秒 —— + * 「2 分 13 秒」比「2 分钟」更能说明这轮到底有多重。 + * + * 不复用 execution-utils 的 formatDuration:那个产出 `1.2s` 这种工程口径, + * 混在正文里很突兀。 + */ +export function formatWorkedFor(ms: number, t: TFunction): string { + const total = Math.max(0, Math.round(ms / 1000)); + // 显式写死 `common:` 前缀。这里的 `t` 由调用方传入,而调用方(AgentMessageBody) + // 用的是 useTranslation(['team','common']) —— react-i18next 会把 t 绑到 + // namespaces[0](即 'team'),于是 `execution.*` 这类只存在于 common 的 key + // 会原样渲染成 "execution.workedForMinutes"。前缀让这个 helper 不依赖调用方绑了哪个 ns。 + if (total < 60) return t('common:execution.workedForSeconds', { seconds: total }); + return t('common:execution.workedForMinutes', { + minutes: Math.floor(total / 60), + seconds: total % 60, + }); +} + // ─── ProcessRun — 一段过程(思考 + 工具)的折叠行 ───────────────────────────── -/** 折叠行开头那个图标的三态。三者轮廓各不相同,缩到 12px 也能一眼分开。 */ -export type ProcessRunState = 'running' | 'error' | 'done'; +/** 折叠行开头那个图标的两种形态。轮廓不同,缩到 12px 也能一眼分开。 */ +export type ProcessRunState = 'running' | 'done'; /** - * 折叠行的状态图标 —— 三个状态给三种完全不同的形状,而不只是换颜色: + * 折叠行的状态图标 —— 两种形状,而不只是换颜色: * * 进行中:转圈的弧线(品牌色)—— 「在动」;旋转由 4Hz tick 驱动,不额外产帧 - * 有失败:三角形惊叹号(红) —— 「出事了」,跑完但没好结果时不能给勾 * 已完成:对勾(弱色) —— 「办完了」 * + * **工具失败不再单独出一种图标**(老板 2026-09-23 明确要求):agent 调工具踩坑是 + * 常态,失败后往往自己重试并继续,给它一个惊叹号等于把「正常干活」画成「出事故」。 + * 失败次数照旧写在文字里(见 summary.errorCount),信息没丢,只是不再抢焦点。 + * * 为什么不能只靠颜色:这行只有 11px 高,颜色差异在暗色主题下最容易被忽略, * 而且对色觉障碍用户等于没有区分;形状差异才是真正可辨的。 */ @@ -604,15 +659,6 @@ export function ProcessRunIcon({ state }: { state: ProcessRunState }) { ); } - if (state === 'error') { - return ( - - ); - } return (
{/* 按时间顺序渲染:正文块 = 正文本身;过程块 = 折叠起来的思考/工具。 顺序完全不重排,所以「思考 → 正文 → 执行 → 思考」照样看得见。 */} - {blocks.map((block, index) => (block.kind === 'text' ? ( - - ) : ( - - )))} + {/* 顶部一行:这轮干了多久,点开回到完整时间线。默认收起 —— + 气泡只在「已工作 N 秒」与最终结论之间。 */} + {hideCount > 0 && ( + + )} + + {blocks.slice(offset).map((block, i) => { + // isLastBlock 必须按**整条时间线**判:切片之后 index 0 并不是首块。 + const globalIndex = i + offset; + return block.kind === 'text' ? ( + + ) : ( + + ); + })} {/* 刚开流、还没有任何 segment —— 别留一个空气泡。 */} {blocks.length === 0 && isStreaming && ( diff --git a/packages/web-ui/src/pages/Team.tsx b/packages/web-ui/src/pages/Team.tsx index f3e8d8ef1..99e607b4d 100644 --- a/packages/web-ui/src/pages/Team.tsx +++ b/packages/web-ui/src/pages/Team.tsx @@ -73,7 +73,7 @@ import { usePageActive } from '../hooks/usePageActive.ts'; import { useConversationBuffers, makeConvKey, NEW_CHAT_PLACEHOLDER_ID } from '../hooks/useConversationBuffers.ts'; import { useChatStream, type ChatStreamVolatileState } from '../hooks/useChatStream.ts'; import { chatStore, useAgentStreaming, useChatStore } from './useChatStore.ts'; -import { Avatar } from '../components/Avatar.tsx'; +import { Avatar, AvatarUpload } from '../components/Avatar.tsx'; import { ChatModelMenu, applyChatModelSelection, type ChatModelSelection } from '../components/ChatModelMenu.tsx'; import { ConfirmModal } from '../components/ConfirmModal.tsx'; import { @@ -543,6 +543,27 @@ export function TeamPage({ initialAgentId, authUser, previewMode, previewData }: // Avatar popover in chat messages const [avatarPopover, setAvatarPopover] = useState<{ agentId: string; top: number; left: number } | null>(null); + // 顶部 Agent 头像可点:展开头像设置浮层(复用 AvatarUpload,type='agent')。 + const [agentAvatarEditing, setAgentAvatarEditing] = useState(false); + const agentAvatarRef = useRef(null); + + // 浮层点击外部 / Esc 关闭。锚点容器同时包住触发按钮与浮层, + // 故点击按钮本身不算「外部」,不会出现「关了又立刻开」的抖动。 + useEffect(() => { + if (!agentAvatarEditing) return; + const onDown = (e: MouseEvent) => { + if (agentAvatarRef.current && !agentAvatarRef.current.contains(e.target as Node)) { + setAgentAvatarEditing(false); + } + }; + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setAgentAvatarEditing(false); }; + document.addEventListener('mousedown', onDown); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('mousedown', onDown); + document.removeEventListener('keydown', onKey); + }; + }, [agentAvatarEditing]); const [profileDefaultTab, setProfileDefaultTab] = useState(); // 深链指向的分组(见 LEGACY_TAB_SECTION):概览页会预先展开它。 @@ -1135,6 +1156,19 @@ export function TeamPage({ initialAgentId, authUser, previewMode, previewData }: // ── Data loading ───────────────────────────────────────────────────────────── const refreshAgents = useCallback(() => api.agents.list().then(d => setAgents(d.agents)).catch(() => {}), []); + + /** + * 头像上传成功后立即把新 URL 写进本地 agents,不等全量 GET /api/agents。 + * + * 那个接口要为每个 Agent 派生运行时信息(buildAgentRuntimeInfo + task / gateway + * 查询),80+ 个 Agent 就是好几秒;头像若等它回来才换,用户得盯着旧头像发呆。 + * 乐观写入后,顶部头像与 L1 / L2 名册同帧更新(三者都从这份 agents 派生, + * 且 L1 / L2 不自行拉列表);全量刷新照旧在后台跑,用于对齐其它字段。 + */ + const patchAgentAvatar = useCallback((agentId: string | undefined, avatarUrl: string) => { + if (!agentId) return; + setAgents(prev => prev.map(a => (a.id === agentId ? { ...a, avatarUrl } : a))); + }, []); const refreshTeams = useCallback(() => api.teams.list().then(d => { setTeams(d.teams); // Success only — see the teamsLoaded declaration for why a failure must not @@ -3937,7 +3971,7 @@ export function TeamPage({ initialAgentId, authUser, previewMode, previewData }: const handleSaveHeaderDesc = async () => { try { if (chatMode === 'direct' && selectedAgent) { - await api.agents.updateConfig(selectedAgent, { roleDescription: headerDescDraft }); + await api.agents.updateConfig(selectedAgent, { title: headerDescDraft }); refreshAgents(); } else if (chatMode === 'channel' && activeTeamId) { await api.teams.update(activeTeamId, { description: headerDescDraft }); @@ -3994,7 +4028,38 @@ export function TeamPage({ initialAgentId, authUser, previewMode, previewData }:
) : ( - +
+ + {agentAvatarEditing && ( +
+ { + // 乐观路径在前:关掉浮层、当场换图;全量刷新只用于后台对齐。 + setAgentAvatarEditing(false); + patchAgentAvatar(currentAgent?.id, url); + refreshAgents(); + }} + /> +
+ )} +
)}
)} @@ -4035,16 +4100,16 @@ export function TeamPage({ initialAgentId, authUser, previewMode, previewData }: onBlur={handleSaveHeaderDesc} onKeyDown={e => { if (e.key === 'Enter') handleSaveHeaderDesc(); if (e.key === 'Escape') setEditingHeaderDesc(false); }} className="text-[11px] text-fg-tertiary bg-transparent border-b border-brand-500/50 outline-none py-0 px-0 w-full max-w-[400px] mt-0.5" - placeholder="Add description..." + placeholder="Add title..." autoFocus /> ) : (
{ setHeaderDescDraft(headerDesc); setEditingHeaderDesc(true); }} - title="Click to edit description" + title="Click to edit title" > - {headerDesc || 'No description'} + {headerDesc || 'No title'}
) )} diff --git a/packages/web-ui/test/avatarUploadFeedback.test.tsx b/packages/web-ui/test/avatarUploadFeedback.test.tsx new file mode 100644 index 000000000..54fa1f696 --- /dev/null +++ b/packages/web-ui/test/avatarUploadFeedback.test.tsx @@ -0,0 +1,113 @@ +/** + * AvatarUpload 反馈回归护栏 —— Team Chat 顶部 Agent 头像设置。 + * + * 用户报障原话:「仍然要等好几秒才会切换,中间也没有加载中的提示。」 + * + * 前半句的根因在后端接口开销(GET /api/agents 要为每个 Agent 派生运行时信息), + * 修法在 Team.tsx:上传成功即乐观写入本地 agents,不等全量刷新。**这里钉的是后半句** + * ——前端反馈,以及那个乐观写入所依赖的 URL 契约: + * + * - 组件内部的 显示的是 FileReader 读出的**本地预览**,文件一选就换了, + * 看起来像「已经传完」,而 POST 其实还在飞。所以上传中必须**强制常显**遮罩 + + * 转圈;沿用平时那套 `opacity-0 group-hover:opacity-100` 是不行的 —— 鼠标一移开 + * 反馈就消失,等于没有反馈。这是本次最容易改回去的一行。 + * - onUploaded 必须交出**服务端返回的 URL**(带 ?v= 版本号),而不是本地 dataURL: + * 乐观写入如果写的是 dataURL,缓存版本号就丢了,直接退回「换头像后列表不刷新」 + * 的老 bug(文件名固定 + 浏览器长缓存命中)。 + * - title 走 i18n,不再硬编码英文(中文界面里冒出 "Click to set avatar" 是同一个 + * 组件的旧账)。 + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, fireEvent, waitFor } from '@testing-library/react'; + +// 组件只依赖 api / i18n;把两者钉住,测试只驱动「上传中 -> 上传完」这一条状态机。 +vi.mock('../src/api.ts', () => ({ + api: { auth: { uploadAvatar: vi.fn() } }, + hubApi: { getUrl: vi.fn(() => null) }, +})); +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +import { api } from '../src/api.ts'; +import { AvatarUpload } from '../src/components/Avatar.tsx'; + +const mockUpload = vi.mocked(api.auth.uploadAvatar); + +/** 遮罩层:按钮内唯一的 absolute inset-0 覆盖块。 */ +function overlay(container: HTMLElement): HTMLElement { + const el = container.querySelector('div.absolute.inset-0'); + if (!el) throw new Error('overlay not found'); + return el; +} + +function pickFile(container: HTMLElement) { + const input = container.querySelector('input[type="file"]'); + if (!input) throw new Error('file input not found'); + const file = new File(['fake-png-bytes'], 'me.png', { type: 'image/png' }); + fireEvent.change(input, { target: { files: [file] } }); +} + +/** 手动控制挂起/兑现的 promise,用来观察「上传中」这一帧。 */ +function deferred() { + let resolve!: (v: T) => void; + let reject!: (e: unknown) => void; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + return { promise, resolve, reject }; +} + +describe('AvatarUpload — 上传中反馈', () => { + beforeEach(() => { + mockUpload.mockReset(); + }); + + it('上传挂起时常显遮罩(不依赖 hover),让用户看得到「在传」', async () => { + const d = deferred<{ avatarUrl: string }>(); + mockUpload.mockReturnValue(d.promise); + + const { container } = render(); + + // 初始(空闲)态:走 hover 才出现 —— 这是正常的相机提示,不能常显。 + expect(overlay(container).className).toContain('opacity-0'); + + pickFile(container); + + // 一旦进入上传中,遮罩必须常显。旧实现这里是 opacity-0 + group-hover, + // 于是「中间也没有加载中的提示」。 + await waitFor(() => { + const cls = overlay(container).className; + expect(cls).toContain('opacity-100'); + expect(cls).not.toContain('opacity-0'); + }); + + // 且此时按钮对外声明 busy。 + const button = container.querySelector('button'); + expect(button?.getAttribute('aria-busy')).toBe('true'); + + // 收尾,避免 act 警告。 + d.resolve({ avatarUrl: '/api/avatars/agent_a1.png?v=1' }); + await waitFor(() => expect(overlay(container).className).toContain('opacity-0')); + }); + + it('onUploaded 交出服务端 URL(带 ?v= 版本号),不是本地 dataURL', async () => { + mockUpload.mockResolvedValue({ avatarUrl: '/api/avatars/agent_a1.png?v=1730000000000' }); + const onUploaded = vi.fn(); + + const { container } = render( + , + ); + pickFile(container); + + await waitFor(() => expect(onUploaded).toHaveBeenCalledTimes(1)); + expect(onUploaded).toHaveBeenCalledWith('/api/avatars/agent_a1.png?v=1730000000000'); + // 关键:绝不能把 FileReader 的 data: URL 当成结果交出去。 + expect(onUploaded.mock.calls[0]![0]).not.toMatch(/^data:/); + expect(mockUpload).toHaveBeenCalledWith(expect.stringMatching(/^data:image\/png/), 'agent', 'a1'); + }); + + it('title 走 i18n,不硬编码英文', () => { + const { container } = render(); + // t 被 mock 成回显 key:硬编码英文会得到 "Click to set avatar"。 + expect(container.querySelector('button')?.getAttribute('title')).toBe('clickToSetAvatar'); + }); +}); diff --git a/packages/web-ui/test/workedLabelI18n.test.ts b/packages/web-ui/test/workedLabelI18n.test.ts new file mode 100644 index 000000000..9a1f8e209 --- /dev/null +++ b/packages/web-ui/test/workedLabelI18n.test.ts @@ -0,0 +1,130 @@ +/** + * 「已工作 N 秒」文案的国际化回归护栏。 + * + * ── 它守的是什么 ──────────────────────────────────────────────────────────── + * 这一行曾原样显示成 `execution.workedForMinutes`。根因**不在语言包** —— + * en / zh-CN / es 三份 common.json 都有这个 key(下面有 parity 断言钉住)。 + * 真正的根因在 react-i18next 的绑定规则(useTranslation.js:76): + * + * getFixedT(lng, nsMode === 'fallback' ? namespaces : namespaces[0]) + * + * 即 `useTranslation(['team','common'])` 里的 `t` **只查 namespaces[0] = 'team'**, + * 于是只在 common 里的 `execution.*` 找不到 → i18next 回退到 key 本身。 + * + * ── 为什么要有这个文件 ────────────────────────────────────────────────────── + * 原来这份断言长在 ChatComponents.test.ts 里,用的是「回显 key」的假 `t`,断言的 + * 就是 key 字符串本身 —— 无论语言包里有没有这个 key,测试都是绿的。这个文件改用 + * **真实语言包 + 复刻真实的绑定公式**,正是为了堵住那个盲区: + * 如果谁把 formatWorkedFor 里的 `common:` 前缀去掉,或用回团队命名的 ns, + * 这里的第一个用例会立刻红。 + */ +import { beforeAll, describe, expect, it } from 'vitest'; +import i18next from 'i18next'; + +import enCommon from '../src/locales/en/common.json'; +import zhCommon from '../src/locales/zh-CN/common.json'; +import esCommon from '../src/locales/es/common.json'; +import appI18n from '../src/i18n'; + +type FormatWorkedFor = (ms: number, t: unknown) => string; + +/** 复刻 react-i18next 的绑定公式,才能重现线上条件(默认 nsMode='default')。 */ +const bindLikeReactI18next = ( + i18n: typeof i18next, + lng: string, + namespaces: string[], + nsMode: string, +) => i18n.getFixedT(lng, nsMode === 'fallback' ? namespaces : namespaces[0]) as never; + +describe('formatWorkedFor — 真实语言包解析(防「渲染成 key」)', () => { + let formatWorkedFor: FormatWorkedFor; + let i18n: typeof i18next; + + beforeAll(async () => { + ({ formatWorkedFor } = (await import('../src/pages/ChatComponents.tsx')) as unknown as { + formatWorkedFor: FormatWorkedFor; + }); + i18n = i18next.createInstance(); + await i18n.init({ + resources: { + en: { common: enCommon }, + 'zh-CN': { common: zhCommon }, + es: { common: esCommon }, + }, + lng: 'zh-CN', + fallbackLng: 'en', + defaultNS: 'common', + ns: ['team', 'common'], + interpolation: { escapeValue: false }, + initImmediate: false, + }); + }); + + it('线上实际条件(t 只绑到 team,即旧 bug 的触发条件)下,也必须翻译出来', () => { + // 这正是 AgentMessageBody 的 useTranslation(['team','common']) 在 nsMode='default' + // 下产生的东西 —— 旧代码就是被这个 t 喂进来的。 + const teamBoundT = bindLikeReactI18next(i18n, 'zh-CN', ['team', 'common'], 'default'); + expect(formatWorkedFor(45_400, teamBoundT)).toBe('已工作 45 秒'); + expect(formatWorkedFor(133_000, teamBoundT)).toBe('已工作 2 分 13 秒'); + }); + + it('无前缀时确实会烂掉 —— 这就是当初线上看到 execution.workedForMinutes 的机制', () => { + const teamBoundT = i18n.getFixedT('zh-CN', 'team'); + // 没有 `common:` 前缀 → 只能在 team 里找 → 找不到 → 原样返回 key。 + expect(teamBoundT('execution.workedForMinutes')).toBe('execution.workedForMinutes'); + // 有前缀 → 强制进 common → 正常。 + expect(teamBoundT('common:execution.workedForMinutes', { minutes: 2, seconds: 13 })) + .toBe('已工作 2 分 13 秒'); + }); + + it('三个语言都出人话,且绝不出现 key 字符串', () => { + const cases: Array<[string, string, string]> = [ + ['en', 'Worked for 45s', 'Worked for 2m 13s'], + ['zh-CN', '已工作 45 秒', '已工作 2 分 13 秒'], + ['es', 'Trabajó 45 s', 'Trabajó 2 min 13 s'], + ]; + for (const [lng, short, long] of cases) { + const t = i18n.getFixedT(lng, 'team'); + expect(formatWorkedFor(45_400, t)).toBe(short); + expect(formatWorkedFor(133_000, t)).toBe(long); + expect(formatWorkedFor(133_000, t)).not.toContain('workedFor'); + } + }); + + it('秒级不写「0 分」;分钟级保留余秒', () => { + const t = i18n.getFixedT('zh-CN', 'team'); + expect(formatWorkedFor(59_400, t)).toBe('已工作 59 秒'); + expect(formatWorkedFor(60_000, t)).toBe('已工作 1 分 0 秒'); + }); +}); + +describe('i18n 配置 — nsMode 必须是 fallback', () => { + it('应用实例开启了 react.nsMode=fallback(数组 ns 才真正成为回退链)', () => { + // 关掉它,所有 useTranslation(['a','b']) 里第二个 ns 的 key 都会渲成 key 字符串。 + expect((appI18n.options as { react?: { nsMode?: string } }).react?.nsMode).toBe('fallback'); + }); +}); + +describe('语言包完整性 — common.json 三语 key 对齐', () => { + const flat = (o: Record, p = ''): string[] => + Object.entries(o).flatMap(([k, v]) => + v && typeof v === 'object' ? flat(v as Record, `${p}${k}.`) : [`${p}${k}`]); + + it('en / zh-CN / es 的 key 集合完全一致(缺一个就会退化成英文,或直接漏字)', () => { + const en = new Set(flat(enCommon as Record)); + for (const [lng, pack] of [['zh-CN', zhCommon], ['es', esCommon]] as const) { + const keys = new Set(flat(pack as Record)); + const missing = [...en].filter(k => !keys.has(k)); + const extra = [...keys].filter(k => !en.has(k)); + expect({ lng, missing, extra }).toEqual({ lng, missing: [], extra: [] }); + } + }); + + it('「已工作」两个 key 在三个语言里都在', () => { + for (const pack of [enCommon, zhCommon, esCommon] as Array>) { + const execution = pack.execution as Record; + expect(execution.workedForSeconds).toBeTypeOf('string'); + expect(execution.workedForMinutes).toBeTypeOf('string'); + } + }); +});