solver-sdk
Version:
SDK for WorkAI API - AI-powered code analysis with WorkCoins billing system
316 lines • 12.1 kB
JavaScript
/**
* Утилиты для обработки потоковых данных от API чата
*/
/**
* Парсит строку SSE потока согласно официальному Anthropic API
*/
export function parseStreamLine(line) {
// Пропускаем пустые строки и комментарии
if (!line.trim() || line.startsWith(':')) {
return {};
}
// Обрабатываем только data: события
if (!line.startsWith('data: ')) {
return {};
}
const data = line.slice(6).trim();
// Пропускаем пустые данные
if (!data) {
return {};
}
try {
const jsonData = JSON.parse(data);
// ✅ Все события Anthropic API имеют поле type
if (!jsonData.type) {
return {};
}
// Создаем chunk согласно официальному формату Anthropic
const chunk = {
type: jsonData.type,
...jsonData
};
return { chunk };
}
catch (error) {
console.warn('Failed to parse SSE line:', line, error);
return {};
}
}
/**
* Проверяет завершен ли поток согласно Anthropic API
*/
export function isStreamComplete(chunk) {
return chunk.type === 'message_stop';
}
/**
* Обрабатывает чанк потоковой передачи данных в формате Server-Sent Events
* Работает только с официальными событиями Anthropic API
*
* @param line Строка из SSE потока
* @param logger Логгер для отладки
* @param options Опции обработки
* @returns Результат обработки чанка
*/
export function processStreamChunk(line, logger, options) {
try {
// Игнорируем пустые строки и комментарии
if (!line.trim() || line.startsWith(':')) {
return {};
}
// Проверяем формат SSE
if (!line.startsWith('data: ')) {
return {};
}
const data = line.slice(6).trim();
// Пропускаем пустые данные
if (!data) {
return {};
}
// Парсим JSON данные
let jsonData;
try {
jsonData = JSON.parse(data);
}
catch (parseError) {
if (logger) {
logger.error('Ошибка парсинга JSON', { error: parseError, data: data.substring(0, 100) });
}
return {};
}
// Валидируем что это официальное событие Anthropic API + кастомные события сервера
const validEvents = [
'message_start', 'content_block_start', 'content_block_delta',
'content_block_stop', 'message_delta', 'message_stop', 'ping', 'error',
'pause_turn', // 🔍 НОВОЕ: Server-side tool execution pause (web_search, code_execution)
'tool_use_ready', // 🔧 Кастомное событие сервера для уведомления о готовности инструмента
'partial_content_complete', // 🆘 НОВОЕ: Accumulated partial content при max_tokens
'incomplete_tool_retry', // 🔄 НОВОЕ: Retry при incomplete tool из-за max_tokens
'heartbeat', // 💓 Heartbeat для длинных операций
'processing_started', // 🚀 Early streaming событие
'resume_started', // 🔄 Resume событие
'todo_update', // 📋 TODO progress tracking
];
if (!validEvents.includes(jsonData.type)) {
if (logger) {
logger.warn(`⚠️ Неизвестное событие: ${jsonData.type}`, jsonData);
}
return {};
}
// Создаем ChatStreamChunk в соответствии с официальным API
const chunk = {
type: jsonData.type
};
// Добавляем поля в зависимости от типа события
switch (jsonData.type) {
case 'message_start':
if (jsonData.message) {
chunk.message = jsonData.message;
}
break;
case 'content_block_start':
if (jsonData.index !== undefined) {
chunk.index = jsonData.index;
}
if (jsonData.content_block) {
chunk.content_block = jsonData.content_block;
}
break;
case 'content_block_delta':
if (jsonData.index !== undefined) {
chunk.index = jsonData.index;
}
if (jsonData.delta) {
chunk.delta = jsonData.delta;
}
break;
case 'content_block_stop':
if (jsonData.index !== undefined) {
chunk.index = jsonData.index;
}
break;
case 'message_delta':
if (jsonData.delta) {
chunk.message_delta = jsonData.delta;
}
break;
case 'error':
if (jsonData.error) {
chunk.error = jsonData.error;
}
break;
case 'message_stop':
// 🎯 ИСПРАВЛЕНИЕ: Добавляем поддержку stop_reason в message_stop
if (jsonData.stop_reason !== undefined) {
chunk.message = {
id: '',
type: 'message',
role: 'assistant',
model: '',
content: [],
stop_reason: jsonData.stop_reason
};
}
break;
case 'pause_turn':
// 🔍 НОВОЕ: Server-side tool execution pause
// Происходит при выполнении web_search или code_execution на стороне Anthropic
chunk.message = {
id: '',
type: 'message',
role: 'assistant',
model: '',
content: [],
stop_reason: 'pause_turn'
};
break;
case 'tool_use_ready':
// 🔧 Кастомное событие сервера - передаем как есть
if (jsonData.tool_use) {
chunk.tool_use = jsonData.tool_use;
}
break;
case 'todo_update':
// 📋 НОВОЕ: TODO Update событие
if (jsonData.operation) {
chunk.operation = jsonData.operation;
}
if (jsonData.todos) {
chunk.todos = jsonData.todos;
}
if (jsonData.tool_use_id) {
chunk.tool_use_id = jsonData.tool_use_id;
}
if (jsonData.progress) {
chunk.progress = jsonData.progress;
}
break;
// ping и error не требуют дополнительных полей
}
return { chunk };
}
catch (error) {
if (logger) {
logger.error('Ошибка обработки чанка', { error, line: line.substring(0, 100) });
}
return {};
}
}
/**
* Обрабатывает ошибки потоковой передачи
* @param error Ошибка для обработки
* @returns Обработанная ошибка
*/
export function handleStreamError(error) {
if (error instanceof Error) {
return error;
}
if (typeof error === 'string') {
return new Error(error);
}
if (error && typeof error === 'object') {
const message = error.message || error.error || JSON.stringify(error);
return new Error(message);
}
return new Error('Неизвестная ошибка потока');
}
/**
* 🛡️ Безопасный парсинг partial JSON с восстановлением
* Исправляет типичные проблемы чанкинга:
* - Незакрытые кавычки
* - Незакрытые скобки
* - Обрезанные строки
*/
export function safeParsePartialJson(json, logger) {
// Сначала пробуем прямой парсинг
try {
return JSON.parse(json);
}
catch (e) {
// Игнорируем - пробуем восстановить
}
// Попытка восстановления
let fixed = json.trim();
// 1. Закрываем незавершенные строки
const quoteCount = (fixed.match(/(?<!\\)"/g) || []).length;
if (quoteCount % 2 !== 0) {
// Нечетное количество кавычек - добавляем закрывающую
fixed += '"';
if (logger) {
logger.debug('🔧 [JSON_RECOVERY] Добавлена закрывающая кавычка');
}
}
// 2. Балансируем скобки
const openBraces = (fixed.match(/\{/g) || []).length;
const closeBraces = (fixed.match(/\}/g) || []).length;
const openBrackets = (fixed.match(/\[/g) || []).length;
const closeBrackets = (fixed.match(/\]/g) || []).length;
// Добавляем недостающие закрывающие скобки
for (let i = 0; i < openBraces - closeBraces; i++) {
fixed += '}';
}
for (let i = 0; i < openBrackets - closeBrackets; i++) {
fixed += ']';
}
if (openBraces !== closeBraces || openBrackets !== closeBrackets) {
if (logger) {
logger.debug(`🔧 [JSON_RECOVERY] Добавлены скобки: {} +${openBraces - closeBraces}, [] +${openBrackets - closeBrackets}`);
}
}
// Пробуем парсить исправленный JSON
try {
const result = JSON.parse(fixed);
if (logger) {
logger.debug('✅ [JSON_RECOVERY] Успешно восстановлен JSON');
}
return result;
}
catch (e) {
if (logger) {
logger.warn('❌ [JSON_RECOVERY] Не удалось восстановить JSON', {
original: json.substring(0, 100),
fixed: fixed.substring(0, 100),
error: e instanceof Error ? e.message : String(e)
});
}
return null;
}
}
/**
* 🔄 Accumulator для безопасного накопления partial JSON chunks
* Использует умный парсинг с восстановлением
*/
export class PartialJsonAccumulator {
constructor(logger) {
this.accumulated = '';
this.logger = logger;
}
/**
* Добавляет новый чанк
*/
add(chunk) {
this.accumulated += chunk;
}
/**
* Пытается распарсить накопленный JSON
* @returns Распарсенный объект или null если еще не готов
*/
tryParse() {
if (!this.accumulated.trim()) {
return null;
}
return safeParsePartialJson(this.accumulated, this.logger);
}
/**
* Возвращает накопленную строку
*/
getAccumulated() {
return this.accumulated;
}
/**
* Сбрасывает накопленные данные
*/
reset() {
this.accumulated = '';
}
}
//# sourceMappingURL=stream-utils.js.map