custom models

This commit is contained in:
2026-06-10 21:05:04 +10:00
parent fadcc460e2
commit 6a731c7a75
5 changed files with 138 additions and 42 deletions

19
package-lock.json generated
View File

@@ -18,6 +18,7 @@
"@electron-toolkit/preload": "^3.0.0", "@electron-toolkit/preload": "^3.0.0",
"@electron-toolkit/utils": "^3.0.0", "@electron-toolkit/utils": "^3.0.0",
"marked": "^17.0.3", "marked": "^17.0.3",
"openai": "^6.42.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"zustand": "^4.5.0" "zustand": "^4.5.0"
@@ -5018,6 +5019,24 @@
"wrappy": "1" "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": { "node_modules/p-cancelable": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",

View File

@@ -35,6 +35,7 @@
"@electron-toolkit/preload": "^3.0.0", "@electron-toolkit/preload": "^3.0.0",
"@electron-toolkit/utils": "^3.0.0", "@electron-toolkit/utils": "^3.0.0",
"marked": "^17.0.3", "marked": "^17.0.3",
"openai": "^6.42.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"zustand": "^4.5.0" "zustand": "^4.5.0"
@@ -45,17 +46,43 @@
"icon": "resources/icon.png", "icon": "resources/icon.png",
"mac": { "mac": {
"icon": "resources/icon.icns", "icon": "resources/icon.icns",
"target": [{ "target": "dmg", "arch": "universal" }] "target": [
{
"target": "dmg",
"arch": "universal"
}
]
}, },
"win": { "win": {
"icon": "resources/icon.ico", "icon": "resources/icon.ico",
"target": [{ "target": "nsis", "arch": ["x64"] }] "target": [
{
"target": "nsis",
"arch": [
"x64"
]
}
]
}, },
"linux": { "linux": {
"icon": "resources/icon.png", "icon": "resources/icon.png",
"target": [{ "target": "AppImage", "arch": ["x64"] }] "target": [
{
"target": "AppImage",
"arch": [
"x64"
]
}
]
}, },
"files": ["out/**/*"], "files": [
"extraResources": [{ "from": "resources/icon.png", "to": "icon.png" }] "out/**/*"
],
"extraResources": [
{
"from": "resources/icon.png",
"to": "icon.png"
}
]
} }
} }

View File

@@ -1,4 +1,5 @@
import Anthropic from '@anthropic-ai/sdk' import Anthropic from '@anthropic-ai/sdk'
import OpenAI from 'openai'
import { getApiKey, getAIConfig } from './globalConfig' import { getApiKey, getAIConfig } from './globalConfig'
import type { Market } from './fileSystem' import type { Market } from './fileSystem'
@@ -20,22 +21,41 @@ export interface AIPayload {
const DEFAULT_MODEL = 'claude-sonnet-4-6' const DEFAULT_MODEL = 'claude-sonnet-4-6'
const DEFAULT_PROMPT_MODEL = 'claude-haiku-4-5-20251001' 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 { export function resetClient(): void {
_client = null _anthropic = null
_openai = null
} }
function getClient(): Anthropic { function isCustomProvider(): boolean {
if (!_client) { 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() const apiKey = getApiKey()
if (!apiKey || apiKey === 'your-api-key-here') { if (!apiKey || apiKey === 'your-api-key-here') {
throw new Error('API key not set. Open Borges → Preferences to configure it.') throw new Error('API key not set. Open Borges → Preferences to configure it.')
} }
const { baseURL } = getAIConfig() 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 { function buildSystemPrompt(payload: AIPayload): string {
@@ -100,15 +120,27 @@ Be candid. If the fit is poor, say so plainly.`
} }
export async function streamPrompt(onChunk: (chunk: string) => void): Promise<void> { export async function streamPrompt(onChunk: (chunk: string) => void): Promise<void> {
const client = getClient()
const { promptModel } = getAIConfig() const { promptModel } = getAIConfig()
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({ const stream = client.messages.stream({
model: promptModel ?? DEFAULT_PROMPT_MODEL, model: promptModel ?? DEFAULT_PROMPT_MODEL,
max_tokens: 120, max_tokens: 120,
messages: [{ messages: [{ role: 'user', content: promptText }]
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) { for await (const chunk of stream) {
if (chunk.type === 'content_block_delta' && chunk.delta.type === 'text_delta') { if (chunk.type === 'content_block_delta' && chunk.delta.type === 'text_delta') {
@@ -116,32 +148,45 @@ export async function streamPrompt(onChunk: (chunk: string) => void): Promise<vo
} }
} }
await stream.finalMessage() await stream.finalMessage()
}
} }
export async function streamMessage( export async function streamMessage(
payload: AIPayload, payload: AIPayload,
onChunk: (chunk: string) => void onChunk: (chunk: string) => void
): Promise<void> { ): Promise<void> {
const client = getClient()
const { model } = getAIConfig() const { model } = getAIConfig()
const system = buildSystemPrompt(payload)
const messages: Anthropic.MessageParam[] = [ const messages = [
...payload.conversationHistory.slice(-10), ...payload.conversationHistory.slice(-10),
{ role: 'user', content: payload.userMessage } { role: 'user' as const, content: payload.userMessage }
] ]
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({ const stream = client.messages.stream({
model: model ?? DEFAULT_MODEL, model: model ?? DEFAULT_MODEL,
max_tokens: 4096, max_tokens: 4096,
system: buildSystemPrompt(payload), system,
messages messages
}) })
for await (const chunk of stream) { for await (const chunk of stream) {
if (chunk.type === 'content_block_delta' && chunk.delta.type === 'text_delta') { if (chunk.type === 'content_block_delta' && chunk.delta.type === 'text_delta') {
onChunk(chunk.delta.text) onChunk(chunk.delta.text)
} }
} }
await stream.finalMessage() await stream.finalMessage()
}
} }

View File

@@ -74,7 +74,9 @@ function buildAppMenu(win: BrowserWindow): void {
{ type: 'separator' }, { type: 'separator' },
{ label: 'Increase Font Size', accelerator: 'CmdOrCtrl+=', click: () => send(win, 'fontIncrease') }, { label: 'Increase Font Size', accelerator: 'CmdOrCtrl+=', click: () => send(win, 'fontIncrease') },
{ label: 'Decrease Font Size', accelerator: 'CmdOrCtrl+-', click: () => send(win, 'fontDecrease') }, { 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() }
] ]
}, },

View File

@@ -138,7 +138,10 @@ export function registerIpcHandlers(): void {
flush() flush()
if (!event.sender.isDestroyed()) event.sender.send('ai:done') if (!event.sender.isDestroyed()) event.sender.send('ai:done')
} catch (err) { } 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) if (!event.sender.isDestroyed()) event.sender.send('ai:error', message)
} }
}) })