initial public release.
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
import { LLMProvider } from '../llm/LLMProvider';
|
||||
import { AgentState } from './AgentState';
|
||||
import { SystemPrompts } from './SystemPrompts';
|
||||
import type { StreamChunk } from '../llm/StreamingTypes';
|
||||
|
||||
/**
|
||||
* Main orchestrator for the AI agent system
|
||||
*/
|
||||
export class AgentCore {
|
||||
private static _instance: AgentCore | null = null;
|
||||
|
||||
private llmProvider: LLMProvider | null = null;
|
||||
private agentState: AgentState;
|
||||
private currentUserMessageId: string | null = null;
|
||||
private currentAssistantMessageId: string | null = null;
|
||||
|
||||
private constructor() {
|
||||
this.agentState = new AgentState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the singleton instance
|
||||
*/
|
||||
static instance(): AgentCore {
|
||||
if (!AgentCore._instance) {
|
||||
AgentCore._instance = new AgentCore();
|
||||
}
|
||||
return AgentCore._instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the LLM provider
|
||||
*/
|
||||
setLLMProvider(provider: LLMProvider): void {
|
||||
this.llmProvider = provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current LLM provider
|
||||
*/
|
||||
getLLMProvider(): LLMProvider | null {
|
||||
return this.llmProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the agent state
|
||||
*/
|
||||
getAgentState(): AgentState {
|
||||
return this.agentState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process user input and generate streaming response
|
||||
*/
|
||||
async *processUserInput(userInput: string): AsyncIterableIterator<StreamChunk> {
|
||||
if (!this.llmProvider) {
|
||||
throw new Error('No LLM provider configured');
|
||||
}
|
||||
|
||||
// Add user message to state and track its ID
|
||||
this.currentUserMessageId = this.agentState.addMessage('user', userInput);
|
||||
|
||||
// Get system prompt with current context
|
||||
const systemPrompt = await SystemPrompts.getSystemPromptWithContext();
|
||||
|
||||
// Get full conversation history with preserved roles
|
||||
const conversationHistory = this.agentState.getMessages();
|
||||
|
||||
// Generate streaming response with full conversation context
|
||||
let assistantResponse = '';
|
||||
|
||||
// Pre-add an empty assistant message that we'll update as we stream
|
||||
this.currentAssistantMessageId = this.agentState.addMessage('assistant', '');
|
||||
|
||||
try {
|
||||
for await (const chunk of this.llmProvider.generateStream(conversationHistory, systemPrompt)) {
|
||||
if (chunk.type === 'text') {
|
||||
assistantResponse += chunk.content;
|
||||
// Update the assistant message in real-time
|
||||
this.agentState.updateMessage(this.currentAssistantMessageId, assistantResponse);
|
||||
}
|
||||
yield chunk;
|
||||
}
|
||||
|
||||
// Final update to ensure the complete response is stored
|
||||
if (assistantResponse) {
|
||||
this.agentState.updateMessage(this.currentAssistantMessageId, assistantResponse);
|
||||
}
|
||||
} finally {
|
||||
// Clear the current message IDs when done (successfully or not)
|
||||
this.currentUserMessageId = null;
|
||||
this.currentAssistantMessageId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process user input and get complete response (non-streaming)
|
||||
*/
|
||||
async processUserInputComplete(userInput: string): Promise<string> {
|
||||
if (!this.llmProvider) {
|
||||
throw new Error('No LLM provider configured');
|
||||
}
|
||||
|
||||
// Add user message to state
|
||||
this.agentState.addMessage('user', userInput);
|
||||
|
||||
// Get system prompt with current context
|
||||
const systemPrompt = await SystemPrompts.getSystemPromptWithContext();
|
||||
|
||||
// Get full conversation history with preserved roles
|
||||
const conversationHistory = this.agentState.getMessages();
|
||||
|
||||
// Generate complete response with full conversation context
|
||||
const response = await this.llmProvider.generateCompletion(conversationHistory, systemPrompt);
|
||||
|
||||
// Add assistant response to state
|
||||
this.agentState.addMessage('assistant', response.content);
|
||||
|
||||
return response.content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort the current streaming request and clean up messages
|
||||
* Returns the content of the user message that was aborted (for restoring to input)
|
||||
*/
|
||||
abortCurrentRequest(): string | null {
|
||||
let userMessageContent = null;
|
||||
|
||||
// Remove the current assistant message (the "in progress" one)
|
||||
if (this.currentAssistantMessageId) {
|
||||
this.agentState.removeMessage(this.currentAssistantMessageId);
|
||||
this.currentAssistantMessageId = null;
|
||||
}
|
||||
|
||||
// Remove the current user message and get its content for restoration
|
||||
if (this.currentUserMessageId) {
|
||||
const messages = this.agentState.getMessages();
|
||||
const userMessage = messages.find(msg => msg.id === this.currentUserMessageId);
|
||||
if (userMessage) {
|
||||
userMessageContent = userMessage.content;
|
||||
}
|
||||
this.agentState.removeMessage(this.currentUserMessageId);
|
||||
this.currentUserMessageId = null;
|
||||
}
|
||||
|
||||
return userMessageContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there's a current streaming request in progress
|
||||
*/
|
||||
isStreamingInProgress(): boolean {
|
||||
return this.currentUserMessageId !== null && this.currentAssistantMessageId !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the conversation history
|
||||
*/
|
||||
clearConversation(): void {
|
||||
this.agentState.clearMessages();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get whether the agent is currently working on a task
|
||||
*/
|
||||
getIsWorkingOnTask(): boolean {
|
||||
return this.agentState.getIsWorkingOnTask();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set whether the agent is currently working on a task
|
||||
*/
|
||||
setIsWorkingOnTask(isWorking: boolean): void {
|
||||
this.agentState.setIsWorkingOnTask(isWorking);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Manages the state of an agent conversation
|
||||
*/
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export class AgentState {
|
||||
private messages: Message[] = [];
|
||||
private conversationId: string;
|
||||
private isWorkingOnTask: boolean = false;
|
||||
|
||||
constructor(conversationId?: string, isWorkingOnTask: boolean = false) {
|
||||
this.conversationId = conversationId || this.generateConversationId();
|
||||
this.isWorkingOnTask = isWorkingOnTask;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a message to the conversation
|
||||
*/
|
||||
addMessage(role: 'user' | 'assistant', content: string): string {
|
||||
const message: Message = {
|
||||
id: this.generateMessageId(),
|
||||
role,
|
||||
content,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
this.messages.push(message);
|
||||
return message.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the content of a message by ID
|
||||
*/
|
||||
updateMessage(messageId: string, content: string): boolean {
|
||||
const messageIndex = this.messages.findIndex(msg => msg.id === messageId);
|
||||
if (messageIndex !== -1) {
|
||||
this.messages[messageIndex].content = content;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a message by ID
|
||||
*/
|
||||
removeMessage(messageId: string): boolean {
|
||||
const messageIndex = this.messages.findIndex(msg => msg.id === messageId);
|
||||
if (messageIndex !== -1) {
|
||||
this.messages.splice(messageIndex, 1);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the last N messages
|
||||
*/
|
||||
removeLastMessages(count: number): void {
|
||||
this.messages.splice(-count, count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all messages in the conversation
|
||||
*/
|
||||
getMessages(): Message[] {
|
||||
return [...this.messages];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the conversation ID
|
||||
*/
|
||||
getConversationId(): string {
|
||||
return this.conversationId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all messages
|
||||
*/
|
||||
clearMessages(): void {
|
||||
this.messages = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last N messages
|
||||
*/
|
||||
getRecentMessages(count: number): Message[] {
|
||||
return this.messages.slice(-count);
|
||||
}
|
||||
|
||||
private generateConversationId(): string {
|
||||
return `conv_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`;
|
||||
}
|
||||
|
||||
private generateMessageId(): string {
|
||||
return `msg_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`;
|
||||
}
|
||||
|
||||
// getters and setters
|
||||
getIsWorkingOnTask(): boolean {
|
||||
return this.isWorkingOnTask;
|
||||
}
|
||||
|
||||
setIsWorkingOnTask(isWorkingOnTask: boolean): void {
|
||||
this.isWorkingOnTask = isWorkingOnTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
import { KGRegion } from '../../core/region/KGRegion';
|
||||
import { KGMidiTrack } from '../../core/track/KGMidiTrack';
|
||||
import { KGTrack } from '../../core/track/KGTrack';
|
||||
import { useProjectStore } from '../../stores/projectStore';
|
||||
import { ConfigManager } from '../../core/config/ConfigManager';
|
||||
import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
|
||||
|
||||
/**
|
||||
* Context data structure for system prompt template replacement
|
||||
*/
|
||||
interface SystemPromptContext {
|
||||
bpm: number;
|
||||
time_signature: string;
|
||||
key_signature: string;
|
||||
track_instrument: string;
|
||||
current_region_start: number;
|
||||
current_region_end: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* System prompts for the AI agent with dynamic context loading
|
||||
*/
|
||||
export class SystemPrompts {
|
||||
private static cachedTemplate: string | null = null;
|
||||
private static readonly FALLBACK_PROMPT = `You are K.G.Studio Musician Assistant Agent, a highly skilled music musician with extensive knowledge in music theory, composition, and production.`;
|
||||
|
||||
/**
|
||||
* Load the system prompt template from the public folder
|
||||
*/
|
||||
private static async loadTemplate(): Promise<string> {
|
||||
if (this.cachedTemplate) {
|
||||
return this.cachedTemplate;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/prompts/system.md');
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load system prompt: ${response.status}`);
|
||||
}
|
||||
|
||||
this.cachedTemplate = await response.text();
|
||||
return this.cachedTemplate;
|
||||
} catch (error) {
|
||||
console.error('Failed to load system prompt template:', error);
|
||||
return this.FALLBACK_PROMPT;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a region by ID across all tracks
|
||||
*/
|
||||
private static findRegionById(regionId: string): KGRegion | null {
|
||||
const core = KGCore.instance();
|
||||
const project = core.getCurrentProject();
|
||||
const tracks = project.getTracks();
|
||||
|
||||
for (const track of tracks) {
|
||||
const regions = track.getRegions();
|
||||
const region = regions.find(r => r.getId() === regionId);
|
||||
if (region) {
|
||||
return region;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find track that contains the given region
|
||||
*/
|
||||
private static findTrackByRegion(region: KGRegion): KGTrack | null {
|
||||
const core = KGCore.instance();
|
||||
const project = core.getCurrentProject();
|
||||
const tracks = project.getTracks();
|
||||
|
||||
return tracks.find(track => track.getRegions().includes(region)) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract current project context from KGCore
|
||||
*/
|
||||
private static extractProjectContext(): Partial<SystemPromptContext> {
|
||||
const core = KGCore.instance();
|
||||
const project = core.getCurrentProject();
|
||||
|
||||
// Get basic project info
|
||||
const context: Partial<SystemPromptContext> = {
|
||||
bpm: project.getBpm(),
|
||||
time_signature: `${project.getTimeSignature().numerator}/${project.getTimeSignature().denominator}`,
|
||||
key_signature: project.getKeySignature(),
|
||||
};
|
||||
|
||||
// Get current track instrument using the corrected logic
|
||||
let trackInstrument = 'Piano'; // Default
|
||||
|
||||
// Step 1: Check if there's an active piano roll region
|
||||
const activeRegionId = this.getActiveRegionId();
|
||||
if (activeRegionId) {
|
||||
const activeRegion = this.findRegionById(activeRegionId);
|
||||
if (activeRegion) {
|
||||
const track = this.findTrackByRegion(activeRegion);
|
||||
if (track && track instanceof KGMidiTrack) {
|
||||
trackInstrument = FLUIDR3_INSTRUMENT_MAP[track.getInstrument()].displayName;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Step 2: Check if user has selected region(s)
|
||||
const selectedItems = core.getSelectedItems();
|
||||
const selectedRegion = selectedItems.find(item => item instanceof KGRegion) as KGRegion;
|
||||
|
||||
if (selectedRegion) {
|
||||
const track = this.findTrackByRegion(selectedRegion);
|
||||
if (track && track instanceof KGMidiTrack) {
|
||||
trackInstrument = FLUIDR3_INSTRUMENT_MAP[track.getInstrument()].displayName;
|
||||
}
|
||||
} else {
|
||||
// Step 3: Find the first track
|
||||
const tracks = project.getTracks();
|
||||
const firstMidiTrack = tracks.find(track => track instanceof KGMidiTrack) as KGMidiTrack;
|
||||
if (firstMidiTrack) {
|
||||
trackInstrument = FLUIDR3_INSTRUMENT_MAP[firstMidiTrack.getInstrument()].displayName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context.track_instrument = trackInstrument;
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active region ID from project store
|
||||
*/
|
||||
private static getActiveRegionId(): string | null {
|
||||
try {
|
||||
const store = useProjectStore.getState();
|
||||
return store.activeRegionId;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract current region context with fallback logic
|
||||
*/
|
||||
private static extractRegionContext(): Partial<SystemPromptContext> {
|
||||
const core = KGCore.instance();
|
||||
const project = core.getCurrentProject();
|
||||
|
||||
// Step 1: Try active piano roll region
|
||||
const activeRegionId = this.getActiveRegionId();
|
||||
if (activeRegionId) {
|
||||
const activeRegion = this.findRegionById(activeRegionId);
|
||||
if (activeRegion) {
|
||||
return {
|
||||
current_region_start: activeRegion.getStartFromBeat(),
|
||||
current_region_end: activeRegion.getStartFromBeat() + activeRegion.getLength(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Try selected region
|
||||
const selectedItems = core.getSelectedItems();
|
||||
const selectedRegion = selectedItems.find(item => item instanceof KGRegion) as KGRegion;
|
||||
|
||||
if (selectedRegion) {
|
||||
return {
|
||||
current_region_start: selectedRegion.getStartFromBeat(),
|
||||
current_region_end: selectedRegion.getStartFromBeat() + selectedRegion.getLength(),
|
||||
};
|
||||
}
|
||||
|
||||
// Step 3: Fallback to project bounds
|
||||
const timeSignature = project.getTimeSignature();
|
||||
const beatsPerBar = timeSignature.numerator;
|
||||
const maxBars = project.getMaxBars();
|
||||
|
||||
return {
|
||||
current_region_start: 0,
|
||||
current_region_end: maxBars * beatsPerBar,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full context by combining project and region data
|
||||
*/
|
||||
private static getFullContext(): SystemPromptContext {
|
||||
const projectContext = this.extractProjectContext();
|
||||
const regionContext = this.extractRegionContext();
|
||||
|
||||
return {
|
||||
bpm: projectContext.bpm || 120,
|
||||
time_signature: projectContext.time_signature || '4/4',
|
||||
key_signature: projectContext.key_signature || 'C major',
|
||||
track_instrument: projectContext.track_instrument || 'Piano',
|
||||
current_region_start: regionContext.current_region_start || 0,
|
||||
current_region_end: regionContext.current_region_end || 32,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace template variables with actual context values
|
||||
*/
|
||||
static replaceTemplateVariables(template: string, context: SystemPromptContext): string {
|
||||
let result = template;
|
||||
|
||||
// Replace all context variables
|
||||
result = result.replace(/{bpm}/g, context.bpm.toString());
|
||||
result = result.replace(/{time_signature}/g, context.time_signature);
|
||||
result = result.replace(/{key_signature}/g, context.key_signature);
|
||||
result = result.replace(/{track_instrument}/g, context.track_instrument);
|
||||
result = result.replace(/{current_region_start}/g, context.current_region_start.toString());
|
||||
result = result.replace(/{current_region_end}/g, context.current_region_end.toString());
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply context to an arbitrary prompt string
|
||||
*/
|
||||
static async getPromptWithContext(prompt: string): Promise<string> {
|
||||
try {
|
||||
const context = this.getFullContext();
|
||||
return this.replaceTemplateVariables(prompt, context);
|
||||
} catch (error) {
|
||||
console.error('Error generating prompt with context:', error);
|
||||
return prompt;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the system prompt with current context applied (backward compatible)
|
||||
*/
|
||||
static async getSystemPromptWithContext(): Promise<string> {
|
||||
try {
|
||||
const template = await this.loadTemplate();
|
||||
let promptWithContext = await this.getPromptWithContext(template);
|
||||
|
||||
// Append custom instructions from config if provided
|
||||
try {
|
||||
const configManager = ConfigManager.instance();
|
||||
if (!configManager.getIsInitialized()) {
|
||||
await configManager.initialize();
|
||||
}
|
||||
const customInstructions = ((configManager.get('templates.custom_instructions') as string) || '').trim();
|
||||
if (customInstructions.length > 0) {
|
||||
promptWithContext += `\n\n====\n\nADDITIONAL INSTRUCTIONS\n\n${customInstructions}`;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to load custom instructions from config:', e);
|
||||
}
|
||||
|
||||
return promptWithContext;
|
||||
} catch (error) {
|
||||
console.error('Error generating system prompt with context:', error);
|
||||
return this.FALLBACK_PROMPT;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the cached template (useful for development/testing)
|
||||
*/
|
||||
static clearCache(): void {
|
||||
this.cachedTemplate = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
/**
|
||||
* XMLToolExecutor - Bridge between XML tool invocations and the existing tool system
|
||||
* Parses XML blocks from LLM responses and executes corresponding tools
|
||||
*/
|
||||
|
||||
import { extractXMLFromString } from '../../util/xmlUtil';
|
||||
import { AVAILABLE_TOOLS, type ToolName } from '../tools';
|
||||
import type { BaseTool, ToolResult } from '../tools/BaseTool';
|
||||
import { useProjectStore } from '../../stores/projectStore';
|
||||
|
||||
/**
|
||||
* Main executor class for XML-based tool invocations
|
||||
* Integrates with existing tool architecture and streaming types
|
||||
*/
|
||||
export class XMLToolExecutor {
|
||||
// Private static instance for singleton pattern
|
||||
private static _instance: XMLToolExecutor | null = null;
|
||||
|
||||
// Private constructor to prevent direct instantiation
|
||||
private constructor() {}
|
||||
|
||||
/**
|
||||
* Get the singleton instance of XMLToolExecutor
|
||||
*/
|
||||
public static instance(): XMLToolExecutor {
|
||||
if (!XMLToolExecutor._instance) {
|
||||
XMLToolExecutor._instance = new XMLToolExecutor();
|
||||
}
|
||||
return XMLToolExecutor._instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute all XML tool invocations found in the given input string
|
||||
* @param input - String containing XML tool invocations (typically LLM response)
|
||||
* @returns Promise resolving to array of tool results in order of appearance
|
||||
*/
|
||||
public async executeXMLTools(input: string): Promise<ToolResult[]> {
|
||||
try {
|
||||
// Extract all XML blocks from the input
|
||||
const xmlBlocks = extractXMLFromString(input);
|
||||
|
||||
if (xmlBlocks.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Process each XML block and collect results
|
||||
const results: ToolResult[] = [];
|
||||
|
||||
for (const xmlBlock of xmlBlocks) {
|
||||
try {
|
||||
const result = await this.executeXMLBlock(xmlBlock);
|
||||
results.push(result);
|
||||
} catch (error) {
|
||||
// Create failed result
|
||||
results.push({
|
||||
success: false,
|
||||
result: `Failed to process XML block: ${error}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
|
||||
} catch (error) {
|
||||
return [{
|
||||
success: false,
|
||||
result: `Failed to execute XML tools: ${error}`
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single XML block as a tool invocation
|
||||
* @param xmlBlock - XML string representing a tool invocation
|
||||
* @returns Promise resolving to tool execution result
|
||||
*/
|
||||
private async executeXMLBlock(xmlBlock: string): Promise<ToolResult> {
|
||||
// Parse XML to extract tool information
|
||||
const parseResult = this.parseXMLBlock(xmlBlock);
|
||||
|
||||
if (!parseResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
result: parseResult.error || 'Failed to parse XML block'
|
||||
};
|
||||
}
|
||||
|
||||
// Check if tool exists in registry
|
||||
if (!(parseResult.toolName in AVAILABLE_TOOLS)) {
|
||||
return {
|
||||
success: false,
|
||||
result: `Unknown tool: ${parseResult.toolName}`
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Create tool instance
|
||||
const ToolClass = AVAILABLE_TOOLS[parseResult.toolName as ToolName];
|
||||
const toolInstance: BaseTool = new ToolClass();
|
||||
|
||||
// Execute the tool
|
||||
const toolResult = await toolInstance.execute(parseResult.parameters);
|
||||
|
||||
// Sync UI state if the tool execution was successful
|
||||
if (toolResult.success) {
|
||||
this.syncUIState();
|
||||
}
|
||||
|
||||
return toolResult;
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
result: `Tool execution failed: ${error}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse XML block to extract tool name and parameters
|
||||
* @param xmlBlock - XML string to parse
|
||||
* @returns Parse result with tool information or error
|
||||
*/
|
||||
private parseXMLBlock(xmlBlock: string): { success: boolean; toolName: string; parameters: Record<string, unknown>; error?: string } {
|
||||
try {
|
||||
// Special pre-processing for attempt_completion: ensure <comment> is wrapped in CDATA
|
||||
const preparedXml = this.preprocessAttemptCompletionXML(xmlBlock);
|
||||
|
||||
// Parse XML using native DOMParser
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(preparedXml, 'text/xml');
|
||||
|
||||
// Check for parsing errors
|
||||
const parserError = doc.querySelector('parsererror');
|
||||
if (parserError) {
|
||||
return {
|
||||
success: false,
|
||||
toolName: '',
|
||||
parameters: {},
|
||||
error: `XML parsing error: ${parserError.textContent}`
|
||||
};
|
||||
}
|
||||
|
||||
// Get the root element (tool name)
|
||||
const rootElement = doc.documentElement;
|
||||
const toolName = rootElement.tagName;
|
||||
|
||||
// Parse XML parameters
|
||||
const parameters = this.parseXMLParameters(rootElement);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
toolName,
|
||||
parameters
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
toolName: '',
|
||||
parameters: {},
|
||||
error: `Failed to parse XML: ${error}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure special tools have CDATA-wrapped content where appropriate.
|
||||
* - attempt_completion: wrap <comment> inner text with CDATA (if not already)
|
||||
* - think / thinking: wrap root inner text with CDATA (if not already)
|
||||
* Decode basic XML entities before wrapping so CDATA contains human-readable text.
|
||||
*/
|
||||
private preprocessAttemptCompletionXML(xml: string): string {
|
||||
try {
|
||||
const leadingWhitespaceMatch = xml.match(/^\s*/);
|
||||
const prefix = leadingWhitespaceMatch ? leadingWhitespaceMatch[0] : '';
|
||||
const withoutLeading = xml.slice(prefix.length);
|
||||
const rootMatch = withoutLeading.match(/^<([A-Za-z_][\w-]*)\b/);
|
||||
const root = rootMatch?.[1] || '';
|
||||
if (root !== 'attempt_completion' && root !== 'think' && root !== 'thinking') return xml;
|
||||
|
||||
// Helper to decode entities
|
||||
const decodeEntities = (text: string): string =>
|
||||
text
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'");
|
||||
|
||||
if (root === 'attempt_completion') {
|
||||
// Find first <comment>...</comment>
|
||||
const commentRegex = /<comment>([\s\S]*?)<\/comment>/i;
|
||||
const match = xml.match(commentRegex);
|
||||
if (!match) return xml;
|
||||
|
||||
const inner = match[1];
|
||||
if (/<!\[CDATA\[/.test(inner)) {
|
||||
// Already wrapped
|
||||
return xml;
|
||||
}
|
||||
|
||||
const decoded = decodeEntities(inner);
|
||||
const replacement = `<comment><![CDATA[${decoded}]]></comment>`;
|
||||
return xml.replace(commentRegex, replacement);
|
||||
}
|
||||
|
||||
// Handle <think>...</think> or <thinking>...</thinking>
|
||||
const rootRegex = new RegExp(`<${root}>([\\s\\S]*?)</${root}>`, 'i');
|
||||
const rootMatchContent = xml.match(rootRegex);
|
||||
if (!rootMatchContent) return xml;
|
||||
const innerRoot = rootMatchContent[1];
|
||||
if (/<!\[CDATA\[/.test(innerRoot)) {
|
||||
return xml; // Already wrapped
|
||||
}
|
||||
const decodedRoot = decodeEntities(innerRoot);
|
||||
const replacementRoot = `<${root}><![CDATA[${decodedRoot}]]></${root}>`;
|
||||
return xml.replace(rootRegex, replacementRoot);
|
||||
} catch {
|
||||
// On any error, return original XML to avoid breaking flow
|
||||
return xml;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse XML element into tool parameters object
|
||||
* Converts XML structure to JavaScript object that matches tool parameter schema
|
||||
* @param element - Root XML element containing tool parameters
|
||||
* @returns Parameters object for tool execution
|
||||
*/
|
||||
private parseXMLParameters(element: Element): Record<string, unknown> {
|
||||
const parameters: Record<string, unknown> = {};
|
||||
|
||||
// Special handling for thinking tool: if no child elements, use text content directly
|
||||
if (element.tagName === 'thinking' && element.children.length === 0) {
|
||||
const textContent = element.textContent?.trim() || '';
|
||||
parameters.content = textContent;
|
||||
return parameters;
|
||||
}
|
||||
|
||||
// Process all child elements
|
||||
for (const child of element.children) {
|
||||
const paramName = child.tagName;
|
||||
const paramValue = this.parseXMLValue(child);
|
||||
|
||||
// Handle arrays (multiple elements with same tag name)
|
||||
if (parameters[paramName] !== undefined) {
|
||||
// Convert to array if not already
|
||||
if (!Array.isArray(parameters[paramName])) {
|
||||
parameters[paramName] = [parameters[paramName]];
|
||||
}
|
||||
(parameters[paramName] as unknown[]).push(paramValue);
|
||||
} else {
|
||||
parameters[paramName] = paramValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply array wrapper flattening
|
||||
return this.flattenArrayWrappers(parameters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten array wrapper patterns in parsed parameters
|
||||
* Converts structures like {notes: {note: [...]}} to {notes: [...]}
|
||||
* @param parameters - Parsed parameters object
|
||||
* @returns Parameters with flattened array wrappers
|
||||
*/
|
||||
private flattenArrayWrappers(parameters: Record<string, unknown>): Record<string, unknown> {
|
||||
const flattened: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(parameters)) {
|
||||
if (this.isArrayWrapperCandidate(key, value)) {
|
||||
// This is an array wrapper - flatten it
|
||||
const wrapperObj = value as Record<string, unknown>;
|
||||
const innerKeys = Object.keys(wrapperObj);
|
||||
|
||||
if (innerKeys.length === 1) {
|
||||
const innerKey = innerKeys[0];
|
||||
const innerValue = wrapperObj[innerKey];
|
||||
|
||||
// Check if inner key is singular form of outer key
|
||||
if (this.isSingularOf(innerKey, key)) {
|
||||
// Flatten: {notes: {note: [...]}} → {notes: [...]}
|
||||
flattened[key] = innerValue;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No flattening needed, keep as is
|
||||
flattened[key] = value;
|
||||
}
|
||||
|
||||
return flattened;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a value is a candidate for array wrapper flattening
|
||||
* @param key - Parameter key (e.g., "notes")
|
||||
* @param value - Parameter value to check
|
||||
* @returns True if this looks like an array wrapper pattern
|
||||
*/
|
||||
private isArrayWrapperCandidate(key: string, value: unknown): boolean {
|
||||
// Must be an object (not array, not primitive)
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const obj = value as Record<string, unknown>;
|
||||
const innerKeys = Object.keys(obj);
|
||||
|
||||
// Must have exactly one property
|
||||
if (innerKeys.length !== 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const innerKey = innerKeys[0];
|
||||
const innerValue = obj[innerKey];
|
||||
|
||||
// Inner value should be an array or could become an array
|
||||
// (single items are often converted to arrays by the parser)
|
||||
return this.isSingularOf(innerKey, key) &&
|
||||
(Array.isArray(innerValue) || typeof innerValue === 'object');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if one word is the singular form of another (simple heuristic)
|
||||
* @param singular - Potential singular form (e.g., "note")
|
||||
* @param plural - Potential plural form (e.g., "notes")
|
||||
* @returns True if singular appears to be singular form of plural
|
||||
*/
|
||||
private isSingularOf(singular: string, plural: string): boolean {
|
||||
// Simple heuristics for common English pluralization
|
||||
if (plural === singular + 's') return true; // note → notes
|
||||
if (plural === singular + 'es') return true; // box → boxes
|
||||
if (plural.endsWith('ies') && singular.endsWith('y')) { // entry → entries
|
||||
return plural === singular.slice(0, -1) + 'ies';
|
||||
}
|
||||
|
||||
// Add more rules as needed for your specific use cases
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single XML element value, handling different data types and structures
|
||||
* @param element - XML element to parse
|
||||
* @returns Parsed value (string, number, boolean, object, or array)
|
||||
*/
|
||||
private parseXMLValue(element: Element): unknown {
|
||||
// If element has children, parse as object
|
||||
if (element.children.length > 0) {
|
||||
return this.parseXMLParameters(element);
|
||||
}
|
||||
|
||||
// Get text content
|
||||
const textContent = element.textContent?.trim() || '';
|
||||
|
||||
// Try to parse as number
|
||||
if (/^-?\d+(\.\d+)?$/.test(textContent)) {
|
||||
return parseFloat(textContent);
|
||||
}
|
||||
|
||||
// Try to parse as boolean
|
||||
if (textContent === 'true') return true;
|
||||
if (textContent === 'false') return false;
|
||||
|
||||
// Return as string
|
||||
return textContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronize UI state after successful tool execution
|
||||
* Uses the centralized refresh method from the project store
|
||||
*/
|
||||
private syncUIState(): void {
|
||||
try {
|
||||
// Use the centralized refresh method from the store
|
||||
const storeActions = useProjectStore.getState();
|
||||
if (storeActions.refreshProjectState) {
|
||||
storeActions.refreshProjectState();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.warn('Failed to sync UI state after XML tool execution:', error);
|
||||
// Don't throw - UI sync failure shouldn't break tool execution
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { LLMProvider } from './LLMProvider';
|
||||
import type { StreamChunk, LLMResponse } from './StreamingTypes';
|
||||
import type { Message } from '../core/AgentState';
|
||||
import { ConfigManager } from '../../core/config/ConfigManager';
|
||||
|
||||
/**
|
||||
* Anthropic Claude API provider implementation
|
||||
*/
|
||||
export class ClaudeProvider extends LLMProvider {
|
||||
readonly name = 'Claude';
|
||||
|
||||
private apiKey: string;
|
||||
private model: string;
|
||||
private baseURL: string = 'https://api.anthropic.com';
|
||||
private apiEndpoint: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
const configManager = ConfigManager.instance();
|
||||
this.apiKey = configManager.get('general.claude.api_key') as string;
|
||||
this.model = configManager.get('general.claude.model') as string;
|
||||
this.apiEndpoint = `${this.baseURL}/v1/messages`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert internal messages to Claude's format
|
||||
*/
|
||||
private convertMessages(messages: Message[], systemPrompt?: string): {
|
||||
system?: string;
|
||||
messages: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||
} {
|
||||
const claudeMessages: Array<{ role: 'user' | 'assistant'; content: string }> = [];
|
||||
|
||||
claudeMessages.push(...messages.map(msg => ({ role: msg.role, content: msg.content })));
|
||||
|
||||
return {
|
||||
system: systemPrompt,
|
||||
messages: claudeMessages
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Claude's streaming response chunks
|
||||
*/
|
||||
private parseClaudeStreamChunk(line: string): { content?: string; isDone?: boolean } {
|
||||
if (!line.startsWith('data: ')) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') {
|
||||
return { isDone: true };
|
||||
}
|
||||
|
||||
try {
|
||||
const json = JSON.parse(data);
|
||||
|
||||
// Handle different Claude streaming event types
|
||||
switch (json.type) {
|
||||
case 'content_block_delta':
|
||||
return {
|
||||
content: json.delta?.text,
|
||||
isDone: false
|
||||
};
|
||||
case 'message_stop':
|
||||
return { isDone: true };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
} catch {
|
||||
return {}; // Skip invalid JSON lines
|
||||
}
|
||||
}
|
||||
|
||||
async *generateStream(
|
||||
messages: Message[],
|
||||
systemPrompt?: string,
|
||||
tools?: Record<string, unknown>[]
|
||||
): AsyncIterableIterator<StreamChunk> {
|
||||
const { system, messages: claudeMessages } = this.convertMessages(messages, systemPrompt);
|
||||
|
||||
const requestBody: {
|
||||
model: string;
|
||||
max_tokens: number;
|
||||
messages: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||
stream: boolean;
|
||||
system?: string;
|
||||
tools?: Record<string, unknown>[];
|
||||
} = {
|
||||
model: this.model,
|
||||
max_tokens: 8192,
|
||||
messages: claudeMessages,
|
||||
stream: true
|
||||
};
|
||||
|
||||
if (system) {
|
||||
requestBody.system = system;
|
||||
}
|
||||
|
||||
if (tools && tools.length > 0) {
|
||||
requestBody.tools = tools;
|
||||
}
|
||||
|
||||
const response = await fetch(this.apiEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-api-key': this.apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-version': '2023-06-01'
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
mode: 'cors'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Claude API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error('Failed to get response reader');
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
if (!trimmedLine) continue;
|
||||
|
||||
const parseResult = this.parseClaudeStreamChunk(trimmedLine);
|
||||
|
||||
if (parseResult.isDone) {
|
||||
yield { type: 'done', content: '' };
|
||||
return;
|
||||
}
|
||||
|
||||
if (parseResult.content) {
|
||||
yield {
|
||||
type: 'text',
|
||||
content: parseResult.content
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
async generateCompletion(
|
||||
messages: Message[],
|
||||
systemPrompt?: string,
|
||||
tools?: Record<string, unknown>[]
|
||||
): Promise<LLMResponse> {
|
||||
const { system, messages: claudeMessages } = this.convertMessages(messages, systemPrompt);
|
||||
|
||||
const requestBody: {
|
||||
model: string;
|
||||
max_tokens: number;
|
||||
messages: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||
stream: boolean;
|
||||
system?: string;
|
||||
tools?: Record<string, unknown>[];
|
||||
} = {
|
||||
model: this.model,
|
||||
max_tokens: 8192,
|
||||
messages: claudeMessages,
|
||||
stream: false
|
||||
};
|
||||
|
||||
if (system) {
|
||||
requestBody.system = system;
|
||||
}
|
||||
|
||||
if (tools && tools.length > 0) {
|
||||
requestBody.tools = tools;
|
||||
}
|
||||
|
||||
const response = await fetch(this.apiEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-api-key': this.apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-version': '2023-06-01'
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
mode: 'cors'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Claude API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Extract content from Claude's response format
|
||||
const content = data.content
|
||||
?.filter((block: { type: string }) => block.type === 'text')
|
||||
?.map((block: { text: string }) => block.text)
|
||||
?.join('') || '';
|
||||
|
||||
// Extract tool calls if present
|
||||
const toolCalls = data.content
|
||||
?.filter((block: { type: string }) => block.type === 'tool_use')
|
||||
?.map((block: { id: string; name: string; input: Record<string, unknown> }) => ({
|
||||
id: block.id,
|
||||
name: block.name,
|
||||
parameters: block.input
|
||||
})) || [];
|
||||
|
||||
return {
|
||||
content,
|
||||
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
|
||||
finished: data.stop_reason === 'end_turn' || data.stop_reason === 'tool_use'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { LLMProvider } from './LLMProvider';
|
||||
import type { StreamChunk, LLMResponse } from './StreamingTypes';
|
||||
import type { Message } from '../core/AgentState';
|
||||
import { ConfigManager } from '../../core/config/ConfigManager';
|
||||
|
||||
/**
|
||||
* Google Gemini API provider implementation
|
||||
*/
|
||||
export class GeminiProvider extends LLMProvider {
|
||||
readonly name = 'Gemini';
|
||||
|
||||
private apiKey: string;
|
||||
private model: string;
|
||||
private baseURL: string = 'https://generativelanguage.googleapis.com';
|
||||
private apiEndpoint: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
const configManager = ConfigManager.instance();
|
||||
this.apiKey = configManager.get('general.gemini.api_key') as string;
|
||||
this.model = configManager.get('general.gemini.model') as string;
|
||||
this.apiEndpoint = `${this.baseURL}/v1beta/models/${this.model}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert internal messages to Gemini's format
|
||||
*/
|
||||
private convertMessages(messages: Message[], systemPrompt?: string): {
|
||||
systemInstruction?: { parts: Array<{ text: string }> };
|
||||
contents: Array<{ role: 'user' | 'model'; parts: Array<{ text: string }> }>;
|
||||
} {
|
||||
const geminiMessages: Array<{ role: 'user' | 'model'; parts: Array<{ text: string }> }> = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
let role: 'user' | 'model';
|
||||
|
||||
if (msg.role === 'assistant') {
|
||||
role = 'model';
|
||||
} else {
|
||||
// Treat system and user messages as 'user' role
|
||||
role = 'user';
|
||||
}
|
||||
|
||||
geminiMessages.push({
|
||||
role,
|
||||
parts: [{ text: msg.content }]
|
||||
});
|
||||
}
|
||||
|
||||
const result: {
|
||||
systemInstruction?: { parts: Array<{ text: string }> };
|
||||
contents: Array<{ role: 'user' | 'model'; parts: Array<{ text: string }> }>;
|
||||
} = {
|
||||
contents: geminiMessages
|
||||
};
|
||||
|
||||
if (systemPrompt) {
|
||||
result.systemInstruction = {
|
||||
parts: [{ text: systemPrompt }]
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Gemini's streaming response chunks
|
||||
*/
|
||||
private parseGeminiStreamChunk(chunk: string): { content?: string; isDone?: boolean } {
|
||||
try {
|
||||
const json = JSON.parse(chunk.trim());
|
||||
|
||||
// Gemini streaming format
|
||||
if (json.candidates && json.candidates.length > 0) {
|
||||
const candidate = json.candidates[0];
|
||||
|
||||
// Check if generation is finished
|
||||
if (candidate.finishReason && candidate.finishReason !== 'STOP') {
|
||||
return { isDone: true };
|
||||
}
|
||||
|
||||
// Extract text content
|
||||
const content = candidate.content?.parts?.[0]?.text;
|
||||
if (content) {
|
||||
return { content, isDone: false };
|
||||
}
|
||||
}
|
||||
|
||||
// Check for explicit done signal
|
||||
if (json.done === true) {
|
||||
return { isDone: true };
|
||||
}
|
||||
|
||||
return {};
|
||||
} catch {
|
||||
return {}; // Skip invalid JSON
|
||||
}
|
||||
}
|
||||
|
||||
async *generateStream(
|
||||
messages: Message[],
|
||||
systemPrompt?: string,
|
||||
tools?: Record<string, unknown>[]
|
||||
): AsyncIterableIterator<StreamChunk> {
|
||||
const { systemInstruction, contents } = this.convertMessages(messages, systemPrompt);
|
||||
|
||||
const requestBody: {
|
||||
contents: Array<{ role: 'user' | 'model'; parts: Array<{ text: string }> }>;
|
||||
generationConfig: { temperature: number; maxOutputTokens: number };
|
||||
systemInstruction?: { parts: Array<{ text: string }> };
|
||||
tools?: Record<string, unknown>[];
|
||||
} = {
|
||||
contents,
|
||||
generationConfig: {
|
||||
temperature: 0.7,
|
||||
maxOutputTokens: 8192,
|
||||
}
|
||||
};
|
||||
|
||||
if (systemInstruction) {
|
||||
requestBody.systemInstruction = systemInstruction;
|
||||
}
|
||||
|
||||
if (tools && tools.length > 0) {
|
||||
requestBody.tools = tools;
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.apiEndpoint}:streamGenerateContent?key=${this.apiKey}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Gemini API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error('Failed to get response reader');
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// Gemini sends JSON objects separated by newlines
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
if (!trimmedLine) continue;
|
||||
|
||||
const parseResult = this.parseGeminiStreamChunk(trimmedLine);
|
||||
|
||||
if (parseResult.isDone) {
|
||||
yield { type: 'done', content: '' };
|
||||
return;
|
||||
}
|
||||
|
||||
if (parseResult.content) {
|
||||
yield {
|
||||
type: 'text',
|
||||
content: parseResult.content
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
async generateCompletion(
|
||||
messages: Message[],
|
||||
systemPrompt?: string,
|
||||
tools?: Record<string, unknown>[]
|
||||
): Promise<LLMResponse> {
|
||||
const { systemInstruction, contents } = this.convertMessages(messages, systemPrompt);
|
||||
|
||||
const requestBody: {
|
||||
contents: Array<{ role: 'user' | 'model'; parts: Array<{ text: string }> }>;
|
||||
generationConfig: { temperature: number; maxOutputTokens: number };
|
||||
systemInstruction?: { parts: Array<{ text: string }> };
|
||||
tools?: Record<string, unknown>[];
|
||||
} = {
|
||||
contents,
|
||||
generationConfig: {
|
||||
temperature: 0.7,
|
||||
maxOutputTokens: 8192,
|
||||
}
|
||||
};
|
||||
|
||||
if (systemInstruction) {
|
||||
requestBody.systemInstruction = systemInstruction;
|
||||
}
|
||||
|
||||
if (tools && tools.length > 0) {
|
||||
requestBody.tools = tools;
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.apiEndpoint}:generateContent?key=${this.apiKey}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Gemini API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Extract content from Gemini's response format
|
||||
const candidate = data.candidates?.[0];
|
||||
const content = candidate?.content?.parts?.[0]?.text || '';
|
||||
|
||||
// Extract tool calls if present (Gemini format)
|
||||
const toolCalls = candidate?.content?.parts
|
||||
?.filter((part: { functionCall?: unknown }) => part.functionCall)
|
||||
?.map((part: { functionCall: { name: string; args: Record<string, unknown> } }) => ({
|
||||
id: `tool_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`, // Generate ID
|
||||
name: part.functionCall.name,
|
||||
parameters: part.functionCall.args
|
||||
})) || [];
|
||||
|
||||
return {
|
||||
content,
|
||||
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
|
||||
finished: candidate?.finishReason === 'STOP' || candidate?.finishReason === 'MAX_TOKENS'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { StreamChunk, LLMResponse } from './StreamingTypes';
|
||||
import type { Message } from '../core/AgentState';
|
||||
|
||||
/**
|
||||
* Abstract interface for LLM providers
|
||||
*/
|
||||
export abstract class LLMProvider {
|
||||
abstract name: string;
|
||||
|
||||
/**
|
||||
* Generate a streaming response from the LLM
|
||||
* @param messages The full conversation history with preserved roles
|
||||
* @param systemPrompt The system prompt (optional, can be included in messages)
|
||||
* @param tools Available tools (optional for now)
|
||||
*/
|
||||
abstract generateStream(
|
||||
messages: Message[],
|
||||
systemPrompt?: string,
|
||||
tools?: Record<string, unknown>[]
|
||||
): AsyncIterableIterator<StreamChunk>;
|
||||
|
||||
/**
|
||||
* Generate a complete response from the LLM (non-streaming)
|
||||
* @param messages The full conversation history with preserved roles
|
||||
* @param systemPrompt The system prompt (optional, can be included in messages)
|
||||
* @param tools Available tools (optional for now)
|
||||
*/
|
||||
abstract generateCompletion(
|
||||
messages: Message[],
|
||||
systemPrompt?: string,
|
||||
tools?: Record<string, unknown>[]
|
||||
): Promise<LLMResponse>;
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
import { LLMProvider } from './LLMProvider';
|
||||
import type { StreamChunk, LLMResponse } from './StreamingTypes';
|
||||
import type { Message } from '../core/AgentState';
|
||||
import { ConfigManager } from '../../core/config/ConfigManager';
|
||||
import { URL_CONSTANTS } from '../../constants/coreConstants';
|
||||
|
||||
/**
|
||||
* OpenAI API provider implementation
|
||||
*/
|
||||
export class OpenAIProvider extends LLMProvider {
|
||||
readonly name = 'OpenAI';
|
||||
|
||||
private apiKey: string;
|
||||
private model: string;
|
||||
private flexMode: boolean = false;
|
||||
private baseURL: string;
|
||||
private isCompatibleProvider: boolean;
|
||||
private apiEndpoint: string;
|
||||
private isOllamaFormat: boolean | null = null; // Detected at runtime
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
const configManager = ConfigManager.instance();
|
||||
const llmProvider = configManager.get('general.llm_provider') as string;
|
||||
this.isCompatibleProvider = llmProvider === 'openai_compatible';
|
||||
|
||||
// Set API key, model, base URL, and endpoint based on provider type
|
||||
if (this.isCompatibleProvider) {
|
||||
this.apiKey = configManager.get('general.openai_compatible.api_key') as string;
|
||||
this.model = configManager.get('general.openai_compatible.model') as string;
|
||||
this.baseURL = configManager.get('general.openai_compatible.base_url') as string;
|
||||
// For compatible providers, use the full URL as provided (assume it includes the endpoint)
|
||||
// Common patterns: http://localhost:11434/api/chat (Ollama), https://api.openrouter.ai/v1 (OpenRouter)
|
||||
this.apiEndpoint = this.baseURL;
|
||||
this.flexMode = false; // Not applicable to compatible providers
|
||||
} else {
|
||||
this.apiKey = configManager.get('general.openai.api_key') as string;
|
||||
this.model = configManager.get('general.openai.model') as string;
|
||||
this.flexMode = (configManager.get('general.openai.flex') as boolean) === true;
|
||||
this.baseURL = URL_CONSTANTS.DEFAULT_OPENAI_BASE_URL;
|
||||
this.apiEndpoint = `${this.baseURL}/chat/completions`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if the response uses Ollama's raw JSON format or OpenAI's SSE format
|
||||
*/
|
||||
private detectStreamFormat(firstChunk: string): boolean {
|
||||
// If it starts with "data: ", it's OpenAI SSE format
|
||||
if (firstChunk.trim().startsWith('data: ')) {
|
||||
return false; // Not Ollama format
|
||||
}
|
||||
|
||||
// Try to parse as JSON - if successful and has 'done' field, it's Ollama format
|
||||
try {
|
||||
const json = JSON.parse(firstChunk.trim());
|
||||
return typeof json.done === 'boolean';
|
||||
} catch {
|
||||
return false; // Not valid JSON, assume OpenAI format
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Ollama's raw JSON chunk format
|
||||
*/
|
||||
private parseOllamaChunk(chunk: string): { thinking?: string; content?: string; isDone?: boolean; toolCalls?: Array<{ name: string; arguments: Record<string, unknown> }> } {
|
||||
try {
|
||||
const json = JSON.parse(chunk.trim());
|
||||
const thinking: string | undefined = json.message?.thinking;
|
||||
const content: string | undefined = json.message?.content || json.response; // Handle both chat and completion formats
|
||||
type WireToolCall = { function?: { name?: unknown; arguments?: unknown } };
|
||||
const toolCallsRaw: unknown[] | undefined = json.message?.tool_calls as unknown[] | undefined;
|
||||
const toolCalls = Array.isArray(toolCallsRaw)
|
||||
? (toolCallsRaw
|
||||
.map((tc: unknown) => {
|
||||
const wire = tc as WireToolCall;
|
||||
const fn = wire?.function;
|
||||
if (!fn || typeof fn.name !== 'string') return null;
|
||||
const args = fn.arguments;
|
||||
if (args === null || typeof args !== 'object' || Array.isArray(args)) return null;
|
||||
return { name: fn.name, arguments: args as Record<string, unknown> };
|
||||
})
|
||||
.filter((v): v is { name: string; arguments: Record<string, unknown> } => v !== null))
|
||||
: undefined;
|
||||
return {
|
||||
thinking,
|
||||
content,
|
||||
isDone: json.done === true,
|
||||
toolCalls
|
||||
};
|
||||
} catch {
|
||||
return {}; // Invalid JSON, return empty object
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse OpenAI's SSE format chunk
|
||||
*/
|
||||
private parseOpenAIChunk(line: string): { thinking?: string; content?: string; isDone?: boolean; toolCallDelta?: Array<{ index?: number; function?: { name?: string; arguments?: string } }> } {
|
||||
if (!line.startsWith('data: ')) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') {
|
||||
return { isDone: true };
|
||||
}
|
||||
|
||||
try {
|
||||
const json = JSON.parse(data);
|
||||
const delta = json.choices?.[0]?.delta;
|
||||
const thinking: string | undefined = delta?.thinking; // Some providers may stream "thinking"
|
||||
const content: string | undefined = delta?.content;
|
||||
const tcd = delta?.tool_calls;
|
||||
const toolCallDelta: Array<{ index?: number; function?: { name?: string; arguments?: string } }> | undefined = Array.isArray(tcd)
|
||||
? (tcd as Array<{ index?: number; function?: { name?: string; arguments?: string } }>)
|
||||
: undefined;
|
||||
return { thinking, content, isDone: false, toolCallDelta };
|
||||
} catch {
|
||||
return {}; // Skip invalid JSON lines
|
||||
}
|
||||
}
|
||||
|
||||
async *generateStream(
|
||||
messages: Message[],
|
||||
systemPrompt?: string,
|
||||
tools?: Record<string, unknown>[]
|
||||
): AsyncIterableIterator<StreamChunk> {
|
||||
// Build OpenAI messages array with role preservation
|
||||
const openAIMessages: Array<{ role: string; content: string }> = [];
|
||||
|
||||
// Add system prompt if provided
|
||||
if (systemPrompt) {
|
||||
openAIMessages.push({ role: 'system', content: systemPrompt });
|
||||
}
|
||||
|
||||
// Add conversation history with preserved roles
|
||||
openAIMessages.push(...messages.map(msg => ({
|
||||
role: msg.role,
|
||||
content: msg.content
|
||||
})));
|
||||
|
||||
const response = await fetch(this.apiEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.model,
|
||||
...(this.flexMode && !this.isCompatibleProvider ? { service_tier: 'flex' } : {}),
|
||||
messages: openAIMessages,
|
||||
stream: true,
|
||||
tools: tools || undefined
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenAI API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error('Failed to get response reader');
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let firstChunkProcessed = false;
|
||||
let lastSegmentType: 'thinking' | 'content' | null = null;
|
||||
const pendingFunctionCalls: Array<{ name: string; arguments: Record<string, unknown> }> = [];
|
||||
const openAIToolCallBuilders: Record<number, { name?: string; argumentsText: string }> = {};
|
||||
|
||||
const escapeXml = (text: string): string =>
|
||||
String(text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
const functionCallToXml = (name: string, args: Record<string, unknown>): string => {
|
||||
const keys = Object.keys(args);
|
||||
const inner = keys
|
||||
.map((k) => {
|
||||
const value = (args as Record<string, unknown>)[k];
|
||||
const text = typeof value === 'string' ? value : JSON.stringify(value);
|
||||
return `<${k}>${escapeXml(text)}</${k}>`;
|
||||
})
|
||||
.join('\n');
|
||||
return `<${name}>\n${inner}\n</${name}>`;
|
||||
};
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// For Ollama format, we need to split by newlines for JSON objects
|
||||
// For OpenAI format, we also split by newlines for SSE
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
if (!trimmedLine) continue;
|
||||
|
||||
// Detect format on first non-empty chunk
|
||||
if (!firstChunkProcessed) {
|
||||
this.isOllamaFormat = this.detectStreamFormat(trimmedLine);
|
||||
firstChunkProcessed = true;
|
||||
}
|
||||
|
||||
if (this.isOllamaFormat) {
|
||||
const { thinking, content, isDone, toolCalls } = this.parseOllamaChunk(trimmedLine);
|
||||
if (isDone) {
|
||||
// Append any pending or current tool calls as XML before finishing
|
||||
const allToolCalls = [
|
||||
...pendingFunctionCalls,
|
||||
...(toolCalls || [])
|
||||
];
|
||||
if (allToolCalls.length > 0) {
|
||||
const xmlBlocks = allToolCalls
|
||||
.map((tc) => `\n${functionCallToXml(tc.name, tc.arguments)}\n`)
|
||||
.join('');
|
||||
yield { type: 'text', content: xmlBlocks };
|
||||
}
|
||||
yield { type: 'done', content: '' };
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof thinking === 'string' && thinking.length > 0) {
|
||||
if (lastSegmentType && lastSegmentType !== 'thinking') {
|
||||
yield { type: 'text', content: '\n\n' };
|
||||
}
|
||||
yield { type: 'text', content: thinking };
|
||||
lastSegmentType = 'thinking';
|
||||
}
|
||||
|
||||
if (typeof content === 'string' && content.length > 0) {
|
||||
if (lastSegmentType && lastSegmentType !== 'content') {
|
||||
yield { type: 'text', content: '\n\n' };
|
||||
}
|
||||
yield { type: 'text', content: content };
|
||||
lastSegmentType = 'content';
|
||||
}
|
||||
|
||||
if (Array.isArray(toolCalls) && toolCalls.length > 0) {
|
||||
// Accumulate and append at the end of stream
|
||||
pendingFunctionCalls.push(...toolCalls);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const { thinking, content, isDone, toolCallDelta } = this.parseOpenAIChunk(trimmedLine);
|
||||
if (isDone) {
|
||||
// Finalize any accumulated OpenAI tool calls and emit XML
|
||||
const finalizedToolCalls: Array<{ name: string; arguments: Record<string, unknown> }> = [];
|
||||
for (const indexStr of Object.keys(openAIToolCallBuilders)) {
|
||||
const idx = Number(indexStr);
|
||||
const builder = openAIToolCallBuilders[idx];
|
||||
if (!builder || !builder.name) continue;
|
||||
let argsObj: Record<string, unknown> | null = null;
|
||||
if (builder.argumentsText) {
|
||||
try {
|
||||
argsObj = JSON.parse(builder.argumentsText);
|
||||
} catch {
|
||||
argsObj = null;
|
||||
}
|
||||
}
|
||||
if (argsObj) {
|
||||
finalizedToolCalls.push({ name: builder.name, arguments: argsObj });
|
||||
}
|
||||
}
|
||||
|
||||
const allToolCalls = [...pendingFunctionCalls, ...finalizedToolCalls];
|
||||
if (allToolCalls.length > 0) {
|
||||
const xmlBlocks = allToolCalls
|
||||
.map((tc) => `\n${functionCallToXml(tc.name, tc.arguments)}\n`)
|
||||
.join('');
|
||||
yield { type: 'text', content: xmlBlocks };
|
||||
}
|
||||
yield { type: 'done', content: '' };
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof thinking === 'string' && thinking.length > 0) {
|
||||
if (lastSegmentType && lastSegmentType !== 'thinking') {
|
||||
yield { type: 'text', content: '\n\n' };
|
||||
}
|
||||
yield { type: 'text', content: thinking };
|
||||
lastSegmentType = 'thinking';
|
||||
}
|
||||
|
||||
if (typeof content === 'string' && content.length > 0) {
|
||||
if (lastSegmentType && lastSegmentType !== 'content') {
|
||||
yield { type: 'text', content: '\n\n' };
|
||||
}
|
||||
yield { type: 'text', content };
|
||||
lastSegmentType = 'content';
|
||||
}
|
||||
|
||||
if (Array.isArray(toolCallDelta) && toolCallDelta.length > 0) {
|
||||
for (const tc of toolCallDelta) {
|
||||
const index: number = typeof tc.index === 'number' ? tc.index : 0;
|
||||
if (!openAIToolCallBuilders[index]) {
|
||||
openAIToolCallBuilders[index] = { argumentsText: '' };
|
||||
}
|
||||
const fn = tc.function;
|
||||
if (fn) {
|
||||
if (typeof fn.name === 'string') {
|
||||
openAIToolCallBuilders[index].name = fn.name;
|
||||
}
|
||||
if (typeof fn.arguments === 'string') {
|
||||
openAIToolCallBuilders[index].argumentsText += fn.arguments;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
async generateCompletion(
|
||||
messages: Message[],
|
||||
systemPrompt?: string,
|
||||
tools?: Record<string, unknown>[]
|
||||
): Promise<LLMResponse> {
|
||||
// Build OpenAI messages array with role preservation
|
||||
const openAIMessages: Array<{ role: string; content: string }> = [];
|
||||
|
||||
// Add system prompt if provided
|
||||
if (systemPrompt) {
|
||||
openAIMessages.push({ role: 'system', content: systemPrompt });
|
||||
}
|
||||
|
||||
// Add conversation history with preserved roles
|
||||
openAIMessages.push(...messages.map(msg => ({
|
||||
role: msg.role,
|
||||
content: msg.content
|
||||
})));
|
||||
|
||||
const response = await fetch(this.apiEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.model,
|
||||
...(this.flexMode && !this.isCompatibleProvider ? { service_tier: 'flex' } : {}),
|
||||
messages: openAIMessages,
|
||||
stream: false,
|
||||
tools: tools || undefined
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenAI API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Handle different response formats
|
||||
const escapeXml = (text: string): string =>
|
||||
String(text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
const functionCallToXml = (name: string, args: Record<string, unknown>): string => {
|
||||
const keys = Object.keys(args);
|
||||
const inner = keys
|
||||
.map((k) => {
|
||||
const value = (args as Record<string, unknown>)[k];
|
||||
const text = typeof value === 'string' ? value : JSON.stringify(value);
|
||||
return `<${k}>${escapeXml(text)}</${k}>`;
|
||||
})
|
||||
.join('\n');
|
||||
return `<${name}>\n${inner}\n</${name}>`;
|
||||
};
|
||||
|
||||
if (data.message) {
|
||||
// Ollama/compatible format
|
||||
const thinking: string = data.message.thinking || '';
|
||||
const contentText: string = data.message.content || data.response || '';
|
||||
const textCombined = thinking && contentText ? `${thinking}\n\n${contentText}` : (thinking || contentText);
|
||||
|
||||
type WireToolCall = { function?: { name?: unknown; arguments?: unknown } };
|
||||
const toolCallsRaw: unknown[] | undefined = data.message.tool_calls as unknown[] | undefined;
|
||||
const toolCalls: Array<{ name: string; arguments: Record<string, unknown> }> = Array.isArray(toolCallsRaw)
|
||||
? (toolCallsRaw
|
||||
.map((tc: unknown) => {
|
||||
const wire = tc as WireToolCall;
|
||||
const fn = wire?.function;
|
||||
if (!fn || typeof fn.name !== 'string') return null;
|
||||
const args = fn.arguments;
|
||||
if (args === null || typeof args !== 'object' || Array.isArray(args)) return null;
|
||||
return { name: fn.name, arguments: args as Record<string, unknown> };
|
||||
})
|
||||
.filter((v): v is { name: string; arguments: Record<string, unknown> } => v !== null))
|
||||
: [];
|
||||
|
||||
const xmlBlocks = toolCalls.length > 0
|
||||
? toolCalls.map((tc) => `\n${functionCallToXml(tc.name, tc.arguments)}\n`).join('')
|
||||
: '';
|
||||
|
||||
return {
|
||||
content: `${textCombined}${xmlBlocks}`,
|
||||
toolCalls: data.message.tool_calls || undefined,
|
||||
finished: data.done === true || data.done_reason === 'stop'
|
||||
};
|
||||
} else {
|
||||
// OpenAI format
|
||||
const choice = data.choices?.[0];
|
||||
const thinking: string = choice?.message?.thinking || '';
|
||||
const contentText: string = choice?.message?.content || '';
|
||||
const textCombined = thinking && contentText ? `${thinking}\n\n${contentText}` : (thinking || contentText);
|
||||
|
||||
type WireToolCall2 = { function?: { name?: unknown; arguments?: unknown } };
|
||||
const toolCallsRaw: unknown[] | undefined = choice?.message?.tool_calls as unknown[] | undefined;
|
||||
const toolCalls: Array<{ name: string; arguments: Record<string, unknown> }> = Array.isArray(toolCallsRaw)
|
||||
? (toolCallsRaw
|
||||
.map((tc: unknown) => {
|
||||
const wire = tc as WireToolCall2;
|
||||
const fn = wire?.function;
|
||||
if (!fn || typeof fn.name !== 'string') return null;
|
||||
const args = fn.arguments;
|
||||
if (args === null || typeof args !== 'object' || Array.isArray(args)) return null;
|
||||
return { name: fn.name, arguments: args as Record<string, unknown> };
|
||||
})
|
||||
.filter((v): v is { name: string; arguments: Record<string, unknown> } => v !== null))
|
||||
: [];
|
||||
|
||||
const xmlBlocks = toolCalls.length > 0
|
||||
? toolCalls.map((tc) => `\n${functionCallToXml(tc.name, tc.arguments)}\n`).join('')
|
||||
: '';
|
||||
|
||||
return {
|
||||
content: `${textCombined}${xmlBlocks}`,
|
||||
toolCalls: choice?.message?.tool_calls || undefined,
|
||||
finished: choice?.finish_reason === 'stop' || choice?.finish_reason === 'tool_calls'
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Types for streaming LLM responses and tool execution
|
||||
*/
|
||||
|
||||
import type { ToolResult } from '../tools/BaseTool';
|
||||
|
||||
// Re-export for convenience
|
||||
export type { ToolResult };
|
||||
|
||||
export interface ToolInvocation {
|
||||
id: string;
|
||||
name: string;
|
||||
parameters: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface StreamChunk {
|
||||
type: 'text' | 'tool_call' | 'tool_result' | 'done';
|
||||
content: string;
|
||||
toolCall?: ToolInvocation;
|
||||
toolResult?: ToolResult;
|
||||
}
|
||||
|
||||
export interface LLMResponse {
|
||||
content: string;
|
||||
toolCalls?: ToolInvocation[];
|
||||
finished: boolean;
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { BaseTool } from './BaseTool';
|
||||
import type { ToolResult, ToolParameter } from './BaseTool';
|
||||
import { CreateNotesCommand } from '../../core/commands/note/CreateNotesCommand';
|
||||
import type { NoteCreationData } from '../../core/commands/note/CreateNotesCommand';
|
||||
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
||||
import { useProjectStore } from '../../stores/projectStore';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
|
||||
/**
|
||||
* Tool for adding notes to MIDI regions
|
||||
* Integrates with the existing command system for undo/redo support
|
||||
*/
|
||||
export class AddNotesTool extends BaseTool {
|
||||
readonly name = 'add_notes';
|
||||
readonly description = 'Create one or more MIDI notes in the current region. Each note requires pitch (e.g., "C4", "F#3"), start_beat (beat position), and length (duration in beats).';
|
||||
|
||||
readonly parameters: Record<string, ToolParameter> = {
|
||||
notes: {
|
||||
type: 'array',
|
||||
description: 'Array of notes to create',
|
||||
required: true,
|
||||
items: {
|
||||
type: 'object',
|
||||
description: 'A MIDI note definition',
|
||||
properties: {
|
||||
pitch: {
|
||||
type: 'string',
|
||||
description: 'Note pitch in scientific notation (e.g., "C4", "F#3", "Bb2")',
|
||||
required: true
|
||||
},
|
||||
start_beat: {
|
||||
type: 'number',
|
||||
description: 'Start position in beats (e.g., 0, 1.5, 2)',
|
||||
required: true
|
||||
},
|
||||
length: {
|
||||
type: 'number',
|
||||
description: 'Note duration in beats (e.g., 1, 0.5, 4)',
|
||||
required: true
|
||||
},
|
||||
velocity: {
|
||||
type: 'number',
|
||||
description: 'Note velocity (1-127, default: 127)',
|
||||
required: false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
region_id: {
|
||||
type: 'string',
|
||||
description: 'ID of the region to add notes to. If not provided, uses the currently selected region.',
|
||||
required: false
|
||||
}
|
||||
};
|
||||
|
||||
async execute(params: Record<string, unknown>): Promise<ToolResult> {
|
||||
try {
|
||||
// Validate parameters
|
||||
this.validateParameters(params);
|
||||
|
||||
const notes = params.notes as Array<{
|
||||
pitch: string;
|
||||
start_beat: number;
|
||||
length: number;
|
||||
velocity?: number;
|
||||
}>;
|
||||
|
||||
const regionId = params.region_id as string | undefined;
|
||||
|
||||
// Find the target region
|
||||
const targetRegion = this.findTargetRegion(regionId);
|
||||
if (!targetRegion) {
|
||||
return this.createErrorResult(
|
||||
regionId
|
||||
? `Region with ID "${regionId}" not found or is not a MIDI region`
|
||||
: 'No active or selected MIDI region found. Please open the piano roll with a region or select a MIDI region first.'
|
||||
);
|
||||
}
|
||||
|
||||
// Validate and convert notes to creation data
|
||||
const noteCreationData: NoteCreationData[] = [];
|
||||
const createdNotes: Array<{ pitch: string; start_beat: number; length: number }> = [];
|
||||
|
||||
for (const note of notes) {
|
||||
try {
|
||||
const midiPitch = this.convertPitchToMidi(note.pitch);
|
||||
const velocity = note.velocity ?? 127;
|
||||
|
||||
// Validate velocity range
|
||||
if (velocity < 1 || velocity > 127) {
|
||||
return this.createErrorResult(`Invalid velocity ${velocity}. Must be between 1 and 127.`);
|
||||
}
|
||||
|
||||
// Validate beat positions
|
||||
if (note.start_beat < 0) {
|
||||
return this.createErrorResult(`Invalid start_beat ${note.start_beat}. Must be >= 0.`);
|
||||
}
|
||||
|
||||
if (note.length <= 0) {
|
||||
return this.createErrorResult(`Invalid length ${note.length}. Must be > 0.`);
|
||||
}
|
||||
|
||||
// Adjust note position relative to region's start beat
|
||||
const regionStartBeat = targetRegion.getStartFromBeat();
|
||||
const adjustedStartBeat = note.start_beat - regionStartBeat;
|
||||
const adjustedEndBeat = adjustedStartBeat + note.length;
|
||||
|
||||
// Create note creation data
|
||||
noteCreationData.push({
|
||||
regionId: targetRegion.getId(),
|
||||
startBeat: adjustedStartBeat,
|
||||
endBeat: adjustedEndBeat,
|
||||
pitch: midiPitch,
|
||||
velocity
|
||||
});
|
||||
|
||||
createdNotes.push({
|
||||
pitch: note.pitch,
|
||||
start_beat: note.start_beat,
|
||||
length: note.length
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
return this.createErrorResult(`Invalid note pitch "${note.pitch}": ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Execute the bulk note creation command
|
||||
const command = new CreateNotesCommand(noteCreationData);
|
||||
await this.executeCommand(command);
|
||||
|
||||
// Create success message
|
||||
const noteCount = createdNotes.length;
|
||||
const noteList = createdNotes
|
||||
.map(note => `${note.pitch} (beat ${note.start_beat}, length ${note.length})`)
|
||||
.join(', ');
|
||||
|
||||
return this.createSuccessResult(
|
||||
`Successfully created ${noteCount} note${noteCount > 1 ? 's' : ''}: ${noteList}`
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
return this.createErrorResult(`Failed to create notes: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the target region for note creation
|
||||
* Priority: 1) Specified regionId, 2) Active piano roll region, 3) Selected regions, 4) Error if none found
|
||||
*/
|
||||
private findTargetRegion(regionId?: string): KGMidiRegion | null {
|
||||
const project = this.getCurrentProject();
|
||||
const tracks = project.getTracks();
|
||||
|
||||
if (regionId) {
|
||||
// Find specific region by ID
|
||||
for (const track of tracks) {
|
||||
const regions = track.getRegions();
|
||||
const region = regions.find(r => r.getId() === regionId);
|
||||
if (region && region instanceof KGMidiRegion) {
|
||||
return region;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} else {
|
||||
// Smart region finding: try different sources in priority order
|
||||
|
||||
// 1. Try active piano roll region
|
||||
const storeState = useProjectStore.getState();
|
||||
if (storeState.activeRegionId) {
|
||||
for (const track of tracks) {
|
||||
const regions = track.getRegions();
|
||||
const region = regions.find(r => r.getId() === storeState.activeRegionId);
|
||||
if (region && region instanceof KGMidiRegion) {
|
||||
return region;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try selected regions
|
||||
const core = this.getKGCore();
|
||||
const selectedItems = core.getSelectedItems();
|
||||
for (const item of selectedItems) {
|
||||
if (item instanceof KGMidiRegion) {
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. No fallback - return null to trigger error
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get KGCore instance for selection access
|
||||
*/
|
||||
private getKGCore() {
|
||||
return KGCore.instance();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert pitch string to MIDI note number
|
||||
* Supports formats like: C4, F#3, Bb2, C#5
|
||||
*/
|
||||
private convertPitchToMidi(pitch: string): number {
|
||||
const match = pitch.match(/^([A-G])([#b]?)(\d+)$/);
|
||||
if (!match) {
|
||||
throw new Error(`Invalid pitch format "${pitch}". Use format like "C4", "F#3", "Bb2"`);
|
||||
}
|
||||
|
||||
const [, noteName, accidental, octaveStr] = match;
|
||||
const octave = parseInt(octaveStr);
|
||||
|
||||
// Base MIDI notes for C octave (C4 = 60)
|
||||
const noteOffsets: Record<string, number> = {
|
||||
'C': 0, 'D': 2, 'E': 4, 'F': 5, 'G': 7, 'A': 9, 'B': 11
|
||||
};
|
||||
|
||||
let midiNote = (octave + 1) * 12 + noteOffsets[noteName];
|
||||
|
||||
// Apply accidentals
|
||||
if (accidental === '#') {
|
||||
midiNote += 1;
|
||||
} else if (accidental === 'b') {
|
||||
midiNote -= 1;
|
||||
}
|
||||
|
||||
// Validate MIDI range
|
||||
if (midiNote < 0 || midiNote > 127) {
|
||||
throw new Error(`Note "${pitch}" is out of MIDI range (0-127)`);
|
||||
}
|
||||
|
||||
return midiNote;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { BaseTool } from './BaseTool';
|
||||
import type { ToolResult, ToolParameter } from './BaseTool';
|
||||
import { AgentCore } from '../core/AgentCore';
|
||||
|
||||
/**
|
||||
* Tool for signaling task completion
|
||||
* This is a pure agent state tool that doesn't modify the DAW but signals
|
||||
* to the agent system that the user's requested task has been completed
|
||||
*/
|
||||
export class AttemptCompletionTool extends BaseTool {
|
||||
readonly name = 'attempt_completion';
|
||||
readonly description = 'Signal that the current user task is fully complete. Only use this when you have successfully fulfilled all aspects of the user\'s request.';
|
||||
|
||||
readonly parameters: Record<string, ToolParameter> = {
|
||||
comment: {
|
||||
type: 'string',
|
||||
description: 'A brief comment describing what was completed and any relevant details about the task fulfillment.',
|
||||
required: true
|
||||
}
|
||||
};
|
||||
|
||||
async execute(params: Record<string, unknown>): Promise<ToolResult> {
|
||||
try {
|
||||
// Validate parameters
|
||||
this.validateParameters(params);
|
||||
|
||||
const comment = params.comment as string;
|
||||
|
||||
// Validate comment is not empty
|
||||
if (!comment.trim()) {
|
||||
return this.createErrorResult('Comment cannot be empty. Please provide a meaningful completion summary.');
|
||||
}
|
||||
|
||||
// Get current agent state and update task completion status
|
||||
const agentCore = AgentCore.instance();
|
||||
const agentState = agentCore.getAgentState();
|
||||
|
||||
// Mark that we're no longer working on a task
|
||||
agentState.setIsWorkingOnTask(false);
|
||||
|
||||
return this.createSuccessResult(
|
||||
`Task completed: ${comment}. Agent task status updated to not working.`
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
return this.createErrorResult(`Failed to mark task as complete: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { KGCommand } from '../../core/commands/KGCommand';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
|
||||
/**
|
||||
* Result of tool execution
|
||||
*/
|
||||
export interface ToolResult {
|
||||
success: boolean;
|
||||
result: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameter definition for tool parameters
|
||||
*/
|
||||
export interface ToolParameter {
|
||||
type: 'string' | 'number' | 'boolean' | 'array' | 'object';
|
||||
description: string;
|
||||
required?: boolean;
|
||||
items?: ToolParameter; // For array types
|
||||
properties?: Record<string, ToolParameter>; // For object types
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool definition schema
|
||||
*/
|
||||
export interface ToolDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, ToolParameter>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract base class for all agent tools
|
||||
* Provides integration with the existing command system and core architecture
|
||||
*/
|
||||
export abstract class BaseTool {
|
||||
abstract readonly name: string;
|
||||
abstract readonly description: string;
|
||||
abstract readonly parameters: Record<string, ToolParameter>;
|
||||
|
||||
/**
|
||||
* Execute the tool with given parameters
|
||||
* @param params Tool parameters
|
||||
* @returns Promise resolving to tool execution result
|
||||
*/
|
||||
abstract execute(params: Record<string, unknown>): Promise<ToolResult>;
|
||||
|
||||
/**
|
||||
* Get the tool definition in OpenAI function calling format
|
||||
*/
|
||||
getDefinition(): ToolDefinition {
|
||||
return {
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
parameters: this.parameters
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate parameters against the tool's parameter schema
|
||||
* @param params Parameters to validate
|
||||
* @returns True if valid, throws error if invalid
|
||||
*/
|
||||
protected validateParameters(params: Record<string, unknown>): boolean {
|
||||
for (const [paramName, paramDef] of Object.entries(this.parameters)) {
|
||||
const value = params[paramName];
|
||||
|
||||
// Check required parameters
|
||||
if (paramDef.required && (value === undefined || value === null)) {
|
||||
throw new Error(`Required parameter '${paramName}' is missing`);
|
||||
}
|
||||
|
||||
// Skip type checking for undefined optional parameters
|
||||
if (value === undefined || value === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Type validation
|
||||
if (!this.validateParameterType(value, paramDef)) {
|
||||
throw new Error(`Parameter '${paramName}' has invalid type. Expected: ${paramDef.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a single parameter value against its type definition
|
||||
*/
|
||||
private validateParameterType(value: unknown, paramDef: ToolParameter): boolean {
|
||||
switch (paramDef.type) {
|
||||
case 'string':
|
||||
return typeof value === 'string';
|
||||
case 'number':
|
||||
return typeof value === 'number';
|
||||
case 'boolean':
|
||||
return typeof value === 'boolean';
|
||||
case 'array':
|
||||
if (!Array.isArray(value)) return false;
|
||||
if (paramDef.items) {
|
||||
return value.every(item => this.validateParameterType(item, paramDef.items!));
|
||||
}
|
||||
return true;
|
||||
case 'object':
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
if (paramDef.properties) {
|
||||
const obj = value as Record<string, unknown>;
|
||||
for (const [propName, propDef] of Object.entries(paramDef.properties)) {
|
||||
if (propDef.required && !(propName in obj)) {
|
||||
return false;
|
||||
}
|
||||
if (propName in obj && !this.validateParameterType(obj[propName], propDef)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command through the existing command system
|
||||
* This provides undo/redo functionality and proper state management
|
||||
* @param command Command to execute
|
||||
*/
|
||||
protected async executeCommand(command: KGCommand): Promise<void> {
|
||||
const core = KGCore.instance();
|
||||
return core.executeCommand(command);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current project from KGCore
|
||||
*/
|
||||
protected getCurrentProject() {
|
||||
const core = KGCore.instance();
|
||||
return core.getCurrentProject();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a successful tool result
|
||||
*/
|
||||
protected createSuccessResult(result: string): ToolResult {
|
||||
return {
|
||||
success: true,
|
||||
result
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a failed tool result
|
||||
*/
|
||||
protected createErrorResult(result: string): ToolResult {
|
||||
return {
|
||||
success: false,
|
||||
result
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import { BaseTool } from './BaseTool';
|
||||
import type { ToolResult, ToolParameter } from './BaseTool';
|
||||
import { KGMidiTrack } from '../../core/track/KGMidiTrack';
|
||||
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
||||
import { convertRegionToABCNotation } from '../../util/abcNotationUtil';
|
||||
import { KEY_SIGNATURE_MAP } from '../../constants/coreConstants';
|
||||
import { useProjectStore } from '../../stores/projectStore';
|
||||
import { KGRegion } from '../../core/region/KGRegion';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
|
||||
/**
|
||||
* Tool for reading music content from the project
|
||||
* Provides read-only access to project data and converts to ABC notation
|
||||
*/
|
||||
export class ReadMusicTool extends BaseTool {
|
||||
readonly name = 'read_music';
|
||||
readonly description = 'Read the music content from a specific track or all tracks, returning the content in ABC notation format.';
|
||||
|
||||
readonly parameters: Record<string, ToolParameter> = {
|
||||
track_id: {
|
||||
type: 'string',
|
||||
description: 'The track ID to read, or "all" to read all tracks. If not provided, reads the first available track.',
|
||||
required: false
|
||||
},
|
||||
start_beat: {
|
||||
type: 'number',
|
||||
description: 'Start beat position to read from (default: 0)',
|
||||
required: false
|
||||
},
|
||||
length: {
|
||||
type: 'number',
|
||||
description: 'Length in beats to read (default: entire track/project)',
|
||||
required: false
|
||||
}
|
||||
};
|
||||
|
||||
async execute(params: Record<string, unknown>): Promise<ToolResult> {
|
||||
try {
|
||||
// Validate parameters
|
||||
this.validateParameters(params);
|
||||
|
||||
const trackId = params.track_id as string | undefined;
|
||||
const startBeat = (params.start_beat as number) || 0;
|
||||
const length = params.length as number | undefined;
|
||||
|
||||
const project = this.getCurrentProject();
|
||||
const tracks = project.getTracks();
|
||||
|
||||
if (tracks.length === 0) {
|
||||
return this.createErrorResult('No tracks found in the project');
|
||||
}
|
||||
|
||||
// Validate start_beat
|
||||
if (startBeat < 0) {
|
||||
return this.createErrorResult(`Invalid start_beat ${startBeat}. Must be >= 0.`);
|
||||
}
|
||||
|
||||
// Validate length
|
||||
if (length !== undefined && length <= 0) {
|
||||
return this.createErrorResult(`Invalid length ${length}. Must be > 0.`);
|
||||
}
|
||||
|
||||
// Get project settings for bar rounding
|
||||
const timeSignature = project.getTimeSignature();
|
||||
const beatsPerBar = timeSignature.numerator;
|
||||
|
||||
// Round startBeat to floor bar beats and calculate endBeat
|
||||
const roundedStartBeat = Math.floor(startBeat / beatsPerBar) * beatsPerBar;
|
||||
const rawEndBeat = length !== undefined ? startBeat + length : undefined;
|
||||
const roundedEndBeat = rawEndBeat !== undefined ? Math.ceil(rawEndBeat / beatsPerBar) * beatsPerBar : undefined;
|
||||
|
||||
let abcOutput = '';
|
||||
|
||||
if (!trackId || trackId === '' || trackId === 'all') {
|
||||
// Read all tracks
|
||||
const midiTracks = tracks.filter(track => track instanceof KGMidiTrack) as KGMidiTrack[];
|
||||
abcOutput = this.generateAllTracksABC(midiTracks, roundedStartBeat, roundedEndBeat);
|
||||
} else {
|
||||
// Read specific track or first available track
|
||||
const targetTrack = trackId
|
||||
? tracks.find(t => t.getId().toString() === trackId)
|
||||
: tracks[0];
|
||||
|
||||
if (!targetTrack) {
|
||||
return this.createErrorResult(
|
||||
trackId
|
||||
? `Track with ID "${trackId}" not found`
|
||||
: 'No tracks available'
|
||||
);
|
||||
}
|
||||
|
||||
if (!(targetTrack instanceof KGMidiTrack)) {
|
||||
return this.createErrorResult(`Track "${targetTrack.getName()}" is not a MIDI track`);
|
||||
}
|
||||
|
||||
abcOutput = this.generateSingleTrackABC(targetTrack, roundedStartBeat, roundedEndBeat);
|
||||
}
|
||||
|
||||
return this.createSuccessResult(abcOutput);
|
||||
|
||||
} catch (error) {
|
||||
return this.createErrorResult(`Failed to read music: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get KGCore instance
|
||||
*/
|
||||
private getKGCore(): KGCore {
|
||||
return KGCore.instance();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the track that contains the active piano roll region or first selected region
|
||||
*/
|
||||
private findTrackToSkip(tracks: KGMidiTrack[]): KGMidiTrack | null {
|
||||
try {
|
||||
const store = useProjectStore.getState();
|
||||
const core = this.getKGCore();
|
||||
|
||||
// First check for active piano roll region
|
||||
if (store.activeRegionId) {
|
||||
const activeRegion = this.findRegionById(store.activeRegionId, tracks);
|
||||
if (activeRegion) {
|
||||
const track = this.findTrackByRegion(activeRegion, tracks);
|
||||
return track;
|
||||
}
|
||||
}
|
||||
|
||||
// Then check for selected regions
|
||||
const selectedItems = core.getSelectedItems();
|
||||
const selectedRegion = selectedItems.find((item: unknown) => item instanceof KGRegion) as KGRegion;
|
||||
|
||||
if (selectedRegion) {
|
||||
const track = this.findTrackByRegion(selectedRegion, tracks);
|
||||
return track;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error finding track to skip:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a region by ID across all tracks
|
||||
*/
|
||||
private findRegionById(regionId: string, tracks: KGMidiTrack[]): KGRegion | null {
|
||||
for (const track of tracks) {
|
||||
const regions = track.getRegions();
|
||||
const region = regions.find(r => r.getId() === regionId);
|
||||
if (region) {
|
||||
return region;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find track that contains the given region
|
||||
*/
|
||||
private findTrackByRegion(region: KGRegion, tracks: KGMidiTrack[]): KGMidiTrack | null {
|
||||
return tracks.find(track => track.getRegions().includes(region)) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate ABC notation for all tracks
|
||||
*/
|
||||
private generateAllTracksABC(tracks: KGMidiTrack[], startBeat: number, endBeat?: number): string {
|
||||
const midiTracks = tracks.filter(track => track instanceof KGMidiTrack);
|
||||
|
||||
if (midiTracks.length === 0) {
|
||||
return 'No MIDI tracks found in the project.';
|
||||
}
|
||||
|
||||
// Find the track to skip (unless it's the first track)
|
||||
const trackToSkip = this.findTrackToSkip(midiTracks);
|
||||
const firstTrack = midiTracks[0]; // The melody track
|
||||
|
||||
// Get project settings for proper notation
|
||||
const project = this.getCurrentProject();
|
||||
const timeSignature = project.getTimeSignature();
|
||||
const keySignature = project.getKeySignature();
|
||||
const abcKeySignature = KEY_SIGNATURE_MAP[keySignature]?.abcNotationKeySignature || 'C';
|
||||
|
||||
let output = `All Tracks (beats ${startBeat}-${endBeat || 'end'}):\n\n`;
|
||||
|
||||
midiTracks.forEach((track, index) => {
|
||||
// Skip this track if it's the track to skip AND it's not the first track (melody)
|
||||
if (trackToSkip && track === trackToSkip && track !== firstTrack) {
|
||||
return; // Skip this track
|
||||
}
|
||||
const trackNumber = index + 1;
|
||||
const trackName = track.getName() || `Track ${trackNumber}`;
|
||||
|
||||
// hardcode the 1st track to be the melody, other track names are the same as the original track names
|
||||
output += `Track ${trackNumber} - ${trackNumber === 1 ? 'Melody' : trackName}:\n`;
|
||||
|
||||
// Get all regions from the track and convert each one
|
||||
const regions = track.getRegions().filter(region => region instanceof KGMidiRegion) as KGMidiRegion[];
|
||||
|
||||
if (regions.length === 0) {
|
||||
output += 'X:' + trackNumber + '\n';
|
||||
output += `M:${timeSignature.numerator}/${timeSignature.denominator}\n`;
|
||||
output += `K:${abcKeySignature}\n`;
|
||||
output += 'z4 | // No regions found\n\n';
|
||||
} else {
|
||||
// Convert each region that overlaps with the requested range
|
||||
let hasContent = false;
|
||||
regions.forEach((region) => {
|
||||
const regionStart = region.getStartFromBeat();
|
||||
const regionEnd = regionStart + region.getLength();
|
||||
|
||||
// Check if region overlaps with requested range
|
||||
if (regionStart < (endBeat || Infinity) && regionEnd > startBeat) {
|
||||
const abcNotation = convertRegionToABCNotation(region, startBeat, endBeat);
|
||||
|
||||
// Update the X: line to include track number
|
||||
const lines = abcNotation.split('\n');
|
||||
lines[0] = `X:${trackNumber}`;
|
||||
output += lines.join('\n') + '\n\n';
|
||||
hasContent = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (!hasContent) {
|
||||
output += 'X:' + trackNumber + '\n';
|
||||
output += `M:${timeSignature.numerator}/${timeSignature.denominator}\n`;
|
||||
output += `K:${abcKeySignature}\n`;
|
||||
output += 'z4 | // No content in specified range\n\n';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return output.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate ABC notation for a single track
|
||||
*/
|
||||
private generateSingleTrackABC(track: KGMidiTrack, startBeat: number, endBeat?: number): string {
|
||||
if (!(track instanceof KGMidiTrack)) {
|
||||
return `Track is not a MIDI track.`;
|
||||
}
|
||||
|
||||
// Get project settings for proper notation
|
||||
const project = this.getCurrentProject();
|
||||
const timeSignature = project.getTimeSignature();
|
||||
const keySignature = project.getKeySignature();
|
||||
const abcKeySignature = KEY_SIGNATURE_MAP[keySignature]?.abcNotationKeySignature || 'C';
|
||||
|
||||
const trackName = track.getName() || 'Unnamed Track';
|
||||
|
||||
let output = `Track "${trackName}" (beats ${startBeat}-${endBeat || 'end'}):\n`;
|
||||
|
||||
// Get all regions from the track and convert each one
|
||||
const regions = track.getRegions().filter(region => region instanceof KGMidiRegion) as KGMidiRegion[];
|
||||
|
||||
if (regions.length === 0) {
|
||||
output += 'X:1\n';
|
||||
output += `T:${trackName}\n`;
|
||||
output += `M:${timeSignature.numerator}/${timeSignature.denominator}\n`;
|
||||
output += `K:${abcKeySignature}\n`;
|
||||
output += `L:1/${timeSignature.denominator}\n`;
|
||||
output += 'z4 | // No regions found';
|
||||
} else {
|
||||
// Convert each region that overlaps with the requested range
|
||||
let hasContent = false;
|
||||
regions.forEach((region) => {
|
||||
const regionStart = region.getStartFromBeat();
|
||||
const regionEnd = regionStart + region.getLength();
|
||||
|
||||
// Check if region overlaps with requested range
|
||||
if (regionStart < (endBeat || Infinity) && regionEnd > startBeat) {
|
||||
const abcNotation = convertRegionToABCNotation(region, startBeat, endBeat);
|
||||
|
||||
// Update the title to include track name
|
||||
const lines = abcNotation.split('\n');
|
||||
lines[1] = `T:${trackName}`;
|
||||
output += lines.join('\n');
|
||||
hasContent = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (!hasContent) {
|
||||
output += 'X:1\n';
|
||||
output += `T:${trackName}\n`;
|
||||
output += `M:${timeSignature.numerator}/${timeSignature.denominator}\n`;
|
||||
output += `K:${abcKeySignature}\n`;
|
||||
output += `L:1/${timeSignature.denominator}\n`;
|
||||
output += 'z4 | // No content in specified range';
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { BaseTool } from './BaseTool';
|
||||
import type { ToolResult, ToolParameter } from './BaseTool';
|
||||
import { DeleteNotesCommand } from '../../core/commands/note/DeleteNotesCommand';
|
||||
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
||||
import { useProjectStore } from '../../stores/projectStore';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
|
||||
/**
|
||||
* Tool for removing notes from MIDI regions within a specified beat range
|
||||
* Integrates with the existing command system for undo/redo support
|
||||
*/
|
||||
export class RemoveNotesTool extends BaseTool {
|
||||
readonly name = 'remove_notes';
|
||||
readonly description = 'Remove MIDI notes from the current region within a specified beat range. All notes that start within the range will be deleted.';
|
||||
|
||||
readonly parameters: Record<string, ToolParameter> = {
|
||||
start_beat: {
|
||||
type: 'number',
|
||||
description: 'Start of the beat range to remove notes from (inclusive)',
|
||||
required: true
|
||||
},
|
||||
end_beat: {
|
||||
type: 'number',
|
||||
description: 'End of the beat range to remove notes from (exclusive)',
|
||||
required: true
|
||||
},
|
||||
region_id: {
|
||||
type: 'string',
|
||||
description: 'ID of the region to remove notes from. If not provided, uses the currently selected region.',
|
||||
required: false
|
||||
}
|
||||
};
|
||||
|
||||
async execute(params: Record<string, unknown>): Promise<ToolResult> {
|
||||
try {
|
||||
// Validate parameters
|
||||
this.validateParameters(params);
|
||||
|
||||
const startBeat = params.start_beat as number;
|
||||
const endBeat = params.end_beat as number;
|
||||
const regionId = params.region_id as string | undefined;
|
||||
|
||||
// Validate beat range
|
||||
if (startBeat < 0) {
|
||||
return this.createErrorResult(`Invalid start_beat ${startBeat}. Must be >= 0.`);
|
||||
}
|
||||
|
||||
if (endBeat <= startBeat) {
|
||||
return this.createErrorResult(`Invalid beat range: end_beat (${endBeat}) must be greater than start_beat (${startBeat}).`);
|
||||
}
|
||||
|
||||
// Find the target region
|
||||
const targetRegion = this.findTargetRegion(regionId);
|
||||
if (!targetRegion) {
|
||||
return this.createErrorResult(
|
||||
regionId
|
||||
? `Region with ID "${regionId}" not found or is not a MIDI region`
|
||||
: 'No active or selected MIDI region found. Please open the piano roll with a region or select a MIDI region first.'
|
||||
);
|
||||
}
|
||||
|
||||
// Adjust beat range relative to region's start beat
|
||||
const regionStartBeat = targetRegion.getStartFromBeat();
|
||||
const adjustedStartBeat = startBeat - regionStartBeat;
|
||||
const adjustedEndBeat = endBeat - regionStartBeat;
|
||||
|
||||
// Find all notes within the specified beat range
|
||||
const notesToRemove = this.findNotesInRange(targetRegion, adjustedStartBeat, adjustedEndBeat);
|
||||
|
||||
if (notesToRemove.length === 0) {
|
||||
return this.createSuccessResult(
|
||||
`No notes found in the range from beat ${startBeat} to ${endBeat}.`
|
||||
);
|
||||
}
|
||||
|
||||
// Extract note IDs for deletion
|
||||
const noteIds = notesToRemove.map(note => note.getId());
|
||||
|
||||
// Execute the deletion command
|
||||
const command = new DeleteNotesCommand(noteIds);
|
||||
await this.executeCommand(command);
|
||||
|
||||
// Create success message
|
||||
const noteCount = notesToRemove.length;
|
||||
const noteList = notesToRemove
|
||||
.map(note => {
|
||||
const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||
const octave = Math.floor(note.getPitch() / 12) - 1;
|
||||
const noteName = noteNames[note.getPitch() % 12];
|
||||
return `${noteName}${octave}`;
|
||||
})
|
||||
.join(', ');
|
||||
|
||||
return this.createSuccessResult(
|
||||
`Successfully removed ${noteCount} note${noteCount > 1 ? 's' : ''} from beats ${startBeat}-${endBeat}: ${noteList}`
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
return this.createErrorResult(`Failed to remove notes: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the target region for note removal
|
||||
* Priority: 1) Specified regionId, 2) Active piano roll region, 3) Selected regions, 4) Error if none found
|
||||
*/
|
||||
private findTargetRegion(regionId?: string): KGMidiRegion | null {
|
||||
const project = this.getCurrentProject();
|
||||
const tracks = project.getTracks();
|
||||
|
||||
if (regionId) {
|
||||
// Find specific region by ID
|
||||
for (const track of tracks) {
|
||||
const regions = track.getRegions();
|
||||
const region = regions.find(r => r.getId() === regionId);
|
||||
if (region && region instanceof KGMidiRegion) {
|
||||
return region;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} else {
|
||||
// Smart region finding: try different sources in priority order
|
||||
|
||||
// 1. Try active piano roll region
|
||||
const storeState = useProjectStore.getState();
|
||||
if (storeState.activeRegionId) {
|
||||
for (const track of tracks) {
|
||||
const regions = track.getRegions();
|
||||
const region = regions.find(r => r.getId() === storeState.activeRegionId);
|
||||
if (region && region instanceof KGMidiRegion) {
|
||||
return region;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try selected regions
|
||||
const core = this.getKGCore();
|
||||
const selectedItems = core.getSelectedItems();
|
||||
for (const item of selectedItems) {
|
||||
if (item instanceof KGMidiRegion) {
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. No fallback - return null to trigger error
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get KGCore instance for selection access
|
||||
*/
|
||||
private getKGCore() {
|
||||
return KGCore.instance();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all notes within the specified beat range
|
||||
* Notes are included if their start beat is within [startBeat, endBeat)
|
||||
*/
|
||||
private findNotesInRange(region: KGMidiRegion, startBeat: number, endBeat: number) {
|
||||
const notes = region.getNotes();
|
||||
return notes.filter(note => {
|
||||
const noteStartBeat = note.getStartBeat();
|
||||
return noteStartBeat >= startBeat && noteStartBeat < endBeat;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { BaseTool } from './BaseTool';
|
||||
import type { ToolResult, ToolParameter } from './BaseTool';
|
||||
|
||||
/**
|
||||
* Pseudo tool for handling <think> tags in LLM responses
|
||||
* This tool displays the thinking content in the UI but doesn't send results back to the LLM
|
||||
* Handles XML format: <think>any content here</think>
|
||||
* This is functionally identical to ThinkingTool but handles the shorter tag name
|
||||
*/
|
||||
export class ThinkTool extends BaseTool {
|
||||
readonly name = 'think';
|
||||
readonly description = 'Pseudo tool for handling LLM thinking content from <think> tags. Shows content in UI but does not send results back to LLM.';
|
||||
|
||||
readonly parameters: Record<string, ToolParameter> = {
|
||||
content: {
|
||||
type: 'string',
|
||||
description: 'The thinking content from the XML tag',
|
||||
required: false
|
||||
}
|
||||
};
|
||||
|
||||
async execute(params: Record<string, unknown>): Promise<ToolResult> {
|
||||
try {
|
||||
// Extract the thinking content from the parameters
|
||||
// const content = params.content as string || '';
|
||||
|
||||
// Return the thinking content as a successful result
|
||||
// This will be displayed in the UI but not sent back to the LLM
|
||||
return this.createSuccessResult("Thinking completed.");
|
||||
|
||||
} catch (error) {
|
||||
return this.createErrorResult(`Failed to process thinking content: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { BaseTool } from './BaseTool';
|
||||
import type { ToolResult, ToolParameter } from './BaseTool';
|
||||
|
||||
/**
|
||||
* Pseudo tool for handling <thinking> tags in LLM responses
|
||||
* This tool displays the thinking content in the UI but doesn't send results back to the LLM
|
||||
* Handles XML format: <thinking>any content here</thinking>
|
||||
*/
|
||||
export class ThinkingTool extends BaseTool {
|
||||
readonly name = 'thinking';
|
||||
readonly description = 'Pseudo tool for handling LLM thinking content. Shows content in UI but does not send results back to LLM.';
|
||||
|
||||
readonly parameters: Record<string, ToolParameter> = {
|
||||
content: {
|
||||
type: 'string',
|
||||
description: 'The thinking content from the XML tag',
|
||||
required: false
|
||||
}
|
||||
};
|
||||
|
||||
async execute(params: Record<string, unknown>): Promise<ToolResult> {
|
||||
try {
|
||||
// Extract the thinking content from the parameters
|
||||
// const content = params.content as string || '';
|
||||
|
||||
// Return the thinking content as a successful result
|
||||
// This will be displayed in the UI but not sent back to the LLM
|
||||
return this.createSuccessResult("Thinking completed.");
|
||||
|
||||
} catch (error) {
|
||||
return this.createErrorResult(`Failed to process thinking content: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Base tool system
|
||||
export { BaseTool } from './BaseTool';
|
||||
export type { ToolResult, ToolParameter, ToolDefinition } from './BaseTool';
|
||||
|
||||
// Specific tools
|
||||
import { AddNotesTool } from './AddNotesTool';
|
||||
import { RemoveNotesTool } from './RemoveNotesTool';
|
||||
import { ReadMusicTool } from './ReadMusicTool';
|
||||
import { AttemptCompletionTool } from './AttemptCompletionTool';
|
||||
import { ThinkingTool } from './ThinkingTool';
|
||||
import { ThinkTool } from './ThinkTool';
|
||||
|
||||
export { AddNotesTool, RemoveNotesTool, ReadMusicTool, AttemptCompletionTool, ThinkingTool, ThinkTool };
|
||||
|
||||
// Tool registry for easy access
|
||||
export const AVAILABLE_TOOLS = {
|
||||
add_notes: AddNotesTool,
|
||||
remove_notes: RemoveNotesTool,
|
||||
read_music: ReadMusicTool,
|
||||
attempt_completion: AttemptCompletionTool,
|
||||
thinking: ThinkingTool,
|
||||
think: ThinkTool
|
||||
} as const;
|
||||
|
||||
export type ToolName = keyof typeof AVAILABLE_TOOLS;
|
||||
Reference in New Issue
Block a user