Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
0882774
分拣台:页签只按 AI 做到哪一步分,保留和 AI 判断分开筛选
PathGao Nov 10, 2026
966abbb
分拣台:卡片上的 AI 按钮,重新细看可以写侧重
PathGao Nov 10, 2026
c14f228
分拣台:细看没有字幕的卡片标「无字幕」,第 3 行加「无字幕」筛选
PathGao Nov 10, 2026
e7ed173
分拣台:「未打标签 · 已打标签」挪到标签行,标签行有自己的「全部」
PathGao Nov 10, 2026
e4975cd
分拣台:开发预览的假数据补上保留过的粗看、无字幕和带侧重的细看
PathGao Nov 10, 2026
feb3d34
文档:分拣台的页签、决定和 AI 判断两组筛选、卡片 AI 按钮、重新细看的侧重、无字幕
PathGao Nov 10, 2026
b6cad6d
摘读记录:「未打标签 · 已打标签」挪到标签行,标签行有自己的「全部」
PathGao Nov 10, 2026
5a23879
分拣台:去掉重新细看的侧重,点了直接重跑这一个
PathGao Nov 10, 2026
767c000
分拣台:未分析的卡片不放「标题粗看」按钮
PathGao Nov 10, 2026
89afbcf
分拣台:页签行「全部」挪到最前面,和状态行、标签行的「全部」对齐;标签行的未打标签 · 已打标签用状态按钮的样子,和标签之间加竖线
PathGao Nov 10, 2026
c9370ed
分拣台:页签改成和状态行一样的按钮,保留步骤之间的箭头
PathGao Nov 10, 2026
220540d
triage: clear 无字幕 on folder switch, batch unfav selected kept videos,…
PathGao Nov 10, 2026
9ac6de4
triage: 无字幕 chip right after the AI verdict group
PathGao Nov 10, 2026
08d4a59
triage: row 3 is one pick: 已保留 | 待决定 with the AI verdicts boxed after it
PathGao Nov 10, 2026
17f3593
triage: the three 全部 pills share a min width so the rules after them …
PathGao Nov 10, 2026
4182cf7
分拣台:清掉「阅览」旧称、没人用的 tagStateGroup 导出,关注帮助和文档跟上标签行
PathGao Nov 10, 2026
3034df3
分拣台:重新细看只剩已保留时写对原因,批量按钮在等 B站 写入时写在跑什么,标题个数不算刚取消收藏的
PathGao Nov 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 28 additions & 19 deletions docs/DESIGN.md

Large diffs are not rendered by default.

18 changes: 11 additions & 7 deletions docs/TRIAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,27 +16,31 @@
- **标签**属于各自的收藏夹,每个收藏夹默认最多 10 个(收藏夹设置里可改,1–50),一个视频可以打多个。标签那一行的「标签管理」里改名、写说明、删除,**✦ AI 打标签** 用一句话让 AI 给一批视频打标签(每次默认最多新建 5 个,收藏夹设置里可改),你检查后再应用。改标签和应用都能按 U 撤销,你之后又改过的视频不动。
- 两层互不影响:打标签不算处理,AI 判断也不改你的标签。

## 四个步骤
## 全部和三个步骤

步骤表示 AI 看到了哪一步。AI 判断是每个步骤里的筛选。
页签行开头是「全部」,后面三个页签只表示 AI 看到了哪一步,和你决定了没有无关。

| 步骤 | 里面是什么 | 你做什么 |
|---|---|---|
| 未分析 | AI 还没看过的 | 点 **✦ 标题粗看下一批**。AI 只看标题和简介,一次跑 100 个(最多 1000),每次请求带 20 个标题,便宜 |
| 粗看完成 | AI 看过标题的 | 拿不准的点 **✦ 字幕细看下一批**(读字幕,每批默认 10 个,最多 50)。有把握的可以直接保留或取消收藏 |
| 细看完成 | AI 读过字幕的,判断最可靠 | 看 AI 总结和要点,一个个或批量保留、取消收藏 |
| 处理完成 | 你处理过的 | 后悔了可以重新收藏,整批取消收藏的可以整批重新收藏 |

- **保留**只在 MoonDigest 里标记,B站收藏夹不变。**取消收藏**会真的在 B站取消,按 U 可以撤销。
- **阅览全部**:整个收藏夹一页看完,同样能按 AI 判断筛选。
- **全部**:整个收藏夹一页看完。
- **保留**只在 MoonDigest 里标记,B站收藏夹不变,视频留在原来的页签。**取消收藏**会真的在 B站取消,按 U 可以撤销;取消收藏的视频不在这些页签里,去「已出分拣范围」。
- 第 3 行(状态)的「已保留」是你保留过的;「待决定」是还没保留的,后面框里的「值得留 / 可清理 / 拿不准」是 AI 对它们的判断。一次选一个。保留过的视频不再按 AI 判断分。第 4 行(标签)开头是它自己的「全部」和「未打标签 / 已打标签」。两行的「全部」各清各的。
- 「字幕细看下一批」跳过已保留的视频,省 AI 的钱;想细看已保留的,选中再点。
- 第 3 行按 AI 判断的批量按钮(如「取消收藏(AI:可清理)」)只动待决定的视频,不碰你保留过的;想取消收藏已保留的,选中再点「取消收藏选中的 N 个」。
- 每张卡片 AI 判断那一行有一个按钮,只跑这一个视频:粗看过的是 **✦ 字幕细看**,细看过的是 **✦ 重新细看**。重新细看跳过缓存再跑一次,用在某次摘要明显不好时;想让整个收藏夹换个写法,改判断标准再「按新标准重新细看」,只想问这一个视频,按 Q 问 AI。
- 细看时视频没有字幕,AI 按简介和热门评论判断,卡片上标灰色的「无字幕」(没登录 B站时是「未登录无字幕」,字幕下载失败是「字幕抓取失败」,悬停看原因)。第 3 行的「无字幕」筛选能把它们挑出来。
- 粗看、细看、批量取消收藏和移动在切换收藏夹后继续跑,进度显示在进度胶囊里。关掉分拣台页面就暂停。
- 标题粗看和字幕细看有选中时只跑选中的(最多一批);选中的都不能跑时按钮灰掉,不改跑下一批。AI 回复出错的视频标「分析失败」排在最后,可以逐个或整批重试;Key、余额、连不上这类 AI 平台的问题会停下并提示去设置。
- 收藏夹设置里能调请求间隔、两步的每批数量和输出上限。分拣台用的是 DeepSeek、智谱或 Kimi 时还有「开启思考」开关:关着快、省;开着判断更细,但慢得多、token 多几倍。

## 选中和批量操作

- 点卡片(标题和按钮以外的地方)或按 X 选中,再点一次取消;或勾「状态」行的「全选 N 个」(再勾一次取消)。点标题是在分拣台里播放。Shift 点一张选中从上一次点的那张到这张的一段;按 Esc 或「清空选中」全部取消。⌘ / Ctrl 点标题照样在 B站打开。关注的 UP 主卡片也一样。
- 选中只属于当前这一页,切到别的步骤或阅览就清空。跑一步或批量操作以后选中不变,只有因此离开这一页的才不再选中。被搜索或筛选藏起来的选中不算进操作,选中条上会写「另有 N 个被筛选隐藏」(都被藏起来时写「已选中 N 个」和「都被筛选隐藏」);选中的都被藏起来时,按钮照样对着选中、写选中的个数,灰掉并写「选中的都被筛选隐藏了」。
- 选中只属于当前这一页,切到别的页签就清空。跑一步或批量操作以后选中不变,只有因此离开这一页的才不再选中。被搜索或筛选藏起来的选中不算进操作,选中条上会写「另有 N 个被筛选隐藏」(都被藏起来时写「已选中 N 个」和「都被筛选隐藏」);选中的都被藏起来时,按钮照样对着选中、写选中的个数,灰掉并写「选中的都被筛选隐藏了」。
- 选中后可以保留、取消收藏,或 **移动 / 复制** 到你 B站账号里的任意收藏夹,也可以新建一个(可设私密)。
- 移到没勾选的收藏夹时,视频进「已出分拣范围」并标出去向。之后在收藏夹设置里勾选那个收藏夹,它们会自动回来。
- **「导出」菜单**:「摘录或笔记…」的范围选当前筛选、选中或整个收藏夹(待播不参与导出),格式选一篇摘录(带每个视频的备注)或逐个视频笔记(字幕加 AI 总结),可以复制、下载或写入 Obsidian。也能导出 JSON 完整备份(不含密钥和待播)和当前收藏夹的 CSV 表格。
Expand All @@ -51,7 +55,7 @@

- **所有收藏夹**:把勾选的收藏夹合在一起看和搜索。第一次要逐个加载,之后只核对有变化的。AI 粗看、细看和 AI 打标签也能在这里做:每个视频按它排在最前的收藏夹的判断标准判断,标签按收藏夹分开打。批量保留和批量取消收藏也能在这里做,取消收藏时勾选从哪些收藏夹取消。
- **稍后再看**:和收藏夹一样分拣,卡片上标出在 B站看到了哪里。
- **已出分拣范围**:视频离开了你勾选的所有收藏夹(在 B站取消收藏、移到没勾选的收藏夹、收藏夹被删、或你取消勾选),它的 AI 判断、AI 总结、备注和标签先放这里,不会悄悄丢掉。打开时逐个问 B站这个视频还在哪些收藏夹里(和 B站收藏按钮弹出的是同一份信息,不读没勾选收藏夹的内容),上方页签分「全部」「已取消收藏」「在未勾选收藏夹」「已失效」。也可以按 AI 判断、标签、看完了筛选,选中后收藏到任意收藏夹,或导出后清理。
- **已出分拣范围**:视频离开了你勾选的所有收藏夹(在 B站取消收藏、移到没勾选的收藏夹、收藏夹被删、或你取消勾选),它的 AI 判断、AI 总结、备注和标签先放这里,不会悄悄丢掉。打开时逐个问 B站这个视频还在哪些收藏夹里(和 B站收藏按钮弹出的是同一份信息,不读没勾选收藏夹的内容),上方页签分「全部」「已取消收藏」「在未勾选收藏夹」「已失效」。也可以按 AI 判断、标签、看完了筛选,选中后收藏到任意收藏夹,或导出后清理。「全部」和「已取消收藏」下面有「最近取消收藏」:每个勾选收藏夹最近取消收藏的视频,点「重新收藏」放回原来的收藏夹,整批取消收藏的可以「这批全部重新收藏」。
- **从 B站刷新**:打开收藏夹时核对 B站的变化,只补有差别的部分。点「刷新」重新读取整个收藏夹。刷新结果列出新增、来自其他收藏夹、已移除和已失效的视频。
- **已失效**:B站把视频换成占位信息时,保留原来的标题、封面、UP 主和简介。被 B站隐藏的失效视频进「已出分拣范围」,标「已失效(B站已隐藏)」。有失效视频时出现「已失效 N」筛选,配合全选可以一次处理。
- 同时开两个分拣台标签页也不会互相覆盖。弹窗和设置页的分拣台入口会先切到已经打开的那个;摘读记录的入口(弹窗、侧边栏、设置页)也一样。
Expand Down
12 changes: 7 additions & 5 deletions extension/history/history.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const unmarked = new Map();
// has no U). A tag edit leaves the tag filters at once, as on the 分拣台; U brings the record back.
const starEdited = new Set();
let starFilter = false;
let tagState = ""; // 第 2 行 "" | "untagged" | "tagged", on the record's own tags (the tag row's), as the 分拣台's
let tagState = ""; // 标签行 "" | "untagged" | "tagged", on the record's own tags (the tag row's), as the 分拣台's
const tagFilter = new Set();
const selected = new Set();
let pickAnchor = null; // the last picked key: Shift picks the run from it
Expand Down Expand Up @@ -416,10 +416,11 @@ function renderHead(all, groups) {
const recent = UI.aiRecentChip({ attrs: 'data-f="recent"', xAttrs: 'data-f="recent-done"', n: visibleGroups({ recent: false }, all).filter((g) => recentKeys.has(readingKey(g))).length, on: recentFilter, who: "记录", check: RECENT_CHECK });
const anyTag = visibleGroups({ tags: [] }, all);
const tagN = visibleGroups({ tagged: "" }, all);
els.stateRow.innerHTML = UI.labeledRow("状态", UI.stateGroup("全部", UI.filterBtn('data-f="all"', "全部", visibleGroups({ star: false, recent: false, tagged: "" }, all).length, !starFilter && !recentFilter && !tagState)) + UI.stateGroup("星标", UI.filterBtn('data-f="star"', "★ 星标", stars, starFilter)) + UI.tagStateGroup((k) => `data-f="tagstate:${k}"`, (k) => tagN.filter((g) => (g.mark?.tags?.length > 0) === (k === "tagged")).length, tagState) + recent);
els.stateRow.innerHTML = UI.labeledRow("状态", UI.stateGroup("全部", UI.filterBtn('data-f="all"', "全部", visibleGroups({ star: false, recent: false }, all).length, !starFilter && !recentFilter)) + UI.stateGroup("星标", UI.filterBtn('data-f="star"', "★ 星标", stars, starFilter)) + recent);
// As the 分拣台's row 4: only the tags the listed records carry, and the ones turned on (so they can be turned off).
const carrying = (id) => anyTag.filter((g) => g.mark?.tags?.includes(id)).length;
els.tagRow.innerHTML = UI.labeledRow("标签", marks.tags.filter((t) => carrying(t.id) || tagFilter.has(t.id)).map((t) => UI.tagFilterChip(`data-f="tag:${esc(t.id)}"`, { name: t.name, color: t.color, n: carrying(t.id), on: tagFilter.has(t.id), what: "记录" })).join(""));
const tagHead = UI.tagRowHead({ allAttrs: 'data-f="tagall"', allN: visibleGroups({ tagged: "", tags: [] }, all).length, on: Boolean(tagState || tagFilter.size), attrs: (k) => `data-f="tagstate:${k}"`, n: (k) => tagN.filter((g) => (g.mark?.tags?.length > 0) === (k === "tagged")).length, cur: tagState });
els.tagRow.innerHTML = UI.labeledRow("标签", tagHead + marks.tags.filter((t) => carrying(t.id) || tagFilter.has(t.id)).map((t) => UI.tagFilterChip(`data-f="tag:${esc(t.id)}"`, { name: t.name, color: t.color, n: carrying(t.id), on: tagFilter.has(t.id), what: "记录" })).join(""));
// 「选中的 N 个」 counts records, as the dialog's 选中 does.
const picked = aiRecords("selected").size || recordsOf(all.filter((g) => selected.has(g.key))).size;
els.tagActs.innerHTML = UI.tagButtons({ manageAttrs: 'data-f="manage"', aiAttrs: 'data-f="ai"', who: "记录", running: aiRunning(), pending: Boolean(AI.proposal), picked, aiReason: aiReason(all) }) + SETTINGS_BTN;
Expand Down Expand Up @@ -902,9 +903,10 @@ els.filters.addEventListener("click", (event) => {
if (f === "settings") return void openSettings();
if (f === "recent-done") return void clearRecent();
if (f === "select-all") UI.toggleAll(visibleGroups().map((g) => g.key), selected);
else if (f === "all") {
starFilter = recentFilter = false;
else if (f === "all") starFilter = recentFilter = false;
else if (f === "tagall") {
tagState = "";
tagFilter.clear();
} else if (f?.startsWith("tagstate:")) tagState = tagState === f.slice(9) ? "" : f.slice(9);
else if (f === "recent") recentFilter = !recentFilter;
else if (f === "star") starFilter = !starFilter;
Expand Down
12 changes: 8 additions & 4 deletions extension/history/history.selftest.js
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ assert.ok(/<dialog id="confirmDialog"[\s\S]*id="confirmTitle"[\s\S]*id="confirmB
// H4: 已打标签 with none tagged lists nothing and says why, as the other filters do.
headClick("tagstate:tagged");
assert.ok(html.includes("没有符合筛选的记录"), html);
headClick("all");
headClick("tagall");
// H9: 删除 removes AI conversations only. A selected video with only a 备注 is not counted, and the tooltip says so;
// with none that has a conversation it is disabled with the reason.
const NOTES = ctx.MdgLimits.KEYS.triageNotes;
Expand Down Expand Up @@ -785,13 +785,17 @@ assert.ok(/<dialog id="confirmDialog"[\s\S]*id="confirmTitle"[\s\S]*id="confirmB
await wait(20);
assert.ok(entry(pageKey) && els.status.textContent === "已撤销:去掉「读过」", els.status.textContent);
await filter(`tag:${tag.id}`);
// 未打标签 / 已打标签 as on the 分拣台, by the record's own tags; 全部 clears them.
// 未打标签 / 已打标签 head the tag row as on the 分拣台, by the record's own tags; the tag row's 全部 clears them,
// the 状态 row's 全部 does not.
await filter("tagstate:untagged");
assert.ok(entry("untagged") && /data-f="tagstate:untagged" aria-pressed="true">未打标签 \d+</.test(els.stateRow.innerHTML), els.stateRow.innerHTML);
assert.ok(entry("untagged") && /data-f="tagstate:untagged" aria-pressed="true">未打标签 \d+</.test(els.tagRow.innerHTML), els.tagRow.innerHTML);
assert.ok(!els.stateRow.innerHTML.includes("tagstate:"), els.stateRow.innerHTML);
await filter("tagstate:tagged");
assert.ok(!entry("untagged") && entry(videoKey) && entry(pageKey), html);
await filter("all");
assert.ok(entry("untagged") && !els.stateRow.innerHTML.includes('aria-pressed="true">已打标签'), els.stateRow.innerHTML);
assert.ok(!entry("untagged") && els.tagRow.innerHTML.includes('aria-pressed="true">已打标签'), els.tagRow.innerHTML);
await filter("tagall");
assert.ok(entry("untagged") && !els.tagRow.innerHTML.includes('aria-pressed="true">已打标签') && /data-f="tagall" aria-pressed="true">全部 /.test(els.tagRow.innerHTML), els.tagRow.innerHTML);
await search("读过");
assert.ok(entry(videoKey) && entry(pageKey), html);
await search("分拣标签");
Expand Down
11 changes: 11 additions & 0 deletions extension/triage/dev/mock-chrome.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,17 @@
[gone[4].bvid]: { item: gone[4], at: now - day, from: [f("1001", "稍后-AI", now - 6 * day), f("1002", "学习", now - day)] },
[gone[5].bvid]: { item: gone[5], at: now - 3600000, from: [f("1001", "稍后-AI", now - 8 * day), f("1002", "学习", now - 4 * day), f("1003", "默认收藏夹", now - 3600000)] }
};
// 稍后-AI: a 保留'd video with only 粗看 (粗看完成, 「✦ 字幕细看」 on its card), 细看 without a subtitle (no subtitle at
// all, and hidden from a logged-out user).
const coarse = (it, verdict, reason) => (store[`triage_title_${it.bvid}`] = { verdict, reason, confidence: "high" });
const fine = (it, verdict, reason, extra = {}) => {
coarse(it, verdict, reason);
store[`triage_analysis_${it.bvid}`] = { bvid: it.bvid, status: "done", source: "subtitle", oneLiner: `${it.title.slice(0, 12)}:先讲原理再给示例。`, points: ["讲清了基本概念", "给出可运行的示例", "总结了常见误区"], verdict, reason, model: "mock-model", analyzedAt: now - day, ...extra };
};
coarse(ai[0], "keep", "标题显示为系统教程");
store.triage_kept = { [ai[0].bvid]: { action: "keep", at: now - day } };
fine(ai[3], "unsure", "只有简介和评论,拿不准", { source: "meta" });
fine(ai[4], "keep", "原理讲得清楚", { source: "meta", needLogin: true });
}

// ?demo swaps in the README screenshot data: B站-style titles and covers, every step filled, tags, 待播, notes and
Expand Down
Loading
Loading