diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index a8cc36a..ba961af 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -1,6 +1,21 @@ -import { useEffect, useMemo, useState, type ReactNode } from 'react'; +import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { + Activity, + Clock, + Download, + Gauge, + Inbox as InboxIcon, + MessageSquare, + RefreshCw, + Send, + Settings, +} from 'lucide-react'; import { + type ClaudeAccountSummary, + type CodexAccountSummary, type CreateScheduledTaskInput, type DashboardInboxAction, type DashboardRoomActivity, @@ -9,15 +24,22 @@ import { type DashboardTaskAction, type DashboardOverview, type DashboardTask, + type ModelConfigSnapshot, + type ModelRoleConfig, type UpdateScheduledTaskInput, type StatusSnapshot, + addClaudeAccount, createScheduledTask, + deleteAccount, + fetchAccounts, fetchDashboardData, - fetchRoomTimeline, + fetchModelConfig, + fetchRoomsTimelineBatch, runInboxAction, runServiceAction, runScheduledTaskAction, sendRoomMessage, + updateModels, updateScheduledTask, } from './api'; import { @@ -45,7 +67,13 @@ type InboxItem = DashboardOverview['inbox'][number]; type RiskLevel = 'ok' | 'warn' | 'critical'; type UsageGroup = 'primary' | 'codex'; type UsageLimitWindow = 'h5' | 'd7'; -type DashboardView = 'usage' | 'inbox' | 'health' | 'rooms' | 'scheduled'; +type DashboardView = + | 'usage' + | 'inbox' + | 'health' + | 'rooms' + | 'scheduled' + | 'settings'; type TaskGroupKey = 'watchers' | 'scheduled' | 'paused' | 'completed'; type TaskResultTone = 'ok' | 'fail' | 'none'; type TaskActionKey = @@ -71,7 +99,7 @@ interface RoomOption { const REFRESH_INTERVAL_MS = 15_000; const LOCALE_STORAGE_KEY = 'ejclaw.dashboard.locale.v2'; -const DEFAULT_VIEW: DashboardView = 'inbox'; +const DEFAULT_VIEW: DashboardView = 'rooms'; const HEALTH_STALE_MS = 5 * 60_000; const HEALTH_DOWN_MS = 15 * 60_000; const DASHBOARD_STALE_MS = 75_000; @@ -88,7 +116,8 @@ function isDashboardView( value === 'inbox' || value === 'health' || value === 'rooms' || - value === 'scheduled' + value === 'scheduled' || + value === 'settings' ); } @@ -117,16 +146,457 @@ function readInitialLocale(): Locale { return 'en'; } +function humanizeError(raw: string, t: Messages): string { + const lower = raw.toLowerCase(); + if (/abort|timeout|timed out/.test(lower)) return t.error.timeout; + if (/network|fetch failed|failed to fetch|networkerror|offline/.test(lower)) + return t.error.network; + const statusMatch = lower.match(/\b(\d{3})\b/); + if (statusMatch) { + const code = Number(statusMatch[1]); + if (code === 401 || code === 403) return t.error.auth; + if (code === 404) return t.error.notFound; + if (code >= 500) return t.error.server; + if (code >= 400) return t.error.unknown; + } + return raw || t.error.unknown; +} + +type ParsedSegment = + | { kind: 'text'; value: string } + | { kind: 'code'; lang: string; value: string } + | { kind: 'inline-code'; value: string } + | { kind: 'bold'; value: string } + | { kind: 'italic'; value: string } + | { kind: 'strike'; value: string } + | { kind: 'url'; href: string; label: string } + | { kind: 'mention'; value: string } + | { kind: 'marker'; tone: 'done' | 'fail' | 'info'; value: string } + | { kind: 'path'; value: string }; + +const URL_RE = /\bhttps?:\/\/[^\s)<>]+/g; +const MENTION_RE = /(^|\s)(@[A-Za-z0-9_-]+)/g; +const MARKER_RE = + /\b(TASK_DONE|TASK_FAILED|TASK_FAIL|TASK_OK|DONE|FAILED|FAIL|OK|ERROR|WARNING|RETRY|STATUS|PASS|PASSED)\b/g; +const PATH_RE = /(?:^|\s)((?:\/|\.\/|\.\.\/)[^\s)<>]+)/g; + +function tokenizeInline(input: string): ParsedSegment[] { + if (!input) return []; + const segments: ParsedSegment[] = []; + const matches: Array<{ + start: number; + end: number; + seg: ParsedSegment; + priority: number; + }> = []; + for (const m of input.matchAll(/`([^`\n]+)`/g)) { + matches.push({ + start: m.index ?? 0, + end: (m.index ?? 0) + m[0].length, + seg: { kind: 'inline-code', value: m[1] }, + priority: 0, + }); + } + for (const m of input.matchAll(/\*\*([^*\n]+)\*\*/g)) { + matches.push({ + start: m.index ?? 0, + end: (m.index ?? 0) + m[0].length, + seg: { kind: 'bold', value: m[1] }, + priority: 0, + }); + } + for (const m of input.matchAll(/(? + a.start === b.start ? a.priority - b.priority : a.start - b.start, + ); + let cursor = 0; + const used: Array<[number, number]> = []; + for (const match of matches) { + if (match.start < cursor) continue; + const overlaps = used.some( + ([s, e]) => !(match.end <= s || match.start >= e), + ); + if (overlaps) continue; + if (match.start > cursor) { + segments.push({ kind: 'text', value: input.slice(cursor, match.start) }); + } + segments.push(match.seg); + used.push([match.start, match.end]); + cursor = match.end; + } + if (cursor < input.length) { + segments.push({ kind: 'text', value: input.slice(cursor) }); + } + return segments; +} + +type ParsedBlock = + | { kind: 'paragraph'; segments: ParsedSegment[] } + | { kind: 'code'; lang: string; value: string } + | { kind: 'heading'; level: 1 | 2 | 3 | 4; segments: ParsedSegment[] } + | { kind: 'list'; items: ParsedSegment[][] } + | { kind: 'olist'; items: ParsedSegment[][]; start: number } + | { kind: 'quote'; segments: ParsedSegment[] }; + +function parseTextBlocks(raw: string): ParsedBlock[] { + const blocks: ParsedBlock[] = []; + const lines = raw.split(/\n/); + let i = 0; + while (i < lines.length) { + const line = lines[i]; + const trimmed = line.trim(); + if (!trimmed) { + i++; + continue; + } + const heading = trimmed.match(/^(#{1,4})\s+(.*)$/); + if (heading) { + const level = Math.min(heading[1].length, 4) as 1 | 2 | 3 | 4; + blocks.push({ + kind: 'heading', + level, + segments: tokenizeInline(heading[2]), + }); + i++; + continue; + } + if (/^>\s+/.test(trimmed)) { + const quoteLines: string[] = []; + while (i < lines.length && /^\s*>\s?/.test(lines[i])) { + quoteLines.push(lines[i].replace(/^\s*>\s?/, '')); + i++; + } + blocks.push({ + kind: 'quote', + segments: tokenizeInline(quoteLines.join('\n')), + }); + continue; + } + if (/^[-*]\s+/.test(trimmed)) { + const items: ParsedSegment[][] = []; + while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) { + const itemText = lines[i].replace(/^\s*[-*]\s+/, ''); + items.push(tokenizeInline(itemText)); + i++; + } + blocks.push({ kind: 'list', items }); + continue; + } + const olistMatch = trimmed.match(/^(\d+)\.\s+/); + if (olistMatch) { + const items: ParsedSegment[][] = []; + const start = Number(olistMatch[1]); + while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) { + const itemText = lines[i].replace(/^\s*\d+\.\s+/, ''); + items.push(tokenizeInline(itemText)); + i++; + } + blocks.push({ kind: 'olist', items, start }); + continue; + } + // Plain paragraph: gather consecutive non-empty lines + const paraLines: string[] = []; + while ( + i < lines.length && + lines[i].trim() && + !/^(#{1,4})\s+/.test(lines[i].trim()) && + !/^[-*]\s+/.test(lines[i].trim()) && + !/^\d+\.\s+/.test(lines[i].trim()) && + !/^>\s+/.test(lines[i].trim()) + ) { + paraLines.push(lines[i]); + i++; + } + if (paraLines.length > 0) { + blocks.push({ + kind: 'paragraph', + segments: tokenizeInline(paraLines.join('\n')), + }); + } + } + return blocks; +} + +function parseMessageBody(raw: string | null | undefined): ParsedBlock[] { + if (!raw) return []; + const cleaned = raw + .replace(/<\/?internal[^>]*>/gi, '') + .replace(/<\/?intern\.{3}/gi, '') + .trim(); + if (!cleaned) return []; + const blocks: ParsedBlock[] = []; + const fenceRe = /```([A-Za-z0-9_+-]*)\n?([\s\S]*?)```/g; + let cursor = 0; + let m: RegExpExecArray | null; + while ((m = fenceRe.exec(cleaned)) !== null) { + if (m.index > cursor) { + const pre = cleaned.slice(cursor, m.index).trim(); + if (pre) blocks.push(...parseTextBlocks(pre)); + } + blocks.push({ kind: 'code', lang: m[1] || '', value: m[2] }); + cursor = m.index + m[0].length; + } + if (cursor < cleaned.length) { + const tail = cleaned.slice(cursor).trim(); + if (tail) blocks.push(...parseTextBlocks(tail)); + } + return blocks; +} + +function renderInlineSegments( + segments: ParsedSegment[], + truncate: number | undefined, + usedRef: { used: number }, +): ReactNode[] { + const out: ReactNode[] = []; + segments.forEach((s, j) => { + if (truncate && usedRef.used >= truncate) return; + const remaining = truncate ? truncate - usedRef.used : Infinity; + if (s.kind === 'text') { + const v = + s.value.length > remaining + ? `${s.value.slice(0, remaining)}…` + : s.value; + usedRef.used += v.length; + out.push({v}); + return; + } + if (s.kind === 'inline-code') { + usedRef.used += s.value.length; + out.push( + + {s.value} + , + ); + return; + } + if (s.kind === 'bold') { + usedRef.used += s.value.length; + out.push({s.value}); + return; + } + if (s.kind === 'italic') { + usedRef.used += s.value.length; + out.push({s.value}); + return; + } + if (s.kind === 'strike') { + usedRef.used += s.value.length; + out.push({s.value}); + return; + } + if (s.kind === 'url') { + usedRef.used += s.label.length; + out.push( + + {s.label} + , + ); + return; + } + if (s.kind === 'mention') { + usedRef.used += s.value.length; + out.push( + + {s.value} + , + ); + return; + } + if (s.kind === 'path') { + usedRef.used += s.value.length; + out.push( + + {s.value} + , + ); + return; + } + if (s.kind === 'marker') { + usedRef.used += s.value.length; + out.push( + + {s.value} + , + ); + return; + } + }); + return out; +} + +function ParsedBody({ + text, + truncate, +}: { + text: string | null | undefined; + truncate?: number; +}): ReactNode { + const cleaned = useMemo(() => { + if (!text) return ''; + let out = text + .replace(SECRET_ASSIGNMENT_RE, (_m, key) => `${key}=***`) + .replace(SECRET_VALUE_RE, '***'); + if (truncate && out.length > truncate) { + out = out.slice(0, truncate) + '…'; + } + return out; + }, [text, truncate]); + + if (!cleaned.trim()) { + return ; + } + + return ( +
+ {cleaned} +
+ ); +} + +function isInternalProtocolPayload( + content: string | null | undefined, +): boolean { + if (!content) return false; + if (/<\/?(sub-agent[-\w]*|tool-call|internal)\b/i.test(content)) return true; + if (/"author"\s*:\s*"[^"]+"\s*,\s*"recipient"\s*:\s*"[^"]+"/.test(content)) { + return true; + } + return false; +} + +function senderRoleClass(value: string | null | undefined): string { + const v = (value ?? '').toLowerCase(); + if (v.includes('오너') || v.includes('owner')) return 'role-owner'; + if (v.includes('리뷰어') || v.includes('reviewer')) return 'role-reviewer'; + if (v.includes('중재자') || v.includes('arbiter')) return 'role-arbiter'; + if (v.includes('cron') || v.includes('sentry') || v.includes('webhook')) + return 'role-cron'; + return 'role-human'; +} + +function sanitizeInboxText(value: string | null | undefined): string { + if (!value) return ''; + return value + .replace(/<\/?internal[^>]*>/gi, '') + .replace(/<\/?intern\.{3}/gi, '') + .replace(/<\/?[a-z][a-z0-9-]*[^>]*>/gi, '') + .replace(/\s{2,}/g, ' ') + .trim(); +} + function formatDate(value: string | null | undefined, locale: Locale): string { if (!value) return '-'; const date = new Date(value); if (Number.isNaN(date.getTime())) return value; - return new Intl.DateTimeFormat(localeTags[locale], { - month: '2-digit', - day: '2-digit', + const now = Date.now(); + const ageMs = now - date.getTime(); + if (ageMs >= 0 && ageMs < 60_000) { + return locale === 'ko' + ? '방금' + : locale === 'ja' + ? 'たった今' + : locale === 'zh' + ? '刚刚' + : 'just now'; + } + if (ageMs >= 0 && ageMs < 3_600_000) { + const mins = Math.floor(ageMs / 60_000); + return locale === 'ko' + ? `${mins}분 전` + : locale === 'ja' + ? `${mins}分前` + : locale === 'zh' + ? `${mins} 分钟前` + : `${mins}m ago`; + } + const sameDay = new Date().toDateString() === date.toDateString(); + if (sameDay) { + return new Intl.DateTimeFormat(localeTags[locale], { + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).format(date); + } + const time = new Intl.DateTimeFormat(localeTags[locale], { hour: '2-digit', minute: '2-digit', - second: '2-digit', + hour12: false, + }).format(date); + if (locale === 'ko') + return `${date.getMonth() + 1}월 ${date.getDate()}일 ${time}`; + if (locale === 'ja') + return `${date.getMonth() + 1}月${date.getDate()}日 ${time}`; + if (locale === 'zh') + return `${date.getMonth() + 1}月${date.getDate()}日 ${time}`; + return new Intl.DateTimeFormat(localeTags[locale], { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: false, }).format(date); } @@ -178,11 +648,21 @@ function formatTaskDate( if (!value) return '-'; const date = new Date(value); if (Number.isNaN(date.getTime())) return value; - return new Intl.DateTimeFormat(localeTags[locale], { - month: '2-digit', - day: '2-digit', + const time = new Intl.DateTimeFormat(localeTags[locale], { hour: '2-digit', minute: '2-digit', + hour12: false, + }).format(date); + if (locale === 'ko') + return `${date.getMonth() + 1}월 ${date.getDate()}일 ${time}`; + if (locale === 'ja' || locale === 'zh') + return `${date.getMonth() + 1}月${date.getDate()}日 ${time}`; + return new Intl.DateTimeFormat(localeTags[locale], { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: false, }).format(date); } @@ -302,6 +782,18 @@ function formatDuration(value: number | null, t: Messages): string { return `${hours}${t.units.hour} ${minutes % 60}${t.units.minute}`; } +function formatLiveElapsed(value: number, t: Messages): string { + const seconds = Math.max(0, Math.floor(value / 1000)); + if (seconds < 60) return `${seconds}${t.units.second}`; + const minutes = Math.floor(seconds / 60); + const remSec = seconds % 60; + if (minutes < 60) + return `${minutes}${t.units.minute} ${remSec}${t.units.second}`; + const hours = Math.floor(minutes / 60); + const remMin = minutes % 60; + return `${hours}${t.units.hour} ${remMin}${t.units.minute} ${remSec}${t.units.second}`; +} + function queueLabel( pendingTasks: number, pendingMessages: boolean, @@ -324,6 +816,7 @@ function safePreview( const cleaned = (value ?? '') .replace(SECRET_ASSIGNMENT_RE, '$1=') .replace(SECRET_VALUE_RE, '') + .replace(/<\/?internal[^>]*>/gi, '') .replace(/\s+/g, ' ') .trim(); if (!cleaned) return fallback; @@ -505,13 +998,23 @@ function inboxTargetHref(item: InboxItem): string | null { return null; } +const NAV_ICONS: Record = { + usage: , + inbox: , + health: , + rooms: , + scheduled: , + settings: , +}; + function navItems(t: Messages) { return [ - { href: '#/usage', label: t.nav.usage, view: 'usage' as const }, - { href: '#/inbox', label: t.nav.inbox, view: 'inbox' as const }, - { href: '#/health', label: t.nav.health, view: 'health' as const }, { href: '#/rooms', label: t.nav.rooms, view: 'rooms' as const }, + { href: '#/inbox', label: t.nav.inbox, view: 'inbox' as const }, { href: '#/scheduled', label: t.nav.scheduled, view: 'scheduled' as const }, + { href: '#/health', label: t.nav.health, view: 'health' as const }, + { href: '#/usage', label: t.nav.usage, view: 'usage' as const }, + { href: '#/settings', label: t.nav.settings, view: 'settings' as const }, ]; } @@ -546,6 +1049,368 @@ function LanguageSelector({ ); } +function SettingsPanel({ + locale, + nickname, + onLocaleChange, + onNicknameChange, + onRestartStack, + t, +}: { + locale: Locale; + nickname: string; + onLocaleChange: (locale: Locale) => void; + onNicknameChange: (next: string) => void; + onRestartStack: () => void; + t: Messages; +}) { + return ( +
+
+

일반

+ + +
+ + + + +
+ ); +} + +function ModelSettings({ onRestartStack }: { onRestartStack: () => void }) { + const [config, setConfig] = useState(null); + const [draft, setDraft] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [savedAt, setSavedAt] = useState(null); + + useEffect(() => { + let cancelled = false; + setBusy(true); + fetchModelConfig() + .then((c) => { + if (cancelled) return; + setConfig(c); + setDraft(c); + setError(null); + }) + .catch((err) => { + if (cancelled) return; + setError(err instanceof Error ? err.message : String(err)); + }) + .finally(() => { + if (!cancelled) setBusy(false); + }); + return () => { + cancelled = true; + }; + }, []); + + async function save() { + if (!draft || !config) return; + setBusy(true); + setError(null); + try { + const next = await updateModels(draft); + setConfig(next); + setDraft(next); + setSavedAt(Date.now()); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + } + + function setRole( + role: keyof ModelConfigSnapshot, + patch: Partial, + ) { + setDraft((prev) => + prev + ? { + ...prev, + [role]: { ...prev[role], ...patch }, + } + : prev, + ); + } + + const dirty = + draft !== null && + config !== null && + JSON.stringify(draft) !== JSON.stringify(config); + + return ( +
+

모델

+ {error ?

{error}

: null} + {!draft ? ( +

+ {busy ? '불러오는 중…' : '모델 정보 없음'} +

+ ) : ( + <> + {(['owner', 'reviewer', 'arbiter'] as const).map((role) => ( +
+ {role} + setRole(role, { model: e.target.value })} + placeholder="claude / codex / claude-opus-4-7 …" + type="text" + value={draft[role].model} + /> + setRole(role, { effort: e.target.value })} + placeholder="effort" + type="text" + value={draft[role].effort} + /> +
+ ))} +
+ + {savedAt && !dirty ? ( + + 저장됨. 적용하려면 스택 재시작 필요. + + ) : null} + +
+ + )} +
+ ); +} + +function AccountSettings({ onRestartStack }: { onRestartStack: () => void }) { + const [data, setData] = useState<{ + claude: ClaudeAccountSummary[]; + codex: CodexAccountSummary[]; + } | null>(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [tokenInput, setTokenInput] = useState(''); + + async function refresh() { + setBusy(true); + setError(null); + try { + const fresh = await fetchAccounts(); + setData(fresh); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + } + + useEffect(() => { + void refresh(); + }, []); + + async function handleDelete(provider: 'claude' | 'codex', index: number) { + if ( + !window.confirm( + `${provider} 계정 #${index} 디렉터리를 삭제합니다. 되돌릴 수 없습니다. 계속할까요?`, + ) + ) { + return; + } + setBusy(true); + setError(null); + try { + await deleteAccount(provider, index); + await refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + } + + async function handleAdd() { + const token = tokenInput.trim(); + if (!token) return; + setBusy(true); + setError(null); + try { + await addClaudeAccount(token); + setTokenInput(''); + await refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + } + + return ( +
+

계정

+ {error ?

{error}

: null} + +
+

Claude

+ {!data ? ( +

{busy ? '불러오는 중…' : '없음'}

+ ) : data.claude.length === 0 ? ( +

계정 없음

+ ) : ( +
    + {data.claude.map((acc) => ( +
  • + #{acc.index} + + {acc.subscriptionType ?? 'unknown'} + {acc.expiresAt + ? ` · 만료 ${new Date(acc.expiresAt).toLocaleDateString()}` + : ''} + + {acc.index > 0 ? ( + + ) : ( + 기본 + )} +
  • + ))} +
+ )} +
+