diff --git a/src/constants/coreConstants.ts b/src/constants/coreConstants.ts
index 34ea5a6..ec73d83 100644
--- a/src/constants/coreConstants.ts
+++ b/src/constants/coreConstants.ts
@@ -100,11 +100,13 @@ export const OPFS_CONSTANTS = {
PROJECT_FILE: 'project.json',
METADATA_FILE: 'meta.json',
MEDIA_DIR: 'media',
+ CONVERSATIONS_DIR: 'conversations',
+ CONVERSATION_FILE: 'conversation.json',
};
export const CONFIG_UPGRADER_CONSTANTS = {
VERSION_KEY: '__config_version',
- CURRENT_VERSION: 4,
+ CURRENT_VERSION: 5,
};
export const URL_CONSTANTS = {
diff --git a/src/core/KGDebugger.ts b/src/core/KGDebugger.ts
index 99476fc..a08763c 100644
--- a/src/core/KGDebugger.ts
+++ b/src/core/KGDebugger.ts
@@ -341,13 +341,13 @@ export class KGDebugger {
* Usage examples in browser console:
*
* // Single tool call:
- * await KGStudio.KGDebugger.testToolCall('{"name":"read_music","arguments":{"start":0,"length":8}}')
+ * await KGDebugger.testToolCall('{"name":"read_music","arguments":{"start":0,"length":8}}')
*
* // Multiple tool calls:
- * await KGStudio.KGDebugger.testToolCall('[{"name":"remove_notes","arguments":{"start":0,"end_beat":4}},{"name":"add_notes","arguments":{"notes":[{"pitch":"C4","start":0,"length":1}]}}]')
+ * await KGDebugger.testToolCall('[{"name":"remove_notes","arguments":{"start":0,"end_beat":4}},{"name":"add_notes","arguments":{"notes":[{"pitch":"C4","start":0,"length":1}]}}]')
*
* // Can also pass a JS object directly (no need to stringify):
- * await KGStudio.KGDebugger.testToolCall({name:"read_music",arguments:{start:0}})
+ * await KGDebugger.testToolCall({name:"read_music",arguments:{start:0}})
*
* @param input - JSON string, object, or array of tool call(s).
* Each tool call should have: { name: string, arguments: object }
@@ -430,13 +430,15 @@ export class KGDebugger {
console.log(" - Use browser developer tools for best experience");
console.log("");
console.log("💡 testToolCall examples:");
- console.log(' await KGStudio.KGDebugger.testToolCall(\'{"name":"read_music","arguments":{"start":0,"length":8}}\')');
- console.log(' await KGStudio.KGDebugger.testToolCall({name:"add_notes",arguments:{notes:[{pitch:"C4",start:0,length:1}]}})');
- console.log(' await KGStudio.KGDebugger.testToolCall([{name:"remove_notes",arguments:{start:0,end_beat:4}},{name:"read_music",arguments:{}}])');
+ console.log(' await KGDebugger.testToolCall(\'{"name":"get_user_selected_music_range_and_track","arguments":{}}\')');
+ console.log(' await KGDebugger.testToolCall(\'{"name":"list_all_tracks","arguments":{}}\')');
+ console.log(' await KGDebugger.testToolCall(\'{"name":"read_music","arguments":{"start":0,"length":8}}\')');
+ console.log(' await KGDebugger.testToolCall({name:"add_notes",arguments:{notes:[{pitch:"C4",start:0,length:1}]}})');
+ console.log(' await KGDebugger.testToolCall([{name:"remove_notes",arguments:{start:0,end_beat:4}},{name:"read_music",arguments:{}}])');
console.log("");
console.log("💡 KGOne input examples:");
- console.log(' await KGStudio.KGDebugger.inputKGOneCaption("Genre: Eurodance, 90s dance-pop, upbeat electronic...", 30)');
- console.log(' await KGStudio.KGDebugger.inputKGOneLyrics("[Verse 1]\\nYour lyrics here...\\n\\n[Chorus]\\n...", 30)');
+ console.log(' await KGDebugger.inputKGOneCaption("Genre: Eurodance, 90s dance-pop, upbeat electronic...", 30)');
+ console.log(' await KGDebugger.inputKGOneLyrics("[Verse 1]\\nYour lyrics here...\\n\\n[Chorus]\\n...", 30)');
}
/**
diff --git a/src/core/KGProject.ts b/src/core/KGProject.ts
index 5eaf7fe..2a9e997 100644
--- a/src/core/KGProject.ts
+++ b/src/core/KGProject.ts
@@ -54,11 +54,19 @@ export class KGProject {
@WithDefault(1)
private pianoRollZoom: number = 1;
+ @Expose()
+ @WithDefault(false)
+ private showGlobalTracks: boolean = false;
+
+ @Expose()
+ @WithDefault(false)
+ private isMetronomeEnabled: boolean = false;
+
@Expose()
@WithDefault(0)
private projectStructureVersion: number = 0;
- public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 15;
+ public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 16;
@Expose()
@Type(() => KGTrack, {
@@ -89,7 +97,7 @@ export class KGProject {
private globalTracks: KGGlobalTrack[] = createDefaultGlobalTracks();
// Constructor
- constructor(name: string = RESERVED_PROJECT_NAME, maxBars: number = 32, currentBars: number = 0, bpm: number = TIME_CONSTANTS.DEFAULT_BPM, timeSignature: TimeSignature = { numerator: 4, denominator: 4 }, keySignature: KeySignature = "C major", selectedMode: string = "ionian", isLooping: boolean = false, loopingRange: [number, number] = [0, 0], barWidthMultiplier: number = 2, tracks: KGTrack[] = [], projectStructureVersion: number = KGProject.CURRENT_PROJECT_STRUCTURE_VERSION, pianoRollZoom: number = 1, globalTracks: KGGlobalTrack[] = createDefaultGlobalTracks()) {
+ constructor(name: string = RESERVED_PROJECT_NAME, maxBars: number = 32, currentBars: number = 0, bpm: number = TIME_CONSTANTS.DEFAULT_BPM, timeSignature: TimeSignature = { numerator: 4, denominator: 4 }, keySignature: KeySignature = "C major", selectedMode: string = "ionian", isLooping: boolean = false, loopingRange: [number, number] = [0, 0], barWidthMultiplier: number = 2, tracks: KGTrack[] = [], projectStructureVersion: number = KGProject.CURRENT_PROJECT_STRUCTURE_VERSION, pianoRollZoom: number = 1, globalTracks: KGGlobalTrack[] = createDefaultGlobalTracks(), showGlobalTracks: boolean = false, isMetronomeEnabled: boolean = false) {
this.name = name;
this.maxBars = maxBars;
this.currentBars = currentBars;
@@ -104,6 +112,8 @@ export class KGProject {
this.projectStructureVersion = projectStructureVersion;
this.pianoRollZoom = pianoRollZoom;
this.globalTracks = globalTracks;
+ this.showGlobalTracks = showGlobalTracks;
+ this.isMetronomeEnabled = isMetronomeEnabled;
}
// Getters
@@ -188,6 +198,22 @@ export class KGProject {
return this.projectStructureVersion;
}
+ public getShowGlobalTracks(): boolean {
+ return this.showGlobalTracks;
+ }
+
+ public setShowGlobalTracks(showGlobalTracks: boolean): void {
+ this.showGlobalTracks = showGlobalTracks;
+ }
+
+ public getIsMetronomeEnabled(): boolean {
+ return this.isMetronomeEnabled;
+ }
+
+ public setIsMetronomeEnabled(isMetronomeEnabled: boolean): void {
+ this.isMetronomeEnabled = isMetronomeEnabled;
+ }
+
public getIsLooping(): boolean {
return this.isLooping;
}
diff --git a/src/core/commands/global-region/GlobalChordCommands.test.ts b/src/core/commands/global-region/GlobalChordCommands.test.ts
index 7f57ecf..c2c3b77 100644
--- a/src/core/commands/global-region/GlobalChordCommands.test.ts
+++ b/src/core/commands/global-region/GlobalChordCommands.test.ts
@@ -9,6 +9,7 @@ import { MoveGlobalRegionCommand } from './MoveGlobalRegionCommand';
import { ReplaceChordRegionsInRangeCommand } from './ReplaceChordRegionsInRangeCommand';
import { ResizeGlobalRegionCommand } from './ResizeGlobalRegionCommand';
import { UpdateChordRegionCommand } from './UpdateChordRegionCommand';
+import { WriteChordProgressionCommand } from './WriteChordProgressionCommand';
describe('global chord region commands', () => {
beforeEach(() => {
@@ -147,4 +148,86 @@ describe('global chord region commands', () => {
{ symbol: 'F', start: 8, length: 4 },
]);
});
+
+ it('writes a chord into the middle of an existing region and preserves both sides', () => {
+ const chordTrack = getChordTrack();
+ chordTrack.setRegions([
+ new KGChordRegion('base', chordTrack.getId(), chordTrack.getTrackIndex(), 'Am', 0, 8),
+ ]);
+
+ const command = new WriteChordProgressionCommand([
+ { startBeat: 3, length: 2, symbol: 'C' },
+ ]);
+
+ command.execute();
+
+ expect((getChordTrack().getRegions() as KGChordRegion[]).map(region => ({
+ symbol: region.getSymbol(),
+ start: region.getStartFromBeat(),
+ length: region.getLength(),
+ }))).toEqual([
+ { symbol: 'Am', start: 0, length: 3 },
+ { symbol: 'C', start: 3, length: 2 },
+ { symbol: 'Am', start: 5, length: 3 },
+ ]);
+
+ command.undo();
+
+ expect((getChordTrack().getRegions() as KGChordRegion[]).map(region => ({
+ symbol: region.getSymbol(),
+ start: region.getStartFromBeat(),
+ length: region.getLength(),
+ }))).toEqual([
+ { symbol: 'Am', start: 0, length: 8 },
+ ]);
+ });
+
+ it('writes multiple non-contiguous chord spans while preserving untouched gaps', () => {
+ const chordTrack = getChordTrack();
+ chordTrack.setRegions([
+ new KGChordRegion('left', chordTrack.getId(), chordTrack.getTrackIndex(), 'Am', 0, 12),
+ ]);
+
+ const command = new WriteChordProgressionCommand([
+ { startBeat: 2, length: 2, symbol: 'C' },
+ { startBeat: 8, length: 2, symbol: 'G' },
+ ]);
+
+ command.execute();
+
+ expect((getChordTrack().getRegions() as KGChordRegion[]).map(region => ({
+ symbol: region.getSymbol(),
+ start: region.getStartFromBeat(),
+ length: region.getLength(),
+ }))).toEqual([
+ { symbol: 'Am', start: 0, length: 2 },
+ { symbol: 'C', start: 2, length: 2 },
+ { symbol: 'Am', start: 4, length: 4 },
+ { symbol: 'G', start: 8, length: 2 },
+ { symbol: 'Am', start: 10, length: 2 },
+ ]);
+ });
+
+ it('writes adjacent chord spans without introducing overlap', () => {
+ const chordTrack = getChordTrack();
+ chordTrack.setRegions([
+ new KGChordRegion('base', chordTrack.getId(), chordTrack.getTrackIndex(), 'Am', 0, 8),
+ ]);
+
+ const command = new WriteChordProgressionCommand([
+ { startBeat: 0, length: 4, symbol: 'C' },
+ { startBeat: 4, length: 4, symbol: 'F' },
+ ]);
+
+ command.execute();
+
+ expect((getChordTrack().getRegions() as KGChordRegion[]).map(region => ({
+ symbol: region.getSymbol(),
+ start: region.getStartFromBeat(),
+ length: region.getLength(),
+ }))).toEqual([
+ { symbol: 'C', start: 0, length: 4 },
+ { symbol: 'F', start: 4, length: 4 },
+ ]);
+ });
});
diff --git a/src/core/commands/global-region/GlobalMarkerCommands.test.ts b/src/core/commands/global-region/GlobalMarkerCommands.test.ts
index a321f70..3b6b2be 100644
--- a/src/core/commands/global-region/GlobalMarkerCommands.test.ts
+++ b/src/core/commands/global-region/GlobalMarkerCommands.test.ts
@@ -8,6 +8,7 @@ import { MoveGlobalRegionCommand } from './MoveGlobalRegionCommand';
import { ResizeGlobalRegionCommand } from './ResizeGlobalRegionCommand';
import { DeleteGlobalRegionCommand } from './DeleteGlobalRegionCommand';
import { UpdateGlobalRegionTextCommand } from './UpdateGlobalRegionTextCommand';
+import { WriteMarkersCommand } from './WriteMarkersCommand';
describe('global marker region commands', () => {
beforeEach(() => {
@@ -106,4 +107,63 @@ describe('global marker region commands', () => {
expect(markerTrack.getRegions()).toHaveLength(1);
expect(markerTrack.getRegions()[0].getId()).toBe('marker');
});
+
+ it('writes a marker into the middle of an existing region and preserves both sides', () => {
+ const markerTrack = getMarkerTrack();
+ markerTrack.setRegions([
+ new KGMarkerRegion('base', markerTrack.getId(), markerTrack.getTrackIndex(), 'Intro', 0, 8),
+ ]);
+
+ const command = new WriteMarkersCommand([
+ { startBeat: 3, length: 2, name: 'Hit' },
+ ]);
+
+ command.execute();
+
+ expect((getMarkerTrack().getRegions() as KGMarkerRegion[]).map(region => ({
+ name: region.getName(),
+ start: region.getStartFromBeat(),
+ length: region.getLength(),
+ }))).toEqual([
+ { name: 'Intro', start: 0, length: 3 },
+ { name: 'Hit', start: 3, length: 2 },
+ { name: 'Intro', start: 5, length: 3 },
+ ]);
+
+ command.undo();
+
+ expect((getMarkerTrack().getRegions() as KGMarkerRegion[]).map(region => ({
+ name: region.getName(),
+ start: region.getStartFromBeat(),
+ length: region.getLength(),
+ }))).toEqual([
+ { name: 'Intro', start: 0, length: 8 },
+ ]);
+ });
+
+ it('writes multiple non-contiguous marker spans while preserving untouched gaps', () => {
+ const markerTrack = getMarkerTrack();
+ markerTrack.setRegions([
+ new KGMarkerRegion('left', markerTrack.getId(), markerTrack.getTrackIndex(), 'Scene', 0, 12),
+ ]);
+
+ const command = new WriteMarkersCommand([
+ { startBeat: 2, length: 2, name: 'Rise' },
+ { startBeat: 8, length: 2, name: 'Drop' },
+ ]);
+
+ command.execute();
+
+ expect((getMarkerTrack().getRegions() as KGMarkerRegion[]).map(region => ({
+ name: region.getName(),
+ start: region.getStartFromBeat(),
+ length: region.getLength(),
+ }))).toEqual([
+ { name: 'Scene', start: 0, length: 2 },
+ { name: 'Rise', start: 2, length: 2 },
+ { name: 'Scene', start: 4, length: 4 },
+ { name: 'Drop', start: 8, length: 2 },
+ { name: 'Scene', start: 10, length: 2 },
+ ]);
+ });
});
diff --git a/src/core/commands/global-region/WriteChordProgressionCommand.ts b/src/core/commands/global-region/WriteChordProgressionCommand.ts
new file mode 100644
index 0000000..f1f1e33
--- /dev/null
+++ b/src/core/commands/global-region/WriteChordProgressionCommand.ts
@@ -0,0 +1,139 @@
+import { KGCommand } from '../KGCommand';
+import { KGCore } from '../../KGCore';
+import { GlobalTrackType } from '../../global-track';
+import { KGChordRegion } from '../../region/KGChordRegion';
+import { findGlobalTrackByType } from '../../../util/globalTrackUtil';
+import { generateUniqueId } from '../../../util/miscUtil';
+
+export interface WriteChordProgressionEntry {
+ startBeat: number;
+ length: number;
+ symbol: string;
+}
+
+function cloneChordRegion(region: KGChordRegion): KGChordRegion {
+ return new KGChordRegion(
+ region.getId(),
+ region.getTrackId(),
+ region.getTrackIndex(),
+ region.getSymbol(),
+ region.getStartFromBeat(),
+ region.getLength(),
+ );
+}
+
+function cloneChordRegions(regions: KGChordRegion[]): KGChordRegion[] {
+ return regions.map(cloneChordRegion);
+}
+
+export class WriteChordProgressionCommand extends KGCommand {
+ private readonly replacements: WriteChordProgressionEntry[];
+ private originalRegions: KGChordRegion[] | null = null;
+ private nextRegions: KGChordRegion[] | null = null;
+
+ constructor(replacements: WriteChordProgressionEntry[]) {
+ super();
+ this.replacements = replacements.map(replacement => ({
+ startBeat: replacement.startBeat,
+ length: replacement.length,
+ symbol: replacement.symbol,
+ }));
+ }
+
+ execute(): void {
+ const project = KGCore.instance().getCurrentProject();
+ const chordTrack = findGlobalTrackByType(project, GlobalTrackType.Chord);
+ if (!chordTrack) {
+ throw new Error('Chord global track not found');
+ }
+
+ if (this.nextRegions) {
+ chordTrack.setRegions(cloneChordRegions(this.nextRegions));
+ return;
+ }
+
+ const currentRegions = chordTrack.getRegions()
+ .filter((region): region is KGChordRegion => region instanceof KGChordRegion)
+ .sort((left, right) => left.getStartFromBeat() - right.getStartFromBeat());
+
+ const sortedReplacements = [...this.replacements].sort((left, right) => left.startBeat - right.startBeat);
+ this.originalRegions = cloneChordRegions(currentRegions);
+
+ const preservedRegions: KGChordRegion[] = [];
+ for (const region of currentRegions) {
+ const regionStart = region.getStartFromBeat();
+ const regionEnd = regionStart + region.getLength();
+ const overlappingReplacements = sortedReplacements.filter(replacement => (
+ replacement.startBeat < regionEnd
+ && replacement.startBeat + replacement.length > regionStart
+ ));
+
+ if (overlappingReplacements.length === 0) {
+ preservedRegions.push(cloneChordRegion(region));
+ continue;
+ }
+
+ let cursor = regionStart;
+ let fragmentIndex = 0;
+ for (const replacement of overlappingReplacements) {
+ const replacementStart = Math.max(regionStart, replacement.startBeat);
+ const replacementEnd = Math.min(regionEnd, replacement.startBeat + replacement.length);
+ if (replacementStart > cursor) {
+ preservedRegions.push(new KGChordRegion(
+ fragmentIndex === 0 ? region.getId() : generateUniqueId('KGChordRegion'),
+ region.getTrackId(),
+ region.getTrackIndex(),
+ region.getSymbol(),
+ cursor,
+ replacementStart - cursor,
+ ));
+ fragmentIndex += 1;
+ }
+ cursor = Math.max(cursor, replacementEnd);
+ }
+
+ if (cursor < regionEnd) {
+ preservedRegions.push(new KGChordRegion(
+ fragmentIndex === 0 ? region.getId() : generateUniqueId('KGChordRegion'),
+ region.getTrackId(),
+ region.getTrackIndex(),
+ region.getSymbol(),
+ cursor,
+ regionEnd - cursor,
+ ));
+ }
+ }
+
+ const replacementRegions = sortedReplacements.map(replacement => new KGChordRegion(
+ generateUniqueId('KGChordRegion'),
+ chordTrack.getId(),
+ chordTrack.getTrackIndex(),
+ replacement.symbol,
+ replacement.startBeat,
+ replacement.length,
+ ));
+
+ this.nextRegions = [...preservedRegions, ...replacementRegions]
+ .sort((left, right) => left.getStartFromBeat() - right.getStartFromBeat());
+
+ chordTrack.setRegions(cloneChordRegions(this.nextRegions));
+ }
+
+ undo(): void {
+ if (!this.originalRegions) {
+ throw new Error('Cannot undo chord progression write without original regions');
+ }
+
+ const project = KGCore.instance().getCurrentProject();
+ const chordTrack = findGlobalTrackByType(project, GlobalTrackType.Chord);
+ if (!chordTrack) {
+ throw new Error('Chord global track not found during undo');
+ }
+
+ chordTrack.setRegions(cloneChordRegions(this.originalRegions));
+ }
+
+ getDescription(): string {
+ return 'Write chord progression';
+ }
+}
diff --git a/src/core/commands/global-region/WriteKeySignatureTrackCommand.test.ts b/src/core/commands/global-region/WriteKeySignatureTrackCommand.test.ts
new file mode 100644
index 0000000..20fcfa7
--- /dev/null
+++ b/src/core/commands/global-region/WriteKeySignatureTrackCommand.test.ts
@@ -0,0 +1,85 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { KGCore } from '../../KGCore';
+import { KGProject } from '../../KGProject';
+import { GlobalTrackType } from '../../global-track';
+import { KGKeySignatureRegion } from '../../region/KGKeySignatureRegion';
+import { WriteKeySignatureTrackCommand } from './WriteKeySignatureTrackCommand';
+
+describe('WriteKeySignatureTrackCommand', () => {
+ beforeEach(() => {
+ const project = new KGProject('Signatures', 8, 0, 120);
+ const mockCore = KGCore.instance() as unknown as {
+ getCurrentProject: ReturnType
;
+ };
+
+ mockCore.getCurrentProject.mockReturnValue(project);
+ });
+
+ const getSignatureTrack = () => {
+ const signatureTrack = KGCore.instance().getCurrentProject().getGlobalTracks()
+ .find(track => track.getType() === GlobalTrackType.Signature);
+
+ if (!signatureTrack) {
+ throw new Error('Signature track missing in test setup');
+ }
+
+ return signatureTrack;
+ };
+
+ it('rebuilds the full signature track from explicit entries', () => {
+ const command = new WriteKeySignatureTrackCommand('C major', [
+ { startBeat: 8, keySignature: 'G major' },
+ { startBeat: 16, keySignature: 'D major' },
+ ]);
+ command.execute();
+
+ const regions = getSignatureTrack().getRegions() as KGKeySignatureRegion[];
+ expect(regions.map(region => ({
+ keySignature: region.getKeySignature(),
+ startBar: region.getStartBar(),
+ lengthBars: region.getLengthBars(),
+ }))).toEqual([
+ { keySignature: 'C major', startBar: 0, lengthBars: 2 },
+ { keySignature: 'G major', startBar: 2, lengthBars: 2 },
+ { keySignature: 'D major', startBar: 4, lengthBars: 4 },
+ ]);
+ });
+
+ it('replaces an existing multi-region track and restores it on undo', () => {
+ const signatureTrack = getSignatureTrack();
+ signatureTrack.setRegions([
+ new KGKeySignatureRegion('existing-1', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'F major', 0, 3, 4),
+ new KGKeySignatureRegion('existing-2', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'Bb major', 3, 5, 4),
+ ]);
+
+ const command = new WriteKeySignatureTrackCommand('A minor', [
+ { startBeat: 12, keySignature: 'E minor' },
+ ]);
+ command.execute();
+
+ expect((signatureTrack.getRegions() as KGKeySignatureRegion[]).map(region => region.getKeySignature()))
+ .toEqual(['A minor', 'E minor']);
+
+ command.undo();
+
+ expect((signatureTrack.getRegions() as KGKeySignatureRegion[]).map(region => ({
+ keySignature: region.getKeySignature(),
+ startBar: region.getStartBar(),
+ lengthBars: region.getLengthBars(),
+ }))).toEqual([
+ { keySignature: 'F major', startBar: 0, lengthBars: 3 },
+ { keySignature: 'Bb major', startBar: 3, lengthBars: 5 },
+ ]);
+ });
+
+ it('uses only the base key signature when no explicit entries are provided', () => {
+ const command = new WriteKeySignatureTrackCommand('E minor', []);
+ command.execute();
+
+ const regions = getSignatureTrack().getRegions() as KGKeySignatureRegion[];
+ expect(regions).toHaveLength(1);
+ expect(regions[0].getKeySignature()).toBe('E minor');
+ expect(regions[0].getStartBar()).toBe(0);
+ expect(regions[0].getLengthBars()).toBe(8);
+ });
+});
diff --git a/src/core/commands/global-region/WriteKeySignatureTrackCommand.ts b/src/core/commands/global-region/WriteKeySignatureTrackCommand.ts
new file mode 100644
index 0000000..ef9318f
--- /dev/null
+++ b/src/core/commands/global-region/WriteKeySignatureTrackCommand.ts
@@ -0,0 +1,123 @@
+import { KGCommand } from '../KGCommand';
+import { KGCore } from '../../KGCore';
+import type { KeySignature } from '../../KGProject';
+import { GlobalTrackType } from '../../global-track';
+import { KGKeySignatureRegion } from '../../region/KGKeySignatureRegion';
+import {
+ cloneKeySignatureRegions,
+ findGlobalTrackByType,
+ getSongEndBar,
+ getSortedKeySignatureRegions,
+} from '../../../util/globalTrackUtil';
+import { generateUniqueId } from '../../../util/miscUtil';
+
+export interface WriteKeySignatureEntry {
+ startBeat: number;
+ keySignature: KeySignature;
+}
+
+function cloneRegions(regions: KGKeySignatureRegion[], beatsPerBar: number): KGKeySignatureRegion[] {
+ return cloneKeySignatureRegions(regions, beatsPerBar);
+}
+
+export class WriteKeySignatureTrackCommand extends KGCommand {
+ private readonly baseKeySignature: KeySignature;
+ private readonly replacements: WriteKeySignatureEntry[];
+ private previousRegions: KGKeySignatureRegion[] | null = null;
+ private nextRegions: KGKeySignatureRegion[] | null = null;
+
+ constructor(baseKeySignature: KeySignature, replacements: WriteKeySignatureEntry[]) {
+ super();
+ this.baseKeySignature = baseKeySignature;
+ this.replacements = replacements.map(replacement => ({
+ startBeat: replacement.startBeat,
+ keySignature: replacement.keySignature,
+ }));
+ }
+
+ execute(): void {
+ const project = KGCore.instance().getCurrentProject();
+ const beatsPerBar = project.getTimeSignature().numerator;
+ const track = findGlobalTrackByType(project, GlobalTrackType.Signature);
+ if (!track) {
+ throw new Error('Signature global track not found');
+ }
+
+ if (this.nextRegions) {
+ track.setRegions(cloneRegions(this.nextRegions, beatsPerBar));
+ return;
+ }
+
+ const currentRegions = getSortedKeySignatureRegions(track, beatsPerBar);
+ this.previousRegions = cloneRegions(currentRegions, beatsPerBar);
+
+ const songEndBar = getSongEndBar(project);
+ if (songEndBar <= 0) {
+ this.nextRegions = [];
+ track.setRegions([]);
+ return;
+ }
+
+ const normalizedReplacements = this.replacements
+ .map(replacement => ({
+ startBar: Math.floor(replacement.startBeat / beatsPerBar),
+ keySignature: replacement.keySignature,
+ }))
+ .sort((left, right) => left.startBar - right.startBar);
+
+ const nextRegions: KGKeySignatureRegion[] = [];
+ let currentStartBar = 0;
+ let currentKeySignature = this.baseKeySignature;
+
+ for (const replacement of normalizedReplacements) {
+ if (replacement.startBar > currentStartBar) {
+ nextRegions.push(new KGKeySignatureRegion(
+ generateUniqueId('KGKeySignatureRegion'),
+ track.getId(),
+ track.getTrackIndex(),
+ currentKeySignature,
+ currentStartBar,
+ replacement.startBar - currentStartBar,
+ beatsPerBar,
+ ));
+ }
+
+ currentStartBar = replacement.startBar;
+ currentKeySignature = replacement.keySignature;
+ }
+
+ if (currentStartBar < songEndBar) {
+ nextRegions.push(new KGKeySignatureRegion(
+ generateUniqueId('KGKeySignatureRegion'),
+ track.getId(),
+ track.getTrackIndex(),
+ currentKeySignature,
+ currentStartBar,
+ songEndBar - currentStartBar,
+ beatsPerBar,
+ ));
+ }
+
+ this.nextRegions = nextRegions;
+ track.setRegions(cloneRegions(this.nextRegions, beatsPerBar));
+ }
+
+ undo(): void {
+ if (!this.previousRegions) {
+ throw new Error('Cannot undo key signature write without original regions');
+ }
+
+ const project = KGCore.instance().getCurrentProject();
+ const beatsPerBar = project.getTimeSignature().numerator;
+ const track = findGlobalTrackByType(project, GlobalTrackType.Signature);
+ if (!track) {
+ throw new Error('Signature global track not found during undo');
+ }
+
+ track.setRegions(cloneRegions(this.previousRegions, beatsPerBar));
+ }
+
+ getDescription(): string {
+ return 'Write key signature track';
+ }
+}
diff --git a/src/core/commands/global-region/WriteMarkersCommand.ts b/src/core/commands/global-region/WriteMarkersCommand.ts
new file mode 100644
index 0000000..0e3b87e
--- /dev/null
+++ b/src/core/commands/global-region/WriteMarkersCommand.ts
@@ -0,0 +1,139 @@
+import { KGCommand } from '../KGCommand';
+import { KGCore } from '../../KGCore';
+import { GlobalTrackType } from '../../global-track';
+import { KGMarkerRegion } from '../../region/KGMarkerRegion';
+import { findGlobalTrackByType } from '../../../util/globalTrackUtil';
+import { generateUniqueId } from '../../../util/miscUtil';
+
+export interface WriteMarkerEntry {
+ startBeat: number;
+ length: number;
+ name: string;
+}
+
+function cloneMarkerRegion(region: KGMarkerRegion): KGMarkerRegion {
+ return new KGMarkerRegion(
+ region.getId(),
+ region.getTrackId(),
+ region.getTrackIndex(),
+ region.getName(),
+ region.getStartFromBeat(),
+ region.getLength(),
+ );
+}
+
+function cloneMarkerRegions(regions: KGMarkerRegion[]): KGMarkerRegion[] {
+ return regions.map(cloneMarkerRegion);
+}
+
+export class WriteMarkersCommand extends KGCommand {
+ private readonly replacements: WriteMarkerEntry[];
+ private originalRegions: KGMarkerRegion[] | null = null;
+ private nextRegions: KGMarkerRegion[] | null = null;
+
+ constructor(replacements: WriteMarkerEntry[]) {
+ super();
+ this.replacements = replacements.map(replacement => ({
+ startBeat: replacement.startBeat,
+ length: replacement.length,
+ name: replacement.name,
+ }));
+ }
+
+ execute(): void {
+ const project = KGCore.instance().getCurrentProject();
+ const markerTrack = findGlobalTrackByType(project, GlobalTrackType.Marker);
+ if (!markerTrack) {
+ throw new Error('Marker global track not found');
+ }
+
+ if (this.nextRegions) {
+ markerTrack.setRegions(cloneMarkerRegions(this.nextRegions));
+ return;
+ }
+
+ const currentRegions = markerTrack.getRegions()
+ .filter((region): region is KGMarkerRegion => region instanceof KGMarkerRegion)
+ .sort((left, right) => left.getStartFromBeat() - right.getStartFromBeat());
+
+ const sortedReplacements = [...this.replacements].sort((left, right) => left.startBeat - right.startBeat);
+ this.originalRegions = cloneMarkerRegions(currentRegions);
+
+ const preservedRegions: KGMarkerRegion[] = [];
+ for (const region of currentRegions) {
+ const regionStart = region.getStartFromBeat();
+ const regionEnd = regionStart + region.getLength();
+ const overlappingReplacements = sortedReplacements.filter(replacement => (
+ replacement.startBeat < regionEnd
+ && replacement.startBeat + replacement.length > regionStart
+ ));
+
+ if (overlappingReplacements.length === 0) {
+ preservedRegions.push(cloneMarkerRegion(region));
+ continue;
+ }
+
+ let cursor = regionStart;
+ let fragmentIndex = 0;
+ for (const replacement of overlappingReplacements) {
+ const replacementStart = Math.max(regionStart, replacement.startBeat);
+ const replacementEnd = Math.min(regionEnd, replacement.startBeat + replacement.length);
+ if (replacementStart > cursor) {
+ preservedRegions.push(new KGMarkerRegion(
+ fragmentIndex === 0 ? region.getId() : generateUniqueId('KGMarkerRegion'),
+ region.getTrackId(),
+ region.getTrackIndex(),
+ region.getName(),
+ cursor,
+ replacementStart - cursor,
+ ));
+ fragmentIndex += 1;
+ }
+ cursor = Math.max(cursor, replacementEnd);
+ }
+
+ if (cursor < regionEnd) {
+ preservedRegions.push(new KGMarkerRegion(
+ fragmentIndex === 0 ? region.getId() : generateUniqueId('KGMarkerRegion'),
+ region.getTrackId(),
+ region.getTrackIndex(),
+ region.getName(),
+ cursor,
+ regionEnd - cursor,
+ ));
+ }
+ }
+
+ const replacementRegions = sortedReplacements.map(replacement => new KGMarkerRegion(
+ generateUniqueId('KGMarkerRegion'),
+ markerTrack.getId(),
+ markerTrack.getTrackIndex(),
+ replacement.name,
+ replacement.startBeat,
+ replacement.length,
+ ));
+
+ this.nextRegions = [...preservedRegions, ...replacementRegions]
+ .sort((left, right) => left.getStartFromBeat() - right.getStartFromBeat());
+
+ markerTrack.setRegions(cloneMarkerRegions(this.nextRegions));
+ }
+
+ undo(): void {
+ if (!this.originalRegions) {
+ throw new Error('Cannot undo marker write without original regions');
+ }
+
+ const project = KGCore.instance().getCurrentProject();
+ const markerTrack = findGlobalTrackByType(project, GlobalTrackType.Marker);
+ if (!markerTrack) {
+ throw new Error('Marker global track not found during undo');
+ }
+
+ markerTrack.setRegions(cloneMarkerRegions(this.originalRegions));
+ }
+
+ getDescription(): string {
+ return 'Write markers';
+ }
+}
diff --git a/src/core/commands/global-region/WriteTempoTrackCommand.test.ts b/src/core/commands/global-region/WriteTempoTrackCommand.test.ts
new file mode 100644
index 0000000..6492279
--- /dev/null
+++ b/src/core/commands/global-region/WriteTempoTrackCommand.test.ts
@@ -0,0 +1,93 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { KGCore } from '../../KGCore';
+import { KGProject } from '../../KGProject';
+import { GlobalTrackType } from '../../global-track';
+import { KGTempoRegion } from '../../region/KGTempoRegion';
+import { WriteTempoTrackCommand } from './WriteTempoTrackCommand';
+
+describe('WriteTempoTrackCommand', () => {
+ beforeEach(() => {
+ const project = new KGProject('Tempo', 8, 0, 120);
+ const mockCore = KGCore.instance() as unknown as {
+ getCurrentProject: ReturnType;
+ };
+
+ mockCore.getCurrentProject.mockReturnValue(project);
+ });
+
+ const getTempoTrack = () => {
+ const tempoTrack = KGCore.instance().getCurrentProject().getGlobalTracks()
+ .find(track => track.getType() === GlobalTrackType.Tempo);
+
+ if (!tempoTrack) {
+ throw new Error('Tempo track missing in test setup');
+ }
+
+ return tempoTrack;
+ };
+
+ it('writes base BPM only by clearing explicit tempo regions and updating project BPM', () => {
+ const tempoTrack = getTempoTrack();
+ tempoTrack.setRegions([
+ new KGTempoRegion('existing-1', tempoTrack.getId(), tempoTrack.getTrackIndex(), 120, 0, 3, 4),
+ new KGTempoRegion('existing-2', tempoTrack.getId(), tempoTrack.getTrackIndex(), 140, 3, 5, 4),
+ ]);
+
+ const command = new WriteTempoTrackCommand(96, []);
+ command.execute();
+
+ expect(KGCore.instance().getCurrentProject().getBpm()).toBe(96);
+ expect(tempoTrack.getRegions()).toEqual([]);
+ });
+
+ it('rebuilds explicit tempo regions into a gapless full-song plan', () => {
+ const command = new WriteTempoTrackCommand(100, [
+ { startBeat: 8, bpm: 120 },
+ { startBeat: 16, bpm: 140 },
+ ]);
+ command.execute();
+
+ const project = KGCore.instance().getCurrentProject();
+ const regions = getTempoTrack().getRegions() as KGTempoRegion[];
+
+ expect(project.getBpm()).toBe(100);
+ expect(regions.map(region => ({
+ bpm: region.getBpm(),
+ startBar: region.getStartBar(),
+ lengthBars: region.getLengthBars(),
+ }))).toEqual([
+ { bpm: 100, startBar: 0, lengthBars: 2 },
+ { bpm: 120, startBar: 2, lengthBars: 2 },
+ { bpm: 140, startBar: 4, lengthBars: 4 },
+ ]);
+ });
+
+ it('restores both project BPM and prior tempo regions on undo', () => {
+ const project = KGCore.instance().getCurrentProject();
+ const tempoTrack = getTempoTrack();
+ tempoTrack.setRegions([
+ new KGTempoRegion('existing-1', tempoTrack.getId(), tempoTrack.getTrackIndex(), 120, 0, 2, 4),
+ new KGTempoRegion('existing-2', tempoTrack.getId(), tempoTrack.getTrackIndex(), 128, 2, 6, 4),
+ ]);
+
+ const command = new WriteTempoTrackCommand(88, [
+ { startBeat: 12, bpm: 144 },
+ ]);
+ command.execute();
+
+ expect(project.getBpm()).toBe(88);
+ expect((tempoTrack.getRegions() as KGTempoRegion[]).map(region => region.getBpm())).toEqual([88, 144]);
+
+ command.undo();
+
+ expect(project.getBpm()).toBe(120);
+ expect((tempoTrack.getRegions() as KGTempoRegion[]).map(region => ({
+ bpm: region.getBpm(),
+ startBar: region.getStartBar(),
+ lengthBars: region.getLengthBars(),
+ }))).toEqual([
+ { bpm: 120, startBar: 0, lengthBars: 2 },
+ { bpm: 128, startBar: 2, lengthBars: 6 },
+ ]);
+ });
+});
diff --git a/src/core/commands/global-region/WriteTempoTrackCommand.ts b/src/core/commands/global-region/WriteTempoTrackCommand.ts
new file mode 100644
index 0000000..972b026
--- /dev/null
+++ b/src/core/commands/global-region/WriteTempoTrackCommand.ts
@@ -0,0 +1,143 @@
+import { KGCommand } from '../KGCommand';
+import { KGCore } from '../../KGCore';
+import { GlobalTrackType } from '../../global-track';
+import { KGTempoRegion } from '../../region/KGTempoRegion';
+import {
+ cloneTempoRegions,
+ findGlobalTrackByType,
+ getSongEndBar,
+ getSortedTempoRegions,
+} from '../../../util/globalTrackUtil';
+import { generateUniqueId } from '../../../util/miscUtil';
+
+export interface WriteTempoEntry {
+ startBeat: number;
+ bpm: number;
+}
+
+function cloneRegions(regions: KGTempoRegion[], beatsPerBar: number): KGTempoRegion[] {
+ return cloneTempoRegions(regions, beatsPerBar);
+}
+
+export class WriteTempoTrackCommand extends KGCommand {
+ private readonly baseBpm: number;
+ private readonly replacements: WriteTempoEntry[];
+ private previousRegions: KGTempoRegion[] | null = null;
+ private previousProjectBpm: number | null = null;
+ private nextRegions: KGTempoRegion[] | null = null;
+
+ constructor(baseBpm: number, replacements: WriteTempoEntry[]) {
+ super();
+ this.baseBpm = baseBpm;
+ this.replacements = replacements.map(replacement => ({
+ startBeat: replacement.startBeat,
+ bpm: replacement.bpm,
+ }));
+ }
+
+ execute(): void {
+ const project = KGCore.instance().getCurrentProject();
+ const beatsPerBar = project.getTimeSignature().numerator;
+ const track = findGlobalTrackByType(project, GlobalTrackType.Tempo);
+ if (!track) {
+ throw new Error('Tempo global track not found');
+ }
+
+ if (this.nextRegions) {
+ project.setBpm(this.baseBpm);
+ track.setRegions(cloneRegions(this.nextRegions, beatsPerBar));
+ return;
+ }
+
+ const currentRegions = getSortedTempoRegions(track, beatsPerBar);
+ this.previousProjectBpm = project.getBpm();
+ this.previousRegions = cloneRegions(currentRegions, beatsPerBar);
+ project.setBpm(this.baseBpm);
+
+ if (this.replacements.length === 0) {
+ this.nextRegions = [];
+ track.setRegions([]);
+ return;
+ }
+
+ const songEndBar = getSongEndBar(project);
+ if (songEndBar <= 0) {
+ this.nextRegions = [];
+ track.setRegions([]);
+ return;
+ }
+
+ const normalizedReplacements = this.replacements
+ .map(replacement => ({
+ startBar: Math.floor(replacement.startBeat / beatsPerBar),
+ bpm: replacement.bpm,
+ }))
+ .sort((left, right) => left.startBar - right.startBar);
+
+ for (let index = 1; index < normalizedReplacements.length; index += 1) {
+ const previous = normalizedReplacements[index - 1];
+ const current = normalizedReplacements[index];
+ if (current.startBar <= previous.startBar) {
+ throw new Error(
+ `Tempo entry ${index + 1} overlaps with or collapses into entry ${index} after bar alignment. Entry ${index} normalizes to bar ${previous.startBar + 1}, and entry ${index + 1} normalizes to bar ${current.startBar + 1}.`,
+ );
+ }
+ }
+
+ const nextRegions: KGTempoRegion[] = [];
+ let currentStartBar = 0;
+ let currentBpm = this.baseBpm;
+
+ for (const replacement of normalizedReplacements) {
+ if (replacement.startBar > currentStartBar) {
+ nextRegions.push(new KGTempoRegion(
+ generateUniqueId('KGTempoRegion'),
+ track.getId(),
+ track.getTrackIndex(),
+ currentBpm,
+ currentStartBar,
+ replacement.startBar - currentStartBar,
+ beatsPerBar,
+ ));
+ }
+
+ currentStartBar = replacement.startBar;
+ currentBpm = replacement.bpm;
+ }
+
+ if (currentStartBar < songEndBar) {
+ nextRegions.push(new KGTempoRegion(
+ generateUniqueId('KGTempoRegion'),
+ track.getId(),
+ track.getTrackIndex(),
+ currentBpm,
+ currentStartBar,
+ songEndBar - currentStartBar,
+ beatsPerBar,
+ ));
+ }
+
+ this.nextRegions = nextRegions;
+ track.setRegions(cloneRegions(this.nextRegions, beatsPerBar));
+ }
+
+ undo(): void {
+ if (!this.previousRegions || this.previousProjectBpm === null) {
+ throw new Error('Cannot undo tempo write without original state');
+ }
+
+ const project = KGCore.instance().getCurrentProject();
+ const beatsPerBar = project.getTimeSignature().numerator;
+ const track = findGlobalTrackByType(project, GlobalTrackType.Tempo);
+ if (!track) {
+ throw new Error('Tempo global track not found during undo');
+ }
+
+ project.setBpm(this.previousProjectBpm);
+ track.setRegions(cloneRegions(this.previousRegions, beatsPerBar));
+ }
+
+ getDescription(): string {
+ return 'Write tempo track';
+ }
+}
diff --git a/src/core/commands/index.ts b/src/core/commands/index.ts
index c55011b..bb02765 100644
--- a/src/core/commands/index.ts
+++ b/src/core/commands/index.ts
@@ -43,6 +43,22 @@ export {
ReplaceChordRegionsInRangeCommand,
type ChordRegionReplacementData,
} from './global-region/ReplaceChordRegionsInRangeCommand';
+export {
+ WriteChordProgressionCommand,
+ type WriteChordProgressionEntry,
+} from './global-region/WriteChordProgressionCommand';
+export {
+ WriteMarkersCommand,
+ type WriteMarkerEntry,
+} from './global-region/WriteMarkersCommand';
+export {
+ WriteKeySignatureTrackCommand,
+ type WriteKeySignatureEntry,
+} from './global-region/WriteKeySignatureTrackCommand';
+export {
+ WriteTempoTrackCommand,
+ type WriteTempoEntry,
+} from './global-region/WriteTempoTrackCommand';
export { CreateKeySignatureRegionCommand } from './global-region/CreateKeySignatureRegionCommand';
export { CreateTempoRegionCommand } from './global-region/CreateTempoRegionCommand';
export { MoveGlobalRegionCommand } from './global-region/MoveGlobalRegionCommand';
diff --git a/src/core/config-upgrader/KGConfigUpgrader.ts b/src/core/config-upgrader/KGConfigUpgrader.ts
index 96acc28..06a9c2d 100644
--- a/src/core/config-upgrader/KGConfigUpgrader.ts
+++ b/src/core/config-upgrader/KGConfigUpgrader.ts
@@ -4,6 +4,7 @@ import { upgradeConfigToV1 } from './upgradeConfigToV1';
import { upgradeConfigToV2 } from './upgradeConfigToV2';
import { upgradeConfigToV3 } from './upgradeConfigToV3';
import { upgradeConfigToV4 } from './upgradeConfigToV4';
+import { upgradeConfigToV5 } from './upgradeConfigToV5';
/**
* KGConfigUpgrader — Orchestrates app-level migrations (e.g., storage backend changes).
@@ -48,6 +49,10 @@ export class KGConfigUpgrader {
await upgradeConfigToV4();
break;
}
+ case 5: {
+ await upgradeConfigToV5();
+ break;
+ }
default: {
throw new Error(`No config upgrader found for version ${nextVersion}`);
}
diff --git a/src/core/config-upgrader/upgradeConfigToV5.test.ts b/src/core/config-upgrader/upgradeConfigToV5.test.ts
new file mode 100644
index 0000000..abcd86b
--- /dev/null
+++ b/src/core/config-upgrader/upgradeConfigToV5.test.ts
@@ -0,0 +1,52 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const getRawMock = vi.fn();
+const saveRawMock = vi.fn();
+
+vi.mock('../io/KGConfigStorage', () => ({
+ KGConfigStorage: {
+ getInstance: vi.fn(() => ({
+ getRaw: getRawMock,
+ saveRaw: saveRawMock,
+ })),
+ },
+}));
+
+import { upgradeConfigToV5 } from './upgradeConfigToV5';
+
+describe('upgradeConfigToV5', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('defaults general.agent_mode to regular when the key is missing', async () => {
+ const config: Record = {
+ general: {
+ llm_provider: 'openai',
+ },
+ };
+ getRawMock.mockResolvedValue(config);
+
+ await upgradeConfigToV5();
+
+ expect(config.general).toEqual({
+ llm_provider: 'openai',
+ agent_mode: 'regular',
+ });
+ expect(saveRawMock).toHaveBeenCalledWith('userConfig', config);
+ });
+
+ it('preserves an explicit general.agent_mode value', async () => {
+ const config: Record = {
+ general: {
+ agent_mode: 'efficient',
+ },
+ };
+ getRawMock.mockResolvedValue(config);
+
+ await upgradeConfigToV5();
+
+ expect(saveRawMock).not.toHaveBeenCalled();
+ expect((config.general as Record).agent_mode).toBe('efficient');
+ });
+});
diff --git a/src/core/config-upgrader/upgradeConfigToV5.ts b/src/core/config-upgrader/upgradeConfigToV5.ts
new file mode 100644
index 0000000..07746be
--- /dev/null
+++ b/src/core/config-upgrader/upgradeConfigToV5.ts
@@ -0,0 +1,30 @@
+import { KGConfigStorage } from '../io/KGConfigStorage';
+
+const CONFIG_KEY = 'userConfig';
+
+export async function upgradeConfigToV5(): Promise {
+ const storage = KGConfigStorage.getInstance();
+ const rawConfig = await storage.getRaw(CONFIG_KEY);
+ if (!rawConfig || typeof rawConfig !== 'object') {
+ return;
+ }
+
+ const config = rawConfig as Record;
+ const general = config.general;
+
+ if (!general || typeof general !== 'object') {
+ config.general = {
+ agent_mode: 'regular',
+ };
+ await storage.saveRaw(CONFIG_KEY, config);
+ return;
+ }
+
+ const generalRecord = general as Record;
+ if ('agent_mode' in generalRecord) {
+ return;
+ }
+
+ generalRecord.agent_mode = 'regular';
+ await storage.saveRaw(CONFIG_KEY, config);
+}
diff --git a/src/core/config/ConfigManager.ts b/src/core/config/ConfigManager.ts
index d7a45b2..86571b7 100644
--- a/src/core/config/ConfigManager.ts
+++ b/src/core/config/ConfigManager.ts
@@ -1,6 +1,7 @@
import type { ChordGuideCustomConfig } from '../ChordGuideTypes';
import { KGConfigStorage } from '../io/KGConfigStorage';
import type { LanguageSetting } from '../../i18n/types';
+import type { AgentMode } from '../../util/agentMode';
/**
* Application configuration interface
@@ -8,8 +9,10 @@ import type { LanguageSetting } from '../../i18n/types';
interface AppConfig {
general: {
language: LanguageSetting;
+ agent_mode: AgentMode;
llm_provider: 'local_browser' | 'openai' | 'gemini' | 'claude' | 'claude_openrouter' | 'openai_compatible';
persist_api_keys_non_localhost: boolean;
+ auto_compact_threshold_percent: 80 | 90 | 95;
local_browser: {
context_length: 32768 | 65536 | 131072;
model_url: string;
@@ -207,8 +210,10 @@ export class ConfigManager {
this.defaultConfig = {
general: {
language: 'auto',
+ agent_mode: 'regular',
llm_provider: 'local_browser',
persist_api_keys_non_localhost: false,
+ auto_compact_threshold_percent: 90,
openai: {
api_key: '',
flex: false,
diff --git a/src/core/io/KGConversationStorage.test.ts b/src/core/io/KGConversationStorage.test.ts
new file mode 100644
index 0000000..c934e52
--- /dev/null
+++ b/src/core/io/KGConversationStorage.test.ts
@@ -0,0 +1,144 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { KGConversationStorage } from './KGConversationStorage';
+
+class MockFileSystemWritableFileStream {
+ public data = '';
+ async write(content: string) { this.data = content; }
+ async close() {}
+}
+
+class MockFileSystemFileHandle {
+ kind = 'file' as const;
+ constructor(public name: string, private _content: string = '') {}
+ async getFile() {
+ return { text: () => Promise.resolve(this._content) };
+ }
+ async createWritable() {
+ const stream = new MockFileSystemWritableFileStream();
+ const origClose = stream.close.bind(stream);
+ stream.close = async () => {
+ this._content = stream.data;
+ await origClose();
+ };
+ return stream;
+ }
+}
+
+class MockFileSystemDirectoryHandle {
+ kind = 'directory' as const;
+ private entries = new Map();
+
+ constructor(public name: string) {}
+
+ async getDirectoryHandle(name: string, options?: { create?: boolean }): Promise {
+ let entry = this.entries.get(name);
+ if (!entry || entry.kind !== 'directory') {
+ if (options?.create) {
+ entry = new MockFileSystemDirectoryHandle(name);
+ this.entries.set(name, entry);
+ } else {
+ throw new DOMException(`Directory "${name}" not found`, 'NotFoundError');
+ }
+ }
+ return entry as MockFileSystemDirectoryHandle;
+ }
+
+ async getFileHandle(name: string, options?: { create?: boolean }): Promise {
+ let entry = this.entries.get(name);
+ if (!entry || entry.kind !== 'file') {
+ if (options?.create) {
+ entry = new MockFileSystemFileHandle(name);
+ this.entries.set(name, entry);
+ } else {
+ throw new DOMException(`File "${name}" not found`, 'NotFoundError');
+ }
+ }
+ return entry as MockFileSystemFileHandle;
+ }
+
+ async removeEntry(name: string): Promise {
+ this.entries.delete(name);
+ }
+
+ async *values(): AsyncIterableIterator {
+ for (const entry of this.entries.values()) {
+ yield entry;
+ }
+ }
+}
+
+const mockRoot = new MockFileSystemDirectoryHandle('root');
+
+vi.stubGlobal('navigator', {
+ ...navigator,
+ storage: {
+ getDirectory: vi.fn(() => Promise.resolve(mockRoot)),
+ persist: vi.fn(() => Promise.resolve(true)),
+ },
+});
+
+describe('KGConversationStorage', () => {
+ let storage: KGConversationStorage;
+
+ beforeEach(async () => {
+ ;(KGConversationStorage as unknown as { _instance: undefined })._instance = undefined;
+ const entries = (mockRoot as unknown as { entries: Map }).entries;
+ entries.clear();
+
+ storage = KGConversationStorage.getInstance();
+ await storage.initialize();
+ });
+
+ it('saves, lists, and loads project-scoped conversations sorted by last turn time', async () => {
+ await storage.saveConversation('Song A', {
+ version: 1,
+ conversationId: 'conv_1',
+ continuationState: {
+ messages: [{ id: 'm1', role: 'user', content: 'first', timestamp: 1 }],
+ todos: [],
+ },
+ fullHistory: {
+ messages: [{ id: 'm1', role: 'user', content: 'first', timestamp: 1 }],
+ },
+ displayTranscript: [{ id: 'display_1', role: 'user', content: 'first' }],
+ }, {
+ conversationId: 'conv_1',
+ title: 'First',
+ createdAt: 1,
+ updatedAt: 1,
+ lastTurnAt: 1,
+ messageCount: 1,
+ preview: 'first',
+ });
+
+ await storage.saveConversation('Song A', {
+ version: 1,
+ conversationId: 'conv_2',
+ continuationState: {
+ messages: [{ id: 'm2', role: 'user', content: 'second', timestamp: 2 }],
+ todos: [],
+ },
+ fullHistory: {
+ messages: [{ id: 'm2', role: 'user', content: 'second', timestamp: 2 }],
+ },
+ displayTranscript: [{ id: 'display_2', role: 'user', content: 'second' }],
+ }, {
+ conversationId: 'conv_2',
+ title: 'Second',
+ createdAt: 2,
+ updatedAt: 2,
+ lastTurnAt: 2,
+ messageCount: 1,
+ preview: 'second',
+ });
+
+ const listed = await storage.listConversations('Song A');
+ expect(listed.map(item => item.conversationId)).toEqual(['conv_2', 'conv_1']);
+
+ const loaded = await storage.loadConversation('Song A', 'conv_1');
+ expect(loaded?.document.conversationId).toBe('conv_1');
+ expect(loaded?.document.displayTranscript).toEqual([
+ { id: 'display_1', role: 'user', content: 'first' },
+ ]);
+ });
+});
diff --git a/src/core/io/KGConversationStorage.ts b/src/core/io/KGConversationStorage.ts
new file mode 100644
index 0000000..7f06603
--- /dev/null
+++ b/src/core/io/KGConversationStorage.ts
@@ -0,0 +1,155 @@
+import { OPFS_CONSTANTS } from '../../constants/coreConstants';
+import type { SavedConversationDocument, SavedConversationMeta } from '../../types/conversationTypes';
+
+export class KGConversationStorage {
+ private static _instance: KGConversationStorage;
+ private rootDirHandle: FileSystemDirectoryHandle | null = null;
+ private projectsDirHandle: FileSystemDirectoryHandle | null = null;
+ private _initialized = false;
+
+ private constructor() {}
+
+ public static getInstance(): KGConversationStorage {
+ if (!KGConversationStorage._instance) {
+ KGConversationStorage._instance = new KGConversationStorage();
+ }
+ return KGConversationStorage._instance;
+ }
+
+ public async initialize(): Promise {
+ if (this._initialized) return;
+
+ if (!navigator.storage?.getDirectory) {
+ throw new Error(
+ 'OPFS is unavailable. K.G.Studio requires a secure context (HTTPS or localhost). ' +
+ 'Access via https:// or use localhost/127.0.0.1 instead of an IP address.',
+ );
+ }
+
+ this.rootDirHandle = await navigator.storage.getDirectory();
+ this.projectsDirHandle = await this.rootDirHandle.getDirectoryHandle(
+ OPFS_CONSTANTS.ROOT_DIR,
+ { create: true },
+ );
+ this._initialized = true;
+ }
+
+ public async saveConversation(
+ projectName: string,
+ document: SavedConversationDocument,
+ meta: SavedConversationMeta,
+ ): Promise {
+ this.ensureInitialized();
+
+ const conversationDir = await this.getConversationDir(projectName, document.conversationId, true);
+ await this.writeFile(
+ conversationDir,
+ OPFS_CONSTANTS.CONVERSATION_FILE,
+ JSON.stringify(document, null, 2),
+ );
+ await this.writeFile(
+ conversationDir,
+ OPFS_CONSTANTS.METADATA_FILE,
+ JSON.stringify(meta, null, 2),
+ );
+ }
+
+ public async loadConversation(
+ projectName: string,
+ conversationId: string,
+ ): Promise<{ document: SavedConversationDocument; meta: SavedConversationMeta } | null> {
+ this.ensureInitialized();
+
+ try {
+ const conversationDir = await this.getConversationDir(projectName, conversationId, false);
+ const [documentRaw, metaRaw] = await Promise.all([
+ this.readFile(conversationDir, OPFS_CONSTANTS.CONVERSATION_FILE),
+ this.readFile(conversationDir, OPFS_CONSTANTS.METADATA_FILE),
+ ]);
+ return {
+ document: JSON.parse(documentRaw) as SavedConversationDocument,
+ meta: JSON.parse(metaRaw) as SavedConversationMeta,
+ };
+ } catch (error) {
+ console.error(`Error loading conversation "${conversationId}" for project "${projectName}":`, error);
+ return null;
+ }
+ }
+
+ public async listConversations(projectName: string): Promise {
+ this.ensureInitialized();
+
+ let conversationsDir: FileSystemDirectoryHandle;
+ try {
+ conversationsDir = await this.getConversationsDir(projectName, false);
+ } catch {
+ return [];
+ }
+
+ const metas: SavedConversationMeta[] = [];
+ for await (const entry of conversationsDir.values()) {
+ if (entry.kind !== 'directory') {
+ continue;
+ }
+
+ try {
+ const conversationDir = await conversationsDir.getDirectoryHandle(entry.name);
+ const metaRaw = await this.readFile(conversationDir, OPFS_CONSTANTS.METADATA_FILE);
+ metas.push(JSON.parse(metaRaw) as SavedConversationMeta);
+ } catch {
+ // Skip malformed entries.
+ }
+ }
+
+ return metas.sort((a, b) => b.lastTurnAt - a.lastTurnAt);
+ }
+
+ public async deleteConversation(projectName: string, conversationId: string): Promise {
+ this.ensureInitialized();
+ const conversationsDir = await this.getConversationsDir(projectName, false);
+ await conversationsDir.removeEntry(conversationId, { recursive: true });
+ }
+
+ private ensureInitialized(): void {
+ if (!this._initialized || !this.projectsDirHandle) {
+ throw new Error('KGConversationStorage not initialized. Call initialize() first.');
+ }
+ }
+
+ private async getConversationsDir(
+ projectName: string,
+ create: boolean,
+ ): Promise {
+ const projectDir = await this.projectsDirHandle!.getDirectoryHandle(projectName, { create });
+ return projectDir.getDirectoryHandle(OPFS_CONSTANTS.CONVERSATIONS_DIR, { create });
+ }
+
+ private async getConversationDir(
+ projectName: string,
+ conversationId: string,
+ create: boolean,
+ ): Promise {
+ const conversationsDir = await this.getConversationsDir(projectName, create);
+ return conversationsDir.getDirectoryHandle(conversationId, { create });
+ }
+
+ private async writeFile(
+ dirHandle: FileSystemDirectoryHandle,
+ fileName: string,
+ content: string,
+ ): Promise {
+ const fileHandle = await dirHandle.getFileHandle(fileName, { create: true });
+ const writable = await fileHandle.createWritable();
+ await writable.write(content);
+ await writable.close();
+ }
+
+ private async readFile(
+ dirHandle: FileSystemDirectoryHandle,
+ fileName: string,
+ ): Promise {
+ const fileHandle = await dirHandle.getFileHandle(fileName);
+ const file = await fileHandle.getFile();
+ return file.text();
+ }
+}
diff --git a/src/core/io/KGProjectStorage.test.ts b/src/core/io/KGProjectStorage.test.ts
index 385a5ff..2dcc4ee 100644
--- a/src/core/io/KGProjectStorage.test.ts
+++ b/src/core/io/KGProjectStorage.test.ts
@@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { KGProjectStorage, DuplicateEntryError } from './KGProjectStorage';
+import { KGConversationStorage } from './KGConversationStorage';
import { KGProject } from '../KGProject';
import { GlobalTrackType } from '../global-track';
import { KGKeySignatureRegion } from '../region/KGKeySignatureRegion';
@@ -10,16 +11,23 @@ import { KGTrack } from '../track/KGTrack';
// --- OPFS mock infrastructure ---
class MockFileSystemWritableFileStream {
- public data = '';
- async write(content: string) { this.data = content; }
+ public data: string | ArrayBuffer = '';
+ async write(content: string | ArrayBuffer) { this.data = content; }
async close() {}
}
class MockFileSystemFileHandle {
kind = 'file' as const;
- constructor(public name: string, private _content: string = '') {}
+ constructor(public name: string, private _content: string | ArrayBuffer = '') {}
async getFile() {
- return { text: () => Promise.resolve(this._content) };
+ return {
+ text: () => Promise.resolve(typeof this._content === 'string' ? this._content : new TextDecoder().decode(this._content)),
+ arrayBuffer: () => Promise.resolve(
+ typeof this._content === 'string'
+ ? new TextEncoder().encode(this._content).buffer
+ : this._content
+ ),
+ };
}
async createWritable() {
const stream = new MockFileSystemWritableFileStream();
@@ -93,6 +101,7 @@ vi.stubGlobal('navigator', {
describe('KGProjectStorage', () => {
let storage: KGProjectStorage;
+ let conversationStorage: KGConversationStorage;
beforeEach(async () => {
// Reset singleton and mock filesystem
@@ -103,6 +112,9 @@ describe('KGProjectStorage', () => {
storage = KGProjectStorage.getInstance();
await storage.initialize();
+ ;(KGConversationStorage as unknown as { _instance: undefined })._instance = undefined;
+ conversationStorage = KGConversationStorage.getInstance();
+ await conversationStorage.initialize();
});
function createTestProject(name = 'Test Project'): KGProject {
@@ -163,6 +175,20 @@ describe('KGProjectStorage', () => {
expect(loaded!.getPianoRollZoom()).toBe(5);
});
+ it('preserves persisted global-track visibility and metronome state when saving and loading', async () => {
+ const project = createTestProject('Toggle Song');
+ project.setShowGlobalTracks(true);
+ project.setIsMetronomeEnabled(true);
+
+ await storage.save('Toggle Song', project);
+
+ const loaded = await storage.load('Toggle Song');
+
+ expect(loaded).not.toBeNull();
+ expect(loaded!.getShowGlobalTracks()).toBe(true);
+ expect(loaded!.getIsMetronomeEnabled()).toBe(true);
+ });
+
it('preserves global tracks and marker regions when saving and loading', async () => {
const project = createTestProject('Marker Song');
const markerTrack = project.getGlobalTracks().find(track => track.getType() === GlobalTrackType.Marker);
@@ -323,4 +349,65 @@ describe('KGProjectStorage', () => {
const loaded = await storage.load('New Name');
expect(loaded!.getName()).toBe('New Name');
});
+
+ it('copies conversation history when saving under a new project name', async () => {
+ await storage.save('Source Song', createTestProject('Source Song'));
+ await conversationStorage.saveConversation('Source Song', {
+ version: 1,
+ conversationId: 'conv_1',
+ continuationState: {
+ messages: [{ id: 'm1', role: 'user', content: 'hello', timestamp: 1 }],
+ todos: [],
+ },
+ fullHistory: {
+ messages: [{ id: 'm1', role: 'user', content: 'hello', timestamp: 1 }],
+ },
+ displayTranscript: [{ id: 'display_1', role: 'user', content: 'hello' }],
+ }, {
+ conversationId: 'conv_1',
+ title: 'hello',
+ createdAt: 1,
+ updatedAt: 1,
+ lastTurnAt: 1,
+ messageCount: 1,
+ preview: 'hello',
+ });
+
+ await storage.saveAs('Source Song', 'Copied Song', createTestProject('Copied Song'));
+
+ const loadedConversation = await conversationStorage.loadConversation('Copied Song', 'conv_1');
+ expect(loadedConversation?.document.conversationId).toBe('conv_1');
+ });
+
+ it('includes conversation history in project bundle export and import', async () => {
+ await storage.save('Bundle Song', createTestProject('Bundle Song'));
+ await conversationStorage.saveConversation('Bundle Song', {
+ version: 1,
+ conversationId: 'conv_bundle',
+ continuationState: {
+ messages: [{ id: 'm1', role: 'user', content: 'bundle', timestamp: 1 }],
+ todos: [],
+ },
+ fullHistory: {
+ messages: [{ id: 'm1', role: 'user', content: 'bundle', timestamp: 1 }],
+ },
+ displayTranscript: [{ id: 'display_1', role: 'user', content: 'bundle' }],
+ }, {
+ conversationId: 'conv_bundle',
+ title: 'bundle',
+ createdAt: 1,
+ updatedAt: 1,
+ lastTurnAt: 1,
+ messageCount: 1,
+ preview: 'bundle',
+ });
+
+ const bundle = await storage.exportAsZip('Bundle Song');
+ const importedName = await storage.importFromZip(bundle);
+ const loadedConversation = await conversationStorage.loadConversation(importedName, 'conv_bundle');
+
+ expect(loadedConversation?.document.displayTranscript).toEqual([
+ { id: 'display_1', role: 'user', content: 'bundle' },
+ ]);
+ });
});
diff --git a/src/core/io/KGProjectStorage.ts b/src/core/io/KGProjectStorage.ts
index 8349d1e..d571372 100644
--- a/src/core/io/KGProjectStorage.ts
+++ b/src/core/io/KGProjectStorage.ts
@@ -232,8 +232,8 @@ export class KGProjectStorage {
project.setName(targetName);
await this.save(targetName, project, false);
- // Copy media files
- await this.copyMediaFiles(sourceName, targetName);
+ // Copy project-scoped auxiliary artifacts such as media and conversation history
+ await this.copyProjectArtifacts(sourceName, targetName);
}
/**
@@ -346,8 +346,8 @@ export class KGProjectStorage {
project.setName(newName);
await this.save(newName, project, false);
- // Copy media files from old to new location
- await this.copyMediaFiles(oldName, newName);
+ // Copy project-scoped auxiliary artifacts such as media and conversation history
+ await this.copyProjectArtifacts(oldName, newName);
// Delete old location
await this.delete(oldName);
@@ -366,7 +366,7 @@ export class KGProjectStorage {
// Migrate media files only if the old folder exists
if (await this.exists(oldName)) {
- await this.copyMediaFiles(oldName, newName);
+ await this.copyProjectArtifacts(oldName, newName);
await this.delete(oldName);
}
}
@@ -381,7 +381,7 @@ export class KGProjectStorage {
await this.save(targetName, data, false);
if (await this.exists(sourceName)) {
- await this.copyMediaFiles(sourceName, targetName);
+ await this.copyProjectArtifacts(sourceName, targetName);
}
}
@@ -414,7 +414,7 @@ export class KGProjectStorage {
if (entry.kind === 'file') {
const fileHandle = entry as FileSystemFileHandle;
const file = await fileHandle.getFile();
- zip.file(entryPath, file.arrayBuffer());
+ zip.file(entryPath, new Uint8Array(await this.readBlobLikeAsArrayBuffer(file)));
} else {
const subDir = entry as FileSystemDirectoryHandle;
await this.addDirectoryToZip(zip, subDir, entryPath);
@@ -551,40 +551,53 @@ export class KGProjectStorage {
// --- Media migration ---
- /**
- * Copy all files from projects//media/ to projects//media/.
- * If the source media directory doesn't exist, returns without error.
- */
- private async copyMediaFiles(fromName: string, toName: string): Promise {
+ private async copyProjectArtifacts(fromName: string, toName: string): Promise {
try {
const fromDir = await this.projectsDirHandle!.getDirectoryHandle(fromName);
- let fromMedia: FileSystemDirectoryHandle;
- try {
- fromMedia = await fromDir.getDirectoryHandle(OPFS_CONSTANTS.MEDIA_DIR);
- } catch {
- // No media directory in source — nothing to copy
- return;
- }
-
const toDir = await this.projectsDirHandle!.getDirectoryHandle(toName);
- const toMedia = await toDir.getDirectoryHandle(OPFS_CONSTANTS.MEDIA_DIR, { create: true });
-
- for await (const entry of fromMedia.values()) {
+ for await (const entry of fromDir.values()) {
+ if (entry.name === OPFS_CONSTANTS.PROJECT_FILE || entry.name === OPFS_CONSTANTS.METADATA_FILE) {
+ continue;
+ }
if (entry.kind === 'file') {
const fileHandle = entry as FileSystemFileHandle;
const file = await fileHandle.getFile();
- const newHandle = await toMedia.getFileHandle(entry.name, { create: true });
+ const newHandle = await toDir.getFileHandle(entry.name, { create: true });
const writable = await newHandle.createWritable();
await writable.write(await file.arrayBuffer());
await writable.close();
+ } else {
+ await this.copyDirectoryContents(
+ entry as FileSystemDirectoryHandle,
+ await toDir.getDirectoryHandle(entry.name, { create: true }),
+ );
}
}
} catch (error) {
- console.error(`Error copying media files from "${fromName}" to "${toName}":`, error);
+ console.error(`Error copying project artifacts from "${fromName}" to "${toName}":`, error);
throw error;
}
}
+ private async copyDirectoryContents(
+ fromDir: FileSystemDirectoryHandle,
+ toDir: FileSystemDirectoryHandle,
+ ): Promise {
+ for await (const entry of fromDir.values()) {
+ if (entry.kind === 'file') {
+ const fileHandle = entry as FileSystemFileHandle;
+ const file = await fileHandle.getFile();
+ const newHandle = await toDir.getFileHandle(entry.name, { create: true });
+ const writable = await newHandle.createWritable();
+ await writable.write(await this.readBlobLikeAsArrayBuffer(file));
+ await writable.close();
+ } else {
+ const newSubDir = await toDir.getDirectoryHandle(entry.name, { create: true });
+ await this.copyDirectoryContents(entry as FileSystemDirectoryHandle, newSubDir);
+ }
+ }
+ }
+
// --- File I/O helpers ---
private async writeFile(
@@ -618,4 +631,16 @@ export class KGProjectStorage {
return { name, createdAt: 0, updatedAt: 0 };
}
}
+
+ private async readBlobLikeAsArrayBuffer(
+ file: { arrayBuffer?: () => Promise; text?: () => Promise },
+ ): Promise {
+ if (typeof file.arrayBuffer === 'function') {
+ return file.arrayBuffer();
+ }
+ if (typeof file.text === 'function') {
+ return new TextEncoder().encode(await file.text()).buffer;
+ }
+ throw new Error('Unsupported file object: expected arrayBuffer() or text().');
+ }
}
diff --git a/src/core/project-upgrader/KGProjectUpgrader.ts b/src/core/project-upgrader/KGProjectUpgrader.ts
index 09e4fad..ec62425 100644
--- a/src/core/project-upgrader/KGProjectUpgrader.ts
+++ b/src/core/project-upgrader/KGProjectUpgrader.ts
@@ -14,6 +14,7 @@ import { upgradeToV12 } from './upgradeToV12';
import { upgradeToV13 } from './upgradeToV13';
import { upgradeToV14 } from './upgradeToV14';
import { upgradeToV15 } from './upgradeToV15';
+import { upgradeToV16 } from './upgradeToV16';
/**
* Upgrade the given project to the latest structure version, one version at a time.
@@ -93,6 +94,10 @@ export function upgradeProjectToLatest(project: KGProject): KGProject {
workingProject = upgradeToV15(workingProject);
break;
}
+ case 16: {
+ workingProject = upgradeToV16(workingProject);
+ break;
+ }
default: {
// If an upgrader is missing, throw to prevent loading incompatible structures
throw new Error(`No upgrader found for project structure version ${nextVersion}`);
diff --git a/src/core/project-upgrader/upgradeToV16.test.ts b/src/core/project-upgrader/upgradeToV16.test.ts
new file mode 100644
index 0000000..21bf400
--- /dev/null
+++ b/src/core/project-upgrader/upgradeToV16.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it } from 'vitest';
+import { KGProject } from '../KGProject';
+import { upgradeProjectToLatest } from './KGProjectUpgrader';
+import { upgradeToV16 } from './upgradeToV16';
+
+describe('upgradeToV16', () => {
+ it('defaults persisted UI/audio toggles to false for legacy projects', () => {
+ const project = new KGProject('Legacy', 32, 0, 125, undefined, undefined, undefined, undefined, [0, 0], 1, [], 15, 1, []);
+
+ upgradeToV16(project);
+
+ expect(project.getProjectStructureVersion()).toBe(16);
+ expect(project.getShowGlobalTracks()).toBe(false);
+ expect(project.getIsMetronomeEnabled()).toBe(false);
+ });
+
+ it('preserves existing persisted toggle values through the main upgrader path', () => {
+ const project = new KGProject('Legacy', 32, 0, 125, undefined, undefined, undefined, undefined, [0, 0], 1, [], 15, 1, [], true, true);
+
+ const upgraded = upgradeProjectToLatest(project);
+
+ expect(upgraded.getProjectStructureVersion()).toBe(KGProject.CURRENT_PROJECT_STRUCTURE_VERSION);
+ expect(upgraded.getShowGlobalTracks()).toBe(true);
+ expect(upgraded.getIsMetronomeEnabled()).toBe(true);
+ });
+});
diff --git a/src/core/project-upgrader/upgradeToV16.ts b/src/core/project-upgrader/upgradeToV16.ts
new file mode 100644
index 0000000..c227b8e
--- /dev/null
+++ b/src/core/project-upgrader/upgradeToV16.ts
@@ -0,0 +1,17 @@
+import { KGProject } from '../KGProject';
+
+export function upgradeToV16(project: KGProject): KGProject {
+ try {
+ if (typeof project.getShowGlobalTracks() !== 'boolean') {
+ project.setShowGlobalTracks(false);
+ }
+
+ if (typeof project.getIsMetronomeEnabled() !== 'boolean') {
+ project.setIsMetronomeEnabled(false);
+ }
+ } finally {
+ project.setProjectStructureVersion(16);
+ }
+
+ return project;
+}
diff --git a/src/hooks/useStreamProcessor.test.ts b/src/hooks/useStreamProcessor.test.ts
index 8685d2d..84fe85f 100644
--- a/src/hooks/useStreamProcessor.test.ts
+++ b/src/hooks/useStreamProcessor.test.ts
@@ -1,5 +1,22 @@
import { act, renderHook } from '@testing-library/react';
-import { describe, expect, it, vi } from 'vitest';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import type { TodoItem } from '../agent/core/todo';
+
+const { mockedStoreState } = vi.hoisted(() => {
+ const state = {
+ activeRegionId: null as string | null,
+ selectedRegionIds: [] as string[],
+ timeSignature: { numerator: 4, denominator: 4 },
+ tracks: [] as unknown[],
+ refreshProjectState: vi.fn(),
+ toolFastForwardEnabled: false,
+ setToolFastForwardEnabled: vi.fn(),
+ };
+ state.setToolFastForwardEnabled.mockImplementation((enabled: boolean) => {
+ state.toolFastForwardEnabled = enabled;
+ });
+ return { mockedStoreState: state };
+});
vi.mock('../agent/core/AgentCore', () => ({
AgentCore: {
@@ -7,6 +24,18 @@ vi.mock('../agent/core/AgentCore', () => ({
}
}));
+vi.mock('../core/KGCore', () => ({
+ KGCore: {
+ instance: vi.fn()
+ }
+}));
+
+vi.mock('../stores/projectStore', () => ({
+ useProjectStore: {
+ getState: vi.fn(() => mockedStoreState),
+ },
+}));
+
vi.mock('../utils/chatMessageUtils', () => ({
createStreamingMessage: () => ({
id: 'streaming-message',
@@ -16,13 +45,16 @@ vi.mock('../utils/chatMessageUtils', () => ({
tokenCount: 0
}),
createMessage: (role: 'user' | 'assistant', content: string) => ({
- id: `${role}-message`,
+ id: `${role}-${content}`,
role,
content
})
}));
import { AgentCore } from '../agent/core/AgentCore';
+import { KGCore } from '../core/KGCore';
+import { KGMidiRegion } from '../core/region/KGMidiRegion';
+import { KGMidiTrack } from '../core/track/KGMidiTrack';
import { useStreamProcessor } from './useStreamProcessor';
import type { ChatMessage } from '../types/projectTypes';
@@ -32,6 +64,14 @@ const flushMicrotasks = async (): Promise => {
};
describe('useStreamProcessor', () => {
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ mockedStoreState.activeRegionId = null;
+ mockedStoreState.toolFastForwardEnabled = false;
+ mockedStoreState.setToolFastForwardEnabled.mockClear();
+ mockedStoreState.refreshProjectState.mockClear();
+ });
+
it('switches from Thinking to Processing after the first text token arrives', async () => {
let releaseDone!: () => void;
const doneGate = new Promise((resolve) => {
@@ -89,4 +129,503 @@ describe('useStreamProcessor', () => {
expect(processingChanges.at(-1)).toBe(false);
});
+
+ it('suppresses update_todo_list tool-call messages and emits structured todo snapshots', async () => {
+ const todoSnapshot: TodoItem[] = [
+ { id: '1', text: 'Inspect melody', status: 'completed', updatedAt: 1 },
+ { id: '2', text: 'Write harmony', status: 'in_progress', activeText: 'Writing harmony', updatedAt: 2 },
+ ];
+
+ vi.spyOn(AgentCore, 'instance').mockReturnValue({
+ getAgentState: () => ({
+ getTodos: () => todoSnapshot,
+ }),
+ processUserInput: async function* () {
+ yield {
+ type: 'tool_call',
+ content: '',
+ toolCall: {
+ id: 'todo-call-1',
+ type: 'function',
+ function: {
+ name: 'update_todo_list',
+ arguments: JSON.stringify({ items: [] }),
+ },
+ },
+ };
+ yield {
+ type: 'tool_result',
+ content: '',
+ toolResult: {
+ toolCallId: 'todo-call-1',
+ name: 'update_todo_list',
+ success: true,
+ result: 'todo fallback content',
+ },
+ };
+ yield { type: 'done', content: '' };
+ },
+ } as unknown as AgentCore);
+
+ const messages = new Map();
+
+ const { result } = renderHook(() => useStreamProcessor({
+ onMessageAdd: (message) => {
+ messages.set(message.id, message);
+ },
+ onMessageUpdate: (messageId, updater) => {
+ const current = messages.get(messageId);
+ if (!current) {
+ throw new Error(`Missing message ${messageId}`);
+ }
+ messages.set(messageId, updater(current));
+ },
+ onMessageRemove: (messageId) => {
+ messages.delete(messageId);
+ },
+ onProcessingChange: () => undefined,
+ }));
+
+ await act(async () => {
+ await result.current.processStream('todo prompt');
+ });
+
+ const addedMessages = [...messages.values()];
+ expect(addedMessages.some(message => message.content.includes('Calling tool: update_todo_list'))).toBe(false);
+ expect(addedMessages.some(message => message.toolName === 'update_todo_list')).toBe(true);
+ const todoMessage = addedMessages.find(message => message.toolName === 'update_todo_list');
+ expect(todoMessage?.toolSuccess).toBe(true);
+ expect(todoMessage?.todoSnapshot).toEqual(todoSnapshot);
+ expect(todoMessage?.content).toBe('todo fallback content');
+ });
+
+ it('continues to show generic tool-call messages for non-todo tools', async () => {
+ vi.spyOn(AgentCore, 'instance').mockReturnValue({
+ getAgentState: () => ({
+ getTodos: () => [],
+ }),
+ processUserInput: async function* () {
+ yield {
+ type: 'tool_call',
+ content: '',
+ toolCall: {
+ id: 'read-call-1',
+ type: 'function',
+ function: {
+ name: 'read_music',
+ arguments: JSON.stringify({}),
+ },
+ },
+ };
+ yield {
+ type: 'tool_result',
+ content: '',
+ toolResult: {
+ toolCallId: 'read-call-1',
+ name: 'read_music',
+ success: true,
+ result: 'music data',
+ },
+ };
+ yield { type: 'done', content: '' };
+ },
+ } as unknown as AgentCore);
+
+ const messages = new Map();
+
+ const { result } = renderHook(() => useStreamProcessor({
+ onMessageAdd: (message) => {
+ messages.set(message.id, message);
+ },
+ onMessageUpdate: (messageId, updater) => {
+ const current = messages.get(messageId);
+ if (!current) {
+ throw new Error(`Missing message ${messageId}`);
+ }
+ messages.set(messageId, updater(current));
+ },
+ onMessageRemove: (messageId) => {
+ messages.delete(messageId);
+ },
+ onProcessingChange: () => undefined,
+ }));
+
+ await act(async () => {
+ await result.current.processStream('read prompt');
+ });
+
+ const addedMessages = [...messages.values()];
+ expect(addedMessages.some(message => message.content.includes('Calling tool: read_music'))).toBe(true);
+ expect(addedMessages.some(message => message.isToolCallMessage)).toBe(true);
+ expect(addedMessages.some(message => message.toolName === 'update_todo_list')).toBe(false);
+ });
+
+ it('uses tool-provided add_notes summary metadata for chat-only rendering while preserving raw content', async () => {
+ const selectedRegion = new KGMidiRegion('region-1', '1', 0, 'Verse Melody');
+
+ vi.spyOn(AgentCore, 'instance').mockReturnValue({
+ getAgentState: () => ({
+ getTodos: () => [],
+ }),
+ processUserInput: async function* () {
+ yield {
+ type: 'tool_call',
+ content: '',
+ toolCall: {
+ id: 'add-notes-call-1',
+ type: 'function',
+ function: {
+ name: 'add_notes',
+ arguments: JSON.stringify({
+ notes: [
+ { pitch: 'C4', start: 16, length: 4 },
+ { pitch: 'E4', start: 20, length: 8 },
+ ],
+ }),
+ },
+ },
+ };
+ yield {
+ type: 'tool_result',
+ content: '',
+ toolResult: {
+ toolCallId: 'add-notes-call-1',
+ name: 'add_notes',
+ success: true,
+ result: 'Successfully created 2 notes: C4 (beat 16, length 4), E4 (beat 20, length 8)',
+ },
+ };
+ yield { type: 'done', content: '' };
+ },
+ } as unknown as AgentCore);
+
+ const leadTrack = new KGMidiTrack('Lead', 1);
+ leadTrack.setRegions([selectedRegion]);
+ vi.spyOn(KGCore, 'instance').mockReturnValue({
+ getCurrentProject: () => ({
+ getTimeSignature: () => ({ numerator: 4, denominator: 4 }),
+ getTracks: () => [leadTrack],
+ }),
+ getSelectedItems: () => [selectedRegion],
+ } as unknown as KGCore);
+
+ const messages = new Map();
+
+ const { result } = renderHook(() => useStreamProcessor({
+ onMessageAdd: (message) => {
+ messages.set(message.id, message);
+ },
+ onMessageUpdate: (messageId, updater) => {
+ const current = messages.get(messageId);
+ if (!current) {
+ throw new Error(`Missing message ${messageId}`);
+ }
+ messages.set(messageId, updater(current));
+ },
+ onMessageRemove: (messageId) => {
+ messages.delete(messageId);
+ },
+ onProcessingChange: () => undefined,
+ }));
+
+ await act(async () => {
+ await result.current.processStream('add notes prompt');
+ });
+
+ const addedMessages = [...messages.values()];
+ const toolCallMessage = addedMessages.find(message => message.isToolCallMessage);
+ const addNotesMessage = addedMessages.find(message => message.toolName === 'add_notes');
+
+ expect(toolCallMessage?.content).toContain('Calling tool: add_notes');
+ expect(addNotesMessage?.content).toContain('Successfully created 2 notes:');
+ expect(addNotesMessage?.toolRawResult).toBe('Successfully created 2 notes: C4 (beat 16, length 4), E4 (beat 20, length 8)');
+ expect(addNotesMessage?.toolResultDisplayContent).toBe(
+ 'Successfully created 2 notes in region **Verse Melody** on track **Lead**, spanning bars 5 to 7.'
+ );
+ });
+
+ it('falls back to the raw tool result when tool summary generation cannot resolve context', async () => {
+ vi.spyOn(AgentCore, 'instance').mockReturnValue({
+ getAgentState: () => ({
+ getTodos: () => [],
+ }),
+ processUserInput: async function* () {
+ yield {
+ type: 'tool_call',
+ content: '',
+ toolCall: {
+ id: 'read-call-fallback',
+ type: 'function',
+ function: {
+ name: 'read_music',
+ arguments: JSON.stringify({}),
+ },
+ },
+ };
+ yield {
+ type: 'tool_result',
+ content: '',
+ toolResult: {
+ toolCallId: 'read-call-fallback',
+ name: 'read_music',
+ success: true,
+ result: 'raw music result',
+ },
+ };
+ yield { type: 'done', content: '' };
+ },
+ } as unknown as AgentCore);
+
+ const messages = new Map();
+
+ const { result } = renderHook(() => useStreamProcessor({
+ onMessageAdd: (message) => {
+ messages.set(message.id, message);
+ },
+ onMessageUpdate: (messageId, updater) => {
+ const current = messages.get(messageId);
+ if (!current) {
+ throw new Error(`Missing message ${messageId}`);
+ }
+ messages.set(messageId, updater(current));
+ },
+ onMessageRemove: (messageId) => {
+ messages.delete(messageId);
+ },
+ onProcessingChange: () => undefined,
+ }));
+
+ await act(async () => {
+ await result.current.processStream('read fallback prompt');
+ });
+
+ const toolResultMessage = [...messages.values()].find(message => message.toolName === 'read_music');
+ expect(toolResultMessage?.toolRawResult).toBe('raw music result');
+ expect(toolResultMessage?.toolResultDisplayContent).toBe('raw music result');
+ });
+
+ it('uses tool-specific history and UI strings for error results when provided', async () => {
+ vi.spyOn(AgentCore, 'instance').mockReturnValue({
+ getAgentState: () => ({
+ getTodos: () => [],
+ }),
+ processUserInput: async function* () {
+ yield {
+ type: 'tool_call',
+ content: '',
+ toolCall: {
+ id: 'add-call-no-target',
+ type: 'function',
+ function: {
+ name: 'add_notes',
+ arguments: JSON.stringify({
+ notes: [{ pitch: 'C4', start: 0, length: 1 }],
+ }),
+ },
+ },
+ };
+ yield {
+ type: 'tool_result',
+ content: '',
+ toolResult: {
+ toolCallId: 'add-call-no-target',
+ name: 'add_notes',
+ success: false,
+ result: 'No MIDI target could be resolved. Select the MIDI region you want me to edit and retry, or tell me which MIDI track to operate on by providing its track_id.',
+ },
+ };
+ yield { type: 'done', content: '' };
+ },
+ } as unknown as AgentCore);
+
+ const messages = new Map();
+
+ const { result } = renderHook(() => useStreamProcessor({
+ onMessageAdd: (message) => {
+ messages.set(message.id, message);
+ },
+ onMessageUpdate: (messageId, updater) => {
+ const current = messages.get(messageId);
+ if (!current) {
+ throw new Error(`Missing message ${messageId}`);
+ }
+ messages.set(messageId, updater(current));
+ },
+ onMessageRemove: (messageId) => {
+ messages.delete(messageId);
+ },
+ onProcessingChange: () => undefined,
+ }));
+
+ await act(async () => {
+ await result.current.processStream('add notes prompt');
+ });
+
+ const addNotesMessage = [...messages.values()].find(message => message.toolName === 'add_notes');
+ expect(addNotesMessage?.toolRawResult).toBe(
+ 'No MIDI target could be resolved. Select the MIDI region you want me to edit and retry, or tell me which MIDI track to operate on by providing its track_id.',
+ );
+ expect(addNotesMessage?.content).toContain('I could not tell which MIDI content to edit.');
+ expect(addNotesMessage?.toolResultDisplayContent).toBe('Select a MIDI region, or specify a track.');
+ });
+
+ it('shows a confirmation card and replaces it with a denied result when the user denies execution', async () => {
+ vi.spyOn(AgentCore, 'instance').mockReturnValue({
+ getAgentState: () => ({
+ getTodos: () => [],
+ }),
+ processUserInput: async function* (_input: string, options?: { requestToolApproval?: (toolCall: { id: string; type: 'function'; function: { name: string; arguments: string } }) => Promise<'allow' | 'always_allow' | 'deny'> }) {
+ const toolCall = {
+ id: 'add-notes-call-confirm',
+ type: 'function' as const,
+ function: {
+ name: 'add_notes',
+ arguments: JSON.stringify({
+ notes: [{ pitch: 'C4', start: 16, length: 4 }],
+ }),
+ },
+ };
+
+ yield { type: 'tool_call', content: '', toolCall };
+ const decision = await options?.requestToolApproval?.(toolCall);
+ yield {
+ type: 'tool_result',
+ content: '',
+ toolResult: {
+ toolCallId: toolCall.id,
+ name: 'add_notes',
+ success: false,
+ result: 'Execution was denied by the user.',
+ denied: decision === 'deny',
+ },
+ };
+ },
+ } as unknown as AgentCore);
+
+ const selectedRegion = new KGMidiRegion('region-1', '1', 0, 'Verse Melody');
+ const leadTrack = new KGMidiTrack('Lead', 1);
+ leadTrack.setRegions([selectedRegion]);
+ vi.spyOn(KGCore, 'instance').mockReturnValue({
+ getCurrentProject: () => ({
+ getTimeSignature: () => ({ numerator: 4, denominator: 4 }),
+ getTracks: () => [leadTrack],
+ }),
+ getSelectedItems: () => [selectedRegion],
+ } as unknown as KGCore);
+ mockedStoreState.activeRegionId = selectedRegion.getId();
+
+ const messages = new Map();
+ const { result } = renderHook(() => useStreamProcessor({
+ onMessageAdd: (message) => {
+ messages.set(message.id, message);
+ },
+ onMessageUpdate: (messageId, updater) => {
+ const current = messages.get(messageId);
+ if (!current) {
+ throw new Error(`Missing message ${messageId}`);
+ }
+ messages.set(messageId, updater(current));
+ },
+ onMessageRemove: (messageId) => {
+ messages.delete(messageId);
+ },
+ onProcessingChange: () => undefined,
+ }));
+
+ let responsePromise!: Promise;
+ await act(async () => {
+ responsePromise = result.current.processStream('confirm prompt');
+ await flushMicrotasks();
+ });
+
+ const confirmationMessage = [...messages.values()].find(message => message.toolConfirmation);
+ expect(confirmationMessage?.toolConfirmation?.toolName).toBe('add_notes');
+ expect(confirmationMessage?.toolConfirmation?.message).toContain('Allow creating 1 note on track **Lead** in region **Verse Melody**');
+
+ act(() => {
+ confirmationMessage?.onToolConfirmationDecision?.('deny');
+ });
+
+ await act(async () => {
+ await responsePromise;
+ });
+
+ expect([...messages.values()].some(message => message.toolConfirmation)).toBe(false);
+ const deniedResult = [...messages.values()].find(message => message.toolName === 'add_notes');
+ expect(deniedResult?.toolDenied).toBe(true);
+ expect(deniedResult?.toolRawResult).toBe('Execution was denied by the user.');
+ });
+
+ it('skips the confirmation card when fast-forward mode is enabled', async () => {
+ mockedStoreState.toolFastForwardEnabled = true;
+ let capturedDecision: string | undefined;
+
+ vi.spyOn(AgentCore, 'instance').mockReturnValue({
+ getAgentState: () => ({
+ getTodos: () => [],
+ }),
+ processUserInput: async function* (_input: string, options?: { requestToolApproval?: (toolCall: { id: string; type: 'function'; function: { name: string; arguments: string } }) => Promise<'allow' | 'always_allow' | 'deny'> }) {
+ const toolCall = {
+ id: 'add-notes-call-auto',
+ type: 'function' as const,
+ function: {
+ name: 'add_notes',
+ arguments: JSON.stringify({
+ notes: [{ pitch: 'C4', start: 0, length: 4 }],
+ }),
+ },
+ };
+
+ yield { type: 'tool_call', content: '', toolCall };
+ capturedDecision = await options?.requestToolApproval?.(toolCall);
+ yield {
+ type: 'tool_result',
+ content: '',
+ toolResult: {
+ toolCallId: toolCall.id,
+ name: 'add_notes',
+ success: true,
+ result: 'Successfully created 1 note: C4 (beat 0, length 4)',
+ },
+ };
+ },
+ } as unknown as AgentCore);
+
+ const selectedRegion = new KGMidiRegion('region-1', '1', 0, 'Intro');
+ const leadTrack = new KGMidiTrack('Lead', 1);
+ leadTrack.setRegions([selectedRegion]);
+ vi.spyOn(KGCore, 'instance').mockReturnValue({
+ getCurrentProject: () => ({
+ getTimeSignature: () => ({ numerator: 4, denominator: 4 }),
+ getTracks: () => [leadTrack],
+ }),
+ getSelectedItems: () => [selectedRegion],
+ } as unknown as KGCore);
+ mockedStoreState.activeRegionId = selectedRegion.getId();
+
+ const messages = new Map();
+ const { result } = renderHook(() => useStreamProcessor({
+ onMessageAdd: (message) => {
+ messages.set(message.id, message);
+ },
+ onMessageUpdate: (messageId, updater) => {
+ const current = messages.get(messageId);
+ if (!current) {
+ throw new Error(`Missing message ${messageId}`);
+ }
+ messages.set(messageId, updater(current));
+ },
+ onMessageRemove: (messageId) => {
+ messages.delete(messageId);
+ },
+ onProcessingChange: () => undefined,
+ }));
+
+ await act(async () => {
+ await result.current.processStream('auto allow prompt');
+ });
+
+ expect(capturedDecision).toBe('allow');
+ expect([...messages.values()].some(message => message.toolConfirmation)).toBe(false);
+ });
});
diff --git a/src/hooks/useStreamProcessor.ts b/src/hooks/useStreamProcessor.ts
index 45209d4..bd62d3f 100644
--- a/src/hooks/useStreamProcessor.ts
+++ b/src/hooks/useStreamProcessor.ts
@@ -1,7 +1,18 @@
import { useState, useCallback } from 'react';
import { AgentCore } from '../agent/core/AgentCore';
+import { createToolInstance } from '../agent/tools';
import { createStreamingMessage, createMessage } from '../utils/chatMessageUtils';
import type { ChatMessage } from '../types/projectTypes';
+import type { ToolApprovalDecision } from '../agent/llm/StreamingTypes';
+import { useProjectStore } from '../stores/projectStore';
+
+const TODO_TOOL_NAME = 'update_todo_list';
+
+interface PendingToolCall {
+ id: string;
+ name: string;
+ arguments: Record | null;
+}
interface StreamProcessorOptions {
onMessageUpdate: (messageId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
@@ -33,6 +44,7 @@ export const useStreamProcessor = (options: StreamProcessorOptions): StreamProce
// Track the current streaming message ID (mutable)
const initialStreamingMsg = createStreamingMessage();
let currentStreamingId = initialStreamingMsg.id;
+ const pendingToolCalls: PendingToolCall[] = [];
onMessageAdd(initialStreamingMsg);
try {
@@ -46,7 +58,55 @@ export const useStreamProcessor = (options: StreamProcessorOptions): StreamProce
console.log(input);
console.log('------------------------------');
- for await (const chunk of agentCore.processUserInput(input)) {
+ const requestToolApproval = async (toolCall: PendingToolCall): Promise => {
+ const { toolFastForwardEnabled, setToolFastForwardEnabled } = useProjectStore.getState();
+ if (toolFastForwardEnabled) {
+ return 'allow';
+ }
+
+ const toolInstance = createToolInstance(toolCall.name);
+ const confirmationContent = toolInstance?.buildConfirmationContent(toolCall.arguments) ?? undefined;
+ if (!confirmationContent) {
+ return 'allow';
+ }
+
+ return await new Promise((resolve) => {
+ const confirmationMessage = {
+ ...createMessage('assistant', confirmationContent),
+ toolConfirmation: {
+ toolCallId: toolCall.id,
+ toolName: toolCall.name,
+ message: confirmationContent,
+ },
+ onToolConfirmationDecision: (decision: ToolApprovalDecision) => {
+ onMessageRemove(confirmationMessage.id);
+ if (decision === 'always_allow') {
+ setToolFastForwardEnabled(true);
+ }
+ resolve(decision);
+ },
+ };
+
+ onMessageAdd(confirmationMessage);
+ });
+ };
+
+ for await (const chunk of agentCore.processUserInput(input, {
+ requestToolApproval: async (toolCall) => {
+ let parsedArguments: Record | null;
+ try {
+ parsedArguments = JSON.parse(toolCall.function.arguments);
+ } catch {
+ parsedArguments = null;
+ }
+
+ return requestToolApproval({
+ id: toolCall.id,
+ name: toolCall.function.name,
+ arguments: parsedArguments,
+ });
+ },
+ })) {
if (controller.signal.aborted) {
return '';
}
@@ -62,6 +122,10 @@ export const useStreamProcessor = (options: StreamProcessorOptions): StreamProce
tokenCount
}));
} else if (chunk.type === 'tool_call' && chunk.toolCall) {
+ console.log('------------ ASSISTANT TOOL CALL ------------');
+ console.log(JSON.stringify(chunk.toolCall, null, 2));
+ console.log('---------------------------------------------');
+
// Finalize or remove the current streaming message
if (hasTextContent) {
onMessageUpdate(currentStreamingId, (msg) => ({
@@ -82,20 +146,65 @@ export const useStreamProcessor = (options: StreamProcessorOptions): StreamProce
// Show tool call in UI
const toolName = chunk.toolCall.function.name;
+ if (toolName === TODO_TOOL_NAME) {
+ continue;
+ }
let argsDisplay = '';
try {
const args = JSON.parse(chunk.toolCall.function.arguments);
argsDisplay = JSON.stringify(args, null, 2);
+ pendingToolCalls.push({ id: chunk.toolCall.id, name: toolName, arguments: args });
} catch {
argsDisplay = chunk.toolCall.function.arguments;
+ pendingToolCalls.push({ id: chunk.toolCall.id, name: toolName, arguments: null });
}
- const toolCallMsg = createMessage('assistant', `🔧 **Calling tool: ${toolName}**\n\n\`\`\`json\n${argsDisplay}\n\`\`\``);
+ const toolCallMsg = {
+ ...createMessage('assistant', `🔧 **Calling tool: ${toolName}**\n\n\`\`\`json\n${argsDisplay}\n\`\`\``),
+ isToolCallMessage: true,
+ };
onMessageAdd(toolCallMsg);
} else if (chunk.type === 'tool_result' && chunk.toolResult) {
+ console.log('------------ TOOL RESULT ------------');
+ console.log(JSON.stringify(chunk.toolResult, null, 2));
+ console.log('-------------------------------------');
+
// Show tool result in UI
- const { name, success, result } = chunk.toolResult;
- const icon = success ? '✅' : '❌';
- const toolResultMsg = createMessage('assistant', `${icon} **${name}**\n\n └── ${result}`);
+ const { toolCallId, name, success, result, denied } = chunk.toolResult;
+ const pendingToolCallIndex = pendingToolCalls.findIndex(toolCall => toolCall.id === toolCallId);
+ const pendingToolCall = pendingToolCallIndex >= 0
+ ? pendingToolCalls.splice(pendingToolCallIndex, 1)[0]
+ : undefined;
+ let toolHistoryContent = result;
+ let toolResultDisplayContent = result;
+ try {
+ const toolInstance = createToolInstance(name);
+ toolHistoryContent = toolInstance?.buildToolHistoryContent(
+ pendingToolCall?.arguments ?? null,
+ { success, result },
+ ) ?? result;
+ toolResultDisplayContent = toolInstance?.buildToolResultDisplayContent(
+ pendingToolCall?.arguments ?? null,
+ { success, result },
+ ) ?? result;
+ } catch {
+ toolHistoryContent = result;
+ toolResultDisplayContent = result;
+ }
+ const toolResultMsg = name === TODO_TOOL_NAME
+ ? {
+ ...createMessage('assistant', result),
+ toolName: name,
+ toolSuccess: success,
+ todoSnapshot: AgentCore.instance().getAgentState().getTodos().map(todo => ({ ...todo })),
+ }
+ : {
+ ...createMessage('assistant', `${success ? '✅' : '❌'} **${name}**\n\n └── ${toolHistoryContent}`),
+ toolName: name,
+ toolSuccess: success,
+ toolRawResult: result,
+ toolResultDisplayContent,
+ toolDenied: denied,
+ };
onMessageAdd(toolResultMsg);
// Reset for the next LLM turn in the agentic loop
@@ -105,9 +214,11 @@ export const useStreamProcessor = (options: StreamProcessorOptions): StreamProce
performanceInfo = undefined;
// Create a fresh streaming placeholder for the next LLM response
- const nextMsg = createStreamingMessage();
- currentStreamingId = nextMsg.id;
- onMessageAdd(nextMsg);
+ if (!denied) {
+ const nextMsg = createStreamingMessage();
+ currentStreamingId = nextMsg.id;
+ onMessageAdd(nextMsg);
+ }
} else if (chunk.type === 'done') {
performanceInfo = chunk.performanceInfo;
// Finalize the streaming message
@@ -138,13 +249,18 @@ export const useStreamProcessor = (options: StreamProcessorOptions): StreamProce
}
console.error('Error processing stream:', error);
- onMessageUpdate(currentStreamingId, (msg) => ({
- ...msg,
- content: `Error: ${error instanceof Error ? error.message : 'Failed to process message'}`,
- isStreaming: false,
- tokenCount: undefined
- }));
- return '';
+ const errorContent = `Error: ${error instanceof Error ? error.message : 'Failed to process message'}`;
+ try {
+ onMessageUpdate(currentStreamingId, (msg) => ({
+ ...msg,
+ content: errorContent,
+ isStreaming: false,
+ tokenCount: undefined
+ }));
+ } catch {
+ onMessageAdd(createMessage('assistant', errorContent));
+ }
+ throw error;
} finally {
setAbortController(null);
setIsProcessing(false);
diff --git a/src/i18n/messages/en_us.ts b/src/i18n/messages/en_us.ts
index 56dacfc..a58f6b7 100644
--- a/src/i18n/messages/en_us.ts
+++ b/src/i18n/messages/en_us.ts
@@ -4,6 +4,22 @@ export const enUsMessages: TranslationMessages = {
'app.loading': 'Loading ...',
'assistant.displayName': 'K.G.Studio Musician Assistant',
'assistant.welcomeFallback': 'Welcome to K.G.Studio Musician Assistant.',
+ 'chatbox.todo.title': 'Task Checklist',
+ 'chatbox.todo.ariaLabel': 'Agent task checklist',
+ 'chatbox.todo.count': '{completed}/{total} completed',
+ 'chatbox.todo.active': 'Working on: {task}',
+ 'chatbox.history.title': 'Conversation history',
+ 'chatbox.history.heading': 'Conversation History',
+ 'chatbox.history.cancel': 'Cancel',
+ 'chatbox.history.delete': 'Delete',
+ 'chatbox.history.deleteConfirm': 'Delete conversation "{title}"? This cannot be undone.',
+ 'chatbox.history.loading': 'Loading conversations...',
+ 'chatbox.history.empty': 'No saved conversations yet.',
+ 'chatbox.fastForward.title': 'Fast forward tool execution approvals',
+ 'chatbox.tool.confirmation.ariaLabel': 'Tool execution approval actions',
+ 'chatbox.tool.confirmation.allow': 'Allow',
+ 'chatbox.tool.confirmation.alwaysAllow': 'Always allow',
+ 'chatbox.tool.confirmation.deny': 'Deny',
'mainContent.createTrack': 'Create track',
'mainContent.showGlobalTracks': 'Show global tracks',
'status.chordGuideCandidate': 'Chord Guide Candidate: {name} - {notes} - {note}',
@@ -35,6 +51,18 @@ export const enUsMessages: TranslationMessages = {
'settings.general.llmProvider.openaiCompatible': 'OpenAI Compatible (e.g. OpenRouter, Ollama)',
'settings.general.persistKeys.label': 'Persist API Keys on Non-Localhost',
'settings.general.persistKeys.help': 'When enabled, API keys will be saved to browser storage even on non-localhost environments. Warning: This may increase security vulnerability to XSS attacks.',
+ 'settings.general.autoCompactThreshold.label': 'Auto-Compact Threshold',
+ 'settings.general.autoCompactThreshold.help': 'Compact the conversation before the next request when estimated context usage reaches this level.',
+ 'settings.general.autoCompactThreshold.conservative': 'Conservative (95%)',
+ 'settings.general.autoCompactThreshold.standard': 'Standard (90%)',
+ 'settings.general.autoCompactThreshold.early': 'Early (80%)',
+ 'settings.general.musicAssistant.section': 'K.G.Studio Music Assistant',
+ 'settings.general.musicAssistant.agentMode.label': 'Agent Mode',
+ 'settings.general.musicAssistant.agentMode.help': 'Regular Mode uses the standard system prompt. Efficient Mode uses a compact system prompt and only tools marked for Efficient Mode.',
+ 'settings.general.musicAssistant.agentMode.localOverride': 'Local LLM (Browser) always runs the assistant in Efficient Mode.',
+ 'settings.general.musicAssistant.agentMode.effectiveValue': 'Effective mode: {mode}.',
+ 'settings.general.musicAssistant.agentMode.regular': 'Regular Mode',
+ 'settings.general.musicAssistant.agentMode.efficient': 'Efficient Mode',
'settings.general.localRuntime.cachedStatus': 'Cached Model Status',
'settings.general.localRuntime.cacheChecking': 'Checking local model cache...',
'settings.general.localRuntime.cacheDownloaded': 'Downloaded in browser cache.',
diff --git a/src/i18n/messages/fr_fr.ts b/src/i18n/messages/fr_fr.ts
index 7e36623..5769f7c 100644
--- a/src/i18n/messages/fr_fr.ts
+++ b/src/i18n/messages/fr_fr.ts
@@ -5,17 +5,33 @@ export const frFrMessages: TranslationMessages = {
...enUsMessages,
'app.loading': 'Chargement...',
'assistant.displayName': 'Assistant musical K.G.Studio',
- 'assistant.welcomeFallback': 'Bienvenue dans l’assistant musical K.G.Studio.',
+ 'assistant.welcomeFallback': 'Bienvenue dans l\'assistant musical K.G.Studio.',
+ 'chatbox.todo.title': 'Liste des tâches',
+ 'chatbox.todo.ariaLabel': 'Liste des tâches de l\'agent',
+ 'chatbox.todo.count': '{completed}/{total} terminée(s)',
+ 'chatbox.todo.active': 'En cours : {task}',
+ 'chatbox.history.title': 'Historique des conversations',
+ 'chatbox.history.heading': 'Historique des conversations',
+ 'chatbox.history.cancel': 'Annuler',
+ 'chatbox.history.delete': 'Supprimer',
+ 'chatbox.history.deleteConfirm': 'Supprimer la conversation "{title}" ? Cette action est irréversible.',
+ 'chatbox.history.loading': 'Chargement des conversations...',
+ 'chatbox.history.empty': 'Aucune conversation enregistrée pour l\'instant.',
+ 'chatbox.fastForward.title': 'Approuver rapidement les exécutions d\'outils',
+ 'chatbox.tool.confirmation.ariaLabel': 'Actions d\'approbation d\'exécution d\'outil',
+ 'chatbox.tool.confirmation.allow': 'Autoriser',
+ 'chatbox.tool.confirmation.alwaysAllow': 'Toujours autoriser',
+ 'chatbox.tool.confirmation.deny': 'Refuser',
'mainContent.createTrack': 'Créer une piste',
'mainContent.showGlobalTracks': 'Afficher les pistes globales',
- 'status.chordGuideCandidate': 'Suggestion du guide d’accords : {name} - {notes} - {note}',
+ 'status.chordGuideCandidate': 'Suggestion du guide d\'accords : {name} - {notes} - {note}',
'settings.sidebar.title': 'Réglages',
'settings.sidebar.close': 'Fermer les réglages',
'settings.sidebar.general': 'Général',
'settings.sidebar.audioIo': 'Entrées/Sorties audio',
'settings.sidebar.behavior': 'Comportement',
'settings.sidebar.templates': 'Modèles',
- 'settings.sidebar.chordGuide': 'Guide d’accords',
+ 'settings.sidebar.chordGuide': 'Guide d\'accords',
'settings.general.title': 'Général',
'settings.general.language.label': 'Langue',
'settings.general.language.help': 'Le mode Auto utilise la langue du système.',
@@ -32,6 +48,18 @@ export const frFrMessages: TranslationMessages = {
'settings.general.llmProvider.openaiCompatible': 'Serveur compatible OpenAI (ex. OpenRouter, Ollama)',
'settings.general.persistKeys.label': 'Conserver les clés API hors localhost',
'settings.general.persistKeys.help': 'Si cette option est activée, les clés API sont enregistrées dans le stockage du navigateur même hors localhost. Attention : cela peut augmenter le risque lié aux attaques XSS.',
+ 'settings.general.autoCompactThreshold.label': 'Seuil d’auto-compactage',
+ 'settings.general.autoCompactThreshold.help': 'Compacte la conversation avant la prochaine requête lorsque l’utilisation estimée du contexte atteint ce niveau.',
+ 'settings.general.autoCompactThreshold.conservative': 'Prudent (95 %)',
+ 'settings.general.autoCompactThreshold.standard': 'Standard (90 %)',
+ 'settings.general.autoCompactThreshold.early': 'Précoce (80 %)',
+ 'settings.general.musicAssistant.section': 'Assistant musical K.G.Studio',
+ 'settings.general.musicAssistant.agentMode.label': 'Mode agent',
+ 'settings.general.musicAssistant.agentMode.help': 'Le mode Régulier utilise le prompt système standard. Le mode Efficace utilise un prompt système compact et seulement les outils marqués pour le mode Efficace.',
+ 'settings.general.musicAssistant.agentMode.localOverride': 'Le LLM local (navigateur) exécute toujours l’assistant en mode Efficace.',
+ 'settings.general.musicAssistant.agentMode.effectiveValue': 'Mode effectif : {mode}.',
+ 'settings.general.musicAssistant.agentMode.regular': 'Mode régulier',
+ 'settings.general.musicAssistant.agentMode.efficient': 'Mode efficace',
'settings.general.localRuntime.cachedStatus': 'État du modèle en cache',
'settings.general.localRuntime.cacheChecking': 'Vérification du cache du modèle local...',
'settings.general.localRuntime.cacheDownloaded': 'Téléchargé dans le cache du navigateur.',
@@ -45,17 +73,18 @@ export const frFrMessages: TranslationMessages = {
'settings.general.uvr5.downloadUrl': 'URL de téléchargement UVR-MDX-NET-Inst_HQ_3',
'settings.general.htdemucs.downloadUrl': 'URL de téléchargement htdemucs_4s',
'settings.general.modelUrl.help': 'Modifier cette URL peut empêcher le téléchargement ou pointer vers un fichier modèle incompatible.',
+ 'settings.general.openai.section': 'OpenAI',
'settings.general.openai.key': 'Clé',
'settings.general.openai.keyPlaceholder': 'Saisissez votre clé API OpenAI',
'settings.general.openai.model': 'Modèle',
'settings.general.openai.flexMode': 'Mode Flex',
- 'settings.general.openai.flexHelp': 'Le mode Flex utilise le palier de service flexible d’OpenAI. Avantages : économies possibles et meilleur débit pendant les périodes chargées. Inconvénients : latence variable, mise en file d’attente ou baisse de priorité possibles. S’applique uniquement au fournisseur OpenAI ; sans effet pour les serveurs compatibles OpenAI.',
+ 'settings.general.openai.flexHelp': 'Le mode Flex utilise le palier de service flexible d\'OpenAI. Avantages : économies possibles et meilleur débit pendant les périodes chargées. Inconvénients : latence variable, mise en file d\'attente ou baisse de priorité possibles. S\'applique uniquement au fournisseur OpenAI ; sans effet pour les serveurs compatibles OpenAI.',
'settings.general.keys.persisted': 'Les clés sont conservées localement (IndexedDB du navigateur).',
'settings.general.keys.sessionOnly': 'Par sécurité, les clés ne sont pas conservées sur les hôtes non locaux et restent uniquement en mémoire pour cette session.',
'settings.general.claudeOpenRouter.section': 'Anthropic Claude (via OpenRouter)',
'settings.general.claudeOpenRouter.keyPlaceholder': 'Saisissez votre clé API Claude',
'settings.general.baseUrl': 'URL de base',
- 'settings.general.claudeOpenRouter.baseUrlHelp': 'Il s’agit de l’URL de base de l’API OpenRouter. Ne la modifiez pas sauf si vous savez précisément ce que vous faites.',
+ 'settings.general.claudeOpenRouter.baseUrlHelp': 'Il s\'agit de l\'URL de base de l\'API OpenRouter. Ne la modifiez pas sauf si vous savez précisément ce que vous faites.',
'settings.general.openaiCompatible.section': 'Serveur compatible OpenAI',
'settings.general.openaiCompatible.keyPlaceholder': 'Saisissez votre clé API',
'settings.general.openaiCompatible.baseUrlPlaceholder': 'ex. https://openrouter.ai/api/v1',
@@ -67,75 +96,75 @@ export const frFrMessages: TranslationMessages = {
'settings.general.soundfont.baseUrlHelp': 'Une source de soundfont incompatible peut provoquer des timbres incorrects ou empêcher certains instruments de jouer.',
'settings.general.kgone.section': 'Réglages K.G.One',
'settings.general.kgone.managed': 'La configuration K.G.One est gérée par le serveur (kgone-server.json). Ces réglages sont en lecture seule.',
- 'settings.general.kgone.enabled': 'Activer l’intégration K.G.One',
+ 'settings.general.kgone.enabled': 'Activer l\'intégration K.G.One',
'settings.general.kgone.disabled': 'Désactivé',
'settings.general.kgone.enabledOption': 'Activé',
'settings.general.kgone.serverBaseUrl': 'URL de base du serveur',
- 'settings.general.kgone.serverBaseUrlHelp': 'URL de base d’un serveur K.G.One Music Studio en cours d’exécution. Utilisée pour la génération de morceau complet, de clip et la séparation de stems.',
+ 'settings.general.kgone.serverBaseUrlHelp': 'URL de base d\'un serveur K.G.One Music Studio en cours d\'exécution. Utilisée pour la génération de morceau complet, de clip et la séparation de stems.',
'settings.audioIo.title': 'Entrées/Sorties audio',
'settings.audioIo.deviceRouting': 'Routage des appareils',
'settings.audioIo.refresh': 'Rafraîchir la liste des appareils',
'settings.audioIo.refreshing': 'Rafraîchissement…',
- 'settings.audioIo.description': 'Choisissez les appareils que KGStudio doit utiliser pour l’enregistrement et la lecture. Les changements d’entrée s’appliquent à la prochaine session d’enregistrement. Les changements de sortie demandent un rechargement de la page.',
- 'settings.audioIo.inputDevice': 'Appareil d’entrée audio',
+ 'settings.audioIo.description': 'Choisissez les appareils que KGStudio doit utiliser pour l\'enregistrement et la lecture. Les changements d\'entrée s\'appliquent à la prochaine session d\'enregistrement. Les changements de sortie demandent un rechargement de la page.',
+ 'settings.audioIo.inputDevice': 'Appareil d\'entrée audio',
'settings.audioIo.outputDevice': 'Appareil de sortie audio',
- 'settings.audioIo.inputHelp': 'Les changements s’appliquent au prochain enregistrement audio. Si l’appareil choisi est retiré, KGStudio reviendra à l’appareil système par défaut.',
- 'settings.audioIo.outputHelp': 'Les changements de sortie demandent un rechargement de la page. La prise en charge du routage vers une sortie non par défaut est limitée dans les navigateurs, donc KGStudio continuera à utiliser la sortie système par défaut lorsqu’elle n’est pas prise en charge.',
- 'settings.audioIo.outputSinkUnsupported': 'Ce navigateur n’expose pas de bascule fiable en direct de la sortie Web Audio. La sélection d’une sortie non par défaut reste un effort au mieux et peut finalement rester sur la sortie système par défaut.',
- 'settings.audioIo.chooseOutputDevice': 'Choisir l’appareil de sortie…',
+ 'settings.audioIo.inputHelp': 'Les changements s\'appliquent au prochain enregistrement audio. Si l\'appareil choisi est retiré, KGStudio reviendra à l\'appareil système par défaut.',
+ 'settings.audioIo.outputHelp': 'Les changements de sortie demandent un rechargement de la page. La prise en charge du routage vers une sortie non par défaut est limitée dans les navigateurs, donc KGStudio continuera à utiliser la sortie système par défaut lorsqu\'elle n\'est pas prise en charge.',
+ 'settings.audioIo.outputSinkUnsupported': 'Ce navigateur n\'expose pas de bascule fiable en direct de la sortie Web Audio. La sélection d\'une sortie non par défaut reste un effort au mieux et peut finalement rester sur la sortie système par défaut.',
+ 'settings.audioIo.chooseOutputDevice': 'Choisir l\'appareil de sortie…',
'settings.audioIo.previousSelectedInput': 'Entrée précédemment sélectionnée (autorisation requise pour vérification)',
'settings.audioIo.previousSelectedOutput': 'Sortie précédemment sélectionnée (autorisation requise pour vérification)',
- 'settings.audioIo.status.inputUnavailable': 'L’appareil d’entrée audio précédemment sélectionné n’est pas disponible ; utilisation de l’appareil système par défaut.',
- 'settings.audioIo.status.outputUnavailable': 'L’appareil de sortie audio précédemment sélectionné n’est pas disponible ; utilisation de l’appareil système par défaut.',
+ 'settings.audioIo.status.inputUnavailable': 'L\'appareil d\'entrée audio précédemment sélectionné n\'est pas disponible ; utilisation de l\'appareil système par défaut.',
+ 'settings.audioIo.status.outputUnavailable': 'L\'appareil de sortie audio précédemment sélectionné n\'est pas disponible ; utilisation de l\'appareil système par défaut.',
'settings.audioIo.status.refreshed': 'La liste des appareils audio a été rafraîchie.',
'settings.audioIo.status.readFailed': 'Impossible de lire les appareils audio depuis le navigateur.',
- 'settings.audioIo.status.inputDefaultNextSession': 'L’entrée audio utilisera l’appareil système par défaut lors de la prochaine session d’enregistrement.',
- 'settings.audioIo.status.inputChangedNextSession': 'L’entrée audio changera lors de la prochaine session d’enregistrement.',
- 'settings.audioIo.status.outputDefaultAfterRefresh': 'La sortie audio utilisera l’appareil système par défaut après rechargement.',
- 'settings.audioIo.status.outputSavedRefreshRequired': 'L’appareil de sortie audio a été enregistré. Rechargez la page pour l’appliquer.',
- 'settings.audioIo.status.outputPromptUnsupported': 'Ce navigateur ne prend pas en charge l’invite de sélection d’un appareil de sortie audio.',
- 'settings.audioIo.status.outputSelectedRefreshRequired': 'Appareil de sortie sélectionné. Rechargez la page pour l’appliquer.',
- 'settings.audioIo.status.outputSelectionDenied': 'Le navigateur n’a pas autorisé la sélection d’une sortie non par défaut.',
+ 'settings.audioIo.status.inputDefaultNextSession': 'L\'entrée audio utilisera l\'appareil système par défaut lors de la prochaine session d\'enregistrement.',
+ 'settings.audioIo.status.inputChangedNextSession': 'L\'entrée audio changera lors de la prochaine session d\'enregistrement.',
+ 'settings.audioIo.status.outputDefaultAfterRefresh': 'La sortie audio utilisera l\'appareil système par défaut après rechargement.',
+ 'settings.audioIo.status.outputSavedRefreshRequired': 'L\'appareil de sortie audio a été enregistré. Rechargez la page pour l\'appliquer.',
+ 'settings.audioIo.status.outputPromptUnsupported': 'Ce navigateur ne prend pas en charge l\'invite de sélection d\'un appareil de sortie audio.',
+ 'settings.audioIo.status.outputSelectedRefreshRequired': 'Appareil de sortie sélectionné. Rechargez la page pour l\'appliquer.',
+ 'settings.audioIo.status.outputSelectionDenied': 'Le navigateur n\'a pas autorisé la sélection d\'une sortie non par défaut.',
'settings.behavior.title': 'Comportement',
'settings.behavior.editor': 'Éditeur',
'settings.behavior.playheadUpdateFrequency': 'Fréquence de mise à jour de la tête de lecture (fps)',
- 'settings.behavior.playheadUpdateFrequencyHelp': 'Fréquence de mise à jour de l’animation de la tête de lecture pendant la lecture. Des valeurs plus élevées (60 fps) donnent une animation plus fluide mais utilisent plus de CPU. Des valeurs plus faibles (10 fps) sont plus efficaces. Les changements s’appliquent immédiatement sans redémarrage.',
+ 'settings.behavior.playheadUpdateFrequencyHelp': 'Fréquence de mise à jour de l\'animation de la tête de lecture pendant la lecture. Des valeurs plus élevées (60 fps) donnent une animation plus fluide mais utilisent plus de CPU. Des valeurs plus faibles (10 fps) sont plus efficaces. Les changements s\'appliquent immédiatement sans redémarrage.',
'settings.behavior.spectrogramHeightResolution': 'Résolution verticale du spectrogramme',
'settings.behavior.spectrogramHeightResolutionHelp': 'Contrôle le niveau de détail vertical du spectrogramme sur la plage de hauteurs visible. Des valeurs plus élevées accentuent les contours de hauteur mais consomment davantage de CPU et de mémoire lors du calcul du spectrogramme.',
'settings.behavior.chatBox': 'Boîte de discussion',
'settings.behavior.openAtStartup': 'Ouvrir au démarrage',
'settings.behavior.audio': 'Audio',
- 'settings.behavior.lookaheadTime': 'Temps d’anticipation (ms)',
- 'settings.behavior.lookaheadTimeHelp': 'Temps d’anticipation de planification audio (0-500 ms). Des valeurs plus faibles (10-20 ms) réduisent la latence d’entrée MIDI mais peuvent provoquer des glitches audio sur les systèmes plus lents. Des valeurs plus élevées (100 ms et plus) améliorent la stabilité de lecture. Les changements s’appliquent immédiatement sans redémarrage.',
+ 'settings.behavior.lookaheadTime': 'Temps d\'anticipation (ms)',
+ 'settings.behavior.lookaheadTimeHelp': 'Temps d\'anticipation de planification audio (0-500 ms). Des valeurs plus faibles (10-20 ms) réduisent la latence d\'entrée MIDI mais peuvent provoquer des glitches audio sur les systèmes plus lents. Des valeurs plus élevées (100 ms et plus) améliorent la stabilité de lecture. Les changements s\'appliquent immédiatement sans redémarrage.',
'settings.behavior.playbackDelay': 'Délai de lecture (ms)',
- 'settings.behavior.playbackDelayHelp': 'La lecture démarrera avec un léger délai après avoir appuyé sur le bouton de démarrage (0-500 ms). Augmenter cette valeur peut aider à stabiliser la lecture, surtout pendant les premiers ticks si la valeur d’anticipation est trop faible. Les changements s’appliquent immédiatement sans redémarrage.',
- 'settings.behavior.midiAutomationInterpolation': 'Interpolation de l’automation MIDI',
+ 'settings.behavior.playbackDelayHelp': 'La lecture démarrera avec un léger délai après avoir appuyé sur le bouton de démarrage (0-500 ms). Augmenter cette valeur peut aider à stabiliser la lecture, surtout pendant les premiers ticks si la valeur d\'anticipation est trop faible. Les changements s\'appliquent immédiatement sans redémarrage.',
+ 'settings.behavior.midiAutomationInterpolation': 'Interpolation de l\'automation MIDI',
'settings.behavior.midiAutomationInterpolation.low': 'Économie (20 ms)',
'settings.behavior.midiAutomationInterpolation.balanced': 'Équilibré (10 ms)',
'settings.behavior.midiAutomationInterpolation.high': 'Haute qualité (5 ms)',
- 'settings.behavior.midiAutomationInterpolationHelp': 'Contrôle la densité de cuisson de l’automation MIDI pour la lecture et le bounce. Des intervalles plus courts sonnent plus fluides mais planifient davantage d’événements.',
- 'settings.behavior.midiInputLatency': 'Latence d’entrée MIDI (ms)',
- 'settings.behavior.midiInputLatencyHelp': 'Correction temporelle pour l’enregistrement MIDI (0-500 ms). Si les notes enregistrées apparaissent légèrement en retard par rapport à ce que vous vouliez jouer, augmentez cette valeur pour l’aligner sur la latence d’entrée de votre appareil MIDI. La position de chaque note est reculée de cette valeur lors de la validation.',
+ 'settings.behavior.midiAutomationInterpolationHelp': 'Contrôle la densité de cuisson de l\'automation MIDI pour la lecture et le bounce. Des intervalles plus courts sonnent plus fluides mais planifient davantage d\'événements.',
+ 'settings.behavior.midiInputLatency': 'Latence d\'entrée MIDI (ms)',
+ 'settings.behavior.midiInputLatencyHelp': 'Correction temporelle pour l\'enregistrement MIDI (0-500 ms). Si les notes enregistrées apparaissent légèrement en retard par rapport à ce que vous vouliez jouer, augmentez cette valeur pour l\'aligner sur la latence d\'entrée de votre appareil MIDI. La position de chaque note est reculée de cette valeur lors de la validation.',
'settings.behavior.bounceStartsFromBeat1': 'Le bounce démarre au temps 1',
'settings.behavior.bounceStartsFromBeat1Help': 'Oui inclut le silence initial entre le début du morceau et la première région rendue lors du bounce WAV/MP3. Non supprime ce silence initial et démarre le bounce à la première note rendue ou à la première région audio.',
- 'settings.behavior.captureAudioForScreenSharing': 'Capturer l’audio pour le partage d’écran',
- 'settings.behavior.captureAudioForScreenSharingHelp': 'Redémarrez KGStudio (rechargez la page) pour appliquer ce réglage. Activez cette option lorsque l’audio de KGStudio ne peut pas être capturé pendant le partage d’écran dans les appels vidéo (ex. Zoom, Teams). Cela crée un flux audio supplémentaire que les applications de capture d’écran peuvent détecter.',
- 'settings.behavior.captureAudioForScreenSharingWarning': 'Lorsque vous partagez l’écran dans Zoom, vérifiez bien que l’option "Share Sound" est activée.',
- 'settings.behavior.validation.lookahead.invalidNumber': 'Le temps d’anticipation doit être un nombre valide',
- 'settings.behavior.validation.lookahead.range': 'Le temps d’anticipation doit être compris entre 0 et 0,5 seconde (0-500 ms)',
+ 'settings.behavior.captureAudioForScreenSharing': 'Capturer l\'audio pour le partage d\'écran',
+ 'settings.behavior.captureAudioForScreenSharingHelp': 'Redémarrez KGStudio (rechargez la page) pour appliquer ce réglage. Activez cette option lorsque l\'audio de KGStudio ne peut pas être capturé pendant le partage d\'écran dans les appels vidéo (ex. Zoom, Teams). Cela crée un flux audio supplémentaire que les applications de capture d\'écran peuvent détecter.',
+ 'settings.behavior.captureAudioForScreenSharingWarning': 'Lorsque vous partagez l\'écran dans Zoom, vérifiez bien que l\'option "Share Sound" est activée.',
+ 'settings.behavior.validation.lookahead.invalidNumber': 'Le temps d\'anticipation doit être un nombre valide',
+ 'settings.behavior.validation.lookahead.range': 'Le temps d\'anticipation doit être compris entre 0 et 0,5 seconde (0-500 ms)',
'settings.behavior.validation.playbackDelay.invalidNumber': 'Le délai de lecture doit être un nombre valide',
'settings.behavior.validation.playbackDelay.range': 'Le délai de lecture doit être compris entre 0 et 0,5 seconde (0-500 ms)',
- 'settings.behavior.validation.recordingOffset.invalidNumber': 'La latence d’entrée MIDI doit être un nombre valide',
- 'settings.behavior.validation.recordingOffset.range': 'La latence d’entrée MIDI doit être comprise entre 0 et 0,5 seconde (0-500 ms)',
+ 'settings.behavior.validation.recordingOffset.invalidNumber': 'La latence d\'entrée MIDI doit être un nombre valide',
+ 'settings.behavior.validation.recordingOffset.range': 'La latence d\'entrée MIDI doit être comprise entre 0 et 0,5 seconde (0-500 ms)',
'settings.templates.title': 'Modèles',
'settings.templates.customInstructions': 'Instructions personnalisées',
- 'settings.templates.placeholder': 'Veuillez saisir vos instructions personnalisées pour l’assistant musical K.G.Studio',
- 'settings.chordGuide.title': 'Guide d’accords',
+ 'settings.templates.placeholder': 'Veuillez saisir vos instructions personnalisées pour l\'assistant musical K.G.Studio',
+ 'settings.chordGuide.title': 'Guide d\'accords',
'settings.chordGuide.resetToDefault': 'Rétablir les valeurs par défaut',
'settings.chordGuide.group.major': 'Accords candidats majeurs',
'settings.chordGuide.group.minor': 'Accords candidats mineurs',
- 'settings.chordGuide.help.major': 'Saisissez des accords relatifs à Do majeur. L’application les transposera automatiquement pour les autres armures majeures.',
- 'settings.chordGuide.help.minor': 'Saisissez des accords relatifs à La mineur. L’application les transposera automatiquement pour les autres armures mineures.',
+ 'settings.chordGuide.help.major': 'Saisissez des accords relatifs à Do majeur. L\'application les transposera automatiquement pour les autres armures majeures.',
+ 'settings.chordGuide.help.minor': 'Saisissez des accords relatifs à La mineur. L\'application les transposera automatiquement pour les autres armures mineures.',
'settings.chordGuide.functionsAria': 'Fonctions pour {group}',
'settings.chordGuide.addTitle': 'Ajouter un accord à {group}',
'settings.chordGuide.deleteSelectedRows': 'Supprimer les lignes sélectionnées',
@@ -143,25 +172,26 @@ export const frFrMessages: TranslationMessages = {
'settings.chordGuide.table.notes': 'Notes',
'settings.chordGuide.table.source': 'Source',
'settings.chordGuide.table.note': 'Note',
- 'settings.chordGuide.loadError': 'Impossible de charger les réglages du guide d’accords.',
- 'settings.chordGuide.invalidChordSymbol': 'Veuillez saisir un symbole d’accord valide. Exemple : Bm7b5',
- 'settings.chordGuide.unsupportedChordSymbol': 'Impossible de déduire les notes pour cet accord. Veuillez utiliser un symbole d’accord pris en charge.',
+ 'settings.chordGuide.loadError': 'Impossible de charger les réglages du guide d\'accords.',
+ 'settings.chordGuide.invalidChordSymbol': 'Veuillez saisir un symbole d\'accord valide. Exemple : Bm7b5',
+ 'settings.chordGuide.unsupportedChordSymbol': 'Impossible de déduire les notes pour cet accord. Veuillez utiliser un symbole d\'accord pris en charge.',
'dialog.title.notice': 'Information',
'dialog.title.timeSignature': 'Chiffrage de mesure',
'dialog.title.tempoDetection': 'Détection du tempo',
'dialog.title.applyTempo': 'Appliquer le tempo',
- 'dialog.title.chordDetection': 'Détection d’accords',
+ 'dialog.title.chordDetection': 'Détection d\'accords',
'dialog.title.input': 'Saisie',
'dialog.title.confirm': 'Confirmation',
'dialog.close': 'Fermer la boîte de dialogue',
'dialog.cancel': 'Annuler',
+ 'dialog.ok': 'OK',
'dialog.experimentalFeature': 'Fonction expérimentale',
'dialog.recommendedSource': 'Source recommandée',
- 'dialog.chordHint.audio': 'L’analyse d’accords reste expérimentale. Le contenu harmonique, la densité de l’arrangement et les matériaux riches en transitoires peuvent affecter la précision. Pour de meilleurs résultats, commencez avec les réglages par défaut puis affinez la sensibilité et la stabilité jusqu’à ce que l’harmonie détectée corresponde à la phrase musicale.',
- 'dialog.chordHint.midi': 'L’analyse d’accords reste expérimentale. La densité des voicings, les chevauchements et les notes d’ornement peuvent influencer le résultat. Pour des étiquettes d’accord plus fiables, partez des réglages par défaut puis ajustez la suppression des notes courtes et le focus harmonique selon le rôle musical du passage.',
- 'dialog.chordHint.tempo': 'L’analyse du tempo reste expérimentale. Le rubato, les transitoires clairsemées et les couches de percussions peuvent réduire la précision. Commencez avec la plage de BPM par défaut, puis resserrez la fenêtre d’analyse vers la zone de tempo la plus plausible si le premier résultat n’est pas convaincant musicalement.',
- 'dialog.sourceHint.kgone': 'Pour des étiquettes d’accord plus fiables, analysez de préférence un stem où les voix et les percussions ont été atténuées ou retirées. Si l’intégration du serveur K.G.One Music Studio est disponible, lancez Separator avec le modèle "Vocal, Drums, Bass, Guitar, Piano, and Others" et utilisez de préférence le stem Piano ou Others.',
- 'dialog.sourceHint.local': 'Pour des étiquettes d’accord plus fiables, analysez de préférence un stem où les voix et les percussions ont été atténuées ou retirées. Si vous utilisez le séparateur local, choisissez le modèle "Vocal, Drums, Bass, and Others" et analysez le stem Others.',
+ 'dialog.chordHint.audio': 'L\'analyse d\'accords reste expérimentale. Le contenu harmonique, la densité de l\'arrangement et les matériaux riches en transitoires peuvent affecter la précision. Pour de meilleurs résultats, commencez avec les réglages par défaut puis affinez la sensibilité et la stabilité jusqu\'à ce que l\'harmonie détectée corresponde à la phrase musicale.',
+ 'dialog.chordHint.midi': 'L\'analyse d\'accords reste expérimentale. La densité des voicings, les chevauchements et les notes d\'ornement peuvent influencer le résultat. Pour des étiquettes d\'accord plus fiables, partez des réglages par défaut puis ajustez la suppression des notes courtes et le focus harmonique selon le rôle musical du passage.',
+ 'dialog.chordHint.tempo': 'L\'analyse du tempo reste expérimentale. Le rubato, les transitoires clairsemées et les couches de percussions peuvent réduire la précision. Commencez avec la plage de BPM par défaut, puis resserrez la fenêtre d\'analyse vers la zone de tempo la plus plausible si le premier résultat n\'est pas convaincant musicalement.',
+ 'dialog.sourceHint.kgone': 'Pour des étiquettes d\'accord plus fiables, analysez de préférence un stem où les voix et les percussions ont été atténuées ou retirées. Si l\'intégration du serveur K.G.One Music Studio est disponible, lancez Separator avec le modèle "Vocal, Drums, Bass, Guitar, Piano, and Others" et utilisez de préférence le stem Piano ou Others.',
+ 'dialog.sourceHint.local': 'Pour des étiquettes d\'accord plus fiables, analysez de préférence un stem où les voix et les percussions ont été atténuées ou retirées. Si vous utilisez le séparateur local, choisissez le modèle "Vocal, Drums, Bass, and Others" et analysez le stem Others.',
'dialog.label.sensitivity': 'Sensibilité',
'dialog.label.stability': 'Stabilité',
'dialog.label.noChordThreshold': 'Seuil sans accord',
@@ -176,25 +206,27 @@ export const frFrMessages: TranslationMessages = {
'dialog.label.minimumBpm': 'BPM minimum',
'dialog.label.maximumBpm': 'BPM maximum',
'dialog.label.autoAlignRegionToBeat': 'Aligner automatiquement la région sur le temps',
- 'dialog.message.chordDetection': 'Ajustez les réglages de détection d’accords audio avant le traitement.',
+ 'dialog.message.chordDetection': 'Ajustez les réglages de détection d\'accords audio avant le traitement.',
'dialog.message.tempoDetection': 'Ajustez les réglages de détection du tempo audio avant le traitement.',
- 'dialog.message.tempoApply': 'Tempo détecté : {bpm} BPM. Choisissez comment l’appliquer.\n\nMettre à jour le tempo courant modifie le tempo actif à l’emplacement de ce clip. Insérer un changement de tempo ajoute une nouvelle région de tempo à la mesure la plus proche avant le début du clip.',
+ 'dialog.message.tempoApply': 'Tempo détecté : {bpm} BPM. Choisissez comment l\'appliquer.\n\nMettre à jour le tempo courant modifie le tempo actif à l\'emplacement de ce clip. Insérer un changement de tempo ajoute une nouvelle région de tempo à la mesure la plus proche avant le début du clip.',
'dialog.action.updateCurrentTempo': 'Mettre à jour le tempo courant',
'dialog.action.insertTempoChange': 'Insérer un changement de tempo',
'eventList.title': 'Liste des événements',
- 'eventList.scopeTabs': 'Portées de la liste d’événements',
+ 'eventList.scopeTabs': 'Portées de la liste d\'événements',
'eventList.scope.region': 'Région',
'eventList.scope.track': 'Piste',
'eventList.scope.global': 'Global',
'eventList.deleteVisibleSelectedRows': 'Supprimer les lignes visibles sélectionnées',
'eventList.table.position': 'Position',
'eventList.table.status': 'État',
+ 'eventList.table.num': 'N°',
+ 'eventList.table.val': 'Val.',
'eventList.table.lengthInfo': 'Durée/Infos',
'eventList.region.filters': 'Filtres des événements de région',
'eventList.region.filter.notes': 'Notes',
'eventList.region.filter.pitchBends': 'Pitch bends',
'eventList.region.filter.controller': 'Contrôleurs',
- 'eventList.region.empty': 'Sélectionnez une région MIDI, ou ouvrez-en une dans le Piano Roll, pour afficher sa liste d’événements.',
+ 'eventList.region.empty': 'Sélectionnez une région MIDI, ou ouvrez-en une dans le Piano Roll, pour afficher sa liste d\'événements.',
'eventList.region.add.label': 'Ajouter',
'eventList.region.add.noteTitle': 'Ajouter une note à la tête de lecture',
'eventList.region.add.pitchBendTitle': 'Ajouter un pitch bend à la tête de lecture',
@@ -202,15 +234,21 @@ export const frFrMessages: TranslationMessages = {
'eventList.region.addType.note': 'Note',
'eventList.region.addType.pitchBend': 'Pitch bend',
'eventList.region.addType.controller': 'Contrôleur',
+ 'eventList.region.status.note': 'Note',
+ 'eventList.region.status.pitchBend': 'Pitch bend',
+ 'eventList.region.status.controller': 'Contrôleur',
'eventList.track.filters': 'Modes de la liste de piste',
'eventList.track.filter.regions': 'Régions',
'eventList.track.filter.volume': 'Volume',
'eventList.track.filter.pan': 'Panoramique',
- 'eventList.track.empty': 'Sélectionnez une piste pour afficher les régions et l’automation de piste.',
+ 'eventList.track.empty': 'Sélectionnez une piste pour afficher les régions et l\'automation de piste.',
+ 'eventList.track.add.label': 'Ajouter',
'eventList.track.add.midiRegionTitle': 'Ajouter une région MIDI à la tête de lecture',
- 'eventList.track.add.volumeTitle': 'Ajouter un point d’automation de volume à la tête de lecture',
- 'eventList.track.add.panTitle': 'Ajouter un point d’automation de panoramique à la tête de lecture',
+ 'eventList.track.add.volumeTitle': 'Ajouter un point d\'automation de volume à la tête de lecture',
+ 'eventList.track.add.panTitle': 'Ajouter un point d\'automation de panoramique à la tête de lecture',
'eventList.track.addType.midiRegion': 'Région MIDI',
+ 'eventList.track.status.audio': 'Audio',
+ 'eventList.track.status.midi': 'MIDI',
'eventList.track.status.volume': 'Volume',
'eventList.track.status.pan': 'Panoramique',
'eventList.global.filters': 'Filtres des événements globaux',
@@ -218,10 +256,11 @@ export const frFrMessages: TranslationMessages = {
'eventList.global.filter.tempo': 'Tempo',
'eventList.global.filter.keySignature': 'Armure',
'eventList.global.filter.chord': 'Accord',
+ 'eventList.global.add.label': 'Ajouter',
'eventList.global.add.markerTitle': 'Ajouter une région de repère à la tête de lecture',
'eventList.global.add.tempoTitle': 'Ajouter une région de tempo à la tête de lecture',
- 'eventList.global.add.keySignatureTitle': 'Ajouter une région d’armure à la tête de lecture',
- 'eventList.global.add.chordTitle': 'Ajouter une région d’accord à la tête de lecture',
+ 'eventList.global.add.keySignatureTitle': 'Ajouter une région d\'armure à la tête de lecture',
+ 'eventList.global.add.chordTitle': 'Ajouter une région d\'accord à la tête de lecture',
'eventList.global.addType.marker': 'Repère',
'eventList.global.addType.tempo': 'Tempo',
'eventList.global.addType.keySignature': 'Armure',
@@ -231,29 +270,42 @@ export const frFrMessages: TranslationMessages = {
'eventList.global.status.chord': 'Accord',
'eventList.global.validation.marker': 'Veuillez saisir un libellé de repère. Un texte non vide est attendu. Exemple : Intro',
'eventList.global.validation.tempo': 'Veuillez saisir une valeur BPM en chiffres uniquement. Un entier entre {min} et {max} est attendu. Exemple : 128',
- 'eventList.global.validation.keySignature': 'Veuillez saisir un nom d’armure exact. Une valeur canonique telle que "Do majeur" ou "Fa# mineur" est attendue. Exemple : Fa# mineur',
- 'eventList.global.validation.chord': 'Veuillez saisir un symbole d’accord valide. Une écriture d’accord que l’application peut analyser est attendue. Exemple : Bm7b5',
+ 'eventList.global.validation.keySignature': 'Veuillez saisir un nom d\'armure exact. Une valeur canonique telle que "Do majeur" ou "Fa# mineur" est attendue. Exemple : Fa# mineur',
+ 'eventList.global.validation.chord': 'Veuillez saisir un symbole d\'accord valide. Une écriture d\'accord que l\'application peut analyser est attendue. Exemple : Bm7b5',
'eventList.global.validation.position': 'Veuillez saisir une position au début du projet ou après. Une position non négative est attendue. Exemple : 1 1 0',
'eventList.global.validation.length': 'Veuillez saisir une durée positive. Une valeur supérieure à zéro est attendue. Exemple : 4 0',
'globalTracks.label': 'Pistes globales',
'globalTracks.marker': 'Repère',
+ 'globalTracks.tempo': 'Tempo',
'globalTracks.signature': 'Armure',
'globalTracks.chord': 'Accord',
'globalTracks.addItem': 'Ajouter un élément global {label}',
'pianoRoll.power.sqrtDefault': '√ (par défaut)',
- 'pianoRoll.chordGuide.off': 'Guide d’accords désactivé',
- 'pianoRoll.chordGuide.tonic': 'Guide d’accords tonique',
- 'pianoRoll.chordGuide.subdominant': 'Guide d’accords sous-dominante',
- 'pianoRoll.chordGuide.dominant': 'Guide d’accords dominante',
+ 'pianoRoll.chordGuide.off': 'Guide d\'accords désactivé',
+ 'pianoRoll.chordGuide.tonic': 'Guide d\'accords tonique',
+ 'pianoRoll.chordGuide.subdominant': 'Guide d\'accords sous-dominante',
+ 'pianoRoll.chordGuide.dominant': 'Guide d\'accords dominante',
'pianoRoll.spectrogramView': 'Vue spectrogramme',
'pianoRoll.sheetMusicView': 'Vue partition',
'pianoRoll.pointerTool': 'Outil pointeur',
'pianoRoll.pencilTool': 'Outil crayon',
- 'pianoRoll.toggleAutomationLane': 'Afficher/masquer la voie d’automation',
+ 'pianoRoll.toggleAutomationLane': 'Afficher/masquer la voie d\'automation',
'pianoRoll.automation': 'Automation',
- 'pianoRoll.automationLane': 'Voie d’automation {label}',
+ 'pianoRoll.automationLane': 'Voie d\'automation {label}',
'pianoRoll.automationType.pitchBend': 'Pitch bend',
- 'pianoRoll.chordGuide': 'Guide d’accords',
+ 'pianoRoll.mode': 'Mode',
+ 'pianoRoll.modeOption.ionian': 'Ionien',
+ 'pianoRoll.modeOption.dorian': 'Dorien',
+ 'pianoRoll.modeOption.phrygian': 'Phrygien',
+ 'pianoRoll.modeOption.lydian': 'Lydien',
+ 'pianoRoll.modeOption.mixolydian': 'Mixolydien',
+ 'pianoRoll.modeOption.aeolian': 'Éolien',
+ 'pianoRoll.modeOption.locrian': 'Locrien',
+ 'pianoRoll.modeOption.harmonic_minor': 'Mineur harmonique',
+ 'pianoRoll.modeOption.melodic_minor': 'Mineur mélodique',
+ 'pianoRoll.modeOption.phrygian_dominant': 'Phrygien dominant',
+ 'pianoRoll.modeOption.harmonic_major': 'Majeur harmonique',
+ 'pianoRoll.chordGuide': 'Guide d\'accords',
'pianoRoll.sheetQuantization': 'Quantif. partition',
'pianoRoll.snap': 'Snap',
'pianoRoll.snap.none': 'SANS SNAP',
@@ -262,21 +314,25 @@ export const frFrMessages: TranslationMessages = {
'pianoRoll.floor': 'Plancher',
'pianoRoll.curve': 'Courbe',
'pianoRoll.zoom': 'Zoom',
- 'pianoRoll.moreOptions': 'Plus d’options',
+ 'pianoRoll.moreOptions': 'Plus d\'options',
'pianoRoll.showActiveRegionOnly': 'Afficher seulement la région active',
'pianoRoll.showEntireTrack': 'Afficher toute la piste',
'pianoRoll.detectChords': 'Détecter les accords...',
'pianoRoll.detectingChords': 'Détection des accords...',
'pianoRoll.detectTempo': 'Détecter le tempo...',
'pianoRoll.detectingTempo': 'Détection du tempo...',
+ 'toolbar.export.kgstudio': 'Exporter au format KGStudio',
+ 'toolbar.export.midi': 'Exporter au format MIDI',
+ 'toolbar.export.wav': 'Exporter en WAV',
+ 'toolbar.export.mp3': 'Exporter en MP3',
'toolbar.projectName.prompt': 'Saisissez le nom du projet :',
'toolbar.projectName.invalid': 'Nom de projet invalide. Seules les lettres, les chiffres, les espaces, les tirets, les underscores, les points et les parenthèses sont autorisés.',
'toolbar.projectName.reserved': '"{name}" est un nom de projet réservé. Choisissez-en un autre.',
- 'toolbar.projectName.overwrite': 'Le projet "{name}" existe déjà. Voulez-vous l’écraser ?',
- 'toolbar.projectName.renameOrCopy': 'Souhaitez-vous renommer ce projet ou l’enregistrer comme nouvelle copie ?',
+ 'toolbar.projectName.overwrite': 'Le projet "{name}" existe déjà. Voulez-vous l\'écraser ?',
+ 'toolbar.projectName.renameOrCopy': 'Souhaitez-vous renommer ce projet ou l\'enregistrer comme nouvelle copie ?',
'toolbar.projectName.saveAsCopy': 'Enregistrer comme copie',
'toolbar.projectName.rename': 'Renommer',
- 'toolbar.save.error': 'Une erreur est survenue lors de l’enregistrement : {error}',
+ 'toolbar.save.error': 'Une erreur est survenue lors de l\'enregistrement : {error}',
'toolbar.load.error': 'Une erreur est survenue lors du chargement du projet : {error}',
'toolbar.newProject.confirm': 'Créer un nouveau projet ? Toutes les modifications non enregistrées seront perdues.',
'toolbar.project.notFound': 'Projet "{name}" introuvable.',
@@ -285,25 +341,25 @@ export const frFrMessages: TranslationMessages = {
'toolbar.status.newProjectCreated': 'Nouveau projet "{name}" créé',
'toolbar.status.projectLoaded': '{description} chargé avec succès',
'toolbar.status.projectExportedKgstudio': 'Projet "{name}" exporté au format KGStudio',
- 'toolbar.status.exportProjectError': 'Erreur lors de l’export du projet : {error}',
- 'toolbar.export.failedProject': 'Échec de l’export du projet : {error}',
+ 'toolbar.status.exportProjectError': 'Erreur lors de l\'export du projet : {error}',
+ 'toolbar.export.failedProject': 'Échec de l\'export du projet : {error}',
'toolbar.status.projectExportedMidi': 'Projet "{name}" exporté au format MIDI',
- 'toolbar.status.exportMidiError': 'Erreur lors de l’export MIDI : {error}',
- 'toolbar.export.failedMidi': 'Échec de l’export du projet en MIDI : {error}',
+ 'toolbar.status.exportMidiError': 'Erreur lors de l\'export MIDI : {error}',
+ 'toolbar.export.failedMidi': 'Échec de l\'export du projet en MIDI : {error}',
'toolbar.status.projectExportedWav': 'Projet "{name}" exporté en WAV',
- 'toolbar.status.exportWavError': 'Erreur lors de l’export WAV : {error}',
- 'toolbar.export.failedWav': 'Échec de l’export du projet en WAV : {error}',
+ 'toolbar.status.exportWavError': 'Erreur lors de l\'export WAV : {error}',
+ 'toolbar.export.failedWav': 'Échec de l\'export du projet en WAV : {error}',
'toolbar.status.projectExportedMp3': 'Projet "{name}" exporté en MP3',
- 'toolbar.status.exportMp3Error': 'Erreur lors de l’export MP3 : {error}',
- 'toolbar.export.failedMp3': 'Échec de l’export du projet en MP3 : {error}',
- 'toolbar.status.importFailed': 'Échec de l’import du fichier : {error}',
- 'toolbar.import.failedProjectFile': 'Échec de l’import du fichier projet : {error}',
+ 'toolbar.status.exportMp3Error': 'Erreur lors de l\'export MP3 : {error}',
+ 'toolbar.export.failedMp3': 'Échec de l\'export du projet en MP3 : {error}',
+ 'toolbar.status.importFailed': 'Échec de l\'import du fichier : {error}',
+ 'toolbar.import.failedProjectFile': 'Échec de l\'import du fichier projet : {error}',
'toolbar.import.corruptedKgstudio': 'Le fichier .kgstudio est corrompu ou invalide : {error}',
'toolbar.status.importingMidi': 'Import du fichier MIDI "{name}"...',
- 'toolbar.status.failedImportMidi': 'Échec de l’import MIDI : {error}',
+ 'toolbar.status.failedImportMidi': 'Échec de l\'import MIDI : {error}',
'toolbar.status.playbackFailedStart': 'Impossible de démarrer la lecture',
'toolbar.status.recordingStoppedCommitted': 'Enregistrement arrêté, notes validées',
- 'toolbar.status.failedStopPlayback': 'Impossible d’arrêter la lecture',
+ 'toolbar.status.failedStopPlayback': 'Impossible d\'arrêter la lecture',
'toolbar.maxBars.prompt': 'Saisissez le nouveau nombre maximal de mesures (>= {min}) :',
'toolbar.input.invalidNumber': 'Entrée invalide. Veuillez saisir un nombre valide.',
'toolbar.maxBars.invalid': 'Valeur invalide. Veuillez saisir un nombre >= {min}.',
@@ -324,13 +380,14 @@ export const frFrMessages: TranslationMessages = {
'toolbar.redo.none': 'Rien à rétablir',
'toolbar.status.undid': 'Action annulée : {description}',
'toolbar.status.redid': 'Action rétablie : {description}',
+ 'toolbar.action': 'action',
'toolbar.status.chatToggled': 'Chat affiché/masqué',
'toolbar.status.settingsToggled': 'Réglages affichés/masqués',
'toolbar.pianoRoll.selectMidiRegion': 'Sélectionnez une région MIDI pour ouvrir le Piano Roll.',
'toolbar.status.recordingStopped': 'Enregistrement arrêté',
'toolbar.status.audioRecordingStarted': 'Enregistrement audio démarré...',
- 'toolbar.recording.openMidiRegion': 'Ouvrez une région MIDI dans le Piano Roll avant de lancer l’enregistrement.',
- 'toolbar.recording.selectMidiRegion': 'Sélectionnez une région MIDI avant de lancer l’enregistrement.',
+ 'toolbar.recording.openMidiRegion': 'Ouvrez une région MIDI dans le Piano Roll avant de lancer l\'enregistrement.',
+ 'toolbar.recording.selectMidiRegion': 'Sélectionnez une région MIDI avant de lancer l\'enregistrement.',
'toolbar.recording.noMidiDevice': 'Aucun périphérique MIDI détecté. Connectez un clavier MIDI puis réessayez.',
'toolbar.status.recordingStarted': 'Enregistrement démarré...',
'toolbar.button.new': 'Nouveau',
@@ -352,25 +409,30 @@ export const frFrMessages: TranslationMessages = {
'toolbar.button.backToBeginning': 'Retour au début',
'toolbar.button.play': 'Lecture',
'toolbar.button.pause': 'Pause',
- 'toolbar.button.stopRecording': 'Arrêter l’enregistrement',
+ 'toolbar.button.stopRecording': 'Arrêter l\'enregistrement',
'toolbar.button.record': 'Enregistrer',
'toolbar.button.loop': 'Boucle',
'toolbar.button.metronome': 'Métronome',
'toolbar.button.piano': 'Piano',
'toolbar.button.kgone': 'Générateur musical K.G.One',
'toolbar.button.chat': 'Chat',
- 'toolbar.button.eventList': 'Éditeur de liste d’événements',
+ 'toolbar.button.eventList': 'Éditeur de liste d\'événements',
'track.controls.automationButton': 'Automation de piste',
'track.controls.automationDropdown': 'Automation',
'track.controls.automation.volume': 'Volume',
'track.controls.automation.pan': 'Panoramique',
'track.controls.settingsDropdown': 'Réglages',
'track.controls.settings.deleteTrack': 'Supprimer la piste',
- 'toolbar.keySignatureChooser': 'Choisir l’armure, actuelle : {value}',
+ 'toolbar.keySignatureChooser': 'Choisir l\'armure, actuelle : {value}',
'toolbar.export.label': 'Exporter',
'toolbar.importProject.title': 'Importer un projet',
'toolbar.importProject.description': 'Glissez-déposez votre fichier projet ici',
'toolbar.openingProject': 'Ouverture du projet...',
+ 'fileImport.invalidType': 'Type de fichier invalide. Veuillez sélectionner un fichier avec l\'une de ces extensions : {extensions}',
+ 'fileImport.close': 'Fermer la fenêtre d\'import',
+ 'fileImport.supportedFormats': 'Formats pris en charge : {formats}',
+ 'fileImport.or': 'ou',
+ 'fileImport.browse': 'Parcourir les fichiers',
'instrument.group.PIANO_AND_KEYBOARDS': 'Pianos et claviers',
'instrument.group.GUITAR': 'Guitare',
'instrument.group.BASS': 'Basse',
@@ -451,51 +513,68 @@ export const frFrMessages: TranslationMessages = {
'kgone.panel.title.server': 'Générateur musical K.G.One',
'kgone.panel.title.local': 'Générateur musical',
'kgone.tab.fullSong': 'Morceau complet',
+ 'kgone.tab.remix': 'Remix',
+ 'kgone.tab.repaint': 'Repaint',
'kgone.tab.separator': 'Séparateur',
- 'kgone.tab.requiresServer': 'Nécessite l’intégration du serveur K.G.One Music Studio',
+ 'kgone.tab.requiresServer': 'Nécessite l\'intégration du serveur K.G.One Music Studio',
'kgone.shared.advancedSettings': 'Réglages avancés',
'kgone.shared.caption': 'Description',
'kgone.shared.lyrics': 'Paroles',
+ 'kgone.shared.lyricsPlaceholder': '[Verse 1]\nVos paroles ici...\n\n[Chorus]\n...',
'kgone.shared.instrumental': 'Instrumental (sans voix)',
- 'kgone.shared.inferenceSteps': 'Étapes d’inférence',
+ 'kgone.shared.inferenceSteps': 'Étapes d\'inférence',
'kgone.shared.guidanceScale': 'Échelle de guidage',
'kgone.shared.useRandomSeed': 'Utiliser une seed aléatoire',
'kgone.shared.seed': 'Seed',
'kgone.shared.thinking': 'Réflexion (génération de métadonnées CoT)',
'kgone.shared.selectedRegion': 'Région sélectionnée',
'kgone.shared.track': 'Piste',
+ 'kgone.shared.poweredBy': 'Propulsé par ',
'kgone.shared.btn.loadingModel': 'Chargement du modèle...',
'kgone.shared.btn.generating': 'Génération...',
'kgone.shared.btn.processing': 'Traitement...',
'kgone.shared.btn.downloading': 'Téléchargement...',
- 'kgone.shared.btn.preparingUpload': 'Préparation de l’envoi...',
+ 'kgone.shared.btn.preparingUpload': 'Préparation de l\'envoi...',
'kgone.shared.btn.importing': 'Import...',
'kgone.shared.hint.loadingModel': 'Chargement du modèle : cela peut prendre plus de 60 secondes, veuillez patienter...',
'kgone.shared.hint.submitting': 'Envoi de la demande de génération...',
- 'kgone.shared.hint.downloadingAudio': 'Téléchargement de l’audio...',
+ 'kgone.shared.hint.downloadingAudio': 'Téléchargement de l\'audio...',
'kgone.clip.field.prompt': 'Prompt',
- 'kgone.clip.field.promptHint': 'Décrivez le clip avec des tags séparés par des virgules : famille d’instrument, sous-type, timbre, effets, nombre de mesures, BPM, tonalité.',
+ 'kgone.clip.field.promptPlaceholder': 'ex. Gritty, Acid, Bassline, 303, Synth Lead, FM, Sub, Upper Mids, High Phaser, High Reverb, Pitch Bend, 8 Bars, 140 BPM, E minor',
+ 'kgone.clip.field.promptHint': 'Décrivez le clip avec des tags séparés par des virgules : famille d\'instrument, sous-type, timbre, effets, nombre de mesures, BPM, tonalité.',
'kgone.clip.field.negativePrompt': 'Prompt négatif',
+ 'kgone.clip.field.negativePromptPlaceholder': 'ex. distorsion, bruit',
'kgone.clip.field.bars': 'Mesures',
+ 'kgone.clip.field.bars4': '4 mesures',
+ 'kgone.clip.field.bars8': '8 mesures',
'kgone.clip.field.note': 'Note',
'kgone.clip.field.scale': 'Mode',
'kgone.clip.field.scaleMajor': 'Majeur',
'kgone.clip.field.scaleMinor': 'Mineur',
+ 'kgone.clip.field.bpm': 'BPM',
'kgone.clip.field.steps': 'Étapes',
+ 'kgone.clip.field.cfgScale': 'Échelle CFG',
'kgone.clip.field.seedLabel': 'Seed (-1 = aléatoire)',
'kgone.clip.field.samplerType': 'Type de sampler',
+ 'kgone.clip.field.sigmaMin': 'Sigma Min',
+ 'kgone.clip.field.sigmaMax': 'Sigma Max',
+ 'kgone.clip.field.cfgRescale': 'Remise à l\'échelle CFG',
'kgone.clip.hint.generating': 'Génération du clip...',
- 'kgone.clip.hint.drag': 'Glissez le lecteur ci-dessus vers une piste pour importer le clip. Déposez-le sur une piste audio pour l’importer comme région WAV (recommandé), ou sur une piste MIDI pour l’importer comme région MIDI. Remarque : le MIDI est retranscrit depuis l’audio et peut ne pas être parfaitement exact.',
+ 'kgone.clip.hint.drag': 'Glissez le lecteur ci-dessus vers une piste pour importer le clip. Déposez-le sur une piste audio pour l\'importer comme région WAV (recommandé), ou sur une piste MIDI pour l\'importer comme région MIDI. Remarque : le MIDI est retranscrit depuis l\'audio et peut ne pas être parfaitement exact.',
'kgone.clip.btn.generate': 'Générer le clip',
- 'kgone.fullSong.field.captionHint': 'Décrivez en langage naturel le style, l’ambiance, le tempo, l’instrumentation et la structure du morceau.',
+ 'kgone.clip.poweredBy': 'Foundation-1',
+ 'kgone.fullSong.field.captionPlaceholder': 'ex. Genre : eurodance, pop électronique des années 90, dynamique. Style : accrocheur, énergique... Tempo : ~130 BPM. Instrumentation : grosse caisse puissante, ligne de basse eurodance...',
+ 'kgone.fullSong.field.captionHint': 'Décrivez en langage naturel le style, l\'ambiance, le tempo, l\'instrumentation et la structure du morceau.',
'kgone.fullSong.field.lyricsHint': 'Utilisez les balises [Intro], [Verse], [Chorus], [Bridge] pour marquer les sections.',
'kgone.fullSong.hint.generating': 'Génération...',
'kgone.fullSong.hint.generatingProgress': 'Génération... {pct}% — {stage}',
'kgone.fullSong.hint.generatingProgressNoStage': 'Génération... {pct}%',
- 'kgone.fullSong.hint.drag': 'Glissez le lecteur ci-dessus vers une piste audio pour importer le morceau. Le dépôt sur une piste MIDI n’est pas pris en charge pour la génération de morceau complet.',
+ 'kgone.fullSong.hint.drag': 'Glissez le lecteur ci-dessus vers une piste audio pour importer le morceau. Le dépôt sur une piste MIDI n\'est pas pris en charge pour la génération de morceau complet.',
'kgone.fullSong.btn.generate': 'Générer le morceau',
- 'kgone.remix.field.captionHint': 'Décrivez le style, l’ambiance et l’instrumentation visés pour le remix.',
- 'kgone.remix.field.lyricsHint': 'Laissez vide pour conserver les paroles d’origine, ou saisissez-en de nouvelles. Utilisez les balises [Verse], [Chorus], [Bridge].',
+ 'kgone.fullSong.poweredBy': 'ACE-Step 1.5',
+ 'kgone.remix.field.captionPlaceholder': 'ex. Genre : jazz, style : doux et mélodieux, instrumentation : piano, contrebasse, batterie brossée...',
+ 'kgone.remix.field.captionHint': 'Décrivez le style, l\'ambiance et l\'instrumentation visés pour le remix.',
+ 'kgone.remix.field.lyricsHint': 'Laissez vide pour conserver les paroles d\'origine, ou saisissez-en de nouvelles. Utilisez les balises [Verse], [Chorus], [Bridge].',
'kgone.remix.field.coverStrength': 'Intensité de reprise',
'kgone.remix.field.coverStrengthHint': '0 = créatif, 1 = fidèle à la structure source',
'kgone.remix.field.noiseStrength': 'Intensité du bruit',
@@ -509,15 +588,17 @@ export const frFrMessages: TranslationMessages = {
'kgone.remix.btn.generate': 'Générer le remix',
'kgone.remix.btn.processingRemix': 'Traitement du remix...',
'kgone.remix.btn.importAligned': 'Importer aligné sur la source',
+ 'kgone.remix.poweredBy': 'ACE-Step 1.5',
'kgone.repaint.field.repaintRange': 'Plage de repaint',
- 'kgone.repaint.field.loopModeOff': 'Le mode boucle est désactivé. Activez le bouton boucle dans la barre d’outils et définissez une plage de boucle pour fixer la fenêtre de repaint.',
+ 'kgone.repaint.field.captionPlaceholder': 'ex. Genre : jazz, style : doux et mélodieux, instrumentation : piano, contrebasse, batterie brossée...',
+ 'kgone.repaint.field.loopModeOff': 'Le mode boucle est désactivé. Activez le bouton boucle dans la barre d\'outils et définissez une plage de boucle pour fixer la fenêtre de repaint.',
'kgone.repaint.field.start': 'Début',
'kgone.repaint.field.end': 'Fin',
- 'kgone.repaint.field.untilEnd': 'jusqu’à la fin de l’audio',
- 'kgone.repaint.field.captionHint': 'Décrivez le style cible et l’instrumentation de la section repeinte.',
- 'kgone.repaint.field.lyricsHint': 'Laissez vide pour conserver les paroles d’origine, ou saisissez-en de nouvelles pour la section repeinte.',
+ 'kgone.repaint.field.untilEnd': 'jusqu\'à la fin de l\'audio',
+ 'kgone.repaint.field.captionHint': 'Décrivez le style cible et l\'instrumentation de la section repeinte.',
+ 'kgone.repaint.field.lyricsHint': 'Laissez vide pour conserver les paroles d\'origine, ou saisissez-en de nouvelles pour la section repeinte.',
'kgone.repaint.field.repaintStrength': 'Intensité du repaint',
- 'kgone.repaint.field.repaintStrengthHint': '0 = préserver l’original, 1 = régénération complète',
+ 'kgone.repaint.field.repaintStrengthHint': '0 = préserver l\'original, 1 = régénération complète',
'kgone.repaint.hint.submitting': 'Envoi de la demande de repaint...',
'kgone.repaint.hint.generating': 'Génération du repaint...',
'kgone.repaint.hint.generatingProgress': 'Génération du repaint... {pct}% — {stage}',
@@ -527,23 +608,32 @@ export const frFrMessages: TranslationMessages = {
'kgone.repaint.btn.generate': 'Générer le repaint',
'kgone.repaint.btn.processingRepaint': 'Traitement du repaint...',
'kgone.repaint.btn.importAligned': 'Importer aligné sur la source',
+ 'kgone.repaint.poweredBy': 'ACE-Step 1.5',
'kgone.separator.field.separationModel': 'Modèle de séparation',
'kgone.separator.field.chunkDuration': 'Durée optionnelle des segments audio (secondes)',
'kgone.separator.field.chunkDurationPlaceholder': 'Laisser vide pour traiter toute la région',
'kgone.separator.field.modelOverlap': 'Recouvrement du modèle',
- 'kgone.separator.hint.drag': 'Glissez chaque lecteur de stem ci-dessus vers une piste audio pour l’importer. Le dépôt sur une piste MIDI n’est pas pris en charge pour la séparation de stems.',
+ 'kgone.separator.hint.drag': 'Glissez chaque lecteur de stem ci-dessus vers une piste audio pour l\'importer. Le dépôt sur une piste MIDI n\'est pas pris en charge pour la séparation de stems.',
'kgone.separator.hint.noRegion.download': 'Téléchargez {model}, puis sélectionnez une région audio sur la timeline pour en extraire les stems.',
'kgone.separator.hint.noRegion.select': 'Sélectionnez une région audio sur la timeline pour en extraire les stems. Seules les régions audio sont prises en charge ; les régions MIDI ne peuvent pas être séparées.',
'kgone.separator.btn.separateStems': 'Séparer les stems',
'kgone.separator.btn.importAllStems': 'Importer tous les stems sur la timeline',
+ 'kgone.separator.poweredBy': 'UVR5 CLI',
+ 'kgone.separator.model.vocalInstMedium': 'Voix et instruments (précision moyenne)',
+ 'kgone.separator.model.vocalInstHigh': 'Voix et instruments (haute précision)',
+ 'kgone.separator.model.6stem': 'Voix, batterie, basse, guitare, piano et autres',
+ 'kgone.separator.model.htdemucs4s': 'Voix, batterie, basse et autres',
+ 'kgone.separator.btn.server.loadingModel': 'Chargement du modèle...',
+ 'kgone.separator.btn.server.generating': 'Préparation de l\'envoi...',
'kgone.separator.btn.server.polling': 'Séparation des stems...',
+ 'kgone.separator.btn.server.downloading': 'Téléchargement...',
'kgone.separator.btn.local.loadingModel': 'Préparation du modèle local...',
- 'kgone.separator.btn.local.generating': 'Préparation de l’audio...',
+ 'kgone.separator.btn.local.generating': 'Préparation de l\'audio...',
'kgone.separator.btn.local.polling': 'Séparation locale en cours...',
'kgone.separator.btn.local.downloading': 'Finalisation...',
'kgone.separator.local.title': 'Mode séparateur local',
- 'kgone.separator.local.description': 'Si K.G.One Music Studio n’est pas disponible, le mode séparateur local offre une solution intégrée pour extraire des stems directement dans votre navigateur. Deux modèles locaux sont disponibles : Vocal and Instrument (Medium Accuracy), et Vocal, Drums, Bass, and Others. L’état de téléchargement ci-dessous correspond au modèle actuellement sélectionné. Vocal and Instrument (Medium Accuracy) prend généralement plus de temps à traiter que Vocal, Drums, Bass, and Others, et la durée totale dépendra tout de même de votre matériel. Si le traitement bascule sur le CPU, la page peut devenir temporairement moins réactive pendant la séparation.',
- 'kgone.separator.local.learnMore': 'En savoir plus sur l’intégration du serveur K.G.One Music Studio.',
+ 'kgone.separator.local.description': 'Si K.G.One Music Studio n\'est pas disponible, le mode séparateur local offre une solution intégrée pour extraire des stems directement dans votre navigateur. Deux modèles locaux sont disponibles : Vocal and Instrument (Medium Accuracy), et Vocal, Drums, Bass, and Others. L\'état de téléchargement ci-dessous correspond au modèle actuellement sélectionné. Vocal and Instrument (Medium Accuracy) prend généralement plus de temps à traiter que Vocal, Drums, Bass, and Others, et la durée totale dépendra tout de même de votre matériel. Si le traitement bascule sur le CPU, la page peut devenir temporairement moins réactive pendant la séparation.',
+ 'kgone.separator.local.learnMore': 'En savoir plus sur l\'intégration du serveur K.G.One Music Studio.',
'kgone.separator.local.provider': 'Fournisseur : ',
'kgone.separator.local.model': 'Modèle : ',
'kgone.separator.local.downloaded': 'téléchargé',
@@ -563,4 +653,34 @@ export const frFrMessages: TranslationMessages = {
'kgone.separator.local.progress.readingAudio': 'Lecture du fichier audio...',
'kgone.separator.local.progress.running': 'Séparation dans le navigateur en cours...',
'kgone.separator.local.progress.complete': 'Séparation terminée.',
+
+ 'openProjectModal.title': 'Ouvrir un projet',
+ 'openProjectModal.close': 'Fermer',
+ 'openProjectModal.loading': 'Chargement des projets...',
+ 'openProjectModal.date.unknown': 'Inconnu',
+ 'openProjectModal.filter.placeholder': 'Filtrer les projets...',
+ 'openProjectModal.viewToggle.projects': 'Projets',
+ 'openProjectModal.viewToggle.trash': 'Corbeille',
+ 'openProjectModal.trash.hint': 'Les projets supprimés sont automatiquement retirés après 30 jours.',
+ 'openProjectModal.sort.name': 'Nom',
+ 'openProjectModal.sort.created': 'Créé',
+ 'openProjectModal.sort.updated': 'Mis à jour',
+ 'openProjectModal.empty.trash': 'La corbeille est vide',
+ 'openProjectModal.empty.noProjects': 'Aucun projet enregistré',
+ 'openProjectModal.empty.noMatch': 'Aucun projet ne correspond au filtre',
+ 'openProjectModal.meta.created': 'Créé le {date}',
+ 'openProjectModal.meta.updated': 'Mis à jour le {date}',
+ 'openProjectModal.meta.deleted': 'Supprimé le {date}',
+ 'openProjectModal.action.open': 'Ouvrir',
+ 'openProjectModal.action.duplicate': 'Dupliquer',
+ 'openProjectModal.action.delete': 'Supprimer',
+ 'openProjectModal.action.restore': 'Restaurer',
+ 'openProjectModal.action.deletePermanently': 'Supprimer définitivement',
+ 'openProjectModal.dialog.duplicateName': 'Saisissez un nom pour le projet dupliqué :',
+ 'openProjectModal.dialog.invalidName': 'Nom de projet invalide. Seules les lettres, les chiffres, les espaces, les tirets, les underscores, les points et les parenthèses sont autorisés.',
+ 'openProjectModal.dialog.duplicateError': 'Échec de la duplication du projet : {error}',
+ 'openProjectModal.dialog.deleteError': 'Échec de la suppression du projet : {error}',
+ 'openProjectModal.dialog.restoreError': 'Échec de la restauration du projet : {error}',
+ 'openProjectModal.dialog.confirmPermanentDelete': 'Êtes-vous sûr de vouloir supprimer définitivement « {name} » ?\n\nCette opération est irréversible.',
+ 'openProjectModal.dialog.permanentDeleteError': 'Échec de la suppression définitive du projet : {error}',
};
diff --git a/src/i18n/messages/zh_cn.ts b/src/i18n/messages/zh_cn.ts
index bc51cdc..23b654f 100644
--- a/src/i18n/messages/zh_cn.ts
+++ b/src/i18n/messages/zh_cn.ts
@@ -6,6 +6,22 @@ export const zhCnMessages: TranslationMessages = {
'app.loading': '加载中...',
'assistant.displayName': 'K.G.Studio 音乐创作助手',
'assistant.welcomeFallback': '欢迎使用 K.G.Studio 音乐创作助手。',
+ 'chatbox.todo.title': '任务清单',
+ 'chatbox.todo.ariaLabel': '代理任务清单',
+ 'chatbox.todo.count': '已完成 {completed}/{total}',
+ 'chatbox.todo.active': '当前进行中:{task}',
+ 'chatbox.history.title': '对话历史',
+ 'chatbox.history.heading': '对话历史',
+ 'chatbox.history.cancel': '取消',
+ 'chatbox.history.delete': '删除',
+ 'chatbox.history.deleteConfirm': '要删除对话“{title}”吗?此操作无法撤销。',
+ 'chatbox.history.loading': '正在加载对话...',
+ 'chatbox.history.empty': '还没有已保存的对话。',
+ 'chatbox.fastForward.title': '快速放行工具执行审批',
+ 'chatbox.tool.confirmation.ariaLabel': '工具执行审批操作',
+ 'chatbox.tool.confirmation.allow': '允许',
+ 'chatbox.tool.confirmation.alwaysAllow': '始终允许',
+ 'chatbox.tool.confirmation.deny': '拒绝',
'mainContent.createTrack': '创建轨道',
'mainContent.showGlobalTracks': '显示全局轨道',
'status.chordGuideCandidate': '和弦指导候选: {name} - {notes} - {note}',
@@ -33,6 +49,18 @@ export const zhCnMessages: TranslationMessages = {
'settings.general.llmProvider.openaiCompatible': 'OpenAI 兼容服务(例如 OpenRouter、Ollama)',
'settings.general.persistKeys.label': '在非 localhost 环境持久化 API Key',
'settings.general.persistKeys.help': '启用后,即使在非 localhost 环境下,API Key 也会保存到浏览器存储中。警告:这可能增加 XSS 攻击带来的安全风险。',
+ 'settings.general.autoCompactThreshold.label': '自动压缩阈值',
+ 'settings.general.autoCompactThreshold.help': '当预估上下文使用量达到此级别时,在下一次请求前压缩对话。',
+ 'settings.general.autoCompactThreshold.conservative': '保守(95%)',
+ 'settings.general.autoCompactThreshold.standard': '标准(90%)',
+ 'settings.general.autoCompactThreshold.early': '提前(80%)',
+ 'settings.general.musicAssistant.section': 'K.G.Studio 音乐创作助手',
+ 'settings.general.musicAssistant.agentMode.label': 'Agent 模式',
+ 'settings.general.musicAssistant.agentMode.help': '常规模式使用标准系统提示词。高效模式使用紧凑系统提示词,并且只允许使用标记为可用于高效模式的工具。',
+ 'settings.general.musicAssistant.agentMode.localOverride': '本地 LLM(浏览器)始终以高效模式运行助手。',
+ 'settings.general.musicAssistant.agentMode.effectiveValue': '当前生效模式:{mode}。',
+ 'settings.general.musicAssistant.agentMode.regular': '常规模式',
+ 'settings.general.musicAssistant.agentMode.efficient': '高效模式',
'settings.general.localRuntime.cachedStatus': '缓存模型状态',
'settings.general.localRuntime.cacheChecking': '正在检查本地模型缓存...',
'settings.general.localRuntime.cacheDownloaded': '已下载到浏览器缓存。',
diff --git a/src/i18n/messages/zh_hk.ts b/src/i18n/messages/zh_hk.ts
index 40dceb5..cb548c0 100644
--- a/src/i18n/messages/zh_hk.ts
+++ b/src/i18n/messages/zh_hk.ts
@@ -6,6 +6,22 @@ export const zhHkMessages: TranslationMessages = {
'app.loading': '載入中...',
'assistant.displayName': 'K.G.Studio 音樂創作助手',
'assistant.welcomeFallback': '歡迎使用 K.G.Studio 音樂創作助手。',
+ 'chatbox.todo.title': '任務清單',
+ 'chatbox.todo.ariaLabel': '代理任務清單',
+ 'chatbox.todo.count': '已完成 {completed}/{total}',
+ 'chatbox.todo.active': '當前進行中:{task}',
+ 'chatbox.history.title': '對話歷史',
+ 'chatbox.history.heading': '對話歷史',
+ 'chatbox.history.cancel': '取消',
+ 'chatbox.history.delete': '刪除',
+ 'chatbox.history.deleteConfirm': '要刪除對話「{title}」嗎?此操作無法撤銷。',
+ 'chatbox.history.loading': '正在載入對話...',
+ 'chatbox.history.empty': '還沒有已儲存的對話。',
+ 'chatbox.fastForward.title': '快速放行工具執行審批',
+ 'chatbox.tool.confirmation.ariaLabel': '工具執行審批操作',
+ 'chatbox.tool.confirmation.allow': '允許',
+ 'chatbox.tool.confirmation.alwaysAllow': '始終允許',
+ 'chatbox.tool.confirmation.deny': '拒絕',
'mainContent.createTrack': '建立音軌',
'mainContent.showGlobalTracks': '顯示全域音軌',
'status.chordGuideCandidate': '和弦指導候選: {name} - {notes} - {note}',
@@ -33,6 +49,18 @@ export const zhHkMessages: TranslationMessages = {
'settings.general.llmProvider.openaiCompatible': 'OpenAI 兼容服務(例如 OpenRouter、Ollama)',
'settings.general.persistKeys.label': '在非 localhost 環境持久化 API Key',
'settings.general.persistKeys.help': '啟用後,即使在非 localhost 環境下,API Key 也會儲存到瀏覽器儲存空間中。警告:這可能增加 XSS 攻擊帶來的安全風險。',
+ 'settings.general.autoCompactThreshold.label': '自動壓縮閾值',
+ 'settings.general.autoCompactThreshold.help': '當預估上下文使用量達到此級別時,在下一次請求前壓縮對話。',
+ 'settings.general.autoCompactThreshold.conservative': '保守(95%)',
+ 'settings.general.autoCompactThreshold.standard': '標準(90%)',
+ 'settings.general.autoCompactThreshold.early': '提前(80%)',
+ 'settings.general.musicAssistant.section': 'K.G.Studio 音樂創作助手',
+ 'settings.general.musicAssistant.agentMode.label': 'Agent 模式',
+ 'settings.general.musicAssistant.agentMode.help': '常規模式使用標準系統提示詞。高效模式使用精簡系統提示詞,並且只允許使用標記為可用於高效模式的工具。',
+ 'settings.general.musicAssistant.agentMode.localOverride': '本地 LLM(瀏覽器)會固定以高效模式執行助手。',
+ 'settings.general.musicAssistant.agentMode.effectiveValue': '目前生效模式:{mode}。',
+ 'settings.general.musicAssistant.agentMode.regular': '常規模式',
+ 'settings.general.musicAssistant.agentMode.efficient': '高效模式',
'settings.general.localRuntime.cachedStatus': '緩存模型狀態',
'settings.general.localRuntime.cacheChecking': '正在檢查本地模型緩存...',
'settings.general.localRuntime.cacheDownloaded': '已下載到瀏覽器緩存。',
diff --git a/src/stores/projectStore.test.ts b/src/stores/projectStore.test.ts
index c4dd175..711c71d 100644
--- a/src/stores/projectStore.test.ts
+++ b/src/stores/projectStore.test.ts
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { act } from '@testing-library/react';
import { KGTrack } from '../core/track/KGTrack';
import { KGMidiTrack } from '../core/track/KGMidiTrack';
+import { createDefaultGlobalTracks } from '../core/global-track';
const pianoRollStateMocks = vi.hoisted(() => ({
setSheetMusicViewEnabled: vi.fn(),
@@ -9,16 +10,27 @@ const pianoRollStateMocks = vi.hoisted(() => ({
}));
let mockTracks: KGTrack[] = [new KGMidiTrack('Track 1', 0, 'acoustic_grand_piano')];
+let mockIsMetronomeEnabled = false;
+let mockShowGlobalTracks = false;
const mockProject = {
getTimeSignature: () => ({ numerator: 4, denominator: 4 }),
getMaxBars: () => 32,
getBarWidthMultiplier: () => 1,
getTracks: () => mockTracks,
+ getGlobalTracks: () => createDefaultGlobalTracks(),
getBpm: () => 120,
getKeySignature: () => 'C major',
getName: () => 'Test Project',
getSelectedMode: () => 'major',
getIsLooping: () => false,
+ getIsMetronomeEnabled: () => mockIsMetronomeEnabled,
+ setIsMetronomeEnabled: vi.fn((value: boolean) => {
+ mockIsMetronomeEnabled = value;
+ }),
+ getShowGlobalTracks: () => mockShowGlobalTracks,
+ setShowGlobalTracks: vi.fn((value: boolean) => {
+ mockShowGlobalTracks = value;
+ }),
getLoopingRange: () => [0, 0] as [number, number],
getPianoRollZoom: () => 1,
};
@@ -37,6 +49,7 @@ const mockAudioInterface = {
setTrackVolume: vi.fn(),
setTrackMute: vi.fn(),
setTrackSolo: vi.fn(),
+ setMetronomeEnabled: vi.fn(),
};
const configValues = new Map([
@@ -126,6 +139,11 @@ describe('projectStore piano roll state', () => {
mockAudioInterface.cancelAudioRecording.mockResolvedValue(undefined);
mockAudioInterface.getTransportPosition.mockReset();
mockAudioInterface.getTransportPosition.mockReturnValue(8);
+ mockAudioInterface.setMetronomeEnabled.mockReset();
+ mockIsMetronomeEnabled = false;
+ mockShowGlobalTracks = false;
+ mockProject.setIsMetronomeEnabled.mockClear();
+ mockProject.setShowGlobalTracks.mockClear();
configValues.set('audio.input_device_id', 'default');
});
@@ -195,6 +213,42 @@ describe('projectStore piano roll state', () => {
expect(state.requestedSheetMusicViewEnabled).toBe(false);
});
+ it('initializes persisted metronome and global-track visibility state from the project', async () => {
+ mockIsMetronomeEnabled = true;
+ mockShowGlobalTracks = true;
+
+ const { useProjectStore } = await import('./projectStore');
+
+ const state = useProjectStore.getState();
+ expect(state.isMetronomeEnabled).toBe(true);
+ expect(state.showGlobalTracks).toBe(true);
+ });
+
+ it('persists metronome toggles to the project and audio interface', async () => {
+ const { useProjectStore } = await import('./projectStore');
+
+ act(() => {
+ useProjectStore.getState().toggleMetronome();
+ });
+
+ const state = useProjectStore.getState();
+ expect(mockProject.setIsMetronomeEnabled).toHaveBeenCalledWith(true);
+ expect(mockAudioInterface.setMetronomeEnabled).toHaveBeenCalledWith(true);
+ expect(state.isMetronomeEnabled).toBe(true);
+ });
+
+ it('persists global-track visibility changes to the project', async () => {
+ const { useProjectStore } = await import('./projectStore');
+
+ act(() => {
+ useProjectStore.getState().setShowGlobalTracks(true);
+ });
+
+ const state = useProjectStore.getState();
+ expect(mockProject.setShowGlobalTracks).toHaveBeenCalledWith(true);
+ expect(state.showGlobalTracks).toBe(true);
+ });
+
it('tracks playback preparation around startPlaying success', async () => {
let resolveStart: (() => void) | null = null;
mockCore.startPlaying.mockImplementationOnce(() => new Promise((resolve) => {
diff --git a/src/stores/projectStore.ts b/src/stores/projectStore.ts
index a0d8179..48e8d0d 100644
--- a/src/stores/projectStore.ts
+++ b/src/stores/projectStore.ts
@@ -92,6 +92,7 @@ interface ProjectState {
selectedMode: string;
isLooping: boolean;
isMetronomeEnabled: boolean;
+ showGlobalTracks: boolean;
loopingRange: [number, number]; // [startBar, endBar] - bar indices (0-based)
playheadPosition: number; // in beats
isPlaying: boolean;
@@ -122,6 +123,7 @@ interface ProjectState {
// ChatBox state
showChatBox: boolean;
+ toolFastForwardEnabled: boolean;
// K.G.One panel state
showKGOnePanel: boolean;
@@ -194,6 +196,7 @@ interface ProjectState {
stopTransport: () => Promise;
toggleLoop: () => void;
toggleMetronome: () => void;
+ setShowGlobalTracks: (show: boolean) => void;
setBpm: (bpm: number) => void;
setMaxBars: (maxBars: number) => void;
setBarWidthMultiplier: (multiplier: number) => void;
@@ -225,6 +228,8 @@ interface ProjectState {
// ChatBox actions
setShowChatBox: (show: boolean) => void;
toggleChatBox: () => void;
+ setToolFastForwardEnabled: (enabled: boolean) => void;
+ toggleToolFastForwardEnabled: () => void;
// K.G.One panel actions
toggleKGOnePanel: () => void;
@@ -432,7 +437,8 @@ export const useProjectStore = create((set, get) => {
keySignature: currentProject.getKeySignature(),
selectedMode: currentProject.getSelectedMode(),
isLooping: currentProject.getIsLooping(),
- isMetronomeEnabled: false,
+ isMetronomeEnabled: currentProject.getIsMetronomeEnabled(),
+ showGlobalTracks: currentProject.getShowGlobalTracks(),
loopingRange: currentProject.getLoopingRange(),
playheadPosition: KGCore.instance().getPlayheadPosition(),
isPlaying: KGCore.instance().getIsPlaying(),
@@ -463,6 +469,7 @@ export const useProjectStore = create((set, get) => {
// Initial ChatBox state
showChatBox: initialChatBoxState,
+ toolFastForwardEnabled: false,
// Initial K.G.One panel state
showKGOnePanel: false,
@@ -870,6 +877,7 @@ export const useProjectStore = create((set, get) => {
// Setup audio synths for all tracks
const audioInterface = KGAudioInterface.instance();
+ audioInterface.setMetronomeEnabled(projectToLoad.getIsMetronomeEnabled());
// Clear any existing synths/buses first
tracks.forEach(track => {
@@ -949,6 +957,8 @@ export const useProjectStore = create((set, get) => {
keySignature,
selectedMode: projectToLoad.getSelectedMode(),
isLooping: projectToLoad.getIsLooping(),
+ isMetronomeEnabled: projectToLoad.getIsMetronomeEnabled(),
+ showGlobalTracks: projectToLoad.getShowGlobalTracks(),
loopingRange: projectToLoad.getLoopingRange(),
activeTrackAutomationTrackId: null,
activeTrackAutomationType: null,
@@ -1455,6 +1465,8 @@ export const useProjectStore = create((set, get) => {
toggleMetronome: () => {
const { isMetronomeEnabled, isPlaying, timeSignature } = get();
const newValue = !isMetronomeEnabled;
+ const project = KGCore.instance().getCurrentProject();
+ project.setIsMetronomeEnabled(newValue);
set({ isMetronomeEnabled: newValue });
const audio = KGAudioInterface.instance();
@@ -1470,6 +1482,12 @@ export const useProjectStore = create((set, get) => {
}
},
+ setShowGlobalTracks: (show: boolean) => {
+ const project = KGCore.instance().getCurrentProject();
+ project.setShowGlobalTracks(show);
+ set({ showGlobalTracks: show });
+ },
+
setBpm: (bpm: number) => {
try {
// Create and execute the change project property command
@@ -1726,6 +1744,14 @@ export const useProjectStore = create((set, get) => {
set({ showChatBox: false });
},
+ setToolFastForwardEnabled: (enabled: boolean) => {
+ set({ toolFastForwardEnabled: enabled });
+ },
+
+ toggleToolFastForwardEnabled: () => {
+ set((state) => ({ toolFastForwardEnabled: !state.toolFastForwardEnabled }));
+ },
+
toggleKGOnePanel: () => {
const { showKGOnePanel, showSettings } = get();
if (showSettings || !showKGOnePanel) {
@@ -1886,6 +1912,8 @@ export const useProjectStore = create((set, get) => {
bpm: project.getBpm(),
keySignature: project.getKeySignature(),
selectedMode: project.getSelectedMode(),
+ isMetronomeEnabled: project.getIsMetronomeEnabled(),
+ showGlobalTracks: project.getShowGlobalTracks(),
currentTime: formatCurrentTime(project, core.getPlayheadPosition()),
});
diff --git a/src/test/integration/utils/abc-notation.integration.test.ts b/src/test/integration/utils/abc-notation.integration.test.ts
index 18a47a1..5d3ea64 100644
--- a/src/test/integration/utils/abc-notation.integration.test.ts
+++ b/src/test/integration/utils/abc-notation.integration.test.ts
@@ -68,8 +68,10 @@ describe('abcNotationUtil - Integration Tests with Real Project Data', () => {
const result = convertRegionToABCNotation(melodyRegion, 0, 32);
// Expected ABC notation output (exactly as provided)
- const expectedABCNotation = `X:1
-T:Melody Region 1
+ const expectedABCNotation = `track_id: 1
+track_name: Melody
+Instrument: Acoustic Grand Piano
+X:1
M:4/4
L:1/4
Q:1/4=125
@@ -95,10 +97,13 @@ E E F G | G F E D | C C D E | E3/2 D1/2 D2 | E E F G | G F E D | C C D E | D3/2
// Verify it contains rest notation and proper headers
expect(result).toBeDefined();
- expect(result).toContain('T:Pad Chord Region 1');
+ expect(result).toContain('track_id: 2');
+ expect(result).toContain('track_name: Pad Chord');
+ expect(result).toContain('Instrument: Pad 1 (new age)');
expect(result).toContain('M:4/4');
expect(result).toContain('Q:1/4=125');
expect(result).toContain('K:C');
+ expect(result).not.toContain('\nT:');
expect(result).toContain('z'); // Should contain rest notation
});
@@ -167,8 +172,12 @@ E E F G | G F E D | C C D E | E3/2 D1/2 D2 | E E F G | G F E D | C C D E | D3/2
const melodyABC = convertRegionToABCNotation(melodyRegion, 0, 32);
const padABC = convertRegionToABCNotation(padRegion, 0, 32);
- expect(melodyABC).toContain('T:Melody Region 1');
- expect(padABC).toContain('T:Pad Chord Region 1');
+ expect(melodyABC).toContain('track_name: Melody');
+ expect(padABC).toContain('track_name: Pad Chord');
+ expect(melodyABC).toContain('Instrument: Acoustic Grand Piano');
+ expect(padABC).toContain('Instrument: Pad 1 (new age)');
+ expect(melodyABC).not.toContain('\nT:');
+ expect(padABC).not.toContain('\nT:');
});
it('should verify complete deserialization hierarchy', () => {
@@ -211,7 +220,8 @@ E E F G | G F E D | C C D E | E3/2 D1/2 D2 | E E F G | G F E D | C C D E | D3/2
const result = convertRegionToABCNotation(melodyRegion, 0, 8);
expect(result).toBeDefined();
- expect(result).toContain('T:Melody Region 1');
+ expect(result).toContain('track_name: Melody');
+ expect(result).not.toContain('\nT:');
// Should only contain the first 2 bars of music
expect(result).not.toContain('D3/2 C1/2 C2'); // This appears later in the song
});
@@ -224,7 +234,8 @@ E E F G | G F E D | C C D E | E3/2 D1/2 D2 | E E F G | G F E D | C C D E | D3/2
const result = convertRegionToABCNotation(melodyRegion, 16, 24);
expect(result).toBeDefined();
- expect(result).toContain('T:Melody Region 1');
+ expect(result).toContain('track_name: Melody');
+ expect(result).not.toContain('\nT:');
// Should start from the second repetition
const lines = result.split('\n');
const musicLine = lines[lines.length - 1]; // Last line contains the music
diff --git a/src/types/conversationTypes.ts b/src/types/conversationTypes.ts
new file mode 100644
index 0000000..592ce64
--- /dev/null
+++ b/src/types/conversationTypes.ts
@@ -0,0 +1,28 @@
+import type { Message } from '../agent/core/AgentState';
+import type { TodoItem } from '../agent/core/todo';
+import type { ChatMessage } from './projectTypes';
+
+export const SAVED_CONVERSATION_VERSION = 1;
+
+export interface SavedConversationMeta {
+ conversationId: string;
+ title: string;
+ createdAt: number;
+ updatedAt: number;
+ lastTurnAt: number;
+ messageCount: number;
+ preview: string;
+}
+
+export interface SavedConversationDocument {
+ version: number;
+ conversationId: string;
+ continuationState: {
+ messages: Message[];
+ todos: TodoItem[];
+ };
+ fullHistory: {
+ messages: Message[];
+ };
+ displayTranscript: ChatMessage[];
+}
diff --git a/src/types/projectTypes.ts b/src/types/projectTypes.ts
index b94f5b4..a0721e2 100644
--- a/src/types/projectTypes.ts
+++ b/src/types/projectTypes.ts
@@ -1,5 +1,6 @@
import { Transform, type TransformFnParams } from 'class-transformer';
-import type { PerformanceInfo } from '../agent/llm/StreamingTypes';
+import type { PerformanceInfo, ToolApprovalDecision } from '../agent/llm/StreamingTypes';
+import type { TodoItem } from '../agent/core/todo';
export interface TimeSignature {
numerator: number;
@@ -13,6 +14,19 @@ export interface ChatMessage {
isStreaming?: boolean;
tokenCount?: number;
performanceInfo?: PerformanceInfo;
+ toolName?: string;
+ toolSuccess?: boolean;
+ toolRawResult?: string;
+ toolResultDisplayContent?: string;
+ toolConfirmation?: {
+ toolCallId: string;
+ toolName: string;
+ message: string;
+ };
+ toolDenied?: boolean;
+ onToolConfirmationDecision?: (decision: ToolApprovalDecision) => void;
+ todoSnapshot?: TodoItem[];
+ isToolCallMessage?: boolean;
}
/**
diff --git a/src/util/abcNotationUtil.ts b/src/util/abcNotationUtil.ts
index f17f2f8..8979964 100644
--- a/src/util/abcNotationUtil.ts
+++ b/src/util/abcNotationUtil.ts
@@ -8,6 +8,7 @@ import { KGMidiNote } from '../core/midi/KGMidiNote';
import { KGCore } from '../core/KGCore';
import { KGProject } from '../core/KGProject';
import { KGChordRegion } from '../core/region/KGChordRegion';
+import { FLUIDR3_INSTRUMENT_MAP } from '../constants/generalMidiConstants';
import { pitchToNoteName } from './midiUtil';
import { beatsToTicks, getTicksPerBar, reduceFraction } from './mathUtil';
import type { TimeSignature } from '../types/projectTypes';
@@ -168,18 +169,38 @@ function convertTicksToABCLength(ticks: number, timeSignature: TimeSignature): s
* @param project - Project containing tempo and time signature info
* @returns ABC header string
*/
+function resolveRegionTrackMetadata(region: KGMidiRegion, project: KGProject): {
+ trackId: string;
+ trackName: string;
+ instrumentName: string;
+} {
+ const trackId = region.getTrackId();
+ const track = project.getTracks().find(candidate => candidate.getId().toString() === trackId);
+ const trackName = track?.getName() || 'Unnamed Track';
+ const instrumentKey = 'getInstrument' in (track ?? {}) && typeof track.getInstrument === 'function'
+ ? track.getInstrument()
+ : null;
+ const instrumentName = instrumentKey
+ ? FLUIDR3_INSTRUMENT_MAP[instrumentKey]?.displayName || instrumentKey
+ : 'Unknown Instrument';
+
+ return { trackId, trackName, instrumentName };
+}
+
function formatABCHeader(region: KGMidiRegion, project: KGProject): string {
const timeSignature = project.getTimeSignature();
const bpm = project.getBpm();
const keySignature = project.getKeySignature();
- const regionName = region.getName();
+ const { trackId, trackName, instrumentName } = resolveRegionTrackMetadata(region, project);
// Get ABC notation key signature from the key signature map
const abcKeySignature = KEY_SIGNATURE_MAP[keySignature]?.abcNotationKeySignature || 'C';
const header = [
+ `track_id: ${trackId}`,
+ `track_name: ${trackName}`,
+ `Instrument: ${instrumentName}`,
'X:1', // Reference number
- `T:${regionName}`, // Title
`M:${timeSignature.numerator}/${timeSignature.denominator}`, // Time signature
`L:1/${timeSignature.denominator}`, // note length unit should be aligned with time signature
`Q:1/${timeSignature.denominator}=${bpm}`, // Tempo (quarter note = BPM)
@@ -353,12 +374,12 @@ export function convertBeatRangeChordProgressionToABCNotation(
if (segments.length === 0) {
return [
- 'Selected Region Chord Progression',
+ 'Chord Progression',
'This progression comes only from user-defined chord regions on the global chord track. If no chord progression is defined for this range, read the notes directly with `read_music`.',
'',
header,
'',
- 'No chord progression is defined for the selected MIDI region range. Use `read_music` to inspect the notes directly.'
+ 'No chord progression is defined for the requested range on the global chord track. Use `read_music` to inspect the notes directly.'
].join('\n');
}
@@ -367,7 +388,7 @@ export function convertBeatRangeChordProgressionToABCNotation(
const chordNotes = formatChordProgressionNoteLine(segments, timeSignature);
return [
- 'Selected Region Chord Progression',
+ 'Chord Progression',
'This progression comes only from user-defined chord regions on the global chord track. Representation 1 uses symbolic chord names such as `Em7b5`. Representation 2 rewrites the same progression as note-based ABC chord tokens.',
'',
header,
diff --git a/src/util/agentMode.ts b/src/util/agentMode.ts
new file mode 100644
index 0000000..6829579
--- /dev/null
+++ b/src/util/agentMode.ts
@@ -0,0 +1,32 @@
+import { ConfigManager } from '../core/config/ConfigManager';
+import { LOCAL_LLM_PROVIDER_KEY } from './localLLMConfig';
+
+export type AgentMode = 'regular' | 'efficient';
+
+export const DEFAULT_AGENT_MODE: AgentMode = 'regular';
+export const EFFICIENT_AGENT_MODE: AgentMode = 'efficient';
+
+export function normalizeAgentMode(value: unknown): AgentMode {
+ return value === EFFICIENT_AGENT_MODE ? EFFICIENT_AGENT_MODE : DEFAULT_AGENT_MODE;
+}
+
+export function isAgentModeForcedByProvider(providerType: string | null | undefined): boolean {
+ return providerType === LOCAL_LLM_PROVIDER_KEY;
+}
+
+export function getConfiguredAgentMode(configManager: ConfigManager = ConfigManager.instance()): AgentMode {
+ return normalizeAgentMode(configManager.get('general.agent_mode'));
+}
+
+export function getEffectiveAgentMode(configManager: ConfigManager = ConfigManager.instance()): AgentMode {
+ const providerType = configManager.get('general.llm_provider');
+ if (typeof providerType === 'string' && isAgentModeForcedByProvider(providerType)) {
+ return EFFICIENT_AGENT_MODE;
+ }
+
+ return getConfiguredAgentMode(configManager);
+}
+
+export function getSystemPromptPathForAgentMode(mode: AgentMode): string {
+ return mode === EFFICIENT_AGENT_MODE ? 'prompts/system_compact.md' : 'prompts/system.md';
+}
diff --git a/src/util/chatUtil.ts b/src/util/chatUtil.ts
index 1553417..ab2e21a 100644
--- a/src/util/chatUtil.ts
+++ b/src/util/chatUtil.ts
@@ -1,4 +1,5 @@
import { AgentCore } from '../agent/core/AgentCore';
+import { useProjectStore } from '../stores/projectStore';
/**
* Clear chat history and reset chat state
@@ -8,7 +9,8 @@ import { AgentCore } from '../agent/core/AgentCore';
export const clearChatHistory = () => {
// Clear agent state
const agentCore = AgentCore.instance();
- agentCore.clearConversation();
+ agentCore.startNewConversation();
+ useProjectStore.getState().setToolFastForwardEnabled(false);
console.log('Chat history cleared programmatically');
};
@@ -52,4 +54,4 @@ export const clearChatHistoryAndUI = (setStatus?: (statusMessage: string) => voi
if (setStatus) {
setStatus('Chat history cleared');
}
-};
\ No newline at end of file
+};
diff --git a/src/util/messageFilter/UserMessageFilter.test.ts b/src/util/messageFilter/UserMessageFilter.test.ts
index c3a2f04..5fac3d6 100644
--- a/src/util/messageFilter/UserMessageFilter.test.ts
+++ b/src/util/messageFilter/UserMessageFilter.test.ts
@@ -35,12 +35,6 @@ vi.mock('../../stores/projectStore', () => ({
},
}));
-vi.mock('../../agent/core/SystemPrompts', () => ({
- SystemPrompts: {
- getPromptWithContext: vi.fn(async (value: string) => value),
- },
-}));
-
vi.mock('../localLLMConfig', async () => {
const actual = await vi.importActual('../localLLMConfig');
return {
@@ -287,14 +281,29 @@ describe('processUserMessage slash commands', () => {
expect(result.pseudoAssistantResponse).toContain('chat/hotkeys.md');
});
+ it('parses /compact and returns manual compaction metadata', async () => {
+ const result = await processUserMessage('/compact keep the current chord decisions');
+
+ expect(result).toMatchObject({
+ displayUserMessage: false,
+ sendToLLM: false,
+ finalMessageForLLM: null,
+ pseudoAssistantResponse: null,
+ metadata: {
+ command: 'compact',
+ focus: 'keep the current chord decisions',
+ },
+ });
+ });
+
it('lists the new hotkeys commands for unknown slash commands', async () => {
const result = await processUserMessage('/unknown foo');
expect(storeState.setStatus).toHaveBeenCalledWith(
- 'Unknown command: /unknown. Available commands: /clear, /welcome, /help, /hotkeys, /hotkey'
+ 'Unknown command: /unknown. Available commands: /clear, /welcome, /help, /hotkeys, /hotkey, /compact'
);
expect(result.pseudoAssistantResponse).toBe(
- 'Unknown command: /unknown foo.\nAvailable commands: /clear, /welcome, /help, /hotkeys, /hotkey'
+ 'Unknown command: /unknown foo.\nAvailable commands: /clear, /welcome, /help, /hotkeys, /hotkey, /compact'
);
expect(result).toMatchObject({
displayUserMessage: false,
@@ -321,6 +330,17 @@ describe('processUserMessage slash commands', () => {
expect(result.metadata).toMatchObject({ error: 'local_browser_unsupported' });
});
+ it('passes non-command messages through to the LLM even when no region is selected', async () => {
+ const result = await processUserMessage('hello');
+
+ expect(result.sendToLLM).toBe(true);
+ expect(result.finalMessageForLLM).toBe('hello');
+ expect(result.pseudoAssistantResponse).toBeNull();
+ expect(result.metadata).toMatchObject({
+ mode: 'pass_through_plain_user_message',
+ });
+ });
+
it('allows local-browser messages when only SharedArrayBuffer isolation support is missing', async () => {
detectLocalLLMRuntimeSupportMock.mockReturnValue({
supported: true,
@@ -330,12 +350,11 @@ describe('processUserMessage slash commands', () => {
secureContext: true,
reason: 'This host may not support the local browser runtime reliably because cross-origin isolation or SharedArrayBuffer is unavailable. COOP/COEP headers may be missing.',
});
- storeState.activeRegionId = 'region-1';
const result = await processUserMessage('hello');
expect(result.sendToLLM).toBe(true);
- expect(result.finalMessageForLLM).toContain('hello');
+ expect(result.finalMessageForLLM).toBe('hello');
expect(result.pseudoAssistantResponse).toBeNull();
});
});
diff --git a/src/util/messageFilter/UserMessageFilter.ts b/src/util/messageFilter/UserMessageFilter.ts
index 3a79eb6..b7b7907 100644
--- a/src/util/messageFilter/UserMessageFilter.ts
+++ b/src/util/messageFilter/UserMessageFilter.ts
@@ -1,7 +1,6 @@
import { clearChatHistoryAndUI } from '../chatUtil';
import { useProjectStore } from '../../stores/projectStore';
import { ConfigManager } from '../../core/config/ConfigManager';
-import { SystemPrompts } from '../../agent/core/SystemPrompts';
import { detectLocalLLMRuntimeSupport, LOCAL_LLM_PROVIDER_KEY } from '../localLLMConfig';
import { normalizeLanguageSetting, resolveLanguageSetting } from '../../i18n/locale';
import type { ResolvedLocaleCode } from '../../i18n/types';
@@ -218,9 +217,22 @@ export async function processUserMessage(originalMessage: string): Promise 0);
-
- if (!hasContextRegion) {
- // No region context: show guidance and do not send to LLM
- const url = `${import.meta.env.BASE_URL}chat/error_no_selected_region.md`;
- let md = 'Please select a region or open a MIDI region in the piano roll before asking for editing.';
- try {
- const resp = await fetch(url);
- if (resp.ok) {
- md = await resp.text();
- }
- } catch (e) {
- console.warn('Failed to fetch error_no_selected_region.md', e);
- // ignore fetch failure, use fallback text
- }
-
- return {
- displayUserMessage: true,
- sendToLLM: false,
- finalMessageForLLM: null,
- pseudoAssistantResponse: md,
- metadata: { error: 'no_selected_region' }
- };
- }
-
- // Has region context: pass through, but append processed appendix to the LLM-bound message
- let appendix = '';
- try {
- const resp = await fetch(`${import.meta.env.BASE_URL}prompts/user_msg_appendix.md`);
- if (resp.ok) {
- const rawAppendix = await resp.text();
- appendix = await SystemPrompts.getPromptWithContext(rawAppendix);
- }
- } catch (e) {
- // If appendix fetch fails, proceed without it
- console.warn('Failed to fetch user_msg_appendix.md', e);
- }
-
- const finalForLLM = appendix ? `${trimmed}${appendix}` : trimmed;
-
return {
displayUserMessage: true,
sendToLLM: true,
- finalMessageForLLM: finalForLLM,
+ finalMessageForLLM: trimmed,
pseudoAssistantResponse: null,
- metadata: { mode: 'pass_through_with_region', appendixIncluded: appendix.length > 0 }
+ metadata: { mode: 'pass_through_plain_user_message' }
};
} catch {
// Fallback: if store access fails, pass through unchanged