feat(desktop): 团队视图加「卡通办公室」皮肤(手绘 SVG 贴纸风)

多智能体协调演成办公室:协调者在白板前统筹(白板即思考/综合流),
专家在工位敲键盘,完成打✓、失败挂!、派发走虚线。

风格对齐 ai-office-react(粗描边+平涂+大头身),但用手写 SVG 而非
PixiJS+Spine:零外部素材、无 Spine 运行时授权问题、几 KB 进包。
先试过 three.js 真 3D,低模程序化角色出来是玩具味,已弃并卸干净依赖。

数据与「卡片看板」同源(deriveTeam),换皮不换数据,后端零改动。
动效=状态编码(敲键盘/思考流/辉光),守 prefers-reduced-motion。

顺带修一个真 bug:并发时间轴复盘卡在 running 的旧任务时,未收口工位
会一路量到 Date.now(),跨度爆表(实测 32103562.4s)。抽 teamNow():
直播用此刻、复盘用最后一个事件时间戳;两个皮肤共用,+3 单测。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-15 16:25:54 +08:00
parent 0152e7837a
commit 225fee5519
5 changed files with 452 additions and 4 deletions
+26 -1
View File
@@ -1,6 +1,6 @@
import { describe, it, expect } from "vitest";
import type { ExecEvent } from "./api";
import { deriveNodes, pendingApproval, isMultiAgent, deriveTeam } from "./run";
import { deriveNodes, pendingApproval, isMultiAgent, deriveTeam, teamNow } from "./run";
let seq = 0;
function ev(node: string, phase: string, extra: Partial<ExecEvent> = {}): ExecEvent {
@@ -160,3 +160,28 @@ describe("deriveTeam(协调者 + 工位)", () => {
expect(t.t1).toBe(5000);
});
});
describe("teamNow(并发时间轴的右端)", () => {
it("直播时用此刻", () => {
const before = Date.now();
expect(teamNow([ev("agent:a", "start", { kind: "agent", ts: 100 })], true)).toBeGreaterThanOrEqual(before);
});
it("复盘时用最后一个事件的时间戳", () => {
const n = teamNow(
[ev("agent:a", "start", { kind: "agent", ts: 100 }), ev("agent:b", "end", { kind: "agent", ts: 900, ms: 800 })],
false,
);
expect(n).toBe(900);
});
it("复盘一条卡在 running 的旧任务,跨度不会拉到今天", () => {
const old = Date.now() - 365 * 24 * 3600 * 1000;
const events = [
ev("agent:a", "start", { kind: "agent", ts: old }),
ev("agent:b", "start", { kind: "agent", ts: old + 2000 }), // 永远没收口
];
const t = deriveTeam(events, teamNow(events, false));
expect(t.t1 - t.t0).toBe(2000);
});
});
+10
View File
@@ -141,6 +141,16 @@ function splitBrief(detail?: string): { brief?: string; output?: string } {
return { output: detail };
}
// teamNow 决定并发时间轴的右端。直播时是此刻——未收口的工位应持续生长;
// 复盘时必须是最后一个事件的时间戳:回放一条卡在 running 的历史任务(崩溃/超时留下的),
// 未收口工位若量到 Date.now(),时间轴会一路拉到今天,跨度直接爆表。
export function teamNow(events: ExecEvent[], live: boolean): number {
if (live) return Date.now();
let last = 0;
for (const e of events) if (e.ts > last) last = e.ts;
return last || Date.now();
}
// deriveTeam 把事件流派生成「协调者 + 工位」模型(保留时间戳供并发时间轴)。
export function deriveTeam(events: ExecEvent[], now = Date.now()): TeamModel {
let lead: TeamLead | null = null;