feat: complete WorkBuddy parity and Windows release workflow
This commit is contained in:
1 parent
143516b3a8
commit
d5512df549
434 files changed
+54977
-1854
No files matched your search
+100
-44
@@ -3,8 +3,12 @@ import { app, safeStorage } from 'electron'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { CustomModelInput, CustomModelProfile, ModelRegistryView, ModelTestResult, ResolvedModel } from '@shared/models'
|
||||
import type { CustomModelInput, CustomModelProfile, ModelRegistryView, ModelTestResult, ResolvedModel, ModelDiscoveryInput, ModelDiscoveryResult, ModelEndpoint } from '@shared/models'
|
||||
import { resolveOpenAIEndpoint } from '@shared/openai-endpoint'
|
||||
import { apiProtocol, endpointCandidates, endpointUrl, inferModelApi, modelEndpoint, modelHeaders, normalizeModelAddress, openAICompletionTokenField } from '@shared/model-endpoint'
|
||||
import { cachedConnection, cachedGeneration, discoverModelConnection, modelFetch, readModelJson, rememberGeneration } from './model-connection'
|
||||
import { modelMetadata } from './model-metadata'
|
||||
import { resolveModelTokenLimits } from '@shared/model-token-limits'
|
||||
import { getGatewayConfig } from './config'
|
||||
|
||||
interface StoredModel extends Omit<CustomModelProfile, 'hasApiKey' | 'keyStorage'> {
|
||||
@@ -44,7 +48,8 @@ function writeKey(model: StoredModel, key: string): void {
|
||||
function viewOf(model: StoredModel): CustomModelProfile {
|
||||
const { apiKeyEnc: _encrypted, apiKeyPlain: _plain, ...profile } = model
|
||||
const { key, storage } = readKey(model)
|
||||
return { ...profile, supportedEfforts: profile.supportedEfforts?.slice(), hasApiKey: !!key, keyStorage: key ? storage : 'none' }
|
||||
const { error, compaction: _compaction, ...budget } = resolveModelTokenLimits(profile)
|
||||
return { ...profile, ...(!error ? budget : {}), supportedEfforts: profile.supportedEfforts?.slice(), hasApiKey: !!key, keyStorage: key ? storage : 'none' }
|
||||
}
|
||||
|
||||
function registryView(registry: StoredRegistry): ModelRegistryView {
|
||||
@@ -120,9 +125,9 @@ function mutate(operation: (registry: StoredRegistry) => Promise<ModelRegistryVi
|
||||
|
||||
function normalize(input: CustomModelInput, existing?: StoredModel, testing = false): StoredModel {
|
||||
if (!input || typeof input !== 'object') throw new Error('模型配置无效')
|
||||
const providerId = typeof input.providerId === 'string' ? input.providerId.trim() : ''
|
||||
const providerId = 'custom'
|
||||
const model = typeof input.model === 'string' ? input.model.trim() : ''
|
||||
const baseUrl = typeof input.baseUrl === 'string' ? input.baseUrl.trim().replace(/\/+$/, '') : ''
|
||||
const baseUrl = typeof input.baseUrl === 'string' ? normalizeModelAddress(input.baseUrl) : ''
|
||||
if (!providerId) throw new Error('请选择供应商')
|
||||
if (!model) throw new Error('请输入模型名称')
|
||||
if (!baseUrl) throw new Error('请输入 API 地址')
|
||||
@@ -141,6 +146,18 @@ function normalize(input: CustomModelInput, existing?: StoredModel, testing = fa
|
||||
const key = authMode === 'none' || input.clearApiKey ? '' : suppliedKey || (existing ? readKey(existing).key : '')
|
||||
if (authMode !== 'none' && !key && (testing || !input.clearApiKey)) throw new Error('请输入 API Key')
|
||||
const next: StoredModel = { id, name, providerId, model, baseUrl, authMode }
|
||||
if (input.protocol !== undefined && !['auto', 'openai', 'anthropic', 'gemini'].includes(input.protocol)) throw new Error('模型协议无效')
|
||||
next.protocol = input.protocol ?? existing?.protocol
|
||||
if (input.tokenBudgetVersion !== undefined && input.tokenBudgetVersion !== 2) throw new Error('模型上下文配置版本无效')
|
||||
if (input.tokenBudgetVersion === 2) next.tokenBudgetVersion = 2
|
||||
const configuredEndpoint = input.endpoint ?? (baseUrl === existing?.baseUrl && next.protocol === existing?.protocol && !suppliedKey ? existing.endpoint : undefined)
|
||||
if (configuredEndpoint) {
|
||||
if (!['openai-completions', 'openai-responses', 'anthropic-messages', 'google-generative-ai'].includes(configuredEndpoint.api)) throw new Error('协商协议无效')
|
||||
const root = normalizeModelAddress(configuredEndpoint.baseUrl)
|
||||
if (new URL(root).origin !== endpoint.origin || next.protocol && next.protocol !== 'auto' && apiProtocol(configuredEndpoint.api) !== next.protocol) throw new Error('协商地址与填写的域名或协议不一致')
|
||||
next.endpoint = { api: configuredEndpoint.api, baseUrl: root, checkedAt: configuredEndpoint.checkedAt,
|
||||
...(['models', 'generation'].includes(configuredEndpoint.verifiedBy ?? '') ? { verifiedBy: configuredEndpoint.verifiedBy } : {}) }
|
||||
}
|
||||
for (const field of ['supportsToolCall', 'supportsImages', 'supportsReasoning', 'onlyReasoning', 'canDisableThinking', 'useCustomProtocol'] as const) {
|
||||
if (typeof input[field] === 'boolean') next[field] = input[field]
|
||||
}
|
||||
@@ -151,10 +168,11 @@ function normalize(input: CustomModelInput, existing?: StoredModel, testing = fa
|
||||
delete next.onlyReasoning
|
||||
delete next.canDisableThinking
|
||||
}
|
||||
for (const field of ['maxInputTokens', 'maxOutputTokens'] as const) {
|
||||
for (const field of ['maxInputTokens', 'maxOutputTokens', 'contextWindowTokens', 'reasoningBudget'] as const) {
|
||||
const value = input[field]
|
||||
if (value !== undefined) {
|
||||
if (!Number.isFinite(value)) throw new Error('Token 数量必须是有效数字')
|
||||
if (!Number.isSafeInteger(value) || value < 0) throw new Error('Token 数量必须是非负整数')
|
||||
next[field] = Math.trunc(value)
|
||||
}
|
||||
}
|
||||
@@ -185,6 +203,30 @@ export function saveModel(input: CustomModelInput, overwrite = false): Promise<M
|
||||
const target = registry.models.find(model => model.id === input?.id?.trim())
|
||||
if (target && target.id !== input.previousId && !overwrite) throw new Error('同名模型已存在,请确认是否覆盖')
|
||||
const next = normalize(input, existing ?? target)
|
||||
const metadata = await modelMetadata(next.model, next.protocol ?? 'auto').catch(() => null)
|
||||
if (metadata) {
|
||||
next.metadata = metadata; next.price = metadata.price
|
||||
if (next.protocol) {
|
||||
for (const field of ['supportsToolCall', 'supportsImages', 'supportsReasoning', 'contextWindowTokens', 'maxInputTokens', 'maxOutputTokens'] as const) {
|
||||
if (input[field] === undefined) (next as unknown as Record<string, unknown>)[field] = metadata[field]
|
||||
}
|
||||
if (next.supportsReasoning) {
|
||||
for (const field of ['onlyReasoning', 'canDisableThinking', 'defaultEffort', 'supportedEfforts'] as const) if (input[field] === undefined) (next as unknown as Record<string, unknown>)[field] = metadata[field]
|
||||
if (input.reasoningBudget === undefined && metadata.reasoningOptions.some(option => option.type === 'budget_tokens')) next.reasoningBudget = 1024
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
if (input.maxInputTokens === undefined) next.tokenBudgetVersion = 2
|
||||
const { error, compaction: _compaction, ...limits } = resolveModelTokenLimits(next)
|
||||
if (error) throw new Error(error)
|
||||
Object.assign(next, limits)
|
||||
}
|
||||
if (next.protocol && !next.endpoint) {
|
||||
// Discovery is read-only; servers without a models API still support manual model IDs.
|
||||
const connection = await discoverModelConnection(next.baseUrl, next.protocol, readKey(next).key).catch(() => undefined)
|
||||
if (connection) next.endpoint = connection.endpoint
|
||||
}
|
||||
const remaining = registry.models.filter(model => model.id !== previousId && model.id !== next.id)
|
||||
const oldIndex = registry.models.findIndex(model => model.id === previousId || model.id === next.id)
|
||||
remaining.splice(oldIndex < 0 ? remaining.length : Math.min(oldIndex, remaining.length), 0, next)
|
||||
@@ -221,16 +263,33 @@ export async function resolveModel(id?: string | null): Promise<ResolvedModel |
|
||||
if (!targetId) return null
|
||||
const model = registry.models.find(model => model.id === targetId)
|
||||
if (!model) throw new Error('所选模型不存在,请重新选择模型')
|
||||
const budget = resolveModelTokenLimits(model)
|
||||
if (budget.error) throw new Error(budget.error)
|
||||
const profile = viewOf(model)
|
||||
const legacy = await getGatewayConfig()
|
||||
const verified = model.protocol ? cachedGeneration(model.baseUrl, model.protocol, readKey(model).key, model.model) : undefined
|
||||
const resolved = verified && verified.checkedAt >= (model.endpoint?.checkedAt ?? 0) ? verified : model.endpoint
|
||||
?? (model.protocol ? cachedConnection(model.baseUrl, model.protocol, readKey(model).key)?.endpoint ?? modelEndpoint(model.baseUrl, inferModelApi(model.baseUrl, model.protocol)) : undefined)
|
||||
return {
|
||||
profile: viewOf(model),
|
||||
config: { ...legacy, baseUrl: model.baseUrl, model: model.model, apiKey: readKey(model).key,
|
||||
authMode: model.authMode, maxOutputTokens: model.maxOutputTokens,
|
||||
profile,
|
||||
config: { ...legacy, baseUrl: resolved?.baseUrl ?? model.baseUrl, api: resolved?.api, model: model.model, apiKey: readKey(model).key,
|
||||
supportsReasoning: model.supportsReasoning, reasoningBudget: model.reasoningBudget, reasoningOptions: model.metadata?.reasoningOptions,
|
||||
temperatureSupported: model.metadata?.temperature,
|
||||
authMode: model.authMode, maxOutputTokens: profile.maxOutputTokens,
|
||||
temperature: model.temperature ?? legacy.temperature, maxToolRounds: model.maxToolRounds ?? legacy.maxToolRounds }
|
||||
}
|
||||
}
|
||||
|
||||
/** Test unsaved settings using only the specified user's OpenAI-compatible endpoint. */
|
||||
export async function fetchModels(input: ModelDiscoveryInput): Promise<ModelDiscoveryResult> {
|
||||
if (!input || typeof input.baseUrl !== 'string' || !['auto', 'openai', 'anthropic', 'gemini'].includes(input.protocol ?? 'auto')) throw new Error('模型查询参数无效')
|
||||
await writes
|
||||
const registry = cache ?? await readRegistry(false), existing = registry.models.find(model => model.id === input.previousId)
|
||||
const key = input.authMode === 'none' ? '' : typeof input.apiKey === 'string' && input.apiKey.trim() ? input.apiKey.replace(/\s/gu, '') : existing ? readKey(existing).key : ''
|
||||
if (!key && input.authMode !== 'none') throw new Error('请输入 API Key')
|
||||
return discoverModelConnection(input.baseUrl, input.protocol ?? 'auto', key, true)
|
||||
}
|
||||
|
||||
/** Test only the user-specified origin, negotiating paths/protocols before reporting success. */
|
||||
export async function testModel(input: CustomModelInput): Promise<ModelTestResult> {
|
||||
const startedAt = Date.now()
|
||||
const result = (success: boolean, message: string): ModelTestResult => ({ success, message, durationMs: Date.now() - startedAt })
|
||||
@@ -244,42 +303,39 @@ export async function testModel(input: CustomModelInput): Promise<ModelTestResul
|
||||
const timer = setTimeout(() => controller.abort(), 15_000)
|
||||
try {
|
||||
const key = readKey(model).key
|
||||
const { api, endpoint } = resolveOpenAIEndpoint(model.baseUrl)
|
||||
const payload = api === 'openai-responses'
|
||||
? { model: model.model, input: 'Reply with OK.', stream: false, max_output_tokens: 16 }
|
||||
: { model: model.model, messages: [{ role: 'user', content: 'Reply with OK.' }], stream: false, max_tokens: 16 }
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST', signal: controller.signal,
|
||||
headers: { 'content-type': 'application/json', ...(key ? { authorization: `Bearer ${key}` } : {}) },
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel()
|
||||
const messages: Record<number, string> = { 401: '鉴权失败,请检查 API Key', 403: '上游拒绝访问,请检查 API Key 和权限',
|
||||
404: '未找到模型或接口,请检查模型名称和 API 地址', 429: '上游请求频率或额度受限,请稍后重试' }
|
||||
return result(false, messages[response.status] ?? `上游返回 HTTP ${response.status}`)
|
||||
const legacy = resolveOpenAIEndpoint(model.baseUrl)
|
||||
const known = model.protocol ? model.endpoint ?? cachedConnection(model.baseUrl, model.protocol, key)?.endpoint : undefined
|
||||
// Re-testing an OpenAI route follows the current preference, including at a negotiated custom root.
|
||||
// Stored routes continue to serve ordinary chat until a new generation probe succeeds.
|
||||
const preferred = known && (known.api === 'openai-completions' || known.api === 'openai-responses')
|
||||
? { ...known, api: inferModelApi(model.baseUrl, model.protocol) } : known
|
||||
const candidates: ModelEndpoint[] = model.protocol ? [preferred, ...endpointCandidates(model.baseUrl, model.protocol), known].filter((value): value is ModelEndpoint => !!value)
|
||||
: [{ ...legacy, checkedAt: 0 }]
|
||||
const tried = new Set<string>(); let status = 0
|
||||
for (const endpoint of candidates) {
|
||||
const url = model.protocol ? endpointUrl(endpoint, 'generate', model.model) : legacy.endpoint
|
||||
if (tried.has(url + endpoint.api)) continue
|
||||
tried.add(url + endpoint.api)
|
||||
const payload = endpoint.api === 'openai-responses' ? { model: model.model, input: 'Reply with OK.', stream: false, max_output_tokens: 16 }
|
||||
: endpoint.api === 'anthropic-messages' ? { model: model.model, messages: [{ role: 'user', content: 'Reply with OK.' }], max_tokens: 16 }
|
||||
: endpoint.api === 'google-generative-ai' ? { contents: [{ role: 'user', parts: [{ text: 'Reply with OK.' }] }], generationConfig: { maxOutputTokens: 16 } }
|
||||
: { model: model.model, messages: [{ role: 'user', content: 'Reply with OK.' }], stream: false, [openAICompletionTokenField(model.model)]: 16 }
|
||||
const response = await modelFetch(url, { method: 'POST', signal: controller.signal, headers: modelHeaders(endpoint.api, key), body: JSON.stringify(payload) })
|
||||
status = response.status
|
||||
if (!response.ok) { await response.body?.cancel(); if ([401, 403, 429].includes(status) && model.protocol !== 'auto') break; continue }
|
||||
let parsed: unknown
|
||||
try { parsed = await readModelJson(response, 1024 * 1024) } catch { continue }
|
||||
const value = parsed as Record<string, unknown> | null
|
||||
const valid = endpoint.api === 'openai-responses' ? isResponsesTestResult(parsed) : endpoint.api === 'anthropic-messages'
|
||||
? value?.type === 'message' && Array.isArray(value.content) : endpoint.api === 'google-generative-ai'
|
||||
? Array.isArray(value?.candidates) && value.candidates.length > 0 : Array.isArray(value?.choices) && value.choices.some(choice => choice && (choice.message || typeof choice.text === 'string'))
|
||||
if (!valid) continue
|
||||
const verified: ModelEndpoint = { api: endpoint.api, baseUrl: endpoint.baseUrl, checkedAt: Date.now(), verifiedBy: 'generation' }
|
||||
if (model.protocol) rememberGeneration(model.baseUrl, model.protocol, key, model.model, verified)
|
||||
return { ...result(true, '连接测试成功'), endpoint: verified }
|
||||
}
|
||||
// Limit response consumption; never return upstream text that could echo credentials.
|
||||
const reader = response.body?.getReader()
|
||||
if (!reader) return result(false, '上游返回为空')
|
||||
const chunks: Uint8Array[] = []
|
||||
let total = 0
|
||||
while (true) {
|
||||
const { value, done } = await reader.read()
|
||||
if (done) break
|
||||
total += value.length
|
||||
if (total > 1024 * 1024) { await reader.cancel(); return result(false, '上游响应过大') }
|
||||
chunks.push(value)
|
||||
}
|
||||
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown
|
||||
if (api === 'openai-responses') {
|
||||
if (!isResponsesTestResult(parsed)) return result(false, '上游未返回兼容的 Responses 结果')
|
||||
} else if (!parsed || typeof parsed !== 'object' || !('choices' in parsed)
|
||||
|| !Array.isArray(parsed.choices) || !parsed.choices.length
|
||||
|| !parsed.choices.some(choice => choice && (choice.message || typeof choice.text === 'string'))) {
|
||||
return result(false, '上游未返回兼容的对话结果')
|
||||
}
|
||||
return result(true, '连接测试成功')
|
||||
const messages: Record<number, string> = { 401: '鉴权失败,请检查 API Key', 403: '上游拒绝访问,请检查 API Key 和权限', 404: '未找到模型或接口,请检查模型名称和 API 地址', 429: '上游请求频率或额度受限,请稍后重试' }
|
||||
return result(false, messages[status] ?? (status >= 400 ? `上游返回 HTTP ${status}` : '上游未返回兼容的对话结果'))
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return result(false, '连接超时,请检查 API 地址或稍后重试')
|
||||
return result(false, error instanceof SyntaxError ? '上游未返回有效的 JSON 对话结果' : '无法连接上游,请检查 API 地址和网络')
|
||||
|
||||
Reference in new issue
Block a user