import { COLUMN_LABELS } from './urlState'; export function relativeTime(dateStr: string): string { const diff = Date.now() - new Date(dateStr).getTime(); const mins = Math.floor(diff / 60000); if (mins < 1) return 'たった今'; if (mins < 60) return `${mins}分前`; const hrs = Math.floor(mins / 60); if (hrs < 24) return `${hrs}時間前`; return `${Math.floor(hrs / 24)}日前`; } export function formatFileDate(isoStr: string): string { const date = new Date(isoStr); if (Number.isNaN(date.getTime())) return ''; const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffMin = Math.floor(diffMs / 60000); if (diffMin < 1) return 'たった今'; if (diffMin < 60) return `${diffMin}分前`; const sameDay = date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() && date.getDate() === now.getDate(); if (sameDay) return date.toLocaleTimeString('ja-JP', { hour: '2-digit', minute: '2-digit' }); const sameYear = date.getFullYear() === now.getFullYear(); if (sameYear) return `${date.getMonth() + 1}/${date.getDate()} ${date.toLocaleTimeString('ja-JP', { hour: '2-digit', minute: '2-digit' })}`; return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`; } export function workerPill(workerId: string | null): string { if (!workerId) return '-'; if (workerId.includes('148')) return `${workerId} (quality)`; if (workerId.includes('074')) return `${workerId} (fast)`; return workerId; } export function stateTone(state: string): { bg: string; fg: string } { if (state === 'open') return { bg: '#e0efff', fg: '#1d4ed8' }; if (state === 'closed') return { bg: '#e2e8f0', fg: '#475569' }; if (state === 'deleted') return { bg: '#fee2e2', fg: '#b91c1c' }; return { bg: '#dbeafe', fg: '#1e3a8a' }; } export function statusTone(status: string): { bg: string; fg: string } { if (status === 'running') return { bg: '#dcfce7', fg: '#166534' }; if (status === 'waiting_human') return { bg: '#fef9c3', fg: '#854d0e' }; if (status === 'waiting_subtasks') return { bg: '#e0e7ff', fg: '#3730a3' }; if (status === 'failed') return { bg: '#fee2e2', fg: '#b91c1c' }; if (status === 'succeeded') return { bg: '#dbeafe', fg: '#1e40af' }; if (status === 'retry') return { bg: '#fef3c7', fg: '#92400e' }; return { bg: '#e2e8f0', fg: '#475569' }; } export function formatStatusLabel(status: string): string { return COLUMN_LABELS[status] ?? status; } export function matchText(value: string | undefined | null, query: string): boolean { if (!query.trim()) return true; return (value ?? '').toLowerCase().includes(query.trim().toLowerCase()); } export function isTextPreviewable(name: string): boolean { return /\.(md|markdown|csv|txt|log|json|jsonl)$/i.test(name); } export function isImagePreviewable(name: string): boolean { return /\.(png|jpe?g|gif|webp|bmp)$/i.test(name); } export function isPdfPreviewable(name: string): boolean { return /\.pdf$/i.test(name); } export function isHtmlPreviewable(name: string): boolean { return /\.html?$/i.test(name); } export function isPreviewable(name: string): boolean { return isTextPreviewable(name) || isImagePreviewable(name) || isPdfPreviewable(name) || isHtmlPreviewable(name); } export type ActivityEventKind = 'movement_start' | 'movement_complete' | 'tool' | 'preview' | 'final' | 'ask' | 'preflight' | 'other'; export interface ActivityEvent { id: string; kind: ActivityEventKind; label: string; note: string; state: 'running' | 'done' | 'idle'; timestamp: string | null; workerId: string | null; mode: string | null; } export function formatActivityMeta(workerId: string | null, mode: string | null): string { return [workerId ? `worker: ${workerPill(workerId)}` : '', mode ? `mode: ${mode}` : ''].filter(Boolean).join(' · '); } export function parseActivityLog(logText: string): ActivityEvent[] { const lines = logText.split('\n').map(line => line.trim()).filter(Boolean); const events: ActivityEvent[] = []; for (const [index, rawLine] of lines.entries()) { const timestampMatch = /^\[([^\]]+)\]\s+/.exec(rawLine); const timestamp = timestampMatch?.[1] ?? null; const workerId = /\[worker:([^\]]+)\]/.exec(rawLine)?.[1] ?? null; const mode = /\[mode:([^\]]+)\]/.exec(rawLine)?.[1] ?? null; const line = rawLine .replace(/^\[[^\]]+\]\s+/, '') .replace(/\[worker:[^\]]+\]\s*/g, '') .replace(/\[mode:[^\]]+\]\s*/g, '') .trim(); const base = { id: `${timestamp ?? 'line'}-${index}`, timestamp, workerId, mode }; const movementStart = /^\[([^\]]+)\] (?:start|ステップ開始)$/.exec(line); if (movementStart) { events.push({ ...base, kind: 'movement_start', label: movementStart[1]!, note: 'started', state: 'running' }); continue; } const movementComplete = /^\[([^\]]+)\] (?:complete ->|完了 →) (.+)$/.exec(line); if (movementComplete) { events.push({ ...base, kind: 'movement_complete', label: movementComplete[1]!, note: `next: ${movementComplete[2]}`, state: 'done' }); continue; } const movementPreview = /^\[([^\]]+)\] preview: (.+)$/.exec(line); if (movementPreview) { events.push({ ...base, kind: 'preview', label: movementPreview[1]!, note: movementPreview[2]!, state: 'running' }); continue; } const preview = /^preview:\s*(.+)$/i.exec(line); if (preview) { events.push({ ...base, kind: 'preview', label: 'assistant', note: preview[1]!, state: 'running' }); continue; } const final = /^(?:final:|最終結果:)\s*([a-z_]+)/i.exec(line); if (final) { const normalized = final[1]!.toLowerCase(); events.push({ ...base, kind: 'final', label: 'final', note: normalized, state: normalized === 'completed' ? 'done' : 'running' }); continue; } const ask = /^(?:ask:|\[ASK\])\s*(.+)$/i.exec(line); if (ask) { events.push({ ...base, kind: 'ask', label: 'ask', note: ask[1]!, state: 'idle' }); continue; } // tool 正規表現より前に preflight をマッチさせる。 // ok 行は短縮版 ("preflight: 12,400/128,000 tokens") として書かれる。 // blocked 行はフル ("[llm-preflight:blocked] ...") のまま出る。 const preflightShort = /^preflight:\s*(.+)$/.exec(line); if (preflightShort) { events.push({ ...base, kind: 'preflight', label: 'preflight', note: preflightShort[1]!, state: 'idle' }); continue; } const preflightBlocked = /^\[llm-preflight:blocked\]\s*(.+)$/.exec(line); if (preflightBlocked) { events.push({ ...base, kind: 'preflight', label: 'preflight (blocked)', note: preflightBlocked[1]!, state: 'idle' }); continue; } const tool = /^([A-Za-z][A-Za-z0-9_]+):\s*(.+)$/.exec(line); if (tool) { events.push({ ...base, kind: 'tool', label: tool[1]!, note: tool[2]!, state: 'idle' }); continue; } events.push({ ...base, kind: 'other', label: 'log', note: line, state: 'idle' }); } return events; } export function buildActivitySteps(events: ActivityEvent[]): Array<{ label: string; state: 'running' | 'done' | 'idle'; note: string }> { const steps = new Map(); let currentMovement: string | null = null; for (const event of events) { const meta = formatActivityMeta(event.workerId, event.mode); const suffix = meta ? ` · ${meta}` : ''; if (event.kind === 'movement_start') { currentMovement = event.label; steps.set(event.label, { state: 'running', note: `started${suffix}` }); continue; } if (event.kind === 'tool' && currentMovement && steps.has(currentMovement)) { steps.set(currentMovement, { state: 'running', note: `tool: ${event.label} · ${event.note}${suffix}` }); continue; } if (event.kind === 'preview' && steps.has(event.label)) { steps.set(event.label, { state: 'running', note: `preview: ${event.note}${suffix}` }); continue; } if (event.kind === 'movement_complete') { currentMovement = null; steps.set(event.label, { state: 'done', note: `${event.note}${suffix}` }); continue; } if (event.kind === 'final') { steps.set('final', { state: event.state, note: `${event.note}${suffix}` }); } } return Array.from(steps.entries()).map(([label, value]) => ({ label, ...value })); } export function formatActivityTimestamp(timestamp: string | null): string { if (!timestamp) return ''; const date = new Date(timestamp); if (Number.isNaN(date.getTime())) return ''; return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); } export function activityKindLabel(kind: ActivityEventKind): string { switch (kind) { case 'movement_start': case 'movement_complete': return 'STEP'; case 'tool': return 'TOOL'; case 'preview': return 'PREVIEW'; case 'final': return 'FINAL'; case 'ask': return 'ASK'; case 'preflight': return 'LLM'; default: return 'LOG'; } } export function activityEventTitle(event: ActivityEvent): string { switch (event.kind) { case 'preview': return `${event.label} response`; case 'final': return 'Run finished'; case 'ask': return 'Need user input'; default: return event.label; } } export function renderCsvRows(csv: string): string[][] { const rows = csv.trim().split(/\r?\n/).map(r => r.split(',')); return rows.slice(0, 120).map(r => r.slice(0, 20)); }