✨ custom models
This commit is contained in:
19
package-lock.json
generated
19
package-lock.json
generated
@@ -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",
|
||||
|
||||
37
package.json
37
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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
]
|
||||
},
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user