UNPKG

@aituber-onair/core

Version:

Core library for AITuber OnAir providing voice synthesis and chat processing

498 lines 19.2 kB
import { ENDPOINT_CLAUDE_API, MODEL_CLAUDE_3_HAIKU, CLAUDE_VISION_SUPPORTED_MODELS, DEFAULT_MAX_TOKENS, } from '../../../../constants'; /** * Claude implementation of ChatService */ export class ClaudeChatService { /** * Constructor * @param apiKey Anthropic API key * @param model Name of the model to use * @param visionModel Name of the vision model * @param tools Array of tool definitions * @param mcpServers Array of MCP server configurations (optional) * @throws Error if the vision model doesn't support vision capabilities */ constructor(apiKey, model = MODEL_CLAUDE_3_HAIKU, visionModel = MODEL_CLAUDE_3_HAIKU, tools = [], mcpServers = []) { /** Provider name */ this.provider = 'claude'; this.apiKey = apiKey; this.model = model || MODEL_CLAUDE_3_HAIKU; this.visionModel = visionModel || MODEL_CLAUDE_3_HAIKU; this.tools = tools; this.mcpServers = mcpServers; // Validate vision model supports vision capabilities if (!CLAUDE_VISION_SUPPORTED_MODELS.includes(this.visionModel)) { throw new Error(`Model ${this.visionModel} does not support vision capabilities.`); } } /** * Get the current model name * @returns Model name */ getModel() { return this.model; } /** * Get the current vision model name * @returns Vision model name */ getVisionModel() { return this.visionModel; } /** * Get configured MCP servers * @returns Array of MCP server configurations */ getMCPServers() { return this.mcpServers; } /** * Add MCP server configuration * @param serverConfig MCP server configuration */ addMCPServer(serverConfig) { this.mcpServers.push(serverConfig); } /** * Remove MCP server by name * @param serverName Name of the server to remove */ removeMCPServer(serverName) { this.mcpServers = this.mcpServers.filter((server) => server.name !== serverName); } /** * Check if MCP servers are configured * @returns True if MCP servers are configured */ hasMCPServers() { return this.mcpServers.length > 0; } /** * Process chat messages * @param messages Array of messages to send * @param onPartialResponse Callback to receive each part of streaming response * @param onCompleteResponse Callback to execute when response is complete */ async processChat(messages, onPartialResponse, onCompleteResponse) { // not use tools or MCP servers if (this.tools.length === 0 && this.mcpServers.length === 0) { const res = await this.callClaude(messages, this.model, true); const full = await this.parsePureStream(res, onPartialResponse); await onCompleteResponse(full); return; } // use tools or MCP servers const result = await this.chatOnce(messages, true, onPartialResponse); if (result.stop_reason === 'end') { const full = result.blocks .filter((b) => b.type === 'text') .map((b) => b.text) .join(''); await onCompleteResponse(full); return; } /* if tool_use, throw error */ throw new Error('processChat received tool_calls. ChatProcessor must use chatOnce() loop when tools are enabled.'); } /** * Process chat messages with images * @param messages Array of messages to send (including images) * @param onPartialResponse Callback to receive each part of streaming response * @param onCompleteResponse Callback to execute when response is complete */ async processVisionChat(messages, onPartialResponse, onCompleteResponse) { /* same branch logic for vision */ if (this.tools.length === 0 && this.mcpServers.length === 0) { const res = await this.callClaude(messages, this.visionModel, true); const full = await this.parsePureStream(res, onPartialResponse); await onCompleteResponse(full); return; } const result = await this.visionChatOnce(messages); // non-stream (tools only) if (result.stop_reason === 'end') { const full = result.blocks .filter((b) => b.type === 'text') .map((b) => b.text) .join(''); await onCompleteResponse(full); return; } throw new Error('processVisionChat received tool_calls. ChatProcessor must use chatOnce() loop when tools are enabled.'); } /** * Convert AITuber OnAir messages to Claude format * @param messages Array of messages * @returns Claude formatted messages */ convertMessagesToClaudeFormat(messages) { return messages.map((msg) => { return { role: this.mapRoleToClaude(msg.role), content: msg.content, }; }); } /** * Convert AITuber OnAir vision messages to Claude format * @param messages Array of vision messages * @returns Claude formatted vision messages */ convertVisionMessagesToClaudeFormat(messages) { return messages.map((msg) => { // If message content is a string, create a text-only message if (typeof msg.content === 'string') { return { role: this.mapRoleToClaude(msg.role), content: [ { type: 'text', text: msg.content, }, ], }; } // If message content is an array of blocks, convert each block if (Array.isArray(msg.content)) { const content = msg.content .map((block) => { if (block.type === 'image_url') { // check if the image url is a data url if (block.image_url.url.startsWith('data:')) { const m = block.image_url.url.match(/^data:([^;]+);base64,(.+)$/); if (m) { return { type: 'image', source: { type: 'base64', media_type: m[1], data: m[2] }, }; } return null; } // if the image url is a normal url return { type: 'image', source: { type: 'url', url: block.image_url.url, media_type: this.getMimeTypeFromUrl(block.image_url.url), }, }; } return block; }) .filter((b) => b); return { role: this.mapRoleToClaude(msg.role), content, }; } return { role: this.mapRoleToClaude(msg.role), content: [], }; }); } /** * Map AITuber OnAir roles to Claude roles * @param role AITuber OnAir role * @returns Claude role */ mapRoleToClaude(role) { switch (role) { case 'system': // Claude handles system messages separately, but we'll map it anyway return 'system'; case 'user': return 'user'; case 'assistant': return 'assistant'; default: return 'user'; } } /** * Get MIME type from URL * @param url Image URL * @returns MIME type */ getMimeTypeFromUrl(url) { const extension = url.split('.').pop()?.toLowerCase(); switch (extension) { case 'jpg': case 'jpeg': return 'image/jpeg'; case 'png': return 'image/png'; case 'gif': return 'image/gif'; case 'webp': return 'image/webp'; default: return 'image/jpeg'; } } /** * Call Claude API * @param messages Array of messages to send * @param model Model name * @param stream Whether to stream the response * @param maxTokens Maximum tokens for response (optional) * @returns Response */ async callClaude(messages, model, stream, maxTokens) { const system = messages.find((m) => m.role === 'system')?.content ?? ''; const content = messages.filter((m) => m.role !== 'system'); const hasVision = content.some((m) => Array.isArray(m.content) && m.content.some((b) => b.type === 'image_url' || b.type === 'image')); const body = { model, system, messages: hasVision ? this.convertVisionMessagesToClaudeFormat(content) : this.convertMessagesToClaudeFormat(content), stream, max_tokens: maxTokens !== undefined ? maxTokens : DEFAULT_MAX_TOKENS, }; if (this.tools.length) { body.tools = this.tools.map((t) => ({ name: t.name, description: t.description, input_schema: t.parameters, })); body.tool_choice = { type: 'auto' }; } // Add MCP servers if configured if (this.mcpServers.length > 0) { body.mcp_servers = this.mcpServers; } const headers = { 'Content-Type': 'application/json', 'x-api-key': this.apiKey, 'anthropic-version': '2023-06-01', 'anthropic-dangerous-direct-browser-access': 'true', }; // Add beta header if MCP servers are configured if (this.mcpServers.length > 0) { headers['anthropic-beta'] = 'mcp-client-2025-04-04'; } const res = await fetch(ENDPOINT_CLAUDE_API, { method: 'POST', headers, body: JSON.stringify(body), }); if (!res.ok) throw new Error(await res.text()); return res; } /** * Parse stream response * @param res Response * @param onPartial Callback to receive each part of streaming response * @returns ClaudeInternalCompletion */ async parseStream(res, onPartial) { const reader = res.body.getReader(); const dec = new TextDecoder(); const textBlocks = []; const toolCalls = new Map(); let buf = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buf += dec.decode(value, { stream: true }); let nl; while ((nl = buf.indexOf('\n')) !== -1) { const line = buf.slice(0, nl).trim(); buf = buf.slice(nl + 1); if (!line.startsWith('data:')) continue; const payload = line.slice(5).trim(); if (payload === '[DONE]') break; const ev = JSON.parse(payload); /* content delta */ if (ev.type === 'content_block_delta' && ev.delta?.text) { onPartial(ev.delta.text); textBlocks.push({ type: 'text', text: ev.delta.text }); } /* tool_call delta */ if (ev.type === 'content_block_start' && ev.content_block?.type === 'tool_use') { toolCalls.set(ev.index, { id: ev.content_block.id, name: ev.content_block.name, args: '', }); } else if (ev.type === 'content_block_start' && ev.content_block?.type === 'mcp_tool_use') { // Handle MCP tool use toolCalls.set(ev.index, { id: ev.content_block.id, name: ev.content_block.name, args: '', server_name: ev.content_block.server_name, }); } else if (ev.type === 'content_block_start' && // case of non-stream ev.content_block?.type === 'tool_result') { textBlocks.push({ type: 'tool_result', tool_use_id: ev.content_block.tool_use_id, content: ev.content_block.content ?? '', }); } else if (ev.type === 'content_block_start' && ev.content_block?.type === 'mcp_tool_result') { // Handle MCP tool result textBlocks.push({ type: 'mcp_tool_result', tool_use_id: ev.content_block.tool_use_id, is_error: ev.content_block.is_error ?? false, content: ev.content_block.content ?? [], }); } /* case of input_json_delta */ if (ev.type === 'content_block_delta' && ev.delta?.type === 'input_json_delta') { const entry = toolCalls.get(ev.index); if (entry) entry.args += ev.delta.partial_json || ''; } /* case of content_block_stop */ if (ev.type === 'content_block_stop' && toolCalls.has(ev.index)) { const { id, name, args, server_name } = toolCalls.get(ev.index); if (server_name) { // MCP tool use textBlocks.push({ type: 'mcp_tool_use', id, name, server_name, input: JSON.parse(args || '{}'), }); } else { // Standard tool use textBlocks.push({ type: 'tool_use', id, name, input: JSON.parse(args || '{}'), }); } toolCalls.delete(ev.index); } } } return { blocks: textBlocks, stop_reason: textBlocks.some((b) => b.type === 'tool_use' || b.type === 'mcp_tool_use') ? 'tool_use' : 'end', }; } async parsePureStream(res, onPartial) { const { blocks } = await this.parseStream(res, onPartial); return blocks .filter((b) => b.type === 'text') .map((b) => b.text) .join(''); } parseOneShot(data) { const blocks = []; (data.content ?? []).forEach((c) => { if (c.type === 'text') { blocks.push({ type: 'text', text: c.text }); } else if (c.type === 'tool_use') { blocks.push({ type: 'tool_use', id: c.id, name: c.name, input: c.input ?? {}, }); } else if (c.type === 'mcp_tool_use') { blocks.push({ type: 'mcp_tool_use', id: c.id, name: c.name, server_name: c.server_name, input: c.input ?? {}, }); } else if (c.type === 'tool_result') { blocks.push({ type: 'tool_result', tool_use_id: c.tool_use_id, content: c.content ?? '', }); } else if (c.type === 'mcp_tool_result') { blocks.push({ type: 'mcp_tool_result', tool_use_id: c.tool_use_id, is_error: c.is_error ?? false, content: c.content ?? [], }); } }); return { blocks, stop_reason: blocks.some((b) => b.type === 'tool_use' || b.type === 'mcp_tool_use') ? 'tool_use' : 'end', }; } /** * Process chat messages * @param messages Array of messages to send * @param stream Whether to stream the response * @param onPartial Callback to receive each part of streaming response * @param maxTokens Maximum tokens for response (optional) * @returns ToolChatCompletion */ async chatOnce(messages, stream = true, onPartial = () => { }, maxTokens) { const res = await this.callClaude(messages, this.model, stream, maxTokens); const internalResult = stream ? await this.parseStream(res, onPartial) : this.parseOneShot(await res.json()); // Convert ClaudeInternalCompletion to ToolChatCompletion for compatibility return this.convertToStandardCompletion(internalResult); } /** * Process vision chat messages * @param messages Array of messages to send * @param stream Whether to stream the response * @param onPartial Callback to receive each part of streaming response * @param maxTokens Maximum tokens for response (optional) * @returns ToolChatCompletion */ async visionChatOnce(messages, stream = false, onPartial = () => { }, maxTokens) { const res = await this.callClaude(messages, this.visionModel, stream, maxTokens); const internalResult = stream ? await this.parseStream(res, onPartial) : this.parseOneShot(await res.json()); // Convert ClaudeInternalCompletion to ToolChatCompletion for compatibility return this.convertToStandardCompletion(internalResult); } /** * Convert internal completion to standard ToolChatCompletion * @param completion Internal completion result * @returns Standard ToolChatCompletion */ convertToStandardCompletion(completion) { // Filter out MCP-specific blocks and convert to standard format const standardBlocks = completion.blocks.filter((block) => { return (block.type === 'text' || block.type === 'tool_use' || block.type === 'tool_result'); }); return { blocks: standardBlocks, stop_reason: completion.stop_reason, }; } } //# sourceMappingURL=ClaudeChatService.js.map