✨ 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/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",
|
||||||
|
|||||||
37
package.json
37
package.json
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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') {
|
||||||
@@ -117,31 +149,44 @@ 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()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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() }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user