Files
hanarang-dashboard/frontend/components/office/ChatWorkspace.tsx

648 lines
18 KiB
TypeScript

'use client';
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import styled, { keyframes } from 'styled-components';
import SisterAvatar from '@/components/common/SisterAvatar';
import { API_URL } from '@/lib/config';
import { withSessionRequest } from '@/lib/csrf';
import type { SisterName, AgentState } from './OfficeScene';
interface RuntimeMessage {
id: string;
role: 'user' | 'assistant' | 'tool';
content: string;
ts: string | null;
}
interface RuntimeSnapshot {
name: SisterName;
gatewayConnected: boolean;
mainState: AgentState;
currentTask: string | null;
activeSessionLabel: string | null;
recentMessages: RuntimeMessage[];
}
interface ChatMessage {
id: string;
role: 'user' | 'assistant' | 'tool';
content: string;
toolName?: string;
ts: string;
}
interface ChatWorkspaceProps {
initialSister: SisterName;
onClose: () => void;
}
const SISTER_DISPLAY: Record<SisterName, string> = {
harang: '하랑이',
narang: '나랑이',
darang: '다랑이',
erang: '이랑이',
};
const SISTER_ROLES: Record<SisterName, string> = {
harang: 'Planning & Orchestration',
narang: 'Development & Implementation',
darang: 'QA & Review',
erang: 'Infra & Deploy',
};
const SISTER_ORDER: SisterName[] = ['harang', 'narang', 'darang', 'erang'];
const fadeIn = keyframes`
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
`;
const Workspace = styled.div`
display: flex;
height: 100%;
border: 1px solid var(--border-color);
background: var(--bg-surface);
overflow: hidden;
@media (max-width: 767px) {
flex-direction: column;
}
`;
const SisterTabs = styled.nav`
width: 200px;
flex-shrink: 0;
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
padding: var(--space-md) 0;
@media (max-width: 767px) {
width: 100%;
flex-direction: row;
padding: 0;
overflow-x: auto;
border-right: none;
border-bottom: 1px solid var(--border-color);
}
`;
const SisterTabHeader = styled.div`
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 0 var(--space-md) var(--space-md);
@media (max-width: 767px) {
display: none;
}
`;
const SisterTab = styled.button<{ $active: boolean }>`
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
background: ${({ $active }) => ($active ? 'rgba(255,255,255,0.04)' : 'transparent')};
border: none;
border-left: 2px solid ${({ $active }) => ($active ? 'var(--text-primary)' : 'transparent')};
color: ${({ $active }) => ($active ? 'var(--text-primary)' : 'var(--text-secondary)')};
cursor: pointer;
text-align: left;
width: 100%;
transition: all 0.15s;
font-family: inherit;
&:hover {
color: var(--text-primary);
background: rgba(255,255,255,0.03);
}
@media (max-width: 767px) {
border-left: none;
border-bottom: 2px solid ${({ $active }) => ($active ? 'var(--text-primary)' : 'transparent')};
white-space: nowrap;
flex-shrink: 0;
padding: var(--space-xs) var(--space-sm);
gap: 4px;
}
`;
const TabMeta = styled.div`
display: flex;
flex-direction: column;
gap: 2px;
`;
const TabName = styled.div`
font-size: 13px;
font-weight: 600;
`;
const TabRole = styled.div`
font-size: 10px;
color: var(--text-secondary);
@media (max-width: 767px) {
display: none;
}
`;
const MessageArea = styled.div`
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
`;
const MessageHeader = styled.div`
padding: var(--space-md) var(--space-lg);
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
gap: var(--space-md);
flex-shrink: 0;
`;
const HeaderMeta = styled.div`
flex: 1;
`;
const HeaderName = styled.div`
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
`;
const HeaderRole = styled.div`
font-size: 11px;
color: var(--text-secondary);
`;
const HeaderBadge = styled.div<{ $ok: boolean }>`
font-family: var(--font-mono);
font-size: 10px;
color: ${({ $ok }) => ($ok ? '#00BFA5' : 'var(--text-secondary)')};
border: 1px solid currentColor;
padding: 2px var(--space-sm);
text-transform: uppercase;
letter-spacing: 0.06em;
`;
const Timeline = styled.div`
flex: 1;
overflow-y: auto;
padding: var(--space-lg);
display: flex;
flex-direction: column;
gap: var(--space-md);
`;
const MessageBubble = styled.div<{ $role: ChatMessage['role'] }>`
display: flex;
flex-direction: column;
gap: 4px;
align-items: ${({ $role }) => ($role === 'user' ? 'flex-end' : 'flex-start')};
animation: ${fadeIn} 0.2s ease;
`;
const BubbleContent = styled.div<{ $role: ChatMessage['role'] }>`
max-width: 75%;
padding: var(--space-sm) var(--space-md);
font-size: 13px;
line-height: 1.6;
color: var(--text-primary);
background: ${({ $role }) =>
$role === 'user'
? 'rgba(255,255,255,0.06)'
: $role === 'tool'
? 'rgba(255, 152, 0, 0.06)'
: 'rgba(255,255,255,0.02)'};
border: 1px solid ${({ $role }) =>
$role === 'user'
? 'rgba(255,255,255,0.12)'
: $role === 'tool'
? 'rgba(255,152,0,0.2)'
: 'rgba(255,255,255,0.06)'};
font-family: ${({ $role }) => ($role === 'tool' ? 'var(--font-mono)' : 'inherit')};
`;
const BubbleMeta = styled.div`
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-secondary);
opacity: 0.6;
`;
const EmptyTimeline = styled.div`
flex: 1;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: var(--space-md);
color: var(--text-secondary);
font-size: 13px;
text-align: center;
`;
const RuntimeNotice = styled.div`
border: 1px solid var(--border-color);
padding: var(--space-md);
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.06em;
line-height: 1.7;
max-width: 320px;
text-align: center;
`;
const InputArea = styled.div`
padding: var(--space-md) var(--space-lg);
border-top: 1px solid var(--border-color);
display: flex;
gap: var(--space-sm);
align-items: flex-end;
flex-shrink: 0;
`;
const MessageInput = styled.textarea`
flex: 1;
background: var(--bg-input, #1a1a1a);
border: 1px solid var(--border-color);
color: var(--text-primary);
font-family: inherit;
font-size: 13px;
padding: var(--space-sm) var(--space-md);
resize: none;
min-height: 40px;
max-height: 120px;
line-height: 1.5;
&:focus {
outline: none;
border-color: var(--border-hover);
}
&::placeholder {
color: var(--text-secondary);
opacity: 0.5;
}
`;
const SendBtn = styled.button`
border: 1px solid var(--border-color);
background: transparent;
color: var(--text-secondary);
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: var(--space-sm) var(--space-md);
cursor: pointer;
font-family: var(--font-mono);
transition: all 0.15s;
white-space: nowrap;
align-self: flex-end;
&:hover:not(:disabled) {
border-color: var(--border-hover);
color: var(--text-primary);
}
&:disabled {
opacity: 0.3;
cursor: not-allowed;
}
`;
const InputMeta = styled.div`
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-secondary);
opacity: 0.6;
text-transform: uppercase;
letter-spacing: 0.06em;
text-align: center;
`;
const SisterContext = styled.aside`
width: 220px;
flex-shrink: 0;
border-left: 1px solid var(--border-color);
padding: var(--space-lg);
display: flex;
flex-direction: column;
gap: var(--space-lg);
overflow-y: auto;
@media (max-width: 1199px) {
display: none;
}
`;
const ContextSection = styled.div`
display: flex;
flex-direction: column;
gap: var(--space-sm);
`;
const CtxLabel = styled.div`
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.08em;
border-bottom: 1px solid var(--border-color);
padding-bottom: 4px;
`;
const CtxValue = styled.div`
font-size: 12px;
color: var(--text-primary);
line-height: 1.5;
`;
const CtxMono = styled.div`
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
`;
const CloseBtn = styled.button`
border: 1px solid var(--border-color);
background: transparent;
color: var(--text-secondary);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: var(--space-xs) var(--space-sm);
cursor: pointer;
font-family: var(--font-mono);
&:hover {
border-color: var(--border-hover);
color: var(--text-primary);
}
`;
function formatTs(ts: string): string {
const date = new Date(ts);
const diff = Date.now() - date.getTime();
const min = Math.floor(diff / 60000);
if (min < 1) return '방금';
if (min < 60) return `${min}분 전`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}시간 전`;
return `${Math.floor(hr / 24)}일 전`;
}
function runtimeStateLabel(state: AgentState): string {
if (state === 'thinking') return 'thinking';
if (state === 'tool_calling') return 'tool_calling';
if (state === 'speaking') return 'speaking';
if (state === 'error') return 'error';
return 'idle';
}
// Fix #4: Remove localStorage token — use cookie-based auth via withSessionRequest
// Token is handled by HttpOnly cookies + CSRF, no client-side access needed
function mergeMessages(prev: ChatMessage[], incoming: ChatMessage[]) {
const map = new Map<string, ChatMessage>();
[...prev, ...incoming].forEach((item) => {
map.set(item.id, item);
});
return Array.from(map.values()).sort((a, b) => new Date(a.ts).getTime() - new Date(b.ts).getTime());
}
function mapRuntimeMessages(items: RuntimeMessage[]): ChatMessage[] {
return items.map((item, index) => ({
id: item.id || `${item.role}-${item.ts ?? 'none'}-${index}`,
role: item.role,
content: item.content,
ts: item.ts ?? new Date().toISOString(),
}));
}
export default function ChatWorkspace({ initialSister, onClose }: ChatWorkspaceProps) {
const [activeSister, setActiveSister] = useState<SisterName>(initialSister);
const [allMessages, setAllMessages] = useState<Partial<Record<SisterName, ChatMessage[]>>>({});
const [runtimeBySister, setRuntimeBySister] = useState<Partial<Record<SisterName, RuntimeSnapshot>>>({});
// Per-sister draft — prevents input leaking across tabs
const [drafts, setDrafts] = useState<Partial<Record<SisterName, string>>>({});
const input = drafts[activeSister] ?? '';
const [sending, setSending] = useState(false);
const timelineRef = useRef<HTMLDivElement>(null);
const messages = useMemo(() => allMessages[activeSister] ?? [], [allMessages, activeSister]);
const runtime = runtimeBySister[activeSister] ?? null;
// Cookie-based auth always available (no token check needed)
useEffect(() => {
setActiveSister(initialSister);
}, [initialSister]);
useEffect(() => {
if (timelineRef.current) {
timelineRef.current.scrollTop = timelineRef.current.scrollHeight;
}
}, [messages]);
const setMessages = useCallback((sister: SisterName, updater: (prev: ChatMessage[]) => ChatMessage[]) => {
setAllMessages((prev) => ({
...prev,
[sister]: updater(prev[sister] ?? []),
}));
}, []);
const loadRuntime = useCallback(async (sister: SisterName) => {
try {
const res = await fetch(`${API_URL}/api/sisters/${sister}/runtime`, withSessionRequest());
if (!res.ok) return;
const data = (await res.json()) as RuntimeSnapshot;
setRuntimeBySister((prev) => ({ ...prev, [sister]: data }));
const runtimeMessages = mapRuntimeMessages(data.recentMessages ?? []);
if (runtimeMessages.length > 0) {
setMessages(sister, (prev) => mergeMessages(prev, runtimeMessages));
}
} catch {
// ignore runtime refresh failures
}
}, [setMessages]);
useEffect(() => {
void loadRuntime(activeSister);
const interval = setInterval(() => {
void loadRuntime(activeSister);
}, 8000);
return () => clearInterval(interval);
}, [activeSister, loadRuntime]);
const handleSend = useCallback(async () => {
const text = input.trim();
if (!text || sending) return;
// Cookie-based auth — no token check needed
const userMessage: ChatMessage = {
id: `user-${crypto.randomUUID()}`,
role: 'user',
content: text,
ts: new Date().toISOString(),
};
setMessages(activeSister, (prev) => mergeMessages(prev, [userMessage]));
setDrafts((prev) => ({ ...prev, [activeSister]: '' }));
setSending(true);
try {
const res = await fetch(`${API_URL}/api/sisters/${activeSister}/chat`, withSessionRequest({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: text }),
}, { csrf: true }));
const data = await res.json();
if (!res.ok) {
throw new Error(data?.message || '채팅 전송 실패');
}
if (data?.runtime) {
setRuntimeBySister((prev) => ({ ...prev, [activeSister]: data.runtime as RuntimeSnapshot }));
}
const reply = String(data?.reply || '').trim();
if (reply) {
setMessages(activeSister, (prev) => mergeMessages(prev, [{
id: `assistant-${crypto.randomUUID()}`,
role: 'assistant',
content: reply,
ts: new Date().toISOString(),
}]));
}
void loadRuntime(activeSister);
} catch (error) {
const message = error instanceof Error ? error.message : '채팅 전송 중 오류가 발생했어.';
setMessages(activeSister, (prev) => mergeMessages(prev, [{
id: `error-${crypto.randomUUID()}`,
role: 'assistant',
content: `전송 실패: ${message}`,
ts: new Date().toISOString(),
}]));
} finally {
setSending(false);
}
}, [activeSister, input, loadRuntime, sending, setMessages]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
void handleSend();
}
};
return (
<Workspace>
<SisterTabs>
<SisterTabHeader>direct chat</SisterTabHeader>
{SISTER_ORDER.map((name) => (
<SisterTab key={name} $active={activeSister === name} onClick={() => setActiveSister(name)}>
<SisterAvatar name={name} size={22} />
<TabMeta>
<TabName>{SISTER_DISPLAY[name]}</TabName>
<TabRole>{SISTER_ROLES[name].split(' ')[0]}</TabRole>
</TabMeta>
</SisterTab>
))}
</SisterTabs>
<MessageArea>
<MessageHeader>
<SisterAvatar name={activeSister} size={28} />
<HeaderMeta>
<HeaderName>{SISTER_DISPLAY[activeSister]}</HeaderName>
<HeaderRole>{SISTER_ROLES[activeSister]}</HeaderRole>
</HeaderMeta>
<HeaderBadge $ok={Boolean(runtime?.gatewayConnected)}>
{runtime?.gatewayConnected ? 'Runtime 연결됨' : 'Runtime 확인 중'}
</HeaderBadge>
<CloseBtn onClick={onClose}> </CloseBtn>
</MessageHeader>
<Timeline ref={timelineRef}>
{messages.length === 0 ? (
<EmptyTimeline>
<SisterAvatar name={activeSister} size={32} />
<div>{SISTER_DISPLAY[activeSister]} .</div>
<RuntimeNotice>
{runtime?.gatewayConnected
? '직접 채팅이 자매 runtime으로 연결돼 있어. 입력하면 바로 전달돼.'
: 'runtime 상태를 확인 중이야. 연결이 느려도 메시지는 다시 시도할 수 있어.'}
</RuntimeNotice>
</EmptyTimeline>
) : (
messages.map((msg) => (
<MessageBubble key={msg.id} $role={msg.role}>
<BubbleContent $role={msg.role}>{msg.content}</BubbleContent>
<BubbleMeta>{formatTs(msg.ts)}</BubbleMeta>
</MessageBubble>
))
)}
</Timeline>
<InputMeta>
shift+enter = · enter = · runtime: {runtime ? runtimeStateLabel(runtime.mainState) : 'loading'}
</InputMeta>
<InputArea>
<MessageInput
placeholder={`${SISTER_DISPLAY[activeSister]}에게 지시해...`}
value={input}
onChange={(e) => setDrafts((prev) => ({ ...prev, [activeSister]: e.target.value }))}
onKeyDown={handleKeyDown}
rows={1}
/>
<SendBtn onClick={() => void handleSend()} disabled={!input.trim() || sending}>
{sending ? '전송 중' : '전송'}
</SendBtn>
</InputArea>
</MessageArea>
<SisterContext>
<ContextSection>
<CtxLabel>agent</CtxLabel>
<CtxValue>{SISTER_DISPLAY[activeSister]}</CtxValue>
<CtxMono>{SISTER_ROLES[activeSister]}</CtxMono>
</ContextSection>
<ContextSection>
<CtxLabel> </CtxLabel>
<CtxValue>{runtime?.gatewayConnected ? 'Runtime 연결됨' : 'Runtime 확인 중'}</CtxValue>
<CtxMono>{runtime ? runtimeStateLabel(runtime.mainState) : 'loading'}</CtxMono>
</ContextSection>
<ContextSection>
<CtxLabel>data source</CtxLabel>
<CtxMono>
control: ssh openclaw agent
<br />
activity: runtime session snapshot
</CtxMono>
</ContextSection>
<ContextSection>
<CtxLabel>current task</CtxLabel>
<CtxValue>{runtime?.currentTask ?? runtime?.activeSessionLabel ?? '작업 정보 없음'}</CtxValue>
</ContextSection>
</SisterContext>
</Workspace>
);
}