ラベル系を別ファイルに逃がす
This commit is contained in:
parent
cae770cef4
commit
25fb6c4dfd
@ -10,7 +10,7 @@
|
|||||||
"takt-cli": "./dist/app/cli/index.js"
|
"takt-cli": "./dist/app/cli/index.js"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"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",
|
"watch": "tsc --watch",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"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', () => {
|
it('replaces multiple different variables', () => {
|
||||||
const result = getPrompt('workflow.iterationLimit.maxReached', undefined, {
|
const result = getPrompt('claude.judgePrompt', undefined, {
|
||||||
currentIteration: '5',
|
agentOutput: 'test output',
|
||||||
maxIterations: '10',
|
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('## 実行コンテキスト');
|
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', () => {
|
it('throws for a non-existent key', () => {
|
||||||
expect(() => getPromptObject('nonexistent.key')).toThrow('Prompt key not found: nonexistent.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.workflowInfo', 'en')).not.toThrow();
|
||||||
expect(() => getPrompt('interactive.conversationLabel', 'en')).not.toThrow();
|
expect(() => getPrompt('interactive.conversationLabel', 'en')).not.toThrow();
|
||||||
expect(() => getPrompt('interactive.noTranscript', '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('summarize.slugGenerator')).not.toThrow();
|
||||||
expect(() => getPrompt('claude.agentDefault')).not.toThrow();
|
expect(() => getPrompt('claude.agentDefault')).not.toThrow();
|
||||||
expect(() => getPrompt('claude.judgePrompt')).not.toThrow();
|
expect(() => getPrompt('claude.judgePrompt')).not.toThrow();
|
||||||
@ -114,7 +109,6 @@ describe('YAML content integrity', () => {
|
|||||||
expect(() => getPromptObject('instruction.reportSections', 'en')).not.toThrow();
|
expect(() => getPromptObject('instruction.reportSections', 'en')).not.toThrow();
|
||||||
expect(() => getPrompt('instruction.statusJudgment.header', 'en')).not.toThrow();
|
expect(() => getPrompt('instruction.statusJudgment.header', 'en')).not.toThrow();
|
||||||
expect(() => getPromptObject('instruction.statusRules', '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', () => {
|
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.workflowInfo', 'ja')).not.toThrow();
|
||||||
expect(() => getPrompt('interactive.conversationLabel', 'ja')).not.toThrow();
|
expect(() => getPrompt('interactive.conversationLabel', 'ja')).not.toThrow();
|
||||||
expect(() => getPrompt('interactive.noTranscript', '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('summarize.slugGenerator', 'ja')).not.toThrow();
|
||||||
expect(() => getPrompt('claude.agentDefault', 'ja')).not.toThrow();
|
expect(() => getPrompt('claude.agentDefault', 'ja')).not.toThrow();
|
||||||
expect(() => getPrompt('claude.judgePrompt', '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(() => getPromptObject('instruction.reportSections', 'ja')).not.toThrow();
|
||||||
expect(() => getPrompt('instruction.statusJudgment.header', 'ja')).not.toThrow();
|
expect(() => getPrompt('instruction.statusJudgment.header', 'ja')).not.toThrow();
|
||||||
expect(() => getPromptObject('instruction.statusRules', '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', () => {
|
it('instruction.metadata has all required fields', () => {
|
||||||
@ -169,14 +161,12 @@ describe('YAML content integrity', () => {
|
|||||||
'interactive.systemPrompt',
|
'interactive.systemPrompt',
|
||||||
'summarize.slugGenerator',
|
'summarize.slugGenerator',
|
||||||
'claude.agentDefault',
|
'claude.agentDefault',
|
||||||
'workflow.iterationLimit.maxReached',
|
|
||||||
];
|
];
|
||||||
for (const key of stringKeys) {
|
for (const key of stringKeys) {
|
||||||
expect(() => getPrompt(key, 'en')).not.toThrow();
|
expect(() => getPrompt(key, 'en')).not.toThrow();
|
||||||
expect(() => getPrompt(key, 'ja')).not.toThrow();
|
expect(() => getPrompt(key, 'ja')).not.toThrow();
|
||||||
}
|
}
|
||||||
const objectKeys = [
|
const objectKeys = [
|
||||||
'interactive.ui',
|
|
||||||
'instruction.metadata',
|
'instruction.metadata',
|
||||||
'instruction.sections',
|
'instruction.sections',
|
||||||
];
|
];
|
||||||
|
|||||||
@ -45,6 +45,8 @@ export interface ReportInstructionContext {
|
|||||||
stepIteration: number;
|
stepIteration: number;
|
||||||
/** Language */
|
/** Language */
|
||||||
language?: Language;
|
language?: Language;
|
||||||
|
/** Target report file name (when generating a single report) */
|
||||||
|
targetFile?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -85,21 +87,30 @@ export class ReportInstructionBuilder {
|
|||||||
execLines.push('');
|
execLines.push('');
|
||||||
sections.push(execLines.join('\n'));
|
sections.push(execLines.join('\n'));
|
||||||
|
|
||||||
// 2. Workflow Context (report info only)
|
// 2. Workflow Context (single file info when targetFile is specified)
|
||||||
const workflowLines = [
|
const workflowLines = [s.workflowContext];
|
||||||
s.workflowContext,
|
if (this.context.targetFile) {
|
||||||
renderReportContext(this.step.report, this.context.reportDir, language),
|
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'));
|
sections.push(workflowLines.join('\n'));
|
||||||
|
|
||||||
// 3. Instructions + report output instruction + format
|
// 3. Instructions (simplified when targetFile is specified)
|
||||||
const instrParts: string[] = [
|
const instrParts: string[] = [s.instructions];
|
||||||
s.instructions,
|
|
||||||
r.instructionBody,
|
if (this.context.targetFile) {
|
||||||
r.reportJsonFormat,
|
instrParts.push(r.instructionBody);
|
||||||
];
|
instrParts.push(`**このフェーズではツールは使えません。レポート内容をテキストとして直接回答してください。**`);
|
||||||
instrParts.push(r.reportPlainAllowed);
|
instrParts.push(`**レポート本文のみを回答してください。** ファイル名やJSON形式は不要です。`);
|
||||||
instrParts.push(r.reportOnlyOutput);
|
} 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)
|
// Report output instruction (auto-generated or explicit order)
|
||||||
const reportContext: InstructionContext = {
|
const reportContext: InstructionContext = {
|
||||||
@ -118,7 +129,7 @@ export class ReportInstructionBuilder {
|
|||||||
const processedOrder = replaceTemplatePlaceholders(this.step.report.order.trimEnd(), this.step, reportContext);
|
const processedOrder = replaceTemplatePlaceholders(this.step.report.order.trimEnd(), this.step, reportContext);
|
||||||
instrParts.push('');
|
instrParts.push('');
|
||||||
instrParts.push(processedOrder);
|
instrParts.push(processedOrder);
|
||||||
} else {
|
} else if (!this.context.targetFile) {
|
||||||
const reportInstruction = renderReportOutputInstruction(this.step, reportContext, language);
|
const reportInstruction = renderReportOutputInstruction(this.step, reportContext, language);
|
||||||
if (reportInstruction) {
|
if (reportInstruction) {
|
||||||
instrParts.push('');
|
instrParts.push('');
|
||||||
|
|||||||
@ -47,33 +47,6 @@ export function needsStatusJudgmentPhase(step: WorkflowStep): boolean {
|
|||||||
return hasTagBasedRules(step);
|
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[] {
|
function getReportFiles(report: WorkflowStep['report']): string[] {
|
||||||
if (!report) return [];
|
if (!report) return [];
|
||||||
if (typeof report === 'string') return [report];
|
if (typeof report === 'string') return [report];
|
||||||
@ -81,35 +54,6 @@ function getReportFiles(report: WorkflowStep['report']): string[] {
|
|||||||
return report.map((rc) => rc.path);
|
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 {
|
function writeReportFile(reportDir: string, fileName: string, content: string): void {
|
||||||
const baseDir = resolve(reportDir);
|
const baseDir = resolve(reportDir);
|
||||||
const targetPath = resolve(reportDir, fileName);
|
const targetPath = resolve(reportDir, fileName);
|
||||||
@ -128,7 +72,8 @@ function writeReportFile(reportDir: string, fileName: string, content: string):
|
|||||||
/**
|
/**
|
||||||
* Phase 2: Report output.
|
* Phase 2: Report output.
|
||||||
* Resumes the agent session with no tools to request report content.
|
* 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(
|
export async function runReportPhase(
|
||||||
step: WorkflowStep,
|
step: WorkflowStep,
|
||||||
@ -136,53 +81,73 @@ export async function runReportPhase(
|
|||||||
ctx: PhaseRunnerContext,
|
ctx: PhaseRunnerContext,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const sessionKey = step.agent ?? step.name;
|
const sessionKey = step.agent ?? step.name;
|
||||||
const sessionId = ctx.getSessionId(sessionKey);
|
let currentSessionId = ctx.getSessionId(sessionKey);
|
||||||
if (!sessionId) {
|
if (!currentSessionId) {
|
||||||
throw new Error(`Report phase requires a session to resume, but no sessionId found for agent "${sessionKey}" in step "${step.name}"`);
|
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, {
|
const reportFiles = getReportFiles(step.report);
|
||||||
cwd: ctx.cwd,
|
if (reportFiles.length === 0) {
|
||||||
reportDir: ctx.reportDir,
|
log.debug('No report files configured, skipping report phase');
|
||||||
stepIteration,
|
return;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for errors in report phase
|
for (const fileName of reportFiles) {
|
||||||
if (reportResponse.status !== 'done') {
|
if (!fileName) {
|
||||||
const errorMsg = reportResponse.error || reportResponse.content || 'Unknown error';
|
throw new Error(`Invalid report file name: ${fileName}`);
|
||||||
ctx.onPhaseComplete?.(step, 2, 'report', reportResponse.content, reportResponse.status, errorMsg);
|
}
|
||||||
throw new Error(`Report phase failed: ${errorMsg}`);
|
|
||||||
}
|
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);
|
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)
|
log.debug('Report phase complete', { step: step.name, filesGenerated: reportFiles.length });
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -19,7 +19,8 @@ import { getProvider, type ProviderType } from '../../infra/providers/index.js';
|
|||||||
import { selectOption } from '../../shared/prompt/index.js';
|
import { selectOption } from '../../shared/prompt/index.js';
|
||||||
import { createLogger, getErrorMessage } from '../../shared/utils/index.js';
|
import { createLogger, getErrorMessage } from '../../shared/utils/index.js';
|
||||||
import { info, error, blankLine, StreamDisplay } from '../../shared/ui/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');
|
const log = createLogger('interactive');
|
||||||
|
|
||||||
/** Shape of interactive UI text */
|
/** Shape of interactive UI text */
|
||||||
@ -58,7 +59,7 @@ function getInteractivePrompts(lang: 'en' | 'ja', workflowContext?: WorkflowCont
|
|||||||
summaryPrompt,
|
summaryPrompt,
|
||||||
conversationLabel: getPrompt('interactive.conversationLabel', lang),
|
conversationLabel: getPrompt('interactive.conversationLabel', lang),
|
||||||
noTranscript: getPrompt('interactive.noTranscript', 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 { createLogger, notifySuccess, notifyError } from '../../../shared/utils/index.js';
|
||||||
import { selectOption, promptInput } from '../../../shared/prompt/index.js';
|
import { selectOption, promptInput } from '../../../shared/prompt/index.js';
|
||||||
import { EXIT_SIGINT } from '../../../shared/exitCodes.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');
|
const log = createLogger('workflow');
|
||||||
|
|
||||||
@ -154,20 +154,20 @@ export async function executeWorkflow(
|
|||||||
|
|
||||||
blankLine();
|
blankLine();
|
||||||
warn(
|
warn(
|
||||||
getPrompt('workflow.iterationLimit.maxReached', undefined, {
|
getLabel('workflow.iterationLimit.maxReached', undefined, {
|
||||||
currentIteration: String(request.currentIteration),
|
currentIteration: String(request.currentIteration),
|
||||||
maxIterations: String(request.maxIterations),
|
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',
|
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') {
|
if (action !== 'continue') {
|
||||||
@ -175,7 +175,7 @@ export async function executeWorkflow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const input = await promptInput(getPrompt('workflow.iterationLimit.inputPrompt'));
|
const input = await promptInput(getLabel('workflow.iterationLimit.inputPrompt'));
|
||||||
if (!input) {
|
if (!input) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -186,7 +186,7 @@ export async function executeWorkflow(
|
|||||||
return additionalIterations;
|
return additionalIterations;
|
||||||
}
|
}
|
||||||
|
|
||||||
warn(getPrompt('workflow.iterationLimit.invalidInput'));
|
warn(getLabel('workflow.iterationLimit.invalidInput'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -198,7 +198,7 @@ export async function executeWorkflow(
|
|||||||
}
|
}
|
||||||
blankLine();
|
blankLine();
|
||||||
info(request.prompt.trim());
|
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;
|
return input && input.trim() ? input.trim() : null;
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
@ -355,7 +355,7 @@ export async function executeWorkflow(
|
|||||||
|
|
||||||
success(`Workflow completed (${state.iteration} iterations${elapsedDisplay})`);
|
success(`Workflow completed (${state.iteration} iterations${elapsedDisplay})`);
|
||||||
info(`Session log: ${ndjsonLogPath}`);
|
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) => {
|
engine.on('workflow:abort', (state, reason) => {
|
||||||
@ -385,7 +385,7 @@ export async function executeWorkflow(
|
|||||||
|
|
||||||
error(`Workflow aborted after ${state.iteration} iterations${elapsedDisplay}: ${reason}`);
|
error(`Workflow aborted after ${state.iteration} iterations${elapsedDisplay}: ${reason}`);
|
||||||
info(`Session log: ${ndjsonLogPath}`);
|
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
|
// SIGINT handler: 1st Ctrl+C = graceful abort, 2nd = force exit
|
||||||
@ -394,11 +394,11 @@ export async function executeWorkflow(
|
|||||||
sigintCount++;
|
sigintCount++;
|
||||||
if (sigintCount === 1) {
|
if (sigintCount === 1) {
|
||||||
blankLine();
|
blankLine();
|
||||||
warn(getPrompt('workflow.sigintGraceful'));
|
warn(getLabel('workflow.sigintGraceful'));
|
||||||
engine.abort();
|
engine.abort();
|
||||||
} else {
|
} else {
|
||||||
blankLine();
|
blankLine();
|
||||||
error(getPrompt('workflow.sigintForce'));
|
error(getLabel('workflow.sigintForce'));
|
||||||
process.exit(EXIT_SIGINT);
|
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
|
# TAKT Prompt Definitions — English
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
# AI-facing prompt definitions only. UI labels live in ../i18n/.
|
||||||
# Template variables use {variableName} syntax.
|
# Template variables use {variableName} syntax.
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# ===== Interactive Mode =====
|
# ===== Interactive Mode — AI prompts for conversation and summarization =====
|
||||||
interactive:
|
interactive:
|
||||||
systemPrompt: |
|
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.
|
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.)"
|
noTranscript: "(No local transcript. Summarize the current session context.)"
|
||||||
|
|
||||||
ui:
|
# ===== Summarize — slug generation for task descriptions =====
|
||||||
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:
|
summarize:
|
||||||
slugGenerator: |
|
slugGenerator: |
|
||||||
You are a slug generator. Given a task description, output ONLY a slug.
|
You are a slug generator. Given a task description, output ONLY a slug.
|
||||||
@ -96,7 +87,7 @@ summarize:
|
|||||||
worktreeを作るときブランチ名をAIで生成 → ai-branch-naming
|
worktreeを作るときブランチ名をAIで生成 → ai-branch-naming
|
||||||
レビュー画面に元の指示を表示する → show-original-instruction
|
レビュー画面に元の指示を表示する → show-original-instruction
|
||||||
|
|
||||||
# ===== Claude Client =====
|
# ===== Claude Client — agent and judge prompts =====
|
||||||
claude:
|
claude:
|
||||||
agentDefault: "You are the {agentName} agent. Follow the standard {agentName} workflow."
|
agentDefault: "You are the {agentName} agent. Follow the standard {agentName} workflow."
|
||||||
judgePrompt: |
|
judgePrompt: |
|
||||||
@ -119,7 +110,7 @@ claude:
|
|||||||
Output ONLY the tag `[JUDGE:N]` where N is the number of the best matching condition.
|
Output ONLY the tag `[JUDGE:N]` where N is the number of the best matching condition.
|
||||||
Do not output anything else.
|
Do not output anything else.
|
||||||
|
|
||||||
# ===== Instruction Builders =====
|
# ===== Instruction Builders — prompt construction for workflow steps =====
|
||||||
instruction:
|
instruction:
|
||||||
metadata:
|
metadata:
|
||||||
heading: "## Execution Context"
|
heading: "## Execution Context"
|
||||||
@ -179,20 +170,3 @@ instruction:
|
|||||||
outputInstruction: "Output the tag corresponding to your decision:"
|
outputInstruction: "Output the tag corresponding to your decision:"
|
||||||
appendixHeading: "### Appendix Template"
|
appendixHeading: "### Appendix Template"
|
||||||
appendixInstruction: "When outputting `[{tag}]`, append the following:"
|
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 — 日本語
|
# TAKT Prompt Definitions — 日本語
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
# AI向けプロンプト定義のみ。UIラベルは ../i18n/ を参照。
|
||||||
# テンプレート変数は {variableName} 形式を使用します。
|
# テンプレート変数は {variableName} 形式を使用します。
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# ===== Interactive Mode =====
|
# ===== Interactive Mode — 対話モードのAIプロンプト =====
|
||||||
interactive:
|
interactive:
|
||||||
systemPrompt: |
|
systemPrompt: |
|
||||||
あなたはTAKT(AIエージェントワークフローオーケストレーションツール)の対話モードを担当しています。
|
あなたはTAKT(AIエージェントワークフローオーケストレーションツール)の対話モードを担当しています。
|
||||||
@ -82,17 +83,7 @@ interactive:
|
|||||||
|
|
||||||
noTranscript: "(ローカル履歴なし。現在のセッション文脈を要約してください。)"
|
noTranscript: "(ローカル履歴なし。現在のセッション文脈を要約してください。)"
|
||||||
|
|
||||||
ui:
|
# ===== Summarize — スラグ生成 =====
|
||||||
intro: "対話モード - タスク内容を入力してください。コマンド: /go(実行), /cancel(終了)"
|
|
||||||
resume: "前回のセッションを再開します"
|
|
||||||
noConversation: "まだ会話がありません。まずタスク内容を入力してください。"
|
|
||||||
summarizeFailed: "会話の要約に失敗しました。再度お試しください。"
|
|
||||||
continuePrompt: "続けてタスク内容を入力してください。"
|
|
||||||
proposed: "提案されたタスク指示:"
|
|
||||||
confirm: "このタスク指示で進めますか?"
|
|
||||||
cancelled: "キャンセルしました"
|
|
||||||
|
|
||||||
# ===== Summarize =====
|
|
||||||
summarize:
|
summarize:
|
||||||
slugGenerator: |
|
slugGenerator: |
|
||||||
You are a slug generator. Given a task description, output ONLY a slug.
|
You are a slug generator. Given a task description, output ONLY a slug.
|
||||||
@ -109,7 +100,7 @@ summarize:
|
|||||||
worktreeを作るときブランチ名をAIで生成 → ai-branch-naming
|
worktreeを作るときブランチ名をAIで生成 → ai-branch-naming
|
||||||
レビュー画面に元の指示を表示する → show-original-instruction
|
レビュー画面に元の指示を表示する → show-original-instruction
|
||||||
|
|
||||||
# ===== Claude Client =====
|
# ===== Claude Client — エージェント・ジャッジプロンプト =====
|
||||||
claude:
|
claude:
|
||||||
agentDefault: "You are the {agentName} agent. Follow the standard {agentName} workflow."
|
agentDefault: "You are the {agentName} agent. Follow the standard {agentName} workflow."
|
||||||
judgePrompt: |
|
judgePrompt: |
|
||||||
@ -132,7 +123,7 @@ claude:
|
|||||||
Output ONLY the tag `[JUDGE:N]` where N is the number of the best matching condition.
|
Output ONLY the tag `[JUDGE:N]` where N is the number of the best matching condition.
|
||||||
Do not output anything else.
|
Do not output anything else.
|
||||||
|
|
||||||
# ===== Instruction Builders =====
|
# ===== Instruction Builders — ワークフローステップのプロンプト構成 =====
|
||||||
instruction:
|
instruction:
|
||||||
metadata:
|
metadata:
|
||||||
heading: "## 実行コンテキスト"
|
heading: "## 実行コンテキスト"
|
||||||
@ -192,20 +183,3 @@ instruction:
|
|||||||
outputInstruction: "判定に対応するタグを出力してください:"
|
outputInstruction: "判定に対応するタグを出力してください:"
|
||||||
appendixHeading: "### 追加出力テンプレート"
|
appendixHeading: "### 追加出力テンプレート"
|
||||||
appendixInstruction: "`[{tag}]` を出力する場合、以下を追記してください:"
|
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;
|
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 {
|
.file-info {
|
||||||
background: #252526;
|
background: #252526;
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
@ -236,6 +317,19 @@
|
|||||||
<input type="file" id="fileInput" accept=".jsonl,.json" style="display: none;">
|
<input type="file" id="fileInput" accept=".jsonl,.json" style="display: none;">
|
||||||
</div>
|
</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="file-info" id="fileInfo">
|
||||||
<div class="stats" id="stats"></div>
|
<div class="stats" id="stats"></div>
|
||||||
</div>
|
</div>
|
||||||
@ -246,10 +340,86 @@
|
|||||||
<script>
|
<script>
|
||||||
const dropZone = document.getElementById('dropZone');
|
const dropZone = document.getElementById('dropZone');
|
||||||
const fileInput = document.getElementById('fileInput');
|
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 fileInfo = document.getElementById('fileInfo');
|
||||||
const statsDiv = document.getElementById('stats');
|
const statsDiv = document.getElementById('stats');
|
||||||
const recordsDiv = document.getElementById('records');
|
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('click', () => fileInput.click());
|
||||||
dropZone.addEventListener('dragover', (e) => {
|
dropZone.addEventListener('dragover', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -269,6 +439,110 @@
|
|||||||
if (file) handleFile(file);
|
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) {
|
function handleFile(file) {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = (e) => {
|
reader.onload = (e) => {
|
||||||
@ -285,7 +559,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function displayRecords(records) {
|
function displayRecords(records) {
|
||||||
// Calculate stats
|
|
||||||
const stats = {
|
const stats = {
|
||||||
total: records.length,
|
total: records.length,
|
||||||
steps: records.filter(r => r.type === 'step_start').length,
|
steps: records.filter(r => r.type === 'step_start').length,
|
||||||
@ -309,7 +582,6 @@
|
|||||||
|
|
||||||
fileInfo.classList.add('active');
|
fileInfo.classList.add('active');
|
||||||
|
|
||||||
// Display records
|
|
||||||
recordsDiv.innerHTML = records.map((record, index) => {
|
recordsDiv.innerHTML = records.map((record, index) => {
|
||||||
const hasInstruction = record.instruction && record.instruction.length > 0;
|
const hasInstruction = record.instruction && record.instruction.length > 0;
|
||||||
const instructionId = `instruction-${index}`;
|
const instructionId = `instruction-${index}`;
|
||||||
@ -388,4 +660,4 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Loading…
x
Reference in New Issue
Block a user