Security: - JwtGuard + RoleGuard on all sisters endpoints - Admin-only access for config/sessions/subagents/activity - ThrottlerGuard on /auth/refresh - HttpOnly SameSite cookies + CSRF (replaces localStorage) Code Quality: - Per-sister draft input (Record<SisterName, string>) - crypto.randomUUID for optimistic message ids (dedupe ready) - Polling disabled while WebSocket connected - SVG keyboard accessibility (role/tabIndex/onKeyDown)
497 lines
15 KiB
TypeScript
497 lines
15 KiB
TypeScript
'use client';
|
|
|
|
import React, { useCallback } from 'react';
|
|
import styled from 'styled-components';
|
|
import { API_URL } from '@/lib/config';
|
|
|
|
export type AgentState = 'idle' | 'thinking' | 'tool_calling' | 'speaking' | 'error';
|
|
export type SisterName = 'harang' | 'narang' | 'darang' | 'erang';
|
|
|
|
export interface SubAgent {
|
|
id: string;
|
|
name: string;
|
|
label: string;
|
|
sister: SisterName;
|
|
state: AgentState;
|
|
currentTask?: string | null;
|
|
updatedAt?: number | null;
|
|
sessionLabel?: string | null;
|
|
}
|
|
|
|
export interface SisterNode {
|
|
name: SisterName;
|
|
displayName: string;
|
|
role: string;
|
|
state: AgentState;
|
|
currentTask: string | null;
|
|
activeSessionLabel?: string | null;
|
|
subagents: SubAgent[];
|
|
}
|
|
|
|
export type SelectedAgent =
|
|
| { type: 'sister'; name: SisterName }
|
|
| { type: 'subagent'; id: string; sister: SisterName };
|
|
|
|
interface OfficeSceneProps {
|
|
sisters: SisterNode[];
|
|
selected: SelectedAgent | null;
|
|
onSelectSister: (name: SisterName) => void;
|
|
onSelectSubagent: (id: string, sister: SisterName) => void;
|
|
dataMode: 'live' | 'snapshot' | 'fallback';
|
|
subagentMode: 'live' | 'fallback';
|
|
}
|
|
|
|
const SceneWrapper = styled.div`
|
|
position: relative;
|
|
width: 100%;
|
|
aspect-ratio: 800 / 460;
|
|
max-height: 60vh;
|
|
border: 1px solid var(--border-color);
|
|
background: var(--bg-surface);
|
|
overflow: hidden;
|
|
user-select: none;
|
|
`;
|
|
|
|
const FreshnessLabel = styled.div`
|
|
position: absolute;
|
|
top: 8px;
|
|
right: 12px;
|
|
font-family: var(--font-mono);
|
|
font-size: 10px;
|
|
color: var(--text-secondary);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
pointer-events: none;
|
|
`;
|
|
|
|
const WsDot = styled.span<{ $connected: boolean }>`
|
|
display: inline-block;
|
|
width: 6px;
|
|
height: 6px;
|
|
border-radius: 50%;
|
|
background: ${({ $connected }) => ($connected ? '#00FF00' : '#666')};
|
|
margin-right: 5px;
|
|
vertical-align: middle;
|
|
`;
|
|
|
|
const VB_W = 800;
|
|
const VB_H = 460;
|
|
type Point = [number, number];
|
|
|
|
const ZONES = {
|
|
harang: [5, 5, 345, 200] as const,
|
|
darang: [450, 5, 345, 200] as const,
|
|
narang: [5, 260, 345, 195] as const,
|
|
erang: [450, 260, 345, 195] as const,
|
|
};
|
|
|
|
const SISTER_POS: Record<SisterName, [number, number]> = {
|
|
harang: [75, 105],
|
|
darang: [725, 105],
|
|
narang: [75, 358],
|
|
erang: [725, 358],
|
|
};
|
|
|
|
const CONF = { cx: 400, cy: 232, rx: 55, ry: 30 };
|
|
|
|
const SUBAGENT_POSITIONS: Record<SisterName, Point[]> = {
|
|
harang: [[175, 65], [270, 65], [220, 160]],
|
|
darang: [[460, 65], [560, 65], [660, 65], [460, 160], [560, 160]],
|
|
narang: [[175, 295], [270, 295], [175, 395], [270, 395]],
|
|
erang: [[460, 295], [555, 295], [650, 295], [460, 395], [555, 395]],
|
|
};
|
|
|
|
const SUBAGENT_TARGETS: Record<SisterName, Point[]> = {
|
|
harang: [[295, 112], [338, 148], [365, 190]],
|
|
darang: [[505, 94], [560, 112], [462, 148], [438, 188], [520, 196]],
|
|
narang: [[292, 332], [330, 302], [362, 276], [344, 374]],
|
|
erang: [[618, 322], [664, 298], [702, 286], [610, 388], [664, 380]],
|
|
};
|
|
|
|
const ZONE_COLORS: Record<SisterName, string> = {
|
|
harang: 'rgba(41, 121, 255, 0.06)',
|
|
narang: 'rgba(0, 191, 165, 0.06)',
|
|
darang: 'rgba(255, 64, 129, 0.06)',
|
|
erang: 'rgba(255, 109, 0, 0.06)',
|
|
};
|
|
|
|
const ZONE_BORDER: Record<SisterName, string> = {
|
|
harang: 'rgba(41, 121, 255, 0.25)',
|
|
narang: 'rgba(0, 191, 165, 0.25)',
|
|
darang: 'rgba(255, 64, 129, 0.25)',
|
|
erang: 'rgba(255, 109, 0, 0.25)',
|
|
};
|
|
|
|
const STATE_COLORS: Record<AgentState, string> = {
|
|
idle: '#444444',
|
|
thinking: '#2979FF',
|
|
tool_calling: '#FF9800',
|
|
speaking: '#00BFA5',
|
|
error: '#FF1744',
|
|
};
|
|
|
|
const STATE_PROGRESS: Record<AgentState, number> = {
|
|
idle: 0,
|
|
error: 0,
|
|
thinking: 0.48,
|
|
tool_calling: 0.88,
|
|
speaking: 0.66,
|
|
};
|
|
|
|
function lerpPoint(from: Point, to: Point, progress: number): Point {
|
|
return [
|
|
from[0] + (to[0] - from[0]) * progress,
|
|
from[1] + (to[1] - from[1]) * progress,
|
|
];
|
|
}
|
|
|
|
function getSubagentPosition(sister: SisterName, index: number, state: AgentState): Point {
|
|
const from = SUBAGENT_POSITIONS[sister][index] ?? SUBAGENT_POSITIONS[sister][0];
|
|
const to = SUBAGENT_TARGETS[sister][index] ?? from;
|
|
return lerpPoint(from, to, STATE_PROGRESS[state]);
|
|
}
|
|
|
|
function getSubagentMotion(state: AgentState, index: number): { dx: number; dy: number; dur: string } | null {
|
|
const phase = index % 2 === 0 ? 1 : -1;
|
|
|
|
if (state === 'thinking') return { dx: 5 * phase, dy: -6, dur: '3.6s' };
|
|
if (state === 'tool_calling') return { dx: 10 * phase, dy: -14, dur: '2s' };
|
|
if (state === 'speaking') return { dx: 6 * phase, dy: -4, dur: '2.8s' };
|
|
return null;
|
|
}
|
|
|
|
function SisterCircle({
|
|
cx,
|
|
cy,
|
|
r,
|
|
state,
|
|
name,
|
|
selected,
|
|
onClick,
|
|
}: {
|
|
cx: number;
|
|
cy: number;
|
|
r: number;
|
|
state: AgentState;
|
|
name: SisterName;
|
|
selected: boolean;
|
|
onClick: () => void;
|
|
}) {
|
|
const color = STATE_COLORS[state];
|
|
const clipId = `avatar-clip-${name}`;
|
|
|
|
return (
|
|
<g
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label={`${name} — state: ${state}`}
|
|
onClick={onClick}
|
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick(); } }}
|
|
style={{ cursor: 'pointer', outline: 'none' }}
|
|
>
|
|
{selected && (
|
|
<circle
|
|
cx={cx}
|
|
cy={cy}
|
|
r={r + 8}
|
|
fill="none"
|
|
stroke="var(--text-primary)"
|
|
strokeWidth={1}
|
|
strokeDasharray="4 3"
|
|
opacity={0.6}
|
|
/>
|
|
)}
|
|
<circle
|
|
cx={cx}
|
|
cy={cy}
|
|
r={r + 4}
|
|
fill="none"
|
|
stroke={color}
|
|
strokeWidth={state === 'error' ? 2.5 : 1.5}
|
|
opacity={state === 'idle' ? 0.4 : 1}
|
|
>
|
|
{state === 'thinking' && (
|
|
<animate attributeName="opacity" values="0.45;1;0.45" dur="1.8s" repeatCount="indefinite" />
|
|
)}
|
|
{state === 'tool_calling' && (
|
|
<animate attributeName="stroke-opacity" values="1;0.2;1" dur="0.9s" repeatCount="indefinite" />
|
|
)}
|
|
</circle>
|
|
<circle cx={cx} cy={cy} r={r} fill="#1e1e1e" stroke={color} strokeWidth={1} />
|
|
<defs>
|
|
<clipPath id={clipId}>
|
|
<circle cx={cx} cy={cy} r={r - 2} />
|
|
</clipPath>
|
|
</defs>
|
|
<image
|
|
href={`${API_URL}/api/sisters/${name}/avatar`}
|
|
x={cx - r + 2}
|
|
y={cy - r + 2}
|
|
width={(r - 2) * 2}
|
|
height={(r - 2) * 2}
|
|
preserveAspectRatio="xMidYMid slice"
|
|
clipPath={`url(#${clipId})`}
|
|
/>
|
|
<text
|
|
x={cx}
|
|
y={cy + r + 16}
|
|
textAnchor="middle"
|
|
fontSize={9}
|
|
fontFamily="var(--font-mono)"
|
|
fill={color}
|
|
style={{ textTransform: 'uppercase', letterSpacing: '0.06em', userSelect: 'none', pointerEvents: 'none' }}
|
|
>
|
|
{state}
|
|
</text>
|
|
</g>
|
|
);
|
|
}
|
|
|
|
function SubagentCircle({
|
|
cx,
|
|
cy,
|
|
r,
|
|
state,
|
|
label,
|
|
selected,
|
|
motion,
|
|
motionBegin,
|
|
onClick,
|
|
}: {
|
|
cx: number;
|
|
cy: number;
|
|
r: number;
|
|
state: AgentState;
|
|
label: string;
|
|
selected: boolean;
|
|
motion: { dx: number; dy: number; dur: string } | null;
|
|
motionBegin: string;
|
|
onClick: () => void;
|
|
}) {
|
|
const color = STATE_COLORS[state];
|
|
|
|
return (
|
|
<g
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label={`subagent ${label} — state: ${state}`}
|
|
onClick={onClick}
|
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick(); } }}
|
|
style={{ cursor: 'pointer', outline: 'none' }}
|
|
>
|
|
{motion && (
|
|
<animateTransform
|
|
attributeName="transform"
|
|
type="translate"
|
|
values={`0 0; ${motion.dx} ${motion.dy}; 0 0`}
|
|
dur={motion.dur}
|
|
begin={motionBegin}
|
|
repeatCount="indefinite"
|
|
/>
|
|
)}
|
|
{selected && (
|
|
<circle cx={cx} cy={cy} r={r + 5} fill="none" stroke="var(--text-primary)" strokeWidth={1} opacity={0.5} />
|
|
)}
|
|
<circle cx={cx} cy={cy} r={r + 2} fill="none" stroke={color} strokeWidth={1} opacity={state === 'idle' ? 0.3 : 0.8}>
|
|
{state === 'thinking' && (
|
|
<animate attributeName="opacity" values="0.35;0.95;0.35" dur="2s" repeatCount="indefinite" />
|
|
)}
|
|
</circle>
|
|
<circle cx={cx} cy={cy} r={r} fill="#1a1a1a" stroke={color} strokeWidth={0.8} />
|
|
<text
|
|
x={cx}
|
|
y={cy + 4}
|
|
textAnchor="middle"
|
|
fontSize={8}
|
|
fontFamily="var(--font-mono)"
|
|
fill={color}
|
|
opacity={0.9}
|
|
style={{ userSelect: 'none', pointerEvents: 'none' }}
|
|
>
|
|
{label.length > 8 ? `${label.slice(0, 7)}…` : label}
|
|
</text>
|
|
</g>
|
|
);
|
|
}
|
|
|
|
function ConnectorLine({ x1, y1, x2, y2, active }: { x1: number; y1: number; x2: number; y2: number; active: boolean }) {
|
|
return (
|
|
<line
|
|
x1={x1}
|
|
y1={y1}
|
|
x2={x2}
|
|
y2={y2}
|
|
stroke={active ? 'rgba(245,245,245,0.35)' : 'rgba(255,255,255,0.08)'}
|
|
strokeWidth={active ? 1.5 : 1}
|
|
strokeDasharray={active ? '6 4' : '3 4'}
|
|
strokeDashoffset={active ? 40 : 0}
|
|
>
|
|
{active && <animate attributeName="stroke-dashoffset" values="40;0" dur="1.4s" repeatCount="indefinite" />}
|
|
</line>
|
|
);
|
|
}
|
|
|
|
function PathConnector({ d, active }: { d: string; active: boolean }) {
|
|
return (
|
|
<path
|
|
d={d}
|
|
fill="none"
|
|
stroke={active ? 'rgba(245,245,245,0.3)' : 'rgba(255,255,255,0.06)'}
|
|
strokeWidth={active ? 1.5 : 1}
|
|
strokeDasharray={active ? '6 4' : '3 4'}
|
|
strokeDashoffset={active ? 40 : 0}
|
|
>
|
|
{active && <animate attributeName="stroke-dashoffset" values="40;0" dur="1.8s" repeatCount="indefinite" />}
|
|
</path>
|
|
);
|
|
}
|
|
|
|
const SISTER_ORDER: SisterName[] = ['harang', 'narang', 'darang', 'erang'];
|
|
|
|
export default function OfficeScene({
|
|
sisters,
|
|
selected,
|
|
onSelectSister,
|
|
onSelectSubagent,
|
|
dataMode,
|
|
subagentMode,
|
|
}: OfficeSceneProps) {
|
|
const getSister = useCallback(
|
|
(name: SisterName) => sisters.find((s) => s.name === name),
|
|
[sisters],
|
|
);
|
|
|
|
const isActive = useCallback(
|
|
(name: SisterName) => {
|
|
const sister = getSister(name);
|
|
return sister?.state === 'thinking' || sister?.state === 'tool_calling' || sister?.state === 'speaking';
|
|
},
|
|
[getSister],
|
|
);
|
|
|
|
const isSisterSelected = (name: SisterName) => selected?.type === 'sister' && selected.name === name;
|
|
const isSubSelected = (id: string) => selected?.type === 'subagent' && selected.id === id;
|
|
|
|
const harangActive = isActive('harang');
|
|
const narangActive = isActive('narang');
|
|
const darangActive = isActive('darang');
|
|
const erangActive = isActive('erang');
|
|
|
|
return (
|
|
<SceneWrapper>
|
|
<FreshnessLabel>
|
|
<WsDot $connected={dataMode === 'live'} />
|
|
{dataMode === 'live' ? 'live · ws' : dataMode === 'snapshot' ? 'snapshot · poll' : 'fallback · doc-derived'}
|
|
{subagentMode === 'live' ? ' · subagents: live · runtime' : ' · subagents: fallback'}
|
|
</FreshnessLabel>
|
|
|
|
<svg viewBox={`0 0 ${VB_W} ${VB_H}`} width="100%" height="100%" style={{ display: 'block' }}>
|
|
{SISTER_ORDER.map((name) => {
|
|
const [zx, zy, zw, zh] = ZONES[name];
|
|
return (
|
|
<rect
|
|
key={`zone-${name}`}
|
|
x={zx}
|
|
y={zy}
|
|
width={zw}
|
|
height={zh}
|
|
rx={4}
|
|
fill={ZONE_COLORS[name]}
|
|
stroke={ZONE_BORDER[name]}
|
|
strokeWidth={1}
|
|
/>
|
|
);
|
|
})}
|
|
|
|
<rect x={358} y={5} width={84} height={450} rx={2} fill="#111111" stroke="rgba(255,255,255,0.04)" strokeWidth={1} />
|
|
<rect x={5} y={205} width={790} height={50} rx={2} fill="#111111" stroke="rgba(255,255,255,0.04)" strokeWidth={1} />
|
|
|
|
<ellipse cx={CONF.cx} cy={CONF.cy} rx={CONF.rx} ry={CONF.ry} fill="#1a1a1a" stroke="rgba(255,255,255,0.15)" strokeWidth={1} />
|
|
<text x={CONF.cx} y={CONF.cy + 4} textAnchor="middle" fontSize={9} fontFamily="var(--font-mono)" fill="rgba(255,255,255,0.4)">
|
|
회의실
|
|
</text>
|
|
|
|
<ConnectorLine x1={SISTER_POS.harang[0]} y1={SISTER_POS.harang[1] + 30} x2={SISTER_POS.narang[0]} y2={SISTER_POS.narang[1] - 30} active={harangActive || narangActive} />
|
|
<ConnectorLine x1={SISTER_POS.darang[0]} y1={SISTER_POS.darang[1] + 30} x2={SISTER_POS.erang[0]} y2={SISTER_POS.erang[1] - 30} active={darangActive || erangActive} />
|
|
<ConnectorLine x1={SISTER_POS.harang[0] + 30} y1={SISTER_POS.harang[1]} x2={SISTER_POS.darang[0] - 30} y2={SISTER_POS.darang[1]} active={harangActive || darangActive} />
|
|
<ConnectorLine x1={SISTER_POS.narang[0] + 30} y1={SISTER_POS.narang[1]} x2={SISTER_POS.erang[0] - 30} y2={SISTER_POS.erang[1]} active={narangActive || erangActive} />
|
|
<PathConnector d={`M${SISTER_POS.narang[0] + 20},${SISTER_POS.narang[1] - 20} Q${CONF.cx},${CONF.cy} ${SISTER_POS.darang[0] - 20},${SISTER_POS.darang[1] + 20}`} active={narangActive && darangActive} />
|
|
|
|
{([
|
|
['harang', 18, 22, '하랑이 · Planning'] as const,
|
|
['darang', 463, 22, '다랑이 · QA'] as const,
|
|
['narang', 18, 272, '나랑이 · Dev'] as const,
|
|
['erang', 463, 272, '이랑이 · Infra'] as const,
|
|
] as const).map(([name, lx, ly, text]) => (
|
|
<text key={`label-${name}`} x={lx} y={ly} fontSize={10} fontFamily="var(--font-mono)" fill={ZONE_BORDER[name]}>
|
|
{text}
|
|
</text>
|
|
))}
|
|
|
|
{SISTER_ORDER.map((name) => {
|
|
const sister = getSister(name);
|
|
const state: AgentState = sister?.state ?? 'idle';
|
|
const [cx, cy] = SISTER_POS[name];
|
|
return (
|
|
<SisterCircle
|
|
key={`sister-${name}`}
|
|
cx={cx}
|
|
cy={cy}
|
|
r={28}
|
|
state={state}
|
|
name={name}
|
|
selected={isSisterSelected(name)}
|
|
onClick={() => onSelectSister(name)}
|
|
/>
|
|
);
|
|
})}
|
|
|
|
{SISTER_ORDER.map((sisterName) => {
|
|
const sister = getSister(sisterName);
|
|
const subagents = sister?.subagents ?? [];
|
|
return subagents.map((sub, i) => {
|
|
const pos = getSubagentPosition(sisterName, i, sub.state);
|
|
const motion = getSubagentMotion(sub.state, i);
|
|
return (
|
|
<SubagentCircle
|
|
key={sub.id}
|
|
cx={pos[0]}
|
|
cy={pos[1]}
|
|
r={14}
|
|
state={sub.state}
|
|
label={sub.name}
|
|
selected={isSubSelected(sub.id)}
|
|
motion={motion}
|
|
motionBegin={`${i * 0.22}s`}
|
|
onClick={() => onSelectSubagent(sub.id, sisterName)}
|
|
/>
|
|
);
|
|
});
|
|
})}
|
|
|
|
{SISTER_ORDER.map((sisterName) => {
|
|
const sister = getSister(sisterName);
|
|
const subagents = sister?.subagents ?? [];
|
|
const [sx, sy] = SISTER_POS[sisterName];
|
|
return subagents.map((sub, i) => {
|
|
const pos = getSubagentPosition(sisterName, i, sub.state);
|
|
const active = sub.state !== 'idle' && sub.state !== 'error';
|
|
return (
|
|
<line
|
|
key={`conn-${sub.id}`}
|
|
x1={sx}
|
|
y1={sy}
|
|
x2={pos[0]}
|
|
y2={pos[1]}
|
|
stroke={active ? ZONE_BORDER[sisterName] : 'rgba(255,255,255,0.05)'}
|
|
strokeWidth={active ? 0.8 : 0.5}
|
|
strokeDasharray="2 3"
|
|
/>
|
|
);
|
|
});
|
|
})}
|
|
</svg>
|
|
</SceneWrapper>
|
|
);
|
|
}
|