// ==UserScript==
// @name 小雅答答答
// @license MIT
// @version 2.7.7
// @description 小雅平台学习助手 📖,智能整理归纳学习资料 📚,辅助完成练习 💪,并提供便捷的查阅和修改功能 📝!
// @author Yi
// @match https://*.ai-augmented.com/*
// @icon https://www.ai-augmented.com/static/logo3.1dbbea8f.png
// @grant GM_xmlhttpRequest
// @grant GM_info
// @run-at document-en
// @require https://cdn.jsdmirror.com/npm/docx@7.1.0/build/index.min.js
// @require https://cdn.jsdmirror.com/npm/file-saver@2.0.5/dist/FileSaver.min.js
// @require https://cdn.jsdmirror.com/npm/js-md5@0.8.3/src/md5.min.js
// @require https://cdn.jsdmirror.com/npm/crypto-js@4.2.0/crypto-js.js
// @require https://cdn.jsdmirror.com/npm/crypto-js@4.2.0/hmac-sha1.js
// @homepageURL https://xiaoya.zygame1314.site
// ==/UserScript==
(function () {
'use strict';
const defaultPrompts = {
'1': `
你是一个用于选择题的答题助手。请根据以下题目和选项,选择最合适的答案。
【题目类型】: {questionType}
【题目内容】:
{questionTitle}
【选项】:
{optionsText}
【输出要求】:
- 选择唯一正确选项的字母。
- 你的回答必须严格遵守以下格式:仅包含选项字母(例如: "A"),不得包含任何其他文字、解释、标点符号或空格。
`.trim(),
'2': `
你是一个用于选择题的答题助手。请根据以下题目和选项,选择最合适的答案。
【题目类型】: {questionType}
【题目内容】:
{questionTitle}
【选项】:
{optionsText}
【输出要求】:
- 选择所有正确选项的字母,并用逗号分隔。
- 你的回答必须严格遵守以下格式:仅包含选项字母(例如: "A,C"),不得包含任何其他文字、解释、标点符号或空格。
`.trim(),
'5': `
你是一个用于选择题的答题助手。请根据以下题目和选项,选择最合适的答案。
【题目类型】: {questionType}
【题目内容】:
{questionTitle}
【选项】:
{optionsText}
【输出要求】:
- 选择唯一正确选项的字母。
- 你的回答必须严格遵守以下格式:仅包含选项字母(例如: "A"),不得包含任何其他文字、解释、标点符号或空格。
`.trim(),
'4': `
你是一个用于填空题的答题助手。请根据以下题目,为每一个空白处生成最合适的答案。
【题目类型】: {questionType}
【题目内容】:
{questionTitle}
【输出要求】:
- 你的回答必须是一个JSON数组,数组中的每个字符串元素按顺序对应题目中的每一个空白处。
- 例如,如果题目有两个空,答案分别是 "答案一" 和 "答案二",则输出 ["答案一", "答案二"]。
- 你的回答必须严格遵守此格式,不要包含任何其他文字、解释或代码块标记。
`.trim(),
'6': `
请按照以下要求生成【{questionType}】的答案:
题目: {questionTitle}
当前答案 (可参考或忽略): {answerContent}
生成要求:
1. 使用简体中文
2. 答案要清晰准确,符合题目要求
3. 适当使用专业术语
4. 分点论述,层次分明
5. 避免废话和重复内容
6. 请直接输出纯文本,不要使用markdown等特殊格式
7. 如需分点,使用数字加顿号格式
8. 根据【{questionType}】的特点组织答案结构
`.trim(),
'10': `
请根据以下【编程题】的要求生成代码答案:
题目描述: {questionTitle}
要求语言: {language}
时间限制: {max_time} ms
内存限制: {max_memory} KB
当前代码 (可参考或忽略): {answerContent}
生成要求:
1. 使用指定的编程语言: {language}
2. 代码必须能够解决题目描述中的问题。
3. 遵循良好的编程规范和风格。
4. 包含必要的注释以解释关键部分。
5. 直接输出完整的、可运行的代码,不要包含任何额外的解释或格式化标记。
`.trim(),
'12': `
你是一个用于排序题的答题助手。请根据以下题目和需要排序的选项,将它们排列成正确的顺序。
【题目类型】: {questionType}
【题目内容】:
{questionTitle}
【需要排序的选项】:
{optionsText}
【输出要求】:
- 你的回答必须是一个JSON数组,其中包含表示正确顺序的选项字母。
- 例如,如果正确顺序是 C -> A -> B,则输出 ["C", "A", "B"]。
- 你的回答必须严格遵守此格式,不要包含任何其他文字、解释或代码块标记。
`.trim(),
'13': `
你是一个用于匹配题的答题助手。请根据以下左侧列表和右侧选项,为左侧的每一项选择最合适的匹配项。
【题目类型】: {questionType}
【题目内容】:
{questionTitle}
【左侧列表 (需要匹配的项)】:
{stemsText}
【右侧列表 (可用的选项)】:
{optionsText}
【输出要求】:
- 你的回答必须是一个JSON对象。
- JSON的键(key)是左侧列表的字母。
- JSON的值(value)是与之匹配的右侧列表的字母。
- 例如: {"A": "e", "B": "a", "C": "d"}
- 你的回答必须严格遵守此格式,不要包含任何其他文字、解释或代码块标记。
`.trim()
};
const SCRIPT_CONFIG = {
priorityApiBaseUrl: 'https://xiaoya-get-cdn.zygame1314.site',
remoteConfigUrls: [
'https://gist.githubusercontent.com/zygame1314/5e8a64928374c3fcc88a235f8f75d6e7/raw/xiaoya-config.json',
'https://gh-proxy.com/gist.githubusercontent.com/zygame1314/5e8a64928374c3fcc88a235f8f75d6e7/raw/xiaoya-config.json',
'https://ghfast.top/gist.githubusercontent.com/zygame1314/5e8a64928374c3fcc88a235f8f75d6e7/raw/xiaoya-config.json'
],
defaultApiBaseUrl: 'https://xiaoya-manage.zygame1314-666.top',
cachedApiBaseUrl: null,
lastFetchTimestamp: 0,
cacheDuration: 300000
};
const HealthCheckVisualizer = {
container: null,
groups: {},
_createContainer() {
if (this.container) return;
this.container = document.createElement('div');
this.container.style.cssText = `
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background-color: rgba(0, 0, 0, 0.75);
padding: 12px 20px;
border-radius: 20px;
z-index: 100001;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
box-shadow: 0 4px 15px rgba(0,0,0,0.25);
backdrop-filter: blur(6px);
transition: opacity 0.4s ease, transform 0.4s ease;
opacity: 0;
transform: translateX(-50%) translateY(20px);
font-family: Microsoft YaHei;
`;
document.body.appendChild(this.container);
requestAnimationFrame(() => {
this.container.style.opacity = '1';
this.container.style.transform = 'translateX(-50%) translateY(0)';
});
},
addGroup(groupId, label, urls, isPriority = false) {
this._createContainer();
if (this.groups[groupId]) return;
const groupDiv = document.createElement('div');
groupDiv.style.cssText = `display: flex; align-items: center; gap: 12px;`;
const labelSpan = document.createElement('span');
labelSpan.textContent = label;
labelSpan.style.color = '#fff';
labelSpan.style.fontSize = '13px';
labelSpan.style.fontWeight = 'bold';
groupDiv.appendChild(labelSpan);
const dotsContainer = document.createElement('div');
dotsContainer.style.cssText = `display: flex; align-items: center; gap: 8px;`;
groupDiv.appendChild(dotsContainer);
const dots = urls.map(() => {
const dot = document.createElement('div');
dot.style.cssText = `
width: ${isPriority ? '14px' : '12px'};
height: ${isPriority ? '14px' : '12px'};
border-radius: 50%;
background-color: #9ca3af;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
${isPriority ? 'border: 2px solid rgba(251, 191, 36, 0.5);' : ''}
`;
dotsContainer.appendChild(dot);
return dot;
});
this.container.appendChild(groupDiv);
this.groups[groupId] = { groupDiv, labelSpan, dots };
},
updateDot(groupId, index, status) {
if (!this.groups[groupId] || !this.groups[groupId].dots[index]) return;
const group = this.groups[groupId];
const dot = group.dots[index];
dot.getAnimations().forEach(anim => anim.cancel());
const colors = {
testing: '#f59e0b',
success: '#22c55e',
failure: '#ef4444'
};
dot.style.backgroundColor = colors[status];
dot.style.transform = 'scale(1)';
switch (status) {
case 'testing':
group.labelSpan.textContent = group.labelSpan.textContent.replace('...', '中...');
dot.animate([
{ transform: 'scale(1.0)', opacity: 0.7 },
{ transform: 'scale(1.3)', opacity: 1 },
{ transform: 'scale(1.0)', opacity: 0.7 }
], {
duration: 1200,
iterations: Infinity,
easing: 'ease-in-out'
});
break;
case 'success':
dot.animate([
{ transform: 'scale(1.4)', backgroundColor: '#a7f3d0' },
{ transform: 'scale(1)' }
], {
duration: 400,
easing: 'ease-out'
});
break;
case 'failure':
dot.animate([
{ transform: 'translateX(-3px)' },
{ transform: 'translateX(3px)' },
{ transform: 'translateX(-2px)' },
{ transform: 'translateX(2px)' },
{ transform: 'translateX(0)' }
], {
duration: 300,
easing: 'ease-in-out'
});
break;
}
},
updateGroupLabel(groupId, newLabel) {
if (this.groups[groupId]) {
this.groups[groupId].labelSpan.textContent = newLabel;
}
},
destroy() {
if (this.container) {
this.container.style.opacity = '0';
this.container.style.transform = 'translateX(-50%) translateY(20px)';
setTimeout(() => {
if (this.container && this.container.parentNode) {
this.container.parentNode.removeChild(this.container);
}
this.container = null;
this.groups = {};
}, 400);
}
}
};
const ContributionProgressUI = {
ring: null,
progressCircle: null,
radius: 36,
circumference: 0,
container: null,
mainBall: null,
originalTitle: '',
init(menuContainer) {
if (this.ring) return;
this.container = menuContainer;
this.mainBall = menuContainer.querySelector('.xiaoya-main-ball');
this.originalTitle = this.mainBall.title || '小雅答答答';
this.circumference = 2 * Math.PI * this.radius;
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('width', '80');
svg.setAttribute('height', '80');
svg.style.cssText = `
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(-90deg);
z-index: -1;
display: none;
opacity: 0;
transition: opacity 0.4s ease;
`;
this.ring = svg;
const trackCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
trackCircle.setAttribute('cx', '40');
trackCircle.setAttribute('cy', '40');
trackCircle.setAttribute('r', this.radius);
trackCircle.setAttribute('stroke', 'rgba(0, 0, 0, 0.1)');
trackCircle.setAttribute('stroke-width', '5');
trackCircle.setAttribute('fill', 'transparent');
svg.appendChild(trackCircle);
this.progressCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
this.progressCircle.setAttribute('cx', '40');
this.progressCircle.setAttribute('cy', '40');
this.progressCircle.setAttribute('r', this.radius);
this.progressCircle.setAttribute('stroke', '#4F46E5');
this.progressCircle.setAttribute('stroke-width', '5');
this.progressCircle.setAttribute('fill', 'transparent');
this.progressCircle.setAttribute('stroke-linecap', 'round');
this.progressCircle.style.strokeDasharray = `${this.circumference} ${this.circumference}`;
this.progressCircle.style.strokeDashoffset = this.circumference;
this.progressCircle.style.transition = 'stroke-dashoffset 0.5s ease-out, stroke 0.5s ease';
svg.appendChild(this.progressCircle);
this.container.appendChild(svg);
},
show(message = '开始后台扫描...') {
if (!this.ring) return;
this.ring.style.display = 'block';
requestAnimationFrame(() => this.ring.style.opacity = '1');
this.mainBall.style.animation = 'contribution-pulse 1.5s infinite';
this.mainBall.title = message;
this.update(0, 1);
},
update(current, total, courseName = '') {
if (!this.ring) return;
const percent = (current / total) * 100;
const offset = this.circumference - (percent / 100) * this.circumference;
this.progressCircle.style.strokeDashoffset = offset;
this.mainBall.title = `[${current}/${total}] 正在扫描: ${courseName}`;
},
complete(message) {
if (!this.ring) return;
this.progressCircle.style.stroke = '#22c55e';
this.mainBall.title = message;
this._fadeOut();
},
error(message) {
if (!this.ring) return;
this.progressCircle.style.stroke = '#ef4444';
this.mainBall.title = `错误: ${message}`;
this._fadeOut(3000);
},
hide() {
if (!this.ring) return;
this._fadeOut();
},
_fadeOut(delay = 1500) {
setTimeout(() => {
this.ring.style.opacity = '0';
this.mainBall.style.animation = '';
setTimeout(() => {
this.ring.style.display = 'none';
this.progressCircle.style.stroke = '#4F46E5';
this.mainBall.title = this.originalTitle;
}, 400);
}, delay);
}
};
const style = document.createElement('style');
style.textContent = `
@keyframes contribution-pulse {
0% { box-shadow: 0 0 0 0 rgba(79, 70, 229, 0.5); }
70% { box-shadow: 0 0 0 10px rgba(79, 70, 229, 0); }
100% { box-shadow: 0 0 0 0 rgba(79, 70, 229, 0); }
}
`;
document.head.appendChild(style);
const {
Document,
Packer,
Paragraph,
HeadingLevel,
AlignmentType,
ImageRun,
TextRun
} = window.docx;
let autoFetchEnabled = localStorage.getItem('autoFetchEnabled') === 'true';
let autoFillEnabled = localStorage.getItem('autoFillEnabled') === 'true';
let autoContributeEnabled = localStorage.getItem('autoContributeEnabled') !== 'false';
let isProcessing = false;
let currentBatchAbortController = null;
const activeAIControllers = new Set();
let debounceTimer = null;
let sttCache = {};
const mediaProcessingLocks = {};
const backgroundTaskManager = {
isTaskRunning: false,
isTaskScheduled: false,
schedule() {
if (sessionStorage.getItem('xiaoya_full_scan_done') === 'true') {
console.log('[后台任务调度器] 本次会话已完成全量扫描,不再调度新任务。');
return;
}
if (this.isTaskRunning || this.isTaskScheduled) {
console.log('[后台任务调度器] 任务已在运行或计划中,忽略新的调度请求。');
return;
}
console.log('[后台任务调度器] 收到新的后台任务请求,将在3秒后执行...');
this.isTaskScheduled = true;
setTimeout(async () => {
if (this.isTaskRunning || sessionStorage.getItem('xiaoya_full_scan_done') === 'true') {
console.log('[后台任务调度器] 延迟后发现任务已运行或已完成,取消本次执行。');
this.isTaskScheduled = false;
return;
}
this.isTaskRunning = true;
this.isTaskScheduled = false;
try {
const scanCompleted = await backgroundContributeAllCourses();
if (scanCompleted) {
this.markAsCompleted();
}
} catch (error) {
console.error('[后台任务调度器] 后台任务执行时发生未捕获的错误:', error);
} finally {
this.isTaskRunning = false;
console.log('[后台任务调度器] 后台任务执行完毕,状态重置为空闲。');
}
}, 3000);
},
markAsCompleted() {
console.log('[后台任务调度器] 全量扫描已成功完成,本次会话将不再触发。');
sessionStorage.setItem('xiaoya_full_scan_done', 'true');
}
};
function registerAIController(controller) {
if (!controller) return;
activeAIControllers.add(controller);
console.log(`注册了一个新的AI AbortController,当前总数: ${activeAIControllers.size}`);
controller.signal.addEventListener('abort', () => {
activeAIControllers.delete(controller);
console.log(`一个AI AbortController已中止并移除,剩余总数: ${activeAIControllers.size}`);
}, { once: true });
}
function cancelAllAITasks() {
console.log(`正在取消 ${activeAIControllers.size} 个活动的AI任务...`);
activeAIControllers.forEach(controller => {
if (!controller.signal.aborted) {
controller.abort();
}
});
activeAIControllers.clear();
if (currentBatchAbortController) {
currentBatchAbortController = null;
}
}
function areAITasksRunning() {
return Array.from(activeAIControllers).some(c => !c.signal.aborted);
}
function getToken() {
const cookies = document.cookie.split('; ');
for (let cookie of cookies) {
const [name, value] = cookie.split('=');
if (name.includes('prd-access-token')) {
return value;
}
}
return null;
}
async function isUrlHealthy(url) {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
const response = await fetch(url, { method: 'HEAD', mode: 'cors', signal: controller.signal });
clearTimeout(timeoutId);
if (response.status < 500) {
console.log(`[健康检查] ✅ ${url} - 状态: ${response.status} (可用)`);
return true;
} else {
console.warn(`[健康检查] ❌ ${url} - 状态: ${response.status} (服务器错误)`);
return false;
}
} catch (error) {
if (error.name === 'AbortError') {
console.warn(`[健康检查] ❌ ${url} - 请求超时`);
} else {
console.warn(`[健康检查] ❌ ${url} - 连接失败: ${error.message}`);
}
return false;
}
}
async function getApiBaseUrl() {
const now = Date.now();
if (SCRIPT_CONFIG.cachedApiBaseUrl && (now - SCRIPT_CONFIG.lastFetchTimestamp < SCRIPT_CONFIG.cacheDuration)) {
return SCRIPT_CONFIG.cachedApiBaseUrl;
}
if (SCRIPT_CONFIG.priorityApiBaseUrl) {
HealthCheckVisualizer.addGroup('priority', '⚡️ 优先线路检测...', [SCRIPT_CONFIG.priorityApiBaseUrl], true);
HealthCheckVisualizer.updateDot('priority', 0, 'testing');
if (await isUrlHealthy(SCRIPT_CONFIG.priorityApiBaseUrl)) {
HealthCheckVisualizer.updateDot('priority', 0, 'success');
HealthCheckVisualizer.updateGroupLabel('priority', '✅ 优先线路连接成功!');
console.log(`[优先线路] ${SCRIPT_CONFIG.priorityApiBaseUrl} 已选定!`);
SCRIPT_CONFIG.cachedApiBaseUrl = SCRIPT_CONFIG.priorityApiBaseUrl;
SCRIPT_CONFIG.lastFetchTimestamp = now;
setTimeout(() => HealthCheckVisualizer.destroy(), 1200);
return SCRIPT_CONFIG.priorityApiBaseUrl;
} else {
HealthCheckVisualizer.updateDot('priority', 0, 'failure');
HealthCheckVisualizer.updateGroupLabel('priority', '❌ 优先线路不可用');
console.warn(`[优先线路] ${SCRIPT_CONFIG.priorityApiBaseUrl} 不可用,回退至动态获取...`);
}
}
for (const url of SCRIPT_CONFIG.remoteConfigUrls) {
try {
const response = await fetch(url, { cache: 'no-cache' });
if (!response.ok) throw new Error(`状态: ${response.status}`);
const config = await response.json();
if (config && Array.isArray(config.baseUrls) && config.baseUrls.length > 0) {
HealthCheckVisualizer.addGroup('dynamic', '🌐 动态节点扫描...', config.baseUrls);
for (let i = 0; i < config.baseUrls.length; i++) {
const baseUrl = config.baseUrls[i];
HealthCheckVisualizer.updateDot('dynamic', i, 'testing');
if (await isUrlHealthy(baseUrl)) {
HealthCheckVisualizer.updateDot('dynamic', i, 'success');
HealthCheckVisualizer.updateGroupLabel('dynamic', '✅ 动态节点连接成功!');
console.log(`[动态配置] 域名 ${baseUrl} 健康检查通过,选定此地址!`);
SCRIPT_CONFIG.cachedApiBaseUrl = baseUrl;
SCRIPT_CONFIG.lastFetchTimestamp = now;
setTimeout(() => HealthCheckVisualizer.destroy(), 1200);
return baseUrl;
} else {
HealthCheckVisualizer.updateDot('dynamic', i, 'failure');
}
}
HealthCheckVisualizer.updateGroupLabel('dynamic', '❌ 所有动态节点均不可用');
throw new Error("域名池中的所有地址都无法连接。");
} else {
throw new Error("远程配置文件格式不正确或域名池为空。");
}
} catch (error) {
console.warn(`[动态配置] 路标 ${url} 尝试失败:`, error.message);
}
}
console.error('[动态配置] 所有远程路标均获取失败!');
if (SCRIPT_CONFIG.cachedApiBaseUrl) {
console.log(`[动态配置] 回退至上次成功的缓存地址: ${SCRIPT_CONFIG.cachedApiBaseUrl}`);
HealthCheckVisualizer.addGroup('fallback', `🔄 回退至缓存: ${SCRIPT_CONFIG.cachedApiBaseUrl}`, []);
SCRIPT_CONFIG.lastFetchTimestamp = now;
setTimeout(() => HealthCheckVisualizer.destroy(), 2000);
return SCRIPT_CONFIG.cachedApiBaseUrl;
}
console.log(`[动态配置] 回退至最终的默认备用地址: ${SCRIPT_CONFIG.defaultApiBaseUrl}`);
HealthCheckVisualizer.addGroup('default', `‼️ 启用最终备用线路,功能可能受限`, []);
showNotification('无法连接到更新服务器,脚本将使用备用线路,功能可能受限。', { type: 'warning' });
setTimeout(() => HealthCheckVisualizer.destroy(), 3000);
return SCRIPT_CONFIG.defaultApiBaseUrl;
}
async function getCurrentUserInfo(token) {
if (!token) {
return null;
}
try {
const cachedUserInfo = sessionStorage.getItem(`userInfo_${token}`);
if (cachedUserInfo) {
try {
const parsedInfo = JSON.parse(cachedUserInfo);
if (parsedInfo && parsedInfo.cacheTimestamp && (Date.now() - parsedInfo.cacheTimestamp < 5 * 60 * 1000)) {
return parsedInfo.data;
}
} catch (e) {
sessionStorage.removeItem(`userInfo_${token}`);
}
}
const response = await fetch(`${window.location.origin}/api/jw-starcmooc/user/currentUserInfo`, {
headers: {
"authorization": `Bearer ${token}`,
"content-type": "application/json; charset=utf-8"
},
method: "GET",
credentials: "include"
});
if (!response.ok) {
console.error(`获取用户信息失败,状态码: ${response.status}`);
return null;
}
const data = await response.json();
if (data.code === 200 && data.result) {
try {
sessionStorage.setItem(`userInfo_${token}`, JSON.stringify({ data: data.result, cacheTimestamp: Date.now() }));
} catch (e) {
console.warn('缓存用户信息到 sessionStorage 失败:', e);
}
return data.result;
} else {
console.warn('获取用户信息API返回非成功状态:', data);
return null;
}
} catch (error) {
console.error('获取用户信息时发生网络错误:', error);
return null;
}
}
function addButtons() {
const style = document.createElement('style');
style.textContent = `
:root {
--menu-bg: rgba(248, 249, 252, 0.85);
--menu-border: rgba(0, 0, 0, 0.08);
--menu-shadow: 0 10px 30px rgba(0, 0, 0, 0.12);
--primary-color: #4F46E5;
--primary-color-hover: #4338CA;
--text-color: #1f2937;
--text-color-secondary: #4b5569;
--separator-color: #e5e7eb;
--button-hover-bg: rgba(79, 70, 229, 0.05);
}
.xiaoya-menu-container {
position: fixed;
top: 150px;
left: 150px;
z-index: 9999;
user-select: none;
}
.xiaoya-main-ball {
width: 60px;
height: 60px;
border-radius: 50%;
background: linear-gradient(145deg, #6366F1, #4F46E5);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
cursor: move;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 24px;
transition: transform 0.4s cubic-bezier(0.19, 1, 0.22, 1), box-shadow 0.3s;
}
.xiaoya-main-ball:not(.menu-open):hover {
transform: scale(1.1);
box-shadow: 0 6px 16px rgba(99, 102, 241, 0.4);
}
.xiaoya-main-ball.menu-open {
transform: rotate(90deg) scale(0.9);
}
.xiaoya-menu-panel {
position: absolute;
top: 80px;
left: -15px;
width: 300px;
background: var(--menu-bg);
backdrop-filter: blur(12px) saturate(180%);
-webkit-backdrop-filter: blur(12px) saturate(180%);
border-radius: 16px;
box-shadow: var(--menu-shadow);
border: 1px solid var(--menu-border);
transform-origin: top left;
transition: transform 0.4s cubic-bezier(0.19, 1, 0.22, 1), opacity 0.3s;
opacity: 0;
transform: scale(0.9) translateY(-10px);
pointer-events: none;
display: flex;
flex-direction: column;
max-height: 70vh;
}
.xiaoya-menu-panel.visible {
opacity: 1;
transform: scale(1) translateY(0);
pointer-events: auto;
}
.xiaoya-menu-header {
padding: 12px 16px;
border-bottom: 1px solid var(--separator-color);
cursor: move;
display: flex;
justify-content: space-between;
align-items: center;
}
.xiaoya-menu-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-color);
}
.xiaoya-menu-body {
padding: 12px;
overflow-y: auto;
flex-grow: 1;
}
.xiaoya-menu-body::-webkit-scrollbar { width: 5px; }
.xiaoya-menu-body::-webkit-scrollbar-track { background: transparent; }
.xiaoya-menu-body::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
.xiaoya-menu-button, .xiaoya-menu-toggle {
display: flex;
align-items: center;
width: 100%;
padding: 10px 12px;
border: none;
background: none;
text-align: left;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.2s, color 0.2s;
font-size: 14px;
color: var(--text-color-secondary);
}
.xiaoya-menu-button:hover, .xiaoya-menu-toggle:hover {
background-color: var(--button-hover-bg);
color: var(--primary-color);
}
.xiaoya-menu-icon {
font-size: 18px;
width: 28px;
text-align: center;
margin-right: 12px;
}
.xiaoya-menu-separator {
border: none;
border-top: 1px solid var(--separator-color);
margin: 8px 0;
}
.xiaoya-menu-toggle-switch {
margin-left: auto;
width: 42px;
height: 24px;
background-color: #e5e7eb;
border-radius: 12px;
position: relative;
transition: background-color 0.3s;
}
.xiaoya-menu-toggle-switch::before {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
background-color: white;
border-radius: 50%;
transition: transform 0.3s cubic-bezier(0.19, 1, 0.22, 1);
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.xiaoya-menu-toggle input:checked + .xiaoya-menu-toggle-switch {
background-color: var(--primary-color);
}
.xiaoya-menu-toggle input:checked + .xiaoya-menu-toggle-switch::before {
transform: translateX(18px);
}
.xiaoya-menu-toggle input { display: none; }
.xiaoya-menu-button.special-action {
background: linear-gradient(135deg, rgba(79, 70, 229, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%);
color: var(--primary-color);
font-weight: 500;
}
.xiaoya-menu-button.special-action:hover {
background: linear-gradient(135deg, rgba(79, 70, 229, 0.15) 0%, rgba(139, 92, 246, 0.15) 100%);
}
`;
document.head.appendChild(style);
const container = document.createElement('div');
container.className = 'xiaoya-menu-container';
const mainBall = document.createElement('div');
mainBall.className = 'xiaoya-main-ball';
mainBall.innerHTML = '✨';
const panel = document.createElement('div');
panel.className = 'xiaoya-menu-panel';
const header = document.createElement('div');
header.className = 'xiaoya-menu-header';
header.innerHTML = '
小雅答答答
';
const body = document.createElement('div');
body.className = 'xiaoya-menu-body';
panel.appendChild(header);
panel.appendChild(body);
container.appendChild(mainBall);
container.appendChild(panel);
document.body.appendChild(container);
ContributionProgressUI.init(container);
const buttonsConfig = [
{
id: 'get-answers',
icon: '🕷️',
text: '获取答案 / 激活',
onClick: () => getAndStoreAnswers(true),
type: 'button',
special: true
},
{
id: 'get-submitted',
icon: '📜',
text: '获取已提交作业',
onClick: () => getSubmittedAnswers(),
type: 'button',
special: true
},
{
id: 'fill-answers',
icon: '✍️',
text: '填写答案',
onClick: () => fillAnswers(),
type: 'button',
special: true
},
{
id: 'view-edit',
icon: '🖋️',
text: '查看 / 编辑答案',
onClick: () => showAnswerEditor(),
type: 'button'
},
{
id: 'export-hw',
icon: '📄',
text: '导出作业',
onClick: () => exportHomework(),
type: 'button'
},
{ type: 'separator' },
{
id: 'auto-fetch',
icon: { enabled: '🔄', disabled: '⭕' },
text: '自动获取答案',
state: () => autoFetchEnabled,
onClick: (el, iconEl) => {
autoFetchEnabled = !autoFetchEnabled;
localStorage.setItem('autoFetchEnabled', autoFetchEnabled);
el.querySelector('input').checked = autoFetchEnabled;
iconEl.textContent = autoFetchEnabled ? '🔄' : '⭕';
},
type: 'toggle'
},
{
id: 'auto-fill',
icon: { enabled: '🔄', disabled: '⭕' },
text: '自动填写答案',
state: () => autoFillEnabled,
onClick: (el, iconEl) => {
autoFillEnabled = !autoFillEnabled;
localStorage.setItem('autoFillEnabled', autoFillEnabled);
el.querySelector('input').checked = autoFillEnabled;
iconEl.textContent = autoFillEnabled ? '🔄' : '⭕';
},
type: 'toggle'
},
{ type: 'separator' },
{
id: 'ai-settings',
icon: '⚙️',
text: 'AI 设置',
onClick: () => showAISettingsPanel(),
type: 'button',
special: true
},
{
id: 'check-usage',
icon: '📊',
text: '检查用量',
onClick: () => checkUsage(),
type: 'button'
},
{
id: 'show-guide',
icon: '🧭',
text: '使用指南',
onClick: () => showTutorial(),
type: 'button',
special: true
},
{ type: 'separator' },
{
id: 'contribute-current',
icon: '💝',
text: '贡献当前作业',
onClick: async () => {
if (!(await checkAccountConsistency())) {
showNotification('操作中止:当前登录账号与脚本激活账号不一致。', { type: 'error', duration: 5000 });
return;
}
if (!(await isTaskPage())) {
showNotification('当前不是有效的作业/测验页面,无法进行贡献。', { type: 'warning' });
return;
}
const groupId = getGroupIDFromUrl(window.location.href);
const nodeId = getNodeIDFromUrl(window.location.href);
if (!groupId || !nodeId) {
showNotification('无法获取页面参数,操作中止。', { type: 'error' });
return;
}
showNotification('正在贡献答案到题库...', { type: 'info', duration: 5000 });
try {
const result = await contributeSingleAssignment(groupId, nodeId);
if (result.success) {
showNotification(`✅ 贡献成功: ${result.message}`, { type: 'success', duration: 8000 });
} else {
showNotification(`❌ 贡献失败: ${result.error}`, { type: 'error', duration: 8000 });
}
} catch (error) {
showNotification(`💥 贡献答案时发生严重错误: ${error.message}`, { type: 'error' });
}
},
type: 'button'
},
{
id: 'auto-contribute',
icon: { enabled: '💖', disabled: '🤍' },
text: '自动贡献答案',
state: () => autoContributeEnabled,
onClick: async (el, iconEl) => {
if (autoContributeEnabled) {
const confirmedToKeep = await showConfirmNotification('感谢你一直以来的贡献!💖', { animation: 'scale', confirmText: '继续贡献', cancelText: '仍要关闭', title: '请留步,有几句话想对你说', description: `你开启的“自动贡献”功能是我们答案库成长的基石。每一次贡献,都在帮助更多和你一样的同学。
郑重承诺:
- 只上传题目和标准答案,不包含你的作答记录或分数。
- 所有上传都是完全匿名的,不涉及任何个人身份信息。
- 你举手之劳将汇聚成强大的力量,感谢你的信任与支持!
` });
if (confirmedToKeep) {
showNotification('非常感谢!自动贡献功能将保持开启。', { type: 'success', animation: 'scale' });
el.querySelector('input').checked = true;
iconEl.textContent = '💖';
return;
}
}
autoContributeEnabled = !autoContributeEnabled;
localStorage.setItem('autoContributeEnabled', autoContributeEnabled);
el.querySelector('input').checked = autoContributeEnabled;
iconEl.textContent = autoContributeEnabled ? '💖' : '🤍';
if (autoContributeEnabled) {
showNotification('后台自动贡献功能已开启。脚本将在后台为你扫描并贡献所有课程的答案。', { type: 'info' });
sessionStorage.removeItem('xiaoya_full_scan_done');
backgroundTaskManager.schedule();
} else {
showNotification('自动贡献功能已关闭。感谢你曾经的付出!', { type: 'info' });
}
},
type: 'toggle'
},
];
buttonsConfig.forEach(config => {
if (config.type === 'separator') {
body.appendChild(document.createElement('hr')).className = 'xiaoya-menu-separator';
return;
}
if (config.type === 'button') {
const button = document.createElement('button');
button.className = 'xiaoya-menu-button';
if (config.special) button.classList.add('special-action');
button.innerHTML = `
${config.text}
`;
button.onclick = config.onClick;
body.appendChild(button);
} else if (config.type === 'toggle') {
const label = document.createElement('label');
label.className = 'xiaoya-menu-toggle';
const isEnabled = config.state();
label.innerHTML = `
${config.text}
`;
const iconSpan = label.querySelector('.xiaoya-menu-icon');
label.onclick = (e) => {
e.preventDefault();
config.onClick(label, iconSpan);
};
body.appendChild(label);
}
});
let isPanelVisible = false;
function togglePanel() {
isPanelVisible = !isPanelVisible;
panel.classList.toggle('visible', isPanelVisible);
mainBall.classList.toggle('menu-open', isPanelVisible);
}
mainBall.addEventListener('click', (e) => {
if (!hasDragged) {
togglePanel();
}
});
let isDragging = false, hasDragged = false;
let initialX, initialY, xOffset = 0, yOffset = 0;
const dragThreshold = 5;
function dragStart(e) {
hasDragged = false;
const target = e.target;
if (target === mainBall || target === header || header.contains(target)) {
isDragging = true;
const clientX = e.type === 'touchstart' ? e.touches[0].clientX : e.clientX;
const clientY = e.type === 'touchstart' ? e.touches[0].clientY : e.clientY;
xOffset = clientX - container.offsetLeft;
yOffset = clientY - container.offsetTop;
initialX = clientX;
initialY = clientY;
}
}
function drag(e) {
if (isDragging) {
e.preventDefault();
const clientX = e.type === 'touchmove' ? e.touches[0].clientX : e.clientX;
const clientY = e.type === 'touchmove' ? e.touches[0].clientY : e.clientY;
if (!hasDragged) {
const dx = clientX - initialX;
const dy = clientY - initialY;
if (Math.sqrt(dx * dx + dy * dy) > dragThreshold) {
hasDragged = true;
}
}
let newX = clientX - xOffset;
let newY = clientY - yOffset;
const containerRect = container.getBoundingClientRect();
newX = Math.max(0, Math.min(newX, window.innerWidth - containerRect.width));
newY = Math.max(0, Math.min(newY, window.innerHeight - containerRect.height));
container.style.left = newX + 'px';
container.style.top = newY + 'px';
}
}
function dragEnd() {
isDragging = false;
setTimeout(() => {
hasDragged = false;
}, 0);
}
header.addEventListener('mousedown', dragStart);
document.addEventListener('mousemove', drag);
document.addEventListener('mouseup', dragEnd);
header.addEventListener('touchstart', dragStart, { passive: true });
document.addEventListener('touchmove', drag, { passive: false });
document.addEventListener('touchend', dragEnd);
mainBall.addEventListener('mousedown', dragStart);
mainBall.addEventListener('touchstart', dragStart, { passive: true });
}
function createProgressBar() {
const style = document.createElement('style');
style.textContent = `
.answer-progress {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 6px;
background: rgba(0, 0, 0, 0.05);
z-index: 10000;
opacity: 0;
transition: opacity 0.4s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
pointer-events: none;
}
.answer-progress-bar {
height: 100%;
background: linear-gradient(90deg, #60a5fa, #818cf8);
width: 0%;
transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
border-radius: 0 3px 3px 0;
box-shadow: 0 0 8px rgba(96, 165, 250, 0.5);
}
.answer-progress-text {
position: fixed;
top: 12px;
right: 20px;
transform: translateY(-10px);
background: #4f46e5;
color: white;
padding: 6px 12px;
border-radius: 6px;
font-size: 13px;
opacity: 0;
transition: all 0.4s ease;
box-shadow: 0 2px 6px rgba(79, 70, 229, 0.3);
font-weight: bold;
pointer-events: none;
}
`;
document.head.appendChild(style);
const progressContainer = document.createElement('div');
progressContainer.className = 'answer-progress';
const progressBar = document.createElement('div');
progressBar.className = 'answer-progress-bar';
const progressText = document.createElement('div');
progressText.className = 'answer-progress-text';
progressContainer.appendChild(progressBar);
document.body.appendChild(progressContainer);
document.body.appendChild(progressText);
return {
show: () => {
progressContainer.style.opacity = '1';
progressText.style.opacity = '1';
progressText.style.transform = 'translateY(0)';
},
hide: () => {
progressContainer.style.opacity = '0';
progressText.style.opacity = '0';
progressText.style.transform = 'translateY(-10px)';
setTimeout(() => {
progressContainer.remove();
progressText.remove();
}, 300);
},
update: (current, total, action = '正在填写', unit = '题') => {
const percent = (current / total) * 100;
progressBar.style.width = percent + '%';
const unitString = unit ? ` ${unit}` : '';
progressText.textContent = `${action}: ${current}/${total}${unitString}`;
},
};
}
addButtons();
function addGlobalStyles() {
const style = document.createElement('style');
style.textContent = `
.image-upload-btn, .ai-assist-btn {
padding: 8px 16px;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-weight: bold;
display: flex;
align-items: center;
gap: 6px;
transition: all 0.2s ease;
height: 36px;
}
.image-upload-btn {
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
box-shadow: 0 2px 4px rgba(22, 163, 74, 0.3);
}
.image-upload-btn:hover {
transform: translateY(-1px);
background: linear-gradient(135deg, #16a34a 0%, #15803d 100%);
box-shadow: 0 4px 8px rgba(22, 163, 74, 0.4);
}
.image-upload-btn:active {
transform: translateY(1px);
}
.image-upload-btn.loading, .ai-assist-btn.loading {
background: #9ca3af;
cursor: not-allowed;
opacity: 0.8;
}
.image-upload-btn .icon, .ai-assist-btn .icon {
font-size: 16px;
}
.image-upload-btn.loading .icon, .ai-assist-btn.loading .icon {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.ai-assist-btn {
background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%);
box-shadow: 0 2px 4px rgba(79, 70, 229, 0.1);
}
.ai-assist-btn:hover {
transform: translateY(-1px);
background: linear-gradient(135deg, #4338ca 0%, #4f46e5 100%);
box-shadow: 0 4px 8px rgba(79, 70, 229, 0.2);
}
.ai-assist-btn:active {
transform: translateY(1px);
}
.char-count {
font-size: 12px;
color: #6b7280;
margin-left: auto;
padding: 4px 8px;
background-color: #f9fafb;
border-radius: 6px;
border: 1px solid #e5e7eb;
transition: all 0.2s ease;
}
.char-count.active {
color: #4f46e5;
border-color: #c7d2fe;
background-color: #eef2ff;
}
`;
document.head.appendChild(style);
}
addGlobalStyles();
class NotificationAnimator {
static animations = {
fadeSlide: {
enter: {
initial: {
opacity: '0',
transform: 'translateY(-20px)'
},
final: {
opacity: '1',
transform: 'translateY(0)'
}
},
exit: {
initial: {
opacity: '1',
transform: 'translateY(0)'
},
final: {
opacity: '0',
transform: 'translateY(-20px)'
}
}
},
scale: {
enter: {
initial: {
opacity: '0',
transform: 'scale(0.8)'
},
final: {
opacity: '1',
transform: 'scale(1)'
}
},
exit: {
initial: {
opacity: '1',
transform: 'scale(1)'
},
final: {
opacity: '0',
transform: 'scale(0.8)'
}
}
},
slideRight: {
enter: {
initial: {
opacity: '0',
transform: 'translateX(-100%)'
},
final: {
opacity: '1',
transform: 'translateX(0)'
}
},
exit: {
initial: {
opacity: '1',
transform: 'translateX(0)'
},
final: {
opacity: '0',
transform: 'translateX(100%)'
}
}
}
};
static applyAnimation(element, animationType, isEnter) {
const animation = this.animations[animationType];
if (!animation) return;
const { initial, final } = isEnter ? animation.enter : animation.exit;
Object.assign(element.style, {
transition: 'all 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55)',
...initial
});
requestAnimationFrame(() => {
Object.assign(element.style, final);
});
}
}
function getNotificationContainer() {
let container = document.getElementById('notification-container');
if (!container) {
container = document.createElement('div');
container.id = 'notification-container';
container.style.cssText = `
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 100000;
max-height: calc(100vh - 40px);
overflow-y: auto;
overflow-x: hidden;
pointer-events: none;
display: flex;
flex-direction: column;
align-items: center;
opacity: 0;
transition: opacity 0.3s ease;
`;
document.body.appendChild(container);
container.offsetHeight;
container.style.opacity = '1';
}
return container;
}
function showNotification(message, options = {}) {
const {
type = 'info',
duration = 3000,
keywords = [],
animation = 'fadeSlide',
id = null
} = options;
const container = getNotificationContainer();
const existingNotifications = container.querySelectorAll('.message-container');
for (let i = 0; i < existingNotifications.length; i++) {
if (existingNotifications[i].textContent === message) {
return;
}
}
if (id) {
let existingNotification = container.querySelector(`[data-notification-id="${id}"]`);
if (existingNotification) {
const messageContainer = existingNotification.querySelector('.message-container');
const icon = existingNotification.querySelector('.notification-icon');
const typeStyles = {
success: { icon: '🎉' },
error: { icon: '❌' },
warning: { icon: '⚠️' },
info: { icon: 'ℹ️' }
};
const currentType = typeStyles[type] || typeStyles.info;
if (icon) icon.textContent = currentType.icon;
if (messageContainer) messageContainer.innerHTML = message;
if (duration > 0) {
if (existingNotification.hideTimeout) clearTimeout(existingNotification.hideTimeout);
existingNotification.hideTimeout = setTimeout(() => {
hideNotification(existingNotification);
}, duration);
}
return;
}
}
const highlightColors = {
success: '#ffba08', error: '#14b8a6', warning: '#8b5cf6', info: '#f472b6'
};
const highlightColor = highlightColors[type] || highlightColors.info;
const highlightStyle = `
color: ${highlightColor}; font-weight: bold; border-bottom: 2px solid ${highlightColor}50;
transition: all 0.3s ease; border-radius: 3px;
`;
let highlightedMessage = message;
if (keywords && keywords.length > 0) {
const uniqueKeywords = [...new Set(keywords)].map(k => String(k).trim()).filter(Boolean);
if (uniqueKeywords.length > 0) {
uniqueKeywords.sort((a, b) => b.length - a.length);
const escapedKeywords = uniqueKeywords.map(k => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
const regex = new RegExp(`\\b(${escapedKeywords.join('|')})\\b`, 'g');
highlightedMessage = message.replace(regex, (match) =>
`${match}`
);
}
}
const notification = document.createElement('div');
if (id) notification.dataset.notificationId = id;
notification.style.cssText = `
position: relative; margin-bottom: 10px; padding: 15px 20px; border-radius: 12px;
color: #333; font-size: 16px; font-weight: bold;
box-shadow: 0 8px 16px rgba(0,0,0,0.08), 0 4px 8px rgba(0,0,0,0.06);
pointer-events: auto; opacity: 0; transform: translateY(-20px);
transition: all 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55);
display: flex; align-items: center; backdrop-filter: blur(8px);
`;
const typeStyles = {
success: { background: 'linear-gradient(145deg, rgba(104, 214, 156, 0.95), rgba(89, 186, 134, 0.95))', icon: '🎉' },
error: { background: 'linear-gradient(145deg, rgba(248, 113, 113, 0.95), rgba(220, 38, 38, 0.95))', icon: '❌' },
warning: { background: 'linear-gradient(145deg, rgba(251, 191, 36, 0.95), rgba(245, 158, 11, 0.95))', icon: '⚠️' },
info: { background: 'linear-gradient(145deg, rgba(96, 165, 250, 0.95), rgba(59, 130, 246, 0.95))', icon: 'ℹ️' }
};
const currentType = typeStyles[type] || typeStyles.info;
notification.style.background = currentType.background;
notification.style.color = type === 'info' || type === 'success' ? '#fff' : '#000';
const progressBar = document.createElement('div');
progressBar.style.cssText = `
position: absolute; bottom: 0; left: 0; height: 4px; width: 100%;
background: rgba(255, 255, 255, 0.3); border-radius: 0 0 12px 12px;
transition: width ${duration}ms cubic-bezier(0.4, 0, 0.2, 1);
`;
const icon = document.createElement('span');
icon.className = 'notification-icon';
icon.style.cssText = 'margin-right: 12px; font-size: 20px; filter: saturate(1.2);';
icon.textContent = currentType.icon;
const messageContainer = document.createElement('div');
messageContainer.className = 'message-container';
messageContainer.innerHTML = highlightedMessage;
messageContainer.textContent = message;
messageContainer.style.cssText = 'flex: 1; font-weight: bold;';
const closeButton = document.createElement('button');
closeButton.innerHTML = ``;
closeButton.style.cssText = `
margin-left: 12px; background: #f3f4f6; border: none; width: 32px; height: 32px;
border-radius: 50%; cursor: pointer; color: #6b7280; display: flex;
align-items: center; justify-content: center; transition: all 0.3s ease; flex-shrink: 0;
`;
closeButton.onmouseover = () => { };
closeButton.onmouseout = () => { };
notification.appendChild(icon);
notification.appendChild(messageContainer);
notification.appendChild(closeButton);
notification.appendChild(progressBar);
container.prepend(notification);
requestAnimationFrame(() => {
NotificationAnimator.applyAnimation(notification, animation, true);
if (duration > 0) {
requestAnimationFrame(() => { progressBar.style.width = '0'; });
}
});
function hideNotification(notificationElement) {
if (!container.contains(notificationElement)) return;
NotificationAnimator.applyAnimation(notificationElement, animation, false);
setTimeout(() => {
if (container.contains(notificationElement)) {
container.removeChild(notificationElement);
}
if (container.children.length === 0 && document.body.contains(container)) {
document.body.removeChild(container);
}
}, 300);
}
const hideThisNotification = () => hideNotification(notification);
closeButton.addEventListener('click', (e) => {
e.stopPropagation();
clearTimeout(notification.hideTimeout);
hideThisNotification();
});
if (duration > 0) {
notification.addEventListener('click', hideThisNotification);
notification.hideTimeout = setTimeout(() => {
hideThisNotification();
}, duration);
}
}
function showConfirmNotification(message, options = {}) {
const {
animation = 'scale',
confirmText = '确认',
cancelText = '取消',
title = null,
description = null
} = options;
return new Promise((resolve) => {
const container = getNotificationContainer();
const notification = document.createElement('div');
notification.style.cssText = `
position: relative;
margin-bottom: 10px;
padding: 20px 25px;
border-radius: 16px;
color: #333;
font-size: 16px;
font-weight: bold;
box-shadow: 0 10px 25px rgba(0,0,0,0.1), 0 5px 10px rgba(0,0,0,0.05);
pointer-events: auto;
opacity: 0;
transform: translateY(-20px);
transition: all 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55);
display: flex;
flex-direction: column;
gap: 15px;
background: linear-gradient(145deg, #ffffff, #f8f9fa);
backdrop-filter: blur(8px);
border: 1px solid rgba(0,0,0,0.05);
max-width: 450px;
`;
if (title) {
const titleDiv = document.createElement('h3');
titleDiv.textContent = title;
titleDiv.style.cssText = `
margin: 0;
font-size: 18px;
font-weight: 700;
color: #1f2937;
text-align: center;
`;
notification.appendChild(titleDiv);
}
const messageDiv = document.createElement('div');
messageDiv.innerHTML = message;
messageDiv.style.fontWeight = '600';
messageDiv.style.textAlign = 'center';
notification.appendChild(messageDiv);
if (description) {
const descriptionDiv = document.createElement('div');
descriptionDiv.innerHTML = description;
descriptionDiv.style.cssText = `
margin-top: 5px;
font-size: 15px;
font-weight: normal;
color: #4b5569;
line-height: 1.5;
text-align: center;
`;
notification.appendChild(descriptionDiv);
}
const buttonContainer = document.createElement('div');
buttonContainer.style.cssText = `
display: flex;
gap: 12px;
justify-content: center;
margin-top: 10px;
`;
const confirmBtn = document.createElement('button');
confirmBtn.textContent = confirmText;
confirmBtn.style.cssText = `
padding: 8px 18px;
border: none;
border-radius: 8px;
background: #4f46e5;
color: white;
cursor: pointer;
font-weight: bold;
transition: all 0.2s ease;
`;
const cancelBtn = document.createElement('button');
cancelBtn.textContent = cancelText;
cancelBtn.style.cssText = `
padding: 8px 18px;
border: 1px solid #d1d5db;
border-radius: 8px;
background: transparent;
color: #4b5569;
cursor: pointer;
font-weight: bold;
transition: all 0.2s ease;
`;
[confirmBtn, cancelBtn].forEach(btn => {
btn.onmouseover = () => { btn.style.transform = 'translateY(-1px)'; btn.style.filter = 'brightness(1.1)'; };
btn.onmouseout = () => { btn.style.transform = 'translateY(0)'; btn.style.filter = 'brightness(1)'; };
});
buttonContainer.appendChild(cancelBtn);
buttonContainer.appendChild(confirmBtn);
notification.appendChild(buttonContainer);
container.appendChild(notification);
requestAnimationFrame(() => {
notification.style.opacity = '1';
notification.style.transform = 'translateY(0)';
});
requestAnimationFrame(() => {
requestAnimationFrame(() => {
NotificationAnimator.applyAnimation(notification, animation, true);
});
});
const hideNotification = (result) => {
NotificationAnimator.applyAnimation(notification, animation, false);
setTimeout(() => {
if (container.contains(notification)) {
container.removeChild(notification);
}
if (container.children.length === 0 && document.body.contains(container)) {
document.body.removeChild(container);
}
resolve(result);
}, 300);
};
confirmBtn.onclick = () => hideNotification(true);
cancelBtn.onclick = () => hideNotification(false);
});
}
function promptActivationCode() {
const modalOverlay = document.createElement('div');
modalOverlay.style.position = 'fixed';
modalOverlay.style.top = '0';
modalOverlay.style.left = '0';
modalOverlay.style.width = '100%';
modalOverlay.style.height = '100%';
modalOverlay.style.backgroundColor = 'rgba(0, 0, 0, 0.75)';
modalOverlay.style.zIndex = '9999';
modalOverlay.style.display = 'flex';
modalOverlay.style.alignItems = 'center';
modalOverlay.style.justifyContent = 'center';
modalOverlay.style.opacity = '0';
modalOverlay.style.transition = 'opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1)';
modalOverlay.style.backdropFilter = 'blur(8px)';
const modalContainer = document.createElement('div');
modalContainer.style.backgroundColor = '#ffffff';
modalContainer.style.padding = '40px';
modalContainer.style.borderRadius = '20px';
modalContainer.style.boxShadow = '0 20px 50px rgba(0,0,0,0.15), 0 0 20px rgba(0,0,0,0.1)';
modalContainer.style.width = '420px';
modalContainer.style.maxWidth = '90%';
modalContainer.style.textAlign = 'center';
modalContainer.style.position = 'relative';
modalContainer.style.transform = 'scale(0.8) translateY(20px)';
modalContainer.style.transition = 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)';
modalContainer.style.border = '1px solid rgba(255, 255, 255, 0.1)';
const modalHeader = document.createElement('div');
modalHeader.style.marginBottom = '30px';
modalHeader.style.position = 'relative';
const icon = document.createElement('div');
icon.innerHTML = ``;
icon.style.marginBottom = '15px';
icon.style.color = '#4CAF50';
const closeButton = document.createElement('button');
closeButton.innerHTML = `
`;
closeButton.style.cssText = `
position: absolute;
top: 15px;
right: 15px;
background: #f3f4f6;
border: none;
width: 36px;
height: 36px;
border-radius: 50%;
cursor: pointer;
color: #6b7280;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
box-shadow: 0 2px 5px rgba(0,0,0,0.08);
`;
closeButton.onmouseover = () => {
closeButton.style.backgroundColor = '#e5e7eb';
closeButton.style.transform = 'rotate(90deg)';
closeButton.style.color = '#000';
closeButton.style.boxShadow = '0 4px 8px rgba(0,0,0,0.12)';
};
closeButton.onmouseout = () => {
closeButton.style.backgroundColor = '#f3f4f6';
closeButton.style.transform = 'rotate(0deg)';
closeButton.style.color = '#6b7280';
closeButton.style.boxShadow = '0 2px 5px rgba(0,0,0,0.08)';
};
const title = document.createElement('h2');
title.textContent = '输入激活码';
title.style.fontSize = '24px';
title.style.fontWeight = '600';
title.style.color = '#333';
title.style.margin = '0 0 8px 0';
const subtitle = document.createElement('p');
subtitle.textContent = '请输入激活码以继续使用完整功能';
subtitle.style.color = '#666';
subtitle.style.fontSize = '14px';
subtitle.style.margin = '0';
const infoMessage = document.createElement('p');
infoMessage.innerHTML = '关于激活码获取,请移步我的主页或者直接访问爱发电';
infoMessage.style.color = '#666';
infoMessage.style.fontSize = '14px';
infoMessage.style.margin = '10px 0 0 0';
const inputContainer = document.createElement('div');
inputContainer.style.position = 'relative';
inputContainer.style.marginTop = '25px';
const input = document.createElement('input');
input.type = 'text';
input.placeholder = '请输入激活码';
input.style.width = '100%';
input.style.padding = '15px 20px';
input.style.border = '2px solid #e0e0e0';
input.style.borderRadius = '12px';
input.style.fontSize = '16px';
input.style.backgroundColor = '#f8f9fa';
input.style.transition = 'all 0.3s ease';
input.style.boxSizing = 'border-box';
input.style.outline = 'none';
input.addEventListener('focus', () => {
input.style.border = '2px solid #4CAF50';
input.style.backgroundColor = '#ffffff';
input.style.boxShadow = '0 0 0 4px rgba(76, 175, 80, 0.1)';
});
input.addEventListener('blur', () => {
input.style.border = '2px solid #e0e0e0';
input.style.backgroundColor = '#f8f9fa';
input.style.boxShadow = 'none';
});
const confirmButton = document.createElement('button');
confirmButton.textContent = '激活';
confirmButton.style.width = '100%';
confirmButton.style.padding = '15px';
confirmButton.style.marginTop = '20px';
confirmButton.style.border = 'none';
confirmButton.style.borderRadius = '12px';
confirmButton.style.backgroundColor = '#4CAF50';
confirmButton.style.color = '#fff';
confirmButton.style.fontSize = '16px';
confirmButton.style.fontWeight = '600';
confirmButton.style.cursor = 'pointer';
confirmButton.style.transition = 'all 0.3s ease';
confirmButton.style.transform = 'translateY(0)';
confirmButton.style.boxShadow = '0 4px 12px rgba(76, 175, 80, 0.2)';
let isLoading = false;
const setLoadingState = (loading) => {
isLoading = loading;
if (loading) {
confirmButton.innerHTML = '验证中...';
confirmButton.style.backgroundColor = '#45a049';
confirmButton.disabled = true;
} else {
confirmButton.textContent = '激活';
confirmButton.style.backgroundColor = '#4CAF50';
confirmButton.disabled = false;
}
};
const style = document.createElement('style');
style.textContent = `
.loading {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid rgba(255,255,255,.3);
border-radius: 50%;
border-top-color: #fff;
animation: spin 1s ease-in-out infinite;
margin-right: 8px;
vertical-align: middle;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
`;
document.head.appendChild(style);
modalHeader.appendChild(icon);
modalHeader.appendChild(title);
modalHeader.appendChild(subtitle);
modalHeader.appendChild(infoMessage);
modalContainer.appendChild(modalHeader);
modalContainer.appendChild(closeButton);
inputContainer.appendChild(input);
modalContainer.appendChild(inputContainer);
modalContainer.appendChild(confirmButton);
modalOverlay.appendChild(modalContainer);
document.body.appendChild(modalOverlay);
requestAnimationFrame(() => {
modalOverlay.style.opacity = '1';
modalContainer.style.transform = 'scale(1) translateY(0)';
});
function closeModal() {
modalOverlay.style.opacity = '0';
modalContainer.style.transform = 'scale(0.8) translateY(20px)';
setTimeout(() => {
document.body.removeChild(modalOverlay);
document.head.removeChild(style);
}, 400);
}
closeButton.addEventListener('click', () => {
closeModal();
showNotification('请输入激活码。', { type: 'warning', keywords: ['激活码'], animation: 'scale' });
});
modalOverlay.addEventListener('click', (e) => {
if (e.target === modalOverlay) {
closeModal();
showNotification('请输入激活码。', { type: 'warning', keywords: ['激活码'], animation: 'scale' });
}
});
confirmButton.addEventListener('click', () => {
const userCode = input.value.trim();
if (isLoading) return;
if (userCode) {
setLoadingState(true);
const token = getToken();
getCurrentUserInfo(token).then(userInfo => {
if (!userInfo || !userInfo.id) {
showNotification('无法获取小雅用户信息,请先登录小雅。', { type: 'error' });
setLoadingState(false);
return;
}
getApiBaseUrl().then(baseUrl => {
fetch(`${baseUrl}/api/activate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
activation_code: userCode,
platform_user_id: userInfo.id.toString(),
xiaoyaToken: token,
origin: window.location.origin
})
})
.then(res => res.json())
.then(data => {
setLoadingState(false);
if (data.success) {
localStorage.setItem('xiaoya_access_token', data.access_token);
localStorage.setItem('xiaoya_refresh_token', data.refresh_token);
localStorage.setItem('xiaoya_bound_user_id', userInfo.id.toString());
showNotification('激活成功!', { type: 'success', animation: 'scale' });
closeModal();
getAndStoreAnswers();
} else {
showNotification(`激活失败: ${data.error}`, { type: 'error' });
}
})
.catch(err => {
setLoadingState(false);
showNotification(`网络错误: ${err.message}`, { type: 'error' });
});
});
});
} else {
input.style.border = '2px solid #ff4444';
input.style.backgroundColor = '#fff8f8';
showNotification('请输入激活码。', { type: 'warning', keywords: ['激活码'], animation: 'fadeSlide' });
input.focus();
}
});
input.addEventListener('keypress', (e) => {
if (e.key === 'Enter' && !isLoading) {
confirmButton.click();
}
});
}
async function authedFetch(action, payload) {
let accessToken = localStorage.getItem('xiaoya_access_token');
if (!accessToken) {
throw new Error('需要激活');
}
const xiaoyaToken = getToken();
if (!xiaoyaToken) throw new Error('无法获取小雅 Token');
const currentUserInfo = await getCurrentUserInfo(xiaoyaToken);
if (!currentUserInfo || !currentUserInfo.id) {
showNotification('无法获取当前小雅用户信息,请确保已登录。', { type: 'error' });
throw new Error('无法获取当前小雅用户信息');
}
const finalPayload = {
...payload,
xiaoyaToken,
origin: window.location.origin,
current_platform_user_id: currentUserInfo.id.toString(),
script_version: GM_info.script.version
};
const baseUrl = await getApiBaseUrl();
async function doFetch(token) {
return fetch(`${baseUrl}/api/action/${action}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(finalPayload)
});
}
let response = await doFetch(accessToken);
if (response.status === 401 || response.status === 403) {
const errorData = await response.json();
const errorMessage = errorData.error || '';
if (errorData.code === 'FRAUD_DETECTED') {
console.error(`[欺诈检测] 后端返回欺诈警告: ${errorMessage}`);
throw new Error(`欺诈行为警告: ${errorMessage}`);
}
if (errorMessage.includes('重新激活') || errorMessage.includes('用户不存在') || errorMessage.includes('已到期')) {
console.warn(`后端要求重新激活: ${errorMessage}`);
throw new Error(`凭证失效,请重新激活: ${errorMessage}`);
}
if (errorData.code === 'TOKEN_EXPIRED') {
console.log('Access Token 过期,尝试刷新...');
const refreshToken = localStorage.getItem('xiaoya_refresh_token');
if (!refreshToken) {
throw new Error('刷新令牌不存在,请重新激活');
}
const refreshResponse = await fetch(`${baseUrl}/api/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken })
});
if (refreshResponse.ok) {
const refreshData = await refreshResponse.json();
accessToken = refreshData.access_token;
localStorage.setItem('xiaoya_access_token', accessToken);
console.log('Token 刷新成功,重试请求...');
response = await doFetch(accessToken);
} else {
const refreshErrorData = await refreshResponse.json();
const message = refreshErrorData.error || '刷新令牌失败';
if (message.includes('数据库中无效')) {
throw new Error('检测到你可能在其他设备上激活,请重新在此设备上激活。');
}
throw new Error('刷新令牌失败,请重新激活');
}
} else {
if (errorMessage.includes('无效的令牌')) {
throw new Error(`凭证无效,请重新激活: ${errorMessage}`);
}
throw new Error(`认证失败: ${errorMessage || '未知错误'}`);
}
}
if (!response.ok) {
const errorData = await response.json();
throw new Error(`请求失败 (${response.status}): ${errorData.error || response.statusText}`);
}
return response.json();
}
function showUsagePanel() {
const overlay = document.createElement('div');
overlay.id = 'usage-panel-overlay';
overlay.style.cssText = `
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background-color: rgba(0, 0, 0, 0.7); z-index: 10001;
display: flex; align-items: center; justify-content: center;
opacity: 0; transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1); backdrop-filter: blur(8px);
`;
const modal = document.createElement('div');
modal.style.cssText = `
background: linear-gradient(145deg, #f9fafb, #f3f4f6);
padding: 32px 40px; border-radius: 20px;
width: 480px; max-width: 90%;
box-shadow: 0 25px 50px -12px rgba(0,0,0,0.25);
transform: scale(0.9) translateY(20px); opacity: 0;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
position: relative; border: 1px solid rgba(255, 255, 255, 0.2);
display: flex; flex-direction: column;
`;
const closeModal = () => {
modal.style.transform = 'scale(0.9) translateY(20px)';
modal.style.opacity = '0';
overlay.style.opacity = '0';
setTimeout(() => {
if (document.body.contains(overlay)) {
document.body.removeChild(overlay);
}
}, 400);
};
const closeButton = document.createElement('button');
closeButton.innerHTML = `
`;
closeButton.style.cssText = `
position: absolute; top: 18px; right: 18px; background: #e5e7eb; border: none;
width: 36px; height: 36px; border-radius: 50%; cursor: pointer; color: #6b7280;
display: flex; align-items: center; justify-content: center; transition: all 0.3s ease;
`;
closeButton.onmouseover = () => { closeButton.style.transform = 'rotate(90deg) scale(1.1)'; closeButton.style.backgroundColor = '#d1d5db'; };
closeButton.onmouseout = () => { closeButton.style.transform = 'rotate(0deg) scale(1)'; closeButton.style.backgroundColor = '#e5e7eb'; };
closeButton.onclick = closeModal;
const title = document.createElement('h2');
title.innerHTML = `
用量状态
`;
title.style.cssText = 'margin-top: 0; margin-bottom: 30px; text-align: center; color: #1f2937; font-size: 24px; font-weight: 700;';
const contentArea = document.createElement('div');
contentArea.style.cssText = `
min-height: 180px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
`;
const spinnerStyle = document.createElement('style');
spinnerStyle.textContent = `@keyframes usage-spinner { to { transform: rotate(360deg); } }`;
document.head.appendChild(spinnerStyle);
contentArea.innerHTML = `
`;
modal.appendChild(closeButton);
modal.appendChild(title);
modal.appendChild(contentArea);
overlay.appendChild(modal);
document.body.appendChild(overlay);
requestAnimationFrame(() => {
overlay.style.opacity = '1';
modal.style.opacity = '1';
modal.style.transform = 'scale(1) translateY(0)';
});
overlay.onclick = (e) => { if (e.target === overlay) closeModal(); };
return { contentArea, closeModal };
}
function populateUsagePanel(contentArea, usageData, closeModal) {
const { expires_at, total_queries, total_query_limit, daily_queries, daily_query_limit } = usageData;
contentArea.innerHTML = '';
contentArea.style.alignItems = 'stretch';
contentArea.style.justifyContent = 'flex-start';
contentArea.style.flexDirection = 'column';
const createUsageBar = (label, used, limit, color) => {
const container = document.createElement('div');
container.style.marginBottom = '25px';
const labelElement = document.createElement('div');
labelElement.style.cssText = 'display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; font-size: 15px; color: #374151;';
const labelText = document.createElement('span');
labelText.textContent = label;
labelText.style.fontWeight = '600';
const usageText = document.createElement('span');
usageText.style.cssText = `font-weight: 700; color: #374151; font-family: Microsoft YaHei; font-size: 16px;`;
usageText.textContent = `${used.toLocaleString()} / ${limit.toLocaleString()}`;
labelElement.appendChild(labelText);
labelElement.appendChild(usageText);
const progressBarBg = document.createElement('div');
progressBarBg.style.cssText = 'height: 12px; background-color: #e5e7eb; border-radius: 6px; overflow: hidden; box-shadow: inset 0 1px 3px rgba(0,0,0,0.1);';
const progressBarFill = document.createElement('div');
const percentage = limit > 0 ? (used / limit) * 100 : 0;
progressBarFill.style.cssText = `
height: 100%; width: 0%; background: ${color};
border-radius: 6px; transition: width 0.8s cubic-bezier(0.4, 0, 0.2, 1);
`;
progressBarBg.appendChild(progressBarFill);
container.appendChild(labelElement);
container.appendChild(progressBarBg);
setTimeout(() => {
progressBarFill.style.width = `${percentage}%`;
}, 100);
return container;
};
const dailyUsageBar = createUsageBar('今日已用额度', daily_queries, daily_query_limit, 'linear-gradient(90deg, #5eead4, #3b82f6)');
const totalUsageBar = createUsageBar('总剩余额度', total_queries, total_query_limit, 'linear-gradient(90deg, #f87171, #ec4899)');
const expiryContainer = document.createElement('div');
expiryContainer.style.cssText = `
margin-top: 10px; padding: 15px; text-align: center;
background-color: #eef2ff; border: 1px solid #c7d2fe; border-radius: 12px;
`;
const expiryLabel = document.createElement('span');
expiryLabel.textContent = '授权到期时间: ';
expiryLabel.style.color = '#4338ca';
expiryLabel.style.fontWeight = '600';
const expiryDate = document.createElement('span');
expiryDate.textContent = expires_at ? new Date(expires_at * 1000).toLocaleString('zh-CN', { hour12: false }) : 'N/A';
expiryDate.style.fontWeight = '700';
expiryDate.style.color = '#4f46e5';
expiryContainer.appendChild(expiryLabel);
expiryContainer.appendChild(expiryDate);
const actionsContainer = document.createElement('div');
actionsContainer.style.cssText = 'text-align: center; margin-top: 25px; margin-bottom: 10px;';
const renewButton = document.createElement('button');
renewButton.textContent = '续费 / 激活';
renewButton.style.cssText = `
padding: 12px 28px;
border: none;
border-radius: 10px;
background: linear-gradient(145deg, #4f46e5, #3b82f6);
color: white;
cursor: pointer;
font-size: 16px;
font-weight: bold;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 4px 15px rgba(79, 70, 229, 0.25);
`;
renewButton.onmouseover = () => {
renewButton.style.transform = 'translateY(-3px)';
renewButton.style.boxShadow = '0 7px 20px rgba(79, 70, 229, 0.35)';
};
renewButton.onmouseout = () => {
renewButton.style.transform = 'translateY(0)';
renewButton.style.boxShadow = '0 4px 15px rgba(79, 70, 229, 0.25)';
};
renewButton.onclick = () => {
if (closeModal) {
closeModal();
setTimeout(() => {
promptActivationCode();
}, 300);
}
};
actionsContainer.appendChild(renewButton);
const announcementContainer = document.createElement('div');
announcementContainer.style.cssText = `
margin-top: 25px;
margin-bottom: 10px;
padding: 15px 20px;
text-align: left;
background-color: #fffbeb;
border: 1px solid #fde68a;
border-radius: 12px;
font-size: 14px;
line-height: 1.7;
color: #78350f;
`;
const announcementTitle = document.createElement('h4');
announcementTitle.innerHTML = '📢 额度规则调整';
announcementTitle.style.cssText = 'margin-top: 0; margin-bottom: 12px; color: #b45309; font-weight: bold; font-size: 16px;';
const announcementBody = document.createElement('div');
announcementBody.style.cssText = 'margin: 0;';
announcementBody.innerHTML = `
1. 总查询额度调整
- 月卡 (30天): 20,000 题
- 季卡 (90天): 70,000 题
- 年卡 (365天): 300,000 题
2. 每日查询限额
为保障系统稳定,所有用户每日上限统一为 1,000 题,无任何例外。该额度足以满足绝大多数使用场景。
3. 续费与额度规则
- 提前续费: 未过期时续费,剩余额度将与新额度叠加。
- 过期后续费: 已过期再续费,旧额度将清零。
4. 老用户福利
作为感谢,所有在 2025年6月30日前 激活的用户,总额度已统一免费提升至 300,000 题!
`;
announcementContainer.appendChild(announcementTitle);
announcementContainer.appendChild(announcementBody);
contentArea.appendChild(dailyUsageBar);
contentArea.appendChild(totalUsageBar);
contentArea.appendChild(expiryContainer);
contentArea.appendChild(actionsContainer);
contentArea.appendChild(announcementContainer);
}
async function checkUsage() {
if (!(await checkAccountConsistency())) {
console.warn("[操作中止] 因账号不一致,已取消检查用量。");
return;
}
const { contentArea, closeModal } = showUsagePanel();
try {
const data = await authedFetch('checkUsage', {});
if (data.success) {
populateUsagePanel(contentArea, data, closeModal);
} else {
throw new Error(data.error || '获取用量失败');
}
} catch (error) {
console.error('检查用量失败:', error);
if (error.message.includes('激活')) {
closeModal();
setTimeout(promptActivationCode, 300);
} else {
contentArea.innerHTML = `获取用量失败:${error.message}
`;
}
}
}
let taskNoticesCache = {
groupId: null,
data: null,
timestamp: null,
CACHE_DURATION: 5 * 60 * 1000
};
const CONTRIBUTED_ASSIGNMENTS_KEY = 'xiaoya_contributed_assignments';
const CONTRIBUTION_RESCAN_THRESHOLD = 7 * 24 * 60 * 60 * 1000;
function getContributedAssignmentsData() {
try {
const storedData = localStorage.getItem(CONTRIBUTED_ASSIGNMENTS_KEY);
if (!storedData) return {};
const parsedData = JSON.parse(storedData);
if (Object.values(parsedData).some(val => typeof val === 'number')) {
console.log('[贡献数据迁移] 检测到旧的课程级冷却数据,将清空以使用新的作业级冷却机制。');
localStorage.removeItem(CONTRIBUTED_ASSIGNMENTS_KEY);
return {};
}
return (typeof parsedData === 'object' && parsedData !== null) ? parsedData : {};
} catch (error) {
console.error('读取已贡献作业数据失败,将重置:', error);
localStorage.removeItem(CONTRIBUTED_ASSIGNMENTS_KEY);
return {};
}
}
function markAssignmentAsContributed(groupId, nodeId) {
if (!groupId || !nodeId) return;
const contributedData = getContributedAssignmentsData();
const groupIdStr = groupId.toString();
const nodeIdStr = nodeId.toString();
if (!contributedData[groupIdStr]) {
contributedData[groupIdStr] = {};
}
contributedData[groupIdStr][nodeIdStr] = Date.now();
localStorage.setItem(CONTRIBUTED_ASSIGNMENTS_KEY, JSON.stringify(contributedData));
console.log(`[本地记录] 作业 (课程 ${groupId}, 节点 ${nodeId}) 的贡献时间戳已更新。`);
}
async function checkAccountConsistency() {
const boundUserId = localStorage.getItem('xiaoya_bound_user_id');
if (!boundUserId) {
return true;
}
const token = getToken();
if (!token) {
showNotification('无法获取小雅 Token,请刷新页面或重新登录。', { type: 'error' });
return false;
}
const currentUserInfo = await getCurrentUserInfo(token);
if (!currentUserInfo || !currentUserInfo.id) {
showNotification('无法获取当前小雅用户信息,请刷新或重新登录。', { type: 'error' });
return false;
}
if (currentUserInfo.id.toString() !== boundUserId) {
showNotification(
'检测到账号不一致!当前操作需要使用激活时绑定的账号。',
{ type: 'error', duration: 8000 }
);
const confirmed = await showConfirmNotification(
'脚本检测到当前登录的小雅账号与激活脚本时使用的账号不一致。是否要清除当前激活信息,以便使用新账号重新激活?(你的激活码依旧有效)',
{
animation: 'scale',
title: '账号不一致警告',
confirmText: '清除并重新激活',
cancelText: '取消操作'
}
);
if (confirmed) {
localStorage.removeItem('xiaoya_access_token');
localStorage.removeItem('xiaoya_refresh_token');
localStorage.removeItem('xiaoya_bound_user_id');
setTimeout(() => promptActivationCode(), 300);
}
return false;
}
return true;
}
async function getTaskNotices(groupId) {
const now = Date.now();
if (
taskNoticesCache.groupId === groupId &&
taskNoticesCache.data &&
(now - taskNoticesCache.timestamp) < taskNoticesCache.CACHE_DURATION
) {
return taskNoticesCache.data;
}
try {
const response = await fetch(
`${window.location.origin}/api/jx-stat/group/task/queryTaskNotices?group_id=${groupId}&role=1`,
{
headers: {
'authorization': `Bearer ${getToken()}`,
'content-type': 'application/json; charset=utf-8'
}
}
);
const data = await response.json();
if (!data.success) {
throw new Error('获取作业信息失败');
}
taskNoticesCache = {
groupId,
data: data.data,
timestamp: now,
CACHE_DURATION: taskNoticesCache.CACHE_DURATION
};
return data.data;
} catch (error) {
console.error('获取任务信息失败:', error);
return null;
}
}
async function checkAssignmentStatus(groupId, nodeId) {
try {
const data = await getTaskNotices(groupId);
if (!data) return null;
const tasks = data.student_tasks || [];
const task = tasks.find(t => t.node_id === nodeId);
if (task) {
const endTime = new Date(task.end_time);
const now = new Date();
const isExpired = now > endTime;
const isCompleted = task.finish === 2;
return {
isExpired,
isCompleted,
canSubmitAfterExpired: task.is_allow_after_submitted,
endTime,
status: isCompleted ? '已完成' : (isExpired ? '已截止' : '进行中')
};
}
throw new Error('未找到作业信息');
} catch (error) {
console.error('检查作业状态失败:', error);
return null;
}
}
async function isTaskPage() {
const groupId = getGroupIDFromUrl(window.location.href);
const nodeId = getNodeIDFromUrl(window.location.href);
if (!groupId || !nodeId) {
return false;
}
const taskData = await getTaskNotices(groupId);
if (!taskData || !taskData.student_tasks) {
return false;
}
const currentTask = taskData.student_tasks.find(task => task.node_id === nodeId);
if (!currentTask) {
return false;
}
const validTaskTypes = [2, 3, 4, 5];
return validTaskTypes.includes(currentTask.task_type);
}
async function getAnswerRecordId(nodeId, groupId, token) {
try {
const status = await checkAssignmentStatus(groupId, nodeId);
if (status) {
if (status.isCompleted) {
showNotification(`该作业已完成,将不会获取答题记录,仅可查看答案。`, {
type: 'warning',
keywords: ['已完成'],
animation: 'scale'
});
return null;
}
if (status.isExpired) {
if (!status.canSubmitAfterExpired) {
showNotification(`作业已于 ${status.endTime.toLocaleString()} 截止,且不允许补交,仅可查看答案。`, {
type: 'warning',
keywords: ['截止', '不允许补交'],
animation: 'fadeSlide'
});
return null;
}
showNotification(`作业已于 ${status.endTime.toLocaleString()} 截止,但允许补交。`, {
type: 'info',
keywords: ['截止', '允许补交'],
animation: 'slideRight'
});
}
}
} catch (error) {
console.warn("检查作业状态时发生错误,将继续尝试获取记录ID:", error);
}
const url = `${window.location.origin}/api/jx-iresource/survey/course/task/flow/v2?node_id=${nodeId}&group_id=${groupId}`;
console.log('[答题记录] 正在请求任务流程信息:', url);
try {
const response = await fetch(url, {
method: 'GET',
headers: {
'authorization': `Bearer ${token}`,
'content-type': 'application/json; charset=utf-8'
},
credentials: 'include'
});
if (!response.ok) {
let errorMsg = `获取答题记录ID失败,服务器状态: ${response.status}`;
try {
const errorData = await response.json();
errorMsg = errorData.message || errorMsg;
} catch (e) {
}
throw new Error(errorMsg);
}
const data = await response.json();
if (data.success && data.data) {
let recordId = null;
if (data.data.task_flow_record && Array.isArray(data.data.task_flow_record) && data.data.task_flow_record.length > 0) {
const record = data.data.task_flow_record[0];
if (record && record.answer_record_id) {
recordId = record.answer_record_id;
console.log(`[答题记录] 从 task_flow_record 成功获取 answer_record_id: ${recordId}`);
return recordId;
}
}
if (!recordId && data.data.task_flow_template && Array.isArray(data.data.task_flow_template) && data.data.task_flow_template.length > 0) {
const template = data.data.task_flow_template[0];
if (template && template.answer_record_id) {
recordId = template.answer_record_id;
console.log(`[答题记录] 从 task_flow_template (兼容模式) 成功获取 answer_record_id: ${recordId}`);
return recordId;
}
}
}
throw new Error('未找到有效的答题记录。请先进入该作业的答题页面以生成它,然后再返回此页面重试。');
} catch (error) {
console.error('获取 answer_record_id 时发生错误:', error);
throw error;
}
}
async function getAndStoreAnswers() {
if (!(await isTaskPage())) {
showNotification('当前不是有效的作业/测验页面,或者脚本无法识别。', { type: 'warning' });
return false;
}
const token = getToken();
if (!token) {
showNotification('无法获取token,请确保已登录。', { type: 'error' });
return false;
}
if (!(await checkAccountConsistency())) {
console.warn("[操作中止] 因账号不一致,已取消获取答案。");
return false;
}
const currentUrl = window.location.href;
const node_id = getNodeIDFromUrl(currentUrl);
const group_id = getGroupIDFromUrl(currentUrl);
if (!node_id || !group_id) {
showNotification('无法获取必要参数,请确保在正确的页面。', { type: 'error' });
return false;
}
const progress = createProgressBar();
progress.show();
let overallSuccess = false;
let hitCount = 0;
let missCount = 0;
let totalQueryableQuestions = 0;
try {
progress.update(0, 100, '正在获取试卷结构', '%');
const resourceResponse = await fetch(`${window.location.origin}/api/jx-iresource/resource/queryResource/v3?node_id=${node_id}`, { headers: { 'authorization': `Bearer ${token}` }, credentials: 'include' });
const resourceData = await resourceResponse.json();
if (!resourceData.success || !resourceData.data || !resourceData.data.resource) {
throw new Error('获取试卷资源失败: ' + (resourceData.message || '返回数据结构不正确'));
}
progress.update(5, 100, '试卷结构获取成功', '%');
const paperId = resourceData.data.resource.id;
const assignmentTitle = resourceData.data.resource.title || '作业答案';
const paperDescription = resourceData.data.resource.description || null;
if (paperDescription) {
localStorage.setItem('paperDescription', paperDescription);
console.log('[全局上下文] 已保存作业头部描述信息。');
} else {
localStorage.removeItem('paperDescription');
}
let questionsFromResource = JSON.parse(JSON.stringify(resourceData.data.resource.questions || []));
progress.update(7, 100, '正在获取答题记录', '%');
const recordId = await getAnswerRecordId(node_id, group_id, token);
localStorage.setItem('recordId', recordId || '');
progress.update(10, 100, '答题记录获取成功', '%');
localStorage.setItem('groupId', group_id);
localStorage.setItem('paperId', paperId);
localStorage.setItem('assignmentTitle', assignmentTitle);
function mergeAnswerIntoQuestion(question, detailedQuestionInfo) {
if (detailedQuestionInfo.title && detailedQuestionInfo.title !== '{}' && detailedQuestionInfo.title !== question.title) {
question.title = detailedQuestionInfo.title;
}
if (!Array.isArray(question.answer_items) || !Array.isArray(detailedQuestionInfo.answer_items)) {
console.warn(`问题 ${question.id}: 原始题目或数据库答案的 answer_items 格式不正确,无法合并。`);
return;
}
switch (question.type) {
case 1: case 2: {
const valueToAnswerInfoMap = new Map();
detailedQuestionInfo.answer_items.forEach(apiItem => {
const identifier = getCanonicalContent(apiItem.value);
if (identifier) {
valueToAnswerInfoMap.set(identifier, {
answer_checked: apiItem.answer_checked
});
}
});
question.answer_items.forEach(qItem => {
const identifier = getCanonicalContent(qItem.value);
const answerInfo = valueToAnswerInfoMap.get(identifier);
if (answerInfo) {
qItem.answer_checked = answerInfo.answer_checked;
} else {
qItem.answer_checked = 1;
console.warn(`问题 ${question.id} (选择题): 无法根据内容匹配选项。`, qItem.value);
}
});
break;
}
case 5: {
const dbCorrectAnswer = detailedQuestionInfo.answer_items.find(item => item.answer_checked === 2);
if (!dbCorrectAnswer) {
console.warn(`问题 ${question.id} (判断题): 题库中未找到正确答案。`);
break;
}
const isDbAnswerTrue = dbCorrectAnswer.value === 'true';
question.answer_items.forEach(qItem => {
const pageOptionText = getCanonicalContent(qItem.value) || parseRichTextToPlainText(qItem.value);
const isPageOptionTrue = pageOptionText.includes('正确') || pageOptionText.toLowerCase().includes('true');
if (isDbAnswerTrue === isPageOptionTrue) {
qItem.answer_checked = 2;
} else {
qItem.answer_checked = 1;
}
});
break;
}
case 4: {
if (question.answer_items.length !== detailedQuestionInfo.answer_items.length) {
console.warn(`问题 ${question.id} (填空题): 原始题目与答案的空的数量不匹配,可能导致答案错位。原始: ${question.answer_items.length}, 答案: ${detailedQuestionInfo.answer_items.length}`);
}
const minLength = Math.min(question.answer_items.length, detailedQuestionInfo.answer_items.length);
for (let i = 0; i < minLength; i++) {
if (detailedQuestionInfo.answer_items[i] && detailedQuestionInfo.answer_items[i].answer !== undefined) {
question.answer_items[i].answer = detailedQuestionInfo.answer_items[i].answer;
}
}
break;
}
case 12: case 13: {
if (question.type === 12) {
const valueToAnswerInfoMap = new Map();
detailedQuestionInfo.answer_items.forEach(apiItem => {
const identifier = getCanonicalContent(apiItem.value);
if (identifier) {
valueToAnswerInfoMap.set(identifier, { answer: apiItem.answer });
}
});
question.answer_items.forEach(qItem => {
const identifier = getCanonicalContent(qItem.value);
const answerInfo = valueToAnswerInfoMap.get(identifier);
if (answerInfo && answerInfo.answer !== null && answerInfo.answer !== undefined) {
qItem.answer = answerInfo.answer;
}
});
} else {
const currentOptionContentToIdMap = new Map();
question.answer_items.forEach(item => {
if (item.is_target_opt) {
const identifier = getCanonicalContent(item.value);
if (identifier) {
currentOptionContentToIdMap.set(identifier, item.id);
}
}
});
const dbStemValueToAnswerContentMap = new Map();
detailedQuestionInfo.answer_items.forEach(apiItem => {
if (!apiItem.is_target_opt) {
const keyIdentifier = getCanonicalContent(apiItem.value);
const valueIdentifier = getCanonicalContent(apiItem.answer);
if (keyIdentifier) {
dbStemValueToAnswerContentMap.set(keyIdentifier, valueIdentifier);
}
}
});
question.answer_items.forEach(qItem => {
if (!qItem.is_target_opt) {
const stemIdentifier = getCanonicalContent(qItem.value);
const correctOptionIdentifier = dbStemValueToAnswerContentMap.get(stemIdentifier);
if (correctOptionIdentifier) {
const currentCorrectOptionId = currentOptionContentToIdMap.get(correctOptionIdentifier);
if (currentCorrectOptionId) {
qItem.answer = currentCorrectOptionId;
} else {
console.warn(`问题 ${question.id} (匹配题): 找到了答案内容 "${correctOptionIdentifier}",但在当前页面选项中找不到匹配项。`);
}
}
}
});
}
break;
}
case 6: case 10: {
if (detailedQuestionInfo.answer_items?.[0]?.answer !== null && detailedQuestionInfo.answer_items?.[0]?.answer !== undefined) {
let rawAnswer = detailedQuestionInfo.answer_items[0].answer;
let finalAnswerObject = deepParseJsonString(rawAnswer);
let finalAnswerString = (typeof finalAnswerObject === 'object')
? JSON.stringify(finalAnswerObject)
: String(finalAnswerObject);
if (!question.answer_items || question.answer_items.length === 0) {
question.answer_items = [{ answer: finalAnswerString }];
} else {
question.answer_items[0].answer = finalAnswerString;
}
}
if (question.type === 10 && detailedQuestionInfo.program_setting) { question.program_setting = detailedQuestionInfo.program_setting; }
break;
}
default:
console.log(`问题 ${question.id}: 类型 ${question.type} 暂无特殊答案处理逻辑。`);
break;
}
}
const questionsToQuery = [];
const SUPPORTED_QUERY_TYPES = [1, 2, 4, 5, 6, 10, 12, 13];
const processQuestionForQuery = (q) => {
if (!q) return;
if (q.type === 9) {
if (q.subQuestions) {
q.subQuestions.forEach(processQuestionForQuery);
}
} else if (SUPPORTED_QUERY_TYPES.includes(q.type)) {
const hash = generateContentHash(q);
if (hash) {
questionsToQuery.push({
question_id: q.id,
content_hash: hash,
paper_id: paperId,
group_id: group_id
});
} else {
console.warn(`[答案获取] 无法为题目 ${q.id} 生成哈希,跳过查询。`);
}
} else {
console.warn(`[答案获取] 跳过不支持的题型 ${q.id} (类型: ${q.type})`);
}
};
questionsFromResource.forEach(processQuestionForQuery);
totalQueryableQuestions = questionsToQuery.length;
if (totalQueryableQuestions === 0) {
throw new Error('试卷中没有支持查询的题目');
}
const chunkSize = 30;
let allAggregatedAnswers = [];
progress.update(10, 100, `分批请求答案 (共 ${totalQueryableQuestions} 题)...`, '%');
for (let i = 0; i < questionsToQuery.length; i += chunkSize) {
const chunk = questionsToQuery.slice(i, i + chunkSize);
const currentProgress = 10 + (i / questionsToQuery.length) * 80;
progress.update(currentProgress, 100, `请求第 ${Math.floor(i / chunkSize) + 1} 批答案...`, '%');
const batchResult = await authedFetch('queryAllAnswers', { questionsToQuery: chunk });
if (!batchResult.success || !Array.isArray(batchResult.allAnswers)) {
throw new Error(`获取批次答案失败: ${batchResult.error || '后端返回数据格式不正确'}`);
}
allAggregatedAnswers.push(...batchResult.allAnswers);
}
progress.update(90, 100, `所有批次请求成功,处理数据...`, '%');
const allAnswersMap = new Map();
hitCount = 0;
allAggregatedAnswers.forEach(item => {
if (!item || !item.result) { console.warn(`获取问题 ${item?.question_id} 答案失败: 无效的返回项`); return; }
const questionData = item.result;
if (questionData && questionData.type) {
hitCount++;
allAnswersMap.set(item.question_id, questionData);
} else {
console.warn(`获取问题 ${item?.question_id} 答案失败:`, questionData.error || '无法识别的数据格式或未找到答案');
}
});
missCount = totalQueryableQuestions - hitCount;
progress.update(95, 100, '正在合并答案...', '%');
questionsFromResource.forEach(question => {
const detailedQuestionInfo = allAnswersMap.get(question.id);
if (detailedQuestionInfo) {
mergeAnswerIntoQuestion(question, detailedQuestionInfo);
}
if (question.type === 9 && question.subQuestions) {
question.subQuestions.forEach(subQuestion => {
const detailedSubQuestionInfo = allAnswersMap.get(subQuestion.id);
if (detailedSubQuestionInfo) {
mergeAnswerIntoQuestion(subQuestion, detailedSubQuestionInfo);
}
});
}
});
localStorage.setItem('answerData', JSON.stringify(questionsFromResource));
progress.update(100, 100, '所有答案信息获取完成', '!');
overallSuccess = true;
} catch (error) {
console.error('获取或处理答案失败:', error);
const errorMessage = error.message.toLowerCase();
if (errorMessage.includes('欺诈行为警告')) {
showNotification('检测到异常操作,你的授权已被吊销,请重新激活。', { type: 'error', duration: 8000, animation: 'scale' });
localStorage.removeItem('xiaoya_access_token'); localStorage.removeItem('xiaoya_refresh_token'); setTimeout(promptActivationCode, 1000);
} else if (errorMessage.includes('激活')) {
showNotification('你的凭证已失效或需要激活,请操作...', { type: 'warning', duration: 5000, animation: 'scale' });
setTimeout(promptActivationCode, 500);
} else {
showNotification(`获取答案数据失败:${error.message}`, { type: 'error' });
}
overallSuccess = false;
} finally {
progress.hide();
if (overallSuccess) {
let message;
let type;
let keywords = [String(hitCount), String(missCount), String(totalQueryableQuestions)];
if (hitCount === totalQueryableQuestions && totalQueryableQuestions > 0) {
message = `答案获取成功!题库精准命中全部 ${totalQueryableQuestions} 道题!`;
type = 'success';
} else if (hitCount > 0) {
message = `答案获取成功!共命中 ${hitCount} 道,未命中 ${missCount} 道。`;
type = 'success';
keywords.push('命中', '未命中');
} else {
message = `答案获取完成,但题库暂无收录 (共查询 ${totalQueryableQuestions} 道题)。`;
type = 'warning';
keywords.push('暂无收录');
}
showNotification(message, { type: type, keywords: keywords, animation: 'slideRight', duration: 8000 });
}
}
return overallSuccess;
}
const SUPPORTED_CONTRIBUTION_TYPES = [1, 2, 4, 5, 6, 10, 12, 13];
function hasValidAnswer_frontEnd(questionData) {
if (!questionData || !SUPPORTED_CONTRIBUTION_TYPES.includes(questionData.type)) {
return false;
}
if (!Array.isArray(questionData.answer_items)) {
return false;
}
switch (questionData.type) {
case 1:
case 2:
case 5:
return questionData.answer_items.some(item => item.answer_checked === 2);
case 4:
return questionData.answer_items.some(item => {
const answer = item.answer;
if (answer === null || answer === undefined || answer === '' || answer === '{}') return false;
try {
const parsed = JSON.parse(answer);
if (parsed.blocks && parsed.blocks.length === 1 && parsed.blocks[0].text === '') {
return false;
}
} catch (e) {
}
return true;
});
case 12:
return questionData.answer_items.length > 0 && questionData.answer_items.every(
item => item.answer !== null && item.answer !== undefined && item.answer !== ''
);
case 13:
return questionData.answer_items.some(
item => !item.is_target_opt && item.answer !== null && item.answer !== undefined && item.answer !== ''
);
case 6:
case 10: {
if (questionData.answer_items.length === 0) return false;
const answer = questionData.answer_items[0]?.answer;
if (answer === null || answer === undefined || answer === '') return false;
try {
const parsed = JSON.parse(answer);
if (parsed.blocks && Array.isArray(parsed.blocks)) {
if (parsed.blocks.length === 0) return false;
if (parsed.blocks.length === 1 && parsed.blocks[0].text === '') {
return parsed.blocks[0].type === 'atomic';
}
}
} catch (e) {
}
return true;
}
default:
return false;
}
}
async function contributeSingleAssignment(groupId, nodeId) {
const token = getToken();
if (!token) return { success: false, error: '无法获取token' };
try {
const resourceResponse = await fetch(`${window.location.origin}/api/jx-iresource/resource/queryResource/v3?node_id=${nodeId}`, { headers: { 'authorization': `Bearer ${token}` } });
const resourceData = await resourceResponse.json();
if (!resourceData.success) return { success: false, error: '获取试卷资源失败' };
const paperId = resourceData.data?.resource?.id;
if (!paperId) return { success: false, error: '无法从资源中获取 paperId' };
const answerSheetResponse = await fetch(`${window.location.origin}/api/jx-iresource/survey/course/queryStuPaper/v2?paper_id=${paperId}&group_id=${groupId}&node_id=${nodeId}`, { headers: { 'authorization': `Bearer ${token}` } });
const answerSheetData = await answerSheetResponse.json();
if (!answerSheetData.success || !answerSheetData.data || !answerSheetData.data.questions || answerSheetData.data.questions.length === 0) {
return { success: false, error: '获取答案数据失败: ' + (answerSheetData.message || '无题目信息') };
}
const flattenQuestions = (questionList) => {
let flatList = [];
if (!Array.isArray(questionList)) {
console.warn("[flattenQuestions] 输入不是一个数组:", questionList);
return flatList;
}
questionList.forEach(q => {
if (!q) return;
flatList.push(q);
if (q.type === 9 && Array.isArray(q.subQuestions)) {
flatList.push(...flattenQuestions(q.subQuestions));
}
});
return flatList;
};
const clonedQuestions = JSON.parse(JSON.stringify(answerSheetData.data.questions));
const allClonedQuestionsMap = new Map(flattenQuestions(clonedQuestions).map(q => [q.id, q]));
const originalQuestionsData = answerSheetData.data.questions;
const allOriginalQuestionsMap = new Map(flattenQuestions(originalQuestionsData).map(q => [q.id, q]));
const studentCorrectAnswers = new Map();
if (answerSheetData.data.answer_record && answerSheetData.data.answer_record.answers) {
answerSheetData.data.answer_record.answers.forEach(ans => {
if (ans.correct === 2 || ans.score > 0) {
studentCorrectAnswers.set(ans.question_id, ans.answer);
}
});
}
allClonedQuestionsMap.forEach(question => {
const studentAnswer = studentCorrectAnswers.get(question.id);
if (studentAnswer !== undefined) {
console.log(`[贡献] 题目 ${question.id} (类型 ${question.type}) 使用【学生正确作答记录】填充。`);
switch (question.type) {
case 1: case 5:
question.answer_items.forEach(item => { item.answer_checked = (item.id === String(studentAnswer)) ? 2 : 1; });
break;
case 2: {
let selectedItemIds = [];
if (Array.isArray(studentAnswer)) selectedItemIds = studentAnswer.map(String);
else if (typeof studentAnswer === 'string') selectedItemIds = studentAnswer.split(',').map(id => id.trim()).filter(id => id);
question.answer_items.forEach(item => { item.answer_checked = selectedItemIds.includes(item.id) ? 2 : 1; });
break;
}
case 4:
try {
const fillAnswersObject = JSON.parse(studentAnswer);
question.answer_items.forEach(item => { if (fillAnswersObject.hasOwnProperty(item.id)) { item.answer = JSON.stringify({ blocks: [{ text: fillAnswersObject[item.id] || '' }] }); } });
} catch (e) { console.warn(`[贡献] 解析学生填空题答案失败 (ID: ${question.id})`, e); }
break;
case 6:
if (question.answer_items && question.answer_items.length > 0) question.answer_items[0].answer = JSON.stringify({ blocks: [{ text: studentAnswer }] });
break;
case 10:
try {
const parsedAnswer = JSON.parse(studentAnswer);
if (!question.program_setting) question.program_setting = {};
question.program_setting.code_answer = parsedAnswer.code || studentAnswer;
} catch (e) {
if (!question.program_setting) question.program_setting = {};
question.program_setting.code_answer = studentAnswer;
}
break;
case 12: {
let sortedItemIds = [];
if (Array.isArray(studentAnswer)) sortedItemIds = studentAnswer.map(String);
else if (typeof studentAnswer === 'string') sortedItemIds = studentAnswer.split(',').map(id => id.trim()).filter(id => id);
question.answer_items.forEach(item => { const order = sortedItemIds.indexOf(item.id); item.answer = (order !== -1) ? (order + 1).toString() : ''; });
break;
}
case 13:
try {
const matchObject = (typeof studentAnswer === 'string' ? JSON.parse(studentAnswer) : studentAnswer)[0] || (typeof studentAnswer === 'string' ? JSON.parse(studentAnswer) : studentAnswer);
const optionIdToValueMap = new Map();
question.answer_items.forEach(item => {
if (item.is_target_opt) {
optionIdToValueMap.set(item.id, item.value);
}
});
question.answer_items.forEach(item => {
if (!item.is_target_opt && matchObject.hasOwnProperty(item.id)) {
const matchedOptionId = matchObject[item.id];
if (optionIdToValueMap.has(matchedOptionId)) {
item.answer = optionIdToValueMap.get(matchedOptionId);
}
}
});
} catch (e) { console.warn(`[贡献] 解析学生匹配题答案失败 (ID: ${question.id})`, e); }
break;
}
}
else {
const originalQuestion = allOriginalQuestionsMap.get(question.id);
if (originalQuestion && hasValidAnswer_frontEnd(originalQuestion)) {
console.log(`[贡献] 题目 ${question.id} (类型 ${question.type}) 无学生作答记录,但使用【原始官方答案】。`);
} else {
console.log(`[贡献] 题目 ${question.id} (类型 ${question.type}) 无任何有效答案源,将在后续被过滤。`);
}
}
});
const contributedQuestions = Array.from(allClonedQuestionsMap.values()).filter(q => hasValidAnswer_frontEnd(q));
console.log(`[贡献] 准备贡献 ${contributedQuestions.length} 道高质量题目。`);
if (contributedQuestions.length === 0) {
return { success: false, error: '未解析到任何有效答案' };
}
const finalContributedData = contributedQuestions.map(q => {
const hash = generateContentHash(q);
if (!hash) {
console.warn(`[贡献] 无法为题目 ${q.id} 生成哈希,跳过贡献。`);
return null;
}
return {
question_id: q.id,
paper_id: q.paper_id,
content_hash: hash,
answer_data: q
};
}).filter(Boolean);
if (finalContributedData.length === 0) {
return { success: false, error: '所有可贡献题目都无法生成有效哈希' };
}
const response = await authedFetch('contributeAnswers', { contributedQuestions: finalContributedData });
if (response.success) {
markAssignmentAsContributed(groupId, nodeId);
return { success: true, message: response.message };
} else {
return { success: false, error: response.error || '上传贡献失败' };
}
} catch (error) {
console.error(`贡献作业 (nodeId: ${nodeId}) 时出错:`, error);
return { success: false, error: error.message };
}
}
async function asyncPool(poolLimit, array, iteratorFn) {
const ret = [];
const executing = [];
for (const item of array) {
const p = Promise.resolve().then(() => iteratorFn(item, array));
ret.push(p);
if (poolLimit <= array.length) {
const e = p.then(() => executing.splice(executing.indexOf(e), 1));
executing.push(e);
if (executing.length >= poolLimit) {
await Promise.race(executing);
}
}
}
return Promise.all(ret);
}
async function scanAndContributeCourse(course) {
const groupId = course.id;
const contributedData = getContributedAssignmentsData();
const now = Date.now();
try {
const tasksData = await getTaskNotices(groupId);
if (!tasksData || !tasksData.student_tasks) {
console.error(`[后台扫描] 获取课程 "${course.name}" (ID: ${groupId}) 的任务列表失败。`);
return { success: 0, failed: 0 };
}
const validTaskTypes = [2, 3, 4, 5];
const allAssignments = tasksData.student_tasks.filter(task =>
validTaskTypes.includes(task.task_type)
);
const assignmentsToScan = allAssignments.filter(task => {
const lastScanTimestamp = contributedData[groupId.toString()]?.[task.node_id.toString()];
if (!lastScanTimestamp) return true;
if (now - lastScanTimestamp > CONTRIBUTION_RESCAN_THRESHOLD) return true;
return false;
});
if (assignmentsToScan.length === 0) {
console.log(`[后台扫描] 课程 "${course.name}" (ID: ${groupId}) 中没有需要贡献的新作业。`);
return { success: 0, failed: 0 };
}
console.log(`[后台扫描] 课程 "${course.name}" (ID: ${groupId}) 中发现 ${assignmentsToScan.length} 个需要处理的作业。`);
let successCount = 0;
let failCount = 0;
const CONCURRENCY_LIMIT = 2;
await asyncPool(CONCURRENCY_LIMIT, assignmentsToScan, async (task) => {
const result = await contributeSingleAssignment(groupId, task.node_id);
if (result.success) {
successCount++;
} else {
if (result.error === '未解析到任何有效答案') {
console.log(`[后台扫描] 作业 (ID: ${task.node_id}) 无有效答案可贡献,标记为已检查。`);
markAssignmentAsContributed(groupId, task.node_id);
} else {
failCount++;
console.warn(`[后台扫描] 贡献作业 (ID: ${task.node_id}) 失败: ${result.error}`);
}
}
await new Promise(resolve => setTimeout(resolve, 800));
});
return { success: successCount, failed: failCount };
} catch (error) {
console.error(`[后台扫描] 处理课程 "${course.name}" (ID: ${groupId}) 时发生严重错误:`, error);
return { success: 0, failed: 1 };
}
}
async function backgroundContributeAllCourses() {
if (!autoContributeEnabled) {
return false;
}
if (!(await checkAccountConsistency())) {
console.log("[后台扫描] 因账号不一致,已中止全量扫描。");
return false;
}
const token = getToken();
if (!token) {
return false;
}
ContributionProgressUI.show('正在准备后台贡献任务...');
console.log('[后台扫描] 开始执行全量课程扫描...');
try {
const MAX_RETRIES = 3;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
console.log(`[后台扫描] 正在进行用量预检 (尝试 ${attempt}/${MAX_RETRIES})...`);
await authedFetch('checkUsage', {});
console.log(`[后台扫描] 用量预检成功。`);
break;
} catch (error) {
console.warn(`[后台扫描] 用量预检尝试 ${attempt} 失败:`, error.message);
if (attempt < MAX_RETRIES) {
const delay = 1500 * attempt;
console.log(`[后台扫描] 将在 ${delay / 1000} 秒后重试...`);
await new Promise(resolve => setTimeout(resolve, delay));
} else {
console.error(`[后台扫描] 用量预检在 ${MAX_RETRIES} 次尝试后彻底失败,后台贡献任务中止。`);
throw error;
}
}
}
const fetchCourses = async (timeFlag) => {
const url = `${window.location.origin}/api/jx-iresource/group/student/groups?time_flag=${timeFlag}`;
const response = await fetch(url, { headers: { 'authorization': `Bearer ${token}` } });
if (!response.ok) throw new Error(`获取课程列表失败 (flag=${timeFlag})`);
const data = await response.json();
return data.success ? data.data : [];
};
const [currentCourses, pastCourses] = await Promise.all([fetchCourses(1), fetchCourses(3)]);
const allCoursesMap = new Map();
[...currentCourses, ...pastCourses].forEach(course => course && course.id && allCoursesMap.set(course.id, course));
const allCourses = Array.from(allCoursesMap.values());
if (allCourses.length === 0) {
console.log('[后台扫描] 未获取到任何课程列表,任务结束。');
ContributionProgressUI.complete('未找到任何课程。');
return true;
}
ContributionProgressUI.show(`后台将检查 ${allCourses.length} 门课程中的新作业...`);
showNotification(`后台将为你检查所有课程,寻找可贡献的新答案...`, { type: 'info', duration: 7000 });
let totalNewContributions = 0;
for (let i = 0; i < allCourses.length; i++) {
const course = allCourses[i];
console.log(`[后台扫描] [${i + 1}/${allCourses.length}] 正在检查课程: ${course.name}`);
ContributionProgressUI.update(i + 1, allCourses.length, course.name);
const result = await scanAndContributeCourse(course);
totalNewContributions += result.success;
}
console.log(`[后台扫描] 全部完成!本次共贡献了 ${totalNewContributions} 个新作业。`);
ContributionProgressUI.complete(`扫描完成!感谢你的 ${totalNewContributions} 个新贡献!`);
if (totalNewContributions > 0) {
showNotification(`后台扫描完成,感谢你为答案库贡献了 ${totalNewContributions} 个新作业!`, { type: 'success', duration: 10000 });
}
return true;
} catch (error) {
const errorMessage = error.message.toLowerCase();
if (errorMessage.includes('激活') || errorMessage.includes('失效') || errorMessage.includes('到期') || errorMessage.includes('欺诈')) {
console.error(`[后台扫描] 因授权问题中止: "${error.message}"`);
} else {
console.error('[后台扫描] 操作失败:', error);
}
ContributionProgressUI.error(error.message);
return false;
}
}
async function getSubmittedAnswers() {
if (!(await isTaskPage())) {
showNotification('当前不是有效的作业/测验页面,或者脚本无法识别。', {
type: 'warning',
keywords: ['作业', '测验'],
animation: 'scale'
});
return;
}
try {
const token = getToken();
if (!token) {
showNotification('无法获取token,请确保已登录。', {
type: 'error',
keywords: ['token', '登录'],
animation: 'fadeSlide'
});
return;
}
const currentUrl = window.location.href;
const node_id = getNodeIDFromUrl(currentUrl);
const group_id = getGroupIDFromUrl(currentUrl);
if (!node_id || !group_id) {
showNotification('无法获取必要参数,请确保在正确的页面。', {
type: 'error',
keywords: ['参数'],
animation: 'slideRight'
});
return;
}
const progress = createProgressBar();
progress.show();
progress.update(0, 1, '正在获取已提交作业');
const resourceData = await fetch(
`${window.location.origin}/api/jx-iresource/resource/queryResource/v3?node_id=${node_id}`,
{
headers: {
'authorization': `Bearer ${token}`,
'content-type': 'application/json; charset=utf-8'
},
credentials: 'include'
}
).then(res => res.json());
if (!resourceData.success) {
throw new Error('获取试卷资源失败');
}
const paperDescription = resourceData.data?.resource?.description || null;
if (paperDescription) {
localStorage.setItem('paperDescription', paperDescription);
console.log('[全局上下文 - 已提交] 已同步更新作业头部描述信息。');
} else {
localStorage.removeItem('paperDescription');
console.log('[全局上下文 - 已提交] 当前作业无头部描述,已清除旧的缓存。');
}
const paper_id = resourceData.data.resource.id;
const submittedAnswerResponse = await fetch(
`${window.location.origin}/api/jx-iresource/survey/course/queryStuPaper/v2?paper_id=${paper_id}&group_id=${group_id}&node_id=${node_id}`,
{
headers: {
'authorization': `Bearer ${token}`,
'content-type': 'application/json; charset=utf-8'
},
credentials: 'include'
}
);
const submittedAnswerData = await submittedAnswerResponse.json();
progress.update(1, 1, '已获取提交答案');
if (!submittedAnswerData.success) {
throw new Error('获取已提交作业失败');
}
if (submittedAnswerData.data && submittedAnswerData.data.answer_record &&
submittedAnswerData.data.answer_record.answers &&
submittedAnswerData.data.answer_record.answers.length > 0) {
localStorage.setItem('submittedAnswerData', JSON.stringify(submittedAnswerData.data.answer_record.answers));
const questionsData = resourceData.data.resource.questions;
const allQuestionsMap = new Map();
questionsData.forEach(q => {
allQuestionsMap.set(q.id, q);
if (q.type === 9 && q.subQuestions) {
q.subQuestions.forEach(sq => {
allQuestionsMap.set(sq.id, sq);
sq.parent_question_id = q.id;
});
}
});
submittedAnswerData.data.answer_record.answers.forEach(submittedAnswer => {
const questionId = submittedAnswer.question_id;
const question = allQuestionsMap.get(questionId);
if (question) {
if (question.type === 1 || question.type === 5) {
const selectedItemId = String(submittedAnswer.answer);
if (question.answer_items) {
question.answer_items.forEach(item => {
item.answer_checked = (item.id === selectedItemId) ? 2 : 1;
});
}
} else if (question.type === 2) {
let selectedItemIds = [];
if (Array.isArray(submittedAnswer.answer)) {
selectedItemIds = submittedAnswer.answer.map(String);
} else if (typeof submittedAnswer.answer === 'string') {
if (submittedAnswer.answer.includes(',')) {
selectedItemIds = submittedAnswer.answer.split(',').map(id => id.trim()).filter(id => id);
} else if (submittedAnswer.answer.length > 0) {
selectedItemIds = [submittedAnswer.answer];
}
}
if (question.answer_items) {
question.answer_items.forEach(item => {
item.answer_checked = selectedItemIds.includes(item.id) ? 2 : 1;
});
}
} else if (question.type === 4) {
try {
const fillAnswersObject = JSON.parse(submittedAnswer.answer);
if (question.answer_items && typeof fillAnswersObject === 'object' && fillAnswersObject !== null) {
question.answer_items.forEach(item => {
if (fillAnswersObject.hasOwnProperty(item.id)) {
const plainTextStudentAnswer = fillAnswersObject[item.id];
item.answer = JSON.stringify({
blocks: [{
key: `ans-${item.id}`,
text: plainTextStudentAnswer,
type: 'unstyled', depth: 0, inlineStyleRanges: [], entityRanges: [], data: {}
}],
entityMap: {}
});
} else {
item.answer = JSON.stringify({
blocks: [{
key: `empty-${item.id}`, text: "",
type: 'unstyled', depth: 0, inlineStyleRanges: [], entityRanges: [], data: {}
}],
entityMap: {}
});
}
});
}
} catch (e) {
console.error(`解析填空题已提交作业失败 (questionId: ${questionId}):`, e, "Raw answer:", submittedAnswer.answer);
if (question.answer_items) {
question.answer_items.forEach(item => {
item.answer = JSON.stringify({
blocks: [{
key: `error-${item.id}`, text: "",
type: 'unstyled', depth: 0, inlineStyleRanges: [], entityRanges: [], data: {}
}],
entityMap: {}
});
});
}
}
} else if (question.type === 6) {
if (question.answer_items && question.answer_items.length > 0) {
const plainTextStudentAnswer = submittedAnswer.answer;
try {
JSON.parse(plainTextStudentAnswer);
question.answer_items[0].answer = plainTextStudentAnswer;
} catch (err) {
question.answer_items[0].answer = JSON.stringify({
blocks: [{
key: `ans-${question.id}`,
text: plainTextStudentAnswer,
type: 'unstyled', depth: 0, inlineStyleRanges: [], entityRanges: [], data: {}
}],
entityMap: {}
});
}
}
} else if (question.type === 10) {
try {
const parsedAnswer = JSON.parse(submittedAnswer.answer);
if (parsedAnswer && parsedAnswer.code) {
if (!question.program_setting) question.program_setting = {};
question.program_setting.code_answer = parsedAnswer.code;
} else if (typeof submittedAnswer.answer === 'string' && !submittedAnswer.answer.startsWith('{')) {
if (!question.program_setting) question.program_setting = {};
question.program_setting.code_answer = submittedAnswer.answer;
}
} catch (e) {
if (question.program_setting) {
question.program_setting.code_answer = submittedAnswer.answer;
} else {
question.program_setting = { code_answer: submittedAnswer.answer };
}
console.warn(`解析编程题已获取答案可能不是标准JSON (questionId: ${questionId}):`, e, "Raw answer:", submittedAnswer.answer);
}
} else if (question.type === 12) {
let sortedItemIds = [];
if (Array.isArray(submittedAnswer.answer)) {
sortedItemIds = submittedAnswer.answer.map(String);
} else if (typeof submittedAnswer.answer === 'string') {
if (submittedAnswer.answer.includes(',')) {
sortedItemIds = submittedAnswer.answer.split(',').map(id => id.trim()).filter(id => id);
} else if (submittedAnswer.answer.length > 0) {
sortedItemIds = [submittedAnswer.answer];
}
}
if (question.answer_items && sortedItemIds.length > 0) {
question.answer_items.forEach(item => {
const order = sortedItemIds.indexOf(item.id);
if (order !== -1) {
item.answer = (order + 1).toString();
} else {
item.answer = '';
console.warn(`排序题 (questionId: ${questionId}) 的选项 item.id: ${item.id} 未在提交的答案中找到:`, sortedItemIds);
}
});
}
} else if (question.type === 13) {
try {
let matchObject = null;
const parsedAnswerData = JSON.parse(submittedAnswer.answer);
if (Array.isArray(parsedAnswerData) && parsedAnswerData.length > 0 && typeof parsedAnswerData[0] === 'object') {
matchObject = parsedAnswerData[0];
} else if (typeof parsedAnswerData === 'object' && !Array.isArray(parsedAnswerData)) {
matchObject = parsedAnswerData;
}
if (matchObject && question.answer_items) {
question.answer_items.forEach(item => {
if (!item.is_target_opt) {
if (matchObject.hasOwnProperty(item.id)) {
item.answer = matchObject[item.id];
} else {
item.answer = '';
}
}
});
}
} catch (e) {
console.error(`解析匹配题已获取答案失败 (questionId: ${questionId}):`, e, "Raw answer:", submittedAnswer.answer);
}
}
} else {
console.warn(`在 submittedAnswers 中找到一个答案,但其 question_id (${questionId}) 在 questionsData 或其子问题中均未找到。`);
}
});
localStorage.setItem('answerData', JSON.stringify(questionsData));
progress.hide();
showNotification('已提交作业获取成功!', {
type: 'success',
keywords: ['已提交', '答案', '获取'],
animation: 'scale'
});
return true;
} else {
progress.hide();
showNotification('未找到已提交的答案,可能尚未提交或无权限查看。', {
type: 'warning',
keywords: ['未找到', '已提交'],
animation: 'fadeSlide'
});
return false;
}
} catch (error) {
console.error('获取已提交作业失败:', error);
showNotification('获取已提交作业失败:' + (error.message || '未知错误'), {
type: 'error',
keywords: ['获取', '失败'],
animation: 'scale'
});
return false;
}
}
async function fillAnswers() {
const answerData = JSON.parse(localStorage.getItem('answerData'));
const recordId = localStorage.getItem('recordId');
const groupId = localStorage.getItem('groupId');
const paperId = localStorage.getItem('paperId');
if (!answerData || !recordId || !groupId || !paperId) {
showNotification('缺少必要数据,请先获取答案或检查作业状态。', {
type: 'error',
keywords: ['数据', '获取', '检查'],
animation: 'scale'
});
return;
}
const token = getToken();
if (!token) {
showNotification('无法获取token。', {
type: 'error',
keywords: ['token'],
animation: 'slideRight'
});
return;
}
const progress = createProgressBar();
progress.show();
try {
let completedCount = 0;
const totalQuestions = answerData.length;
const batchSize = 10;
for (let i = 0; i < answerData.length; i += batchSize) {
const batch = answerData.slice(i, i + batchSize);
let localCompletedCount = completedCount;
await Promise.all(batch.map(async question => {
await submitAnswer(question, recordId, groupId, paperId, token);
localCompletedCount++;
progress.update(localCompletedCount, totalQuestions);
}));
completedCount = localCompletedCount;
}
progress.hide();
showNotification('答案填写完成!页面将于0.5s后刷新。', {
type: 'success',
keywords: ['答案', '填写', '刷新'],
animation: 'slideRight'
});
const nodeId = getNodeIDFromUrl(window.location.href);
const currentGroupId = getGroupIDFromUrl(window.location.href);
if (nodeId && currentGroupId) sessionStorage.setItem(`xiaoya_autofilled_${currentGroupId}_${nodeId}`, 'true');
setTimeout(() => {
location.reload();
}, 500);
} catch (error) {
progress.hide();
console.error('填写答案失败:', error);
showNotification('填写答案失败,请查看控制台。', {
type: 'error',
keywords: ['填写', '失败'],
animation: 'scale'
});
}
}
async function submitAnswer(question, recordId, groupId, paperId, token) {
let answer;
let extAnswer = '';
switch (question.type) {
case 1: {
answer = [question.answer_items.find(item => item.answer_checked === 2)?.id];
break;
}
case 2: {
answer = question.answer_items.filter(item => item.answer_checked === 2).map(item => item.id);
break;
}
case 4: {
const fillObject = {};
question.answer_items.forEach(item => {
fillObject[item.id] = parseRichTextToPlainText(item.answer);
});
answer = [fillObject];
break;
}
case 5: {
answer = [question.answer_items.find(item => item.answer_checked === 2)?.id];
break;
}
case 6: {
answer = [question.answer_items[0].answer];
break;
}
case 9: {
if (question.subQuestions && question.subQuestions.length > 0) {
for (const subQuestion of question.subQuestions) {
await submitAnswer(subQuestion, recordId, groupId, paperId, token);
}
}
return;
}
case 10: {
const progSetting = question.program_setting || {};
const answerItem = question.answer_items?.[0];
answer = [{
language: progSetting.language?.[0] || 'c',
code: progSetting.code_answer || '',
answer_item_id: answerItem?.id || ''
}];
break;
}
case 12: {
answer = question.answer_items
.sort((a, b) => parseInt(a.answer) - parseInt(b.answer))
.map(item => item.id);
break;
}
case 13: {
const matchObject = {};
question.answer_items
.filter(item => !item.is_target_opt && item.answer)
.forEach(item => {
matchObject[item.id] = item.answer;
});
if (Object.keys(matchObject).length > 0) {
answer = [matchObject];
} else {
return;
}
break;
}
default:
return;
}
const requestBody = {
record_id: recordId,
question_id: question.id,
answer: answer,
ext_answer: extAnswer,
group_id: groupId,
paper_id: paperId,
is_try: 0
};
return fetch(`${window.location.origin}/api/jx-iresource/survey/answer`, {
method: 'POST',
headers: {
'accept': '*/*',
'authorization': `Bearer ${token}`,
'content-type': 'application/json; charset=UTF-8'
},
body: JSON.stringify(requestBody)
});
}
async function parseRichTextContentAsync(content) {
if (!content || typeof content !== 'string') return content || '';
try {
const jsonContent = JSON.parse(content);
if (!jsonContent || !Array.isArray(jsonContent.blocks)) {
return content;
}
let htmlResult = '';
const aiConfig = JSON.parse(localStorage.getItem('aiConfig') || '{}');
for (const block of jsonContent.blocks) {
if (block.type === 'atomic' && block.data) {
switch (block.data.type) {
case 'IMAGE':
if (block.data.src) {
const fileIdMatch = block.data.src.match(/\/cloud\/file_access\/(\d+)/);
if (fileIdMatch && fileIdMatch[1]) {
const fileId = fileIdMatch[1];
const imageUrl = `${window.location.origin}/api/jx-oresource/cloud/file_access/${fileId}?random=${Date.now()}`;
htmlResult += `
[图片加载失败]
`;
} else {
htmlResult += `[图片链接格式无法解析]
`;
}
}
break;
case 'AUDIO':
if (block.data.data && block.data.data.quote_id) {
const fileId = block.data.data.quote_id;
const cacheKey = `audio_url_${fileId}`;
let audioUrl = sessionStorage.getItem(cacheKey);
if (!audioUrl) {
audioUrl = await getAudioUrl(fileId);
if (audioUrl) sessionStorage.setItem(cacheKey, audioUrl);
}
if (audioUrl) {
htmlResult += ``;
} else {
htmlResult += `[音频加载失败]
`;
}
}
break;
case 'VIDEO':
if (block.data.data && block.data.data.video_id) {
const videoId = block.data.data.video_id;
const cacheKey = `video_urls_${videoId}`;
let urls = JSON.parse(sessionStorage.getItem(cacheKey) || 'null');
if (!urls) {
urls = await getVideoUrl(videoId);
if (urls.videoUrl) sessionStorage.setItem(cacheKey, JSON.stringify(urls));
}
if (urls && urls.videoUrl) {
let videoHtml = `
`;
if (aiConfig.sttEnabled && aiConfig.sttVideoEnabled) {
videoHtml += `
`;
}
videoHtml += `
`;
htmlResult += videoHtml;
} else {
htmlResult += `[视频加载失败: ${videoId}]
`;
}
}
break;
}
} else {
const textContent = block.text.replace(/\n/g, '
');
htmlResult += `${textContent || ' '}
`;
}
}
return htmlResult;
} catch (e) {
return content.replace(//g, ">");
}
}
function getNodeIDFromUrl(url) {
let nodeId = null;
let urlObj = new URL(url);
let pathParts = urlObj.pathname.split('/').filter(part => part);
nodeId = pathParts[pathParts.length - 1];
return nodeId;
}
function getGroupIDFromUrl(url) {
const match = url.match(/mycourse\/(\d+)/);
return match ? match[1] : null;
}
function addKeyboardShortcuts() {
document.addEventListener('keydown', function (e) {
if (e.ctrlKey && e.shiftKey && !e.altKey) {
switch (e.key.toLowerCase()) {
case 'a':
e.preventDefault();
getAndStoreAnswers();
break;
case 'f':
e.preventDefault();
fillAnswers();
break;
case 'e':
e.preventDefault();
showAnswerEditor();
break;
case 'q':
e.preventDefault();
exportHomework();
break;
default:
break;
}
}
});
}
addKeyboardShortcuts();
function showTutorial() {
const style = document.createElement('style');
style.textContent = `
@keyframes modalFadeIn {
from { opacity: 0; transform: scale(0.8); }
to { opacity: 1; transform: scale(1); }
}
@keyframes floatAnimation {
0% { transform: translateY(0px); }
50% { transform: translateY(-10px); }
100% { transform: translateY(0px); }
}
.highlight-text {
background: linear-gradient(120deg, rgba(255,223,186,0.6) 0%, rgba(255,223,186,0) 100%);
padding: 0 4px;
}
.feature-icon {
display: inline-block;
width: 24px;
height: 24px;
margin-right: 8px;
vertical-align: middle;
animation: floatAnimation 3s ease-in-out infinite;
}
`;
document.head.appendChild(style);
let modalOverlay = document.createElement('div');
modalOverlay.style.position = 'fixed';
modalOverlay.style.top = '0';
modalOverlay.style.left = '0';
modalOverlay.style.width = '100%';
modalOverlay.style.height = '100%';
modalOverlay.style.backgroundColor = 'rgba(0, 0, 0, 0.65)';
modalOverlay.style.zIndex = '10000';
modalOverlay.style.display = 'flex';
modalOverlay.style.alignItems = 'center';
modalOverlay.style.justifyContent = 'center';
modalOverlay.style.opacity = '0';
modalOverlay.style.backdropFilter = 'blur(5px)';
modalOverlay.style.transition = 'opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1)';
let modalContent = document.createElement('div');
modalContent.style.backgroundColor = '#fff';
modalContent.style.borderRadius = '16px';
modalContent.style.width = '90%';
modalContent.style.maxWidth = '680px';
modalContent.style.maxHeight = '85vh';
modalContent.style.overflowY = 'auto';
modalContent.style.padding = '32px';
modalContent.style.boxShadow = '0 20px 50px rgba(0, 0, 0, 0.2)';
modalContent.style.position = 'relative';
modalContent.style.transform = 'scale(0.8)';
modalContent.style.opacity = '0';
modalContent.style.animation = 'modalFadeIn 0.6s cubic-bezier(0.4, 0, 0.2, 1) forwards';
modalContent.style.background = 'linear-gradient(135deg, #fff 0%, #f8f9fa 100%)';
let closeButton = document.createElement('button');
closeButton.innerHTML = `
`;
closeButton.style.cssText = `
position: absolute;
top: 15px;
right: 15px;
background: #f3f4f6;
border: none;
width: 36px;
height: 36px;
border-radius: 50%;
cursor: pointer;
color: #6b7280;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
box-shadow: 0 2px 5px rgba(0,0,0,0.08);
`;
closeButton.onmouseover = () => {
closeButton.style.backgroundColor = '#e5e7eb';
closeButton.style.transform = 'rotate(90deg)';
closeButton.style.color = '#000';
closeButton.style.boxShadow = '0 4px 8px rgba(0,0,0,0.12)';
};
closeButton.onmouseout = () => {
closeButton.style.backgroundColor = '#f3f4f6';
closeButton.style.transform = 'rotate(0deg)';
closeButton.style.color = '#6b7280';
closeButton.style.boxShadow = '0 2px 5px rgba(0,0,0,0.08)';
};
closeButton.onclick = () => {
modalContent.style.transform = 'scale(0.8)';
modalContent.style.opacity = '0';
modalOverlay.style.opacity = '0';
setTimeout(() => document.body.removeChild(modalOverlay), 400);
};
let tutorialContent = document.createElement('div');
tutorialContent.innerHTML = `
✨ 使用指南
欢迎使用 小雅答答答 答题助手!
探索以下功能,让你的学习事半功倍~
🎯 核心功能
- 获取答案 - 快速从题库获取参考答案。
- 填写答案 - 一键自动填充答案到页面。
- 编辑答案 - 灵活修改,支持图片和音频展示。
- 导出作业 - 将作业保存为 Word 文档,方便复习。
🤖 AI 助手 (大语言模型)
- 默认选项:小雅 AI - 无需任何配置,开箱即用。根据观察,应该是 DeepSeek 系列的模型。能处理绝大多数文本题目,但请注意,它不支持图片识别。
- 高级选项:自定义 API - 可灵活配置 OpenAI、Gemini、Azure 等服务。开启 Vision (识图) 功能后,AI 将能理解题目中的图片内容。
- 即时调用 - 在编辑答案时,点击 按钮即可启动。
- 高度自定义 - 在“AI设置”中可为不同题型定制专属的 Prompt 模板,释放AI全部潜能。
🎤 特色功能:AI 语音转文本 (STT)
- 听力题神器 - 自动将题目中的音频转换为文字,让 AI 理解听力内容并作答,彻底解决听力难题。
-
🎬 视频音轨转录 (高级)
这是一项强大的实验性功能!开启后,脚本能自动提取并转录题目中视频的音轨,让 AI 也能“听懂”视频内容。
- 由于需要下载和处理视频,此功能会消耗更多的时间和计算资源。
- 默认关闭,可在 ⚙️ AI 设置中,勾选“启用STT”后,再勾选“启用视频音轨转录”来开启。
- 免费方案推荐 - 配置 STT 并非难事!你可以使用 SiliconFlow (硅基流动) 等服务,其语音模型(如 'FunAudioLLM/SenseVoiceSmall')效果优异且有大量的免费额度。
- 独立配置 - STT 功能可独立于大语言模型进行配置,自由组合,例如使用小雅 AI 对话 + SiliconFlow 转录。
- 启用方法 - 前往悬浮球的 ⚙️ AI 设置,勾选“启用语音转文本(STT)功能”并填入你的 API 信息。
📝 脚本支持题型
- 单选题、多选题、填空题、判断题
- 简答题、数组题、编程题、排序题、匹配题
🤖 AI 支持题型
- 核心支持: 单选、多选、判断、填空、简答、编程
- 实验性支持: 排序题、匹配题(需AI严格按JSON格式返回)
- 支持批量处理、流式生成、思考过程显示
⌨️ 快捷键
- Ctrl + Shift + A: 获取答案
- Ctrl + Shift + F: 填写答案
- Ctrl + Shift + E: 编辑答案
- Ctrl + Shift + Q: 导出作业
💡 使用提示
- 使用前请确保已登录小雅平台。
- 必须在作业的“资源”页面(URL 包含 /resource/)点击“获取答案”,而不是在答题页面。
- AI 功能需要你在设置中提供自己的 API Key,脚本不提供任何 Key。
- AI 解题能力有限,尤其是复杂题目,请务必自行检查核对答案。
- 导出的 Word 文档如需导入其他软件,建议先手动打开并保存一次,以确保图片等内容被正确识别。
别太依赖脚本哦,多动脑才是真本事!😉
版权 © zygame1314 保留所有权利。
`;
tutorialContent.style.fontSize = '16px';
tutorialContent.style.lineHeight = '1.6';
modalContent.style.scrollbarWidth = 'thin';
modalContent.style.scrollbarColor = '#4e4376 #f1f1f1';
const scrollbarStyles = `
.tutorial-modal::-webkit-scrollbar {
width: 8px;
}
.tutorial-modal::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
.tutorial-modal::-webkit-scrollbar-thumb {
background: #4e4376;
border-radius: 4px;
}
`;
style.textContent += scrollbarStyles;
modalContent.classList.add('tutorial-modal');
modalContent.appendChild(closeButton);
modalContent.appendChild(tutorialContent);
modalOverlay.appendChild(modalContent);
document.body.appendChild(modalOverlay);
setTimeout(() => {
modalOverlay.style.opacity = '1';
}, 10);
}
function aliyunEncodeURI(str) {
var result = encodeURIComponent(str);
result = result.replace(/\+/g, "%20");
result = result.replace(/\*/g, "%2A");
result = result.replace(/%7E/g, "~");
return result;
}
function makeUTF8sort(params) {
var sortedKeys = Object.keys(params).sort();
var sortedParams = [];
for (var i = 0; i < sortedKeys.length; i++) {
var key = sortedKeys[i];
if (key && params[key]) {
sortedParams.push(aliyunEncodeURI(key) + "=" + aliyunEncodeURI(params[key]));
}
}
return sortedParams.join("&");
}
function makeChangeSiga(params, accessSecret) {
const stringToSign = "GET&%2F&" + aliyunEncodeURI(makeUTF8sort(params));
const signature = CryptoJS.HmacSHA1(stringToSign, accessSecret + "&");
return signature.toString(CryptoJS.enc.Base64);
}
const SignatureUtil = {
NONCE_STR_MAX: 32,
createNonceStr(len = 16) {
len = len > this.NONCE_STR_MAX ? this.NONCE_STR_MAX : len;
let str = "";
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
for (let i = 0; i < len; i++) {
str += chars[Math.floor(Math.random() * chars.length)];
}
return str;
},
createSignature(params) {
const message = params.message;
const timestamp = params.timestamp || new Date().getTime().toString();
const nonce = params.nonce || this.createNonceStr();
const elements = [
encodeURIComponent(message),
timestamp,
nonce,
"--xy-create-signature--"
];
const signature = CryptoJS.SHA1(elements.sort().join("")).toString();
return {
message: message,
signature: signature,
timestamp: timestamp,
nonce: nonce,
};
}
};
async function getAudioUrl(fileId) {
try {
const token = getToken();
if (!token) throw new Error("无法获取Token");
const message = JSON.stringify({ file_id: fileId });
const signedPayload = SignatureUtil.createSignature({ message });
const response = await fetch(`${window.location.origin}/api/jx-oresource/cloud/file/audio`, {
method: 'POST',
headers: {
'authorization': `Bearer ${token}`,
'content-type': 'application/json; charset=UTF-8'
},
body: JSON.stringify(signedPayload)
});
if (!response.ok) {
throw new Error(`获取音频URL失败, 状态: ${response.status}`);
}
const data = await response.json();
if (data.success && data.data) {
return data.data.audio_transcode_url || data.data.url;
} else {
throw new Error(data.message || '返回数据格式不正确');
}
} catch (error) {
console.error(`获取音频URL时出错 (File ID: ${fileId}):`, error);
return null;
}
}
function gmFetch(url, onProgress) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: url,
responseType: 'arraybuffer',
onload: (response) => {
if (response.status >= 200 && response.status < 300) {
if (onProgress) onProgress(1);
resolve(response.response);
} else {
reject(new Error(`gmFetch 请求失败: 状态 ${response.status}`));
}
},
onerror: (response) => {
reject(new Error(`gmFetch 网络错误: ${response.statusText}`));
},
onprogress: (event) => {
if (event.lengthComputable && onProgress) {
onProgress(event.loaded / event.total);
}
}
});
});
}
async function getVideoUrl(videoId) {
try {
const token = getToken();
if (!token) throw new Error("无法获取Token");
const authResponse = await fetch(`${window.location.origin}/api/jx-oresource/vod/video/play_auth/${videoId}?is_public=1`, {
headers: { 'authorization': `Bearer ${token}` }
});
if (!authResponse.ok) throw new Error(`获取视频凭证失败, 状态: ${authResponse.status}`);
const authData = await authResponse.json();
if (!authData.success || !authData.data || !authData.data.play_auth) {
throw new Error(authData.message || '返回的播放凭证数据格式不正确');
}
const playAuthData = JSON.parse(atob(authData.data.play_auth));
const s = {
vid: playAuthData.VideoMeta.VideoId,
accessId: playAuthData.AccessKeyId,
accessSecret: playAuthData.AccessKeySecret,
stsToken: playAuthData.SecurityToken,
domainRegion: playAuthData.Region,
authInfo: playAuthData.AuthInfo,
format: "mp4",
mediaType: "video"
};
const signatureNonce = crypto.randomUUID();
const params = {
AccessKeyId: s.accessId,
Action: "GetPlayInfo",
VideoId: s.vid,
Formats: s.format,
SecurityToken: s.stsToken,
StreamType: s.mediaType,
Format: "JSON",
Version: "2017-03-21",
SignatureMethod: "HMAC-SHA1",
SignatureVersion: "1.0",
SignatureNonce: signatureNonce,
AuthInfo: s.authInfo
};
const signature = makeChangeSiga(params, s.accessSecret);
const queryString = makeUTF8sort(params) + "&Signature=" + aliyunEncodeURI(signature);
const finalUrl = `https://vod.${s.domainRegion}.aliyuncs.com/?${queryString}`;
const playInfoResponse = await fetch(finalUrl);
if (!playInfoResponse.ok) {
const errorText = await playInfoResponse.text();
console.error('从阿里云获取播放信息失败,原始响应:', errorText);
throw new Error(`从阿里云获取播放信息失败, 状态: ${playInfoResponse.status}`);
}
const playInfoData = await playInfoResponse.json();
if (playInfoData && playInfoData.PlayInfoList && playInfoData.PlayInfoList.PlayInfo && playInfoData.PlayInfoList.PlayInfo.length > 0) {
const playInfos = playInfoData.PlayInfoList.PlayInfo;
const videoInfo = playInfos
.filter(p => p.Format === 'mp4')
.sort((a, b) => (b.Width || 0) - (a.Width || 0))[0];
const audioInfo = playInfos.find(p => p.Format === 'm4a');
return {
videoUrl: videoInfo ? videoInfo.PlayURL : null,
audioUrl: audioInfo ? audioInfo.PlayURL : null,
};
} else if (playInfoData.Code) {
throw new Error(`阿里云API错误: ${playInfoData.Code} - ${playInfoData.Message}`);
} else {
throw new Error('播放信息列表中没有可用的地址');
}
} catch (error) {
console.error(`获取视频/音频URL时出错 (Video ID: ${videoId}):`, error);
return { videoUrl: null, audioUrl: null };
}
}
async function extractAndEncodeAudio(videoUrl, onProgress) {
let worker = null;
try {
if (onProgress) onProgress(0.05, "下载中");
const videoData = await gmFetch(videoUrl, (progress) => {
if (onProgress) onProgress(0.05 + progress * 0.25, "下载中");
});
if (onProgress) onProgress(0.3, "解码中");
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const audioBuffer = await audioContext.decodeAudioData(videoData);
await audioContext.close();
if (onProgress) onProgress(0.6, "编码中");
return await new Promise((resolve, reject) => {
const workerBlob = new Blob([WavEncoderWorker], { type: 'application/javascript' });
worker = new Worker(URL.createObjectURL(workerBlob));
worker.onmessage = (e) => {
if (onProgress) onProgress(1, "完成");
resolve(e.data);
worker.terminate();
};
worker.onerror = (e) => {
console.error("WAV 编码 Worker 出错:", e);
reject(new Error(`WAV 编码失败: ${e.message}`));
worker.terminate();
};
const channels = [];
for (let i = 0; i < audioBuffer.numberOfChannels; i++) {
channels.push(audioBuffer.getChannelData(i));
}
worker.postMessage({
channels: channels,
sampleRate: audioBuffer.sampleRate,
length: audioBuffer.length
});
});
} catch (error) {
if (worker) worker.terminate();
console.error("从视频提取音频失败:", error);
throw error;
}
}
async function callSttApi(audioSource, sttConfig) {
const { sttProvider, sttEndpoint, sttApiKey, sttModel, apiKey: llmApiKey } = sttConfig;
if (!sttEndpoint) throw new Error("STT API 地址未配置。");
const finalApiKey = sttApiKey || llmApiKey;
if (!finalApiKey) throw new Error("STT API Key 未配置(也未提供备用的 LLM Key)。");
console.log(`[STT] 使用 [${sttProvider}] 提供商开始转录...`);
showNotification('🎧 语音转录中...', { type: 'info', duration: 10000 });
try {
switch (sttProvider) {
case 'openai_compatible':
return await callWhisperCompatibleApi(audioSource, sttEndpoint, finalApiKey, sttModel);
case 'gemini':
return await callGeminiSttApi(audioSource, sttEndpoint, finalApiKey, sttModel);
default:
throw new Error(`未知的 STT 提供商: ${sttProvider}`);
}
} catch (error) {
console.error('[STT] 语音转录失败:', error);
showNotification(`语音转录失败: ${error.message}`, { type: 'error', duration: 8000 });
throw error;
}
}
async function callWhisperCompatibleApi(audioSource, endpoint, apiKey, model) {
let audioBlob;
let fileName = 'audio.wav';
if (typeof audioSource === 'string') {
const audioResponse = await fetch(audioSource);
if (!audioResponse.ok) {
throw new Error(`下载音频文件失败, 状态: ${audioResponse.status}`);
}
audioBlob = await audioResponse.blob();
fileName = audioSource.split('/').pop().split('?')[0] || 'audio.mp3';
} else if (audioSource instanceof Blob) {
audioBlob = audioSource;
} else {
throw new Error('无效的音频源类型');
}
const formData = new FormData();
formData.append('file', audioBlob, fileName);
formData.append('model', model || 'whisper-1');
const sttApiResponse = await fetch(endpoint, {
method: 'POST',
headers: { 'Authorization': `Bearer ${apiKey}` },
body: formData
});
if (!sttApiResponse.ok) {
const errorText = await sttApiResponse.text();
throw new Error(`STT API 请求失败 (${sttApiResponse.status}): ${errorText}`);
}
const result = await sttApiResponse.json();
if (typeof result.text === 'string') {
showNotification('🎤 转录完成!', { type: 'success', duration: 2000 });
return result.text;
} else {
throw new Error("STT API 返回的数据格式不正确,未找到 'text' 字段。");
}
}
async function callGeminiSttApi(audioSource, endpoint, apiKey, model) {
let audioBlob;
let mimeType;
if (typeof audioSource === 'string') {
const audioResponse = await fetch(audioSource);
if (!audioResponse.ok) {
throw new Error(`下载音频文件失败, 状态: ${audioResponse.status}`);
}
audioBlob = await audioResponse.blob();
mimeType = audioBlob.type || 'audio/mp3';
} else if (audioSource instanceof Blob) {
audioBlob = audioSource;
mimeType = audioBlob.type;
} else {
throw new Error('无效的音频源类型');
}
const base64Audio = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result.split(',')[1]);
reader.onerror = reject;
reader.readAsDataURL(audioBlob);
});
const requestBody = {
contents: [
{
parts: [
{ text: "Please provide a transcript for this audio." },
{
inlineData: {
mimeType: mimeType,
data: base64Audio,
},
},
],
},
],
};
let finalEndpoint = endpoint.endsWith('/') ? endpoint : endpoint + '/';
finalEndpoint += `${model}:generateContent?key=${apiKey}`;
const sttApiResponse = await fetch(finalEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
});
if (!sttApiResponse.ok) {
const errorText = await sttApiResponse.text();
throw new Error(`Gemini STT API 请求失败 (${sttApiResponse.status}): ${errorText}`);
}
const result = await sttApiResponse.json();
const transcription = result.candidates?.[0]?.content?.parts?.[0]?.text;
if (typeof transcription === 'string') {
showNotification('🎤 转录完成 (Gemini)!', { type: 'success', duration: 2000 });
return transcription;
} else {
console.error('[STT-Gemini] 返回数据格式不正确:', result);
throw new Error("Gemini STT API 返回的数据格式不正确。");
}
}
function containsAudio(richTextContent) {
if (!richTextContent || typeof richTextContent !== 'string') return false;
try {
const jsonContent = JSON.parse(richTextContent);
if (jsonContent && Array.isArray(jsonContent.blocks)) {
return jsonContent.blocks.some(block =>
block.type === 'atomic' && block.data?.type === 'AUDIO'
);
}
} catch (e) {
return false;
}
return false;
}
function containsVideo(richTextContent) {
if (!richTextContent || typeof richTextContent !== 'string') return false;
try {
const jsonContent = JSON.parse(richTextContent);
if (jsonContent && Array.isArray(jsonContent.blocks)) {
return jsonContent.blocks.some(block =>
block.type === 'atomic' && block.data?.type === 'VIDEO'
);
}
} catch (e) {
return false;
}
return false;
}
function getQuestionType(typeCode) {
const typeMap = {
1: "单选题",
2: "多选题",
4: "填空题",
5: "判断题",
6: "简答题",
9: "数组题",
10: "编程题",
12: "排序题",
13: "匹配题"
};
return typeMap[typeCode] || "未知题型";
}
async function callXiaoyaStream(userPrompt, onChunk, onComplete, onError, signal) {
const effectiveSignal = signal || new AbortController().signal;
let timeoutId = null;
if (!signal) {
timeoutId = setTimeout(() => {
console.error("Xiaoya Stream fetch 超时 (内部)");
if (typeof onError === 'function') {
onError(new Error("小雅流式 API 网络错误: 请求超时 (内部)"));
}
}, 60000);
} else {
effectiveSignal.addEventListener('abort', () => {
console.log("Xiaoya Stream 请求被外部信号中止。");
if (typeof onError === 'function') {
onError(new DOMException('请求被中止', 'AbortError'));
}
}, { once: true });
}
try {
const bearerToken = getToken();
if (!bearerToken) {
throw new Error("无法获取 Bearer Token");
}
let jwtToken = null;
try {
const xyGlobalConfig = localStorage.getItem('XY_GLOBAL_CONFIG');
if (xyGlobalConfig) {
jwtToken = JSON.parse(xyGlobalConfig).xy_ai_token;
}
} catch (e) {
console.warn("解析 XY_GLOBAL_CONFIG 失败:", e);
}
if (!jwtToken) {
console.warn("无法从 localStorage 获取小雅 JWT Token,将尝试使用 Bearer Token");
jwtToken = bearerToken;
}
const groupId = getGroupIDFromUrl(window.location.href) || "";
const aiConfig = JSON.parse(localStorage.getItem('aiConfig') || '{}');
const xiaoyaAiMode = aiConfig.xiaoyaAiMode || 'deep_think';
const useDeepThink = xiaoyaAiMode === 'deep_think';
const requestBody = {
token: jwtToken,
ask_key: "chat_scene_dialogue",
ask_object: {
question: userPrompt,
multilingual_description: ""
},
deep_think_mode: useDeepThink,
group_id: groupId
};
console.log(`调用 Xiaoya Stream API (模式: ${useDeepThink ? '深度思考' : '快速'})`, { body: requestBody });
const response = await fetch(`${window.location.origin}/api/jx-oresource/assistant/chat/stream`, {
method: "POST",
headers: {
"accept": "*/*",
"authorization": `Bearer ${bearerToken}`,
"content-type": "application/json",
},
body: JSON.stringify(requestBody),
signal: effectiveSignal,
});
if (timeoutId) clearTimeout(timeoutId);
if (!response.ok) {
let errorMsg = `小雅流式 API 错误 (${response.status}): ${response.statusText}`;
try {
const errorData = await response.json();
errorMsg = `小雅流式 API 错误 (${response.status}): ${errorData.message || response.statusText}`;
} catch (e) {
}
console.error("Xiaoya Stream fetch 错误:", errorMsg);
if (typeof onError === 'function') {
onError(new Error(errorMsg));
}
return;
}
if (response.body) {
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let accumulatedContent = '';
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) {
console.log("Xiaoya Stream finished.");
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.startsWith('data: ')) {
const dataJson = line.substring(6).trim();
if (dataJson === '[DONE]') {
console.log("Xiaoya Stream received [DONE]");
continue;
}
try {
const data = JSON.parse(dataJson);
const delta = data.choices?.[0]?.delta;
if (delta) {
const deltaContent = delta.content;
const reasoningContent = delta.reasoning_content || delta.reasoning;
if (reasoningContent) {
if (typeof onChunk === 'function') {
onChunk(`${reasoningContent}`);
}
} else if (deltaContent) {
accumulatedContent += deltaContent;
if (typeof onChunk === 'function') {
onChunk(deltaContent);
}
}
}
} catch (parseError) {
if (dataJson) {
console.warn("Xiaoya Stream SSE JSON parsing error:", parseError, "Data:", dataJson);
}
}
}
}
}
if (typeof onComplete === 'function') {
onComplete(accumulatedContent);
}
} else {
console.error("Xiaoya Stream 响应体为空");
if (typeof onError === 'function') {
onError(new Error("小雅流式 API 错误: 响应体为空"));
}
}
} catch (error) {
if (timeoutId) clearTimeout(timeoutId);
if (error.name === 'AbortError') {
console.log("Xiaoya Stream 请求被中止.");
if (!signal && typeof onError === 'function') {
onError(error);
}
} else {
console.error("Xiaoya Stream 调用/处理失败:", error);
if (typeof onError === 'function') {
onError(new Error(`小雅流式 API 网络或处理错误: ${error.message}`));
}
}
}
}
async function callOpenAI(endpoint, apiKey, userPrompt, modelId, temperature = 0.7, max_tokens = 8000, onChunk = null, onComplete = null, onError = null, signal = null, visionEnabled = false) {
const effectiveSignal = signal || new AbortController().signal;
let timeoutId = null;
if (!signal) {
timeoutId = setTimeout(() => {
console.error("OpenAI fetch 超时 (内部)");
if (typeof onError === 'function') {
onError(new Error("OpenAI API 网络错误: 请求超时 (内部)"));
}
}, 60000);
} else {
effectiveSignal.addEventListener('abort', () => {
console.log("OpenAI 请求被外部信号中止。");
if (typeof onError === 'function') {
onError(new DOMException('请求被中止', 'AbortError'));
}
}, { once: true });
}
try {
const aiConfig = JSON.parse(localStorage.getItem('aiConfig') || '{}');
const disableMaxTokens = aiConfig.disableMaxTokens || false;
const modelToUse = modelId || "gpt-4o";
const payloadData = {
model: modelToUse,
messages: [{
role: "user",
content: visionEnabled ? userPrompt : String(userPrompt)
}],
temperature: temperature,
stream: true
};
if (!disableMaxTokens) {
payloadData.max_tokens = max_tokens;
}
const payload = JSON.stringify(payloadData);
console.log("调用 OpenAI (流式 Fetch):", { endpoint, model: modelToUse, temperature, max_tokens: disableMaxTokens ? 'unlimited' : max_tokens });
const disableCorrection = aiConfig.disableCorrection || false;
let finalEndpoint = endpoint;
if (!disableCorrection) {
let cleanEndpoint = endpoint.split('?')[0].replace(/\/$/, '');
const targetPath = '/v1/chat/completions';
if (!cleanEndpoint.endsWith(targetPath)) {
if (cleanEndpoint.includes('/v1')) {
cleanEndpoint = cleanEndpoint.substring(0, cleanEndpoint.indexOf('/v1')) + targetPath;
} else {
cleanEndpoint += targetPath;
}
console.warn("OpenAI Endpoint 已自动修正为:", cleanEndpoint);
}
finalEndpoint = cleanEndpoint + (endpoint.includes('?') ? endpoint.substring(endpoint.indexOf('?')) : '');
}
const response = await fetch(finalEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
body: payload,
signal: effectiveSignal
});
if (timeoutId) clearTimeout(timeoutId);
if (!response.ok) {
let errorMsg = `OpenAI API 错误 (${response.status}): ${response.statusText}`;
try {
const errorData = await response.json();
errorMsg = `OpenAI API 错误 (${response.status}): ${errorData.error?.message || errorData.message || response.statusText}`;
} catch (e) {
try {
const textError = await response.text();
console.error("OpenAI 原始错误响应:", textError);
errorMsg += ` - ${textError.substring(0, 100)}`;
} catch (textE) { }
}
console.error("OpenAI fetch 错误:", errorMsg);
if (typeof onError === 'function') {
onError(new Error(errorMsg));
}
return;
}
if (response.body) {
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let accumulatedContent = '';
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) {
console.log("OpenAI Stream finished.");
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (let j = 0; j < lines.length; j++) {
const line = lines[j];
if (line.startsWith('data: ')) {
const dataJson = line.substring(6).trim();
if (dataJson === '[DONE]') {
console.log("OpenAI Stream received [DONE]");
continue;
}
try {
const data = JSON.parse(dataJson);
const delta = data.choices?.[0]?.delta;
if (delta) {
const deltaContent = delta.content;
const reasoningContent = delta.reasoning_content || delta.reasoning;
if (reasoningContent) {
if (typeof onChunk === 'function') {
onChunk(`${reasoningContent}`);
}
} else if (deltaContent) {
accumulatedContent += deltaContent;
if (typeof onChunk === 'function') {
onChunk(deltaContent);
}
}
}
} catch (parseError) {
if (dataJson) {
console.warn("SSE JSON parsing error:", parseError, "Data:", dataJson);
}
}
}
}
}
if (typeof onComplete === 'function') {
onComplete(accumulatedContent);
}
} else {
console.error("OpenAI 响应体为空");
if (typeof onError === 'function') {
onError(new Error("OpenAI API 错误: 响应体为空"));
}
}
} catch (error) {
if (timeoutId) clearTimeout(timeoutId);
if (error.name === 'AbortError') {
console.log("OpenAI 请求被中止.");
if (!signal && typeof onError === 'function') {
onError(error);
}
} else {
console.error("OpenAI 调用/处理失败:", error);
if (typeof onError === 'function') {
onError(new Error(`OpenAI API 网络或处理错误: ${error.message}`));
}
}
}
}
async function callGemini(endpoint, apiKey, userPrompt, modelId, temperature = 0.7, max_tokens = 8000, onChunk = null, onComplete = null, onError = null, signal = null, visionEnabled = false) {
const effectiveSignal = signal || new AbortController().signal;
let timeoutId = null;
if (!signal) {
timeoutId = setTimeout(() => {
console.error("Gemini fetch 超时 (内部)");
if (typeof onError === 'function') {
onError(new Error("Gemini API 网络错误: 请求超时 (内部)"));
}
}, 60000);
} else {
effectiveSignal.addEventListener('abort', () => {
console.log("Gemini 请求被外部信号中止。");
if (typeof onError === 'function') {
onError(new DOMException('请求被中止', 'AbortError'));
}
}, { once: true });
}
try {
const aiConfig = JSON.parse(localStorage.getItem('aiConfig') || '{}');
const disableCorrection = aiConfig.disableCorrection || false;
const disableMaxTokens = aiConfig.disableMaxTokens || false;
const modelToUse = modelId || "gemini-2.5-flash";
const apiVersion = "v1beta";
let finalEndpoint;
if (disableCorrection) {
finalEndpoint = endpoint;
if (!finalEndpoint.includes('key=')) {
finalEndpoint += (finalEndpoint.includes('?') ? '&' : '?') + `key=${apiKey}`;
}
if (!finalEndpoint.includes('alt=sse')) {
finalEndpoint += (finalEndpoint.includes('?') ? '&' : '?') + 'alt=sse';
}
} else {
let cleanBaseEndpoint = endpoint.replace(/\/v\d+(beta)?\/models\/.*$/, '').replace(/\/models\/.*$/, '').replace(/\/$/, '');
finalEndpoint = `${cleanBaseEndpoint}/${apiVersion}/models/${modelToUse}:streamGenerateContent?key=${apiKey}&alt=sse`;
}
console.log("调用 Gemini (流式 Fetch):", { fullEndpoint: finalEndpoint, model: modelToUse, temperature, max_tokens: disableMaxTokens ? 'unlimited' : max_tokens });
const generationConfig = { temperature: temperature };
if (!disableMaxTokens) {
generationConfig.maxOutputTokens = max_tokens;
}
let finalParts;
if (visionEnabled) {
finalParts = userPrompt.map(part => {
if (part.type === 'image_url') {
const base64Data = part.image_url.url;
const parts = base64Data.split(',');
const mimeMatch = parts[0].match(/:(.*?);/);
return { inline_data: { mime_type: mimeMatch[1], data: parts[1] } };
}
return { text: part.text };
});
} else {
finalParts = [{ text: userPrompt }];
}
const payload = JSON.stringify({
contents: [{ parts: finalParts }],
generationConfig: generationConfig
});
const response = await fetch(finalEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: payload,
signal: effectiveSignal
});
if (timeoutId) clearTimeout(timeoutId);
if (!response.ok) {
let errorMsg = `Gemini API 错误 (${response.status}): ${response.statusText}`;
try {
const errorData = await response.json();
errorMsg = `Gemini API 错误 (${response.status}): ${errorData.error?.message || errorData.message || response.statusText}`;
} catch (e) {
try {
const textError = await response.text();
console.error("Gemini 原始错误响应:", textError);
errorMsg += ` - ${textError.substring(0, 100)}`;
} catch (textE) { }
}
console.error("Gemini fetch 错误:", errorMsg);
if (typeof onError === 'function') {
onError(new Error(errorMsg));
}
return;
}
if (response.body) {
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let accumulatedContent = '';
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) {
console.log("Gemini Stream finished.");
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
function handleGeminiLine(line) {
if (line.startsWith('data: ')) {
const dataJson = line.substring(6).trim();
try {
const data = JSON.parse(dataJson);
const delta = data.candidates?.[0]?.content?.parts?.[0]?.text;
if (delta) {
accumulatedContent += delta;
if (typeof onChunk === 'function') {
onChunk(delta);
}
}
const finishReason = data.candidates?.[0]?.finishReason;
if (finishReason && finishReason !== "STOP") {
console.warn("Gemini stream finished with reason:", finishReason);
if (finishReason === "SAFETY") {
const safetyError = new Error("Gemini API 错误: 响应因安全设置被阻止。");
if (typeof onError === 'function') onError(safetyError);
}
}
const promptFeedback = data.promptFeedback;
if (promptFeedback?.blockReason) {
console.error(`Gemini API 错误: 提示因 ${promptFeedback.blockReason} 被阻止`, data);
const promptError = new Error(`Gemini API 错误: 提示因 ${promptFeedback.blockReason} 被阻止`);
if (typeof onError === 'function') onError(promptError);
}
} catch (parseError) {
if (dataJson) {
console.warn("Gemini SSE JSON parsing error:", parseError, "Data:", dataJson);
}
}
}
}
lines.forEach(handleGeminiLine);
}
if (typeof onComplete === 'function') {
onComplete(accumulatedContent);
}
} else {
console.error("Gemini 响应体为空");
if (typeof onError === 'function') {
onError(new Error("Gemini API 错误: 响应体为空"));
}
}
} catch (error) {
if (timeoutId) clearTimeout(timeoutId);
if (error.name === 'AbortError') {
console.log("Gemini 请求被中止.");
if (!signal && typeof onError === 'function') {
onError(error);
}
} else {
console.error("Gemini 调用/处理失败:", error);
if (typeof onError === 'function') {
onError(new Error(`Gemini API 网络或处理错误: ${error.message}`));
}
}
}
}
async function callAnthropic(endpoint, apiKey, userPrompt, modelId, temperature = 0.7, max_tokens = 8000, onChunk = null, onComplete = null, onError = null, signal = null, visionEnabled = false) {
const effectiveSignal = signal || new AbortController().signal;
let timeoutId = null;
if (!signal) {
timeoutId = setTimeout(() => {
console.error("Anthropic fetch 超时 (内部)");
if (typeof onError === 'function') {
onError(new Error("Anthropic API 网络错误: 请求超时 (内部)"));
}
}, 60000);
} else {
effectiveSignal.addEventListener('abort', () => {
console.log("Anthropic 请求被外部信号中止。");
if (typeof onError === 'function') {
onError(new DOMException('请求被中止', 'AbortError'));
}
}, { once: true });
}
try {
const aiConfig = JSON.parse(localStorage.getItem('aiConfig') || '{}');
const disableMaxTokens = aiConfig.disableMaxTokens || false;
const modelToUse = modelId || "claude-sonnet-4-20250514";
let finalContent;
if (visionEnabled) {
finalContent = userPrompt.map(part => {
if (part.type === 'image_url') {
const base64Data = part.image_url.url;
const parts = base64Data.split(',');
const mimeMatch = parts[0].match(/:(.*?);/);
return { type: 'image', source: { type: 'base64', media_type: mimeMatch[1], data: parts[1] } };
}
return { type: 'text', text: part.text };
});
} else {
finalContent = [{ type: 'text', text: userPrompt }];
}
const payloadData = {
model: modelToUse,
messages: [{ role: "user", content: finalContent }],
temperature: temperature,
stream: true
};
if (!disableMaxTokens) {
payloadData.max_tokens = max_tokens;
}
const payload = JSON.stringify(payloadData);
console.log("调用 Anthropic (流式 Fetch):", { endpoint, model: modelToUse, temperature, max_tokens: disableMaxTokens ? 'unlimited' : max_tokens });
const disableCorrection = aiConfig.disableCorrection || false;
let finalEndpoint = endpoint;
if (!disableCorrection) {
let cleanEndpoint = endpoint.split('?')[0].replace(/\/$/, '');
const targetPath = '/v1/messages';
if (!cleanEndpoint.endsWith(targetPath)) {
if (cleanEndpoint.includes('/v1')) {
cleanEndpoint = cleanEndpoint.substring(0, cleanEndpoint.indexOf('/v1')) + targetPath;
} else {
cleanEndpoint += targetPath;
}
console.warn("Anthropic Endpoint 已自动修正为:", cleanEndpoint);
}
finalEndpoint = cleanEndpoint + (endpoint.includes('?') ? endpoint.substring(endpoint.indexOf('?')) : '');
}
const response = await fetch(finalEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
},
body: payload,
signal: effectiveSignal
});
if (timeoutId) clearTimeout(timeoutId);
if (!response.ok) {
let errorMsg = `Anthropic API 错误 (${response.status}): ${response.statusText}`;
try {
const errorData = await response.json();
errorMsg = `Anthropic API 错误 (${response.status}): ${errorData.error?.type || errorData.type || response.statusText} - ${errorData.error?.message || errorData.message || ''}`;
} catch (e) {
try {
const textError = await response.text();
console.error("Anthropic 原始错误响应:", textError);
errorMsg += ` - ${textError.substring(0, 100)}`;
} catch (textE) { }
}
console.error("Anthropic fetch 错误:", errorMsg);
if (typeof onError === 'function') {
onError(new Error(errorMsg));
}
return;
}
if (response.body) {
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let accumulatedContent = '';
let buffer = '';
let streamEnded = false;
while (!streamEnded) {
const { done, value } = await reader.read();
if (done) {
console.log("Anthropic Stream finished.");
streamEnded = true;
break;
}
buffer += decoder.decode(value, { stream: true });
const blocks = buffer.split('\n\n');
buffer = blocks.pop() || '';
blocks.forEach(block => {
if (!block.trim()) return;
let eventType = null;
let dataJson = null;
const lines = block.split('\n');
for (let k = 0; k < lines.length; k++) {
const line = lines[k];
if (line.startsWith('event: ')) {
eventType = line.substring(7).trim();
} else if (line.startsWith('data: ')) {
dataJson = line.substring(6).trim();
}
}
if (eventType && dataJson) {
try {
const data = JSON.parse(dataJson);
if (eventType === 'content_block_delta') {
if (data.type === 'content_block_delta' && data.delta?.type === 'text_delta') {
const delta = data.delta.text;
accumulatedContent += delta;
if (typeof onChunk === 'function') {
onChunk(delta);
}
} else if (data.type === 'content_block_delta' && data.delta?.type === 'thinking_delta') {
const thinkingDelta = data.delta.thinking;
if (thinkingDelta && typeof onChunk === 'function') {
onChunk(`${thinkingDelta}`);
}
}
} else if (eventType === 'message_start') {
} else if (eventType === 'message_delta') {
} else if (eventType === 'message_stop') {
console.log("Anthropic 流式传输已停止 (收到 message_stop 事件)");
streamEnded = true;
} else if (eventType === 'ping') {
} else if (eventType === 'error') {
console.error("Anthropic 流式传输错误事件:", data);
{
const streamError = new Error(`Anthropic API 错误: ${data.error?.type} - ${data.error?.message}`);
if (typeof onError === 'function') onError(streamError);
streamEnded = true;
}
} else {
console.warn("未知的 Anthropic 事件类型:", eventType, data);
}
} catch (parseError) {
console.warn("Anthropic SSE JSON 解析错误:", parseError, "数据:", dataJson);
}
}
});
}
if (typeof onComplete === 'function') {
onComplete(accumulatedContent);
}
} else {
console.error("Anthropic 响应体为空");
if (typeof onError === 'function') {
onError(new Error("Anthropic API 错误: 响应体为空"));
}
}
} catch (error) {
if (timeoutId) clearTimeout(timeoutId);
if (error.name === 'AbortError') {
console.log("Anthropic 请求被中止.");
if (!signal && typeof onError === 'function') {
onError(error);
}
} else {
console.error("Anthropic 调用/处理失败:", error);
if (typeof onError === 'function') {
onError(new Error(`Anthropic API 网络或处理错误: ${error.message}`));
}
}
}
}
async function callAzureOpenAI(endpoint, apiKey, apiVersion, modelId, userPrompt, temperature = 0.7, max_tokens = 8000, onChunk = null, onComplete = null, onError = null, signal = null, visionEnabled = false) {
const effectiveSignal = signal || new AbortController().signal;
let timeoutId = null;
if (!signal) {
timeoutId = setTimeout(() => {
console.error("Azure OpenAI fetch 超时 (内部)");
if (typeof onError === 'function') {
onError(new Error("Azure OpenAI API 网络错误: 请求超时 (内部)"));
}
}, 60000);
} else {
effectiveSignal.addEventListener('abort', () => {
console.log("Azure OpenAI 请求被外部信号中止。");
if (typeof onError === 'function') {
onError(new DOMException('请求被中止', 'AbortError'));
}
}, { once: true });
}
try {
const aiConfig = JSON.parse(localStorage.getItem('aiConfig') || '{}');
const disableCorrection = aiConfig.disableCorrection || false;
const disableMaxTokens = aiConfig.disableMaxTokens || false;
const version = apiVersion || '2024-05-01-preview';
let finalEndpoint;
let cleanEndpointBase = endpoint.split('?')[0].replace(/\/$/, '');
const urlParams = new URLSearchParams(endpoint.split('?')[1] || '');
if (!urlParams.has('api-version')) {
urlParams.set('api-version', version);
}
if (disableCorrection) {
finalEndpoint = `${cleanEndpointBase}?${urlParams.toString()}`;
} else {
const isOpenAIStyle = cleanEndpointBase.includes('.openai.azure.com');
const isAIServicesStyle = cleanEndpointBase.includes('.services.ai.azure.com') || cleanEndpointBase.includes('.inference.ai.azure.com');
if (!isOpenAIStyle && !isAIServicesStyle) {
console.warn("Azure Endpoint URL hostname does not seem standard (expected '*.openai.azure.com' or '*.services.ai.azure.com' or '*.inference.ai.azure.com'):", cleanEndpointBase);
}
if (isOpenAIStyle) {
if (!cleanEndpointBase.includes('/openai/deployments/')) {
console.warn("Azure OpenAI-style endpoint path might be incomplete. Expected format: '.../openai/deployments//chat/completions'. Current:", cleanEndpointBase);
} else if (!cleanEndpointBase.endsWith('/chat/completions')) {
console.warn("Azure OpenAI-style endpoint path might be incomplete. Ensuring it ends with '/chat/completions'. Current:", cleanEndpointBase);
if (/\/openai\/deployments\/[^/]+$/.test(cleanEndpointBase)) {
cleanEndpointBase += '/chat/completions';
}
}
} else if (isAIServicesStyle) {
if (!cleanEndpointBase.endsWith('/models/chat/completions')) {
console.warn("Azure AI Services-style endpoint path might be incomplete. Expected format: '.../models/chat/completions'. Current:", cleanEndpointBase);
if (cleanEndpointBase.endsWith('/models/chat')) {
cleanEndpointBase += '/completions';
}
}
}
finalEndpoint = `${cleanEndpointBase}?${urlParams.toString()}`;
}
console.log("调用 Azure OpenAI (流式 Fetch):", { fullEndpoint: finalEndpoint, model: modelId, temperature, max_tokens: disableMaxTokens ? 'unlimited' : max_tokens });
const requestBody = {
model: modelId,
messages: [{
role: "user",
content: visionEnabled ? userPrompt : [{ type: "text", text: String(userPrompt) }]
}],
temperature: temperature,
stream: true
};
if (!disableMaxTokens) {
requestBody.max_tokens = max_tokens;
}
const payload = JSON.stringify(requestBody);
const response = await fetch(finalEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'api-key': apiKey
},
body: payload,
signal: effectiveSignal
});
if (timeoutId) clearTimeout(timeoutId);
if (!response.ok) {
let errorMsg = `Azure OpenAI API 错误 (${response.status}): ${response.statusText}`;
try {
const errorData = await response.json();
errorMsg = `Azure OpenAI API 错误 (${response.status}): ${errorData.error?.message || errorData.message || response.statusText}`;
} catch (e) {
try {
const textError = await response.text();
console.error("Azure OpenAI 原始错误响应:", textError);
errorMsg += ` - ${textError.substring(0, 100)}`;
} catch (textE) { }
}
console.error("Azure OpenAI fetch 错误:", errorMsg);
if (typeof onError === 'function') {
onError(new Error(errorMsg));
}
return;
}
if (response.body) {
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let accumulatedContent = '';
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) {
console.log("Azure OpenAI Stream finished.");
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
function handleAzureLine(line) {
if (line.startsWith('data: ')) {
const dataJson = line.substring(6).trim();
if (dataJson === '[DONE]') {
console.log("Azure OpenAI Stream received [DONE]");
return;
}
try {
const data = JSON.parse(dataJson);
const choice = data.choices?.[0];
if (choice) {
const delta = choice.delta;
if (delta) {
const deltaContent = delta.content;
const reasoningContent = delta.reasoning_content || delta.reasoning;
if (reasoningContent) {
if (typeof onChunk === 'function') {
onChunk(`${reasoningContent}`);
}
} else if (deltaContent) {
accumulatedContent += deltaContent;
if (typeof onChunk === 'function') {
onChunk(deltaContent);
}
}
}
const finishReason = choice.finish_reason;
if (finishReason && finishReason !== "stop") {
console.warn("Azure OpenAI stream finished with reason:", finishReason, "Data:", dataJson);
if (finishReason === "content_filter") {
let filterMessage = "Azure OpenAI API 错误: 响应因内容过滤器被阻止。";
if (data.prompt_filter_results && data.prompt_filter_results.length > 0) {
filterMessage = `Azure OpenAI API 错误: 提示因内容过滤器 (${data.prompt_filter_results[0].content_filter_results.hate.filtered ? 'hate' : ''}...)被阻止。`;
} else if (choice.content_filter_results) {
const results = choice.content_filter_results;
let reasons = [];
if (results.hate?.filtered) reasons.push("hate");
if (results.self_harm?.filtered) reasons.push("self_harm");
if (results.sexual?.filtered) reasons.push("sexual");
if (results.violence?.filtered) reasons.push("violence");
if (reasons.length > 0) filterMessage += ` 检测到: ${reasons.join(', ')}.`;
}
const filterError = new Error(filterMessage);
if (typeof onError === 'function') onError(filterError);
}
}
}
} catch (parseError) {
if (dataJson) {
console.warn("Azure SSE JSON parsing error:", parseError, "Data:", dataJson);
}
}
}
}
lines.forEach(handleAzureLine);
}
if (typeof onComplete === 'function') {
onComplete(accumulatedContent);
}
} else {
console.error("Azure OpenAI 响应体为空");
if (typeof onError === 'function') {
onError(new Error("Azure OpenAI API 错误: 响应体为空"));
}
}
} catch (error) {
if (timeoutId) clearTimeout(timeoutId);
if (error.name === 'AbortError') {
console.log("Azure OpenAI 请求被中止.");
if (!signal && typeof onError === 'function') {
onError(error);
}
} else {
console.error("Azure OpenAI 调用/处理失败:", error);
if (typeof onError === 'function') {
onError(new Error(`Azure OpenAI API 网络或处理错误: ${error.message}`));
}
}
}
}
class ThinkingHandler {
constructor(container) {
this.container = container;
this.thinkingProcessDiv = null;
this.preElement = null;
this.detailsElement = null;
this.arrowSvg = null;
}
_ensureDiv() {
if (!this.container) return false;
if (!this.thinkingProcessDiv || !this.container.contains(this.thinkingProcessDiv)) {
this.thinkingProcessDiv = this.container.querySelector('.ai-thinking-process');
if (!this.thinkingProcessDiv) {
this.thinkingProcessDiv = document.createElement('div');
this.thinkingProcessDiv.className = 'ai-thinking-process';
this.thinkingProcessDiv.style.marginTop = '15px';
this.thinkingProcessDiv.style.display = 'none';
this.container.appendChild(this.thinkingProcessDiv);
}
this.detailsElement = this.thinkingProcessDiv.querySelector('details');
if (!this.detailsElement) {
this.thinkingProcessDiv.innerHTML = `
查看 AI 思考过程
`;
this.detailsElement = this.thinkingProcessDiv.querySelector('details');
const summaryElement = this.thinkingProcessDiv.querySelector('summary');
this.arrowSvg = summaryElement.querySelector('svg');
this.detailsElement.addEventListener('toggle', () => {
if (this.arrowSvg) {
this.arrowSvg.style.transform = this.detailsElement.open ? 'rotate(90deg)' : 'rotate(0deg)';
}
});
} else {
const summaryElement = this.detailsElement.querySelector('summary');
this.arrowSvg = summaryElement ? summaryElement.querySelector('svg') : null;
}
this.preElement = this.thinkingProcessDiv.querySelector('pre');
}
return true;
}
update(contentToAdd) {
if (!this._ensureDiv() || !this.preElement) return;
requestAnimationFrame(() => {
this.preElement.appendChild(document.createTextNode(contentToAdd));
this.preElement.scrollTop = this.preElement.scrollHeight;
});
}
show(makeOpen = true) {
if (!this._ensureDiv()) return;
this.thinkingProcessDiv.style.display = 'block';
if (makeOpen && this.detailsElement && !this.detailsElement.open) {
this.detailsElement.open = true;
}
}
hide() {
if (this.thinkingProcessDiv) {
this.thinkingProcessDiv.style.display = 'none';
}
}
reset() {
if (this._ensureDiv() && this.preElement) {
this.preElement.textContent = '';
}
this.hide();
}
displayFinal(thinkingProcess) {
if (!thinkingProcess || !thinkingProcess.trim()) {
this.reset();
return;
}
if (!this._ensureDiv() || !this.preElement) return;
this.preElement.textContent = thinkingProcess;
this.show(false);
}
}
class StreamProcessor {
constructor(targetElement, questionTypeNum, thinkingHandler, onUpdateTarget, onFinalizeTarget) {
this.targetElement = targetElement;
this.questionTypeNum = questionTypeNum;
this.thinkingHandler = thinkingHandler;
this.onUpdateTarget = onUpdateTarget;
this.onFinalizeTarget = onFinalizeTarget;
this.buffer = '';
this.isThinking = false;
this.currentMainContent = '';
this.currentThinkingContent = '';
this.thinkStartTag = '';
this.thinkEndTag = '';
}
processChunk(delta) {
if (!delta) return;
this.buffer += delta;
let thinkStartIndex, thinkEndIndex;
while (true) {
if (!this.isThinking) {
thinkStartIndex = this.buffer.indexOf(this.thinkStartTag);
if (thinkStartIndex !== -1) {
const beforeThink = this.buffer.substring(0, thinkStartIndex);
this.currentMainContent += beforeThink;
if (typeof this.onUpdateTarget === 'function') {
this.onUpdateTarget(beforeThink);
}
this.isThinking = true;
this.buffer = this.buffer.substring(thinkStartIndex + this.thinkStartTag.length);
this.thinkingHandler.show();
} else {
this.currentMainContent += this.buffer;
if (typeof this.onUpdateTarget === 'function') {
this.onUpdateTarget(this.buffer);
}
this.buffer = '';
break;
}
} else {
thinkEndIndex = this.buffer.indexOf(this.thinkEndTag);
if (thinkEndIndex !== -1) {
const thinkingPart = this.buffer.substring(0, thinkEndIndex);
this.currentThinkingContent += thinkingPart;
this.thinkingHandler.update(thinkingPart);
this.isThinking = false;
this.buffer = this.buffer.substring(thinkEndIndex + this.thinkEndTag.length);
} else {
this.currentThinkingContent += this.buffer;
this.thinkingHandler.update(this.buffer);
this.buffer = '';
break;
}
}
}
}
processComplete() {
console.log("Stream complete in StreamProcessor.");
if (this.buffer) {
if (this.isThinking) {
this.currentThinkingContent += this.buffer;
this.thinkingHandler.update(this.buffer);
} else {
this.currentMainContent += this.buffer;
if (typeof this.onUpdateTarget === 'function') {
if (this.questionTypeNum !== 4) this.onUpdateTarget(this.buffer);
}
}
this.buffer = '';
}
if (!this.currentThinkingContent.trim()) {
this.thinkingHandler.hide();
}
return {
mainContent: this.currentMainContent,
thinkingContent: this.currentThinkingContent
};
}
reset() {
this.buffer = '';
this.isThinking = false;
this.currentMainContent = '';
this.currentThinkingContent = '';
this.thinkingHandler.reset();
}
}
function isEmptyRichText(content) {
try {
let jsonContent = JSON.parse(content);
if (jsonContent.blocks.length === 1 &&
jsonContent.blocks[0].text === "" &&
Object.keys(jsonContent.entityMap).length === 0) {
return true;
}
return false;
} catch (e) {
return false;
}
}
async function uploadImage(file) {
try {
const token = getToken();
if (!token) {
throw new Error('无法获取授权,请确保已登录');
}
const uploadId = `rc-upload-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
const credentialResponse = await fetch(`${window.location.origin}/api/jx-oresource/disk/files`, {
method: 'POST',
headers: {
"accept": "*/*",
"authorization": `Bearer ${token}`,
"content-type": "application/json; charset=UTF-8"
},
body: JSON.stringify({
uploadId: uploadId,
filename: file.name,
file_size: file.size
})
});
const credentialData = await credentialResponse.json();
if (!credentialData.success || !credentialData.data) {
console.error('上传凭证数据不完整:', credentialData);
throw new Error(credentialData.message || '获取上传凭证失败,返回的数据结构不完整');
}
const formData = new FormData();
formData.append('key', credentialData.data.multipart.key);
for (const key in credentialData.data.multipart) {
if (key !== 'key') {
formData.append(key, credentialData.data.multipart[key]);
}
}
formData.append('file', file);
console.log('上传地址:', credentialData.data.host);
console.log('表单数据:', Object.keys(credentialData.data.multipart));
const uploadResponse = await fetch(credentialData.data.host, {
method: 'POST',
body: formData
});
if (!uploadResponse.ok) {
const errorText = await uploadResponse.text();
console.error('上传失败响应:', errorText);
throw new Error(`文件上传失败,状态码: ${uploadResponse.status}, 错误信息: ${errorText}`);
}
if (!credentialData.data.multipart.id) {
console.error('缺少文件ID:', credentialData);
throw new Error('上传成功但缺少文件ID');
}
return `${window.location.origin}/api/jx-oresource/cloud/file_access/${credentialData.data.multipart.id}`;
} catch (error) {
console.error('上传图片失败:', error);
throw error;
}
}
function insertImageToEditor(editor, imageUrl) {
const imgElement = `
`;
if (window.getSelection) {
const selection = window.getSelection();
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
if (range.commonAncestorContainer === editor || editor.contains(range.commonAncestorContainer)) {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = imgElement;
const imgNode = tempDiv.firstElementChild;
if (imgNode) {
range.deleteContents();
range.insertNode(imgNode);
try {
range.setStartAfter(imgNode);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
} catch (error) {
console.warn('设置光标位置失败,但图片已成功插入:', error);
}
return;
}
}
}
}
editor.innerHTML += imgElement;
}
function updateAnswerWithContent(question, htmlContent) {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = htmlContent;
const blocks = [];
let currentTextBlock = "";
let blockKey = 0;
function processNodes() {
const allNodes = [];
const walkNodes = (node, isRoot = false) => {
if (node.nodeType === Node.TEXT_NODE && node.textContent.trim() !== '') {
allNodes.push({ type: 'text', node: node });
} else if (node.nodeType === Node.ELEMENT_NODE) {
if (node.nodeName === 'IMG') {
allNodes.push({ type: 'image', node: node });
} else if (node.nodeName === 'BR') {
allNodes.push({ type: 'linebreak' });
} else if (!isRoot && (node.nodeName === 'DIV' || node.style.display === 'block')) {
const childNodes = Array.from(node.childNodes);
childNodes.forEach(child => walkNodes(child));
if (node.nextSibling) {
allNodes.push({ type: 'linebreak' });
}
} else {
Array.from(node.childNodes).forEach(child => walkNodes(child));
}
}
};
walkNodes(tempDiv, true);
return allNodes;
}
const nodes = processNodes();
for (let i = 0; i < nodes.length; i++) {
const nodeInfo = nodes[i];
if (nodeInfo.type === 'text') {
currentTextBlock += nodeInfo.node.textContent;
} else if (nodeInfo.type === 'linebreak') {
if (i < nodes.length - 1) {
currentTextBlock += '\n';
}
} else if (nodeInfo.type === 'image') {
if (currentTextBlock) {
blocks.push({
key: `block${blockKey++}`,
text: currentTextBlock,
type: 'unstyled',
depth: 0,
inlineStyleRanges: [],
entityRanges: [],
data: {}
});
currentTextBlock = "";
}
const img = nodeInfo.node;
if (img && img.src) {
const fileIdMatch = img.src.match(/\/cloud\/file_access\/(\d+)/);
if (fileIdMatch && fileIdMatch[1]) {
blocks.push({
key: `block${blockKey++}`,
text: "",
type: "atomic",
depth: 0,
inlineStyleRanges: [],
entityRanges: [],
data: {
type: "IMAGE",
src: `${window.location.origin}/api/jx-oresource/cloud/file_access/${fileIdMatch[1]}`
}
});
}
}
}
}
if (currentTextBlock) {
blocks.push({
key: `block${blockKey++}`,
text: currentTextBlock,
type: 'unstyled',
depth: 0,
inlineStyleRanges: [],
entityRanges: [],
data: {}
});
}
if (blocks.length === 0) {
blocks.push({
key: 'empty',
text: '',
type: 'unstyled',
depth: 0,
inlineStyleRanges: [],
entityRanges: [],
data: {}
});
}
const richTextContent = {
blocks: blocks,
entityMap: {}
};
question.answer_items[0].answer = JSON.stringify(richTextContent);
}
async function buildMultimodalPrompt(provider, question, promptTemplate, customPrompts, currentAnswerContent, extraText = '', paperDescription = null, temporaryPrompt = '') {
const questionTypeNum = question.type;
let multimodalContent = [];
if (temporaryPrompt) {
multimodalContent.push({ type: 'text', text: `【临时指令】:\n${temporaryPrompt}\n\n---\n\n` });
}
if (paperDescription) {
multimodalContent.push({ type: 'text', text: '【作业说明及公共材料】:\n' });
multimodalContent.push(...await parseRichTextToMultimodalContent(paperDescription));
multimodalContent.push({ type: 'text', text: '\n\n---\n\n' });
}
const placeholderRegex = /(\{questionTitle\}|\{optionsText\}|\{stemsText\}|\{answerContent\})/g;
const templateParts = promptTemplate.split(placeholderRegex);
const parseToStandardFormat = async (richText) => {
return await parseRichTextToMultimodalContent(richText);
};
for (const part of templateParts) {
switch (part) {
case '{questionTitle}':
if (question.parentQuestion && question.parentQuestion.title) {
multimodalContent.push(...await parseToStandardFormat(question.parentQuestion.title));
multimodalContent.push({ type: 'text', text: '\n\n--- (子题目) ---\n\n' });
}
multimodalContent.push(...await parseToStandardFormat(question.title));
break;
case '{optionsText}':
if ([1, 2, 5, 12].includes(questionTypeNum)) {
for (const [idx, item] of question.answer_items.entries()) {
const letter = String.fromCharCode(65 + idx);
const prefix = questionTypeNum === 5 ? (idx === 0 ? '正确' : '错误') : '';
multimodalContent.push({ type: 'text', text: `\n${letter}. ${prefix}` });
if (questionTypeNum !== 5) {
multimodalContent.push(...await parseToStandardFormat(item.value));
}
}
} else if (questionTypeNum === 13) {
const rightItems = question.answer_items.filter(item => item.is_target_opt);
for (const [idx, item] of rightItems.entries()) {
const letter = String.fromCharCode(97 + idx);
multimodalContent.push({ type: 'text', text: `\n${letter}. ` });
multimodalContent.push(...await parseToStandardFormat(item.value));
}
}
break;
case '{stemsText}':
if (questionTypeNum === 13) {
const leftItems = question.answer_items.filter(item => !item.is_target_opt);
for (const [idx, item] of leftItems.entries()) {
const letter = String.fromCharCode(65 + idx);
multimodalContent.push({ type: 'text', text: `\n${letter}. ` });
multimodalContent.push(...await parseToStandardFormat(item.value));
}
}
break;
case '{answerContent}':
if ([4, 6, 10].includes(questionTypeNum)) {
const content = currentAnswerContent !== null ? currentAnswerContent : parseRichTextToPlainText(question.answer_items[0]?.answer || '');
multimodalContent.push({ type: 'text', text: content });
}
break;
default:
if (part) {
let textPart = part;
textPart = textPart.replace('{questionType}', getQuestionType(question.type));
if (question.type === 10) {
const progSetting = question.program_setting || {};
textPart = textPart.replace('{language}', progSetting.language?.join(', ') || '未指定');
textPart = textPart.replace('{max_time}', progSetting.max_time || 'N/A');
textPart = textPart.replace('{max_memory}', progSetting.max_memory || 'N/A');
}
multimodalContent.push({ type: 'text', text: textPart });
}
}
}
if (extraText) {
multimodalContent.push({ type: 'text', text: `\n\n【听力原文】:\n${extraText}` });
}
const mergedContent = [];
let textBuffer = '';
for (const item of multimodalContent) {
if (item.type === 'text') {
textBuffer += item.text;
} else {
if (textBuffer) {
mergedContent.push({ type: 'text', text: textBuffer });
textBuffer = '';
}
mergedContent.push(item);
}
}
if (textBuffer) {
mergedContent.push({ type: 'text', text: textBuffer });
}
return mergedContent;
}
async function _getAIAnswer(question, aiConfig, customPrompts, temporaryPrompt = '', currentAnswerContent = null, onChunk = null, onComplete = null, signal = null) {
if (signal?.aborted) {
return Promise.resolve({ cancelled: true });
}
const {
provider = 'default',
endpoint,
apiKey,
model,
temperature,
max_tokens,
azureApiVersion,
visionEnabled,
sttEnabled
} = aiConfig;
const questionTypeNum = question.type;
const questionType = getQuestionType(questionTypeNum);
const typeCodeStr = String(questionTypeNum);
let promptTemplate = customPrompts[typeCodeStr] || defaultPrompts[typeCodeStr];
if (!promptTemplate) {
console.warn(`未找到题型 ${questionTypeNum} (${questionType}) 的 Prompt 模板!将跳过此题。`);
return Promise.resolve({ skipped: true, reason: `不支持的题型 (${questionType})` });
}
const paperDescription = localStorage.getItem('paperDescription');
let transcriptionText = '';
const questionIdForLog = question.parentQuestion ?
`${question.parentQuestion.id} (子问题: ${question.id})` :
question.id;
const hasAudioInSelf = containsAudio(question.title);
const hasAudioInParent = question.parentQuestion && containsAudio(question.parentQuestion.title);
const hasAudioInPaper = paperDescription && containsAudio(paperDescription);
const videoCheckEnabled = aiConfig.sttVideoEnabled === true;
const hasVideoInSelf = videoCheckEnabled && containsVideo(question.title);
const hasVideoInParent = videoCheckEnabled && question.parentQuestion && containsVideo(question.parentQuestion.title);
const hasVideoInPaper = videoCheckEnabled && paperDescription && containsVideo(paperDescription);
const isListeningTest = hasAudioInSelf || hasAudioInParent || hasAudioInPaper ||
hasVideoInSelf || hasVideoInParent || hasVideoInPaper;
if (isListeningTest && sttEnabled) {
const STT_PROGRESS_ID = `stt-progress-${question.id}`;
try {
let allMediaBlocks = [];
const addedMediaIds = new Set();
const collectMediaBlocks = (richText) => {
if (!richText) return;
try {
const jsonContent = JSON.parse(richText);
const mediaBlocks = jsonContent.blocks.filter(block =>
block.type === 'atomic' && block.data && (block.data.type === 'AUDIO' || block.data.type === 'VIDEO')
);
mediaBlocks.forEach(block => {
const mediaType = block.data.type;
const mediaId = (mediaType === 'AUDIO')
? block.data.data?.quote_id
: block.data.data?.video_id;
if (mediaId && !addedMediaIds.has(mediaId)) {
allMediaBlocks.push(block);
addedMediaIds.add(mediaId);
}
});
} catch (e) {
console.error('解析富文本失败:', e, richText);
}
};
if (hasAudioInPaper || hasVideoInPaper) collectMediaBlocks(paperDescription);
if (hasAudioInParent || hasVideoInParent) collectMediaBlocks(question.parentQuestion.title);
if (hasAudioInSelf || hasVideoInSelf) collectMediaBlocks(question.title);
if (allMediaBlocks.length > 0) {
console.log(`[STT流程] 题 ${questionIdForLog}: 发现 ${allMediaBlocks.length} 个媒体文件,开始处理...`);
const transcriptionPromises = allMediaBlocks.map(async (mediaBlock, mapIndex) => {
const mediaType = mediaBlock.data.type;
const mediaId = (mediaType === 'AUDIO') ? mediaBlock.data.data.quote_id : mediaBlock.data.data.video_id;
const cacheKey = `${mediaType.toLowerCase()}_transcription_${mediaId}`;
if (sttCache[cacheKey]) {
console.log(`[STT Cache] HIT for ${mediaType}: ${mediaId}`);
return sttCache[cacheKey];
}
if (mediaProcessingLocks[mediaId]) {
console.log(`[STT Lock] 题 ${questionIdForLog}: 媒体 ${mediaId} 正在被其他任务处理,等待结果...`);
return await mediaProcessingLocks[mediaId];
}
const processingPromise = (async () => {
try {
let mediaSource;
if (mediaType === 'AUDIO') {
mediaSource = await getAudioUrl(mediaId);
} else {
const urls = await getVideoUrl(mediaId);
if (!urls || !urls.videoUrl) throw new Error(`无法获取Video ID ${mediaId}的播放地址`);
const progressCallback = (progress, stage) => {
const message = `🎬 [${mapIndex + 1}/${allMediaBlocks.length}] 提取视频音轨: ${stage}...(${(progress * 100).toFixed(0)}%)`;
showNotification(message, { id: STT_PROGRESS_ID, type: 'info', duration: 0 });
};
mediaSource = await extractAndEncodeAudio(urls.videoUrl, progressCallback);
}
if (!mediaSource) throw new Error(`无法获取 ${mediaType} ID ${mediaId} 的媒体源`);
if (!signal?.aborted) {
showNotification(`☁️ [${mapIndex + 1}/${allMediaBlocks.length}] 上传转录 ${mediaType}...`, { id: STT_PROGRESS_ID, type: 'info', duration: 0 });
const transcription = await callSttApi(mediaSource, aiConfig);
sttCache[cacheKey] = transcription;
return transcription;
}
return `[${mediaType}转录取消]`;
} catch (err) {
console.error(`[STT Worker] 媒体 ${mediaId} 处理失败:`, err);
throw err;
} finally {
delete mediaProcessingLocks[mediaId];
}
})();
mediaProcessingLocks[mediaId] = processingPromise;
return await processingPromise;
});
const allTranscriptions = await Promise.all(transcriptionPromises);
showNotification('媒体处理完成', { id: STT_PROGRESS_ID, type: 'success', duration: 500 });
if (allTranscriptions.length === 1) {
transcriptionText = allTranscriptions[0];
} else {
transcriptionText = allTranscriptions
.map((text, i) => `【媒体内容 ${i + 1}】:\n${text}`)
.join('\n\n---\n\n');
}
console.log('[STT流程] 所有媒体处理完成,合并后的文本:', transcriptionText);
} else {
console.warn(`[STT流程] 听力题 ${questionIdForLog} 中未找到有效的媒体块。`);
}
} catch (error) {
showNotification(`媒体处理失败`, { id: STT_PROGRESS_ID, type: 'error', duration: 3000 });
console.error(`[STT流程] 为题目 ${questionIdForLog} 处理媒体时发生严重错误: ${error.message}`);
showNotification(`处理媒体失败,将仅使用题目文本进行AI辅助。`, { type: 'warning' });
transcriptionText = "[语音/视频转录失败]";
}
}
let finalPrompt;
const isVisionRequest = visionEnabled && ['openai', 'gemini', 'anthropic', 'azure'].includes(provider);
if (isVisionRequest) {
finalPrompt = await buildMultimodalPrompt(provider, question, promptTemplate, customPrompts, currentAnswerContent, transcriptionText, paperDescription, temporaryPrompt);
} else {
let parentTitleText = question.parentQuestion ? parseRichTextToPlainText(question.parentQuestion.title) : '';
let currentTitleText = parseRichTextToPlainText(question.title);
let paperDescriptionText = paperDescription ? parseRichTextToPlainText(paperDescription) : '';
let fullContext = '';
if (paperDescriptionText) {
fullContext += `【作业说明及公共材料】:\n${paperDescriptionText}\n\n---\n\n`;
}
if (parentTitleText) {
fullContext += `${parentTitleText}\n\n--- (子题目) ---\n\n`;
}
let questionTitle = `${fullContext}${currentTitleText}`;
if (transcriptionText) {
questionTitle += `\n\n【听力/视频原文】:\n${transcriptionText}`;
}
let optionsText = '', answerContent = '', language = '', max_time = '', max_memory = '', stemsText = '';
if ([1, 2, 5, 12].includes(questionTypeNum)) {
optionsText = question.answer_items.map((item, idx) => {
const letter = String.fromCharCode(65 + idx);
const valueText = questionTypeNum === 5 ? (idx === 0 ? '正确' : '错误') : parseRichTextToPlainText(item.value);
return `${letter}. ${valueText}`;
}).join('\n');
} else if (questionTypeNum === 4) {
answerContent = '';
} else if ([6].includes(questionTypeNum)) {
answerContent = currentAnswerContent !== null ? currentAnswerContent : parseRichTextToPlainText(question.answer_items[0]?.answer || '');
} else if (questionTypeNum === 10) {
const progSetting = question.program_setting || {};
answerContent = currentAnswerContent !== null ? currentAnswerContent : (progSetting.code_answer || '');
language = progSetting.language?.join(', ') || '未指定';
max_time = progSetting.max_time || 'N/A';
max_memory = progSetting.max_memory || 'N/A';
} else if (questionTypeNum === 13) {
const leftItems = question.answer_items.filter(item => !item.is_target_opt);
const rightItems = question.answer_items.filter(item => item.is_target_opt);
stemsText = leftItems.map((item, idx) => `${String.fromCharCode(65 + idx)}. ${parseRichTextToPlainText(item.value)}`).join('\n');
optionsText = rightItems.map((item, idx) => `${String.fromCharCode(97 + idx)}. ${parseRichTextToPlainText(item.value)}`).join('\n');
} else {
return Promise.resolve({ skipped: true, reason: `不支持的题型 (${questionType})` });
}
const placeholderValues = {
'{questionType}': questionType, '{questionTitle}': questionTitle, '{optionsText}': optionsText,
'{stemsText}': stemsText, '{answerContent}': answerContent, '{language}': language,
'{max_time}': max_time, '{max_memory}': max_memory
};
let promptStr = promptTemplate;
for (const placeholder in placeholderValues) {
promptStr = promptStr.replace(new RegExp(placeholder.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'), 'g'), placeholderValues[placeholder]);
}
if (temporaryPrompt) {
finalPrompt = `【临时指令】:\n${temporaryPrompt}\n\n---\n\n${promptStr}`;
} else {
finalPrompt = promptStr;
}
}
console.log(`[AI Helper] 题 ${question.id} (${questionType}) | Provider: ${provider} | Vision: ${isVisionRequest} | STT: ${isListeningTest && sttEnabled} | Final Prompt:`, finalPrompt);
return new Promise(async (resolve, reject) => {
const handleInternalComplete = (content) => {
if (typeof onComplete === 'function') onComplete(content);
resolve({ aiResult: content });
};
const handleInternalError = (error) => reject(error);
try {
if (signal?.aborted) { reject(new DOMException('请求在发送前被中止', 'AbortError')); return; }
if (provider === 'default') {
await callXiaoyaStream(typeof finalPrompt === 'string' ? finalPrompt : finalPrompt.map(p => p.text).join('\n'), onChunk, handleInternalComplete, handleInternalError, signal);
} else if (!endpoint || !apiKey) {
throw new Error('请先在 AI 设置中配置 API 地址和 Key');
} else {
switch (provider) {
case 'openai':
await callOpenAI(endpoint, apiKey, finalPrompt, model, temperature, max_tokens, onChunk, handleInternalComplete, handleInternalError, signal, isVisionRequest);
break;
case 'gemini':
await callGemini(endpoint, apiKey, finalPrompt, model, temperature, max_tokens, onChunk, handleInternalComplete, handleInternalError, signal, isVisionRequest);
break;
case 'anthropic':
await callAnthropic(endpoint, apiKey, finalPrompt, model, temperature, max_tokens, onChunk, handleInternalComplete, handleInternalError, signal, isVisionRequest);
break;
case 'azure':
await callAzureOpenAI(endpoint, apiKey, azureApiVersion, model, finalPrompt, temperature, max_tokens, onChunk, handleInternalComplete, handleInternalError, signal, isVisionRequest);
break;
default:
throw new Error(`不支持的 AI 提供商: ${provider}`);
}
}
} catch (error) {
reject(error);
}
});
}
async function promptReport(question) {
const overlay = document.createElement('div');
overlay.style.cssText = `
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background-color: rgba(0, 0, 0, 0.7); z-index: 10001;
display: flex; align-items: center; justify-content: center;
opacity: 0; transition: opacity 0.3s ease; backdrop-filter: blur(5px);
`;
const modal = document.createElement('div');
modal.style.cssText = `
background: #ffffff; padding: 32px 40px; border-radius: 20px;
width: 500px; max-width: 90%;
box-shadow: 0 20px 40px rgba(0,0,0,0.15);
transform: scale(0.95); opacity: 0;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
position: relative;
`;
const title = document.createElement('h2');
title.innerHTML = `
举报/纠错
`;
title.style.cssText = `margin-top: 0; margin-bottom: 25px; text-align: center; color: #1f2937; font-size: 22px;`;
const description = document.createElement('p');
description.textContent = '请选择问题类型,并提供简要说明(可选):';
description.style.cssText = 'margin-bottom: 20px; color: #4b5569; text-align: center; font-size: 15px;';
const reportTypes = [
{ id: 'wrong_answer', text: '答案错误' },
{ id: 'format_error', text: '格式问题' },
{ id: 'spam_or_abuse', text: '违规内容' },
{ id: 'other', text: '其他问题' }
];
let selectedType = '';
const typeContainer = document.createElement('div');
typeContainer.style.cssText = 'display: flex; justify-content: center; gap: 10px; margin-bottom: 20px; flex-wrap: wrap;';
reportTypes.forEach(type => {
const btn = document.createElement('button');
btn.textContent = type.text;
btn.dataset.type = type.id;
btn.style.cssText = `
padding: 8px 16px; border: 1px solid #d1d5db; border-radius: 8px;
cursor: pointer; background-color: #f9fafb; color: #374151;
font-weight: 500; transition: all 0.2s ease;
`;
btn.onclick = () => {
selectedType = type.id;
typeContainer.querySelectorAll('button').forEach(b => {
b.style.backgroundColor = '#f9fafb';
b.style.color = '#374151';
b.style.borderColor = '#d1d5db';
});
btn.style.backgroundColor = '#eef2ff';
btn.style.color = '#4f46e5';
btn.style.borderColor = '#6366f1';
};
typeContainer.appendChild(btn);
});
const commentTextarea = document.createElement('textarea');
commentTextarea.placeholder = '请在此处详细说明问题(选填)...';
commentTextarea.rows = 4;
commentTextarea.style.cssText = `
width: 100%; padding: 12px; border: 1px solid #d1d5db;
border-radius: 10px; font-size: 14px; resize: vertical; margin-bottom: 25px;
box-sizing: border-box; outline: none; transition: all 0.2s ease;
`;
commentTextarea.onfocus = () => {
commentTextarea.style.borderColor = '#6366f1';
commentTextarea.style.boxShadow = '0 0 0 3px rgba(99, 102, 241, 0.15)';
};
commentTextarea.onblur = () => {
commentTextarea.style.borderColor = '#d1d5db';
commentTextarea.style.boxShadow = 'none';
};
const buttonContainer = document.createElement('div');
buttonContainer.style.cssText = 'display: flex; justify-content: flex-end; gap: 12px;';
const submitButton = document.createElement('button');
submitButton.textContent = '提交反馈';
submitButton.style.cssText = `
padding: 10px 20px; background: #ef4444; color: white;
border: none; border-radius: 8px; cursor: pointer;
font-weight: 600; transition: all 0.2s ease;
`;
submitButton.onmouseover = () => { submitButton.style.backgroundColor = '#dc2626'; };
submitButton.onmouseout = () => { submitButton.style.backgroundColor = '#ef4444'; };
const cancelButton = document.createElement('button');
cancelButton.textContent = '取消';
cancelButton.style.cssText = `
padding: 10px 20px; background-color: #f3f4f6; color: #4b5563;
border: 1px solid #d1d5db; border-radius: 8px; cursor: pointer;
font-weight: 500; transition: all 0.2s ease;
`;
cancelButton.onmouseover = () => { cancelButton.style.backgroundColor = '#e5e7eb'; };
cancelButton.onmouseout = () => { cancelButton.style.backgroundColor = '#f3f4f6'; };
modal.appendChild(title);
modal.appendChild(description);
modal.appendChild(typeContainer);
modal.appendChild(commentTextarea);
buttonContainer.appendChild(cancelButton);
buttonContainer.appendChild(submitButton);
modal.appendChild(buttonContainer);
overlay.appendChild(modal);
document.body.appendChild(overlay);
requestAnimationFrame(() => {
overlay.style.opacity = '1';
modal.style.opacity = '1';
modal.style.transform = 'scale(1)';
});
const closeModal = () => {
modal.style.transform = 'scale(0.95)';
modal.style.opacity = '0';
overlay.style.opacity = '0';
setTimeout(() => document.body.removeChild(overlay), 300);
};
cancelButton.onclick = closeModal;
overlay.onclick = (e) => { if (e.target === overlay) closeModal(); };
submitButton.onclick = async () => {
if (!selectedType) {
showNotification('请至少选择一个问题类型!', { type: 'warning' });
return;
}
const contentHash = generateContentHash(question);
if (!contentHash) {
showNotification('无法为此题生成唯一标识,举报失败。', { type: 'error' });
return;
}
submitButton.disabled = true;
submitButton.textContent = '提交中...';
submitButton.style.opacity = '0.7';
submitButton.style.cursor = 'not-allowed';
try {
const response = await authedFetch('reportAnswer', {
content_hash: contentHash,
report_type: selectedType,
comment: commentTextarea.value.trim()
});
if (response.success) {
showNotification(response.message, { type: 'success' });
closeModal();
} else {
throw new Error(response.error);
}
} catch (error) {
showNotification(`举报失败: ${error.message}`, { type: 'error' });
submitButton.disabled = false;
submitButton.textContent = '提交反馈';
submitButton.style.opacity = '1';
submitButton.style.cursor = 'pointer';
}
};
}
function createReportButton(question) {
const reportButton = document.createElement('button');
reportButton.textContent = '答案有误?';
reportButton.style.cssText = `
background: none; border: none; color: #9ca3af;
font-size: 13px; cursor: pointer; transition: color 0.2s;
`;
reportButton.onmouseover = () => { reportButton.style.color = '#ef4444'; };
reportButton.onmouseout = () => { reportButton.style.color = '#9ca3af'; };
reportButton.onclick = async () => {
if (await checkAccountConsistency()) {
promptReport(question);
} else {
console.warn("[操作中止] 因账号不一致,已取消举报操作。");
}
};
const actionsContainer = document.createElement('div');
actionsContainer.style.textAlign = 'right';
actionsContainer.style.marginTop = '15px';
actionsContainer.appendChild(reportButton);
return actionsContainer;
}
function attachSttOnlyButtonListeners(container) {
const buttons = container.querySelectorAll('[id^="stt-only-btn-"]');
buttons.forEach(button => {
if (button.dataset.listenerAttached) return;
button.dataset.listenerAttached = 'true';
button.onclick = async () => {
const fileId = button.dataset.fileId;
const resultContainer = container.querySelector(`#stt-result-container-${fileId}`);
const aiConfig = JSON.parse(localStorage.getItem('aiConfig') || '{}');
if (!aiConfig.sttEnabled) {
showNotification('请先在 AI 设置中启用 STT 功能。', { type: 'warning' });
return;
}
button.disabled = true;
button.textContent = '🔄 转录中...';
try {
const audioUrl = await getAudioUrl(fileId);
if (!audioUrl) throw new Error("无法获取音频URL");
const transcription = await callSttApi(audioUrl, aiConfig);
const pre = document.createElement('pre');
pre.textContent = transcription;
pre.style.cssText = `white-space: pre-wrap; word-wrap: break-word; margin: 0; font-size: 14px; color: #334155; line-height: 1.6;`;
resultContainer.innerHTML = '';
resultContainer.appendChild(pre);
resultContainer.style.display = 'block';
button.textContent = '✅ 转录完成';
} catch (error) {
console.error('仅转录音频时失败:', error);
showNotification(`转录失败: ${error.message}`, { type: 'error' });
button.disabled = false;
button.textContent = '🎤 重新尝试转录';
}
};
});
}
function attachVideoSttButtonListeners(container) {
const buttons = container.querySelectorAll('[id^="video-stt-btn-"]');
buttons.forEach(button => {
if (button.dataset.listenerAttached) return;
button.dataset.listenerAttached = 'true';
button.onclick = async () => {
const videoId = button.id.replace('video-stt-btn-', '');
const videoUrl = button.dataset.videoUrl;
const resultContainer = container.querySelector(`#video-stt-result-container-${videoId}`);
const aiConfig = JSON.parse(localStorage.getItem('aiConfig') || '{}');
if (!aiConfig.sttEnabled) {
showNotification('请先在 AI 设置中启用 STT 功能。', { type: 'warning' });
return;
}
button.disabled = true;
button.innerHTML = '🔄 处理中...';
const progressTextSpan = button.querySelector('.progress-text');
const updateProgress = (progress, stage) => {
if (progressTextSpan) {
const percentage = (progress * 100).toFixed(0);
progressTextSpan.textContent = `${stage}... (${percentage}%)`;
}
};
try {
if (!videoUrl) throw new Error("无效的视频URL");
const audioBlob = await extractAndEncodeAudio(videoUrl, updateProgress);
if (progressTextSpan) progressTextSpan.textContent = '上传转录中...';
const transcription = await callSttApi(audioBlob, aiConfig);
const pre = document.createElement('pre');
pre.textContent = transcription;
pre.style.cssText = `white-space: pre-wrap; word-wrap: break-word; margin: 0; font-size: 14px; color: #334155; line-height: 1.6;`;
resultContainer.innerHTML = '';
resultContainer.appendChild(pre);
resultContainer.style.display = 'block';
button.textContent = '✅ 转录完成';
} catch (error) {
console.error('视频音频转录失败:', error);
showNotification(`视频音频转录失败: ${error.message}`, { type: 'error' });
button.disabled = false;
button.innerHTML = '🎬 重新尝试转录';
}
};
});
}
function showAnswerEditor() {
const questionTypeStyles = {
'1': { text: '单选题', bg: '#eef2ff', color: '#4338ca' },
'2': { text: '多选题', bg: '#e0f2fe', color: '#0369a1' },
'4': { text: '填空题', bg: '#f0fdf4', color: '#15803d' },
'5': { text: '判断题', bg: '#fdf2f8', color: '#9d174d' },
'6': { text: '简答题', bg: '#fffbeb', color: '#b45309' },
'9': { text: '数组题', bg: '#f3f4f6', color: '#475569' },
'10': { text: '编程题', bg: '#1f2937', color: '#e5e7eb' },
'12': { text: '排序题', bg: '#f5f3ff', color: '#6d28d9' },
'13': { text: '匹配题', bg: '#fefce8', color: '#a16207' },
'default': { text: '未知', bg: '#f1f5f9', color: '#475569' }
};
sttCache = {};
let storedData = localStorage.getItem('answerData');
if (!storedData) {
showNotification('未找到存储的数据,请先点击"获取答案"按钮。', { type: 'error', keywords: ['存储', '答案', '获取'], animation: 'fadeSlide' });
return;
}
let isContentModified = false;
let answerData = JSON.parse(storedData);
let overlay = document.createElement('div');
let modalContainer = document.createElement('div');
let resizeHandle = document.createElement('div');
let dragHandle = document.createElement('div');
let closeButton = document.createElement('button');
let modalContentWrapper = document.createElement('div');
let title = document.createElement('h2');
let saveButton = document.createElement('button');
overlay.style.position = 'fixed';
overlay.style.top = '0';
overlay.style.left = '0';
overlay.style.width = '100%';
overlay.style.height = '100%';
overlay.style.backgroundColor = 'transparent';
overlay.style.pointerEvents = 'none';
overlay.style.zIndex = '9999';
overlay.style.opacity = '0';
overlay.style.transition = 'opacity 0.3s ease-in-out';
modalContainer.id = 'modal-container';
modalContainer.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
z-index: 10000;
width: 90%;
max-width: 1500px;
height: 85vh;
min-width: 400px;
background-color: #ffffff;
border-radius: 20px;
padding: 48px 32px 32px 32px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
opacity: 0;
transition: opacity 0.3s ease;
display: flex;
flex-direction: column;
`;
resizeHandle.style.cssText = `
position: absolute;
right: 2px;
bottom: 2px;
width: 20px;
height: 20px;
cursor: nw-resize;
border-radius: 0 0 18px 0;
background: linear-gradient(135deg,
transparent 25%,
#e2e8f0 25%,
#e2e8f0 37%,
#6366f1 37%,
#6366f1 50%,
transparent 50%,
transparent 62%,
#6366f1 62%,
#6366f1 75%,
transparent 75%
);
opacity: 0.6;
`;
resizeHandle.addEventListener('mouseenter', () => {
resizeHandle.style.opacity = '1';
resizeHandle.style.transform = 'scale(1.1)';
});
resizeHandle.addEventListener('mouseleave', () => {
resizeHandle.style.opacity = '0.6';
resizeHandle.style.transform = 'scale(1)';
});
let isResizing = false;
let originalWidth, originalHeight, originalX, originalY;
resizeHandle.addEventListener('mousedown', (e) => {
isResizing = true;
const rect = modalContainer.getBoundingClientRect();
originalWidth = rect.width;
originalHeight = rect.height;
originalX = e.clientX;
originalY = e.clientY;
modalContainer.style.transform = 'none';
modalContainer.style.top = rect.top + 'px';
modalContainer.style.left = rect.left + 'px';
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (!isResizing) return;
const newWidth = originalWidth + (e.clientX - originalX);
const newHeight = originalHeight + (e.clientY - originalY);
const minWidth = 400;
const minHeight = 300;
if (newWidth >= minWidth) {
modalContainer.style.width = newWidth + 'px';
}
if (newHeight >= minHeight) {
modalContainer.style.height = newHeight + 'px';
}
});
document.addEventListener('mouseup', () => {
isResizing = false;
});
dragHandle.style.cssText = `
position: absolute;
top: 0;
left: 0;
right: 0;
height: 48px;
cursor: move;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
background: linear-gradient(to right, rgba(243, 244, 246, 0.95), rgba(243, 244, 246, 0.5));
border-radius: 20px 20px 0 0;
user-select: none;
transition: all 0.3s ease;
`;
dragHandle.innerHTML = `
`;
dragHandle.onmouseover = () => {
dragHandle.style.background = 'linear-gradient(to right, rgba(243, 244, 246, 1), rgba(243, 244, 246, 0.8))';
dragHandle.style.transform = 'translateY(1px)';
};
dragHandle.onmouseout = () => {
dragHandle.style.background = 'linear-gradient(to right, rgba(243, 244, 246, 0.95), rgba(243, 244, 246, 0.5))';
dragHandle.style.transform = 'translateY(0)';
};
let isDragging = false;
let currentX;
let currentY;
let initialX;
let initialY;
function onDragStart(e) {
if (e.target !== dragHandle) return;
isDragging = true;
const point = e.touches ? e.touches[0] : e;
const rect = modalContainer.getBoundingClientRect();
initialX = point.clientX - rect.left;
initialY = point.clientY - rect.top;
modalContainer.style.transition = 'none';
modalContainer.style.transform = 'none';
modalContainer.style.left = `${rect.left}px`;
modalContainer.style.top = `${rect.top}px`;
}
function onDragMove(e) {
if (!isDragging) return;
e.preventDefault();
const point = e.touches ? e.touches[0] : e;
currentX = point.clientX - initialX;
currentY = point.clientY - initialY;
const maxX = window.innerWidth - modalContainer.offsetWidth;
const maxY = window.innerHeight - modalContainer.offsetHeight;
currentX = Math.min(Math.max(0, currentX), maxX);
currentY = Math.min(Math.max(0, currentY), maxY);
modalContainer.style.left = `${currentX}px`;
modalContainer.style.top = `${currentY}px`;
}
function onDragEnd() {
isDragging = false;
modalContainer.style.transition = 'opacity 0.3s ease';
}
dragHandle.addEventListener('mousedown', onDragStart, false);
dragHandle.addEventListener('touchstart', onDragStart, { passive: false });
document.addEventListener('mousemove', onDragMove, false);
document.addEventListener('touchmove', onDragMove, { passive: false });
document.addEventListener('mouseup', onDragEnd, false);
document.addEventListener('touchend', onDragEnd, false);
function cleanup() {
dragHandle.removeEventListener('mousedown', onDragStart);
dragHandle.removeEventListener('touchstart', onDragStart);
document.removeEventListener('mousemove', onDragMove);
document.removeEventListener('touchmove', onDragMove);
document.removeEventListener('mouseup', onDragEnd);
document.removeEventListener('touchend', onDragEnd);
}
overlay.onclick = (e) => {
if (e.target === overlay) {
cleanup();
closeModal();
}
};
modalContentWrapper.id = 'modal-content-wrapper';
modalContentWrapper.style.cssText = `
display: flex;
gap: 20px;
flex: 1;
overflow: hidden;
`;
closeButton.innerHTML = `
`;
closeButton.style.cssText = `
position: absolute;
top: 15px;
right: 15px;
background: #f3f4f6;
border: none;
width: 36px;
height: 36px;
border-radius: 50%;
cursor: pointer;
color: #6b7280;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
box-shadow: 0 2px 5px rgba(0,0,0,0.08);
`;
const closeModal = async (forceClose = false) => {
if (isContentModified && !forceClose) {
const confirmed = await showConfirmNotification(
'你有未保存的修改,确定要关闭吗?所有改动将会丢失。',
{
animation: 'scale',
title: '确认关闭',
confirmText: '仍要关闭',
cancelText: '取消'
}
);
if (!confirmed) {
showNotification('操作已取消。', { type: 'info' });
return;
}
}
if (areAITasksRunning()) {
const confirmed = await showConfirmNotification(
'AI 任务正在进行中。确定要关闭并中止所有 AI 请求吗?',
{ animation: 'scale' }
);
if (!confirmed) {
showNotification('操作已取消,AI 任务将继续。', { type: 'info' });
return;
}
cancelAllAITasks();
showNotification('所有 AI 任务已中止。', { type: 'warning' });
}
modalContainer.style.transition = 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)';
modalContainer.style.transform = 'none';
modalContainer.style.left = '50%';
modalContainer.style.top = '50%';
requestAnimationFrame(() => {
overlay.style.opacity = '0';
modalContainer.style.opacity = '0';
modalContainer.style.transform = 'translate(-50%, -50%) scale(0.95)';
});
sttCache = {};
setTimeout(() => {
if (document.body.contains(overlay)) {
document.body.removeChild(overlay);
}
if (document.body.contains(modalContainer)) {
document.body.removeChild(modalContainer);
}
document.removeEventListener('keydown', handleEscapeKey);
}, 400);
};
function createAIButton(targetInput, question) {
let aiButton = document.createElement('button');
aiButton.innerHTML = '🤖AI 辅助';
aiButton.className = 'ai-assist-btn';
aiButton.title = '使用 AI 生成答案建议';
let isLoading = false;
aiButton.abortController = null;
aiButton.onclick = async () => {
if (isLoading) {
if (aiButton.abortController) {
aiButton.abortController.abort();
console.log("AI request aborted by user.");
}
return;
}
const aiConfig = { provider: 'default', ...JSON.parse(localStorage.getItem('aiConfig') || '{}') };
const customPrompts = JSON.parse(localStorage.getItem('aiCustomPrompts') || '{}');
const provider = aiConfig.provider || 'default';
const abortController = new AbortController();
aiButton.abortController = abortController;
registerAIController(abortController);
isLoading = true;
aiButton.className = 'ai-assist-btn loading';
aiButton.innerHTML = '⏳取消';
aiButton.title = '点击取消生成';
try {
const temporaryPrompt = document.getElementById('temporary-ai-prompt-textarea')?.value.trim() || '';
const result = await callAIForQuestion(question, targetInput, aiConfig, customPrompts, abortController.signal, temporaryPrompt);
if (result.skipped) {
showNotification(`题型 "${getQuestionType(question.type)}" 暂不支持 AI 辅助`, { type: 'warning' });
} else if (result.success !== false && !result.cancelled) {
showNotification(`AI (${provider === 'default' ? '小雅' : provider}) 已成功生成答案建议。`, { type: 'success', animation: 'scale' });
}
} catch (error) {
if (error.name === 'AbortError') {
showNotification('AI 生成已取消', { type: 'warning', animation: 'scale' });
} else {
console.error('AI 请求失败 (来自 createAIButton):', error);
showNotification(`AI 生成失败 (${provider}): ${error.message}`, { type: 'error' });
}
} finally {
isLoading = false;
aiButton.className = 'ai-assist-btn';
aiButton.innerHTML = '🤖AI 辅助';
aiButton.title = '使用 AI 生成答案建议';
if (aiButton.abortController) {
activeAIControllers.delete(aiButton.abortController);
}
aiButton.abortController = null;
}
};
return aiButton;
}
async function callAIForQuestion(question, targetElement, aiConfig, customPrompts, signal = null, temporaryPrompt = '') {
if (signal?.aborted) {
console.log(`[AI辅助] 题 ${question.id} 请求在 callAIForQuestion 开始前已取消`);
return { cancelled: true };
}
const questionTypeNum = question.type;
let originalContent = null;
let thinkingContainer = null;
if (targetElement) {
const subQuestionContainer = targetElement.closest('div[data-subquestion-id]');
if (subQuestionContainer) {
thinkingContainer = subQuestionContainer;
console.log(`[思维链容器] 已定位到子题目容器:`, subQuestionContainer);
} else {
thinkingContainer = targetElement.closest('.question-editor-container');
console.log(`[思维链容器] 已定位到主题目容器:`, thinkingContainer);
}
if (!thinkingContainer) {
thinkingContainer = targetElement.parentElement || document.body;
console.warn(`[思维链容器] 未找到标准容器,回退至父元素。`);
}
if (questionTypeNum === 4) {
originalContent = Array.from(targetElement.querySelectorAll('input')).map(input => input.value);
} else if (questionTypeNum === 6) {
originalContent = targetElement.innerHTML || '';
} else if (questionTypeNum === 10) {
originalContent = targetElement.value || '';
}
}
if (!thinkingContainer) {
console.warn(`[AI辅助] 题 ${question.id}: 未找到 thinkingContainer`);
thinkingContainer = document.body;
}
const thinkingHandler = new ThinkingHandler(thinkingContainer);
thinkingHandler.reset();
const onUpdateTarget = (contentToAdd) => {
if (questionTypeNum === 4) return;
requestAnimationFrame(() => {
if (!targetElement) return;
if (questionTypeNum === 6) {
targetElement.appendChild(document.createTextNode(contentToAdd));
targetElement.scrollTop = targetElement.scrollHeight;
} else if (questionTypeNum === 10) {
targetElement.value += contentToAdd;
targetElement.scrollTop = targetElement.scrollHeight;
}
if (targetElement.dispatchEvent) {
targetElement.dispatchEvent(new Event('input', { bubbles: true }));
}
});
};
const onFinalizeTarget = (finalContent) => {
console.log(`[AI辅助] Finalizing target for question ${question.id}`);
if (questionTypeNum === 4) {
if (!targetElement) return { success: false, reason: "Target element for fill-in-blanks not found" };
try {
const cleanedContent = finalContent.replace(/```json\n?([\s\S]+?)\n?```/g, '$1').trim();
const answers = JSON.parse(cleanedContent);
if (!Array.isArray(answers)) {
throw new Error("AI返回的不是一个数组");
}
const inputs = targetElement.querySelectorAll('input');
inputs.forEach((input, index) => {
if (answers[index] !== undefined) {
input.value = answers[index];
input.dispatchEvent(new Event('input', { bubbles: true }));
}
});
return { success: true };
} catch (error) {
console.error(`[AI填空题] 解析或应用答案失败 (题 ${question.id}):`, error, "原始返回:", finalContent);
showNotification('AI返回的填空题答案格式错误,无法应用。请检查是否为JSON数组。', { type: 'error' });
return { success: false, reason: "JSON parsing failed or invalid format" };
}
} else if (questionTypeNum === 1 || questionTypeNum === 2 || questionTypeNum === 5) {
if (!targetElement) {
console.error(`[AI辅助] 题 ${question.id}: 无法最终确定选项,因为 targetElement 为空`);
return { success: false, reason: "Target element for choices not found during finalization" };
}
const selectedLetters = finalContent.toUpperCase().replace(/[^A-Z,]/g, '').split(',').filter(l => l);
if (selectedLetters.length > 0) {
const optionLabels = targetElement.querySelectorAll('label');
let changed = false;
question.answer_items.forEach(item => { item.answer_checked = 1; });
optionLabels.forEach((label) => {
const input = label.querySelector('input');
if (input) input.checked = false;
const customCheckbox = label.querySelector('span[style*="background-color"]');
if (customCheckbox) {
customCheckbox.style.backgroundColor = '#e5e7eb';
const toggleCircle = customCheckbox.firstChild;
if (toggleCircle) toggleCircle.style.left = '2px';
const icon = toggleCircle ? toggleCircle.firstChild : null;
if (icon) icon.innerHTML = '';
}
});
optionLabels.forEach((label, idx) => {
const currentLetter = String.fromCharCode(65 + idx);
if (selectedLetters.includes(currentLetter)) {
question.answer_items[idx].answer_checked = 2;
const input = label.querySelector('input');
if (input) {
input.checked = true;
}
changed = true;
const customCheckbox = label.querySelector('span[style*="background-color"]');
if (customCheckbox) {
customCheckbox.style.backgroundColor = '#6366f1';
const toggleCircle = customCheckbox.firstChild;
if (toggleCircle) toggleCircle.style.left = '22px';
const icon = toggleCircle ? toggleCircle.firstChild : null;
if (icon) icon.innerHTML = '';
}
}
});
if (!changed) {
console.warn(`[AI辅助] 题 ${question.id}: AI 未能识别出有效选项字母: ${finalContent}`);
return { success: false, reason: `AI 未识别有效选项: ${finalContent}` };
}
return { success: true };
} else {
console.warn(`[AI辅助] 题 ${question.id}: AI 未能识别出有效选项字母: ${finalContent}`);
return { success: false, reason: `AI 未识别有效选项: ${finalContent}` };
}
} else if (questionTypeNum === 10 && targetElement) {
const codeBlockMatch = finalContent.match(/^```(?:\w*\n)?([\s\S]*?)```$/);
const code = codeBlockMatch ? codeBlockMatch[1].trim() : finalContent.trim();
targetElement.value = code;
if (question.program_setting) {
question.program_setting.code_answer = code;
} else {
question.program_setting = { code_answer: code };
}
targetElement.dispatchEvent(new Event('input', { bubbles: true }));
} else if (questionTypeNum === 12 && targetElement) {
try {
const cleanedContent = finalContent.replace(/```json\n?([\s\S]+?)\n?```/g, '$1').trim();
const orderedLetters = JSON.parse(cleanedContent);
if (!Array.isArray(orderedLetters)) {
throw new Error("AI返回的不是一个数组");
}
const itemMap = new Map(question.answer_items.map((item, index) => {
return [String.fromCharCode(65 + index), { id: item.id, item: item }];
}));
const newSortedItems = orderedLetters.map(letter => itemMap.get(letter.toUpperCase())?.item).filter(Boolean);
if (newSortedItems.length !== question.answer_items.length) {
console.warn(`[AI排序题] AI返回的项数量与原始项数量不匹配。AI: ${newSortedItems.length}, 原始: ${question.answer_items.length}`);
}
newSortedItems.forEach((item, newIndex) => {
item.answer = (newIndex + 1).toString();
});
const currentUiItems = Array.from(targetElement.children);
const uiItemMap = new Map(currentUiItems.map(uiItem => {
const itemText = uiItem.querySelector('div[style*="flex: 1"]').textContent.trim();
return [itemText, uiItem];
}));
targetElement.innerHTML = '';
newSortedItems.forEach((sortedItemData, index) => {
const itemTextContent = parseRichTextToPlainText(sortedItemData.value).trim();
const correspondingUiItem = uiItemMap.get(itemTextContent);
if (correspondingUiItem) {
correspondingUiItem.querySelector('div[style*="width: 28px"]').textContent = index + 1;
correspondingUiItem.dataset.index = index;
targetElement.appendChild(correspondingUiItem);
} else {
console.error(`[AI排序题] 无法在现有UI中找到与数据匹配的项: "${itemTextContent}"`);
}
});
} catch (error) {
console.error(`[AI排序题] 解析或应用排序题答案失败 (题 ${question.id}):`, error, "原始返回:", finalContent);
showNotification('AI返回的排序结果格式错误,无法应用。', { type: 'error' });
return { success: false, reason: "JSON parsing failed or invalid format" };
}
} else if (questionTypeNum === 13 && targetElement) {
try {
const cleanedContent = finalContent.replace(/```json\n?([\s\S]+?)\n?```/g, '$1').trim();
const matches = JSON.parse(cleanedContent);
const leftItems = question.answer_items.filter(item => !item.is_target_opt);
const rightItems = question.answer_items.filter(item => item.is_target_opt);
Object.entries(matches).forEach(([leftLetter, rightLetter]) => {
const leftIndex = leftLetter.toUpperCase().charCodeAt(0) - 65;
const rightIndex = rightLetter.toLowerCase().charCodeAt(0) - 97;
if (leftItems[leftIndex] && rightItems[rightIndex]) {
leftItems[leftIndex].answer = rightItems[rightIndex].id;
}
});
const matchItemsUI = targetElement.querySelectorAll('div[data-matching-item="true"]');
matchItemsUI.forEach((matchItemUI, index) => {
if (typeof matchItemUI._updateUI === 'function') {
matchItemUI._updateUI();
}
});
targetElement.dispatchEvent(new Event('input', { bubbles: true }));
} catch (error) {
console.error(`[AI匹配题] 解析或应用匹配题答案失败:`, error, "原始返回:", finalContent);
showNotification('AI返回的匹配结果格式错误。', { type: 'error' });
return { success: false, reason: "JSON parsing failed" };
}
} else if ([6].includes(questionTypeNum) && targetElement) {
updateAnswerWithContent(question, targetElement.innerHTML);
}
return { success: true };
};
const streamProcessor = new StreamProcessor(targetElement, questionTypeNum, thinkingHandler, onUpdateTarget, onFinalizeTarget);
streamProcessor.reset();
if (targetElement) {
if (questionTypeNum === 4) {
targetElement.querySelectorAll('input').forEach(input => input.value = '');
} else if (questionTypeNum === 6) {
targetElement.textContent = '';
} else if (questionTypeNum === 10) {
targetElement.value = '';
}
}
try {
const result = await _getAIAnswer(
question,
aiConfig,
customPrompts,
temporaryPrompt,
originalContent,
streamProcessor.processChunk.bind(streamProcessor),
() => streamProcessor.processComplete(),
signal
);
if (signal?.aborted) {
console.log(`[AI辅助] 题 ${question.id} 请求在 _getAIAnswer 返回后检测到取消`);
return { cancelled: true };
}
if (result.cancelled) {
console.log(`[AI辅助] 题 ${question.id} 在 _getAIAnswer 中被取消`);
return { cancelled: true };
}
if (result.skipped) {
return { skipped: true, reason: result.reason };
}
isContentModified = true;
const finalizationOutcome = onFinalizeTarget(result.aiResult);
return finalizationOutcome || { success: true };
} catch (error) {
console.error(`[AI辅助] 题 ${question.id} (${getQuestionType(question.type)}) 处理失败:`, error);
showNotification(`AI辅助失败: ${error.message}`, { type: 'error', animation: 'scale' });
thinkingHandler.hide();
const restoreOriginalContent = () => {
if (originalContent !== null && targetElement) {
console.log(`[AI辅助] 恢复问题 ${question.id} 的原始内容 (因错误或取消)`);
if (questionTypeNum === 4) {
targetElement.querySelectorAll('input').forEach((input, index) => {
if (originalContent[index] !== undefined) {
input.value = originalContent[index];
input.dispatchEvent(new Event('input', { bubbles: true }));
}
});
} else if (questionTypeNum === 6) {
targetElement.innerHTML = originalContent;
} else if (questionTypeNum === 10) {
targetElement.value = originalContent;
}
if (targetElement.dispatchEvent) {
targetElement.dispatchEvent(new Event('input', { bubbles: true }));
}
}
};
restoreOriginalContent();
if (error.name === 'AbortError') {
console.log(`[AI辅助] 题 ${question.id} 请求被取消 (捕获于 callAIForQuestion catch)`);
return { cancelled: true };
}
return { success: false, reason: error.message };
}
}
async function startAIAssistAll(answerData, modalContainer) {
const confirmed = await showConfirmNotification(
'即将为所有支持的题目触发 AI 请求。请确保你的 AI 设置正确。是否继续?',
{ animation: 'scale' }
);
if (!confirmed) return;
if (currentBatchAbortController) {
console.log("[AI辅助] 检测到正在进行的任务,正在取消...");
currentBatchAbortController.abort();
await new Promise(resolve => setTimeout(resolve, 100));
}
currentBatchAbortController = new AbortController();
registerAIController(currentBatchAbortController);
const signal = currentBatchAbortController.signal;
const aiConfig = { provider: 'default', ...JSON.parse(localStorage.getItem('aiConfig') || '{}') };
const provider = aiConfig.provider;
if (provider !== 'default' && (!aiConfig.endpoint || !aiConfig.apiKey)) {
showNotification('请先在 AI 设置中配置 API 地址和 Key', { type: 'error' });
currentBatchAbortController = null;
return;
}
const temporaryPrompt = document.getElementById('temporary-ai-prompt-textarea')?.value.trim() || '';
if (temporaryPrompt) {
showNotification('批量任务将使用您提供的临时提示词。', { type: 'info' });
}
const concurrencyValue = parseInt(aiConfig.batchConcurrency, 10) || 1;
const customPrompts = JSON.parse(localStorage.getItem('aiCustomPrompts') || '{}');
const progress = createProgressBar();
progress.show();
let processedCount = 0;
let skippedCount = 0;
let errorCount = 0;
let cancelledCount = 0;
let stopProcessing = false;
const questionElements = modalContainer.querySelectorAll('.question-editor-container');
const questionsToProcess = [];
const individualAiButtons = [];
questionElements.forEach((qContainer, index) => {
const aiButton = qContainer.querySelector('.ai-assist-btn');
if (aiButton) {
aiButton.disabled = true;
aiButton.style.opacity = '0.5';
aiButton.style.cursor = 'not-allowed';
individualAiButtons.push(aiButton);
}
const question = answerData[index];
if (!question) return;
let targetElement = null;
const questionTypeNum = question.type;
if ([1, 2, 5].includes(questionTypeNum)) {
targetElement = qContainer.querySelector('div[style*="display: grid"]');
} else if (questionTypeNum === 4) {
targetElement = qContainer.querySelector('input[id^="blank-input-"]')?.closest('div[style*="display: flex; flex-direction: column;"]');
} else if (questionTypeNum === 6) {
targetElement = qContainer.querySelector('div[contenteditable="true"]');
} else if (questionTypeNum === 10) {
targetElement = qContainer.querySelector('textarea');
} else if (questionTypeNum === 12) {
targetElement = qContainer.querySelector('div[data-sortable-container="true"]');
} else if (questionTypeNum === 13) {
targetElement = qContainer.querySelector('div[data-matching-container="true"]');
} else if (question.type === 9 && question.subQuestions) {
const subQuestionContainers = qContainer.querySelectorAll('div[data-subquestion-id]');
subQuestionContainers.forEach(subContainer => {
const subIndex = parseInt(subContainer.dataset.subquestionIndex, 10);
const subQuestion = question.subQuestions[subIndex];
if (!subQuestion) return;
let subTargetElement = null;
const subTypeNum = subQuestion.type;
if ([1, 2, 5].includes(subTypeNum)) {
subTargetElement = subContainer.querySelector('div[style*="display: grid"]');
} else if (subTypeNum === 4) {
subTargetElement = subContainer.querySelector('input[id^="blank-input-"]')?.closest('div[style*="display: flex; flex-direction: column;"]');
} else if (subTypeNum === 6) {
subTargetElement = subContainer.querySelector('div[contenteditable="true"]');
} else if (subTypeNum === 10) {
subTargetElement = subContainer.querySelector('textarea');
}
if (subTargetElement) {
questionsToProcess.push({ question: subQuestion, element: subTargetElement });
} else {
console.warn(`跳过数组题的子问题 ${index + 1}.${subIndex + 1} (类型 ${subTypeNum}),未找到目标元素`);
skippedCount++;
}
});
return;
}
if (targetElement) {
questionsToProcess.push({ question, element: targetElement });
} else {
console.warn(`跳过问题 ${index + 1} (类型 ${questionTypeNum}),未找到目标元素或不支持`);
skippedCount++;
}
});
const totalQuestions = questionsToProcess.length;
const aiAssistAllButton = modalContainer.querySelector('#ai-assist-all-btn');
const saveButton = modalContainer.querySelector('button[style*="background-color: #4f46e5;"]');
const originalButtonHTML = aiAssistAllButton.innerHTML;
aiAssistAllButton.disabled = true;
aiAssistAllButton.style.opacity = '0.6';
aiAssistAllButton.style.cursor = 'not-allowed';
aiAssistAllButton.innerHTML = `
处理中...
`;
if (!document.getElementById('spin-animation-style')) {
const style = document.createElement('style');
style.id = 'spin-animation-style';
style.textContent = `@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }`;
document.head.appendChild(style);
}
const cancelButton = document.createElement('button');
cancelButton.textContent = '取消处理';
cancelButton.style.cssText = `
width: 100%; margin-bottom: 15px; padding: 12px 24px; font-size: 16px; border: none;
border-radius: 12px; background-color: #ef4444; color: white; cursor: pointer;
transition: all 0.3s ease; box-shadow: 0 4px 6px rgba(239, 68, 68, 0.2);
`;
cancelButton.onclick = () => {
stopProcessing = true;
if (currentBatchAbortController) {
console.log('[AI辅助] 请求取消,发送 abort 信号...');
currentBatchAbortController.abort();
} else {
console.log('[AI辅助] 请求取消,但没有进行中的批量任务。');
}
showNotification('AI 批量处理取消中...', { type: 'warning' });
cancelButton.disabled = true;
cancelButton.style.opacity = '0.6';
cancelButton.style.cursor = 'not-allowed';
};
if (saveButton && saveButton.parentNode) {
saveButton.parentNode.insertBefore(cancelButton, saveButton);
} else {
modalContainer.appendChild(cancelButton);
}
try {
if (concurrencyValue <= 1) {
progress.update(0, totalQuestions, `开始顺序处理`);
aiAssistAllButton.innerHTML = `...顺序处理中...`;
for (let i = 0; i < totalQuestions; i++) {
if (stopProcessing || signal.aborted) {
showNotification('手动停止处理成功。', { type: 'warning' });
cancelledCount = totalQuestions - (processedCount + skippedCount + errorCount);
break;
}
const { question, element } = questionsToProcess[i];
const questionType = getQuestionType(question.type);
const currentProgress = i + 1;
progress.update(currentProgress, totalQuestions, `[顺序] 处理 ${questionType}`);
try {
const result = await callAIForQuestion(question, element, aiConfig, customPrompts, signal, temporaryPrompt);
if (result.cancelled) {
cancelledCount++;
console.log(`[顺序] 问题 ${i + 1} 已取消`);
} else if (result.skipped) {
console.warn(`[顺序] callAIForQuestion 内部跳过了问题 ${i + 1}: ${result.reason}`);
errorCount++;
} else if (result.success === false) {
errorCount++;
console.error(`[顺序] 处理问题 ${i + 1} 失败: ${result.reason}`);
} else {
processedCount++;
}
} catch (error) {
if (error.name === 'AbortError') {
cancelledCount++;
console.log(`[顺序] 问题 ${i + 1} 请求被取消 (捕获于 startAIAssistAll loop catch)`);
} else {
errorCount++;
console.error(`[顺序] 处理问题 ${i + 1} 时发生严重错误:`, error);
}
}
const requestInterval = parseInt(aiConfig.requestInterval, 10) || 200;
if (requestInterval > 0 && !signal.aborted) {
await new Promise(resolve => setTimeout(resolve, requestInterval));
}
}
} else {
progress.update(0, totalQuestions, `开始并发处理`);
aiAssistAllButton.innerHTML = `...并发处理中... (并发: 0/${concurrencyValue})`;
const queue = [...questionsToProcess];
let running = 0;
let resolveCompletion;
const completionPromise = new Promise(resolve => {
resolveCompletion = resolve;
});
const processNext = async () => {
while (running < concurrencyValue && queue.length > 0) {
if (stopProcessing || signal.aborted) return;
const task = queue.shift();
const completedCount = processedCount + errorCount + cancelledCount;
const { question, element } = task;
const questionType = getQuestionType(question.type);
running++;
progress.update(completedCount, totalQuestions, `[并发] 处理 ${questionType}`);
aiAssistAllButton.innerHTML = `...并发处理中... (并发: ${running}/${concurrencyValue})`;
function handleResult(result) {
if (result.cancelled) cancelledCount++;
else if (result.skipped) errorCount++;
else if (result.success === false) errorCount++;
else processedCount++;
}
function handleError(error) {
if (error.name === 'AbortError') cancelledCount++;
else errorCount++;
}
function handleFinally() {
running--;
const finalCompleted = processedCount + errorCount + cancelledCount;
progress.update(finalCompleted, totalQuestions, `[并发] 处理完成`);
aiAssistAllButton.innerHTML = `...并发处理中... (并发: ${running}/${concurrencyValue})`;
if (queue.length === 0 && running === 0) {
if (resolveCompletion) resolveCompletion();
} else if (!stopProcessing && !signal.aborted) {
processNext();
} else if (resolveCompletion) {
resolveCompletion();
}
}
callAIForQuestion(question, element, aiConfig, customPrompts, signal, temporaryPrompt)
.then(handleResult)
.catch(handleError)
.finally(handleFinally);
}
};
const initialTasks = Math.min(concurrencyValue, queue.length);
for (let i = 0; i < initialTasks; i++) {
if (signal.aborted) break;
processNext();
}
if (initialTasks === 0 && queue.length === 0) {
resolveCompletion();
}
await completionPromise;
if (stopProcessing || signal.aborted) {
cancelledCount = totalQuestions - (processedCount + errorCount);
}
}
} catch (e) {
console.error("AI 批量处理主逻辑出错:", e);
showNotification("AI 批量处理过程中发生意外错误。", { type: 'error' });
errorCount = totalQuestions - processedCount - skippedCount - cancelledCount;
} finally {
progress.hide();
aiAssistAllButton.disabled = false;
aiAssistAllButton.style.opacity = '1';
aiAssistAllButton.style.cursor = 'pointer';
aiAssistAllButton.innerHTML = originalButtonHTML;
if (cancelButton.parentNode) {
cancelButton.parentNode.removeChild(cancelButton);
}
individualAiButtons.forEach(btn => {
btn.disabled = false;
btn.style.opacity = '1';
btn.style.cursor = 'pointer';
});
const finalProcessed = processedCount;
const finalSkipped = skippedCount;
const finalCancelled = cancelledCount;
const finalError = errorCount;
let summaryMessage = `AI 批量处理完成:成功 ${finalProcessed} 个`;
if (finalSkipped > 0) summaryMessage += `,跳过 ${finalSkipped} 个`;
if (finalCancelled > 0) summaryMessage += `,取消 ${finalCancelled} 个`;
if (finalError > 0) summaryMessage += `,失败 ${finalError} 个`;
summaryMessage += '。请检查结果。';
showNotification(summaryMessage, {
type: finalError > 0 ? 'warning' : (finalCancelled > 0 ? 'info' : 'success'),
duration: 8000,
keywords: ['批量处理', '完成', '成功', '跳过', '取消', '失败']
});
if (currentBatchAbortController && !currentBatchAbortController.signal.aborted) {
activeAIControllers.delete(currentBatchAbortController);
}
currentBatchAbortController = null;
}
}
async function handleChoiceQuestion(question, container) {
let optionsContainer = document.createElement('div');
optionsContainer.style.display = 'grid';
optionsContainer.style.gap = '12px';
const stats = question.statistics;
let totalOptionVotes = 0;
if (stats && stats.options) {
totalOptionVotes = stats.options.reduce((sum, opt) => sum + opt.count, 0);
}
for (const [idx, item] of question.answer_items.entries()) {
let optionLabel = document.createElement('label');
optionLabel.style.cssText = `
display: flex;
align-items: center;
padding: 20px;
background-color: #ffffff;
border-radius: 16px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
border: 2px solid #f1f5f9;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
overflow: hidden;
min-height: 60px;
`;
optionLabel.onmouseover = () => {
optionLabel.style.borderColor = '#c7d2fe';
optionLabel.style.transform = 'translateY(-2px)';
optionLabel.style.boxShadow = '0 8px 16px rgba(0, 0, 0, 0.08)';
};
optionLabel.onmouseout = () => {
optionLabel.style.borderColor = '#f1f5f9';
optionLabel.style.transform = 'translateY(0)';
optionLabel.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.04)';
};
const stats = question.statistics;
let percentage = 0;
let count = 0;
if (stats && stats.totalRespondents > 0) {
const optionStat = stats.options.find(opt => opt.id === item.id);
if (optionStat) {
count = optionStat.count;
if (totalOptionVotes > 0) {
percentage = (count / totalOptionVotes) * 100;
}
}
}
if (stats) {
const isCorrectAnswer = item.answer_checked === 2;
const statsBarContainer = document.createElement('div');
statsBarContainer.style.cssText = `
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 100%;
background-color: transparent;
z-index: 0;
`;
const statsBarFill = document.createElement('div');
statsBarFill.style.cssText = `
height: 100%;
width: ${Math.max(percentage, 2)}%;
background: ${isCorrectAnswer
? 'linear-gradient(90deg, rgba(34, 197, 94, 0.08) 0%, rgba(34, 197, 94, 0.15) 100%)'
: 'linear-gradient(90deg, rgba(148, 163, 184, 0.05) 0%, rgba(148, 163, 184, 0.12) 100%)'
};
transition: width 0.8s cubic-bezier(0.4, 0, 0.2, 1);
border-radius: 16px;
`;
const statsText = document.createElement('div');
statsText.innerHTML = `
${count}
${percentage.toFixed(1)}%
`;
statsText.style.cssText = `
position: absolute;
right: 20px;
top: 50%;
transform: translateY(-50%);
display: flex;
flex-direction: column;
align-items: flex-end;
z-index: 2;
font-family: Microsoft YaHei;
`;
if (!document.querySelector('#choice-stats-styles')) {
const style = document.createElement('style');
style.id = 'choice-stats-styles';
style.textContent = `
.count {
font-size: 11px;
font-weight: 500;
color: #64748b;
line-height: 1.2;
}
.percentage {
font-size: 14px;
font-weight: 700;
color: ${isCorrectAnswer ? '#059669' : '#475569'};
line-height: 1.2;
}
`;
document.head.appendChild(style);
}
statsBarContainer.appendChild(statsBarFill);
optionLabel.appendChild(statsBarContainer);
optionLabel.appendChild(statsText);
}
let optionContentWrapper = document.createElement('div');
optionContentWrapper.style.cssText = `
display: flex;
align-items: center;
position: relative;
z-index: 1;
width: 100%;
padding-right: ${stats ? '50px' : '0px'};
`;
let optionInput = document.createElement('input');
optionInput.type = question.type === 2 ? 'checkbox' : 'radio';
optionInput.name = `question_${question.id}`;
optionInput.value = item.id;
optionInput.checked = item.answer_checked === 2;
optionInput.style.display = 'none';
let customCheckbox = document.createElement('span');
customCheckbox.style.cssText = `
width: 48px;
height: 28px;
background-color: ${optionInput.checked ? '#6366f1' : '#e5e7eb'};
border-radius: 28px;
position: relative;
margin-right: 20px;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: ${optionInput.checked
? '0 4px 12px rgba(99, 102, 241, 0.3)'
: '0 2px 6px rgba(0, 0, 0, 0.08)'
};
flex-shrink: 0;
`;
let toggleCircle = document.createElement('span');
toggleCircle.style.cssText = `
width: 24px;
height: 24px;
background-color: #ffffff;
border-radius: 50%;
position: absolute;
top: 2px;
left: ${optionInput.checked ? '22px' : '2px'};
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15);
transform: ${optionInput.checked ? 'scale(1.05)' : 'scale(1)'};
`;
let icon = document.createElement('span');
icon.style.cssText = `
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
transition: all 0.3s ease;
color: #6366f1;
`;
icon.innerHTML = optionInput.checked ?
'' :
'';
toggleCircle.appendChild(icon);
customCheckbox.appendChild(toggleCircle);
optionLabel.onclick = () => {
isContentModified = true;
if (question.type !== 2) {
question.answer_items.forEach(answerItem => {
answerItem.answer_checked = 1;
});
item.answer_checked = 2;
let siblingInputs = optionsContainer.querySelectorAll(`input[name="question_${question.id}"]`);
siblingInputs.forEach(sibling => {
const siblingLabel = sibling.closest('label');
const siblingToggle = siblingLabel.querySelector('span[style*="background-color"]');
const siblingCircle = siblingToggle.firstChild;
const siblingIcon = siblingCircle.firstChild;
sibling.checked = false;
siblingToggle.style.backgroundColor = '#e5e7eb';
siblingToggle.style.boxShadow = '0 2px 6px rgba(0, 0, 0, 0.08)';
siblingCircle.style.left = '2px';
siblingCircle.style.transform = 'scale(1)';
siblingIcon.innerHTML = '';
});
optionInput.checked = true;
} else {
optionInput.checked = !optionInput.checked;
item.answer_checked = optionInput.checked ? 2 : 1;
}
if (optionInput.checked) {
customCheckbox.style.backgroundColor = '#6366f1';
customCheckbox.style.boxShadow = '0 4px 12px rgba(99, 102, 241, 0.3)';
toggleCircle.style.left = '22px';
toggleCircle.style.transform = 'scale(1.05)';
icon.innerHTML = '';
} else {
customCheckbox.style.backgroundColor = '#e5e7eb';
customCheckbox.style.boxShadow = '0 2px 6px rgba(0, 0, 0, 0.08)';
toggleCircle.style.left = '2px';
toggleCircle.style.transform = 'scale(1)';
icon.innerHTML = '';
}
};
let optionText = document.createElement('span');
optionText.innerHTML = question.type === 5 ?
(idx === 0 ? '正确' : '错误') :
await parseRichTextContentAsync(item.value);
optionText.style.cssText = `
color: #1f2937;
flex: 1;
font-size: 16px;
font-weight: 500;
line-height: 1.5;
word-break: break-word;
`;
optionContentWrapper.appendChild(optionInput);
optionContentWrapper.appendChild(customCheckbox);
optionContentWrapper.appendChild(optionText);
optionLabel.appendChild(optionContentWrapper);
optionsContainer.appendChild(optionLabel);
}
container.appendChild(optionsContainer);
return optionsContainer;
}
function handleFillInBlankQuestion(question, container, createAIButton) {
const fillContainer = document.createElement('div');
fillContainer.style.cssText = 'display: flex; flex-direction: column; gap: 15px; margin-top: 10px;';
question.answer_items.forEach((item, index) => {
const blankGroup = document.createElement('div');
blankGroup.style.display = 'flex';
blankGroup.style.alignItems = 'center';
blankGroup.style.gap = '10px';
const label = document.createElement('label');
label.textContent = `空 ${index + 1}:`;
label.style.fontWeight = '600';
label.style.color = '#4f46e5';
label.style.minWidth = '50px';
label.htmlFor = `blank-input-${item.id}`;
const input = document.createElement('input');
input.type = 'text';
input.id = `blank-input-${item.id}`;
input.value = parseRichTextToPlainText(item.answer);
input.style.cssText = `
flex-grow: 1;
padding: 10px 12px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 14px;
transition: all 0.2s ease;
outline: none;
`;
input.onfocus = () => {
input.style.borderColor = '#6366f1';
input.style.boxShadow = '0 0 0 3px rgba(99, 102, 241, 0.15)';
};
input.onblur = () => {
input.style.borderColor = '#d1d5db';
input.style.boxShadow = 'none';
};
input.oninput = () => {
isContentModified = true;
const newText = input.value;
item.answer = JSON.stringify({
blocks: [{
key: `ans-${item.id}`,
text: newText,
type: 'unstyled', depth: 0, inlineStyleRanges: [], entityRanges: [], data: {}
}],
entityMap: {}
});
};
blankGroup.appendChild(label);
blankGroup.appendChild(input);
fillContainer.appendChild(blankGroup);
});
const aiButtonContainer = document.createElement('div');
aiButtonContainer.style.textAlign = 'right';
aiButtonContainer.style.marginTop = '15px';
const aiButton = createAIButton(fillContainer, question);
if (aiButton) {
aiButtonContainer.appendChild(aiButton);
fillContainer.appendChild(aiButtonContainer);
}
container.appendChild(fillContainer);
return fillContainer;
}
function handleTextQuestion(question, container, createAIButton) {
let inputContainer = document.createElement('div');
inputContainer.style.position = 'relative';
inputContainer.style.width = '100%';
inputContainer.style.marginTop = '8px';
inputContainer.style.paddingBottom = '40px';
let answerInput = document.createElement('div');
answerInput.contentEditable = true;
answerInput.style.width = '100%';
answerInput.style.minHeight = '160px';
answerInput.style.maxHeight = '400px';
answerInput.style.padding = '16px';
answerInput.style.paddingTop = '24px';
answerInput.style.border = '1px solid #e5e7eb';
answerInput.style.borderRadius = '12px';
answerInput.style.fontSize = '15px';
answerInput.style.lineHeight = '1.6';
answerInput.style.color = '#1f2937';
answerInput.style.transition = 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)';
answerInput.style.backgroundColor = '#ffffff';
answerInput.style.boxShadow = '0 2px 4px rgba(0, 0, 0, 0.05)';
answerInput.style.outline = 'none';
answerInput.style.display = 'block';
answerInput.style.boxSizing = 'border-box';
answerInput.style.overflow = 'auto';
answerInput.style.whiteSpace = 'pre-wrap';
answerInput.style.wordBreak = 'break-word';
let buttonContainer = document.createElement('div');
buttonContainer.style.position = 'absolute';
buttonContainer.style.bottom = '-5px';
buttonContainer.style.display = 'flex';
buttonContainer.style.gap = '12px';
buttonContainer.style.alignItems = 'center';
buttonContainer.style.width = '100%';
buttonContainer.style.justifyContent = 'center';
buttonContainer.style.zIndex = '10';
let imageUploadButton = null;
let fileInput = null;
if (question.type !== 4) {
imageUploadButton = document.createElement('button');
imageUploadButton.innerHTML = '🖼️插入图片';
imageUploadButton.className = 'image-upload-btn';
imageUploadButton.title = '插入图片到答案中';
fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'image/*';
fileInput.style.display = 'none';
inputContainer.appendChild(fileInput);
imageUploadButton.onclick = (e) => {
e.preventDefault();
fileInput.click();
};
fileInput.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
showNotification('请选择图片文件', {
type: 'error',
keywords: ['图片'],
animation: 'scale'
});
return;
}
if (file.size > 5 * 1024 * 1024) {
showNotification('图片大小不能超过5MB', {
type: 'error',
keywords: ['图片', '大小'],
animation: 'scale'
});
return;
}
try {
imageUploadButton.disabled = true;
imageUploadButton.className = 'image-upload-btn loading';
imageUploadButton.innerHTML = '🔄上传中...';
const imageUrl = await uploadImage(file);
if (imageUrl) {
insertImageToEditor(answerInput, imageUrl);
answerInput.dispatchEvent(new Event('input', { bubbles: true }));
showNotification('图片上传成功', {
type: 'success',
keywords: ['图片', '上传', '成功'],
animation: 'scale'
});
} else {
throw new Error('图片上传失败');
}
} catch (error) {
console.error('图片上传失败:', error);
showNotification(`图片上传失败: ${error.message}`, {
type: 'error',
keywords: ['图片', '上传', '失败'],
animation: 'scale'
});
} finally {
imageUploadButton.disabled = false;
imageUploadButton.className = 'image-upload-btn';
imageUploadButton.innerHTML = '🖼️插入图片';
fileInput.value = '';
}
};
}
let charCount = document.createElement('div');
charCount.className = 'char-count';
charCount.style.pointerEvents = 'none';
answerInput.onfocus = () => {
answerInput.style.borderColor = '#6366f1';
answerInput.style.backgroundColor = '#ffffff';
answerInput.style.boxShadow = '0 4px 6px rgba(99, 102, 241, 0.1)';
charCount.classList.add('active');
const scrollPos = window.scrollY;
setTimeout(() => {
window.scrollTo(0, scrollPos);
}, 10);
};
answerInput.onblur = () => {
answerInput.style.borderColor = '#e5e7eb';
answerInput.style.boxShadow = '0 2px 4px rgba(0, 0, 0, 0.05)';
charCount.classList.remove('active');
};
answerInput.oninput = () => {
isContentModified = true;
let textLength = answerInput.textContent.length;
charCount.textContent = `${textLength} 个字符`;
updateAnswerWithContent(question, answerInput.innerHTML);
};
let answerContent = '';
question.answer_items.forEach(item => {
try {
const jsonContent = JSON.parse(item.answer);
if (jsonContent && jsonContent.blocks) {
jsonContent.blocks.forEach(block => {
if (block.type === 'atomic' && block.data && block.data.type === 'IMAGE') {
let imageSrc = block.data.src;
let fileIdMatch = imageSrc.match(/\/cloud\/file_access\/(\d+)/);
if (fileIdMatch && fileIdMatch[1]) {
let fileId = fileIdMatch[1];
let randomParam = Date.now();
let imageUrl = `${window.location.origin}/api/jx-oresource/cloud/file_access/${fileId}?random=${randomParam}`;
answerContent += `
`;
} else {
answerContent += '[图片加载失败]
';
}
} else {
answerContent += block.text.replace(/\n/g, '
');
}
});
} else {
answerContent += item.answer || '';
}
} catch (e) {
answerContent += item.answer || '';
}
answerContent += '
';
});
answerInput.innerHTML = answerContent.trim();
let initialTextLength = answerInput.textContent.length;
charCount.textContent = `${initialTextLength} 个字符`;
let decorativeLine = document.createElement('div');
decorativeLine.style.position = 'absolute';
decorativeLine.style.left = '16px';
decorativeLine.style.right = '16px';
decorativeLine.style.bottom = '40px';
decorativeLine.style.height = '1px';
decorativeLine.style.background = 'linear-gradient(to right, #e5e7eb 50%, transparent)';
decorativeLine.style.opacity = '0.5';
if (imageUploadButton) {
buttonContainer.appendChild(imageUploadButton);
}
if (createAIButton) {
let aiButton = createAIButton(answerInput, question);
buttonContainer.appendChild(aiButton);
}
buttonContainer.appendChild(charCount);
inputContainer.appendChild(answerInput);
inputContainer.appendChild(buttonContainer);
inputContainer.appendChild(decorativeLine);
let thinkingProcessDiv = document.createElement('div');
thinkingProcessDiv.className = 'ai-thinking-process';
thinkingProcessDiv.style.marginTop = '15px';
thinkingProcessDiv.style.display = 'none';
inputContainer.appendChild(thinkingProcessDiv);
container.appendChild(inputContainer);
return answerInput;
}
closeButton.onmouseover = () => {
closeButton.style.backgroundColor = '#e5e7eb';
closeButton.style.transform = 'rotate(90deg)';
closeButton.style.color = '#000';
closeButton.style.boxShadow = '0 4px 8px rgba(0,0,0,0.12)';
};
closeButton.onmouseout = () => {
closeButton.style.backgroundColor = '#f3f4f6';
closeButton.style.transform = 'rotate(0deg)';
closeButton.style.color = '#6b7280';
closeButton.style.boxShadow = '0 2px 5px rgba(0,0,0,0.08)';
};
closeButton.onclick = (e) => {
e.stopPropagation();
cleanup();
closeModal();
};
title.style.cssText = `
margin: 20px 0 28px 0;
color: #111827;
font-size: 24px;
font-weight: 600;
text-align: center;
`;
title.textContent = '查看/编辑答案';
saveButton.style.cssText = `
width: 100%;
margin-bottom: 24px;
padding: 12px 24px;
font-size: 16px;
border: none;
border-radius: 12px;
background-color: #4f46e5;
color: #ffffff;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 4px 6px -1px rgba(79, 70, 229, 0.1), 0 2px 4px -1px rgba(79, 70, 229, 0.06);
`;
saveButton.innerHTML = `
保存修改
`;
saveButton.onmouseover = () => {
saveButton.style.backgroundColor = '#4338ca';
saveButton.style.transform = 'translateY(-1px)';
saveButton.style.boxShadow = '0 6px 8px -1px rgba(79, 70, 229, 0.1), 0 4px 6px -1px rgba(79, 70, 229, 0.06)';
};
saveButton.onmouseout = () => {
saveButton.style.backgroundColor = '#4f46e5';
saveButton.style.transform = 'translateY(0)';
saveButton.style.boxShadow = '0 4px 6px -1px rgba(79, 70, 229, 0.1), 0 2px 4px -1px rgba(79, 70, 229, 0.06)';
};
saveButton.title = '保存修改后的答案到本地存储';
let aiAssistAllButton = document.createElement('button');
aiAssistAllButton.id = 'ai-assist-all-btn';
aiAssistAllButton.innerHTML = `
AI 批量处理
`;
aiAssistAllButton.style.cssText = `
width: 100%;
margin-bottom: 15px;
padding: 12px 24px;
font-size: 16px;
border: none;
border-radius: 12px;
background: #10b981;
color: #ffffff;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 4px 6px -1px rgba(16, 185, 129, 0.2), 0 2px 4px -1px rgba(16, 185, 129, 0.1);
display: flex;
align-items: center;
justify-content: center;
`;
aiAssistAllButton.title = '使用 AI 尝试完成所有支持的题目(消耗 Token 较多)';
aiAssistAllButton.onmouseover = () => {
aiAssistAllButton.style.background = '#059669';
aiAssistAllButton.style.transform = 'translateY(-1px)';
aiAssistAllButton.style.boxShadow = '0 6px 8px -1px rgba(16, 185, 129, 0.2), 0 4px 6px -1px rgba(16, 185, 129, 0.1)';
};
aiAssistAllButton.onmouseout = () => {
aiAssistAllButton.style.background = '#10b981';
aiAssistAllButton.style.transform = 'translateY(0)';
aiAssistAllButton.style.boxShadow = '0 4px 6px -1px rgba(16, 185, 129, 0.2), 0 2px 4px -1px rgba(16, 185, 129, 0.1)';
};
aiAssistAllButton.onclick = () => {
startAIAssistAll(answerData, modalContainer);
};
saveButton.onclick = () => {
answerData.forEach(question => {
if (question.subQuestions && Array.isArray(question.subQuestions)) {
question.subQuestions.forEach(subQuestion => {
if (subQuestion.parentQuestion) {
delete subQuestion.parentQuestion;
}
});
}
});
localStorage.setItem('answerData', JSON.stringify(answerData));
showNotification('答案已保存,旧答案已被替换', { type: 'success', keywords: ['答案', '保存', '替换'], animation: 'scale' });
closeModal(true);
};
let tocContainer = document.createElement('div');
tocContainer.id = 'toc-container';
tocContainer.style.cssText = `
width: 230px;
position: sticky;
max-height: 680px;
overflow-y: auto;
padding: 16px;
border: 1px solid #e5e7eb;
border-radius: 12px;
background: #f9fafb;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
transition: all 0.3s ease;
`;
tocContainer.addEventListener('mouseenter', () => {
tocContainer.style.borderColor = '#d1d5db';
tocContainer.style.boxShadow = '0 4px 6px rgba(0,0,0,0.05)';
});
tocContainer.addEventListener('mouseleave', () => {
tocContainer.style.borderColor = '#e5e7eb';
tocContainer.style.boxShadow = '0 1px 3px rgba(0,0,0,0.1)';
});
let tocTitle = document.createElement('h3');
tocTitle.textContent = '目录';
tocTitle.style.cssText = `
font-size: 18px;
font-weight: 600;
margin-bottom: 12px;
color: #111827;
`;
let tocList = document.createElement('ul');
tocList.style.cssText = `
list-style: none;
display: flex;
flex-wrap: wrap;
gap: 8px;
`;
let tocLinks = [];
let questionContainers = [];
answerData.forEach((_, index) => {
let tocItem = document.createElement('li');
let tocLink = document.createElement('a');
tocLink.textContent = `${index + 1}`;
tocLink.href = '#';
tocLink.style.cssText = `
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background-color: #f3f4f6;
border-radius: 8px;
color: #1f2937;
font-size: 16px;
font-weight: 600;
text-decoration: none;
transition: background-color 0.2s ease;
`;
tocLink.isActive = false;
tocLink.onmouseover = () => {
if (!tocLink.isActive) {
tocLink.style.backgroundColor = '#e5e7eb';
}
};
tocLink.onmouseout = () => {
if (!tocLink.isActive) {
tocLink.style.backgroundColor = '#f3f4f6';
}
};
tocLink.onclick = (e) => {
e.preventDefault();
let targetQuestion = document.getElementById(`question_${index}`);
if (targetQuestion) {
targetQuestion.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
};
tocItem.appendChild(tocLink);
tocList.appendChild(tocItem);
tocLinks.push(tocLink);
});
tocContainer.appendChild(tocTitle);
tocContainer.appendChild(tocList);
let content = document.createElement('div');
content.style.cssText = `
flex: 1;
display: grid;
gap: 20px;
overflow-y: auto;
padding-right: 16px;
`;
const renderTemporaryPromptUI = () => {
const promptContainer = document.createElement('div');
promptContainer.id = 'temporary-prompt-container';
promptContainer.style.cssText = `
margin-bottom: 25px;
border-radius: 16px;
border: 1px dashed #a5b4fc;
background-color: #fafaff;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04);
`;
const details = document.createElement('details');
const summary = document.createElement('summary');
summary.style.cssText = `
padding: 16px 20px;
font-size: 16px;
font-weight: 600;
color: #4338ca;
cursor: pointer;
list-style: none;
display: flex;
align-items: center;
transition: background-color 0.2s ease;
`;
summary.innerHTML = `
✍️ 临时AI提示词 (对当前作业生效)
`;
const summaryArrow = summary.querySelector('svg');
details.addEventListener('toggle', () => {
summaryArrow.style.transform = details.open ? 'rotate(90deg)' : 'rotate(0deg)';
});
const promptContent = document.createElement('div');
promptContent.style.cssText = `
padding: 0 20px 20px 20px;
border-top: 1px solid #ddd6fe;
color: #374151;
`;
const description = document.createElement('p');
description.textContent = '在此处输入补充信息或特定指令(如解题思路、关键公式等),AI在处理本页所有题目时都会参考。';
description.style.cssText = 'font-size: 14px; color: #6b7280; margin-top: 15px; margin-bottom: 10px; line-height: 1.6;';
const textarea = document.createElement('textarea');
textarea.id = 'temporary-ai-prompt-textarea';
textarea.rows = 4;
textarea.placeholder = '例如:听力原文如下...,请根据内容回答后续问题。';
textarea.style.cssText = `
width: 100%;
padding: 12px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 14px;
resize: vertical;
box-sizing: border-box;
outline: none;
transition: all 0.2s ease;
`;
textarea.onfocus = () => { textarea.style.borderColor = '#6366f1'; textarea.style.boxShadow = '0 0 0 3px rgba(99, 102, 241, 0.15)'; };
textarea.onblur = () => { textarea.style.borderColor = '#d1d5db'; textarea.style.boxShadow = 'none'; };
promptContent.appendChild(description);
promptContent.appendChild(textarea);
details.appendChild(summary);
details.appendChild(promptContent);
promptContainer.appendChild(details);
const descriptionContainer = content.querySelector('#paper-description-container');
if (descriptionContainer) {
descriptionContainer.insertAdjacentElement('afterend', promptContainer);
} else {
content.prepend(promptContainer);
}
};
const renderQuestions = async () => {
for (const [index, question] of answerData.entries()) {
const questionContainer = document.createElement('div');
questionContainer.id = `question_${index}`;
questionContainer.className = 'question-editor-container';
questionContainer.dataset.contentHash = generateContentHash(question);
questionContainer.style.padding = '24px';
questionContainer.style.backgroundColor = '#ffffff';
questionContainer.style.borderRadius = '16px';
questionContainer.style.border = '1px solid #e5e7eb';
questionContainer.style.transition = 'box-shadow 0.3s ease, margin-top 0.3s ease';
questionContainer.style.marginTop = '0';
questionContainer.style.boxShadow = '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)';
questionContainer.onmouseover = () => {
questionContainer.style.boxShadow = '0 8px 16px rgba(0, 0, 0, 0.1)';
};
questionContainer.onmouseout = () => {
questionContainer.style.boxShadow = '0 1px 3px rgba(0, 0, 0, 0.1)';
};
const questionTitle = document.createElement('div');
const typeInfo = questionTypeStyles[question.type] || questionTypeStyles['default'];
const badgeHtml = `
${typeInfo.text}
`;
const titleHtml = await parseRichTextContentAsync(question.title);
questionTitle.innerHTML = `题目 ${index + 1}:${badgeHtml}${titleHtml}`;
questionTitle.style.marginBottom = '16px';
questionTitle.style.color = '#111827';
questionTitle.style.fontSize = '16px';
questionTitle.style.lineHeight = '1.6';
questionContainer.appendChild(questionTitle);
attachSttOnlyButtonListeners(questionContainer);
attachVideoSttButtonListeners(questionContainer);
if ([1, 2, 5].includes(question.type)) {
const aiButtonContainer = document.createElement('div');
aiButtonContainer.style.textAlign = 'right';
aiButtonContainer.style.marginBottom = '10px';
const aiButton = createAIButton(questionContainer, question);
if (aiButton) {
aiButtonContainer.appendChild(aiButton);
questionContainer.appendChild(aiButtonContainer);
}
await handleChoiceQuestion(question, questionContainer);
let thinkingProcessDivChoice = document.createElement('div');
thinkingProcessDivChoice.className = 'ai-thinking-process';
thinkingProcessDivChoice.style.marginTop = '15px';
thinkingProcessDivChoice.style.display = 'none';
questionContainer.appendChild(thinkingProcessDivChoice);
} else if (question.type === 4) {
handleFillInBlankQuestion(question, questionContainer, createAIButton);
} else if ([6].includes(question.type)) {
handleTextQuestion(question, questionContainer, createAIButton);
} else if (question.type === 9 && question.subQuestions?.length) {
let subQuestionsContainer = document.createElement('div');
subQuestionsContainer.style.display = 'flex';
subQuestionsContainer.style.flexDirection = 'column';
subQuestionsContainer.style.gap = '24px';
subQuestionsContainer.style.marginTop = '20px';
subQuestionsContainer.style.padding = '20px';
subQuestionsContainer.style.backgroundColor = '#f8fafc';
subQuestionsContainer.style.borderRadius = '12px';
subQuestionsContainer.style.border = '1px solid #e2e8f0';
let subQuestionTitle = document.createElement('div');
subQuestionTitle.textContent = '子题目:';
subQuestionTitle.style.fontSize = '16px';
subQuestionTitle.style.fontWeight = '600';
subQuestionTitle.style.color = '#475569';
subQuestionTitle.style.marginBottom = '16px';
subQuestionsContainer.appendChild(subQuestionTitle);
for (const [subIndex, subQuestion] of question.subQuestions.entries()) {
subQuestion.parentQuestion = question;
let subQuestionBox = document.createElement('div');
subQuestionBox.dataset.subquestionId = subQuestion.id;
subQuestionBox.dataset.subquestionIndex = subIndex;
subQuestionBox.style.padding = '20px';
subQuestionBox.style.backgroundColor = '#ffffff';
subQuestionBox.style.borderRadius = '10px';
subQuestionBox.style.border = '1px solid #e5e7eb';
subQuestionBox.style.boxShadow = '0 2px 4px rgba(0, 0, 0, 0.05)';
let subQuestionHeader = document.createElement('div');
const subTypeInfo = questionTypeStyles[subQuestion.type] || questionTypeStyles['default'];
const subBadgeHtml = `
${subTypeInfo.text}
`;
const subTitleHtml = await parseRichTextContentAsync(subQuestion.title);
subQuestionHeader.innerHTML = `${subIndex + 1}. ${subBadgeHtml}${subTitleHtml}`;
subQuestionHeader.style.marginBottom = '16px';
subQuestionHeader.style.color = '#1e293b';
subQuestionHeader.style.fontSize = '15px';
subQuestionBox.appendChild(subQuestionHeader);
attachSttOnlyButtonListeners(subQuestionBox);
attachVideoSttButtonListeners(subQuestionBox);
if ([1, 2, 5].includes(subQuestion.type)) {
const aiButtonContainer = document.createElement('div');
aiButtonContainer.style.textAlign = 'right';
aiButtonContainer.style.marginBottom = '10px';
const aiButton = createAIButton(subQuestionBox, subQuestion);
if (aiButton) {
aiButtonContainer.appendChild(aiButton);
subQuestionBox.appendChild(aiButtonContainer);
}
await handleChoiceQuestion(subQuestion, subQuestionBox);
} else if (subQuestion.type === 4) {
handleFillInBlankQuestion(subQuestion, subQuestionBox, createAIButton);
} else if ([6].includes(subQuestion.type)) {
handleTextQuestion(subQuestion, subQuestionBox, createAIButton);
}
subQuestionBox.appendChild(createReportButton(subQuestion));
subQuestionsContainer.appendChild(subQuestionBox);
}
questionContainer.appendChild(subQuestionsContainer);
}
else if (question.type === 10) {
let programmingContainer = document.createElement('div');
programmingContainer.style.display = 'flex';
programmingContainer.style.flexDirection = 'column';
programmingContainer.style.gap = '16px';
programmingContainer.style.marginTop = '16px';
const progSetting = question.program_setting;
let infoContainer = document.createElement('div');
infoContainer.style.display = 'flex';
infoContainer.style.gap = '16px';
infoContainer.style.fontSize = '14px';
infoContainer.style.color = '#4b5563';
infoContainer.style.padding = '10px';
infoContainer.style.backgroundColor = '#f9fafb';
infoContainer.style.borderRadius = '8px';
infoContainer.style.border = '1px solid #e5e7eb';
infoContainer.innerHTML = `
语言: ${progSetting?.language?.join(', ') || 'N/A'}
时间限制: ${progSetting?.max_time || 'N/A'} ms
内存限制: ${progSetting?.max_memory || 'N/A'} KB
`;
programmingContainer.appendChild(infoContainer);
if (progSetting?.example_code) {
let exampleCodeContainer = document.createElement('div');
exampleCodeContainer.innerHTML = '示例代码:';
exampleCodeContainer.style.fontWeight = '600';
exampleCodeContainer.style.marginBottom = '8px';
let exampleCodeBlock = document.createElement('pre');
exampleCodeBlock.textContent = progSetting.example_code;
exampleCodeBlock.style.padding = '12px';
exampleCodeBlock.style.backgroundColor = '#f3f4f6';
exampleCodeBlock.style.borderRadius = '8px';
exampleCodeBlock.style.border = '1px solid #e5e7eb';
exampleCodeBlock.style.whiteSpace = 'pre-wrap';
exampleCodeBlock.style.wordBreak = 'break-all';
exampleCodeBlock.style.maxHeight = '200px';
exampleCodeBlock.style.overflowY = 'auto';
exampleCodeContainer.appendChild(exampleCodeBlock);
programmingContainer.appendChild(exampleCodeContainer);
}
let answerCodeContainer = document.createElement('div');
answerCodeContainer.innerHTML = '答案代码:';
answerCodeContainer.style.fontWeight = '600';
answerCodeContainer.style.marginBottom = '8px';
let answerCodeInput = document.createElement('textarea');
answerCodeInput.value = progSetting?.code_answer || '';
answerCodeInput.style.width = '100%';
answerCodeInput.style.minHeight = '200px';
answerCodeInput.style.padding = '12px';
answerCodeInput.style.border = '1px solid #d1d5db';
answerCodeInput.style.borderRadius = '8px';
answerCodeInput.style.fontSize = '14px';
answerCodeInput.style.lineHeight = '1.5';
answerCodeInput.style.resize = 'vertical';
answerCodeInput.style.boxSizing = 'border-box';
answerCodeInput.oninput = () => {
isContentModified = true;
if (question.program_setting) {
question.program_setting.code_answer = answerCodeInput.value;
} else {
question.program_setting = { code_answer: answerCodeInput.value };
}
};
answerCodeContainer.appendChild(answerCodeInput);
programmingContainer.appendChild(answerCodeContainer);
let aiButtonContainer = document.createElement('div');
aiButtonContainer.style.marginTop = '10px';
aiButtonContainer.style.textAlign = 'right';
let aiButton = createAIButton(answerCodeInput, question);
aiButtonContainer.appendChild(aiButton);
programmingContainer.appendChild(aiButtonContainer);
if (question.answer_items?.[0]?.answer) {
let testCasesContainer = document.createElement('div');
testCasesContainer.innerHTML = '测试用例:';
testCasesContainer.style.fontWeight = '600';
testCasesContainer.style.marginBottom = '8px';
let testCasesBlock = document.createElement('div');
testCasesBlock.style.padding = '12px';
testCasesBlock.style.backgroundColor = '#f3f4f6';
testCasesBlock.style.borderRadius = '8px';
testCasesBlock.style.border = '1px solid #e5e7eb';
testCasesBlock.style.maxHeight = '150px';
testCasesBlock.style.overflowY = 'auto';
try {
const testCases = JSON.parse(question.answer_items[0].answer);
if (Array.isArray(testCases)) {
testCases.forEach((tc, i) => {
let tcDiv = document.createElement('div');
tcDiv.style.marginBottom = '8px';
tcDiv.innerHTML = `
用例 ${i + 1}:
输入: ${tc.in}
输出: ${tc.out}
`;
testCasesBlock.appendChild(tcDiv);
});
} else {
testCasesBlock.textContent = question.answer_items[0].answer;
}
} catch (e) {
testCasesBlock.textContent = question.answer_items[0].answer;
}
testCasesContainer.appendChild(testCasesBlock);
programmingContainer.appendChild(testCasesContainer);
}
questionContainer.appendChild(programmingContainer);
let thinkingProcessDivProg = document.createElement('div');
thinkingProcessDivProg.className = 'ai-thinking-process';
thinkingProcessDivProg.style.marginTop = '15px';
thinkingProcessDivProg.style.display = 'none';
questionContainer.appendChild(thinkingProcessDivProg);
} else if (question.type === 12) {
question.answer_items.sort((a, b) => {
const answerA = parseInt(a.answer, 10);
const answerB = parseInt(b.answer, 10);
if (isNaN(answerA) || isNaN(answerB)) {
return 0;
}
return answerA - answerB;
});
let sortableContainer = document.createElement('div');
sortableContainer.dataset.sortableContainer = "true";
sortableContainer.style.display = 'flex';
sortableContainer.style.flexDirection = 'column';
sortableContainer.style.gap = '12px';
sortableContainer.style.marginTop = '16px';
for (const [index, item] of question.answer_items.entries()) {
let sortableItem = document.createElement('div');
sortableItem.setAttribute('draggable', 'true');
sortableItem.dataset.id = item.id;
sortableItem.dataset.index = index;
sortableItem.style.display = 'flex';
sortableItem.style.alignItems = 'center';
sortableItem.style.padding = '16px';
sortableItem.style.backgroundColor = '#ffffff';
sortableItem.style.borderRadius = '12px';
sortableItem.style.border = '1px solid #e5e7eb';
sortableItem.style.cursor = 'move';
sortableItem.style.transition = 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)';
sortableItem.style.boxShadow = '0 2px 4px rgba(0, 0, 0, 0.05)';
sortableItem.style.userSelect = 'none';
let orderNumber = document.createElement('div');
orderNumber.textContent = index + 1;
orderNumber.style.width = '28px';
orderNumber.style.height = '28px';
orderNumber.style.borderRadius = '50%';
orderNumber.style.backgroundColor = '#6366f1';
orderNumber.style.color = '#ffffff';
orderNumber.style.display = 'flex';
orderNumber.style.alignItems = 'center';
orderNumber.style.justifyContent = 'center';
orderNumber.style.marginRight = '16px';
orderNumber.style.fontWeight = '600';
orderNumber.style.fontSize = '14px';
orderNumber.style.flexShrink = '0';
let dragHandle = document.createElement('div');
dragHandle.innerHTML = `
`;
dragHandle.style.marginRight = '12px';
dragHandle.style.flexShrink = '0';
dragHandle.style.opacity = '0.5';
dragHandle.style.transition = 'opacity 0.2s ease';
let itemText = document.createElement('div');
itemText.innerHTML = await parseRichTextContentAsync(item.value);
itemText.style.flex = '1';
itemText.style.color = '#1f2937';
itemText.style.fontSize = '15px';
itemText.style.fontWeight = '500';
sortableItem.ondragstart = (e) => {
e.stopPropagation();
sortableItem.style.opacity = '0.6';
sortableItem.style.transform = 'scale(1.02)';
e.dataTransfer.setData('text/plain', sortableItem.dataset.index);
sortableItem.style.backgroundColor = '#f8fafc';
};
sortableItem.ondragend = (e) => {
e.stopPropagation();
sortableItem.style.opacity = '1';
sortableItem.style.transform = 'scale(1)';
sortableItem.style.backgroundColor = '#ffffff';
};
sortableItem.ondragover = (e) => {
e.preventDefault();
e.stopPropagation();
sortableItem.style.transform = 'scale(1.02)';
sortableItem.style.borderColor = '#6366f1';
sortableItem.style.boxShadow = '0 4px 6px rgba(99, 102, 241, 0.1)';
};
sortableItem.ondragleave = (e) => {
e.preventDefault();
e.stopPropagation();
sortableItem.style.transform = 'scale(1)';
sortableItem.style.borderColor = '#e5e7eb';
sortableItem.style.boxShadow = '0 2px 4px rgba(0, 0, 0, 0.05)';
};
sortableItem.ondrop = (e) => {
e.preventDefault();
e.stopPropagation();
const fromIndex = parseInt(e.dataTransfer.getData('text/plain'));
const toIndex = parseInt(sortableItem.dataset.index);
if (fromIndex !== toIndex) {
isContentModified = true;
const items = Array.from(sortableContainer.children);
const movingItem = items[fromIndex];
const targetItem = items[toIndex];
if (fromIndex < toIndex) {
targetItem.parentNode.insertBefore(movingItem, targetItem.nextSibling);
} else {
targetItem.parentNode.insertBefore(movingItem, targetItem);
}
const newOrder = Array.from(sortableContainer.children).map((item, idx) => {
item.querySelector('div:nth-child(2)').textContent = idx + 1;
item.dataset.index = idx;
return item.dataset.id;
});
newOrder.forEach((id, idx) => {
const answerItem = question.answer_items.find(item => item.id === id);
if (answerItem) {
answerItem.answer = (idx + 1).toString();
}
});
}
sortableItem.style.transform = 'scale(1)';
sortableItem.style.borderColor = '#e5e7eb';
sortableItem.style.boxShadow = '0 2px 4px rgba(0, 0, 0, 0.05)';
};
sortableItem.onmouseover = () => {
sortableItem.style.backgroundColor = '#f8fafc';
sortableItem.style.transform = 'translateY(-1px)';
sortableItem.style.boxShadow = '0 4px 6px rgba(0, 0, 0, 0.08)';
dragHandle.style.opacity = '1';
};
sortableItem.onmouseout = () => {
sortableItem.style.backgroundColor = '#ffffff';
sortableItem.style.transform = 'translateY(0)';
sortableItem.style.boxShadow = '0 2px 4px rgba(0, 0, 0, 0.05)';
dragHandle.style.opacity = '0.5';
};
sortableItem.appendChild(dragHandle);
sortableItem.appendChild(orderNumber);
sortableItem.appendChild(itemText);
sortableContainer.appendChild(sortableItem);
}
const aiButtonContainer = document.createElement('div');
aiButtonContainer.style.textAlign = 'right';
aiButtonContainer.style.marginBottom = '10px';
const aiButton = createAIButton(sortableContainer, question);
if (aiButton) {
aiButtonContainer.appendChild(aiButton);
questionContainer.appendChild(aiButtonContainer);
}
questionContainer.appendChild(sortableContainer);
} else if (question.type === 13) {
let matchingContainer = document.createElement('div');
matchingContainer.dataset.matchingContainer = "true";
matchingContainer.style.cssText = `
display: flex; flex-direction: column; gap: 16px; margin-top: 20px;
padding: 16px; background-color: #f8fafc; border-radius: 16px;
`;
const leftItems = question.answer_items.filter(item => !item.is_target_opt);
const rightItems = question.answer_items.filter(item => item.is_target_opt);
const rightItemMap = new Map(rightItems.map((item, idx) => [item.id, {
letter: String.fromCharCode(97 + idx),
content: item.value
}]));
for (const [idx, leftItem] of leftItems.entries()) {
let matchItem = document.createElement('div');
matchItem.dataset.matchingItem = "true";
matchItem.style.cssText = `
display: flex; flex-direction: column; padding: 20px; background-color: #ffffff;
border-radius: 12px; border: 1px solid #e2e8f0;
box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -1px rgba(0,0,0,0.06);
transition: all 0.3s ease; position: relative;
`;
let headerContainer = document.createElement('div');
headerContainer.style.cssText = 'display: flex; align-items: flex-start; margin-bottom: 16px;';
let leftLabel = document.createElement('div');
leftLabel.textContent = String.fromCharCode(65 + idx) + '.';
leftLabel.style.cssText = 'margin-right: 12px; font-weight: 600; color: #6366f1; font-size: 16px; width: 24px;';
let leftContent = document.createElement('div');
leftContent.innerHTML = await parseRichTextContentAsync(leftItem.value);
leftContent.style.cssText = 'flex: 1; color: #1e293b; font-size: 15px; font-weight: 500; line-height: 1.6;';
let chipContainer = document.createElement('div');
chipContainer.style.cssText = 'display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; min-height: 36px;';
let dropdownButton = document.createElement('button');
dropdownButton.innerHTML = `
添加匹配项
`;
dropdownButton.style.cssText = `
display: flex; align-items: center; justify-content: center; margin-top: 16px;
padding: 10px 16px; background-color: #4f46e5; color: #ffffff;
border: none; border-radius: 8px; cursor: pointer; transition: all 0.2s ease;
font-size: 14px; font-weight: 500; width: 100%;
`;
dropdownButton.onmouseover = () => { dropdownButton.style.backgroundColor = '#4338ca'; dropdownButton.style.transform = 'translateY(-1px)'; };
dropdownButton.onmouseout = () => { dropdownButton.style.backgroundColor = '#4f46e5'; dropdownButton.style.transform = 'translateY(0)'; };
let dropdownList = document.createElement('div');
dropdownList.style.cssText = `
position: absolute; top: 100%; left: 0; width: 100%; max-height: 300px;
overflow-y: auto; border: 1px solid #e2e8f0; border-radius: 12px;
background-color: #ffffff; box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1);
z-index: 1000; margin-top: 8px; display: none; opacity: 0;
transform: scaleY(0.9) translateY(-10px); transform-origin: top;
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
`;
const updateUI = async () => {
const currentAnswerIds = new Set(leftItem.answer ? String(leftItem.answer).split(',').filter(id => id) : []);
chipContainer.innerHTML = '';
for (const answerId of currentAnswerIds) {
if (rightItemMap.has(answerId)) {
const rightItemData = rightItemMap.get(answerId);
let chip = document.createElement('div');
chip.style.cssText = `display: flex; align-items: center; padding: 6px 12px; background-color: #eef2ff; border: 1px solid #e0e7ff; border-radius: 8px; color: #4f46e5; font-size: 14px; font-weight: 500; transition: all 0.2s ease;`;
let chipText = document.createElement('span');
chipText.innerHTML = `${rightItemData.letter}. ${await parseRichTextContentAsync(rightItemData.content)}`;
chipText.style.marginRight = '8px';
let removeIcon = document.createElement('span');
removeIcon.innerHTML = ``;
removeIcon.style.cssText = 'cursor: pointer; display: flex; align-items: center; padding: 2px; border-radius: 4px; transition: all 0.2s ease;';
removeIcon.onmouseover = () => { removeIcon.style.backgroundColor = '#e0e7ff'; };
removeIcon.onmouseout = () => { removeIcon.style.backgroundColor = 'transparent'; };
removeIcon.onclick = (e) => {
e.stopPropagation();
const ids = new Set(leftItem.answer ? String(leftItem.answer).split(',') : []);
ids.delete(answerId);
leftItem.answer = Array.from(ids).join(',');
updateUI();
};
chip.appendChild(chipText);
chip.appendChild(removeIcon);
chipContainer.appendChild(chip);
}
}
const checkboxes = dropdownList.querySelectorAll('input[type="checkbox"]');
checkboxes.forEach((cb, cbIndex) => {
const rightItemId = rightItems[cbIndex].id;
cb.checked = currentAnswerIds.has(rightItemId);
});
};
matchItem._updateUI = updateUI;
for (const [rIdx, rightItem] of rightItems.entries()) {
let dropdownOption = document.createElement('div');
dropdownOption.style.cssText = `
padding: 12px 16px; cursor: pointer; display: flex; align-items: center;
transition: all 0.2s ease; position: relative;
border-bottom: ${rIdx < rightItems.length - 1 ? '1px solid #f1f5f9' : 'none'};
`;
dropdownOption.onmouseover = () => { dropdownOption.style.backgroundColor = '#f8fafc'; };
dropdownOption.onmouseout = () => { dropdownOption.style.backgroundColor = '#ffffff'; };
let checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.style.cssText = 'margin-right: 12px; width: 16px; height: 16px; accent-color: #4f46e5;';
checkbox.onchange = (e) => {
isContentModified = true;
e.stopPropagation();
const selectedIds = new Set(leftItem.answer ? String(leftItem.answer).split(',').filter(id => id) : []);
if (checkbox.checked) {
selectedIds.add(rightItem.id);
} else {
selectedIds.delete(rightItem.id);
}
leftItem.answer = Array.from(selectedIds).join(',');
updateUI();
};
let optionContent = document.createElement('div');
optionContent.style.cssText = 'flex: 1; display: flex; align-items: center;';
optionContent.innerHTML = `
${String.fromCharCode(97 + rIdx)}.
${await parseRichTextContentAsync(rightItem.value)}
`;
dropdownOption.appendChild(checkbox);
dropdownOption.appendChild(optionContent);
dropdownOption.onclick = (e) => {
if (e.target !== checkbox) {
checkbox.checked = !checkbox.checked;
checkbox.dispatchEvent(new Event('change'));
}
};
dropdownList.appendChild(dropdownOption);
}
dropdownButton.onclick = (e) => {
e.stopPropagation();
if (dropdownList.style.display === 'none') {
dropdownList.style.display = 'block';
requestAnimationFrame(() => {
dropdownList.style.opacity = '1';
dropdownList.style.transform = 'scaleY(1) translateY(0)';
});
} else {
dropdownList.style.opacity = '0';
dropdownList.style.transform = 'scaleY(0.9) translateY(-10px)';
setTimeout(() => { dropdownList.style.display = 'none'; }, 200);
}
};
document.addEventListener('click', (e) => {
if (!matchItem.contains(e.target)) {
dropdownList.style.opacity = '0';
dropdownList.style.transform = 'scaleY(0.9) translateY(-10px)';
setTimeout(() => { dropdownList.style.display = 'none'; }, 200);
}
});
headerContainer.appendChild(leftLabel);
headerContainer.appendChild(leftContent);
matchItem.appendChild(headerContainer);
matchItem.appendChild(chipContainer);
matchItem.appendChild(dropdownButton);
matchItem.appendChild(dropdownList);
matchingContainer.appendChild(matchItem);
updateUI();
}
const aiButtonContainer = document.createElement('div');
aiButtonContainer.style.textAlign = 'right';
aiButtonContainer.style.marginBottom = '10px';
const aiButton = createAIButton(matchingContainer, question);
if (aiButton) {
aiButtonContainer.appendChild(aiButton);
}
if (aiButton) {
questionContainer.appendChild(aiButtonContainer);
}
questionContainer.appendChild(matchingContainer);
} else {
let notSupportedMessage = document.createElement('div');
notSupportedMessage.style.padding = '20px';
notSupportedMessage.style.backgroundColor = '#fff3cd';
notSupportedMessage.style.border = '1px solid #ffeeba';
notSupportedMessage.style.borderRadius = '8px';
notSupportedMessage.style.color = '#856404';
notSupportedMessage.style.fontSize = '15px';
notSupportedMessage.style.marginTop = '16px';
notSupportedMessage.style.textAlign = 'center';
notSupportedMessage.innerHTML = `
`;
questionContainer.appendChild(notSupportedMessage);
}
content.appendChild(questionContainer);
questionContainers.push(questionContainer);
if (SUPPORTED_CONTRIBUTION_TYPES.includes(question.type)) {
questionContainer.appendChild(createReportButton(question));
}
}
};
modalContainer.appendChild(resizeHandle);
modalContainer.appendChild(dragHandle);
modalContainer.appendChild(closeButton);
modalContainer.appendChild(title);
modalContainer.appendChild(aiAssistAllButton);
modalContainer.appendChild(saveButton);
modalContainer.appendChild(modalContentWrapper);
modalContentWrapper.appendChild(tocContainer);
modalContentWrapper.appendChild(content);
const handleEscapeKey = (e) => {
if (e.key === 'Escape') {
closeModal();
}
};
document.addEventListener('keydown', handleEscapeKey);
document.body.appendChild(overlay);
document.body.appendChild(modalContainer);
function updateCurrentQuestionHighlight() {
const contentRect = content.getBoundingClientRect();
const viewportTop = contentRect.top;
const viewportHeight = contentRect.height;
const viewportCenter = viewportTop + (viewportHeight / 2);
let currentQuestionIndex = -1;
let minDistance = Infinity;
questionContainers.forEach((qc, index) => {
const qcRect = qc.getBoundingClientRect();
const qcCenter = qcRect.top + (qcRect.height / 2);
const distance = Math.abs(qcCenter - viewportCenter);
if (distance < minDistance) {
minDistance = distance;
currentQuestionIndex = index;
}
});
if (currentQuestionIndex !== -1) {
tocLinks.forEach((tocLink, idx) => {
if (idx === currentQuestionIndex) {
tocLink.isActive = true;
tocLink.style.backgroundColor = '#6366f1';
tocLink.style.color = '#ffffff';
tocLink.style.transform = 'scale(1.05)';
tocLink.style.boxShadow = '0 4px 6px -1px rgba(99, 102, 241, 0.1)';
} else {
tocLink.isActive = false;
tocLink.style.backgroundColor = '#f3f4f6';
tocLink.style.color = '#1f2937';
tocLink.style.transform = 'scale(1)';
tocLink.style.boxShadow = 'none';
}
});
}
}
const renderPaperDescription = async () => {
const paperDescription = localStorage.getItem('paperDescription');
if (!paperDescription || paperDescription === '{}') {
return;
}
const descriptionContainer = document.createElement('div');
descriptionContainer.id = 'paper-description-container';
descriptionContainer.style.cssText = `
margin-bottom: 25px;
border-radius: 16px;
border: 1px solid #c7d2fe;
background-color: #f5f3ff;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
`;
const details = document.createElement('details');
details.open = containsAudio(paperDescription);
const summary = document.createElement('summary');
summary.style.cssText = `
padding: 16px 20px;
font-size: 16px;
font-weight: 600;
color: #4338ca;
cursor: pointer;
list-style: none;
display: flex;
align-items: center;
transition: background-color 0.2s ease;
`;
summary.innerHTML = `
作业头部材料
`;
const summaryArrow = summary.querySelector('svg');
details.addEventListener('toggle', () => {
summaryArrow.style.transform = details.open ? 'rotate(90deg)' : 'rotate(0deg)';
});
const descriptionContent = document.createElement('div');
descriptionContent.style.cssText = `
padding: 20px;
border-top: 1px solid #ddd6fe;
line-height: 1.7;
color: #374151;
font-size: 15px;
`;
descriptionContent.innerHTML = await parseRichTextContentAsync(paperDescription);
attachSttOnlyButtonListeners(descriptionContent);
attachVideoSttButtonListeners(descriptionContent);
details.appendChild(summary);
details.appendChild(descriptionContent);
descriptionContainer.appendChild(details);
content.prepend(descriptionContainer);
};
renderQuestions().then(() => {
renderPaperDescription().then(() => {
renderTemporaryPromptUI();
updateCurrentQuestionHighlight();
requestAnimationFrame(() => {
overlay.style.opacity = '1';
modalContainer.style.transform = 'translate(-50%, -50%) scale(1)';
modalContainer.style.opacity = '1';
});
});
});
}
async function exportHomework() {
console.log('调用 exportHomework 函数 (带头部信息增强版)');
let storedData = localStorage.getItem('answerData');
if (!storedData) {
showNotification('未找到存储的数据,请先点击"获取答案"按钮。', {
type: 'error',
keywords: ['存储', '答案', '获取'],
animation: 'fadeSlide'
});
return;
}
const answerData = JSON.parse(storedData);
let assignmentTitle = localStorage.getItem('assignmentTitle') || '作业答案';
const paperDescription = localStorage.getItem('paperDescription');
const progress = createProgressBar();
progress.show();
try {
const docContent = [];
showNotification('开始导出作业,正在准备内容...', {
type: 'info',
keywords: ['导出', '准备'],
animation: 'scale'
});
docContent.push(
new Paragraph({
text: assignmentTitle,
heading: HeadingLevel.TITLE,
alignment: AlignmentType.CENTER,
spacing: { after: 400 },
}),
new Paragraph({
text: `导出时间:${new Date().toLocaleString()}`,
alignment: AlignmentType.CENTER,
spacing: { after: 400 },
})
);
if (paperDescription && paperDescription !== '{}' && !isEmptyRichText(paperDescription)) {
console.log('发现作业头部信息,开始处理并添加到文档...');
progress.update(0, answerData.length, '正在处理头部信息');
docContent.push(new Paragraph({
text: "作业说明 / 公共材料",
heading: HeadingLevel.HEADING_1,
style: "Heading1",
spacing: { before: 400, after: 200 },
}));
const descriptionParagraphs = await parseRichTextToParagraphs(paperDescription);
docContent.push(...descriptionParagraphs);
docContent.push(new Paragraph({
children: [new TextRun("__________________________________________________________")],
alignment: AlignmentType.CENTER,
spacing: { after: 600 },
}));
docContent.push(new Paragraph({
children: [new TextRun("__________________________________________________________")],
alignment: AlignmentType.CENTER,
spacing: { after: 600 },
}));
}
for (let index = 0; index < answerData.length; index++) {
try {
const question = answerData[index];
const questionNumber = `${index + 1}、`;
const titleRuns = await parseRichTextToParagraphs(question.title);
const titleParagraph = new Paragraph({
children: [
new TextRun({
text: questionNumber,
bold: true,
}),
...titleRuns,
],
});
docContent.push(titleParagraph);
switch (question.type) {
case 1:
case 2:
{
const options = question.answer_items.map((item, idx) => ({
letter: String.fromCharCode(65 + idx),
content: item.value,
}));
for (const option of options) {
const optionRuns = await parseRichTextToParagraphs(option.content);
docContent.push(new Paragraph({
children: [
new TextRun({ text: `${option.letter}. `, bold: true }),
...optionRuns,
],
}));
}
const correctOptions = question.answer_items
.map((item, idx) => (item.answer_checked === 2 ? String.fromCharCode(65 + idx) : null))
.filter(Boolean)
.join('');
docContent.push(new Paragraph({ text: `答案:${correctOptions}`, spacing: { before: 100, after: 100 } }));
if (question.description && question.description !== '{}' && !isEmptyRichText(question.description)) {
docContent.push(new Paragraph({
children: [new TextRun({ text: '解析:', bold: true })],
spacing: { before: 100, after: 0 },
}));
const descriptionParagraphs = await parseRichTextToParagraphs(question.description);
docContent.push(...descriptionParagraphs);
}
break;
}
case 5:
{
const isCorrect = question.answer_items.some(item => item.answer_checked === 2 && (item.value === '正确' || item.value.toLowerCase() === 'true'));
docContent.push(new Paragraph({ text: `答案:${isCorrect ? '对' : '错'}`, spacing: { before: 100, after: 100 } }));
if (question.description && question.description !== '{}' && !isEmptyRichText(question.description)) {
const descriptionRuns = await parseRichTextToParagraphs(question.description);
docContent.push(new Paragraph({
children: [
new TextRun({ text: '解析:', bold: true }),
...descriptionRuns,
],
spacing: { before: 100, after: 100 },
}));
}
break;
}
case 4:
{
let blanks = '(____)'.repeat(question.answer_items.length);
docContent.push(new Paragraph({ text: blanks, spacing: { before: 100, after: 100 } }));
const answers = question.answer_items.map(item => parseRichTextToPlainText(item.answer)).join(' | ');
docContent.push(new Paragraph({ text: `答案:${answers}`, spacing: { before: 100, after: 100 } }));
if (question.description && question.description !== '{}' && !isEmptyRichText(question.description)) {
const descriptionRuns = await parseRichTextToParagraphs(question.description);
docContent.push(new Paragraph({
children: [
new TextRun({ text: '解析:', bold: true }),
...descriptionRuns,
],
spacing: { before: 100, after: 100 },
}));
}
break;
}
case 6:
{
for (const item of question.answer_items) {
const answerRuns = await parseRichTextToParagraphs(item.answer);
docContent.push(new Paragraph({
children: [
new TextRun({ text: '答案:', bold: true }),
...answerRuns,
],
spacing: { before: 100, after: 100 },
}));
}
if (question.description && question.description !== '{}' && !isEmptyRichText(question.description)) {
const descriptionRuns = await parseRichTextToParagraphs(question.description);
docContent.push(new Paragraph({
children: [
new TextRun({ text: '解析:', bold: true }),
...descriptionRuns,
],
spacing: { before: 100, after: 100 },
}));
}
break;
}
case 9:
{
if (question.subQuestions && question.subQuestions.length > 0) {
for (let subIndex = 0; subIndex < question.subQuestions.length; subIndex++) {
const subQuestion = question.subQuestions[subIndex];
const subQuestionNumber = `${index + 1}.${subIndex + 1}、`;
const subTitleRuns = await parseRichTextToParagraphs(subQuestion.title);
docContent.push(
new Paragraph({
children: [
new TextRun({
text: subQuestionNumber,
bold: true,
}),
...subTitleRuns
],
spacing: { before: 200 }
})
);
switch (subQuestion.type) {
case 1:
case 2: {
for (const [idx, item] of subQuestion.answer_items.entries()) {
const optionLetter = String.fromCharCode(65 + idx);
const optionRuns = await parseRichTextToParagraphs(item.value);
const optionParagraph = new Paragraph({
children: [
new TextRun({
text: `${optionLetter}. `,
bold: true,
}),
...optionRuns,
],
});
docContent.push(optionParagraph);
}
const correctOptions = subQuestion.answer_items
.map((item, idx) => item.answer_checked === 2 ? String.fromCharCode(65 + idx) : null)
.filter(item => item !== null)
.join('');
docContent.push(
new Paragraph({
text: `答案:${correctOptions}`,
spacing: { before: 100, after: 100 },
})
);
break;
}
case 4: {
const blankCount = subQuestion.answer_items.length;
let blanks = '';
for (let i = 0; i < blankCount; i++) {
blanks += '(____)';
}
docContent.push(
new Paragraph({
text: blanks,
spacing: { before: 100, after: 100 }
})
);
const answers = subQuestion.answer_items
.map(item => parseRichTextToPlainText(item.answer))
.join('|');
docContent.push(
new Paragraph({
text: `答案:${answers}`,
spacing: { before: 100, after: 100 }
})
);
break;
}
case 5: {
const isCorrect = subQuestion.answer_items
.some(item => item.answer_checked === 2 &&
(item.value === '正确' || item.value.toLowerCase() === 'true'));
const answerText = isCorrect ? '对' : '错';
docContent.push(
new Paragraph({
text: `答案:${answerText}`,
spacing: { before: 100, after: 100 }
})
);
break;
}
case 6: {
const answers = subQuestion.answer_items
.map(item => parseRichTextToPlainText(item.answer))
.join(';');
docContent.push(
new Paragraph({
text: `答案:${answers}`,
spacing: { before: 100, after: 100 }
})
);
break;
}
}
if (subQuestion.description && subQuestion.description !== '{}') {
const descriptionRuns = await parseRichTextToParagraphs(subQuestion.description);
docContent.push(
new Paragraph({
children: [
new TextRun({
text: '解析:',
bold: true
}),
...descriptionRuns
],
spacing: { before: 100, after: 100 }
})
);
}
docContent.push(
new Paragraph({
text: '',
spacing: { after: 200 }
})
);
}
}
break;
}
case 10:
{
docContent.push(
new Paragraph({
text: `语言:${question.program_setting?.language?.join(', ') || '未指定'}`,
spacing: { before: 100, after: 100 },
})
);
if (question.program_setting?.example_code) {
docContent.push(
new Paragraph({ text: "示例代码:", bold: true, spacing: { before: 100 } }),
new Paragraph({ text: question.program_setting.example_code, style: "CodeStyle" })
);
}
if (question.program_setting?.code_answer) {
docContent.push(
new Paragraph({ text: "答案代码:", bold: true, spacing: { before: 100 } }),
new Paragraph({ text: question.program_setting.code_answer, style: "CodeStyle" })
);
}
if (question.answer_items?.[0]?.answer) {
try {
const testCases = JSON.parse(question.answer_items[0].answer);
if (Array.isArray(testCases) && testCases.length > 0) {
docContent.push(new Paragraph({ text: "测试用例:", bold: true, spacing: { before: 100 } }));
testCases.forEach((tc, i) => {
docContent.push(new Paragraph({ text: ` 用例 ${i + 1}:`, spacing: { before: 50 } }));
docContent.push(new Paragraph({ text: ` 输入: ${tc.in}`, style: "CodeStyle" }));
docContent.push(new Paragraph({ text: ` 输出: ${tc.out}`, style: "CodeStyle" }));
});
}
} catch (e) {
console.warn("解析测试用例失败:", e);
docContent.push(new Paragraph({ text: `测试用例数据:${question.answer_items[0].answer}`, spacing: { before: 100 } }));
}
}
if (question.description && question.description !== '{}' && !isEmptyRichText(question.description)) {
const descriptionRuns = await parseRichTextToParagraphs(question.description);
const descriptionParagraph = new Paragraph({
children: [
new TextRun({ text: '解析:', bold: true }),
...descriptionRuns,
],
spacing: { before: 100, after: 100 },
});
docContent.push(descriptionParagraph);
}
break;
}
case 12:
{
const options = question.answer_items.map((item, idx) => {
const optionLetter = String.fromCharCode(65 + idx);
return {
letter: optionLetter,
content: item.value,
originalIndex: idx,
};
});
for (const option of options) {
const optionRuns = await parseRichTextToParagraphs(option.content);
const optionParagraph = new Paragraph({
children: [
new TextRun({
text: `${option.letter}. `,
bold: true,
}),
...optionRuns,
],
});
docContent.push(optionParagraph);
}
const sortedItems = question.answer_items.slice().sort((a, b) => parseInt(a.answer) - parseInt(b.answer));
const answerLetters = sortedItems.map(item => {
const originalIndex = question.answer_items.indexOf(item);
return String.fromCharCode(65 + originalIndex);
}).join('');
docContent.push(
new Paragraph({
text: `答案:${answerLetters}`,
spacing: { before: 100, after: 100 },
})
);
if (question.description && question.description !== '{}' && !isEmptyRichText(question.description)) {
const descriptionRuns = await parseRichTextToParagraphs(question.description);
const descriptionParagraph = new Paragraph({
children: [
new TextRun({
text: '解析:',
bold: true,
}),
...descriptionRuns,
],
spacing: { before: 100, after: 100 },
});
docContent.push(descriptionParagraph);
}
break;
}
case 13:
{
const leftItems = question.answer_items.filter(item => !item.is_target_opt);
const rightItems = question.answer_items.filter(item => item.is_target_opt);
docContent.push(new Paragraph({ text: "左侧选项:" }));
leftItems.forEach((leftItem, index) => {
const leftContent = parseRichTextToPlainText(leftItem.value);
docContent.push(new Paragraph({
text: `左${index + 1}:${leftContent}`,
}));
});
docContent.push(new Paragraph({ text: "右侧选项:" }));
rightItems.forEach((rightItem, index) => {
const rightContent = parseRichTextToPlainText(rightItem.value);
docContent.push(new Paragraph({
text: `右${index + 1}:${rightContent}`,
}));
});
const answerText = '答案:' + leftItems.map((leftItem, leftIndex) => {
const leftOptionNumber = `左${leftIndex + 1}`;
const matchedRightIds = leftItem.answer ? leftItem.answer.toString().split(',') : [];
const matchedRightNumbers = matchedRightIds.map((id) => {
const rightIndex = rightItems.findIndex(item => item.id === id);
return rightIndex >= 0 ? `右${rightIndex + 1}` : '';
}).join('、');
return `${leftOptionNumber} - ${matchedRightNumbers}`;
}).join('|');
docContent.push(
new Paragraph({
text: answerText,
spacing: { before: 100, after: 100 },
})
);
if (question.description && question.description !== '{}' && !isEmptyRichText(question.description)) {
const descriptionRuns = await parseRichTextToParagraphs(question.description);
const descriptionParagraph = new Paragraph({
children: [
new TextRun({
text: '解析:',
bold: true,
}),
...descriptionRuns,
],
spacing: { before: 100, after: 100 },
});
docContent.push(descriptionParagraph);
}
break;
}
default:
{
docContent.push(new Paragraph({
text: "该题型暂不支持查看答案。",
spacing: { before: 100, after: 100 },
}));
if (question.description && question.description !== '{}' && !isEmptyRichText(question.description)) {
const descriptionRuns = await parseRichTextToParagraphs(question.description);
docContent.push(new Paragraph({
children: [
new TextRun({ text: '解析:', bold: true }),
...descriptionRuns,
],
spacing: { before: 100, after: 100 },
}));
}
break;
}
}
} catch (questionError) {
console.error(`处理第 ${index + 1} 题时发生错误:`, questionError, "题目数据:", answerData[index]);
docContent.push(new Paragraph({
children: [
new TextRun({ text: `${index + 1}、`, bold: true }),
new TextRun({
text: "处理此题时发生错误,已跳过。请打开浏览器控制台(F12)查看详细错误信息。",
color: "FF0000",
italics: true
})
]
}));
}
progress.update(index + 1, answerData.length, '正在导出');
docContent.push(new Paragraph({ text: "", spacing: { after: 200 } }));
}
console.log("所有题目处理完毕,准备生成文档...");
progress.update(answerData.length, answerData.length, '正在生成文档');
const doc = new Document({
creator: "小雅答答答",
description: `导出的作业答案 - ${assignmentTitle}`,
title: assignmentTitle,
styles: {
paragraphStyles: [
{
id: "Normal",
name: "Normal",
run: { font: "Microsoft YaHei", size: 24 },
paragraph: { spacing: { line: 360, before: 0, after: 0 } },
},
{
id: "Heading1",
name: "Heading 1",
basedOn: "Normal",
next: "Normal",
run: { font: "Microsoft YaHei", size: 32, bold: true },
paragraph: { spacing: { before: 240, after: 120 } },
},
{
id: "CodeStyle",
name: "Code Style",
basedOn: "Normal",
run: { font: "Consolas", size: 20 },
paragraph: {
indentation: { left: 400 },
spacing: { before: 100, after: 100 }
},
},
],
},
sections: [
{
properties: {},
children: docContent,
},
],
});
const blob = await Packer.toBlob(doc);
let safeTitle = assignmentTitle.replace(/[\\/:*?"<>|]/g, '_');
window.saveAs(blob, `${safeTitle}.docx`);
progress.hide();
showNotification('作业导出成功,如需导入其他题库,请手动编辑保存一次以确保被准确识别。', {
type: 'success',
keywords: ['导出', '成功', '题库'],
animation: 'fadeSlide'
});
} catch (error) {
progress.hide();
console.error('导出作业时发生严重错误 (非题目处理阶段):', error);
showNotification('导出失败,请查看控制台日志以获取详细信息。', {
type: 'error',
keywords: ['导出', '失败', '日志'],
animation: 'scale'
});
}
}
async function parseRichTextToParagraphs(content) {
if (!content || typeof content !== 'string' || content === '{}' || isEmptyRichText(content)) {
return [];
}
let paragraphs = [];
try {
let jsonContent = JSON.parse(content);
if (!jsonContent.blocks || !Array.isArray(jsonContent.blocks)) {
paragraphs.push(new Paragraph({
children: [new TextRun({ text: content, font: "Microsoft YaHei" })],
}));
return paragraphs;
}
for (const block of jsonContent.blocks) {
if (block.type === 'atomic' && block.data && block.data.type === 'IMAGE') {
let imageSrc = block.data.src;
let fileIdMatch = imageSrc.match(/.*cloud\/file_access\/(\d+)/);
if (fileIdMatch && fileIdMatch[1]) {
let fileId = fileIdMatch[1];
let randomParam = Date.now();
let imageUrl = `${window.location.origin}/api/jx-oresource/cloud/file_access/${fileId}?random=${randomParam}`;
const imageData = await fetchImageData(imageUrl);
if (imageData) {
const imageSize = await getImageSize(imageData);
if (imageSize) {
let { width, height } = imageSize;
const maxWidth = 450;
if (width > maxWidth) {
const ratio = maxWidth / width;
width = maxWidth;
height = height * ratio;
}
paragraphs.push(new Paragraph({
children: [new ImageRun({
data: imageData,
transformation: { width, height },
})],
alignment: AlignmentType.CENTER,
}));
} else {
paragraphs.push(new Paragraph({ text: '[图片加载失败]' }));
}
} else {
paragraphs.push(new Paragraph({ text: '[图片加载失败]' }));
}
} else {
paragraphs.push(new Paragraph({ text: '[无法解析图片链接]' }));
}
} else {
const sanitizedText = (block.text || '').replace(/[\x00-\x1F\x7F]/g, '');
paragraphs.push(new Paragraph({
children: [new TextRun({
text: sanitizedText,
font: "Microsoft YaHei",
eastAsia: "Microsoft YaHei"
})],
}));
}
}
} catch (e) {
console.error("解析富文本到段落时出错:", e, "原始内容:", content);
const sanitizedContent = content.replace(/[\x00-\x1F\x7F\u200B-\u200D\uFEFF]/g, '');
if (sanitizedContent) {
paragraphs.push(new Paragraph({
children: [new TextRun({ text: `[解析错误] ${sanitizedContent}`, font: "Microsoft YaHei" })],
}));
}
}
return paragraphs;
}
function parseRichTextToPlainText(content) {
if (!content) return '';
try {
const jsonContent = JSON.parse(content);
if (jsonContent && Array.isArray(jsonContent.blocks)) {
return jsonContent.blocks.map(block => block.text || '').join('\n').trim();
}
} catch (e) {
}
return String(content).trim();
}
function deepParseJsonString(str) {
if (typeof str !== 'string' || str.trim() === '') {
return str;
}
try {
const parsed = JSON.parse(str);
if (typeof parsed === 'string') {
return deepParseJsonString(parsed);
}
if (typeof parsed === 'object' && parsed !== null) {
if (Array.isArray(parsed.blocks) && parsed.blocks.length > 0 && parsed.blocks[0].text) {
const innerText = parsed.blocks[0].text;
if (typeof innerText === 'string' && innerText.startsWith('{') && innerText.endsWith('}')) {
return deepParseJsonString(innerText);
}
}
}
return parsed;
} catch (e) {
return str;
}
}
async function parseRichTextToMultimodalContent(richTextContent) {
const content = [];
if (!richTextContent || richTextContent === '{}') return content;
try {
const jsonContent = JSON.parse(richTextContent);
if (!jsonContent || !Array.isArray(jsonContent.blocks)) {
content.push({ type: 'text', text: String(richTextContent) });
return content;
}
for (const block of jsonContent.blocks) {
if (block.text) {
content.push({ type: 'text', text: block.text });
}
if (block.type === 'atomic' && block.data?.type === 'IMAGE' && block.data.src) {
let imageSrc = block.data.src;
let fileIdMatch = imageSrc.match(/.*cloud\/file_access\/(\d+)/);
if (fileIdMatch && fileIdMatch[1]) {
let fileId = fileIdMatch[1];
let randomParam = Date.now();
let imageUrl = `${window.location.origin}/api/jx-oresource/cloud/file_access/${fileId}?random=${randomParam}`;
const base64Data = await imageToBase64(imageUrl);
if (base64Data) {
content.push({ type: 'image_url', image_url: { url: base64Data } });
} else {
content.push({ type: 'text', text: '[图片加载失败]' });
}
} else {
console.warn('[Vision] 无法从src中解析出图片fileId:', imageSrc);
content.push({ type: 'text', text: '[无法解析图片链接]' });
}
}
}
} catch (e) {
content.push({ type: 'text', text: String(richTextContent) });
}
if (content.length <= 1) return content;
const mergedContent = [];
let textBuffer = '';
for (const item of content) {
if (item.type === 'text') {
textBuffer += (textBuffer ? '\n' : '') + item.text;
} else {
if (textBuffer) {
mergedContent.push({ type: 'text', text: textBuffer.trim() });
textBuffer = '';
}
mergedContent.push(item);
}
}
if (textBuffer) {
mergedContent.push({ type: 'text', text: textBuffer.trim() });
}
return mergedContent;
}
async function imageToBase64(url) {
try {
const response = await fetch(url);
if (!response.ok) {
console.error(`获取图片失败: ${response.status} ${response.statusText}`, url);
return null;
}
const blob = await response.blob();
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
} catch (error) {
console.error("图片转Base64时发生错误:", error, url);
return null;
}
}
const getCanonicalContent = (richText) => {
if (!richText) return '';
try {
const jsonContent = JSON.parse(richText);
if (jsonContent && Array.isArray(jsonContent.blocks)) {
return jsonContent.blocks.map(block => {
if (block.type === 'atomic' && block.data) {
if (block.data.type === 'IMAGE' && block.data.src) {
const fileIdMatch = block.data.src.match(/file_access\/(\d+)/);
if (fileIdMatch && fileIdMatch[1]) {
return `[IMAGE:${fileIdMatch[1]}]`;
}
} else if (block.data.type === 'AUDIO' && block.data.data?.quote_id) {
return `[AUDIO:${block.data.data.quote_id}]`;
}
}
if (block.text && block.text.trim()) {
return block.text.trim().replace(/\s+/g, ' ');
}
return '';
}).filter(Boolean).join('');
}
} catch (e) {
return String(richText || '').replace(/<[^>]+>/g, '').trim().replace(/\s+/g, ' ');
}
return '';
};
function generateContentHash(rawQuestionData) {
if (!rawQuestionData || typeof rawQuestionData !== 'object') {
return null;
}
const cleanQuestion = {
type: rawQuestionData.type,
title: rawQuestionData.title,
answer_items: [],
subQuestions: []
};
if (!cleanQuestion.type || typeof cleanQuestion.title === 'undefined' || cleanQuestion.title === null) {
console.warn("无法生成哈希:缺少 type 或 title 为 null/undefined", rawQuestionData);
return null;
}
const title = getCanonicalContent(cleanQuestion.title);
if (title === '' && (!Array.isArray(rawQuestionData.answer_items) || rawQuestionData.answer_items.length === 0)) {
console.warn("无法生成哈希:title 为空且没有 answer_items", rawQuestionData);
return null;
}
if (Array.isArray(rawQuestionData.answer_items)) {
cleanQuestion.answer_items = rawQuestionData.answer_items.map(item => ({
value: item.value,
is_target_opt: item.is_target_opt
}));
}
if (Array.isArray(rawQuestionData.subQuestions)) {
cleanQuestion.subQuestions = rawQuestionData.subQuestions.map(subQ => generateContentHash(subQ));
}
const type = cleanQuestion.type;
let keyParts = [type, title];
if ([1, 2, 5, 12, 13].includes(type) && Array.isArray(cleanQuestion.answer_items)) {
if (type === 13) {
const leftOptions = cleanQuestion.answer_items.filter(item => !item.is_target_opt).map(item => getCanonicalContent(item.value)).sort();
const rightOptions = cleanQuestion.answer_items.filter(item => item.is_target_opt).map(item => getCanonicalContent(item.value)).sort();
keyParts.push('LEFT:', ...leftOptions, 'RIGHT:', ...rightOptions);
} else {
const sortedOptions = cleanQuestion.answer_items.map(item => getCanonicalContent(item.value)).sort();
keyParts.push(...sortedOptions);
}
}
if (cleanQuestion.subQuestions.length > 0) {
keyParts.push('SUB:', ...cleanQuestion.subQuestions.filter(Boolean).sort());
}
const canonicalString = keyParts.join('|');
return md5(canonicalString);
}
async function getImageSize(imageData) {
return new Promise((resolve, reject) => {
const blob = new Blob([imageData]);
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = function () {
const width = img.width;
const height = img.height;
URL.revokeObjectURL(url);
resolve({ width, height });
};
img.onerror = function () {
URL.revokeObjectURL(url);
reject(new Error('无法加载图片'));
};
img.src = url;
});
}
async function fetchImageData(url) {
try {
const response = await fetch(url, {
method: 'GET'
});
if (response.ok) {
const blob = await response.blob();
return await blob.arrayBuffer();
} else {
console.error('获取图片失败:', response.statusText);
return null;
}
} catch (error) {
console.error('fetchImageData 错误:', error);
return null;
}
}
async function checkAndExecuteAuto() {
if (isProcessing) {
return;
}
if (debounceTimer) {
clearTimeout(debounceTimer);
}
debounceTimer = setTimeout(async () => {
const nodeId = getNodeIDFromUrl(window.location.href);
const groupId = getGroupIDFromUrl(window.location.href);
const flagKey = `xiaoya_autofilled_${groupId}_${nodeId}`;
if (nodeId && groupId && sessionStorage.getItem(flagKey)) {
sessionStorage.removeItem(flagKey);
showNotification('自动填写完成。', { type: 'success' });
console.log('[自动执行] 检测到自动填写后的重载,本次跳过。');
return;
}
if (autoFetchEnabled && (await isTaskPage())) {
try {
isProcessing = true;
showNotification('正在自动获取答案...', {
type: 'info',
keywords: ['自动', '获取', '答案'],
animation: 'fadeSlide'
});
await getAndStoreAnswers();
if (autoFillEnabled) {
await new Promise(resolve => setTimeout(resolve, 1000));
await fillAnswers();
}
} catch (error) {
console.error('自动执行出错:', error);
} finally {
isProcessing = false;
debounceTimer = null;
}
} else {
debounceTimer = null;
}
}, 500);
}
function detectPageChange() {
let lastUrl = location.href;
const observer = new MutationObserver(async () => {
if (location.href !== lastUrl) {
lastUrl = location.href;
isProcessing = false;
if (debounceTimer) {
clearTimeout(debounceTimer);
debounceTimer = null;
}
setTimeout(() => {
checkAndExecuteAuto();
}, 1000);
if (autoContributeEnabled) {
backgroundTaskManager.schedule();
}
}
});
observer.observe(document, {
subtree: true,
childList: true
});
checkAndExecuteAuto();
if (autoContributeEnabled) {
backgroundTaskManager.schedule();
}
}
detectPageChange();
let modelListCache = {};
function showAISettingsPanel() {
let aiConfig = JSON.parse(localStorage.getItem('aiConfig') || '{}');
const currentProvider = aiConfig.provider || 'default';
const currentEndpoint = aiConfig.endpoint || '';
const currentApiKey = aiConfig.apiKey || '';
const currentAzureApiVersion = aiConfig.azureApiVersion || '2023-07-01-preview';
const currentModelId = aiConfig.model || '';
const currentTemperature = aiConfig.temperature !== undefined ? aiConfig.temperature : 0.7;
const currentMaxTokens = aiConfig.max_tokens !== undefined ? aiConfig.max_tokens : 8000;
const currentDisableCorrection = aiConfig.disableCorrection || false;
const currentDisableMaxTokens = aiConfig.disableMaxTokens || false;
const currentBatchConcurrency = aiConfig.batchConcurrency || 'sequential';
const currentXiaoyaAiMode = aiConfig.xiaoyaAiMode || 'deep_think';
const overlay = document.createElement('div');
overlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.7);
z-index: 10001;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.5s cubic-bezier(0.19, 1, 0.22, 1);
backdrop-filter: blur(8px);
`;
const modal = document.createElement('div');
modal.style.cssText = `
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
padding: 32px 40px;
border-radius: 20px;
width: 650px;
max-width: 95%;
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.18), 0 0 25px rgba(0,0,0,0.12);
transform: scale(0.95) translateY(15px);
opacity: 0;
transition: all 0.5s cubic-bezier(0.19, 1, 0.22, 1);
position: relative;
max-height: 90vh;
display: flex;
flex-direction: column;
border: 1px solid rgba(255, 255, 255, 0.2);
`;
const title = document.createElement('h2');
title.innerHTML = `
AI 助手设置
`;
title.style.cssText = `
margin-top: 0;
margin-bottom: 35px;
text-align: center;
color: #1f2937;
font-size: 26px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
position: relative;
`;
const titleUnderline = document.createElement('div');
titleUnderline.style.cssText = `
position: absolute;
bottom: -12px;
left: 50%;
transform: translateX(-50%);
width: 80px;
height: 3px;
background: linear-gradient(to right, #6366f1, #8b5cf6);
border-radius: 3px;
`;
title.appendChild(titleUnderline);
const form = document.createElement('div');
form.style.cssText = `
overflow-y: auto;
padding-right: 18px;
padding-left: 18px;
margin-bottom: 25px;
flex-grow: 1;
scrollbar-width: thin;
scrollbar-color: #cbd5e1 #f1f5f9;
`;
form.innerHTML = `
`;
form.classList.add('ai-settings-form');
const closeButton = document.createElement('button');
closeButton.innerHTML = `
`;
closeButton.style.cssText = `
position: absolute;
top: 15px;
right: 15px;
background: #f3f4f6;
border: none;
width: 36px;
height: 36px;
border-radius: 50%;
cursor: pointer;
color: #6b7280;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
box-shadow: 0 2px 5px rgba(0,0,0,0.08);
`;
closeButton.onmouseover = () => {
closeButton.style.backgroundColor = '#e5e7eb';
closeButton.style.transform = 'rotate(90deg)';
closeButton.style.color = '#000';
closeButton.style.boxShadow = '0 4px 8px rgba(0,0,0,0.12)';
};
closeButton.onmouseout = () => {
closeButton.style.backgroundColor = '#f3f4f6';
closeButton.style.transform = 'rotate(0deg)';
closeButton.style.color = '#6b7280';
closeButton.style.boxShadow = '0 2px 5px rgba(0,0,0,0.08)';
};
closeButton.onclick = () => closeModal();
const fields = [
{
id: 'ai-provider',
label: 'AI 提供商:',
type: 'select',
options: [
{ value: 'default', text: '默认 - 小雅 AI (无需配置)' },
{ value: 'openai', text: 'OpenAI / 兼容 OpenAI 接口' },
{ value: 'gemini', text: 'Google Gemini' },
{ value: 'anthropic', text: 'Anthropic Claude' },
{ value: 'azure', text: 'Azure OpenAI' }
],
value: currentProvider
},
{
id: 'xiaoya-ai-mode',
label: '小雅 AI 模式:',
type: 'select',
options: [
{ value: 'deep_think', text: '深度思考模式 (默认,推理模型)' },
{ value: 'no_deep_think', text: '快速模式 (速度快,质量一般)' }
],
value: currentXiaoyaAiMode,
dependsOn: ['default']
},
{
id: 'ai-endpoint',
label: 'API 地址:',
type: 'text',
placeholder: '例如: https://api.openai.com/v1/chat/completions',
value: currentEndpoint,
dependsOn: ['openai', 'gemini', 'anthropic', 'azure']
},
{
id: 'ai-key',
label: 'API Key:',
type: 'password',
placeholder: '请输入你的 API Key',
value: currentApiKey,
dependsOn: ['openai', 'gemini', 'anthropic', 'azure']
},
{
id: 'ai-model',
label: '模型 ID:',
type: 'text',
placeholder: '例如: gpt-4o, gemini-2.0-flash, claude-3-7-sonnet',
value: currentModelId,
dependsOn: ['openai', 'gemini', 'anthropic', 'azure'],
hasFetchButton: true
},
{
id: 'ai-vision-enabled',
label: '模型具备图片识别 (Vision) 能力:',
type: 'checkbox',
value: aiConfig.visionEnabled || false,
dependsOn: ['openai', 'gemini', 'anthropic', 'azure']
},
{
id: 'stt-enabled',
label: '启用语音转文本(STT)功能 (用于听力题):',
type: 'checkbox',
value: aiConfig.sttEnabled || false,
},
{
id: 'stt-video-enabled',
label: '启用视频音轨转录:',
type: 'checkbox',
value: aiConfig.sttVideoEnabled || false,
dependsOn: ['stt-enabled']
},
{
id: 'stt-provider',
label: 'STT 提供商',
type: 'select',
options: [
{ value: 'openai_compatible', text: 'OpenAI Whisper / 兼容接口 (如 SiliconFlow)' },
{ value: 'gemini', text: 'Google Gemini' },
],
value: aiConfig.sttProvider || 'openai_compatible',
dependsOn: ['stt-enabled']
},
{
id: 'stt-endpoint',
label: 'STT API 地址',
type: 'text',
placeholder: '例如: https://api.siliconflow.cn/v1/audio/transcriptions',
value: aiConfig.sttEndpoint || '',
dependsOn: ['stt-enabled']
},
{
id: 'stt-api-key',
label: 'STT API Key',
type: 'password',
placeholder: '请输入 STT 服务的 API Key',
value: aiConfig.sttApiKey || '',
dependsOn: ['stt-enabled']
},
{
id: 'stt-model',
label: 'STT 模型 ID',
type: 'text',
placeholder: '例如: whisper-1 或 FunAudioLLM/SenseVoiceSmall',
value: aiConfig.sttModel || 'whisper-1',
dependsOn: ['stt-enabled']
},
{
id: 'ai-temperature',
label: 'Temperature (随机性):',
type: 'range',
min: 0,
max: 1,
step: 0.1,
value: currentTemperature,
dependsOn: ['openai', 'gemini', 'anthropic', 'azure']
},
{
id: 'ai-max-tokens',
label: 'Max Tokens (最大长度):',
type: 'number',
min: 10,
max: 8000,
step: 10,
value: currentMaxTokens,
dependsOn: ['openai', 'gemini', 'anthropic', 'azure']
},
{
id: 'ai-azure-apiversion',
label: 'Azure API Version (可选):',
type: 'text',
placeholder: '例如: 2024-05-01-preview',
value: currentAzureApiVersion,
dependsOn: ['azure']
},
{
id: 'ai-disable-correction',
label: '禁用 API 地址自动修正/补全:',
type: 'checkbox',
value: currentDisableCorrection,
dependsOn: ['openai', 'gemini', 'anthropic', 'azure']
},
{
id: 'ai-disable-max-tokens',
label: '不限制 Max Tokens (可能导致费用增加或API出错):',
type: 'checkbox',
value: currentDisableMaxTokens,
dependsOn: ['openai', 'gemini', 'anthropic', 'azure']
},
{
id: 'ai-batch-concurrency',
label: 'AI 批量处理并发数:',
type: 'number',
min: 1,
placeholder: '输入数字 (1 = 顺序处理, >1 = 并发处理)',
value: currentBatchConcurrency === 'sequential' ? 1 : (parseInt(currentBatchConcurrency, 10) || 2),
dependsOn: ['openai', 'gemini', 'anthropic', 'azure', 'default']
},
{
id: 'ai-request-interval',
label: 'AI 顺序处理请求间隔 (毫秒):',
type: 'number',
min: 0,
placeholder: '例如: 500 (表示 0.5 秒)',
value: aiConfig.requestInterval || 1000,
dependsOn: ['openai', 'gemini', 'anthropic', 'azure', 'default']
},
];
const promptPlaceholders = {
'1': ['{questionType}', '{questionTitle}', '{optionsText}'],
'2': ['{questionType}', '{questionTitle}', '{optionsText}'],
'5': ['{questionType}', '{questionTitle}', '{optionsText}'],
'4': ['{questionType}', '{questionTitle}', '{answerContent}'],
'6': ['{questionType}', '{questionTitle}', '{answerContent}'],
'10': ['{questionType}', '{questionTitle}', '{language}', '{max_time}', '{max_memory}', '{answerContent}'],
'12': ['{questionType}', '{questionTitle}', '{optionsText}'],
'13': ['{questionType}', '{questionTitle}', '{stemsText}', '{optionsText}']
};
let customPrompts = JSON.parse(localStorage.getItem('aiCustomPrompts') || '{}');
const inputElements = {};
fields.forEach(field => {
const group = document.createElement('div');
group.className = 'form-group';
group.dataset.dependsOn = field.dependsOn ? JSON.stringify(field.dependsOn) : '';
const label = document.createElement('label');
label.textContent = field.label;
label.htmlFor = field.id;
let input;
if (field.type === 'select') {
input = document.createElement('select');
field.options.forEach(opt => {
const option = document.createElement('option');
option.value = opt.value;
option.textContent = opt.text;
if (opt.value === field.value) option.selected = true;
input.appendChild(option);
});
input.onchange = () => updateFieldVisibility();
} else if (field.type === 'range') {
const rangeGroup = document.createElement('div');
rangeGroup.className = 'range-group';
input = document.createElement('input');
input.type = 'range';
input.min = field.min;
input.max = field.max;
input.step = field.step;
input.value = field.value;
const valueDisplay = document.createElement('span');
valueDisplay.className = 'range-value';
valueDisplay.textContent = parseFloat(input.value).toFixed(1);
input.oninput = () => {
valueDisplay.textContent = parseFloat(input.value).toFixed(1);
};
rangeGroup.appendChild(input);
rangeGroup.appendChild(valueDisplay);
group.appendChild(label);
group.appendChild(rangeGroup);
form.appendChild(group);
inputElements[field.id] = input;
return;
} else if (field.type === 'checkbox') {
input = document.createElement('input');
input.type = 'checkbox';
input.checked = field.value;
input.style.width = '20px';
input.style.height = '20px';
input.style.marginRight = '10px';
input.style.verticalAlign = 'middle';
const checkboxLabel = document.createElement('span');
if (field.id === 'ai-vision-enabled') {
checkboxLabel.innerHTML = '勾选后,AI 将能够识别题目或选项中的图片内容。请确保模型支持。';
} else if (field.id === 'stt-enabled') {
checkboxLabel.innerHTML = '勾选后,AI 将能够识别语音输入。请填入可用的 STT 模型。';
} else if (field.id === 'stt-video-enabled') {
checkboxLabel.innerHTML = '勾选后,将自动提取视频中的音轨进行转录。这会消耗更多资源和时间。';
} else if (field.id === 'ai-disable-correction') {
checkboxLabel.textContent = '强制使用填写的地址,不进行任何自动修改。';
} else if (field.id === 'ai-disable-max-tokens') {
checkboxLabel.textContent = '勾选后将不发送 max_tokens 参数,某些 API 可能不支持。';
}
checkboxLabel.style.verticalAlign = 'middle';
checkboxLabel.style.fontSize = '13px';
checkboxLabel.style.color = '#555';
const checkboxContainer = document.createElement('div');
checkboxContainer.style.display = 'flex';
checkboxContainer.style.alignItems = 'center';
checkboxContainer.appendChild(input);
checkboxContainer.appendChild(checkboxLabel);
group.appendChild(label);
group.appendChild(checkboxContainer);
form.appendChild(group);
inputElements[field.id] = input;
return;
} else if (field.type === 'number') {
input = document.createElement('input');
input.type = 'number';
input.min = field.min;
input.max = field.max;
input.step = field.step;
input.placeholder = field.placeholder || '';
input.value = field.value;
} else {
input = document.createElement('input');
input.type = field.type;
input.placeholder = field.placeholder || '';
input.value = field.value;
}
input.id = field.id;
group.appendChild(label);
if (field.hasFetchButton) {
const inputContainer = document.createElement('div');
inputContainer.style.display = 'flex';
inputContainer.style.gap = '10px';
inputContainer.style.alignItems = 'center';
input.style.flexGrow = '1';
inputContainer.appendChild(input);
const fetchButton = document.createElement('button');
fetchButton.innerHTML = `
获取`;
fetchButton.type = 'button';
fetchButton.style.cssText = `
padding: 8px 12px; background-color: #e5e7eb; color: #374151; border: 1px solid #d1d5db;
border-radius: 6px; cursor: pointer; font-size: 13px; transition: all 0.2s; display: inline-flex; align-items: center;
`;
fetchButton.onmouseover = () => { fetchButton.style.backgroundColor = '#d1d5db'; };
fetchButton.onmouseout = () => { fetchButton.style.backgroundColor = '#e5e7eb'; };
fetchButton.onclick = () => fetchModelsAndPopulateDropdown(field.id);
inputContainer.appendChild(fetchButton);
group.appendChild(inputContainer);
const modelSearchInput = document.createElement('input');
modelSearchInput.type = 'text';
modelSearchInput.id = `${field.id}-search`;
modelSearchInput.placeholder = '搜索模型...';
modelSearchInput.style.marginTop = '8px';
modelSearchInput.style.display = 'none';
modelSearchInput.style.width = 'calc(100% - 30px)';
modelSearchInput.style.padding = '8px 12px';
modelSearchInput.style.border = '1px solid #d1d5db';
modelSearchInput.style.borderRadius = '6px';
modelSearchInput.style.fontSize = '13px';
const modelSelect = document.createElement('select');
modelSelect.id = `${field.id}-select`;
modelSelect.style.marginTop = '8px';
modelSelect.style.display = 'none';
modelSelect.innerHTML = '';
modelSelect.onchange = () => {
if (modelSelect.value) {
input.value = modelSelect.value;
}
};
modelSearchInput.oninput = () => {
const searchTerm = modelSearchInput.value.toLowerCase();
let firstVisibleOption = null;
for (let i = 0; i < modelSelect.options.length; i++) {
const option = modelSelect.options[i];
if (option.value === "") {
option.style.display = '';
continue;
}
const optionText = option.textContent.toLowerCase();
const isVisible = optionText.includes(searchTerm);
option.style.display = isVisible ? '' : 'none';
if (isVisible && !firstVisibleOption) {
firstVisibleOption = option;
}
}
};
group.appendChild(modelSearchInput);
group.appendChild(modelSelect);
inputElements[`${field.id}-search`] = modelSearchInput;
inputElements[`${field.id}-select`] = modelSelect;
} else {
group.appendChild(input);
}
form.appendChild(group);
inputElements[field.id] = input;
});
const advancedDetails = document.createElement('details');
const advancedSummary = document.createElement('summary');
advancedSummary.textContent = '高级设置';
advancedDetails.appendChild(advancedSummary);
const advancedContentWrapper = document.createElement('div');
advancedContentWrapper.className = 'advanced-content-wrapper';
advancedDetails.appendChild(advancedContentWrapper);
const advancedFieldIds = [
'ai-temperature', 'ai-max-tokens', 'ai-azure-apiversion',
'ai-disable-correction', 'ai-disable-max-tokens',
'ai-batch-concurrency', 'ai-request-interval'
];
advancedFieldIds.forEach(id => {
const element = inputElements[id];
if (element) {
const group = element.closest('.form-group');
if (group) {
advancedContentWrapper.appendChild(group);
}
}
});
const promptSectionTitle = document.createElement('h3');
promptSectionTitle.textContent = '自定义 Prompt';
promptSectionTitle.style.cssText = `
margin-top: 35px;
margin-bottom: 20px;
font-size: 20px;
font-weight: 600;
color: #1f2937;
border-top: 1px solid #e5e7eb;
padding-top: 25px;
text-align: center;
position: relative;
`;
const titleIcon = document.createElement('span');
titleIcon.innerHTML = `
`;
promptSectionTitle.insertBefore(titleIcon, promptSectionTitle.firstChild);
advancedContentWrapper.appendChild(promptSectionTitle);
const promptDescription = document.createElement('p');
promptDescription.style.cssText = `
margin-bottom: 25px;
color: #6b7280;
font-size: 14px;
line-height: 1.5;
text-align: center;
max-width: 80%;
margin-left: auto;
margin-right: auto;
`;
promptDescription.innerHTML = '自定义各题型的 AI 提示模板,使用占位符来插入题目内容。高质量的提示将带来更准确的 AI 回答。';
advancedContentWrapper.appendChild(promptDescription);
const promptEditContainer = document.createElement('div');
promptEditContainer.style.cssText = `
display: flex;
flex-direction: column;
gap: 25px;
background: #f9fafb;
padding: 20px;
border-radius: 12px;
border: 1px solid #e5e7eb;
box-shadow: 0 2px 5px rgba(0,0,0,0.05);
`;
Object.keys(defaultPrompts).forEach(typeCode => {
const questionTypeName = getQuestionType(parseInt(typeCode, 10));
const promptGroup = document.createElement('div');
promptGroup.className = 'form-group prompt-group';
promptGroup.style.cssText = `
padding: 15px;
background: #ffffff;
border-radius: 10px;
border: 1px solid #e5e7eb;
transition: all 0.3s ease;
box-shadow: 0 1px 3px rgba(0,0,0,0.02);
`;
promptGroup.addEventListener('mouseenter', () => {
promptGroup.style.boxShadow = '0 4px 12px rgba(0,0,0,0.08)';
promptGroup.style.borderColor = '#d1d5db';
promptGroup.style.transform = 'translateY(-2px)';
});
promptGroup.addEventListener('mouseleave', () => {
promptGroup.style.boxShadow = '0 1px 3px rgba(0,0,0,0.02)';
promptGroup.style.borderColor = '#e5e7eb';
promptGroup.style.transform = 'translateY(0)';
});
const promptHeader = document.createElement('div');
promptHeader.style.cssText = `
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
`;
const promptLabel = document.createElement('label');
promptLabel.htmlFor = `prompt-type-${typeCode}`;
promptLabel.style.cssText = `
font-weight: 600;
color: #374151;
font-size: 16px;
display: flex;
align-items: center;
`;
const typeIcon = document.createElement('span');
typeIcon.style.cssText = `
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
margin-right: 10px;
background: #eef2ff;
border-radius: 8px;
color: #6366f1;
font-size: 16px;
font-weight: bold;
`;
switch (parseInt(typeCode)) {
case 1: typeIcon.innerHTML = '单'; break;
case 2: typeIcon.innerHTML = '多'; break;
case 5: typeIcon.innerHTML = '判'; break;
case 4: typeIcon.innerHTML = '填'; break;
case 6: typeIcon.innerHTML = '简'; break;
case 10: typeIcon.innerHTML = '编'; break;
case 12: typeIcon.innerHTML = '排'; break;
case 13: typeIcon.innerHTML = '匹'; break;
default: typeIcon.innerHTML = typeCode;
}
promptLabel.appendChild(typeIcon);
promptLabel.appendChild(document.createTextNode(`${questionTypeName} Prompt`));
const restoreButton = document.createElement('button');
restoreButton.type = 'button';
restoreButton.style.cssText = `
padding: 6px 12px;
font-size: 13px;
background: linear-gradient(to bottom, #f8fafc, #eef2ff);
color: #4f46e5;
border: 1px solid #c7d2fe;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 1px 2px rgba(99, 102, 241, 0.1);
display: flex;
align-items: center;
font-weight: 500;
`;
restoreButton.innerHTML = `
恢复默认
`;
restoreButton.onmouseover = () => {
restoreButton.style.background = 'linear-gradient(to bottom, #eef2ff, #dbeafe)';
restoreButton.style.transform = 'translateY(-2px)';
restoreButton.style.boxShadow = '0 3px 6px rgba(99, 102, 241, 0.2)';
};
restoreButton.onmouseout = () => {
restoreButton.style.background = 'linear-gradient(to bottom, #f8fafc, #eef2ff)';
restoreButton.style.transform = 'translateY(0)';
restoreButton.style.boxShadow = '0 1px 2px rgba(99, 102, 241, 0.1)';
};
restoreButton.onclick = (e) => {
e.preventDefault();
const textarea = inputElements[`prompt-type-${typeCode}`];
if (textarea) {
restoreButton.style.transition = 'all 0.2s';
restoreButton.style.background = '#818cf8';
restoreButton.style.color = 'white';
restoreButton.innerHTML = ` 已恢复`;
textarea.value = defaultPrompts[typeCode];
textarea.style.borderColor = '#818cf8';
textarea.style.boxShadow = '0 0 0 3px rgba(99, 102, 241, 0.15)';
setTimeout(() => {
restoreButton.style.transition = 'all 0.3s ease';
restoreButton.style.background = 'linear-gradient(to bottom, #f8fafc, #eef2ff)';
restoreButton.style.color = '#4f46e5';
restoreButton.innerHTML = `
恢复默认
`;
setTimeout(() => {
textarea.style.borderColor = '#d1d5db';
textarea.style.boxShadow = 'none';
}, 300);
}, 1000);
showNotification(`${questionTypeName} Prompt 已恢复默认`, {
type: 'success',
duration: 1500,
animation: 'scale'
});
}
};
promptHeader.appendChild(promptLabel);
promptHeader.appendChild(restoreButton);
promptGroup.appendChild(promptHeader);
const promptTextarea = document.createElement('textarea');
promptTextarea.id = `prompt-type-${typeCode}`;
promptTextarea.rows = 8;
promptTextarea.style.cssText = `
width: 100%;
padding: 12px 15px;
border: 1px solid #d1d5db;
border-radius: 10px;
font-size: 14px;
line-height: 1.6;
resize: vertical;
background-color: #f9fafb;
transition: all 0.25s ease;
outline: none;
box-sizing: border-box;
min-height: 160px;
color: #374151;
`;
promptTextarea.value = customPrompts[typeCode] || defaultPrompts[typeCode];
promptTextarea.onfocus = () => {
promptTextarea.style.borderColor = '#6366f1';
promptTextarea.style.boxShadow = '0 0 0 3px rgba(99, 102, 241, 0.15)';
promptTextarea.style.backgroundColor = '#ffffff';
};
promptTextarea.onblur = () => {
promptTextarea.style.borderColor = '#d1d5db';
promptTextarea.style.boxShadow = 'none';
promptTextarea.style.backgroundColor = '#f9fafb';
};
const placeholderInfo = document.createElement('div');
placeholderInfo.style.cssText = `
margin-top: 10px;
color: #6b7280;
font-size: 13px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
`;
const infoIcon = document.createElement('span');
infoIcon.innerHTML = `
`;
infoIcon.style.marginRight = '5px';
placeholderInfo.appendChild(infoIcon);
const placeholderText = document.createElement('span');
placeholderText.textContent = '可用占位符: ';
placeholderInfo.appendChild(placeholderText);
promptPlaceholders[typeCode].forEach((placeholder, index) => {
const placeholderChip = document.createElement('code');
placeholderChip.textContent = placeholder;
placeholderChip.style.cssText = `
background: #e0e7ff;
color: #4338ca;
padding: 3px 6px;
border-radius: 4px;
font-family: Microsoft YaHei;
font-size: 12px;
display: inline-block;
cursor: pointer;
transition: all 0.2s ease;
border: 1px solid #c7d2fe;
`;
placeholderChip.onclick = () => {
const start = promptTextarea.selectionStart;
const end = promptTextarea.selectionEnd;
const text = promptTextarea.value;
promptTextarea.value = text.substring(0, start) + placeholder + text.substring(end);
promptTextarea.focus();
promptTextarea.setSelectionRange(start + placeholder.length, start + placeholder.length);
placeholderChip.style.backgroundColor = '#818cf8';
placeholderChip.style.color = 'white';
setTimeout(() => {
placeholderChip.style.backgroundColor = '#e0e7ff';
placeholderChip.style.color = '#4338ca';
}, 200);
};
placeholderChip.onmouseover = () => {
placeholderChip.style.backgroundColor = '#c7d2fe';
placeholderChip.style.transform = 'translateY(-1px)';
placeholderChip.style.boxShadow = '0 2px 4px rgba(99, 102, 241, 0.2)';
};
placeholderChip.onmouseout = () => {
placeholderChip.style.backgroundColor = '#e0e7ff';
placeholderChip.style.transform = 'translateY(0)';
placeholderChip.style.boxShadow = 'none';
};
placeholderInfo.appendChild(placeholderChip);
});
promptGroup.appendChild(promptTextarea);
promptGroup.appendChild(placeholderInfo);
promptEditContainer.appendChild(promptGroup);
inputElements[`prompt-type-${typeCode}`] = promptTextarea;
});
advancedContentWrapper.appendChild(promptEditContainer);
advancedContentWrapper.appendChild(promptEditContainer);
form.appendChild(advancedDetails);
const urlPreviewContainer = document.createElement('div');
urlPreviewContainer.className = 'url-preview-container';
urlPreviewContainer.innerHTML = `
请求 URL 预览:
-
-
`;
form.appendChild(urlPreviewContainer);
const urlDisplayElement = urlPreviewContainer.querySelector('.url-display');
const statusElement = urlPreviewContainer.querySelector('.status');
const suggestionElement = urlPreviewContainer.querySelector('.correction-suggestion');
const buttonContainer = document.createElement('div');
buttonContainer.style.cssText = `
display: flex;
justify-content: flex-end;
gap: 15px;
margin-top: 20px;
padding-top: 25px;
border-top: 1px solid #e5e7eb;
`;
const saveButton = document.createElement('button');
saveButton.innerHTML = `
保存设置
`;
saveButton.style.cssText = `
padding: 12px 24px;
background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);
color: white;
border: none;
border-radius: 10px;
cursor: pointer;
font-size: 15px;
font-weight: 600;
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.2);
`;
saveButton.onmouseover = () => {
saveButton.style.transform = 'translateY(-2px)';
saveButton.style.boxShadow = '0 6px 15px rgba(79, 70, 229, 0.3)';
};
saveButton.onmouseout = () => {
saveButton.style.transform = 'translateY(0)';
saveButton.style.boxShadow = '0 4px 12px rgba(79, 70, 229, 0.2)';
};
saveButton.onclick = () => {
const newConfig = {
provider: inputElements['ai-provider'].value,
endpoint: inputElements['ai-endpoint'].value.trim(),
apiKey: inputElements['ai-key'].value.trim(),
model: inputElements['ai-model'].value.trim(),
temperature: parseFloat(inputElements['ai-temperature'].value),
max_tokens: parseInt(inputElements['ai-max-tokens'].value, 10),
azureApiVersion: inputElements['ai-azure-apiversion'].value.trim(),
disableCorrection: inputElements['ai-disable-correction'].checked,
disableMaxTokens: inputElements['ai-disable-max-tokens'].checked,
visionEnabled: inputElements['ai-vision-enabled'].checked,
batchConcurrency: inputElements['ai-batch-concurrency'].value,
requestInterval: parseInt(inputElements['ai-request-interval'].value, 10) || 200,
xiaoyaAiMode: inputElements['xiaoya-ai-mode'].value,
sttEnabled: inputElements['stt-enabled'].checked,
sttVideoEnabled: inputElements['stt-video-enabled'].checked,
sttProvider: inputElements['stt-provider'].value,
sttEndpoint: inputElements['stt-endpoint'].value.trim(),
sttApiKey: inputElements['stt-api-key'].value.trim(),
sttModel: inputElements['stt-model'].value.trim()
};
if (isNaN(newConfig.temperature)) newConfig.temperature = 0.7;
if (isNaN(newConfig.max_tokens) || newConfig.max_tokens <= 0) newConfig.max_tokens = 8000;
const newCustomPrompts = {};
Object.keys(defaultPrompts).forEach(typeCode => {
const textarea = inputElements[`prompt-type-${typeCode}`];
if (textarea) {
if (textarea.value.trim() !== defaultPrompts[typeCode].trim()) {
newCustomPrompts[typeCode] = textarea.value;
}
}
});
localStorage.setItem('aiCustomPrompts', JSON.stringify(newCustomPrompts));
localStorage.setItem('aiConfig', JSON.stringify(newConfig));
showNotification('AI 设置已保存!', { type: 'success', animation: 'scale' });
closeModal();
};
const cancelButton = document.createElement('button');
cancelButton.innerHTML = `
取消
`;
cancelButton.style.cssText = `
padding: 12px 24px;
background-color: #f3f4f6;
color: #4b5563;
border: 1px solid #d1d5db;
border-radius: 10px;
cursor: pointer;
font-size: 15px;
font-weight: 500;
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
`;
cancelButton.onmouseover = () => {
cancelButton.style.backgroundColor = '#e5e7eb';
cancelButton.style.transform = 'translateY(-1px)';
};
cancelButton.onmouseout = () => {
cancelButton.style.backgroundColor = '#f3f4f6';
cancelButton.style.transform = 'translateY(0)';
};
cancelButton.onclick = () => closeModal();
buttonContainer.appendChild(cancelButton);
buttonContainer.appendChild(saveButton);
modal.appendChild(closeButton);
modal.appendChild(title);
modal.appendChild(form);
modal.appendChild(buttonContainer);
overlay.appendChild(modal);
document.body.appendChild(overlay);
requestAnimationFrame(() => {
overlay.style.opacity = '1';
modal.style.opacity = '1';
modal.style.transform = 'scale(1) translateY(0)';
});
async function fetchAvailableModels(provider, endpoint, apiKey, azureApiVersion) {
const cacheKey = `${provider}-${endpoint}-${apiKey}-${azureApiVersion || ''}`;
const cachedData = modelListCache[cacheKey];
if (cachedData) {
console.log(`从缓存加载 ${provider} 模型列表`);
showNotification(`从缓存加载 ${provider} 模型列表`, { type: 'info', duration: 1000 });
return Promise.resolve(cachedData.models);
}
console.log(`正在为 ${provider} 获取可用模型...`);
showNotification(`正在为 ${provider} 获取可用模型...`, { type: 'info', duration: 2000 });
try {
switch (provider) {
case 'openai': {
let modelsEndpoint = endpoint.split('?')[0].replace(/\/$/, '');
if (modelsEndpoint.endsWith('/chat/completions')) {
modelsEndpoint = modelsEndpoint.replace('/chat/completions', '/models');
} else if (!modelsEndpoint.endsWith('/models')) {
if (modelsEndpoint.includes('/v1')) {
modelsEndpoint = modelsEndpoint.substring(0, modelsEndpoint.indexOf('/v1')) + '/v1/models';
} else {
modelsEndpoint += '/v1/models';
}
}
console.log("OpenAI 模型端点:", modelsEndpoint);
return new Promise((resolve, reject) => {
fetch(modelsEndpoint, {
method: 'GET',
headers: { 'Authorization': `Bearer ${apiKey}` },
signal: AbortSignal.timeout(15000)
})
.then(response => {
if (response.ok) {
return response.json();
} else {
const errorMsg = `获取 OpenAI 模型列表失败 (${response.status})`;
showNotification(errorMsg, { type: 'error' });
throw new Error(errorMsg);
}
})
.then(data => {
const models = (data.data || data)
.map(m => m.id)
.sort();
console.log("找到 OpenAI 可用模型:", models);
modelListCache[cacheKey] = { models: models, timestamp: Date.now() };
resolve(models);
})
.catch(error => {
if (error.name === 'AbortError') {
showNotification('获取 OpenAI 模型列表超时', { type: 'error' });
reject(new Error('获取 OpenAI 模型列表超时'));
} else {
showNotification('获取 OpenAI 模型列表失败: ' + error.message, { type: 'error' });
reject(error);
}
});
});
}
case 'gemini': {
let modelsEndpoint = endpoint.replace(/\/v\d+(beta)?\/models\/.*$/, '').replace(/\/models\/.*$/, '').replace(/\/$/, '');
modelsEndpoint += `/v1beta/models?key=${apiKey}`;
console.log("Gemini 模型端点:", modelsEndpoint);
return new Promise((resolve, reject) => {
fetch(modelsEndpoint, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(15000)
})
.then(response => {
if (response.ok) {
return response.json();
} else {
const errorMsg = `获取 Gemini 模型列表失败 (${response.status})`;
showNotification(errorMsg, { type: 'error' });
throw new Error(errorMsg);
}
})
.then(data => {
const models = (data.models || [])
.map(m => m.name.replace('models/', ''))
.sort();
console.log("找到 Gemini 可用模型:", models);
modelListCache[cacheKey] = { models: models, timestamp: Date.now() };
resolve(models);
})
.catch(error => {
if (error.name === 'AbortError') {
showNotification('获取 Gemini 模型列表超时', { type: 'error' });
reject(new Error('获取 Gemini 模型列表超时'));
} else {
showNotification('获取 Gemini 模型列表失败: ' + error.message, { type: 'error' });
reject(error);
}
});
});
}
case 'anthropic': {
let modelsEndpoint = endpoint.split('?')[0].replace(/\/$/, '');
if (modelsEndpoint.endsWith('/v1/messages')) {
modelsEndpoint = modelsEndpoint.replace('/v1/messages', '/v1/models');
} else if (!modelsEndpoint.endsWith('/v1/models')) {
if (modelsEndpoint.includes('/v1')) {
modelsEndpoint = modelsEndpoint.substring(0, modelsEndpoint.indexOf('/v1')) + '/v1/models';
} else {
modelsEndpoint += '/v1/models';
}
}
console.log("Anthropic 模型端点:", modelsEndpoint);
return new Promise((resolve, reject) => {
fetch(modelsEndpoint, {
method: 'GET',
headers: {
'x-api-key': apiKey,
'Content-Type': 'application/json'
},
signal: AbortSignal.timeout(15000)
})
.then(response => {
if (response.ok) {
return response.json();
} else {
const errorMsg = `获取 Anthropic 模型列表失败 (${response.status})`;
showNotification(errorMsg, { type: 'error' });
throw new Error(errorMsg);
}
})
.then(data => {
const models = (data.data || [])
.map(m => m.id)
.sort();
console.log("找到 Anthropic 可用模型:", models);
modelListCache[cacheKey] = { models: models, timestamp: Date.now() };
resolve(models);
})
.catch(error => {
if (error.name === 'AbortError') {
showNotification('获取 Anthropic 模型列表超时', { type: 'error' });
reject(new Error('获取 Anthropic 模型列表超时'));
} else {
showNotification('获取 Anthropic 模型列表失败: ' + error.message, { type: 'error' });
reject(error);
}
});
});
}
case 'azure':
default:
showNotification(`${provider} 提供商暂不支持自动获取模型列表。`, { type: 'warning' });
return Promise.resolve([]);
}
} catch (error) {
console.error(`获取 ${provider} 模型列表时出错:`, error);
showNotification(`获取 ${provider} 模型列表失败: ${error.message}`, { type: 'error' });
return Promise.resolve([]);
}
}
async function fetchModelsAndPopulateDropdown(modelInputId) {
const provider = inputElements['ai-provider'].value;
const endpoint = inputElements['ai-endpoint'].value.trim();
const apiKey = inputElements['ai-key'].value.trim();
const azureApiVersion = inputElements['ai-azure-apiversion']?.value.trim();
const modelSelect = inputElements[`${modelInputId}-select`];
const modelInput = inputElements[modelInputId];
const fetchButton = modelInput.closest('.form-group').querySelector('button[type="button"]');
if (provider === 'default') {
showNotification('默认提供商无需获取模型。', { type: 'info' });
return;
}
if (!endpoint || !apiKey) {
showNotification('请先填写 API 地址和 API Key。', { type: 'warning' });
return;
}
fetchButton.disabled = true;
const originalButtonText = fetchButton.innerHTML;
fetchButton.innerHTML = `
获取中...`;
if (!document.getElementById('spin-animation-style')) {
const style = document.createElement('style');
style.id = 'spin-animation-style';
style.textContent = `
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`;
document.head.appendChild(style);
}
try {
const models = await fetchAvailableModels(provider, endpoint, apiKey, azureApiVersion);
modelSelect.innerHTML = '';
if (models && models.length > 0) {
models.forEach(modelId => {
const option = document.createElement('option');
option.value = modelId;
option.textContent = modelId;
modelSelect.appendChild(option);
});
modelSelect.style.display = 'block';
inputElements[`${modelInputId}-search`].style.display = 'block';
showNotification(`成功获取 ${models.length} 个模型。`, { type: 'success' });
} else if (models) {
modelSelect.style.display = 'none';
inputElements[`${modelInputId}-search`].style.display = 'none';
showNotification(`未能获取到 ${provider} 的模型列表。`, { type: 'warning' });
} else {
modelSelect.style.display = 'none';
inputElements[`${modelInputId}-search`].style.display = 'none';
}
} catch (error) {
console.error("Error in fetchModelsAndPopulateDropdown:", error);
modelSelect.style.display = 'none';
inputElements[`${modelInputId}-search`].style.display = 'none';
} finally {
fetchButton.disabled = false;
fetchButton.innerHTML = originalButtonText;
}
}
const updateSttFieldsForProvider = () => {
const sttProvider = inputElements['stt-provider'].value;
const endpointInput = inputElements['stt-endpoint'];
const modelInput = inputElements['stt-model'];
const apiKeyInput = inputElements['stt-api-key'];
if (sttProvider === 'gemini') {
if (!endpointInput.value) {
endpointInput.value = 'https://generativelanguage.googleapis.com/v1beta/models/';
}
endpointInput.placeholder = 'Gemini API 地址';
if (!modelInput.value) {
modelInput.value = 'gemini-1.5-flash';
}
apiKeyInput.placeholder = "请输入你的 Gemini API Key";
} else if (sttProvider === 'openai_compatible') {
if (!endpointInput.value) {
endpointInput.value = 'https://api.openai.com/v1/audio/transcriptions';
}
endpointInput.placeholder = '例如: https://api.openai.com/v1/audio/transcriptions';
if (!modelInput.value) {
modelInput.value = 'whisper-1';
}
apiKeyInput.placeholder = "可选,可与 LLM Key 不同";
}
};
const updateFieldVisibility = () => {
const selectedProvider = inputElements['ai-provider'].value;
const sttEnabled = inputElements['stt-enabled'].checked;
fields.forEach(field => {
const group = inputElements[field.id]?.closest('.form-group');
if (!group) return;
let shouldBeVisible = true;
const dependenciesStr = group.dataset.dependsOn;
if (dependenciesStr) {
try {
const dependencies = JSON.parse(dependenciesStr);
if (dependencies.includes('stt-enabled')) {
shouldBeVisible = sttEnabled;
} else {
shouldBeVisible = dependencies.includes(selectedProvider);
}
} catch (e) {
console.error("解析字段依赖项时出错:", field.id, dependenciesStr);
shouldBeVisible = false;
}
}
if (shouldBeVisible) {
group.classList.remove('hidden');
group.style.maxHeight = '500px';
group.style.opacity = '1';
group.style.marginBottom = '24px';
group.style.display = 'flex';
} else {
group.classList.add('hidden');
group.style.maxHeight = '0';
group.style.opacity = '0';
group.style.marginBottom = '0';
setTimeout(() => {
if (group.classList.contains('hidden')) {
group.style.display = 'none';
}
}, 300);
}
});
const advancedWrapper = advancedDetails.querySelector('.advanced-content-wrapper');
const hasVisibleAdvancedChild = Array.from(advancedWrapper.querySelectorAll('.form-group')).some(group => !group.classList.contains('hidden'));
advancedDetails.style.display = hasVisibleAdvancedChild ? 'block' : 'none';
};
const closeModal = () => {
modal.style.transform = 'scale(0.95) translateY(15px)';
modal.style.opacity = '0';
overlay.style.opacity = '0';
setTimeout(() => {
if (document.body.contains(overlay)) {
document.body.removeChild(overlay);
}
}, 400);
};
const updateUrlPreview = () => {
const provider = inputElements['ai-provider'].value;
const endpoint = inputElements['ai-endpoint'].value.trim();
const apiKey = inputElements['ai-key'].value.trim();
const modelId = inputElements['ai-model'].value.trim();
const azureApiVersion = inputElements['ai-azure-apiversion'].value.trim() || '2024-05-01-preview';
const disableCorrection = inputElements['ai-disable-correction'].checked;
let finalUrl = '-';
let status = '-';
let statusClass = '';
let suggestion = '';
if (provider === 'default') {
finalUrl = '使用小雅内置 AI,无需配置 URL。';
status = '默认配置';
statusClass = 'valid';
suggestion = '注意: 小雅内置 AI 必须在课程页面内使用。';
} else if (endpoint) {
if (disableCorrection) {
let baseEndpoint = endpoint.split('?')[0].replace(/\/$/, '');
const urlParams = new URLSearchParams(endpoint.split('?')[1] || '');
if (provider === 'azure' && !urlParams.has('api-version')) {
urlParams.set('api-version', azureApiVersion);
}
finalUrl = `${baseEndpoint}${urlParams.toString() ? '?' + urlParams.toString() : ''}`;
status = '已禁用自动修正';
statusClass = 'valid';
suggestion = '将强制使用你输入的地址。请确保格式正确。';
if (provider === 'azure' && !urlParams.has('api-version')) {
suggestion += ` (已自动添加 api-version=${azureApiVersion})`;
}
} else {
try {
let cleanEndpoint = endpoint.split('?')[0].replace(/\/$/, '');
const originalUrlParams = new URLSearchParams(endpoint.split('?')[1] || '');
switch (provider) {
case 'openai': {
let corrected = false;
if (!cleanEndpoint.endsWith('/v1/chat/completions')) {
if (cleanEndpoint.includes('/v1')) {
cleanEndpoint = cleanEndpoint.substring(0, cleanEndpoint.indexOf('/v1')) + '/v1/chat/completions';
} else {
cleanEndpoint += '/v1/chat/completions';
}
corrected = true;
}
finalUrl = cleanEndpoint;
status = corrected ? '格式已自动修正' : '格式有效';
statusClass = corrected ? 'warning' : 'valid';
if (corrected) suggestion = `建议使用标准路径: ${finalUrl}
`;
break;
}
case 'gemini': {
let cleanBaseEndpoint = endpoint.replace(/\/v\d+(beta)?\/models\/.*$/, '').replace(/\/models\/.*$/, '').replace(/\/$/, '');
const modelToUse = modelId || "gemini-1.5-flash-latest";
const apiVersion = "v1beta";
originalUrlParams.set('key', apiKey ? '***' : '[需要API Key]');
finalUrl = `${cleanBaseEndpoint}/${apiVersion}/models/${modelToUse}:generateContent?${originalUrlParams.toString()}`;
if (!apiKey) {
status = '缺少 API Key';
statusClass = 'invalid';
suggestion = 'Gemini 请求需要在 URL 中包含 API Key。';
} else {
status = '格式有效 (请确认基础地址)';
statusClass = 'valid';
suggestion = `预览显示的是预期请求格式 (Key已隐藏)。请确保基础地址正确。`;
}
break;
}
case 'anthropic': {
let corrected = false;
if (!cleanEndpoint.endsWith('/v1/messages')) {
if (cleanEndpoint.includes('/v1')) {
cleanEndpoint = cleanEndpoint.substring(0, cleanEndpoint.indexOf('/v1')) + '/v1/messages';
} else {
cleanEndpoint += '/v1/messages';
}
corrected = true;
}
finalUrl = cleanEndpoint;
status = corrected ? '格式已自动修正' : '格式有效';
statusClass = corrected ? 'warning' : 'valid';
if (corrected) suggestion = `建议使用标准路径: ${finalUrl}
`;
break;
}
case 'azure': {
if (!originalUrlParams.has('api-version')) {
originalUrlParams.set('api-version', azureApiVersion);
}
const isOpenAIStyleHost = cleanEndpoint.includes('.openai.azure.com');
const isAIServicesStyleHost = cleanEndpoint.includes('.services.ai.azure.com') || cleanEndpoint.includes('.inference.ai.azure.com');
if (isOpenAIStyleHost) {
const expectedPathSegment = '/openai/deployments/';
const expectedSuffix = '/chat/completions';
if (!cleanEndpoint.includes(expectedPathSegment)) {
status = 'URL 格式不规范 (OpenAI-Style)';
statusClass = 'invalid';
suggestion = `对于 *.openai.azure.com
主机,路径应包含部署名: ...${expectedPathSegment}<部署名>${expectedSuffix}
。`;
} else if (!cleanEndpoint.endsWith(expectedSuffix)) {
if (/\/openai\/deployments\/[^/]+$/.test(cleanEndpoint)) {
cleanEndpoint += expectedSuffix;
status = '路径已自动补全 (OpenAI-Style)';
statusClass = 'warning';
suggestion = `已自动添加 ${expectedSuffix}
。部署名应在路径中。`;
} else {
status = 'URL 路径不完整 (OpenAI-Style)';
statusClass = 'invalid';
suggestion = `路径应以 ${expectedSuffix}
结尾,并包含部署名。`;
}
} else {
status = '格式有效 (OpenAI-Style Azure)';
statusClass = 'valid';
suggestion = 'URL 格式符合 OpenAI on Azure 部署要求。模型 ID (部署名) 已在路径中。';
}
} else if (isAIServicesStyleHost) {
const expectedPath = '/models/chat/completions';
const partialPath = '/models/chat';
if (cleanEndpoint.endsWith(expectedPath)) {
status = '格式有效 (AI Services-Style Azure)';
statusClass = 'valid';
suggestion = 'URL 格式符合 Azure AI Services 模型部署。模型 ID 在请求体中指定。';
} else if (cleanEndpoint.endsWith(partialPath)) {
cleanEndpoint += '/completions';
status = '路径已自动补全 (AI Services-Style)';
statusClass = 'warning';
suggestion = `已自动补全为 ${expectedPath}
。`;
} else if (!cleanEndpoint.includes('/models/')) {
cleanEndpoint += expectedPath;
status = '路径已自动添加 (AI Services-Style)';
statusClass = 'warning';
suggestion = `已自动添加标准路径 ${expectedPath}
。`;
}
else {
status = 'URL 格式不规范 (AI Services-Style)';
statusClass = 'invalid';
suggestion = `对于 *.services.ai.azure.com
或 *.inference.ai.azure.com
主机, 路径通常是 ${expectedPath}
。`;
}
} else {
status = 'Azure URL 主机格式未知';
statusClass = 'invalid';
suggestion = `请确保 Endpoint 指向 *.openai.azure.com
, *.services.ai.azure.com
, 或 *.inference.ai.azure.com
。`;
}
finalUrl = `${cleanEndpoint}?${originalUrlParams.toString()}`;
break;
}
default:
finalUrl = endpoint;
status = '未知提供商';
statusClass = 'warning';
}
} catch (e) {
finalUrl = endpoint;
status = 'URL 解析失败';
statusClass = 'invalid';
suggestion = `无法解析输入的 Endpoint: ${e.message}`;
}
}
} else if (provider !== 'default') {
status = '请输入 API 地址';
statusClass = 'invalid';
}
urlDisplayElement.textContent = finalUrl;
statusElement.textContent = status;
statusElement.className = `status ${statusClass}`;
suggestionElement.innerHTML = suggestion;
};
inputElements['ai-provider'].addEventListener('change', updateUrlPreview);
inputElements['ai-endpoint'].addEventListener('input', updateUrlPreview);
inputElements['ai-key'].addEventListener('input', updateUrlPreview);
inputElements['ai-model'].addEventListener('input', updateUrlPreview);
inputElements['ai-azure-apiversion'].addEventListener('input', updateUrlPreview);
inputElements['ai-disable-correction'].addEventListener('change', updateUrlPreview);
inputElements['stt-enabled'].addEventListener('change', updateFieldVisibility);
inputElements['stt-provider'].addEventListener('change', updateSttFieldsForProvider);
updateFieldVisibility();
updateUrlPreview();
updateSttFieldsForProvider();
}
const WavEncoderWorker = `
self.onmessage = function(e) {
const { channels, sampleRate, length } = e.data;
const numOfChan = channels.length;
const bufferLength = length * numOfChan * 2 + 44;
const buffer = new ArrayBuffer(bufferLength);
const view = new DataView(buffer);
let pos = 0;
function setUint16(data) {
view.setUint16(pos, data, true);
pos += 2;
}
function setUint32(data) {
view.setUint32(pos, data, true);
pos += 4;
}
setUint32(0x46464952);
setUint32(bufferLength - 8);
setUint32(0x45564157);
setUint32(0x20746d66);
setUint32(16);
setUint16(1);
setUint16(numOfChan);
setUint32(sampleRate);
setUint32(sampleRate * 2 * numOfChan);
setUint16(numOfChan * 2);
setUint16(16);
setUint32(0x61746164);
setUint32(bufferLength - pos - 4);
let offset = 0;
while (pos < bufferLength) {
for (let i = 0; i < numOfChan; i++) {
let sample = Math.max(-1, Math.min(1, channels[i][offset]));
sample = (0.5 + sample < 0 ? sample * 32768 : sample * 32767) | 0;
view.setInt16(pos, sample, true);
pos += 2;
}
offset++;
}
self.postMessage(new Blob([view], { type: 'audio/wav' }));
};
`;
const updateChecker = {
API_URL: 'https://api.zygame1314.site/check/scripts',
SCRIPT_NAME: '小雅答答答',
CURRENT_VERSION: GM_info.script.version,
async check() {
console.log(`[更新检查] 当前版本: ${this.CURRENT_VERSION},正在请求版本列表...`);
try {
const response = await fetch(this.API_URL);
if (!response.ok) {
console.error('[更新检查] 请求版本API失败:', response.statusText);
return;
}
const scriptsData = await response.json();
if (!Array.isArray(scriptsData)) {
console.error('[更新检查] API返回数据格式不正确,期望一个数组,但收到了:', scriptsData);
return;
}
const targetScript = scriptsData.find(s => s.name === this.SCRIPT_NAME);
if (!targetScript) {
console.warn(`[更新检查] 在API列表中未找到脚本: ${this.SCRIPT_NAME}`);
return;
}
console.log(`[更新检查] 最新版本: ${targetScript.version}`);
if (this.isNewerVersion(targetScript.version, this.CURRENT_VERSION)) {
console.log('[更新检查] 发现新版本!准备推送更新通知。');
this.showUpdateNotification(targetScript);
} else {
console.log('[更新检查] 当前已是最新版本。');
}
} catch (error) {
console.error('[更新检查] 发生错误:', error);
}
},
isNewerVersion(newVersion, oldVersion) {
const newParts = newVersion.split('.').map(Number);
const oldParts = oldVersion.split('.').map(Number);
for (let i = 0; i < Math.max(newParts.length, oldParts.length); i++) {
const newPart = newParts[i] || 0;
const oldPart = oldParts[i] || 0;
if (newPart > oldPart) return true;
if (newPart < oldPart) return false;
}
return false;
},
showUpdateNotification(scriptInfo) {
showNotification(
`发现新版本 v${scriptInfo.version}!点击立即更新。`,
{
type: 'success',
duration: 0,
keywords: ['新版本', `v${scriptInfo.version}`, '更新'],
animation: 'scale'
}
);
setTimeout(() => {
const container = document.getElementById('notification-container');
if (container && container.lastChild) {
const notificationElement = container.lastChild;
notificationElement.style.cursor = 'pointer';
notificationElement.onclick = () => {
window.open(scriptInfo.downloadUrl, '_blank');
notificationElement.innerHTML = '正在跳转至更新页面...';
setTimeout(() => {
if (container.contains(notificationElement)) {
container.removeChild(notificationElement);
}
}, 2000);
};
}
}, 100);
},
init() {
setTimeout(() => this.check(), 10000);
setInterval(() => this.check(), 4 * 60 * 60 * 1000);
}
};
updateChecker.init();
})();