@aituber-onair/core
Version:
Core library for AITuber OnAir providing voice synthesis and chat processing
337 lines • 14 kB
JavaScript
import { ENDPOINT_CLAUDE_API, MODEL_CLAUDE_3_HAIKU, CLAUDE_VISION_SUPPORTED_MODELS, } 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
*/
constructor(apiKey, model = MODEL_CLAUDE_3_HAIKU, visionModel = MODEL_CLAUDE_3_HAIKU) {
/** Provider name */
this.provider = 'claude';
this.apiKey = apiKey;
this.model = model || MODEL_CLAUDE_3_HAIKU;
this.visionModel = visionModel || MODEL_CLAUDE_3_HAIKU;
}
/**
* 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;
}
/**
* 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) {
try {
// Extract system message (if any) and regular messages
const systemMessage = messages.find((msg) => msg.role === 'system');
const nonSystemMessages = messages.filter((msg) => msg.role !== 'system');
// Convert messages to Claude format
const claudeMessages = this.convertMessagesToClaudeFormat(nonSystemMessages);
// Request to Claude API
const response = await fetch(ENDPOINT_CLAUDE_API, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': this.apiKey,
'anthropic-version': '2023-06-01',
'anthropic-dangerous-direct-browser-access': 'true',
},
body: JSON.stringify({
model: this.model,
messages: claudeMessages,
system: systemMessage?.content || '',
stream: true,
max_tokens: 1000,
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(`Claude API error: ${errorData.error?.message || response.statusText}`);
}
// Process streaming response
const reader = response.body?.getReader();
const decoder = new TextDecoder('utf-8');
let fullText = '';
if (!reader) {
throw new Error('Failed to get response reader');
}
while (true) {
const { done, value } = await reader.read();
if (done)
break;
const chunk = decoder.decode(value);
// Claude API serves responses as Server-Sent Events (SSE)
// Each event is prefixed with "event: " and "data: "
const lines = chunk.split('\n').filter((line) => line.trim() !== '');
for (const line of lines) {
try {
// Check if this is a data line
if (line.startsWith('data: ')) {
const data = line.slice(6); // Remove 'data: ' prefix
// Ignore [DONE] marker
if (data === '[DONE]')
continue;
const json = JSON.parse(data);
// Extract delta content if available
if (json.type === 'content_block_delta') {
const deltaText = json.delta?.text || '';
if (deltaText) {
fullText += deltaText;
onPartialResponse(deltaText);
}
}
// Extract full message content if this is a message_start event
else if (json.type === 'message_start') {
// Initial message metadata, no text yet
}
// Extract content blocks from message
else if (json.type === 'content_block_start') {
// Content block metadata, no text yet
}
// Message completion
else if (json.type === 'message_stop') {
// Message is complete
}
}
}
catch (e) {
console.error('Error parsing Claude stream:', e);
}
}
}
// Complete response callback
await onCompleteResponse(fullText);
}
catch (error) {
console.error('Error in processChat:', error);
throw error;
}
}
/**
* 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
* @throws Error if the selected model doesn't support vision
*/
async processVisionChat(messages, onPartialResponse, onCompleteResponse) {
try {
// Check if the vision model supports vision capabilities
if (!CLAUDE_VISION_SUPPORTED_MODELS.includes(this.visionModel)) {
throw new Error(`Model ${this.visionModel} does not support vision capabilities.`);
}
// Extract system message (if any) and regular messages
const systemMessage = messages.find((msg) => msg.role === 'system');
const nonSystemMessages = messages.filter((msg) => msg.role !== 'system');
// Convert messages to Claude vision format
const claudeMessages = this.convertVisionMessagesToClaudeFormat(nonSystemMessages);
// Request to Claude API
const response = await fetch(ENDPOINT_CLAUDE_API, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': this.apiKey,
'anthropic-version': '2023-06-01',
'anthropic-dangerous-direct-browser-access': 'true',
},
body: JSON.stringify({
model: this.visionModel,
messages: claudeMessages,
system: systemMessage?.content || '',
stream: true,
max_tokens: 1000,
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(`Claude API error: ${errorData.error?.message || response.statusText}`);
}
// Process streaming response
const reader = response.body?.getReader();
const decoder = new TextDecoder('utf-8');
let fullText = '';
if (!reader) {
throw new Error('Failed to get response reader');
}
while (true) {
const { done, value } = await reader.read();
if (done)
break;
const chunk = decoder.decode(value);
// Claude API serves responses as Server-Sent Events (SSE)
// Each event is prefixed with "event: " and "data: "
const lines = chunk.split('\n').filter((line) => line.trim() !== '');
for (const line of lines) {
try {
// Check if this is a data line
if (line.startsWith('data: ')) {
const data = line.slice(6); // Remove 'data: ' prefix
// Ignore [DONE] marker
if (data === '[DONE]')
continue;
const json = JSON.parse(data);
// Extract delta content if available
if (json.type === 'content_block_delta') {
const deltaText = json.delta?.text || '';
if (deltaText) {
fullText += deltaText;
onPartialResponse(deltaText);
}
}
// Other event types (handled same as in processChat)
}
}
catch (e) {
console.error('Error parsing Claude stream:', e);
}
}
}
// Complete response callback
await onCompleteResponse(fullText);
}
catch (error) {
console.error('Error in processVisionChat:', error);
throw error;
}
}
/**
* 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
else if (Array.isArray(msg.content)) {
const content = msg.content
.map((block) => {
if (block.type === 'text') {
return {
type: 'text',
text: block.text,
};
}
else if (block.type === 'image_url') {
// check if the image url is a data url
if (block.image_url.url.startsWith('data:')) {
// extract the base64 data from the data url
const matches = block.image_url.url.match(/^data:([A-Za-z-+/]+);base64,(.+)$/);
if (matches && matches.length >= 3) {
const mediaType = matches[1];
const base64Data = matches[2];
return {
type: 'image',
source: {
type: 'base64',
media_type: mediaType,
data: base64Data,
},
};
}
}
// 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 null;
})
.filter((item) => item !== null);
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';
}
}
}
//# sourceMappingURL=ClaudeChatService.js.map