// ==UserScript==
// @name AI 解卦辅助工具
// @namespace http://tampermonkey.net/
// @version 2.7
// @description 只是一个解卦工具
// @author NULLUSER
// @match https://www.china95.net/paipan/*
// @match https://paipan.china95.net/*
// @require https://cdn.jsdelivr.net/npm/[email protected]/dist/showdown.min.js
// @require https://cdn.jsdelivr.net/npm/[email protected]/dist/html2canvas.min.js
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @license MIT
// ==/UserScript==
(function() {
'use strict';
// =========================================================================
// == 核心配置与状态变量 ==
// =========================================================================
const S_version = GM_info.script.version; // 脚本当前版本号
const DENO_SERVER_URL = 'https://works.shatang.me'; // 后端服务基准URL
let settings = {}; // 存储用户配置(API Key, 模型, WORK ID等)
let conversations = []; // 存储所有已保存的历史对话(每个对话包含多条消息)
let currentConversation = []; // 存储当前活跃对话的消息流
// let SHOW_URL = ''; // 不再直接使用此变量,已注释或删除
// 脚本的默认设置
const DEFAULTS = { apiKey: '', model: 'gemini-2.5-flash', workId: '01', maxHistory: 30, panelWidth: 520 };
// UI元素的全局引用,便于访问和操作
let analysisContainer, chatLogContainer, questionInput, sendButton, toggleButton;
let isUserScrolledUp = false; // 标记用户是否已向上滚动,用于智能滚动判断
// =========================================================================
// == 界面样式定义 (CSS-in-JS) ==
// =========================================================================
// UI设计常量
const BORDER_RADIUS = '14px'; // 统一的圆角大小
const BOX_SHADOW = '0 12px 35px -8px rgba(0, 0, 0, 0.5)'; // 阴影效果
// 字体栈:优先使用苹果和微软系统的圆润字体,确保中文显示美观
const FONT_FAMILY = `"SF Pro SC", "SF Pro Display", "SF Pro Text", "PingFang SC", "Helvetica Neue", "Microsoft YaHei", sans-serif`;
// 主题强调色渐变:紫罗兰色系
const ACCENT_GRADIENT = 'linear-gradient(135deg, #a78bfa, #6d28d9)'; // Violet-400 to Violet-700
const ACCENT_COLOR = '#8b5cf6'; // Violet-500,用于单色强调
// 注入CSS样式规则
GM_addStyle(`
/* 关键帧动画:用于消息淡入等效果 */
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
/* 主面板容器:"暗夜极光"主题优化版 */
#ai-gua-container {
position: fixed; top: 20px; right: 20px; height: calc(100vh - 40px);
background: #18181B; /* 深色背景 */
color: #d4d4d8; /* 默认文本颜色 */
z-index: 1000; padding: 0; box-shadow: ${BOX_SHADOW}; border-radius: ${BORDER_RADIUS};
display: flex; flex-direction: column; font-family: ${FONT_FAMILY};
border: 1px solid #27272a; /* 边框颜色 */
resize: horizontal; overflow: auto; min-width: 420px; max-width: 90vw; /* 可调整大小 */
transition: all 0.3s ease; /* 过渡动画 */
}
#ai-gua-container.hidden { display: none; } /* 隐藏状态 */
/* 悬浮切换按钮 */
#ai-toggle-button {
position: fixed; top: 20px; right: 20px; z-index: 1001;
background: ${ACCENT_GRADIENT}; border: none; color: white;
padding: 12px 18px; font-size: 16px; cursor: pointer;
border-radius: ${BORDER_RADIUS}; box-shadow: ${BOX_SHADOW};
transition: all 0.3s ease; display: flex; align-items: center; gap: 8px;
font-family: ${FONT_FAMILY};
}
#ai-toggle-button:hover { transform: scale(1.05); } /* 鼠标悬停放大效果 */
/* 面板头部区域 */
#ai-gua-header {
display: flex; justify-content: space-between; align-items: center;
padding: 8px 16px; background-color: #1f1f23; /* 头部背景色 */
border-bottom: 1px solid #27272a; border-top-left-radius: ${BORDER_RADIUS}; border-top-right-radius: ${BORDER_RADIUS};
}
#ai-gua-header h2 { margin: 0; font-size: 1.2em; color: #f9fafb; white-space: nowrap; font-weight: 600; }
#ai-gua-header h2 .version { font-size: 0.7em; color: #9ca3af; margin-left:8px; }
#ai-gua-header .header-buttons { display: flex; align-items: center; }
#ai-gua-header .header-buttons button {
background: transparent; border: none; color: #c4b5fd; /* 按钮图标颜色 */
cursor: pointer; margin-left: 4px; transition: all 0.2s; padding: 6px; border-radius: 8px;
}
#ai-gua-header .header-buttons button svg { width: 22px; height: 22px; stroke: currentColor; fill: none; stroke-width: 1.5; }
#ai-gua-header .header-buttons button:hover { background-color: rgba(255,255,255,0.1); color: white; transform: scale(1.1); }
/* 聊天记录区域 */
#chat-log-container { position: relative; flex-grow: 1; overflow-y: auto; padding: 20px; word-break: break-word; }
.message { margin-bottom: 24px; max-width: 98%; display: flex; flex-direction: column; animation: fadeIn 0.5s ease-out; }
.message-bubble { padding: 16px 20px; line-height: 1.7; box-shadow: 0 4px 10px rgba(0,0,0,0.2); font-size: 16px; /* 增大字体 */ }
/* 用户消息气泡 */
.user-message { align-self: flex-end; align-items: flex-end; }
.user-message .message-bubble { background: ${ACCENT_GRADIENT}; color: #FFFFFF; border-radius: ${BORDER_RADIUS} ${BORDER_RADIUS} 4px ${BORDER_RADIUS}; }
/* AI消息气泡 */
.ai-message { align-self: flex-start; align-items: flex-start; }
.ai-message .message-bubble { background-color: #27272a; color: #d4d4d8; border-radius: ${BORDER_RADIUS} ${BORDER_RADIUS} ${BORDER_RADIUS} 4px; }
/* 系统警报消息(不记录到历史) */
.system-alert { align-self: center; width: 100%; max-width: 100%;}
.system-alert .message-bubble { background-color: #7f1d1d; color: #fecaca; padding: 15px; border-radius: ${BORDER_RADIUS}; }
/* 欢迎界面 */
.welcome-screen { display: flex; flex-direction: column; justify-content: center; align-items: center; height: 100%; text-align: center; color: #6b7280;}
.message-bubble h3 { font-size: 1.15em; margin: 0 0 10px 0; color: #e5e7eb; font-weight: 600; border-bottom: 1px solid #4b4664; padding-bottom: 10px; }
.message-bubble .model-name { font-size: 0.8em; color: #a1a1aa; margin-bottom: 12px; font-weight: 400; display: block; background: #3f3f46; padding: 2px 8px; border-radius: 6px; display: inline-block; }
.message-bubble .analysis-section { margin-top: 15px; }
.message-bubble .analysis-section strong { font-size: 1.05em; color: ${ACCENT_COLOR}; display: block; margin-bottom: 8px; font-weight:600; }
.message-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 15px; padding-top: 10px; border-top: 1px solid #4b4664; font-size: 0.8em; color: #a5b4fc; }
.share-btn {
background: transparent; border: 1px solid #a5b4fc; color: #a5b4fc;
padding: 4px 10px; border-radius: 6px; cursor: pointer; transition: all 0.2s;
display: inline-flex; align-items: center; gap: 4px; font-size: 0.9em;
}
.share-btn:hover { background: #a5b4fc; color: #1c192c; }
.share-btn.copied { background: #22C55E; color: white; border-color:#22C55E; }
/* 输入区域 */
#input-area { padding: 16px; border-top: 1px solid #27272a; background-color: #1f1f23; position:relative; }
#question-input {
width: 100%; height: 80px; padding: 14px; padding-right: 35px; /* 为清空按钮留出空间 */
margin-bottom: 16px; border: 1px solid #3f3f46; border-radius: 10px;
background-color: #18181B; color: #e4e4e7; font-size: 16px; resize: vertical;
outline: none; transition: all 0.3s ease; box-sizing: border-box;
}
#question-input:focus { border-color: ${ACCENT_COLOR}; box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.4); }
/* 输入框清空按钮 */
#clear-input-btn { position: absolute; top: 30px; right: 30px; background: transparent; border: none; color: #71717a; cursor: pointer; padding: 5px; display: none; }
#clear-input-btn:hover { color: #e4e4e7; }
/* 发送按钮 */
#send-button {
background: ${ACCENT_GRADIENT}; border: none; color: white;
padding: 12px 28px; font-size: 16px; font-weight: 600; cursor: pointer;
border-radius: 10px; transition: all 0.3s ease;
}
#send-button:hover:not(:disabled) { transform: translateY(-2px); box-shadow: 0 4px 15px -5px rgba(139, 92, 246, 0.5); }
#send-button:disabled { background: #4b5563; cursor: not-allowed; transform: none; box-shadow: none; }
/* 新消息提示按钮(智能滚动使用) */
#new-message-prompt {
position: absolute; bottom: 180px; left: 50%; transform: translateX(-50%);
background: ${ACCENT_COLOR}; color: white; padding: 8px 16px; border-radius: 20px;
cursor: pointer; box-shadow: 0 4px 10px rgba(0,0,0,0.3); z-index: 10; display: none;
animation: fadeIn 0.3s; font-size: 0.9em;
}
/* 模态弹窗通用样式 */
.modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); z-index: 2000; display: flex; justify-content: center; align-items: center; }
.modal-content { background: #282a36; padding: 24px; border: 1px solid #44475a; width: 650px; max-width: 95vw; border-radius: ${BORDER_RADIUS}; font-family: ${FONT_FAMILY}; position: relative; }
.modal-content h2 { margin: 0 0 20px 0; color: #f8f8f2; }
.modal-content label { display: block; margin-top: 15px; margin-bottom: 5px; color: #f8f8f2; font-size: 0.95em; }
.modal-content input { width: 100%; padding: 10px; border: 1px solid #6272a4; border-radius: 8px; background-color: #383a59; color: #f8f8f2; box-sizing: border-box; font-size: 1em; }
.modal-content .button-group { margin-top: 25px; text-align: right; }
.modal-content .button-group button { background: ${ACCENT_GRADIENT}; border: none; color: white; padding: 10px 20px; cursor: pointer; border-radius: 8px; margin-left: 10px; font-weight: 500;}
.modal-content .button-group button.secondary { background: #6272a4; }
.modal-content .close-modal-btn { position: absolute; top: 10px; right: 10px; }
/* 历史记录弹窗特有样式 */
.history-group h3 { font-size: 0.9em; color: #bd93f9; margin: 16px 0 8px 4px; text-transform: uppercase; letter-spacing: 0.05em; }
.history-item { background-color: transparent;display: flex; align-items: center; padding: 12px; border-radius: 10px; transition: background-color 0.2s; }
.history-item:hover { background-color: #383a59; }
.history-item-title { color: #f8f8f2; flex-grow: 1; margin: 0 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; font-size: 1.05em; }
.history-item-title input { background: #383a59; border: 1px solid ${ACCENT_COLOR}; color:#f8f8f2; padding: 6px 10px; border-radius: 6px; width: 100%; box-sizing: border-box; font-size: 1em; }
.history-item-actions { display: inline-flex; gap: 4px; }
.history-item-actions button { background: transparent; border: none; color: #f8f8f2; cursor: pointer; padding: 4px; transition: color 0.2s;}
.history-item-actions button svg { width: 18px; height: 18px; }
.history-item-actions button:hover { color: #8be9fd; }
`);
// =========================================================================
// == SVG图标定义 ==
// =========================================================================
// 用于界面元素的SVG图标,减少HTTP请求,提高加载速度和显示质量
const ICONS = {
saveAndNew: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9 3.75H6.912a2.25 2.25 0 00-2.15 1.588L2.35 13.177a2.25 2.25 0 00-.1.661V18a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18v-4.162c0-.224-.034-.447-.1-.661L19.24 5.338A2.25 2.25 0 0017.088 3.75H15M12 3.75v9m-4.5-4.5h9" /></svg>`,
history: `<svg fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" /></svg>`,
settings: `<svg fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.438.995s.145.755.438.995l1.003.827c.481.398.688 1.054.26 1.431l-1.296 2.247a1.125 1.125 0 01-1.37.49l-1.217-.456c-.355-.133-.75-.072-1.075.124a6.57 6.57 0 01-.22.127c-.332.183-.582.495-.645.87l-.213 1.281c-.09.543-.56.94-1.11.94h-2.593c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.063-.374-.313-.686-.645-.87a6.52 6.52 0 01-.22-.127c-.324-.196-.72-.257-1.075-.124l-1.217.456a1.125 1.125 0 01-1.37-.49l-1.296-2.247a1.125 1.125 0 01.26-1.431l1.003-.827c.293-.24.438-.613.438-.995s-.145-.755-.438-.995l-1.003-.827a1.125 1.125 0 01-.26-1.431l1.296-2.247a1.125 1.125 0 011.37-.49l1.217.456c.355.133.75.072 1.075-.124.073-.044.146-.087.22-.127.332-.183.582-.495.645-.87l.213-1.281z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>`,
close: `<svg fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>`,
welcome: `<svg fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.898 20.572L16.5 21.75l-.398-1.178a3.375 3.375 0 00-2.456-2.456L12.5 18l1.178-.398a3.375 3.375 0 002.456 2.456L16.5 14.25l.398 1.178a3.375 3.375 0 002.456 2.456L20.25 18l-1.178.398a3.375 3.375 0 00-2.456 2.456z" /></svg>`,
share: `<svg fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M7.217 10.907a2.25 2.25 0 100 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186l9.566-5.314m-9.566 7.5l9.566 5.314m0 0a2.25 2.25 0 103.935 2.186 2.25 2.25 0 00-3.935-2.186zm0-12.828a2.25 2.25 0 103.935-2.186 2.25 2.25 0 00-3.935 2.186z" /></svg>`,
rename: `<svg viewBox="0 0 20 20" fill="currentColor"><path d="M2.695 14.763l-1.262 3.154a.5.5 0 00.65.65l3.155-1.262a4 4 0 001.343-.885L17.5 5.5a2.121 2.121 0 00-3-3L3.58 13.42a4 4 0 00-.885 1.343zM4.5 12.25l10-10l1.25 1.25-10 10-1.25-1.25z"/></svg>`,
restore: `<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M15.312 11.344A8.001 8.001 0 004.688 8.656a.75.75 0 011.062-1.062 6.5 6.5 0 019.19 9.19.75.75 0 11-1.06 1.062A7.96 7.96 0 0015.312 11.344zM4.75 4.75a.75.75 0 01.75-.75h4.5a.75.75 0 010 1.5h-3.75v3.75a.75.75 0 01-1.5 0v-4.5z" clip-rule="evenodd"/></svg>`,
trash: `<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M8.75 1A2.75 2.75 0 006 3.75v.443c-.795.077-1.58.22-2.365.468a.75.75 0 10.23 1.482l.149-.022A18.809 18.809 0 015.22 6.02a.75.75 0 00.867.625l.89-.125a.75.75 0 01.625.866l-.125.89a.75.75 0 00.625.867l.89-.125a.75.75 0 01.625.866l-.125.89a.75.75 0 00.625.867l1.498.212A.75.75 0 0015 11.25v-2.365a.75.75 0 00-1.482-.23l-.022.149a18.81 18.81 0 01-1.242-2.102.75.75 0 00-.625-.867l-.89.125a.75.75 0 01-.866-.625l.125-.89a.75.75 0 00-.867-.625l-.89.125a.75.75 0 01-.866-.625l.125-.89A2.75 2.75 0 008.75 1zM11.25 1a2.75 2.75 0 012.75 2.75v.443c.795.077 1.58.22 2.365.468a.75.75 0 11-.23 1.482l-.149-.022a18.809 18.809 0 00-1.242 2.102.75.75 0 01.625.867l.89-.125a.75.75 0 00.866.625l.89.125a.75.75 0 01.625.866l-.125.89a.75.75 0 00.625.867l-1.498.212A.75.75 0 0115 11.25v2.365a.75.75 0 01-1.482.23l.022-.149a18.81 18.81 0 00-1.242-2.102.75.75 0 01-.625-.867l-.89.125a.75.75 0 00-.866-.625l-.89-.125a.75.75 0 01-.625-.866l.125-.89a.75.75 0 00-.867-.625l-.89.125a.75.75 0 01-.866-.625L8.32 3.75v-.443A2.75 2.75 0 0111.25 1z" clip-rule="evenodd"/></svg>`,
clear: `<svg fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clip-rule="evenodd" /></svg>`
};
// =========================================================================
// == 核心逻辑与辅助函数 ==
// =========================================================================
/** 调试日志输出 */
const Dbg = {
log: (msg, ...args) => console.log(`[AI助手 v${S_version}] ${msg}`, ...args),
err: (msg, ...args) => console.error(`[AI助手 v${S_version}] ${msg}`, ...args)
};
/** 安全地解析JSON字符串,失败返回null */
const safeJsonParse = str => { try { return JSON.parse(str); } catch(e){ return null; } };
/** 从GM存储加载设置 */
const loadSettings = () => { settings = { ...DEFAULTS, ...safeJsonParse(GM_getValue('aiGuaSettings', '{}')) }; };
/** 保存当前设置到GM存储(包括面板宽度) */
const saveSettings = () => { try { settings.panelWidth = analysisContainer.offsetWidth; } catch(e){} GM_setValue('aiGuaSettings', JSON.stringify(settings)); };
/** 从GM存储加载所有历史对话 */
const loadConversations = () => { conversations = safeJsonParse(GM_getValue('conversations', '[]')) || []; };
/** 保存所有历史对话到GM存储,并处理数量上限 */
const saveConversations = () => { while (conversations.length > settings.maxHistory) { conversations.shift(); } GM_setValue('conversations', JSON.stringify(conversations)); };
/** 归档当前对话到历史记录 */
const archiveCurrentConversation = () => {
if (currentConversation.length > 0) {
const firstQuestion = currentConversation[0].question;
const title = firstQuestion.length > 40 ? firstQuestion.substring(0, 37) + '...' : firstQuestion;
conversations.push({ id: Date.now(), title: title || "无标题对话", messages: currentConversation });
saveConversations();
Dbg.log(`已归档对话: "${title}"`);
}
};
/**
* 多策略JSON提取与解析函数:
* 1. 尝试匹配 ```json ... ``` 代码块 (更宽松的匹配)
* 2. 尝试匹配 ``` ... ``` (不带json) 代码块 (更宽松的匹配)
* 3. 尝试匹配第一个 { 到最后一个 } 之间的内容 (增加合法性初步校验)
* 4. 否则,将整个文本作为JSON字符串尝试解析
* @param {string} text - AI返回的原始文本
* @returns {object|null} - 解析后的JSON对象或null(如果解析失败)
*/
const extractAndParseJson = text => {
/**
* 安全地解析JSON字符串。
* 捕获解析错误并返回null。
* @param {string} str - 待解析的字符串
* @returns {object|null} - 解析后的JSON对象或null
*/
const safeJsonParse = (str) => { // safeJsonParse 定义在这里
try {
if (typeof str !== 'string' || !str.trim()) {
return null; // 非字符串或空字符串直接返回null
}
// 尝试移除可能导致解析失败的特殊字符(如BOM头 \uFEFF)和处理极端空白情况
const cleanedStr = str.trim().replace(/^\uFEFF/, '');
return JSON.parse(cleanedStr);
} catch (e) {
// 在这里打印错误信息,假设Dbg.err可用。
// 如果Dbg不可用,请替换为console.error或适合你项目的日志方式。
// Dbg.err("safeJsonParse 错误:", e.message, "尝试解析的字符串:", str);
return null;
}
};
let jsonString = null;
// 统一处理输入文本,移除前后空白,这很重要,因为有时代码块前后会有多余换行
const trimmedText = text.trim();
// 策略1: 严格的json代码块(允许代码块前后有任何空白字符,包括换行)
// `/s` 标志让 `.` 匹配包括换行符在内的所有字符
const jsonBlockMatch = trimmedText.match(/```json\s*([\s\S]*?)\s*```/s);
if (jsonBlockMatch && jsonBlockMatch[1]) {
jsonString = jsonBlockMatch[1];
} else {
// 策略2: 通用代码块(允许代码块前后有任何空白字符,包括换行)
const genericBlockMatch = trimmedText.match(/```\s*([\s\S]*?)\s*```/s);
if (genericBlockMatch && genericBlockMatch[1]) {
jsonString = genericBlockMatch[1];
} else {
// 策略3: 括号匹配
const firstBrace = trimmedText.indexOf('{');
const lastBrace = trimmedText.lastIndexOf('}');
// 只有当都找到且末尾括号在开始括号之后时才尝试截取
if (firstBrace !== -1 && lastBrace > firstBrace) {
let potentialJson = trimmedText.substring(firstBrace, lastBrace + 1); // 修正:lastBrase -> lastBrace
// 进一步检查 potentialJson 是否以有效的JSON开始字符开头(`{` 或 `[`)
// 某些JSON可能直接是数组,所以加上 `[`
if (potentialJson.trim().startsWith('{') || potentialJson.trim().startsWith('[')) {
jsonString = potentialJson;
} else {
jsonString = trimmedText; // 退化到策略4
}
} else {
jsonString = trimmedText; // 策略4: 整个文本
}
}
}
// 最终尝试解析提取到的字符串
const result = safeJsonParse(jsonString);
// 如果解析失败,可以在此处记录详细的错误信息,方便调试
if (!result) {
// 假设 Dbg.err 是一个日志函数,你需要确保它在你的环境中可用
// Dbg.err("JSON解析失败。原始文本:", text, "尝试解析的字符串:", jsonString);
}
return result;
};
// =========================================================================
// == 用户界面构建 ==
// =========================================================================
/** 初始化并构建AI助手的主界面 */
const createMainUI = () => {
// 创建主切换按钮
toggleButton = document.createElement('button');
toggleButton.id = 'ai-toggle-button';
toggleButton.title = 'AI 解卦助手';
toggleButton.innerHTML = `AI 解卦`;
document.body.appendChild(toggleButton);
// 创建主AI面板容器
analysisContainer = document.createElement('div');
analysisContainer.id = 'ai-gua-container';
analysisContainer.className = 'hidden';
analysisContainer.innerHTML = `
<div id="ai-gua-header">
<h2>AI 解卦<span class="version">v${S_version}</span></h2>
<div class="header-buttons">
<button id="save-new-btn" title="保存当前对话并新建一个空白对话">${ICONS.saveAndNew}</button>
<button id="history-btn" title="查看并管理历史对话">${ICONS.history}</button>
<button id="settings-btn" title="设置API Key与模型">${ICONS.settings}</button>
<button id="close-btn" title="隐藏AI助手面板">${ICONS.close}</button>
</div>
</div>
<div id="chat-log-container">
<div id="new-message-prompt" title="点击滚动到底部">↓ 有新消息</div>
</div>
<div id="input-area">
<textarea id="question-input" placeholder="请在这里输入你的问题..."></textarea>
<button id="clear-input-btn" title="清空输入框内容">${ICONS.clear}</button>
<div style="display:flex;justify-content:space-between;align-items:center">
<label style="color:#9ca3af;display:inline-flex;align-items:center;cursor:pointer;font-size:14px">
<input type="checkbox" id="use-history" style="margin-right:5px" checked>基于历史追问
</label>
<button id="send-button">发送分析</button>
</div>
</div>`;
document.body.appendChild(analysisContainer);
// 缓存常用的UI元素引用 - 这些应该在元素被添加到DOM后立即获取
chatLogContainer = document.getElementById('chat-log-container');
questionInput = document.getElementById('question-input');
sendButton = document.getElementById('send-button');
// 绑定事件监听器
toggleButton.addEventListener('click', () => {
analysisContainer.classList.remove('hidden');
toggleButton.style.display = 'none';
});
// 获取并绑定 analysisContainer 内部的按钮
document.getElementById('close-btn').addEventListener('click', () => { archiveCurrentConversation(); analysisContainer.classList.add('hidden'); toggleButton.style.display = 'flex'; });
document.getElementById('save-new-btn').addEventListener('click', handleSaveAndNew);
document.getElementById('history-btn').addEventListener('click', showHistoryModal);
document.getElementById('settings-btn').addEventListener('click', showSettingsModal);
sendButton.addEventListener('click', handleSend);
questionInput.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } }); // 回车键提交
chatLogContainer.addEventListener('click', e => { const shareBtn = e.target.closest('.share-btn'); if(shareBtn) { handleCopyToClipboard(shareBtn); } });
// 智能滚动事件监听
const newMessagePrompt = document.getElementById('new-message-prompt');
chatLogContainer.addEventListener('scroll', () => {
// 判断用户是否已向上滚动超过100px
isUserScrolledUp = chatLogContainer.scrollHeight - chatLogContainer.scrollTop - chatLogContainer.clientHeight > 100;
if (newMessagePrompt && !isUserScrolledUp) { // 添加 newMessagePrompt 存在性检查
newMessagePrompt.style.display = 'none';
}
});
if (newMessagePrompt) { // 添加 newMessagePrompt 存在性检查
newMessagePrompt.addEventListener('click', () => { scrollToBottom(true); }); // 点击提示滚动到底部
}
// 输入框清空按钮功能
const clearInputBtn = document.getElementById('clear-input-btn');
if (questionInput && clearInputBtn) { // 添加存在性检查
questionInput.addEventListener('input', () => { clearInputBtn.style.display = questionInput.value ? 'block' : 'none'; });
clearInputBtn.addEventListener('click', () => { questionInput.value = ''; clearInputBtn.style.display = 'none'; questionInput.focus(); });
}
};
/** 渲染聊天记录为空时的欢迎界面 */
const renderEmptyState = () => { chatLogContainer.innerHTML = `<div class="welcome-screen">${ICONS.welcome}<h3>AI 解卦助手</h3><p>准备好开始了吗?<br>请在下方的输入框中提出你的问题。</p></div>`; };
/**
* 在聊天记录中追加一条消息
* @param {string} content - 消息内容
* @param {string} type - 'user', 'ai', 或 'system'
* @param {object} options - 额外选项,如消息ID
* @returns {string} - 生成的消息ID
*/
const appendMessage = (content, type, options = {}) => {
// 如果当前显示的是欢迎界面,则清空
if (chatLogContainer.querySelector('.welcome-screen')) chatLogContainer.innerHTML = '';
const id = options.id || `msg-${Date.now()}`;
const messageDiv = document.createElement('div'); messageDiv.id = id; messageDiv.className = `message ${type}-message`;
messageDiv.innerHTML = `<div class="message-bubble"><p>${content}</p></div>`;
chatLogContainer.appendChild(messageDiv);
// 如果用户未滚动,则自动滚动到底部并隐藏新消息提示
if (!isUserScrolledUp) { scrollToBottom(true); } else { document.getElementById('new-message-prompt').style.display = 'block'; }
return id;
};
/**
* 更新指定ID消息的内容,用于加载中或最终结果显示
* @param {string} id - 消息ID
* @param {object|string} content - JSON分析结果对象或错误信息字符串
* @param {object} options - {isAnalysis: true, question: "...", error: true}
*/
const updateMessage = (id, content, options = {}) => {
const messageDiv = document.getElementById(id); if (!messageDiv) return;
messageDiv.className = 'message ai-message'; // 默认设为AI消息类型
const bubbleDiv = messageDiv.querySelector('.message-bubble');
if (options.isAnalysis) { // 是AI的分析结果
bubbleDiv.innerHTML = `<div class="message-header"><h3>你的问题: ${options.question || '...'}</h3><span class="model-name">Model: ${settings.model}</span></div><div class="analysis-section"><strong>综合判断</strong><p>${content['结果'] || 'N/A'}</p></div><div class="analysis-section"><strong>分析过程</strong><p>${content['分析过程'] || 'N/A'}</p></div><div class="message-footer"><span>${new Date().toLocaleString()}</span><button class="share-btn" title="复制为图片">${ICONS.share} 复制</button></div>`;
} else { // 是系统警报或加载提示
messageDiv.classList.add('system-alert'); // 添加警报样式
bubbleDiv.innerHTML = `<h4>系统警报</h4><p>${content}</p>`;
}
// 智能滚动逻辑
if (!isUserScrolledUp) { scrollToBottom(true); } else { document.getElementById('new-message-prompt').style.display = 'block'; }
};
/**
* 渲染指定对话的消息到聊天记录区域
* @param {Array<object>} conversation - 消息数组
*/
const renderConversation = conversation => {
chatLogContainer.innerHTML = '';
if (!conversation || conversation.length === 0) { renderEmptyState(); return; }
conversation.forEach(item => {
appendMessage(item.question, 'user');
const aiMsgId = `ai-msg-${item.id || Date.now()}`; appendMessage('...', 'ai', {id:aiMsgId}); // 先显示占位符
updateMessage(aiMsgId, item.result, { isAnalysis: true, question: item.question }); // 再更新为实际内容
});
scrollToBottom();
};
/**
* 滚动聊天记录到底部
* @param {boolean} smooth - 是否平滑滚动
*/
const scrollToBottom = (smooth = false) => { chatLogContainer.scrollTo({ top: chatLogContainer.scrollHeight, behavior: smooth ? 'smooth' : 'auto' }); };
/**
* 切换输入框和发送按钮的禁用状态
* @param {boolean} disabled - 是否禁用(true=禁用,false=启用)
*/
const toggleInputs = (disabled) => {
if (questionInput) questionInput.disabled = disabled;
if (sendButton) sendButton.disabled = disabled;
// 清空按钮在禁用时隐藏(避免误操作)
const clearInputBtn = document.getElementById('clear-input-btn');
if (clearInputBtn) clearInputBtn.style.display = disabled ? 'none' : (questionInput.value ? 'block' : 'none');
};
// =========================================================================
// == API请求与分析处理 ==
// =========================================================================
/**
* 发送用户问题和页面HTML内容到后端进行AI分析(修复输入框状态版)
* @param {string} question - 用户提出的问题
*/
const analyzeHTML = async question => {
// 输入为空时直接返回
if (!question.trim()) {
alert("请输入需要分析的问题内容");
return;
}
// 清除之前的系统警报(避免历史提示干扰)
document.querySelectorAll('.system-alert').forEach(el => el.remove());
// 显示用户消息
appendMessage(question, 'user');
// 生成加载消息ID(用于后续更新)
const loadingMsgId = `loading-${Date.now()}`;
appendMessage('AI分析中,请稍候...', 'ai', {
id: loadingMsgId,
isProcessing: true // 标记为处理中状态
});
// 禁用输入框和发送按钮(防止重复提交)
toggleInputs(true);
try {
// 构建请求载荷(包含用户问题、历史对话、页面HTML)
const payload = {
modelName: settings.model,
modelKey: settings.apiKey,
promptId: settings.workId,
userData: JSON.stringify({
question: question,
history: document.getElementById('use-history').checked ? currentConversation : [], // 按需携带历史
htmlContent: document.body.innerHTML // 当前页面完整HTML
})
};
// 发送GM_xmlhttpRequest请求
const response = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "POST",
url: `${DENO_SERVER_URL}/api`,
headers: { "Content-Type": "application/json" },
data: JSON.stringify(payload),
timeout: 90000, // 90秒超时
onload: res => resolve(res),
onerror: err => reject(err),
ontimeout: () => reject(new Error("请求超时(90秒)"))
});
});
// 处理成功响应
if (response.status === 200) {
const rawResponse = safeJsonParse(response.responseText);
if (!rawResponse) throw new Error("服务器返回无效JSON");
// 提取AI分析结果(使用健壮的JSON解析器)
const analysisResult = extractAndParseJson(rawResponse.choices?.[0]?.message?.content || "");
if (analysisResult?.结果) {
// 移除加载消息
const loadingMsg = document.getElementById(loadingMsgId);
if (loadingMsg) loadingMsg.remove();
// 生成最终消息ID并追加
const finalMsgId = `ai-result-${Date.now()}`;
appendMessage("分析完成", 'ai', { id: finalMsgId }); // 占位符
updateMessage(finalMsgId, analysisResult, {
isAnalysis: true,
question: question,
timestamp: new Date().toLocaleString()
});
} else {
throw new Error("AI未返回有效分析结果");
}
} else {
throw new Error(`服务器返回异常状态码:${response.status} ${response.statusText}`);
}
} catch (error) {
// 统一错误处理
console.error("分析过程发生错误:", error);
const errorMsg = error.message || "未知错误,请重试";
// 更新加载消息为错误提示
const loadingMsg = document.getElementById(loadingMsgId);
if (loadingMsg) {
loadingMsg.textContent = `❌ ${errorMsg}`;
loadingMsg.classList.add('system-alert');
}
} finally {
// 无论成功/失败/超时,最终启用输入框
toggleInputs(false);
}
};
// =========================================================================
// == 弹窗与事件处理 ==
// =========================================================================
/** 处理发送按钮点击或回车键提交 */
const handleSend = () => { const q = questionInput.value; if(!q.trim()) return; questionInput.value = ''; document.getElementById('clear-input-btn').style.display = 'none'; analyzeHTML(q); };
/** 处理“保存并新建”按钮点击 */
const handleSaveAndNew = () => { archiveCurrentConversation(); currentConversation = []; renderEmptyState(); };
/**
* 通用模态弹窗创建函数
* @param {string} content - 弹窗内容HTML
* @returns {HTMLElement} - 弹窗的DOM元素
*/
const showModal = content => {
const overlay = document.createElement('div'); overlay.className = 'modal-overlay';
overlay.innerHTML = `<div class="modal-content"><button class="header-buttons close-modal-btn" title="关闭弹窗">${ICONS.close}</button>${content}</div>`;
document.body.appendChild(overlay);
const closeModal = () => overlay.remove();
overlay.addEventListener('click', e => { if (e.target === overlay) closeModal(); }); // 点击背景关闭
overlay.querySelector('.close-modal-btn').addEventListener('click', closeModal); // 点击关闭按钮关闭
return overlay;
};
/** 显示设置弹窗 */
const showSettingsModal = () => {
const modal = showModal(`
<h2>设置</h2>
<label for="api-key-input">Gemini API Key: <a href="https://aistudio.google.com/app/apikey" target="_blank" style="color:${ACCENT_COLOR};">(如何获取API Key?)</a></label>
<input type="text" id="api-key-input" value="${settings.apiKey}" placeholder="例如: AIzaSyB...ABC">
<label for="model-input">Gemini 模型名称:</label>
<input type="text" id="model-input" value="${settings.model}" placeholder="例如: gemini-pro">
<label for="work-id-input">后端服务 WORK ID:</label>
<input type="text" id="work-id-input" value="${settings.workId}" placeholder="咨询服务提供者获取,例如: 01">
<label for="history-count-input">历史对话归档数量 (1-100):</label>
<input type="number" id="history-count-input" value="${settings.maxHistory}" min="1" max="100">
<div class="button-group">
<button id="save-settings-btn">保存设置</button>
</div>`);
modal.querySelector('#save-settings-btn').addEventListener('click', () => {
settings.apiKey = modal.querySelector('#api-key-input').value.trim();
settings.model = modal.querySelector('#model-input').value.trim();
settings.workId = modal.querySelector('#work-id-input').value.trim();
settings.maxHistory = parseInt(modal.querySelector('#history-count-input').value, 10) || 30;
saveSettings(); loadConversations(); // 保存后重新加载设置和对话
alert('设置已保存!'); modal.remove();
});
};
/** 显示历史记录弹窗 */
const showHistoryModal = () => {
// 根据日期将对话分组
const groups = { today: [], yesterday: [], last7Days: [], older: [] };
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
conversations.forEach(conv => {
const convDate = new Date(conv.id);
if (convDate >= todayStart) groups.today.push(conv);
else if (convDate >= new Date(todayStart).setDate(todayStart.getDate() - 1)) groups.yesterday.push(conv);
else if (convDate >= new Date(todayStart).setDate(todayStart.getDate() - 7)) groups.last7Days.push(conv);
else groups.older.push(conv);
});
// 渲染对话分组HTML
const renderGroup = (title, convs) => convs.length === 0 ? '' : `
<div class="history-group"><h3>${title}</h3>
${convs.slice().reverse().map(c => `<div class="history-item" data-id="${c.id}" title="${c.title}">
<span class="history-item-title">${c.title}</span><div class="history-item-actions">
<button class="rename-btn" title="重命名对话">${ICONS.rename}</button>
<button class="restore-btn" title="恢复此对话到聊天面板">${ICONS.restore}</button>
<button class="delete-btn" title="删除此对话">${ICONS.trash}</button></div></div>`).join('')}</div>`;
// 组合所有分组的HTML,如果没有历史则显示提示
const historyHtml = conversations.length === 0 ? '<p style="text-align:center; color:#9ca3af; padding: 20px 0;">暂无已保存的对话</p>' : renderGroup('今天', groups.today) + renderGroup('昨天', groups.yesterday) + renderGroup('过去7天', groups.last7Days) + renderGroup('更早', groups.older);
const modal = showModal(`<h2>对话历史</h2><div style="max-height: 60vh; overflow-y: auto;">${historyHtml}</div><div class="button-group"><button id="export-history-btn" class="secondary">导出所有历史为.txt</button></div>`);
modal.querySelector('#export-history-btn').addEventListener('click', exportHistory);
modal.addEventListener('click', e => {
const item = e.target.closest('.history-item'); if (!item) return;
const convId = Number(item.dataset.id); const titleSpan = item.querySelector('.history-item-title');
if (e.target.closest('.rename-btn')) { // 重命名按钮点击逻辑
const currentTitle = titleSpan.textContent;
titleSpan.innerHTML = `<input type="text" value="${currentTitle}" autofocus/>`;
const input = titleSpan.querySelector('input'); input.focus(); input.select();
const saveRename = () => { const newTitle = input.value.trim() || currentTitle; const convIndex = conversations.findIndex(c=>c.id===convId); if(convIndex > -1){conversations[convIndex].title=newTitle;saveConversations();titleSpan.textContent=newTitle;}else{titleSpan.textContent=currentTitle;}};
input.addEventListener('blur', saveRename);
// 修复历史记录重命名输入框的笔误:将 'i.blur()' 改为 'input.blur()'
input.addEventListener('keydown', ev=>{if(ev.key==='Enter')input.blur();if(ev.key==='Escape'){input.value=currentTitle;input.blur();}});
} else if (e.target.closest('.restore-btn') || e.target === titleSpan) { // 恢复或点击标题逻辑
archiveCurrentConversation(); // 归档当前对话
const conv = conversations.find(c => c.id === convId);
if(conv){ currentConversation = conv.messages; conversations = conversations.filter(c => c.id !== convId); saveConversations(); renderConversation(currentConversation); modal.remove();}
} else if (e.target.closest('.delete-btn')) { // 删除按钮点击逻辑
if (confirm(`确定要删除对话 "${titleSpan.textContent}" 吗?此操作不可撤销。`)) {
conversations = conversations.filter(c => c.id !== convId);
saveConversations();
item.remove(); // 从DOM中移除
}
}
});
};
/** 导出所有历史对话为TXT文件 */
const exportHistory = () => {
if(conversations.length === 0 && currentConversation.length === 0) { alert('没有可以导出的对话。'); return; }
let textContent = `AI 解卦助手 - 导出时间: ${new Date().toLocaleString()}\n==================================================\n\n`;
// 导出当前未保存的对话
if (currentConversation.length > 0) {
textContent += `======== 当前未保存对话 ========\n\n`;
currentConversation.forEach(item => { textContent += `[问题]\n${item.question}\n\n[AI 回答 (by ${settings.model})]\n结果: ${item.result?.['结果']}\n分析过程: ${item.result?.['分析过程']}\n\n`; });
textContent += `\n--------------------------------------------------\n\n`;
}
// 导出所有已归档的对话
conversations.forEach((conv, index) => {
textContent += `======== 对话 ${index + 1}: ${conv.title} ========\n\n`;
conv.messages.forEach(item => { textContent += `[问题]\n${item.question}\n\n[AI 回答 (by ${settings.model})]\n结果: ${item.result?.['结果']}\n分析过程: ${item.result?.['分析过程']}\n\n`; });
textContent += `\n--------------------------------------------------\n\n`;
});
const blob = new Blob([textContent], {type: 'text/plain;charset=utf-8'});
const link = document.createElement('a'); link.href = URL.createObjectURL(blob);
link.download = `AI 解卦历史-${new Date().toISOString().split('T')[0]}.txt`;
link.click(); URL.revokeObjectURL(link.href);
};
/**
* 将消息内容(气泡)转换为Canvas并复制到剪贴板
* @param {HTMLElement} button - 触发此操作的按钮
*/
const handleCopyToClipboard = async button => {
if (!navigator.clipboard?.write) { alert('您的浏览器不支持直接复制图片到剪贴板,请升级浏览器或使用Ctrl+V粘贴。'); return; }
const bubble = button.closest('.message-bubble'); button.innerHTML = '生成中...'; button.disabled = true;
try {
const canvas = await html2canvas(bubble, { useCORS: true, backgroundColor: '#2d2a44', scale: 2 }); // 统一背景色避免透明问题
canvas.toBlob(async blob => {
try {
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
button.innerHTML = '已复制 ✅'; button.classList.add('copied');
} catch (err) { alert(`复制失败: ${err.message}`); button.innerHTML = `${ICONS.share} 复制`; }
}, 'image/png');
} catch (err) { alert(`生成图片失败: ${err.message}`); button.innerHTML = `${ICONS.share} 复制`; }
finally { setTimeout(() => { button.innerHTML = `${ICONS.share} 复制`; button.disabled = false; button.classList.remove('copied'); }, 3000); }
};
// =========================================================================
// == 初始化 ==
// =========================================================================
/** 脚本主入口,负责初始化所有功能 */
const initialize = () => {
// 防止重复加载脚本或在非顶级框架中运行
if (window.self !== window.top || window.hasAIGuaAssistant) return;
window.hasAIGuaAssistant = true; Dbg.log(`脚本初始化...`);
loadSettings(); // 优先加载设置
createMainUI(); // 创建 UI 元素
// 确保 analysisContainer 已经被成功创建并添加到DOM
if (analysisContainer) {
analysisContainer.style.width = `${settings.panelWidth}px`;
// 面板宽度调整并保存
let resizeTimeout;
new ResizeObserver(() => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(saveSettings, 500);
}).observe(analysisContainer);
} else {
Dbg.err("analysisContainer 未能成功创建或获取。AI助手可能无法正常显示。");
return; // 如果核心容器都无法创建,则中止后续初始化
}
loadConversations();
renderEmptyState(); // 渲染初始的欢迎界面
// 注册油猴菜单命令
GM_registerMenuCommand("AI助手设置", showSettingsModal);
Dbg.log("脚本初始化完成。");
}
// 启动脚本初始化流程
// 确保在DOM完全加载后再执行初始化,提高兼容性和健壮性
if (document.readyState === 'complete' || document.readyState === 'interactive') {
initialize();
} else {
document.addEventListener('DOMContentLoaded', initialize);
}
})();