ラベル系を別ファイルに逃がす

This commit is contained in:
nrslib 2026-02-03 22:59:35 +09:00
parent cae770cef4
commit 25fb6c4dfd
14 changed files with 1563 additions and 205 deletions

View File

@ -10,7 +10,7 @@
"takt-cli": "./dist/app/cli/index.js"
},
"scripts": {
"build": "tsc && mkdir -p dist/shared/prompts && cp src/shared/prompts/prompts_en.yaml src/shared/prompts/prompts_ja.yaml dist/shared/prompts/",
"build": "tsc && mkdir -p dist/shared/prompts dist/shared/i18n && cp src/shared/prompts/prompts_en.yaml src/shared/prompts/prompts_ja.yaml dist/shared/prompts/ && cp src/shared/i18n/labels_en.yaml src/shared/i18n/labels_ja.yaml dist/shared/i18n/",
"watch": "tsc --watch",
"test": "vitest run",
"test:watch": "vitest",

140
src/__tests__/i18n.test.ts Normal file
View File

@ -0,0 +1,140 @@
/**
* Tests for UI label loader utility (src/shared/i18n/index.ts)
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { getLabel, getLabelObject, _resetLabelCache } from '../shared/i18n/index.js';
beforeEach(() => {
_resetLabelCache();
});
describe('getLabel', () => {
it('returns a label by key (defaults to en)', () => {
const result = getLabel('interactive.ui.intro');
expect(result).toContain('Interactive mode');
});
it('returns an English label when lang is "en"', () => {
const result = getLabel('interactive.ui.intro', 'en');
expect(result).toContain('Interactive mode');
});
it('returns a Japanese label when lang is "ja"', () => {
const result = getLabel('interactive.ui.intro', 'ja');
expect(result).toContain('対話モード');
});
it('throws for a non-existent key', () => {
expect(() => getLabel('nonexistent.key')).toThrow('Label key not found: nonexistent.key');
});
it('throws for a non-existent key with language', () => {
expect(() => getLabel('nonexistent.key', 'en')).toThrow('Label key not found: nonexistent.key (lang: en)');
});
describe('template variable substitution', () => {
it('replaces {variableName} placeholders with provided values', () => {
const result = getLabel('workflow.iterationLimit.maxReached', undefined, {
currentIteration: '5',
maxIterations: '10',
});
expect(result).toContain('(5/10)');
});
it('replaces single variable', () => {
const result = getLabel('workflow.notifyComplete', undefined, {
iteration: '3',
});
expect(result).toContain('3 iterations');
});
it('leaves unmatched placeholders as-is', () => {
const result = getLabel('workflow.notifyAbort', undefined, {});
expect(result).toContain('{reason}');
});
});
});
describe('getLabelObject', () => {
it('returns interactive UI text object', () => {
const result = getLabelObject<{ intro: string }>('interactive.ui', 'en');
expect(result.intro).toContain('Interactive mode');
});
it('returns Japanese interactive UI text object', () => {
const result = getLabelObject<{ intro: string }>('interactive.ui', 'ja');
expect(result.intro).toContain('対話モード');
});
it('throws for a non-existent key', () => {
expect(() => getLabelObject('nonexistent.key')).toThrow('Label key not found: nonexistent.key');
});
});
describe('caching', () => {
it('returns the same data on repeated calls', () => {
const first = getLabel('interactive.ui.intro');
const second = getLabel('interactive.ui.intro');
expect(first).toBe(second);
});
it('reloads after cache reset', () => {
const first = getLabel('interactive.ui.intro');
_resetLabelCache();
const second = getLabel('interactive.ui.intro');
expect(first).toBe(second);
});
});
describe('label integrity', () => {
it('contains all expected interactive UI keys in en', () => {
const ui = getLabelObject<Record<string, string>>('interactive.ui', 'en');
expect(ui).toHaveProperty('intro');
expect(ui).toHaveProperty('resume');
expect(ui).toHaveProperty('noConversation');
expect(ui).toHaveProperty('summarizeFailed');
expect(ui).toHaveProperty('continuePrompt');
expect(ui).toHaveProperty('proposed');
expect(ui).toHaveProperty('confirm');
expect(ui).toHaveProperty('cancelled');
});
it('contains all expected workflow keys in en', () => {
expect(() => getLabel('workflow.iterationLimit.maxReached')).not.toThrow();
expect(() => getLabel('workflow.iterationLimit.currentStep')).not.toThrow();
expect(() => getLabel('workflow.iterationLimit.continueQuestion')).not.toThrow();
expect(() => getLabel('workflow.iterationLimit.continueLabel')).not.toThrow();
expect(() => getLabel('workflow.iterationLimit.continueDescription')).not.toThrow();
expect(() => getLabel('workflow.iterationLimit.stopLabel')).not.toThrow();
expect(() => getLabel('workflow.iterationLimit.inputPrompt')).not.toThrow();
expect(() => getLabel('workflow.iterationLimit.invalidInput')).not.toThrow();
expect(() => getLabel('workflow.iterationLimit.userInputPrompt')).not.toThrow();
expect(() => getLabel('workflow.notifyComplete')).not.toThrow();
expect(() => getLabel('workflow.notifyAbort')).not.toThrow();
expect(() => getLabel('workflow.sigintGraceful')).not.toThrow();
expect(() => getLabel('workflow.sigintForce')).not.toThrow();
});
it('en and ja have the same key structure', () => {
const stringKeys = [
'interactive.ui.intro',
'interactive.ui.cancelled',
'workflow.iterationLimit.maxReached',
'workflow.notifyComplete',
'workflow.sigintGraceful',
];
for (const key of stringKeys) {
expect(() => getLabel(key, 'en')).not.toThrow();
expect(() => getLabel(key, 'ja')).not.toThrow();
}
const objectKeys = [
'interactive.ui',
];
for (const key of objectKeys) {
expect(() => getLabelObject(key, 'en')).not.toThrow();
expect(() => getLabelObject(key, 'ja')).not.toThrow();
}
});
});

View File

@ -51,11 +51,12 @@ describe('getPrompt', () => {
});
it('replaces multiple different variables', () => {
const result = getPrompt('workflow.iterationLimit.maxReached', undefined, {
currentIteration: '5',
maxIterations: '10',
const result = getPrompt('claude.judgePrompt', undefined, {
agentOutput: 'test output',
conditionList: '| 1 | Success |',
});
expect(result).toContain('(5/10)');
expect(result).toContain('test output');
expect(result).toContain('| 1 | Success |');
});
});
});
@ -71,11 +72,6 @@ describe('getPromptObject', () => {
expect(result.heading).toBe('## 実行コンテキスト');
});
it('returns interactive UI text object', () => {
const result = getPromptObject<{ intro: string }>('interactive.ui', 'en');
expect(result.intro).toContain('Interactive mode');
});
it('throws for a non-existent key', () => {
expect(() => getPromptObject('nonexistent.key')).toThrow('Prompt key not found: nonexistent.key');
});
@ -103,7 +99,6 @@ describe('YAML content integrity', () => {
expect(() => getPrompt('interactive.workflowInfo', 'en')).not.toThrow();
expect(() => getPrompt('interactive.conversationLabel', 'en')).not.toThrow();
expect(() => getPrompt('interactive.noTranscript', 'en')).not.toThrow();
expect(() => getPromptObject('interactive.ui', 'en')).not.toThrow();
expect(() => getPrompt('summarize.slugGenerator')).not.toThrow();
expect(() => getPrompt('claude.agentDefault')).not.toThrow();
expect(() => getPrompt('claude.judgePrompt')).not.toThrow();
@ -114,7 +109,6 @@ describe('YAML content integrity', () => {
expect(() => getPromptObject('instruction.reportSections', 'en')).not.toThrow();
expect(() => getPrompt('instruction.statusJudgment.header', 'en')).not.toThrow();
expect(() => getPromptObject('instruction.statusRules', 'en')).not.toThrow();
expect(() => getPrompt('workflow.iterationLimit.maxReached')).not.toThrow();
});
it('contains all expected top-level keys in ja', () => {
@ -123,7 +117,6 @@ describe('YAML content integrity', () => {
expect(() => getPrompt('interactive.workflowInfo', 'ja')).not.toThrow();
expect(() => getPrompt('interactive.conversationLabel', 'ja')).not.toThrow();
expect(() => getPrompt('interactive.noTranscript', 'ja')).not.toThrow();
expect(() => getPromptObject('interactive.ui', 'ja')).not.toThrow();
expect(() => getPrompt('summarize.slugGenerator', 'ja')).not.toThrow();
expect(() => getPrompt('claude.agentDefault', 'ja')).not.toThrow();
expect(() => getPrompt('claude.judgePrompt', 'ja')).not.toThrow();
@ -134,7 +127,6 @@ describe('YAML content integrity', () => {
expect(() => getPromptObject('instruction.reportSections', 'ja')).not.toThrow();
expect(() => getPrompt('instruction.statusJudgment.header', 'ja')).not.toThrow();
expect(() => getPromptObject('instruction.statusRules', 'ja')).not.toThrow();
expect(() => getPrompt('workflow.iterationLimit.maxReached', 'ja')).not.toThrow();
});
it('instruction.metadata has all required fields', () => {
@ -169,14 +161,12 @@ describe('YAML content integrity', () => {
'interactive.systemPrompt',
'summarize.slugGenerator',
'claude.agentDefault',
'workflow.iterationLimit.maxReached',
];
for (const key of stringKeys) {
expect(() => getPrompt(key, 'en')).not.toThrow();
expect(() => getPrompt(key, 'ja')).not.toThrow();
}
const objectKeys = [
'interactive.ui',
'instruction.metadata',
'instruction.sections',
];

View File

@ -45,6 +45,8 @@ export interface ReportInstructionContext {
stepIteration: number;
/** Language */
language?: Language;
/** Target report file name (when generating a single report) */
targetFile?: string;
}
/**
@ -85,21 +87,30 @@ export class ReportInstructionBuilder {
execLines.push('');
sections.push(execLines.join('\n'));
// 2. Workflow Context (report info only)
const workflowLines = [
s.workflowContext,
renderReportContext(this.step.report, this.context.reportDir, language),
];
// 2. Workflow Context (single file info when targetFile is specified)
const workflowLines = [s.workflowContext];
if (this.context.targetFile) {
const sectionStr = getPromptObject<{ reportDirectory: string; reportFile: string }>('instruction.sections', language);
workflowLines.push(`- ${sectionStr.reportDirectory}: ${this.context.reportDir}/`);
workflowLines.push(`- ${sectionStr.reportFile}: ${this.context.reportDir}/${this.context.targetFile}`);
} else {
workflowLines.push(renderReportContext(this.step.report, this.context.reportDir, language));
}
sections.push(workflowLines.join('\n'));
// 3. Instructions + report output instruction + format
const instrParts: string[] = [
s.instructions,
r.instructionBody,
r.reportJsonFormat,
];
instrParts.push(r.reportPlainAllowed);
instrParts.push(r.reportOnlyOutput);
// 3. Instructions (simplified when targetFile is specified)
const instrParts: string[] = [s.instructions];
if (this.context.targetFile) {
instrParts.push(r.instructionBody);
instrParts.push(`**このフェーズではツールは使えません。レポート内容をテキストとして直接回答してください。**`);
instrParts.push(`**レポート本文のみを回答してください。** ファイル名やJSON形式は不要です。`);
} else {
instrParts.push(r.instructionBody);
instrParts.push(r.reportJsonFormat);
instrParts.push(r.reportPlainAllowed);
instrParts.push(r.reportOnlyOutput);
}
// Report output instruction (auto-generated or explicit order)
const reportContext: InstructionContext = {
@ -118,7 +129,7 @@ export class ReportInstructionBuilder {
const processedOrder = replaceTemplatePlaceholders(this.step.report.order.trimEnd(), this.step, reportContext);
instrParts.push('');
instrParts.push(processedOrder);
} else {
} else if (!this.context.targetFile) {
const reportInstruction = renderReportOutputInstruction(this.step, reportContext, language);
if (reportInstruction) {
instrParts.push('');

View File

@ -47,33 +47,6 @@ export function needsStatusJudgmentPhase(step: WorkflowStep): boolean {
return hasTagBasedRules(step);
}
function extractJsonPayload(content: string): string | null {
const trimmed = content.trim();
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
return trimmed;
}
const match = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
return match ? match[1]!.trim() : null;
}
function parseReportJson(content: string): Record<string, string> | null {
const payload = extractJsonPayload(content);
if (!payload) return null;
try {
const parsed = JSON.parse(payload);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const obj = parsed as Record<string, unknown>;
for (const value of Object.values(obj)) {
if (typeof value !== 'string') return null;
}
return obj as Record<string, string>;
}
return null;
} catch {
return null;
}
}
function getReportFiles(report: WorkflowStep['report']): string[] {
if (!report) return [];
if (typeof report === 'string') return [report];
@ -81,35 +54,6 @@ function getReportFiles(report: WorkflowStep['report']): string[] {
return report.map((rc) => rc.path);
}
function resolveReportOutputs(
report: WorkflowStep['report'],
reportDir: string,
content: string,
): Map<string, string> {
if (!report) return new Map();
const files = getReportFiles(report);
const json = parseReportJson(content);
if (!json) {
const raw = content;
if (!raw || raw.trim().length === 0) {
throw new Error('Report output is empty.');
}
return new Map(files.map((file) => [file, raw]));
}
const outputs = new Map<string, string>();
for (const file of files) {
const absolutePath = resolve(reportDir, file);
const value = json[file] ?? json[absolutePath];
if (typeof value !== 'string') {
throw new Error(`Report output missing content for file: ${file}`);
}
outputs.set(file, value);
}
return outputs;
}
function writeReportFile(reportDir: string, fileName: string, content: string): void {
const baseDir = resolve(reportDir);
const targetPath = resolve(reportDir, fileName);
@ -128,7 +72,8 @@ function writeReportFile(reportDir: string, fileName: string, content: string):
/**
* Phase 2: Report output.
* Resumes the agent session with no tools to request report content.
* The engine writes the report files to the Report Directory.
* Each report file is generated individually in a loop.
* Plain text responses are written directly to files (no JSON parsing).
*/
export async function runReportPhase(
step: WorkflowStep,
@ -136,53 +81,73 @@ export async function runReportPhase(
ctx: PhaseRunnerContext,
): Promise<void> {
const sessionKey = step.agent ?? step.name;
const sessionId = ctx.getSessionId(sessionKey);
if (!sessionId) {
let currentSessionId = ctx.getSessionId(sessionKey);
if (!currentSessionId) {
throw new Error(`Report phase requires a session to resume, but no sessionId found for agent "${sessionKey}" in step "${step.name}"`);
}
log.debug('Running report phase', { step: step.name, sessionId });
log.debug('Running report phase', { step: step.name, sessionId: currentSessionId });
const reportInstruction = new ReportInstructionBuilder(step, {
cwd: ctx.cwd,
reportDir: ctx.reportDir,
stepIteration,
language: ctx.language,
}).build();
ctx.onPhaseStart?.(step, 2, 'report', reportInstruction);
const reportOptions = ctx.buildResumeOptions(step, sessionId, {
allowedTools: [],
maxTurns: 3,
});
let reportResponse;
try {
reportResponse = await runAgent(step.agent, reportInstruction, reportOptions);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
ctx.onPhaseComplete?.(step, 2, 'report', '', 'error', errorMsg);
throw error;
const reportFiles = getReportFiles(step.report);
if (reportFiles.length === 0) {
log.debug('No report files configured, skipping report phase');
return;
}
// Check for errors in report phase
if (reportResponse.status !== 'done') {
const errorMsg = reportResponse.error || reportResponse.content || 'Unknown error';
ctx.onPhaseComplete?.(step, 2, 'report', reportResponse.content, reportResponse.status, errorMsg);
throw new Error(`Report phase failed: ${errorMsg}`);
}
for (const fileName of reportFiles) {
if (!fileName) {
throw new Error(`Invalid report file name: ${fileName}`);
}
log.debug('Generating report file', { step: step.name, fileName });
const reportInstruction = new ReportInstructionBuilder(step, {
cwd: ctx.cwd,
reportDir: ctx.reportDir,
stepIteration,
language: ctx.language,
targetFile: fileName,
}).build();
ctx.onPhaseStart?.(step, 2, 'report', reportInstruction);
const reportOptions = ctx.buildResumeOptions(step, currentSessionId, {
allowedTools: [],
maxTurns: 3,
});
let reportResponse;
try {
reportResponse = await runAgent(step.agent, reportInstruction, reportOptions);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
ctx.onPhaseComplete?.(step, 2, 'report', '', 'error', errorMsg);
throw error;
}
if (reportResponse.status !== 'done') {
const errorMsg = reportResponse.error || reportResponse.content || 'Unknown error';
ctx.onPhaseComplete?.(step, 2, 'report', reportResponse.content, reportResponse.status, errorMsg);
throw new Error(`Report phase failed for ${fileName}: ${errorMsg}`);
}
const content = reportResponse.content.trim();
if (content.length === 0) {
throw new Error(`Report output is empty for file: ${fileName}`);
}
const outputs = resolveReportOutputs(step.report, ctx.reportDir, reportResponse.content);
for (const [fileName, content] of outputs.entries()) {
writeReportFile(ctx.reportDir, fileName, content);
if (reportResponse.sessionId) {
currentSessionId = reportResponse.sessionId;
ctx.updateAgentSession(sessionKey, currentSessionId);
}
ctx.onPhaseComplete?.(step, 2, 'report', reportResponse.content, reportResponse.status);
log.debug('Report file generated', { step: step.name, fileName });
}
// Update session (phase 2 may update it)
ctx.updateAgentSession(sessionKey, reportResponse.sessionId);
ctx.onPhaseComplete?.(step, 2, 'report', reportResponse.content, reportResponse.status);
log.debug('Report phase complete', { step: step.name, status: reportResponse.status });
log.debug('Report phase complete', { step: step.name, filesGenerated: reportFiles.length });
}
/**

View File

@ -19,7 +19,8 @@ import { getProvider, type ProviderType } from '../../infra/providers/index.js';
import { selectOption } from '../../shared/prompt/index.js';
import { createLogger, getErrorMessage } from '../../shared/utils/index.js';
import { info, error, blankLine, StreamDisplay } from '../../shared/ui/index.js';
import { getPrompt, getPromptObject } from '../../shared/prompts/index.js';
import { getPrompt } from '../../shared/prompts/index.js';
import { getLabelObject } from '../../shared/i18n/index.js';
const log = createLogger('interactive');
/** Shape of interactive UI text */
@ -58,7 +59,7 @@ function getInteractivePrompts(lang: 'en' | 'ja', workflowContext?: WorkflowCont
summaryPrompt,
conversationLabel: getPrompt('interactive.conversationLabel', lang),
noTranscript: getPrompt('interactive.noTranscript', lang),
ui: getPromptObject<InteractiveUIText>('interactive.ui', lang),
ui: getLabelObject<InteractiveUIText>('interactive.ui', lang),
};
}

View File

@ -47,7 +47,7 @@ import {
import { createLogger, notifySuccess, notifyError } from '../../../shared/utils/index.js';
import { selectOption, promptInput } from '../../../shared/prompt/index.js';
import { EXIT_SIGINT } from '../../../shared/exitCodes.js';
import { getPrompt } from '../../../shared/prompts/index.js';
import { getLabel } from '../../../shared/i18n/index.js';
const log = createLogger('workflow');
@ -154,20 +154,20 @@ export async function executeWorkflow(
blankLine();
warn(
getPrompt('workflow.iterationLimit.maxReached', undefined, {
getLabel('workflow.iterationLimit.maxReached', undefined, {
currentIteration: String(request.currentIteration),
maxIterations: String(request.maxIterations),
})
);
info(getPrompt('workflow.iterationLimit.currentStep', undefined, { currentStep: request.currentStep }));
info(getLabel('workflow.iterationLimit.currentStep', undefined, { currentStep: request.currentStep }));
const action = await selectOption(getPrompt('workflow.iterationLimit.continueQuestion'), [
const action = await selectOption(getLabel('workflow.iterationLimit.continueQuestion'), [
{
label: getPrompt('workflow.iterationLimit.continueLabel'),
label: getLabel('workflow.iterationLimit.continueLabel'),
value: 'continue',
description: getPrompt('workflow.iterationLimit.continueDescription'),
description: getLabel('workflow.iterationLimit.continueDescription'),
},
{ label: getPrompt('workflow.iterationLimit.stopLabel'), value: 'stop' },
{ label: getLabel('workflow.iterationLimit.stopLabel'), value: 'stop' },
]);
if (action !== 'continue') {
@ -175,7 +175,7 @@ export async function executeWorkflow(
}
while (true) {
const input = await promptInput(getPrompt('workflow.iterationLimit.inputPrompt'));
const input = await promptInput(getLabel('workflow.iterationLimit.inputPrompt'));
if (!input) {
return null;
}
@ -186,7 +186,7 @@ export async function executeWorkflow(
return additionalIterations;
}
warn(getPrompt('workflow.iterationLimit.invalidInput'));
warn(getLabel('workflow.iterationLimit.invalidInput'));
}
};
@ -198,7 +198,7 @@ export async function executeWorkflow(
}
blankLine();
info(request.prompt.trim());
const input = await promptInput(getPrompt('workflow.iterationLimit.userInputPrompt'));
const input = await promptInput(getLabel('workflow.iterationLimit.userInputPrompt'));
return input && input.trim() ? input.trim() : null;
}
: undefined;
@ -355,7 +355,7 @@ export async function executeWorkflow(
success(`Workflow completed (${state.iteration} iterations${elapsedDisplay})`);
info(`Session log: ${ndjsonLogPath}`);
notifySuccess('TAKT', getPrompt('workflow.notifyComplete', undefined, { iteration: String(state.iteration) }));
notifySuccess('TAKT', getLabel('workflow.notifyComplete', undefined, { iteration: String(state.iteration) }));
});
engine.on('workflow:abort', (state, reason) => {
@ -385,7 +385,7 @@ export async function executeWorkflow(
error(`Workflow aborted after ${state.iteration} iterations${elapsedDisplay}: ${reason}`);
info(`Session log: ${ndjsonLogPath}`);
notifyError('TAKT', getPrompt('workflow.notifyAbort', undefined, { reason }));
notifyError('TAKT', getLabel('workflow.notifyAbort', undefined, { reason }));
});
// SIGINT handler: 1st Ctrl+C = graceful abort, 2nd = force exit
@ -394,11 +394,11 @@ export async function executeWorkflow(
sigintCount++;
if (sigintCount === 1) {
blankLine();
warn(getPrompt('workflow.sigintGraceful'));
warn(getLabel('workflow.sigintGraceful'));
engine.abort();
} else {
blankLine();
error(getPrompt('workflow.sigintForce'));
error(getLabel('workflow.sigintForce'));
process.exit(EXIT_SIGINT);
}
};

112
src/shared/i18n/index.ts Normal file
View File

@ -0,0 +1,112 @@
/**
* UI label loader utility
*
* Loads user-facing display strings from language-specific YAML files
* (labels_en.yaml / labels_ja.yaml) and provides
* key-based access with template variable substitution.
*
* This module handles UI labels only AI prompts live in ../prompts/.
*/
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parse as parseYaml } from 'yaml';
import type { Language } from '../../core/models/types.js';
import { DEFAULT_LANGUAGE } from '../constants.js';
/** Cached YAML data per language */
const labelCache = new Map<Language, Record<string, unknown>>();
function loadLabels(lang: Language): Record<string, unknown> {
const cached = labelCache.get(lang);
if (cached) return cached;
const __dirname = dirname(fileURLToPath(import.meta.url));
const yamlPath = join(__dirname, `labels_${lang}.yaml`);
const content = readFileSync(yamlPath, 'utf-8');
const data = parseYaml(content) as Record<string, unknown>;
labelCache.set(lang, data);
return data;
}
/**
* Resolve a dot-separated key path to a value in a nested object.
* Returns undefined if the path does not exist.
*/
function resolveKey(obj: Record<string, unknown>, keyPath: string): unknown {
const parts = keyPath.split('.');
let current: unknown = obj;
for (const part of parts) {
if (current === null || current === undefined || typeof current !== 'object') {
return undefined;
}
current = (current as Record<string, unknown>)[part];
}
return current;
}
/**
* Replace {key} placeholders in a template string with values from vars.
* Unmatched placeholders are left as-is.
*/
function applyVars(template: string, vars: Record<string, string>): string {
return template.replace(/\{(\w+)\}/g, (match, key: string) => {
if (key in vars) {
const value: string = vars[key] as string;
return value;
}
return match;
});
}
/**
* Get a UI label string from the language-specific YAML by dot-separated key.
*
* When `lang` is provided, loads the corresponding language file.
* When `lang` is omitted, uses DEFAULT_LANGUAGE.
*
* Template variables in `{name}` format are replaced when `vars` is given.
*/
export function getLabel(
key: string,
lang?: Language,
vars?: Record<string, string>,
): string {
const effectiveLang = lang ?? DEFAULT_LANGUAGE;
const data = loadLabels(effectiveLang);
const value = resolveKey(data, key);
if (typeof value !== 'string') {
throw new Error(`Label key not found: ${key}${lang ? ` (lang: ${lang})` : ''}`);
}
if (vars) {
return applyVars(value, vars);
}
return value;
}
/**
* Get a nested object from the language-specific YAML by dot-separated key.
*
* When `lang` is provided, loads the corresponding language file.
* When `lang` is omitted, uses DEFAULT_LANGUAGE.
*
* Useful for structured label groups (e.g. UI text objects).
*/
export function getLabelObject<T>(key: string, lang?: Language): T {
const effectiveLang = lang ?? DEFAULT_LANGUAGE;
const data = loadLabels(effectiveLang);
const value = resolveKey(data, key);
if (value === undefined || value === null) {
throw new Error(`Label key not found: ${key}${lang ? ` (lang: ${lang})` : ''}`);
}
return value as T;
}
/** Reset cached data (for testing) */
export function _resetLabelCache(): void {
labelCache.clear();
}

View File

@ -0,0 +1,35 @@
# =============================================================================
# TAKT UI Labels — English
# =============================================================================
# User-facing display strings (not AI prompts).
# Template variables use {variableName} syntax.
# =============================================================================
# ===== Interactive Mode UI =====
interactive:
ui:
intro: "Interactive mode - describe your task. Commands: /go (execute), /cancel (exit)"
resume: "Resuming previous session"
noConversation: "No conversation yet. Please describe your task first."
summarizeFailed: "Failed to summarize conversation. Please try again."
continuePrompt: "Okay, continue describing your task."
proposed: "Proposed task instruction:"
confirm: "Use this task instruction?"
cancelled: "Cancelled"
# ===== Workflow Execution UI =====
workflow:
iterationLimit:
maxReached: "最大イテレーションに到達しました ({currentIteration}/{maxIterations})"
currentStep: "現在のステップ: {currentStep}"
continueQuestion: "続行しますか?"
continueLabel: "続行する(追加イテレーション数を入力)"
continueDescription: "入力した回数だけ上限を増やします"
stopLabel: "終了する"
inputPrompt: "追加するイテレーション数を入力してください1以上"
invalidInput: "1以上の整数を入力してください。"
userInputPrompt: "追加の指示を入力してください(空で中止)"
notifyComplete: "ワークフロー完了 ({iteration} iterations)"
notifyAbort: "中断: {reason}"
sigintGraceful: "Ctrl+C: ワークフローを中断しています..."
sigintForce: "Ctrl+C: 強制終了します"

View File

@ -0,0 +1,35 @@
# =============================================================================
# TAKT UI Labels — 日本語
# =============================================================================
# ユーザー向け表示文字列AIプロンプトではない
# テンプレート変数は {variableName} 形式を使用します。
# =============================================================================
# ===== Interactive Mode UI =====
interactive:
ui:
intro: "対話モード - タスク内容を入力してください。コマンド: /go実行, /cancel終了"
resume: "前回のセッションを再開します"
noConversation: "まだ会話がありません。まずタスク内容を入力してください。"
summarizeFailed: "会話の要約に失敗しました。再度お試しください。"
continuePrompt: "続けてタスク内容を入力してください。"
proposed: "提案されたタスク指示:"
confirm: "このタスク指示で進めますか?"
cancelled: "キャンセルしました"
# ===== Workflow Execution UI =====
workflow:
iterationLimit:
maxReached: "最大イテレーションに到達しました ({currentIteration}/{maxIterations})"
currentStep: "現在のステップ: {currentStep}"
continueQuestion: "続行しますか?"
continueLabel: "続行する(追加イテレーション数を入力)"
continueDescription: "入力した回数だけ上限を増やします"
stopLabel: "終了する"
inputPrompt: "追加するイテレーション数を入力してください1以上"
invalidInput: "1以上の整数を入力してください。"
userInputPrompt: "追加の指示を入力してください(空で中止)"
notifyComplete: "ワークフロー完了 ({iteration} iterations)"
notifyAbort: "中断: {reason}"
sigintGraceful: "Ctrl+C: ワークフローを中断しています..."
sigintForce: "Ctrl+C: 強制終了します"

View File

@ -1,10 +1,11 @@
# =============================================================================
# TAKT Prompt Definitions — English
# =============================================================================
# AI-facing prompt definitions only. UI labels live in ../i18n/.
# Template variables use {variableName} syntax.
# =============================================================================
# ===== Interactive Mode =====
# ===== Interactive Mode — AI prompts for conversation and summarization =====
interactive:
systemPrompt: |
You are a task planning assistant. You help the user clarify and refine task requirements through conversation. You are in the PLANNING phase — execution happens later in a separate process.
@ -69,17 +70,7 @@ interactive:
noTranscript: "(No local transcript. Summarize the current session context.)"
ui:
intro: "Interactive mode - describe your task. Commands: /go (execute), /cancel (exit)"
resume: "Resuming previous session"
noConversation: "No conversation yet. Please describe your task first."
summarizeFailed: "Failed to summarize conversation. Please try again."
continuePrompt: "Okay, continue describing your task."
proposed: "Proposed task instruction:"
confirm: "Use this task instruction?"
cancelled: "Cancelled"
# ===== Summarize =====
# ===== Summarize — slug generation for task descriptions =====
summarize:
slugGenerator: |
You are a slug generator. Given a task description, output ONLY a slug.
@ -96,7 +87,7 @@ summarize:
worktreeを作るときブランチ名をAIで生成 → ai-branch-naming
レビュー画面に元の指示を表示する → show-original-instruction
# ===== Claude Client =====
# ===== Claude Client — agent and judge prompts =====
claude:
agentDefault: "You are the {agentName} agent. Follow the standard {agentName} workflow."
judgePrompt: |
@ -119,7 +110,7 @@ claude:
Output ONLY the tag `[JUDGE:N]` where N is the number of the best matching condition.
Do not output anything else.
# ===== Instruction Builders =====
# ===== Instruction Builders — prompt construction for workflow steps =====
instruction:
metadata:
heading: "## Execution Context"
@ -179,20 +170,3 @@ instruction:
outputInstruction: "Output the tag corresponding to your decision:"
appendixHeading: "### Appendix Template"
appendixInstruction: "When outputting `[{tag}]`, append the following:"
# ===== Workflow Execution =====
workflow:
iterationLimit:
maxReached: "最大イテレーションに到達しました ({currentIteration}/{maxIterations})"
currentStep: "現在のステップ: {currentStep}"
continueQuestion: "続行しますか?"
continueLabel: "続行する(追加イテレーション数を入力)"
continueDescription: "入力した回数だけ上限を増やします"
stopLabel: "終了する"
inputPrompt: "追加するイテレーション数を入力してください1以上"
invalidInput: "1以上の整数を入力してください。"
userInputPrompt: "追加の指示を入力してください(空で中止)"
notifyComplete: "ワークフロー完了 ({iteration} iterations)"
notifyAbort: "中断: {reason}"
sigintGraceful: "Ctrl+C: ワークフローを中断しています..."
sigintForce: "Ctrl+C: 強制終了します"

View File

@ -1,10 +1,11 @@
# =============================================================================
# TAKT Prompt Definitions — 日本語
# =============================================================================
# AI向けプロンプト定義のみ。UIラベルは ../i18n/ を参照。
# テンプレート変数は {variableName} 形式を使用します。
# =============================================================================
# ===== Interactive Mode =====
# ===== Interactive Mode — 対話モードのAIプロンプト =====
interactive:
systemPrompt: |
あなたはTAKTAIエージェントワークフローオーケストレーションツールの対話モードを担当しています。
@ -82,17 +83,7 @@ interactive:
noTranscript: "(ローカル履歴なし。現在のセッション文脈を要約してください。)"
ui:
intro: "対話モード - タスク内容を入力してください。コマンド: /go実行, /cancel終了"
resume: "前回のセッションを再開します"
noConversation: "まだ会話がありません。まずタスク内容を入力してください。"
summarizeFailed: "会話の要約に失敗しました。再度お試しください。"
continuePrompt: "続けてタスク内容を入力してください。"
proposed: "提案されたタスク指示:"
confirm: "このタスク指示で進めますか?"
cancelled: "キャンセルしました"
# ===== Summarize =====
# ===== Summarize — スラグ生成 =====
summarize:
slugGenerator: |
You are a slug generator. Given a task description, output ONLY a slug.
@ -109,7 +100,7 @@ summarize:
worktreeを作るときブランチ名をAIで生成 → ai-branch-naming
レビュー画面に元の指示を表示する → show-original-instruction
# ===== Claude Client =====
# ===== Claude Client — エージェント・ジャッジプロンプト =====
claude:
agentDefault: "You are the {agentName} agent. Follow the standard {agentName} workflow."
judgePrompt: |
@ -132,7 +123,7 @@ claude:
Output ONLY the tag `[JUDGE:N]` where N is the number of the best matching condition.
Do not output anything else.
# ===== Instruction Builders =====
# ===== Instruction Builders — ワークフローステップのプロンプト構成 =====
instruction:
metadata:
heading: "## 実行コンテキスト"
@ -192,20 +183,3 @@ instruction:
outputInstruction: "判定に対応するタグを出力してください:"
appendixHeading: "### 追加出力テンプレート"
appendixInstruction: "`[{tag}]` を出力する場合、以下を追記してください:"
# ===== Workflow Execution =====
workflow:
iterationLimit:
maxReached: "最大イテレーションに到達しました ({currentIteration}/{maxIterations})"
currentStep: "現在のステップ: {currentStep}"
continueQuestion: "続行しますか?"
continueLabel: "続行する(追加イテレーション数を入力)"
continueDescription: "入力した回数だけ上限を増やします"
stopLabel: "終了する"
inputPrompt: "追加するイテレーション数を入力してください1以上"
invalidInput: "1以上の整数を入力してください。"
userInputPrompt: "追加の指示を入力してください(空で中止)"
notifyComplete: "ワークフロー完了 ({iteration} iterations)"
notifyAbort: "中断: {reason}"
sigintGraceful: "Ctrl+C: ワークフローを中断しています..."
sigintForce: "Ctrl+C: 強制終了します"

849
tools/debug-log-viewer.html Normal file
View File

@ -0,0 +1,849 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TAKT Debug Log Viewer</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1e1e1e;
color: #d4d4d4;
padding: 20px;
}
.container {
max-width: 1600px;
margin: 0 auto;
}
h1 {
font-size: 24px;
margin-bottom: 20px;
color: #ffffff;
}
.drop-zone {
border: 2px dashed #007acc;
border-radius: 8px;
padding: 40px;
text-align: center;
background: #252526;
cursor: pointer;
transition: all 0.3s;
margin-bottom: 20px;
}
.drop-zone:hover,
.drop-zone.drag-over {
background: #2d2d30;
border-color: #0098ff;
}
.drop-zone-text {
font-size: 16px;
color: #858585;
}
.quick-actions {
display: flex;
gap: 10px;
margin-bottom: 20px;
justify-content: center;
}
.action-btn {
padding: 10px 20px;
background: #007acc;
color: #ffffff;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.action-btn:hover {
background: #005a9e;
}
.action-btn:disabled {
background: #3e3e42;
color: #858585;
cursor: not-allowed;
}
.action-btn.secondary {
background: #252526;
border: 1px solid #3e3e42;
}
.action-btn.secondary:hover:not(:disabled) {
background: #2d2d30;
border-color: #007acc;
}
.navigation {
display: none;
background: #252526;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
align-items: center;
gap: 10px;
}
.navigation.active {
display: flex;
}
.current-file {
flex: 1;
text-align: center;
font-size: 14px;
color: #9cdcfe;
}
.nav-buttons {
display: flex;
gap: 8px;
}
.nav-btn {
padding: 6px 12px;
background: #1e1e1e;
color: #d4d4d4;
border: 1px solid #3e3e42;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: all 0.2s;
}
.nav-btn:hover:not(:disabled) {
background: #2d2d30;
border-color: #007acc;
}
.nav-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.controls {
background: #252526;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
display: none;
}
.controls.active {
display: block;
}
.filter-group {
margin-bottom: 15px;
}
.filter-label {
font-size: 14px;
font-weight: 600;
color: #9cdcfe;
margin-bottom: 8px;
display: block;
}
.filter-buttons {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.filter-btn {
padding: 6px 12px;
border: 1px solid #3e3e42;
border-radius: 4px;
background: #1e1e1e;
color: #d4d4d4;
cursor: pointer;
font-size: 12px;
transition: all 0.2s;
}
.filter-btn:hover {
background: #2d2d30;
border-color: #007acc;
}
.filter-btn.active {
background: #007acc;
border-color: #007acc;
color: #ffffff;
}
.search-box {
width: 100%;
padding: 8px 12px;
background: #1e1e1e;
border: 1px solid #3e3e42;
border-radius: 4px;
color: #d4d4d4;
font-size: 14px;
}
.search-box:focus {
outline: none;
border-color: #007acc;
}
.stats {
display: flex;
gap: 20px;
flex-wrap: wrap;
margin-bottom: 15px;
}
.stat {
display: flex;
flex-direction: column;
}
.stat-label {
font-size: 12px;
color: #858585;
}
.stat-value {
font-size: 16px;
font-weight: 600;
color: #ffffff;
}
.logs {
display: flex;
flex-direction: column;
gap: 2px;
}
.log-entry {
background: #252526;
padding: 8px 12px;
border-left: 3px solid #3e3e42;
font-family: 'Courier New', monospace;
font-size: 13px;
line-height: 1.5;
}
.log-entry.DEBUG {
border-left-color: #858585;
}
.log-entry.INFO {
border-left-color: #4ec9b0;
}
.log-entry.WARN {
border-left-color: #dcdcaa;
}
.log-entry.ERROR {
border-left-color: #f48771;
}
.log-entry.hidden {
display: none;
}
.log-timestamp {
color: #858585;
margin-right: 8px;
}
.log-level {
font-weight: 600;
margin-right: 8px;
}
.log-level.DEBUG {
color: #858585;
}
.log-level.INFO {
color: #4ec9b0;
}
.log-level.WARN {
color: #dcdcaa;
}
.log-level.ERROR {
color: #f48771;
}
.log-category {
color: #9cdcfe;
margin-right: 8px;
}
.log-message {
color: #d4d4d4;
}
.log-json {
background: #1e1e1e;
border-radius: 4px;
padding: 8px;
margin-top: 4px;
white-space: pre-wrap;
overflow-x: auto;
border: 1px solid #3e3e42;
color: #ce9178;
}
.highlight {
background-color: #ffff0044;
}
.error {
background: #f48771;
color: #1e1e1e;
padding: 10px;
border-radius: 4px;
margin-top: 10px;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #1e1e1e;
}
::-webkit-scrollbar-thumb {
background: #3e3e42;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #4e4e52;
}
</style>
</head>
<body>
<div class="container">
<h1>TAKT Debug Log Viewer</h1>
<div class="drop-zone" id="dropZone">
<div class="drop-zone-text">
ここにデバッグログファイルをドラッグ&ドロップ<br>
またはクリックしてファイルを選択
</div>
<input type="file" id="fileInput" accept=".log,.txt" style="display: none;">
</div>
<div class="quick-actions">
<button class="action-btn" id="loadLatestBtn">📂 ログディレクトリを指定する</button>
<button class="action-btn secondary" id="clearDirBtn" style="display: none;">🗑️ 保存したディレクトリをクリア</button>
</div>
<div class="navigation" id="navigation">
<div class="nav-buttons">
<button class="nav-btn" id="oldestBtn">⏮️ 最古</button>
<button class="nav-btn" id="prevBtn">◀️ Prev</button>
</div>
<div class="current-file" id="currentFile">-</div>
<div class="nav-buttons">
<button class="nav-btn" id="nextBtn">Next ▶️</button>
<button class="nav-btn" id="latestBtn">最新 ⏭️</button>
</div>
</div>
<div class="controls" id="controls">
<div class="stats" id="stats"></div>
<div class="filter-group">
<label class="filter-label">ログレベル</label>
<div class="filter-buttons" id="levelFilters"></div>
</div>
<div class="filter-group">
<label class="filter-label">カテゴリ</label>
<div class="filter-buttons" id="categoryFilters"></div>
</div>
<div class="filter-group">
<label class="filter-label">検索</label>
<input type="text" class="search-box" id="searchBox" placeholder="メッセージを検索...">
</div>
</div>
<div class="logs" id="logs"></div>
</div>
<script>
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const loadLatestBtn = document.getElementById('loadLatestBtn');
const clearDirBtn = document.getElementById('clearDirBtn');
const navigation = document.getElementById('navigation');
const currentFileDiv = document.getElementById('currentFile');
const oldestBtn = document.getElementById('oldestBtn');
const prevBtn = document.getElementById('prevBtn');
const nextBtn = document.getElementById('nextBtn');
const latestBtn = document.getElementById('latestBtn');
const controls = document.getElementById('controls');
const statsDiv = document.getElementById('stats');
const logsDiv = document.getElementById('logs');
const levelFiltersDiv = document.getElementById('levelFilters');
const categoryFiltersDiv = document.getElementById('categoryFilters');
const searchBox = document.getElementById('searchBox');
let logEntries = [];
let activeLevels = new Set(['DEBUG', 'INFO', 'WARN', 'ERROR']);
let activeCategories = new Set();
let searchTerm = '';
let lastLogDirectory = null;
let logFiles = [];
let currentFileIndex = -1;
// IndexedDB setup
const DB_NAME = 'TaktLogViewerDB';
const DB_VERSION = 1;
const STORE_NAME = 'directories';
let db = null;
async function initDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
db = request.result;
resolve(db);
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME);
}
};
});
}
async function saveDirectoryHandle(handle) {
if (!db) await initDB();
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
store.put(handle, 'logDirectory');
return tx.complete;
}
async function getDirectoryHandle() {
if (!db) await initDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const request = store.get('logDirectory');
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async function clearDirectoryHandle() {
if (!db) await initDB();
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
store.delete('logDirectory');
return tx.complete;
}
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('drag-over');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('drag-over');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('drag-over');
const file = e.dataTransfer.files[0];
if (file) handleFile(file);
});
fileInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) handleFile(file);
});
searchBox.addEventListener('input', (e) => {
searchTerm = e.target.value.toLowerCase();
applyFilters();
});
// Initialize
initDB().then(async () => {
const savedHandle = await getDirectoryHandle();
if (savedHandle) {
clearDirBtn.style.display = 'inline-block';
loadLatestBtn.textContent = '🔄 最新のログを読み込む';
// Try to load automatically
try {
await loadFromDirectoryHandle(savedHandle);
} catch (err) {
console.log('Saved directory handle is no longer valid:', err);
}
}
});
oldestBtn.addEventListener('click', () => loadFileByIndex(logFiles.length - 1));
prevBtn.addEventListener('click', () => loadFileByIndex(currentFileIndex + 1));
nextBtn.addEventListener('click', () => loadFileByIndex(currentFileIndex - 1));
latestBtn.addEventListener('click', () => loadFileByIndex(0));
clearDirBtn.addEventListener('click', async () => {
await clearDirectoryHandle();
clearDirBtn.style.display = 'none';
loadLatestBtn.textContent = '📂 ログディレクトリを指定する';
logFiles = [];
currentFileIndex = -1;
navigation.classList.remove('active');
logsDiv.innerHTML = '';
controls.classList.remove('active');
});
loadLatestBtn.addEventListener('click', async () => {
try {
// Check if File System Access API is supported
if (!('showDirectoryPicker' in window)) {
alert('お使いのブラウザはこの機能に対応していません。Chrome、Edge、Brave、またはOperaをご使用ください。');
return;
}
// Check if we have a saved directory handle
const savedHandle = await getDirectoryHandle();
let dirHandle;
if (savedHandle) {
// Try to use saved handle
try {
// Verify we still have permission
const permission = await savedHandle.queryPermission({ mode: 'read' });
if (permission === 'granted') {
dirHandle = savedHandle;
} else {
// Request permission again
const newPermission = await savedHandle.requestPermission({ mode: 'read' });
if (newPermission === 'granted') {
dirHandle = savedHandle;
}
}
} catch (err) {
console.log('Saved handle is invalid, requesting new directory');
}
}
// If no saved handle or permission denied, request new directory
if (!dirHandle) {
dirHandle = await window.showDirectoryPicker({
id: 'takt-logs',
startIn: 'documents',
mode: 'read'
});
// Save the new handle
await saveDirectoryHandle(dirHandle);
clearDirBtn.style.display = 'inline-block';
loadLatestBtn.textContent = '🔄 最新のログを読み込む';
}
await loadFromDirectoryHandle(dirHandle);
} catch (err) {
if (err.name === 'AbortError') {
console.log('User cancelled directory selection');
} else {
console.error('Error loading latest log:', err);
logsDiv.innerHTML = `<div class="error">Error loading latest log: ${err.message}</div>`;
}
}
});
async function loadFromDirectoryHandle(dirHandle) {
lastLogDirectory = dirHandle;
// Find all .log files
logFiles = [];
for await (const entry of dirHandle.values()) {
if (entry.kind === 'file' && (entry.name.endsWith('.log') || entry.name.endsWith('.txt'))) {
const fileHandle = entry;
const file = await fileHandle.getFile();
logFiles.push({ name: entry.name, file, modifiedTime: file.lastModified });
}
}
if (logFiles.length === 0) {
alert('ログファイルが見つかりませんでした。');
return;
}
// Sort by modified time (newest first)
logFiles.sort((a, b) => b.modifiedTime - a.modifiedTime);
// Load the latest file (index 0)
loadFileByIndex(0);
}
function loadFileByIndex(index) {
if (index < 0 || index >= logFiles.length) return;
currentFileIndex = index;
const fileData = logFiles[index];
handleFile(fileData.file);
updateNavigation();
}
function updateNavigation() {
if (logFiles.length === 0) {
navigation.classList.remove('active');
return;
}
navigation.classList.add('active');
const fileData = logFiles[currentFileIndex];
const date = new Date(fileData.modifiedTime).toLocaleString('ja-JP');
currentFileDiv.textContent = `${fileData.name} (${currentFileIndex + 1}/${logFiles.length}) - ${date}`;
// Update button states
oldestBtn.disabled = currentFileIndex === logFiles.length - 1;
prevBtn.disabled = currentFileIndex === logFiles.length - 1;
nextBtn.disabled = currentFileIndex === 0;
latestBtn.disabled = currentFileIndex === 0;
}
function handleFile(file) {
const reader = new FileReader();
reader.onload = (e) => {
try {
const content = e.target.result;
parseLog(content);
} catch (err) {
logsDiv.innerHTML = `<div class="error">Error parsing log: ${err.message}</div>`;
}
};
reader.readAsText(file);
}
function parseLog(content) {
const lines = content.split('\n');
logEntries = [];
const categories = new Set();
const levels = { DEBUG: 0, INFO: 0, WARN: 0, ERROR: 0 };
let currentEntry = null;
let jsonBuffer = [];
let inJson = false;
for (let line of lines) {
// Skip header lines
if (line.startsWith('=====') || line.startsWith('TAKT Debug Log') ||
line.startsWith('Started:') || line.startsWith('Project:')) {
continue;
}
// Match log line: [timestamp] [level] [category] message
const match = line.match(/^\[([^\]]+)\] \[([^\]]+)\] \[([^\]]+)\] (.+)$/);
if (match) {
// Save previous entry if exists
if (currentEntry) {
if (jsonBuffer.length > 0) {
currentEntry.json = jsonBuffer.join('\n');
jsonBuffer = [];
}
logEntries.push(currentEntry);
}
const [, timestamp, level, category, message] = match;
currentEntry = {
timestamp,
level: level.toUpperCase(),
category,
message,
json: null
};
categories.add(category);
levels[level.toUpperCase()]++;
inJson = false;
} else if (currentEntry) {
// Check if this is a JSON line
if (line.trim().startsWith('{') || line.trim().startsWith('[')) {
inJson = true;
}
if (inJson || line.trim().match(/^[}\],]/) || line.trim().match(/^"[\w-]+":/) ||
(jsonBuffer.length > 0 && line.trim())) {
jsonBuffer.push(line);
}
}
}
// Save last entry
if (currentEntry) {
if (jsonBuffer.length > 0) {
currentEntry.json = jsonBuffer.join('\n');
}
logEntries.push(currentEntry);
}
activeCategories = new Set(categories);
displayStats(levels, categories.size);
createFilters(categories);
displayLogs();
}
function displayStats(levels, categoryCount) {
statsDiv.innerHTML = `
<div class="stat">
<span class="stat-label">Total Logs</span>
<span class="stat-value">${logEntries.length}</span>
</div>
<div class="stat">
<span class="stat-label">DEBUG</span>
<span class="stat-value">${levels.DEBUG}</span>
</div>
<div class="stat">
<span class="stat-label">INFO</span>
<span class="stat-value">${levels.INFO}</span>
</div>
<div class="stat">
<span class="stat-label">WARN</span>
<span class="stat-value">${levels.WARN}</span>
</div>
<div class="stat">
<span class="stat-label">ERROR</span>
<span class="stat-value">${levels.ERROR}</span>
</div>
<div class="stat">
<span class="stat-label">Categories</span>
<span class="stat-value">${categoryCount}</span>
</div>
`;
controls.classList.add('active');
}
function createFilters(categories) {
// Level filters
levelFiltersDiv.innerHTML = ['DEBUG', 'INFO', 'WARN', 'ERROR'].map(level => `
<button class="filter-btn active" data-level="${level}" onclick="toggleLevel('${level}')">
${level}
</button>
`).join('');
// Category filters
const sortedCategories = Array.from(categories).sort();
categoryFiltersDiv.innerHTML = sortedCategories.map(cat => `
<button class="filter-btn active" data-category="${cat}" onclick="toggleCategory('${cat}')">
${cat}
</button>
`).join('');
}
function toggleLevel(level) {
if (activeLevels.has(level)) {
activeLevels.delete(level);
} else {
activeLevels.add(level);
}
const btn = document.querySelector(`[data-level="${level}"]`);
btn.classList.toggle('active');
applyFilters();
}
function toggleCategory(category) {
if (activeCategories.has(category)) {
activeCategories.delete(category);
} else {
activeCategories.add(category);
}
const btn = document.querySelector(`[data-category="${category}"]`);
btn.classList.toggle('active');
applyFilters();
}
function applyFilters() {
const entries = document.querySelectorAll('.log-entry');
entries.forEach((entry, index) => {
const log = logEntries[index];
const levelMatch = activeLevels.has(log.level);
const categoryMatch = activeCategories.has(log.category);
const searchMatch = !searchTerm ||
log.message.toLowerCase().includes(searchTerm) ||
(log.json && log.json.toLowerCase().includes(searchTerm));
if (levelMatch && categoryMatch && searchMatch) {
entry.classList.remove('hidden');
} else {
entry.classList.add('hidden');
}
});
}
function highlightText(text, term) {
if (!term) return escapeHtml(text);
const regex = new RegExp(`(${escapeRegex(term)})`, 'gi');
return escapeHtml(text).replace(regex, '<span class="highlight">$1</span>');
}
function escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function displayLogs() {
logsDiv.innerHTML = logEntries.map((log, index) => {
const messageHtml = searchTerm ?
highlightText(log.message, searchTerm) :
escapeHtml(log.message);
const jsonHtml = log.json ?
`<div class="log-json">${searchTerm ? highlightText(log.json, searchTerm) : escapeHtml(log.json)}</div>` :
'';
return `
<div class="log-entry ${log.level}">
<span class="log-timestamp">${escapeHtml(log.timestamp)}</span>
<span class="log-level ${log.level}">[${log.level}]</span>
<span class="log-category">[${escapeHtml(log.category)}]</span>
<span class="log-message">${messageHtml}</span>
${jsonHtml}
</div>
`;
}).join('');
}
function escapeHtml(text) {
if (typeof text !== 'string') return String(text);
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, m => map[m]);
}
</script>
</body>
</html>

View File

@ -51,6 +51,87 @@
color: #858585;
}
.quick-actions {
display: flex;
gap: 10px;
margin-bottom: 20px;
justify-content: center;
}
.action-btn {
padding: 10px 20px;
background: #007acc;
color: #ffffff;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.action-btn:hover {
background: #005a9e;
}
.action-btn:disabled {
background: #3e3e42;
color: #858585;
cursor: not-allowed;
}
.action-btn.secondary {
background: #252526;
border: 1px solid #3e3e42;
}
.action-btn.secondary:hover:not(:disabled) {
background: #2d2d30;
border-color: #007acc;
}
.navigation {
display: none;
background: #252526;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
align-items: center;
gap: 10px;
}
.navigation.active {
display: flex;
}
.nav-btn {
padding: 8px 16px;
background: #007acc;
color: #ffffff;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.nav-btn:hover:not(:disabled) {
background: #005a9e;
}
.nav-btn:disabled {
background: #3e3e42;
color: #858585;
cursor: not-allowed;
}
.current-file {
flex: 1;
text-align: center;
font-size: 14px;
color: #d4d4d4;
padding: 0 10px;
}
.file-info {
background: #252526;
padding: 15px;
@ -236,6 +317,19 @@
<input type="file" id="fileInput" accept=".jsonl,.json" style="display: none;">
</div>
<div class="quick-actions">
<button class="action-btn" id="loadLatestBtn">📂 セッションディレクトリを指定する</button>
<button class="action-btn secondary" id="clearDirBtn" style="display: none;">🗑️ 保存したディレクトリをクリア</button>
</div>
<div class="navigation" id="navigation">
<button class="nav-btn" id="oldestBtn">⏮️ Oldest</button>
<button class="nav-btn" id="prevBtn">◀️ Prev</button>
<div class="current-file" id="currentFile"></div>
<button class="nav-btn" id="nextBtn">Next ▶️</button>
<button class="nav-btn" id="latestBtn">Latest ⏭️</button>
</div>
<div class="file-info" id="fileInfo">
<div class="stats" id="stats"></div>
</div>
@ -246,10 +340,86 @@
<script>
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const loadLatestBtn = document.getElementById('loadLatestBtn');
const clearDirBtn = document.getElementById('clearDirBtn');
const navigation = document.getElementById('navigation');
const currentFileDiv = document.getElementById('currentFile');
const oldestBtn = document.getElementById('oldestBtn');
const prevBtn = document.getElementById('prevBtn');
const nextBtn = document.getElementById('nextBtn');
const latestBtn = document.getElementById('latestBtn');
const fileInfo = document.getElementById('fileInfo');
const statsDiv = document.getElementById('stats');
const recordsDiv = document.getElementById('records');
let sessionFiles = [];
let currentFileIndex = -1;
let lastLogDirectory = null;
// IndexedDB setup
const DB_NAME = 'TaktSessionViewerDB';
const DB_VERSION = 1;
const STORE_NAME = 'directories';
let db = null;
async function initDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
db = request.result;
resolve(db);
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME);
}
};
});
}
async function saveDirectoryHandle(handle) {
if (!db) await initDB();
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
store.put(handle, 'sessionDirectory');
return tx.complete;
}
async function getDirectoryHandle() {
if (!db) await initDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const request = store.get('sessionDirectory');
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async function clearDirectoryHandle() {
if (!db) await initDB();
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
store.delete('sessionDirectory');
return tx.complete;
}
// Initialize
initDB().then(async () => {
const savedHandle = await getDirectoryHandle();
if (savedHandle) {
clearDirBtn.style.display = 'inline-block';
loadLatestBtn.textContent = '🔄 最新のセッションを読み込む';
try {
await loadFromDirectoryHandle(savedHandle);
} catch (err) {
console.log('Saved directory handle is no longer valid:', err);
}
}
});
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
@ -269,6 +439,110 @@
if (file) handleFile(file);
});
oldestBtn.addEventListener('click', () => loadFileByIndex(sessionFiles.length - 1));
prevBtn.addEventListener('click', () => loadFileByIndex(currentFileIndex + 1));
nextBtn.addEventListener('click', () => loadFileByIndex(currentFileIndex - 1));
latestBtn.addEventListener('click', () => loadFileByIndex(0));
clearDirBtn.addEventListener('click', async () => {
await clearDirectoryHandle();
clearDirBtn.style.display = 'none';
loadLatestBtn.textContent = '📂 セッションディレクトリを指定する';
sessionFiles = [];
currentFileIndex = -1;
navigation.classList.remove('active');
recordsDiv.innerHTML = '';
fileInfo.classList.remove('active');
});
loadLatestBtn.addEventListener('click', async () => {
try {
if (!('showDirectoryPicker' in window)) {
alert('お使いのブラウザはこの機能に対応していません。Chrome、Edge、Brave、またはOperaをご使用ください。');
return;
}
const savedHandle = await getDirectoryHandle();
let dirHandle;
if (savedHandle) {
try {
const permission = await savedHandle.queryPermission({ mode: 'read' });
if (permission === 'granted') {
dirHandle = savedHandle;
} else {
const newPermission = await savedHandle.requestPermission({ mode: 'read' });
if (newPermission === 'granted') {
dirHandle = savedHandle;
}
}
} catch (err) {
console.log('Saved handle is invalid, requesting new directory');
}
}
if (!dirHandle) {
dirHandle = await window.showDirectoryPicker({
id: 'takt-sessions',
startIn: 'documents',
mode: 'read'
});
await saveDirectoryHandle(dirHandle);
clearDirBtn.style.display = 'inline-block';
loadLatestBtn.textContent = '🔄 最新のセッションを読み込む';
}
await loadFromDirectoryHandle(dirHandle);
} catch (err) {
if (err.name === 'AbortError') {
console.log('User cancelled directory selection');
} else {
console.error('Error loading latest session:', err);
recordsDiv.innerHTML = `<div class="error">Error loading latest session: ${err.message}</div>`;
}
}
});
async function loadFromDirectoryHandle(dirHandle) {
lastLogDirectory = dirHandle;
sessionFiles = [];
for await (const entry of dirHandle.values()) {
if (entry.kind === 'file' && (entry.name.endsWith('.jsonl') || entry.name.endsWith('.json'))) {
const fileHandle = entry;
const file = await fileHandle.getFile();
sessionFiles.push({ name: entry.name, file, modifiedTime: file.lastModified });
}
}
if (sessionFiles.length === 0) {
alert('JSONLファイルが見つかりませんでした。');
return;
}
sessionFiles.sort((a, b) => b.modifiedTime - a.modifiedTime);
loadFileByIndex(0);
}
function loadFileByIndex(index) {
if (index < 0 || index >= sessionFiles.length) return;
currentFileIndex = index;
const fileData = sessionFiles[index];
handleFile(fileData.file);
// Update navigation
navigation.classList.add('active');
currentFileDiv.textContent = `${fileData.name} (${index + 1} / ${sessionFiles.length})`;
oldestBtn.disabled = index === sessionFiles.length - 1;
prevBtn.disabled = index === sessionFiles.length - 1;
nextBtn.disabled = index === 0;
latestBtn.disabled = index === 0;
}
function handleFile(file) {
const reader = new FileReader();
reader.onload = (e) => {
@ -285,7 +559,6 @@
}
function displayRecords(records) {
// Calculate stats
const stats = {
total: records.length,
steps: records.filter(r => r.type === 'step_start').length,
@ -309,7 +582,6 @@
fileInfo.classList.add('active');
// Display records
recordsDiv.innerHTML = records.map((record, index) => {
const hasInstruction = record.instruction && record.instruction.length > 0;
const instructionId = `instruction-${index}`;
@ -388,4 +660,4 @@
}
</script>
</body>
</html>
</html>