feat: complete WorkBuddy parity and Windows release workflow

This commit is contained in:
xiaohan.yuan committed 2026-10-18 12:45:21 +08:00
1 parent 143516b3a8
commit d5512df549
434 files changed
+54977 -1854

No files matched your search

+100 -44
View File
@@ -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 地址和网络')