[JavaScript] 纯文本查看 复制代码
// ==UserScript==
// @name 智语助手
// @version 1.0
// @description 精准提示词优化、中英翻译、自定义优化、快捷键操作、历史记录查询。
// @AuThor Lay.zhang
// @match *://*/*
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addStyle
// @grant GM_setClipboard
// @connect *
// @license MIT
// ==/UserScript==
(function () {
'use strict';
const P = 'tmo-';
const Z = { base: 99990, panel: 99992, modal: 99995, menu: 99998, toast: 99999, quick: 99997 };
const SK = { SETTINGS: 'tmo_s', HISTORY: 'tmo_h', BTN_POS: 'tmo_bp', PANEL_POS: 'tmo_pp', HIST_POS: 'tmo_hp', BTN_VIS: 'tmo_bv' };
const MAX_HIST = 100;
const MAX_CHARS_PER_CHUNK = 1500;
/* ─── API 预设 ──────────────────────────────────────────────── */
const API_PRESETS = [
{ name: 'DeepSeek', url: 'https://api.deepseek.com/v1', model: 'deepseek-chat' },
{ name: 'OpenAI', url: 'https://api.openai.com/v1', model: 'gpt-4o-mini' },
{ name: 'Moonshot', url: 'https://api.moonshot.cn/v1', model: 'moonshot-v1-8k' },
{ name: 'Qwen', url: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-plus' },
{ name: 'Gemini', url: 'https://generativelanguage.googleapis.com/v1beta/openai', model: 'gemini-2.0-flash' },
{ name: 'Ollama', url: 'http://localhost:11434/v1', model: 'llama3' },
];
const DEFAULT_SETTINGS = {
apiBaseUrl: 'https://api.deepseek.com/v1',
apiKey: '',
model: 'deepseek-chat',
temperature: 0.7,
maxTokens: 2000,
systemPrompt: `你是一名顶级提示词工程师(Prompt Engineer),同时精通中英文写作与文字优化。
你的职责是根据用户指定的【优化模式】,对输入文本进行精确处理。
核心规范:
1. 严格遵守每种模式的输出要求,不得混用模式规则
2. 只输出处理后的最终文本,禁止添加任何解释、序号前缀、markdown标题或多余标注
3. 保持原文意图不变,除非模式明确要求扩展或改变风格
4. 输出语言与原文一致(翻译模式除外)`,
streaming: false,
selBubble: true,
autoRetry: false,
maxRetries: 3,
};
/* ═══════════════════════════════════════════════════════════════
风格定义(增强 & 翻译)
═══════════════════════════════════════════════════════════════ */
const STYLES = [
{
id: 'enhance',
name: '🚀 提示词增强',
temperature: 0.7,
prompt: `【模式:提示词增强】
你将扮演提示词工程师,把用户输入的简短或模糊描述,扩展为一条结构完整、指令清晰、可直接投入使用的高质量 AI 提示词。
操作步骤:
① 识别用户的核心意图与目标受众
② 使用精准的动词指令替换模糊表达,不要过于冗余
③ 合并为一段流畅、完整的提示词,不要修改原有格式,不加任何标注或分项说明
现在请处理用户输入,直接输出优化后的提示词,不要有任何前缀或解释。`,
},
{
id: 'trans_en',
name: '🌐 译为英文',
temperature: 0.3,
prompt: `【模式:中译英】
将原文翻译成专业、地道的英文,遵循以下原则:
① 意译优先:不逐字硬译,根据英文表达习惯重组句子结构
② 词汇选择:优先使用母语级英文用词,避免"Chinese English"(如不用 "very very" "do" 动词滥用等)
③ 语气匹配:原文正式则译文正式,原文口语则译文口语
④ 专名处理:人名/地名保留拼音或使用通行英译;技术术语使用业界标准译法
⑤ 文化转换:涉及中文特有表达时,用对等的英文习语或加简短括号说明
只输出英文译文,不附中文,不加注释。`,
},
{
id: 'trans_zh',
name: '🌐 译为中文',
temperature: 0.3,
prompt: `【模式:英译中】
将原文翻译成流畅、自然的现代中文,遵循以下原则:
① 意译优先:不逐词直译,根据中文表达习惯重组语序,避免"翻译腔"
② 词汇选择:优先使用中文母语习惯的词汇,避免生硬的音译(有约定俗成译法的专名除外)
③ 语气匹配:正式原文译为书面中文;口语原文译为自然白话
④ 长句处理:适当拆分英文长句,符合中文"短句多、断句灵活"的节奏
⑤ 文化转换:西方文化特有概念用中文读者熟悉的对应表达,或加简短括号注释
只输出中文译文,不附英文,不加注释。`,
},
{ id: 'custom', name: '⚙ 自定义', temperature: null, prompt: '', isCustom: true },
];
/* ═══════════════════════════════════════════════════════════════
持久化存储
═══════════════════════════════════════════════════════════════ */
const store = {
get(k, def = null) {
try {
const v = GM_getValue(k, null);
if (v === null || v === undefined) return def;
if (typeof v === 'string') { try { return JSON.parse(v); } catch { return def; } }
return v;
} catch { return def; }
},
set(k, v) {
try { GM_setValue(k, JSON.stringify(v)); }
catch { try { localStorage.setItem(k, JSON.stringify(v)); } catch {} }
},
};
const getSettings = () => ({ ...DEFAULT_SETTINGS, ...store.get(SK.SETTINGS, {}) });
const saveSettings = s => store.set(SK.SETTINGS, s);
const getHistory = () => { const h = store.get(SK.HISTORY, []); return Array.isArray(h) ? h : []; };
const saveHistory = h => store.set(SK.HISTORY, h.slice(0, MAX_HIST));
/* ═══════════════════════════════════════════════════════════════
Toast 通知
═══════════════════════════════════════════════════════════════ */
function showToast(msg, type = 'info', ms = 2500) {
document.querySelector(`.${P}toast`)?.remove();
const el = document.createElement('div');
el.className = `${P}toast`;
const bg = { success: '#10b981', error: '#ef4444', info: '#6366f1', warning: '#f59e0b' }[type] ?? '#6366f1';
Object.assign(el.style, {
position: 'fixed', top: '18px', left: '50%', transform: 'translateX(-50%)',
zIndex: Z.toast, background: bg, color: '#fff', padding: '9px 18px',
borderRadius: '8px', fontSize: '13px', fontFamily: 'system-ui,sans-serif',
boxShadow: '0 6px 20px rgba(0,0,0,.22)', whiteSpace: 'nowrap', pointerEvents: 'none',
animation: `${P}fadeDown .25s ease`,
});
el.textContent = msg;
document.body.appendChild(el);
setTimeout(() => { el.style.transition = 'opacity .3s'; el.style.opacity = '0'; setTimeout(() => el.remove(), 300); }, ms);
}
/* ═══════════════════════════════════════════════════════════════
工具函数
═══════════════════════════════════════════════════════════════ */
const esc = s => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
function textStats(s) {
const chars = s.length;
const words = s.trim() ? s.trim().split(/\s+/).length : 0;
return chars > 0 ? `${chars}字${words > 1 ? ` ${words}词` : ''}` : '0字';
}
const TEXT_INPUT_TYPES = new Set(['text','search','url','email','tel','password','number','']);
function isTextEl(el) {
if (!el) return false;
if (el.tagName === 'TEXTAREA') return true;
if (el.tagName === 'INPUT') return TEXT_INPUT_TYPES.has((el.type || '').toLowerCase());
if (el.isContentEditable) return true;
return false;
}
function splitIntoChunks(text, maxLen = MAX_CHARS_PER_CHUNK) {
if (text.length <= maxLen) return [text];
const chunks = [];
const paragraphs = text.split(/\n{2,}/);
let buf = '';
for (const para of paragraphs) {
if (buf.length + para.length + 2 > maxLen) {
if (buf) { chunks.push(buf.trim()); buf = ''; }
if (para.length > maxLen) {
const sentences = para.split(/(?<=[。!?.!?])/);
for (const sent of sentences) {
if (buf.length + sent.length > maxLen) {
if (buf) { chunks.push(buf.trim()); buf = ''; }
if (sent.length > maxLen) {
for (let i = 0; i < sent.length; i += maxLen) chunks.push(sent.slice(i, i + maxLen));
} else { buf = sent; }
} else { buf += sent; }
}
} else { buf = para; }
} else {
buf += (buf ? '\n\n' : '') + para;
}
}
if (buf.trim()) chunks.push(buf.trim());
return chunks;
}
function makeDraggable(el, handle, { exclude = null, onEnd = null } = {}) {
let dragging = false, ox = 0, oy = 0;
const start = (cx, cy) => {
dragging = true;
const r = el.getBoundingClientRect();
ox = cx - r.left; oy = cy - r.top;
el.style.transition = 'none';
};
const move = (cx, cy) => {
if (!dragging) return;
const x = Math.max(0, Math.min(window.innerWidth - el.offsetWidth, cx - ox));
const y = Math.max(0, Math.min(window.innerHeight - el.offsetHeight, cy - oy));
el.style.left = x + 'px'; el.style.top = y + 'px';
};
const end = () => {
if (!dragging) return;
dragging = false;
el.style.transition = '';
onEnd?.({ x: parseInt(el.style.left) || 0, y: parseInt(el.style.top) || 0 });
};
handle.addEventListener('mousedown', e => {
if (e.button !== 0) return;
if (exclude && e.target.closest(exclude)) return;
e.preventDefault(); start(e.clientX, e.clientY);
});
handle.addEventListener('touchstart', e => {
if (e.touches.length !== 1) return;
if (exclude && e.target.closest(exclude)) return;
e.preventDefault(); start(e.touches[0].clientX, e.touches[0].clientY);
}, { passive: false });
document.addEventListener('mousemove', e => move(e.clientX, e.clientY));
document.addEventListener('touchmove', e => {
if (dragging) { e.preventDefault(); move(e.touches[0].clientX, e.touches[0].clientY); }
}, { passive: false });
document.addEventListener('mouseup', end);
document.addEventListener('touchend', end);
}
/* ═══════════════════════════════════════════════════════════════
主类 TextOptimizer
═══════════════════════════════════════════════════════════════ */
class TextOptimizer {
constructor() {
this.cfg = getSettings();
this.history = getHistory();
this.open = false;
this.loading = false;
this.srcEl = null;
this.srcSel = null;
this.style = 'enhance';
this.abort = null;
this.btnVis = store.get(SK.BTN_VIS, true) !== false;
this.btnPos = store.get(SK.BTN_POS, null) || { x: window.innerWidth - 70, y: window.innerHeight / 2 - 25 };
this.panelPos = store.get(SK.PANEL_POS, null) || { x: Math.max(20, window.innerWidth - 450), y: Math.max(20, window.innerHeight / 2 - 300) };
this._draggingBtn = false;
this._btnOffset = { x: 0, y: 0 };
this._bubbleText = '';
this._chunkTotal = 0;
this._chunkDone = 0;
this.lastSelectionRange = null;
this._lastQuickOriginal = '';
this._lastQuickTargetLang = '';
this.quickPanel = null;
this.activeQuickAbort = null;
this._bubbleVisible = false;
this.init();
}
/* ── 初始化 ─────────────────────────────────────────────── */
init() {
this.injectCSS();
this.createBtn();
this.createPanel();
this.createBubble();
this.createHistoryModal();
this.createQuickTranslatePanel();
this.bindKeys();
}
/* ── 注入样式(保持原有)────────────────────────────────── */
injectCSS() {
GM_addStyle(`
@keyframes ${P}fadeDown { from{opacity:0;transform:translateX(-50%) translateY(-16px);}to{opacity:1;transform:translateX(-50%) translateY(0);} }
@keyframes ${P}fadeIn { from{opacity:0;transform:scale(.93);}to{opacity:1;transform:scale(1);} }
@keyframes ${P}pulse { 0%,100%{box-shadow:0 0 0 0 rgba(99,102,241,.5);}50%{box-shadow:0 0 0 11px rgba(99,102,241,0);} }
@keyframes ${P}spin { to{transform:rotate(360deg);} }
@keyframes ${P}bubblePop { from{opacity:0;transform:translateY(5px) scale(.94);}to{opacity:1;transform:none;} }
.${P}btn{position:fixed;width:46px;height:46px;border-radius:50%;
background:linear-gradient(135deg,#667eea,#764ba2);cursor:pointer;
z-index:${Z.base};display:flex;align-items:center;justify-content:center;
font-size:22px;box-shadow:0 4px 16px rgba(102,126,234,.45);
user-select:none;border:2px solid rgba(255,255,255,.22);
animation:${P}pulse 2.5s infinite;transition:transform .2s,box-shadow .2s;}
.${P}btn:hover{transform:scale(1.1);box-shadow:0 8px 28px rgba(102,126,234,.6);}
.${P}btn.drag{animation:none;cursor:grabbing;box-shadow:0 12px 32px rgba(0,0,0,.3);}
.${P}panel{position:fixed;z-index:${Z.panel};width:430px;
max-width:calc(100vw - 12px);max-height:88vh;
background:#fff;border-radius:16px;
box-shadow:0 20px 60px rgba(0,0,0,.2),0 0 0 1px rgba(0,0,0,.05);
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Microsoft YaHei',sans-serif;
display:none;flex-direction:column;overflow:hidden;
animation:${P}fadeIn .22s ease;}
.${P}panel.open{display:flex;}
.${P}ph{display:flex;align-items:center;padding:11px 14px;
background:linear-gradient(135deg,#667eea,#764ba2);color:#fff;
cursor:move;user-select:none;gap:8px;flex-shrink:0;
border-radius:16px 16px 0 0;}
.${P}ph-title{flex:1;font-weight:600;font-size:14px;}
.${P}hbtn{width:28px;height:28px;border-radius:50%;border:none;
background:rgba(255,255,255,.22);color:#fff;cursor:pointer;
font-size:13px;display:flex;align-items:center;justify-content:center;
padding:0;transition:background .18s;flex-shrink:0;}
.${P}hbtn:hover{background:rgba(255,255,255,.38);}
.${P}body{flex:1;overflow-y:auto;padding:12px 14px;
display:flex;flex-direction:column;gap:9px;scrollbar-width:thin;}
.${P}body::-webkit-scrollbar{width:4px;}
.${P}body::-webkit-scrollbar-thumb{background:#d1d5db;border-radius:2px;}
.${P}settings{display:none;flex-direction:column;gap:8px;
padding:10px 12px;background:#f8fafc;border-radius:10px;
border:1px solid #e2e8f0;}
.${P}settings.show{display:flex;}
.${P}set-label{font-size:10px;font-weight:700;color:#64748b;
text-transform:uppercase;letter-spacing:.7px;}
.${P}presets{display:flex;flex-wrap:wrap;gap:5px;}
.${P}preset{padding:4px 10px;border-radius:10px;border:1px solid #e2e8f0;
background:#fff;cursor:pointer;font-size:11px;font-weight:500;
color:#475569;transition:all .15s;}
.${P}preset:hover{border-color:#667eea;color:#667eea;background:#f5f3ff;}
.${P}set-row{display:flex;align-items:center;gap:8px;font-size:12px;color:#475569;}
.${P}set-row label{min-width:58px;font-weight:500;}
.${P}set-inp{flex:1;padding:5px 8px;border:1px solid #e2e8f0;
border-radius:6px;font-size:12px;outline:none;background:#fff;}
.${P}set-inp:focus{border-color:#667eea;}
.${P}set-ta{width:100%;min-height:46px;padding:5px 8px;font-size:11px;
border:1px solid #e2e8f0;border-radius:6px;outline:none;font-family:inherit;
resize:vertical;background:#fff;box-sizing:border-box;}
.${P}set-ta:focus{border-color:#667eea;}
.${P}check-row{display:flex;flex-wrap:wrap;gap:12px;align-items:center;}
.${P}check-row label{display:flex;align-items:center;gap:4px;
font-size:12px;color:#475569;cursor:pointer;user-select:none;}
.${P}lbl{font-size:10px;font-weight:700;color:#64748b;
text-transform:uppercase;letter-spacing:.7px;}
.${P}tags{display:flex;flex-wrap:wrap;gap:5px;}
.${P}tag{padding:5px 11px;border-radius:20px;border:1.5px solid #e2e8f0;
background:#fff;cursor:pointer;font-size:12px;font-weight:500;
color:#374151;transition:all .16s;user-select:none;}
.${P}tag:hover{border-color:#667eea;background:#f5f3ff;}
.${P}tag.on{border-color:#667eea;background:#667eea;color:#fff;}
.${P}custom{display:none;flex-direction:column;gap:4px;}
.${P}custom.show{display:flex;}
.${P}custom textarea{width:100%;min-height:56px;padding:7px 9px;
border:1.5px solid #e2e8f0;border-radius:8px;font-size:12px;
font-family:inherit;resize:vertical;outline:none;box-sizing:border-box;}
.${P}custom textarea:focus{border-color:#667eea;}
.${P}taw{position:relative;}
.${P}ta{width:100%;min-height:88px;max-height:200px;
padding:8px 26px 18px 10px;border:1.5px solid #e2e8f0;border-radius:10px;
font-size:13px;font-family:inherit;resize:vertical;outline:none;
transition:border-color .2s,background .2s;box-sizing:border-box;
background:#f8fafc;overflow-y:auto;}
.${P}ta:focus{border-color:#667eea;background:#fff;}
.${P}ta.out{background:#f0fdf4;border-color:#86efac;cursor:default;}
.${P}ta.stream{border-color:#667eea;background:#fafaff;}
.${P}ta.err{border-color:#fca5a5;background:#fef2f2;}
.${P}ta-cnt{position:absolute;bottom:5px;right:8px;
font-size:10px;color:#9ca3af;pointer-events:none;user-select:none;}
.${P}ta-x{position:absolute;top:6px;right:6px;width:17px;height:17px;
border-radius:50%;border:none;background:#e5e7eb;cursor:pointer;
font-size:10px;color:#6b7280;display:none;align-items:center;
justify-content:center;padding:0;line-height:1;transition:background .15s;}
.${P}ta-x:hover{background:#d1d5db;color:#111;}
.${P}taw:hover .${P}ta-x{display:flex;}
.${P}progress{display:none;flex-direction:column;gap:4px;
background:#f0f9ff;border:1px solid #bae6fd;border-radius:8px;padding:8px 10px;}
.${P}progress.show{display:flex;}
.${P}progress-label{font-size:11px;color:#0369a1;font-weight:500;}
.${P}progress-bar{height:4px;background:#e0f2fe;border-radius:2px;overflow:hidden;}
.${P}progress-fill{height:100%;background:linear-gradient(90deg,#667eea,#764ba2);
border-radius:2px;transition:width .3s ease;}
.${P}row{display:flex;gap:7px;flex-wrap:wrap;}
.${P}bp{flex:1;min-width:80px;padding:9px 14px;border-radius:9px;border:none;
cursor:pointer;font-size:12px;font-weight:600;
background:linear-gradient(135deg,#667eea,#764ba2);color:#fff;
display:flex;align-items:center;justify-content:center;gap:5px;
transition:all .2s;}
.${P}bp:hover:not(:disabled){transform:translateY(-1px);box-shadow:0 4px 14px rgba(102,126,234,.4);}
.${P}bp:disabled{opacity:.6;cursor:not-allowed;}
.${P}bs{padding:9px 12px;border-radius:9px;border:1.5px solid #e2e8f0;
cursor:pointer;font-size:12px;font-weight:500;background:#fff;
color:#374151;display:flex;align-items:center;justify-content:center;
gap:4px;transition:all .16s;}
.${P}bs:hover{border-color:#667eea;color:#667eea;background:#f5f3ff;}
.${P}bs.danger{border-color:#fca5a5;color:#b91c1c;}
.${P}bs.danger:hover{background:#fef2f2;}
.${P}spin{display:inline-block;width:13px;height:13px;border-radius:50%;
border:2px solid rgba(255,255,255,.3);border-top-color:#fff;
animation:${P}spin .7s linear infinite;flex-shrink:0;}
.${P}bubble{position:fixed;z-index:${Z.menu};background:#1e293b;
border-radius:9px;padding:5px 7px;display:none;gap:3px;
box-shadow:0 8px 24px rgba(0,0,0,.3);animation:${P}bubblePop .16s ease;}
.${P}bubble::after{content:'';position:absolute;bottom:-5px;left:50%;
transform:translateX(-50%);border:5px solid transparent;
border-bottom:none;border-top-color:#1e293b;}
.${P}bbtn{padding:4px 9px;border-radius:5px;border:none;
background:transparent;color:#e2e8f0;cursor:pointer;
font-size:12px;font-weight:500;transition:background .15s;white-space:nowrap;}
.${P}bbtn:hover{background:rgba(255,255,255,.15);}
.${P}bsep{width:1px;background:rgba(255,255,255,.18);align-self:stretch;margin:3px 0;}
/* 快速翻译浮层 */
.${P}quick-panel{position:fixed;z-index:${Z.quick};width:340px;
max-width:calc(100vw - 24px);background:#fff;border-radius:12px;
box-shadow:0 12px 32px rgba(0,0,0,.25),0 0 0 1px rgba(0,0,0,.05);
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
display:none;flex-direction:column;overflow:hidden;animation:${P}fadeIn .18s ease;}
.${P}quick-panel.show{display:flex;}
.${P}quick-head{display:flex;align-items:center;padding:8px 12px;
background:linear-gradient(135deg,#667eea,#764ba2);color:#fff;
cursor:move;user-select:none;gap:6px;font-size:12px;font-weight:500;}
.${P}quick-head span{flex:1;}
.${P}quick-close{background:rgba(255,255,255,.2);border:none;
border-radius:6px;color:#fff;width:24px;height:24px;
display:flex;align-items:center;justify-content:center;
cursor:pointer;font-size:14px;}
.${P}quick-body{max-height:280px;overflow-y:auto;padding:12px;
font-size:13px;line-height:1.5;white-space:pre-wrap;
word-break:break-word;background:#fefefe;}
.${P}quick-loading{display:flex;align-items:center;gap:8px;
color:#667eea;font-size:12px;padding:12px;justify-content:center;}
.${P}quick-actions{display:flex;gap:8px;padding:8px 12px;
border-top:1px solid #eef2f6;background:#fafcff;flex-wrap:wrap;}
.${P}quick-actions button{flex:1;padding:6px 0;border-radius:6px;
border:1px solid #e2e8f0;background:#fff;cursor:pointer;
font-size:12px;transition:all .15s;}
.${P}quick-actions button:hover{background:#f1f5f9;border-color:#667eea;color:#667eea;}
.${P}quick-err{color:#e11d48;background:#fff0f3;padding:10px;
border-radius:8px;font-size:12px;margin:6px;}
.${P}hov{display:none;position:fixed;inset:0;
background:rgba(0,0,0,.38);z-index:${Z.modal};}
.${P}hov.open{display:block;}
.${P}hbox{position:absolute;background:#fff;border-radius:16px;
padding:18px 20px;width:min(720px,94vw);max-height:85vh;
display:flex;flex-direction:column;
box-shadow:0 20px 40px rgba(0,0,0,.3);
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC',sans-serif;
transform:translate(-50%,-50%);left:50%;top:50%;}
.${P}hhdr{display:flex;align-items:center;gap:8px;margin-bottom:10px;
cursor:move;user-select:none;padding-bottom:10px;
border-bottom:1px solid #f1f5f9;flex-shrink:0;}
.${P}htitle{flex:1;font-size:15px;font-weight:600;color:#1e293b;margin:0;}
.${P}hsearch{flex:1.4;padding:6px 10px;border:1.5px solid #e2e8f0;
border-radius:8px;font-size:13px;outline:none;min-width:0;}
.${P}hsearch:focus{border-color:#667eea;}
.${P}hlist{flex:1;overflow-y:auto;display:flex;flex-direction:column;
gap:10px;min-height:60px;scrollbar-width:thin;padding-right:2px;}
.${P}hempty{text-align:center;color:#9ca3af;padding:24px;font-size:14px;}
.${P}hi{background:#f8fafc;border:1px solid #e2e8f0;border-radius:10px;padding:11px;}
.${P}hi-meta{display:flex;justify-content:space-between;align-items:center;
margin-bottom:7px;font-size:11px;color:#64748b;}
.${P}hi-badge{background:#667eea;color:#fff;padding:2px 8px;
border-radius:10px;font-size:10px;font-weight:600;}
.${P}hi-cols{display:flex;gap:8px;}
.${P}hi-col{flex:1;background:#fff;border:1px solid #e2e8f0;
border-radius:7px;padding:7px;font-size:11px;max-height:60px;
overflow-y:auto;white-space:pre-wrap;word-break:break-word;color:#374151;}
.${P}hi-acts{display:flex;gap:6px;margin-top:8px;flex-wrap:wrap;}
.${P}hi-acts button{padding:4px 10px;border-radius:6px;border:1px solid #e2e8f0;
background:#fff;cursor:pointer;font-size:11px;color:#475569;transition:all .15s;}
.${P}hi-acts button:hover{border-color:#667eea;color:#667eea;background:#f5f3ff;}
.${P}hi-acts button.del{color:#b91c1c;}
.${P}hi-acts button.del:hover{border-color:#fca5a5;background:#fef2f2;}
.${P}hfoot{display:flex;gap:8px;margin-top:12px;padding-top:10px;
border-top:1px solid #f1f5f9;flex-shrink:0;}
@media(max-width:480px){
.${P}panel{width:calc(100vw - 8px);border-radius:12px;}
.${P}btn{width:40px;height:40px;font-size:18px;}
.${P}hbox{padding:14px 12px;width:96vw;}
.${P}quick-panel{width:280px;}
}
`);
}
/* ── 悬浮球 ─────────────────────────────────────────────── */
createBtn() {
this.btnEl = document.createElement('div');
this.btnEl.className = `${P}btn`;
this.btnEl.title = '智语助手';
this.btnEl.innerHTML = '✨';
Object.assign(this.btnEl.style, { left: this.btnPos.x + 'px', top: this.btnPos.y + 'px' });
document.body.appendChild(this.btnEl);
if (!this.btnVis) this.btnEl.style.display = 'none';
let wasDragged = false, startXY = null;
const onMove = (cx, cy) => {
if (!this._draggingBtn) return;
if (startXY && Math.hypot(cx - startXY.x, cy - startXY.y) > 4) wasDragged = true;
const x = Math.max(0, Math.min(window.innerWidth - 48, cx - this._btnOffset.x));
const y = Math.max(0, Math.min(window.innerHeight - 48, cy - this._btnOffset.y));
this.btnEl.style.left = x + 'px'; this.btnEl.style.top = y + 'px';
this.btnPos = { x, y };
};
const onEnd = () => {
if (!this._draggingBtn) return;
this._draggingBtn = false;
this.btnEl.classList.remove('drag');
store.set(SK.BTN_POS, this.btnPos);
setTimeout(() => { wasDragged = false; }, 60);
};
const onStart = (cx, cy) => {
wasDragged = false; startXY = { x: cx, y: cy };
const r = this.btnEl.getBoundingClientRect();
this._btnOffset = { x: cx - r.left, y: cy - r.top };
this._draggingBtn = true;
this.btnEl.classList.add('drag');
};
this.btnEl.addEventListener('mousedown', e => { if (e.button !== 0) return; e.preventDefault(); onStart(e.clientX, e.clientY); });
document.addEventListener('mousemove', e => onMove(e.clientX, e.clientY));
document.addEventListener('mouseup', onEnd);
this.btnEl.addEventListener('touchstart', e => {
if (e.touches.length !== 1) return; e.preventDefault(); onStart(e.touches[0].clientX, e.touches[0].clientY);
}, { passive: false });
document.addEventListener('touchmove', e => {
if (this._draggingBtn) { e.preventDefault(); onMove(e.touches[0].clientX, e.touches[0].clientY); }
}, { passive: false });
document.addEventListener('touchend', onEnd);
this.btnEl.addEventListener('click', () => { if (!wasDragged) this.togglePanel(); });
}
/* ── 主面板(保持原有)──────────────────────────────────── */
createPanel() {
this.panelEl = document.createElement('div');
this.panelEl.className = `${P}panel`;
Object.assign(this.panelEl.style, { left: this.panelPos.x + 'px', top: this.panelPos.y + 'px' });
this.panelEl.innerHTML = this._panelHTML();
document.body.appendChild(this.panelEl);
makeDraggable(this.panelEl, this.panelEl.querySelector(`.${P}ph`), {
exclude: `.${P}hbtn`,
onEnd: pos => { this.panelPos = pos; store.set(SK.PANEL_POS, pos); },
});
this._bindPanel();
this.setStyle(this.style);
}
_panelHTML() {
const presets = API_PRESETS.map(p =>
`<span class="${P}preset" data-url="${esc(p.url)}" data-model="${esc(p.model)}">${esc(p.name)}</span>`
).join('');
const tags = STYLES.map(s =>
`<span class="${P}tag" data-style="${s.id}">${s.name}</span>`
).join('');
return `
<div class="${P}ph">
<span class="${P}ph-title">✨智语助手</span>
<button class="${P}hbtn" id="${P}btn-cfg" title="设置">⚙</button>
<button class="${P}hbtn" id="${P}btn-close" title="关闭 Esc">✕</button>
</div>
<div class="${P}body">
<div class="${P}settings" id="${P}settings">
<span class="${P}set-label">快速切换服务商</span>
<div class="${P}presets">${presets}</div>
<span class="${P}set-label">API 配置</span>
<div class="${P}set-row"><label>Base URL</label><input class="${P}set-inp" id="${P}s-url" type="text" spellcheck="false"></div>
<div class="${P}set-row"><label>API Key</label> <input class="${P}set-inp" id="${P}s-key" type="password"></div>
<div class="${P}set-row"><label>Model</label> <input class="${P}set-inp" id="${P}s-model" type="text" spellcheck="false"></div>
<span class="${P}set-label">系统提示词</span>
<textarea class="${P}set-ta" id="${P}s-sys" rows="3"></textarea>
<div class="${P}check-row">
<label><input type="checkbox" id="${P}s-bubble"> 选文弹窗</label>
</div>
<div class="${P}row">
<button class="${P}bp" id="${P}btn-save" style="font-size:12px;padding:7px 12px;">💾 保存设置</button>
<button class="${P}bs" id="${P}btn-vis" style="font-size:12px;padding:7px 12px;"></button>
</div>
</div>
<span class="${P}lbl">操作模式</span>
<div class="${P}tags" id="${P}tags">${tags}</div>
<div class="${P}custom" id="${P}custom">
<span style="font-size:11px;color:#64748b">自定义指令(直接写你的需求,无需加"请")</span>
<textarea id="${P}custom-txt" placeholder="例:将文本改写为微信朋友圈风格,加适量emoji,不超过200字"></textarea>
</div>
<span class="${P}lbl">输入文本</span>
<div class="${P}taw">
<textarea class="${P}ta" id="${P}input" placeholder="在此输入或粘贴需要操作的文本(或选中文字按快捷键自动填入)…\n快捷键:\nCtrl+Shift+O 开启面板/自动填充\nCtrl+Shift+Enter 快捷运行\nCtrl+Shift+H 隐藏/显示悬浮球\nCtrl+Shift+D 开关选文弹窗"></textarea>
<button class="${P}ta-x" id="${P}input-x" title="清空">✕</button>
<span class="${P}ta-cnt" id="${P}ic">0字</span>
</div>
<div class="${P}progress" id="${P}progress">
<div class="${P}progress-label" id="${P}progress-label">处理中…</div>
<div class="${P}progress-bar"><div class="${P}progress-fill" id="${P}progress-fill" style="width:0%"></div></div>
</div>
<div class="${P}row">
<button class="${P}bp" id="${P}btn-opt">🚀 运行</button>
<button class="${P}bs" id="${P}btn-stop" style="display:none;">⏹ 停止</button>
</div>
<span class="${P}lbl">输出结果</span>
<div class="${P}taw">
<textarea class="${P}ta out" id="${P}output" placeholder="输出完成后结果将显示在此处…" readonly></textarea>
<button class="${P}ta-x" id="${P}output-x" title="清空">✕</button>
<span class="${P}ta-cnt" id="${P}oc">0字</span>
</div>
<div class="${P}row">
<button class="${P}bs" id="${P}btn-copy">📋 复制</button>
<button class="${P}bs" id="${P}btn-apply">✅ 应用</button>
</div>
<div class="${P}row">
<button class="${P}bs" id="${P}btn-hist" style="flex:1;">📜 历史记录</button>
</div>
</div>`;
}
_bindPanel() {
const q = id => this.panelEl.querySelector(`#${P}${id}`);
const qa = sel => this.panelEl.querySelectorAll(sel);
q('btn-close').addEventListener('click', () => this.closePanel());
q('btn-cfg').addEventListener('click', () => {
q('settings').classList.toggle('show');
if (q('settings').classList.contains('show')) this._syncSettingsUI();
});
qa(`.${P}preset`).forEach(el => el.addEventListener('click', () => {
q('s-url').value = el.dataset.url;
q('s-model').value = el.dataset.model;
}));
q('btn-save').addEventListener('click', () => this._saveSettingsUI());
if (store.get(SK.BTN_VIS, null) === null) {
store.set(SK.BTN_VIS, true); // 首次安装默认显示
}
this.btnVis = store.get(SK.BTN_VIS, true) !== false;
q('btn-vis').addEventListener('click', () => {
this.btnVis = !this.btnVis;
store.set(SK.BTN_VIS, this.btnVis);
this.btnEl.style.display = this.btnVis ? 'flex' : 'none';
q('btn-vis').textContent = this.btnVis ? '隐藏悬浮球' : '显示悬浮球';
showToast(this.btnVis ? '悬浮球已显示' : '悬浮球已隐藏', 'info');
});
q('tags').addEventListener('click', e => {
const t = e.target.closest(`.${P}tag`);
if (t) this.setStyle(t.dataset.style);
});
const inpEl = q('input'), outEl = q('output');
const autoResize = ta => { ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, 200) + 'px'; };
inpEl.addEventListener('input', () => { q('ic').textContent = textStats(inpEl.value); autoResize(inpEl); });
outEl.addEventListener('input', () => { q('oc').textContent = textStats(outEl.value); autoResize(outEl); });
q('input-x').addEventListener('click', () => { inpEl.value = ''; q('ic').textContent = '0字'; inpEl.style.height = ''; inpEl.focus(); });
q('output-x').addEventListener('click', () => { outEl.value = ''; q('oc').textContent = '0字'; outEl.style.height = ''; });
q('btn-opt').addEventListener('click', () => this.optimize());
q('btn-stop').addEventListener('click', () => this.stopOptimize());
q('btn-copy').addEventListener('click', () => this.copyResult());
q('btn-apply').addEventListener('click',() => this.applyResult());
q('btn-hist').addEventListener('click', () => this.openHistory());
}
_syncSettingsUI() {
const q = id => this.panelEl.querySelector(`#${P}${id}`);
q('s-url').value = this.cfg.apiBaseUrl;
q('s-key').value = this.cfg.apiKey;
q('s-model').value = this.cfg.model;
q('s-sys').value = this.cfg.systemPrompt;
q('s-bubble').checked = this.cfg.selBubble !== false;
q('btn-vis').textContent = this.btnVis ? '隐藏悬浮球' : '显示悬浮球';
}
_saveSettingsUI() {
const q = id => this.panelEl.querySelector(`#${P}${id}`);
this.cfg = {
...this.cfg,
apiBaseUrl: q('s-url').value.trim() || DEFAULT_SETTINGS.apiBaseUrl,
apiKey: q('s-key').value.trim(),
model: q('s-model').value.trim() || DEFAULT_SETTINGS.model,
systemPrompt: q('s-sys').value.trim() || DEFAULT_SETTINGS.systemPrompt,
selBubble: q('s-bubble').checked,
};
saveSettings(this.cfg);
showToast('✅ 设置已保存', 'success');
this.panelEl.querySelector(`#${P}settings`).classList.remove('show');
}
setStyle(id) {
this.style = id;
this.panelEl.querySelectorAll(`.${P}tag`).forEach(t => t.classList.toggle('on', t.dataset.style === id));
this.panelEl.querySelector(`#${P}custom`).classList.toggle('show', id === 'custom');
}
togglePanel() { this.open ? this.closePanel() : this.openPanel(); }
openPanel(text = null) {
if (this.open) {
if (text !== null) this._setInput(text);
return;
}
this.open = true;
this.panelEl.classList.add('open');
this._clampPanelToViewport();
text !== null ? this._setInput(text) : setTimeout(() => this._grabText(), 80);
}
_clampPanelToViewport() {
let x = parseInt(this.panelEl.style.left) || 0;
let y = parseInt(this.panelEl.style.top) || 0;
x = Math.max(0, Math.min(window.innerWidth - Math.min(430, window.innerWidth - 12), x));
y = Math.max(0, Math.min(window.innerHeight - 80, y));
this.panelEl.style.left = x + 'px'; this.panelEl.style.top = y + 'px';
}
closePanel() {
if (!this.open) return;
this.open = false;
this.panelEl.classList.remove('open');
this.stopOptimize();
}
_setInput(text) {
const ta = this.panelEl.querySelector(`#${P}input`);
ta.value = text;
ta.style.height = 'auto';
ta.style.height = Math.min(ta.scrollHeight, 200) + 'px';
this.panelEl.querySelector(`#${P}ic`).textContent = textStats(text);
}
_grabText() {
const sel = window.getSelection()?.toString().trim();
const ae = document.activeElement;
if (sel) {
if (ae && ae.tagName === 'TEXTAREA') {
this.srcEl = ae;
this.srcSel = { start: ae.selectionStart, end: ae.selectionEnd };
} else if (ae && ae.tagName === 'INPUT' && isTextEl(ae)) {
this.srcEl = ae;
this.srcSel = { start: ae.selectionStart, end: ae.selectionEnd };
} else if (ae?.isContentEditable) {
this.srcEl = ae; this.srcSel = null;
}
this._setInput(sel);
return;
}
if (!ae) return;
if (ae.tagName === 'TEXTAREA' || (ae.tagName === 'INPUT' && isTextEl(ae))) {
const val = ae.value?.trim();
if (val) { this.srcEl = ae; this.srcSel = null; this._setInput(val); }
} else if (ae.isContentEditable && ae.innerText.trim()) {
this.srcEl = ae; this.srcSel = null; this._setInput(ae.innerText.trim());
}
}
/* ── 核心优化流程(完整保留)──────────────────────────────── */
async optimize() {
if (this.loading) return;
const inpEl = this.panelEl.querySelector(`#${P}input`);
const outEl = this.panelEl.querySelector(`#${P}output`);
const text = inpEl.value.trim();
if (!text) { showToast('请输入文本', 'warning'); inpEl.focus(); return; }
if (!this.cfg.apiKey) { showToast('请先在设置中填写 API Key', 'warning'); this.panelEl.querySelector(`#${P}settings`).classList.add('show'); this._syncSettingsUI(); return; }
let prompt = STYLES.find(s => s.id === this.style)?.prompt ?? '';
if (this.style === 'custom') {
const raw = this.panelEl.querySelector(`#${P}custom-txt`).value.trim();
prompt = raw ? `【自定义模式】\n${raw}\n\n只输出处理后的文本,不加任何解释或前缀。` : '请优化以下文本,只返回结果。';
}
this._setLoading(true);
outEl.value = ''; outEl.className = `${P}ta out`;
this.panelEl.querySelector(`#${P}oc`).textContent = '0字';
try {
const chunks = splitIntoChunks(text, MAX_CHARS_PER_CHUNK);
if (chunks.length > 1) await this._optimizeLong(chunks, prompt, outEl);
else if (this.cfg.streaming) await this._optimizeStream(text, prompt, outEl);
else { const result = await this._callWithRetry(text, prompt); outEl.value = result; this._syncOutputUI(outEl); }
const finalResult = outEl.value.trim();
if (finalResult) {
this.history = [{ original: text, optimized: finalResult, style: this.style, ts: Date.now() }, ...this.history].slice(0, MAX_HIST);
saveHistory(this.history);
showToast('✅ 运行完成', 'success');
}
} catch (err) { const msg = err?.message ?? String(err); if (/abort/i.test(msg)) { outEl.className = `${P}ta out`; showToast('⏹ 已停止', 'info'); } else { outEl.className = `${P}ta out err`; showToast(this._errMsg(err), 'error', 4000); } }
finally { this._setLoading(false); this._hideProgress(); this.abort = null; }
}
async _optimizeStream(text, prompt, outEl) { outEl.className = `${P}ta stream`; let acc=''; await this._callStream(text, prompt, ch=>{ acc+=ch; outEl.value=acc; this._syncOutputUI(outEl); }); outEl.className=`${P}ta out`; return acc; }
async _optimizeLong(chunks, prompt, outEl) { const total=chunks.length; this._showProgress(0,total); showToast(`📄 长文本已分为 ${total} 块处理`, 'info',3000); const results=[]; for(let i=0;i<total;i++){ if(!this.loading)break; this._showProgress(i,total); let partResult; if(this.cfg.streaming){ let acc=''; outEl.className=`${P}ta stream`; await this._callStream(chunks[i], prompt, ch=>{ acc+=ch; outEl.value=results.join('\n\n')+(results.length?'\n\n':'')+acc+'▌'; this._syncOutputUI(outEl); }); partResult=acc; } else partResult=await this._callWithRetry(chunks[i], prompt); results.push(partResult); this._showProgress(i+1,total); } const combined=results.join('\n\n'); outEl.value=combined; outEl.className=`${P}ta out`; this._syncOutputUI(outEl); return combined; }
_syncOutputUI(outEl) { outEl.style.height='auto'; outEl.style.height=Math.min(outEl.scrollHeight,200)+'px'; outEl.scrollTop=outEl.scrollHeight; this.panelEl.querySelector(`#${P}oc`).textContent=textStats(outEl.value.replace(/▌$/,'')); }
_showProgress(done,total){ const el=this.panelEl.querySelector(`#${P}progress`); const label=this.panelEl.querySelector(`#${P}progress-label`); const fill=this.panelEl.querySelector(`#${P}progress-fill`); el.classList.add('show'); label.textContent=`处理分块 ${done} / ${total}`; fill.style.width=`${total?(done/total)*100:0}%`; }
_hideProgress(){ this.panelEl.querySelector(`#${P}progress`).classList.remove('show'); }
stopOptimize(){ if(typeof this.abort ==='function'){ this.abort(); this.abort=null; } }
_setLoading(on){ this.loading=on; const opt=this.panelEl.querySelector(`#${P}btn-opt`); const stop=this.panelEl.querySelector(`#${P}btn-stop`); if(on){ opt.disabled=true; opt.innerHTML=`<span class="${P}spin"></span> 运行中…`; stop.style.display='flex'; }else{ opt.disabled=false; opt.innerHTML='🚀 运行'; stop.style.display='none'; } }
_errMsg(err){ const m=err instanceof Error?(err.message??''):String(err??'未知错误'); if(/401|403/.test(m)) return '❌ API Key 无效或无权限'; if(/429/.test(m)) return '❌ 请求过于频繁,请稍候再试'; if(/402|quota|insufficient/.test(m)) return '❌ API 额度不足'; if(/timeout/.test(m)) return '❌ 请求超时,请检查网络'; if(/NetworkError|network|fetch/i.test(m)) return '❌ 网络连接失败'; if(/empty|空/.test(m)) return '❌ 模型返回内容为空,请重试'; return `❌ ${m.slice(0,80)||'未知错误'}`; }
_getTemperature(){ const styleCfg=STYLES.find(s=>s.id===this.style); if(this.style==='custom'||styleCfg?.temperature===null) return this.cfg.temperature; return styleCfg?.temperature??this.cfg.temperature; }
_buildMessages(text,stylePrompt){ const systemContent=stylePrompt?`${this.cfg.systemPrompt}\n\n---\n${stylePrompt}`:this.cfg.systemPrompt; return [{role:'system',content:systemContent},{role:'user',content:`<input>\n${text}\n</input>`}]; }
_buildBody(text,prompt,stream){ return JSON.stringify({model:this.cfg.model,messages:this._buildMessages(text,prompt),temperature:this._getTemperature(),max_tokens:this.cfg.maxTokens,stream}); }
_apiURL(){ return this.cfg.apiBaseUrl.replace(/\/+$/,'')+'/chat/completions'; }
async _callWithRetry(text,prompt){ const maxRetries=this.cfg.autoRetry?(this.cfg.maxRetries||3):1; let lastErr; for(let attempt=0;attempt<maxRetries;attempt++){ try{ return await this._callAPI(text,prompt); }catch(err){ lastErr=err; const msg=err?.message??''; if(/abort|401|403|402|quota|insufficient/i.test(msg)) throw err; if(attempt<maxRetries-1){ const delay=1000*Math.pow(2,attempt); showToast(`⚠️ 请求失败,${delay/1000}s 后重试 (${attempt+1}/${maxRetries-1})…`,'warning',delay); await new Promise(r=>setTimeout(r,delay)); } } } throw lastErr; }
_callAPI(text,prompt){ return new Promise((resolve,reject)=>{ let settled=false; const settle=(fn,val)=>{ if(settled)return; settled=true; fn(val); }; let xhrHandle=null; this.abort=()=>{ xhrHandle?.abort?.(); settle(reject,new Error('abort')); }; xhrHandle=GM_xmlhttpRequest({method:'POST',url:this._apiURL(),headers:{'Content-Type':'application/json',Authorization:`Bearer ${this.cfg.apiKey}`},data:this._buildBody(text,prompt,false),timeout:60000,onload:r=>{ if(r.status===200){ try{ const d=JSON.parse(r.responseText); const c=d.choices?.[0]?.message?.content; c?settle(resolve,c.trim()):settle(reject,new Error('响应内容为空')); }catch{ settle(reject,new Error('解析响应失败')); } }else{ let msg=`HTTP ${r.status}`; try{ const e=JSON.parse(r.responseText); msg+=': '+(e.error?.message??e.message??''); }catch{} settle(reject,new Error(msg)); } },onerror:()=>settle(reject,new Error('NetworkError')),ontimeout:()=>settle(reject,new Error('timeout')),onabort:()=>settle(reject,new Error('abort'))}); }); }
_callStream(text,prompt,onChunk){ return new Promise((resolve,reject)=>{ let done=false,lineBuffer='',processed=0; let xhrHandle=null; this.abort=()=>{ if(done)return; done=true; xhrHandle?.abort?.(); reject(new Error('abort')); }; const parseLine=line=>{ const t=line.trim(); if(!t||t==='data: [DONE]')return; if(t.startsWith('data: ')){ try{ const d=JSON.parse(t.slice(6)); const delta=d.choices?.[0]?.delta?.content; if(delta) onChunk(delta); }catch{} } }; const flushBuffer=buf=>{ const lines=buf.split('\n'); const remaining=lines.pop()??''; lines.forEach(parseLine); return remaining; }; xhrHandle=GM_xmlhttpRequest({method:'POST',url:this._apiURL(),headers:{'Content-Type':'application/json',Authorization:`Bearer ${this.cfg.apiKey}`},data:this._buildBody(text,prompt,true),timeout:120000,onprogress:r=>{ if(done)return; const newData=r.responseText.slice(processed); processed=r.responseText.length; lineBuffer+=newData; lineBuffer=flushBuffer(lineBuffer); },onload:r=>{ if(done)return; done=true; if(lineBuffer.trim()) parseLine(lineBuffer); lineBuffer=''; if(r.status>=400){ let msg=`HTTP ${r.status}`; try{ const e=JSON.parse(r.responseText); msg+=': '+(e.error?.message??''); }catch{} reject(new Error(msg)); }else{ resolve(); } },onerror:()=>{ if(!done){ done=true; reject(new Error('NetworkError')); } },ontimeout:()=>{ if(!done){ done=true; reject(new Error('timeout')); } },onabort:()=>{ if(!done){ done=true; reject(new Error('abort')); } } }); }); }
copyResult(){ const t=this.panelEl.querySelector(`#${P}output`).value.trim(); if(!t){ showToast('暂无结果可复制','warning'); return; } try{ GM_setClipboard(t); showToast('✅ 已复制','success'); } catch{ navigator.clipboard?.writeText(t).then(()=>showToast('✅ 已复制','success')).catch(()=>showToast('复制失败','error')); } }
applyResult(){ const t=this.panelEl.querySelector(`#${P}output`).value.trim(); if(!t){ showToast('暂无结果可应用','warning'); return; } this._applyToInput(t); }
_applyToInput(text){ const setNative=(el,val)=>{ try{ const desc=Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el),'value'); if(desc?.set) desc.set.call(el,val); else el.value=val; }catch{ el.value=val; } el.dispatchEvent(new Event('input',{bubbles:true})); el.dispatchEvent(new Event('change',{bubbles:true})); }; const applyToEditable=el=>{ el.focus(); const sel=window.getSelection(); const range=document.createRange(); range.selectNodeContents(el); sel.removeAllRanges(); sel.addRange(range); if(!document.execCommand('insertText',false,text)){ el.innerText=text; el.dispatchEvent(new Event('input',{bubbles:true})); } }; const applyEl=el=>{ if(el.isContentEditable){ applyToEditable(el); } else{ setNative(el,text); el.focus(); } }; if(this.srcEl&&document.contains(this.srcEl)&&isTextEl(this.srcEl)){ try{ if(this.srcSel&&!this.srcEl.isContentEditable&&this.srcEl.selectionStart!==undefined){ const{start,end}=this.srcSel; setNative(this.srcEl,this.srcEl.value.slice(0,start)+text+this.srcEl.value.slice(end)); this.srcEl.selectionStart=start; this.srcEl.selectionEnd=start+text.length; this.srcEl.focus(); }else{ applyEl(this.srcEl); } showToast('✅ 已应用到输入框','success'); return; }catch{} } const ae=document.activeElement; if(ae&&!this.panelEl.contains(ae)&&isTextEl(ae)){ try{ applyEl(ae); showToast('✅ 已应用','success'); return; }catch{} } const fallback=()=>showToast('📋 已复制到剪贴板(未检测到目标输入框,请手动粘贴)','info'); try{ GM_setClipboard(text); fallback(); }catch{ navigator.clipboard?.writeText(text).then(fallback).catch(()=>showToast('请手动粘贴','warning')); } }
/* ─── 统一的划词气泡(新增快捷键开关)────────────────────── */
createBubble() {
this.bubbleEl = document.createElement('div');
this.bubbleEl.className = `${P}bubble`;
this.bubbleEl.innerHTML = `
<button class="${P}bbtn" id="${P}bb-enhance">🚀 增强</button>
<div class="${P}bsep"></div>
<button class="${P}bbtn" id="${P}bb-quick-en">📝 译英</button>
<div class="${P}bsep"></div>
<button class="${P}bbtn" id="${P}bb-quick-zh">📝 译中</button>
`;
document.body.appendChild(this.bubbleEl);
// 隐藏气泡并更新可见标志
const hide = () => {
this.bubbleEl.style.display = 'none';
this._bubbleVisible = false;
};
// 显示气泡(由内部调用时同时记录位置和文本)
const show = (x, y) => {
this.bubbleEl.style.display = 'flex';
const bw = this.bubbleEl.offsetWidth || 220;
const bh = this.bubbleEl.offsetHeight || 34;
let l = x - bw / 2;
let t = y - bh - 10;
l = Math.max(8, Math.min(window.innerWidth - bw - 8, l));
if (t < 8) t = y + 12;
this.bubbleEl.style.left = l + 'px';
this.bubbleEl.style.top = t + 'px';
this._bubbleVisible = true;
};
// 公共显示逻辑:从当前选中文本生成气泡
const tryShowFromSelection = () => {
if (!this.cfg.selBubble) return false;
if (this.open) return false;
let selText = window.getSelection()?.toString().trim() ?? '';
let anchorX = 0, anchorY = 0, hasAnchor = false;
let selRange = null;
if (selText.length >= 1) {
try {
const selection = window.getSelection();
if (selection.rangeCount > 0) {
const r = selection.getRangeAt(0);
selRange = r.cloneRange();
const rect = r.getBoundingClientRect();
if (rect.width > 0 || rect.height > 0) {
anchorX = rect.left + rect.width / 2;
anchorY = rect.top;
hasAnchor = true;
}
}
} catch {}
}
if (!selText || !hasAnchor) {
const ae = document.activeElement;
if (ae && isTextEl(ae) && !ae.isContentEditable) {
const { selectionStart: s, selectionEnd: end, value } = ae;
if (typeof s === 'number' && s !== end) {
const candidate = value.slice(s, end).trim();
if (candidate.length >= 3) {
selText = candidate;
const r = ae.getBoundingClientRect();
anchorX = r.left + r.width / 2;
anchorY = r.top;
hasAnchor = true;
selRange = null;
}
}
}
}
if (selText && hasAnchor) {
this._bubbleText = selText;
this.lastSelectionRange = selRange;
show(anchorX, anchorY);
return true;
}
return false;
};
// 鼠标释放时尝试显示气泡
document.addEventListener('mouseup', e => {
if (this.bubbleEl.contains(e.target)) return;
setTimeout(() => {
if (tryShowFromSelection()) return;
hide();
}, 10);
});
document.addEventListener('mousedown', e => { if (!this.bubbleEl.contains(e.target)) hide(); });
document.addEventListener('keydown', e => { if (e.key === 'Escape') hide(); });
// 增强模式:打开主面板并运行增强
this.bubbleEl.querySelector(`#${P}bb-enhance`).addEventListener('click', () => {
hide();
this.setStyle('enhance');
this.openPanel(this._bubbleText);
setTimeout(() => this.optimize(), 300);
});
// 统一翻译入口:快速翻译浮层
this.bubbleEl.querySelector(`#${P}bb-quick-en`).addEventListener('click', (e) => {
e.stopPropagation();
hide();
this._quickTranslate(this._bubbleText, 'en', e);
});
this.bubbleEl.querySelector(`#${P}bb-quick-zh`).addEventListener('click', (e) => {
e.stopPropagation();
hide();
this._quickTranslate(this._bubbleText, 'zh', e);
});
// 为快捷键暴露公共方法
this._showBubbleFromSelection = () => tryShowFromSelection();
this._hideBubble = () => hide();
}
/* ─── 快速翻译浮层(带复制/替换/面板编辑)──────────────── */
createQuickTranslatePanel() {
this.quickPanel = document.createElement('div');
this.quickPanel.className = `${P}quick-panel`;
this.quickPanel.innerHTML = `
<div class="${P}quick-head">
<span>⚡ 划词翻译</span>
<button class="${P}quick-close">✕</button>
</div>
<div class="${P}quick-body"></div>
<div class="${P}quick-actions">
<button class="${P}quick-copy">📋 复制译文</button>
<button class="${P}quick-replace">🔄 替换原文</button>
<button class="${P}quick-paneledit">✏️ 面板编辑</button>
</div>
`;
document.body.appendChild(this.quickPanel);
const head = this.quickPanel.querySelector(`.${P}quick-head`);
makeDraggable(this.quickPanel, head, { exclude: 'button' });
this.quickPanel.querySelector(`.${P}quick-close`).addEventListener('click', () => this._hideQuickPanel());
this.quickPanel.querySelector(`.${P}quick-copy`).addEventListener('click', () => {
const body = this.quickPanel.querySelector(`.${P}quick-body`);
const text = body.innerText;
if (text && !text.includes('翻译中') && !text.includes('失败')) {
GM_setClipboard(text);
showToast('译文已复制', 'success');
} else showToast('暂无译文可复制', 'warning');
});
this.quickPanel.querySelector(`.${P}quick-replace`).addEventListener('click', () => {
const body = this.quickPanel.querySelector(`.${P}quick-body`);
const translation = body.innerText;
if (!translation || translation.includes('翻译中') || translation.includes('失败')) {
showToast('没有可用的译文', 'warning');
return;
}
this._replaceSelectedText(translation);
this._hideQuickPanel();
showToast('已替换原文', 'success');
});
this.quickPanel.querySelector(`.${P}quick-paneledit`).addEventListener('click', () => {
if (!this._lastQuickOriginal) {
showToast('无可编辑的原文', 'warning');
return;
}
const styleId = this._lastQuickTargetLang === 'en' ? 'trans_en' : 'trans_zh';
this.setStyle(styleId);
this.openPanel(this._lastQuickOriginal);
this._hideQuickPanel();
showToast('已打开面板,可继续编辑或运行', 'success');
});
}
_hideQuickPanel() {
if (this.quickPanel) this.quickPanel.classList.remove('show');
if (this.activeQuickAbort) { this.activeQuickAbort(); this.activeQuickAbort = null; }
}
async _quickTranslate(text, targetLang, event) {
if (!text || text.length < 1) return;
if (!this.cfg.apiKey) {
showToast('请先在设置中填写 API Key', 'warning');
return;
}
if (this.activeQuickAbort) { this.activeQuickAbort(); this.activeQuickAbort = null; }
const bodyEl = this.quickPanel.querySelector(`.${P}quick-body`);
bodyEl.innerHTML = `<div class="${P}quick-loading"><span class="${P}spin"></span> 翻译中…</div>`;
this.quickPanel.classList.add('show');
let x = 0, y = 0;
if (event && event.clientX) { x = event.clientX; y = event.clientY; }
else if (this.lastSelectionRange) {
const rect = this.lastSelectionRange.getBoundingClientRect();
x = rect.left + rect.width/2; y = rect.top;
} else { x = window.innerWidth/2; y = window.innerHeight/2; }
const w = this.quickPanel.offsetWidth || 340;
const h = this.quickPanel.offsetHeight || 200;
let left = Math.min(window.innerWidth - w - 10, Math.max(10, x - w/2));
let top = Math.min(window.innerHeight - h - 10, Math.max(40, y - h - 15));
if (top < 40) top = y + 20;
this.quickPanel.style.left = left + 'px';
this.quickPanel.style.top = top + 'px';
const styleId = targetLang === 'en' ? 'trans_en' : 'trans_zh';
const styleCfg = STYLES.find(s => s.id === styleId);
if (!styleCfg) { bodyEl.innerHTML = '<div class="quick-err">翻译配置错误</div>'; return; }
const prompt = styleCfg.prompt;
try {
const result = await this._callQuickAPI(text, prompt);
bodyEl.innerHTML = `<div style="white-space:pre-wrap;">${esc(result)}</div>`;
this._lastQuickOriginal = text;
this._lastQuickTargetLang = targetLang;
this.history = [
{ original: text, optimized: result, style: styleId, ts: Date.now() },
...this.history
].slice(0, MAX_HIST);
saveHistory(this.history);
} catch (err) {
const errMsg = this._errMsg(err);
bodyEl.innerHTML = `<div class="${P}quick-err">${errMsg}</div>`;
showToast(errMsg, 'error', 3000);
} finally {
this.activeQuickAbort = null;
}
}
_callQuickAPI(text, prompt) {
return new Promise((resolve, reject) => {
let settled = false;
const settle = (fn, val) => { if (settled) return; settled = true; fn(val); };
let xhr = null;
this.activeQuickAbort = () => { xhr?.abort?.(); settle(reject, new Error('abort')); };
const messages = this._buildMessages(text, prompt);
const body = JSON.stringify({
model: this.cfg.model,
messages,
temperature: 0.3,
max_tokens: Math.min(1500, this.cfg.maxTokens),
stream: false
});
xhr = GM_xmlhttpRequest({
method: 'POST',
url: this._apiURL(),
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.cfg.apiKey}` },
data: body,
timeout: 45000,
onload: r => {
if (r.status === 200) {
try {
const d = JSON.parse(r.responseText);
const c = d.choices?.[0]?.message?.content;
c ? settle(resolve, c.trim()) : settle(reject, new Error('翻译结果为空'));
} catch { settle(reject, new Error('解析失败')); }
} else {
let msg = `HTTP ${r.status}`;
try { const e = JSON.parse(r.responseText); msg += ': ' + (e.error?.message ?? e.message ?? ''); } catch { }
settle(reject, new Error(msg));
}
},
onerror: () => settle(reject, new Error('NetworkError')),
ontimeout: () => settle(reject, new Error('timeout')),
onabort: () => settle(reject, new Error('abort'))
});
});
}
_replaceSelectedText(newText) {
if (!newText) return;
if (this.lastSelectionRange && this.lastSelectionRange.startContainer) {
try {
const range = this.lastSelectionRange;
range.deleteContents();
const textNode = document.createTextNode(newText);
range.insertNode(textNode);
range.setStartAfter(textNode);
range.collapse(true);
window.getSelection()?.removeAllRanges();
window.getSelection()?.addRange(range);
return;
} catch (e) { console.warn('替换Range失败', e); }
}
const ae = document.activeElement;
if (ae && isTextEl(ae)) {
if (!ae.isContentEditable && ae.selectionStart !== undefined) {
const start = ae.selectionStart, end = ae.selectionEnd;
const val = ae.value;
ae.value = val.slice(0, start) + newText + val.slice(end);
ae.selectionStart = start;
ae.selectionEnd = start + newText.length;
ae.dispatchEvent(new Event('input', { bubbles: true }));
} else if (ae.isContentEditable) {
const sel = window.getSelection();
if (sel.rangeCount) {
const range = sel.getRangeAt(0);
range.deleteContents();
range.insertNode(document.createTextNode(newText));
range.collapse(false);
} else {
ae.innerText = newText;
ae.dispatchEvent(new Event('input', { bubbles: true }));
}
}
} else {
showToast('未检测到可替换的文本区域,已复制到剪贴板', 'info');
GM_setClipboard(newText);
}
}
/* ── 历史记录 Modal (完整保留) ──────────────────────────── */
createHistoryModal() {
this.histEl = document.createElement('div');
this.histEl.className = `${P}hov`;
this.histEl.innerHTML = `
<div class="${P}hbox" id="${P}hbox">
<div class="${P}hhdr" id="${P}hhdr">
<h3 class="${P}htitle">📜 运行历史</h3>
<input class="${P}hsearch" id="${P}hsearch" placeholder="搜索历史记录…">
<button style="background:none;border:none;font-size:20px;cursor:pointer;color:#64748b;padding:0 4px;flex-shrink:0;" id="${P}hclose">✕</button>
</div>
<div class="${P}hlist" id="${P}hlist"></div>
<div class="${P}hfoot">
<button class="${P}bs danger" id="${P}hclear">🗑 清空历史</button>
<button class="${P}bs" id="${P}hexport">⬇ 导出 JSON</button>
</div>
</div>`;
document.body.appendChild(this.histEl);
const box = this.histEl.querySelector(`#${P}hbox`);
const hdr = this.histEl.querySelector(`#${P}hhdr`);
makeDraggable(box, hdr, { exclude: `input,button`, onEnd: pos => store.set(SK.HIST_POS, pos) });
const savedPos = store.get(SK.HIST_POS, null);
if (savedPos) { box.style.transform = 'none'; box.style.left = savedPos.x + 'px'; box.style.top = savedPos.y + 'px'; }
this.histEl.querySelector(`#${P}hclose`).addEventListener('click', () => this.closeHistory());
this.histEl.addEventListener('click', e => { if (e.target === this.histEl) this.closeHistory(); });
this.histEl.querySelector(`#${P}hsearch`).addEventListener('input', e => this._renderHistory(e.target.value));
this.histEl.querySelector(`#${P}hclear`).addEventListener('click', () => { if (!confirm('确认清空所有历史记录?')) return; this.history = []; saveHistory([]); this._renderHistory(); showToast('历史记录已清空', 'info'); });
this.histEl.querySelector(`#${P}hexport`).addEventListener('click', () => this._exportHistory());
this.histEl.querySelector(`#${P}hlist`).addEventListener('click', e => { const btn = e.target.closest('button[data-a]'); if (!btn) return; const idx = parseInt(btn.dataset.i); if (isNaN(idx) || !this.history[idx]) return; const h = this.history[idx]; switch (btn.dataset.a) { case 'copy': try{ GM_setClipboard(h.optimized); } catch{ navigator.clipboard?.writeText(h.optimized); } showToast('已复制', 'success'); break; case 'apply': this._applyToInput(h.optimized); this.closeHistory(); break; case 'del': this.history.splice(idx,1); saveHistory(this.history); this._renderHistory(this.histEl.querySelector(`#${P}hsearch`).value); break; } });
}
openHistory() { this._renderHistory(); this.histEl.classList.add('open'); }
closeHistory() { this.histEl.classList.remove('open'); }
_renderHistory(filter = '') { const list=this.histEl.querySelector(`#${P}hlist`); const filtered=filter?this.history.filter(h=>h.original.includes(filter)||h.optimized.includes(filter)):this.history; if(!filtered.length){ list.innerHTML=`<div class="${P}hempty">${filter?'🔍 无匹配记录':'暂无历史记录'}</div>`; return; } list.innerHTML=filtered.map(h=>{ const idx=this.history.indexOf(h); const style=STYLES.find(s=>s.id===h.style)?.name??'自定义'; const time=new Date(h.ts).toLocaleString('zh-CN',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}); const oPrev=esc(h.original.slice(0,120)+(h.original.length>120?'…':'')); const rPrev=esc(h.optimized.slice(0,120)+(h.optimized.length>120?'…':'')); return `<div class="${P}hi"><div class="${P}hi-meta"><span>${time}</span><span class="${P}hi-badge">${esc(style)}</span></div><div class="${P}hi-cols"><div class="${P}hi-col"><strong style="font-size:10px;color:#64748b;display:block;margin-bottom:2px">原文</strong>${oPrev}</div><div class="${P}hi-col"><strong style="font-size:10px;color:#166534;display:block;margin-bottom:2px">结果</strong>${rPrev}</div></div><div class="${P}hi-acts"><button data-a="copy" data-i="${idx}">📋 复制</button><button data-a="apply" data-i="${idx}">✅ 应用</button><button data-a="del" data-i="${idx}" class="del">🗑</button></div></div>`; }).join(''); }
_exportHistory(){ if(!this.history.length){ showToast('暂无历史记录可导出','warning'); return; } try{ const blob=new Blob([JSON.stringify(this.history,null,2)],{type:'application/json'}); const url=URL.createObjectURL(blob); const a=document.createElement('a'); a.href=url; a.download=`tmo-history-${Date.now()}.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); showToast('✅ 历史已导出','success'); }catch(e){ showToast('导出失败:'+(e?.message??e),'error'); } }
/* ── 全局快捷键(新增 Ctrl+Shift+D 开关选文弹窗)────────── */
bindKeys() {
document.addEventListener('keydown', e => {
// Ctrl+Shift+O 打开面板
if (e.ctrlKey && e.shiftKey && e.code === 'KeyO') {
e.preventDefault();
this.togglePanel();
return;
}
// Ctrl+Shift+H 隐藏/显示悬浮球
if (e.ctrlKey && e.shiftKey && e.code === 'KeyH') {
e.preventDefault();
this.btnVis = !this.btnVis;
store.set(SK.BTN_VIS, this.btnVis);
this.btnEl.style.display = this.btnVis ? 'flex' : 'none';
showToast(this.btnVis ? '悬浮球已显示' : '悬浮球已隐藏', 'info');
return;
}
// Ctrl+Shift+D 开关选文弹窗
if (e.ctrlKey && e.shiftKey && e.code === 'KeyD') {
e.preventDefault();
// 切换全局开关
this.cfg.selBubble = !this.cfg.selBubble;
saveSettings(this.cfg);
// 同步设置面板中的复选框状态(如果面板存在)
if (this.panelEl) {
const cb = this.panelEl.querySelector(`#${P}s-bubble`);
if (cb) cb.checked = this.cfg.selBubble;
}
if (this.cfg.selBubble) {
// 开启选文弹窗:尝试立即显示当前选中文本的气泡
const shown = this._showBubbleFromSelection?.();
showToast(shown ? '选文弹窗已开启' : '选文弹窗已开启(未检测到选中文本)', 'info');
} else {
// 关闭选文弹窗:隐藏当前可见的气泡,并禁用后续弹出
this._hideBubble?.();
showToast('选文弹窗已关闭', 'info');
}
return;
}
// Ctrl+Shift+Enter 在主面板运行时触发
if (e.ctrlKey && e.shiftKey && e.code === 'Enter' && this.open) {
e.preventDefault();
this.optimize();
return;
}
// ESC: 关闭快速翻译浮层,再关闭主面板/历史(如果没有其他弹窗)
if (e.key === 'Escape') {
// 优先关闭快速翻译浮层
if (this.quickPanel && this.quickPanel.classList.contains('show')) {
e.preventDefault();
this._hideQuickPanel();
return;
}
// 关闭选文弹窗
if (this._bubbleVisible && this.bubbleEl && this.bubbleEl.style.display === 'flex') {
e.preventDefault();
this._hideBubble();
return;
}
// 再关闭历史模态框
if (this.histEl && this.histEl.classList.contains('open')) {
e.preventDefault();
this.closeHistory();
return;
}
// 最后关闭主面板
if (this.open) {
e.preventDefault();
this.closePanel();
return;
}
}
});
window.addEventListener('resize', () => {
const mx = window.innerWidth - 50, my = window.innerHeight - 50;
if (this.btnPos.x > mx) { this.btnPos.x = mx; this.btnEl.style.left = mx + 'px'; }
if (this.btnPos.y > my) { this.btnPos.y = my; this.btnEl.style.top = my + 'px'; }
});
}
}
/* ── 启动 ──────────────────────────────────────────────────── */
const boot = () => setTimeout(() => new TextOptimizer(), 500);
document.readyState === 'loading'
? document.addEventListener('DOMContentLoaded', boot)
: boot();})();