/
alexefan136
/
flowstack
Обзор
Документация
Войти
/
alexefan136
/
flowstack
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
main
ui/src/lib/api.ts
2 204 строки
57 KB
Alexander Efanov
upd fix
04 авг 2026, 14:09
04 авг 2026, 14:09
f63ab86
Код
Авторство
О чём код?
// src/lib/api.ts import { API_KEY } from "./config"; import type { GraphEdge, GraphNode } from "./knowledge"; import { getAuthHeaders, useAuthStore } from "./stores/auth-store"; // ============================================================================ // Constants // ============================================================================ export const API_BASE = "http://localhost:8080"; // ============================================================================ // Types // ============================================================================ export interface Flow { id: string; name: string; description: string; agents: string[]; } export interface Message { role: "user" | "assistant" | "system"; content: string; } export interface RunOutput { output?: string; messages?: Array<{ name?: string; content?: string; role?: string }>; } export interface AgentEvent { type: | "agent_start" | "agent_message" | "agent_done" | "tool_call" | "citation" | "done" | "error" | "content" | "flow_done" | "experts_done" | "options_researched" | "ideas_generated"; agent?: string; content?: string; tool?: string; citation?: { doc_id: string; title: string; score: number }; error?: string; model?: string; tokens?: number; tokens_prompt?: number; tokens_completion?: number; tokens_total?: number; output?: string; flow_id?: string; stages?: Record<string, unknown>; metadata?: Record<string, unknown>; structured?: Record<string, unknown>; experts_count?: number; experts_tokens?: Record<string, number>; options_count?: number; options?: string[]; options_tokens?: Record<string, number>; generators_count?: number; generators?: string[]; generators_tokens?: Record<string, number>; } interface EngineSSEEvent { event: string; data: Record<string, unknown>; } // ✅ FIX: LLMConfig теперь соответствует GET /api/v1/llm/config export interface LLMConfig { provider: string; current_model: string; available_models: string[]; total_models: number; base_url: string; configured: boolean; settings: { max_tokens: number; temperature: number; top_p: number; }; } // ============================================================================ // Chat Types // ============================================================================ export interface Chat { id: string; workspace_id: string; title: string | null; rag_enabled: boolean; rag_workspace_id: string | null; model: string; temperature: number; top_k: number; messages_count: number; unread_count?: number; last_message_at?: string; last_message_role?: "user" | "assistant"; created_at: string; updated_at: string; } export interface ChatSource { chunk_id: string; content: string; score: number; document_title?: string; source?: string; rerank_score?: number; metadata?: Record<string, unknown>; } export interface ChatAttachment { id: string; filename: string; mime_type: string; size_bytes: number; content?: string; preview?: string; doc_type?: string; } export interface ChatMessage { id: string; chat_id: string; role: "user" | "assistant" | "system"; content: string; sources: ChatSource[]; attachments?: ChatAttachment[]; tokens_used: number; model?: string; created_at: string; } export interface ChatWithMessages { chat: Chat; messages: ChatMessage[]; } export interface ChatEvent { type: "user_message" | "rag_context" | "content" | "done" | "error"; message?: ChatMessage; sources?: ChatSource[]; sources_count?: number; content?: string; tokens_prompt?: number; tokens_completion?: number; error?: string; } // ============================================================================ // RAG / Knowledge Base Types // ============================================================================ export interface IngestResponse { document_id: string; chunks_count: number; processing_time_ms: number; timing?: { chunking_ms: number; embedding_ms: number; storage_ms: number; total_ms: number; }; chunking_strategy?: string; vector_dimensions?: number; } export interface KnowledgeSource { chunk_id: string; document_id: string; content: string; score: number; rerank_score?: number; source?: string; metadata: Record<string, unknown>; } export interface SearchResponse { query: string; results: KnowledgeSource[]; context: string; results_count: number; } export interface RagStats { total_chunks: number; total_documents: number; workspace_id: string; documents?: Array<{ document_id: string; title: string; chunks_count: number; size_bytes?: number; created_at: string; }>; } export interface DocumentInfo { document_id: string; title: string; source: string; doc_type: string; author?: string; language: string; chunks_count: number; total_chars: number; created_at: string; updated_at?: string; workspace_id: string; } // ============================================================================ // Agent Types // ============================================================================ export interface Agent { id: string; name: string; description: string; status: "active" | "idle" | "error"; tasks: number; cost: string; icon?: string; system_prompt: string; model: string; tools?: string[]; workspace_id: string; created_at: string; updated_at: string; } export interface CreateAgentRequest { name: string; description: string; system_prompt: string; model: string; tools?: string[]; workspace_id: string; rag_enabled?: boolean; } // ============================================================================ // Headers Helper // ============================================================================ export function getRequestHeaders( extraHeaders?: Record<string, string>, ): Record<string, string> { const authHeaders = getAuthHeaders(); if (!authHeaders["X-API-Key"] && !authHeaders["Authorization"] && API_KEY) { authHeaders["X-API-Key"] = API_KEY; } return { ...authHeaders, ...extraHeaders, }; } // ============================================================================ // Error Extraction // ============================================================================ function extractErrorMessage(text: string, status: number): string { try { const data = JSON.parse(text); if (typeof data === "string") return data; if (data.detail) { if (typeof data.detail === "string") { if ( data.detail.includes("<!DOCTYPE html>") || data.detail.includes("<html") ) { const titleMatch = data.detail.match(/<title>(.*?)<\/title>/i); if (titleMatch) { return `API ошибка (${status}): ${titleMatch[1]}`; } return `API вернул HTML-страницу (статус ${status}).`; } return fixMojibake(data.detail); } if (typeof data.detail === "object") { return ( data.detail.message || data.detail.error || JSON.stringify(data.detail) ); } } if (data.error) { if (typeof data.error === "string") return fixMojibake(data.error); return data.error.message || JSON.stringify(data.error); } if (data.message) return fixMojibake(data.message); return JSON.stringify(data); } catch { if (text.includes("<!DOCTYPE html>") || text.includes("<html")) { const titleMatch = text.match(/<title>(.*?)<\/title>/i); if (titleMatch) { return `API ошибка (${status}): ${titleMatch[1]}`; } return `API вернул HTML-страницу (статус ${status}).`; } return fixMojibake(text.slice(0, 500)) || `Ошибка ${status}`; } } // ============================================================================ // Mojibake Decoder // ============================================================================ function fixMojibake(text: string): string { if (!text) return text; let current = text; let iterations = 0; const maxIterations = 5; while (iterations < maxIterations) { if (!/[\u00C0-\u00FF]/.test(current)) { return current; } try { const bytes = new Uint8Array(current.length); for (let i = 0; i < current.length; i++) { bytes[i] = current.charCodeAt(i) & 0xff; } const decoder = new TextDecoder("utf-8", { fatal: false }); const decoded = decoder.decode(bytes); if (decoded === current) { return current; } const replacementCount = (decoded.match(/\uFFFD/g) || []).length; const prevReplacementCount = (current.match(/\uFFFD/g) || []).length; if (replacementCount > prevReplacementCount) { return current; } current = decoded; iterations++; } catch { return current; } } return current; } // ============================================================================ // Event Adapters // ============================================================================ function adaptEngineEvent(raw: EngineSSEEvent): AgentEvent[] { const { event, data } = raw; switch (event) { case "run_start": return [{ type: "agent_start", agent: "Orchestrator" }]; case "agent_start": return [ { type: "agent_start", agent: (data.agent as string) || "unknown" }, ]; case "agent_message": return [ { type: "agent_message", agent: (data.agent as string) || "unknown", content: fixMojibake((data.content as string) || ""), }, ]; case "agent_done": return [ { type: "done", agent: (data.agent as string) || "unknown", tokens_total: (data.tokens as number) || 0, }, ]; case "flow_done": return [ { type: "done", tokens_total: (data.tokens as number) || 0, }, ]; case "agent_update": { const agent = (data.agent as string) || "unknown"; const output = fixMojibake((data.output as string) || ""); const events: AgentEvent[] = []; events.push({ type: "agent_start", agent }); if (output) { events.push({ type: "agent_message", agent, content: output }); } return events; } case "run_complete": { const finalOutput = fixMojibake((data.output as string) || ""); const events: AgentEvent[] = []; if (finalOutput) { events.push({ type: "agent_message", agent: "Final", content: `\n\n---\n\n**✅ Финальный ответ:**\n\n${finalOutput}`, }); } events.push({ type: "done" }); return events; } case "error": return [ { type: "error", error: fixMojibake( (data.error as string) || (data.message as string) || "Unknown error", ), }, ]; default: console.warn("[Adapt] Unknown event type:", event); return []; } } function adaptLLMEvent(data: Record<string, unknown>): AgentEvent | null { const type = data.type as string; switch (type) { case "content": return { type: "content", content: fixMojibake((data.content as string) || ""), model: data.model as string, }; case "reasoning": return { type: "content", content: fixMojibake((data.content as string) || ""), model: data.model as string, }; case "tool_calls": return { type: "tool_call", tool: JSON.stringify(data.tool_calls), model: data.model as string, }; case "done": return { type: "done", model: data.model as string, tokens_prompt: (data.tokens_prompt as number) || 0, tokens_completion: (data.tokens_completion as number) || 0, tokens_total: (data.tokens_total as number) || 0, }; case "error": return { type: "error", error: fixMojibake((data.error as string) || "Unknown error"), model: data.model as string, }; default: console.warn("[LLM Adapt] Unknown event type:", type); return null; } } function fixChatEvent(event: ChatEvent): ChatEvent { const fixed = { ...event }; if (fixed.content) { fixed.content = fixMojibake(fixed.content); } if (fixed.message) { fixed.message = { ...fixed.message, content: fixMojibake(fixed.message.content), }; } if (fixed.sources && Array.isArray(fixed.sources)) { fixed.sources = fixed.sources.map((src) => ({ ...src, content: fixMojibake(src.content || ""), document_title: fixMojibake(src.document_title || ""), })); } return fixed; } // ============================================================================ // SSE Parser // ============================================================================ class SSEParser { private buffer = ""; private _isHtmlDetected = false; private _htmlBuffer = ""; get isHtmlDetected(): boolean { return this._isHtmlDetected; } get htmlContent(): string { return this._htmlBuffer; } parse(chunk: string): Array<{ event: string; data: string }> { this.buffer += chunk; const events: Array<{ event: string; data: string }> = []; if (!this._isHtmlDetected && this.buffer.length > 0) { const trimmed = this.buffer.trimStart(); if ( trimmed.startsWith("<!DOCTYPE html>") || trimmed.startsWith("<html") || trimmed.startsWith("<!doctype") ) { this._isHtmlDetected = true; this._htmlBuffer = this.buffer; this.buffer = ""; return events; } } if (this._isHtmlDetected) { this._htmlBuffer += chunk; return events; } const parts = this.buffer.split(/\r?\n\r?\n/); this.buffer = parts.pop() || ""; for (const part of parts) { const parsed = this.parseEvent(part); if (parsed) events.push(parsed); } return events; } private parseEvent(block: string): { event: string; data: string } | null { const lines = block.split(/\r?\n/); let eventName = "message"; const dataLines: string[] = []; for (const line of lines) { if (!line.trim() || line.startsWith(":")) continue; if (line.startsWith("event:")) { eventName = line.slice(6).trim(); } else if (line.startsWith("data:")) { dataLines.push(line.slice(5).trimStart()); } } if (dataLines.length === 0) return null; return { event: eventName, data: dataLines.join("\n") }; } reset() { this.buffer = ""; this._isHtmlDetected = false; this._htmlBuffer = ""; } } // ============================================================================ // Auth Functions (Gateway) // ============================================================================ export interface AuthUser { id: string; email: string; workspace_id: string; roles: string[]; display_name?: string; created_at?: string; } export interface AuthResponse { access_token: string; refresh_token: string; token_type: string; expires_in: number; user: AuthUser; } export async function fetchCurrentUser(): Promise<{ authenticated: boolean; user?: AuthUser; }> { const res = await fetch(`${API_BASE}/api/v1/auth/me`, { headers: getRequestHeaders(), }); if (!res.ok) throw new Error("Failed to fetch current user"); return res.json(); } // ============================================================================ // Chat Functions (Gateway → Engine) // ============================================================================ export async function fetchChats(): Promise<Chat[]> { const res = await fetch(`${API_BASE}/api/v1/chats`, { headers: getRequestHeaders(), }); if (!res.ok) { if (res.status === 401) { useAuthStore.getState().logout(); window.location.href = "/login"; } throw new Error(`Failed to fetch chats: ${res.status}`); } const chats = await res.json(); return chats.map((chat: Chat) => ({ ...chat, title: chat.title ? fixMojibake(chat.title) : null, })); } export async function createChat(data: { title?: string; rag_enabled?: boolean; rag_workspace_id?: string; model?: string; temperature?: number; top_k?: number; }): Promise<Chat> { const res = await fetch(`${API_BASE}/api/v1/chats`, { method: "POST", headers: getRequestHeaders(), body: JSON.stringify(data), }); if (!res.ok) { if (res.status === 401) { useAuthStore.getState().logout(); window.location.href = "/login"; } const errText = await res.text(); throw new Error(extractErrorMessage(errText, res.status)); } const chat = await res.json(); return { ...chat, title: chat.title ? fixMojibake(chat.title) : null, }; } export async function fetchChat(chatId: string): Promise<ChatWithMessages> { const res = await fetch(`${API_BASE}/api/v1/chats/${chatId}`, { headers: getRequestHeaders(), }); if (!res.ok) throw new Error(`Failed to fetch chat: ${res.status}`); const data = await res.json(); const fixedChat = { ...data.chat, title: data.chat?.title ? fixMojibake(data.chat.title) : null, }; const fixedMessages = data.messages?.map((m: ChatMessage) => ({ ...m, content: fixMojibake(m.content), sources: m.sources?.map((src) => ({ ...src, content: fixMojibake(src.content || ""), document_title: fixMojibake(src.document_title || ""), })) || [], })) || []; return { chat: fixedChat, messages: fixedMessages }; } export async function updateChat( chatId: string, data: Partial<Chat>, ): Promise<Chat> { const res = await fetch(`${API_BASE}/api/v1/chats/${chatId}`, { method: "PATCH", headers: getRequestHeaders(), body: JSON.stringify(data), }); if (!res.ok) throw new Error(`Failed to update chat: ${res.status}`); const chat = await res.json(); return { ...chat, title: chat.title ? fixMojibake(chat.title) : null, }; } export async function deleteChat(chatId: string): Promise<void> { const res = await fetch(`${API_BASE}/api/v1/chats/${chatId}`, { method: "DELETE", headers: getRequestHeaders(), }); if (!res.ok) throw new Error(`Failed to delete chat: ${res.status}`); } /** * SSE streaming для отправки сообщения в чат (RAG + LLM). */ export async function* streamChatMessage( chatId: string, content: string, attachments: ChatAttachment[] = [], signal?: AbortSignal, options?: { agentId?: string; flowId?: string; ragEnabled?: boolean; ragWorkspaceId?: string; }, ): AsyncGenerator<ChatEvent, void, unknown> { const url = `${API_BASE}/api/v1/chats/${chatId}/send`; const res = await fetch(url, { method: "POST", headers: getRequestHeaders({ Accept: "text/event-stream", }), body: JSON.stringify({ content, attachments, stream: true, agent_id: options?.agentId, flow_id: options?.flowId, rag_enabled: options?.ragEnabled, rag_workspace_id: options?.ragWorkspaceId, }), signal, }); if (!res.ok) { if (res.status === 401) { useAuthStore.getState().logout(); window.location.href = "/login"; return; } const errText = await res.text(); throw new Error(extractErrorMessage(errText, res.status)); } if (!res.body) { throw new Error("No response body for streaming"); } const reader = res.body.getReader(); const decoder = new TextDecoder("utf-8"); let buffer = ""; try { while (true) { const { done, value } = await reader.read(); if (done) { if (buffer.trim()) { const event = parseSSEBlock(buffer); if (event) { try { const chatEvent = JSON.parse(event.data) as ChatEvent; yield fixChatEvent(chatEvent); } catch { // skip } } } break; } buffer += decoder.decode(value, { stream: true }); const parts = buffer.split(/\r?\n\r?\n/); buffer = parts.pop() || ""; for (const eventBlock of parts) { if (!eventBlock.trim()) continue; const parsed = parseSSEBlock(eventBlock); if (parsed) { try { const chatEvent = JSON.parse(parsed.data) as ChatEvent; yield fixChatEvent(chatEvent); } catch { // skip } } } } } catch (err: unknown) { if (err instanceof Error && err.name === "AbortError") return; throw err; } finally { reader.releaseLock(); } } function parseSSEBlock(block: string): { event: string; data: string } | null { const lines = block.split(/\r?\n/); let eventName = "message"; const dataLines: string[] = []; for (const line of lines) { if (!line.trim() || line.startsWith(":")) continue; if (line.startsWith("event:")) { eventName = line.slice(6).trim(); } else if (line.startsWith("data:")) { dataLines.push(line.slice(5).trimStart()); } } if (dataLines.length === 0) return null; return { event: eventName, data: dataLines.join("\n") }; } // ============================================================================ // RAG / Knowledge Base Functions (Gateway → RAG Service) // ============================================================================ export async function ingestDocument(data: { content: string; source: string; doc_type: string; workspace_id: string; title: string; }): Promise<IngestResponse> { const normalizedDocType = [ "pdf", "txt", "markdown", "html", "docx", "url", ].includes(data.doc_type) ? data.doc_type : mimeToDocType(data.doc_type, data.source); const res = await fetch(`${API_BASE}/api/v1/rag/ingest`, { method: "POST", headers: getRequestHeaders({ "Content-Type": "application/json", }), body: JSON.stringify({ ...data, doc_type: normalizedDocType, }), }); if (!res.ok) { const errText = await res.text(); throw new Error(extractErrorMessage(errText, res.status)); } return res.json(); } export async function searchKnowledge( query: string, workspace_id: string, top_k: number = 10, ): Promise<SearchResponse> { const res = await fetch(`${API_BASE}/api/v1/rag/search`, { method: "POST", headers: getRequestHeaders(), body: JSON.stringify({ query, workspace_id, top_k }), }); if (!res.ok) { const errText = await res.text(); throw new Error(extractErrorMessage(errText, res.status)); } const data = await res.json(); return { ...data, context: fixMojibake(data.context || ""), results: data.results?.map((r: KnowledgeSource) => ({ ...r, content: fixMojibake(r.content || ""), metadata: { ...r.metadata, title: fixMojibake((r.metadata?.title as string) || ""), }, })) || [], }; } export async function listDocuments( workspace_id: string, ): Promise<DocumentInfo[]> { try { const res = await fetch( `${API_BASE}/api/v1/rag/documents?workspace_id=${encodeURIComponent(workspace_id)}`, { headers: getRequestHeaders() }, ); if (res.ok) { const documents: DocumentInfo[] = await res.json(); return documents.map((doc) => ({ ...doc, title: fixMojibake(doc.title || ""), source: fixMojibake(doc.source || ""), })); } if (res.status === 404) { return listDocumentsViaSearch(workspace_id); } const errText = await res.text(); throw new Error(extractErrorMessage(errText, res.status)); } catch (error) { console.warn( "[listDocuments] Direct endpoint failed, using fallback:", error, ); return listDocumentsViaSearch(workspace_id); } } async function listDocumentsViaSearch( workspace_id: string, ): Promise<DocumentInfo[]> { try { const response = await searchKnowledge("", workspace_id, 1000); const docsMap = new Map<string, DocumentInfo>(); for (const result of response.results) { const docId = result.document_id; if (!docId) continue; if (!docsMap.has(docId)) { docsMap.set(docId, { document_id: docId, title: fixMojibake( (result.metadata?.title as string) || result.source || "Untitled", ), source: fixMojibake(result.source || ""), doc_type: (result.metadata?.doc_type as string) || "txt", author: result.metadata?.author as string | undefined, language: (result.metadata?.language as string) || "ru", chunks_count: 0, total_chars: 0, created_at: (result.metadata?.created_at as string) || new Date().toISOString(), workspace_id, }); } const doc = docsMap.get(docId)!; doc.chunks_count += 1; doc.total_chars += (result.content || "").length; } return Array.from(docsMap.values()).sort((a, b) => { return ( new Date(b.created_at).getTime() - new Date(a.created_at).getTime() ); }); } catch (error) { console.error("[listDocumentsViaSearch] Failed:", error); return []; } } export async function deleteDocument(documentId: string): Promise<void> { const res = await fetch(`${API_BASE}/api/v1/rag/documents/${documentId}`, { method: "DELETE", headers: getRequestHeaders(), }); if (!res.ok) { const errText = await res.text(); throw new Error(extractErrorMessage(errText, res.status)); } } export async function deleteWorkspaceDocuments( workspace_id: string, ): Promise<void> { const res = await fetch(`${API_BASE}/api/v1/rag/workspaces/${workspace_id}`, { method: "DELETE", headers: getRequestHeaders(), }); if (!res.ok) { const errText = await res.text(); throw new Error(extractErrorMessage(errText, res.status)); } } export async function getRagStats(workspace_id: string): Promise<RagStats> { const res = await fetch( `${API_BASE}/api/v1/rag/stats?workspace_id=${workspace_id}`, { headers: getRequestHeaders() }, ); if (!res.ok) { const errText = await res.text(); throw new Error(extractErrorMessage(errText, res.status)); } const data = await res.json(); if (data.documents) { data.documents = data.documents.map( (doc: { title?: string; [key: string]: unknown }) => ({ ...doc, title: fixMojibake(doc.title || ""), }), ); } return data; } // ============================================================================ // Flow Functions (Gateway → Engine) // ============================================================================ export async function fetchFlows(): Promise<Flow[]> { try { const res = await fetch(`${API_BASE}/api/v1/flows`, { headers: getRequestHeaders(), }); if (!res.ok) throw new Error(`Failed to fetch flows: ${res.status}`); return res.json(); } catch (error) { console.error("[API] Failed to fetch flows:", error); return []; } } // ✅ FIX: теперь использует GET /api/v1/llm/config (плоский формат для UI) export async function fetchLLMConfig(): Promise<LLMConfig | null> { try { const res = await fetch(`${API_BASE}/api/v1/llm/config`, { headers: getRequestHeaders(), }); if (!res.ok) { console.warn("[API] Failed to fetch LLM config:", res.status); return null; } return res.json(); } catch (error) { console.error("[API] Failed to fetch LLM config:", error); return null; } } // ✅ FIX: использует POST /api/v1/flows/{flowId}/run (не /api/v1/runs) export async function runFlow( flowId: string, input: Record<string, unknown>, ): Promise<{ output?: RunOutput }> { const res = await fetch(`${API_BASE}/api/v1/flows/${flowId}/run`, { method: "POST", headers: getRequestHeaders(), body: JSON.stringify({ input, stream: false }), }); if (!res.ok) { const errText = await res.text(); throw new Error(extractErrorMessage(errText, res.status)); } return res.json(); } // ============================================================================ // Streaming Functions (Gateway → Engine) // ============================================================================ // ✅ FIX: использует POST /api/v1/flows/{flowId}/run export async function* streamFlowRun( flowId: string, input: Record<string, unknown>, signal?: AbortSignal, ): AsyncGenerator<AgentEvent, void, unknown> { const res = await fetch(`${API_BASE}/api/v1/flows/${flowId}/run`, { method: "POST", headers: getRequestHeaders({ Accept: "text/event-stream", }), body: JSON.stringify({ input, stream: true }), signal, }); if (!res.ok) { const errText = await res.text(); throw new Error(extractErrorMessage(errText, res.status)); } const contentType = res.headers.get("content-type") || ""; if (contentType.includes("application/json")) { const data = await res.json(); const output = data.output; if (output) { yield { type: "agent_start", agent: "Assistant" }; const text = output.output || output.messages ?.map( (m: { name?: string; content?: string }) => `**${m.name || "Agent"}**: ${m.content || ""}`, ) .join("\n\n") || JSON.stringify(output, null, 2); yield { type: "agent_message", content: fixMojibake(text) }; yield { type: "done" }; } return; } if (!res.body) { throw new Error("No response body for streaming"); } const reader = res.body.getReader(); const decoder = new TextDecoder("utf-8"); const parser = new SSEParser(); try { while (true) { const { done, value } = await reader.read(); if (done) { if (parser.isHtmlDetected) { const errorMsg = extractErrorMessage(parser.htmlContent, res.status); yield { type: "error", error: errorMsg }; return; } break; } const text = decoder.decode(value, { stream: true }); const events = parser.parse(text); if (parser.isHtmlDetected) { const errorMsg = extractErrorMessage(parser.htmlContent, res.status); yield { type: "error", error: errorMsg }; return; } for (const { event: eventName, data: dataStr } of events) { if (eventName === "done" || eventName === "end") { return; } try { const rawData = JSON.parse(dataStr); const engineEvent: EngineSSEEvent = { event: eventName, data: rawData, }; const adapted = adaptEngineEvent(engineEvent); for (const evt of adapted) { yield evt; } } catch (e) { console.warn("[SSE] JSON parse error:", dataStr.slice(0, 200), e); } } } } catch (err) { parser.reset(); throw err; } finally { reader.releaseLock(); } } export async function* streamLLMChat( messages: Message[], options: { model?: string; temperature?: number; max_tokens?: number; top_p?: number; } = {}, signal?: AbortSignal, ): AsyncGenerator<AgentEvent, void, unknown> { const res = await fetch(`${API_BASE}/api/v1/llm/chat`, { method: "POST", headers: getRequestHeaders({ Accept: "text/event-stream", }), body: JSON.stringify({ messages, model: options.model, temperature: options.temperature, max_tokens: options.max_tokens, top_p: options.top_p, stream: true, }), signal, }); if (!res.ok) { const errText = await res.text(); throw new Error(extractErrorMessage(errText, res.status)); } const contentType = res.headers.get("content-type") || ""; if (contentType.includes("application/json")) { const data = await res.json(); yield { type: "content", content: fixMojibake(data.content || ""), model: data.model, }; yield { type: "done", model: data.model, tokens_prompt: data.tokens_prompt || 0, tokens_completion: data.tokens_completion || 0, tokens_total: data.tokens_total || 0, }; return; } if (!res.body) { throw new Error("No response body for LLM streaming"); } const reader = res.body.getReader(); const decoder = new TextDecoder("utf-8"); const parser = new SSEParser(); try { while (true) { const { done, value } = await reader.read(); if (done) { if (parser.isHtmlDetected) { const errorMsg = extractErrorMessage(parser.htmlContent, res.status); yield { type: "error", error: errorMsg }; return; } break; } const text = decoder.decode(value, { stream: true }); const events = parser.parse(text); if (parser.isHtmlDetected) { const errorMsg = extractErrorMessage(parser.htmlContent, res.status); yield { type: "error", error: errorMsg }; return; } for (const { data: dataStr } of events) { if (dataStr === "[DONE]") { return; } try { const rawData = JSON.parse(dataStr); const adapted = adaptLLMEvent(rawData); if (adapted) { yield adapted; } } catch (e) { console.warn("[LLM SSE] JSON parse error:", dataStr.slice(0, 200), e); } } } } catch (err) { parser.reset(); throw err; } finally { reader.releaseLock(); } } // ============================================================================ // Agents API (Gateway → Engine) // ============================================================================ export async function fetchAgents(workspace_id: string): Promise<Agent[]> { try { const res = await fetch(`${API_BASE}/api/v1/agents`, { headers: getRequestHeaders(), }); if (!res.ok) throw new Error(`Failed: ${res.status}`); const agents = await res.json(); return agents.map((agent: Agent) => ({ ...agent, name: fixMojibake(agent.name || ""), description: fixMojibake(agent.description || ""), system_prompt: fixMojibake(agent.system_prompt || ""), })); } catch (err) { console.warn("[Agents] API unavailable, using fallback:", err); const stored = localStorage.getItem(`agents-${workspace_id}`); return stored ? JSON.parse(stored) : []; } } export async function createAgent(request: CreateAgentRequest): Promise<Agent> { try { const res = await fetch(`${API_BASE}/api/v1/agents`, { method: "POST", headers: getRequestHeaders(), body: JSON.stringify(request), }); if (!res.ok) { const errText = await res.text(); throw new Error(extractErrorMessage(errText, res.status)); } const agent = await res.json(); return { ...agent, name: fixMojibake(agent.name || ""), description: fixMojibake(agent.description || ""), system_prompt: fixMojibake(agent.system_prompt || ""), }; } catch (err) { console.warn( "[Agents] Create API unavailable, using localStorage fallback:", err, ); const stored = localStorage.getItem(`agents-${request.workspace_id}`); const existing: Agent[] = stored ? JSON.parse(stored) : []; const newAgent: Agent = { id: crypto.randomUUID(), ...request, status: "idle", tasks: 0, cost: "$0.00", created_at: new Date().toISOString(), updated_at: new Date().toISOString(), }; localStorage.setItem( `agents-${request.workspace_id}`, JSON.stringify([newAgent, ...existing]), ); return newAgent; } } export async function updateAgent( agentId: string, data: Partial<CreateAgentRequest>, ): Promise<Agent> { try { const res = await fetch(`${API_BASE}/api/v1/agents/${agentId}`, { method: "PATCH", headers: getRequestHeaders(), body: JSON.stringify(data), }); if (!res.ok) { const errText = await res.text(); throw new Error(extractErrorMessage(errText, res.status)); } const agent = await res.json(); return { ...agent, name: fixMojibake(agent.name || ""), description: fixMojibake(agent.description || ""), system_prompt: fixMojibake(agent.system_prompt || ""), }; } catch (err) { console.warn( "[Agents] Update API unavailable, using localStorage fallback:", err, ); const stored = localStorage.getItem( `agents-${data.workspace_id || "default"}`, ); const existing: Agent[] = stored ? JSON.parse(stored) : []; const idx = existing.findIndex((a) => a.id === agentId); if (idx !== -1) { existing[idx] = { ...existing[idx], ...data, updated_at: new Date().toISOString(), }; localStorage.setItem( `agents-${data.workspace_id || "default"}`, JSON.stringify(existing), ); return existing[idx]; } throw err; } } export async function deleteAgent( agentId: string, workspace_id: string, ): Promise<void> { try { const res = await fetch(`${API_BASE}/api/v1/agents/${agentId}`, { method: "DELETE", headers: getRequestHeaders(), }); if (!res.ok) { const errText = await res.text(); throw new Error(extractErrorMessage(errText, res.status)); } } catch (err) { console.warn( "[Agents] Delete API unavailable, using localStorage fallback:", err, ); const stored = localStorage.getItem(`agents-${workspace_id}`); if (stored) { const existing: Agent[] = JSON.parse(stored); localStorage.setItem( `agents-${workspace_id}`, JSON.stringify(existing.filter((a) => a.id !== agentId)), ); } } } export async function* runAgent( agentId: string, input: string, signal?: AbortSignal, ): AsyncGenerator<AgentEvent, void, unknown> { const res = await fetch(`${API_BASE}/api/v1/agents/${agentId}/run`, { method: "POST", headers: getRequestHeaders({ "Content-Type": "application/json", Accept: "text/event-stream", }), body: JSON.stringify({ input, stream: true, }), signal, }); if (!res.ok) { const errText = await res.text(); throw new Error(extractErrorMessage(errText, res.status)); } if (!res.body) { throw new Error("No response body for agent streaming"); } const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; try { while (true) { const { done, value } = await reader.read(); if (done) { if (buffer.trim()) { yield* parseAgentBuffer(buffer); } break; } buffer += decoder.decode(value, { stream: true }); const parts = buffer.split(/\r?\n\r?\n/); buffer = parts.pop() || ""; for (const eventBlock of parts) { if (!eventBlock.trim()) continue; yield* parseAgentBuffer(eventBlock); } } } finally { reader.releaseLock(); } } function* parseAgentBuffer( block: string, ): Generator<AgentEvent, void, unknown> { const lines = block.split(/\r?\n/); let eventName = "message"; let eventData = ""; for (const line of lines) { if (line.startsWith("event:")) { eventName = line.slice(6).trim(); } else if (line.startsWith("data:")) { eventData += line.slice(5).trimStart(); } } if (!eventData) return; try { const data = JSON.parse(eventData); switch (eventName) { case "agent_start": case "agent.started": yield { type: "agent_start", agent: data.agent_id || data.agent }; break; case "reasoning": case "content": case "agent_message": yield { type: "content", content: fixMojibake(data.content || ""), model: data.model, }; break; case "tool_call": yield { type: "tool_call", tool: data.tool_name || data.tool }; break; case "tool_result": break; case "agent_done": case "done": yield { type: "done", model: data.model, tokens_total: data.tokens || data.tokens_total, }; break; case "error": yield { type: "error", error: fixMojibake(data.error || "Unknown error"), }; break; } } catch { // skip malformed JSON } } export async function stopAgent(agentId: string): Promise<void> { try { const res = await fetch(`${API_BASE}/api/v1/agents/${agentId}/stop`, { method: "POST", headers: getRequestHeaders(), }); if (!res.ok) { const errText = await res.text(); throw new Error(extractErrorMessage(errText, res.status)); } } catch (err) { console.warn("[Agents] Stop API unavailable:", err); } } // ============================================================================ // LLM-powered analysis functions // ============================================================================ export async function generateAgentPrompt( description: string, ): Promise<string> { const metaPrompt = `Ты — мета-агент, который создаёт system_prompt для AI-агентов. Пользователь хочет создать агента со следующим описанием: "${description}" Создай профессиональный, детальный system_prompt на русском языке для этого агента. Верни ТОЛЬКО system_prompt, без пояснений.`; const res = await fetch(`${API_BASE}/api/v1/llm/chat`, { method: "POST", headers: getRequestHeaders(), body: JSON.stringify({ messages: [{ role: "user", content: metaPrompt }], temperature: 0.5, max_tokens: 8192, stream: false, }), }); if (!res.ok) { const errText = await res.text(); throw new Error(extractErrorMessage(errText, res.status)); } const data = await res.json(); return fixMojibake(data.content || data.output || ""); } export async function improveAgent(agent: Agent): Promise<string> { const metaPrompt = `Ты — мета-агент, который улучшает system_prompt AI-агентов. Текущий агент: "${agent.name}" Описание: ${agent.description} Текущий system_prompt: """ ${agent.system_prompt} """ Улучши этот system_prompt. Верни ТОЛЬКО улучшенный system_prompt, без пояснений.`; const res = await fetch(`${API_BASE}/api/v1/llm/chat`, { method: "POST", headers: getRequestHeaders(), body: JSON.stringify({ messages: [{ role: "user", content: metaPrompt }], temperature: 0.7, max_tokens: 8192, stream: false, }), }); if (!res.ok) { const errText = await res.text(); throw new Error(extractErrorMessage(errText, res.status)); } const data = await res.json(); return fixMojibake(data.content || data.output || ""); } export async function analyzeDocumentFreshness(workspace_id: string): Promise<{ documents: Array<{ document_id: string; title: string; freshness_score: number; reason: string; recommendation: "update" | "archive" | "keep"; }>; }> { const stats = await getRagStats(workspace_id); const documents = stats.documents || []; if (documents.length === 0) { return { documents: [] }; } const docList = documents .map( (d) => `- "${d.title}" (${d.chunks_count} чанков, создан: ${d.created_at})`, ) .join("\n"); const prompt = `Проанализируй список документов и оцени их свежесть (0-100). Документы: ${docList} Верни JSON массив: [{"title": "название", "freshness_score": 85, "reason": "краткое объяснение", "recommendation": "update" | "archive" | "keep"}] Верни ТОЛЬКО валидный JSON.`; const res = await fetch(`${API_BASE}/api/v1/llm/chat`, { method: "POST", headers: getRequestHeaders(), body: JSON.stringify({ messages: [{ role: "user", content: prompt }], temperature: 0.3, max_tokens: 8192, stream: false, }), }); if (!res.ok) { throw new Error(`LLM analysis failed: ${res.status}`); } const data = await res.json(); const content = fixMojibake(data.content || ""); const jsonMatch = content.match(/\[[\s\S]*\]/); if (!jsonMatch) { throw new Error("LLM не вернул валидный JSON"); } const parsed = JSON.parse(jsonMatch[0]); return { documents: parsed.map( (p: { title: string; freshness_score: number; reason: string; recommendation: string; }) => { const doc = documents.find((d) => d.title === p.title); return { document_id: doc?.document_id || `unknown-${p.title}`, title: p.title, freshness_score: p.freshness_score, reason: p.reason, recommendation: p.recommendation as "update" | "archive" | "keep", }; }, ), }; } export async function detectKnowledgeGaps(workspace_id: string): Promise<{ gaps: Array<{ id: string; title: string; description: string; severity: "critical" | "high" | "medium" | "low"; domain: string; suggested_content: string; }>; }> { const stats = await getRagStats(workspace_id); const docTitles = (stats.documents || []).map((d) => d.title).join(", "); const prompt = `Проанализируй базу знаний и выяви пробелы. Существующие документы: ${docTitles || "пусто"} Верни JSON массив (5-10 штук): [{"title": "...", "description": "...", "severity": "critical"|"high"|"medium"|"low", "domain": "...", "suggested_content": "..."}] Верни ТОЛЬКО валидный JSON.`; const res = await fetch(`${API_BASE}/api/v1/llm/chat`, { method: "POST", headers: getRequestHeaders(), body: JSON.stringify({ messages: [{ role: "user", content: prompt }], temperature: 0.5, max_tokens: 8192, stream: false, }), }); if (!res.ok) { throw new Error(`Gap detection failed: ${res.status}`); } const data = await res.json(); const content = fixMojibake(data.content || ""); const jsonMatch = content.match(/\[[\s\S]*\]/); if (!jsonMatch) { return { gaps: [] }; } const parsed = JSON.parse(jsonMatch[0]); return { gaps: parsed.map( (g: { title: string; description: string; severity: string; domain: string; suggested_content: string; }) => ({ id: `gap-${crypto.randomUUID()}`, title: g.title, description: g.description, severity: g.severity as "critical" | "high" | "medium" | "low", domain: g.domain, suggested_content: g.suggested_content, }), ), }; } export async function resolveGapWithLLM( gap: { title: string; description: string; suggested_content?: string }, workspace_id: string, ): Promise<{ document_id: string; chunks_count: number }> { const prompt = `Напиши полный технический документ на русском языке. Заголовок: ${gap.title} Описание: ${gap.description} Предложенное содержание: ${gap.suggested_content || "на твоё усмотрение"} Требования: markdown, структура, примеры, 1000-2000 слов.`; const res = await fetch(`${API_BASE}/api/v1/llm/chat`, { method: "POST", headers: getRequestHeaders(), body: JSON.stringify({ messages: [{ role: "user", content: prompt }], temperature: 0.7, max_tokens: 8192, stream: false, }), }); if (!res.ok) { throw new Error(`Gap resolution failed: ${res.status}`); } const data = await res.json(); const content = fixMojibake( data.content || `# ${gap.title}\n\n${gap.description}`, ); const ingestResult = await ingestDocument({ content, source: `${gap.title}.md`, doc_type: "markdown", workspace_id, title: gap.title, }); return { document_id: ingestResult.document_id, chunks_count: ingestResult.chunks_count, }; } // ============================================================================ // Knowledge Graph // ============================================================================ export async function getKnowledgeGraph( workspace_id: string, ): Promise<{ nodes: GraphNode[]; edges: GraphEdge[] }> { try { const res = await fetch( `${API_BASE}/api/v1/rag/graph?workspace_id=${encodeURIComponent(workspace_id)}`, { headers: getRequestHeaders() }, ); if (res.ok) { const data = await res.json(); return { nodes: data.nodes || [], edges: data.edges || [], }; } if (res.status === 404) { return getMockGraphData(); } const errText = await res.text(); throw new Error(extractErrorMessage(errText, res.status)); } catch (error) { console.warn("[getKnowledgeGraph] Failed, using mock data:", error); return getMockGraphData(); } } function getMockGraphData(): { nodes: GraphNode[]; edges: GraphEdge[] } { return { nodes: [ { id: "n1", label: "RAG", type: "concept", x: 400, y: 200, size: 28, color: "#8b5cf6", }, { id: "n2", label: "Векторный поиск", type: "concept", x: 250, y: 150, size: 22, color: "#06b6d4", }, { id: "n3", label: "Embeddings", type: "concept", x: 550, y: 150, size: 22, color: "#06b6d4", }, { id: "n4", label: "Chunking", type: "concept", x: 300, y: 300, size: 20, color: "#10b981", }, { id: "n5", label: "Документ A", type: "document", x: 150, y: 250, size: 16, color: "#f59e0b", }, { id: "n6", label: "Документ B", type: "document", x: 650, y: 250, size: 16, color: "#f59e0b", }, { id: "n7", label: "Qdrant", type: "entity", x: 400, y: 350, size: 18, color: "#ec4899", }, { id: "n8", label: "Hybrid Search", type: "decision", x: 500, y: 80, size: 20, color: "#ef4444", }, ], edges: [ { source: "n1", target: "n2", weight: 1 }, { source: "n1", target: "n3", weight: 1 }, { source: "n1", target: "n4", weight: 1 }, { source: "n2", target: "n5", weight: 1 }, { source: "n3", target: "n6", weight: 1 }, { source: "n4", target: "n7", weight: 1 }, { source: "n3", target: "n8", weight: 1 }, ], }; } // ============================================================================ // Agent Streaming (расширенные функции) // ============================================================================ /** * Стриминг запуска агента. * Используется agent-store. Endpoint: POST /api/v1/agents/{id}/run (SSE). */ export async function* runAgentStream( agentId: string, input: string, signal?: AbortSignal, ): AsyncGenerator<AgentEvent, void, unknown> { const res = await fetch(`${API_BASE}/api/v1/agents/${agentId}/run`, { method: "POST", headers: getRequestHeaders({ "Content-Type": "application/json", Accept: "text/event-stream", }), body: JSON.stringify({ input, stream: true, }), signal, }); if (!res.ok) { const errText = await res.text(); throw new Error(extractErrorMessage(errText, res.status)); } if (!res.body) { throw new Error("No response body for agent streaming"); } const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; try { while (true) { const { done, value } = await reader.read(); if (done) { if (buffer.trim()) { yield* parseAgentBuffer(buffer); } break; } buffer += decoder.decode(value, { stream: true }); const parts = buffer.split(/\r?\n\r?\n/); buffer = parts.pop() || ""; for (const eventBlock of parts) { if (!eventBlock.trim()) continue; yield* parseAgentBuffer(eventBlock); } } } finally { reader.releaseLock(); } } /** * Обычный (не-стриминг) запуск агента. */ export async function runAgentWithInput( agentId: string, input: string, signal?: AbortSignal, ): Promise<{ agent_id: string; status: string; output: string; tokens: number; model: string; error?: string; }> { const res = await fetch(`${API_BASE}/api/v1/agents/${agentId}/run`, { method: "POST", headers: getRequestHeaders({ "Content-Type": "application/json", }), body: JSON.stringify({ input, stream: false, }), signal, }); if (!res.ok) { const errText = await res.text(); throw new Error(extractErrorMessage(errText, res.status)); } const data = await res.json(); return { agent_id: data.agent_id || agentId, status: data.status || "completed", output: fixMojibake(data.output || ""), tokens: data.tokens || 0, model: data.model || "", error: data.error, }; } // ============================================================================ // Knowledge Base — LLM-powered regeneration // ============================================================================ /** * Обновление документа через LLM (стриминг). * Используется KnowledgeFreshness.tsx. */ export async function* regenerateDocumentWithLLM( documentId: string, workspace_id: string, signal?: AbortSignal, ): AsyncGenerator< { type: "content" | "done" | "error"; content?: string; error?: string }, void, unknown > { const search = await searchKnowledge("", workspace_id, 100); const docChunks = search.results.filter((r) => r.document_id === documentId); const currentContent = docChunks.map((c) => c.content).join("\n\n---\n\n"); const prompt = `Обнови и улучши следующий документ, сделай его более актуальным и структурированным. Текущее содержимое: """ ${currentContent || "Документ пуст"} """ Верни улучшенную версию в markdown формате.`; const res = await fetch(`${API_BASE}/api/v1/llm/chat`, { method: "POST", headers: getRequestHeaders({ Accept: "text/event-stream" }), body: JSON.stringify({ messages: [{ role: "user", content: prompt }], temperature: 0.5, max_tokens: 8192, stream: true, }), signal, }); if (!res.ok) { throw new Error(`LLM regeneration failed: ${res.status}`); } if (!res.body) { throw new Error("No response body"); } const reader = res.body.getReader(); const decoder = new TextDecoder("utf-8"); let buffer = ""; try { while (true) { const { done, value } = await reader.read(); if (done) { if (buffer.trim()) { yield* parseLLMStreamBuffer(buffer); } break; } buffer += decoder.decode(value, { stream: true }); const parts = buffer.split(/\r?\n\r?\n/); buffer = parts.pop() || ""; for (const block of parts) { yield* parseLLMStreamBuffer(block); } } yield { type: "done" }; } finally { reader.releaseLock(); } } function* parseLLMStreamBuffer( block: string, ): Generator< { type: "content" | "error"; content?: string; error?: string }, void, unknown > { if (!block.trim()) return; const lines = block.split(/\r?\n/); let eventData = ""; for (const line of lines) { if (line.startsWith("data:")) eventData += line.slice(5).trimStart(); } if (!eventData || eventData === "[DONE]") return; try { const data = JSON.parse(eventData); if (data.content) yield { type: "content", content: fixMojibake(data.content) }; if (data.error) yield { type: "error", error: fixMojibake(data.error) }; } catch { // skip } } // ============================================================================ // Utils // ============================================================================ export function mimeToDocType(mime: string, filename: string): string { const ext = filename.toLowerCase().split(".").pop(); switch (ext) { case "pdf": return "pdf"; case "txt": return "txt"; case "md": case "markdown": return "markdown"; case "html": case "htm": return "html"; case "doc": case "docx": return "docx"; } if (mime.includes("pdf")) return "pdf"; if (mime.includes("markdown") || mime === "text/markdown") return "markdown"; if (mime.includes("html")) return "html"; if (mime.includes("word") || mime.includes("docx") || mime.includes("doc")) return "docx"; if (mime.startsWith("text/")) return "txt"; return "txt"; }