# 技术细节 本文件面向**改代码、审代码、排错**的读者。只想安装和使用请看 [README](../README.md); 版本历史见 [CHANGELOG](../CHANGELOG.md)。 ## 1. 仓库结构与数据流 本仓库既是插件的**能力核心**,也是 **dsh 原生插件包的发布源**:Python 核心、MCP 服务器、 讲义 Skill,以及 dsh 用的 `package.json` / `cordis.patch.yml` / `lib/` 都在这里。 ```text dsh-zju-lecture-note/ ├── package.json # npm / dsh 插件包清单(包名 dsh-zju-lecture-note) ├── cordis.patch.yml # dsh bundle patch(把插件插进 cordis) ├── lib/ # dsh 插件实现(ESM) │ ├── index.js # 插件入口:按开关注册工具 + 内存注册随包 Skill │ ├── bridge.js # 拉起随包 Python 核心(JSON-RPC over stdio) │ ├── client.js # 浏览器半边:设置页 + 左栏「智云讲义」入口 │ ├── tools.js # 生成物:dsh 工具定义 │ └── tools.schema.json # 生成物:工具定义快照(供测试比对) ├── .minimax-plugin/plugin.json # MiniMax Code(MCode)插件清单 ├── zju-lecture.mcp.json # MCP 服务器定义(stdio → node ./server.mjs) ├── server.mjs # 启动器:解析 Python 解释器并拉起 MCP 服务 ├── mcp_server.py # MCP stdio 服务器(JSON-RPC 2.0,隐私边界在这里) ├── zju_lecture/ # 核心库(纯标准库) │ ├── client.py # 智云课堂 API 客户端(TLS 校验 + 同域重定向) │ ├── cookies.py # Edge Cookie 读取与安全清理 │ ├── security.py # Cookie 校验 / redactor / 本地文件边界 │ ├── paths.py # 工作区与相对路径解析 │ ├── courseware.py # 课件文本提取(带路径校验) │ ├── note_lint.py # 讲义 Markdown 公式规范检查 / 自动修复 │ ├── handout.py # 讲义 Markdown → 阅读版 PDF(自动排版) │ ├── handout_prelude.typ # 自包含讲义模板(不依赖 Typst Universe 包) │ ├── deps.py # Typst 自举:查找 / 版本 / 下载 / SHA256 / 缓存 │ ├── typst_assets.json # 各平台 Typst Release 的固定校验和 │ ├── typst-packages/ # vendor 的 @preview 包(离线编译,逐包带许可) │ ├── fonts/ # Notes Serif SC 子集 + OFL.txt + FONT-NOTICE.md │ ├── study_notes.py # 讲义 → 填空底稿 → Typst → PDF │ ├── prelude.typ # scholia 淡彩 palette(填空笔记用,唯一权威副本) │ ├── slides.py # 投影截图下载 / PDF 合成(可选模块) │ ├── formatter.py # 时间戳 / 分钟聚合 / 分块 │ └── cli.py # 命令行入口 ├── skills/zju-lecture-summarizer/ # 讲义生成 SOP(五段式模板 + 公式规范) ├── skills/zju-lecture-summarizer.manifest.json ├── skills/scholia-notes/ # 填空式学习笔记 Skill(MIT,独立授权) │ ├── SKILL.md / LICENSE / README.md / THIRD_PARTY_NOTICES.md │ ├── references/ # API / 风格指南 / 排错 / 格式规范 │ └── prelude.typ # 生成副本(同步自 zju_lecture/prelude.typ) ├── skills/scholia-notes.manifest.json ├── SECURITY.md # 威胁模型 / Cookie 生命周期 / 已知边界 ├── CHANGELOG.md # 版本历史 ├── docs/ # 技术文档与历史 issue 记录 ├── adapters/ # 其它宿主的接入示例(不进入插件包) ├── tests/ # 回归测试(含隐私扫描与插件形态测试) ├── tools/ # 打包、schema 生成、隐私扫描、冒烟测试 ├── icon.png / icon-dark.png ├── LICENSE / ATTRIBUTION.md └── README.md ``` 主链路(两条 PDF 链路共用同一份 Markdown 与同一套公式转换): ```text 回放 URL / sub_id → zju_fetch_transcript(公开字幕接口,按分钟 / 逐句 / 分块 / JSON) →(可选)zju_extract_courseware(本地课件校准术语) → Agent 写 Markdown → zju_write_note:命名 → 修公式 → 校验 → 写文件 → 排版同名 PDF →(可选)zju_build_study_notes:讲义 →(draft)填空底稿 →(patch,一次写回挖空) →(compile)scholia PDF + 逐页 PNG ``` ## 2. 安装形态与依赖 | 形态 | 载体 | 依赖 | | :--- | :--- | :--- | | dsh 原生插件 | 仓库根目录的 npm 包(`package.json` + `cordis.patch.yml` + `lib/`) | pnpm 装 peer;Python 3.9+;Typst 由插件自举 | | 标准 MCP + Skill | `server.mjs` 拉起 stdio MCP Server | PATH 上的 `node`;Python 3.9+ | | MiniMax Code 插件包 | `tools/build_plugin.py` 产出的 `dist/*.zip` | 同上 | | 命令行 | `python -m zju_lecture.cli` | Python 3.9+;`pypdf` 仅 PDF 课件需要 | **Typst 自举**(v2.1.0):`zju_lecture/deps.py` 把「本机没装 Typst」从静默失败变成一次可解释的准备: 1. 查找顺序 `ZJU_TYPST_BIN` → 插件缓存 `%LOCALAPPDATA%\dsh-zju-lecture\typst\v<版本>`(macOS/Linux 对应 `~/Library/Caches`、`~/.cache`)→ `PATH` → 常见安装目录; 2. 版本低于 0.13.0 时提示升级(0.13 是 `--package-path` 与现有模板的下限); 3. 找不到且允许自动安装时,从 GitHub Release 下载对应平台归档,**只信任** `zju_lecture/typst_assets.json` 里钉死的 SHA256,解压后再用 `typst --version` 复核; 4. 下载失败会写一个 6 小时的冷却标记,避免每写一份讲义都卡一次超时。 `ZJU_TYPST_AUTO_INSTALL=0` 关闭自动安装;`ZJU_TYPST_MIRROR` 可给下载 URL 加镜像前缀 (只影响传输路径,哈希仍按清单校验)。`zju_doctor` 报告上述全部状态,`install=true` 立即重试安装。 **离线编译**:6 个 `@preview` 包(scholia / frame-it / marginalia / theoretic / showybox / noteworthy) 已 vendor 到 `zju_lecture/typst-packages/`,编译统一带 `--package-path` 与工作区内的 `--package-cache-path`,填空笔记首次编译不再访问 Typst 包注册表。逐包保留 `LICENSE` / `UNLICENSE` 与 `typst.toml`(后者是 `@preview/name:version` 解析的依据),只删除 README 与模板示例。 **peer 依赖**:`lib/index.js` 里的 `@deepseek-ai/*` 全部是 `peerDependencies` (与 `dshmarket` 一致),由 pnpm 在 `dsh plugin add` 时装进 profile 的 `node_modules` (当前是 `@deepseek-ai/schemastery` 3.18.x)。直接解压 zip 加载会报: ```text Cannot find package '@deepseek-ai/schemastery' imported from ...\\lib\\index.js ``` 若一定要用本地目录,先补一颗可解析的 schemastery(其余 `@deepseek-ai/dsh-*` 仍由 dsh 运行时提供): ```bash cd /absolute/path/to/zju-lecture-agent-plugin npm install --no-save --no-package-lock @deepseek-ai/schemastery@^3.18.1 dsh plugin --profile desktop add file:/absolute/path/to/zju-lecture-agent-plugin ``` `--no-save --no-package-lock` 让这次安装只落进目录的 `node_modules`,不改 `package.json`。 **版本要求**:dsh 按 `peerDependencies` 中的 `@deepseek-ai/dsh-*` 范围校验运行时版本, 本插件声明 `>=0.2.0-rc.2 <0.3.0-0`,不匹配时需要显式的版本豁免 (`dsh plugin allow-version`)。 **Python 探测顺序**:插件配置项 → 环境变量 `ZJU_PYTHON` → DeepSeek Harness 自带 Python → PATH 上的 `python3` / `python` / `py -3`。 **client 元数据在 Host 启动时缓存**:`dsh.client` 新增或修改后,只重装插件、甚至刷新页面都不够, 必须**完全重启 DeepSeek Harness**;可在 `cordis_inspect_query` 的 `Slots` → `factory:dsh-zju-lecture-note` / `settings.section` 验证。 **浏览器半边的两个席位(同一份配置)**:`lib/client.js` 只创建一个 `PipelineSettings` 控制器、一个 `PipelineSection` 组件,然后从两个席位挂同一个页面: | 席位 | 位置 | 注册 | | :--- | :--- | :--- | | `settings.section` | 设置弹窗的左栏「浙大智云课堂讲义」 | `id: PANEL_ID`、`order: 25` | | `sidebar.panellist` + `main` | 主界面左栏「智云讲义」一行,点开在主区域显示同一页 | 行 `id` 与面板 `key` 都取 `PANEL_ID` | - 侧边栏按 id 调 `ctx.layout.selectPanel(id)`,所以**行的 `id` 与 `main` 的 `key` 必须一致**; 两处都引用同一个 `PANEL_ID = 'zju-lecture'` 常量,不要写死两份字符串。 - 两个席位嵌套注册(`main` 外层、`sidebar.panellist` 内层),任一侧失败都不会留下半套入口。 - `sidebar.panellist` 没有图标字段:注册项自己画 SVG,用 `currentColor` 才能跟随行的 悬停/选中颜色;`label` 是 thunk,跟随语言切换。 - `main` 占的是整列页面,页首要用 `calc(20px + var(--dsh-frame-top-clearance, 0px))` 留出 窗口标题栏的高度,否则 Windows 上第一行会被标题栏压住。 - 面板页把标题渲染成 `h1`(`asPage`),设置弹窗里仍是 `h2`(弹窗自己提供标题)。 ## 3. 工具与开关分层 v2.0.1 起,「字幕 → 讲义 → PDF」主链路与所有可选能力解耦。每个模块有自己的工具、 Skill 附录和系统提示;关掉就**不存在**,不占工具列表、不读登录态、不加载代码。 | 分层 | 工具 | 依赖 | 默认 | | :--- | :--- | :--- | :--- | | **字幕主链路** | `zju_parse_url` / `zju_fetch_transcript` / `zju_extract_courseware` / `zju_check_note` / `zju_write_note`(含自动 PDF) / `zju_doctor` | 公开字幕接口 + 本地文件 + 可选 Typst | 始终开启 | | **填空笔记** | `zju_build_study_notes` + `scholia-notes` Skill | 本机 Typst | 关闭(`enableStudyNotes`) | | **投影抓取** | `zju_fetch_slides` | 登录态 + 截图下载 + PDF 合成 | 关闭(`enableSlides`) | | **投影插图** | (`zju_fetch_slides` 的子开关) | 依赖投影抓取 | 关闭(`embedSlideImages`) | | **登录辅助** | `zju_get_video_url` / `zju_read_browser_cookie` | 本机 Edge 或 `ZJU_COOKIE` / `ZJU_COOKIE_FILE` | 关闭(`enableLoginTools`) | - dsh 默认注册 **6** 个工具;`enableStudyNotes` +1、`enableSlides` +1、`enableLoginTools` +2, 全开 **10** 个。 - **`.volatile()` 字段是引用,不是布尔值**:dsh 把它们投影成 `{ get() }`,供插件在设置改动后 读取新值。`lib/index.js` 的 `readSetting()` 统一解包;直接写 `config.enableSlides === true` 会永远得到 `false`——2.1.1 修的「设置全开仍只有字幕」就是这个原因。新增设置项时务必走 `readSetting()`,并补一条 ref 形状的测试。 - MCP 未设置新变量时保持旧行为(10 个);仓库自带的 `zju-lecture.mcp.json` 默认关掉三个可选模块(6 个)。 - 关闭时:不注册对应工具、不加载对应模块、Skill 不追加对应附录;系统提示会说明 「这个模块已关闭」,Agent 不会调用或假装调用。 - 实现上,`lib/index.js` 用 `registerableTools()` 过滤;`mcp_server.available_tools()` 同步过滤 `tools/list`;直接调用会抛 `SlidesDisabledError`(RPC 侧是 `isError`)。 - `zju_lecture/__init__.py` 用 PEP 562 懒导出 `download_slides` / `build_slides_pdf` / `SlideError`; `mcp_server.py` 只在进入 `zju_fetch_slides` 分支时才导入 `zju_lecture.slides` (纯字幕进程的 `sys.modules` 里不会出现截图模块,有子进程测试守住)。 - dsh 单独把 `zju_fetch_slides` 的超时提高到 600s(下载整节课截图用),其他工具仍用配置值。 - 投影抓取与填空笔记互不依赖;登录辅助也与投影抓取解耦——投影工具会自己解析登录态。 ### 环境变量 | 变量 | 作用 | | :--- | :--- | | `ZJU_PYTHON` | 指定 Python 解释器 | | `ZJU_WORKSPACE` | 相对路径的解析根;dsh 插件自动传入 session workspace | | `ZJU_BACKUP_DIR` | `zju_check_note` / `zju_write_note` 的备份目录(默认系统临时目录) | | `ZJU_COOKIE` / `ZJU_COOKIE_FILE` | 登录态(仅投影 / 视频直链需要) | | `ZJU_RENDER_PDF` | 默认 `0`(MCP);对应 dsh `renderPdf`(默认开) | | `ZJU_TYPST_AUTO_INSTALL` | 缺 Typst 时自动下载安装(默认 `1`;dsh 对应 `typstAutoInstall`) | | `ZJU_TYPST_BIN` | 指定已有 Typst 可执行文件(优先级最高) | | `ZJU_TYPST_MIRROR` | Typst 下载 URL 的镜像前缀,例如 `https://ghfast.top/` | | `ZJU_TYPST_CACHE` | 覆盖 Typst 缓存目录(默认用户缓存;测试与换盘时用) | | `ZJU_ENABLE_STUDY_NOTES` / `ZJU_ENABLE_SLIDES` / `ZJU_EMBED_SLIDES` / `ZJU_ENABLE_LOGIN_TOOLS` | 可选模块开关 | | `ZJU_ALLOW_SENSITIVE_PATHS` | 显式关闭本地敏感路径保护(不推荐) | 相对路径解析顺序:绝对路径 → `ZJU_WORKSPACE` → `PWD` → 服务进程 cwd;都找不到时, 错误信息会列出**尝试过的绝对路径**。 ## 4. 主链路:`zju_write_note` 内部 一次工具调用完成五件事,Agent 不需要再调第二个工具: 1. **命名**:`name` 参数优先(Agent 按「上课日期 + 本节内容」起名);缺省回退 `知识总结--<课程简称>.md`。同名默认拒绝覆盖,返回 `exists`,要覆盖须显式 `overwrite=true`。 2. **修公式**:`fix=true`(默认)先跑一遍可机械修复的规则(见 §7)。 3. **校验**:静态扫描讲义,按 `code` 聚合报告。 4. **写文件**:原子替换;回写前备份到 `ZJU_BACKUP_DIR`。 5. **排版 PDF**:把同一份内容写进 `<工作区>/.zju-lecture/handout//main.typ` 并编译; 成功时 PDF 落在讲义同目录,返回一行 `PDF: <路径>(N 页)`;失败只回一行 `PDF 跳过: …`,Markdown 已写入。 省 token 的约定:`zju_write_note` 已经包含校验,**通常不需要**再调 `zju_check_note`; 只有手工改过文件、或想给已有讲义补 PDF 时才用 `zju_check_note(pdf=true)`。 同一节课的字幕也只抓一次(长课用 `chunked` 视图分批总结)。 ## 5. 阅读版讲义 PDF 实现:`zju_lecture/handout.py` + `zju_lecture/handout_prelude.typ`。 - **自包含**:`handout_prelude.typ` **不 import `@preview/scholia`**,不访问 Typst 包注册表; 样式全部内联进生成的 `main.typ`,离线也能编译。`@preview/scholia` 只继续用于可选的填空笔记。 - **工程位置**:`<工作区>/.zju-lecture/handout//`(已 gitignore),PDF 与讲义同目录。 不导出逐页 PNG——只有填空笔记需要。 - **版式**:封面(标题 / 副题 / 日期)、目录(≥3 节时)、标题层级、正文、列表、三线表、 公式、代码、引用、工作区图片。 - 版心、封面、边注栏、字号等**数值**统一见 [`typography-spec.md`](../skills/scholia-notes/references/typography-spec.md); 本节只保留机制。 - 一级标题:灰蓝编号 + 蓝色标题 + 细线;方框淡彩底 + 左侧竖线、无圆角; 证明以斜体 `Proof.` + 行末 qed。 - 各级标题后的间距自 2.2.2 起加大(用户在实地会话里要求「小节标题后的行间距 适当大一些」),具体值见规范 §2;两条链路同步改。 - `par.first-line-indent` **不可靠**:它只在「前一个兄弟元素是段落」时生效, 标题 / 块 / 图 / 公式后的段落一律不缩进 ⇒ 正文改用行内 `#h(2em)` 顶首行; 直觉块不缩进(块自身的左内边距已经分开)。 - 块级公式的上下间距吃 `par.spacing`,压段间距会把公式压到下一段上 ⇒ 用 `#show math.equation.where(block: true): set block(above: …, below: …)` 解耦 (数值见规范)。 - 行内公式的墨迹会溢出「行盒」:Typst 的行内数学盒子高度是**固定值**(11pt 下 ≈0.68em,`measure($frac(1,2)$)` 与 `measure($x$)` 都是 7.51pt),分式的分子/分母、 大算子的上下限压到邻行上。解决办法是两处**显式**留白:模板里的 `#show math.equation.where(block: false): it => box(inset: (top: …, bottom: …), it)` 给每个行内公式补一点;`study_notes.pad_inline_math()` 再给分式/根式/矩阵/大算子 这类「结构上会撑高」的公式包一层 `#box(inset: (bottom: …))`。只按最大值统一给会把 只有 `$x$` 的行也撑松,所以按结构分级(数值与实测见规范 §3.2)。 - 边注用 `box(width: 0pt, context { let p = here().position(); place(top + left, dx: page.width - p.x - note-width - right-pad, …) })`: `note-width` / `right-pad` 的数值见规范;直接 `context` + `place(top + right)` 会把边注甩到页面顶端。 - **标记语法**(转义前处理;`____` 先于转义,否则会被变成 `#fillin` 后漏掉): | 标记 | 装置 | | :--- | :--- | | `[直觉]` | `#note`(青绿淡底) | | `[定义] 标题 —— 正文` / `[定理]` / `[命题]` / `[例]` | 带标签的方框 | | `[证明]` | 斜体 `Proof.` + qed | | `[练习]` | `#yourturn` + 3 行 `#workspace(n: 3)`(练习块里的短 `____` 一律丢弃) | | `[边注]` / `[边注!]` | 右侧留白边注(`[边注!]` 带强调) | | `[强调]` / `[易错]` / `[弱注]` / `[分隔]` | 行内高亮 / 提示 / 弱化 / 分隔线 | | `---`(独占一行) | 同 `[分隔]` 的细分隔线(不再被智能标点排成孤立「–」) | | `____` | `#fillin(width: 2.2cm)`(灰色,提示留给读者写) | | `[缺口: …]` | `#gap` 留白 | | `