diff --git a/packages/app/src/main.ts b/packages/app/src/main.ts index 469165ee..fbd921aa 100644 --- a/packages/app/src/main.ts +++ b/packages/app/src/main.ts @@ -3,6 +3,7 @@ import { createPinia } from 'pinia' import { createRouter, createWebHistory } from 'vue-router' import App from './App.vue' import './styles/main.css' +import { initializePlugins } from './plugins' const router = createRouter({ history: createWebHistory(), @@ -23,4 +24,9 @@ const router = createRouter({ const app = createApp(App) app.use(createPinia()) app.use(router) + +initializePlugins().catch((err) => { + console.error('[AIUI] Failed to initialize plugins:', err) +}) + app.mount('#app') diff --git a/packages/app/src/plugins/claude-provider.ts b/packages/app/src/plugins/claude-provider.ts new file mode 100644 index 00000000..7a627403 --- /dev/null +++ b/packages/app/src/plugins/claude-provider.ts @@ -0,0 +1,161 @@ +import type { + AIProviderAdapter, + AIModel, + ChatMessage, + ChatOptions, + ChatChunk, + PluginContext, +} from '@aiui/core' + +const CLAUDE_PATH = '/api/claude/v1/messages' + +async function* streamChat( + messages: ChatMessage[], + options: ChatOptions, + systemPrompt?: string, +): AsyncGenerator { + const body: Record = { + model: options.model, + messages: messages.filter(m => m.role !== 'system').map(m => ({ + role: m.role, + content: typeof m.content === 'string' ? m.content : m.content.map(p => p.text ?? '').join(''), + })), + stream: options.stream ?? true, + } + + // Extract system message from messages or use provided systemPrompt + const systemMsg = messages.find(m => m.role === 'system') + if (systemPrompt) { + body.system = systemPrompt + } else if (systemMsg) { + body.system = typeof systemMsg.content === 'string' + ? systemMsg.content + : systemMsg.content.map(p => p.text ?? '').join('') + } + + if (options.maxTokens) body.max_tokens = options.maxTokens + if (options.temperature !== undefined) body.temperature = options.temperature + + const res = await fetch(CLAUDE_PATH, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + + if (!res.ok) { + const errBody = await res.text().catch(() => 'Could not read error body') + yield { type: 'error', error: `Claude proxy error ${res.status}: ${errBody}` } + return + } + + const reader = res.body?.getReader() + if (!reader) { + yield { type: 'error', error: 'No response body' } + return + } + + const decoder = new TextDecoder() + let buffer = '' + + try { + while (true) { + 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]') { + yield { type: 'done' } + return + } + try { + const parsed = JSON.parse(payload) + if (parsed.type === 'content_block_delta' && parsed.delta?.text) { + yield { type: 'text', text: parsed.delta.text } + } else if (parsed.type === 'message_stop') { + yield { type: 'done', usage: parsed.usage ? { + promptTokens: parsed.usage.input_tokens ?? 0, + completionTokens: parsed.usage.output_tokens ?? 0, + } : undefined } + } else if (parsed.type === 'error') { + yield { type: 'error', error: parsed.error?.message ?? 'Claude stream error' } + } + } catch { + // skip malformed chunks + } + } + } + } finally { + reader.cancel().catch(() => {}) + } + + yield { type: 'done' } +} + +export const claudeProvider: AIProviderAdapter = { + id: 'claude', + name: 'Claude (Anthropic)', + version: '1.0.0', + type: 'ai-provider', + description: 'Anthropic Claude AI via proxy server', + + supportsStreaming: true, + supportsVision: true, + supportsTools: true, + + async init(_context: PluginContext): Promise { + // No initialization needed — uses proxy + }, + + async destroy(): Promise { + // No cleanup needed + }, + + async isAvailable(): Promise { + try { + const res = await fetch(CLAUDE_PATH, { method: 'OPTIONS' }) + return res.ok || res.status === 405 // OPTIONS may not be supported but proxy is up + } catch { + return false + } + }, + + chat(messages: ChatMessage[], options: ChatOptions): AsyncIterable { + return streamChat(messages, options) + }, + + async models(): Promise { + return [ + { + id: 'claude-haiku-4.5', + name: 'Claude 4.5 Haiku', + provider: 'claude', + supportsVision: true, + supportsTools: true, + contextWindow: 200000, + }, + { + id: 'claude-sonnet-4', + name: 'Claude Sonnet 4', + provider: 'claude', + supportsVision: true, + supportsTools: true, + contextWindow: 200000, + }, + { + id: 'claude-opus-4', + name: 'Claude Opus 4', + provider: 'claude', + supportsVision: true, + supportsTools: true, + contextWindow: 200000, + }, + ] + }, +} diff --git a/packages/app/src/plugins/index.ts b/packages/app/src/plugins/index.ts new file mode 100644 index 00000000..c73669c2 --- /dev/null +++ b/packages/app/src/plugins/index.ts @@ -0,0 +1,26 @@ +import { registerPlugin } from '@aiui/core' + +export async function initializePlugins(): Promise { + // Register built-in AI provider + const { claudeProvider } = await import('./claude-provider') + registerPlugin(claudeProvider) + await claudeProvider.init({ + settings: { + get: () => undefined, + set: () => {}, + }, + events: { + emit: () => {}, + on: () => () => {}, + }, + logger: { + info: console.info.bind(console, '[AIUI plugin]'), + warn: console.warn.bind(console, '[AIUI plugin]'), + error: console.error.bind(console, '[AIUI plugin]'), + }, + }) + + if (import.meta.env.DEV) { + console.log('[AIUI] Plugins initialized:', claudeProvider.id) + } +}