中学生学习工具:多学科自学启导者(轻量版)实现

中学生学习工具:多学科自学启导者(轻量版)实现

一、它是什么

一句话:Python + pywebview + 一个 OpenAI 兼容的 API Key。

后端:app.py,纯 Python,用 pywebview 拉起一个原生窗口,内嵌 HTML 前端。

前端:index.html,单文件,无框架,KaTeX 渲染公式,利用JSXGraph画图【注】。

存储:SQLite 便携数据库,放在软件身边 data/math_tutor_data.db,U 盘一拔就走。

通信:JS 通过 window.pywebview.api 调用 Python 方法,读写数据库。

模型:任意 OpenAI 兼容协议的 API(DeepSeek / 通义千问 / GPT / 各类中转均可)。

比较适合中学生,小学高年级学生、成年自学者也可以。

【注:本工具在数学/物理学科解答完一道题后,会自动判断该题是否适合可视化。若适合,会利用JSXGraph在讲解卡片下方紧贴显示一张函数图或几何图。创建一个独立的气泡消息(图形卡片):

图形自动生成的原理:主回答完成 → 再次调用 AI 判断"该不该画图" → 若该画,返回图形 JSON → 由 JSXGraph 在浏览器端实时绘制。

它不依赖任何外部数学库,完全靠 JSXGraph 自己的解析器 + 浏览器的原生渲染能力。它的稳定性来自分层解耦------图形失败永远不会拖垮讲解。图形由 JSXGraph 渲染为矢量 SVG,也可随打印/PDF 一起输出。目前版本输出图像能力较弱:只支持一元函数曲线(如 y=x²-4x+3、y=sin(x))和简单几何点线图(如三角形顶点与边),暂不支持复杂几何、受力箭头、电路元件、化学结构式、三维图形。】

Python 第三方库pywebview 简介

安装 webview 包需要使用名称 pywebview。

pywebview 的核心作用是让你能用网页技术(HTML、CSS、JavaScript)来构建 Python 桌面应用程序的图形界面(GUI)。它的实现原理是,在你的 Python 应用中创建一个原生窗口,窗口里内嵌了一个浏览器引擎来渲染网页内容,就像把一个网页"装"进了一个桌面应用里。注意,导入时使用的是 webview,而不是 pywebview。

该项目目录标准结构

text

多学科自学启导者/

├── app.exe (核心主程序)

├── _internal/ (运行环境与动态链接库)

└── data/

└── math_tutor_data.db (软件启动后自动生成:便携数据库,可以手工复制替换)

运行界面:

快捷键

快捷键 作用

Ctrl+V 粘贴剪贴板中的截图

Enter 发送(输入框内)

Shift+Enter 输入框内换行

Esc 关闭灯箱 / 模态框 / 历史侧栏

各学科的五大模块对照表

学科 模块 1 模块 2 模块 3 模块 4 模块 5
📐 数学 题目分析 常规解法 替代解法 知识总结与同类练习 启发性提问
⚡ 物理 物理模型与已知条件 状态与受力/过程分析 规范推导与求解 核心规律总结 启发性思考
🧪 化学 反应原理与已知 关键过程与推断 规范解答 核心知识总结 启发思考
🧬 生物 生命观念与已知条件 现象与机理分析 规范解答 知识网络总结 启发思考
🔤 英语 文本与题干分析 核心语法与词汇 规范解析与翻译 高分表达积累 举一反三练习
📖 语文与文综 背景与材料分析 核心考点剖析 规范解答与赏析 知识框架归纳 思维延伸

二、 使用方法和注意事项

使用方法

1、首次配置:引擎管理和导师偏好

引擎管理设置

点击顶栏「⚙️ 引擎管理」,添加 API Key,选择学科和引擎。

本工具采用 OpenAI 兼容协议,可以同时配置多个不同厂商的 API(DeepSeek、通义千问、OpenAI、本地中转......),每个配置称为一个"引擎 Profile"。顶栏引擎下拉框可随时切换。

导师偏好设置

点击顶栏 � 导师偏好,可自定义 AI 的教学风格。

(1). 三段式结构

层级 内容 是否可改

锁定规则 强制 KaTeX 公式 + ### 五模块结构 + 多题只讲第一题 ❌ 只读

自定义人设 500 字以内自由填写 ✅ 可改

预设风格 4 个一键套用按钮 ✅ 可改

(2). 预设风格

按钮 风格描述

� 中高考名师型 推导严密,直击考点,语言干练

� 启发鼓励型 生动比喻,正向反馈,引导思考

� 小学通俗型 生活例子类比,拆解到最基础

� AP/IB双语型 中英双语,保留学术英文术语

(3). 最高裁判法则

无论你自定义什么偏好,系统都会无条件优先遵循 KaTeX 语法 + 五模块结构。这是为了避免自由发挥破坏排版。若偏好与格式冲突,格式优先。

2、提问与发图建议

文本题:在底部输入框直接输入题目或 LaTeX 公式,回车发送。

图片题:

快捷键截图(最推荐):使用微信/QQ/系统截图后,在软件界面直接按 Ctrl+V 粘贴预览;

本地图片:点击工具栏 � 选择图片。

附加指令:可在输入框补充文字(如:"只讲第 (2) 问")后点击发送。

引擎选择建议:

� 看图 (Vision) 引擎(推荐首选):全能型,适用于包含几何图形、函数图、电路图、化学式、手写笔记或公式排版的题目,模型端到端直接理解,无信息损耗。

� 纯文本 (Text) 引擎:仅适用于纯键盘打字输入或纯 LaTeX 题目,响应极快且最省 Token。注意:纯文本引擎不支持图片输入,若发送图片将自动提示切换至看图引擎。

3、追问与扩展

直接在输入框继续输入疑问,或点击「另一种解法」「出同类变式题」按钮。

在不同学科下,扩展按钮文案会自动切换(如英语下自动变为「高分表达升级」「考点语法练兵」),以凸显系统的学科针对性。

解答完毕后,点击「➕ 新题目」开启新题,清空上下文,开始新对话。

4、历史记录:点击「历史记录」查看、载入、删除过往记录。

5、PDF 导出:点击「导出/打印PDF」,在打印对话框选择"另存为 PDF"。

注意事项

1、纯文本引擎的图片限制:纯文本引擎仅接受文字输入,无法理解图片。若发送图片,程序会提示切换至看图引擎;若用户不切换则取消操作。请为图片题选择 �� 看图引擎。

2、依赖网络:调用大模型需要联网。

3、API Key 安全:密钥明文存储在本地数据库,请妥善保管个人电脑。

方式A(Ctrl+V 截图)与方式B(�� 选择图片)的异同

1、不同点

操作来源:方式A从剪贴板读取图片,方式B从本地文件选择图片。

其余处理流程完全相同:两者都会将图片转为 pendingImageBase64,发送时调用 explainProblem(imgData, true, false, text),走同一个函数。

2、Token 消耗

相同点:在相同引擎下,两种方式 Token 消耗完全一样,因为最终图片处理和发送内容一致。

差异点:

若当前引擎是视觉引擎(如 DeepSeek-Vision、Qwen-VL、GPT-4o 等),图片直接发送给模型,消耗图像 token + 文本 token,通常较高。

若当前引擎是纯文本引擎,则无法发送图片(会提示切换),因此不存在图片 token 消耗。

结论:Token 差异来自引擎类型,而不是图片输入方式。

3、题意识别准确性

两种方式对题意识别没有本质区别,因为最终处理的是同一张图片。

但补充文字可以辅助模型理解,尤其在题目要求不明确时,用户补充说明能提高准确性。

结论:方式A和B仅操作便捷性不同,其余无差别;Token 消耗取决于当前引擎是否视觉,而非输入方式。

三、 常见问题解答( FAQ)

  1. 学习讲解一道题时,不同轮次切换不同的引擎 Provider 是否可以?

可以,这正是本系统架构的强大优势之一!但需要注意以下几点:

✅ 可行性

顶部引擎下拉框可随时切换,切换后下一次调用 API 会使用新引擎。

chatHistory 会保留,新引擎能继续基于之前的对话上下文回答。

OpenAI 兼容格式使得大多数模型可无缝衔接。

⚠️ 关键限制

图片消息的兼容性:

如果首轮使用视觉引擎发送了图片,chatHistory 中会包含 image_url 格式的消息。

之后切换到纯文本引擎继续追问,V3.0.2 会自动拦截并弹出中文提示:'当前引擎为纯文本,但对话历史包含图片消息,请切换到支持看图的引擎'。不会再出现 API 报错。

建议:

图片题一旦开始,最好始终使用视觉引擎进行追问,不要中途切到纯文本引擎。

反之,纯文本题开始后,切到视觉引擎一般没问题(纯文本消息视觉引擎也能处理),但可能会消耗更多 Token。

模型能力差异:不同模型对上下文的理解可能不同,但通常可以继续对话。

� 结论

技术上可行,但建议保持同一类引擎(视觉或纯文本),避免因历史图片导致纯文本引擎出错。

  1. 一个问题多轮问答,删除某次问答对 AI 回答影响如何?

当前工具支持"� 删除本轮问答"(即某次追问和对应回答),删除后:

✅ 正面影响

减少干扰:删除无效或错误的追问,可以让后续回答更聚焦于有效上下文,避免模型被无关信息带偏。

节省 Token:删除后 chatHistory 变短,后续请求携带的上下文减少,降低 Token 消耗。

⚠️ 需要注意的风险

勿删首轮:首轮包含系统提示和原始题目(可能含图片)。如果删除首轮,模型将丢失题目信息,后续追问可能无法正确解答。

当前代码没有限制删除首轮,但首轮通常没有"删除本轮问答"按钮(因为首轮助手回答也可能显示删除按钮,用户可能误删)。建议避免手动删除首轮。

中间轮次删除影响:删除中间轮次后,上下文出现"跳跃",模型仍能根据剩余消息理解,但可能缺失某些推理步骤。通常影响不大。

首轮图文资产依然坚固:代码中特别锁定了rootUserMsg(首轮原题和原图),删除中间的某个追问轮次绝不会破坏最初的题目图文背景。

3.长解答显示"正在续写第 N 段..."是什么?

这是 自动续写机制。

模型单次输出有上限(本工具设为 8000 token)。当模型回答超出上限时,API 会返回 finish_reason: "length" 表示被截断。本工具检测到后,自动追加"请从断点继续"指令再次请求,最多续写 2 次。用户无需任何操作。若 2 次后续写仍截断,会提示分步追问。

  1. 如果用户忘记切换学科,问了另一个学科问题,是否可以?

可以问,主流模型具备强大的自适应纠偏能力。但回答可能不专业或不符合预期,因为:

若用户在会话中忘记切换学科,直接输入另一学科题目,模型仍以旧学科的角色和规范回答。

界面层的影响:

顶部的辅助按钮(如"另一种解法"、"出同类变式题")会沿用当前选中学科的动作提示词(例如在语文模式下点变式题会出阅读拓展,数学模式下会出同类计算题);

历史记录归档标题会短暂打上当前选中的学科前缀。

例如:当前是"数学",输入物理题,模型可能会用数学的思维和格式(五模块:题目分析、常规解法等)回答,而不是物理的"物理模型与已知条件"等结构。

对于跨学科问题,回答可能仍然正确,但结构不对,或缺少学科特有分析。

常见错误对照表:

|-----------------------------|-----------------------|-------------|
| 显示信息 | 原因 | 解决 |
| 请先点击 ⚙️ 引擎管理 配置 API Key | 该引擎 Key 为空 | 填入 Key 并保存 |
| API Key 无效或未授权 | Key 错误或已吊销 | 到服务商后台核对 |
| API 调用配额超限或欠费 | 余额不足 | 充值或换引擎 |
| 请求超时(等待超 120 秒) | 网络不通或节点宕机 | 换引擎或检查网络 |
| 当前引擎为纯文本,但对话历史含图片 | 带图会话切到了纯文本引擎 | 切回视觉引擎 |
| 引擎地址格式非法 | URL 拼写错误 | 修正 URL |
| 模型未返回有效解答内容 | 触发安全过滤或图片过大 | 换截图或简化问题 |
| 返回 200 但响应格式非 OpenAI 兼容 | Endpoint 不是 OpenAI 协议 | 换正确的 API 地址 |

注意

:可以安全删除非首轮的问答,通常有利于对话质量。

不要删除首轮,否则模型可能"失忆"。

✅ 建议

提问前先确认顶部学科下拉框。

如果发现忘了切换,应:

点击「➕ 新题目」清空上下文;

切换到正确学科;

再输入题目。

小结

问题 结论

切换引擎 可以,但图片会话请保持视觉引擎,避免切到纯文本

删除某轮问答 可删除非首轮,有助于聚焦;勿删首轮

忘记切换学科 可以问,但回答可能不专业,应切换学科并开新题

四、源码:

app.py

python 复制代码
import os
import sys
import json
import sqlite3
import webview

def get_app_root_dir():
    """ 
    获取软件真实的物理根目录:
    - 打包为 onedir 时:指向 app.exe 所在的文件夹根目录
    - 源码运行模式时:指向当前脚本所在文件夹
    """
    if hasattr(sys, '_MEIPASS'):
        return os.path.dirname(os.path.abspath(sys.argv[0]))
    return os.path.abspath(".")

def get_user_db_path():
    r""" 
    智能便携式数据库路径:
    1. 优先使用软件身边的 data/math_tutor_data.db(实现真·便携绿色版,数据随 U 盘带走)
    2. 若检测到当前运行在 C:\Program Files 等只读受限目录,自动安全退回到系统 %APPDATA%
    """
    app_root = get_app_root_dir()
    local_data_dir = os.path.join(app_root, "data")
    
    try:
        os.makedirs(local_data_dir, exist_ok=True)
        test_file = os.path.join(local_data_dir, ".perm_test")
        with open(test_file, "w") as f:
            f.write("ok")
        os.remove(test_file)
        return os.path.join(local_data_dir, "math_tutor_data.db")
    except (PermissionError, OSError):
        if os.name == 'nt':
            base_dir = os.environ.get('APPDATA', os.path.expanduser('~'))
        else:
            base_dir = os.environ.get('XDG_DATA_HOME', os.path.join(os.path.expanduser('~'), '.local', 'share'))
        
        fallback_dir = os.path.join(base_dir, 'MathTutor')
        os.makedirs(fallback_dir, exist_ok=True)
        return os.path.join(fallback_dir, 'math_tutor_data.db')

def get_resource_path(relative_path):
    """ 获取静态资源绝对路径 (兼容 PyInstaller 打包与任意路径下的源码运行) """
    if hasattr(sys, '_MEIPASS'):
        return os.path.join(sys._MEIPASS, relative_path)
    base_dir = os.path.dirname(os.path.abspath(__file__))
    return os.path.join(base_dir, relative_path)


class MathTutorBridge:
    """ Python 与前端 JS 通信 API 桥接类 (纯云端多模态极简版) """
    
    def __init__(self):
        self.db_path = get_user_db_path()
        print(f"[INFO] 本地 SQLite 便携数据库路径: {self.db_path}")
        self._init_db()

    def _init_db(self):
        conn = sqlite3.connect(self.db_path)
        cursor = conn.cursor()
        
        cursor.execute('''
            CREATE TABLE IF NOT EXISTS profiles (
                id TEXT PRIMARY KEY,
                data TEXT NOT NULL
            )
        ''')
        
        cursor.execute('''
            CREATE TABLE IF NOT EXISTS history (
                id TEXT PRIMARY KEY,
                title TEXT,
                time TEXT,
                image TEXT,
                api_history TEXT,
                snapshot TEXT,
                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
            )
        ''')

        cursor.execute('''
            CREATE TABLE IF NOT EXISTS settings (
                id TEXT PRIMARY KEY,
                data TEXT NOT NULL
            )
        ''')
        # 性能索引(历史记录积累后搜索/排序提速;老库会自动补建,绝对安全)
        try:
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_history_created ON history(created_at DESC)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_history_title ON history(title)")
        except Exception as e:
            print("[WARN] 索引创建失败(不影响功能):", e)
        conn.commit()
        conn.close()

    # ================= API: 引擎配置管理 =================
    def load_profiles(self):
        try:
            conn = sqlite3.connect(self.db_path)
            cursor = conn.cursor()
            cursor.execute("SELECT data FROM profiles WHERE id = 'user_profiles'")
            row = cursor.fetchone()
            conn.close()
            return json.loads(row[0]) if row else None
        except Exception as e:
            print("读取 Profiles 异常:", e)
            return None

    def save_profiles(self, profiles_json, active_id):
        try:
            conn = sqlite3.connect(self.db_path)
            cursor = conn.cursor()
            payload = json.dumps({"profiles": profiles_json, "activeId": active_id})
            cursor.execute("INSERT OR REPLACE INTO profiles (id, data) VALUES ('user_profiles', ?)", (payload,))
            conn.commit()
            conn.close()
            return {"status": "success"}
        except Exception as e:
            return {"status": "error", "message": str(e)}

    # ================= API: 导师偏好设置 =================
    def load_settings(self):
        try:
            conn = sqlite3.connect(self.db_path)
            cursor = conn.cursor()
            cursor.execute("SELECT data FROM settings WHERE id = 'user_settings'")
            row = cursor.fetchone()
            conn.close()
            return json.loads(row[0]) if row else None
        except Exception as e:
            print("读取 Settings 异常:", e)
            return None

    def save_settings(self, settings_json):
        try:
            conn = sqlite3.connect(self.db_path)
            cursor = conn.cursor()
            cursor.execute("INSERT OR REPLACE INTO settings (id, data) VALUES ('user_settings', ?)", (settings_json,))
            conn.commit()
            conn.close()
            return {"status": "success"}
        except Exception as e:
            return {"status": "error", "message": str(e)}

    # ================= API: 历史记录数据库操作 =================
    def save_history_item(self, item_json):
        try:
            item = json.loads(item_json)
            conn = sqlite3.connect(self.db_path)
            cursor = conn.cursor()
            cursor.execute('''
                INSERT OR REPLACE INTO history (id, title, time, image, api_history, snapshot)
                VALUES (?, ?, ?, ?, ?, ?)
            ''', (
                item['id'],
                item['title'],
                item['time'],
                item['image'],
                json.dumps(item['apiHistory']),
                json.dumps(item['snapshot'])
            ))
            conn.commit()
            conn.close()
            return {"status": "success"}
        except Exception as e:
            print("保存历史记录异常:", e)
            return {"status": "error", "message": str(e)}

    def get_history_list(self, page=1, limit=20, keyword=""):
        try:
            conn = sqlite3.connect(self.db_path)
            cursor = conn.cursor()
            
            offset = (page - 1) * limit
            if keyword:
                query = "SELECT id, title, time, image FROM history WHERE title LIKE ? ORDER BY created_at DESC LIMIT ? OFFSET ?"
                cursor.execute(query, (f'%{keyword}%', limit, offset))
            else:
                query = "SELECT id, title, time, image FROM history ORDER BY created_at DESC LIMIT ? OFFSET ?"
                cursor.execute(query, (limit, offset))
                
            rows = cursor.fetchall()
            
            if keyword:
                cursor.execute("SELECT COUNT(*) FROM history WHERE title LIKE ?", (f'%{keyword}%',))
            else:
                cursor.execute("SELECT COUNT(*) FROM history")
            total_count = cursor.fetchone()[0]
            conn.close()
            
            history_data = []
            for row in rows:
                history_data.append({
                    "id": row[0],
                    "title": row[1],
                    "time": row[2],
                    "image": row[3]
                })
            return {
                "status": "success",
                "items": history_data,
                "has_more": (offset + len(history_data)) < total_count,
                "total": total_count
            }
        except Exception as e:
            print("获取历史记录列表异常:", e)
            return {"status": "error", "items": [], "has_more": False, "total": 0}

    def get_history_item(self, item_id):
        try:
            conn = sqlite3.connect(self.db_path)
            cursor = conn.cursor()
            cursor.execute("SELECT id, title, time, image, api_history, snapshot FROM history WHERE id = ?", (item_id,))
            row = cursor.fetchone()
            conn.close()
            if row:
                return {
                    "status": "success",
                    "item": {
                        "id": row[0],
                        "title": row[1],
                        "time": row[2],
                        "image": row[3],
                        "apiHistory": json.loads(row[4]),
                        "snapshot": json.loads(row[5])
                    }
                }
            return {"status": "error", "message": "未找到指定历史记录"}
        except Exception as e:
            return {"status": "error", "message": str(e)}

    def delete_history_item(self, item_id):
        try:
            conn = sqlite3.connect(self.db_path)
            cursor = conn.cursor()
            cursor.execute("DELETE FROM history WHERE id = ?", (item_id,))
            conn.commit()
            conn.close()
            return {"status": "success"}
        except Exception as e:
            return {"status": "error", "message": str(e)}

    def clear_all_history(self):
        try:
            conn = sqlite3.connect(self.db_path)
            cursor = conn.cursor()
            cursor.execute("DELETE FROM history")
            conn.commit()
            conn.close()
            return {"status": "success"}
        except Exception as e:
            return {"status": "error", "message": str(e)}


def main():
    api = MathTutorBridge()
    html_file = get_resource_path("index.html")

    window = webview.create_window(
        title='📐 全学科自学启导者 · PC 桌面原生版 (V3.0.2 纯多模态版)',
        url=html_file,
        js_api=api,
        width=1100,
        height=780,
        min_size=(880, 600),
        resizable=True
    )
    webview.start(debug=False)

if __name__ == '__main__':
    main()

index.html

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>全学科自学启导者 · V3.0.2 Desktop</title>

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/jsxgraph@1.6.2/distrib/jsxgraphcore.js"></script>
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/jsxgraph@1.6.2/distrib/jsxgraph.css">

<style>
:root{--bg:#1a1a2e;--surface:#16213e;--primary:#0f3460;--accent:#e94560;--text:#eee;--text-secondary:#bbbbbb;--border:#2a2a4a;--card-bg:#1f2b42;--success:#2ec4b6;--danger:#ff6b6b;}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Segoe UI',system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);height:100vh;display:flex;overflow:hidden}
#app{display:flex;flex-direction:column;height:100vh;width:100%;max-width:1020px;margin:0 auto;padding:1rem;position:relative}
#header{display:flex;justify-content:space-between;align-items:center;padding-bottom:.8rem;border-bottom:1px solid var(--border);flex-wrap:wrap;gap:.5rem}
#header h1{font-size:1.3rem;background:linear-gradient(135deg,#e94560,#ff6b6b);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
.header-actions{display:flex;gap:.4rem;align-items:center;flex-wrap:wrap}
.select-box{background:var(--surface);border:1px solid var(--accent);color:var(--text);padding:.4rem .6rem;border-radius:8px;font-size:.85rem;outline:none;cursor:pointer}
.btn-icon{background:var(--surface);border:1px solid var(--border);color:var(--text);padding:.4rem .7rem;border-radius:8px;cursor:pointer;font-size:.85rem;display:flex;align-items:center;gap:.3rem}
.btn-icon:hover{background:var(--primary)}
#chat-container{flex:1;overflow-y:auto;padding:1rem 0;display:flex;flex-direction:column;gap:1rem}
.message{display:flex;gap:1rem;max-width:100%}
.message.user{justify-content:flex-end}
.message .avatar{width:36px;height:36px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:1.2rem;flex-shrink:0}
.message.ai .avatar{background:var(--primary)}
.message.user .avatar{background:var(--accent)}
.message .bubble{background:var(--surface);padding:1rem 1.2rem;border-radius:1rem;line-height:1.6;max-width:min(100%,780px);position:relative;word-break:break-word;overflow-wrap:break-word;user-select:text!important;-webkit-user-select:text!important}
.message.user .bubble{background:var(--primary);white-space:pre-wrap}
.message .bubble.has-graph{min-width:480px}

/* 图形-only 消息:与上一条 AI 回答视觉上紧贴,避免割裂 */
.message.ai.graph-only{margin-top:-0.5rem}
.message.ai.graph-only .avatar{visibility:hidden}

.chat-thumbnail{max-width:220px;max-height:200px;border-radius:8px;border:2px solid var(--border);margin-top:.5rem;cursor:pointer;display:block;transition:transform .2s,border-color .2s}
.chat-thumbnail:hover{transform:scale(1.02);border-color:var(--accent)}
.explanation-card{background:var(--card-bg);border-radius:12px;padding:1.2rem;margin-top:.8rem;border:1px solid var(--border);user-select:text!important;-webkit-user-select:text!important}
.explanation-card h3{color:var(--accent);font-size:1.1rem;margin-bottom:.8rem;padding-bottom:.4rem;border-bottom:2px solid var(--primary)}
.explanation-card .card-content{white-space:pre-wrap;line-height:1.6}
.split-line{display:block;width:100%;height:1px;background:var(--border);margin:1rem 0}
.custom-table{width:100%;border-collapse:collapse;margin:.8rem 0;font-size:.88rem;background:rgba(0,0,0,.25);border-radius:8px;overflow:hidden;border:1px solid var(--border)}
.custom-table th,.custom-table td{padding:.6rem .8rem;border:1px solid var(--border);text-align:left}
.custom-table th{background:rgba(15,52,96,.7);color:#4ea8de;font-weight:bold}
.custom-table tr:nth-child(even){background:rgba(255,255,255,.02)}
.katex-display{overflow-x:auto;overflow-y:hidden;margin:.8rem 0;padding:.2rem 0}
.katex-html{overflow-x:auto;overflow-y:hidden}
.katex-mathml{position:absolute!important;width:0!important;height:0!important;opacity:0}
.graph-container{margin:1rem 0;border-radius:8px;overflow:hidden;border:1px solid var(--border);background:#fff;width:100%}
.graph-container .jxgbox{width:100%;height:340px}
.msg-actions{display:flex;gap:.8rem;align-items:center;margin-top:.5rem}
.raw-toggle,.delete-turn-btn{font-size:.8rem;color:var(--text-secondary);cursor:pointer;display:inline-block;transition:color .2s}
.delete-turn-btn:hover{color:var(--danger)}
.raw-toggle:hover{color:var(--accent)}
.raw-content{display:none;background:#111;color:#0f0;padding:.8rem;border-radius:6px;white-space:pre-wrap;font-family:monospace;font-size:.85rem;margin-top:.5rem;overflow-x:auto;user-select:text!important}
.toolbar{display:flex;gap:.5rem;align-items:center;flex-wrap:wrap;margin-bottom:.5rem}
#input-area{display:flex;flex-direction:column;gap:.5rem;padding-top:.5rem;border-top:1px solid var(--border)}
.input-row{display:flex;gap:.5rem;width:100%;align-items:flex-end}
#user-input{flex:1;background:var(--surface);border:1px solid var(--border);color:var(--text);padding:.8rem;border-radius:1rem;resize:none;outline:none;font-family:inherit;min-height:60px}
#user-input:disabled{opacity:.6;cursor:not-allowed}
#image-preview-bar{display:none;align-items:center;gap:.8rem;background:var(--card-bg);border:1px dashed var(--accent);padding:.5rem .8rem;border-radius:10px}
#image-preview-bar.active{display:flex}
.preview-thumb-img{width:50px;height:50px;object-fit:cover;border-radius:6px;border:1px solid var(--border);cursor:pointer}
.preview-info{flex:1;font-size:.85rem;color:var(--text-secondary)}
.cancel-img-btn{background:var(--danger);border:none;color:#fff;width:24px;height:24px;border-radius:50%;cursor:pointer}
#image-lightbox{display:none;position:fixed;top:0;left:0;width:100vw;height:100vh;background:rgba(0,0,0,.88);z-index:300;justify-content:center;align-items:center;flex-direction:column;cursor:zoom-out}
#image-lightbox.active{display:flex}
#image-lightbox img{max-width:90vw;max-height:85vh;border-radius:8px;object-fit:contain}
.lightbox-close-btn{position:absolute;top:25px;right:35px;background:rgba(233,69,96,.85);color:#fff;border:2px solid rgba(255,255,255,.6);width:42px;height:42px;border-radius:50%;font-size:1.4rem;display:flex;align-items:center;justify-content:center;cursor:pointer}
.lightbox-hint{margin-top:1rem;color:var(--text-secondary);font-size:.85rem;background:rgba(0,0,0,.5);padding:.3rem .8rem;border-radius:20px;border:1px solid var(--border)}
button{background:var(--accent);border:none;color:white;padding:.6rem 1.2rem;border-radius:1rem;cursor:pointer;white-space:nowrap;font-size:.9rem;transition:opacity .2s}
button:disabled{opacity:.4;cursor:not-allowed}
#more-method-btn,#variant-btn,#new-btn{background:var(--primary)}
#print-btn{background:var(--success);color:#000;font-weight:bold}
#new-btn{background:transparent;border:1px solid var(--border)}
#image-upload{display:none}
.upload-btn-label{background:var(--surface);border:1px solid var(--border);color:var(--text);padding:.5rem .9rem;border-radius:1rem;cursor:pointer;display:inline-flex;align-items:center;gap:.3rem;font-size:.9rem}
.upload-btn-label:hover{background:var(--primary)}
.modal-overlay{display:none;position:absolute;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.75);z-index:100;justify-content:center;align-items:center;padding:1rem}
.modal-overlay.active{display:flex}
.modal-box{background:var(--surface);border:1px solid var(--border);border-radius:16px;width:100%;max-width:740px;max-height:85vh;display:flex;flex-direction:column;overflow:hidden}
.modal-header{padding:1.2rem;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center}
.modal-header h2{font-size:1.15rem;color:var(--accent)}
.modal-close{background:none;border:none;font-size:1.5rem;color:var(--text-secondary);cursor:pointer;padding:0}
.modal-body{padding:1.2rem;overflow-y:auto;display:flex;flex-direction:column;gap:1.2rem;font-size:.93rem;line-height:1.6;user-select:text!important}
.modal-body *{user-select:text!important}
.modal-body h4{color:#ff6b6b;font-size:1.02rem;margin-top:.3rem;border-bottom:1px solid var(--border);padding-bottom:.3rem}
.modal-body ul{padding-left:1.2rem}
.modal-body code{background:#111;padding:.15rem .4rem;border-radius:4px;color:#4ea8de;font-family:monospace}
.profile-card{background:var(--card-bg);border:1px solid var(--border);padding:.8rem;border-radius:8px;display:flex;flex-direction:column;gap:.5rem;margin-bottom:.5rem}
.profile-card-header{display:flex;justify-content:space-between;align-items:center;font-weight:bold}
.profile-inputs{display:grid;grid-template-columns:1fr 1fr;gap:.5rem}
.profile-inputs input,.profile-inputs select{background:var(--bg);border:1px solid var(--border);color:var(--text);padding:.4rem;border-radius:6px;font-size:.85rem}
.tag-vision{background:var(--success);color:#000;font-size:.7rem;padding:.1rem .4rem;border-radius:4px;font-weight:bold}
.tag-text{background:var(--primary);color:#fff;font-size:.7rem;padding:.1rem .4rem;border-radius:4px}
.locked-prompt-box{background:#111625;border:1px solid var(--border);padding:.8rem;border-radius:8px;font-size:.82rem;color:var(--text-secondary);max-height:140px;overflow-y:auto;font-family:monospace;white-space:pre-wrap;line-height:1.4;user-select:text!important}
.persona-textarea{background:var(--bg);border:1px solid var(--border);color:var(--text);padding:.8rem;border-radius:8px;outline:none;font-family:inherit;min-height:90px;resize:vertical;line-height:1.5;font-size:.9rem}
.preset-chips-bar{display:flex;gap:.4rem;flex-wrap:wrap;align-items:center}
.chip-btn{background:var(--card-bg);border:1px solid var(--border);color:var(--text);padding:.3rem .6rem;border-radius:6px;font-size:.8rem;cursor:pointer}
.chip-btn:hover{background:var(--primary);border-color:var(--accent)}
#sidebar-overlay{display:none;position:fixed;top:0;left:0;width:100vw;height:100vh;background:rgba(0,0,0,.6);z-index:150}
#sidebar-overlay.active{display:block}
#history-sidebar{position:fixed;top:0;right:-360px;width:360px;max-width:85vw;height:100vh;background:var(--surface);border-left:1px solid var(--border);z-index:160;transition:right .3s cubic-bezier(.4,0,.2,1);display:flex;flex-direction:column}
#history-sidebar.active{right:0}
.sidebar-header{padding:1.2rem;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center}
.sidebar-header h3{font-size:1.1rem;color:var(--accent);display:flex;align-items:center;gap:.4rem}
.sidebar-body{flex:1;overflow-y:auto;padding:1rem;display:flex;flex-direction:column;gap:.8rem;user-select:text!important}
.sidebar-footer{padding:1rem;border-top:1px solid var(--border);display:flex;justify-content:space-between;gap:.5rem}
.history-card{background:var(--card-bg);border:1px solid var(--border);border-radius:10px;padding:.8rem;display:flex;flex-direction:column;gap:.5rem}
.history-card:hover{border-color:var(--accent)}
.history-card-title{font-size:.9rem;font-weight:bold;color:var(--text);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;line-height:1.4}
.history-card-meta{font-size:.75rem;color:var(--text-secondary);display:flex;justify-content:space-between;align-items:center}
.history-card-actions{display:flex;gap:.4rem;justify-content:flex-end;margin-top:.3rem}
.btn-sm{padding:.25rem .6rem;font-size:.75rem;border-radius:6px}
.btn-danger{background:var(--danger)}
.modal-footer{padding:1rem;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:.5rem}
.loading{animation:pulse 1.5s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
.profile-test-row{display:flex;align-items:center;gap:.6rem;margin-top:.3rem;padding-top:.5rem;border-top:1px dashed var(--border);flex-wrap:wrap}
.test-btn{background:var(--success);color:#000;padding:.35rem .9rem;border-radius:6px;font-size:.82rem;font-weight:bold;cursor:pointer;border:none}
.test-btn:disabled{opacity:.5;cursor:not-allowed}
.test-result{font-size:.82rem;line-height:1.4;word-break:break-all}
.test-result.ok{color:var(--success);font-weight:bold}
.test-result.err{color:var(--danger);font-weight:bold}
.test-result.pending{color:var(--text-secondary)}
@media print{
  body{background:#fff!important;color:#000!important;height:auto!important;overflow:visible!important}
  #app{max-width:100%!important;margin:0!important;padding:.5cm!important;height:auto!important}
  #header,.toolbar,#input-area,.msg-actions,#sidebar-overlay,#history-sidebar,.modal-overlay,#image-preview-bar,#image-lightbox{display:none!important}
  #chat-container{overflow:visible!important;height:auto!important;padding:0!important}
  .message .bubble{background:#fff!important;color:#000!important;border:1px solid #ccc!important;max-width:100%!important}
  .message .bubble.has-graph{min-width:auto!important}
  .message.ai.graph-only{margin-top:0!important}
  .explanation-card{background:#fdfdfd!important;border:1px solid #e0e0e0!important;color:#000!important}
  .explanation-card h3{color:#c62828!important}
  .chat-thumbnail{max-width:280px!important}
  .graph-container{border:1px solid #ccc!important;background:#fff!important}
  .custom-table th{background:#e0e0e0!important;color:#000!important}
  .custom-table td,.custom-table th{border:1px solid #999!important}
}
@media screen and (max-width:640px){
  #app{padding:.5rem}
  .message .bubble{max-width:92vw;padding:.8rem}
  .message .bubble.has-graph{min-width:88vw}
  .profile-inputs{grid-template-columns:1fr}
  #history-sidebar{width:88vw}
}
</style>
</head>
<body>

<div id="app">
  <div id="header">
    <h1>📐 自学启导者 <span style="font-size:.7rem;color:var(--success);vertical-align:middle;">● V3.0.2 纯多模态极速版</span></h1>
    <div class="header-actions">
      <select id="subject-select" class="select-box"></select>
      <select id="active-engine-select" class="select-box"></select>
      <button class="btn-icon" id="open-history-btn">📜 历史记录</button>
      <button class="btn-icon" id="open-persona-btn">🎓 导师偏好</button>
      <button class="btn-icon" id="open-settings-btn">⚙️ 引擎管理</button>
      <button class="btn-icon" id="open-help-btn">❓ 帮助说明</button>
    </div>
  </div>

  <div id="chat-container"></div>

  <div class="toolbar">
    <button id="new-btn">➕ 新题目</button>
    <button id="more-method-btn" disabled>🔄 另一种解法</button>
    <button id="variant-btn" disabled>📝 出同类变式题</button>
    <button id="print-btn">🖨️ 导出/打印PDF</button>
    <label for="image-upload" class="upload-btn-label" id="upload-label">📷 选择图片</label>
    <input type="file" id="image-upload" accept="image/*">
  </div>

  <div id="input-area">
    <div id="image-preview-bar">
      <img id="preview-thumb-img" class="preview-thumb-img" src="" onclick="openImageLightbox(this.src)">
      <div class="preview-info">已选择图片(支持 Ctrl+V 快捷粘贴;可在下方补充特定讲解要求)</div>
      <button class="cancel-img-btn" id="cancel-img-btn">&times;</button>
    </div>
    <div class="input-row">
      <textarea id="user-input" rows="2" placeholder="输入题目/LaTeX(支持 Ctrl+V 直接粘贴截图)......"></textarea>
      <button id="send-btn">➤ 讲解/追问</button>
    </div>
  </div>

  <div id="image-lightbox" onclick="closeImageLightbox()">
    <div class="lightbox-close-btn" onclick="closeImageLightbox()">&times;</div>
    <img id="lightbox-img" src="" onclick="event.stopPropagation()">
    <div class="lightbox-hint">💡 点击空白处、顶部按钮或按 ESC 退出</div>
  </div>

  <div id="sidebar-overlay"></div>
  <div id="history-sidebar">
    <div class="sidebar-header"><h3>📜 历史记录库</h3><button class="modal-close" id="close-sidebar-btn">&times;</button></div>
    <div style="padding:.6rem 1rem;border-bottom:1px solid var(--border)">
      <input type="text" id="history-search-input" placeholder="🔍 搜索历史题目关键词..." style="width:100%;background:var(--bg);border:1px solid var(--border);color:var(--text);padding:.45rem .7rem;border-radius:8px;outline:none;font-size:.85rem">
    </div>
    <div class="sidebar-body" id="history-sidebar-container"></div>
    <div class="sidebar-footer">
      <button class="btn-danger btn-sm" id="clear-all-history-btn">🗑️ 清空全部记录</button>
      <button class="btn-sm" onclick="toggleSidebar(false)" style="background:var(--primary)">关闭侧栏</button>
    </div>
  </div>

  <div class="modal-overlay" id="print-modal">
    <div class="modal-box" style="max-width:480px">
      <div class="modal-header"><h2>🖨️ 本地 PDF 导出指引</h2><button class="modal-close" onclick="closeModal('print-modal')">&times;</button></div>
      <div class="modal-body">
        <p style="font-size:.95rem">在即将调出的系统打印对话框中:</p>
        <div style="background:#111625;border:1px solid var(--accent);padding:.8rem 1rem;border-radius:8px;color:#4ea8de;font-weight:bold;font-size:.9rem;margin:.5rem 0">
          💡 请将【目标打印机】下拉框选择为:<br>
          <span style="color:#2ec4b6">"另存为 PDF" (Save as PDF)</span> 或 <span style="color:#2ec4b6">"Microsoft Print to PDF"</span>
        </div>
        <p style="font-size:.85rem;color:var(--text-secondary)">即可免费保存为本地矢量 PDF,公式与图形排版 100% 高清不变形!</p>
      </div>
      <div class="modal-footer">
        <button onclick="closeModal('print-modal')" style="background:var(--card-bg);border:1px solid var(--border)">取消</button>
        <button onclick="executePrint()" style="background:var(--success);color:#000;font-weight:bold">确认并打开打印窗口</button>
      </div>
    </div>
  </div>

  <div class="modal-overlay" id="persona-modal">
    <div class="modal-box">
      <div class="modal-header"><h2>🎓 导师人设偏好与 Prompt 管理 (V3.0.2)</h2><button class="modal-close" onclick="closeModal('persona-modal')">&times;</button></div>
      <div class="modal-body">
        <label style="font-size:.85rem;color:var(--text-secondary);font-weight:bold">🔒 核心格式与安全规则(系统只读/死锁保障排版不变形):</label>
        <div class="locked-prompt-box">1. 强制使用 KaTeX 公式语法(行内 $...$,独立 $$...$$);
2. 强制使用 Markdown ### 标题区分五大模块卡片;
3. 照片多题默认仅识别精讲【第一道题】避免爆 Token 截断;
4. 【最高裁判法则】:若下方自定义偏好与上述格式规则发生冲突,系统将无条件优先遵循本硬性约束!</div>
        <div style="display:flex;justify-content:space-between;align-items:center;margin-top:.4rem">
          <label style="font-size:.85rem;color:var(--text-secondary);font-weight:bold">✍️ 自定义导师人设与教学偏好:</label>
          <span style="font-size:.8rem;color:var(--text-secondary)"><span id="persona-char-count">0</span>/500字</span>
        </div>
        <textarea id="cfg-persona" class="persona-textarea" maxlength="500" placeholder="在此自定义您的专属导师人设。例如:'语气严谨,推导极其严密,严禁跳步,解答后额外附带 1 道同类练习题。'"></textarea>
        <label style="font-size:.85rem;color:var(--text-secondary);font-weight:bold">💡 预设常用导师风格(点击一键套用):</label>
        <div class="preset-chips-bar">
          <button class="chip-btn" onclick="applyPersonaPreset('exam')">🏆 中高考名师型</button>
          <button class="chip-btn" onclick="applyPersonaPreset('encourage')">🌱 启发鼓励型</button>
          <button class="chip-btn" onclick="applyPersonaPreset('primary')">🍎 小学通俗型</button>
          <button class="chip-btn" onclick="applyPersonaPreset('bilingual')">🔤 AP/IB双语型</button>
        </div>
      </div>
      <div class="modal-footer">
        <button id="reset-persona-btn" style="background:var(--card-bg);border:1px solid var(--border)">🔄 恢复默认偏好</button>
        <button id="save-persona-btn">💾 保存导师偏好</button>
      </div>
    </div>
  </div>

  <div class="modal-overlay" id="settings-modal">
    <div class="modal-box">
      <div class="modal-header"><h2>⚙️ 引擎 Profile 多 Key 管理</h2><button class="modal-close" onclick="closeModal('settings-modal')">&times;</button></div>
      <div class="modal-body" id="profiles-list-container"></div>
      <div class="modal-footer">
        <button id="add-profile-btn" style="background:var(--primary)">➕ 添加新引擎配置</button>
        <button id="save-profiles-btn">保存并应用配置</button>
      </div>
    </div>
  </div>

  <div class="modal-overlay" id="help-modal">
    <div class="modal-box">
      <div class="modal-header"><h2>❓ 使用帮助(V3.0.2)💻设计:WKJ</h2><button class="modal-close" onclick="closeModal('help-modal')">&times;</button></div>
      <div class="modal-body">
        <h4>1. 🖨️ 本地矢量级 PDF 导出</h4>
        <ul><li>点击 <code>🖨️ 导出/打印PDF</code>,将【目标打印机】切换为 <b>"另存为 PDF"</b> 或 <b>"Microsoft Print to PDF"</b>。</li>
        <li>导出 PDF 基于矢量渲染引擎,KaTeX 公式与 JSXGraph 图形 100% 高清不变形。</li></ul>
        <h4>2. 📷 发图与多模态理解</h4>
        <ul><li><b>Ctrl+V 粘贴截图</b> 或点击 <b>选择图片</b>。</li>
        <li>📷 <b>Vision 引擎</b>:推荐用于几何图、函数图、电路图、手写笔记。</li>
        <li>📝 <b>纯文本引擎</b>:仅适用于纯文字/LaTeX,无法理解图片。</li></ul>
        <h4>3. 🔄 追问与 ➕ 新题目</h4>
        <ul><li>追问建议指向具体卡片,如:"请针对【常规解法】第 2 行进一步推导"。</li>
        <li>开启<b>完全无关的新题目</b>时,务必点击 <b>➕ 新题目</b>,清空历史 Token 缓存,节省消耗。</li></ul>
        <h4>4. 🎓 导师偏好</h4>
        <ul><li>自定义语气/风格;但底层硬约束强制保留 <code>###</code> 五模块结构与 KaTeX 公式规范。</li></ul>
        <h4>5. 🔒 便携数据与隐私</h4>
        <ul><li>数据库默认在软件身边的 <code>data/math_tutor_data.db</code>,支持 U 盘便携,不上传云端。</li></ul>
        <h4>6. ⚡ V3.0.2 新增</h4>
        <ul><li><b>解答自动续写:</b>长解答被截断时自动续写 1~2 次,无需手动操作。</li>
        <li><b>🔌 测试连接:</b>引擎卡片底部按钮,秒级验证 Key / URL / 网络。</li>
        <li><b>📈 函数图原生解析:</b>使用 JSXGraph 内置表达式解析器,支持 <code>x^2</code>、<code>sin(x)</code>、<code>sqrt(x)</code> 等写法。有图形时操作按钮自动移动到底部图形卡片(无重复)。</li></ul>
      </div>
      <div class="modal-footer"><button onclick="closeModal('help-modal')">我知道了</button></div>
    </div>
  </div>
</div>

<script>
/* ============================================================
   基础工具
   ============================================================ */
function escapeHtml(text) {
  if (!text) return '';
  return String(text)
    .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;').replace(/'/g, '&#039;');
}

const PERSONA_PRESETS = {
  default: "解题步骤推导清晰严密,禁止跳步,关键步骤需指出依据的定理法则,启发性提问需直击易错点。",
  exam: "你是一位严谨的高考名师。解题步骤要求极其推导严密,严禁省略中途计算步骤,直击中高考易错点与核心考点,语言干练专业。",
  encourage: "你是一位亲切鼓励型的大师级导师。多用生动比喻,语言通俗易懂,多在解答中给予正向反馈与启发式提问,引导学生主动思考。",
  primary: "请用对小学生极度友好的通俗语言讲解。多用苹果、蛋糕等生活例子类比,避免复杂的学术术语,步骤拆解到最基础。",
  bilingual: "Please provide explanations in bilingual (English & Chinese). Retain standard academic English terminology for equations and key concepts."
};

const SUBJECTS = {
  math: {
    name: '📐 数学', btnMethod: '🔄 另一种解法', btnVariant: '📝 出同类变式题',
    promptMethod: '请为这道题提供另一种不同的解法或思路:',
    promptVariant: '请基于题目【{problem}】的核心考点与解法,出 2 道难度相当的同类变式练习题,并附带详细解答与分析。',
    prompt: `你是一位专业的数学启导老师。请用清晰的结构讲解这道题,使用中文,数学公式用 $$ 或 $ 包裹。
特别约束与防脑补守则:
1. 【忠实题干与图形】:请仔细识别题目文字、公式及配图。并在【题目分析】中以规范的 LaTeX 写出你理解的真实完整题干与已知条件;
2. 【严禁擅自凭空添加核心前提】:严禁擅自设定未给出的特殊几何角、特殊取值范围或经典母题数据;若条件不全导致存在多种解,必须在【题目分析】中指出并进行严格的【分类讨论】!
3. 保持推导过程极其严密规范,每一步指出定理依据。
首次讲解强制规范:
1. 固定五模块,每块以 ### 标题独占一行;
2. 段落仅空一行,禁止连续3行以上空白;
3. 不同解法只用单行 --- 分拆;
4. 独立公式单独一行用 $$ 包裹,行内公式用 $ 或 \\( \\) 包裹;
5. 不要大量多余换行。
模块列表:
### 题目分析
### 常规解法
### 替代解法
### 知识总结与同类练习
### 启发性提问
如果是后续追问,请针对用户的疑问进行简明解答。`
  },
  physics: {
    name: '⚡ 物理', btnMethod: '🔄 另一种解法', btnVariant: '📝 出同类变式题',
    promptMethod: '请为这道物理题提供另一种物理模型视角或不同求解方法:',
    promptVariant: '请基于物理题目【{problem}】的物理模型与规律,出 2 道同类变式练习题并附带详细推导。',
    prompt: `你是一位专业的物理启导老师。请用严谨的物理思维讲解这道题,公式用 $$ 或 $ 包裹。
特别约束与防脑补守则:
1. 【忠实题干与物理模型】:结合题目文字与图示,在首模块写出清晰的物理情景与已知条件;若照片中包含多题,默认精讲【第一道题】。
2. 【严禁盲目脑补物理条件】:若题干未指明斜面是否有摩擦、碰撞是否弹性等核心前提,须在首模块声明并分类讨论!
首次讲解规范:
### 物理模型与已知条件
### 状态与受力/过程分析
### 规范推导与求解
### 核心规律总结
### 启发性思考
如果是后续追问,请针对疑问简明回答。`
  },
  chemistry: {
    name: '🧪 化学', btnMethod: '🔄 另一种思路', btnVariant: '📝 同类反应演练',
    promptMethod: '请为这道化学题提供另一种推断角度或反应原理分析思路:',
    promptVariant: '请基于化学题目【{problem}】的反应原理与知识点,出 2 道同类推断/计算变式练习题。',
    prompt: `你是一位专业的化学启导老师。请讲解这道化学题。
特别约束:
1. 【忠实题干与结构式】:结合化学反应原理、实验装置图或分子结构式进行分析;若照片中包含多题,默认精讲【第一道题】。
2. 【严禁擅自假设反应物用量】:若未标明"过量"或"少量",在首模块指出并按不同用量比分类讨论离子方程式!
首次讲解规范:
### 反应原理与已知
### 关键过程与推断
### 规范解答
### 核心知识总结
### 启发思考
如果是后续追问,请针对疑问简明回答。`
  },
  biology: {
    name: '🧬 生物', btnMethod: '🔄 机理深入剖析', btnVariant: '📝 核心概念演练',
    promptMethod: '请从更深层的生理机理与生命观念角度深入剖析这道题:',
    promptVariant: '请基于生物题目【{problem}】的核心考点,出 2 道同类概念辨析或图表分析演练题。',
    prompt: `你是一位专业的生物启导老师。请用生物学核心素养分析讲解这道题。
特别约束:结合生物生理机制、遗传图谱或实验曲线图展开分析;若未指定,默认精讲【第一道题】。
首次讲解规范:
### 生命观念与已知条件
### 现象与机理分析
### 规范解答
### 知识网络总结
### 启发思考
如果是后续追问,请针对疑问简明回答。`
  },
  english: {
    name: '🔤 英语', btnMethod: '✨ 高分表达升级', btnVariant: '📝 考点语法练兵',
    promptMethod: '请为本段英文/本题提供更地道的高级词汇、句式升级方案与地道学术表达:',
    promptVariant: '请基于题目【{problem}】的核心语法或考点,出 2 道举一反三的练习题并解析。',
    prompt: `你是一位专业的英语启导老师。请分析讲解本题或批改本段英文。
首次讲解规范:
### 文本与题干分析
### 核心语法与词汇
### 规范解析与翻译
### 高分表达积累
### 举一反三练习
如果是后续追问,请针对疑问解答。`
  },
  humanities: {
    name: '📖 语文与文综', btnMethod: '💡 另一答题视角', btnVariant: '📝 同类材料拓展',
    promptMethod: '请结合不同的答题维度/史料视角/思想主题,对本题展开另一种深度的赏析与解答:',
    promptVariant: '请结合题目【{problem}】的核心考点,提供 1 道同类拓展材料分析题并给出规范答案。',
    prompt: `你是一位专业的文综/语文启导老师。请对本题进行深度材料剖析与规范解答。
首次讲解规范:
### 背景与材料分析
### 核心考点剖析
### 规范解答与赏析
### 知识框架归纳
### 思维延伸
如果是后续追问,请针对疑问解答。`
  }
};

const DEFAULT_PROFILES = [
  { id: 'deepseek-vision', name: 'DeepSeek (V4 视觉看图版)', url: 'https://api.deepseek.com/v1/chat/completions', model: 'deepseek-v4-flash-vision-exp', key: '', isVision: true },
  { id: 'qwen-vl', name: '通义千问 (Qwen-2.5-VL 硅基流动)', url: 'https://api.siliconflow.cn/v1/chat/completions', model: 'Qwen/Qwen2.5-VL-72B-Instruct', key: '', isVision: true },
  { id: 'openai-vision', name: 'OpenAI (gpt-4o-mini 看图)', url: 'https://api.openai.com/v1/chat/completions', model: 'gpt-4o-mini', key: '', isVision: true },
  { id: 'deepseek-text', name: 'DeepSeek (纯文本极速版)', url: 'https://api.deepseek.com/v1/chat/completions', model: 'deepseek-chat', key: '', isVision: false }
];

let profiles = DEFAULT_PROFILES;
let activeProfileId = profiles[0].id;
let currentSubjectKey = localStorage.getItem('math_app_subject') || 'math';
let userPersona = PERSONA_PRESETS.default;

let historyPage = 1;
const historyLimit = 20;
let isLoadingHistory = false;
let hasMoreHistory = true;
let currentSearchKeyword = '';
let searchDebounceTimer = null;

let chatHistory = [];
let fullSnapshotHistory = [];
let currentProblem = '';
let currentImageBase64 = '';
let currentHistoryId = '';
let pendingImageBase64 = '';
let isSending = false;

const chatContainer = document.getElementById('chat-container');
const userInput = document.getElementById('user-input');
const sendBtn = document.getElementById('send-btn');
const moreMethodBtn = document.getElementById('more-method-btn');
const variantBtn = document.getElementById('variant-btn');
const printBtn = document.getElementById('print-btn');
const newBtn = document.getElementById('new-btn');
const imageUpload = document.getElementById('image-upload');
const uploadLabel = document.getElementById('upload-label');
const activeEngineSelect = document.getElementById('active-engine-select');
const subjectSelect = document.getElementById('subject-select');
const profilesListContainer = document.getElementById('profiles-list-container');
const cfgPersona = document.getElementById('cfg-persona');
const personaCharCount = document.getElementById('persona-char-count');
const imagePreviewBar = document.getElementById('image-preview-bar');
const previewThumbImg = document.getElementById('preview-thumb-img');
const cancelImgBtn = document.getElementById('cancel-img-btn');
const historySidebar = document.getElementById('history-sidebar');
const sidebarOverlay = document.getElementById('sidebar-overlay');
const historySidebarContainer = document.getElementById('history-sidebar-container');
const historySearchInput = document.getElementById('history-search-input');

const GRAPH_GENERATE_PROMPT = `你是一个数学图形生成助手。请分析以下题目,并输出 JSXGraph 标准的图形 JSON。
题目:{problem}
摘要:{summary}

【默认规则】
- 只要题目涉及函数、方程、几何图形、坐标、图像、曲线、直线、圆、三角形等任何可视化元素,必须输出对应 JSON。
- 题目即使只要求"求值/求顶点/求交点/求最值",只要方程或函数可以画,也必须输出 JSON。
- 只有完全无法用坐标系或几何表达的内容,才回复:NONE。

【输出格式】
- 函数图:{"type":"function","functions":[{"expr":"x^2 - 4*x + 3","color":"blue","label":"y=x^2-4x+3"}],"options":{"xaxis":[-2,6],"yaxis":[-2,10]}}
- 几何图:{"type":"geometry","elements":[{"type":"point","coords":[0,0],"name":"A"},{"type":"point","coords":[3,4],"name":"B"},{"type":"line","points":["A","B"]}]}

【关键约定】
1. options.xaxis 与 options.yaxis 请给出合理范围,确保图形完整可见。
2. 表达式 expr 使用数学自然写法:^ 表幂运算,sin/cos/tan/sqrt/abs/exp/log 等函数名直接写,pi 表示圆周率,隐式乘法(如 2x)也可。不要使用 LaTeX 语法。
3. 只输出 JSON,或只输出 NONE,不要有任何其他解释文字。`;

function isPythonDesktop() {
  return typeof window.pywebview !== 'undefined' && window.pywebview.api;
}

document.addEventListener('paste', (e) => {
  const items = (e.clipboardData || e.originalEvent.clipboardData).items;
  for (let item of items) {
    if (item.type.indexOf('image') !== -1) {
      const blob = item.getAsFile();
      const reader = new FileReader();
      reader.onload = (event) => {
        pendingImageBase64 = event.target.result;
        previewThumbImg.src = pendingImageBase64;
        imagePreviewBar.classList.add('active');
      };
      reader.readAsDataURL(blob);
      e.preventDefault();
      break;
    }
  }
});

function openImageLightbox(src) {
  if (!src) return;
  document.getElementById('lightbox-img').src = src;
  document.getElementById('image-lightbox').classList.add('active');
}
function closeImageLightbox() {
  document.getElementById('image-lightbox').classList.remove('active');
}

window.addEventListener('keydown', (e) => {
  if (e.key === 'Escape') {
    closeImageLightbox();
    closeModal('settings-modal'); closeModal('persona-modal');
    closeModal('help-modal'); closeModal('print-modal');
    toggleSidebar(false);
  }
});

function updateToolbarButtonTexts() {
  const sub = SUBJECTS[currentSubjectKey] || SUBJECTS.math;
  moreMethodBtn.innerText = sub.btnMethod || '🔄 另一种解法';
  variantBtn.innerText = sub.btnVariant || '📝 出同类变式题';
}

function renderSubjectSelect() {
  subjectSelect.innerHTML = Object.keys(SUBJECTS).map(k =>
    `<option value="${k}" ${k === currentSubjectKey ? 'selected' : ''}>${escapeHtml(SUBJECTS[k].name)}</option>`
  ).join('');
  updateToolbarButtonTexts();
}

subjectSelect.addEventListener('change', (e) => {
  currentSubjectKey = e.target.value;
  localStorage.setItem('math_app_subject', currentSubjectKey);
  updateToolbarButtonTexts();
  addMessage('ai', `✨ 已切换当前学科为:<b>${escapeHtml(SUBJECTS[currentSubjectKey].name)}</b>`, { isHTML: true, skipSnapshot: true });
});

async function initStoreData() {
  if (isPythonDesktop()) {
    try {
      const resProfiles = await window.pywebview.api.load_profiles();
      if (resProfiles) {
        profiles = resProfiles.profiles || DEFAULT_PROFILES;
        activeProfileId = resProfiles.activeId || profiles[0].id;
      }
      const resSettings = await window.pywebview.api.load_settings();
      if (resSettings && resSettings.persona) userPersona = resSettings.persona;
    } catch (e) { console.warn('PyBridge Init Exception', e); }
  } else {
    profiles = JSON.parse(localStorage.getItem('math_app_profiles_v3')) || DEFAULT_PROFILES;
    activeProfileId = localStorage.getItem('math_app_active_profile_id') || profiles[0].id;
    userPersona = localStorage.getItem('math_app_persona') || PERSONA_PRESETS.default;
  }
  renderEngineSelect();
  renderSubjectSelect();
}

function setInputLock(locked) {
  userInput.disabled = locked;
  sendBtn.disabled = locked;
  newBtn.disabled = locked;
  imageUpload.disabled = locked;
  uploadLabel.style.pointerEvents = locked ? 'none' : 'auto';
  uploadLabel.style.opacity = locked ? '0.5' : '1';
  sendBtn.innerText = locked ? '⌛ 生成中...' : '➤ 讲解/追问';
}

function getActiveProfile() {
  return profiles.find(p => p.id === activeProfileId) || profiles[0];
}

function renderEngineSelect() {
  activeEngineSelect.innerHTML = profiles.map(p =>
    `<option value="${p.id}" ${p.id === activeProfileId ? 'selected' : ''}>${p.isVision ? '📷' : '📝'} ${escapeHtml(p.name)}</option>`
  ).join('');
}

activeEngineSelect.addEventListener('change', async (e) => {
  activeProfileId = e.target.value;
  if (isPythonDesktop()) {
    await window.pywebview.api.save_profiles(profiles, activeProfileId);
  } else {
    localStorage.setItem('math_app_active_profile_id', activeProfileId);
  }
  addMessage('ai', `✨ 已切换引擎为:<b>${escapeHtml(getActiveProfile().name)}</b>`, { isHTML: true, skipSnapshot: true });
});

function updatePersonaCharCount() { personaCharCount.innerText = cfgPersona.value.length; }
cfgPersona.addEventListener('input', updatePersonaCharCount);
function applyPersonaPreset(key) {
  if (PERSONA_PRESETS[key]) { cfgPersona.value = PERSONA_PRESETS[key]; updatePersonaCharCount(); }
}
document.getElementById('open-persona-btn').addEventListener('click', () => {
  cfgPersona.value = userPersona; updatePersonaCharCount(); openModal('persona-modal');
});
document.getElementById('reset-persona-btn').addEventListener('click', () => {
  cfgPersona.value = PERSONA_PRESETS.default; updatePersonaCharCount();
});
document.getElementById('save-persona-btn').addEventListener('click', async () => {
  userPersona = cfgPersona.value.trim() || PERSONA_PRESETS.default;
  if (isPythonDesktop()) {
    await window.pywebview.api.save_settings(JSON.stringify({ persona: userPersona }));
  } else {
    localStorage.setItem('math_app_persona', userPersona);
  }
  closeModal('persona-modal');
  alert('导师人设与教学偏好已保存并生效!');
});

function compressImage(base64Str, maxWidth = 300, quality = 0.6) {
  return new Promise((resolve) => {
    if (!base64Str || !base64Str.startsWith('data:image')) { resolve(''); return; }
    const img = new Image();
    img.src = base64Str;
    img.onload = () => {
      const canvas = document.createElement('canvas');
      let width = img.width, height = img.height;
      if (width > maxWidth) { height = Math.round(height * maxWidth / width); width = maxWidth; }
      canvas.width = width; canvas.height = height;
      canvas.getContext('2d').drawImage(img, 0, 0, width, height);
      resolve(canvas.toDataURL('image/jpeg', quality));
    };
    img.onerror = () => resolve('');
  });
}

function toggleSidebar(show) {
  if (show) {
    if (historySearchInput) historySearchInput.value = '';
    historyPage = 1; hasMoreHistory = true; currentSearchKeyword = '';
    historySidebarContainer.innerHTML = '';
    loadHistoryList(true);
    historySidebar.classList.add('active');
    sidebarOverlay.classList.add('active');
  } else {
    historySidebar.classList.remove('active');
    sidebarOverlay.classList.remove('active');
  }
}

function openModal(id) {
  if (id === 'settings-modal') renderProfilesManager();
  document.getElementById(id).classList.add('active');
}
function closeModal(id) { document.getElementById(id).classList.remove('active'); }

document.querySelectorAll('.modal-overlay').forEach(modal => {
  modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(modal.id); });
});

document.getElementById('open-history-btn').addEventListener('click', () => toggleSidebar(true));
document.getElementById('close-sidebar-btn').addEventListener('click', () => toggleSidebar(false));
sidebarOverlay.addEventListener('click', () => toggleSidebar(false));
document.getElementById('open-settings-btn').addEventListener('click', () => openModal('settings-modal'));
document.getElementById('open-help-btn').addEventListener('click', () => openModal('help-modal'));

async function saveToHistory(problemTitle, imageSrc, apiHistory, snapshot) {
  if (!currentHistoryId) currentHistoryId = 'hist-' + Date.now();
  const compressedThumb = imageSrc ? await compressImage(imageSrc, 300, 0.6) : '';
  const compressedApiImg = imageSrc ? await compressImage(imageSrc, 1280, 0.9) : '';
  const cleanSnapshot = snapshot.map(msg => {
    const clone = { ...msg };
    if (clone.imageUrl && clone.imageUrl.length > 500) clone.imageUrl = compressedApiImg;
    return clone;
  });
  const cleanApiHistory = apiHistory.map(msg => {
    if (msg.role === 'user' && Array.isArray(msg.content)) {
      return {
        ...msg,
        content: msg.content.map(c => {
          if (c.type === 'image_url' && c.image_url?.url?.length > 500) {
            return { ...c, image_url: { url: compressedApiImg } };
          }
          return c;
        })
      };
    }
    return msg;
  });
  const item = {
    id: currentHistoryId,
    time: new Date().toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }),
    title: problemTitle || '[图片题目]',
    image: compressedThumb,
    apiHistory: cleanApiHistory,
    snapshot: cleanSnapshot
  };
  if (isPythonDesktop()) {
    await window.pywebview.api.save_history_item(JSON.stringify(item));
  } else {
    try {
      let localData = JSON.parse(localStorage.getItem('math_app_history_v1.0.6')) || [];
      const existingIdx = localData.findIndex(h => h.id === currentHistoryId);
      if (existingIdx >= 0) localData[existingIdx] = item;
      else localData.unshift(item);
      if (localData.length > 20) localData.pop();
      localStorage.setItem('math_app_history_v1.0.6', JSON.stringify(localData));
    } catch (e) {
      console.warn("LocalStorage 配额不足:", e);
      alert("⚠️ 本地历史存储空间不足,本条记录未能完整保存。建议清理部分旧记录后重试。");
    }
  }
}

async function loadHistoryList(reset = false) {
  if (isLoadingHistory || (!hasMoreHistory && !reset)) return;
  isLoadingHistory = true;
  if (reset) { historyPage = 1; hasMoreHistory = true; historySidebarContainer.innerHTML = ''; }
  let items = [];
  if (isPythonDesktop()) {
    const res = await window.pywebview.api.get_history_list(historyPage, historyLimit, currentSearchKeyword);
    if (res && res.status === 'success') { items = res.items; hasMoreHistory = res.has_more; }
  } else {
    const raw = JSON.parse(localStorage.getItem('math_app_history_v1.0.6')) || [];
    let filtered = currentSearchKeyword ? raw.filter(i => i.title.includes(currentSearchKeyword)) : raw;
    const start = (historyPage - 1) * historyLimit;
    items = filtered.slice(start, start + historyLimit);
    hasMoreHistory = (start + items.length) < filtered.length;
  }
  if (reset && items.length === 0) {
    historySidebarContainer.innerHTML = '<p style="text-align:center;color:var(--text-secondary);padding:3rem 1rem;">未找到匹配的历史记录</p>';
    isLoadingHistory = false;
    return;
  }
  const html = items.map((item) => `
    <div class="history-card">
      <div class="history-card-title">${item.image ? '📷 ' : '📝 '}${escapeHtml(item.title)}</div>
      <div class="history-card-meta"><span>🕒 ${item.time}</span><span>${item.image ? '含附件' : '纯文本'}</span></div>
      <div class="history-card-actions">
        <button class="btn-sm" style="background:var(--primary)" onclick="loadHistoryItem('${item.id}')">载入现场</button>
        <button class="btn-sm btn-danger" onclick="deleteHistoryItem('${item.id}')">删除</button>
      </div>
    </div>
  `).join('');
  historySidebarContainer.insertAdjacentHTML('beforeend', html);
  historyPage++;
  isLoadingHistory = false;
}

historySidebarContainer.addEventListener('scroll', () => {
  const { scrollTop, scrollHeight, clientHeight } = historySidebarContainer;
  if (scrollHeight - scrollTop - clientHeight < 40) loadHistoryList(false);
});

historySearchInput.addEventListener('input', (e) => {
  clearTimeout(searchDebounceTimer);
  searchDebounceTimer = setTimeout(() => {
    currentSearchKeyword = e.target.value.trim();
    loadHistoryList(true);
  }, 280);
});

async function deleteHistoryItem(id) {
  if (isPythonDesktop()) {
    await window.pywebview.api.delete_history_item(id);
  } else {
    let localData = JSON.parse(localStorage.getItem('math_app_history_v1.0.6')) || [];
    const idx = localData.findIndex(h => h.id === id);
    if (idx >= 0) localData.splice(idx, 1);
    localStorage.setItem('math_app_history_v1.0.6', JSON.stringify(localData));
  }
  if (currentHistoryId === id) currentHistoryId = '';
  loadHistoryList(true);
}

document.getElementById('clear-all-history-btn').addEventListener('click', async () => {
  if (confirm('确定要清空所有历史记录吗?此操作不可撤销。')) {
    if (isPythonDesktop()) await window.pywebview.api.clear_all_history();
    else localStorage.removeItem('math_app_history_v1.0.6');
    currentHistoryId = '';
    loadHistoryList(true);
  }
});

async function loadHistoryItem(id) {
  let item = null;
  if (isPythonDesktop()) {
    const res = await window.pywebview.api.get_history_item(id);
    if (res && res.status === 'success') item = res.item;
  } else {
    const localData = JSON.parse(localStorage.getItem('math_app_history_v1.0.6')) || [];
    item = localData.find(h => h.id === id);
  }
  if (!item) { alert("载入历史记录失败,未在数据库中找到该项。"); return; }
  chatContainer.innerHTML = '';
  currentHistoryId = item.id;
  chatHistory = JSON.parse(JSON.stringify(item.apiHistory));
  const restoredSnapshot = JSON.parse(JSON.stringify(item.snapshot));
  fullSnapshotHistory = restoredSnapshot;
  currentProblem = item.title;
  const snapshotWithImg = restoredSnapshot.find(msg => msg.imageUrl && msg.imageUrl.length > 500);
  currentImageBase64 = snapshotWithImg ? snapshotWithImg.imageUrl : (item.image || '');
  let lastTextMsgDiv = null;
  restoredSnapshot.forEach((msg) => {
    const div = addMessage(msg.role, msg.content, {
      isExplanation: msg.isExplanation, rawText: msg.rawText,
      imageUrl: msg.imageUrl, graphData: msg.graphData,
      turnId: msg.turnId, skipSnapshot: true
    });
    if (msg.role === 'ai' && msg.isExplanation && !msg.graphData) {
      lastTextMsgDiv = div;
    }
    if (msg.role === 'ai' && msg.graphData && lastTextMsgDiv) {
      moveActionsToGraph(lastTextMsgDiv, div);
      lastTextMsgDiv = null;
    }
  });
  moreMethodBtn.disabled = false;
  variantBtn.disabled = false;
  toggleSidebar(false);
  addMessage('ai', `📜 已载入题目:<b>${escapeHtml(item.title)}</b> 的完整现场,您可以继续在下方追问。`, { isHTML: true, skipSnapshot: true });
}

// 把讲解消息里的操作按钮移动到底部图形消息
function moveActionsToGraph(textMsgDiv, graphMsgDiv) {
  if (!textMsgDiv || !graphMsgDiv) return;
  const targetBubble = graphMsgDiv.querySelector('.bubble');
  if (!targetBubble) return;
  // 图形卡片若已有自带的 actions,先移除(避免重复)
  const ownActions = targetBubble.querySelector('.msg-actions');
  if (ownActions) ownActions.remove();
  const actions = textMsgDiv.querySelector('.msg-actions');
  const rawContent = textMsgDiv.querySelector('.raw-content');
  if (actions) targetBubble.appendChild(actions);
  if (rawContent) targetBubble.appendChild(rawContent);
}

function renderProfilesManager() {
  profilesListContainer.innerHTML = profiles.map((p, index) => `
    <div class="profile-card">
      <div class="profile-card-header">
        <span>${escapeHtml(p.name)}</span>
        <div>
          <span class="${p.isVision ? 'tag-vision' : 'tag-text'}">${p.isVision ? '支持看图' : '纯文本'}</span>
          ${profiles.length > 1 ? `<button onclick="deleteProfile(${index})" style="background:#ff6b6b;padding:.2rem .5rem;font-size:.75rem;margin-left:.5rem;">删除</button>` : ''}
        </div>
      </div>
      <div class="profile-inputs">
        <input type="text" value="${escapeHtml(p.name)}" onchange="profiles[${index}].name=this.value" placeholder="配置名称">
        <select onchange="profiles[${index}].isVision = (this.value==='true')">
          <option value="true" ${p.isVision ? 'selected' : ''}>📷 支持看图 (Vision)</option>
          <option value="false" ${!p.isVision ? 'selected' : ''}>📝 纯文本引擎 (Text)</option>
        </select>
        <input type="text" value="${escapeHtml(p.url)}" onchange="profiles[${index}].url=this.value" placeholder="API Endpoint">
        <input type="text" value="${escapeHtml(p.model)}" onchange="profiles[${index}].model=this.value" placeholder="Model 名称">
        <input type="password" value="${escapeHtml(p.key)}" onchange="profiles[${index}].key=this.value" placeholder="API Key (sk-...)" style="grid-column:span 2">
      </div>
      <div class="profile-test-row">
        <button class="test-btn" id="test-btn-${index}" onclick="testProfileConnection(${index})">🔌 测试连接</button>
        <span class="test-result" id="test-result-${index}">点击按钮验证 Key 与网络是否可用</span>
      </div>
    </div>
  `).join('');
}

function validateApiUrl(url) {
  if (!url || typeof url !== 'string') throw new Error('API 地址为空');
  let u;
  try { u = new URL(url); } catch (e) { throw new Error(`引擎地址格式非法:${url}`); }
  const isHttps = u.protocol === 'https:';
  const isLocalhost = u.hostname === 'localhost' || u.hostname === '127.0.0.1';
  if (!isHttps && !isLocalhost) {
    throw new Error(`出于安全考虑,仅允许 https:// 或 localhost 的 API 地址(当前:${url})`);
  }
  return true;
}

async function testProfileConnection(index) {
  const p = profiles[index];
  const btnEl = document.getElementById(`test-btn-${index}`);
  const resultEl = document.getElementById(`test-result-${index}`);
  if (!p || !btnEl || !resultEl) return;
  if (!p.url || !p.model) {
    resultEl.className = 'test-result err'; resultEl.innerText = '✗ 请先填写 API Endpoint 与 Model 名称'; return;
  }
  if (!p.key) {
    resultEl.className = 'test-result err'; resultEl.innerText = '✗ 请先填写 API Key'; return;
  }
  try { validateApiUrl(p.url); } catch (e) {
    resultEl.className = 'test-result err'; resultEl.innerText = `✗ ${e.message}`; return;
  }
  btnEl.disabled = true; btnEl.innerText = '⌛ 测试中...';
  resultEl.className = 'test-result pending'; resultEl.innerText = '正在向 API 发送最小验证请求...';
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), 20000);
  const t0 = performance.now();
  try {
    const res = await fetch(p.url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${p.key}` },
      body: JSON.stringify({ model: p.model, messages: [{ role: 'user', content: 'hi' }], max_tokens: 5 }),
      signal: controller.signal
    });
    clearTimeout(timer);
    const elapsed = Math.round(performance.now() - t0);
    const data = await res.json().catch(() => ({}));
    if (!res.ok) {
      let errMsg = data?.error?.message || `HTTP ${res.status}`;
      if (res.status === 401) errMsg = 'API Key 无效或未授权';
      else if (res.status === 403) errMsg = '访问被拒绝(可能地区/账户受限)';
      else if (res.status === 404) errMsg = '接口路径或 Model 名称不存在';
      else if (res.status === 429) errMsg = '配额超限或账户欠费';
      else if (res.status >= 500) errMsg = '服务端故障';
      if (errMsg.length > 120) errMsg = errMsg.slice(0, 120) + '...';
      resultEl.className = 'test-result err'; resultEl.innerText = `✗ 失败 (${elapsed}ms):${errMsg}`; return;
    }
    const hasChoices = Array.isArray(data.choices) && data.choices.length > 0;
    const visionTag = p.isVision ? ' · 图片支持已声明' : ' · 纯文本';
    if (hasChoices) {
      resultEl.className = 'test-result ok'; resultEl.innerText = `✓ 连接成功 (${elapsed}ms)${visionTag}`;
    } else {
      resultEl.className = 'test-result err'; resultEl.innerText = `⚠ 返回 200 但响应格式非 OpenAI 兼容(无 choices 字段),请确认 Endpoint 是否正确`;
    }
  } catch (err) {
    clearTimeout(timer);
    const elapsed = Math.round(performance.now() - t0);
    resultEl.className = 'test-result err';
    if (err.name === 'AbortError') {
      resultEl.innerText = `✗ 超时 (${elapsed}ms):超过 20 秒未响应,请检查网络或 API 地址`;
    } else if (err.message && err.message.includes('Failed to fetch')) {
      resultEl.innerText = `✗ 网络错误:无法连接到该 API 地址(可能 URL 错误、DNS 失败或跨域被拦截)`;
    } else {
      resultEl.innerText = `✗ 异常:${err.message || '未知错误'}`;
    }
  } finally {
    btnEl.disabled = false; btnEl.innerText = '🔌 测试连接';
  }
}

document.getElementById('add-profile-btn').addEventListener('click', () => {
  profiles.push({
    id: 'profile-' + Date.now(), name: '自定义 API 引擎',
    url: 'https://api.openai.com/v1/chat/completions', model: 'gpt-4o-mini', key: '', isVision: true
  });
  renderProfilesManager();
});

async function deleteProfile(index) {
  if (!profiles[index]) return;
  if (confirm('确定删除该引擎配置吗?')) {
    profiles.splice(index, 1);
    if (!profiles.find(p => p.id === activeProfileId)) activeProfileId = profiles[0].id;
    if (isPythonDesktop()) await window.pywebview.api.save_profiles(profiles, activeProfileId);
    else {
      localStorage.setItem('math_app_profiles_v3', JSON.stringify(profiles));
      localStorage.setItem('math_app_active_profile_id', activeProfileId);
    }
    renderProfilesManager();
    renderEngineSelect();
  }
}

document.getElementById('save-profiles-btn').addEventListener('click', async () => {
  for (let i = 0; i < profiles.length; i++) {
    const p = profiles[i];
    if (p.url && p.url.trim()) {
      try { validateApiUrl(p.url.trim()); } catch (e) {
        alert(`引擎【${p.name}】地址校验失败:\n${e.message}`); return;
      }
    }
  }
  if (isPythonDesktop()) await window.pywebview.api.save_profiles(profiles, activeProfileId);
  else {
    localStorage.setItem('math_app_profiles_v3', JSON.stringify(profiles));
    localStorage.setItem('math_app_active_profile_id', activeProfileId);
  }
  renderEngineSelect();
  closeModal('settings-modal');
  alert('引擎配置已保存并生效!');
});

/* ============================================================
   JSXGraph 渲染
   ============================================================ */

function normalizeExpr(expr) {
  let s = String(expr).trim();
  s = s.replace(/^\$+|\$+$/g, '');
  s = s.replace(/^\\\(|\\\)$/g, '');
  s = s.replace(/^\s*f\s*\(\s*x\s*\)\s*=\s*/i, '');
  s = s.replace(/^\s*y\s*=\s*/i, '');
  s = s.replace(/^\s*[a-zA-Z]\s*=\s*/, '');
  s = s.replace(/\\frac\{([^{}]+)\}\{([^{}]+)\}/g, '($1)/($2)');
  s = s.replace(/\\sqrt\{([^{}]+)\}/g, 'sqrt($1)');
  s = s.replace(/\\cdot|\\times/g, '*');
  s = s.replace(/\\pi/g, 'pi');
  s = s.replace(/×/g, '*');
  s = s.replace(/÷/g, '/');
  s = s.replace(/π/g, 'pi');
  s = s.replace(/√/g, 'sqrt');
  s = s.replace(/²/g, '^2');
  s = s.replace(/³/g, '^3');
  s = s.replace(/\bln\s*\(/g, 'log(');
  s = s.replace(/\|([^|]+)\|/g, 'abs($1)');
  return s;
}

function validateGraphDataStructure(graphData) {
  if (!graphData || typeof graphData !== 'object') return false;
  if (graphData.type === 'function') {
    return Array.isArray(graphData.functions) && graphData.functions.length > 0 &&
           graphData.functions.every(f => f && typeof f.expr === 'string' && f.expr.trim());
  }
  if (graphData.type === 'geometry' || Array.isArray(graphData.elements)) {
    return Array.isArray(graphData.elements) && graphData.elements.length > 0;
  }
  return false;
}

function renderGraphSafely(container, graphData) {
  const errors = [];
  try {
    if (!container) return { ok: false, reason: '容器为空' };
    if (typeof JXG === 'undefined' || !JXG.JSXGraph) {
      return { ok: false, reason: 'JSXGraph 未加载' };
    }

    const board = JXG.JSXGraph.initBoard(container, {
      boundingbox: [-10, 15, 10, -5],
      axis: true,
      showCopyright: false,
      showNavigation: false
    });

    if (graphData.options && graphData.options.xaxis && graphData.options.yaxis) {
      const opts = graphData.options;
      try {
        board.setBoundingBox([opts.xaxis[0], opts.yaxis[1], opts.xaxis[1], opts.yaxis[0]]);
      } catch (e) { /* 忽略 */ }
    }

    let drawSuccess = false;

    if (graphData.type === 'function' && Array.isArray(graphData.functions)) {
      graphData.functions.forEach(f => {
        if (!f.expr) return;
        const exprStr = normalizeExpr(f.expr);
        if (!exprStr) { errors.push(`空表达式(原文:${f.expr})`); return; }
        try {
          board.create('functiongraph', [exprStr], {
            strokeColor: f.color || '#4ea8de',
            strokeWidth: 2
          });
          drawSuccess = true;
        } catch (errA) {
          console.warn('[图形] JSXGraph 字符串解析失败:', exprStr, errA);
          try {
            let jsExpr = exprStr
              .replace(/\^/g, '**')
              .replace(/\b(sin|cos|tan|asin|acos|atan|sinh|cosh|tanh|sqrt|cbrt|abs|exp|log|log2|log10|floor|ceil|round|sign|pow|min|max)\b/g, 'Math.$1')
              .replace(/\bpi\b/gi, 'Math.PI')
              .replace(/\be\b/g, 'Math.E');
            const fn = new Function('x', '"use strict"; return (' + jsExpr + ');');
            const testVal = fn(1);
            if (typeof testVal !== 'number') throw new Error('返回值不是数值');
            board.create('functiongraph', [fn], {
              strokeColor: f.color || '#4ea8de',
              strokeWidth: 2
            });
            drawSuccess = true;
          } catch (errB) {
            console.error('[图形] new Function 回退也失败:', errB);
            errors.push(`表达式 "${exprStr}" 无法解析`);
          }
        }
      });
    } else if (graphData.elements && Array.isArray(graphData.elements)) {
      const points = {};
      graphData.elements.forEach(el => {
        if (el.type === 'point' && el.name && el.coords) {
          try {
            points[el.name] = board.create('point', el.coords, { name: el.name, size: 4 });
            drawSuccess = true;
          } catch (e) { errors.push(`点 ${el.name} 创建失败`); }
        }
      });
      graphData.elements.forEach(el => {
        if (el.type === 'line' && el.points) {
          const p1 = points[el.points[0]], p2 = points[el.points[1]];
          if (p1 && p2) {
            try {
              board.create('line', [p1, p2], { straightFirst: false, straightLast: false });
              drawSuccess = true;
            } catch (e) { errors.push(`线 ${el.points.join('-')} 创建失败`); }
          }
        }
      });
    }

    if (!drawSuccess && errors.length === 0) errors.push('未找到可绘制的函数或几何元素');
    return { ok: drawSuccess, reason: errors.join('; ') };
  } catch (e) {
    console.error('JSXGraph 渲染总异常:', e);
    return { ok: false, reason: '初始化异常:' + (e.message || String(e)) };
  }
}

/* ============================================================
   Markdown / 表格 / 公式渲染
   ============================================================ */
function renderMarkdownTables(text) {
  const mathTokens = [];
  let safeText = text.replace(/(\$\$[\s\S]*?\$\$|\$[^\$\n]+?\$|\\\[[\s\S]*?\\\]|\\\(.+?\\\))/g, (m) => {
    const token = `@@MATH_TOKEN_${mathTokens.length}@@`;
    mathTokens.push(m);
    return token;
  });

  const tableRegex = /((?:\|[^\n\r]+\|(?:\r?\n|$))+)/g;
  safeText = safeText.replace(tableRegex, (match) => {
    const lines = match.trim().split(/\r?\n/).map(l => l.trim()).filter(l => l.startsWith('|') && l.endsWith('|'));
    if (lines.length < 2) return match;
    const isDivider = /^\|(?:\s*:?-+:?\s*\|)+$/.test(lines[1]);
    if (!isDivider) return match;
    const headers = lines[0].slice(1, -1).split('|').map(c => c.trim());
    let tableHtml = '<table class="custom-table"><thead><tr>';
    headers.forEach(h => { tableHtml += `<th>${escapeHtml(h)}</th>`; });
    tableHtml += '</tr></thead><tbody>';
    for (let i = 2; i < lines.length; i++) {
      const cells = lines[i].slice(1, -1).split('|').map(c => c.trim());
      tableHtml += '<tr>';
      cells.forEach(c => { tableHtml += `<td>${escapeHtml(c)}</td>`; });
      tableHtml += '</tr>';
    }
    tableHtml += '</tbody></table>';
    return tableHtml;
  });

  mathTokens.forEach((m, idx) => {
    safeText = safeText.replace(`@@MATH_TOKEN_${idx}@@`, m);
  });
  return safeText;
}

function processExplanation(rawText) {
  if (!rawText) return '';
  let cleanText = rawText.replace(/\r\n/g, '\n').replace(/\n{4,}/g, '\n\n').replace(/^---+$/gm, '|||SPLIT|||');

  if (!cleanText.includes('###')) {
    let formatted = renderMarkdownTables(cleanText);
    const safe = formatted.includes('<table') ? formatted.replace(/\|\|\|SPLIT\|\|\|/g, '<span class="split-line"></span>') : escapeHtml(cleanText).replace(/\|\|\|SPLIT\|\|\|/g, '<span class="split-line"></span>');
    return `<div class="explanation-card"><div class="card-content">${safe}</div></div>`;
  }

  const sections = cleanText.split(/(?=###\s)/);
  let html = '';

  sections.forEach(sec => {
    const trimmed = sec.trim();
    if (!trimmed) return;
    if (trimmed.startsWith('###')) {
      const lines = trimmed.split('\n');
      const titleRaw = lines[0].replace(/^###\s*/, '').trim();
      const contentRaw = lines.slice(1).join('\n').trim();
      const titleEsc = escapeHtml(titleRaw);
      let contentFormatted = renderMarkdownTables(contentRaw);
      let contentEsc = contentFormatted.includes('<table')
        ? contentFormatted.replace(/\|\|\|SPLIT\|\|\|/g, '<span class="split-line"></span>')
        : escapeHtml(contentRaw).replace(/\|\|\|SPLIT\|\|\|/g, '<span class="split-line"></span>');
      html += `<div class="explanation-card"><h3>${titleEsc}</h3><div class="card-content">${contentEsc}</div></div>`;
    } else {
      let contentFormatted = renderMarkdownTables(trimmed);
      let contentEsc = contentFormatted.includes('<table')
        ? contentFormatted.replace(/\|\|\|SPLIT\|\|\|/g, '<span class="split-line"></span>')
        : escapeHtml(trimmed).replace(/\|\|\|SPLIT\|\|\|/g, '<span class="split-line"></span>');
      html += `<div class="explanation-card"><div class="card-content">${contentEsc}</div></div>`;
    }
  });
  return html;
}

function renderMathInMsg(element) {
  if (window.renderMathInElement) {
    renderMathInElement(element, {
      delimiters: [
        { left: '$$', right: '$$', display: true },
        { left: '$', right: '$', display: false },
        { left: '\\(', right: '\\)', display: false },
        { left: '\\[', right: '\\]', display: true }
      ],
      throwOnError: false
    });
  }
}

async function deleteTurn(turnId) {
  if (!confirm('确定要删除这轮问答吗?(将成对清除该提问与解答,并同步数据库)')) return;
  document.querySelectorAll(`[data-turn-id="${turnId}"]`).forEach(el => el.remove());
  fullSnapshotHistory = fullSnapshotHistory.filter(m => m.turnId !== turnId);
  chatHistory = chatHistory.filter(m => m.turnId !== turnId);
  const hasAnyImage = fullSnapshotHistory.some(m => m.imageUrl && m.imageUrl.length > 500)
    || chatHistory.some(m => Array.isArray(m.content) && m.content.some(c => c && c.type === 'image_url'));
  if (!hasAnyImage) currentImageBase64 = '';
  await saveToHistory(currentProblem, currentImageBase64, chatHistory, fullSnapshotHistory);
}

function addMessage(role, content, options = {}) {
  const { isExplanation = false, rawText = '', isHTML = false, imageUrl = '', graphData = null, skipSnapshot = false, turnId = '' } = options;
  const div = document.createElement('div');
  div.className = `message ${role}`;
  if (turnId) div.setAttribute('data-turn-id', turnId);

  let bubbleContent = '';
  if (role === 'ai' && isExplanation) bubbleContent = processExplanation(content);
  else if (isHTML) bubbleContent = content;
  else bubbleContent = escapeHtml(content);

  let imageHTML = imageUrl ? `<div><img src="${imageUrl}" class="chat-thumbnail" onclick="openImageLightbox(this.src)"></div>` : '';

  // 判断本条是否是"图形-only"消息(无文字、无rawText、无isHTML)
  const isGraphOnly = role === 'ai' && !!graphData && !content && !rawText && !isHTML;

  let actionsHTML = '';
  // 【关键修复】graph-only 消息不生成自带 actionsHTML,避免与"移动过来的按钮"重复
  if (role === 'ai' && !isHTML && !isGraphOnly) {
    actionsHTML = `
      <div class="msg-actions">
        ${rawText ? `<span class="raw-toggle" onclick="const c=this.parentNode.nextElementSibling; c.style.display=c.style.display==='block'?'none':'block'">[查看原始 Markdown]</span>` : ''}
        ${turnId ? `<span class="delete-turn-btn" onclick="deleteTurn('${turnId}')">🗑️ 删除本轮问答</span>` : ''}
      </div>
      ${rawText ? `<pre class="raw-content">${escapeHtml(rawText)}</pre>` : ''}
    `;
  }

  div.innerHTML = `
    <div class="avatar">${role === 'user' ? '👤' : '🤖'}</div>
    <div class="bubble">
      ${bubbleContent}
      ${imageHTML}
      ${graphData ? `<div class="graph-container"><div class="jxgbox"></div></div>` : ''}
      ${actionsHTML}
    </div>
  `;

  if (graphData) {
    const bubbleEl = div.querySelector('.bubble');
    if (bubbleEl) bubbleEl.classList.add('has-graph');
    if (isGraphOnly) div.classList.add('graph-only');
  }

  chatContainer.appendChild(div);

  if (graphData) {
    const boardDiv = div.querySelector('.jxgbox');
    if (boardDiv) {
      requestAnimationFrame(() => {
        const result = renderGraphSafely(boardDiv, graphData);
        if (!result.ok) {
          const tip = document.createElement('div');
          tip.style.cssText = 'font-size:.78rem;color:#ff6b6b;margin-top:.4rem;word-break:break-all;line-height:1.4;';
          tip.textContent = '⚠️ 图形渲染失败:' + (result.reason || '未知原因');
          const container = boardDiv.parentNode;
          if (container) container.appendChild(tip);
        }
      });
    }
  }

  renderMathInMsg(div.querySelector('.bubble'));
  chatContainer.scrollTop = chatContainer.scrollHeight;

  if (!skipSnapshot) {
    fullSnapshotHistory.push({ role, content, isExplanation, rawText, imageUrl, graphData, turnId });
  }
  return div;
}

function showLoading(msgText = "正在分析推导中......") {
  removeLoading();
  const div = document.createElement('div');
  div.className = 'message ai loading';
  div.id = 'loading-msg';
  div.innerHTML = `<div class="avatar">🤖</div><div class="bubble"><em>${escapeHtml(msgText)}</em></div>`;
  chatContainer.appendChild(div);
  chatContainer.scrollTop = chatContainer.scrollHeight;
}

function removeLoading() {
  const loading = document.getElementById('loading-msg');
  if (loading) loading.remove();
}

async function callAPI(messages, maxTokens = 8000, timeoutMs = 120000, options = {}) {
  const { skipOptimize = false } = options;
  const active = getActiveProfile();
  if (!active.key) throw new Error(`请先点击 [⚙️ 引擎管理] 为 【${active.name}】 配置 API Key`);

  const hasImageInHistory = messages.some(m =>
    Array.isArray(m.content) && m.content.some(c => c && c.type === 'image_url')
  );
  if (hasImageInHistory && !active.isVision) {
    throw new Error(`当前引擎【${active.name}】为纯文本引擎,但对话历史包含图片消息。请切换到 [📷 支持看图] 的引擎后再继续追问。`);
  }

  validateApiUrl(active.url);

  let optimizedMessages = [];
  if (skipOptimize || messages.length <= 11) {
    optimizedMessages = [...messages];
  } else {
    const combined = [messages[0], messages[1], ...messages.slice(-8)];
    const seen = new Set();
    optimizedMessages = [];
    for (const msg of combined) {
      const key = msg.turnId || (msg.role + '::' + (typeof msg.content === 'string' ? msg.content.slice(0, 64) : 'arr'));
      if (seen.has(key)) continue;
      seen.add(key);
      optimizedMessages.push(msg);
    }
  }
  optimizedMessages = optimizedMessages.map(msg => ({ role: msg.role, content: msg.content }));

  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
  try {
    const res = await fetch(active.url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${active.key}` },
      body: JSON.stringify({
        model: active.model,
        messages: optimizedMessages,
        temperature: 0.5,
        max_tokens: maxTokens
      }),
      signal: controller.signal
    });
    clearTimeout(timeoutId);
    const data = await res.json().catch(() => ({}));
    if (!res.ok) {
      console.error("[API Raw Error]:", data);
      if (res.status === 401) throw new Error("API Key 无效或未授权,请检查引擎管理中的 Key 配置。");
      if (res.status === 429) throw new Error("API 调用配额超限或欠费,请检查账户余额。");
      if (res.status >= 500) throw new Error("大模型服务提供商接口故障,请稍后重试或切换引擎。");
      throw new Error(data.error?.message || `请求失败 (HTTP ${res.status})`);
    }
    return data;
  } catch (err) {
    clearTimeout(timeoutId);
    if (err.name === 'AbortError') {
      throw new Error(`请求超时(等待超 ${timeoutMs / 1000} 秒),请检查网络或更换 API 节点。`);
    }
    throw err;
  }
}

async function explainProblem(inputData, isImage = false, isAnotherMethod = false, extraTextPrompt = '', isVariant = false) {
  if (isSending) return;
  isSending = true;

  const active = getActiveProfile();
  const turnId = 'turn-' + Date.now();
  const isNewProblem = chatHistory.length === 0;

  if (isImage && !active.isVision) {
    const visionProfile = profiles.find(p => p.isVision && p.key);
    if (visionProfile) {
      if (confirm(`当前引擎【${active.name}】为纯文本引擎,无法直接理解图片。\n是否自动切换至支持看图的【${visionProfile.name}】进行多模态解答?`)) {
        activeProfileId = visionProfile.id;
        renderEngineSelect();
        if (isPythonDesktop()) await window.pywebview.api.save_profiles(profiles, activeProfileId);
        else localStorage.setItem('math_app_active_profile_id', activeProfileId);
        isSending = false;
        explainProblem(inputData, true, isAnotherMethod, extraTextPrompt, isVariant);
        return;
      } else {
        addMessage('ai', '⚠️ 已取消操作。纯文本引擎不支持图片输入,请在顶栏切换为 [📷 支持看图] 引擎。', { skipSnapshot: true });
        isSending = false;
        return;
      }
    } else {
      alert(`当前引擎【${active.name}】不支持识图,且未配置其他看图引擎。\n请点击顶部 [⚙️ 引擎管理] 选择/配置看图引擎。`);
      openModal('settings-modal');
      isSending = false;
      return;
    }
  }

  setInputLock(true);
  showLoading("AI 正在深度思考解答中...");
  moreMethodBtn.disabled = true;
  variantBtn.disabled = true;

  try {
    let userContentPayload;
    const currentSubjectObj = SUBJECTS[currentSubjectKey] || SUBJECTS.math;

    if (isNewProblem) {
      if (isImage) {
        currentImageBase64 = inputData;
        currentProblem = extraTextPrompt ? `📷 [${currentSubjectObj.name}] ${extraTextPrompt}` : `📷 [${currentSubjectObj.name}] 照片题目`;
      } else {
        let cleanText = inputData.replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim();
        currentProblem = cleanText.slice(0, 30) || '📝 自定义题目';
        currentImageBase64 = '';
      }
    }

    if (isImage) {
      const userShowMsg = extraTextPrompt ? `📷 照片 + 附加要求:${extraTextPrompt}` : '📷 上传了一张题目照片:';
      addMessage('user', userShowMsg, { imageUrl: inputData, turnId });
      const promptText = extraTextPrompt
        ? `请详细解答分析这张图片中的题目。附加特别要求:${extraTextPrompt}`
        : '请详细解答分析这张图片中的题目。';
      userContentPayload = [
        { type: 'text', text: promptText },
        { type: 'image_url', image_url: { url: inputData } }
      ];
    } else {
      let userText = inputData;
      let displayUserMsg = '';
      if (isAnotherMethod) {
        const methodPrefix = currentSubjectObj.promptMethod || '请为这道题提供另一种不同的解法或思路:';
        userText = `${methodPrefix}${currentProblem}`;
        displayUserMsg = `${currentSubjectObj.btnMethod || '🔄 另一种解法'}:${currentProblem}`;
      } else if (isVariant) {
        const variantTemplate = currentSubjectObj.promptVariant || '请基于题目【{problem}】的核心考点与解法,出 2 道难度相当的同类变式练习题,并附带详细解答与分析。';
        userText = variantTemplate.replace('{problem}', currentProblem);
        displayUserMsg = `${currentSubjectObj.btnVariant || '📝 出同类变式题'}:${currentProblem}`;
      } else {
        displayUserMsg = userText;
      }
      addMessage('user', displayUserMsg, { turnId });
      userContentPayload = userText;
    }

    if (chatHistory.length === 0) {
      const fullSystemPrompt = `${currentSubjectObj.prompt}

【用户自定义导师偏好】:
"""
${userPersona}
"""

【终极排版裁判硬约束】:无论上述用户偏好如何设置,必须无条件优先遵循 ### 标题卡片分块、$$/行内$ LaTeX 公式包裹规范。若偏好与格式冲突,无条件优先执行本硬约束!`;
      chatHistory.push({ role: 'system', content: fullSystemPrompt, turnId: 'system' });
    }
    chatHistory.push({ role: 'user', content: userContentPayload, turnId });

    const MAX_TOKENS = 8000;
    const MAX_CONTINUATIONS = 2;
    const CONTINUE_PROMPT = '你上面的回答被截断了,请从中断处继续输出,不要重复已输出的内容,直接从断点续写。';

    let apiMessages = [...chatHistory];
    let accumulatedText = '';
    let lastFinishReason = '';
    let stillTruncated = false;

    for (let round = 0; round <= MAX_CONTINUATIONS; round++) {
      if (round > 0) showLoading(`内容较长,正在续写第 ${round} 段...`);
      const textResponse = await callAPI(apiMessages, MAX_TOKENS, 120000, { skipOptimize: round > 0 });
      removeLoading();
      const choice = textResponse.choices?.[0];
      const messageObj = choice?.message;
      lastFinishReason = choice?.finish_reason || '';
      let chunkText = '';
      if (messageObj) {
        if (typeof messageObj.content === 'string' && messageObj.content.trim()) chunkText = messageObj.content;
        else if (Array.isArray(messageObj.content)) chunkText = messageObj.content.map(c => c.text || '').join('\n');
        else if (messageObj.reasoning_content && messageObj.reasoning_content.trim()) chunkText = messageObj.reasoning_content;
      }
      if (chunkText) accumulatedText += (accumulatedText ? '\n' : '') + chunkText;
      if (lastFinishReason !== 'length') { stillTruncated = false; break; }
      stillTruncated = true;
      if (round === MAX_CONTINUATIONS) break;
      apiMessages = [...apiMessages, { role: 'assistant', content: chunkText }, { role: 'user', content: CONTINUE_PROMPT }];
    }

    let textContent = accumulatedText.trim();
    if (!textContent) textContent = '⚠️ 模型未返回有效解答内容(可能触发了服务商安全过滤或输入图片过大,请尝试框选更精准的截图重试)。';
    if (stillTruncated) {
      textContent += '\n\n---\n\n> ⚠️ **提示**:本回答已自动续写 2 次,但仍达到模型输出上限。建议:\n> 1. 点击 **🔄 另一种解法** 让模型用更精简思路重讲;\n> 2. 或在下方输入「请详细展开第X步」分步追问。';
    }

    chatHistory.push({ role: 'assistant', content: textContent, turnId });
    const textMsgDiv = addMessage('ai', textContent, { isExplanation: true, rawText: textContent, turnId });

    if (isNewProblem && textContent.includes('###')) {
      const match = textContent.match(/###\s*[^\r\n]+\s*[\r\n]+([^\r\n]+)/);
      if (match && match[1]) {
        let extractedTitle = match[1].replace(/[\$\*\#\`\:\:\-\d\.]/g, '').trim();
        if (extractedTitle.length >= 2) {
          currentProblem = `${currentSubjectObj.name} · ${extractedTitle.slice(0, 22)}`;
        }
      }
    }

    if (['math', 'physics'].includes(currentSubjectKey) && !isVariant) {
      try {
        const graphMessages = [
          { role: 'system', content: GRAPH_GENERATE_PROMPT.replace('{problem}', currentProblem).replace('{summary}', textContent.substring(0, 250)) },
          { role: 'user', content: '请输出 JSON 或 NONE。' }
        ];
        const graphResponse = await callAPI(graphMessages, 1200);
        const graphChoice = graphResponse.choices?.[0];
        const graphMessage = graphChoice?.message;
        const graphFinish = graphChoice?.finish_reason || '';

        let graphRaw = '';
        if (graphMessage) {
          if (typeof graphMessage.content === 'string' && graphMessage.content.trim()) graphRaw = graphMessage.content.trim();
          else if (Array.isArray(graphMessage.content)) graphRaw = graphMessage.content.map(c => c.text || '').join('\n').trim();
          else if (graphMessage.reasoning_content) graphRaw = graphMessage.reasoning_content.trim();
        }

        console.log('[图形生成] finish_reason:', graphFinish);
        console.log('[图形生成] 原始返回:', graphRaw.slice(0, 300));

        if (graphFinish === 'length') {
          console.warn('[图形生成] 响应被截断,建议提高 max_tokens');
        } else if (!graphRaw || graphRaw.includes('NONE')) {
          console.log('[图形生成] 模型判定为无图形(NONE)');
        } else {
          const jsonMatch = graphRaw.match(/\{[\s\S]*\}/);
          if (jsonMatch) {
            try {
              const graphData = JSON.parse(jsonMatch[0]);
              if (validateGraphDataStructure(graphData)) {
                const graphMsgDiv = addMessage('ai', '', { graphData, turnId });
                moveActionsToGraph(textMsgDiv, graphMsgDiv);
              } else {
                console.warn('[图形生成] JSON 结构未通过校验:', graphData);
              }
            } catch (jsonErr) {
              console.warn('[图形生成] JSON 解析失败:', jsonErr, '原文:', jsonMatch[0]);
            }
          } else {
            console.warn('[图形生成] 未找到 JSON 片段:', graphRaw);
          }
        }
      } catch (graphErr) {
        console.warn('图形自动生成跳过:', graphErr);
      }
    }

    await saveToHistory(currentProblem, currentImageBase64, chatHistory, fullSnapshotHistory);
    moreMethodBtn.disabled = false;
    variantBtn.disabled = false;
  } catch (e) {
    removeLoading();
    addMessage('ai', `❌ 错误: ${e.message}`, { turnId });
  } finally {
    setInputLock(false);
    isSending = false;
  }
}

imageUpload.addEventListener('change', (e) => {
  const file = e.target.files[0];
  if (!file) return;
  const reader = new FileReader();
  reader.onload = (event) => {
    pendingImageBase64 = event.target.result;
    previewThumbImg.src = pendingImageBase64;
    imagePreviewBar.classList.add('active');
  };
  reader.readAsDataURL(file);
  imageUpload.value = '';
});

cancelImgBtn.addEventListener('click', () => {
  pendingImageBase64 = '';
  imagePreviewBar.classList.remove('active');
});

sendBtn.addEventListener('click', () => {
  if (isSending) return;
  const text = userInput.value.trim();
  const imgData = pendingImageBase64;
  pendingImageBase64 = '';
  imagePreviewBar.classList.remove('active');
  if (imgData) {
    explainProblem(imgData, true, false, text);
    userInput.value = '';
  } else if (text) {
    explainProblem(text, false);
    userInput.value = '';
  }
});

userInput.addEventListener('keydown', (e) => {
  if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendBtn.click(); }
});

moreMethodBtn.addEventListener('click', () => {
  if (isSending) return;
  if (currentProblem) explainProblem(currentProblem, false, true);
});

variantBtn.addEventListener('click', () => {
  if (isSending) return;
  if (currentProblem) explainProblem(currentProblem, false, false, '', true);
});

printBtn.addEventListener('click', () => openModal('print-modal'));

function executePrint() {
  closeModal('print-modal');
  setTimeout(() => {
    try { window.print(); }
    catch (e) { alert('⚠️ 当前运行环境未能调出打印对话框。\n请改用浏览器方式打开 index.html 后按 Ctrl+P 打印,或直接截图保存。'); }
  }, 300);
}

newBtn.addEventListener('click', () => {
  if (isSending) return;
  currentProblem = ''; currentImageBase64 = ''; currentHistoryId = '';
  pendingImageBase64 = '';
  imagePreviewBar.classList.remove('active');
  chatHistory = []; fullSnapshotHistory = [];
  moreMethodBtn.disabled = true; variantBtn.disabled = true;
  userInput.value = '';
  addMessage('ai', '✨ 已为您开启新题目对话,请输入新题目或选择图片。', { skipSnapshot: true });
});

window.addEventListener('pywebviewready', () => { initStoreData(); });
if (!isPythonDesktop()) { initStoreData(); }

addMessage('ai', `👋 我是<b>全学科自学启导者(V3.0.2 纯多模态极速版)</b>。<br><br>
🔹 <b>✨ 端到端视觉大模型:</b>已升级原生多模态视觉推理,支持几何辅助线、受力分析图、电路实物图等高维解析;<br>
🔹 <b>🎯 极速秒开与轻量便携:</b>极轻量架构秒速瞬开,历史数据便携漫游;<br>
🔹 <b>📊 规范表格支持:</b>Markdown 实验数据表与包含 <code>$|x|$</code> 绝对值公式的表格完美渲染;<br>
🔹 <b>📈 函数图原生解析:</b>使用 JSXGraph 内置表达式解析器,支持 <code>x^2</code>、<code>sin(x)</code>、<code>sqrt(x)</code> 等写法。有图形时操作按钮自动移动到底部图形卡片;<br>
🔹 <b>🖨️ 矢量 PDF 本地保存:</b>点击 <code>🖨️ 导出/打印PDF</code> 可选择"另存为 PDF"保存矢量文件;<br>
🔹 <b>⚡ V3.0.2 新增:</b>长解答自动续写 + 引擎连接测试。<br>
📝 请点击顶栏 <b>[⚙️ 引擎管理]</b> 配置 Key 并点击 <b>[🔌 测试连接]</b> 验证后即可开始体验。`, { isHTML: true, skipSnapshot: true });
</script>
</body>
</html>

五、打包与分发

本节面向希望自行打包分发的开发者。普通用户无需关心此节,直接使用现成的 app.exe 即可。

打包命令

D:\Python\python313\Scripts\pyinstaller.exe --clean --onedir --noconsole ^

--add-data "D:\学习笔记(研习版)2\多学科自学启导者(轻量版)\轻量版打包专用\index.html;." ^

--distpath "D:\学习笔记(研习版)2\多学科自学启导者(轻量版)\轻量版打包专用" ^

"D:\学习笔记(研习版)2\多学科自学启导者(轻量版)\轻量版打包专用\app.py"

其中

D:\Python\python313 是python版本安装实际路径

D:\学习笔记(研习版)2\多学科自学启导者(轻量版)\轻量版打包专用 是app.py所在的实际路径

或者

D:\Python\python313\Scripts\pyinstaller.exe --clean --onedir --noconsole --add-data "D:\学习笔记(研习版)2\多学科自学启导者(轻量版)\轻量版打包专用\index.html;." --distpath "D:/学习笔记(研习版)2/多学科自学启导者(轻量版)/轻量版打包专用" "D:/学习笔记(研习版)2/多学科自学启导者(轻量版)/轻量版打包专用/app.py"

分发目录标准结构

多学科自学启导者/

├── app.exe (核心主程序)

├── _internal/ (运行环境与动态链接库)

└── data/

└── math_tutor_data.db (软件启动后自动生成:便携数据库,可以手工复制替换)

相关推荐
Joe_Wang51 小时前
【从0到1学习JVM · 13】点进JDK源码只有一个分号?搞懂本地方法栈与JNI机制
java·jvm·学习·本地方法栈
~光~~1 小时前
【嵌入式Linux学习】GFP_NOIO / GFP_NOFS(Linux 内核 gfp 分配标志)
linux·运维·学习
坤坤子吖2 小时前
ELF加载、动态链接、GOT与PLT
linux·笔记·学习
zhanghaha13142 小时前
AI Agent_5 AI 大语言模型基础(LLM)
ai
感谢地心引力3 小时前
来试试我做的音乐播放器吧
ai·rust·音乐·navidrome·music·tarui
fundroid3 小时前
Android AI 开发,真正拉开差距的是工程闭环
android·ai·大模型·agent
李航19833 小时前
自动动手开发图形引擎,不仅能AI建模,还能AI渲染
人工智能·python·计算机视觉·ai·ai编程
71-34 小时前
MySQL密码重置
数据库·笔记·学习·mysql
Zootopia6264 小时前
快递无人车与无人机各自进入配送网络后,路线能否一起算?
c++·人工智能·机器学习·matlab·ai·机器人·无人机