// ==UserScript==
// @name Select2AI
// @name:zh-CN Select2AI
// @namespace http://tampermonkey.net/
// @version 2.2
// @description Display a blue dot after selecting the text, show a prompt menu when hovering, call on AI, and then complete the filling in.
// @description:zh-CN 划词后显示蓝色圆点,悬停显示提示词菜单,调用 AI 并回填。
// @author easychen
// @match *://*/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_addStyle
// @grant GM_registerMenuCommand
// @grant GM_xmlhttpRequest
// @grant window.close
// @grant window.focus
// @downloadURL none
// ==/UserScript==
(function() {
'use strict';
// === 多语言系统 ===
const LANGUAGES = {
zh: {
// 默认提示词
defaultPrompts: [
{ id: 1, name: '总结内容', text: '请帮我总结以下内容:\n{text}', enabled: true, downloadFile: false, fileExtension: 'txt' },
{ id: 2, name: '翻译成英文', text: '请将以下内容翻译为英文:\n{text}', enabled: true, downloadFile: false, fileExtension: 'txt' },
{ id: 3, name: '润色改进', text: '请帮我润色和改进以下文本,使其更专业、流畅,直接输出润色后的结果:\n{text}', enabled: true, downloadFile: false, fileExtension: 'txt' },
{ id: 4, name: '解释代码', text: '请解释以下代码的功能和逻辑:\n{text}', enabled: true, downloadFile: false, fileExtension: 'txt' },
{ id: 5, name: '整理为MD表格', text: '请将以下数据整理成 Markdown 表格格式,保持数据的完整性和结构:\n{text}', enabled: true, downloadFile: false, fileExtension: 'md' },
{ id: 6, name: '整理为CSV', text: '请将以下数据整理成 CSV 格式,用逗号分隔,第一行为标题行:\n{text}', enabled: true, downloadFile: false, fileExtension: 'csv' },
],
// UI文本
ui: {
noPromptsEnabled: '⚠️ 未启用任何提示词,请在设置中配置。',
pleaseSelectText: '⚠️ 请先选中文本',
configureApiKey: '⚠️ 请先在油猴脚本菜单中配置 API Key!',
processing: '⏳ [{name}] 处理中...',
completedAndFilled: '✅ 已完成并回填到原位置!',
completedAndFilledSimple: '✅ 已完成并回填!',
copiedToClipboard: '✅ 结果已复制到剪贴板!\n\n{result}',
copyFailed: '✅ 结果如下 (复制失败):\n\n{result}',
failed: '❌ 失败: {error}',
apiReturnEmpty: 'API 返回内容为空',
settingsSaved: '✅ 设置已保存',
promptUpdated: '✅ 提示词已更新',
nameContentRequired: '⚠️ 名称和内容不能为空',
confirmDelete: '确定删除?',
// 设置面板
aiSettings: '⚙️ AI 设置',
apiEndpoint: 'API Endpoint (Base URL):',
modelName: '模型名称 (Model):',
apiKey: 'API Key:',
managePrompts: '🧠 管理提示词',
manageModels: '🤖 管理模型',
save: '保存',
close: '关闭',
// 模型管理
modelManagement: '🤖 模型管理',
addModel: '➕ 新增模型',
editModel: '✏️ 编辑模型',
modelNameLabel: '模型名称:',
setAsDefault: '设为默认',
defaultModel: '默认模型',
selectModel: '选择模型:',
noModel: '无模型',
// 提示词管理
promptManagement: '🧠 提示词管理',
promptPlaceholder: '使用 {text} 作为划词内容的占位符。',
addNew: '➕ 新增',
editPrompt: '✏️ 编辑提示词',
addPrompt: '➕ 新增提示词',
name: '名称:',
promptContent: '提示词内容:',
nameExample: '例如:翻译成英文',
placeholderExample: '使用 {text} 作为占位符',
cancel: '取消',
edit: '编辑',
clone: '复刻',
delete: '删除',
enableDisable: '启用/禁用',
downloadFile: '下载为文件:',
fileExtension: '文件扩展名:',
fileExtensionPlaceholder: '例如:txt, md, csv',
// 语言设置
language: '界面语言:',
languageAuto: '🌐 跟随系统',
languageChinese: '🇨🇳 中文',
languageEnglish: '🇺🇸 English',
// 菜单命令
menuSettings: '⚙️ AI 设置 & 提示词',
menuRun: '🚀 {name}'
}
},
en: {
// 默认提示词
defaultPrompts: [
{ id: 1, name: 'Summarize', text: 'Please summarize the following content:\n{text}', enabled: true, downloadFile: false, fileExtension: 'txt' },
{ id: 2, name: 'Translate to Chinese', text: 'Please translate the following content to Chinese:\n{text}', enabled: true, downloadFile: false, fileExtension: 'txt' },
{ id: 3, name: 'Polish & Improve', text: 'Please polish and improve the following text to make it more professional and fluent, output the polished result directly:\n{text}', enabled: true, downloadFile: false, fileExtension: 'txt' },
{ id: 4, name: 'Explain Code', text: 'Please explain the functionality and logic of the following code:\n{text}', enabled: true, downloadFile: false, fileExtension: 'txt' },
{ id: 5, name: 'To Markdown Table', text: 'Please organize the following data into a Markdown table format, maintaining data integrity and structure:\n{text}', enabled: true, downloadFile: false, fileExtension: 'md' },
{ id: 6, name: 'To CSV', text: 'Please organize the following data into CSV format, comma-separated with the first row as headers:\n{text}', enabled: true, downloadFile: false, fileExtension: 'csv' },
],
// UI文本
ui: {
noPromptsEnabled: '⚠️ No prompts enabled. Please configure in settings.',
pleaseSelectText: '⚠️ Please select text first',
configureApiKey: '⚠️ Please configure API Key in Tampermonkey script menu first!',
processing: '⏳ [{name}] Processing...',
completedAndFilled: '✅ Completed and filled back to original position!',
completedAndFilledSimple: '✅ Completed and filled back!',
copiedToClipboard: '✅ Result copied to clipboard!\n\n{result}',
copyFailed: '✅ Result (copy failed):\n\n{result}',
failed: '❌ Failed: {error}',
apiReturnEmpty: 'API returned empty content',
settingsSaved: '✅ Settings saved',
promptUpdated: '✅ Prompt updated',
nameContentRequired: '⚠️ Name and content cannot be empty',
confirmDelete: 'Confirm delete?',
// 设置面板
aiSettings: '⚙️ AI Settings',
apiEndpoint: 'API Endpoint (Base URL):',
modelName: 'Model Name:',
apiKey: 'API Key:',
managePrompts: '🧠 Manage Prompts',
manageModels: '🤖 Manage Models',
save: 'Save',
close: 'Close',
// 模型管理
modelManagement: '🤖 Model Management',
addModel: '➕ Add Model',
editModel: '✏️ Edit Model',
modelNameLabel: 'Model Name:',
setAsDefault: 'Set as Default',
defaultModel: 'Default Model',
selectModel: 'Select Model:',
noModel: 'No Model',
// 提示词管理
promptManagement: '🧠 Prompt Management',
promptPlaceholder: 'Use {text} as placeholder for selected content.',
addNew: '➕ Add New',
editPrompt: '✏️ Edit Prompt',
addPrompt: '➕ Add Prompt',
name: 'Name:',
promptContent: 'Prompt Content:',
nameExample: 'e.g.: Translate to English',
placeholderExample: 'Use {text} as placeholder',
cancel: 'Cancel',
edit: 'Edit',
clone: 'Clone',
delete: 'Delete',
enableDisable: 'Enable/Disable',
downloadFile: 'Download as File:',
fileExtension: 'File Extension:',
fileExtensionPlaceholder: 'e.g.: txt, md, csv',
// Language Settings
language: 'Language:',
languageAuto: '🌐 System',
languageChinese: '🇨🇳 中文',
languageEnglish: '🇺🇸 English',
// 菜单命令
menuSettings: '⚙️ AI Settings & Prompts',
menuRun: '🚀 {name}'
}
}
};
// 获取系统语言
function getSystemLanguage() {
const lang = navigator.language || navigator.userLanguage || 'en';
return lang.startsWith('zh') ? 'zh' : 'en';
}
// 获取当前语言设置(支持手动设置)
function getCurrentLanguage() {
const savedLang = GM_getValue('user_language', '');
if (savedLang && LANGUAGES[savedLang]) {
return savedLang;
}
return GM_getValue('language', getSystemLanguage());
}
// 设置语言
function setLanguage(lang) {
if (LANGUAGES[lang]) {
GM_setValue('user_language', lang);
return true;
}
return false;
}
// 获取本地化文本
function t(key, params = {}) {
const lang = getCurrentLanguage();
const langData = LANGUAGES[lang] || LANGUAGES.en;
let text = langData.ui[key] || LANGUAGES.en.ui[key] || key;
// 替换参数
Object.keys(params).forEach(param => {
text = text.replace(new RegExp(`\\{${param}\\}`, 'g'), params[param]);
});
return text;
}
// 获取默认提示词
function getDefaultPrompts() {
const lang = getCurrentLanguage();
return LANGUAGES[lang]?.defaultPrompts || LANGUAGES.en.defaultPrompts;
}
// === 默认配置 ===
const DEFAULT_PROMPTS = getDefaultPrompts();
// === 数据处理 ===
function initDefaults() {
// 兼容旧版本的单模型配置,迁移到多模型结构
const oldEndpoint = GM_getValue('endpoint');
const oldModel = GM_getValue('model');
const oldApikey = GM_getValue('apikey');
// 如果存在旧配置且没有新的models配置,则迁移
if (oldEndpoint && !GM_getValue('models')) {
const defaultModel = {
id: 1,
name: 'Default Model',
endpoint: oldEndpoint,
model: oldModel || 'gpt-3.5-turbo',
apikey: oldApikey || '',
isDefault: true
};
GM_setValue('models', JSON.stringify([defaultModel]));
// 清理旧配置
GM_deleteValue('endpoint');
GM_deleteValue('model');
GM_deleteValue('apikey');
}
// 如果没有任何模型配置,创建默认模型
if (GM_getValue('models') === undefined) {
const defaultModel = {
id: 1,
name: 'Default Model',
endpoint: 'https://api.openai.com/v1/chat/completions',
model: 'gpt-3.5-turbo',
apikey: '',
isDefault: true
};
GM_setValue('models', JSON.stringify([defaultModel]));
}
if (GM_getValue('prompts') === undefined) GM_setValue('prompts', JSON.stringify(DEFAULT_PROMPTS));
}
function getModels() {
return JSON.parse(GM_getValue('models', '[]'));
}
function saveModels(models) {
GM_setValue('models', JSON.stringify(models));
}
function getDefaultModel() {
const models = getModels();
return models.find(m => m.isDefault) || models[0] || null;
}
function getModelById(id) {
const models = getModels();
return models.find(m => m.id === id) || getDefaultModel();
}
function getPrompts() {
return JSON.parse(GM_getValue('prompts', JSON.stringify(DEFAULT_PROMPTS)));
}
function savePrompts(prompts) {
GM_setValue('prompts', JSON.stringify(prompts));
}
// === 核心状态变量 ===
let bubbleBtn = null;
let promptMenu = null;
let lastSelection = null;
let closeTimer = null; // 用于 Hover 延迟关闭
let menuCmdIds = []; // Tampermonkey 菜单项 ID 列表
let selectedIndex = 0; // 当前选中的菜单项索引
let menuItems = []; // 当前菜单项列表
// === 样式 CSS ===
GM_addStyle(`
/* CSS 重置和隔离 - 防止页面样式影响 */
.ai-panel-overlay, .ai-panel-overlay * {
all: unset !important;
box-sizing: border-box !important;
}
.ai-panel-container, .ai-panel-container * {
all: unset !important;
box-sizing: border-box !important;
}
/* 呼吸红点按钮 - 核心改动 */
@keyframes ai-breathing {
0%, 100% {
transform: scale(1);
opacity: 0.85;
}
50% {
transform: scale(1.3);
opacity: 1;
}
}
.ai-bubble-btn {
position: absolute !important;
width: 9px !important;
height: 9px !important;
background: #007aff !important; /* 鲜艳的红色 */
border-radius: 50% !important;
box-shadow: 0 0 10px rgba(0, 122, 255, 0.7) !important; /* 蓝色辉光效果 */
cursor: pointer !important;
z-index: 999999 !important;
border: none !important;
margin: 0 !important;
padding: 0 !important;
/* 应用呼吸动画 */
animation: ai-breathing 2.5s ease-in-out infinite !important;
}
/* 提示词菜单 */
.ai-prompt-menu {
position: absolute !important;
background: #fff !important;
border-radius: 6px !important;
box-shadow: 0 4px 15px rgba(0,0,0,0.15) !important;
padding: 4px 0 !important;
z-index: 1000000 !important;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important;
min-width: 120px !important;
border: 1px solid #eee !important;
animation: ai-fade-in 0.15s ease-out !important;
margin: 0 !important;
display: block !important;
}
@keyframes ai-fade-in {
from { opacity: 0; transform: translateY(-5px); }
to { opacity: 1; transform: translateY(0); }
}
.ai-prompt-item {
padding: 6px 12px !important;
cursor: pointer !important;
font-size: 13px !important;
color: #333 !important;
white-space: nowrap !important;
transition: background 0.1s !important;
display: block !important;
margin: 0 !important;
border: none !important;
text-decoration: none !important;
font-weight: normal !important;
line-height: 1.4 !important;
}
.ai-prompt-item:hover {
background: #f2f2f7 !important;
color: #000 !important;
}
.ai-prompt-item.selected {
background: #007aff !important;
color: #fff !important;
}
/* 状态弹窗 */
.ai-status-popup {
position: fixed !important;
top: 20px !important;
right: 20px !important;
background: rgba(0, 0, 0, 0.8) !important;
color: #fff !important;
backdrop-filter: blur(4px) !important;
padding: 8px 12px !important;
border-radius: 6px !important;
z-index: 1000001 !important;
font-size: 13px !important;
max-width: 300px !important;
line-height: 1.4 !important;
box-shadow: 0 2px 8px rgba(0,0,0,.2) !important;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important;
animation: ai-fade-in 0.2s ease-out !important;
margin: 0 !important;
border: none !important;
display: block !important;
}
/* 通用设置面板样式 - 强化隔离 */
.ai-panel-overlay {
position: fixed !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
background: rgba(0,0,0,0.4) !important;
z-index: 1000002 !important;
display: block !important;
margin: 0 !important;
padding: 0 !important;
border: none !important;
}
.ai-panel-container {
position: fixed !important;
top: 50% !important;
left: 50% !important;
transform: translate(-50%, -50%) !important;
background: #fff !important;
border-radius: 10px !important;
box-shadow: 0 10px 30px rgba(0,0,0,.25) !important;
padding: 20px !important;
z-index: 1000003 !important;
width: 360px !important;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important;
color: #333 !important;
display: block !important;
margin: 0 !important;
border: 1px solid #ddd !important;
max-height: 90vh !important;
overflow-y: auto !important;
}
.ai-panel-container h3 {
margin: 0 0 15px 0 !important;
font-size: 18px !important;
border-bottom: 1px solid #eee !important;
padding-bottom: 8px !important;
font-weight: 600 !important;
color: #333 !important;
display: block !important;
line-height: 1.3 !important;
}
.ai-panel-container label {
display: block !important;
margin-bottom: 10px !important;
font-size: 13px !important;
font-weight: 600 !important;
color: #555 !important;
line-height: 1.4 !important;
cursor: default !important;
}
.ai-panel-container input[type="text"],
.ai-panel-container input[type="password"] {
width: 100% !important;
box-sizing: border-box !important;
padding: 8px !important;
margin-top: 4px !important;
border: 1px solid #ccc !important;
border-radius: 4px !important;
font-size: 13px !important;
background: #fff !important;
color: #333 !important;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important;
line-height: 1.4 !important;
outline: none !important;
display: block !important;
}
.ai-panel-container input[type="text"]:focus,
.ai-panel-container input[type="password"]:focus {
border-color: #007aff !important;
box-shadow: 0 0 0 2px rgba(0, 122, 255, 0.2) !important;
}
.ai-panel-container textarea {
width: 100% !important;
box-sizing: border-box !important;
padding: 8px !important;
margin-top: 4px !important;
border: 1px solid #ccc !important;
border-radius: 4px !important;
font-size: 13px !important;
min-height: 120px !important;
line-height: 1.4 !important;
resize: vertical !important;
background: #fff !important;
color: #333 !important;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important;
outline: none !important;
display: block !important;
}
.ai-panel-container textarea:focus {
border-color: #007aff !important;
box-shadow: 0 0 0 2px rgba(0, 122, 255, 0.2) !important;
}
.ai-panel-container input[type="checkbox"] {
width: 14px !important;
height: 14px !important;
margin: 0 6px 0 0 !important;
padding: 0 !important;
display: inline-block !important;
vertical-align: middle !important;
appearance: auto !important;
-webkit-appearance: checkbox !important;
}
.ai-btn-group {
margin-top: 15px !important;
display: flex !important;
justify-content: flex-end !important;
gap: 8px !important;
align-items: center !important;
}
.ai-btn {
padding: 6px 12px !important;
border: 1px solid #ccc !important;
border-radius: 4px !important;
cursor: pointer !important;
font-size: 13px !important;
background: #f9f9f9 !important;
color: #333 !important;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important;
text-decoration: none !important;
display: inline-block !important;
line-height: 1.4 !important;
font-weight: normal !important;
text-align: center !important;
transition: all 0.2s ease !important;
margin: 0 !important;
}
.ai-btn:hover {
background: #eee !important;
border-color: #bbb !important;
}
.ai-btn-primary {
background: #007aff !important;
color: white !important;
border-color: #006ae6 !important;
}
.ai-btn-primary:hover {
background: #006ae6 !important;
border-color: #005bb5 !important;
}
/* 设置面板主操作区与全宽按钮 */
.ai-primary-actions {
display: flex !important;
flex-direction: column !important;
gap: 8px !important;
margin-bottom: 12px !important;
}
.ai-btn-full {
width: 100% !important;
justify-content: center !important;
}
/* 在管理列表内强制内容换行,避免溢出 */
.ai-panel-container .ai-prompt-item {
white-space: normal !important;
}
.ai-panel-container .ai-prompt-preview {
white-space: normal !important;
word-break: break-all !important;
overflow-wrap: anywhere !important;
}
.ai-panel-container .ai-prompt-name {
white-space: normal !important;
word-break: break-word !important;
overflow-wrap: anywhere !important;
}
/* 语言选择器样式 */
.ai-language-selector {
display: flex !important;
gap: 4px !important;
margin-top: 4px !important;
align-items: stretch !important;
}
.ai-lang-btn {
padding: 6px 10px !important;
border: 1px solid #ccc !important;
border-radius: 4px !important;
cursor: pointer !important;
font-size: 12px !important;
background: #f9f9f9 !important;
transition: all 0.2s !important;
flex: 1 !important;
text-align: center !important;
color: #333 !important;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important;
text-decoration: none !important;
display: block !important;
line-height: 1.4 !important;
font-weight: normal !important;
margin: 0 !important;
}
.ai-lang-btn:hover {
background: #eee !important;
border-color: #bbb !important;
}
.ai-lang-btn.active {
background: #007aff !important;
color: white !important;
border-color: #006ae6 !important;
}
/* 提示词管理列表样式 */
#ai-prompt-list {
max-height: 300px !important;
overflow-y: auto !important;
border: 1px solid #eee !important;
border-radius: 4px !important;
margin-bottom: 15px !important;
background: #fff !important;
display: block !important;
}
.ai-prompt-list-item {
padding: 8px 12px !important;
border-bottom: 1px solid #f0f0f0 !important;
display: flex !important;
align-items: center !important;
gap: 8px !important;
background: #fff !important;
margin: 0 !important;
}
.ai-prompt-list-item:last-child {
border-bottom: none !important;
}
.ai-prompt-list-item.disabled {
opacity: 0.5 !important;
background: #f9f9f9 !important;
}
.ai-prompt-name {
flex: 1 !important;
font-size: 13px !important;
color: #333 !important;
font-weight: 500 !important;
margin: 0 !important;
padding: 0 !important;
display: block !important;
line-height: 1.4 !important;
}
.ai-prompt-actions {
display: flex !important;
gap: 4px !important;
align-items: center !important;
margin: 0 !important;
padding: 0 !important;
}
.ai-prompt-actions button {
padding: 2px 6px !important;
font-size: 11px !important;
border: 1px solid #ddd !important;
border-radius: 3px !important;
cursor: pointer !important;
background: #fff !important;
color: #666 !important;
margin: 0 !important;
display: inline-block !important;
line-height: 1.2 !important;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important;
text-decoration: none !important;
font-weight: normal !important;
}
.ai-prompt-actions button:hover {
background: #f5f5f5 !important;
border-color: #ccc !important;
}
/* 小按钮样式 */
.ai-btn-small {
padding: 2px 6px !important;
font-size: 11px !important;
border: 1px solid #ddd !important;
border-radius: 3px !important;
cursor: pointer !important;
background: #fff !important;
color: #666 !important;
margin: 0 !important;
display: inline-block !important;
line-height: 1.2 !important;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important;
text-decoration: none !important;
font-weight: normal !important;
}
.ai-btn-small:hover {
background: #f5f5f5 !important;
border-color: #ccc !important;
}
.ai-btn-small.ai-btn-danger {
color: #d32f2f !important;
}
.ai-btn-small.ai-btn-danger:hover {
background: #ffebee !important;
border-color: #d32f2f !important;
}
/* 提示词列表项样式 */
.ai-prompt-item {
padding: 12px !important;
border-bottom: 1px solid #f0f0f0 !important;
display: flex !important;
align-items: flex-start !important;
gap: 12px !important;
background: #fff !important;
margin: 0 !important;
}
.ai-prompt-item:last-child {
border-bottom: none !important;
}
.ai-prompt-item:hover {
background: #f9f9f9 !important;
color: #006ae6 !important;
}
.ai-prompt-info {
flex: 1 !important;
overflow: hidden !important;
margin: 0 !important;
padding: 0 !important;
}
.ai-prompt-preview {
font-size: 12px !important;
color: #888 !important;
margin-top: 4px !important;
white-space: normal !important;
word-break: break-all !important;
overflow-wrap: anywhere !important;
line-height: 1.4 !important;
max-height: 60px !important;
overflow: hidden !important;
display: -webkit-box !important;
-webkit-line-clamp: 3 !important;
-webkit-box-orient: vertical !important;
}
.ai-prompt-actions input[type="checkbox"] {
width: 16px !important;
height: 16px !important;
margin: 2px 8px 0 0 !important;
cursor: pointer !important;
appearance: auto !important;
-webkit-appearance: auto !important;
}
`);
// === 事件监听 ===
// 跟踪鼠标位置,用于快捷键触发时的菜单定位
document.addEventListener('mousemove', (e) => {
window.lastMouseY = e.clientY;
});
document.addEventListener("mouseup", (e) => {
// 仅处理左键抬起
if (e.button !== 0) return;
// 如果点击的是UI内部,忽略
if (e.target.closest('.ai-bubble-btn, .ai-prompt-menu, .ai-panel-container')) return;
// 稍微延迟,确保选区完成
setTimeout(() => {
const sel = window.getSelection();
const text = sel.toString().trim();
if (!text) {
removeUI();
return;
}
lastSelection = { sel, text };
const range = sel.getRangeAt(0);
// --- 核心改动:获取选区结束位置 ---
// 克隆 Range 并折叠到末尾,以获取最后一个字符后面的位置
const endRange = range.cloneRange();
endRange.collapse(false); // false 表示折叠到 end
let rect = endRange.getBoundingClientRect();
// 某些情况下折叠后的 range 获取不到 rect (width/height为0),回退到使用原 range 的右侧
let x, y;
if (rect.left === 0 && rect.top === 0) {
const fullRect = range.getBoundingClientRect();
x = fullRect.right;
y = fullRect.top;
} else {
x = rect.left;
y = rect.top;
}
// 显示圆点
showBubble(x, y);
}, 100);
});
// 点击页面其他地方关闭 UI (只处理左键点击)
document.addEventListener('mousedown', (e) => {
if (e.button !== 0) return; // 只处理左键点击
if (!e.target.closest('.ai-bubble-btn, .ai-prompt-menu')) {
removeUI();
}
});
// 快捷键触发提示词菜单 (Cmd+Shift+X)
document.addEventListener('keydown', (e) => {
// 检测 Cmd+Shift+X (Mac) 或 Ctrl+Shift+X (Windows/Linux)
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'x') {
e.preventDefault();
// 获取光标位置或使用页面中央
let x, y;
let mouseY = 200; // 默认高度
const selection = window.getSelection();
if (selection.rangeCount > 0) {
// 有选区时,使用选区位置
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
x = rect.left + rect.width / 2;
y = rect.top + rect.height / 2;
} else {
// 检查是否在输入框中
const activeEl = document.activeElement;
if (activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA')) {
const rect = activeEl.getBoundingClientRect();
x = rect.left + rect.width / 2;
y = rect.top + rect.height / 2;
} else {
// 使用页面中央
x = window.innerWidth / 2;
y = window.innerHeight / 2;
}
}
// 如果坐标计算结果为 (0,0) 或接近左上角,使用屏幕中央
if (x <= 10 && y <= 10) {
x = window.innerWidth / 2;
// 尝试使用鼠标位置的高度,如果没有则使用默认值
if (window.lastMouseY !== undefined) {
y = window.lastMouseY;
} else {
y = mouseY;
}
}
// 获取当前选中的文本(如果有)
let selText = window.getSelection().toString().trim();
if (!selText && document.activeElement) {
const el = document.activeElement;
if (el && typeof el.selectionStart === 'number' && el.selectionEnd > el.selectionStart) {
selText = el.value.substring(el.selectionStart, el.selectionEnd).trim();
}
}
// 存储详细的选区信息,包括活动元素和选区位置
const activeEl = document.activeElement;
const currentSelection = window.getSelection();
lastSelection = {
sel: currentSelection,
text: selText || '',
activeElement: activeEl,
// 保存输入框的选区位置
selectionStart: activeEl && typeof activeEl.selectionStart === 'number' ? activeEl.selectionStart : null,
selectionEnd: activeEl && typeof activeEl.selectionEnd === 'number' ? activeEl.selectionEnd : null,
// 保存页面选区的范围
range: currentSelection.rangeCount > 0 ? currentSelection.getRangeAt(0).cloneRange() : null
};
// 显示菜单
showPromptMenuAt(x, y);
}
});
// === UI 逻辑 ===
// 延迟移除 UI (用于 Hover 离开时的缓冲)
function deferRemoveUI() {
clearTimeout(closeTimer);
closeTimer = setTimeout(() => {
removeUI();
}, 250); // 给用户 250ms 的时间从圆点移动到菜单,或者从菜单移回圆点
}
function removeUI() {
clearTimeout(closeTimer);
bubbleBtn?.remove();
promptMenu?.remove();
bubbleBtn = null;
promptMenu = null;
}
function showBubble(client_x, client_y) {
removeUI(); // 先清理
bubbleBtn = document.createElement("div");
bubbleBtn.className = "ai-bubble-btn";
// 计算绝对坐标 (考虑滚动条)
// 定位在最后一个字的右上角:x 向右稍微偏移,y 向上偏移
const absX = client_x + window.scrollX + 1;
const absY = client_y + window.scrollY - 14; // 向上提,使圆点中心大致对齐文字顶端
bubbleBtn.style.left = `${absX}px`;
bubbleBtn.style.top = `${absY}px`;
document.body.appendChild(bubbleBtn);
// --- Hover 交互逻辑 ---
bubbleBtn.addEventListener("mouseenter", () => {
clearTimeout(closeTimer); // 取消关闭
showPromptMenu();
});
bubbleBtn.addEventListener("mouseleave", deferRemoveUI); // 离开开始倒计时关闭
}
function showPromptMenu() {
if (promptMenu) return; // 菜单已存在则不重建
const prompts = getPrompts().filter(p => p.enabled);
if (!prompts.length) {
showPopup(t('noPromptsEnabled'));
return;
}
promptMenu = document.createElement("div");
promptMenu.className = "ai-prompt-menu";
promptMenu.setAttribute('tabindex', '-1'); // 使菜单可以获得焦点
// 菜单显示在圆点的正下方,稍微重叠一点以便鼠标过渡
const btnRect = bubbleBtn.getBoundingClientRect();
promptMenu.style.left = `${btnRect.left + window.scrollX}px`;
promptMenu.style.top = `${btnRect.bottom + window.scrollY + 2}px`;
promptMenu.innerHTML = prompts.map((p, index) =>
`
${p.name}
`
).join("");
document.body.appendChild(promptMenu);
// 初始化键盘导航状态
selectedIndex = 0;
menuItems = promptMenu.querySelectorAll(".ai-prompt-item");
// 设置焦点以接收键盘事件
promptMenu.focus();
// --- 菜单的 Hover 逻辑 ---
promptMenu.addEventListener("mouseenter", () => clearTimeout(closeTimer)); // 进入菜单,取消关闭
promptMenu.addEventListener("mouseleave", deferRemoveUI); // 离开菜单,开始倒计时关闭
// 键盘导航事件
promptMenu.addEventListener("keydown", handleMenuKeydown);
// 鼠标悬停更新选中状态和点击菜单项
menuItems.forEach((item, index) => {
item.addEventListener("mouseenter", () => {
updateSelectedItem(index);
});
item.addEventListener("click", async (e) => {
e.stopPropagation(); // 防止触发 document 的 mousedown 立即关闭
removeUI(); // 点击后立即关闭菜单
const id = parseInt(item.dataset.id);
const p = prompts.find(x => x.id === id);
if (p) await runPrompt(p);
});
});
}
// 注册 Tampermonkey 右键子菜单命令
function registerMenuCommands() {
try {
if (typeof GM_unregisterMenuCommand === 'function' && Array.isArray(menuCmdIds)) {
menuCmdIds.forEach(id => {
try { GM_unregisterMenuCommand(id); } catch (_) {}
});
menuCmdIds = [];
}
} catch (_) {}
// 基础设置入口
try {
const id = GM_registerMenuCommand(t('menuSettings'), createSettingsPanel);
if (id !== undefined) menuCmdIds.push(id);
} catch (_) {}
// 为每个启用的提示词添加运行命令
const prompts = getPrompts().filter(p => p.enabled);
prompts.forEach(p => {
try {
const id = GM_registerMenuCommand(t('menuRun', {name: p.name}), () => {
// 获取当前选区文本(支持 input/textarea)
const selText = getSelectedText();
if (!selText) {
showPopup(t('pleaseSelectText'));
return;
}
lastSelection = { sel: window.getSelection(), text: selText };
runPrompt(p);
});
if (id !== undefined) menuCmdIds.push(id);
} catch (_) {}
});
}
// 在指定坐标显示提示词菜单
function showPromptMenuAt(client_x, client_y) {
removeUI();
const prompts = getPrompts().filter(p => p.enabled);
if (!prompts.length) {
showPopup(t('noPromptsEnabled'));
return;
}
promptMenu = document.createElement("div");
promptMenu.className = "ai-prompt-menu";
promptMenu.setAttribute('tabindex', '-1'); // 使菜单可以获得焦点
const absX = client_x + window.scrollX;
const absY = client_y + window.scrollY;
promptMenu.style.left = `${absX}px`;
promptMenu.style.top = `${absY + 2}px`;
promptMenu.innerHTML = prompts.map((p, index) =>
`${p.name}
`
).join("");
document.body.appendChild(promptMenu);
// 初始化键盘导航状态
selectedIndex = 0;
menuItems = promptMenu.querySelectorAll(".ai-prompt-item");
// 设置焦点以接收键盘事件
promptMenu.focus();
promptMenu.addEventListener("mouseenter", () => clearTimeout(closeTimer));
promptMenu.addEventListener("mouseleave", deferRemoveUI);
// 键盘导航事件
promptMenu.addEventListener("keydown", handleMenuKeydown);
// 鼠标悬停更新选中状态
menuItems.forEach((item, index) => {
item.addEventListener("mouseenter", () => {
updateSelectedItem(index);
});
item.addEventListener("click", async (e) => {
e.stopPropagation();
removeUI();
const id = parseInt(item.dataset.id);
const p = prompts.find(x => x.id === id);
if (p) await runPrompt(p);
});
});
}
// === AI 执行逻辑 ===
async function runPrompt(prompt) {
// 根据提示词的modelId获取对应的模型配置,如果没有则使用默认模型
let modelConfig;
if (prompt.modelId) {
modelConfig = getModelById(prompt.modelId);
if (!modelConfig) {
// 如果指定的模型不存在,使用默认模型
modelConfig = getDefaultModel();
}
} else {
// 使用默认模型
modelConfig = getDefaultModel();
}
if (!modelConfig) {
alert('未找到可用的模型配置,请先在设置中添加模型');
createSettingsPanel();
return;
}
const { endpoint, model, apikey } = modelConfig;
if (!apikey) {
alert(t('configureApiKey'));
createSettingsPanel();
return;
}
const content = prompt.text.replace("{text}", lastSelection.text);
const targetEl = getEditableElement();
showPopup(t('processing', {name: prompt.name}), 0); // 0 表示不自动消失
try {
const response = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "POST",
url: endpoint,
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${apikey}`
},
data: JSON.stringify({
model: model,
messages: [
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: content }
],
temperature: 0.7
}),
onload: function(response) {
resolve(response);
},
onerror: function(error) {
reject(error);
}
});
});
if (response.status !== 200) {
throw new Error(`API Error (${response.status}): ${response.responseText}`);
}
const data = JSON.parse(response.responseText);
const reply = data.choices?.[0]?.message?.content?.trim();
if (!reply) throw new Error(t('apiReturnEmpty'));
// 优先尝试回填到原选中区域
let pasteSuccess = false;
// 如果有保存的选区信息,尝试回填
if (lastSelection && (lastSelection.activeElement || lastSelection.range)) {
try {
if (lastSelection.activeElement &&
(lastSelection.activeElement.tagName === 'INPUT' ||
lastSelection.activeElement.tagName === 'TEXTAREA') &&
lastSelection.selectionStart !== null &&
lastSelection.selectionEnd !== null) {
// 处理输入框/文本域
const el = lastSelection.activeElement;
el.focus();
el.selectionStart = lastSelection.selectionStart;
el.selectionEnd = lastSelection.selectionEnd;
replaceSelectedText(el, reply);
pasteSuccess = true;
} else if (lastSelection.range) {
// 处理页面选区或contentEditable元素
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(lastSelection.range);
// 如果是contentEditable元素
if (lastSelection.activeElement && lastSelection.activeElement.isContentEditable) {
lastSelection.activeElement.focus();
replaceSelectedText(lastSelection.activeElement, reply);
pasteSuccess = true;
} else {
// 普通页面选区,直接替换
const range = selection.getRangeAt(0);
range.deleteContents();
range.insertNode(document.createTextNode(reply));
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
pasteSuccess = true;
}
}
} catch (err) {
console.warn('[AI Selector] 回填失败,将复制到剪贴板:', err);
pasteSuccess = false;
}
}
// 如果回填成功,显示成功消息
if (pasteSuccess) {
showPopup(t('completedAndFilled'));
} else if (targetEl) {
// 兼容原有的targetEl逻辑
replaceSelectedText(targetEl, reply);
showPopup(t('completedAndFilledSimple'));
} else {
// 回填失败或没有选区,复制到剪贴板
navigator.clipboard.writeText(reply).then(() => {
showPopup(t('copiedToClipboard', {result: reply}), 5000);
}).catch(() => {
showPopup(t('copyFailed', {result: reply}), 10000);
});
}
// 如果设置了下载文件,则下载结果
if (prompt.downloadFile) {
downloadAsFile(reply, prompt.name, prompt.fileExtension || 'txt');
}
} catch (err) {
console.error('[AI Selector Error]', err);
showPopup(t('failed', {error: err.message}), 5000);
}
}
// === 辅助工具 ===
function downloadAsFile(content, promptName, extension) {
try {
// 创建文件名,使用提示词名称和时间戳
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const filename = `${promptName}_${timestamp}.${extension}`;
// 创建 Blob 对象
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
// 创建下载链接
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.style.display = 'none';
// 添加到页面并触发下载
document.body.appendChild(a);
a.click();
// 清理
document.body.removeChild(a);
URL.revokeObjectURL(url);
//console.log(`[AI Selector] 文件已下载: ${filename}`);
} catch (err) {
console.error('[AI Selector] 文件下载失败:', err);
}
}
function showPopup(msg, duration = 3000) {
const old = document.getElementById('ai-status-popup-id');
if (old) old.remove();
const div = document.createElement("div");
div.id = 'ai-status-popup-id';
div.className = "ai-status-popup";
div.innerText = msg; // 使用 innerText 防止注入,保持换行
document.body.appendChild(div);
if (duration > 0) {
setTimeout(() => {
div.style.opacity = '0';
div.style.transition = 'opacity 0.3s';
setTimeout(() => div.remove(), 300);
}, duration);
}
}
function getEditableElement() {
const active = document.activeElement;
if (!active) return null;
// 判断是否是输入框或富文本编辑器
if (['textarea', 'input'].includes(active.tagName.toLowerCase()) || active.isContentEditable) {
return active;
}
return null;
}
function replaceSelectedText(el, newText) {
el.focus();
// 针对 input/textarea
if (typeof el.selectionStart === 'number') {
const start = el.selectionStart;
const end = el.selectionEnd;
const originalText = el.value;
el.value = originalText.substring(0, start) + newText + originalText.substring(end);
// 移动光标到新文本末尾
el.selectionStart = el.selectionEnd = start + newText.length;
// 尝试触发 input 事件以适配现代前端框架 (React/Vue等)
el.dispatchEvent(new Event('input', { bubbles: true }));
}
// 针对 contentEditable
else if (document.selection && document.selection.createRange) {
document.selection.createRange().text = newText;
} else {
// 标准 API
const sel = window.getSelection();
if (sel.rangeCount) {
const range = sel.getRangeAt(0);
range.deleteContents();
range.insertNode(document.createTextNode(newText));
// 移动光标到末尾
range.collapse(false);
sel.removeAllRanges();
sel.addRange(range);
}
}
}
// 获取当前选区文本(页面或输入框)
function getSelectedText() {
try {
const s = window.getSelection();
let text = (s && typeof s.toString === 'function') ? s.toString().trim() : '';
if (text) return text;
} catch (_) {}
const el = document.activeElement;
if (el && typeof el.selectionStart === 'number' && el.selectionEnd > el.selectionStart) {
return el.value.substring(el.selectionStart, el.selectionEnd).trim();
}
return '';
}
// === 设置面板 (保持逻辑,美化样式) ===
function createSettingsPanel() {
if (document.getElementById('ai-settings-overlay')) return;
const overlay = document.createElement('div');
overlay.id = 'ai-settings-overlay';
overlay.className = 'ai-panel-overlay';
const panel = document.createElement('div');
panel.className = 'ai-panel-container';
panel.innerHTML = `
${t('aiSettings')}
`;
overlay.appendChild(panel);
document.body.appendChild(overlay);
const close = () => overlay.remove();
// 语言切换事件处理
const updateLanguageButtons = () => {
const currentLang = getCurrentLanguage();
const isAuto = !GM_getValue('user_language', '');
document.querySelectorAll('.ai-lang-btn').forEach(btn => btn.classList.remove('active'));
if (isAuto) {
document.getElementById('ai-lang-auto').classList.add('active');
} else if (currentLang === 'zh') {
document.getElementById('ai-lang-zh').classList.add('active');
} else if (currentLang === 'en') {
document.getElementById('ai-lang-en').classList.add('active');
}
};
// 初始化语言按钮的选中状态
updateLanguageButtons();
document.getElementById('ai-lang-auto').onclick = () => {
GM_deleteValue('user_language');
updateLanguageButtons();
// 重新渲染面板以更新文本
setTimeout(() => {
close();
createSettingsPanel();
}, 100);
};
document.getElementById('ai-lang-zh').onclick = () => {
setLanguage('zh');
updateLanguageButtons();
// 重新渲染面板以更新文本
setTimeout(() => {
close();
createSettingsPanel();
}, 100);
};
document.getElementById('ai-lang-en').onclick = () => {
setLanguage('en');
updateLanguageButtons();
// 重新渲染面板以更新文本
setTimeout(() => {
close();
createSettingsPanel();
}, 100);
};
document.getElementById('ai-btn-close').onclick = close;
overlay.onclick = (e) => { if(e.target === overlay) close(); };
document.getElementById('ai-btn-models').onclick = () => {
close();
createModelManager();
};
document.getElementById('ai-btn-prompts').onclick = () => {
close();
createPromptManager();
};
}
// === 模型管理器 ===
function createModelManager() {
if (document.getElementById('ai-model-overlay')) return;
const overlay = document.createElement('div');
overlay.id = 'ai-model-overlay';
overlay.className = 'ai-panel-overlay';
const panel = document.createElement('div');
panel.className = 'ai-panel-container';
panel.style.width = '600px';
panel.innerHTML = `
${t('modelManagement')}
`;
overlay.appendChild(panel);
document.body.appendChild(overlay);
const close = () => overlay.remove();
document.getElementById('ai-model-close').onclick = close;
overlay.onclick = (e) => { if(e.target === overlay) close(); };
// 渲染模型列表
function renderModelList() {
const models = getModels();
const listEl = document.getElementById('ai-model-list');
if (models.length === 0) {
listEl.innerHTML = '暂无模型配置
';
return;
}
listEl.innerHTML = models.map(model => `
${model.name} ${model.isDefault ? `(${t('defaultModel')})` : ''}
${model.endpoint} - ${model.model}
${!model.isDefault ? `` : ''}
`).join('');
// 绑定操作事件
listEl.querySelectorAll('.ai-prompt-item').forEach(item => {
const id = parseInt(item.dataset.id);
const model = models.find(m => m.id === id);
item.querySelectorAll('[data-action]').forEach(btn => {
btn.onclick = (e) => {
e.stopPropagation();
const action = btn.dataset.action;
if (action === 'edit') {
openAddModelModal(model);
} else if (action === 'clone') {
// 复刻:打开新增模型窗口,但预填当前模型的值
openAddModelModal(model, true);
} else if (action === 'delete') {
if (models.length === 1) {
alert('至少需要保留一个模型');
return;
}
if (confirm(t('confirmDelete'))) {
const updatedModels = models.filter(m => m.id !== id);
// 如果删除的是默认模型,将第一个模型设为默认
if (model.isDefault && updatedModels.length > 0) {
updatedModels[0].isDefault = true;
}
saveModels(updatedModels);
renderModelList();
}
} else if (action === 'setDefault') {
const updatedModels = models.map(m => ({
...m,
isDefault: m.id === id
}));
saveModels(updatedModels);
renderModelList();
}
};
});
});
}
document.getElementById('ai-add-model-btn').onclick = () => openAddModelModal();
renderModelList();
}
// === 新增/编辑模型弹窗 ===
function openAddModelModal(prefillData = null, isClone = false) {
const addOverlay = document.createElement('div');
addOverlay.id = 'ai-add-model-overlay';
addOverlay.className = 'ai-panel-overlay';
const addPanel = document.createElement('div');
addPanel.className = 'ai-panel-container';
addPanel.style.width = '480px';
addPanel.innerHTML = `
${prefillData && !isClone ? t('editModel') : t('addModel')}
`;
addOverlay.appendChild(addPanel);
document.body.appendChild(addOverlay);
const nameInput = document.getElementById('ai-add-model-name');
const endpointInput = document.getElementById('ai-add-model-endpoint');
const modelInput = document.getElementById('ai-add-model-model');
const apikeyInput = document.getElementById('ai-add-model-apikey');
const defaultInput = document.getElementById('ai-add-model-default');
// 如果有预填充数据,则填充表单
if (prefillData) {
nameInput.value = prefillData.name || '';
endpointInput.value = prefillData.endpoint || '';
modelInput.value = prefillData.model || '';
apikeyInput.value = prefillData.apikey || '';
defaultInput.checked = prefillData.isDefault || false;
}
const closeAdd = () => addOverlay.remove();
document.getElementById('ai-add-model-cancel').onclick = closeAdd;
addOverlay.onclick = (e) => { if (e.target === addOverlay) closeAdd(); };
document.getElementById('ai-add-model-save').onclick = () => {
const newName = nameInput.value.trim();
const newEndpoint = endpointInput.value.trim();
const newModel = modelInput.value.trim();
const newApikey = apikeyInput.value.trim();
const isDefault = defaultInput.checked;
if (!newName || !newEndpoint || !newModel) {
alert(t('nameContentRequired'));
return;
}
const models = getModels();
if (prefillData && !isClone) {
// 编辑现有模型
const updatedModels = models.map(m => {
if (m.id === prefillData.id) {
return {
...m,
name: newName,
endpoint: newEndpoint,
model: newModel,
apikey: newApikey,
isDefault: isDefault
};
}
// 如果当前模型设为默认,其他模型取消默认
return { ...m, isDefault: isDefault ? false : m.isDefault };
});
saveModels(updatedModels);
} else {
// 新增模型
const newId = Math.max(...models.map(m => m.id), 0) + 1;
const newModelData = {
id: newId,
name: newName,
endpoint: newEndpoint,
model: newModel,
apikey: newApikey,
isDefault: isDefault
};
// 如果设为默认,其他模型取消默认
const updatedModels = isDefault
? [...models.map(m => ({ ...m, isDefault: false })), newModelData]
: [...models, newModelData];
saveModels(updatedModels);
}
showPopup(t('settingsSaved'));
closeAdd();
// 刷新模型列表
const modelList = document.getElementById('ai-model-list');
if (modelList) {
const modelManager = document.getElementById('ai-model-overlay');
if (modelManager) {
modelManager.remove();
createModelManager();
}
}
};
}
// === 新增提示词弹窗(全局函数)===
function openAddPromptModal(prefillData = null) {
const addOverlay = document.createElement('div');
addOverlay.id = 'ai-add-overlay';
addOverlay.className = 'ai-panel-overlay';
const addPanel = document.createElement('div');
addPanel.className = 'ai-panel-container';
addPanel.style.width = '480px';
addPanel.innerHTML = `
${t('addPrompt')}
`;
addOverlay.appendChild(addPanel);
document.body.appendChild(addOverlay);
const nameInput = document.getElementById('ai-add-name');
const textInput = document.getElementById('ai-add-text');
const modelSelect = document.getElementById('ai-add-model');
const downloadInput = document.getElementById('ai-add-download');
const extensionInput = document.getElementById('ai-add-extension');
const extensionLabel = document.getElementById('ai-add-extension-label');
// 填充模型选择下拉框
const models = getModels();
models.forEach(model => {
const option = document.createElement('option');
option.value = model.id;
option.textContent = model.name;
modelSelect.appendChild(option);
});
// 如果有预填充数据,则填充表单
if (prefillData) {
nameInput.value = prefillData.name || '';
textInput.value = prefillData.text || '';
modelSelect.value = prefillData.modelId || '';
downloadInput.checked = prefillData.downloadFile || false;
extensionInput.value = prefillData.fileExtension || 'txt';
// 根据下载选项显示/隐藏扩展名输入框
if (downloadInput.checked) {
extensionLabel.style.display = 'block';
}
}
// Show/hide extension input based on download checkbox
downloadInput.onchange = () => {
extensionLabel.style.display = downloadInput.checked ? 'block' : 'none';
};
const closeAdd = () => addOverlay.remove();
document.getElementById('ai-add-cancel').onclick = closeAdd;
addOverlay.onclick = (e) => { if (e.target === addOverlay) closeAdd(); };
document.getElementById('ai-add-save').onclick = () => {
const newName = nameInput.value.trim();
const newText = textInput.value.trim();
const newModelId = modelSelect.value || null;
const newDownloadFile = downloadInput.checked;
const newFileExtension = extensionInput.value.trim() || 'txt';
if (!newName || !newText) {
showPopup(t('nameContentRequired'));
return;
}
const prompts = getPrompts();
if (prefillData) {
// 编辑现有提示词
const updatedPrompts = prompts.map(p => {
if (p.id === prefillData.id) {
return {
...p,
name: newName,
text: newText,
modelId: newModelId,
downloadFile: newDownloadFile,
fileExtension: newFileExtension
};
}
return p;
});
savePrompts(updatedPrompts);
} else {
// 新增提示词
prompts.push({
id: Date.now(),
name: newName,
text: newText,
modelId: newModelId,
enabled: true,
downloadFile: newDownloadFile,
fileExtension: newFileExtension
});
savePrompts(prompts);
}
showPopup(t('promptUpdated'));
closeAdd();
registerMenuCommands();
// 如果提示词管理面板打开,刷新列表
const promptManager = document.getElementById('ai-prompts-overlay');
if (promptManager) {
// 触发重新渲染列表
const renderEvent = new CustomEvent('refreshPromptList');
document.dispatchEvent(renderEvent);
}
};
// 自动聚焦到名称输入框
setTimeout(() => nameInput.focus(), 100);
}
// === 提示词管理面板 ===
function createPromptManager() {
if (document.getElementById('ai-prompts-overlay')) return;
const overlay = document.createElement('div');
overlay.id = 'ai-prompts-overlay';
overlay.className = 'ai-panel-overlay';
const panel = document.createElement('div');
panel.className = 'ai-panel-container';
panel.style.width = '500px';
panel.innerHTML = `
${t('promptManagement')}
${t('promptPlaceholder')}
`;
overlay.appendChild(panel);
document.body.appendChild(overlay);
const close = () => overlay.remove();
document.getElementById('ai-btn-p-close').onclick = close;
overlay.onclick = (e) => { if(e.target === overlay) close(); };
// 编辑提示词弹窗
function openEditPromptModal(idx) {
const prompts = getPrompts();
const p = prompts[idx];
const editOverlay = document.createElement('div');
editOverlay.id = 'ai-edit-overlay';
editOverlay.className = 'ai-panel-overlay';
const editPanel = document.createElement('div');
editPanel.className = 'ai-panel-container';
editPanel.style.width = '480px';
editPanel.innerHTML = `
${t('editPrompt')}
`;
editOverlay.appendChild(editPanel);
document.body.appendChild(editOverlay);
const nameInput = document.getElementById('ai-edit-name');
const textInput = document.getElementById('ai-edit-text');
const downloadInput = document.getElementById('ai-edit-download');
const extensionInput = document.getElementById('ai-edit-extension');
const extensionLabel = document.getElementById('ai-edit-extension-label');
nameInput.value = p.name;
textInput.value = p.text;
downloadInput.checked = p.downloadFile || false;
extensionInput.value = p.fileExtension || 'txt';
// Show/hide extension input based on download checkbox
if (downloadInput.checked) {
extensionLabel.style.display = 'block';
}
downloadInput.onchange = () => {
extensionLabel.style.display = downloadInput.checked ? 'block' : 'none';
};
const closeEdit = () => editOverlay.remove();
document.getElementById('ai-edit-cancel').onclick = closeEdit;
editOverlay.onclick = (e) => { if (e.target === editOverlay) closeEdit(); };
document.getElementById('ai-edit-save').onclick = () => {
const newName = nameInput.value.trim();
const newText = textInput.value.trim();
const newDownloadFile = downloadInput.checked;
const newFileExtension = extensionInput.value.trim() || 'txt';
if (!newName || !newText) {
showPopup(t('nameContentRequired'));
return;
}
p.name = newName;
p.text = newText;
p.downloadFile = newDownloadFile;
p.fileExtension = newFileExtension;
savePrompts(prompts);
renderList();
closeEdit();
showPopup(t('promptUpdated'));
registerMenuCommands();
};
}
// 渲染列表
const renderList = () => {
const listEl = document.getElementById('ai-prompt-list');
listEl.innerHTML = '';
const prompts = getPrompts();
if (prompts.length === 0) {
listEl.innerHTML = '暂无提示词配置
';
return;
}
listEl.innerHTML = prompts.map((p, index) => `
`).join('');
// 绑定列表内事件
listEl.querySelectorAll('.ai-p-enable').forEach(el => el.onchange = (e) => {
prompts[e.target.dataset.idx].enabled = e.target.checked;
savePrompts(prompts);
registerMenuCommands();
});
listEl.querySelectorAll('[data-action="delete"]').forEach(el => el.onclick = (e) => {
if(confirm('确定删除?')) {
prompts.splice(e.target.dataset.idx, 1);
savePrompts(prompts);
renderList();
registerMenuCommands();
}
});
listEl.querySelectorAll('[data-action="edit"]').forEach(el => el.onclick = (e) => {
const idx = parseInt(e.target.dataset.idx, 10);
openEditPromptModal(idx);
});
};
document.getElementById('ai-btn-add').onclick = () => {
openAddPromptModal();
};
// 监听自定义事件,用于刷新列表
document.addEventListener('refreshPromptList', () => {
renderList();
});
renderList();
}
// === 键盘导航辅助函数 ===
function updateSelectedItem(newIndex) {
if (!menuItems || menuItems.length === 0) return;
// 移除所有选中状态
menuItems.forEach(item => item.classList.remove('selected'));
// 设置新的选中项
selectedIndex = newIndex;
menuItems[selectedIndex].classList.add('selected');
}
function handleMenuKeydown(e) {
if (!menuItems || menuItems.length === 0) return;
switch(e.key) {
case 'ArrowUp':
e.preventDefault();
const prevIndex = selectedIndex > 0 ? selectedIndex - 1 : menuItems.length - 1;
updateSelectedItem(prevIndex);
break;
case 'ArrowDown':
e.preventDefault();
const nextIndex = selectedIndex < menuItems.length - 1 ? selectedIndex + 1 : 0;
updateSelectedItem(nextIndex);
break;
case 'Enter':
e.preventDefault();
if (menuItems[selectedIndex]) {
// 触发选中项的点击事件
menuItems[selectedIndex].click();
}
break;
case 'Escape':
e.preventDefault();
removeUI();
break;
}
}
// === Select2ai:// 协议处理 ===
function parseSelect2aiProtocol(url) {
try {
//console.log('[Select2ai] 开始解析协议 URL:', url);
// 检查是否是 Select2ai:// 协议(大小写不敏感)
const lowerUrl = url.toLowerCase();
if (!lowerUrl.startsWith('select2ai://')) {
//console.log('[Select2ai] 不是有效的 Select2ai 协议 URL');
return null;
}
// 解析 URL 参数 - 支持两种格式: select2ai://? 和 select2ai://addPrompt?(大小写不敏感)
let urlToParse = url;
if (lowerUrl.startsWith('select2ai://addprompt?')) {
urlToParse = url.replace(/^select2ai:\/\/addprompt\?/i, 'https://dummy.com/?');
} else if (lowerUrl.startsWith('select2ai://?')) {
urlToParse = url.replace(/^select2ai:\/\/\?/i, 'https://dummy.com/?');
} else {
//console.log('[Select2ai] 不支持的协议格式');
return null;
}
const urlObj = new URL(urlToParse);
const params = urlObj.searchParams;
const result = {
name: decodeURIComponent(params.get('name') || ''),
text: decodeURIComponent(params.get('text') || ''),
downloadFile: params.get('downloadfile') === 'true',
fileExtension: params.get('fileExtension') || 'txt'
};
//console.log('[Select2ai] 解析结果:', result);
return result;
} catch (error) {
console.error('[Select2ai] 解析协议失败:', error);
return null;
}
}
function handleSelect2aiProtocol(protocolData) {
if (!protocolData) {
//console.log('[Select2ai] 协议数据为空,跳过处理');
return;
}
//console.log('[Select2ai] 处理协议数据:', protocolData);
// 打开添加提示词弹窗并预填充数据
setTimeout(() => {
//console.log('[Select2ai] 打开添加提示词弹窗');
openAddPromptModal(protocolData);
}, 100);
}
// 检查当前页面 URL 是否包含协议
function checkCurrentUrlForProtocol() {
const currentUrl = window.location.href;
//console.log('[Select2ai] 检查当前 URL:', currentUrl);
if (currentUrl.includes('Select2ai://')) {
const match = currentUrl.match(/Select2ai:\/\/[^?\s]*/);
if (match) {
//console.log('[Select2ai] 发现协议 URL:', match[0]);
const protocolData = parseSelect2aiProtocol(match[0]);
if (protocolData) {
handleSelect2aiProtocol(protocolData);
// 清理 URL
if (window.history && window.history.replaceState) {
window.history.replaceState({}, document.title, window.location.pathname);
}
}
}
}
}
// 监听协议链接点击事件
document.addEventListener('click', (e) => {
//console.log('[Select2ai] 点击事件触发:', e.target);
const target = e.target.closest('a[href^="Select2ai://"]');
if (target) {
//console.log('[Select2ai] 发现协议链接:', target.href);
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
const protocolData = parseSelect2aiProtocol(target.href);
if (protocolData) {
handleSelect2aiProtocol(protocolData);
}
return false;
}
}, true); // 使用捕获阶段
// 额外的协议处理 - 监听 beforeunload 事件
window.addEventListener('beforeunload', (e) => {
const currentUrl = window.location.href;
if (currentUrl.includes('Select2ai://')) {
//console.log('[Select2ai] beforeunload 事件中发现协议 URL');
e.preventDefault();
const protocolData = parseSelect2aiProtocol(currentUrl);
if (protocolData) {
handleSelect2aiProtocol(protocolData);
}
return false;
}
});
// === 初始化 ===
initDefaults();
registerMenuCommands();
// 检查页面加载时的协议 URL
checkCurrentUrlForProtocol();
// 监听 hashchange 事件
window.addEventListener('hashchange', checkCurrentUrlForProtocol);
// 监听 popstate 事件
window.addEventListener('popstate', checkCurrentUrlForProtocol);
})();