中学生学习工具:多学科自学启导者(轻量版)实现
一、它是什么
一句话: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)
- 学习讲解一道题时,不同轮次切换不同的引擎 Provider 是否可以?
可以,这正是本系统架构的强大优势之一!但需要注意以下几点:
✅ 可行性
顶部引擎下拉框可随时切换,切换后下一次调用 API 会使用新引擎。
chatHistory 会保留,新引擎能继续基于之前的对话上下文回答。
OpenAI 兼容格式使得大多数模型可无缝衔接。
⚠️ 关键限制
图片消息的兼容性:
如果首轮使用视觉引擎发送了图片,chatHistory 中会包含 image_url 格式的消息。
之后切换到纯文本引擎继续追问,V3.0.2 会自动拦截并弹出中文提示:'当前引擎为纯文本,但对话历史包含图片消息,请切换到支持看图的引擎'。不会再出现 API 报错。
建议:
图片题一旦开始,最好始终使用视觉引擎进行追问,不要中途切到纯文本引擎。
反之,纯文本题开始后,切到视觉引擎一般没问题(纯文本消息视觉引擎也能处理),但可能会消耗更多 Token。
模型能力差异:不同模型对上下文的理解可能不同,但通常可以继续对话。
� 结论
技术上可行,但建议保持同一类引擎(视觉或纯文本),避免因历史图片导致纯文本引擎出错。
- 一个问题多轮问答,删除某次问答对 AI 回答影响如何?
当前工具支持"� 删除本轮问答"(即某次追问和对应回答),删除后:
✅ 正面影响
减少干扰:删除无效或错误的追问,可以让后续回答更聚焦于有效上下文,避免模型被无关信息带偏。
节省 Token:删除后 chatHistory 变短,后续请求携带的上下文减少,降低 Token 消耗。
⚠️ 需要注意的风险
勿删首轮:首轮包含系统提示和原始题目(可能含图片)。如果删除首轮,模型将丢失题目信息,后续追问可能无法正确解答。
当前代码没有限制删除首轮,但首轮通常没有"删除本轮问答"按钮(因为首轮助手回答也可能显示删除按钮,用户可能误删)。建议避免手动删除首轮。
中间轮次删除影响:删除中间轮次后,上下文出现"跳跃",模型仍能根据剩余消息理解,但可能缺失某些推理步骤。通常影响不大。
首轮图文资产依然坚固:代码中特别锁定了rootUserMsg(首轮原题和原图),删除中间的某个追问轮次绝不会破坏最初的题目图文背景。
3.长解答显示"正在续写第 N 段..."是什么?
这是 自动续写机制。
模型单次输出有上限(本工具设为 8000 token)。当模型回答超出上限时,API 会返回 finish_reason: "length" 表示被截断。本工具检测到后,自动追加"请从断点继续"指令再次请求,最多续写 2 次。用户无需任何操作。若 2 次后续写仍截断,会提示分步追问。
- 如果用户忘记切换学科,问了另一个学科问题,是否可以?
可以问,主流模型具备强大的自适应纠偏能力。但回答可能不专业或不符合预期,因为:
若用户在会话中忘记切换学科,直接输入另一学科题目,模型仍以旧学科的角色和规范回答。
界面层的影响:
顶部的辅助按钮(如"另一种解法"、"出同类变式题")会沿用当前选中学科的动作提示词(例如在语文模式下点变式题会出阅读拓展,数学模式下会出同类计算题);
历史记录归档标题会短暂打上当前选中的学科前缀。
例如:当前是"数学",输入物理题,模型可能会用数学的思维和格式(五模块:题目分析、常规解法等)回答,而不是物理的"物理模型与已知条件"等结构。
对于跨学科问题,回答可能仍然正确,但结构不对,或缺少学科特有分析。
常见错误对照表:
|-----------------------------|-----------------------|-------------|
| 显示信息 | 原因 | 解决 |
| 请先点击 ⚙️ 引擎管理 配置 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">×</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()">×</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">×</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')">×</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')">×</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')">×</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')">×</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, '&').replace(/</g, '<').replace(/>/g, '>')
.replace(/"/g, '"').replace(/'/g, ''');
}
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 (软件启动后自动生成:便携数据库,可以手工复制替换)