{"repo": "chrislin96/english-essay-grader", "files": {"js/ai-grader.js": {"8f49e7ee94c5eeedd15db28a0ecedbf3fee612fb": {"t": 1785674646.2126832, "n": 25531, "h": "4ad35d1061c23be3", "c": "/**\n * AI 批改模块\n * 支持 Google Gemini / DeepSeek / 通义千问 / Moonshot / ChatGLM / 零一万物 / 百川 / MiniMax / 硅基流动 / OpenAI / 自定义\n *\n * 提示词设计:让 AI 返回结构化 JSON,包含:\n * - corrections: 错误列表(位置、类型、中文批注、建议)\n * - overall: 总评\n * - scores: 评分\n */\nconst AIGrader = (() => {\n // 预设服务商配置表\n const PROVIDERS = {\n gemini: {\n name: 'Google Gemini',\n type: 'gemini',\n baseUrl: '',\n model: 'gemini-1.5-flash',\n visionModel: 'gemini-1.5-flash',\n models: ['gemini-1.5-flash', 'gemini-2.0-flash', 'gemini-2.5-flash', 'gemini-1.5-pro', 'gemini-2.0-pro'],\n keyUrl: 'https://aistudio.google.com/app/apikey',\n keyPrefix: '',\n free: true,\n note: '免费层:每天 1500 次请求。Gemini 全系列均支持图片识别(看图识字)。',\n },\n deepseek: {\n name: 'DeepSeek 深度求索',\n type: 'openai',\n baseUrl: 'https://api.deepseek.com',\n model: 'deepseek-v4-flash',\n visionModel: 'deepseek-v4-flash',\n models: ['deepseek-v4-flash', 'deepseek-v4-pro'],\n keyUrl: 'https://platform.deepseek.com/api_keys',\n keyPrefix: 'sk-',\n free: false,\n vision: false, // ⚠️ DeepSeek 的 API 是纯文本,不支持图片识别(官方无视觉模型)\n note: '性价比极高,文本批改能力强。但其 API 仅支持文字、不支持图片识别——「AI 看图识字」会自动回退本地 OCR。若想用 AI 看图,请在设置里把服务商换成通义千问等支持视觉的。base_url 为 https://api.deepseek.com(不带 /v1)。',\n },\n qwen: {\n name: '通义千问(阿里云)',\n type: 'openai',\n baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n model: 'qwen-vl-max',\n visionModel: 'qwen-vl-max',\n models: ['qwen-turbo', 'qwen-plus', 'qwen-max', 'qwen-vl-max', 'qwen2.5-vl-72b-instruct'],\n keyUrl: 'https://dashscope.console.aliyun.com/apiKey',\n keyPrefix: 'sk-',\n free: false,\n tts: true,\n ttsStyle: 'dashscope', // 走 DashScope 专属 CosyVoice / Qwen-Audio-TTS 接口,非 OpenAI 格式\n ttsModel: 'qwen-audio-3.0-tts-flash',\n ttsVoice: 'longanhuan_v3.6',\n note: '国内稳定,免费额度较多。默认使用 qwen-vl-max(支持图片识别),并支持 AI 语音合成(CosyVoice / Qwen-Audio-TTS,听写默认即用自然语音)。',\n },\n moonshot: {\n name: 'Moonshot 月之暗面(Kimi)',\n type: 'openai',\n baseUrl: 'https://api.moonshot.cn/v1',\n model: 'moonshot-v1-8k-vision-preview',\n visionModel: 'moonshot-v1-8k-vision-preview',\n models: ['moonshot-v1-8k', 'moonshot-v1-32k', 'moonshot-v1-128k'],\n keyUrl: 'https://platform.moonshot.cn/console/api-keys',\n keyPrefix: 'sk-',\n free: false,\n note: '默认使用 moonshot-v1-8k-vision-preview(支持图片识别)。',\n },\n chatglm: {\n name: 'ChatGLM 智谱清言',\n type: 'openai',\n baseUrl: 'https://open.bigmodel.cn/api/paas/v4',\n model: 'glm-4v-plus',\n visionModel: 'glm-4v-plus',\n models: ['glm-4-flash', 'glm-4-plus', 'glm-4v-plus'],\n keyUrl: 'https://open.bigmodel.cn/usercenter/apikeys',\n keyPrefix: '',\n free: false,\n note: '默认使用 glm-4v-plus(支持图片识别)。',\n },\n yi: {\n name: '零一万物 Yi',\n type: 'openai',\n baseUrl: 'https://api.lingyiwanwu.com/v1',\n model: 'yi-vision',\n visionModel: 'yi-vision',\n models: ['yi-lightning', 'yi-medium'],\n keyUrl: 'https://platform.lingyiwanwu.com/apikeys',\n keyPrefix: '',\n free: false,\n note: '默认使用 yi-vision(支持图片识别)。',\n },\n baichuan: {\n name: '百川智能',\n type: 'openai',\n baseUrl: 'https://api.baichuan-ai.com/v1',\n model: 'Baichuan4-Vision',\n visionModel: 'Baichuan4-Vision',\n models: ['Baichuan4', 'Baichuan3-Turbo', 'Baichuan4-Turbo'],\n keyUrl: 'https://platform.baichuan-ai.com/console/apikey',\n keyPrefix: 'sk-',\n free: false,\n note: '默认使用 Baichuan4-Vision(支持图片识别)。',\n },\n minimax: {\n name: 'MiniMax 海螺 AI',\n type: 'openai',\n baseUrl: 'https://api.minimax.chat/v1',\n model: 'MiniMax-VL-01',\n visionModel: 'MiniMax-VL-01',\n models: ['MiniMax-Text-01', 'MiniMax-VL-01'],\n keyUrl: 'https://platform.minimaxi.com/user-center/api-keys',\n keyPrefix: '',\n free: false,\n note: '默认使用 MiniMax-VL-01(支持图片识别)。',\n },\n siliconflow: {\n name: '硅基流动 SiliconFlow',\n type: 'openai',\n baseUrl: 'https://api.siliconflow.cn/v1',\n model: 'Qwen/Qwen2.5-VL-72B-Instruct',\n visionModel: 'Qwen/Qwen2.5-VL-72B-Instruct',\n keyUrl: 'https://cloud.siliconflow.cn/account/ak',\n keyPrefix: 'sk-',\n free: false,\n note: '聚合多模型平台,默认使用 Qwen2.5-VL-72B-Instruct(支持图片识别)。',\n },\n openai: {\n name: 'OpenAI',\n type: 'openai',\n baseUrl: 'https://api.openai.com/v1',\n model: 'gpt-4o-mini',\n visionModel: 'gpt-4o-mini',\n models: ['gpt-4o-mini', 'gpt-4o', 'gpt-4-turbo', 'o1-mini', 'o3-mini'],\n keyUrl: 'https://platform.openai.com/api-keys',\n keyPrefix: 'sk-',\n free: false,\n tts: true,\n ttsStyle: 'openai',\n ttsModel: 'tts-1',\n ttsVoice: 'alloy',\n note: '效果最好但成本较高。默认使用 gpt-4o-mini(支持图片识别),并支持 AI 语音合成(TTS)。',\n },\n custom: {\n name: '自定义',\n type: 'openai',\n baseUrl: '',\n model: 'gpt-4o-mini',\n visionModel: 'gpt-4o-mini',\n keyUrl: '',\n keyPrefix: '',\n free: false,\n tts: true,\n ttsStyle: 'openai',\n ttsModel: 'tts-1',\n ttsVoice: 'alloy',\n note: '填写 OpenAI 兼容协议的 Base URL;默认使用 gpt-4o-mini(支持图片识别),可在 ai-grader.js 调整。若网关代理了 /v1/audio/speech 即可用于 AI 朗读。',\n },\n };\n\n /**\n * 获取预设服务商配置\n */\n function getProviderConfig(providerId) {\n const base = PROVIDERS[providerId] || PROVIDERS.custom;\n const cfg = window.APP_CONFIG || {};\n const overrides = cfg.baseUrlOverrides || {};\n const ov = overrides[providerId];\n if (ov && String(ov).trim() && ov !== 'PASTE_YOUR_PROXY_URL_HERE') {\n return { ...base, baseUrl: ov.trim().replace(/\\/+$/, '') };\n }\n return base;\n }\n\n /**\n * 获取所有预设服务商列表\n */\n function getProviders() {\n return PROVIDERS;\n }\n // 批改风格对应的提示词\n const STYLE_PROMPTS = {\n standard: '请像一位严谨的英语老师一样批改,指出语法、用词、搭配、逻辑等问题,并给出中肯的改进建议。',\n concise: '请只标注最关键的 3-5 个错误,简洁明了,重点突出。',\n encouraging: '请以鼓励为主,多肯定学生的优点,错误用温和的方式指出,适合初学者。',\n advanced: '请从高级表达、学术写作角度批改,提出向母语者水平提升的建议。',\n };\n\n /**\n * 调用 AI 批改\n * @param {string} text - 待批改的英文文本\n * @param {Object} settings - { provider, apiKey, baseUrl, model, style, rubric }\n * @returns {Promise} 批改结果\n */\n // 解析最终生效的设置:模型默认使用支持图片识别的视觉模型;API Key 必须来自用户设置\n function resolveSettings(settings) {\n const providerConfig = getProviderConfig(settings.provider);\n // 默认使用支持图片识别的模型;用户/旧设置若已指定则尊重之\n const model = (settings.model || '').trim() || providerConfig.visionModel || providerConfig.model;\n return { ...settings, model };\n }\n\n async function grade(text, settings) {\n if (!text || !text.trim()) {\n throw new Error('作文内容为空');\n }\n const resolved = resolveSettings(settings);\n if (!resolved.apiKey) {\n throw new Error('未配置 API Key:请先在「设置」中填写你自己的 Key');\n }\n\n // 用预设配置补全 baseUrl 和 model\n const providerConfig = getProviderConfig(resolved.provider);\n const mergedSettings = {\n ...resolved,\n provider: providerConfig.type,\n baseUrl: resolved.baseUrl || providerConfig.baseUrl,\n model: resolved.model || providerConfig.model,\n };\n\n const prompt = buildPrompt(text, resolved.style || 'standard', resolved.rubric || '');\n let result;\n\n if (mergedSettings.provider === 'gemini') {\n result = await callGemini(prompt, mergedSettings);\n } else {\n result = await callOpenAICompatible(prompt, mergedSettings);\n }\n\n return parseResult(result, settings.rubric);\n }\n\n /**\n * 构造批改 prompt\n * @param {string} text - 学生作文\n * @param {string} style - 批改风格\n * @param {string} rubric - 用户自定义评分标准(可选)\n */\n function buildPrompt(text, style, rubric) {\n const stylePrompt = STYLE_PROMPTS[style] || STYLE_PROMPTS.standard;\n\n // 评分标准部分\n let rubricSection = '';\n let scoreFields = '';\n let scoreJson = '';\n let scoreLabels = '';\n\n if (rubric && rubric.trim()) {\n // 有自定义评分标准时,让 AI 按用户的标准来评分\n rubricSection = `\\n## 用户自定义评分标准\\n\\n你必须严格按照以下评分标准来批改和评分:\\n\\n\"\"\"${rubric.trim()}\"\"\"\\n\\n**重要**:评分维度和权重必须与上述标准一致。`;\n scoreFields = '评分维度和权重必须与用户评分标准一致,按标准中定义的维度评分(0-100 或标准中指定的分数范围)。';\n scoreJson = 'scores 的维度必须与用户评分标准一致,如标准定义了 4 个维度就用 4 个维度,5 个就用 5 个,维度名称用英文 key。';\n scoreLabels = '必须与用户评分标准中的维度名称完全一致';\n } else {\n // 默认评分标准\n scoreFields = `语法(grammar):0-100\\n - 词汇(vocabulary):0-100\\n - 逻辑(logic):0-100\\n - 总分(total):0-100`;\n scoreJson = `\"grammar\": 85,\\n \"vocabulary\": 88,\\n \"logic\": 90,\\n \"total\": 87`;\n scoreLabels = '默认的语法/词汇/逻辑/总分四维评分';\n }\n\n return `你是一位经验丰富的英语老师,正在批改学生的英语作文。\n\n${stylePrompt}\n${rubricSection}\n\n## 学生作文\n\"\"\"\n${text}\n\"\"\"\n\n## 批改要求\n\n1. **找出所有错误和值得改进的地方**(语法、用词、搭配、句式、逻辑等),按出现顺序编号(1, 2, 3...)。\n2. **对每个错误**:\n - 标注错误类型(如:语法错误、搭配错误、用词不当、句式问题、拼写错误 等)\n - 用中文写一段简短的批注(1-2 句话),既要指出问题,也要肯定亮点\n - 给出建议修改\n3. **总评**:用中文写 80-150 字的整体评价,风格温暖鼓励,既要肯定优点也要指出不足。\n4. **评分**:\n ${scoreFields}\n\n## 重要规则\n\n- **必须严格按以下 JSON 格式返回**,不要包含任何 JSON 之外的文字、不要用 \\`\\`\\`json 等代码块包裹。\n- **必须包含 original 字段**:标注错误对应的原文片段(从原文中精确复制,不要修改大小写或标点)。\n- 如果没有错误,corrections 返回空数组。\n- 中文批注要像老师手写评语一样自然有温度。\n- ${scoreLabels}\n\n## JSON 格式\n\n{\n \"corrections\": [\n {\n \"id\": 1,\n \"type\": \"用词不当\",\n \"original\": \"原文中错误的片段\",\n \"corrected\": \"建议修改后的版本(如果原句没问题可省略)\",\n \"comment\": \"中文批注,1-2 句话,自然有温度\",\n \"suggestion\": \"改进建议\"\n }\n ],\n \"overall\": \"整体评价,80-150 字\",\n \"scores\": {\n ${scoreJson}\n }\n}\n\n现在请开始批改。`;\n }\n\n /**\n * 调用 Google Gemini API\n */\n async function callGemini(prompt, settings) {\n const model = settings.model || 'gemini-1.5-flash';\n const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${settings.apiKey}`;\n\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n contents: [{\n parts: [{ text: prompt }],\n }],\n generationConfig: {\n temperature: 0.4,\n topK: 32,\n topP: 1,\n maxOutputTokens: 4096,\n },\n safetySettings: [\n { category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_NONE' },\n { category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_NONE' },\n { category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', threshold: 'BLOCK_NONE' },\n { category: 'HARM_CATEGORY_DANGEROUS_CONTENT', threshold: 'BLOCK_NONE' },\n ],\n }),\n });\n\n if (!response.ok) {\n const err = await response.json().catch(() => ({}));\n const msg = err.error?.message || `HTTP ${response.status}`;\n throw new Error('Gemini API 错误:' + msg);\n }\n\n const data = await response.json();\n return data.candidates?.[0]?.content?.parts?.[0]?.text || '';\n }\n\n /**\n * 调用 OpenAI 兼容 API\n */\n async function callOpenAICompatible(prompt, settings) {\n const baseUrl = (settings.baseUrl || 'https://api.openai.com/v1').replace(/\\/$/, '');\n const model = settings.model || 'gpt-4o-mini';\n const url = `${baseUrl}/chat/completions`;\n\n const response = await fetch(url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${settings.apiKey}`,\n },\n body: JSON.stringify({\n model,\n messages: [\n {\n role: 'system',\n content: '你是一位经验丰富的英语老师,擅长批改学生作文,评语温暖有建设性。',\n },\n { role: 'user', content: prompt },\n ],\n temperature: 0.4,\n max_tokens: 4096,\n }),\n });\n\n if (!response.ok) {\n const err = await response.json().catch(() => ({}));\n const msg = err.error?.message || `HTTP ${response.status}`;\n throw new Error('API 错误:' + msg);\n }\n\n const data = await response.json();\n return data.choices?.[0]?.message?.content || '';\n }\n\n /**\n * 多模态大模型识别图片中的文字(替代/增强 Tesseract OCR)\n * 优先用 Gemini / 支持视觉的 OpenAI 兼容模型直接\"看图识字\",\n * 识别质量(尤其手写体、拍照、歪斜图)远胜本地 Tesseract。\n * @param {string} dataUrl - 图片 dataURL(含 base64)\n * @param {Object} settings - { provider, apiKey, baseUrl, model }\n * @returns {Promise} 识别出的文本\n */\n async function recognizeImage(dataUrl, settings) {\n const resolved = resolveSettings(settings);\n if (!resolved.apiKey) {\n throw new Error('未配置 API Key:请先在「设置」中填写你自己的 Key');\n }\n const providerConfig = getProviderConfig(resolved.provider);\n // 纯文本服务商(如 DeepSeek)的 API 看不到图片,明确报错,由上层回退本地 OCR 并提示。\n if (providerConfig.vision === false) {\n throw new Error(`所选服务商「${providerConfig.name}」的 API 仅支持文字、不支持图片识别。请改用通义千问、智谱、硅基流动、Gemini 等支持视觉的服务商,或继续使用本地 OCR 兜底。`);\n }\n const merged = {\n ...resolved,\n provider: providerConfig.type,\n providerId: resolved.provider, // 保留原始服务商 id(用于判断是否为 DeepSeek 等)\n baseUrl: resolved.baseUrl || providerConfig.baseUrl,\n // 看图识字强制使用服务商自带的视觉模型(如 qwen-vl-max),不依赖 user 为批改选的纯文本模型\n model: providerConfig.visionModel || providerConfig.model,\n };\n const mime = (dataUrl.match(/^data:([^;]+);base64,/) || [])[1] || 'image/jpeg';\n const b64 = dataUrl.split(',')[1] || '';\n\n const prompt = '请识别这张图片中的英文文本。要求:\\n' +\n '1. 逐字照抄原文,保留大小写、标点、换行与段落结构;\\n' +\n '2. 不要翻译、不要改写、不要纠正拼写或语法错误、不要添加任何解释;\\n' +\n '3. 如果是手写体,请尽力辨认每个字母,不确定的字符用「?」标注,不要臆测;\\n' +\n '4. 仅输出图片里实际出现的文字内容,不要输出任何额外说明。';\n\n if (merged.provider === 'gemini') {\n return await callGeminiVision(prompt, merged, mime, b64);\n }\n // OpenAI 兼容:直接尝试图片识别。支持视觉的模型(GPT-4o、DeepSeek V4、\n // 通义 VL、智谱 GLM-4V 等)会正常返回;纯文本模型会报错,由上层回退本地 Tesseract。\n return await callOpenAICompatibleVision(prompt, merged, dataUrl);\n }\n\n /**\n * 多模态大模型识别图片中的「单词 / 句子」,专为单词听写设计。\n * 与 recognizeImage(返回整段文本)不同:这里要求 AI 把每个单词/句子单独成行输出,\n * 方便直接作为听写条目(每行一条)。支持中英文,自动按图片顺序编号输出。\n * @param {string} dataUrl - 图片 dataURL(含 base64)\n * @param {Object} settings - { provider, apiKey, baseUrl }\n * @returns {Promise} 每行一个单词/句子的多行文本\n */\n async function recognizeDictationItems(dataUrl, settings) {\n const resolved = resolveSettings(settings);\n if (!resolved.apiKey) {\n throw new Error('未配置 API Key:请先在「设置」中填写你自己的 Key');\n }\n const providerConfig = getProviderConfig(resolved.provider);\n // 纯文本服务商(如 DeepSeek)的 API 看不到图片,明确报错,由上层回退本地 OCR。\n if (providerConfig.vision === false) {\n throw new Error(`所选服务商「${providerConfig.name}」的 API 仅支持文字、不支持图片识别。请改用通义千问、智谱、硅基流动、Gemini 等支持视觉的服务商,或继续使用本地 OCR 兜底。`);\n }\n const merged = {\n ...resolved,\n provider: providerConfig.type,\n providerId: resolved.provider,\n baseUrl: resolved.baseUrl || providerConfig.baseUrl,\n model: providerConfig.visionModel || providerConfig.model,\n };\n const mime = (dataUrl.match(/^data:([^;]+);base64,/) || [])[1] || 'image/jpeg';\n const b64 = dataUrl.split(',')[1] || '';\n\n const prompt = '请识别这张图片中的单词或句子,用于制作「单词听写」词表。要求:\\n' +\n '1. 支持中文和英文:中文按词语或短句识别,英文按单词或句子识别;\\n' +\n '2. 每个单词或句子【单独占一行】,按图片中出现的先后顺序逐行输出;\\n' +\n '3. 不要加序号、不要加编号、不要加任何解释或标点修饰;\\n' +\n '4. 若一行内有多个由标点或换行分隔的独立词,请拆成独立行;但保持固定搭配/短语完整(如 \"in front of\" 不要拆开);\\n' +\n '5. 仅输出这些行,其它内容一律不要。';\n\n if (merged.provider === 'gemini') {\n return await callGeminiVision(prompt, merged, mime, b64);\n }\n return await callOpenAICompatibleVision(prompt, merged, dataUrl);\n }\n\n async function callGeminiVision(prompt, settings, mime, b64) {\n const model = settings.model || 'gemini-1.5-flash';\n const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${settings.apiKey}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: prompt },\n { inline_data: { mime_type: mime, data: b64 } },\n ],\n }],\n generationConfig: { temperature: 0.2, maxOutputTokens: 4096 },\n safetySettings: [\n { category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_NONE' },\n { category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_NONE' },\n { category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', threshold: 'BLOCK_NONE' },\n { category: 'HARM_CATEGORY_DANGEROUS_CONTENT', threshold: 'BLOCK_NONE' },\n ],\n }),\n });\n if (!response.ok) {\n const err = await response.json().catch(() => ({}));\n const msg = err.error?.message || `HTTP ${response.status}`;\n throw new Error('Gemini 识别失败:' + msg);\n }\n const data = await response.json();\n return data.candidates?.[0]?.content?.parts?.map(p => p.text || '').join('') || '';\n }\n\n async function callOpenAICompatibleVision(prompt, settings, dataUrl) {\n const baseUrl = (settings.baseUrl || 'https://api.openai.com/v1').replace(/\\/$/, '');\n const model = settings.model || 'gpt-4o-mini';\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), 60000);\n // 组装请求体。thinking 参数只发给 DeepSeek:V4 默认开启思考模式,看图识字无需推理,\n // 关闭后答案落在 content;其他 OpenAI 兼容服务商(如 OpenAI 官方)不认识该字段可能报错。\n const body = {\n model,\n messages: [\n { role: 'user', content: [\n { type: 'text', text: prompt },\n { type: 'image_url', image_url: { url: dataUrl } },\n ] },\n ],\n temperature: 0.2,\n max_tokens: 4096,\n };\n if (settings.providerId === 'deepseek') {\n body.thinking = { type: 'disabled' };\n }\n let response;\n try {\n response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${settings.apiKey}`,\n },\n body: JSON.stringify(body),\n signal: controller.signal,\n });\n } catch (e) {\n clearTimeout(timer);\n if (e && e.name === 'AbortError') {\n throw new Error('请求超时(60 秒无响应):可能是网络慢或图片过大,请换更小/更清晰的图片重试');\n }\n // 网络层失败:最常见是浏览器跨域(CORS)被拦,或网络不可达\n throw new Error('网络请求失败(可能被跨域/CORS 拦截或网络不可达):' + (e.message || e));\n }\n clearTimeout(timer);\n if (!response.ok) {\n const err = await response.json().catch(() => ({}));\n const msg = err.error?.message || `HTTP ${response.status}`;\n throw new Error('API 识别失败:' + msg);\n }\n const data = await response.json();\n const msg = data.choices?.[0]?.message || {};\n const content = (msg.content || '').trim();\n const reasoning = (msg.reasoning_content || '').trim();\n // 兼容两种返回:常规 content,或思考模式下的 reasoning_content;\n // 两者皆空说明模型确实没返回文字(如模型不支持看图、或返回异常),明确抛出便于排查,避免静默回退。\n if (!content && !reasoning) {\n const preview = JSON.stringify(data).slice(0, 600);\n throw new Error('API 返回了空内容,原始响应:' + preview);\n }\n return content || reasoning;\n }\n\n /**\n * 判断当前服务商是否支持 AI 语音合成(TTS)。\n * 仅显式声明 tts:true 的服务商可用(如 OpenAI、自定义网关、通义千问)。\n * @param {string} providerId\n * @returns {boolean}\n */\n function supportsTTS(providerId) {\n const cfg = getProviderConfig(providerId);\n return !!(cfg && cfg.tts);\n }\n\n /**\n * AI 语音合成(TTS):根据服务商走不同接口,把文字转成语音音频,\n * 返回可直接给