275 lines
11 KiB
TypeScript
275 lines
11 KiB
TypeScript
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' };
|
|
}
|
|
|
|
/**
|
|
* Status badge colors. Returned values are CSS `light-dark()` pairs so the
|
|
* inline-styled badges (style={{ background, color }}) adapt to the theme —
|
|
* `color-scheme` is set on :root / [data-theme=dark] (see index.css), which is
|
|
* what light-dark() resolves against. Dark = a subtle tinted chip + light text.
|
|
*/
|
|
export function statusTone(status: string): { bg: string; fg: string } {
|
|
const ld = (l: string, d: string) => `light-dark(${l}, ${d})`;
|
|
if (status === 'running') return { bg: ld('#dcfce7', 'rgba(34,197,94,.15)'), fg: ld('#166534', '#86efac') };
|
|
if (status === 'waiting_human') return { bg: ld('#fef9c3', 'rgba(234,179,8,.15)'), fg: ld('#854d0e', '#fde047') };
|
|
if (status === 'waiting_subtasks') return { bg: ld('#e0e7ff', 'rgba(99,102,241,.18)'), fg: ld('#3730a3', '#a5b4fc') };
|
|
if (status === 'failed') return { bg: ld('#fee2e2', 'rgba(239,68,68,.15)'), fg: ld('#b91c1c', '#fca5a5') };
|
|
if (status === 'succeeded') return { bg: ld('#dbeafe', 'rgba(59,130,246,.18)'), fg: ld('#1e40af', '#93c5fd') };
|
|
if (status === 'retry') return { bg: ld('#fef3c7', 'rgba(245,158,11,.15)'), fg: ld('#92400e', '#fcd34d') };
|
|
return { bg: ld('#e2e8f0', 'var(--surface-2)'), fg: ld('#475569', 'var(--muted)') };
|
|
}
|
|
|
|
export type Tone = 'neutral' | 'info' | 'success' | 'warning' | 'danger';
|
|
|
|
/**
|
|
* Shared light+dark Tailwind classes for semantic badge/chip tones. One source
|
|
* of truth so badges stay consistent in both themes. `dark:` follows the
|
|
* in-app [data-theme] (tailwind darkMode is the selector variant).
|
|
*/
|
|
const TONE_CLASSES: Record<Tone, string> = {
|
|
neutral: 'bg-surface-2 text-slate-700 border-hairline',
|
|
info: 'bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-500/15 dark:text-blue-300 dark:border-blue-500/30',
|
|
success: 'bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-300 dark:border-emerald-500/30',
|
|
warning: 'bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-500/15 dark:text-amber-300 dark:border-amber-500/30',
|
|
danger: 'bg-red-50 text-red-700 border-red-200 dark:bg-red-500/15 dark:text-red-300 dark:border-red-500/30',
|
|
};
|
|
|
|
export function toneClasses(tone: Tone): string {
|
|
return TONE_CLASSES[tone];
|
|
}
|
|
|
|
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<string, { state: 'running' | 'done' | 'idle'; note: string }>();
|
|
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));
|
|
}
|