diff --git a/package-lock.json b/package-lock.json index ce2fc0a..4b53154 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@electron-toolkit/preload": "^3.0.0", "@electron-toolkit/utils": "^3.0.0", "marked": "^17.0.3", + "openai": "^6.42.0", "react": "^18.2.0", "react-dom": "^18.2.0", "zustand": "^4.5.0" @@ -5018,6 +5019,24 @@ "wrappy": "1" } }, + "node_modules/openai": { + "version": "6.42.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.42.0.tgz", + "integrity": "sha512-1WFEt/uXMXOLhYRNkgJWo08Y2YNvNwpVU72K7ibrWgWpNOXd4VojXLbe6SQ4bLiUQ3Y8jz4IiyVkylJCL1DtZg==", + "license": "Apache-2.0", + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/p-cancelable": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", diff --git a/package.json b/package.json index e038050..e0ac35b 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@electron-toolkit/preload": "^3.0.0", "@electron-toolkit/utils": "^3.0.0", "marked": "^17.0.3", + "openai": "^6.42.0", "react": "^18.2.0", "react-dom": "^18.2.0", "zustand": "^4.5.0" @@ -45,17 +46,43 @@ "icon": "resources/icon.png", "mac": { "icon": "resources/icon.icns", - "target": [{ "target": "dmg", "arch": "universal" }] + "target": [ + { + "target": "dmg", + "arch": "universal" + } + ] }, "win": { "icon": "resources/icon.ico", - "target": [{ "target": "nsis", "arch": ["x64"] }] + "target": [ + { + "target": "nsis", + "arch": [ + "x64" + ] + } + ] }, "linux": { "icon": "resources/icon.png", - "target": [{ "target": "AppImage", "arch": ["x64"] }] + "target": [ + { + "target": "AppImage", + "arch": [ + "x64" + ] + } + ] }, - "files": ["out/**/*"], - "extraResources": [{ "from": "resources/icon.png", "to": "icon.png" }] + "files": [ + "out/**/*" + ], + "extraResources": [ + { + "from": "resources/icon.png", + "to": "icon.png" + } + ] } } diff --git a/src/main/aiService.ts b/src/main/aiService.ts index 009c988..1dd0e94 100644 --- a/src/main/aiService.ts +++ b/src/main/aiService.ts @@ -1,4 +1,5 @@ import Anthropic from '@anthropic-ai/sdk' +import OpenAI from 'openai' import { getApiKey, getAIConfig } from './globalConfig' import type { Market } from './fileSystem' @@ -20,22 +21,41 @@ export interface AIPayload { const DEFAULT_MODEL = 'claude-sonnet-4-6' const DEFAULT_PROMPT_MODEL = 'claude-haiku-4-5-20251001' -let _client: Anthropic | null = null +let _anthropic: Anthropic | null = null +let _openai: OpenAI | null = null export function resetClient(): void { - _client = null + _anthropic = null + _openai = null } -function getClient(): Anthropic { - if (!_client) { +function isCustomProvider(): boolean { + return !!getAIConfig().baseURL +} + +function getAnthropicClient(): Anthropic { + if (!_anthropic) { + const apiKey = getApiKey() + if (!apiKey || apiKey === 'your-api-key-here') { + throw new Error('API key not set. Open Borges → Preferences to configure it.') + } + console.log('[aiService] provider: Anthropic (default)') + _anthropic = new Anthropic({ apiKey }) + } + return _anthropic +} + +function getOpenAIClient(): OpenAI { + if (!_openai) { const apiKey = getApiKey() if (!apiKey || apiKey === 'your-api-key-here') { throw new Error('API key not set. Open Borges → Preferences to configure it.') } const { baseURL } = getAIConfig() - _client = new Anthropic({ apiKey, ...(baseURL ? { baseURL } : {}) }) + console.log('[aiService] provider: OpenAI-compatible, baseURL:', baseURL) + _openai = new OpenAI({ apiKey, baseURL }) } - return _client + return _openai } function buildSystemPrompt(payload: AIPayload): string { @@ -100,48 +120,73 @@ Be candid. If the fit is poor, say so plainly.` } export async function streamPrompt(onChunk: (chunk: string) => void): Promise { - const client = getClient() const { promptModel } = getAIConfig() - const stream = client.messages.stream({ - model: promptModel ?? DEFAULT_PROMPT_MODEL, - max_tokens: 120, - messages: [{ - role: 'user', - content: 'Generate a single flash fiction writing prompt in one sentence (under 25 words). Be specific and evocative — give a concrete situation, image, or constraint. No preamble, no label, just the prompt itself.' - }] - }) - for await (const chunk of stream) { - if (chunk.type === 'content_block_delta' && chunk.delta.type === 'text_delta') { - onChunk(chunk.delta.text) + const promptText = 'Generate a single flash fiction writing prompt in one sentence (under 25 words). Be specific and evocative — give a concrete situation, image, or constraint. No preamble, no label, just the prompt itself.' + + if (isCustomProvider()) { + const client = getOpenAIClient() + const stream = await client.chat.completions.create({ + model: promptModel ?? 'default', + max_tokens: 120, + stream: true, + messages: [{ role: 'user', content: promptText }] + }) + for await (const chunk of stream) { + const text = chunk.choices[0]?.delta?.content + if (text) onChunk(text) } + } else { + const client = getAnthropicClient() + const stream = client.messages.stream({ + model: promptModel ?? DEFAULT_PROMPT_MODEL, + max_tokens: 120, + messages: [{ role: 'user', content: promptText }] + }) + for await (const chunk of stream) { + if (chunk.type === 'content_block_delta' && chunk.delta.type === 'text_delta') { + onChunk(chunk.delta.text) + } + } + await stream.finalMessage() } - await stream.finalMessage() } export async function streamMessage( payload: AIPayload, onChunk: (chunk: string) => void ): Promise { - const client = getClient() const { model } = getAIConfig() - - const messages: Anthropic.MessageParam[] = [ + const system = buildSystemPrompt(payload) + const messages = [ ...payload.conversationHistory.slice(-10), - { role: 'user', content: payload.userMessage } + { role: 'user' as const, content: payload.userMessage } ] - const stream = client.messages.stream({ - model: model ?? DEFAULT_MODEL, - max_tokens: 4096, - system: buildSystemPrompt(payload), - messages - }) - - for await (const chunk of stream) { - if (chunk.type === 'content_block_delta' && chunk.delta.type === 'text_delta') { - onChunk(chunk.delta.text) + if (isCustomProvider()) { + const client = getOpenAIClient() + const stream = await client.chat.completions.create({ + model: model ?? 'default', + max_tokens: 4096, + stream: true, + messages: [{ role: 'system', content: system }, ...messages] + }) + for await (const chunk of stream) { + const text = chunk.choices[0]?.delta?.content + if (text) onChunk(text) } + } else { + const client = getAnthropicClient() + const stream = client.messages.stream({ + model: model ?? DEFAULT_MODEL, + max_tokens: 4096, + system, + messages + }) + for await (const chunk of stream) { + if (chunk.type === 'content_block_delta' && chunk.delta.type === 'text_delta') { + onChunk(chunk.delta.text) + } + } + await stream.finalMessage() } - - await stream.finalMessage() } diff --git a/src/main/index.ts b/src/main/index.ts index 1dc09cb..4348a0e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -74,7 +74,9 @@ function buildAppMenu(win: BrowserWindow): void { { type: 'separator' }, { label: 'Increase Font Size', accelerator: 'CmdOrCtrl+=', click: () => send(win, 'fontIncrease') }, { label: 'Decrease Font Size', accelerator: 'CmdOrCtrl+-', click: () => send(win, 'fontDecrease') }, - { label: 'Reset Font Size', accelerator: 'CmdOrCtrl+0', click: () => send(win, 'fontReset') } + { label: 'Reset Font Size', accelerator: 'CmdOrCtrl+0', click: () => send(win, 'fontReset') }, + { type: 'separator' }, + { label: 'Toggle Developer Tools', accelerator: 'CmdOrCtrl+Option+I', click: () => win.webContents.toggleDevTools() } ] }, diff --git a/src/main/ipcHandlers.ts b/src/main/ipcHandlers.ts index aaad20c..bcf4e70 100644 --- a/src/main/ipcHandlers.ts +++ b/src/main/ipcHandlers.ts @@ -138,7 +138,10 @@ export function registerIpcHandlers(): void { flush() if (!event.sender.isDestroyed()) event.sender.send('ai:done') } catch (err) { - const message = err instanceof Error ? err.message : String(err) + console.error('[ai:streamMessage] error:', err) + const message = err instanceof Error + ? `${err.message}${(err as NodeJS.ErrnoException & { status?: number; url?: string }).status ? ` (HTTP ${(err as NodeJS.ErrnoException & { status?: number }).status})` : ''}` + : String(err) if (!event.sender.isDestroyed()) event.sender.send('ai:error', message) } })