在 Anki 内复刻 Obsidian OmniChat 的侧边 AI 对话体验:右侧常驻可停靠/可折叠的聊天面板, 以「单张卡片(Note)」为文档上下文,Note ID 作为会话 key,不同卡片的聊天历史相互隔离。
- 兼容版本:Anki 2.1.60 ~ 2.1.66,并已在 Anki 25.x / 26.x(PyQt6 / Python 3.13) 上做适配验证
- AnkiWeb:ankiweb.net/shared/info/1939948095(Add-on ID
1939948095) - 技术约束:只用官方 Qt WebEngine、
anki/aqthooks 与 Anki 自带库;零第三方 pip 依赖 - 双后端:模式 A(默认)WebView 内嵌免费网页 / 模式 B OpenAI 兼容 API(流式)
- Qt 适配:PyQt5 与 PyQt6 的枚举差异(作用域/非作用域)统一由
qt_compat.py处理
复习中翻到答案面 → 侧边栏自动切到这张卡的会话并把卡片全部字段注入 → 一键写入网页输入框提问。
📺 介绍视频(2 分钟,中文):在 Anki 里装一个 AI 侧边栏:AnkiOmniChat 插件介绍 (由 f(t) 工作流制作,动画源码随制作仓库开源。)
| 能力 | 说明 |
|---|---|
| 侧边面板 | 右侧 QDockWidget,可停靠/浮动/折叠;复习界面与浏览界面(Browse)都可启用,同一实例在两个窗口间迁移 |
| 会话隔离 | 会话 key = note:<Note ID>;切换卡片自动切换会话(可关闭);无卡片时为「自由会话」 |
| 卡片注入 | 快捷键一键注入当前卡片全部字段;翻卡(显示答案)自动注入;字段可排除、可自定义模板 |
| 模式 A 网页 | 内置豆包 / Kimi / DeepSeek 入口,用户在面板内自行登录,cookie 本地持久化;一键把卡片文本写入网页输入框,默认不自动发送 |
| 模式 B API | OpenAI 兼容 /v1/chat/completions,SSE 流式逐字输出,工作线程请求不卡界面,可随时「停止」 |
| 渲染 | 自研极简 Markdown 渲染器 + 复用 Anki 自带 MathJax 渲染 $...$ / $$...$$ |
| 动作按钮 | 每条 AI 消息下方:追加到卡片字段 / 覆盖卡片字段 / 复制文本 |
| 持久化 | 会话历史写入本地 JSON(原子写 + 损坏自动备份),重启 Anki 不丢失 |
- Anki → 工具 → 插件 → 「获取插件…」
- 粘贴 Add-on ID:
1939948095,回车,重启 Anki。
也可直接打开 https://ankiweb.net/shared/info/1939948095 查看页面与下载。
-
关闭 Anki。
-
找到插件目录:
- Windows:
%APPDATA%\Anki2\addons21 - macOS:
~/Library/Application Support/Anki2/addons21 - Linux:
~/.local/share/Anki2/addons21
- Windows:
-
把整个
AnkiOmniChat文件夹复制进去,得到:addons21/ └── AnkiOmniChat/ ├── __init__.py ├── meta.json ├── config.json ├── config.py ├── session_manager.py ├── backend.py ├── anki_hook.py └── ui/ -
启动 Anki。首次加载会自动在
addons21/AnkiOmniChat/下生成默认配置;主窗口右侧出现 「AnkiOmniChat」面板(也可在 工具 → AnkiOmniChat:显示/隐藏侧边栏 打开)。
git clone https://github.com/safeblood/AnkiOmniChat.git
cp -r AnkiOmniChat/AnkiOmniChat <addons21 目录>/或直接从 Releases 页下载打好包的 AnkiOmniChat.ankiaddon。
- 把
AnkiOmniChat文件夹压缩为 zip(注意:zip 根目录内直接是文件,不要再套一层文件夹)。 - 重命名为
AnkiOmniChat.ankiaddon。 - Anki → 工具 → 插件 → 安装插件文件,选择该文件,重启 Anki。
卸载:工具 → 插件 → 选中 AnkiOmniChat → 删除;再手动删除 profile 目录下的
Anki2/<用户名>/AnkiOmniChat/(会话历史)。
AnkiOmniChat/
├── __init__.py 入口:注册 profile_did_open / profile_will_close
├── config.py 配置中心:默认值、点号路径读写、数据目录
├── session_manager.py 会话持久化:JSON 原子写、按 Note ID 隔离
├── backend.py 双后端:WebBackend(注入/抓取脚本)+ ApiBackend(SSE 流式线程)
├── qt_compat.py Qt5(PyQt5)/Qt6(PyQt6) 枚举与 WebEngine 导入统一适配
├── anki_hook.py 集成层:Dock 停靠、hooks、快捷键、菜单、动作分发
└── ui/
├── markdown.py 极简 Markdown → HTML(含 LaTeX 抽取)
├── latex.py 定位/复用 Anki 自带 MathJax,失败降级 CDN
├── chat_page.py 生成消息页 HTML(OmniChat 风格 CSS + JS 接口)
├── chat_view.py QWebEngineView 封装:渲染、流式、ankiomni:// 动作回调
├── web_panel.py 模式 A 的网页视图(独立持久化 profile)
├── panel.py 面板主体:标题栏 / 模式切换 / 输入区 / 状态栏
├── actions.py 追加 / 覆盖卡片字段、复制、卡片文本拼装
└── settings.py 设置对话框(六个分组)
| 项 | 默认 | 说明 |
|---|---|---|
| 默认后端模式 | 网页模式 | web = 内置浏览器免费模式;api = OpenAI 兼容接口 |
| 切换卡片时自动切换会话 | 开 | 关闭后所有卡片共用一个会话 |
| 翻卡(显示答案)时自动注入当前卡片 | 开 | 网页模式下默认只复制到剪贴板,不打扰阅读 |
| API 请求始终附带当前卡片上下文 | 开 | 即使没手动注入,也会把卡片内容随请求发送 |
| 进入复习界面自动显示侧边栏 | 开 | — |
| 单次请求携带历史条数 | 20 | 控制 token 消耗 |
| 侧边栏宽度 / 停靠位置 | 400 / 右侧 | — |
| 项 | 默认 | 说明 |
|---|---|---|
| 网页入口预设 | 豆包 / Kimi / DeepSeek | 可增删改:名称、URL、输入框选择器、回复选择器 |
| 注入卡片时自动写入网页输入框 | 关 | 关闭时只复制到剪贴板,需点「粘贴卡片到输入框」 |
| 粘贴后自动点击发送 | 关 | 不建议开启 |
| 允许「抓取网页回复」 | 开 | 尽力而为,站点改版后可能失效 |
| User-Agent | 常见 Chrome UA | 降低被站点拦截概率 |
| 项 | 示例 |
|---|---|
| API 地址 | https://api.deepseek.com/v1/chat/completions |
| API Key | sk-xxxx(留空则不发送 Authorization 头,适合本地网关) |
| 模型名称 | deepseek-chat / gpt-4o-mini / moonshot-v1-8k … |
| 温度 / 最大 tokens / 超时 | 0.3 / 2048 / 120s |
| 流式输出 | 开(逐字显示) |
| 系统提示词 | 可自定义 |
任何 OpenAI 兼容端点都可以(DeepSeek、Moonshot、通义、智谱、本地 Ollama/LM Studio、 One-API 等)。若走本地网关,把 Key 留空即可。
字段模板:{name}:{value}({name}=字段名,{value}=字段值)前缀/后缀:包裹整段卡片文本排除字段:逗号分隔,例如备注,额外剥离 HTML:注入前把 HTML 转成纯文本(推荐开)
| 动作 | 快捷键 |
|---|---|
| 显示/隐藏侧边栏 | Ctrl+Alt+C |
| 注入当前卡片 | Ctrl+Alt+I |
| 聚焦输入框 | Ctrl+Alt+U |
| 切换下一个提示词 | Ctrl+Alt+P |
| 发送 | Ctrl+Return(面板内) |
写法遵循 Qt 按键序列:Ctrl+Alt+X、Ctrl+Shift+A、F8、Ctrl+Return。留空即禁用;
保存后立即生效(若未生效,切换一次界面状态即可)。
- 会话文件默认在当前 profile 目录下:
Anki2/<用户名>/AnkiOmniChat/sessions.json - 可在设置里「打开目录」「清空全部会话历史」
-
面板顶部切到「网页模式」,在预设下拉里选 豆包 / Kimi / DeepSeek,点「打开」。
-
首次使用需要在面板内置浏览器里登录一次;登录态(cookie)由 QtWebEngine 本地持久化, 之后重启 Anki 仍是登录状态。插件不读取、不上传 cookie,也不调用任何付费接口。
-
复习时按
Ctrl+Alt+I(或翻到答案面自动注入):- 默认只把卡片文本复制到剪贴板(状态栏有提示),点「粘贴卡片到输入框」写入网页;
- 若设置了「注入卡片时自动写入网页输入框」,则直接写入网页输入框。
-
不会自动发送,由你在网页里确认(如需自动发送,自行到设置里开启,风险自负)。
-
拿到回答后可点「抓取网页回复」把网页上最后一条回答存进当前会话(尽力而为; 抓不到就在网页里复制后粘贴回输入框,同样会被记录),随后就能用「追加/覆盖卡片字段」。
-
在网页里右键可以直接把内容存进卡片:选中要抓取的部分后右键 → 「抓取选中内容为 Markdown → 追加到卡片字段」→ 弹窗选目标字段即写入。 不选中时自动退化为抓取网页上最后一条 AI 回复。同一菜单里还有「抓取为富文本」 (保留原网页渲染,直接在 Anki 里正常显示)以及被接管后补回的复制 / 粘贴 / 全选 / 刷新。
- 设置里填好 API 地址 / Key / 模型,面板顶部切到「API 模式」。
- 复习时按
Ctrl+Alt+I注入卡片 → 输入框出现卡片内容 → 补充问题 →Enter或点「发送」。 - 回答以流式逐字显示,过程中可点「停止」;Markdown 与
$...$公式实时渲染。 - 每条 AI 回答下面有三个按钮:
- 追加到卡片字段:弹窗选字段,追加到该字段末尾
- 覆盖卡片字段:弹窗选字段,替换该字段内容
- 复制:复制到剪贴板
写入时会走 mw.checkpoint(),可用 Anki 自带的撤销(Ctrl+Z)兜底。
- 复习界面:显示问题面即按当前卡片切换会话;显示答案面时按设置自动注入。
- 浏览界面:选中行自动切换会话;右键菜单也有「用 AnkiOmniChat 打开选中卡片」。
- 复习界面右键菜单:「注入当前卡片到 AnkiOmniChat」。
- 每张卡片一个独立会话(key =
note:<Note ID>),互不串台;面板标题栏显示当前会话。
- 所有 hook、网络请求、文件读写都有异常兜底,出错只会提示,不会影响复习流程。
- 网络请求在子线程执行(QThread + 信号),流式刷新限流 90ms,长时间输出不会卡界面。
- 会话写入采用「临时文件 +
os.replace」原子替换,JSON 损坏时自动备份为.corrupt.bak并重建。 - 网页模式的抓取回复依赖站点 DOM,站点改版后会失效;这是「免费模式」的固有限制, 此时请手动复制,或改用 API 模式。
- LaTeX 依赖 Anki 自带的 MathJax:找到本地文件则离线渲染,找不到时回退 CDN, 都不可用时原样显示 TeX 源码(等宽字体)。
- 浏览界面把同一个面板实例迁移过去,关闭浏览窗口前会自动取回,避免面板被销毁。
| 现象 | 处理 |
|---|---|
| 面板没出现 | 工具 → AnkiOmniChat:显示/隐藏侧边栏;或检查 工具 → 插件 → 查看日志 |
| 网页打不开/被拦截 | 换 UA、换预设 URL,或确认网络可访问该站点 |
| 粘贴没进输入框 | 文本已复制到剪贴板,手动 Ctrl+V;也可在设置里为该站点填「输入框选择器」 |
| API 报错 401/404 | 检查 API 地址是否以 /v1/chat/completions 结尾、Key 与模型名是否正确 |
| 公式没渲染 | 看日志是否有 MathJax 加载失败;离线环境可手动放一份 tex-chtml.js 到数据目录 web/mathjax.js |
1.1.5 — 目标 Anki 2.1.60 ~ 2.1.66(Qt5/PyQt5),并通过 qt_compat.py 兼容
Anki 23.10+ / 25.x / 26.x 的 Qt6 环境(已在本机 Anki 26.9.3 + PyQt6 + Python 3.13 上完成
导入与枚举适配验证)。
新增
- 提示词库:注入卡片时可以选一套提问方式(面板上的下拉框,或
Ctrl+Alt+P轮换)。 内置 8 套覆盖复习时的高频问法:默认问答、讲透原理、举例类比、对比辨析、出题自测、 挑错补漏、助记口诀、英英释义。同一张卡反复复习时换个问法,比反复读同一份解释有用。 在「设置 → 卡片注入 → 提示词库」里可以增 / 删 / 复制 / 排序 / 恢复内置,也能改每一条 的前缀后缀。升级前自定义过前缀后缀的话,那份会被自动保留成第一条「我的提示词」。 不选中时自动退化为抓取最后一条 AI 回复;另有「抓取为富文本」直接保留原网页渲染。 接管右键后手工补回了复制 / 粘贴 / 全选 / 刷新,行为与原生一致。 - 抓取内容会转成 Markdown 结构:代码块 / 列表 / 表格 / 标题 / 引用 / 行内标记 / 链接
都会保住,不再是
innerText揉成一坨。
修复
- 复习中往字段写东西会把当前卡顶掉(严重)。旧实现写完调
mw.reset(),它会合成一个 所有字段都为True的OpChanges广播出去,Reviewer 收到study_queues=True把自己标成RefreshNeeded.QUEUES,随后refresh_if_needed()直接nextCard()——也就是说 「追加到卡片字段」这个高频动作等于跳过当前卡。现在改为只广播note_text=True(Anki 官方推荐的 operation 机制),老版本没有该机制时退回手工重载 + 重绘当前卡。 - 切换 profile 会把上一个 profile 的会话历史写进新 profile。现在按正确顺序重载: 先把旧 profile 未落盘的改动 flush 回去 → 再读新配置 → 最后清空并重载会话。
- 同一张卡反复复习时,会话里堆出一串完全相同的卡片上下文。注入上下文改为幂等写入:
尾消息是上下文就覆盖内容与时间戳,不再追加,
max_messages_per_session不再被无效文本吃掉。 - Qt6(Anki 23.10+ / 25.x / 26.x)上插件加载失败(严重)。PyQt6 把枚举挪进了作用域,
而面板里直接按 Qt5 的写法取了
QComboBox.AdjustToContents——构造界面时抛AttributeError,用户看到的就是"加载失败"。同类的QMessageBox.No(设置里「恢复内置 提示词」用到)一并修掉。现在这些取值统一走qt_compat.py。 - 顺带:提示词下拉不再把"名称 — 说明"整串塞进选项文本(
AdjustToContents会按内容撑宽、 把侧栏挤变形),说明改挂到每一项的 tooltip 上,窄面板里悬停即可看清。
改进
- 写入字段时会按内容格式处理,不再被 HTML 吞掉。Anki 字段本身是一段 HTML,AI 回答里
的
<stdio.h>、a<b、&会被浏览器当标签解析而静默消失。现在提供三档 (设置 → 通用 →「写入字段时」):存 Markdown 源码(默认):< > &转义、换行转<br>。编辑区看到原始源码, 复习界面显示正常文本,一个字符都不会少。渲染成 HTML 再写入:用内置的 Markdown 渲染器转成 HTML,复习时直接看到排版效果。原样写入:内容本身就是 HTML 时用(右键的「抓取为富文本」自动走这一档)。
- 追加内容不再用裸换行拼接。字段是 HTML 上下文,
\n会被折叠成一个空格,换行本来就 显示不出来——现在统一用<br>分隔。 - 提示词的前缀 / 后缀支持
{card}占位符:写了它,卡片正文就插在那个位置,而不是固定 夹在两者中间。这样可以写出「先布置任务、再给材料」这类更顺的句式。
- 修复:卡片名以代码围栏等多行内容开头时,标题栏被撑成多行、显示成
text `` + 换行的路径。 现在生成会话标题时折叠全部空白(含换行),显示时截断补「…」。 - 加固:面板标题强制纯文本渲染,卡片名里的
<&(HTML 残留或数学式)不会被 QLabel 当富文本解析; 旧会话数据里已存的脏标题在显示时同样被折叠,无需迁移。
- 网页模式『发送』按钮找不到:DeepSeek / 豆包 这类站点用
div[role=button]+ 自研 class, 既没有 aria-label 也不是 submit,旧选择器全部落空。现在改成多层兜底: 配置选择器 → 严格语义选择器 → class/id 含 send/submit(按面积取最小,避免点到整块容器) → 输入框就近查找 → Enter 兜底。 - 顺带修掉一个老逻辑 bug:只要某个选择器查得到元素就不再往后试,哪怕这些元素全都 hidden / disabled,结果明明有按钮却报「未找到」。
- 非表单元素(
div[role=button])的disabled属性、aria-disabled、含disabled的类名、pointer-events:none现在都能识别为不可点。 - 预设新增
send_selector(设置表格加一列),站点改版时可在设置里手动指定;旧配置自动补齐。 - 点击发送后会把结果反馈出来(成功 / 走了 Enter / 试了多少个元素没命中),不再静默失败。
- 在设置里开启『粘贴后自动点击网页的发送按钮』后,面板『发送』按钮立即可用,不必重启 Anki。
- 新增
qt_compat.py:统一解析 Qt5 / Qt6 枚举差异(Qt.RightDockWidgetArea↔Qt.DockWidgetArea.RightDockWidgetArea、QEvent.Close↔QEvent.Type.Close等), 并集中处理 QtWebEngine 的模块导入路径。 - 菜单挂载兼容新版 Anki:
mw.form.menuTools不存在时退回在菜单栏建 "AnkiOmniChat" 菜单。 - 夜间模式判断同时支持
mw.pm.night_mode()与aqt.theme.theme_manager.night_mode。 - QtWebEngine 缺失时优雅降级(提示改用 API 模式),不再导致插件加载失败。
发新版的最小步骤:改 meta.json 的版本号 → pack → update。
AnkiWeb 上的 Add-on ID 是 1939948095,版本支持范围 2.1.60 – 26.09.0。
Qt6 把枚举搬进了作用域(QComboBox.AdjustToContents → QComboBox.SizeAdjustPolicy. AdjustToContents),按 Qt5 写法取就成了运行时的 AttributeError,而且常常炸在构造界面
的地方,用户看到的现象只是"插件加载失败"。发版前跑一次:
python tools/check_qt_api.py AnkiOmniChat # 需要本机装有 PyQt6它扫描源码里所有 Qt 类上的属性访问,逐个到真实 PyQt6 上核对是否存在,发现问题时直接
提示正确的写法。枚举的双写只保留在 qt_compat.py 里:那里用 _pick() 依次尝试
Qt5 / Qt6 两种写法,脚本会自动跳过这部分。

