diff --git a/sundynix-desktop/frontend/src/components/OfficeView.tsx b/sundynix-desktop/frontend/src/components/OfficeView.tsx new file mode 100644 index 0000000..ca2693b --- /dev/null +++ b/sundynix-desktop/frontend/src/components/OfficeView.tsx @@ -0,0 +1,395 @@ +import { useMemo } from "react"; +import { deriveTeam, teamNow, type RunPhase, type TeamSeat } from "../lib/run"; +import type { ExecEvent } from "../lib/api"; + +// 2D 卡通办公室:把多智能体协调演成「协调者在白板前统筹 + 专家在工位干活」。 +// 手绘贴纸风(粗描边 + 平涂 + 大头身),纯 SVG 手写——不引 PixiJS/Spine、不下任何素材, +// 因此没有授权问题,整个场景几 KB。动效=状态编码(敲键盘/思考流),并守 reduced-motion。 +// 数据与「卡片看板」同源(deriveTeam),换皮不换数据。 + +const VB_W = 720; +const VB_H = 460; +const FLOOR_Y = 200; + +const OUTLINE = "#2f2a26"; +const SKIN = "#f7d9bd"; +const WALL = "#f3ede4"; +const FLOOR = "#ddd0be"; +const DESK_TOP = "#ffffff"; +const DESK_FRONT = "#f1ebe2"; + +const RUN = "#7c5cf6"; +const OK = "#34d399"; +const ERR = "#fb7185"; + +// 身份色:按工位序号稳定取,跟状态无关(状态另用显示器辉光 / 气泡 / 铭牌圆点表达)。 +const SHIRTS = ["#7c5cf6", "#38bdf8", "#34d399", "#fbbf24", "#fb7185", "#a78bfa", "#22d3ee", "#f472b6"]; +const HAIRS = ["#3f3229", "#6b4a2f", "#c8913f", "#2e3f52", "#8c4a4a", "#4a3a6b", "#5c5148", "#7a4a2a"]; + +function toneOf(s: TeamSeat): string { + return s.status === "error" ? ERR : s.status === "running" ? RUN : OK; +} + +function fmtMs(ms?: number): string { + if (ms == null) return ""; + return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`; +} + +function truncate(s: string | undefined, n: number): string { + if (!s) return ""; + return s.length > n ? s.slice(0, n) + "…" : s; +} + +// 铭牌按视觉宽度截断:中日韩字符约 1em,拉丁/数字约 0.55em。 +// 按字数一刀切会把 wiki_search 这类窄字名字冤枉截掉。 +const emOf = (c: string) => (/[ -鿿＀-￯]/.test(c) ? 1 : 0.55); + +function fitText(s: string, emBudget: number): string { + let used = 0; + for (let i = 0; i < s.length; i++) { + used += emOf(s[i]); + if (used > emBudget) return s.slice(0, Math.max(1, i)) + "…"; + } + return s; +} + +// ---- 角色 ---- +// 局部坐标:脚在原点,向上为负。整体高约 150 单位,由外层 scale 缩放。 + +function Hair({ color, style }: { color: string; style: number }) { + return ( + <> + + {style === 0 && ( + <> + + + + )} + {style === 1 && ( + <> + + + + )} + {style === 2 && } + + ); +} + +function Chibi({ + shirt, + hair, + style, + working, + happy, + sad, + delay, +}: { + shirt: string; + hair: string; + style: number; + working?: boolean; + happy?: boolean; + sad?: boolean; + delay: number; +}) { + const d = `${delay}s`; + return ( + + {/* 手臂:working 时上下敲键盘 */} + + + + + + + + {/* 躯干 */} + + + + {/* 头 */} + + + {/* 刘海 */} + + + + {/* 眼睛:完成时眯眼笑,失败时垂眼 */} + {happy ? ( + <> + + + + ) : ( + <> + + + + )} + {sad ? ( + + ) : ( + + )} + + ); +} + +// 头顶气泡:工作中=打字三点,完成=✓,失败=! +function Bubble({ status, delay }: { status: TeamSeat["status"]; delay: number }) { + const tone = status === "error" ? ERR : status === "running" ? RUN : OK; + return ( + + + + + {status === "running" ? ( + [-11, 0, 11].map((x, i) => ( + + )) + ) : status === "error" ? ( + <> + + + + ) : ( + + )} + + ); +} + +// 工位:白桌 + 显示器辉光(状态色)+ 桌面铭牌。桌子挡住下半身,人只露上半身。 +function Deskette({ name, sub, tone, running, w = 200 }: { name: string; sub: string; tone: string; running?: boolean; w?: number }) { + const hw = w / 2; + return ( + + {/* 显示器(背对我们,屏幕辉光溢出到桌面) */} + + + + + + + {/* 桌面 + 桌沿 */} + + + {/* 咖啡杯 */} + + + + + {/* 铭牌 */} + + + + {fitText(name, (w - 62) / 17)} + + + {sub} + + + ); +} + +function Seat({ seat, x, y, scale, index, bubble }: { seat: TeamSeat; x: number; y: number; scale: number; index: number; bubble: boolean }) { + const tone = toneOf(seat); + const running = seat.status === "running"; + const sub = running ? "工作中…" : seat.status === "error" ? "失败" : `已回报 ${fmtMs(seat.ms)}`; + return ( + + + + {bubble && } + + + ); +} + +// 场景道具 +function Room() { + return ( + + + + + {/* 地毯 */} + + {/* 窗 */} + + + + + + {/* 挂钟 */} + + + + + {/* 绿植 */} + + + + + + + ); +} + +export function OfficeView({ events, output, phase }: { events: ExecEvent[]; output?: string; phase?: RunPhase }) { + const team = useMemo(() => deriveTeam(events, teamNow(events, phase === "streaming")), [events, phase]); + const { lead, seats, t0, t1 } = team; + const span = Math.max(t1 - t0, 1); + const streaming = phase === "streaming"; + const thinking = truncate((output ?? "").slice(-110).replace(/\s+/g, " "), 110); + + if (!lead && seats.length === 0) { + return
暂无协调轨迹。
; + } + + // 排位:≤5 人一排(前排),否则拆两排,后排小、前排大 —— 用缩放造纵深。 + // 两排时后排不出头顶气泡:气泡很高,会顶到协调者的桌子;状态仍由显示器辉光和铭牌圆点表达。 + const rows: TeamSeat[][] = seats.length <= 5 ? [seats] : [seats.slice(0, Math.ceil(seats.length / 2)), seats.slice(Math.ceil(seats.length / 2))]; + const rowCfg = rows.length === 1 ? [{ y: 430, s: 1 }] : [{ y: 322, s: 0.6 }, { y: 438, s: 0.82 }]; + + const placed = rows.flatMap((row, r) => { + const pitch = VB_W / row.length; + const cfg = rowCfg[r]; + const scale = Math.min(cfg.s * Math.min(pitch / 230, 1), 0.9); + return row.map((seat, i) => ({ + seat, + x: pitch * (i + 0.5), + y: cfg.y, + scale, + index: seats.indexOf(seat), + bubble: rows.length === 1 || r === 1, + })); + }); + + const leadTone = streaming ? RUN : lead?.status === "error" ? ERR : OK; + + return ( +
+ + + + + + {/* 白板:协调者的思考/综合过程 */} + + + + +
+
+ {streaming ? "综合中…" : lead?.status === "error" ? "协调失败" : lead?.status === "done" ? "已综合" : "协调中"} +
+
+ {thinking || lead?.detail || "等待协调者输出…"} + {streaming && } +
+
+
+
+ + {/* 派发连线:只画正在工作的工位 */} + {placed + .filter((p) => p.seat.status === "running") + .map((p) => ( + + ))} + + {/* 协调者:白板前,比前排小 → 纵深 */} + + + + + + + {placed.map((p) => ( + + ))} +
+ +
+
并发时间轴 · 共 {fmtMs(span)}
+
+ {seats.map((s) => { + const left = ((s.startTS - t0) / span) * 100; + const width = Math.max((((s.endTS ?? t1) - s.startTS) / span) * 100, 2); + return ( +
+ {s.name} +
+
+
+
+ ); + })} +
+
+
+ ); +} diff --git a/sundynix-desktop/frontend/src/components/TeamView.tsx b/sundynix-desktop/frontend/src/components/TeamView.tsx index 511d98e..0b1dfab 100644 --- a/sundynix-desktop/frontend/src/components/TeamView.tsx +++ b/sundynix-desktop/frontend/src/components/TeamView.tsx @@ -1,6 +1,6 @@ import { useMemo } from "react"; import { Users, Wrench, Network } from "lucide-react"; -import { deriveTeam, type RunPhase, type TeamSeat } from "../lib/run"; +import { deriveTeam, teamNow, type RunPhase, type TeamSeat } from "../lib/run"; import type { ExecEvent } from "../lib/api"; import { cn } from "../ui"; @@ -67,7 +67,7 @@ function SeatCard({ seat }: { seat: TeamSeat }) { } export function TeamView({ events, output, phase }: { events: ExecEvent[]; output?: string; phase?: RunPhase }) { - const team = useMemo(() => deriveTeam(events), [events]); + const team = useMemo(() => deriveTeam(events, teamNow(events, phase === "streaming")), [events, phase]); const { lead, seats, t0, t1 } = team; const span = Math.max(t1 - t0, 1); const streaming = phase === "streaming"; diff --git a/sundynix-desktop/frontend/src/lib/run.test.ts b/sundynix-desktop/frontend/src/lib/run.test.ts index 5742c2c..ae363cb 100644 --- a/sundynix-desktop/frontend/src/lib/run.test.ts +++ b/sundynix-desktop/frontend/src/lib/run.test.ts @@ -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 { @@ -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); + }); +}); diff --git a/sundynix-desktop/frontend/src/lib/run.ts b/sundynix-desktop/frontend/src/lib/run.ts index f37c771..4b65d1e 100644 --- a/sundynix-desktop/frontend/src/lib/run.ts +++ b/sundynix-desktop/frontend/src/lib/run.ts @@ -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; diff --git a/sundynix-desktop/frontend/src/views/RunsView.tsx b/sundynix-desktop/frontend/src/views/RunsView.tsx index fca06a7..fcebcae 100644 --- a/sundynix-desktop/frontend/src/views/RunsView.tsx +++ b/sundynix-desktop/frontend/src/views/RunsView.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react"; import { Activity, FileText, History, Users, Wrench } from "lucide-react"; import { ExecTrace } from "../components/ExecTrace"; import { TeamView } from "../components/TeamView"; +import { OfficeView } from "../components/OfficeView"; import { Markdown } from "../components/Markdown"; import { ChartView } from "../components/ChartView"; import { extractChartBlocks, hasChart } from "../lib/chartspec"; @@ -33,6 +34,7 @@ export function RunsView({ run }: { run: RunState }) { const [replay, setReplay] = useState(emptyRun); const [evalRes, setEvalRes] = useState(null); const [tab, setTab] = useState("trace"); + const [teamSkin, setTeamSkin] = useState<"office" | "board">("office"); // 团队视图皮肤:卡通办公室 / 卡片看板 useEffect(() => { let alive = true; @@ -139,7 +141,23 @@ export function RunsView({ run }: { run: RunState }) { ) : activeTab === "trace" ? ( ) : activeTab === "team" ? ( - +
+
+ {(["office", "board"] as const).map((k) => ( + + ))} +
+
+ {teamSkin === "office" ? ( + + ) : ( + + )} +
+
) : activeTab === "tools" ? ( ) : (