@@ -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 (
< >
< circle cx = { 0 } cy = { - 119 } r = { 28.5 } fill = { color } stroke = { OUTLINE } strokeWidth = { 2.4 } / >
{ style === 0 && (
< >
< ellipse cx = { - 25 } cy = { - 108 } rx = { 6.5 } ry = { 13 } fill = { color } stroke = { OUTLINE } strokeWidth = { 2.4 } / >
< ellipse cx = { 25 } cy = { - 108 } rx = { 6.5 } ry = { 13 } fill = { color } stroke = { OUTLINE } strokeWidth = { 2.4 } / >
< / >
) }
{ style === 1 && (
< >
< circle cx = { 27 } cy = { - 131 } r = { 9 } fill = { color } stroke = { OUTLINE } strokeWidth = { 2.4 } / >
< ellipse cx = { 33 } cy = { - 114 } rx = { 6 } ry = { 11 } fill = { color } stroke = { OUTLINE } strokeWidth = { 2.4 } / >
< / >
) }
{ style === 2 && < ellipse cx = { 0 } cy = { - 142 } rx = { 9 } ry = { 7 } fill = { color } stroke = { OUTLINE } strokeWidth = { 2.4 } / > }
< / >
) ;
}
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 (
< g className = { working ? "sdx-bob" : "sdx-breathe" } style = { { animationDelay : d } } >
{ /* 手臂:working 时上下敲键盘 */ }
< g className = { working ? "sdx-arm sdx-type" : "sdx-arm" } style = { { animationDelay : d } } >
< rect x = { - 30 } y = { - 90 } width = { 11 } height = { 36 } rx = { 5.5 } fill = { shirt } stroke = { OUTLINE } strokeWidth = { 2.4 } / >
< / g >
< g className = { working ? "sdx-arm sdx-type" : "sdx-arm" } style = { { animationDelay : ` ${ delay + 0.18 } s ` } } >
< rect x = { 19 } y = { - 90 } width = { 11 } height = { 36 } rx = { 5.5 } fill = { shirt } stroke = { OUTLINE } strokeWidth = { 2.4 } / >
< / g >
{ /* 躯干 */ }
< rect x = { - 21 } y = { - 96 } width = { 42 } height = { 54 } rx = { 15 } fill = { shirt } stroke = { OUTLINE } strokeWidth = { 2.6 } / >
< path d = { ` M -8 -95 L 0 -86 L 8 -95 ` } fill = "none" stroke = { OUTLINE } strokeWidth = { 2.2 } strokeLinecap = "round" / >
{ /* 头 */ }
< Hair color = { hair } style = { style } / >
< ellipse cx = { 0 } cy = { - 112 } rx = { 24 } ry = { 23 } fill = { SKIN } stroke = { OUTLINE } strokeWidth = { 2.5 } / >
{ /* 刘海 */ }
< path
d = "M -23 -120 C -19 -107 -7 -111 -1 -117 C 7 -109 17 -110 23 -120 C 20 -134 -20 -134 -23 -120 Z"
fill = { hair }
stroke = { OUTLINE }
strokeWidth = { 2.2 }
strokeLinejoin = "round"
/ >
< ellipse cx = { - 15 } cy = { - 104 } rx = { 4.5 } ry = { 3 } fill = "#f0a0a0" opacity = { 0.5 } / >
< ellipse cx = { 15 } cy = { - 104 } rx = { 4.5 } ry = { 3 } fill = "#f0a0a0" opacity = { 0.5 } / >
{ /* 眼睛:完成时眯眼笑,失败时垂眼 */ }
{ happy ? (
< >
< path d = "M -13 -110 q 4.5 -5 9 0" fill = "none" stroke = { OUTLINE } strokeWidth = { 2.4 } strokeLinecap = "round" / >
< path d = "M 4 -110 q 4.5 -5 9 0" fill = "none" stroke = { OUTLINE } strokeWidth = { 2.4 } strokeLinecap = "round" / >
< / >
) : (
< >
< ellipse cx = { - 8.5 } cy = { - 110 } rx = { 3 } ry = { sad ? 2.6 : 4 } fill = { OUTLINE } / >
< ellipse cx = { 8.5 } cy = { - 110 } rx = { 3 } ry = { sad ? 2.6 : 4 } fill = { OUTLINE } / >
< / >
) }
{ sad ? (
< path d = "M -4 -98 q 4 -4 8 0" fill = "none" stroke = { OUTLINE } strokeWidth = { 2.2 } strokeLinecap = "round" / >
) : (
< path d = "M -4 -101 q 4 5 8 0" fill = "none" stroke = { OUTLINE } strokeWidth = { 2.2 } strokeLinecap = "round" / >
) }
< / g >
) ;
}
// 头顶气泡:工作中=打字三点,完成=✓,失败=!
function Bubble ( { status , delay } : { status : TeamSeat [ "status" ] ; delay : number } ) {
const tone = status === "error" ? ERR : status === "running" ? RUN : OK ;
return (
< g className = "sdx-pop" style = { { animationDelay : ` ${ delay } s ` } } >
< rect x = { - 24 } y = { - 186 } width = { 48 } height = { 30 } rx = { 11 } fill = "#fff" stroke = { OUTLINE } strokeWidth = { 2.4 } / >
< path d = "M -6 -158 L 0 -148 L 6 -158 Z" fill = "#fff" stroke = { OUTLINE } strokeWidth = { 2.4 } strokeLinejoin = "round" / >
< path d = "M -7 -158.6 L 7 -158.6" stroke = "#fff" strokeWidth = { 3 } / >
{ status === "running" ? (
[ - 11 , 0 , 11 ] . map ( ( x , i ) = > (
< circle key = { x } className = "sdx-dot" style = { { animationDelay : ` ${ i * 0.18 } s ` } } cx = { x } cy = { - 171 } r = { 3.6 } fill = { tone } / >
) )
) : status === "error" ? (
< >
< rect x = { - 2.2 } y = { - 181 } width = { 4.4 } height = { 12 } rx = { 2.2 } fill = { ERR } / >
< circle cx = { 0 } cy = { - 164 } r = { 2.6 } fill = { ERR } / >
< / >
) : (
< path d = "M -9 -171 L -3 -165 L 10 -178" fill = "none" stroke = { OK } strokeWidth = { 3.6 } strokeLinecap = "round" strokeLinejoin = "round" / >
) }
< / g >
) ;
}
// 工位:白桌 + 显示器辉光(状态色)+ 桌面铭牌。桌子挡住下半身,人只露上半身。
function Deskette ( { name , sub , tone , running , w = 200 } : { name : string ; sub : string ; tone : string ; running? : boolean ; w? : number } ) {
const hw = w / 2 ;
return (
< g >
{ /* 显示器(背对我们,屏幕辉光溢出到桌面) */ }
< g transform = { ` translate( ${ hw - 34 } , 0) ` } >
< ellipse cx = { 0 } cy = { - 46 } rx = { 26 } ry = { 9 } fill = { tone } opacity = { running ? 0.35 : 0.18 } className = { running ? "sdx-glow" : undefined } / >
< rect x = { - 3 } y = { - 58 } width = { 6 } height = { 12 } fill = "#c9c2b8" stroke = { OUTLINE } strokeWidth = { 2 } / >
< rect x = { - 21 } y = { - 84 } width = { 42 } height = { 28 } rx = { 4 } fill = "#eceae6" stroke = { OUTLINE } strokeWidth = { 2.4 } / >
< rect x = { - 17 } y = { - 80 } width = { 34 } height = { 20 } rx = { 2 } fill = { tone } opacity = { running ? 0.8 : 0.45 } / >
< / g >
{ /* 桌面 + 桌沿 */ }
< rect x = { - hw } y = { - 46 } width = { w } height = { 9 } rx = { 4 } fill = { DESK_TOP } stroke = { OUTLINE } strokeWidth = { 2.6 } / >
< rect x = { - hw + 8 } y = { - 38 } width = { w - 16 } height = { 36 } rx = { 3 } fill = { DESK_FRONT } stroke = { OUTLINE } strokeWidth = { 2.4 } / >
{ /* 咖啡杯 */ }
< g transform = { ` translate( ${ - hw + 26 } , -46) ` } >
< rect x = { - 6 } y = { - 13 } width = { 12 } height = { 13 } rx = { 2.5 } fill = "#fff" stroke = { OUTLINE } strokeWidth = { 2 } / >
< path d = "M 6 -10 q 6 3 0 7" fill = "none" stroke = { OUTLINE } strokeWidth = { 2 } / >
< / g >
{ /* 铭牌 */ }
< rect x = { - hw + 18 } y = { - 32 } width = { w - 36 } height = { 24 } rx = { 4 } fill = "#fff" stroke = { OUTLINE } strokeWidth = { 2.2 } / >
< circle cx = { - hw + 31 } cy = { - 20 } r = { 4.2 } fill = { tone } / >
< text x = { - hw + 42 } y = { - 15 } fontSize = { 17 } fontWeight = { 600 } fill = "#2f2a26" style = { { fontFamily : "system-ui, sans-serif" } } >
{ fitText ( name , ( w - 62 ) / 17 ) }
< / text >
< text x = { 0 } y = { 11 } fontSize = { 14 } textAnchor = "middle" fill = "#6b6156" style = { { fontFamily : "system-ui, sans-serif" } } >
{ sub }
< / text >
< / g >
) ;
}
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 (
< g transform = { ` translate( ${ x } , ${ y } ) scale( ${ scale } ) ` } >
< ellipse cx = { 0 } cy = { 2 } rx = { 46 } ry = { 9 } fill = "#000" opacity = { 0.09 } / >
< Chibi
shirt = { SHIRTS [ index % SHIRTS . length ] }
hair = { HAIRS [ index % HAIRS . length ] }
style = { index % 3 }
working = { running }
happy = { seat . status === "done" }
sad = { seat . status === "error" }
delay = { index * 0.23 }
/ >
{ bubble && < Bubble status = { seat . status } delay = { index * 0.23 } / > }
< Deskette name = { seat . name } sub = { sub } tone = { tone } running = { running } / >
< / g >
) ;
}
// 场景道具
function Room() {
return (
< g >
< rect x = { 0 } y = { 0 } width = { VB_W } height = { FLOOR_Y } fill = { WALL } / >
< rect x = { 0 } y = { FLOOR_Y } width = { VB_W } height = { VB_H - FLOOR_Y } fill = { FLOOR } / >
< rect x = { 0 } y = { FLOOR_Y - 5 } width = { VB_W } height = { 5 } fill = "#cabfae" / >
{ /* 地毯 */ }
< ellipse cx = { 360 } cy = { 402 } rx = { 318 } ry = { 48 } fill = "#e9dfd0" stroke = "#cfc2ae" strokeWidth = { 2 } / >
{ /* 窗 */ }
< g >
< rect x = { 38 } y = { 30 } width = { 112 } height = { 92 } rx = { 5 } fill = "#cfe9f5" stroke = { OUTLINE } strokeWidth = { 2.6 } / >
< path d = "M 94 30 L 94 122 M 38 76 L 150 76" stroke = { OUTLINE } strokeWidth = { 2.2 } / >
< path d = "M 48 116 L 88 38 L 104 38 L 64 116 Z" fill = "#fff" opacity = { 0.35 } / >
< / g >
{ /* 挂钟 */ }
< g transform = "translate(660, 54)" >
< circle r = { 22 } fill = "#fff" stroke = { OUTLINE } strokeWidth = { 2.6 } / >
< path d = "M 0 0 L 0 -13 M 0 0 L 9 5" stroke = { OUTLINE } strokeWidth = { 2.4 } strokeLinecap = "round" / >
< / g >
{ /* 绿植 */ }
< g transform = "translate(668, 186)" >
< path d = "M 0 -6 C -22 -14 -26 -44 -4 -52 C -12 -30 -4 -18 0 -6 Z" fill = "#4a9c6d" stroke = { OUTLINE } strokeWidth = { 2.2 } strokeLinejoin = "round" / >
< path d = "M 0 -6 C 22 -18 24 -50 2 -58 C 12 -32 4 -18 0 -6 Z" fill = "#5cb07d" stroke = { OUTLINE } strokeWidth = { 2.2 } strokeLinejoin = "round" / >
< path d = "M -13 -6 L 13 -6 L 9 16 L -9 16 Z" fill = "#e6e0d6" stroke = { OUTLINE } strokeWidth = { 2.4 } strokeLinejoin = "round" / >
< / g >
< / g >
) ;
}
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 < div className = "p-6 text-center text-xs text-slate-600" > 暂 无 协 调 轨 迹 。 < / div > ;
}
// 排位:≤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 (
< div className = "flex h-full min-h-0 flex-col overflow-auto" >
< style > { `
@keyframes sdx-breathe{0%,100%{transform:translateY(0)}50%{transform:translateY(-1.5px)}}
@keyframes sdx-bob{0%,100%{transform:translateY(0)}50%{transform:translateY(-3px)}}
@keyframes sdx-type{0%,100%{transform:rotate(-7deg)}50%{transform:rotate(9deg)}}
@keyframes sdx-dot{0%,80%,100%{transform:translateY(0);opacity:.45}40%{transform:translateY(-3.5px);opacity:1}}
@keyframes sdx-glow{0%,100%{opacity:.2}50%{opacity:.5}}
@keyframes sdx-pop{from{transform:translateY(4px) scale(.85);opacity:0}to{transform:translateY(0) scale(1);opacity:1}}
@keyframes sdx-flow{to{stroke-dashoffset:-18}}
@keyframes sdx-blink{50%{opacity:0}}
.sdx-breathe{animation:sdx-breathe 3.4s ease-in-out infinite}
.sdx-bob{animation:sdx-bob 1.6s ease-in-out infinite}
.sdx-arm{transform-box:fill-box;transform-origin:50% 8%}
.sdx-type{animation:sdx-type .34s ease-in-out infinite}
.sdx-dot{transform-box:fill-box;transform-origin:50% 50%;animation:sdx-dot 1.1s ease-in-out infinite}
.sdx-glow{animation:sdx-glow 1.8s ease-in-out infinite}
.sdx-pop{transform-box:fill-box;transform-origin:50% 100%;animation:sdx-pop .32s cubic-bezier(.34,1.56,.64,1) both}
.sdx-flow{stroke-dasharray:5 5;animation:sdx-flow 1.1s linear infinite}
.sdx-caret{animation:sdx-blink 1.05s step-end infinite}
@media(prefers-reduced-motion:reduce){
.sdx-breathe,.sdx-bob,.sdx-type,.sdx-dot,.sdx-glow,.sdx-pop,.sdx-flow,.sdx-caret{animation:none}
}
` } < / style >
< svg viewBox = { ` 0 0 ${ VB_W } ${ VB_H } ` } className = "w-full shrink-0 rounded-lg" role = "img" aria-label = "办公室视图:协调者与专家工位" >
< Room / >
{ /* 白板:协调者的思考/综合过程 */ }
< g >
< rect x = { 196 } y = { 18 } width = { 330 } height = { 94 } rx = { 6 } fill = "#fff" stroke = { OUTLINE } strokeWidth = { 2.8 } / >
< rect x = { 196 } y = { 104 } width = { 330 } height = { 8 } rx = { 3 } fill = "#e2dbcf" stroke = { OUTLINE } strokeWidth = { 2.2 } / >
< foreignObject x = { 208 } y = { 26 } width = { 306 } height = { 74 } >
< div style = { { fontFamily : "system-ui, sans-serif" , color : "#4a4238" , lineHeight : 1.35 } } >
< div style = { { fontSize : 12 , color : leadTone , fontWeight : 600 } } >
{ streaming ? "综合中…" : lead ? . status === "error" ? "协调失败" : lead ? . status === "done" ? "已综合" : "协调中" }
< / div >
< div
style = { {
fontSize : 13 ,
marginTop : 3 ,
wordBreak : "break-word" ,
display : "-webkit-box" ,
WebkitBoxOrient : "vertical" ,
WebkitLineClamp : 3 ,
overflow : "hidden" ,
} }
>
{ thinking || lead ? . detail || "等待协调者输出…" }
{ streaming && < span className = "sdx-caret" style = { { color : RUN } } > ▌ < / span > }
< / div >
< / div >
< / foreignObject >
< / g >
{ /* 派发连线:只画正在工作的工位 */ }
{ placed
. filter ( ( p ) = > p . seat . status === "running" )
. map ( ( p ) = > (
< path
key = { ` w- ${ p . seat . node } ` }
d = { ` M 360 214 Q ${ ( 360 + p . x ) / 2 } ${ ( 214 + p . y ) / 2 - 24 } ${ p . x } ${ p . y - 100 * p . scale } ` }
fill = "none"
stroke = { RUN }
strokeWidth = { 1.6 }
opacity = { 0.35 }
className = "sdx-flow"
/ >
) ) }
{ /* 协调者:白板前,比前排小 → 纵深 */ }
< g transform = { ` translate(360, 232) scale(0.62) ` } >
< ellipse cx = { 0 } cy = { 2 } rx = { 46 } ry = { 9 } fill = "#000" opacity = { 0.09 } / >
< Chibi shirt = { RUN } hair = "#3f3229" style = { 2 } working = { streaming } happy = { lead ? . status === "done" } sad = { lead ? . status === "error" } delay = { 0 } / >
< Deskette name = { lead ? . label ? ? "协调者" } sub = "协调者" tone = { leadTone } running = { streaming } w = { 230 } / >
< / g >
{ placed . map ( ( p ) = > (
< Seat key = { p . seat . node } seat = { p . seat } x = { p . x } y = { p . y } scale = { p . scale } index = { p . index } bubble = { p . bubble } / >
) ) }
< / svg >
< div className = "shrink-0 border-t border-line px-3 pb-3 pt-2" >
< div className = "mb-1.5 text-[11px] text-slate-600" > 并 发 时 间 轴 · 共 { fmtMs ( span ) } < / div >
< div className = "space-y-1" >
{ seats . map ( ( s ) = > {
const left = ( ( s . startTS - t0 ) / span ) * 100 ;
const width = Math . max ( ( ( ( s . endTS ? ? t1 ) - s . startTS ) / span ) * 100 , 2 ) ;
return (
< div key = { s . node } className = "grid grid-cols-[80px_1fr] items-center gap-2" >
< span className = "truncate text-[11px] text-slate-500" > { s . name } < / span >
< div className = "h-1.5 rounded-full bg-ink-800" >
< div
className = "h-1.5 rounded-full"
style = { {
marginLeft : ` ${ left } % ` ,
width : ` ${ width } % ` ,
background : s.status === "running" ? RUN : s.status === "error" ? ERR : OK ,
} }
/ >
< / div >
< / div >
) ;
} ) }
< / div >
< / div >
< / div >
) ;
}