Files

1 line
30 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "1935417243/grillmind", "files": {"backend/src/ai/client.js": {"64ef20c3a89f69ec55a718a94cbee7022c966c09": {"t": 1785746675.472135, "n": 6232, "h": "dc4ca68e629fb6f9", "c": "// AI 客户端工厂与调用封装\nimport OpenAI from 'openai';\nimport { db } from '../db/index.js';\nimport { decrypt } from '../utils/crypto.js';\n\n/**\n * 根据 'provider::modelName' 格式动态构造 OpenAI 兼容客户端\n * @param {string} providerModel - 格式如 'deepseek::deepseek-v4-pro'\n * @returns {OpenAI} - OpenAI 客户端实例\n */\nexport function getAIClient(providerModel) {\n const [provider] = providerModel.split('::');\n\n const row = db.prepare(\n 'SELECT api_key_enc, base_url, is_connected FROM model_providers WHERE provider = ?'\n ).get(provider);\n\n if (!row?.is_connected) {\n throw new Error(`Provider \"${provider}\" not configured or not connected`);\n }\n\n return new OpenAI({\n apiKey: decrypt(row.api_key_enc),\n baseURL: row.base_url,\n timeout: 300 * 1000, // 300秒超时(推理模型如 deepseek-v4-pro 思考时间较长)\n maxRetries: 0, // 由 chatCompletionWithRetry 统一管理重试\n });\n}\n\n/**\n * 从 providerModel 字符串中提取模型名\n * @param {string} providerModel\n * @returns {string}\n */\nexport function getModelName(providerModel) {\n return providerModel.split('::')[1];\n}\n\n/**\n * 获取 providerModel 对应的供应商配置\n * @param {string} providerModel\n * @returns {{ provider: string, baseUrl: string|null, isConnected: boolean }}\n */\nexport function getProviderConfig(providerModel) {\n const [provider] = providerModel.split('::');\n const row = db.prepare(\n 'SELECT base_url, is_connected FROM model_providers WHERE provider = ?'\n ).get(provider);\n\n return {\n provider,\n baseUrl: row?.base_url || null,\n isConnected: !!row?.is_connected,\n };\n}\n\n/**\n * 判断是否为 DeepSeek 原生 API。阿里百炼里的 DeepSeek 不支持 DeepSeek JSON Output。\n * @param {string} providerModel - 'provider::model'\n * @returns {boolean}\n */\nexport function isNativeDeepSeekProvider(providerModel) {\n const { provider, baseUrl, isConnected } = getProviderConfig(providerModel);\n if (provider !== 'deepseek' || !isConnected || !baseUrl) return false;\n\n try {\n const host = new URL(baseUrl.trim()).hostname.toLowerCase();\n return host === 'api.deepseek.com';\n } catch {\n return false;\n }\n}\n\n/**\n * 获取任务对应的 providerModel 字符串\n * @param {'parse' | 'interview' | 'report'} task\n * @returns {string}\n */\nexport function getTaskModel(task) {\n const binding = db.prepare(\n \"SELECT * FROM task_model_binding WHERE id = 'singleton'\"\n ).get();\n\n const map = {\n parse: binding?.parse_model,\n interview: binding?.interview_model,\n report: binding?.report_model || binding?.interview_model,\n base: binding?.base_model,\n };\n\n if (!map[task]) throw new Error(`Task model for \"${task}\" not bound`);\n return map[task];\n}\n\n/**\n * 获取任务对应的深度思考开关状态\n * @param {'parse' | 'interview' | 'report' | 'base'} task\n * @returns {boolean}\n */\nexport function getTaskThinking(task) {\n const binding = db.prepare(\n \"SELECT * FROM task_model_binding WHERE id = 'singleton'\"\n ).get();\n\n const map = {\n parse: !!binding?.parse_thinking,\n interview: false, // 面试对话永久关闭深度思考\n report: !!binding?.report_thinking,\n base: !!binding?.base_thinking,\n };\n\n return map[task] || false;\n}\n\n/**\n * 根据供应商和深度思考开关构建 extraBody\n * - deepseek-v4-pro:传 thinking 开关,关闭时显式禁用默认思考模式\n * - deepseek-v4-flash:显式禁用 thinking\n * - bailian/qwen:传 enable_thinking\n * @param {string} providerModel - 'provider::model'\n * @param {boolean} enableThinking - 是否开启深度思考\n * @returns {object}\n */\nexport function buildThinkingExtraBody(providerModel, enableThinking) {\n const [provider, ...rest] = providerModel.split('::');\n const modelName = rest.join('::').toLowerCase();\n\n if (provider === 'deepseek' && modelName === 'deepseek-v4-pro') {\n return {\n thinking: { type: enableThinking ? 'enabled' : 'disabled' },\n ...(enableThinking ? { reasoning_effort: 'high' } : {}),\n };\n }\n\n if (provider === 'deepseek' && modelName === 'deepseek-v4-flash') {\n return { thinking: { type: 'disabled' } };\n }\n\n if (provider === 'bailian') {\n return { enable_thinking: !!enableThinking };\n }\n\n // DeepSeek 旧别名和其他供应商不传额外参数\n return {};\n}\n\n/**\n * 构建 JSON Output 参数。仅 DeepSeek 原生 API 支持该参数。\n * @param {string} providerModel - 'provider::model'\n * @returns {object}\n */\nexport function buildJsonModeExtraBody(providerModel) {\n if (!isNativeDeepSeekProvider(providerModel)) return {};\n\n return {\n response_format: { type: 'json_object' },\n };\n}\n\n/**\n * 调用 AI Chat Completion\n * @param {object} options\n * @param {string} options.providerModel - 'provider::model'\n * @param {Array} options.messages - 消息数组\n * @param {boolean} [options.stream=false] - 是否流式\n * @param {boolean} [options.jsonMode=false] - 是否请求结构化 JSON 输出(仅 DeepSeek 原生 API 生效)\n * @param {object} [options.extraBody={}] - 透传给供应商的扩展参数(如百炼的 enable_thinking)\n * @returns {Promise}\n */\nexport async function chatCompletion({ providerModel, messages, stream = false, jsonMode = false, extraBody = {} }) {\n const client = getAIClient(providerModel);\n const model = getModelName(providerModel);\n const jsonModeExtraBody = jsonMode && !stream ? buildJsonModeExtraBody(providerModel) : {};\n\n return client.chat.completions.create({ model, messages, stream, ...jsonModeExtraBody, ...extraBody });\n}\n\n/**\n * 带重试的非流式调用(用于解析、报告生成)\n * 429(限流)和 500+(服务器错误)会自动重试\n * @param {object} options - chatCompletion 参数\n * @param {number} [maxRetries=2] - 最大重试次数\n * @returns {Promise<string>} - AI 返回的文本内容\n */\nexport async function chatCompletionWithRetry(options, maxRetries = 2) {\n let lastError;\n for (let i = 0; i <= maxRetries; i++) {\n try {\n const resp = await chatCompletion({ ...options, stream: false });\n const content = resp.choices[0].message.content;\n if (options.jsonMode && (!content || !content.trim())) {\n throw new Error('AI returned empty JSON content');\n }\n return content;\n } catch (err) {\n lastError = err;\n console.warn(`AI 调用失败 (第${i + 1}次):`, err.message || err);\n\n // 401/403 等认证错误不重试\n if (err.status && err.status < 500 && err.status !== 429) {\n throw err;\n }\n\n // 429 限流:递增等待\n if (err.status === 429) { await sleep(2000 * (i + 1)); continue; }\n // 500+ 服务器错误 或 网络层错误(无 status,如 Premature close、ECONNRESET):短暂等待后重试\n await sleep(1000 * (i + 1));\n }\n }\n throw lastError;\n}\n\nconst sleep = ms => new Promise(r => setTimeout(r, ms));\n"}, "874672e594b44690e1eed7b053774661d68a663a": {"t": 1785766748.6527762, "n": 6232, "h": "dc4ca68e629fb6f9", "c": "// AI 客户端工厂与调用封装\nimport OpenAI from 'openai';\nimport { db } from '../db/index.js';\nimport { decrypt } from '../utils/crypto.js';\n\n/**\n * 根据 'provider::modelName' 格式动态构造 OpenAI 兼容客户端\n * @param {string} providerModel - 格式如 'deepseek::deepseek-v4-pro'\n * @returns {OpenAI} - OpenAI 客户端实例\n */\nexport function getAIClient(providerModel) {\n const [provider] = providerModel.split('::');\n\n const row = db.prepare(\n 'SELECT api_key_enc, base_url, is_connected FROM model_providers WHERE provider = ?'\n ).get(provider);\n\n if (!row?.is_connected) {\n throw new Error(`Provider \"${provider}\" not configured or not connected`);\n }\n\n return new OpenAI({\n apiKey: decrypt(row.api_key_enc),\n baseURL: row.base_url,\n timeout: 300 * 1000, // 300秒超时(推理模型如 deepseek-v4-pro 思考时间较长)\n maxRetries: 0, // 由 chatCompletionWithRetry 统一管理重试\n });\n}\n\n/**\n * 从 providerModel 字符串中提取模型名\n * @param {string} providerModel\n * @returns {string}\n */\nexport function getModelName(providerModel) {\n return providerModel.split('::')[1];\n}\n\n/**\n * 获取 providerModel 对应的供应商配置\n * @param {string} providerModel\n * @returns {{ provider: string, baseUrl: string|null, isConnected: boolean }}\n */\nexport function getProviderConfig(providerModel) {\n const [provider] = providerModel.split('::');\n const row = db.prepare(\n 'SELECT base_url, is_connected FROM model_providers WHERE provider = ?'\n ).get(provider);\n\n return {\n provider,\n baseUrl: row?.base_url || null,\n isConnected: !!row?.is_connected,\n };\n}\n\n/**\n * 判断是否为 DeepSeek 原生 API。阿里百炼里的 DeepSeek 不支持 DeepSeek JSON Output。\n * @param {string} providerModel - 'provider::model'\n * @returns {boolean}\n */\nexport function isNativeDeepSeekProvider(providerModel) {\n const { provider, baseUrl, isConnected } = getProviderConfig(providerModel);\n if (provider !== 'deepseek' || !isConnected || !baseUrl) return false;\n\n try {\n const host = new URL(baseUrl.trim()).hostname.toLowerCase();\n return host === 'api.deepseek.com';\n } catch {\n return false;\n }\n}\n\n/**\n * 获取任务对应的 providerModel 字符串\n * @param {'parse' | 'interview' | 'report'} task\n * @returns {string}\n */\nexport function getTaskModel(task) {\n const binding = db.prepare(\n \"SELECT * FROM task_model_binding WHERE id = 'singleton'\"\n ).get();\n\n const map = {\n parse: binding?.parse_model,\n interview: binding?.interview_model,\n report: binding?.report_model || binding?.interview_model,\n base: binding?.base_model,\n };\n\n if (!map[task]) throw new Error(`Task model for \"${task}\" not bound`);\n return map[task];\n}\n\n/**\n * 获取任务对应的深度思考开关状态\n * @param {'parse' | 'interview' | 'report' | 'base'} task\n * @returns {boolean}\n */\nexport function getTaskThinking(task) {\n const binding = db.prepare(\n \"SELECT * FROM task_model_binding WHERE id = 'singleton'\"\n ).get();\n\n const map = {\n parse: !!binding?.parse_thinking,\n interview: false, // 面试对话永久关闭深度思考\n report: !!binding?.report_thinking,\n base: !!binding?.base_thinking,\n };\n\n return map[task] || false;\n}\n\n/**\n * 根据供应商和深度思考开关构建 extraBody\n * - deepseek-v4-pro:传 thinking 开关,关闭时显式禁用默认思考模式\n * - deepseek-v4-flash:显式禁用 thinking\n * - bailian/qwen:传 enable_thinking\n * @param {string} providerModel - 'provider::model'\n * @param {boolean} enableThinking - 是否开启深度思考\n * @returns {object}\n */\nexport function buildThinkingExtraBody(providerModel, enableThinking) {\n const [provider, ...rest] = providerModel.split('::');\n const modelName = rest.join('::').toLowerCase();\n\n if (provider === 'deepseek' && modelName === 'deepseek-v4-pro') {\n return {\n thinking: { type: enableThinking ? 'enabled' : 'disabled' },\n ...(enableThinking ? { reasoning_effort: 'high' } : {}),\n };\n }\n\n if (provider === 'deepseek' && modelName === 'deepseek-v4-flash') {\n return { thinking: { type: 'disabled' } };\n }\n\n if (provider === 'bailian') {\n return { enable_thinking: !!enableThinking };\n }\n\n // DeepSeek 旧别名和其他供应商不传额外参数\n return {};\n}\n\n/**\n * 构建 JSON Output 参数。仅 DeepSeek 原生 API 支持该参数。\n * @param {string} providerModel - 'provider::model'\n * @returns {object}\n */\nexport function buildJsonModeExtraBody(providerModel) {\n if (!isNativeDeepSeekProvider(providerModel)) return {};\n\n return {\n response_format: { type: 'json_object' },\n };\n}\n\n/**\n * 调用 AI Chat Completion\n * @param {object} options\n * @param {string} options.providerModel - 'provider::model'\n * @param {Array} options.messages - 消息数组\n * @param {boolean} [options.stream=false] - 是否流式\n * @param {boolean} [options.jsonMode=false] - 是否请求结构化 JSON 输出(仅 DeepSeek 原生 API 生效)\n * @param {object} [options.extraBody={}] - 透传给供应商的扩展参数(如百炼的 enable_thinking)\n * @returns {Promise}\n */\nexport async function chatCompletion({ providerModel, messages, stream = false, jsonMode = false, extraBody = {} }) {\n const client = getAIClient(providerModel);\n const model = getModelName(providerModel);\n const jsonModeExtraBody = jsonMode && !stream ? buildJsonModeExtraBody(providerModel) : {};\n\n return client.chat.completions.create({ model, messages, stream, ...jsonModeExtraBody, ...extraBody });\n}\n\n/**\n * 带重试的非流式调用(用于解析、报告生成)\n * 429(限流)和 500+(服务器错误)会自动重试\n * @param {object} options - chatCompletion 参数\n * @param {number} [maxRetries=2] - 最大重试次数\n * @returns {Promise<string>} - AI 返回的文本内容\n */\nexport async function chatCompletionWithRetry(options, maxRetries = 2) {\n let lastError;\n for (let i = 0; i <= maxRetries; i++) {\n try {\n const resp = await chatCompletion({ ...options, stream: false });\n const content = resp.choices[0].message.content;\n if (options.jsonMode && (!content || !content.trim())) {\n throw new Error('AI returned empty JSON content');\n }\n return content;\n } catch (err) {\n lastError = err;\n console.warn(`AI 调用失败 (第${i + 1}次):`, err.message || err);\n\n // 401/403 等认证错误不重试\n if (err.status && err.status < 500 && err.status !== 429) {\n throw err;\n }\n\n // 429 限流:递增等待\n if (err.status === 429) { await sleep(2000 * (i + 1)); continue; }\n // 500+ 服务器错误 或 网络层错误(无 status,如 Premature close、ECONNRESET):短暂等待后重试\n await sleep(1000 * (i + 1));\n }\n }\n throw lastError;\n}\n\nconst sleep = ms => new Promise(r => setTimeout(r, ms));\n"}, "10fbb09cfcee485f695bc8b09fe24ef47e91862f": {"t": 1785766750.1760654, "n": 4089, "h": "f9bd84d5896f6713", "c": "// AI 客户端工厂与调用封装\nimport OpenAI from 'openai';\nimport { db } from '../db/index.js';\nimport { decrypt } from '../utils/crypto.js';\n\n/**\n * 根据 'provider::modelName' 格式动态构造 OpenAI 兼容客户端\n * @param {string} providerModel - 格式如 'deepseek::deepseek-chat'\n * @returns {OpenAI} - OpenAI 客户端实例\n */\nexport function getAIClient(providerModel) {\n const [provider] = providerModel.split('::');\n\n const row = db.prepare(\n 'SELECT api_key_enc, base_url, is_connected FROM model_providers WHERE provider = ?'\n ).get(provider);\n\n if (!row?.is_connected) {\n throw new Error(`Provider \"${provider}\" not configured or not connected`);\n }\n\n return new OpenAI({\n apiKey: decrypt(row.api_key_enc),\n baseURL: row.base_url,\n timeout: 300 * 1000, // 300秒超时(推理模型如 deepseek-reasoner 思考时间较长)\n maxRetries: 0, // 由 chatCompletionWithRetry 统一管理重试\n });\n}\n\n/**\n * 从 providerModel 字符串中提取模型名\n * @param {string} providerModel\n * @returns {string}\n */\nexport function getModelName(providerModel) {\n return providerModel.split('::')[1];\n}\n\n/**\n * 获取任务对应的 providerModel 字符串\n * @param {'parse' | 'interview' | 'report'} task\n * @returns {string}\n */\nexport function getTaskModel(task) {\n const binding = db.prepare(\n \"SELECT * FROM task_model_binding WHERE id = 'singleton'\"\n ).get();\n\n const map = {\n parse: binding?.parse_model,\n interview: binding?.interview_model,\n report: binding?.report_model || binding?.interview_model,\n base: binding?.base_model,\n };\n\n if (!map[task]) throw new Error(`Task model for \"${task}\" not bound`);\n return map[task];\n}\n\n/**\n * 获取任务对应的深度思考开关状态\n * @param {'parse' | 'interview' | 'report' | 'base'} task\n * @returns {boolean}\n */\nexport function getTaskThinking(task) {\n const binding = db.prepare(\n \"SELECT * FROM task_model_binding WHERE id = 'singleton'\"\n ).get();\n\n const map = {\n parse: !!binding?.parse_thinking,\n interview: false, // 面试对话永久关闭深度思考\n report: !!binding?.report_thinking,\n base: !!binding?.base_thinking,\n };\n\n return map[task] || false;\n}\n\n/**\n * 根据供应商和深度思考开关构建 extraBody\n * - deepseek:不传额外参数(由模型自身决定)\n * - bailian/qwen:传 enable_thinking\n * @param {string} providerModel - 'provider::model'\n * @param {boolean} enableThinking - 是否开启深度思考\n * @returns {object}\n */\nexport function buildThinkingExtraBody(providerModel, enableThinking) {\n const [provider] = providerModel.split('::');\n if (provider === 'bailian') {\n return { enable_thinking: !!enableThinking };\n }\n // DeepSeek 和其他供应商不传额外参数\n return {};\n}\n\n/**\n * 调用 AI Chat Completion\n * @param {object} options\n * @param {string} options.providerModel - 'provider::model'\n * @param {Array} options.messages - 消息数组\n * @param {boolean} [options.stream=false] - 是否流式\n * @param {object} [options.extraBody={}] - 透传给供应商的扩展参数(如百炼的 enable_thinking)\n * @returns {Promise}\n */\nexport async function chatCompletion({ providerModel, messages, stream = false, extraBody = {} }) {\n const client = getAIClient(providerModel);\n const model = getModelName(providerModel);\n\n return client.chat.completions.create({ model, messages, stream, ...extraBody });\n}\n\n/**\n * 带重试的非流式调用(用于解析、报告生成)\n * 429(限流)和 500+(服务器错误)会自动重试\n * @param {object} options - chatCompletion 参数\n * @param {number} [maxRetries=2] - 最大重试次数\n * @returns {Promise<string>} - AI 返回的文本内容\n */\nexport async function chatCompletionWithRetry(options, maxRetries = 2) {\n let lastError;\n for (let i = 0; i <= maxRetries; i++) {\n try {\n const resp = await chatCompletion({ ...options, stream: false });\n return resp.choices[0].message.content;\n } catch (err) {\n lastError = err;\n console.warn(`AI 调用失败 (第${i + 1}次):`, err.message || err);\n\n // 401/403 等认证错误不重试\n if (err.status && err.status < 500 && err.status !== 429) {\n throw err;\n }\n\n // 429 限流:递增等待\n if (err.status === 429) { await sleep(2000 * (i + 1)); continue; }\n // 500+ 服务器错误 或 网络层错误(无 status,如 Premature close、ECONNRESET):短暂等待后重试\n await sleep(1000 * (i + 1));\n }\n }\n throw lastError;\n}\n\nconst sleep = ms => new Promise(r => setTimeout(r, ms));\n"}, "260843481e4b080894c4a2c92db4b4602d97f01d": {"t": 1785766751.4308043, "n": 3148, "h": "0dff4739380b402c", "c": "// AI 客户端工厂与调用封装\nimport OpenAI from 'openai';\nimport { db } from '../db/index.js';\nimport { decrypt } from '../utils/crypto.js';\n\n/**\n * 根据 'provider::modelName' 格式动态构造 OpenAI 兼容客户端\n * @param {string} providerModel - 格式如 'deepseek::deepseek-chat'\n * @returns {OpenAI} - OpenAI 客户端实例\n */\nexport function getAIClient(providerModel) {\n const [provider] = providerModel.split('::');\n\n const row = db.prepare(\n 'SELECT api_key_enc, base_url, is_connected FROM model_providers WHERE provider = ?'\n ).get(provider);\n\n if (!row?.is_connected) {\n throw new Error(`Provider \"${provider}\" not configured or not connected`);\n }\n\n return new OpenAI({\n apiKey: decrypt(row.api_key_enc),\n baseURL: row.base_url,\n timeout: 300 * 1000, // 300秒超时(推理模型如 deepseek-reasoner 思考时间较长)\n maxRetries: 0, // 由 chatCompletionWithRetry 统一管理重试\n });\n}\n\n/**\n * 从 providerModel 字符串中提取模型名\n * @param {string} providerModel\n * @returns {string}\n */\nexport function getModelName(providerModel) {\n return providerModel.split('::')[1];\n}\n\n/**\n * 获取任务对应的 providerModel 字符串\n * @param {'parse' | 'interview' | 'report'} task\n * @returns {string}\n */\nexport function getTaskModel(task) {\n const binding = db.prepare(\n \"SELECT * FROM task_model_binding WHERE id = 'singleton'\"\n ).get();\n\n const map = {\n parse: binding?.parse_model,\n interview: binding?.interview_model,\n report: binding?.report_model || binding?.interview_model,\n base: binding?.base_model,\n };\n\n if (!map[task]) throw new Error(`Task model for \"${task}\" not bound`);\n return map[task];\n}\n\n/**\n * 调用 AI Chat Completion\n * @param {object} options\n * @param {string} options.providerModel - 'provider::model'\n * @param {Array} options.messages - 消息数组\n * @param {boolean} [options.stream=false] - 是否流式\n * @param {object} [options.extraBody={}] - 透传给供应商的扩展参数(如百炼的 enable_thinking)\n * @returns {Promise}\n */\nexport async function chatCompletion({ providerModel, messages, stream = false, extraBody = {} }) {\n const client = getAIClient(providerModel);\n const model = getModelName(providerModel);\n\n return client.chat.completions.create({ model, messages, stream, ...extraBody });\n}\n\n/**\n * 带重试的非流式调用(用于解析、报告生成)\n * 429(限流)和 500+(服务器错误)会自动重试\n * @param {object} options - chatCompletion 参数\n * @param {number} [maxRetries=2] - 最大重试次数\n * @returns {Promise<string>} - AI 返回的文本内容\n */\nexport async function chatCompletionWithRetry(options, maxRetries = 2) {\n let lastError;\n for (let i = 0; i <= maxRetries; i++) {\n try {\n const resp = await chatCompletion({ ...options, stream: false });\n return resp.choices[0].message.content;\n } catch (err) {\n lastError = err;\n console.warn(`AI 调用失败 (第${i + 1}次):`, err.message || err);\n\n // 401/403 等认证错误不重试\n if (err.status && err.status < 500 && err.status !== 429) {\n throw err;\n }\n\n // 429 限流:递增等待\n if (err.status === 429) { await sleep(2000 * (i + 1)); continue; }\n // 500+ 服务器错误 或 网络层错误(无 status,如 Premature close、ECONNRESET):短暂等待后重试\n await sleep(1000 * (i + 1));\n }\n }\n throw lastError;\n}\n\nconst sleep = ms => new Promise(r => setTimeout(r, ms));\n"}, "abc2e06f4ac8039e0f4198211e133ac8095c3740": {"t": 1785766752.6910133, "n": 3112, "h": "b5f579321fc035f7", "c": "// AI 客户端工厂与调用封装\nimport OpenAI from 'openai';\nimport { db } from '../db/index.js';\nimport { decrypt } from '../utils/crypto.js';\n\n/**\n * 根据 'provider::modelName' 格式动态构造 OpenAI 兼容客户端\n * @param {string} providerModel - 格式如 'deepseek::deepseek-chat'\n * @returns {OpenAI} - OpenAI 客户端实例\n */\nexport function getAIClient(providerModel) {\n const [provider] = providerModel.split('::');\n\n const row = db.prepare(\n 'SELECT api_key_enc, base_url, is_connected FROM model_providers WHERE provider = ?'\n ).get(provider);\n\n if (!row?.is_connected) {\n throw new Error(`Provider \"${provider}\" not configured or not connected`);\n }\n\n return new OpenAI({\n apiKey: decrypt(row.api_key_enc),\n baseURL: row.base_url,\n timeout: 300 * 1000, // 300秒超时(推理模型如 deepseek-reasoner 思考时间较长)\n maxRetries: 0, // 由 chatCompletionWithRetry 统一管理重试\n });\n}\n\n/**\n * 从 providerModel 字符串中提取模型名\n * @param {string} providerModel\n * @returns {string}\n */\nexport function getModelName(providerModel) {\n return providerModel.split('::')[1];\n}\n\n/**\n * 获取任务对应的 providerModel 字符串\n * @param {'parse' | 'interview' | 'report'} task\n * @returns {string}\n */\nexport function getTaskModel(task) {\n const binding = db.prepare(\n \"SELECT * FROM task_model_binding WHERE id = 'singleton'\"\n ).get();\n\n const map = {\n parse: binding?.parse_model,\n interview: binding?.interview_model,\n report: binding?.report_model || binding?.interview_model,\n };\n\n if (!map[task]) throw new Error(`Task model for \"${task}\" not bound`);\n return map[task];\n}\n\n/**\n * 调用 AI Chat Completion\n * @param {object} options\n * @param {string} options.providerModel - 'provider::model'\n * @param {Array} options.messages - 消息数组\n * @param {boolean} [options.stream=false] - 是否流式\n * @param {object} [options.extraBody={}] - 透传给供应商的扩展参数(如百炼的 enable_thinking)\n * @returns {Promise}\n */\nexport async function chatCompletion({ providerModel, messages, stream = false, extraBody = {} }) {\n const client = getAIClient(providerModel);\n const model = getModelName(providerModel);\n\n return client.chat.completions.create({ model, messages, stream, ...extraBody });\n}\n\n/**\n * 带重试的非流式调用(用于解析、报告生成)\n * 429(限流)和 500+(服务器错误)会自动重试\n * @param {object} options - chatCompletion 参数\n * @param {number} [maxRetries=2] - 最大重试次数\n * @returns {Promise<string>} - AI 返回的文本内容\n */\nexport async function chatCompletionWithRetry(options, maxRetries = 2) {\n let lastError;\n for (let i = 0; i <= maxRetries; i++) {\n try {\n const resp = await chatCompletion({ ...options, stream: false });\n return resp.choices[0].message.content;\n } catch (err) {\n lastError = err;\n console.warn(`AI 调用失败 (第${i + 1}次):`, err.message || err);\n\n // 401/403 等认证错误不重试\n if (err.status && err.status < 500 && err.status !== 429) {\n throw err;\n }\n\n // 429 限流:递增等待\n if (err.status === 429) { await sleep(2000 * (i + 1)); continue; }\n // 500+ 服务器错误 或 网络层错误(无 status,如 Premature close、ECONNRESET):短暂等待后重试\n await sleep(1000 * (i + 1));\n }\n }\n throw lastError;\n}\n\nconst sleep = ms => new Promise(r => setTimeout(r, ms));\n"}, "3503e0ffcf022db92ab80fcf7c713b15f47af07b": {"t": 1785766754.3898747, "n": 3004, "h": "267cc499e7957e67", "c": "// AI 客户端工厂与调用封装\nimport OpenAI from 'openai';\nimport { db } from '../db/index.js';\nimport { decrypt } from '../utils/crypto.js';\n\n/**\n * 根据 'provider::modelName' 格式动态构造 OpenAI 兼容客户端\n * @param {string} providerModel - 格式如 'deepseek::deepseek-chat'\n * @returns {OpenAI} - OpenAI 客户端实例\n */\nexport function getAIClient(providerModel) {\n const [provider] = providerModel.split('::');\n\n const row = db.prepare(\n 'SELECT api_key_enc, base_url, is_connected FROM model_providers WHERE provider = ?'\n ).get(provider);\n\n if (!row?.is_connected) {\n throw new Error(`Provider \"${provider}\" not configured or not connected`);\n }\n\n return new OpenAI({\n apiKey: decrypt(row.api_key_enc),\n baseURL: row.base_url,\n timeout: 300 * 1000, // 300秒超时(推理模型如 deepseek-reasoner 思考时间较长)\n maxRetries: 0, // 由 chatCompletionWithRetry 统一管理重试\n });\n}\n\n/**\n * 从 providerModel 字符串中提取模型名\n * @param {string} providerModel\n * @returns {string}\n */\nexport function getModelName(providerModel) {\n return providerModel.split('::')[1];\n}\n\n/**\n * 获取任务对应的 providerModel 字符串\n * @param {'parse' | 'interview' | 'report'} task\n * @returns {string}\n */\nexport function getTaskModel(task) {\n const binding = db.prepare(\n \"SELECT * FROM task_model_binding WHERE id = 'singleton'\"\n ).get();\n\n const map = {\n parse: binding?.parse_model,\n interview: binding?.interview_model,\n report: binding?.report_model || binding?.interview_model,\n };\n\n if (!map[task]) throw new Error(`Task model for \"${task}\" not bound`);\n return map[task];\n}\n\n/**\n * 调用 AI Chat Completion\n * @param {object} options\n * @param {string} options.providerModel - 'provider::model'\n * @param {Array} options.messages - 消息数组\n * @param {boolean} [options.stream=false] - 是否流式\n * @returns {Promise}\n */\nexport async function chatCompletion({ providerModel, messages, stream = false }) {\n const client = getAIClient(providerModel);\n const model = getModelName(providerModel);\n\n return client.chat.completions.create({ model, messages, stream });\n}\n\n/**\n * 带重试的非流式调用(用于解析、报告生成)\n * 429(限流)和 500+(服务器错误)会自动重试\n * @param {object} options - chatCompletion 参数\n * @param {number} [maxRetries=2] - 最大重试次数\n * @returns {Promise<string>} - AI 返回的文本内容\n */\nexport async function chatCompletionWithRetry(options, maxRetries = 2) {\n let lastError;\n for (let i = 0; i <= maxRetries; i++) {\n try {\n const resp = await chatCompletion({ ...options, stream: false });\n return resp.choices[0].message.content;\n } catch (err) {\n lastError = err;\n console.warn(`AI 调用失败 (第${i + 1}次):`, err.message || err);\n\n // 401/403 等认证错误不重试\n if (err.status && err.status < 500 && err.status !== 429) {\n throw err;\n }\n\n // 429 限流:递增等待\n if (err.status === 429) { await sleep(2000 * (i + 1)); continue; }\n // 500+ 服务器错误 或 网络层错误(无 status,如 Premature close、ECONNRESET):短暂等待后重试\n await sleep(1000 * (i + 1));\n }\n }\n throw lastError;\n}\n\nconst sleep = ms => new Promise(r => setTimeout(r, ms));\n"}}}}