initial public release.

This commit is contained in:
Xiaohan-Tian
2025-08-11 18:37:21 -07:00
commit de51967b49
186 changed files with 32322 additions and 0 deletions
+176
View File
@@ -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);
}
}
+112
View File
@@ -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;
}
}
+266
View File
@@ -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;
}
}
+389
View File
@@ -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(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&apos;/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
}
}
}
+229
View File
@@ -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'
};
}
}
+245
View File
@@ -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'
};
}
}
+33
View File
@@ -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>;
}
+454
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
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'
};
}
}
}
+27
View File
@@ -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;
}
+235
View File
@@ -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;
}
}
+49
View File
@@ -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}`);
}
}
}
+162
View File
@@ -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
};
}
}
+299
View File
@@ -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;
}
}
+168
View File
@@ -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;
});
}
}
+35
View File
@@ -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}`);
}
}
}
+34
View File
@@ -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}`);
}
}
}
+25
View File
@@ -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;