ラベル系を別ファイルに逃がす
This commit is contained in:
parent
cae770cef4
commit
25fb6c4dfd
@ -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
140
src/__tests__/i18n.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -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',
|
||||
];
|
||||
|
||||
@ -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,
|
||||
];
|
||||
// 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('');
|
||||
|
||||
@ -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,23 +81,37 @@ 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 reportFiles = getReportFiles(step.report);
|
||||
if (reportFiles.length === 0) {
|
||||
log.debug('No report files configured, skipping report phase');
|
||||
return;
|
||||
}
|
||||
|
||||
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, sessionId, {
|
||||
const reportOptions = ctx.buildResumeOptions(step, currentSessionId, {
|
||||
allowedTools: [],
|
||||
maxTurns: 3,
|
||||
});
|
||||
@ -166,23 +125,29 @@ export async function runReportPhase(
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 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}`);
|
||||
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);
|
||||
}
|
||||
|
||||
// Update session (phase 2 may update it)
|
||||
ctx.updateAgentSession(sessionKey, reportResponse.sessionId);
|
||||
if (reportResponse.sessionId) {
|
||||
currentSessionId = reportResponse.sessionId;
|
||||
ctx.updateAgentSession(sessionKey, currentSessionId);
|
||||
}
|
||||
|
||||
ctx.onPhaseComplete?.(step, 2, 'report', reportResponse.content, reportResponse.status);
|
||||
log.debug('Report phase complete', { step: step.name, status: reportResponse.status });
|
||||
log.debug('Report file generated', { step: step.name, fileName });
|
||||
}
|
||||
|
||||
log.debug('Report phase complete', { step: step.name, filesGenerated: reportFiles.length });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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
112
src/shared/i18n/index.ts
Normal 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();
|
||||
}
|
||||
35
src/shared/i18n/labels_en.yaml
Normal file
35
src/shared/i18n/labels_en.yaml
Normal 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: 強制終了します"
|
||||
35
src/shared/i18n/labels_ja.yaml
Normal file
35
src/shared/i18n/labels_ja.yaml
Normal 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: 強制終了します"
|
||||
@ -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: 強制終了します"
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
# =============================================================================
|
||||
# TAKT Prompt Definitions — 日本語
|
||||
# =============================================================================
|
||||
# AI向けプロンプト定義のみ。UIラベルは ../i18n/ を参照。
|
||||
# テンプレート変数は {variableName} 形式を使用します。
|
||||
# =============================================================================
|
||||
|
||||
# ===== Interactive Mode =====
|
||||
# ===== Interactive Mode — 対話モードのAIプロンプト =====
|
||||
interactive:
|
||||
systemPrompt: |
|
||||
あなたはTAKT(AIエージェントワークフローオーケストレーションツール)の対話モードを担当しています。
|
||||
@ -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
849
tools/debug-log-viewer.html
Normal 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 = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
};
|
||||
return text.replace(/[&<>"']/g, m => map[m]);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -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}`;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user