1 line
30 KiB
JSON
1 line
30 KiB
JSON
{"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"}}}} |