Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/chrislin96_english-essay-grader.json
T

1 line
32 KiB
JSON
Raw 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": "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<Object>} 批改结果\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<string>} 识别出的文本\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<string>} 每行一个单词/句子的多行文本\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 * 返回可直接给 <audio> 播放的 dataURL(或 DashScope 返回的临时音频 URL)。\n * - ttsStyle==='openai':OpenAI 兼容 /v1/audio/speech(OpenAI、自定义网关)\n * - ttsStyle==='dashscope':通义千问 DashScope SpeechSynthesizer(CosyVoice / Qwen-Audio-TTS)\n * 不支持的服务商由上层回退浏览器朗读。\n * @param {string} text - 要朗读的文字(中英文均可)\n * @param {Object} settings - { provider, apiKey, baseUrl?, speed? }\n * @returns {Promise<string>} 音频 dataURL 或 URL\n */\n async function textToSpeech(text, settings) {\n if (!text || !text.trim()) throw new Error('待朗读文本为空');\n const providerConfig = getProviderConfig(settings.provider);\n if (!providerConfig.tts) {\n throw new Error(`所选服务商「${providerConfig.name}」暂不支持 AI 语音合成。请改用浏览器朗读,或换成支持 TTS 的服务商。`);\n }\n const apiKey = (settings.apiKey || '').trim();\n if (!apiKey) throw new Error('未配置 API Key:请先在「设置」中填写你自己的 Key');\n if (providerConfig.ttsStyle === 'dashscope') {\n return await dashscopeTTS(text, settings, providerConfig);\n }\n // 默认:OpenAI 兼容 /v1/audio/speech\n const baseUrl = (settings.baseUrl || providerConfig.baseUrl).replace(/\\/$/, '');\n const url = `${baseUrl}/audio/speech`;\n const body = {\n model: providerConfig.ttsModel || 'tts-1',\n voice: providerConfig.ttsVoice || 'alloy',\n input: text,\n response_format: 'mp3',\n };\n if (settings.speed && !isNaN(settings.speed)) {\n body.speed = Math.max(0.25, Math.min(4.0, settings.speed));\n }\n const res = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },\n body: JSON.stringify(body),\n });\n if (!res.ok) {\n let msg = `HTTP ${res.status}`;\n try {\n const j = await res.json();\n msg = j.error?.message || msg;\n } catch (e) { /* ignore */ }\n throw new Error('AI 语音合成失败:' + msg);\n }\n const blob = await res.blob();\n return await blobToDataURL(blob);\n }\n\n /**\n * 通义千问 DashScope 语音合成(CosyVoice / Qwen-Audio-TTS)。\n * 接口:POST https://dashscope.aliyuncs.com/api/v1/services/audio/tts/SpeechSynthesizer\n * 返回音频可能为 base64(audio.data)或临时链接(audio.url),二者都兼容。\n */\n async function dashscopeTTS(text, settings, cfg) {\n const apiKey = (settings.apiKey || '').trim();\n if (!apiKey) throw new Error('未配置 API Key');\n const url = 'https://dashscope.aliyuncs.com/api/v1/services/audio/tts/SpeechSynthesizer';\n const body = {\n model: cfg.ttsModel || 'qwen-audio-3.0-tts-flash',\n input: {\n text,\n voice: cfg.ttsVoice || 'longanhuan_v3.6',\n format: 'mp3',\n sample_rate: 24000,\n },\n };\n if (settings.speed && !isNaN(settings.speed)) {\n body.input.rate = Math.max(0.5, Math.min(2.0, settings.speed));\n }\n const res = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },\n body: JSON.stringify(body),\n });\n if (!res.ok) {\n let msg = `HTTP ${res.status}`;\n try {\n const j = await res.json();\n msg = j.message || j.code || JSON.stringify(j);\n } catch (e) { /* ignore */ }\n throw new Error('通义语音合成失败:' + msg);\n }\n const data = await res.json();\n const audio = data.output && data.output.audio;\n if (!audio) throw new Error('通义语音合成返回异常:' + JSON.stringify(data).slice(0, 300));\n // 优先用内联 base64;否则用临时链接(尝试转 dataURL 以便缓存,跨域失败则直接用链接播放)\n if (audio.data && audio.data.length) {\n return 'data:audio/mp3;base64,' + audio.data;\n }\n if (audio.url) {\n try {\n const blob = await fetch(audio.url).then(r => r.blob());\n return await blobToDataURL(blob);\n } catch (e) {\n return audio.url;\n }\n }\n throw new Error('通义语音合成未返回音频数据');\n }\n\n function blobToDataURL(blob) {\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => resolve(reader.result);\n reader.onerror = () => reject(new Error('音频读取失败'));\n reader.readAsDataURL(blob);\n });\n }\n\n /**\n * 解析 AI 返回的结果\n * 处理可能的 markdown 代码块包裹\n * @param {string} raw - AI 返回的原始文本\n * @param {string} rubric - 用户自定义评分标准(用于决定评分维度)\n */\n function parseResult(raw, rubric) {\n if (!raw) throw new Error('AI 返回为空');\n\n // 尝试提取 JSON\n let jsonStr = raw.trim();\n\n // 去掉 ```json ... ``` 包裹\n jsonStr = jsonStr.replace(/^```(?:json)?\\s*/i, '').replace(/```\\s*$/, '');\n\n // 如果还是找不到 { ... },尝试提取\n const jsonMatch = jsonStr.match(/\\{[\\s\\S]*\\}/);\n if (jsonMatch) {\n jsonStr = jsonMatch[0];\n }\n\n let data;\n try {\n data = JSON.parse(jsonStr);\n } catch (e) {\n console.error('JSON 解析失败:', raw);\n throw new Error('AI 返回格式错误,请重试或更换模型');\n }\n\n // 动态处理 scores:如果用户有自定义评分标准,scores 维度可能不同\n const scores = data.scores || {};\n\n // 如果有自定义评分标准但 scores 仍是默认四维,尝试兼容\n // 否则保留 AI 返回的任意维度\n const defaultScores = {\n grammar: scores.grammar ?? 0,\n vocabulary: scores.vocabulary ?? 0,\n logic: scores.logic ?? 0,\n total: scores.total ?? 0,\n };\n\n // 如果有自定义评分标准,使用 AI 返回的全部维度(动态)\n const finalScores = rubric && rubric.trim() ? { ...scores } : defaultScores;\n\n // 数据校验与补全\n return {\n corrections: (data.corrections || []).map((c, i) => ({\n id: c.id || i + 1,\n type: c.type || '其他',\n typeTag: c.type || '其他',\n original: c.original || '',\n corrected: c.corrected || '',\n comment: c.comment || '',\n suggestion: c.suggestion || '',\n start: 0,\n end: 0,\n })),\n overall: data.overall || '(暂无总评)',\n scores: finalScores,\n };\n }\n\n /**\n * 测试 API 连接\n */\n async function testConnection(settings) {\n const resolved = resolveSettings(settings);\n if (!resolved.apiKey) {\n throw new Error('请先填写 API Key');\n }\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 const testPrompt = '请用 JSON 格式回复:{\"ok\": true}';\n let result;\n if (mergedSettings.provider === 'gemini') {\n result = await callGemini(testPrompt, mergedSettings);\n } else {\n result = await callOpenAICompatible(testPrompt, mergedSettings);\n }\n return { success: true, sample: result.slice(0, 100) };\n }\n\n return { grade, testConnection, getProviderConfig, getProviders, recognizeImage, recognizeDictationItems, supportsTTS, textToSpeech };\n})();\n\nwindow.AIGrader = AIGrader;\n"}}}}