feat: implemented confirmation mechanism for tool invokation
This commit is contained in:
@@ -131,4 +131,55 @@ describe('AgentCore todo integration', () => {
|
|||||||
|
|
||||||
expect(provider.calls[1][provider.calls[1].length - 1]?.content).not.toContain('Keep the task list current');
|
expect(provider.calls[1][provider.calls[1].length - 1]?.content).not.toContain('Keep the task list current');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('requests approval for non-read-only tools and continues after allow', async () => {
|
||||||
|
const provider = new ScriptedProvider([
|
||||||
|
[
|
||||||
|
{ type: 'tool_call', content: '', toolCall: makeToolCall('add_notes', { notes: [{ pitch: 'C4', start: 0, length: 1 }] }, 'tool_1') },
|
||||||
|
{ type: 'done', content: '', finishReason: 'tool_calls' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ type: 'text', content: 'Completed' },
|
||||||
|
{ type: 'done', content: '', finishReason: 'stop' },
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
AgentCore.instance().setLLMProvider(provider);
|
||||||
|
|
||||||
|
const requestToolApproval = vi.fn(async () => 'allow' as const);
|
||||||
|
const chunks: StreamChunk[] = [];
|
||||||
|
for await (const chunk of AgentCore.instance().processUserInput('Write notes', { requestToolApproval })) {
|
||||||
|
chunks.push(chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(requestToolApproval).toHaveBeenCalledTimes(1);
|
||||||
|
expect(chunks.some(chunk => chunk.type === 'tool_result' && chunk.toolResult?.name === 'add_notes')).toBe(true);
|
||||||
|
expect(chunks.at(-1)?.type).toBe('done');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records denied tool execution and stops the turn after deny', async () => {
|
||||||
|
const provider = new ScriptedProvider([
|
||||||
|
[
|
||||||
|
{ type: 'tool_call', content: '', toolCall: makeToolCall('add_notes', { notes: [{ pitch: 'C4', start: 0, length: 1 }] }, 'tool_1') },
|
||||||
|
{ type: 'done', content: '', finishReason: 'tool_calls' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ type: 'text', content: 'Should not run' },
|
||||||
|
{ type: 'done', content: '', finishReason: 'stop' },
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
AgentCore.instance().setLLMProvider(provider);
|
||||||
|
|
||||||
|
const chunks: StreamChunk[] = [];
|
||||||
|
for await (const chunk of AgentCore.instance().processUserInput('Write notes', {
|
||||||
|
requestToolApproval: async () => 'deny',
|
||||||
|
})) {
|
||||||
|
chunks.push(chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deniedChunk = chunks.find(chunk => chunk.type === 'tool_result' && chunk.toolResult?.name === 'add_notes');
|
||||||
|
expect(deniedChunk?.toolResult?.denied).toBe(true);
|
||||||
|
expect(deniedChunk?.toolResult?.result).toBe('Execution was denied by the user.');
|
||||||
|
expect(provider.calls).toHaveLength(1);
|
||||||
|
expect(AgentCore.instance().getAgentState().getMessages().at(-1)?.role).toBe('tool');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+29
-10
@@ -1,9 +1,9 @@
|
|||||||
import type { LLMProvider } from '../llm/LLMProvider';
|
import type { LLMProvider } from '../llm/LLMProvider';
|
||||||
import { AgentState } from './AgentState';
|
import { AgentState } from './AgentState';
|
||||||
import { SystemPrompts } from './SystemPrompts';
|
import { SystemPrompts } from './SystemPrompts';
|
||||||
import { AVAILABLE_TOOLS } from '../tools';
|
import { AVAILABLE_TOOLS, createToolInstance } from '../tools';
|
||||||
import { useProjectStore } from '../../stores/projectStore';
|
import { useProjectStore } from '../../stores/projectStore';
|
||||||
import type { StreamChunk } from '../llm/StreamingTypes';
|
import type { StreamChunk, ToolApprovalDecision } from '../llm/StreamingTypes';
|
||||||
import type { ToolCall } from './AgentState';
|
import type { ToolCall } from './AgentState';
|
||||||
import type { OpenAIToolDefinition } from '../tools/BaseTool';
|
import type { OpenAIToolDefinition } from '../tools/BaseTool';
|
||||||
import { ConversationCompactor, type CompactProgress } from '../compact/ConversationCompactor';
|
import { ConversationCompactor, type CompactProgress } from '../compact/ConversationCompactor';
|
||||||
@@ -21,6 +21,10 @@ export interface CompactConversationResult {
|
|||||||
compactedConversation: string;
|
compactedConversation: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProcessUserInputOptions {
|
||||||
|
requestToolApproval?: (toolCall: ToolCall) => Promise<ToolApprovalDecision>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main orchestrator for the AI agent system.
|
* Main orchestrator for the AI agent system.
|
||||||
* Handles the full agentic loop: LLM streaming → tool execution → result feedback → repeat.
|
* Handles the full agentic loop: LLM streaming → tool execution → result feedback → repeat.
|
||||||
@@ -79,16 +83,13 @@ export class AgentCore {
|
|||||||
* Execute a single tool call and return the result
|
* Execute a single tool call and return the result
|
||||||
*/
|
*/
|
||||||
private async executeTool(toolCall: ToolCall): Promise<{ success: boolean; result: string }> {
|
private async executeTool(toolCall: ToolCall): Promise<{ success: boolean; result: string }> {
|
||||||
const toolName = toolCall.function.name;
|
const toolInstance = createToolInstance(toolCall.function.name);
|
||||||
const ToolClass = AVAILABLE_TOOLS[toolName as keyof typeof AVAILABLE_TOOLS];
|
if (!toolInstance) {
|
||||||
|
return { success: false, result: `Unknown tool: ${toolCall.function.name}` };
|
||||||
if (!ToolClass) {
|
|
||||||
return { success: false, result: `Unknown tool: ${toolName}` };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const params = JSON.parse(toolCall.function.arguments);
|
const params = JSON.parse(toolCall.function.arguments);
|
||||||
const toolInstance = new ToolClass();
|
|
||||||
const result = await toolInstance.execute(params);
|
const result = await toolInstance.execute(params);
|
||||||
|
|
||||||
// Sync UI state after successful tool execution
|
// Sync UI state after successful tool execution
|
||||||
@@ -107,7 +108,10 @@ export class AgentCore {
|
|||||||
* Handles the full agentic loop internally: if the LLM returns tool_calls,
|
* Handles the full agentic loop internally: if the LLM returns tool_calls,
|
||||||
* execute them and feed results back until the LLM produces a final text response.
|
* execute them and feed results back until the LLM produces a final text response.
|
||||||
*/
|
*/
|
||||||
async *processUserInput(userInput: string): AsyncIterableIterator<StreamChunk> {
|
async *processUserInput(
|
||||||
|
userInput: string,
|
||||||
|
options?: ProcessUserInputOptions,
|
||||||
|
): AsyncIterableIterator<StreamChunk> {
|
||||||
if (!this.llmProvider) {
|
if (!this.llmProvider) {
|
||||||
throw new Error('No LLM provider configured');
|
throw new Error('No LLM provider configured');
|
||||||
}
|
}
|
||||||
@@ -165,7 +169,16 @@ export class AgentCore {
|
|||||||
// Notify UI about the tool call
|
// Notify UI about the tool call
|
||||||
yield { type: 'tool_call', content: '', toolCall };
|
yield { type: 'tool_call', content: '', toolCall };
|
||||||
|
|
||||||
const result = await this.executeTool(toolCall);
|
let denied = false;
|
||||||
|
const toolInstance = createToolInstance(toolCall.function.name);
|
||||||
|
if (toolInstance && !toolInstance.isReadOnlyTool() && options?.requestToolApproval) {
|
||||||
|
const approvalDecision = await options.requestToolApproval(toolCall);
|
||||||
|
denied = approvalDecision === 'deny';
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = denied
|
||||||
|
? { success: false, result: 'Execution was denied by the user.' }
|
||||||
|
: await this.executeTool(toolCall);
|
||||||
|
|
||||||
// Add tool result message to conversation history
|
// Add tool result message to conversation history
|
||||||
this.agentState.addMessage('tool', JSON.stringify(result), {
|
this.agentState.addMessage('tool', JSON.stringify(result), {
|
||||||
@@ -181,8 +194,14 @@ export class AgentCore {
|
|||||||
name: toolCall.function.name,
|
name: toolCall.function.name,
|
||||||
success: result.success,
|
success: result.success,
|
||||||
result: result.result,
|
result: result.result,
|
||||||
|
denied,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (denied) {
|
||||||
|
continueLoop = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear assistant message ID before next iteration creates a new one
|
// Clear assistant message ID before next iteration creates a new one
|
||||||
|
|||||||
@@ -9,11 +9,19 @@ export interface PerformanceInfo {
|
|||||||
generationTps?: number;
|
generationTps?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ToolApprovalDecision = 'allow' | 'always_allow' | 'deny';
|
||||||
|
|
||||||
export interface StreamChunk {
|
export interface StreamChunk {
|
||||||
type: 'text' | 'tool_call' | 'tool_result' | 'done';
|
type: 'text' | 'tool_call' | 'tool_result' | 'done';
|
||||||
content: string;
|
content: string;
|
||||||
toolCall?: ToolCall;
|
toolCall?: ToolCall;
|
||||||
toolResult?: { toolCallId?: string; name: string; success: boolean; result: string };
|
toolResult?: {
|
||||||
|
toolCallId?: string;
|
||||||
|
name: string;
|
||||||
|
success: boolean;
|
||||||
|
result: string;
|
||||||
|
denied?: boolean;
|
||||||
|
};
|
||||||
performanceInfo?: PerformanceInfo;
|
performanceInfo?: PerformanceInfo;
|
||||||
finishReason?: string;
|
finishReason?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,32 @@ describe('AddNotesTool', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('builds a confirmation summary for note creation', () => {
|
||||||
|
const track = new KGMidiTrack('Lead', 1);
|
||||||
|
const region = new KGMidiRegion('region-1', track.getId().toString(), track.getTrackIndex(), 'Verse Melody', 0, 32);
|
||||||
|
track.setRegions([region]);
|
||||||
|
const project = new KGProject('summary-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||||
|
project.setTracks([track]);
|
||||||
|
storeState.activeRegionId = region.getId();
|
||||||
|
|
||||||
|
vi.spyOn(KGCore, 'instance').mockReturnValue({
|
||||||
|
getCurrentProject: () => project,
|
||||||
|
getSelectedItems: () => [],
|
||||||
|
} as unknown as KGCore);
|
||||||
|
|
||||||
|
const tool = new AddNotesTool();
|
||||||
|
|
||||||
|
expect(tool.isReadOnlyTool()).toBe(false);
|
||||||
|
expect(tool.buildConfirmationContent({
|
||||||
|
notes: [
|
||||||
|
{ pitch: 'C4', start: 16, length: 4 },
|
||||||
|
{ pitch: 'E4', start: 20, length: 8 },
|
||||||
|
],
|
||||||
|
})).toBe(
|
||||||
|
'Allow creating 2 notes in region **Verse Melody** on track **Lead**, spanning bars 5 to 7?'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('returns no compact summary when the target region cannot be resolved', () => {
|
it('returns no compact summary when the target region cannot be resolved', () => {
|
||||||
const project = new KGProject('summary-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
const project = new KGProject('summary-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||||
vi.spyOn(KGCore, 'instance').mockReturnValue({
|
vi.spyOn(KGCore, 'instance').mockReturnValue({
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ export class AddNotesTool extends BaseTool {
|
|||||||
readonly name = 'add_notes';
|
readonly name = 'add_notes';
|
||||||
readonly description = 'Add one or more MIDI notes to the current region. Use this to create melodies, chords, or any musical content. Notes use absolute beat positions on the project timeline — not relative to the region start.';
|
readonly description = 'Add one or more MIDI notes to the current region. Use this to create melodies, chords, or any musical content. Notes use absolute beat positions on the project timeline — not relative to the region start.';
|
||||||
|
|
||||||
|
override isReadOnlyTool(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
readonly parameters: Record<string, ToolParameter> = {
|
readonly parameters: Record<string, ToolParameter> = {
|
||||||
notes: {
|
notes: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
@@ -74,6 +78,19 @@ export class AddNotesTool extends BaseTool {
|
|||||||
return `Successfully created ${summary.noteCount} ${summary.noteCount === 1 ? 'note' : 'notes'} in region **${summary.regionName}** on track **${summary.trackName}**, spanning bars ${summary.earliestNoteStartBar} to ${summary.latestNoteEndBar}.`;
|
return `Successfully created ${summary.noteCount} ${summary.noteCount === 1 ? 'note' : 'notes'} in region **${summary.regionName}** on track **${summary.trackName}**, spanning bars ${summary.earliestNoteStartBar} to ${summary.latestNoteEndBar}.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override buildConfirmationContent(args: Record<string, unknown> | null): string | undefined {
|
||||||
|
if (!args) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = this.buildSummaryData(args);
|
||||||
|
if (!summary) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Allow creating ${summary.noteCount} ${summary.noteCount === 1 ? 'note' : 'notes'} in region **${summary.regionName}** on track **${summary.trackName}**, spanning bars ${summary.earliestNoteStartBar} to ${summary.latestNoteEndBar}?`;
|
||||||
|
}
|
||||||
|
|
||||||
async execute(params: Record<string, unknown>): Promise<ToolResult> {
|
async execute(params: Record<string, unknown>): Promise<ToolResult> {
|
||||||
try {
|
try {
|
||||||
// Validate parameters
|
// Validate parameters
|
||||||
|
|||||||
@@ -67,6 +67,13 @@ export abstract class BaseTool {
|
|||||||
*/
|
*/
|
||||||
abstract execute(params: Record<string, unknown>): Promise<ToolResult>;
|
abstract execute(params: Record<string, unknown>): Promise<ToolResult>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the tool only reads state and can execute without user approval.
|
||||||
|
*/
|
||||||
|
isReadOnlyTool(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optionally build a compact UI summary for a successful tool result.
|
* Optionally build a compact UI summary for a successful tool result.
|
||||||
* The raw tool result remains the canonical output stored in agent history.
|
* The raw tool result remains the canonical output stored in agent history.
|
||||||
@@ -78,6 +85,16 @@ export abstract class BaseTool {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optionally build a user-facing confirmation summary before execution.
|
||||||
|
* Non-read-only tools should override this with a concise approval prompt.
|
||||||
|
*/
|
||||||
|
buildConfirmationContent(
|
||||||
|
_args: Record<string, unknown> | null,
|
||||||
|
): string | undefined {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the tool definition in OpenAI function calling format
|
* Get the tool definition in OpenAI function calling format
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { RemoveNotesTool } from './RemoveNotesTool';
|
||||||
|
import { KGCore } from '../../core/KGCore';
|
||||||
|
import { KGProject } from '../../core/KGProject';
|
||||||
|
import { KGMidiTrack } from '../../core/track/KGMidiTrack';
|
||||||
|
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
||||||
|
import { KGMidiNote } from '../../core/midi/KGMidiNote';
|
||||||
|
|
||||||
|
const storeState = {
|
||||||
|
activeRegionId: null as string | null,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('../../stores/projectStore', () => ({
|
||||||
|
useProjectStore: {
|
||||||
|
getState: () => storeState,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('RemoveNotesTool', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
storeState.activeRegionId = null;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds confirmation and result summaries for note removal', () => {
|
||||||
|
const track = new KGMidiTrack('Lead', 1);
|
||||||
|
const region = new KGMidiRegion('region-1', track.getId().toString(), track.getTrackIndex(), 'Verse Melody', 0, 32);
|
||||||
|
region.setNotes([
|
||||||
|
new KGMidiNote('note-1', 16, 20, 60, 100),
|
||||||
|
new KGMidiNote('note-2', 20, 28, 64, 100),
|
||||||
|
]);
|
||||||
|
track.setRegions([region]);
|
||||||
|
const project = new KGProject('summary-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||||
|
project.setTracks([track]);
|
||||||
|
storeState.activeRegionId = region.getId();
|
||||||
|
|
||||||
|
vi.spyOn(KGCore, 'instance').mockReturnValue({
|
||||||
|
getCurrentProject: () => project,
|
||||||
|
getSelectedItems: () => [],
|
||||||
|
} as unknown as KGCore);
|
||||||
|
|
||||||
|
const tool = new RemoveNotesTool();
|
||||||
|
const args = {
|
||||||
|
start: 16,
|
||||||
|
end: 24,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(tool.isReadOnlyTool()).toBe(false);
|
||||||
|
expect(tool.buildConfirmationContent(args)).toBe(
|
||||||
|
'Allow removing 2 notes from beats 16-24, in region **Verse Melody** on track **Lead**, spanning bars 5 to 7?'
|
||||||
|
);
|
||||||
|
expect(tool.buildToolResultDisplayContent(args, { success: true, result: 'raw result' })).toBe(
|
||||||
|
'Successfully removed 2 notes from beats 16-24, in region **Verse Melody** on track **Lead**, spanning bars 5 to 7.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,16 @@ import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
|||||||
import { useProjectStore } from '../../stores/projectStore';
|
import { useProjectStore } from '../../stores/projectStore';
|
||||||
import { KGCore } from '../../core/KGCore';
|
import { KGCore } from '../../core/KGCore';
|
||||||
|
|
||||||
|
interface RemoveNotesSummaryData {
|
||||||
|
noteCount: number;
|
||||||
|
startBeat: number;
|
||||||
|
endBeat: number;
|
||||||
|
regionName: string;
|
||||||
|
trackName: string;
|
||||||
|
earliestNoteStartBar: number;
|
||||||
|
latestNoteEndBar: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tool for removing notes from MIDI regions within a specified beat range
|
* Tool for removing notes from MIDI regions within a specified beat range
|
||||||
* Integrates with the existing command system for undo/redo support
|
* Integrates with the existing command system for undo/redo support
|
||||||
@@ -13,6 +23,10 @@ export class RemoveNotesTool extends BaseTool {
|
|||||||
readonly name = 'remove_notes';
|
readonly name = 'remove_notes';
|
||||||
readonly description = 'Remove all MIDI notes whose start position falls within the specified beat range. Use this to clear a section before rewriting it, or to delete unwanted notes. Beat positions are absolute on the project timeline.';
|
readonly description = 'Remove all MIDI notes whose start position falls within the specified beat range. Use this to clear a section before rewriting it, or to delete unwanted notes. Beat positions are absolute on the project timeline.';
|
||||||
|
|
||||||
|
override isReadOnlyTool(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
readonly parameters: Record<string, ToolParameter> = {
|
readonly parameters: Record<string, ToolParameter> = {
|
||||||
start: {
|
start: {
|
||||||
type: 'number',
|
type: 'number',
|
||||||
@@ -31,6 +45,32 @@ export class RemoveNotesTool extends BaseTool {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
override buildToolResultDisplayContent(args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
|
||||||
|
if (!toolResult.success || !args) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = this.buildSummaryData(args);
|
||||||
|
if (!summary || summary.noteCount === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Successfully removed ${summary.noteCount} ${summary.noteCount === 1 ? 'note' : 'notes'} from beats ${summary.startBeat}-${summary.endBeat}, in region **${summary.regionName}** on track **${summary.trackName}**, spanning bars ${summary.earliestNoteStartBar} to ${summary.latestNoteEndBar}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
override buildConfirmationContent(args: Record<string, unknown> | null): string | undefined {
|
||||||
|
if (!args) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = this.buildSummaryData(args);
|
||||||
|
if (!summary) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Allow removing ${summary.noteCount} ${summary.noteCount === 1 ? 'note' : 'notes'} from beats ${summary.startBeat}-${summary.endBeat}, in region **${summary.regionName}** on track **${summary.trackName}**, spanning bars ${summary.earliestNoteStartBar} to ${summary.latestNoteEndBar}?`;
|
||||||
|
}
|
||||||
|
|
||||||
async execute(params: Record<string, unknown>): Promise<ToolResult> {
|
async execute(params: Record<string, unknown>): Promise<ToolResult> {
|
||||||
try {
|
try {
|
||||||
// Validate parameters
|
// Validate parameters
|
||||||
@@ -105,6 +145,10 @@ export class RemoveNotesTool extends BaseTool {
|
|||||||
* Priority: 1) Specified regionId, 2) Active piano roll region, 3) Selected regions, 4) Error if none found
|
* Priority: 1) Specified regionId, 2) Active piano roll region, 3) Selected regions, 4) Error if none found
|
||||||
*/
|
*/
|
||||||
private findTargetRegion(regionId?: string): KGMidiRegion | null {
|
private findTargetRegion(regionId?: string): KGMidiRegion | null {
|
||||||
|
return this.findTargetRegionContext(regionId)?.region ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private findTargetRegionContext(regionId?: string): { region: KGMidiRegion; trackName: string } | null {
|
||||||
const project = this.getCurrentProject();
|
const project = this.getCurrentProject();
|
||||||
const tracks = project.getTracks();
|
const tracks = project.getTracks();
|
||||||
|
|
||||||
@@ -114,7 +158,10 @@ export class RemoveNotesTool extends BaseTool {
|
|||||||
const regions = track.getRegions();
|
const regions = track.getRegions();
|
||||||
const region = regions.find(r => r.getId() === regionId);
|
const region = regions.find(r => r.getId() === regionId);
|
||||||
if (region && region instanceof KGMidiRegion) {
|
if (region && region instanceof KGMidiRegion) {
|
||||||
return region;
|
return {
|
||||||
|
region,
|
||||||
|
trackName: track.getName() || `Track ${track.getTrackIndex() + 1}`,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -128,7 +175,10 @@ export class RemoveNotesTool extends BaseTool {
|
|||||||
const regions = track.getRegions();
|
const regions = track.getRegions();
|
||||||
const region = regions.find(r => r.getId() === storeState.activeRegionId);
|
const region = regions.find(r => r.getId() === storeState.activeRegionId);
|
||||||
if (region && region instanceof KGMidiRegion) {
|
if (region && region instanceof KGMidiRegion) {
|
||||||
return region;
|
return {
|
||||||
|
region,
|
||||||
|
trackName: track.getName() || `Track ${track.getTrackIndex() + 1}`,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,7 +188,11 @@ export class RemoveNotesTool extends BaseTool {
|
|||||||
const selectedItems = core.getSelectedItems();
|
const selectedItems = core.getSelectedItems();
|
||||||
for (const item of selectedItems) {
|
for (const item of selectedItems) {
|
||||||
if (item instanceof KGMidiRegion) {
|
if (item instanceof KGMidiRegion) {
|
||||||
return item;
|
const track = tracks.find(candidate => candidate.getId().toString() === item.getTrackId());
|
||||||
|
return {
|
||||||
|
region: item,
|
||||||
|
trackName: track?.getName() || `Track ${item.getTrackIndex() + 1}`,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,6 +201,47 @@ export class RemoveNotesTool extends BaseTool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildSummaryData(args: Record<string, unknown>): RemoveNotesSummaryData | null {
|
||||||
|
const typedArgs = args as {
|
||||||
|
start?: number;
|
||||||
|
end?: number;
|
||||||
|
region_id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof typedArgs.start !== 'number' || typeof typedArgs.end !== 'number' || typedArgs.end <= typedArgs.start) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetRegion = this.findTargetRegionContext(typedArgs.region_id);
|
||||||
|
if (!targetRegion) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const regionStartBeat = targetRegion.region.getStartFromBeat();
|
||||||
|
const adjustedStartBeat = typedArgs.start - regionStartBeat;
|
||||||
|
const adjustedEndBeat = typedArgs.end - regionStartBeat;
|
||||||
|
const notesToRemove = this.findNotesInRange(targetRegion.region, adjustedStartBeat, adjustedEndBeat);
|
||||||
|
const beatsPerBar = this.getCurrentProject().getTimeSignature().numerator;
|
||||||
|
|
||||||
|
let earliestBeat = typedArgs.start;
|
||||||
|
let latestBeat = typedArgs.end;
|
||||||
|
|
||||||
|
if (notesToRemove.length > 0) {
|
||||||
|
earliestBeat = Math.min(...notesToRemove.map(note => note.getStartBeat() + regionStartBeat));
|
||||||
|
latestBeat = Math.max(...notesToRemove.map(note => note.getEndBeat() + regionStartBeat));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
noteCount: notesToRemove.length,
|
||||||
|
startBeat: typedArgs.start,
|
||||||
|
endBeat: typedArgs.end,
|
||||||
|
regionName: targetRegion.region.getName(),
|
||||||
|
trackName: targetRegion.trackName,
|
||||||
|
earliestNoteStartBar: Math.floor(earliestBeat / beatsPerBar) + 1,
|
||||||
|
latestNoteEndBar: Math.max(1, Math.ceil(latestBeat / beatsPerBar)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get KGCore instance for selection access
|
* Get KGCore instance for selection access
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// Base tool system
|
// Base tool system
|
||||||
|
import { BaseTool } from './BaseTool';
|
||||||
export { BaseTool } from './BaseTool';
|
export { BaseTool } from './BaseTool';
|
||||||
export type { ToolResult, ToolParameter, ToolDefinition, OpenAIToolDefinition, OpenAIFunctionParameters } from './BaseTool';
|
export type { ToolResult, ToolParameter, ToolDefinition, OpenAIToolDefinition, OpenAIFunctionParameters } from './BaseTool';
|
||||||
|
|
||||||
@@ -21,3 +22,8 @@ export const AVAILABLE_TOOLS = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type ToolName = keyof typeof AVAILABLE_TOOLS;
|
export type ToolName = keyof typeof AVAILABLE_TOOLS;
|
||||||
|
|
||||||
|
export const createToolInstance = (toolName: string): BaseTool | null => {
|
||||||
|
const ToolClass = AVAILABLE_TOOLS[toolName as ToolName];
|
||||||
|
return ToolClass ? new ToolClass() : null;
|
||||||
|
};
|
||||||
|
|||||||
@@ -46,6 +46,16 @@
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chatbox-toggle-btn.is-active {
|
||||||
|
background-color: #e0e0e0;
|
||||||
|
color: #2d2d2d;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatbox-toggle-btn.is-active:hover {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ChatBox export button wrapper and dropdown positioning */
|
/* ChatBox export button wrapper and dropdown positioning */
|
||||||
.chatbox-export-wrapper {
|
.chatbox-export-wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -418,6 +428,37 @@
|
|||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-tool-confirmation-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-tool-confirmation-btn {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0;
|
||||||
|
min-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-tool-confirmation-btn-always.dialog-btn-primary {
|
||||||
|
background-color: #5aa36a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-tool-confirmation-btn-always.dialog-btn-primary:hover {
|
||||||
|
background-color: #4a935a;
|
||||||
|
box-shadow: 0 4px 12px rgba(90, 163, 106, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-tool-confirmation-btn-deny.dialog-btn-primary {
|
||||||
|
background-color: #c96a6a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-tool-confirmation-btn-deny.dialog-btn-primary:hover {
|
||||||
|
background-color: #b85b5b;
|
||||||
|
box-shadow: 0 4px 12px rgba(201, 106, 106, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
.message-tool-summary-content > :first-child {
|
.message-tool-summary-content > :first-child {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ const {
|
|||||||
processUserMessageMock,
|
processUserMessageMock,
|
||||||
processStreamMock,
|
processStreamMock,
|
||||||
streamProcessorCallbacks,
|
streamProcessorCallbacks,
|
||||||
|
clearChatHistoryAndUIMock,
|
||||||
|
projectStoreState,
|
||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
agentCoreMock: {
|
agentCoreMock: {
|
||||||
setLLMProvider: vi.fn(),
|
setLLMProvider: vi.fn(),
|
||||||
@@ -27,6 +29,13 @@ const {
|
|||||||
},
|
},
|
||||||
processUserMessageMock: vi.fn(),
|
processUserMessageMock: vi.fn(),
|
||||||
processStreamMock: vi.fn(async () => ''),
|
processStreamMock: vi.fn(async () => ''),
|
||||||
|
clearChatHistoryAndUIMock: vi.fn(),
|
||||||
|
projectStoreState: {
|
||||||
|
toolFastForwardEnabled: false,
|
||||||
|
setStatus: vi.fn(),
|
||||||
|
setToolFastForwardEnabled: vi.fn(),
|
||||||
|
toggleToolFastForwardEnabled: vi.fn(),
|
||||||
|
},
|
||||||
streamProcessorCallbacks: {
|
streamProcessorCallbacks: {
|
||||||
onMessageAdd: undefined as ((message: ChatMessage) => void) | undefined,
|
onMessageAdd: undefined as ((message: ChatMessage) => void) | undefined,
|
||||||
onMessageUpdate: undefined as ((messageId: string, updater: (msg: ChatMessage) => ChatMessage) => void) | undefined,
|
onMessageUpdate: undefined as ((messageId: string, updater: (msg: ChatMessage) => ChatMessage) => void) | undefined,
|
||||||
@@ -35,6 +44,13 @@ const {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
projectStoreState.setToolFastForwardEnabled.mockImplementation((enabled: boolean) => {
|
||||||
|
projectStoreState.toolFastForwardEnabled = enabled;
|
||||||
|
});
|
||||||
|
projectStoreState.toggleToolFastForwardEnabled.mockImplementation(() => {
|
||||||
|
projectStoreState.toolFastForwardEnabled = !projectStoreState.toolFastForwardEnabled;
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock('./chat', () => ({
|
vi.mock('./chat', () => ({
|
||||||
UserMessage: ({ content }: { content: string }) => <div>{content}</div>,
|
UserMessage: ({ content }: { content: string }) => <div>{content}</div>,
|
||||||
AssistantMessage: ({
|
AssistantMessage: ({
|
||||||
@@ -81,11 +97,14 @@ vi.mock('../core/config/ConfigManager', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../stores/projectStore', () => ({
|
vi.mock('../stores/projectStore', () => ({
|
||||||
useProjectStore: {
|
useProjectStore: Object.assign(
|
||||||
getState: () => ({
|
((selector?: (state: typeof projectStoreState) => unknown) => (
|
||||||
setStatus: vi.fn(),
|
selector ? selector(projectStoreState) : projectStoreState
|
||||||
}),
|
)) as never,
|
||||||
},
|
{
|
||||||
|
getState: () => projectStoreState,
|
||||||
|
}
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../agent/core/SystemPrompts', () => ({
|
vi.mock('../agent/core/SystemPrompts', () => ({
|
||||||
@@ -95,7 +114,7 @@ vi.mock('../agent/core/SystemPrompts', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../util/chatUtil', () => ({
|
vi.mock('../util/chatUtil', () => ({
|
||||||
clearChatHistoryAndUI: vi.fn(),
|
clearChatHistoryAndUI: clearChatHistoryAndUIMock,
|
||||||
registerClearChatUICallback: vi.fn(),
|
registerClearChatUICallback: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -189,12 +208,17 @@ describe('ChatBox', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
processUserMessageMock.mockReset();
|
processUserMessageMock.mockReset();
|
||||||
processStreamMock.mockClear();
|
processStreamMock.mockClear();
|
||||||
|
clearChatHistoryAndUIMock.mockClear();
|
||||||
agentCoreMock.compactConversation.mockClear();
|
agentCoreMock.compactConversation.mockClear();
|
||||||
agentCoreMock.shouldCompactBeforeNextTurn.mockResolvedValue(false);
|
agentCoreMock.shouldCompactBeforeNextTurn.mockResolvedValue(false);
|
||||||
streamProcessorCallbacks.onMessageAdd = undefined;
|
streamProcessorCallbacks.onMessageAdd = undefined;
|
||||||
streamProcessorCallbacks.onMessageUpdate = undefined;
|
streamProcessorCallbacks.onMessageUpdate = undefined;
|
||||||
streamProcessorCallbacks.onMessageRemove = undefined;
|
streamProcessorCallbacks.onMessageRemove = undefined;
|
||||||
streamProcessorCallbacks.onProcessingChange = undefined;
|
streamProcessorCallbacks.onProcessingChange = undefined;
|
||||||
|
projectStoreState.toolFastForwardEnabled = false;
|
||||||
|
projectStoreState.setStatus.mockClear();
|
||||||
|
projectStoreState.setToolFastForwardEnabled.mockClear();
|
||||||
|
projectStoreState.toggleToolFastForwardEnabled.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the English assistant title under en_us', () => {
|
it('renders the English assistant title under en_us', () => {
|
||||||
@@ -388,4 +412,53 @@ describe('ChatBox', () => {
|
|||||||
expect(screen.getByText('TODO SNAPSHOT: Render bounce')).toBeTruthy();
|
expect(screen.getByText('TODO SNAPSHOT: Render bounce')).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders and toggles the fast-forward button state', () => {
|
||||||
|
const { rerender } = renderWithLocale('en_us');
|
||||||
|
|
||||||
|
const button = screen.getByTitle('Fast forward tool execution approvals');
|
||||||
|
expect(button).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
|
||||||
|
fireEvent.click(button);
|
||||||
|
rerender(
|
||||||
|
<I18nContext.Provider
|
||||||
|
value={{
|
||||||
|
languageSetting: 'en_us',
|
||||||
|
resolvedLocale: 'en_us',
|
||||||
|
setLanguageSetting: async () => undefined,
|
||||||
|
t: (key, params) => translate(key, params, 'en_us'),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChatBox isVisible={true} />
|
||||||
|
</I18nContext.Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTitle('Fast forward tool execution approvals')).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets fast-forward through the shared new chat clear path', () => {
|
||||||
|
projectStoreState.toolFastForwardEnabled = true;
|
||||||
|
clearChatHistoryAndUIMock.mockImplementation(() => {
|
||||||
|
projectStoreState.setToolFastForwardEnabled(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { rerender } = renderWithLocale('en_us');
|
||||||
|
fireEvent.click(screen.getByTitle('New Chat'));
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<I18nContext.Provider
|
||||||
|
value={{
|
||||||
|
languageSetting: 'en_us',
|
||||||
|
resolvedLocale: 'en_us',
|
||||||
|
setLanguageSetting: async () => undefined,
|
||||||
|
t: (key, params) => translate(key, params, 'en_us'),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChatBox isVisible={true} />
|
||||||
|
</I18nContext.Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(clearChatHistoryAndUIMock).toHaveBeenCalled();
|
||||||
|
expect(screen.getByTitle('Fast forward tool execution approvals')).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useRef, useEffect, memo, useCallback } from 'react';
|
import React, { useState, useRef, useEffect, memo, useCallback } from 'react';
|
||||||
import './ChatBox.css';
|
import './ChatBox.css';
|
||||||
import { FaPlus, FaBan, FaDownload } from 'react-icons/fa';
|
import { FaPlus, FaDownload, FaForward } from 'react-icons/fa';
|
||||||
import { UserMessage, AssistantMessage } from './chat';
|
import { UserMessage, AssistantMessage } from './chat';
|
||||||
import { AgentCore } from '../agent/core/AgentCore';
|
import { AgentCore } from '../agent/core/AgentCore';
|
||||||
import { summarizeTodoCounts } from '../agent/core/todo';
|
import { summarizeTodoCounts } from '../agent/core/todo';
|
||||||
@@ -76,6 +76,8 @@ interface ChatBoxProps {
|
|||||||
|
|
||||||
const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const toolFastForwardEnabled = useProjectStore((state) => state.toolFastForwardEnabled);
|
||||||
|
const toggleToolFastForwardEnabled = useProjectStore((state) => state.toggleToolFastForwardEnabled);
|
||||||
const [inputValue, setInputValue] = useState('');
|
const [inputValue, setInputValue] = useState('');
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
@@ -455,16 +457,15 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
|||||||
<div className="chatbox-header">
|
<div className="chatbox-header">
|
||||||
<h3>{t('assistant.displayName')}</h3>
|
<h3>{t('assistant.displayName')}</h3>
|
||||||
<div className="chatbox-actions">
|
<div className="chatbox-actions">
|
||||||
{isProcessing && (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title="Abort"
|
title={t('chatbox.fastForward.title')}
|
||||||
onClick={handleAbort}
|
aria-pressed={toolFastForwardEnabled}
|
||||||
className="chatbox-action-btn"
|
onClick={toggleToolFastForwardEnabled}
|
||||||
|
className={`chatbox-action-btn chatbox-toggle-btn ${toolFastForwardEnabled ? 'is-active' : ''}`}
|
||||||
>
|
>
|
||||||
<FaBan />
|
<FaForward />
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
<div className="chatbox-export-wrapper">
|
<div className="chatbox-export-wrapper">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -551,6 +552,9 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
|||||||
toolSuccess={message.toolSuccess}
|
toolSuccess={message.toolSuccess}
|
||||||
toolRawResult={message.toolRawResult}
|
toolRawResult={message.toolRawResult}
|
||||||
toolResultDisplayContent={message.toolResultDisplayContent}
|
toolResultDisplayContent={message.toolResultDisplayContent}
|
||||||
|
toolConfirmation={message.toolConfirmation}
|
||||||
|
toolDenied={message.toolDenied}
|
||||||
|
onToolConfirmationDecision={message.onToolConfirmationDecision}
|
||||||
todoSnapshot={message.todoSnapshot}
|
todoSnapshot={message.todoSnapshot}
|
||||||
isToolCallMessage={message.isToolCallMessage}
|
isToolCallMessage={message.isToolCallMessage}
|
||||||
onAbort={message.isStreaming ? handleAbort : undefined}
|
onAbort={message.isStreaming ? handleAbort : undefined}
|
||||||
|
|||||||
@@ -230,4 +230,40 @@ describe('AssistantMessage', () => {
|
|||||||
expect(screen.getByText(/└──/)).toBeInTheDocument();
|
expect(screen.getByText(/└──/)).toBeInTheDocument();
|
||||||
expect(screen.getByText('C D E F')).toBeInTheDocument();
|
expect(screen.getByText('C D E F')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders tool confirmation buttons and fires the selected action', () => {
|
||||||
|
const onToolConfirmationDecision = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AssistantMessage
|
||||||
|
content="confirmation fallback"
|
||||||
|
toolConfirmation={{
|
||||||
|
toolCallId: 'tool-1',
|
||||||
|
toolName: 'add_notes',
|
||||||
|
message: 'Allow creating 2 notes in region **Verse Melody** on track **Lead**, spanning bars 5 to 7?',
|
||||||
|
}}
|
||||||
|
onToolConfirmationDecision={onToolConfirmationDecision}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('add_notes')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Allow creating 2 notes/i)).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Always allow' }));
|
||||||
|
expect(onToolConfirmationDecision).toHaveBeenCalledWith('always_allow');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders denied tool results with the denied result text', () => {
|
||||||
|
render(
|
||||||
|
<AssistantMessage
|
||||||
|
content="❌ **add_notes**\n\n └── Execution was denied by the user."
|
||||||
|
toolName="add_notes"
|
||||||
|
toolSuccess={false}
|
||||||
|
toolRawResult="Execution was denied by the user."
|
||||||
|
toolDenied={true}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('add_notes')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Execution was denied by the user.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import remarkMath from 'remark-math';
|
|||||||
import { FaCaretDown, FaCaretUp } from 'react-icons/fa';
|
import { FaCaretDown, FaCaretUp } from 'react-icons/fa';
|
||||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||||
import type { PerformanceInfo } from '../../agent/llm/StreamingTypes';
|
import type { PerformanceInfo, ToolApprovalDecision } from '../../agent/llm/StreamingTypes';
|
||||||
import { summarizeTodoCounts } from '../../agent/core/todo';
|
import { summarizeTodoCounts } from '../../agent/core/todo';
|
||||||
import type { TodoItem } from '../../agent/core/todo';
|
import type { TodoItem } from '../../agent/core/todo';
|
||||||
|
import { useI18n } from '../../i18n/useI18n';
|
||||||
|
|
||||||
interface AssistantMessageProps {
|
interface AssistantMessageProps {
|
||||||
content: string;
|
content: string;
|
||||||
@@ -19,6 +20,13 @@ interface AssistantMessageProps {
|
|||||||
toolSuccess?: boolean;
|
toolSuccess?: boolean;
|
||||||
toolRawResult?: string;
|
toolRawResult?: string;
|
||||||
toolResultDisplayContent?: string;
|
toolResultDisplayContent?: string;
|
||||||
|
toolConfirmation?: {
|
||||||
|
toolCallId: string;
|
||||||
|
toolName: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
toolDenied?: boolean;
|
||||||
|
onToolConfirmationDecision?: (decision: ToolApprovalDecision) => void;
|
||||||
todoSnapshot?: TodoItem[];
|
todoSnapshot?: TodoItem[];
|
||||||
isToolCallMessage?: boolean;
|
isToolCallMessage?: boolean;
|
||||||
}
|
}
|
||||||
@@ -169,9 +177,12 @@ const AssistantMessage: React.FC<AssistantMessageProps> = ({
|
|||||||
toolSuccess,
|
toolSuccess,
|
||||||
toolRawResult,
|
toolRawResult,
|
||||||
toolResultDisplayContent,
|
toolResultDisplayContent,
|
||||||
|
toolConfirmation,
|
||||||
|
onToolConfirmationDecision,
|
||||||
todoSnapshot,
|
todoSnapshot,
|
||||||
isToolCallMessage = false,
|
isToolCallMessage = false,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useI18n();
|
||||||
const prefillTps = formatTps(performanceInfo?.prefillTps);
|
const prefillTps = formatTps(performanceInfo?.prefillTps);
|
||||||
const generationTps = formatTps(performanceInfo?.generationTps);
|
const generationTps = formatTps(performanceInfo?.generationTps);
|
||||||
const hasPerformanceInfo = Boolean(prefillTps || generationTps);
|
const hasPerformanceInfo = Boolean(prefillTps || generationTps);
|
||||||
@@ -182,6 +193,7 @@ const AssistantMessage: React.FC<AssistantMessageProps> = ({
|
|||||||
|| content === COMPACTION_DONE_LABEL
|
|| content === COMPACTION_DONE_LABEL
|
||||||
|| content === COMPACTION_EMPTY_LABEL;
|
|| content === COMPACTION_EMPTY_LABEL;
|
||||||
const isTodoSnapshotCard = toolName === 'update_todo_list' && Array.isArray(todoSnapshot);
|
const isTodoSnapshotCard = toolName === 'update_todo_list' && Array.isArray(todoSnapshot);
|
||||||
|
const isToolConfirmationCard = Boolean(toolConfirmation) && Boolean(onToolConfirmationDecision);
|
||||||
const shouldRenderGenericToolResult = Boolean(toolName) && typeof toolSuccess === 'boolean' && !isTodoSnapshotCard;
|
const shouldRenderGenericToolResult = Boolean(toolName) && typeof toolSuccess === 'boolean' && !isTodoSnapshotCard;
|
||||||
const genericToolDisplayContent = toolResultDisplayContent ?? toolRawResult ?? content;
|
const genericToolDisplayContent = toolResultDisplayContent ?? toolRawResult ?? content;
|
||||||
|
|
||||||
@@ -236,6 +248,54 @@ const AssistantMessage: React.FC<AssistantMessageProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isToolConfirmationCard && toolConfirmation && onToolConfirmationDecision) {
|
||||||
|
return (
|
||||||
|
<div className="message-tool-result">
|
||||||
|
<p className="message-tool-result-title">
|
||||||
|
<span aria-hidden="true">?</span>{' '}
|
||||||
|
<strong>{toolConfirmation.toolName}</strong>
|
||||||
|
</p>
|
||||||
|
<div className="message-tool-summary">
|
||||||
|
<span className="message-tool-summary-prefix" aria-hidden="true">└── </span>
|
||||||
|
<div className="message-tool-summary-content">
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm, remarkMath]}
|
||||||
|
rehypePlugins={[rehypeKatex]}
|
||||||
|
components={{
|
||||||
|
code: (props) => <CodeComponent {...props} isToolCallMessage={false} />,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{toolConfirmation.message}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="message-tool-confirmation-actions" aria-label={t('chatbox.tool.confirmation.ariaLabel')}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="message-tool-confirmation-btn dialog-btn dialog-btn-primary kgone-btn-generate"
|
||||||
|
onClick={() => onToolConfirmationDecision('allow')}
|
||||||
|
>
|
||||||
|
{t('chatbox.tool.confirmation.allow')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="message-tool-confirmation-btn message-tool-confirmation-btn-always dialog-btn dialog-btn-primary kgone-btn-generate"
|
||||||
|
onClick={() => onToolConfirmationDecision('always_allow')}
|
||||||
|
>
|
||||||
|
{t('chatbox.tool.confirmation.alwaysAllow')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="message-tool-confirmation-btn message-tool-confirmation-btn-deny dialog-btn dialog-btn-primary kgone-btn-generate"
|
||||||
|
onClick={() => onToolConfirmationDecision('deny')}
|
||||||
|
>
|
||||||
|
{t('chatbox.tool.confirmation.deny')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (isCompactionBanner) {
|
if (isCompactionBanner) {
|
||||||
return (
|
return (
|
||||||
<div className="message-divider-banner" aria-label={content}>
|
<div className="message-divider-banner" aria-label={content}>
|
||||||
|
|||||||
@@ -1,7 +1,23 @@
|
|||||||
import { act, renderHook } from '@testing-library/react';
|
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';
|
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', () => ({
|
vi.mock('../agent/core/AgentCore', () => ({
|
||||||
AgentCore: {
|
AgentCore: {
|
||||||
instance: vi.fn()
|
instance: vi.fn()
|
||||||
@@ -16,13 +32,7 @@ vi.mock('../core/KGCore', () => ({
|
|||||||
|
|
||||||
vi.mock('../stores/projectStore', () => ({
|
vi.mock('../stores/projectStore', () => ({
|
||||||
useProjectStore: {
|
useProjectStore: {
|
||||||
getState: vi.fn(() => ({
|
getState: vi.fn(() => mockedStoreState),
|
||||||
activeRegionId: null,
|
|
||||||
selectedRegionIds: [],
|
|
||||||
timeSignature: { numerator: 4, denominator: 4 },
|
|
||||||
tracks: [],
|
|
||||||
refreshProjectState: vi.fn(),
|
|
||||||
})),
|
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -53,6 +63,13 @@ const flushMicrotasks = async (): Promise<void> => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('useStreamProcessor', () => {
|
describe('useStreamProcessor', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
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 () => {
|
it('switches from Thinking to Processing after the first text token arrives', async () => {
|
||||||
let releaseDone!: () => void;
|
let releaseDone!: () => void;
|
||||||
const doneGate = new Promise<void>((resolve) => {
|
const doneGate = new Promise<void>((resolve) => {
|
||||||
@@ -388,4 +405,172 @@ describe('useStreamProcessor', () => {
|
|||||||
expect(toolResultMessage?.toolRawResult).toBe('raw music result');
|
expect(toolResultMessage?.toolRawResult).toBe('raw music result');
|
||||||
expect(toolResultMessage?.toolResultDisplayContent).toBe('raw music result');
|
expect(toolResultMessage?.toolResultDisplayContent).toBe('raw music result');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
vi.spyOn(KGCore, 'instance').mockReturnValue({
|
||||||
|
getCurrentProject: () => ({
|
||||||
|
getTimeSignature: () => ({ numerator: 4, denominator: 4 }),
|
||||||
|
getTracks: () => [
|
||||||
|
{
|
||||||
|
getId: () => '1',
|
||||||
|
getName: () => 'Lead',
|
||||||
|
getRegions: () => [selectedRegion],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
getSelectedItems: () => [selectedRegion],
|
||||||
|
} as unknown as KGCore);
|
||||||
|
mockedStoreState.activeRegionId = selectedRegion.getId();
|
||||||
|
|
||||||
|
const messages = new Map<string, ChatMessage>();
|
||||||
|
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<string>;
|
||||||
|
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 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');
|
||||||
|
vi.spyOn(KGCore, 'instance').mockReturnValue({
|
||||||
|
getCurrentProject: () => ({
|
||||||
|
getTimeSignature: () => ({ numerator: 4, denominator: 4 }),
|
||||||
|
getTracks: () => [
|
||||||
|
{
|
||||||
|
getId: () => '1',
|
||||||
|
getName: () => 'Lead',
|
||||||
|
getRegions: () => [selectedRegion],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
getSelectedItems: () => [selectedRegion],
|
||||||
|
} as unknown as KGCore);
|
||||||
|
mockedStoreState.activeRegionId = selectedRegion.getId();
|
||||||
|
|
||||||
|
const messages = new Map<string, ChatMessage>();
|
||||||
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { AgentCore } from '../agent/core/AgentCore';
|
import { AgentCore } from '../agent/core/AgentCore';
|
||||||
import { AVAILABLE_TOOLS } from '../agent/tools';
|
import { createToolInstance } from '../agent/tools';
|
||||||
import { createStreamingMessage, createMessage } from '../utils/chatMessageUtils';
|
import { createStreamingMessage, createMessage } from '../utils/chatMessageUtils';
|
||||||
import type { ChatMessage } from '../types/projectTypes';
|
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';
|
const TODO_TOOL_NAME = 'update_todo_list';
|
||||||
|
|
||||||
@@ -12,29 +14,6 @@ interface PendingToolCall {
|
|||||||
arguments: Record<string, unknown> | null;
|
arguments: Record<string, unknown> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildToolResultDisplayContent = (
|
|
||||||
toolName: string,
|
|
||||||
success: boolean,
|
|
||||||
rawResult: string,
|
|
||||||
toolArgs: Record<string, unknown> | null,
|
|
||||||
): string => {
|
|
||||||
if (!success) {
|
|
||||||
return rawResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ToolClass = AVAILABLE_TOOLS[toolName as keyof typeof AVAILABLE_TOOLS];
|
|
||||||
if (!ToolClass) {
|
|
||||||
return rawResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const toolInstance = new ToolClass();
|
|
||||||
return toolInstance.buildToolResultDisplayContent(toolArgs, { success, result: rawResult }) ?? rawResult;
|
|
||||||
} catch {
|
|
||||||
return rawResult;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
interface StreamProcessorOptions {
|
interface StreamProcessorOptions {
|
||||||
onMessageUpdate: (messageId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
|
onMessageUpdate: (messageId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
|
||||||
onMessageAdd: (message: ChatMessage) => void;
|
onMessageAdd: (message: ChatMessage) => void;
|
||||||
@@ -79,7 +58,55 @@ export const useStreamProcessor = (options: StreamProcessorOptions): StreamProce
|
|||||||
console.log(input);
|
console.log(input);
|
||||||
console.log('------------------------------');
|
console.log('------------------------------');
|
||||||
|
|
||||||
for await (const chunk of agentCore.processUserInput(input)) {
|
const requestToolApproval = async (toolCall: PendingToolCall): Promise<ToolApprovalDecision> => {
|
||||||
|
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<ToolApprovalDecision>((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<string, unknown> | 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) {
|
if (controller.signal.aborted) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -142,17 +169,23 @@ export const useStreamProcessor = (options: StreamProcessorOptions): StreamProce
|
|||||||
console.log('-------------------------------------');
|
console.log('-------------------------------------');
|
||||||
|
|
||||||
// Show tool result in UI
|
// Show tool result in UI
|
||||||
const { toolCallId, name, success, result } = chunk.toolResult;
|
const { toolCallId, name, success, result, denied } = chunk.toolResult;
|
||||||
const pendingToolCallIndex = pendingToolCalls.findIndex(toolCall => toolCall.id === toolCallId);
|
const pendingToolCallIndex = pendingToolCalls.findIndex(toolCall => toolCall.id === toolCallId);
|
||||||
const pendingToolCall = pendingToolCallIndex >= 0
|
const pendingToolCall = pendingToolCallIndex >= 0
|
||||||
? pendingToolCalls.splice(pendingToolCallIndex, 1)[0]
|
? pendingToolCalls.splice(pendingToolCallIndex, 1)[0]
|
||||||
: undefined;
|
: undefined;
|
||||||
const toolResultDisplayContent = buildToolResultDisplayContent(
|
let toolResultDisplayContent = result;
|
||||||
name,
|
if (success) {
|
||||||
success,
|
try {
|
||||||
result,
|
const toolInstance = createToolInstance(name);
|
||||||
|
toolResultDisplayContent = toolInstance?.buildToolResultDisplayContent(
|
||||||
pendingToolCall?.arguments ?? null,
|
pendingToolCall?.arguments ?? null,
|
||||||
);
|
{ success, result },
|
||||||
|
) ?? result;
|
||||||
|
} catch {
|
||||||
|
toolResultDisplayContent = result;
|
||||||
|
}
|
||||||
|
}
|
||||||
const toolResultMsg = name === TODO_TOOL_NAME
|
const toolResultMsg = name === TODO_TOOL_NAME
|
||||||
? {
|
? {
|
||||||
...createMessage('assistant', result),
|
...createMessage('assistant', result),
|
||||||
@@ -166,6 +199,7 @@ export const useStreamProcessor = (options: StreamProcessorOptions): StreamProce
|
|||||||
toolSuccess: success,
|
toolSuccess: success,
|
||||||
toolRawResult: result,
|
toolRawResult: result,
|
||||||
toolResultDisplayContent,
|
toolResultDisplayContent,
|
||||||
|
toolDenied: denied,
|
||||||
};
|
};
|
||||||
onMessageAdd(toolResultMsg);
|
onMessageAdd(toolResultMsg);
|
||||||
|
|
||||||
@@ -176,9 +210,11 @@ export const useStreamProcessor = (options: StreamProcessorOptions): StreamProce
|
|||||||
performanceInfo = undefined;
|
performanceInfo = undefined;
|
||||||
|
|
||||||
// Create a fresh streaming placeholder for the next LLM response
|
// Create a fresh streaming placeholder for the next LLM response
|
||||||
|
if (!denied) {
|
||||||
const nextMsg = createStreamingMessage();
|
const nextMsg = createStreamingMessage();
|
||||||
currentStreamingId = nextMsg.id;
|
currentStreamingId = nextMsg.id;
|
||||||
onMessageAdd(nextMsg);
|
onMessageAdd(nextMsg);
|
||||||
|
}
|
||||||
} else if (chunk.type === 'done') {
|
} else if (chunk.type === 'done') {
|
||||||
performanceInfo = chunk.performanceInfo;
|
performanceInfo = chunk.performanceInfo;
|
||||||
// Finalize the streaming message
|
// Finalize the streaming message
|
||||||
@@ -209,12 +245,17 @@ export const useStreamProcessor = (options: StreamProcessorOptions): StreamProce
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.error('Error processing stream:', error);
|
console.error('Error processing stream:', error);
|
||||||
|
const errorContent = `Error: ${error instanceof Error ? error.message : 'Failed to process message'}`;
|
||||||
|
try {
|
||||||
onMessageUpdate(currentStreamingId, (msg) => ({
|
onMessageUpdate(currentStreamingId, (msg) => ({
|
||||||
...msg,
|
...msg,
|
||||||
content: `Error: ${error instanceof Error ? error.message : 'Failed to process message'}`,
|
content: errorContent,
|
||||||
isStreaming: false,
|
isStreaming: false,
|
||||||
tokenCount: undefined
|
tokenCount: undefined
|
||||||
}));
|
}));
|
||||||
|
} catch {
|
||||||
|
onMessageAdd(createMessage('assistant', errorContent));
|
||||||
|
}
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
setAbortController(null);
|
setAbortController(null);
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ export const enUsMessages: TranslationMessages = {
|
|||||||
'chatbox.todo.ariaLabel': 'Agent task checklist',
|
'chatbox.todo.ariaLabel': 'Agent task checklist',
|
||||||
'chatbox.todo.count': '{completed}/{total} completed',
|
'chatbox.todo.count': '{completed}/{total} completed',
|
||||||
'chatbox.todo.active': 'Working on: {task}',
|
'chatbox.todo.active': 'Working on: {task}',
|
||||||
|
'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.createTrack': 'Create track',
|
||||||
'mainContent.showGlobalTracks': 'Show global tracks',
|
'mainContent.showGlobalTracks': 'Show global tracks',
|
||||||
'status.chordGuideCandidate': 'Chord Guide Candidate: {name} - {notes} - {note}',
|
'status.chordGuideCandidate': 'Chord Guide Candidate: {name} - {notes} - {note}',
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ export const frFrMessages: TranslationMessages = {
|
|||||||
'chatbox.todo.ariaLabel': 'Liste des tâches de l\'agent',
|
'chatbox.todo.ariaLabel': 'Liste des tâches de l\'agent',
|
||||||
'chatbox.todo.count': '{completed}/{total} terminée(s)',
|
'chatbox.todo.count': '{completed}/{total} terminée(s)',
|
||||||
'chatbox.todo.active': 'En cours : {task}',
|
'chatbox.todo.active': 'En cours : {task}',
|
||||||
|
'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.createTrack': 'Créer une piste',
|
||||||
'mainContent.showGlobalTracks': 'Afficher les pistes globales',
|
'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}',
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ export const zhCnMessages: TranslationMessages = {
|
|||||||
'chatbox.todo.ariaLabel': '代理任务清单',
|
'chatbox.todo.ariaLabel': '代理任务清单',
|
||||||
'chatbox.todo.count': '已完成 {completed}/{total}',
|
'chatbox.todo.count': '已完成 {completed}/{total}',
|
||||||
'chatbox.todo.active': '当前进行中:{task}',
|
'chatbox.todo.active': '当前进行中:{task}',
|
||||||
|
'chatbox.fastForward.title': '快速放行工具执行审批',
|
||||||
|
'chatbox.tool.confirmation.ariaLabel': '工具执行审批操作',
|
||||||
|
'chatbox.tool.confirmation.allow': '允许',
|
||||||
|
'chatbox.tool.confirmation.alwaysAllow': '始终允许',
|
||||||
|
'chatbox.tool.confirmation.deny': '拒绝',
|
||||||
'mainContent.createTrack': '创建轨道',
|
'mainContent.createTrack': '创建轨道',
|
||||||
'mainContent.showGlobalTracks': '显示全局轨道',
|
'mainContent.showGlobalTracks': '显示全局轨道',
|
||||||
'status.chordGuideCandidate': '和弦指导候选: {name} - {notes} - {note}',
|
'status.chordGuideCandidate': '和弦指导候选: {name} - {notes} - {note}',
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ export const zhHkMessages: TranslationMessages = {
|
|||||||
'chatbox.todo.ariaLabel': '代理任務清單',
|
'chatbox.todo.ariaLabel': '代理任務清單',
|
||||||
'chatbox.todo.count': '已完成 {completed}/{total}',
|
'chatbox.todo.count': '已完成 {completed}/{total}',
|
||||||
'chatbox.todo.active': '當前進行中:{task}',
|
'chatbox.todo.active': '當前進行中:{task}',
|
||||||
|
'chatbox.fastForward.title': '快速放行工具執行審批',
|
||||||
|
'chatbox.tool.confirmation.ariaLabel': '工具執行審批操作',
|
||||||
|
'chatbox.tool.confirmation.allow': '允許',
|
||||||
|
'chatbox.tool.confirmation.alwaysAllow': '始終允許',
|
||||||
|
'chatbox.tool.confirmation.deny': '拒絕',
|
||||||
'mainContent.createTrack': '建立音軌',
|
'mainContent.createTrack': '建立音軌',
|
||||||
'mainContent.showGlobalTracks': '顯示全域音軌',
|
'mainContent.showGlobalTracks': '顯示全域音軌',
|
||||||
'status.chordGuideCandidate': '和弦指導候選: {name} - {notes} - {note}',
|
'status.chordGuideCandidate': '和弦指導候選: {name} - {notes} - {note}',
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ interface ProjectState {
|
|||||||
|
|
||||||
// ChatBox state
|
// ChatBox state
|
||||||
showChatBox: boolean;
|
showChatBox: boolean;
|
||||||
|
toolFastForwardEnabled: boolean;
|
||||||
|
|
||||||
// K.G.One panel state
|
// K.G.One panel state
|
||||||
showKGOnePanel: boolean;
|
showKGOnePanel: boolean;
|
||||||
@@ -227,6 +228,8 @@ interface ProjectState {
|
|||||||
// ChatBox actions
|
// ChatBox actions
|
||||||
setShowChatBox: (show: boolean) => void;
|
setShowChatBox: (show: boolean) => void;
|
||||||
toggleChatBox: () => void;
|
toggleChatBox: () => void;
|
||||||
|
setToolFastForwardEnabled: (enabled: boolean) => void;
|
||||||
|
toggleToolFastForwardEnabled: () => void;
|
||||||
|
|
||||||
// K.G.One panel actions
|
// K.G.One panel actions
|
||||||
toggleKGOnePanel: () => void;
|
toggleKGOnePanel: () => void;
|
||||||
@@ -466,6 +469,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
|
|
||||||
// Initial ChatBox state
|
// Initial ChatBox state
|
||||||
showChatBox: initialChatBoxState,
|
showChatBox: initialChatBoxState,
|
||||||
|
toolFastForwardEnabled: false,
|
||||||
|
|
||||||
// Initial K.G.One panel state
|
// Initial K.G.One panel state
|
||||||
showKGOnePanel: false,
|
showKGOnePanel: false,
|
||||||
@@ -1740,6 +1744,14 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
set({ showChatBox: false });
|
set({ showChatBox: false });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setToolFastForwardEnabled: (enabled: boolean) => {
|
||||||
|
set({ toolFastForwardEnabled: enabled });
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleToolFastForwardEnabled: () => {
|
||||||
|
set((state) => ({ toolFastForwardEnabled: !state.toolFastForwardEnabled }));
|
||||||
|
},
|
||||||
|
|
||||||
toggleKGOnePanel: () => {
|
toggleKGOnePanel: () => {
|
||||||
const { showKGOnePanel, showSettings } = get();
|
const { showKGOnePanel, showSettings } = get();
|
||||||
if (showSettings || !showKGOnePanel) {
|
if (showSettings || !showKGOnePanel) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Transform, type TransformFnParams } from 'class-transformer';
|
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';
|
import type { TodoItem } from '../agent/core/todo';
|
||||||
|
|
||||||
export interface TimeSignature {
|
export interface TimeSignature {
|
||||||
@@ -18,6 +18,13 @@ export interface ChatMessage {
|
|||||||
toolSuccess?: boolean;
|
toolSuccess?: boolean;
|
||||||
toolRawResult?: string;
|
toolRawResult?: string;
|
||||||
toolResultDisplayContent?: string;
|
toolResultDisplayContent?: string;
|
||||||
|
toolConfirmation?: {
|
||||||
|
toolCallId: string;
|
||||||
|
toolName: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
toolDenied?: boolean;
|
||||||
|
onToolConfirmationDecision?: (decision: ToolApprovalDecision) => void;
|
||||||
todoSnapshot?: TodoItem[];
|
todoSnapshot?: TodoItem[];
|
||||||
isToolCallMessage?: boolean;
|
isToolCallMessage?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { AgentCore } from '../agent/core/AgentCore';
|
import { AgentCore } from '../agent/core/AgentCore';
|
||||||
|
import { useProjectStore } from '../stores/projectStore';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear chat history and reset chat state
|
* Clear chat history and reset chat state
|
||||||
@@ -9,6 +10,7 @@ export const clearChatHistory = () => {
|
|||||||
// Clear agent state
|
// Clear agent state
|
||||||
const agentCore = AgentCore.instance();
|
const agentCore = AgentCore.instance();
|
||||||
agentCore.clearConversation();
|
agentCore.clearConversation();
|
||||||
|
useProjectStore.getState().setToolFastForwardEnabled(false);
|
||||||
|
|
||||||
console.log('Chat history cleared programmatically');
|
console.log('Chat history cleared programmatically');
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user