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/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",

View File

@@ -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"
}
]
}
}

View File

@@ -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<void> {
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<void> {
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()
}

View File

@@ -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() }
]
},

View File

@@ -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)
}
})