diff --git a/packages/app/src/adapters/claude-adapter.ts b/packages/app/src/adapters/claude-adapter.ts new file mode 100644 index 00000000..c65c3ee9 --- /dev/null +++ b/packages/app/src/adapters/claude-adapter.ts @@ -0,0 +1,106 @@ +import type { AIAdapter, ChatMessage, ChatOptions } from './types' +import { getApiKey } from '@/utils/key-vault' + +const CLAUDE_PATH = '/api/claude/v1/messages' + +export const claudeAdapter: AIAdapter = { + id: 'claude', + name: 'Claude (Max)', + supportsStreaming: true, + supportsVision: true, + supportsTools: true, + + models() { + return [ + { id: 'claude-haiku-4.5', name: 'Claude 4.5 Haiku' }, + { id: 'claude-sonnet-4', name: 'Claude Sonnet 4' }, + { id: 'claude-opus-4', name: 'Claude Opus 4' }, + ] + }, + + async chat(messages, options, onToken, onError) { + const headers: Record = { 'Content-Type': 'application/json' } + + const vaultKey = await getApiKey('claude') + if (vaultKey) { + headers['x-api-key'] = vaultKey + } + + const apiMessages = messages + .filter(m => m.role !== 'system') + .map(m => ({ role: m.role, content: m.content })) + + const res = await fetch(CLAUDE_PATH, { + method: 'POST', + headers, + body: JSON.stringify({ + model: options.model, + system: options.systemPrompt, + messages: apiMessages, + stream: true, + webSearch: options.webSearch ?? false, + }), + signal: options.signal, + }) + + if (!res.ok) { + const body = await res.text().catch(() => 'Could not read error body') + onError(`Claude proxy error ${res.status}: ${body}`) + return + } + + await readSSE(res, (data) => { + const parsed = JSON.parse(data) + if (parsed.type === 'content_block_delta' && parsed.delta?.text) { + onToken(parsed.delta.text) + } else if (parsed.type === 'error') { + onError(parsed.error?.message ?? 'Claude stream error') + } + }, onError, options.signal) + }, +} + +async function readSSE( + res: Response, + onData: (data: string) => void, + onError: (err: string) => void, + signal?: AbortSignal, +): Promise { + const reader = res.body?.getReader() + if (!reader) { + onError('No response body') + return + } + + const decoder = new TextDecoder() + let buffer = '' + + try { + while (true) { + if (signal?.aborted) { + reader.cancel() + return + } + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed || !trimmed.startsWith('data: ')) continue + const payload = trimmed.slice(6) + if (payload === '[DONE]') return + try { + onData(payload) + } catch { + // skip malformed chunks + } + } + } + } finally { + reader.cancel().catch(() => {}) + } +} diff --git a/packages/app/src/adapters/ollama-adapter.ts b/packages/app/src/adapters/ollama-adapter.ts new file mode 100644 index 00000000..2aadc1d6 --- /dev/null +++ b/packages/app/src/adapters/ollama-adapter.ts @@ -0,0 +1,94 @@ +import type { AIAdapter, ChatOptions } from './types' + +const OLLAMA_BASE = 'http://localhost:11434' + +export const ollamaAdapter: AIAdapter = { + id: 'ollama', + name: 'Ollama (Local)', + supportsStreaming: true, + supportsVision: false, + supportsTools: false, + + models() { + return [ + { id: 'llama3.2', name: 'Llama 3.2' }, + { id: 'mistral', name: 'Mistral' }, + { id: 'gemma2', name: 'Gemma 2' }, + { id: 'qwen2.5', name: 'Qwen 2.5' }, + ] + }, + + async chat(messages, options, onToken, onError) { + const ollamaMessages = messages.map(m => ({ + role: m.role, + content: m.content, + })) + + if (options.systemPrompt) { + ollamaMessages.unshift({ role: 'system', content: options.systemPrompt }) + } + + let res: Response + try { + res = await fetch(`${OLLAMA_BASE}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: options.model, + messages: ollamaMessages, + stream: true, + }), + signal: options.signal, + }) + } catch { + onError('Cannot connect to Ollama. Is it running on localhost:11434?') + return + } + + if (!res.ok) { + const body = await res.text().catch(() => 'Could not read error body') + onError(`Ollama error ${res.status}: ${body}`) + return + } + + // Ollama uses newline-delimited JSON (not SSE) + const reader = res.body?.getReader() + if (!reader) { + onError('No response body') + return + } + + const decoder = new TextDecoder() + let buffer = '' + + try { + while (true) { + if (options.signal?.aborted) { + reader.cancel() + return + } + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + + for (const line of lines) { + if (!line.trim()) continue + try { + const parsed = JSON.parse(line) + if (parsed.message?.content) { + onToken(parsed.message.content) + } + if (parsed.done) return + } catch { + // skip malformed lines + } + } + } + } finally { + reader.cancel().catch(() => {}) + } + }, +} diff --git a/packages/app/src/adapters/openrouter-adapter.ts b/packages/app/src/adapters/openrouter-adapter.ts new file mode 100644 index 00000000..ac2ee05b --- /dev/null +++ b/packages/app/src/adapters/openrouter-adapter.ts @@ -0,0 +1,113 @@ +import type { AIAdapter, ChatOptions } from './types' +import { getApiKey } from '@/utils/key-vault' + +const OPENROUTER_PATH = '/api/openrouter' + +export const openrouterAdapter: AIAdapter = { + id: 'openrouter', + name: 'OpenRouter', + supportsStreaming: true, + supportsVision: false, + supportsTools: false, + + models() { + return [ + { id: 'meta-llama/llama-4-maverick', name: 'Llama 4 Maverick' }, + { id: 'qwen/qwen3-235b-a22b-thinking-2507', name: 'Qwen3 235B Thinking' }, + { id: 'mistralai/mistral-small-3.1-24b-instruct:free', name: 'Mistral Small 3.1 (free)' }, + { id: 'google/gemma-3-27b-it:free', name: 'Gemma 3 27B (free)' }, + ] + }, + + async chat(messages, options, onToken, onError) { + const orMessages = messages.map(m => ({ + role: m.role as 'user' | 'assistant' | 'system', + content: m.content, + })) + + // Prepend system prompt as system message + if (options.systemPrompt) { + orMessages.unshift({ role: 'system', content: options.systemPrompt }) + } + + const headers: Record = { + 'Content-Type': 'application/json', + 'HTTP-Referer': window.location.origin, + 'X-Title': 'AIUI', + } + + const vaultKey = await getApiKey('openrouter') + if (vaultKey) { + headers['Authorization'] = `Bearer ${vaultKey}` + } + + const res = await fetch(OPENROUTER_PATH, { + method: 'POST', + headers, + body: JSON.stringify({ + model: options.model, + messages: orMessages, + stream: true, + }), + signal: options.signal, + }) + + if (!res.ok) { + const body = await res.text().catch(() => 'Could not read error body') + onError(`OpenRouter error ${res.status}: ${body}`) + return + } + + await readSSE(res, (data) => { + if (data === '[DONE]') return + const parsed = JSON.parse(data) + const delta = parsed.choices?.[0]?.delta?.content + if (delta) onToken(delta) + }, onError, options.signal) + }, +} + +async function readSSE( + res: Response, + onData: (data: string) => void, + onError: (err: string) => void, + signal?: AbortSignal, +): Promise { + const reader = res.body?.getReader() + if (!reader) { + onError('No response body') + return + } + + const decoder = new TextDecoder() + let buffer = '' + + try { + while (true) { + if (signal?.aborted) { + reader.cancel() + return + } + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed || !trimmed.startsWith('data: ')) continue + const payload = trimmed.slice(6) + if (payload === '[DONE]') return + try { + onData(payload) + } catch { + // skip malformed chunks + } + } + } + } finally { + reader.cancel().catch(() => {}) + } +} diff --git a/packages/app/src/adapters/types.ts b/packages/app/src/adapters/types.ts new file mode 100644 index 00000000..7f33a839 --- /dev/null +++ b/packages/app/src/adapters/types.ts @@ -0,0 +1,34 @@ +/** + * Unified AI adapter interface for normalizing different provider APIs. + */ + +export interface ChatMessage { + role: 'user' | 'assistant' | 'system' + content: string +} + +export interface ChatOptions { + model: string + systemPrompt?: string + webSearch?: boolean + signal?: AbortSignal +} + +export interface AIAdapter { + id: string + name: string + supportsStreaming: boolean + supportsVision: boolean + supportsTools: boolean + + /** List available models for this adapter */ + models(): { id: string; name: string }[] + + /** Stream chat completion, yielding text tokens */ + chat( + messages: ChatMessage[], + options: ChatOptions, + onToken: (text: string) => void, + onError: (err: string) => void, + ): Promise +}