chore: establish YCode replication baseline and TODO

This commit is contained in:
xiaohan.yuan committed 2026-10-17 02:43:49 +08:00
commit 6dc59f89ba
1118 files changed
+196002

No files matched your search

+315
View File
@@ -0,0 +1,315 @@
/** Local custom models. Credentials stay in the main process; legacy config is read only. */
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 { resolveOpenAIEndpoint } from '@shared/openai-endpoint'
import { getGatewayConfig } from './config'
interface StoredModel extends Omit<CustomModelProfile, 'hasApiKey' | 'keyStorage'> {
apiKeyEnc?: string
apiKeyPlain?: string
}
interface StoredRegistry {
version: 1
models: StoredModel[]
selectedModelId: string | null
}
let cache: StoredRegistry | null = null
let loading: Promise<StoredRegistry> | null = null
let writes: Promise<unknown> = Promise.resolve()
function filePath(): string { return join(app.getPath('userData'), 'models.json') }
function readKey(model: StoredModel): { key: string; storage: CustomModelProfile['keyStorage'] } {
if (model.apiKeyEnc) {
try { return { key: safeStorage.decryptString(Buffer.from(model.apiKeyEnc, 'base64')), storage: 'encrypted' } }
catch { return { key: '', storage: 'none' } }
}
return model.apiKeyPlain ? { key: model.apiKeyPlain, storage: 'plaintext' } : { key: '', storage: 'none' }
}
function writeKey(model: StoredModel, key: string): void {
delete model.apiKeyEnc
delete model.apiKeyPlain
if (!key) return
try {
if (safeStorage.isEncryptionAvailable()) model.apiKeyEnc = safeStorage.encryptString(key).toString('base64')
else model.apiKeyPlain = key
} catch { throw new Error('无法安全保存 API Key,请检查系统加密服务') }
}
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' }
}
function registryView(registry: StoredRegistry): ModelRegistryView {
return { models: registry.models.map(viewOf), selectedModelId: registry.selectedModelId }
}
async function persist(registry: StoredRegistry): Promise<void> {
const file = filePath()
await fs.mkdir(join(file, '..'), { recursive: true })
const temporary = `${file}.${randomUUID()}.tmp`
try {
await fs.writeFile(temporary, JSON.stringify(registry, null, 2), { encoding: 'utf8', mode: 0o600 })
await fs.rename(temporary, file)
cache = registry
} catch (error) {
await fs.unlink(temporary).catch(() => undefined)
throw error
}
}
async function readRegistry(persistMigration: boolean): Promise<StoredRegistry> {
let raw: string
try { raw = await fs.readFile(filePath(), 'utf8') }
catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
const legacy = await getGatewayConfig()
const migrated: StoredModel = {
id: 'legacy-default', name: legacy.model, providerId: 'custom',
model: legacy.model, baseUrl: legacy.baseUrl, authMode: 'api-key',
supportsToolCall: true, supportsImages: false, supportsReasoning: false,
temperature: legacy.temperature, maxToolRounds: legacy.maxToolRounds
}
writeKey(migrated, legacy.apiKey)
const initial: StoredRegistry = { version: 1, models: [migrated], selectedModelId: migrated.id }
if (persistMigration) await persist(initial)
return initial
}
let parsed: Partial<StoredRegistry> & { schemaVersion?: number }
try { parsed = JSON.parse(raw) as typeof parsed }
catch { throw new Error('模型配置文件损坏,请保留 models.json 后检查内容') }
// Accept either version marker without rewriting existing secrets during a read.
if (!parsed || typeof parsed !== 'object' || (parsed.version ?? parsed.schemaVersion) !== 1 || !Array.isArray(parsed.models)) {
throw new Error('无法读取此版本的模型配置文件')
}
const ids = new Set<string>()
for (const model of parsed.models) {
if (!model || typeof model.id !== 'string' || !model.id || ids.has(model.id)
|| typeof model.model !== 'string' || typeof model.baseUrl !== 'string'
|| typeof model.name !== 'string' || typeof model.providerId !== 'string') {
throw new Error('模型配置内容无效,请保留 models.json 后检查内容')
}
ids.add(model.id)
}
const selectedModelId = parsed.selectedModelId === null ? null
: typeof parsed.selectedModelId === 'string' && ids.has(parsed.selectedModelId)
? parsed.selectedModelId : parsed.models[0]?.id ?? null
return { version: 1, models: parsed.models, selectedModelId }
}
async function load(): Promise<StoredRegistry> {
if (cache) return cache
if (loading) return loading
loading = readRegistry(true).then(registry => { cache = registry; return registry })
try { return await loading }
finally { loading = null }
}
function mutate(operation: (registry: StoredRegistry) => Promise<ModelRegistryView>): Promise<ModelRegistryView> {
const next = writes.then(async () => operation(await load()))
writes = next.catch(() => undefined)
return next
}
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 model = typeof input.model === 'string' ? input.model.trim() : ''
const baseUrl = typeof input.baseUrl === 'string' ? input.baseUrl.trim().replace(/\/+$/, '') : ''
if (!providerId) throw new Error('请选择供应商')
if (!model) throw new Error('请输入模型名称')
if (!baseUrl) throw new Error('请输入 API 地址')
let endpoint: URL
try { endpoint = new URL(baseUrl) } catch { throw new Error('请输入有效的 API 地址') }
if (!['http:', 'https:'].includes(endpoint.protocol) || endpoint.username || endpoint.password) throw new Error('API 地址必须使用 http 或 https,且不能包含账户密码')
if (endpoint.search || endpoint.hash) throw new Error('API 地址不能包含查询参数或片段')
const id = typeof input.id === 'string' ? input.id.trim() : ''
const name = typeof input.name === 'string' ? input.name.trim() : ''
if (!id) throw new Error('模型标识不能为空')
if (!name) throw new Error('请输入模型显示名称')
if (input.authMode !== undefined && !['api-key', 'none'].includes(input.authMode)) throw new Error('鉴权方式无效')
const suppliedKey = typeof input.apiKey === 'string' ? input.apiKey.replace(/\s/gu, '') : ''
if (suppliedKey && !/^[\x21-\x7e]+$/.test(suppliedKey)) throw new Error('API Key 只能包含 ASCII 可打印字符')
const authMode = input.authMode ?? 'api-key'
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 }
for (const field of ['supportsToolCall', 'supportsImages', 'supportsReasoning', 'onlyReasoning', 'canDisableThinking', 'useCustomProtocol'] as const) {
if (typeof input[field] === 'boolean') next[field] = input[field]
}
if (next.supportsReasoning) {
if (typeof input.defaultEffort === 'string') next.defaultEffort = input.defaultEffort
if (Array.isArray(input.supportedEfforts) && input.supportedEfforts.every(value => typeof value === 'string')) next.supportedEfforts = [...new Set(input.supportedEfforts)]
} else {
delete next.onlyReasoning
delete next.canDisableThinking
}
for (const field of ['maxInputTokens', 'maxOutputTokens'] as const) {
const value = input[field]
if (value !== undefined) {
if (!Number.isFinite(value)) throw new Error('Token 数量必须是有效数字')
next[field] = Math.trunc(value)
}
}
for (const field of ['temperature', 'maxToolRounds'] as const) {
const value = input[field] ?? existing?.[field]
if (value !== undefined) {
if (!Number.isFinite(value)) throw new Error('模型参数必须是有效数字')
next[field] = field === 'temperature' ? Math.max(0, Math.min(2, value)) : Math.max(1, Math.min(24, Math.round(value)))
}
}
// Preserve ciphertext when editing without a new key (including on platforms temporarily unable to encrypt).
if (existing && authMode !== 'none' && !input.clearApiKey && !suppliedKey) {
next.apiKeyEnc = existing.apiKeyEnc
next.apiKeyPlain = existing.apiKeyPlain
} else writeKey(next, key)
return next
}
export async function listModels(): Promise<ModelRegistryView> {
await writes
return registryView(await load())
}
export function saveModel(input: CustomModelInput, overwrite = false): Promise<ModelRegistryView> {
return mutate(async registry => {
const previousId = input?.previousId || input?.id
const existing = registry.models.find(model => model.id === previousId)
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 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)
const selectedModelId = !registry.selectedModelId || registry.selectedModelId === previousId ? next.id : registry.selectedModelId
const updated: StoredRegistry = { version: 1, models: remaining, selectedModelId }
await persist(updated)
return registryView(updated)
})
}
export function deleteModel(id: string): Promise<ModelRegistryView> {
return mutate(async registry => {
const models = registry.models.filter(model => model.id !== id)
const selectedModelId = registry.selectedModelId === id ? models[0]?.id ?? null : registry.selectedModelId
const updated: StoredRegistry = { version: 1, models, selectedModelId }
await persist(updated)
return registryView(updated)
})
}
export function setSelectedModel(id: string | null): Promise<ModelRegistryView> {
return mutate(async registry => {
if (id !== null && !registry.models.some(model => model.id === id)) throw new Error('所选模型不存在')
const updated: StoredRegistry = { ...registry, selectedModelId: id }
await persist(updated)
return registryView(updated)
})
}
export async function resolveModel(id?: string | null): Promise<ResolvedModel | null> {
await writes
const registry = await load()
const targetId = id === undefined ? registry.selectedModelId : id
if (!targetId) return null
const model = registry.models.find(model => model.id === targetId)
if (!model) throw new Error('所选模型不存在,请重新选择模型')
const legacy = await getGatewayConfig()
return {
profile: viewOf(model),
config: { ...legacy, baseUrl: model.baseUrl, model: model.model, apiKey: readKey(model).key,
authMode: model.authMode, maxOutputTokens: model.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 testModel(input: CustomModelInput): Promise<ModelTestResult> {
const startedAt = Date.now()
const result = (success: boolean, message: string): ModelTestResult => ({ success, message, durationMs: Date.now() - startedAt })
let model: StoredModel
try {
await writes
const registry = cache ?? await readRegistry(false)
model = normalize(input, registry.models.find(item => item.id === (input.previousId || input.id)), true)
} catch (error) { return result(false, (error as Error).message) }
const controller = new AbortController()
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}`)
}
// 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, '连接测试成功')
} catch (error) {
if (controller.signal.aborted) return result(false, '连接超时,请检查 API 地址或稍后重试')
return result(false, error instanceof SyntaxError ? '上游未返回有效的 JSON 对话结果' : '无法连接上游,请检查 API 地址和网络')
} finally { clearTimeout(timer) }
}
/** A short probe may spend its whole output budget on reasoning without producing visible text. */
function isResponsesTestResult(value: unknown): boolean {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false
const response = value as Record<string, unknown>
if (response.error != null || (response.object !== undefined && response.object !== 'response')
|| (response.status !== undefined && response.status !== 'completed' && response.status !== 'incomplete')
|| !Array.isArray(response.output)) return false
const validOutput = response.output.every(item => {
if (!item || typeof item !== 'object' || Array.isArray(item)) return false
const output = item as Record<string, unknown>
if (output.type === 'reasoning') {
return Array.isArray(output.summary) && output.summary.every(part => part && typeof part === 'object'
&& part.type === 'summary_text' && typeof part.text === 'string')
}
return output.type === 'message' && output.role === 'assistant' && Array.isArray(output.content)
&& output.content.length > 0 && output.content.every(part => part && typeof part === 'object'
&& ((part.type === 'output_text' && typeof part.text === 'string')
|| (part.type === 'refusal' && typeof part.refusal === 'string')))
})
if (!validOutput) return false
if (response.status === 'incomplete') {
const details = response.incomplete_details
return !!details && typeof details === 'object' && 'reason' in details
&& (details.reason === 'max_output_tokens' || details.reason === 'content_filter')
}
return response.output.length > 0
}