648 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|