@aituber-onair/core
Version:
Core library for AITuber OnAir providing voice synthesis and chat processing
498 lines • 19.2 kB
JavaScript
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