maestro/ui/src/lib/utils.ts
oss-sync caa0d03900
Some checks failed
CI / build-and-test (push) Has been cancelled
sync: update from private repo (fd190dc)
2026-06-06 00:39:26 +00:00

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));
}