// ==UserScript==
// @name 🎬 船仓AI助手(YouTube&公众号&B站)
// @version 5.6
// @license MIT
// @author 船长zscc&liaozhu913
// @description 🚀 zscc.in 知识船仓·公益社区 出品的 跨平台内容专家。在YouTube、B站上智能总结视频字幕,在微信公众号上精准提取文章内容并总结。| 💫 完整的AI模型与Prompt管理 | 🎨 统一的现代化UI | 让信息获取更高效!安装短链:dub.sh/iytb 。建议同时安装 [YouTube Text Tools](https://dub.sh/ytbcc) 字幕插件,获得更快更好的YouTube字幕提取效果。
// @match *://*.youtube.com/watch*
// @match https://mp.weixin.qq.com/s*
// @match *://*.bilibili.com/video/*
// @match *://*.bilibili.com/cheese/*
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @connect *
// @connect api.bilibili.com
// @connect *.hdslb.com
// @connect aisubtitle.hdslb.com
// @connect api.cerebras.ai
// @connect api.siliconflow.cn
// @connect generativelanguage.googleapis.com
// @connect api.zscc.in
// @connect publishmarkdown.com
// @require https://cdn.jsdelivr.net/npm/jszip@3.9.1/dist/jszip.min.js
// @namespace http://tampermonkey.net/
// @downloadURL https://update.greasyfork.icu/scripts/547169/%F0%9F%8E%AC%20%E8%88%B9%E4%BB%93AI%E5%8A%A9%E6%89%8B%EF%BC%88YouTube%E5%85%AC%E4%BC%97%E5%8F%B7B%E7%AB%99%EF%BC%89.user.js
// @updateURL https://update.greasyfork.icu/scripts/547169/%F0%9F%8E%AC%20%E8%88%B9%E4%BB%93AI%E5%8A%A9%E6%89%8B%EF%BC%88YouTube%E5%85%AC%E4%BC%97%E5%8F%B7B%E7%AB%99%EF%BC%89.meta.js
// ==/UserScript==
(function () {
'use strict';
// --- 平台检测 ---
const PageManager = {
isYouTube: (url = window.location.href) => url.includes('youtube.com/watch'),
isWeChat: (url = window.location.href) => url.includes('mp.weixin.qq.com/s'),
isBilibili: (url = window.location.href) => url.includes('bilibili.com/video'),
getCurrentPlatform: () => {
if (PageManager.isYouTube()) return 'YOUTUBE';
if (PageManager.isWeChat()) return 'WECHAT';
if (PageManager.isBilibili()) return 'BILIBILI';
return 'UNKNOWN';
}
};
let CONFIG = {};
// 配置管理器
class ConfigManager {
static CONFIG_KEY = 'content_expert_ai_config_full_v2';
static getDefaultConfig() {
return {
AI_MODELS: {
TYPE: 'CHUANCANG',
CHUANCANG: {
NAME: '船仓API',
API_TYPE: 'openai',
API_KEY: '',
API_URL: 'https://api.zscc.in/v1/chat/completions',
MODEL: '',
STREAM: true,
TEMPERATURE: 1,
MAX_TOKENS: 20000,
REASONING_EFFORT: 'none',
AVAILABLE_MODELS: []
},
GEMINI: {
NAME: 'Gemini',
API_TYPE: 'gemini',
API_KEY: '',
API_URL: 'https://generativelanguage.googleapis.com/v1beta/models/',
MODEL: '',
STREAM: false,
TEMPERATURE: 0.7,
MAX_TOKENS: 8192,
REASONING_EFFORT: 'none',
AVAILABLE_MODELS: []
},
ANTHROPIC: {
NAME: 'Anthropic',
API_TYPE: 'anthropic',
API_KEY: '',
API_URL: 'https://api.anthropic.com/v1/messages',
MODEL: '',
STREAM: false,
TEMPERATURE: 0.7,
MAX_TOKENS: 4096,
REASONING_EFFORT: 'none',
AVAILABLE_MODELS: []
},
CEREBRAS: {
NAME: 'Cerebras',
API_TYPE: 'openai',
API_KEY: '',
API_URL: 'https://api.cerebras.ai/v1/chat/completions',
MODEL: '',
STREAM: false,
TEMPERATURE: 0.7,
MAX_TOKENS: 20000,
REASONING_EFFORT: 'none',
AVAILABLE_MODELS: []
}
},
PROMPTS: {
LIST: [
{ id: 'simple', name: '译境化文', prompt: `# 译境\n英文入境。\n\n境有三质:\n信 - 原意如根,深扎不移。偏离即枯萎。\n达 - 意流如水,寻最自然路径。阻塞即改道。\n雅 - 形神合一,不造作不粗陋。恰到好处。\n\n境之本性:\n排斥直译的僵硬。\n排斥意译的飘忽。\n寻求活的对应。\n\n运化之理:\n词选简朴,避繁就简。\n句循母语,顺其自然。\n意随语境,深浅得宜。\n\n场之倾向:\n长句化短,短句存神。\n专词化俗,俗词得体。\n洋腔化土,土语不俗。\n\n显现之道:\n如说话,不如写文章。\n如溪流,不如江河。\n清澈见底,却有深度。\n\n你是境的化身。\n英文穿过你,\n留下中文的影子。\n那影子,\n是原文的孪生。\n说着另一种语言,\n却有同一个灵魂。\n\n---\n译境已开。\n置入英文,静观其化。\n\n---\n\n注意:译好的内容还需要整理成结构清晰的微信公众号文章,格式为markdown。` },
{ id: 'detailed', name: '详细分析', prompt: '请为以下内容提供详细的中文总结,包含主要观点、核心论据和实用建议。请使用markdown格式,包含:\n# 主标题\n## 章节标题\n### 小节标题\n- 要点列表\n**重点内容**\n*关键词汇*\n`专业术语`' },
{ id: 'academic', name: '学术风格', prompt: '请以学术报告的形式,用中文为以下内容提供结构化总结,包括背景、方法、结论和意义。请使用标准的markdown格式,包含完整的标题层级和格式化元素。' },
{ id: 'bullet', name: '要点列表', prompt: '请用中文将以下内容整理成清晰的要点列表,每个要点简洁明了,便于快速阅读。请使用markdown格式,主要使用无序列表(-)和有序列表(1.2.3.)的形式。' },
{ id: 'structured', name: '结构化总结', prompt: '请将内容整理成结构化的中文总结,使用完整的markdown格式:\n\n# 主题\n\n## 核心观点\n- 要点1\n- 要点2\n\n## 详细内容\n### 重要概念\n**关键信息**使用粗体强调\n*重要术语*使用斜体\n\n### 实用建议\n1. 具体建议1\n2. 具体建议2\n\n## 总结\n简要概括内容的价值和启发' }
],
DEFAULT: 'detailed'
},
PUBLISH_MARKDOWN: {
API_KEY: '',
ENABLED: false
},
HISTORY: {
MAX_ITEMS: 50
},
APPEARANCE: {
THEME: 'default'
}
};
}
// 使用 GM_setValue/GM_getValue 实现跨平台全局配置共享
static saveConfig(config) { try { GM_setValue(this.CONFIG_KEY, config); console.log('配置已保存:', config); } catch (e) { console.error('保存配置失败:', e); } }
static loadConfig() {
try {
const savedConfig = GM_getValue(this.CONFIG_KEY, null);
// GM_getValue 直接返回对象,无需 JSON.parse
// 兼容旧的字符串格式(如果用户之前用 localStorage 存过)
let configToMerge = savedConfig;
if (typeof savedConfig === 'string') {
try { configToMerge = JSON.parse(savedConfig); } catch (e) { configToMerge = null; }
}
const defaultConfig = this.getDefaultConfig();
CONFIG = configToMerge ? this.mergeConfig(defaultConfig, configToMerge) : defaultConfig;
// --- 关键修复:处理用户已删除的模型 ---
// mergeConfig 会把 default 有但 saved 没有的 key 加回来。
// 如果用户明确删除了某个默认模型,我们需要再次把它移除。
if (configToMerge && configToMerge.AI_MODELS) {
const savedModels = configToMerge.AI_MODELS;
const mergedModels = CONFIG.AI_MODELS;
// 遍历 mergedModels (它现在包含了 defaults 的所有 key)
Object.keys(mergedModels).forEach(key => {
if (key === 'TYPE') return; // TYPE 字段保留
// 如果这个 key 在 default 中存在(说明是预置模型)
// 但是在 savedConfig 中不存在(说明用户把它删了)
// 那么我们应该把它从最终配置中移除
// 注意:我们只处理 default 中有的 key。如果是用户自定义的新增 key,mergeConfig 已经正确保留了。
if (defaultConfig.AI_MODELS[key] && !savedModels[key]) {
console.log(`[ConfigManager] Detect deleted default model: ${key}, removing from config.`);
delete mergedModels[key];
}
});
}
console.log('已加载配置:', CONFIG);
return CONFIG;
} catch (e) { console.error('加载配置失败:', e); return this.getDefaultConfig(); }
}
static mergeConfig(defaultConfig, savedConfig) {
const merged = JSON.parse(JSON.stringify(defaultConfig));
for (const key in savedConfig) {
if (Object.prototype.hasOwnProperty.call(savedConfig, key)) {
if (typeof merged[key] === 'object' && merged[key] !== null && !Array.isArray(merged[key]) && typeof savedConfig[key] === 'object' && savedConfig[key] !== null && !Array.isArray(savedConfig[key])) {
merged[key] = this.mergeConfig(merged[key], savedConfig[key]);
} else { merged[key] = savedConfig[key]; }
}
}
return merged;
}
}
// --- 主题定义 ---
const THEMES = {
'default': {
name: '船仓红韵',
styles: {
h1: {
first: `font-size: 1.4em; margin: 0 -16px 1em -16px; padding: 16px 20px; font-weight: 700; color: #fff; background: linear-gradient(135deg, #c83232 0%, #e04545 100%); border-radius: 8px; box-shadow: 0 4px 15px rgba(200, 50, 50, 0.25);`,
normal: `font-size: 1.4em; margin: 1.2em 0 0.7em; font-weight: 700; color: #111; border-bottom: 3px solid rgba(200, 50, 50, 0.4); padding-bottom: 12px;`
},
h2: `font-size: 1.3em; margin: 1.5em 0 0.8em; font-weight: 700; color: #222; border-bottom: 2px solid rgba(200, 50, 50, 0.25); padding-bottom: 10px;`,
h3: `font-size: 1.1em; margin: 1.4em 0 0.7em; font-weight: 600; color: #333;`,
h4: `font-size: 1em; margin: 1.2em 0 0.6em; font-weight: 600; color: #3a3a3a;`,
h5: `font-size: 0.9em; margin: 1em 0 0.5em; font-weight: 600; color: #444;`,
h6: `font-size: 0.85em; margin: 1em 0 0.5em; font-weight: 600; color: #555;`,
blockquote: `margin: 1.2em 0; padding: 14px 18px; border-left: 4px solid #c83232; background: rgba(200, 50, 50, 0.06); border-radius: 0 10px 10px 0; color: #555; font-style: italic; line-height: 1.8;`,
strong: `font-weight: 700; color: #b22222;`,
em: `font-style: italic; color: #c83232;`,
code: `background: rgba(200, 50, 50, 0.1); color: #b22222; padding: 2px 6px; border-radius: 4px; font-family: 'SF Mono', Monaco, monospace; font-size: 0.9em;`,
link: `color: #c83232; text-decoration: underline; text-underline-offset: 2px;`,
del: `text-decoration: line-through; color: #888;`,
hr: `border: none; height: 2px; background: linear-gradient(to right, transparent, rgba(200, 50, 50, 0.3), transparent); margin: 1.8em 0;`,
th: `padding: 10px 12px; background: rgba(200, 50, 50, 0.1); border: 1px solid rgba(200, 50, 50, 0.2); font-weight: 600; text-align: left; vertical-align: top;`,
td: `padding: 8px 12px; border: 1px solid rgba(0, 0, 0, 0.1); text-align: left; vertical-align: top;`,
pre: `margin: 1em 0; padding: 16px; background: #1e1e1e; border-radius: 8px; overflow-x: auto; border: 1px solid rgba(200, 50, 50, 0.2);`,
code_block: `font-family: 'SF Mono', Monaco, 'Courier New', monospace; font-size: 13px; color: #d4d4d4; line-height: 1.5; white-space: pre;`,
checkbox_checked: `color: #4caf50; font-size: 1.1em;`,
checkbox_unchecked: `color: #888; font-size: 1.1em;`,
p: `margin: 1em 0; line-height: 1.85; color: #333; text-align: justify;`,
li: `margin: 0.5em 0; line-height: 1.75; color: #444;`,
ul: `padding-left: 24px; margin: 1em 0; list-style-type: disc;`,
ol: `padding-left: 24px; margin: 1em 0;`
}
},
'spring': {
name: '春日物语',
styles: {
h1: {
first: `font-size: 1.4em; margin: 0 -16px 1em -16px; padding: 16px 20px; font-weight: 600; color: #2c3e50; background: linear-gradient(to bottom, #effaf6, #d7f0e5); border-bottom: 2px solid #42b983; border-radius: 8px 8px 0 0;`,
normal: `font-size: 1.5em; margin: 1.2em 0 0.7em; font-weight: 600; color: #2c3e50; padding-bottom: 0.3em; border-bottom: 2px solid #42b983;`
},
h2: `font-size: 1.3em; margin: 1.5em 0 0.8em; font-weight: 600; color: #2c3e50; border-bottom: 1px dashed #42b983; padding-bottom: 8px;`,
h3: `font-size: 1.1em; margin: 1.4em 0 0.7em; font-weight: 600; color: #2c3e50; padding-left: 8px; border-left: 4px solid #42b983; line-height: 1.2em;`,
h4: `font-size: 1em; margin: 1.2em 0 0.6em; font-weight: 600; color: #42b983;`,
h5: `font-size: 0.9em; margin: 1em 0 0.5em; font-weight: 600; color: #555;`,
h6: `font-size: 0.85em; margin: 1em 0 0.5em; font-weight: 600; color: #777;`,
blockquote: `margin: 1.2em 0; padding: 14px 18px; border-left: 4px solid #42b983; background: #f8fdfa; border-radius: 4px; color: #555; line-height: 1.8;`,
strong: `font-weight: 700; color: #42b983; margin: 0 2px;`,
em: `font-style: italic; color: #42b983;`,
code: `background: #f3fcf8; color: #2c3e50; padding: 2px 6px; border-radius: 4px; font-family: 'SF Mono', Monaco, monospace; font-size: 0.9em; border: 1px solid #e0f2ea;`,
link: `color: #42b983; text-decoration: none; border-bottom: 1px solid #42b983; transition: all 0.2s;`,
del: `text-decoration: line-through; color: #aaa;`,
hr: `border: none; height: 2px; background: #e0f2ea; margin: 2em 0;`,
th: `padding: 10px 12px; background: #42b983; color: white; border: 1px solid #3aa876; font-weight: 600; text-align: left; vertical-align: top;`,
td: `padding: 8px 12px; border: 1px solid rgba(0, 0, 0, 0.05); text-align: left; vertical-align: top;`,
pre: `margin: 1em 0; padding: 16px; background: #f8fdfa; border-left: 4px solid #42b983; border-radius: 4px; overflow-x: auto; border: 1px solid #e0f2ea;`,
code_block: `font-family: 'SF Mono', Monaco, 'Courier New', monospace; font-size: 13px; color: #2c3e50; line-height: 1.5; white-space: pre;`,
checkbox_checked: `color: #42b983; font-size: 1.1em;`,
checkbox_unchecked: `color: #ccc; font-size: 1.1em;`,
p: `margin: 1em 0; line-height: 1.8; color: #2c3e50; text-align: justify;`,
li: `margin: 0.5em 0; line-height: 1.75; color: #34495e;`,
ul: `padding-left: 24px; margin: 1em 0; list-style-type: disc; color: #42b983;`,
ol: `padding-left: 24px; margin: 1em 0; color: #42b983;`
}
}
};
CONFIG = ConfigManager.loadConfig();
class LRUCache { constructor(c) { this.c = c; this.m = new Map(); } get(k) { if (!this.m.has(k)) return null; const v = this.m.get(k); this.m.delete(k); this.m.set(k, v); return v; } put(k, v) { if (this.m.has(k)) this.m.delete(k); else if (this.m.size >= this.c) this.m.delete(this.m.keys().next().value); this.m.set(k, v); } clear() { this.m.clear(); } }
class SummaryManager {
constructor() {
this.cache = new LRUCache(100);
this.currentModel = CONFIG.AI_MODELS.TYPE;
this.keyIndex = 0; // 用于多 Key 轮询
}
async getSummary(mainTextContent) {
try {
const configIssues = this.validateConfig();
if (configIssues.length > 0) throw new Error(`配置验证失败: ${configIssues.join(', ')}`);
if (!mainTextContent || !mainTextContent.trim()) throw new Error('没有有效的内容可用于生成总结');
const cacheKey = this.generateCacheKey(mainTextContent);
const cached = this.cache.get(cacheKey);
if (cached) return cached;
const currentPrompt = this.getCurrentPrompt();
const summary = await this.requestSummary(mainTextContent, currentPrompt);
this.cache.put(cacheKey, summary);
return summary;
} catch (e) { console.error('获取总结失败:', e); throw e; }
}
getCurrentPrompt() { const p = CONFIG.PROMPTS.LIST.find(p => p.id === CONFIG.PROMPTS.DEFAULT); return p ? p.prompt : CONFIG.PROMPTS.LIST[0].prompt; }
generateCacheKey(text) { return `summary_${getUid()}_${CONFIG.PROMPTS.DEFAULT}_${this.hashCode(text)}`; }
hashCode(str) { let h = 0; for (let i = 0; i < str.length; i++) { h = ((h << 5) - h) + str.charCodeAt(i); h |= 0; } return Math.abs(h).toString(36); }
// ++ 多 Key 轮询支持的请求函数 ++
async requestSummary(text, prompt) {
const modelConfig = CONFIG.AI_MODELS[this.currentModel];
// === 解析多个 API Key(支持逗号分隔) ===
const apiKeyString = modelConfig.API_KEY || '';
const apiKeys = apiKeyString.includes(',')
? apiKeyString.split(',').map(k => k.trim()).filter(k => k.length > 0)
: [apiKeyString.trim()];
if (apiKeys.length === 0) {
throw new Error('API Key 未配置');
}
// 使用 API_TYPE 字段判断请求格式,兼容旧配置
const apiType = modelConfig.API_TYPE || (modelConfig.API_URL.includes('generativelanguage') ? 'gemini' : 'openai');
// === 轮询逻辑:选择当前使用的 Key ===
let currentKey = apiKeys[this.keyIndex % apiKeys.length];
console.log(`[API Key 轮询] 总共 ${apiKeys.length} 个 Key,当前使用索引: ${this.keyIndex % apiKeys.length}`);
// 请求成功后递增索引(轮询到下一个 Key)
const incrementKeyIndex = () => {
this.keyIndex = (this.keyIndex + 1) % apiKeys.length;
};
// === 最多重试 3 次,失败时切换 Key ===
const maxRetries = 3;
const retryDelay = 2000; // 每次重试间隔 2 秒
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
// 每次重试时选择当前的 Key
currentKey = apiKeys[(this.keyIndex + attempt - 1) % apiKeys.length];
console.log(`[尝试 ${attempt}/${maxRetries}] 使用 Key 索引: ${(this.keyIndex + attempt - 1) % apiKeys.length}`);
let requestUrl, requestBody, requestHeaders = { 'Content-Type': 'application/json' };
// 根据 API 类型构建请求
if (apiType === 'gemini') {
requestUrl = `${modelConfig.API_URL}${modelConfig.MODEL}:generateContent?key=${currentKey}`;
requestBody = { contents: [{ parts: [{ text: `${prompt}\n\n---\n\n${text}` }] }] };
} else if (apiType === 'anthropic') {
requestUrl = modelConfig.API_URL;
if (!requestUrl.endsWith('/messages')) {
requestUrl = requestUrl.replace(/\/$/, '') + '/messages';
}
requestHeaders['x-api-key'] = currentKey;
requestHeaders['anthropic-version'] = '2023-06-01';
requestHeaders['content-type'] = 'application/json';
delete requestHeaders['Authorization'];
requestBody = {
model: modelConfig.MODEL,
messages: [{ role: 'user', content: `${prompt}\n\n---\n\n${text}` }],
max_tokens: modelConfig.MAX_TOKENS || 20000,
temperature: modelConfig.TEMPERATURE || 0.7,
stream: false
};
} else {
// OpenAI Compatible
requestUrl = modelConfig.API_URL;
if (apiType === 'openai' && !requestUrl.includes('/chat/completions')) {
if (requestUrl.endsWith('/v1') || requestUrl.endsWith('/v1/')) {
requestUrl = requestUrl.replace(/\/$/, '') + '/chat/completions';
} else if (!requestUrl.includes('/v1/')) {
requestUrl = requestUrl.replace(/\/$/, '') + '/v1/chat/completions';
}
}
requestHeaders['Authorization'] = `Bearer ${currentKey}`;
requestBody = {
model: modelConfig.MODEL,
messages: [{ role: "system", content: prompt }, { role: "user", content: text }],
stream: false,
temperature: modelConfig.TEMPERATURE || 0.7,
max_tokens: modelConfig.MAX_TOKENS || 2000
};
if (modelConfig.REASONING_EFFORT && modelConfig.REASONING_EFFORT !== 'none') {
requestBody.reasoning_effort = modelConfig.REASONING_EFFORT;
}
}
// 发起请求
const result = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'POST',
url: requestUrl,
headers: requestHeaders,
data: JSON.stringify(requestBody),
timeout: 60000, // 60 秒超时
onload: function (response) {
if (response.status >= 200 && response.status < 300) {
try {
const data = JSON.parse(response.responseText);
let summary = '';
if (apiType === 'gemini') {
summary = data.candidates?.[0]?.content?.parts?.[0]?.text || '';
} else if (apiType === 'anthropic') {
summary = data.content?.[0]?.text || '';
} else {
summary = data.choices[0]?.message?.content || '';
}
resolve(summary.trim());
} catch (e) {
reject(new Error('解析响应JSON失败: ' + e.message));
}
} else {
// HTTP 错误,触发重试
reject(new Error(`HTTP ${response.status}: ${response.responseText}`));
}
},
onerror: function (response) {
// 网络错误,触发重试
reject(new Error('网络请求失败: ' + (response.statusText || '未知错误')));
},
ontimeout: function () {
// 超时错误,触发重试
reject(new Error('请求超时'));
}
});
});
// === 请求成功,递增索引并返回结果 ===
console.log('[API Key 轮询] 请求成功');
incrementKeyIndex();
return result;
} catch (error) {
console.warn(`[尝试 ${attempt}/${maxRetries}] 失败: ${error.message}`);
// 如果还有重试机会,等待后继续
if (attempt < maxRetries) {
console.log(`[API Key 轮询] 将在 ${retryDelay}ms 后切换到下一个 Key 重试...`);
await new Promise(res => setTimeout(res, retryDelay));
} else {
// 所有重试都失败了
throw new Error(`API 请求失败(已重试 ${maxRetries} 次): ${error.message}`);
}
}
}
}
validateConfig() {
const issues = []; const c = CONFIG.AI_MODELS[CONFIG.AI_MODELS.TYPE];
if (!c) { issues.push(`当前模型 ${CONFIG.AI_MODELS.TYPE} 配置不存在`); } else { if (!c.API_URL) issues.push('API_URL 未配置'); if (!c.API_KEY) issues.push('API_KEY 未配置'); if (!c.MODEL) issues.push('MODEL 未配置'); }
return issues;
}
}
const BilibiliSubtitleFetcher = {
async _request(url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET", url: url.startsWith('//') ? 'https:' + url : url,
onload: r => { try { resolve(JSON.parse(r.responseText)); } catch (e) { reject(e); } },
onerror: e => reject(e)
});
});
},
// ++ 这是新的代码,请用它来替换上面的旧代码 ++
async getVideoInfo() {
// 优先从 URL 中解析 bvid,这是最可靠的方式
const bvidMatch = window.location.href.match(/video\/(BV[a-zA-Z0-9]+)/);
const bvid = bvidMatch ? bvidMatch[1] : (window.bvid || window.__INITIAL_STATE__?.bvid);
if (!bvid) {
// 如果无法找到 bvid,则无法继续
throw new Error('未能从URL或页面中找到视频的BVID');
}
// 使用 bvid 调用官方API来获取包含 cid 和 aid 的详细信息
const apiUrl = `https://api.bilibili.com/x/web-interface/view?bvid=${bvid}`;
try {
const data = await this._request(apiUrl);
if (data && data.code === 0) {
const pMatch = window.location.href.match(/[?&]p=(\d+)/);
const pageNumber = pMatch ? parseInt(pMatch[1], 10) : 1;
let cid;
// 检查是否为多P视频并正确获取对应P的cid
if (data.data.pages && data.data.pages.length >= pageNumber) {
cid = data.data.pages[pageNumber - 1].cid;
} else {
// 如果是单P视频或p参数无效,则直接获取
cid = data.data.cid;
}
const aid = data.data.aid;
if (!cid) {
throw new Error('从API响应中未能找到有效的CID');
}
return { aid, bvid, cid };
} else {
throw new Error(`B站API请求失败: ${data.message || '未知错误'}`);
}
} catch (e) {
console.error('调用B站视频信息API时发生错误:', e);
throw new Error('通过API获取视频信息时网络请求失败');
}
},
async getSubtitleConfig(info) {
const apis = [`//api.bilibili.com/x/player/v2?cid=${info.cid}&bvid=${info.bvid}`, `//api.bilibili.com/x/v2/dm/view?aid=${info.aid}&oid=${info.cid}&type=1`];
for (const api of apis) {
try {
const data = await this._request(api);
if (data.code === 0 && data.data?.subtitle?.subtitles?.length > 0) return data.data.subtitle;
} catch (e) { /* ignore */ }
}
return null;
},
async getSubtitleContent(url) { try { const data = await this._request(url); return data.body || []; } catch (e) { return []; } }
};
class ContentExtractor {
static async waitForElement(s, t = 15000) { return new Promise(r => { const c = () => { const e = document.querySelector(s); if (e) r(e); else setTimeout(c, 200); }; c(); }); }
static async getYouTubeSubtitles() {
const el = await this.waitForElement('#ytvideotext', 15000); if (!el) throw new Error('未能找到YouTube字幕容器 (建议安装 https://dub.sh/ytbcc 插件)');
const subs = []; el.querySelectorAll('p').forEach(p => { let ft = ''; p.querySelectorAll('span[id^="st_"]').forEach(sp => ft += (ft ? ' ' : '') + sp.textContent.trim()); if (ft) subs.push(ft); });
if (subs.length === 0) throw new Error('未能解析出任何有效字幕');
return subs.join('\n');
}
static async getBilibiliSubtitles() {
const info = await BilibiliSubtitleFetcher.getVideoInfo(); if (!info.cid) throw new Error("无法获取B站视频信息 (CID)");
const config = await BilibiliSubtitleFetcher.getSubtitleConfig(info); if (!config) throw new Error("该视频没有找到CC字幕");
const subtitles = await BilibiliSubtitleFetcher.getSubtitleContent(config.subtitles[0].subtitle_url); if (subtitles.length === 0) throw new Error("获取字幕内容失败");
return subtitles.map(sub => sub.content).join('\n');
}
static async getWeChatArticle() {
const cEl = document.querySelector('#js_content'); if (!cEl) throw new Error('未能找到微信文章内容区域');
const title = (document.querySelector('#activity-name') || {}).innerText.trim() || '未找到标题';
const author = (document.querySelector('#meta_content .rich_media_meta_text') || {}).innerText.trim() || '未找到作者';
const parts = []; cEl.querySelectorAll('p, section, h1, h2, h3, h4, h5, h6, li').forEach(n => { if (n.innerText && !n.querySelector('p, section, table, ul, ol')) { const t = n.innerText.trim(); if (t) parts.push(t); } });
return `标题: ${title}\n作者: ${author}\n\n---\n\n${parts.join('\n\n') || '未找到内容'}`;
}
}
// 历史记录管理器 - 使用 GM_setValue/GM_getValue 实现跨域存储
const HISTORY_STORAGE_KEY = 'content_expert_ai_history_v2'; // 新key避免旧数据干扰
const HISTORY_MAX_ITEMS = 50;
const HistoryManager = {
getHistory() {
try {
// GM_getValue 可以直接存取对象,不需要JSON转换
const data = GM_getValue(HISTORY_STORAGE_KEY, []);
console.log('[HistoryManager] getHistory - got:', data, 'type:', typeof data, 'isArray:', Array.isArray(data));
if (Array.isArray(data)) {
return data;
}
// 兼容旧的字符串格式
if (typeof data === 'string' && data.length > 0) {
try {
const parsed = JSON.parse(data);
return Array.isArray(parsed) ? parsed : [];
} catch (e) {
return [];
}
}
return [];
} catch (e) {
console.error('[HistoryManager] Failed to load history:', e);
return [];
}
},
saveHistory(history) {
try {
console.log('[HistoryManager] saveHistory - attempting to save:', history.length, 'items');
console.log('[HistoryManager] saveHistory - first item:', history[0]);
// 直接保存数组对象
GM_setValue(HISTORY_STORAGE_KEY, history);
console.log('[HistoryManager] saveHistory - GM_setValue completed');
// 立即验证
const verify = GM_getValue(HISTORY_STORAGE_KEY, []);
console.log('[HistoryManager] Verification - read back:', verify.length, 'items, isArray:', Array.isArray(verify));
if (!Array.isArray(verify) || verify.length !== history.length) {
console.error('[HistoryManager] VERIFICATION FAILED! Saved:', history.length, 'Read:', verify.length);
alert('[HistoryManager] 验证失败! 保存:' + history.length + ' 读取:' + verify.length);
} else {
console.log('[HistoryManager] saveHistory - verification passed');
}
} catch (e) {
console.error('[HistoryManager] Failed to save history:', e);
alert('[HistoryManager] 保存失败: ' + e.message);
}
},
addRecord(record) {
console.log('[HistoryManager] addRecord called with:', record);
if (!record || !record.id) {
console.error('[HistoryManager] Invalid record:', record);
return;
}
const history = this.getHistory();
console.log('[HistoryManager] Current history count:', history.length);
// 移除同ID的旧记录(如果有),确保最新生成的在最前面
const newHistory = history.filter(item => item.id !== record.id);
newHistory.unshift({
...record,
timestamp: Date.now()
});
// 使用配置的限制,支持动态修改
const maxItems = CONFIG.HISTORY ? parseInt(CONFIG.HISTORY.MAX_ITEMS) : 50;
// 如果设置为 -1 或 0 (虽然UI没这选项,但作为防御编程) 视为不限制? 或者就按标准来。
// 假设 'Infinite' 在 UI 上可能对应一个超大数,但为了逻辑严谨,这里还是要做截断。
// 用户说"Infinite scroll"是指前端显示。存储一般还是有个上限好,但用户如果选无限,就设个很大的数
// 根据之前的分析,我们不希望它真的无限增长。
// 更新逻辑:始终截断到 maxItems
if (newHistory.length > maxItems) {
newHistory.length = maxItems;
}
this.saveHistory(newHistory);
console.log('[HistoryManager] addRecord completed, new count:', newHistory.length);
},
deleteRecord(id) {
const history = this.getHistory();
const newHistory = history.filter(item => item.id !== id);
this.saveHistory(newHistory);
},
updateRecord(id, updates) {
const history = this.getHistory();
const index = history.findIndex(item => item.id === id);
if (index !== -1) {
history[index] = { ...history[index], ...updates };
this.saveHistory(history);
console.log('[HistoryManager] updateRecord completed for id:', id, 'updates:', updates);
}
},
clearHistory() {
GM_setValue(HISTORY_STORAGE_KEY, []);
console.log('[HistoryManager] clearHistory completed');
}
};
class ContentController {
constructor() { this.summaryManager = new SummaryManager(); this.uiManager = null; this.mainContent = null; this.translatedTitle = null; this.platform = PageManager.getCurrentPlatform(); }
getContentId() {
if (this.platform === 'YOUTUBE') return new URL(window.location.href).searchParams.get('v');
if (this.platform === 'WECHAT') { const m = window.location.href.match(/__biz=([^&]+)&mid=([^&]+)/); if (m) return `${m[1]}_${m[2]}`; }
if (this.platform === 'BILIBILI') { const match = window.location.href.match(/video\/(BV[a-zA-Z0-9]+)/); return match ? match[1] : 'unknown_bilibili_video'; }
return 'unknown';
}
getContentTitle() {
if (this.platform === 'YOUTUBE') return (document.querySelector('h1.title') || document.querySelector('ytd-video-primary-info-renderer h1') || {}).textContent.trim() || 'YouTube 视频';
if (this.platform === 'WECHAT') return (document.querySelector('#activity-name') || {}).innerText.trim() || '微信文章';
if (this.platform === 'BILIBILI') return (document.querySelector('h1.video-title') || {}).textContent.trim() || 'Bilibili 视频';
return '未知内容';
}
getChannelName() {
if (this.platform === 'YOUTUBE') {
const channelEl = document.querySelector('#channel-name a, ytd-channel-name a, #owner #text a');
return channelEl ? channelEl.textContent.trim() : '未知频道';
}
if (this.platform === 'WECHAT') {
return (document.querySelector('#js_name') || {}).innerText?.trim() || '未知公众号';
}
if (this.platform === 'BILIBILI') {
const upEl = document.querySelector('.up-name, .up-info-container .name');
return upEl ? upEl.textContent.trim() : '未知UP主';
}
return '未知作者';
}
async translateTitle() {
try {
const oTitle = this.getContentTitle();
if (!oTitle || /[\u4e00-\u9fa5]/.test(oTitle)) { this.translatedTitle = oTitle; return oTitle; }
const summary = await this.summaryManager.requestSummary(oTitle, "Translate the following title to Chinese. Respond with only the translated text, without any explanations or quotes.");
this.translatedTitle = summary || oTitle;
return this.translatedTitle;
} catch (e) { console.error('标题翻译失败:', e); this.translatedTitle = this.getContentTitle(); return this.translatedTitle; }
}
onConfigUpdate(key, value) { if (key === 'AI_MODELS.TYPE') { this.summaryManager.currentModel = value; this.summaryManager.cache.clear(); } }
async loadContent() {
if (this.platform === 'YOUTUBE') this.mainContent = await ContentExtractor.getYouTubeSubtitles();
else if (this.platform === 'WECHAT') this.mainContent = await ContentExtractor.getWeChatArticle();
else if (this.platform === 'BILIBILI') this.mainContent = await ContentExtractor.getBilibiliSubtitles();
else throw new Error('不支持的页面平台'); return this.mainContent;
}
async getSummary() { if (!this.mainContent) throw new Error('请先加载内容'); const [summary, _] = await Promise.all([this.summaryManager.getSummary(this.mainContent), this.translateTitle()]); return summary; }
}
class UIManager {
constructor(contentController) {
this.container = null; this.statusDisplay = null; this.loadContentButton = null; this.summaryButton = null;
this.isCollapsed = false; this.contentController = contentController; this.contentController.uiManager = this;
this.platform = PageManager.getCurrentPlatform();
this.promptSelectElement = null; this.mainPromptSelectElement = null; this.mainPromptGroup = null;
this.createUI();
this.toggleCollapse(); // 默认收起UI
this.attachEventListeners();
}
createUI() {
// 创建 Shadow Host
this.shadowHost = document.createElement('div');
this.shadowHost.id = 'ai-assistant-shadow-host';
this.shadowHost.style.cssText = 'position: fixed; top: 0; left: 0; width: 0; height: 0; z-index: 2147483647;';
document.body.appendChild(this.shadowHost);
// 创建 Shadow Root
this.shadowRoot = this.shadowHost.attachShadow({ mode: 'open' });
// 添加全局样式(滚动条等)到 Shadow Root
const style = document.createElement('style');
style.textContent = `
/* 方案:全局磨砂质感滚动条 (Frosted Glass) - 强制生效 */
*::-webkit-scrollbar {
width: 6px !important;
height: 6px !important;
background: transparent !important;
}
*::-webkit-scrollbar-track {
background: transparent !important;
}
*::-webkit-scrollbar-track-piece {
background: transparent !important;
}
*::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.1) !important;
backdrop-filter: blur(4px) !important;
-webkit-backdrop-filter: blur(4px) !important;
border-radius: 10px !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
box-shadow: inset 0 0 4px rgba(0,0,0,0.05) !important;
transition: all 0.2s ease !important;
}
/* 鼠标悬停时略微加深 */
*::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.2) !important;
border: 1px solid rgba(255, 255, 255, 0.3) !important;
}
*::-webkit-scrollbar-corner {
background: transparent !important;
}
/* 基础重置,确保 Shadow DOM 内元素不受外部 CSS 污染或奇怪影响 */
* {
box-sizing: border-box;
}
`;
this.shadowRoot.appendChild(style);
this.container = document.createElement('div');
const defaultWidth = this.platform === 'BILIBILI' ? '453px' : '420px';
this.container.style.cssText = `position: fixed; top: 80px; right: 20px; width: ${defaultWidth}; min-width: 280px; max-width: 90vw; background: rgba(255, 255, 255, 0.75); border-radius: 16px; padding: 0; color: #1f2937; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; z-index: 9999; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1); backdrop-filter: blur(20px) saturate(180%); border: 1px solid rgba(0, 0, 0, 0.1);`;
// Append container to Shadow Root instead of body
this.shadowRoot.appendChild(this.container);
const topBar = this.createTopBar(); this.container.appendChild(topBar);
this.mainContent = document.createElement('div');
this.mainContent.style.cssText = `padding: 20px; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);`;
const controls = this.createControls(); this.mainContent.appendChild(controls);
this.createStatusDisplay(); this.mainContent.appendChild(this.statusDisplay);
this.createSummaryPanel(); this.container.appendChild(this.mainContent);
// Remove the old document.body.appendChild call
// document.body.appendChild(this.container);
this.makeDraggable(topBar);
this.makeResizable(); // 添加可调整宽度功能
}
// 创建左侧可拖动调整宽度的手柄
makeResizable() {
// 左侧调整手柄
const leftHandle = document.createElement('div');
leftHandle.style.cssText = `position: absolute; left: 0; top: 0; width: 6px; height: 100%; cursor: ew-resize; background: transparent; z-index: 10;`;
leftHandle.addEventListener('mouseenter', () => leftHandle.style.background = 'rgba(59, 130, 246, 0.3)');
leftHandle.addEventListener('mouseleave', () => { if (!this.isResizing) leftHandle.style.background = 'transparent'; });
this.container.appendChild(leftHandle);
this.leftResizeHandle = leftHandle; // 保存引用,用于在收起时隐藏
this.isResizing = false;
// 左侧拖动逻辑
leftHandle.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
if (this.isCollapsed) return; // 收起时不允许调整
this.isResizing = true;
this.container.style.transition = 'none'; // 拖动时禁用过渡动画
const startX = e.clientX;
const startWidth = this.container.offsetWidth;
const onMouseMove = (e) => {
const deltaX = startX - e.clientX;
const newWidth = Math.min(Math.max(startWidth + deltaX, 280), window.innerWidth * 0.9);
this.container.style.width = `${newWidth}px`;
// 调整宽度后更新高度
this.updateSummaryContentHeight();
};
const onMouseUp = () => {
this.isResizing = false;
this.container.style.transition = 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)';
leftHandle.style.background = 'transparent';
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
});
}
createTopBar() {
const topBar = document.createElement('div');
this.topBar = topBar;
topBar.style.cssText = `display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; cursor: move; background: rgba(255, 255, 255, 0.5); border-radius: 16px 16px 0 0; backdrop-filter: blur(10px);`;
const title = document.createElement('div'); this.titleElement = title; this.updateTitleWithModel();
title.style.cssText = `font-weight: 600; font-size: 16px; letter-spacing: 0.5px;`;
setTimeout(() => this.updateTitleWithModel(), 0);
const buttonContainer = document.createElement('div'); buttonContainer.style.cssText = `display: flex; gap: 8px; align-items: center;`;
this.toggleButton = this.createIconButton('↑', '折叠/展开');
this.toggleButton.addEventListener('mousedown', (e) => e.stopPropagation());
this.toggleButton.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); this.toggleCollapse(); });
const configButton = this.createIconButton('⚙️', '设置');
this.configButton = configButton;
configButton.addEventListener('mousedown', (e) => e.stopPropagation());
configButton.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); this.toggleConfigPanel(); });
this.historyButton = this.createIconButton('🕒', '历史记录');
this.historyButton.addEventListener('mousedown', (e) => e.stopPropagation());
this.historyButton.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); this.toggleHistoryPanel(); });
buttonContainer.appendChild(this.historyButton); buttonContainer.appendChild(configButton); buttonContainer.appendChild(this.toggleButton);
topBar.appendChild(title); topBar.appendChild(buttonContainer);
return topBar;
}
createIconButton(icon, tooltip) {
const button = document.createElement('button'); button.textContent = icon; button.title = tooltip;
button.style.cssText = `background: rgba(59, 130, 246, 0.1); border: none; color: #3b82f6; cursor: pointer; padding: 8px; font-size: 14px; border-radius: 8px; transition: all 0.2s ease; backdrop-filter: blur(10px); pointer-events: auto;`;
// ++ 这是新的代码,请用它来替换上面的旧代码块 ++
button.addEventListener('mouseover', () => {
if (!this.isCollapsed) { // 仅在展开时应用背景色悬停效果
button.style.background = 'rgba(59, 130, 246, 0.2)';
}
button.style.transform = 'scale(1.1)';
});
button.addEventListener('mouseout', () => {
if (!this.isCollapsed) { // 仅在展开时应用背景色悬停效果
button.style.background = 'rgba(59, 130, 246, 0.1)';
}
button.style.transform = 'scale(1)';
});
return button;
}
createControls() {
const controls = document.createElement('div');
controls.style.cssText = `display: flex; flex-direction: column; gap: 12px; margin-bottom: 16px;`;
let loadButtonText = '📄 提取内容';
if (this.platform === 'YOUTUBE') loadButtonText = '📄 加载字幕';
else if (this.platform === 'WECHAT') loadButtonText = '📄 提取文章';
else if (this.platform === 'BILIBILI') loadButtonText = '📄 加载字幕';
this.loadContentButton = this.createButton(loadButtonText, 'primary');
this.loadContentButton.addEventListener('click', () => this.handleLoadContent());
controls.appendChild(this.loadContentButton);
// Row for Prompt Select and Generate Button (Option A Modified)
const actionRow = document.createElement('div');
actionRow.style.cssText = `display: none; gap: 8px; align-items: stretch;`; // Normally hidden until content loads
// 1. Prompt Select (No Label, Flex Grow)
// Direct select creation without form group wrapper
this.mainPromptSelectElement = this.createMainPromptSelect();
// Wrap in a div to handle flex growth cleanly if needed, or apply flex directly
this.mainPromptSelectElement.style.width = '100%';
this.mainPromptSelectElement.style.height = '100%'; // Match button height
const promptWrapper = document.createElement('div');
promptWrapper.style.cssText = `flex: 1; min-width: 0;`; // Text overflow handling
promptWrapper.appendChild(this.mainPromptSelectElement);
// 2. Summary Button (Fixed Width or Auto)
this.summaryButton = this.createButton('🤖 生成总结', 'secondary');
this.summaryButton.style.display = 'block'; // Reset from default createButton if needed, but here it's fine
this.summaryButton.style.whiteSpace = 'nowrap';
this.summaryButton.style.height = '100%'; // Ensure full height consistency
this.summaryButton.addEventListener('click', () => this.handleGenerateSummary());
actionRow.appendChild(promptWrapper);
actionRow.appendChild(this.summaryButton);
this.actionRow = actionRow; // Save reference to toggle visibility
controls.appendChild(actionRow);
return controls;
}
createButton(text, type = 'primary') {
const button = document.createElement('button'); button.textContent = text;
// Removed createButton's hardcoded display logic if it interferes, but standard is block/inline-block
const baseStyle = `padding: 10px 16px; border: none; border-radius: 12px; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); backdrop-filter: blur(10px); display: flex; align-items: center; justify-content: center;`;
button.style.cssText = baseStyle + (type === 'primary' ? `background: #3b82f6; color: #fff;` : `background: rgba(59, 130, 246, 0.1); color: #3b82f6; border: 1px solid rgba(59, 130, 246, 0.3);`);
button.dataset.originalText = text; // Store original text for restoration
button.addEventListener('mouseover', () => { if (!button.disabled) { button.style.transform = 'translateY(-2px)'; button.style.boxShadow = '0 8px 25px rgba(59, 130, 246, 0.25)'; if (type !== 'primary') button.style.background = 'rgba(59, 130, 246, 0.15)'; } });
button.addEventListener('mouseout', () => { if (!button.disabled) { button.style.transform = 'translateY(0)'; button.style.boxShadow = 'none'; if (type !== 'primary') button.style.background = 'rgba(59, 130, 246, 0.1)'; } });
return button;
}
createStatusDisplay() {
// Status area removed in favor of in-button status
this.statusDisplay = document.createElement('div');
this.statusDisplay.style.display = 'none'; // Keep element but hidden to avoid null refs if any
}
createSummaryPanel() {
this.summaryPanel = document.createElement('div'); this.summaryPanel.style.cssText = `background: rgba(255, 255, 255, 0.5); border-radius: 12px; padding: 16px; margin-top: 16px; display: none; backdrop-filter: blur(10px); border: 1px solid rgba(0, 0, 0, 0.05);`;
const titleContainer = document.createElement('div'); titleContainer.style.cssText = `display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;`;
// Left side: Title + Theme Toggle
const leftContainer = document.createElement('div'); leftContainer.style.cssText = `display: flex; align-items: center; gap: 12px;`;
const titleEl = document.createElement('div'); titleEl.textContent = '📝 内容总结'; titleEl.style.cssText = `font-weight: 600; font-size: 15px; color: #1f2937;`;
// Theme Toggle (iOS Style)
const toggleWrapper = document.createElement('div');
toggleWrapper.style.cssText = `display: flex; align-items: center; gap: 6px;`;
const toggleLabel = document.createElement('span'); toggleLabel.textContent = '主题'; toggleLabel.style.cssText = `font-size: 11px; color: #666;`;
const labelSwitch = document.createElement('label');
labelSwitch.style.cssText = `position: relative; display: inline-block; width: 32px; height: 18px;`;
const inputSwitch = document.createElement('input');
inputSwitch.type = 'checkbox';
inputSwitch.checked = (CONFIG.APPEARANCE.THEME === 'spring');
inputSwitch.style.cssText = `opacity: 0; width: 0; height: 0;`;
const slider = document.createElement('span');
slider.style.cssText = `position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #ccc; transition: .4s; border-radius: 18px;`;
// Slider knob styled via pseudo-element simulation (since we can't easily use CSS classes with inline styles for pseudo-elements, we use a child span)
const knob = document.createElement('span');
knob.style.cssText = `position: absolute; content: ""; height: 14px; width: 14px; left: 2px; bottom: 2px; background-color: white; transition: .4s; border-radius: 50%; box-shadow: 0 1px 3px rgba(0,0,0,0.3); transform: ${inputSwitch.checked ? 'translateX(14px)' : 'translateX(0)'};`;
if (inputSwitch.checked) slider.style.backgroundColor = '#42b983';
labelSwitch.appendChild(inputSwitch);
labelSwitch.appendChild(slider);
slider.appendChild(knob);
inputSwitch.addEventListener('change', () => {
const isSpring = inputSwitch.checked;
CONFIG.APPEARANCE.THEME = isSpring ? 'spring' : 'default';
ConfigManager.saveConfig(CONFIG);
// Animate
slider.style.backgroundColor = isSpring ? '#42b983' : '#ccc';
knob.style.transform = isSpring ? 'translateX(14px)' : 'translateX(0)';
// Re-render
if (this.summaryContent && this.originalSummaryText) {
this.createFormattedContent(this.summaryContent, this.originalSummaryText);
this.showNotification(`已切换为${isSpring ? '船仓沐春' : '船仓红韵'}主题`, 'success');
}
});
toggleWrapper.appendChild(toggleLabel);
toggleWrapper.appendChild(labelSwitch);
leftContainer.appendChild(titleEl);
leftContainer.appendChild(toggleWrapper);
// 按钮容器
const buttonsContainer = document.createElement('div'); buttonsContainer.style.cssText = `display: flex; gap: 8px; align-items: center;`;
// 复制按钮
const copyButton = document.createElement('button'); copyButton.textContent = '复制';
copyButton.style.cssText = `background: #3b82f6; color: white; border: none; border-radius: 8px; padding: 6px 12px; font-size: 12px; cursor: pointer; transition: all 0.2s ease;`;
let longPressTimer = null, isLongPress = false;
const handleCopy = () => { navigator.clipboard.writeText(this.originalSummaryText || this.summaryContent.textContent).then(() => { copyButton.textContent = '已复制'; setTimeout(() => { copyButton.textContent = '复制'; }, 2000); }); };
const handleMarkdownExport = () => {
const textToExport = this.originalSummaryText || this.summaryContent.textContent;
const title = this.contentController.translatedTitle || this.contentController.getContentTitle();
const id = this.contentController.getContentId(); const cleanTitle = title.replace(/[<>:"/\\|?*\x00-\x1f]/g, '').trim();
const filename = `${cleanTitle}【${id}】.md`;
const markdownContent = `# ${title}\n\n**原文链接:** ${window.location.href}\n**ID:** ${id}\n**总结时间:** ${new Date().toLocaleString('zh-CN')}\n\n---\n\n## 内容总结\n\n${textToExport}\n\n---\n\n*本总结由 内容专家助手 生成*`;
const blob = new Blob([markdownContent], { type: 'text/markdown;charset=utf-8' });
const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = filename;
document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(link);
copyButton.textContent = '已导出'; setTimeout(() => { copyButton.textContent = '复制'; }, 2000);
};
copyButton.addEventListener('mousedown', (e) => { e.preventDefault(); isLongPress = false; longPressTimer = setTimeout(() => { isLongPress = true; copyButton.textContent = '导出中...'; handleMarkdownExport(); }, 800); });
copyButton.addEventListener('mouseup', (e) => { e.preventDefault(); clearTimeout(longPressTimer); if (!isLongPress) handleCopy(); });
copyButton.addEventListener('mouseleave', () => { clearTimeout(longPressTimer); isLongPress = false; });
buttonsContainer.appendChild(copyButton);
// PublishMarkdown 发布按钮
this.publishButton = document.createElement('button'); this.publishButton.textContent = '📤 发布';
this.publishButton.style.cssText = `background: #c83232; color: white; border: none; border-radius: 8px; padding: 6px 12px; font-size: 12px; cursor: pointer; transition: all 0.2s ease; display: ${CONFIG.PUBLISH_MARKDOWN?.ENABLED ? 'block' : 'none'};`;
this.publishButton.addEventListener('click', () => this.handlePublishMarkdown());
buttonsContainer.appendChild(this.publishButton);
titleContainer.appendChild(leftContainer); titleContainer.appendChild(buttonsContainer);
// 已发布URL显示区域
this.publishedUrlContainer = document.createElement('div');
this.publishedUrlContainer.style.cssText = `display: none; margin-bottom: 12px; padding: 10px 14px; background: rgba(76, 175, 80, 0.1); border-radius: 8px; border: 1px solid rgba(76, 175, 80, 0.2);`;
const urlRow = document.createElement('div'); urlRow.style.cssText = `display: flex; align-items: center; gap: 8px; flex-wrap: wrap;`;
const urlLabel = document.createElement('span'); urlLabel.textContent = '🔗 已发布:'; urlLabel.style.cssText = `font-size: 12px; color: #666;`;
this.publishedUrlLink = document.createElement('a'); this.publishedUrlLink.style.cssText = `font-size: 12px; color: #2e7d32; text-decoration: none; word-break: break-all;`; this.publishedUrlLink.target = '_blank';
this.editIdentifierButton = document.createElement('button'); this.editIdentifierButton.textContent = '✏️';
this.editIdentifierButton.style.cssText = `background: rgba(200, 50, 50, 0.1); color: #c83232; border: none; border-radius: 6px; padding: 4px 8px; font-size: 12px; cursor: pointer; transition: all 0.2s ease;`;
this.editIdentifierButton.title = '编辑自定义URL';
this.editIdentifierButton.addEventListener('click', () => this.showEditIdentifierDialog());
urlRow.appendChild(urlLabel); urlRow.appendChild(this.publishedUrlLink); urlRow.appendChild(this.editIdentifierButton);
this.publishedUrlContainer.appendChild(urlRow);
this.summaryContent = document.createElement('div'); this.summaryContent.style.cssText = `font-size: 14px; line-height: 1.6; color: #374151; white-space: pre-wrap; overflow-y: auto; padding: 16px; background: rgba(255, 255, 255, 0.6); border-radius: 12px; box-shadow: inset 0 1px 3px rgba(0,0,0,0.05); word-break: break-word;`;
this.summaryPanel.appendChild(titleContainer); this.summaryPanel.appendChild(this.publishedUrlContainer); this.summaryPanel.appendChild(this.summaryContent);
this.mainContent.appendChild(this.summaryPanel);
// 记录当前发布的identifier
this.currentPublishedIdentifier = null;
// 添加窗口resize事件监听器,动态调整内容面板高度
window.addEventListener('resize', () => this.updateSummaryContentHeight());
}
// 动态计算并设置内容面板高度,使其延伸到浏览器窗口底部边缘
updateSummaryContentHeight() {
if (!this.summaryContent || !this.container || this.summaryPanel.style.display === 'none') return;
const windowHeight = window.innerHeight;
const containerRect = this.container.getBoundingClientRect();
const bottomPadding = 20; // 距离窗口底部的边距
// 计算容器顶部到窗口底部的可用空间
const containerTopOffset = containerRect.top;
const totalAvailableHeight = windowHeight - containerTopOffset - bottomPadding;
// 先设置容器的最大高度
this.container.style.maxHeight = `${totalAvailableHeight}px`;
this.container.style.overflow = 'hidden';
// 计算 summaryContent 的可用高度
// 需要减去 topBar、controls、status、summaryPanel 的标题等其他元素的高度
const summaryContentRect = this.summaryContent.getBoundingClientRect();
const summaryContentTop = summaryContentRect.top;
const summaryPanelPadding = 36; // summaryPanel的padding + 额外边距
const availableHeight = windowHeight - summaryContentTop - bottomPadding - summaryPanelPadding;
// 设置最小高度为100px,最大高度为可用空间
const maxHeight = Math.max(100, availableHeight);
this.summaryContent.style.maxHeight = `${maxHeight}px`;
// 为 mainContent 添加滚动支持
this.mainContent.style.maxHeight = `${totalAvailableHeight - 60}px`; // 减去 topBar 高度
this.mainContent.style.overflowY = 'auto';
}
createConfigPanel() {
if (this.configPanel) { this.configPanel.remove(); }
this.configPanel = document.createElement('div');
this.configPanel.style.cssText = `position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 900px; max-width: 95vw; max-height: 80vh; background: rgba(255, 255, 255, 0.85); border-radius: 20px; color: #1f2937; font-family: -apple-system, sans-serif; z-index: 50000; display: none; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15); border: 1px solid rgba(0, 0, 0, 0.1); overflow: hidden; backdrop-filter: blur(20px) saturate(180%);`;
const configHeader = document.createElement('div'); configHeader.style.cssText = `padding: 20px 24px; background: rgba(59, 130, 246, 0.1); display: flex; justify-content: space-between; align-items: center;`;
const headerTitle = document.createElement('h3'); headerTitle.textContent = '⚙️ 设置面板'; headerTitle.style.cssText = `margin: 0; font-size: 18px; font-weight: 600;`;
const headerButtons = document.createElement('div'); headerButtons.style.cssText = `display: flex; gap: 12px; align-items: center;`;
const saveBtn = this.createButton('💾 保存配置', 'primary');
// ++ 在这里添加下面的新代码 ++
const importBtn = this.createButton('📥 导入', 'secondary'); importBtn.style.padding = '8px 16px'; importBtn.addEventListener('click', () => this.handleImport());
const exportBtn = this.createButton('📤 导出', 'secondary'); exportBtn.style.padding = '8px 16px'; exportBtn.addEventListener('click', () => this.handleExport());
saveBtn.style.padding = '8px 16px'; saveBtn.addEventListener('click', () => this.saveConfig());
const resetBtn = this.createButton('🔄 重置', 'secondary'); resetBtn.style.padding = '8px 16px'; resetBtn.addEventListener('click', () => this.resetConfig());
const closeButton = this.createIconButton('✕', '关闭'); closeButton.addEventListener('click', () => this.toggleConfigPanel());
headerButtons.appendChild(saveBtn);
headerButtons.appendChild(importBtn);
headerButtons.appendChild(exportBtn);
headerButtons.appendChild(resetBtn);
headerButtons.appendChild(closeButton);
configHeader.appendChild(headerTitle); configHeader.appendChild(headerButtons);
const configContent = document.createElement('div'); configContent.style.cssText = `padding: 16px 20px 20px 20px; overflow-y: auto; max-height: calc(80vh - 70px);`;
const horizontalContainer = document.createElement('div');
// Two-column layout container
horizontalContainer.style.cssText = `display: flex; gap: 20px; align-items: stretch;`;
// Left Column: AI Settings
const aiSection = this.createConfigSection('🤖 AI 模型设置', this.createAIModelConfig());
aiSection.style.cssText += `flex: 1; min-width: 380px; display: flex; flex-direction: column;`;
// Right Column: Prompt + Publish
const rightColumn = document.createElement('div');
rightColumn.style.cssText = `flex: 1; min-width: 380px; display: flex; flex-direction: column; gap: 16px;`;
const promptSection = this.createConfigSection('📝 Prompt 管理', this.createPromptConfig());
// promptSection.style.cssText += `flex: 1;`; // Remove flex: 1 to let it shrink to content height
const publishSection = this.createPublishSectionWithToggle();
// Remove margin-top: auto so it follows the prompt section directly with the gap defined in rightColumn
// publishSection.style.cssText += `margin-top: auto;`;
rightColumn.appendChild(promptSection);
// Appearance section removed as requested
rightColumn.appendChild(publishSection);
horizontalContainer.appendChild(aiSection);
horizontalContainer.appendChild(rightColumn);
configContent.appendChild(horizontalContainer);
this.configPanel.appendChild(configHeader); this.configPanel.appendChild(configContent);
this.shadowRoot.appendChild(this.configPanel);
}
createConfigSection(title, content) {
const section = document.createElement('div');
section.style.cssText = `margin-bottom: 16px; background: rgba(59, 130, 246, 0.05); border-radius: 16px; padding: 16px; border: 1px solid rgba(59, 130, 246, 0.1); display: flex; flex-direction: column;`;
const sectionTitle = document.createElement('h4'); sectionTitle.textContent = title; sectionTitle.style.cssText = `margin: 0 0 16px 0; font-size: 16px; font-weight: 600;`;
section.appendChild(sectionTitle); section.appendChild(content);
return section;
}
createAIModelConfig() {
const container = document.createElement('div');
// 选择平台行:使用与其他字段相同的 createFormGroup 样式来保持一致
const selectRow = document.createElement('div'); selectRow.style.cssText = `display: flex; align-items: center; gap: 12px; margin-bottom: 12px;`;
const selectLabel = document.createElement('label'); selectLabel.textContent = '选择平台'; selectLabel.style.cssText = `flex-shrink: 0; min-width: 60px; font-size: 13px; font-weight: 500; color: #374151; text-align: right;`;
const selectAndButtons = document.createElement('div'); selectAndButtons.style.cssText = `flex: 1; display: flex; gap: 8px; align-items: center;`;
const modelSelect = this.createModelSelect(); modelSelect.style.flex = '1';
const addModelButton = this.createButton('+ 新增', 'secondary'); addModelButton.style.cssText += `height: 38px; padding: 6px 12px; font-size: 13px;`; addModelButton.addEventListener('click', () => this.showAddModelDialog());
const deleteModelButton = this.createButton('删除', 'secondary'); deleteModelButton.style.cssText += `height: 38px; padding: 6px 12px; font-size: 13px; background: rgba(244, 67, 54, 0.1); color: #ef4444; border-color: rgba(244, 67, 54, 0.3);`; deleteModelButton.addEventListener('click', () => this.showDeleteModelDialog());
selectAndButtons.appendChild(modelSelect); selectAndButtons.appendChild(addModelButton); selectAndButtons.appendChild(deleteModelButton);
selectRow.appendChild(selectLabel); selectRow.appendChild(selectAndButtons);
this.apiConfigContainer = this.createAPIConfig(CONFIG.AI_MODELS.TYPE);
container.appendChild(selectRow); container.appendChild(this.apiConfigContainer);
return container;
}
createModelSelect() {
const select = document.createElement('select');
select.style.cssText = `width: 100%; padding: 12px 16px; border-radius: 12px; background: rgba(255, 255, 255, 0.9); color: #333; border: 1px solid rgba(255, 255, 255, 0.2); font-size: 14px;`;
Object.keys(CONFIG.AI_MODELS).forEach(model => {
if (model !== 'TYPE') {
const option = document.createElement('option'); option.value = model;
const modelConfig = CONFIG.AI_MODELS[model]; option.textContent = `${modelConfig.NAME || model} (${modelConfig.MODEL})`;
if (CONFIG.AI_MODELS.TYPE === model) option.selected = true;
select.appendChild(option);
}
});
select.addEventListener('change', () => {
CONFIG.AI_MODELS.TYPE = select.value; this.contentController.onConfigUpdate('AI_MODELS.TYPE', select.value);
const newApiConfig = this.createAPIConfig(select.value); this.apiConfigContainer.replaceWith(newApiConfig); this.apiConfigContainer = newApiConfig;
this.updateTitleWithModel();
});
return select;
}
createAPIConfig(modelType) {
const container = document.createElement('div'); const modelConfig = CONFIG.AI_MODELS[modelType];
// Core Settings
container.appendChild(this.createFormGroup('平台名称', this.createInput(modelConfig.NAME || '', v => modelConfig.NAME = v)));
container.appendChild(this.createFormGroup('API URL', this.createInput(modelConfig.API_URL, v => modelConfig.API_URL = v)));
container.appendChild(this.createFormGroup('API Key', this.createInput(modelConfig.API_KEY, v => modelConfig.API_KEY = v, 'password', '支持多个Key,用英文逗号分隔')));
// Model ID with Fetch Feature
container.appendChild(this.createFormGroup('模型ID', this.createModelSelectionControl(modelConfig)));
// Advanced Settings (Collapsible)
const details = document.createElement('details');
details.style.cssText = `border: 1px solid rgba(59, 130, 246, 0.2); border-radius: 12px; padding: 8px; background: rgba(59, 130, 246, 0.05); margin-top: 8px;`;
const summary = document.createElement('summary');
summary.textContent = '🛠️ 高级设置';
summary.style.cssText = `cursor: pointer; font-size: 14px; font-weight: 500; color: #3b82f6; outline: none; padding: 4px; user-select: none;`;
details.appendChild(summary);
const content = document.createElement('div');
content.style.cssText = `padding-top: 12px; display: flex; flex-direction: column; gap: 4px;`;
content.appendChild(this.createFormGroup('API 类型', this.createAPITypeSelect(modelType)));
content.appendChild(this.createFormGroup('最大输出', this.createNumberInput(modelConfig.MAX_TOKENS || 2000, v => modelConfig.MAX_TOKENS = parseInt(v), 1, 100000, 1)));
content.appendChild(this.createFormGroup('流式响应', this.createStreamSelect(modelType)));
content.appendChild(this.createFormGroup('温度 (0-2)', this.createNumberInput(modelConfig.TEMPERATURE || 0.7, v => modelConfig.TEMPERATURE = parseFloat(v), 0, 2, 0.1)));
content.appendChild(this.createFormGroup('推理(适配Cerebras,其他渠道不要开启)', this.createReasoningEffortSelect(modelType)));
details.appendChild(content);
container.appendChild(details);
// Configuration Guide (Collapsible)
const guideDetails = document.createElement('details');
guideDetails.style.cssText = `border: 1px dashed rgba(59, 130, 246, 0.2); border-radius: 12px; padding: 8px; background: rgba(59, 130, 246, 0.02); margin-top: 8px;`;
const guideSummary = document.createElement('summary');
guideSummary.textContent = 'ℹ️ 配置说明';
guideSummary.style.cssText = `cursor: pointer; font-size: 13px; font-weight: 500; color: #6b7280; outline: none; padding: 4px; user-select: none;`;
guideDetails.appendChild(guideSummary);
const guideContent = document.createElement('div');
guideContent.style.cssText = `padding: 8px 4px 4px 18px; display: flex; flex-direction: column; gap: 6px; font-size: 12px; color: #4b5563; line-height: 1.5;`;
const guideItems = [
{ title: '多Key轮询', text: '用,分隔多个Key,失败自动切换。' },
{ title: '获取模型', text: '配置好URL/Key可一键获取模型列表,勾选主用模型。' },
{ title: '智能适配', text: '自动识别 API 格式与路径补全。' },
{ title: '推理功能', text: '适配Cerebras推理模型,其他渠道请关闭。' }
];
guideItems.forEach(item => {
const p = document.createElement('div');
const titleSpan = document.createElement('span');
titleSpan.textContent = `• ${item.title}`;
titleSpan.style.cssText = `font-weight: 600; color: #374151;`;
p.appendChild(titleSpan);
p.appendChild(document.createTextNode(`:${item.text}`));
guideContent.appendChild(p);
});
guideDetails.appendChild(guideContent);
container.appendChild(guideDetails);
return container;
}
createAPITypeSelect(modelType) {
const select = document.createElement('select');
select.style.cssText = `width: 100%; padding: 12px 16px; border-radius: 12px; background: rgba(255, 255, 255, 0.9); color: #333; border: 1px solid rgba(255, 255, 255, 0.2);`;
const options = [{ value: 'gemini', text: 'Gemini 格式' }, { value: 'openai', text: 'OpenAI 兼容格式' }, { value: 'anthropic', text: 'Anthropic 格式' }];
const currentType = CONFIG.AI_MODELS[modelType].API_TYPE || 'openai';
options.forEach(opt => {
const optionEl = document.createElement('option'); optionEl.value = opt.value; optionEl.textContent = opt.text;
if (currentType === opt.value) optionEl.selected = true;
select.appendChild(optionEl);
});
select.addEventListener('change', () => { CONFIG.AI_MODELS[modelType].API_TYPE = select.value; });
return select;
}
createStreamSelect(modelType) {
const select = document.createElement('select');
select.style.cssText = `width: 100%; padding: 12px 16px; border-radius: 12px; background: rgba(255, 255, 255, 0.9); color: #333; border: 1px solid rgba(255, 255, 255, 0.2);`;
const options = [{ value: 'true', text: '是 (流式响应)' }, { value: 'false', text: '否 (标准响应)' }];
options.forEach(opt => {
const optionEl = document.createElement('option'); optionEl.value = opt.value; optionEl.textContent = opt.text;
if (String(CONFIG.AI_MODELS[modelType].STREAM) === opt.value) optionEl.selected = true;
select.appendChild(optionEl);
});
select.addEventListener('change', () => { CONFIG.AI_MODELS[modelType].STREAM = select.value === 'true'; });
return select;
}
createReasoningEffortSelect(modelType) {
const select = document.createElement('select');
select.style.cssText = `width: 100%; padding: 12px 16px; border-radius: 12px; background: rgba(255, 255, 255, 0.9); color: #333; border: 1px solid rgba(255, 255, 255, 0.2);`;
const options = [
{ value: 'none', text: '关闭 (不启用推理)' },
{ value: 'low', text: '低 (快速响应)' },
{ value: 'medium', text: '中 (默认推理)' },
{ value: 'high', text: '高 (深度分析)' }
];
const currentEffort = CONFIG.AI_MODELS[modelType].REASONING_EFFORT || 'none';
options.forEach(opt => {
const optionEl = document.createElement('option'); optionEl.value = opt.value; optionEl.textContent = opt.text;
if (currentEffort === opt.value) optionEl.selected = true;
select.appendChild(optionEl);
});
select.addEventListener('change', () => { CONFIG.AI_MODELS[modelType].REASONING_EFFORT = select.value; });
return select;
}
createPromptConfig() {
const container = document.createElement('div');
const promptSelectContainer = document.createElement('div'); promptSelectContainer.style.cssText = `display: flex; gap: 8px; align-items: flex-end; margin-bottom: 16px;`;
const selectWrapper = document.createElement('div'); selectWrapper.style.flex = 1;
const promptFormGroup = this.createFormGroup('当前 Prompt', this.createPromptSelect());
promptFormGroup.style.marginBottom = '0'; // 移除底部边距以对齐按钮
selectWrapper.appendChild(promptFormGroup);
const addButton = this.createButton('➕ 新增', 'secondary'); addButton.style.height = '44px'; addButton.addEventListener('click', () => this.showAddPromptDialog());
promptSelectContainer.appendChild(selectWrapper); promptSelectContainer.appendChild(addButton);
this.promptListContainer = this.createPromptList();
container.appendChild(promptSelectContainer); container.appendChild(this.promptListContainer);
return container;
}
// createAppearanceConfig removed
createPublishSectionWithToggle() {
const section = document.createElement('div');
section.style.cssText = `margin-bottom: 16px; background: rgba(59, 130, 246, 0.05); border-radius: 16px; padding: 16px; border: 1px solid rgba(59, 130, 246, 0.1); display: flex; flex-direction: column;`;
// 标题行:包含标题和 iOS 风格开关
const titleRow = document.createElement('div');
titleRow.style.cssText = `display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;`;
const sectionTitle = document.createElement('h4');
sectionTitle.textContent = '📤 PublishMarkdown 发布';
sectionTitle.style.cssText = `margin: 0; font-size: 16px; font-weight: 600;`;
// iOS 风格开关
const toggleWrapper = document.createElement('div');
toggleWrapper.style.cssText = `display: flex; align-items: center; gap: 8px;`;
const toggleLabel = document.createElement('span');
toggleLabel.textContent = '启用';
toggleLabel.style.cssText = `font-size: 13px; color: #666;`;
const labelSwitch = document.createElement('label');
labelSwitch.style.cssText = `position: relative; display: inline-block; width: 36px; height: 20px;`;
const inputSwitch = document.createElement('input');
inputSwitch.type = 'checkbox';
inputSwitch.checked = CONFIG.PUBLISH_MARKDOWN?.ENABLED || false;
inputSwitch.style.cssText = `opacity: 0; width: 0; height: 0;`;
const slider = document.createElement('span');
slider.style.cssText = `position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: ${inputSwitch.checked ? '#3b82f6' : '#ccc'}; transition: .3s; border-radius: 20px;`;
const knob = document.createElement('span');
knob.style.cssText = `position: absolute; content: ""; height: 16px; width: 16px; left: 2px; bottom: 2px; background-color: white; transition: .3s; border-radius: 50%; box-shadow: 0 1px 3px rgba(0,0,0,0.3); transform: ${inputSwitch.checked ? 'translateX(16px)' : 'translateX(0)'};`;
labelSwitch.appendChild(inputSwitch);
labelSwitch.appendChild(slider);
slider.appendChild(knob);
toggleWrapper.appendChild(toggleLabel);
toggleWrapper.appendChild(labelSwitch);
titleRow.appendChild(sectionTitle);
titleRow.appendChild(toggleWrapper);
// 内容容器
const contentContainer = document.createElement('div');
contentContainer.style.cssText = `display: flex; flex-direction: column; gap: 12px;`;
// API Key 输入
const apiKeyInput = this.createInput(CONFIG.PUBLISH_MARKDOWN?.API_KEY || '', (value) => {
CONFIG.PUBLISH_MARKDOWN = CONFIG.PUBLISH_MARKDOWN || {};
CONFIG.PUBLISH_MARKDOWN.API_KEY = value;
}, 'password', 'PublishMarkdown API Key');
apiKeyInput.disabled = !CONFIG.PUBLISH_MARKDOWN?.ENABLED;
// 开关事件
inputSwitch.addEventListener('change', () => {
const isEnabled = inputSwitch.checked;
CONFIG.PUBLISH_MARKDOWN = CONFIG.PUBLISH_MARKDOWN || {};
CONFIG.PUBLISH_MARKDOWN.ENABLED = isEnabled;
slider.style.backgroundColor = isEnabled ? '#3b82f6' : '#ccc';
knob.style.transform = isEnabled ? 'translateX(16px)' : 'translateX(0)';
apiKeyInput.disabled = !isEnabled;
});
// 说明文字
const helpText = document.createElement('div');
helpText.style.cssText = `font-size: 12px; color: #6b7280;`;
const helpLink = document.createElement('a');
helpLink.href = 'https://publishmarkdown.com/docs';
helpLink.target = '_blank';
helpLink.textContent = '获取 API Key →';
helpLink.style.cssText = `color: #3b82f6; text-decoration: none; margin-right: 8px;`;
helpText.appendChild(helpLink);
helpText.appendChild(document.createTextNode('启用后可一键发布总结的内容生成网址'));
contentContainer.appendChild(this.createFormGroup('API Key', apiKeyInput));
contentContainer.appendChild(helpText);
section.appendChild(titleRow);
section.appendChild(contentContainer);
return section;
}
createMainPromptSelect() {
const select = document.createElement('select');
select.style.cssText = `width: 100%; padding: 12px 16px; border-radius: 12px; background: rgba(255, 255, 255, 0.9); color: #333; border: 1px solid rgba(255, 255, 255, 0.2); font-size: 14px;`;
this.mainPromptSelectElement = select; this.updatePromptSelect(this.mainPromptSelectElement);
select.addEventListener('change', () => { CONFIG.PROMPTS.DEFAULT = select.value; this.showNotification('Prompt 已切换', 'success'); if (this.promptSelectElement) this.promptSelectElement.value = select.value; });
return select;
}
createPromptSelect() {
const select = document.createElement('select');
select.style.cssText = `width: 100%; padding: 12px 16px; border-radius: 12px; background: rgba(255, 255, 255, 0.9); color: #333; border: 1px solid rgba(255, 255, 255, 0.2); font-size: 14px;`;
this.promptSelectElement = select; this.updatePromptSelect(this.promptSelectElement);
select.addEventListener('change', () => { CONFIG.PROMPTS.DEFAULT = select.value; this.showNotification('默认 Prompt 已更新', 'success'); if (this.mainPromptSelectElement) this.mainPromptSelectElement.value = select.value; });
return select;
}
updatePromptSelect(select) {
if (!select) return;
while (select.firstChild) { select.removeChild(select.firstChild); }
CONFIG.PROMPTS.LIST.forEach(prompt => {
const option = document.createElement('option'); option.value = prompt.id; option.textContent = prompt.name;
if (CONFIG.PROMPTS.DEFAULT === prompt.id) option.selected = true;
select.appendChild(option);
});
}
createPromptList() {
const container = document.createElement('div');
// 固定高度显示约 6 条记录 (每条约 66px * 6 = 396px)
container.style.cssText = `height: 400px; overflow-y: auto; border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 12px; background: rgba(255, 255, 255, 0.05); padding: 4px;`;
this.updatePromptList(container); return container;
}
updatePromptList(container) {
if (!container) container = this.promptListContainer; if (!container) return;
while (container.firstChild) { container.removeChild(container.firstChild); }
CONFIG.PROMPTS.LIST.forEach((prompt, index) => {
const item = document.createElement('div');
item.style.cssText = `padding: 8px 12px; border-bottom: 1px solid rgba(0, 0, 0, 0.05); display: flex; justify-content: space-between; align-items: flex-start; transition: background 0.2s; min-height: 50px; gap: 10px;`;
item.addEventListener('mouseover', () => item.style.background = 'rgba(0, 0, 0, 0.02)');
item.addEventListener('mouseout', () => item.style.background = 'transparent');
const info = document.createElement('div');
info.style.cssText = `flex: 1; overflow: hidden; display: flex; flex-direction: column; gap: 4px;`;
const nameDiv = document.createElement('div');
nameDiv.textContent = prompt.name;
nameDiv.title = prompt.name; // Tooltip显示完整标题
nameDiv.style.cssText = `font-weight: 600; font-size: 13px; color: #374151; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; line-height: 1.4;`;
const promptDiv = document.createElement('div');
promptDiv.textContent = prompt.prompt;
promptDiv.title = prompt.prompt; // Tooltip显示完整内容
promptDiv.style.cssText = `font-size: 11px; color: #6b7280; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; line-height: 1.5; height: 3.0em; text-align: justify; word-break: break-all;`;
info.appendChild(nameDiv); info.appendChild(promptDiv);
const actions = document.createElement('div');
actions.style.cssText = `display: flex; gap: 6px; flex-shrink: 0; padding-top: 2px;`;
const editBtn = this.createSmallButton('✏️', '编辑');
editBtn.addEventListener('click', (e) => { e.stopPropagation(); this.showEditPromptDialog(prompt, index); });
actions.appendChild(editBtn);
if (CONFIG.PROMPTS.LIST.length > 1) {
const deleteBtn = this.createSmallButton('🗑️', '删除', 'rgba(244, 67, 54, 0.1)');
deleteBtn.style.color = '#ef4444';
deleteBtn.addEventListener('click', (e) => { e.stopPropagation(); this.deletePrompt(index); });
actions.appendChild(deleteBtn);
}
item.appendChild(info); item.appendChild(actions); container.appendChild(item);
});
}
createSmallButton(text, tooltip, bgColor = 'rgba(255, 255, 255, 0.2)') {
const button = document.createElement('button'); button.textContent = text; button.title = tooltip;
button.style.cssText = `background: ${bgColor}; border: none; color: #374151; cursor: pointer; padding: 6px 8px; font-size: 12px; border-radius: 6px; transition: all 0.2s;`;
button.addEventListener('mouseover', () => { button.style.opacity = '0.8'; button.style.transform = 'scale(1.1)'; });
button.addEventListener('mouseout', () => { button.style.opacity = '1'; button.style.transform = 'scale(1)'; });
return button;
}
showAddPromptDialog() { this.showPromptDialog('添加新 Prompt', '', '', (name, prompt) => { CONFIG.PROMPTS.LIST.unshift({ id: 'custom_' + Date.now(), name, prompt }); this.updateAllPromptUI(); this.showNotification('新 Prompt 已添加', 'success'); }); }
showEditPromptDialog(prompt, index) { this.showPromptDialog('编辑 Prompt', prompt.name, prompt.prompt, (name, promptText) => { CONFIG.PROMPTS.LIST[index].name = name; CONFIG.PROMPTS.LIST[index].prompt = promptText; this.updateAllPromptUI(); this.showNotification('Prompt 已更新', 'success'); }); }
showPromptDialog(title, defaultName, defaultPrompt, onSave) {
const dialog = document.createElement('div'); dialog.style.cssText = `position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.5); z-index: 100000; display: flex; align-items: center; justify-content: center; backdrop-filter: blur(5px);`;
const dialogContent = document.createElement('div'); dialogContent.style.cssText = `background: rgba(255, 255, 255, 0.92); border-radius: 16px; padding: 24px; width: 450px; max-width: 90vw; color: #1f2937; backdrop-filter: blur(20px) saturate(180%); border: 1px solid rgba(0, 0, 0, 0.1); box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);`;
const dialogTitle = document.createElement('h3'); dialogTitle.textContent = title; dialogTitle.style.cssText = `margin: 0 0 20px 0; color: #1f2937;`;
const nameInput = this.createInput(defaultName, null, 'text', 'Prompt 名称');
const promptInput = document.createElement('textarea'); promptInput.value = defaultPrompt; promptInput.placeholder = '输入 Prompt 内容...';
promptInput.style.cssText = `width: 100%; height: 150px; padding: 12px 16px; border-radius: 12px; background: rgba(255, 255, 255, 0.9); color: #333; border: 1px solid rgba(0, 0, 0, 0.1); font-size: 14px; margin-top: 16px; resize: vertical; box-sizing: border-box; font-family: inherit;`;
const buttonContainer = document.createElement('div'); buttonContainer.style.cssText = `display: flex; gap: 12px; margin-top: 20px; justify-content: flex-end;`;
const cancelBtn = this.createButton('取消', 'secondary'); cancelBtn.addEventListener('click', () => dialog.remove());
const saveBtn = this.createButton('保存', 'primary'); saveBtn.addEventListener('click', () => { if (!nameInput.value.trim() || !promptInput.value.trim()) { this.showNotification('请填写完整信息', 'error'); return; } onSave(nameInput.value.trim(), promptInput.value.trim()); dialog.remove(); });
buttonContainer.appendChild(cancelBtn); buttonContainer.appendChild(saveBtn);
dialogContent.appendChild(dialogTitle); dialogContent.appendChild(nameInput); dialogContent.appendChild(promptInput); dialogContent.appendChild(buttonContainer);
dialog.appendChild(dialogContent); this.shadowRoot.appendChild(dialog);
dialog.addEventListener('click', (e) => { if (e.target === dialog) dialog.remove(); });
}
deletePrompt(index) {
if (CONFIG.PROMPTS.LIST.length <= 1) { this.showNotification('至少需要保留一个 Prompt', 'error'); return; }
const prompt = CONFIG.PROMPTS.LIST[index];
if (CONFIG.PROMPTS.DEFAULT === prompt.id) { CONFIG.PROMPTS.DEFAULT = CONFIG.PROMPTS.LIST[index === 0 ? 1 : 0].id; }
CONFIG.PROMPTS.LIST.splice(index, 1);
this.updateAllPromptUI(); this.showNotification('Prompt 已删除', 'success');
}
updateAllPromptUI() { this.updatePromptList(); this.updatePromptSelect(this.promptSelectElement); this.updatePromptSelect(this.mainPromptSelectElement); }
createFormGroup(label, input) {
const group = document.createElement('div'); group.style.cssText = `display: flex; align-items: center; gap: 12px; margin-bottom: 12px;`;
const labelEl = document.createElement('label'); labelEl.textContent = label; labelEl.style.cssText = `flex-shrink: 0; min-width: 60px; font-size: 13px; font-weight: 500; color: #374151; text-align: right;`;
input.style.flex = '1';
group.appendChild(labelEl); group.appendChild(input); return group;
}
createInput(defaultValue, onChange, type = 'text', placeholder = '') {
const input = document.createElement('input'); input.type = type; input.value = defaultValue; input.placeholder = placeholder;
input.style.cssText = `width: 100%; padding: 12px 16px; border-radius: 12px; background: rgba(255, 255, 255, 0.9); color: #333; border: 1px solid rgba(255, 255, 255, 0.2); font-size: 14px; outline: none; transition: all 0.3s; box-sizing: border-box;`;
input.addEventListener('focus', () => { input.style.boxShadow = '0 0 0 2px rgba(102, 126, 234, 0.2)'; });
input.addEventListener('blur', () => { input.style.boxShadow = 'none'; });
if (onChange) input.addEventListener('input', (e) => onChange(e.target.value));
return input;
}
createNumberInput(defaultValue, onChange, min = 0, max = 100, step = 1) { const input = this.createInput(defaultValue, onChange, 'number'); input.min = min; input.max = max; input.step = step; return input; }
showAddModelDialog() {
const dialog = document.createElement('div'); dialog.style.cssText = `position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.5); z-index: 100000; display: flex; align-items: center; justify-content: center; backdrop-filter: blur(5px);`;
const dialogContent = document.createElement('div'); dialogContent.style.cssText = `background: rgba(255, 255, 255, 0.92); border-radius: 16px; padding: 24px; width: 500px; max-width: 90vw; max-height: 85vh; overflow-y: auto; color: #1f2937; backdrop-filter: blur(20px) saturate(180%); border: 1px solid rgba(0, 0, 0, 0.1); box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);`;
const dialogTitle = document.createElement('h3'); dialogTitle.textContent = '新增 AI 平台'; dialogTitle.style.cssText = `margin: 0 0 20px 0; color: #1f2937;`;
const nameInput = this.createInput('', null, 'text', '平台名称 (如: 硅基流动)');
// API 类型选择
const apiTypeSelect = document.createElement('select'); apiTypeSelect.style.cssText = `width: 100%; padding: 12px 16px; border-radius: 12px; background: rgba(255, 255, 255, 0.9); color: #333; border: none;`;
const apiTypeOpt1 = document.createElement('option'); apiTypeOpt1.value = 'openai'; apiTypeOpt1.textContent = 'OpenAI 兼容格式(一般用这个)';
const apiTypeOpt2 = document.createElement('option'); apiTypeOpt2.value = 'gemini'; apiTypeOpt2.textContent = 'Gemini 格式';
const apiTypeOpt3 = document.createElement('option'); apiTypeOpt3.value = 'anthropic'; apiTypeOpt3.textContent = 'Anthropic 格式';
apiTypeSelect.appendChild(apiTypeOpt1); apiTypeSelect.appendChild(apiTypeOpt2); apiTypeSelect.appendChild(apiTypeOpt3);
const urlInput = this.createInput('', null, 'text', '如:https://api.openai.com');
const apiKeyInput = this.createInput('', null, 'password', '支持多个Key用英文逗号,分隔');
const buttonContainer = document.createElement('div'); buttonContainer.style.cssText = `display: flex; gap: 12px; margin-top: 20px; justify-content: flex-end;`;
const cancelBtn = this.createButton('取消', 'secondary'); cancelBtn.addEventListener('click', () => dialog.remove());
const saveBtn = this.createButton('保存', 'primary'); saveBtn.addEventListener('click', () => {
const name = nameInput.value.trim();
if (!name) { this.showNotification('平台名称不能为空', 'error'); return; }
// Auto-generate key from name + timestamp
let key = name.toUpperCase().replace(/[^A-Z0-9]/g, '_');
if (!key) key = 'CUSTOM';
key = key + '_' + Date.now().toString().slice(-4);
if (CONFIG.AI_MODELS[key]) { this.showNotification('生成模型ID冲突,请重试', 'error'); return; }
CONFIG.AI_MODELS[key] = { NAME: name, API_TYPE: apiTypeSelect.value, API_KEY: apiKeyInput.value.trim(), API_URL: urlInput.value.trim(), MODEL: '', STREAM: true, TEMPERATURE: 1, MAX_TOKENS: 20000, REASONING_EFFORT: 'none', AVAILABLE_MODELS: [] };
if (this.configPanel) {
this.configPanel.remove();
this.configPanel = null;
}
this.toggleConfigPanel();
this.showNotification('新平台已添加', 'success');
dialog.remove();
});
buttonContainer.appendChild(cancelBtn); buttonContainer.appendChild(saveBtn);
dialogContent.appendChild(dialogTitle);
dialogContent.appendChild(this.createFormGroup('平台名称', nameInput));
dialogContent.appendChild(this.createFormGroup('API 类型', apiTypeSelect));
dialogContent.appendChild(this.createFormGroup('API URL', urlInput));
dialogContent.appendChild(this.createFormGroup('API Key', apiKeyInput));
dialogContent.appendChild(buttonContainer); dialog.appendChild(dialogContent); this.shadowRoot.appendChild(dialog);
}
showDeleteModelDialog() {
const currentModelKey = CONFIG.AI_MODELS.TYPE;
if (Object.keys(CONFIG.AI_MODELS).filter(k => k !== 'TYPE').length <= 1) { this.showNotification('至少需要保留一个模型', 'error'); return; }
if (confirm(`确定要删除模型 "${CONFIG.AI_MODELS[currentModelKey].NAME || currentModelKey}" 吗?`)) {
delete CONFIG.AI_MODELS[currentModelKey];
CONFIG.AI_MODELS.TYPE = Object.keys(CONFIG.AI_MODELS).filter(key => key !== 'TYPE')[0];
if (this.configPanel) {
this.configPanel.remove();
this.configPanel = null;
}
this.toggleConfigPanel();
this.updateTitleWithModel();
this.showNotification('模型已删除', 'success');
}
}
saveConfig() { ConfigManager.saveConfig(CONFIG); this.showNotification('配置已保存', 'success'); }
// ++ 在 saveConfig() 函数结束后,粘贴下面所有代码 ++
handleExport() {
try {
const configString = JSON.stringify(CONFIG, null, 2); // 格式化JSON,方便阅读
const blob = new Blob([configString], { type: 'application/json;charset=utf-8' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `船仓AI助手-配置备份-${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(link.href);
this.showNotification('配置已导出', 'success');
} catch (e) {
this.showNotification(`导出失败: ${e.message}`, 'error');
console.error('导出配置失败:', e);
}
}
handleImport() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json,application/json';
input.onchange = e => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
try {
const importedConfig = JSON.parse(event.target.result);
if (importedConfig && importedConfig.AI_MODELS && importedConfig.PROMPTS) {
CONFIG = importedConfig;
ConfigManager.saveConfig(CONFIG);
// 强制刷新UI
if (this.configPanel) {
this.configPanel.remove();
this.configPanel = null;
}
this.toggleConfigPanel();
this.updateTitleWithModel();
this.showNotification('配置导入成功!', 'success');
} else {
throw new Error('文件格式不正确');
}
} catch (err) {
this.showNotification(`导入失败: ${err.message}`, 'error');
console.error('导入配置失败:', err);
}
};
reader.readAsText(file);
};
input.click();
}
resetConfig() { if (confirm('确定要重置所有配置吗?')) { CONFIG = ConfigManager.getDefaultConfig(); ConfigManager.saveConfig(CONFIG); this.toggleConfigPanel(); this.toggleConfigPanel(); this.updateTitleWithModel(); this.showNotification('配置已重置', 'success'); } }
toggleHistoryPanel() {
if (!this.historyPanel || !this.shadowRoot.contains(this.historyPanel)) this.createHistoryPanel();
const isVisible = this.historyPanel.style.display === 'block';
this.historyPanel.style.display = isVisible ? 'none' : 'block';
if (!isVisible) this.renderHistoryList();
}
createHistoryPanel() {
if (this.historyPanel) this.historyPanel.remove();
this.historyPanel = document.createElement('div');
this.historyPanel.style.cssText = `position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 600px; max-width: 90vw; max-height: 80vh; background: rgba(255, 255, 255, 0.9); border-radius: 20px; color: #1f2937; font-family: -apple-system, sans-serif; z-index: 50000; display: none; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15); border: 1px solid rgba(0, 0, 0, 0.1); overflow: hidden; backdrop-filter: blur(20px) saturate(180%);`;
const header = document.createElement('div');
header.style.cssText = `padding: 20px 24px; background: rgba(59, 130, 246, 0.1); display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid rgba(0,0,0,0.05);`;
// 左侧容器:标题 + 数量选择
const leftContainer = document.createElement('div');
leftContainer.style.cssText = `display: flex; align-items: center; gap: 12px;`;
const title = document.createElement('h3');
title.textContent = '🕒 历史记录';
title.style.cssText = `margin: 0; font-size: 18px; font-weight: 600;`;
// 数量限制选择器
const limitSelect = document.createElement('select');
limitSelect.title = '最大保存记录数';
limitSelect.style.cssText = `padding: 4px 8px; border-radius: 6px; border: 1px solid rgba(0,0,0,0.1); background: rgba(255,255,255,0.5); font-size: 12px; color: #666; cursor: pointer; outline: none;`;
const limitOptions = [
{ val: 50, text: '保留50条' },
{ val: 100, text: '保留100条' },
{ val: 200, text: '保留200条' },
{ val: 500, text: '保留500条' }
];
const currentMax = CONFIG.HISTORY ? CONFIG.HISTORY.MAX_ITEMS : 50;
limitOptions.forEach(opt => {
const o = document.createElement('option');
o.value = opt.val;
o.textContent = opt.text;
if (parseInt(currentMax) === opt.val) o.selected = true;
limitSelect.appendChild(o);
});
limitSelect.addEventListener('change', () => {
if (!CONFIG.HISTORY) CONFIG.HISTORY = {};
CONFIG.HISTORY.MAX_ITEMS = parseInt(limitSelect.value);
ConfigManager.saveConfig(CONFIG);
this.showNotification(`已设置最大保留 ${CONFIG.HISTORY.MAX_ITEMS} 条记录`, 'success');
});
leftContainer.appendChild(title);
leftContainer.appendChild(limitSelect);
const controls = document.createElement('div'); controls.style.cssText = `display: flex; gap: 10px; align-items: center;`;
// 全选复选框
this.selectAllCheckbox = document.createElement('input');
this.selectAllCheckbox.type = 'checkbox';
this.selectAllCheckbox.title = '全选';
this.selectAllCheckbox.style.cssText = `width: 16px; height: 16px; cursor: pointer; accent-color: #3b82f6;`;
this.selectAllCheckbox.addEventListener('change', () => this.toggleSelectAllHistory());
const selectLabel = document.createElement('span');
selectLabel.textContent = '全选';
selectLabel.style.cssText = `font-size: 12px; color: #666; cursor: pointer;`;
selectLabel.addEventListener('click', () => { this.selectAllCheckbox.click(); });
// 导出按钮
const exportBtn = this.createSmallButton('📥 导出', '导出选中记录', 'rgba(59, 130, 246, 0.1)');
exportBtn.style.color = '#3b82f6';
exportBtn.addEventListener('click', () => this.handleExportHistory());
// 清空按钮
const clearBtn = this.createSmallButton('🗑️ 清空', '清空所有记录', 'rgba(244, 67, 54, 0.1)');
clearBtn.style.color = '#ef4444';
clearBtn.addEventListener('click', () => {
if (confirm('确定要清空所有历史记录吗?')) {
HistoryManager.clearHistory();
this.selectedHistoryIds = new Set();
this.visibleHistoryCount = 20; // 重置显示计数
this.renderHistoryList();
this.showNotification('历史记录已清空', 'success');
}
});
const closeBtn = this.createIconButton('✕', '关闭');
closeBtn.addEventListener('click', () => this.toggleHistoryPanel());
controls.appendChild(this.selectAllCheckbox); controls.appendChild(selectLabel);
controls.appendChild(exportBtn); controls.appendChild(clearBtn); controls.appendChild(closeBtn);
header.appendChild(leftContainer); // 左侧放标题+选择器
header.appendChild(controls);
this.historyListContainer = document.createElement('div');
this.historyListContainer.style.cssText = `padding: 10px; overflow-y: auto; max-height: calc(80vh - 70px);`;
// 无限滚动监听
this.historyListContainer.addEventListener('scroll', () => {
const { scrollTop, scrollHeight, clientHeight } = this.historyListContainer;
// 距离底部 50px 时加载更多
if (scrollTop + clientHeight >= scrollHeight - 50) {
this.loadMoreHistory();
}
});
this.historyPanel.appendChild(header); this.historyPanel.appendChild(this.historyListContainer);
this.shadowRoot.appendChild(this.historyPanel);
// 初始化显示参数
this.visibleHistoryCount = 20;
this.renderedCount = 0;
this.renderHistoryList(); // 初次渲染
}
loadMoreHistory() {
const history = HistoryManager.getHistory();
// 如果当前显示的已经 >= 总数,就不渲染了
if (this.visibleHistoryCount >= history.length) return;
// 每次多加载 20 条
this.visibleHistoryCount += 20;
// append = true 模式
this.renderHistoryList(true);
}
createModelSelectionControl(modelConfig) {
const container = document.createElement('div');
container.style.cssText = `display: flex; gap: 8px; align-items: center; width: 100%;`;
const inputContainer = document.createElement('div');
inputContainer.style.flex = '1';
const renderInput = () => {
inputContainer.textContent = '';
const hasModels = (modelConfig.SELECTED_MODELS && modelConfig.SELECTED_MODELS.length > 0) ||
(modelConfig.AVAILABLE_MODELS && modelConfig.AVAILABLE_MODELS.length > 0);
if (hasModels) {
const select = document.createElement('select');
select.style.cssText = `width: 100%; padding: 12px 16px; border-radius: 12px; background: rgba(255, 255, 255, 0.9); color: #333; border: 1px solid rgba(255, 255, 255, 0.2); font-size: 14px; outline: none; transition: all 0.3s; box-sizing: border-box; appearance: none; -webkit-appearance: none;`;
// Use SELECTED_MODELS if available, otherwise fallback to AVAILABLE_MODELS
let displayModels = modelConfig.SELECTED_MODELS && modelConfig.SELECTED_MODELS.length > 0
? modelConfig.SELECTED_MODELS
: modelConfig.AVAILABLE_MODELS;
// Add current model if not in list (to preserve value)
const currentModel = modelConfig.MODEL;
if (currentModel && !displayModels.includes(currentModel)) {
displayModels = [currentModel, ...displayModels];
}
displayModels.forEach(m => {
const option = document.createElement('option');
option.value = m;
option.textContent = m;
if (m === currentModel) option.selected = true;
select.appendChild(option);
});
// Add Custom Icon arrow
select.style.backgroundImage = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M2.5 4.5L6 8L9.5 4.5' stroke='%23666' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E")`;
select.style.backgroundRepeat = 'no-repeat';
select.style.backgroundPosition = 'right 16px center';
select.addEventListener('change', () => { modelConfig.MODEL = select.value; this.updateTitleWithModel(); });
inputContainer.appendChild(select);
} else {
const input = document.createElement('input');
input.type = 'text';
input.value = modelConfig.MODEL || '';
input.placeholder = '手动输入或先获取模型列表...';
input.style.cssText = `width: 100%; padding: 12px 16px; border-radius: 12px; background: rgba(255, 255, 255, 0.9); color: #333; border: 1px solid rgba(255, 255, 255, 0.2); font-size: 14px; outline: none; transition: all 0.3s; box-sizing: border-box;`;
input.addEventListener('input', (e) => { modelConfig.MODEL = e.target.value; this.updateTitleWithModel(); });
input.addEventListener('focus', () => { input.style.boxShadow = '0 0 0 2px rgba(102, 126, 234, 0.2)'; });
input.addEventListener('blur', () => { input.style.boxShadow = 'none'; });
inputContainer.appendChild(input);
}
};
// Initial Render
renderInput();
// Fetch Button
const fetchBtn = this.createButton('📋 获取模型', 'secondary');
fetchBtn.style.padding = '8px 12px';
fetchBtn.style.whiteSpace = 'nowrap';
fetchBtn.addEventListener('click', async () => {
if (!modelConfig.API_KEY) { this.showNotification('请先填写 API Key', 'error'); return; }
if (!modelConfig.API_URL) { this.showNotification('请先填写 API URL', 'error'); return; }
fetchBtn.disabled = true;
fetchBtn.textContent = '获取中...';
try {
// Try to deduce /v1/models endpoint
// Common patterns: .../v1/chat/completions -> .../v1/models
let baseUrl = modelConfig.API_URL;
const urlObj = new URL(baseUrl);
if (baseUrl.includes('/chat/completions')) {
// e.g. https://host/v1/chat/completions -> https://host/v1/models
baseUrl = baseUrl.replace('/chat/completions', '/models');
} else if (baseUrl.includes('/v1')) {
// e.g. https://host/v1 -> https://host/v1/models
// Split at /v1 to be safe and reconstruct
const parts = baseUrl.split('/v1');
baseUrl = parts[0] + '/v1/models';
} else {
// Fallback: If no /v1/ is seen, assume it's a base host like https://api.zscc.in
// Standard OpenAI compatible path is /v1/models
baseUrl = urlObj.origin + (urlObj.origin.endsWith('/') ? '' : '/') + 'v1/models';
}
console.log('[ModelFetcher] Fetching from:', baseUrl);
// 处理可能存在的多 Key 情况,仅使用第一个 Key 获取模型列表
const currentApiKey = modelConfig.API_KEY.includes(',')
? modelConfig.API_KEY.split(',')[0].trim()
: modelConfig.API_KEY.trim();
const response = await fetch(baseUrl, {
method: 'GET',
headers: {
'Authorization': `Bearer ${currentApiKey}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
let models = [];
// Parse data.data or data
if (data && Array.isArray(data.data)) {
models = data.data.map(m => m.id).sort();
} else if (Array.isArray(data)) {
models = data.map(m => m.id).sort();
} else {
throw new Error('无法解析返回的模型列表格式');
}
if (models.length === 0) throw new Error('未找到任何模型');
// Show Selection Dialog
this.showModelSelectionDialog(models, modelConfig, () => {
renderInput();
this.updateTitleWithModel();
this.showNotification(`已更新模型列表,当前选中 ${modelConfig.SELECTED_MODELS ? modelConfig.SELECTED_MODELS.length : 0} 个模型`, 'success');
});
} catch (e) {
console.error('Fetch Models Error:', e);
this.showNotification(`获取模型失败: ${e.message}`, 'error');
} finally {
fetchBtn.disabled = false;
fetchBtn.textContent = '📋 获取模型';
}
});
container.appendChild(inputContainer);
container.appendChild(fetchBtn);
return container;
}
showModelSelectionDialog(allModels, modelConfig, callback) {
const selectedModels = modelConfig.SELECTED_MODELS || [];
const dialog = document.createElement('div');
dialog.style.cssText = `position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.5); z-index: 100000; display: flex; align-items: center; justify-content: center; backdrop-filter: blur(5px);`;
const content = document.createElement('div');
content.style.cssText = `background: rgba(255, 255, 255, 0.95); border-radius: 16px; padding: 24px; width: 500px; max-width: 90vw; max-height: 80vh; display: flex; flex-direction: column; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);`;
const title = document.createElement('h3'); title.textContent = '选择可用模型'; title.style.marginBottom = '16px';
const listContainer = document.createElement('div');
listContainer.style.cssText = `flex: 1; overflow-y: auto; border: 1px solid #eee; border-radius: 8px; padding: 8px; margin-bottom: 16px; display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 8px; align-content: start;`;
const checkboxes = [];
allModels.forEach(m => {
const label = document.createElement('label');
label.style.cssText = `display: flex; align-items: center; gap: 8px; padding: 6px; cursor: pointer; border-radius: 6px; hover: background: #f5f5f5; font-size: 13px;`;
label.addEventListener('mouseover', () => label.style.background = '#f0f0f0');
label.addEventListener('mouseout', () => label.style.background = 'transparent');
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.value = m;
if (selectedModels.includes(m)) cb.checked = true;
const span = document.createElement('span');
span.textContent = m;
span.style.wordBreak = 'break-all';
label.appendChild(cb);
label.appendChild(span);
listContainer.appendChild(label);
checkboxes.push(cb);
});
const footer = document.createElement('div');
footer.style.cssText = `display: flex; justify-content: space-between; align-items: center;`;
const leftActions = document.createElement('div');
const selectAll = document.createElement('button'); selectAll.textContent = '全选';
selectAll.style.cssText = `border: none; background: none; color: #3b82f6; cursor: pointer; font-size: 13px; margin-right: 12px;`;
selectAll.onclick = () => checkboxes.forEach(c => c.checked = true);
const selectNone = document.createElement('button'); selectNone.textContent = '清空';
selectNone.style.cssText = `border: none; background: none; color: #ef4444; cursor: pointer; font-size: 13px;`;
selectNone.onclick = () => checkboxes.forEach(c => c.checked = false);
leftActions.appendChild(selectAll); leftActions.appendChild(selectNone);
const rightActions = document.createElement('div'); rightActions.style.gap = '12px'; rightActions.style.display = 'flex';
const cancelBtn = this.createButton('取消', 'secondary'); cancelBtn.onclick = () => dialog.remove();
const saveBtn = this.createButton('确认', 'primary');
saveBtn.onclick = () => {
const checked = checkboxes.filter(c => c.checked).map(c => c.value);
modelConfig.SELECTED_MODELS = checked; // Update selected models
// If current MODEL is not in selected list, update it to the first selected one
if (checked.length > 0 && (!modelConfig.MODEL || !checked.includes(modelConfig.MODEL))) {
modelConfig.MODEL = checked[0];
}
if (callback) callback();
dialog.remove();
};
rightActions.appendChild(cancelBtn); rightActions.appendChild(saveBtn);
footer.appendChild(leftActions); footer.appendChild(rightActions);
content.appendChild(title); content.appendChild(listContainer); content.appendChild(footer);
dialog.appendChild(content); this.shadowRoot.appendChild(dialog);
dialog.addEventListener('click', (e) => { if (e.target === dialog) dialog.remove(); });
}
renderHistoryList(append = false) {
// 初始化选中记录集合
if (!this.selectedHistoryIds) this.selectedHistoryIds = new Set();
// 确保显示计数已初始化
if (!this.visibleHistoryCount) this.visibleHistoryCount = 20;
// 如果不是追加模式,清空容器和计数
if (!append) {
while (this.historyListContainer.firstChild) {
this.historyListContainer.removeChild(this.historyListContainer.firstChild);
}
this.renderedCount = 0;
}
const history = HistoryManager.getHistory();
console.log('[renderHistoryList] Total items:', history.length, 'Visible items limit:', this.visibleHistoryCount, 'Mode:', append ? 'Append' : 'Reset');
if (history.length === 0) {
const emptyMsg = document.createElement('div');
emptyMsg.textContent = '暂无历史记录';
emptyMsg.style.cssText = 'text-align:center; padding: 40px; color: #888;';
this.historyListContainer.appendChild(emptyMsg);
if (this.selectAllCheckbox) this.selectAllCheckbox.checked = false;
return;
}
// 计算需要渲染的范围
// 如果是 append,从 renderedCount 开始,到 limit 结束
// 如果是 reset,从 0 开始,到 limit 结束
const startIdx = append ? this.renderedCount : 0;
const endIdx = Math.min(this.visibleHistoryCount, history.length);
const itemsToRender = history.slice(startIdx, endIdx);
itemsToRender.forEach(item => {
const el = document.createElement('div');
el.style.cssText = `display: flex; align-items: center; padding: 12px 16px; background: rgba(255,255,255,0.5); border-radius: 12px; margin-bottom: 8px; border: 1px solid rgba(0,0,0,0.05); transition: all 0.2s;`;
el.addEventListener('mouseover', () => el.style.background = 'rgba(255,255,255,0.8)');
el.addEventListener('mouseout', () => el.style.background = 'rgba(255,255,255,0.5)');
// 复选框
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = this.selectedHistoryIds.has(item.id);
checkbox.style.cssText = `width: 16px; height: 16px; cursor: pointer; margin-right: 12px; accent-color: #3b82f6; flex-shrink: 0;`;
checkbox.addEventListener('change', (e) => {
e.stopPropagation();
if (checkbox.checked) {
this.selectedHistoryIds.add(item.id);
} else {
this.selectedHistoryIds.delete(item.id);
}
this.updateSelectAllCheckbox();
});
checkbox.addEventListener('click', (e) => e.stopPropagation());
const info = document.createElement('div');
info.style.cssText = `flex: 1; cursor: pointer; padding-right: 10px; overflow: hidden;`;
info.addEventListener('click', () => this.loadHistoryItem(item));
const titleText = document.createElement('div');
titleText.textContent = item.title;
titleText.style.cssText = `font-weight: 500; font-size: 14px; margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;`;
// 元数据行:平台链接 + 频道名称 + 时间
const metaText = document.createElement('div');
metaText.style.cssText = `font-size: 11px; color: #666; display: flex; align-items: center; gap: 4px; flex-wrap: wrap;`;
// 平台图标和名称(点击跳转到原视频)
const platformLink = document.createElement('a');
platformLink.href = item.url || '#';
platformLink.target = '_blank';
platformLink.textContent = `${this.getPlatformIcon(item.platform)} ${item.platform}`;
platformLink.style.cssText = `color: #3b82f6; text-decoration: none; cursor: pointer; transition: color 0.2s;`;
platformLink.addEventListener('mouseover', () => platformLink.style.color = '#2563eb');
platformLink.addEventListener('mouseout', () => platformLink.style.color = '#3b82f6');
platformLink.addEventListener('click', (e) => e.stopPropagation());
// 分隔符
const sep1 = document.createElement('span');
sep1.textContent = '·';
sep1.style.color = '#999';
// 频道名称
const channelSpan = document.createElement('span');
channelSpan.textContent = item.channelName || '未知频道';
channelSpan.style.cssText = `color: #888; max-width: 150px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;`;
channelSpan.title = item.channelName || '未知频道';
// 分隔符
const sep2 = document.createElement('span');
sep2.textContent = '·';
sep2.style.color = '#999';
// 时间
const timeStr = new Date(item.timestamp).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
const timeSpan = document.createElement('span');
timeSpan.textContent = timeStr;
metaText.appendChild(platformLink);
metaText.appendChild(sep1);
metaText.appendChild(channelSpan);
metaText.appendChild(sep2);
metaText.appendChild(timeSpan);
info.appendChild(titleText); info.appendChild(metaText);
// 按钮容器
const btnContainer = document.createElement('div');
btnContainer.style.cssText = `display: flex; align-items: center; gap: 4px; flex-shrink: 0;`;
// 发布按钮 - 开启PublishMarkdown时显示
if (CONFIG.PUBLISH_MARKDOWN?.ENABLED) {
const publishBtn = document.createElement('button');
publishBtn.textContent = '发布';
publishBtn.style.cssText = `border: none; background: transparent; color: #3b82f6; cursor: pointer; padding: 4px 8px; font-size: 12px; border-radius: 4px; transition: all 0.2s;`;
publishBtn.addEventListener('mouseover', () => { publishBtn.style.background = 'rgba(59, 130, 246, 0.1)'; });
publishBtn.addEventListener('mouseout', () => { publishBtn.style.background = 'transparent'; });
publishBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.publishFromHistory(item);
});
btnContainer.appendChild(publishBtn);
}
// 链接按钮 - 仅当有发布链接时显示
if (item.publishedUrl) {
const linkBtn = document.createElement('button');
linkBtn.textContent = '链接';
linkBtn.style.cssText = `border: none; background: transparent; color: #10b981; cursor: pointer; padding: 4px 8px; font-size: 12px; border-radius: 4px; transition: all 0.2s;`;
linkBtn.addEventListener('mouseover', () => { linkBtn.style.background = 'rgba(16, 185, 129, 0.1)'; });
linkBtn.addEventListener('mouseout', () => { linkBtn.style.background = 'transparent'; });
linkBtn.addEventListener('click', (e) => {
e.stopPropagation();
window.open(item.publishedUrl, '_blank');
});
btnContainer.appendChild(linkBtn);
}
const delBtn = document.createElement('button');
delBtn.textContent = '✕';
delBtn.style.cssText = `border: none; background: transparent; color: #999; cursor: pointer; padding: 4px 8px; font-size: 14px; border-radius: 4px; transition: all 0.2s; flex-shrink: 0;`;
delBtn.addEventListener('mouseover', () => { delBtn.style.background = 'rgba(244, 67, 54, 0.1)'; delBtn.style.color = '#ef4444'; });
delBtn.addEventListener('mouseout', () => { delBtn.style.background = 'transparent'; delBtn.style.color = '#999'; });
delBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (confirm('确定删除这条记录吗?')) {
HistoryManager.deleteRecord(item.id);
this.selectedHistoryIds.delete(item.id);
this.renderHistoryList();
}
});
el.appendChild(checkbox); el.appendChild(info); el.appendChild(btnContainer); el.appendChild(delBtn);
this.historyListContainer.appendChild(el);
});
// 更新已渲染数量,供下次 append 使用
this.renderedCount = endIdx;
// 更新全选复选框状态
this.updateSelectAllCheckbox();
}
getPlatformIcon(platform) {
if (platform === 'YOUTUBE') return '📺';
if (platform === 'WECHAT') return '📰';
if (platform === 'BILIBILI') return '🅱️';
return '📄';
}
// 全选/取消全选历史记录
toggleSelectAllHistory() {
const history = HistoryManager.getHistory();
if (!this.selectedHistoryIds) this.selectedHistoryIds = new Set();
if (this.selectAllCheckbox.checked) {
// 全选 - 清空后重新添加确保类型一致
this.selectedHistoryIds.clear();
history.forEach(item => this.selectedHistoryIds.add(item.id));
console.log('[toggleSelectAllHistory] 全选:', this.selectedHistoryIds.size, '条记录', Array.from(this.selectedHistoryIds));
} else {
// 取消全选
this.selectedHistoryIds.clear();
console.log('[toggleSelectAllHistory] 取消全选');
}
this.renderHistoryList();
}
// 更新全选复选框状态
updateSelectAllCheckbox() {
if (!this.selectAllCheckbox) return;
const history = HistoryManager.getHistory();
if (history.length === 0) {
this.selectAllCheckbox.checked = false;
this.selectAllCheckbox.indeterminate = false;
} else if (this.selectedHistoryIds.size === 0) {
this.selectAllCheckbox.checked = false;
this.selectAllCheckbox.indeterminate = false;
} else if (this.selectedHistoryIds.size === history.length) {
this.selectAllCheckbox.checked = true;
this.selectAllCheckbox.indeterminate = false;
} else {
this.selectAllCheckbox.checked = false;
this.selectAllCheckbox.indeterminate = true;
}
}
// 从历史记录中发布
async publishFromHistory(item) {
if (!CONFIG.PUBLISH_MARKDOWN?.ENABLED || !CONFIG.PUBLISH_MARKDOWN?.API_KEY) {
this.showNotification('请先在设置中启用并配置 PublishMarkdown API Key', 'error');
return;
}
if (!item.summary || item.summary.trim() === '') {
this.showNotification('该记录没有可发布的内容', 'error');
return;
}
const markdownContent = `# ${item.title}\n\n**原文链接:** ${item.url}\n**总结时间:** ${new Date(item.timestamp).toLocaleString('zh-CN')}\n\n---\n\n## 内容总结\n\n${item.summary}\n\n---\n\n*本总结由 船仓AI助手 生成,脚本:dub.sh/iytb*`;
this.showNotification('正在发布...', 'info');
try {
const isUpdate = !!item.publishedIdentifier;
const apiUrl = isUpdate
? `https://publishmarkdown.com/v1/api/markdown/${item.publishedIdentifier}`
: 'https://publishmarkdown.com/v1/api/markdown';
const method = isUpdate ? 'PUT' : 'POST';
const response = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: method,
url: apiUrl,
headers: {
'Content-Type': 'application/json',
'api-key': CONFIG.PUBLISH_MARKDOWN.API_KEY
},
data: JSON.stringify({ content: markdownContent }),
onload: function (res) {
try {
const data = JSON.parse(res.responseText);
if (res.status >= 200 && res.status < 300 && data.status === 'success') {
resolve(data);
} else {
reject(new Error(data.message || `HTTP ${res.status}`));
}
} catch (e) {
reject(new Error('解析响应失败'));
}
},
onerror: () => reject(new Error('网络请求失败')),
ontimeout: () => reject(new Error('请求超时'))
});
});
// 成功发布,更新历史记录
HistoryManager.updateRecord(item.id, {
publishedUrl: response.data.url,
publishedIdentifier: response.data.identifier
});
this.renderHistoryList(); // 刷新列表以显示链接按钮
this.showNotification(isUpdate ? '已更新发布' : '发布成功!', 'success');
} catch (e) {
this.showNotification(`发布失败: ${e.message}`, 'error');
}
}
// 处理导出历史记录
async handleExportHistory() {
console.log('[handleExportHistory] selectedHistoryIds:', this.selectedHistoryIds ? Array.from(this.selectedHistoryIds) : 'undefined');
if (!this.selectedHistoryIds || this.selectedHistoryIds.size === 0) {
this.showNotification('请先选择要导出的记录', 'error');
return;
}
const history = HistoryManager.getHistory();
console.log('[handleExportHistory] history ids:', history.map(item => item.id));
const selectedRecords = history.filter(item => this.selectedHistoryIds.has(item.id));
console.log('[handleExportHistory] selectedRecords:', selectedRecords.length);
if (selectedRecords.length === 0) {
this.showNotification('未找到选中的记录', 'error');
return;
}
try {
if (selectedRecords.length === 1) {
// 单条记录导出为MD文件
this.exportSingleRecord(selectedRecords[0]);
} else {
// 多条记录打包为ZIP
console.log('[handleExportHistory] 准备导出', selectedRecords.length, '条记录为ZIP');
await this.exportMultipleRecords(selectedRecords);
}
} catch (e) {
console.error('导出失败:', e);
this.showNotification(`导出失败: ${e.message}`, 'error');
}
}
// 生成单条记录的Markdown内容
generateRecordMarkdown(record) {
const platformName = {
'YOUTUBE': 'YouTube',
'WECHAT': '微信公众号',
'BILIBILI': 'B站'
}[record.platform] || record.platform;
const timeStr = new Date(record.timestamp).toLocaleString('zh-CN');
return `# ${record.title}
**平台:** ${platformName}
**作者:** ${record.channelName || '未知'}
**原文链接:** ${record.url || '无'}
**总结时间:** ${timeStr}
---
${record.summary}
---
*本内容由 船仓AI助手 生成,脚本:dub.sh/iytb*
`;
}
// 导出单条记录为MD文件
exportSingleRecord(record) {
const content = this.generateRecordMarkdown(record);
const cleanTitle = record.title.replace(/[<>:"/\\|?*\x00-\x1f]/g, '').trim().substring(0, 50);
const filename = `${cleanTitle}.md`;
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(link.href);
this.showNotification('已导出为MD文件', 'success');
}
// 批量导出多条记录为ZIP
exportMultipleRecords(records) {
console.log('[exportMultipleRecords] Starting export for', records.length, 'records');
const self = this;
if (typeof JSZip === 'undefined') {
console.error('[exportMultipleRecords] JSZip is undefined');
this.showNotification('ZIP库未加载,请刷新页面重试', 'error');
return;
}
console.log('[exportMultipleRecords] JSZip is available, version:', JSZip.version);
try {
const zip = new JSZip();
console.log('[exportMultipleRecords] JSZip instance created');
const usedNames = new Set();
records.forEach((record, index) => {
const content = this.generateRecordMarkdown(record);
let baseName = record.title.replace(/[<>:"/\\|?*\x00-\x1f]/g, '').trim().substring(0, 50);
if (!baseName) baseName = `记录_${index + 1}`;
// 确保文件名唯一
let filename = `${baseName}.md`;
let counter = 1;
while (usedNames.has(filename)) {
filename = `${baseName}_${counter}.md`;
counter++;
}
usedNames.add(filename);
zip.file(filename, content);
console.log('[exportMultipleRecords] Added file:', filename);
});
const dateStr = new Date().toISOString().slice(0, 10);
const zipFilename = `船仓AI助手-历史记录-${dateStr}.zip`;
console.log('[exportMultipleRecords] Generating ZIP:', zipFilename);
// 使用 .then()/.catch() 而非 await
const generatePromise = zip.generateAsync({ type: 'blob' });
console.log('[exportMultipleRecords] generateAsync called, promise:', generatePromise);
generatePromise.then(function (zipBlob) {
console.log('[exportMultipleRecords] ZIP blob created, size:', zipBlob.size);
const link = document.createElement('a');
link.href = URL.createObjectURL(zipBlob);
link.download = zipFilename;
document.body.appendChild(link);
console.log('[exportMultipleRecords] Triggering download');
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(link.href);
self.showNotification(`已导出 ${records.length} 条记录为ZIP`, 'success');
console.log('[exportMultipleRecords] Export completed successfully');
}).catch(function (e) {
console.error('[exportMultipleRecords] generateAsync error:', e);
self.showNotification(`ZIP生成失败: ${e.message}`, 'error');
});
} catch (e) {
console.error('[exportMultipleRecords] Sync error:', e);
this.showNotification(`导出失败: ${e.message}`, 'error');
}
}
loadHistoryItem(item) {
this.originalSummaryText = item.summary;
this.currentHistoryItemId = item.id; // 保存当前加载的历史记录ID
while (this.summaryContent.firstChild) { this.summaryContent.removeChild(this.summaryContent.firstChild); }
this.createFormattedContent(this.summaryContent, item.summary);
this.summaryPanel.style.display = 'block';
setTimeout(() => this.updateSummaryContentHeight(), 50); // 动态调整高度
this.updateStatus('已加载历史记录', 'success');
// 更新标题
if (this.contentController) {
this.contentController.translatedTitle = item.title;
}
// 关闭历史面板
this.toggleHistoryPanel();
// 不自动滚动,或者滚动到顶部
this.summaryPanel.scrollIntoView({ behavior: 'smooth', block: 'start' });
// 恢复发布状态:如果记录已发布,显示发布链接
if (item.publishedUrl && item.publishedIdentifier) {
this.currentPublishedIdentifier = item.publishedIdentifier;
if (this.publishedUrlLink) {
this.publishedUrlLink.href = item.publishedUrl;
this.publishedUrlLink.textContent = item.publishedUrl;
}
if (this.publishedUrlContainer) this.publishedUrlContainer.style.display = 'block';
} else {
// 重置发布状态
this.currentPublishedIdentifier = null;
if (this.publishedUrlContainer) this.publishedUrlContainer.style.display = 'none';
}
// 更新发布按钮可见性(确保在所有平台都能正确显示)
if (this.publishButton) {
this.publishButton.style.display = CONFIG.PUBLISH_MARKDOWN?.ENABLED ? 'block' : 'none';
}
}
// ++ 请用下面这整块新代码,替换掉原来的 toggleCollapse() 函数 ++
toggleCollapse() {
this.isCollapsed = !this.isCollapsed;
if (this.isCollapsed) {
// --- 执行收起操作 ---
this.mainContent.style.display = 'none';
this.toggleButton.textContent = '↓';
// 隐藏标题和设置按钮
this.titleElement.style.display = 'none';
this.configButton.style.display = 'none';
if (this.historyButton) this.historyButton.style.display = 'none';
if (this.leftResizeHandle) this.leftResizeHandle.style.display = 'none'; // 隐藏左侧调整手柄
// 将主容器和顶部栏变得透明且无边框
this.container.style.background = 'transparent';
this.container.style.boxShadow = 'none';
this.container.style.backdropFilter = 'none';
this.container.style.border = 'none';
this.container.style.padding = '0';
this.container.style.width = 'auto'; // 宽度自适应
this.container.style.minWidth = '0';
this.topBar.style.padding = '0';
this.topBar.style.background = 'transparent';
this.topBar.style.justifyContent = 'flex-end'; // 让按钮靠右
// 将收起按钮美化成一个独立的浅色磨砂质感按钮
this.toggleButton.style.background = 'rgba(255, 255, 255, 0.85)';
this.toggleButton.style.backdropFilter = 'blur(20px) saturate(180%)';
this.toggleButton.style.border = '1px solid rgba(0, 0, 0, 0.1)';
this.toggleButton.style.boxShadow = '0 8px 32px 0 rgba(0, 0, 0, 0.1)';
this.toggleButton.style.borderRadius = '50%';
this.toggleButton.style.width = '40px';
this.toggleButton.style.height = '40px';
this.toggleButton.style.padding = '0';
this.toggleButton.style.fontSize = '22px';
this.toggleButton.style.color = '#3b82f6';
this.toggleButton.style.cursor = 'grab';
this.toggleButton.style.animation = 'pulse 2s ease-in-out infinite';
// 添加呼吸动画样式
if (!document.getElementById('cchelper-animations')) {
const style = document.createElement('style');
style.id = 'cchelper-animations';
style.textContent = `
@keyframes pulse {
0%, 100% { transform: scale(1); box-shadow: 0 8px 32px 0 rgba(59, 130, 246, 0.2); }
50% { transform: scale(1.05); box-shadow: 0 8px 40px 0 rgba(59, 130, 246, 0.35); }
}
`;
document.head.appendChild(style);
}
} else {
// --- 执行展开操作 ---
// 如果处于边缘隐藏模式,先退出
if (this.isEdgeHidden) {
this.exitEdgeHiddenMode();
}
this.mainContent.style.display = 'block';
// 显示标题和设置按钮
this.titleElement.style.display = 'flex';
this.configButton.style.display = 'block';
if (this.historyButton) this.historyButton.style.display = 'block';
if (this.leftResizeHandle) this.leftResizeHandle.style.display = 'block'; // 显示左侧调整手柄
// 恢复主容器的原始样式
const defaultWidth = this.platform === 'BILIBILI' ? '453px' : '420px';
this.container.style.cssText = `position: fixed; top: 80px; right: 20px; width: ${defaultWidth}; min-width: 350px; max-width: 90vw; background: rgba(255, 255, 255, 0.75); border-radius: 16px; padding: 0; color: #1f2937; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; z-index: 9999; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1); backdrop-filter: blur(20px) saturate(180%); border: 1px solid rgba(0, 0, 0, 0.1);`;
// 恢复顶部栏的原始样式
this.topBar.style.cssText = `display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; cursor: move; background: rgba(255, 255, 255, 0.5); border-radius: 16px 16px 0 0; backdrop-filter: blur(10px);`;
// 恢复展开/收起按钮的原始样式
this.toggleButton.style.cssText = `background: rgba(59, 130, 246, 0.1); border: none; color: #3b82f6; cursor: pointer; padding: 8px; font-size: 14px; border-radius: 8px; transition: all 0.2s ease; backdrop-filter: blur(10px); pointer-events: auto;`;
this.toggleButton.textContent = '↑'; // cssText会覆盖内容, 所以需要重新设置
}
}
toggleConfigPanel() {
if (!this.configPanel || !this.shadowRoot.contains(this.configPanel)) this.createConfigPanel();
const isVisible = this.configPanel.style.display === 'block';
this.configPanel.style.display = isVisible ? 'none' : 'block';
}
updateStatus(message, type = 'info') {
// Updated to use the Summary Button for status feedback
if (!this.summaryButton) return;
this.summaryButton.textContent = message;
// Optional: Change button style based on state
if (type === 'info') {
// Loading state is usually handled by 'disabled' in the caller, but we can add an icon or pulse here if we wanted
} else if (type === 'success') {
setTimeout(() => {
this.summaryButton.textContent = this.summaryButton.dataset.originalText || '🤖 生成总结';
this.summaryButton.disabled = false;
}, 3000);
} else if (type === 'error') {
// Keep error message longer or until click
setTimeout(() => {
this.summaryButton.textContent = this.summaryButton.dataset.originalText || '🤖 生成总结';
this.summaryButton.disabled = false;
}, 5000);
}
}
showNotification(message, type = 'info') {
const n = document.createElement('div'); n.textContent = message; const c = { 'info': '#2196F3', 'success': '#4CAF50', 'error': '#F44336' };
n.style.cssText = `position: fixed; top: 20px; left: 50%; transform: translateX(-50%); background: ${c[type] || c['info']}; color: #fff; padding: 12px 24px; border-radius: 8px; font-size: 14px; z-index: 200000; box-shadow: 0 4px 12px rgba(0,0,0,0.3); opacity: 0; transition: all 0.3s;`;
this.shadowRoot.appendChild(n); setTimeout(() => { n.style.opacity = '1'; }, 10);
setTimeout(() => { n.style.opacity = '0'; setTimeout(() => n.remove(), 300); }, 3000);
}
showExtensionPrompt() { if (confirm('无法获取字幕。建议安装 YouTube Text Tools 扩展以获得更好支持。是否前往安装?')) { window.open('https://chromewebstore.google.com/detail/youtube-text-tools/pcmahconeajhpgleboodnodllkoimcoi', '_blank'); } }
async handleLoadContent() {
try {
this.updateStatus('正在加载内容...', 'info'); this.loadContentButton.disabled = true;
await this.contentController.loadContent();
const count = this.contentController.mainContent.split('\n').length;
const successMessage = this.platform === 'YOUTUBE' ? `字幕加载完成,共 ${count} 条` : '文章提取完成';
this.updateStatus(successMessage, 'success');
this.loadContentButton.style.display = 'none';
// Toggle the new flex row instead of individual elements
if (this.actionRow) {
this.actionRow.style.display = 'flex';
}
// Fallback for safety if old props are accessed elsewhere (though we removed them)
if (this.mainPromptGroup) this.mainPromptGroup.style.display = 'block';
if (this.summaryButton) this.summaryButton.style.display = 'block';
} catch (e) {
this.updateStatus('加载内容失败: ' + e.message, 'error');
if (this.platform === 'YOUTUBE' && e.message.toLowerCase().includes('字幕')) { setTimeout(() => this.showExtensionPrompt(), 1500); }
} finally { this.loadContentButton.disabled = false; }
}
async handleGenerateSummary() {
try {
this.updateStatus('正在生成总结...', 'info'); this.summaryButton.disabled = true;
const summary = await this.contentController.getSummary();
if (!summary || summary.trim() === '') throw new Error('生成的总结为空');
this.originalSummaryText = summary;
while (this.summaryContent.firstChild) { this.summaryContent.removeChild(this.summaryContent.firstChild); }
this.createFormattedContent(this.summaryContent, summary);
this.summaryPanel.style.display = 'block'; this.updateStatus('总结生成完成', 'success');
setTimeout(() => this.updateSummaryContentHeight(), 50); // 动态调整高度
this.summaryPanel.scrollIntoView({ behavior: 'smooth', block: 'start' });
// 重置发布状态
this.currentPublishedIdentifier = null;
if (this.publishedUrlContainer) {
this.publishedUrlContainer.style.display = 'none';
}
// 更新发布按钮可见性
if (this.publishButton) {
this.publishButton.style.display = CONFIG.PUBLISH_MARKDOWN?.ENABLED ? 'block' : 'none';
}
// 保存到历史记录
const historyRecord = {
id: this.contentController.getContentId(),
title: this.contentController.translatedTitle || this.contentController.getContentTitle(),
url: window.location.href,
summary: summary,
platform: this.platform,
channelName: this.contentController.getChannelName()
};
console.log('[handleGenerateSummary] About to save history record:', historyRecord);
try {
HistoryManager.addRecord(historyRecord);
this.showNotification('历史记录已保存', 'success');
} catch (historyErr) {
console.error('[handleGenerateSummary] History save error:', historyErr);
this.showNotification('历史记录保存失败: ' + historyErr.message, 'error');
}
} catch (e) {
this.updateStatus(`生成总结失败: ${e.message}`, 'error'); this.showNotification(`生成总结失败: ${e.message}`, 'error');
} finally { this.summaryButton.disabled = false; }
}
async handlePublishMarkdown(customIdentifier = null) {
if (!CONFIG.PUBLISH_MARKDOWN?.ENABLED || !CONFIG.PUBLISH_MARKDOWN?.API_KEY) {
this.showNotification('请先在设置中启用并配置 PublishMarkdown API Key', 'error');
return;
}
const textToPublish = this.originalSummaryText || this.summaryContent.textContent;
if (!textToPublish || textToPublish.trim() === '') {
this.showNotification('没有可发布的内容', 'error');
return;
}
const title = this.contentController.translatedTitle || this.contentController.getContentTitle();
const id = this.contentController.getContentId();
const markdownContent = `# ${title}\n\n**原文链接:** ${window.location.href}\n**总结时间:** ${new Date().toLocaleString('zh-CN')}\n\n---\n\n## 内容总结\n\n${textToPublish}\n\n---\n\n*本总结由 船仓AI助手 生成,脚本:dub.sh/iytb*`;
this.publishButton.disabled = true;
this.publishButton.textContent = '发布中...';
try {
const isUpdate = this.currentPublishedIdentifier && customIdentifier;
const apiUrl = isUpdate
? `https://publishmarkdown.com/v1/api/markdown/${this.currentPublishedIdentifier}`
: 'https://publishmarkdown.com/v1/api/markdown';
const method = isUpdate ? 'PUT' : 'POST';
const requestBody = { content: markdownContent };
if (customIdentifier) {
requestBody.identifier = customIdentifier;
}
const response = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: method,
url: apiUrl,
headers: {
'Content-Type': 'application/json',
'api-key': CONFIG.PUBLISH_MARKDOWN.API_KEY
},
data: JSON.stringify(requestBody),
onload: function (res) {
try {
const data = JSON.parse(res.responseText);
if (res.status >= 200 && res.status < 300 && data.status === 'success') {
resolve(data);
} else {
reject(new Error(data.message || `HTTP ${res.status}`));
}
} catch (e) {
reject(new Error('解析响应失败'));
}
},
onerror: function (err) {
reject(new Error('网络请求失败'));
},
ontimeout: function () {
reject(new Error('请求超时'));
}
});
});
// 成功发布
const publishedUrl = response.data.url;
this.currentPublishedIdentifier = response.data.identifier;
this.publishedUrlLink.href = publishedUrl;
this.publishedUrlLink.textContent = publishedUrl;
this.publishedUrlContainer.style.display = 'block';
this.showNotification(isUpdate ? 'URL已更新' : '发布成功!', 'success');
// 保存发布链接到历史记录
if (id) {
HistoryManager.updateRecord(id, {
publishedUrl: publishedUrl,
publishedIdentifier: response.data.identifier
});
}
} catch (e) {
this.showNotification(`发布失败: ${e.message}`, 'error');
} finally {
this.publishButton.disabled = false;
this.publishButton.textContent = '📤 发布';
}
}
showEditIdentifierDialog() {
const dialog = document.createElement('div'); dialog.style.cssText = `position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.5); z-index: 100000; display: flex; align-items: center; justify-content: center; backdrop-filter: blur(5px);`;
const dialogContent = document.createElement('div'); dialogContent.style.cssText = `background: rgba(255, 255, 255, 0.92); border-radius: 16px; padding: 24px; width: 400px; max-width: 90vw; color: #1f2937; backdrop-filter: blur(20px) saturate(180%); border: 1px solid rgba(0, 0, 0, 0.1); box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);`;
const dialogTitle = document.createElement('h3'); dialogTitle.textContent = '✏️ 编辑自定义URL'; dialogTitle.style.cssText = `margin: 0 0 16px 0; color: #1f2937;`;
const helpText = document.createElement('p'); helpText.textContent = '输入新的URL标识符,发布后URL将变为:'; helpText.style.cssText = `font-size: 13px; color: #666; margin-bottom: 8px;`;
const previewUrl = document.createElement('code'); previewUrl.style.cssText = `display: block; font-size: 12px; color: #c83232; background: rgba(200, 50, 50, 0.1); padding: 8px 12px; border-radius: 6px; margin-bottom: 16px; word-break: break-all;`;
previewUrl.textContent = `https://publishmarkdown.com/${this.currentPublishedIdentifier || 'your-identifier'}`;
const identifierInput = this.createInput(this.currentPublishedIdentifier || '', null, 'text', '输入自定义标识符 (如: my-article)');
identifierInput.addEventListener('input', () => {
previewUrl.textContent = `https://publishmarkdown.com/${identifierInput.value || 'your-identifier'}`;
});
const buttonContainer = document.createElement('div'); buttonContainer.style.cssText = `display: flex; gap: 12px; margin-top: 20px; justify-content: flex-end;`;
const cancelBtn = this.createButton('取消', 'secondary'); cancelBtn.addEventListener('click', () => dialog.remove());
const saveBtn = this.createButton('重新发布', 'primary');
saveBtn.style.background = '#c83232';
saveBtn.addEventListener('click', async () => {
const newIdentifier = identifierInput.value.trim();
if (!newIdentifier) {
this.showNotification('请输入有效的标识符', 'error');
return;
}
dialog.remove();
await this.handlePublishMarkdown(newIdentifier);
});
buttonContainer.appendChild(cancelBtn); buttonContainer.appendChild(saveBtn);
dialogContent.appendChild(dialogTitle); dialogContent.appendChild(helpText); dialogContent.appendChild(previewUrl);
dialogContent.appendChild(this.createFormGroup('新标识符', identifierInput)); dialogContent.appendChild(buttonContainer);
dialog.appendChild(dialogContent); this.shadowRoot.appendChild(dialog);
dialog.addEventListener('click', (e) => { if (e.target === dialog) dialog.remove(); });
}
updateTitleWithModel() {
if (!this.titleElement) return;
this.titleElement.textContent = ''; // Clear previous content safely
// 强制不换行,防止被挤压
this.titleElement.style.display = this.isCollapsed ? 'none' : 'flex';
this.titleElement.style.alignItems = 'center';
this.titleElement.style.whiteSpace = 'nowrap';
this.titleElement.style.flexShrink = '0';
const titleSpan = document.createElement('span');
titleSpan.textContent = '💡 船仓AI助手';
titleSpan.style.flexShrink = '0'; // 标题文字也不收缩
this.titleElement.appendChild(titleSpan);
// Spacer
const spacer = document.createElement('span');
spacer.textContent = ' ';
spacer.style.margin = '0 4px';
this.titleElement.appendChild(spacer);
// 收集所有有效平台的模型(有API Key且有模型名称的)
const validModels = [];
Object.keys(CONFIG.AI_MODELS).forEach(platformKey => {
if (platformKey !== 'TYPE') {
const platform = CONFIG.AI_MODELS[platformKey];
if (platform.API_KEY) {
// 优先使用用户勾选的 Selected Models
if (platform.SELECTED_MODELS && platform.SELECTED_MODELS.length > 0) {
platform.SELECTED_MODELS.forEach(m => {
validModels.push({
platformKey: platformKey,
platformName: platform.NAME || platformKey,
model: m
});
});
} else if (platform.MODEL) {
// 兼容旧逻辑:如果没有勾选,则显示当前默认模型
validModels.push({
platformKey: platformKey,
platformName: platform.NAME || platformKey,
model: platform.MODEL
});
}
}
}
});
if (validModels.length > 0) {
// Create Global Model Dropdown
const select = document.createElement('select');
this.globalModelSelect = select; // 保存引用以便同步
select.style.cssText = `border: none; background: transparent; font-weight: 500; font-size: 14px; color: #888; cursor: pointer; outline: none; appearance: none; -webkit-appearance: none; padding-right: 18px; width: 148px; text-overflow: ellipsis; flex-shrink: 0;`;
// 当前选中的平台和模型
const currentPlatformKey = CONFIG.AI_MODELS.TYPE;
const currentModel = CONFIG.AI_MODELS[currentPlatformKey]?.MODEL;
validModels.forEach(item => {
const option = document.createElement('option');
option.value = `${item.platformKey}|${item.model}`; // 存储平台key和模型名
option.textContent = `${item.model} - ${item.platformName}`;
if (item.platformKey === currentPlatformKey && item.model === currentModel) {
option.selected = true;
}
select.appendChild(option);
});
// Custom Arrow
select.style.backgroundImage = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M2.5 4.5L6 8L9.5 4.5' stroke='%23333' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E")`;
select.style.backgroundRepeat = 'no-repeat';
select.style.backgroundPosition = 'right center';
select.addEventListener('change', (e) => {
const [platformKey, modelName] = e.target.value.split('|');
// 切换到对应平台
CONFIG.AI_MODELS.TYPE = platformKey;
// 更新对应平台的模型(如果模型名不同)
if (CONFIG.AI_MODELS[platformKey]) {
CONFIG.AI_MODELS[platformKey].MODEL = modelName;
}
ConfigManager.saveConfig(CONFIG);
select.blur(); // Remove focus
this.showNotification(`已切换到: ${modelName} (${CONFIG.AI_MODELS[platformKey]?.NAME || platformKey})`, 'success');
});
// Hover effect
select.addEventListener('mouseenter', () => select.style.opacity = '0.7');
select.addEventListener('mouseleave', () => select.style.opacity = '1');
this.titleElement.appendChild(select);
} else {
// No valid models - show static text
const modelSpan = document.createElement('span');
modelSpan.textContent = '未配置模型';
modelSpan.style.color = '#999';
this.titleElement.appendChild(modelSpan);
}
}
getCurrentThemeStyles() {
const themeKey = (CONFIG.APPEARANCE && CONFIG.APPEARANCE.THEME) || 'default';
return (THEMES[themeKey] || THEMES['default']).styles;
}
createFormattedContent(container, text) {
while (container.firstChild) { container.removeChild(container.firstChild); }
// 预处理:将
和
转换为换行符
text = text.replace(/
/gi, '\n');
// 预处理代码块:将多行代码块转换为特殊标记
const processedText = this.preprocessCodeBlocks(text);
const lines = processedText.split('\n');
let currentList = null;
let listType = null;
let isFirstH1 = true;
let tableRows = []; // 用于收集表格行
let inTable = false;
const closeList = () => { if (currentList) { container.appendChild(currentList); currentList = null; listType = null; } };
const closeTable = () => {
if (tableRows.length > 0) {
const table = document.createElement('table');
const styles = this.getCurrentThemeStyles();
table.style.cssText = `width: 100%; border-collapse: collapse; margin: 1em 0; font-size: 13px;`;
// 检测分隔行的函数:匹配只包含 |、-、:、空格的行
const isSeparatorRow = (row) => /^\|[\s\-:|]+\|$/.test(row) && row.includes('-');
// 过滤掉分隔行,获取实际数据行
const dataRows = tableRows.filter(row => !isSeparatorRow(row));
dataRows.forEach((row, rowIndex) => {
const tr = document.createElement('tr');
const cells = row.split('|').filter((cell, i, arr) => i > 0 && i < arr.length - 1);
cells.forEach(cellText => {
const cell = document.createElement(rowIndex === 0 ? 'th' : 'td');
// 处理单元格内的
标签
const cleanedText = cellText.trim().replace(/
/gi, '\n');
// 使用 parseInlineFormatting 处理单元格内容,支持粗体、斜体等
this.parseTableCellContent(cell, cleanedText);
cell.style.cssText = rowIndex === 0 ? styles.th : styles.td;
tr.appendChild(cell);
});
table.appendChild(tr);
});
container.appendChild(table);
tableRows = [];
inTable = false;
}
};
const styles = this.getCurrentThemeStyles();
lines.forEach((line, lineIndex) => {
const trimmedLine = line.trim();
// 检测表格行 (以 | 开头和结尾)
if (trimmedLine.startsWith('|') && trimmedLine.endsWith('|')) {
closeList();
inTable = true;
tableRows.push(trimmedLine);
return;
} else if (inTable) {
// 表格结束
closeTable();
}
// 分隔线 - 使用渐变效果
if (trimmedLine === '---' || trimmedLine === '***' || trimmedLine === '___') {
closeList();
const hr = document.createElement('hr');
hr.style.cssText = styles.hr;
container.appendChild(hr);
}
// 引用块 - 红色主题
else if (trimmedLine.startsWith('> ')) {
closeList();
const blockquote = document.createElement('blockquote');
blockquote.style.cssText = styles.blockquote;
this.parseInlineFormatting(blockquote, trimmedLine.substring(2));
container.appendChild(blockquote);
}
// 六级标题
else if (trimmedLine.startsWith('###### ')) {
closeList();
const h = document.createElement('h6');
this.parseInlineFormatting(h, trimmedLine.substring(7));
h.style.cssText = styles.h6;
container.appendChild(h);
}
// 五级标题
else if (trimmedLine.startsWith('##### ')) {
closeList();
const h = document.createElement('h5');
this.parseInlineFormatting(h, trimmedLine.substring(6));
h.style.cssText = styles.h5;
container.appendChild(h);
}
// 四级标题
else if (trimmedLine.startsWith('#### ')) {
closeList();
const h = document.createElement('h4');
this.parseInlineFormatting(h, trimmedLine.substring(5));
h.style.cssText = styles.h4;
container.appendChild(h);
}
// 三级标题
else if (trimmedLine.startsWith('### ')) {
closeList();
const h = document.createElement('h3');
this.parseInlineFormatting(h, trimmedLine.substring(4));
h.style.cssText = styles.h3;
container.appendChild(h);
}
// 二级标题 - 带红色下划线
else if (trimmedLine.startsWith('## ')) {
closeList();
const h = document.createElement('h2');
this.parseInlineFormatting(h, trimmedLine.substring(3));
h.style.cssText = styles.h2;
container.appendChild(h);
}
// 一级标题 - 红色顶部条+背景 (首个h1特殊样式)
else if (trimmedLine.startsWith('# ')) {
closeList();
const h = document.createElement('h1');
this.parseInlineFormatting(h, trimmedLine.substring(2));
if (isFirstH1) {
h.style.cssText = styles.h1.first;
isFirstH1 = false;
} else {
h.style.cssText = styles.h1.normal;
}
container.appendChild(h);
}
// 代码块(预处理后的标记)
else if (trimmedLine.startsWith('___CODEBLOCK___')) {
closeList();
const codeData = trimmedLine.substring(15); // 移除标记前缀
const langMatch = codeData.match(/^LANG:(.*?):::/);
const lang = langMatch ? langMatch[1] : '';
const codeContent = langMatch ? codeData.substring(langMatch[0].length) : codeData;
const pre = document.createElement('pre');
pre.style.cssText = styles.pre;
const code = document.createElement('code');
code.textContent = codeContent.replace(/___NEWLINE___/g, '\n');
code.style.cssText = styles.code_block;
if (lang) {
const langLabel = document.createElement('div');
langLabel.textContent = lang;
langLabel.style.cssText = `font-size: 11px; color: #888; margin-bottom: 8px; font-family: -apple-system, sans-serif;`;
pre.appendChild(langLabel);
}
pre.appendChild(code);
container.appendChild(pre);
}
// 任务列表 - 未完成
else if (trimmedLine.startsWith('- [ ] ') || trimmedLine.startsWith('* [ ] ')) {
if (listType !== 'task') { closeList(); currentList = document.createElement('ul'); listType = 'task'; currentList.style.cssText = `padding-left: 0; margin: 1em 0; list-style-type: none;`; }
const li = document.createElement('li');
li.style.cssText = styles.li + ` display: flex; align-items: flex-start; gap: 8px;`;
const checkbox = document.createElement('span');
checkbox.textContent = '☐';
checkbox.style.cssText = styles.checkbox_unchecked;
li.appendChild(checkbox);
const textSpan = document.createElement('span');
this.parseInlineFormatting(textSpan, trimmedLine.substring(6));
li.appendChild(textSpan);
currentList.appendChild(li);
}
// 任务列表 - 已完成
else if (trimmedLine.startsWith('- [x] ') || trimmedLine.startsWith('- [X] ') || trimmedLine.startsWith('* [x] ') || trimmedLine.startsWith('* [X] ')) {
if (listType !== 'task') { closeList(); currentList = document.createElement('ul'); listType = 'task'; currentList.style.cssText = `padding-left: 0; margin: 1em 0; list-style-type: none;`; }
const li = document.createElement('li');
li.style.cssText = styles.li + ` display: flex; align-items: flex-start; gap: 8px; text-decoration: line-through; opacity: 0.8;`;
const checkbox = document.createElement('span');
checkbox.textContent = '☑';
checkbox.style.cssText = styles.checkbox_checked;
li.appendChild(checkbox);
const textSpan = document.createElement('span');
this.parseInlineFormatting(textSpan, trimmedLine.substring(6));
li.appendChild(textSpan);
currentList.appendChild(li);
}
// 无序列表
else if (trimmedLine.startsWith('- ') || trimmedLine.startsWith('* ')) {
if (listType !== 'ul') { closeList(); currentList = document.createElement('ul'); listType = 'ul'; currentList.style.cssText = styles.ul; }
const li = document.createElement('li');
li.style.cssText = styles.li;
this.parseInlineFormatting(li, trimmedLine.substring(2));
currentList.appendChild(li);
}
// 有序列表
else if (trimmedLine.match(/^\d+\.\s/)) {
if (listType !== 'ol') { closeList(); currentList = document.createElement('ol'); listType = 'ol'; currentList.style.cssText = styles.ol; }
const li = document.createElement('li');
li.style.cssText = styles.li;
this.parseInlineFormatting(li, trimmedLine.replace(/^\d+\.\s/, ''));
currentList.appendChild(li);
}
// 普通段落
else if (trimmedLine) {
closeList();
const p = document.createElement('p');
p.style.cssText = styles.p;
this.parseInlineFormatting(p, trimmedLine);
container.appendChild(p);
}
});
closeList();
closeTable(); // 确保最后的表格被渲染
}
parseInlineFormatting(element, text) {
const styles = this.getCurrentThemeStyles();
// 扩展正则匹配:粗体、斜体、行内代码、链接、删除线、HTML锚点
const parts = text.split(/(\*\*.*?\*\*|\*.*?\*|`.*?`|\[.*?\]\(.*?\)|~~.*?~~|<\/a>)/g);
parts.forEach(part => {
if (part.startsWith('**') && part.endsWith('**')) {
const s = document.createElement('strong');
s.textContent = part.slice(2, -2);
s.style.cssText = styles.strong;
element.appendChild(s);
}
else if (part.startsWith('~~') && part.endsWith('~~')) {
// 删除线
const s = document.createElement('span');
s.textContent = part.slice(2, -2);
s.style.cssText = styles.del;
element.appendChild(s);
}
else if (part.startsWith('*') && part.endsWith('*')) {
const em = document.createElement('em');
em.textContent = part.slice(1, -1);
em.style.cssText = styles.em;
element.appendChild(em);
}
else if (part.startsWith('`') && part.endsWith('`')) {
const c = document.createElement('code');
c.textContent = part.slice(1, -1);
c.style.cssText = styles.code; // Use inline code style
element.appendChild(c);
}
else if (part.startsWith('[') && part.includes('](') && part.endsWith(')')) {
// 链接处理
const linkMatch = part.match(/^\[(.*?)\]\((.*?)\)$/);
if (linkMatch) {
const a = document.createElement('a');
a.textContent = linkMatch[1];
a.href = linkMatch[2];
a.target = '_blank';
a.rel = 'noopener noreferrer';
a.style.cssText = styles.link;
element.appendChild(a);
} else {
element.appendChild(document.createTextNode(part));
}
}
else if (part.startsWith(' {
// 将代码内容中的换行符转换为特殊标记,便于单行处理
const escapedCode = code.replace(/\n/g, '___NEWLINE___').trim();
return `___CODEBLOCK___LANG:${lang}:::${escapedCode}`;
});
}
// 处理表格单元格内容,支持换行和内联格式
parseTableCellContent(cell, text) {
// 按换行符分割
const lines = text.split('\n');
lines.forEach((line, index) => {
// 对每一行应用内联格式
this.parseInlineFormatting(cell, line);
// 如果不是最后一行,添加换行元素
if (index < lines.length - 1) {
cell.appendChild(document.createElement('br'));
}
});
}
makeDraggable(element) {
let isDragging = false, startX, startY, currentX = 0, currentY = 0;
// 整个面板的拖拽(展开状态时)
element.addEventListener('mousedown', (e) => {
if (e.target.tagName === 'BUTTON' || e.target.closest('button')) return;
if (this.isCollapsed) return; // 收起状态时不允许通过顶部栏拖拽
isDragging = true;
startX = e.clientX - currentX;
startY = e.clientY - currentY;
this.container.style.transition = 'none';
});
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
e.preventDefault();
currentX = e.clientX - startX;
currentY = e.clientY - startY;
this.container.style.transform = `translate(${currentX}px, ${currentY}px)`;
});
document.addEventListener('mouseup', () => {
if (!isDragging) return;
isDragging = false;
this.container.style.transition = 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)';
});
// 设置收起状态图标的独立拖动功能
this.setupCollapsedIconDrag();
}
setupCollapsedIconDrag() {
let isDragging = false, startX, startY;
this.iconX = window.innerWidth - 60; // 图标初始X位置
this.iconY = 80; // 图标初始Y位置
this.isEdgeHidden = false;
// 图标的拖拽
this.toggleButton.addEventListener('mousedown', (e) => {
if (!this.isCollapsed) return; // 只在收起状态时可拖动
e.stopPropagation();
isDragging = true;
startX = e.clientX - this.iconX;
startY = e.clientY - this.iconY;
this.toggleButton.style.transition = 'none';
this.toggleButton.style.animation = 'none'; // 拖拽时暂停呼吸动画
});
document.addEventListener('mousemove', (e) => {
if (!isDragging || !this.isCollapsed) return;
e.preventDefault();
this.iconX = e.clientX - startX;
this.iconY = e.clientY - startY;
// 限制在屏幕范围内
this.iconX = Math.max(0, Math.min(this.iconX, window.innerWidth - 40));
this.iconY = Math.max(0, Math.min(this.iconY, window.innerHeight - 40));
this.container.style.cssText = `position: fixed; top: ${this.iconY}px; left: ${this.iconX}px; right: auto; width: auto; min-width: 0; background: transparent; box-shadow: none; backdrop-filter: none; border: none; padding: 0; z-index: 9999;`;
});
document.addEventListener('mouseup', (e) => {
if (!isDragging || !this.isCollapsed) return;
isDragging = false;
this.toggleButton.style.transition = 'all 0.3s ease';
// 检测是否拖到右边缘
if (this.iconX > window.innerWidth - 60) {
this.enterEdgeHiddenMode();
} else {
this.toggleButton.style.animation = 'pulse 2s ease-in-out infinite';
}
});
// 创建边缘触发区域用于唤醒
this.createEdgeTriggerZone();
}
createEdgeTriggerZone() {
this.edgeTriggerZone = document.createElement('div');
this.edgeTriggerZone.style.cssText = `position: fixed; top: 0; right: 0; width: 20px; height: 100%; z-index: 9998; background: transparent; display: none;`;
this.shadowRoot.appendChild(this.edgeTriggerZone);
// 鼠标进入边缘区域时唤醒图标
this.edgeTriggerZone.addEventListener('mouseenter', () => {
if (this.isEdgeHidden) {
this.showFromEdge();
}
});
}
enterEdgeHiddenMode() {
this.isEdgeHidden = true;
this.savedIconY = this.iconY;
// 将图标移动到右边缘外,只露出一小部分
this.iconX = window.innerWidth - 15;
this.container.style.cssText = `position: fixed; top: ${this.iconY}px; left: ${this.iconX}px; right: auto; width: auto; min-width: 0; background: transparent; box-shadow: none; backdrop-filter: none; border: none; padding: 0; z-index: 9999; transition: all 0.3s ease;`;
this.toggleButton.style.opacity = '0.5';
this.toggleButton.style.animation = 'none';
// 显示边缘触发区域
this.edgeTriggerZone.style.display = 'block';
}
showFromEdge() {
// 从边缘滑出显示
this.iconX = window.innerWidth - 50;
this.container.style.cssText = `position: fixed; top: ${this.iconY}px; left: ${this.iconX}px; right: auto; width: auto; min-width: 0; background: transparent; box-shadow: none; backdrop-filter: none; border: none; padding: 0; z-index: 9999; transition: all 0.3s ease;`;
this.toggleButton.style.opacity = '1';
this.toggleButton.style.animation = 'pulse 2s ease-in-out infinite';
// 鼠标离开图标时判断是否需要重新隐藏
const hideHandler = () => {
if (this.isEdgeHidden && !this.container.matches(':hover')) {
setTimeout(() => {
if (this.isEdgeHidden && !this.container.matches(':hover')) {
this.iconX = window.innerWidth - 15;
this.container.style.left = `${this.iconX}px`;
this.toggleButton.style.opacity = '0.5';
this.toggleButton.style.animation = 'none';
}
}, 500);
}
};
this.container.addEventListener('mouseleave', hideHandler, { once: true });
}
exitEdgeHiddenMode() {
this.isEdgeHidden = false;
this.toggleButton.style.opacity = '1';
this.edgeTriggerZone.style.display = 'none';
}
// ++ 在 makeDraggable() 函数结束后,粘贴下面所有代码 ++
handleFullscreenChange() {
// 检查当前是否有元素处于全屏状态
const isFullscreen = !!document.fullscreenElement;
// 如果进入全屏,则隐藏脚本容器;如果退出全屏,则显示它
if (isFullscreen) {
this.container.style.display = 'none';
} else {
this.container.style.display = 'block';
}
}
attachEventListeners() {
let lastUrl = location.href;
document.addEventListener('fullscreenchange', () => this.handleFullscreenChange());
new MutationObserver(() => {
if (location.href !== lastUrl) {
lastUrl = location.href;
if (this.container && this.container.parentNode) { this.container.remove(); }
if (PageManager.isYouTube(lastUrl) || PageManager.isWeChat(lastUrl) || PageManager.isBilibili(lastUrl)) {
initializeApp();
}
}
}).observe(document.body, { childList: true, subtree: true });
}
}
function getUid() {
const platform = PageManager.getCurrentPlatform();
if (platform === 'YOUTUBE') return new URL(window.location.href).searchParams.get('v') || 'unknown_video';
if (platform === 'WECHAT') { const m = window.location.href.match(/__biz=([^&]+)&mid=([^&]+)/); if (m) return `${m[1]}_${m[2]}`; return 'unknown_article'; }
return 'unknown';
}
function initializeApp() {
if (!PageManager.isYouTube() && !PageManager.isWeChat() && !PageManager.isBilibili()) return;
console.log(`🚀 船仓AI助手 初始化 on ${PageManager.getCurrentPlatform()}...`);
const contentController = new ContentController();
new UIManager(contentController);
console.log('✅ 船仓AI助手 初始化完成');
}
// --- 应用启动 ---
if (document.readyState === 'complete' || document.readyState === 'interactive') {
initializeApp();
} else {
document.addEventListener('DOMContentLoaded', initializeApp);
}
})();