From 088277406bdb90936ef34fc07d33edb75dccf382 Mon Sep 17 00:00:00 2001
From: PathGao <42336971+PathGao@users.noreply.github.com>
Date: Tue, 10 Nov 2026 14:08:21 +0800
Subject: [PATCH 01/17] =?UTF-8?q?=E5=88=86=E6=8B=A3=E5=8F=B0=EF=BC=9A?=
=?UTF-8?q?=E9=A1=B5=E7=AD=BE=E5=8F=AA=E6=8C=89=20AI=20=E5=81=9A=E5=88=B0?=
=?UTF-8?q?=E5=93=AA=E4=B8=80=E6=AD=A5=E5=88=86=EF=BC=8C=E4=BF=9D=E7=95=99?=
=?UTF-8?q?=E5=92=8C=20AI=20=E5=88=A4=E6=96=AD=E5=88=86=E5=BC=80=E7=AD=9B?=
=?UTF-8?q?=E9=80=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 删掉「处理完成」页签。保留的视频留在它 AI 深度的页签里;取消收藏的视频不在任何页签里,去「已出分拣范围」。
- 第 3 行加「未决定 · 已保留」一组;值得留 / 可清理 / 拿不准只按 AI 判断筛,和决定无关。按 AI 判断的批量按钮仍只处理未决定的。
- 已保留的视频也能字幕细看:选中的照跑,「字幕细看下一批」和「按新标准重新细看下一批」跳过已保留的。
- 「最近取消收藏」和「这批全部重新收藏」挪到「已出分拣范围」,列出每个勾选收藏夹的取消收藏记录,重新收藏回原来的收藏夹。
---
extension/triage/fav-ai.js | 9 +-
extension/triage/fav-base.js | 53 ++++----
extension/triage/fav-folders.js | 8 +-
extension/triage/fav-page.js | 9 +-
extension/triage/fav-render.js | 72 ++++++-----
extension/triage/fav-tags.js | 2 +-
extension/triage/fav-writes.js | 42 +++----
extension/triage/triage.html | 8 +-
extension/triage/triage.selftest.js | 187 ++++++++++++++++++++--------
9 files changed, 248 insertions(+), 142 deletions(-)
diff --git a/extension/triage/fav-ai.js b/extension/triage/fav-ai.js
index d39561b7..1a72ebbc 100644
--- a/extension/triage/fav-ai.js
+++ b/extension/triage/fav-ai.js
@@ -12,7 +12,7 @@ const { TITLES_PER_REQUEST } = TriageUi;
// What 标题粗看 counts and sends: the first coarseBatchSize() 未分析 videos listed now (search and filters applied),
// not the hidden ones. A selection wins, like 细看's nextBatch.
-const coarseOpen = () => S.items.filter((it) => stageOf(it) === "none" && !S.coarseFailed.has(it.bvid) && passFilter(it));
+const coarseOpen = () => S.items.filter((it) => inStage(it, "none") && !S.coarseFailed.has(it.bvid) && passFilter(it));
const stage1Pending = () => pickBatch(coarseOpen(), coarseBatchSize(), "none");
// list: 未分析 by default; 重新粗看 passes the cards whose 粗看 used an older 判断标准. Their old results stay on the
@@ -32,7 +32,7 @@ async function runStage1(list = stage1Pending(), redo = false) {
};
// In its own folder the live step decides (a card may have been sorted meanwhile); elsewhere only the result does.
const pending = () =>
- list.filter((it) => !S.coarseFailed.has(it.bvid) && !it.invalid && due(it) && (S.mediaId !== folder || ["none", "coarse"].includes(stageOf(it))));
+ list.filter((it) => !S.coarseFailed.has(it.bvid) && !it.invalid && due(it) && (S.mediaId !== folder || (["none", "coarse"].includes(stageOf(it)) && !isUnfaved(it))));
const run = (S.stage1 = { mediaId: folder });
const name = runName("coarse", redo);
const running = runs.run("stage1", {
@@ -91,12 +91,13 @@ function otherFolderToast(run, name, r) {
// ---------- AI stage 2: subtitle group ----------
// group: a 细看 run, which judges by its own items (the open folder's list is empty while a folder loads, and may be
-// another folder's); 已处理 is read only in its own folder.
+// another folder's). 保留 does not matter (the next batch leaves 已保留 out on its own: skipKept); 取消收藏 in its own
+// folder (read only there) does, the video has left.
const needsAnalysis = (b, group = null) => {
const own = !group || group.mediaId === String(S.mediaId);
const it = group ? group.items.get(b) : S.itemMap.get(b);
const a = S.analyses[b];
- return it && !it.invalid && !(own && isProcessed(b)) && a?.status !== "done" && a?.status !== "error";
+ return it && !it.invalid && !(own && isUnfaved(it)) && a?.status !== "done" && a?.status !== "error";
};
// redo: 细看 these again under the current criteria; each keeps its old result until the new one arrives.
diff --git a/extension/triage/fav-base.js b/extension/triage/fav-base.js
index b3f4e57f..4f36f5e0 100644
--- a/extension/triage/fav-base.js
+++ b/extension/triage/fav-base.js
@@ -10,13 +10,12 @@ const THROTTLES = {
AI_THROTTLED: MdgRuns.AI_HOLD
};
const SYNC_MIN_GAP_MS = 60 * 1000;
-// Progress tabs: how far a video has been looked at. The AI class is a filter inside a tab, never a tab.
-// 阅览 sits apart after them.
+// Progress tabs: how far the AI has looked at a video, whatever the user decided. The AI class and the decision are
+// filters inside a tab, never a tab. 阅览 sits apart after them.
const STAGES = [
["none", "未分析"],
["coarse", "粗看完成"],
- ["fine", "细看完成"],
- ["done", "处理完成"]
+ ["fine", "细看完成"]
];
const READ_TAB = "阅览全部";
const NEXT_BATCH = "下一批"; // a step's button and the cards it will send
@@ -62,10 +61,11 @@ const UNDO = globalThis.TriageUi.UNDO;
// 待播's remove: the list's button, E on a card in it, the card's ✓ 待播.
const BASKET_REMOVE = "从待播移除";
const BASKET_REMOVED = `已${BASKET_REMOVE}${UNDO}`;
-const RECENT_UNFAV = "最近取消收藏"; // 处理完成's list of the unfavorited, where each can be 重新收藏
+const RECENT_UNFAV = "最近取消收藏"; // 已出分拣范围's list of every chosen folder's 取消收藏 records, each can be 重新收藏
+const RECENT_UNFAV_AT = `「${REMOVED_NAME}」的「${RECENT_UNFAV}」`;
// 取消收藏 or 保留 on a video already unfavorited: its card's buttons and the D / S keys say the same.
const UNFAVED = "已经取消收藏了";
-const KEEP_UNFAVED = `${UNFAVED},要保留先在「${RECENT_UNFAV}」里${REFAV}`;
+const KEEP_UNFAVED = `${UNFAVED},要保留先在${RECENT_UNFAV_AT}里${REFAV}`;
const TOVIEW = "toview"; // 稍后再看, listed by triage-bg as one more folder
// The fixed AI classes, shown as chips inside 粗看完成 and 细看完成.
// The ids are the badge color classes too.
@@ -104,7 +104,7 @@ const S = {
kept: {},
removedCount: 0,
folderIntro: {}, // { [mediaId]: Bilibili folder intro }, read with the folder list
- folderDecisions: {}, // 所有收藏夹 only: { [mediaId]: that folder's decisions }
+ folderDecisions: {}, // 所有收藏夹 and 已出分拣范围 only: { [mediaId]: that folder's decisions }
loadAll: null, // 所有收藏夹 only: { lists: { [mediaId]: { items, at } }, check, checkTotal, queue, phase (LOAD_ALL_STEPS), error, partial }
tags: [],
folderCriteria: {},
@@ -114,6 +114,7 @@ const S = {
finishedFilter: false, // 看完了
dupFilter: false, // 所有收藏夹 only: 重复收藏
tagState: "", // row 3: "" | "untagged" | "tagged"
+ decisionFilter: "", // row 3: "" | "undecided" | "kept"
aiRecent: {},
sortBy: {}, // triage_sort
aiRecentFilter: false, // AI 刚打的
@@ -219,8 +220,8 @@ const judgeOf = (it) => {
// current one), so after the criteria change the old results can be redone.
const isStale = (r, crit = criteria()) => Boolean(r) && (r.criteria ?? crit) !== crit;
const judged = () => S.mediaId && S.mediaId !== REMOVED;
-const staleCoarse = () => (judged() ? S.items.filter((it) => !it.invalid && stageOf(it) === "coarse" && isStale(S.titleRes[it.bvid], criteriaFor(homeOf(it)))) : []);
-const staleFine = () => (judged() ? S.items.filter((it) => !it.invalid && stageOf(it) === "fine" && isStale(S.analyses[it.bvid], criteriaFor(homeOf(it)))) : []);
+const staleCoarse = () => (judged() ? S.items.filter((it) => !it.invalid && inStage(it, "coarse") && isStale(S.titleRes[it.bvid], criteriaFor(homeOf(it)))) : []);
+const staleFine = () => (judged() ? S.items.filter((it) => !it.invalid && inStage(it, "fine") && isStale(S.analyses[it.bvid], criteriaFor(homeOf(it)))) : []);
const $ = (id) => document.getElementById(id);
@@ -498,8 +499,10 @@ function tagIdsOf(bvid) {
return t && (!folders || folders.includes(t.folder));
});
}
-// Only 取消收藏 / 保留 finish a video; tags and notes never do.
+// Only 取消收藏 / 保留 are decisions; tags and notes never are.
const isProcessed = (bvid) => Boolean(S.decisions[bvid]);
+const isKept = (it) => S.decisions[it.bvid]?.action === "keep";
+const isUnfaved = (it) => S.decisions[it.bvid]?.action === "unfav";
// 粗看 and 细看 runs keep going in their own folder (mediaId) after another one opens; these are the open folder's.
const ownStage1 = () => Boolean(runs.get("stage1")) && S.stage1.mediaId === String(S.mediaId);
@@ -556,6 +559,7 @@ const isDup = (it) => it.folders?.length > 1;
function passFilter(it, skip = "") {
const on = (g) => skip !== g && (skip !== "states" || g === "tags");
if (on("dup") && S.dupFilter && !isDup(it)) return false;
+ if (on("decision") && S.decisionFilter && (isKept(it) ? "kept" : "undecided") !== S.decisionFilter) return false;
if (on("seen") && S.finishedFilter && !isFinished(it)) return false;
if (on("recent") && S.aiRecentFilter && !aiRecentSet().has(it.bvid)) return false;
if ((S.mediaId === REMOVED || on("kind")) && S.kindFilter && kindOf(it) !== S.kindFilter) return false;
@@ -571,25 +575,26 @@ function passFilter(it, skip = "") {
let queryMemo = ["", []];
const queryWords = () => (queryMemo[0] === S.query ? queryMemo[1] : (queryMemo = [S.query, S.query.toLowerCase().split(/\s+/).filter(Boolean)])[1]);
-// Which AI steps have run, not what they said. Invalid videos (可清理) can never be 细看'd, so they stop at 粗看;
-// a failed 细看 stays where its 粗看 put it.
+// Which AI steps have run, not what they said nor what the user decided. Invalid videos (可清理) can never be 细看'd,
+// so they stop at 粗看; a failed 细看 stays where its 粗看 put it.
function stageOf(it) {
const b = it.bvid;
- if (isProcessed(b)) return "done";
if (S.analyses[b]?.status === "done") return "fine";
if (it.invalid || VERDICTS[S.titleRes[b]?.verdict]) return "coarse";
return "none";
}
+// A tab's videos before rows 3 and 4. 阅览 (and 已出分拣范围) is every video, whatever its step. A video unfavorited
+// here is in no tab: it stays in S.items only so U can undo it, and 已出分拣范围 lists it.
+const inStage = (it, tab) => !isUnfaved(it) && (tab === "read" || stageOf(it) === tab);
function inTab(it, tab) {
- // 阅览 (and 已出分拣范围) is every video of the folder, whatever its step.
- if (tab !== "read" && stageOf(it) !== tab) return false;
+ if (!inStage(it, tab)) return false;
const f = S.classFilter[tab];
return !f || f === "all" || classOf(it) === f;
}
-// The filter chip a card falls under: the user's decision once there is one (as on the card), else the AI verdict.
-const classOf = (it) => (isProcessed(it.bvid) ? (S.decisions[it.bvid].action === "keep" ? "kept" : "unfav") : verdictOf(it).verdict);
+// The AI-verdict chip a card falls under, decided or not: 已保留 + 可清理 finds kept videos the AI said to drop.
+const classOf = (it) => verdictOf(it).verdict;
const failedAnalysis = (b) => S.analyses[b]?.status === "error";
// A 粗看 that failed on a card still without one: it stays in 未分析 with the failure and a retry, like a failed 细看.
@@ -675,10 +680,12 @@ const stage1Label = (batch) => batchLabel(STEP_NAME.coarse, batch, coarseOpen(),
const groupLabel = (batch) => batchLabel(STEP_NAME.fine, batch, fineOpen(), "coarse");
// Why it is disabled when the selection has nothing it can run (why: 「选中的都不能字幕细看」 and the like).
const pickReason = (why, batch, tab) => (!picking(tab) || batch.length ? "" : pickedShown() ? why : UI.HIDDEN_ALL);
+// 细看's next batch leaves 已保留 out to save AI cost; a selection runs whatever was decided.
+const skipKept = (open, tab) => (picking(tab) ? open : open.filter((it) => !isKept(it)));
// What 按新标准重新粗看 / 细看 can send: the stale ones its tab lists now (search and filters applied), from any tab
// (the 判断标准 dialog offers 重新粗看 too); redoState takes one run's batch of them.
const redoCoarseOpen = () => staleCoarse().filter((it) => inTab(it, "coarse") && passFilter(it));
-const redoFineOpen = () => staleFine().filter((it) => inTab(it, "fine") && passFilter(it));
+const redoFineOpen = () => skipKept(staleFine().filter((it) => inTab(it, "fine") && passFilter(it)), "fine");
// 按新标准重新粗看 / 细看's batch, label and why it is disabled: row 3's button and the 「判断标准改了」 dialog's.
const redoState = (verb, open, cap, tab) => {
const batch = pickBatch(open, cap, tab);
@@ -692,9 +699,9 @@ const redoOf = (step) => (step === "coarse" ? redoState("粗看", redoCoarseOpen
const visibleSelected = () => selectedIn(visibleItems());
function stageCounts() {
- const c = { none: 0, coarse: 0, fine: 0, done: 0, read: 0 };
+ const c = { none: 0, coarse: 0, fine: 0, read: 0 };
for (const it of S.items) {
- if (!passFilter(it)) continue;
+ if (isUnfaved(it) || !passFilter(it)) continue;
c[stageOf(it)]++;
c.read++;
}
@@ -703,8 +710,10 @@ function stageCounts() {
// The earliest step that still has videos.
const currentStage = (c) => STAGES.find(([k]) => c[k])?.[0] || "none";
-// The 细看 batch: the first fineBatchSize() selected cards of 粗看完成, otherwise its first fineBatchSize(), 拿不准 / low confidence first.
+// The 细看 batch: the first fineBatchSize() selected cards of 粗看完成, otherwise its first fineBatchSize() not 已保留,
+// 拿不准 / low confidence first.
const fineOpen = () => S.items.filter((it) => inTab(it, "coarse") && passFilter(it) && needsAnalysis(it.bvid)).sort((x, y) => unsureFirst(x) - unsureFirst(y));
+const fineBatch = () => pickBatch(skipKept(fineOpen(), "coarse"), fineBatchSize(), "coarse");
function nextBatch() {
- return pickBatch(fineOpen(), fineBatchSize(), "coarse").map((it) => it.bvid);
+ return fineBatch().map((it) => it.bvid);
}
diff --git a/extension/triage/fav-folders.js b/extension/triage/fav-folders.js
index 79c4acf9..7f595821 100644
--- a/extension/triage/fav-folders.js
+++ b/extension/triage/fav-folders.js
@@ -223,7 +223,7 @@ async function openFolder(mediaId) {
S.selected.clear();
S.tagFilter.clear();
S.finishedFilter = S.dupFilter = false;
- S.tagState = "";
+ S.tagState = S.decisionFilter = "";
S.aiRecentFilter = false;
S.kindFilter = "";
S.undo = agentSteps();
@@ -485,6 +485,8 @@ async function openRemoved(run = S.view.current) {
if (!run.alive) return false;
const rec = got[K.removed] || {};
const decisionsByFolder = Object.fromEntries(keys.map((k) => [k.slice(KEYS.triageDecisionsPrefix.length), got[k]]));
+ // 最近取消收藏 lists the chosen folders' 取消收藏 records.
+ for (const f of S.folders) S.folderDecisions[String(f.id)] = decisionsByFolder[String(f.id)] || {};
setItems(Object.values(rec)
.sort((x, y) => y.at - x.at)
.map(({ item, at, movedTo, hidden, inFolder, from }) => ({ ...item, removedAt: at, movedTo, hidden, inFolder, from: from || inferFrom(decisionsByFolder, item.bvid, folderName) })));
@@ -590,7 +592,7 @@ async function cleanRemoved(list) {
if (!list.length) return;
if (favBusyReason()) return toast(favBusyReason(), true);
const one = list.length === 1 ? `《${shortTitle(list[0])}》` : `这 ${list.length} 个视频`;
- const body = `
删除${esc(one)}的 AI 判断、AI 总结、备注、分拣标签、保留标记、待播记录和取消收藏记录,无法撤销。摘读记录里的星标和标签会保留。删除后不能再从「处理完成」重新收藏。 ${UI.CLEAN_BACKUP.ask}
`;
+ const body = `
删除${esc(one)}的 AI 判断、AI 总结、备注、分拣标签、保留标记、待播记录和取消收藏记录,无法撤销。摘读记录里的星标和标签会保留。删除后不能再从「${RECENT_UNFAV}」重新收藏。 ${UI.CLEAN_BACKUP.ask}
`;
if (!(await askConfirm(`清理${one}?`, body, `清理 ${list.length} 个`, { danger: true }))) return;
return lockRecords(async () => {
const bvids = list.map((it) => it.bvid);
@@ -688,7 +690,7 @@ function rebuildAll() {
const items = mergeFolderItems(lists);
const decisions = {};
for (const it of items) if (S.kept[it.bvid]) decisions[it.bvid] = S.kept[it.bvid];
- // Keep videos unfavorited from every folder this session, so they sit in 处理完成 and U can undo.
+ // Keep videos unfavorited from every folder this session, so U can undo (no tab lists them).
const have = new Set(items.map((it) => it.bvid));
for (const it of S.items) {
if (!have.has(it.bvid) && S.decisions[it.bvid]?.action === "unfav") {
diff --git a/extension/triage/fav-page.js b/extension/triage/fav-page.js
index 16fcca4d..d5889430 100644
--- a/extension/triage/fav-page.js
+++ b/extension/triage/fav-page.js
@@ -153,12 +153,14 @@ function followShared(changes) {
else delete S.analyses[b];
changed = true;
}
- // 取消收藏 records: the open folder's, as openFolder builds them; in 所有收藏夹 those of the loaded folders.
+ // 取消收藏 records: the open folder's, as openFolder builds them; in 所有收藏夹 those of the loaded folders; in
+ // 已出分拣范围 every chosen folder's (最近取消收藏).
for (const [key, c] of Object.entries(changes)) {
const id = /^triage_decisions_(.+)$/.exec(key)?.[1];
if (!id || ownEcho(key, c)) continue;
const d = c.newValue || {};
if (id === String(S.mediaId)) S.decisions = { ...S.kept, ...d };
+ else if (S.mediaId === REMOVED && S.folders.some((f) => String(f.id) === id)) S.folderDecisions[id] = d;
else if (S.loadAll && S.folderDecisions[id]) {
S.folderDecisions[id] = d;
rebuildAll();
@@ -378,6 +380,7 @@ function bindEvents() {
if (b.matches("[data-airecent-done]")) endAiRecent();
else if (b.matches("[data-states-all]")) pickState("all");
else if (b.dataset.classFilter) pickState("class", b.dataset.classFilter);
+ else if (b.dataset.decision) pickState("decision", b.dataset.decision);
else if (b.matches("[data-finishedfilter]")) pickState("seen");
else if (b.matches("[data-dupfilter]")) pickState("dup");
else if (b.dataset.tagstate) pickState("tagged", b.dataset.tagstate);
@@ -455,9 +458,9 @@ function bindEvents() {
if (e.target.closest("[data-pick-folders]")) return openSettings(false, true);
if (e.target.closest("[data-refresh]")) return el.refreshBtn.click();
const refav = e.target.closest("[data-refav]");
- if (refav) return fav.refav(refav.dataset.refav);
+ if (refav) return fav.refav(refav.dataset.refav, refav.dataset.folder);
const refavAll = e.target.closest("[data-refav-batch]");
- if (refavAll) return fav.refavBatch(Number(refavAll.dataset.refavBatch));
+ if (refavAll) return fav.refavBatch(Number(refavAll.dataset.refavBatch), refavAll.dataset.folder);
const picked = e.target.closest(".card[data-bvid]");
if (picked && UI.cardPickClick(e)) {
e.preventDefault();
diff --git a/extension/triage/fav-render.js b/extension/triage/fav-render.js
index 37b86e09..2ec50ce7 100644
--- a/extension/triage/fav-render.js
+++ b/extension/triage/fav-render.js
@@ -100,11 +100,8 @@ function tick() {
function renderTabs() {
const c = stageCounts();
- // 处理完成 says what 保留 and 取消收藏 did (one only marks the video here, the other changed B站): in its tooltip, with
- // the dot while it is open.
- const tip = (key) => (key === "done" ? DONE_TIP : "");
const tab = (key, label, cls, n) =>
- ``;
+ ``;
const steps = STAGES.map(([key, label]) => tab(key, label, c[key] ? "step" : "step zero", c[key]));
// Say what the box searches: the open folder (or 所有收藏夹 / 已出分拣范围) and the tab you are on.
const tabName = Object.fromEntries([...STAGES, ["read", READ_TAB]])[S.tab];
@@ -116,7 +113,7 @@ function renderTabs() {
// Row 4: only the tags the tab on screen has (or a filter that is on, so it can be turned off), each with how many
// videos listed here carry it (row 3 and search applied, row 4's own picks left out).
- const here = S.mediaId === REMOVED || S.tab === "read" ? S.items : S.items.filter((it) => stageOf(it) === S.tab);
+ const here = S.items.filter((it) => inStage(it, S.tab));
const hereTags = new Set(here.flatMap((it) => tagIdsOf(it.bvid)));
const chips = tagChips().filter((c) => c.ids.some((id) => hereTags.has(id) || S.tagFilter.has(id)));
const counted = here.filter((it) => inTab(it, S.tab) && passFilter(it, "tags")).map((it) => tagIdsOf(it.bvid));
@@ -170,14 +167,14 @@ function criteriaBtn() {
}
// Whether anything in row 3 is on (全部 is pressed when not).
-const statesOn = (t = S.tab) => (S.classFilter[t] && S.classFilter[t] !== "all") || Boolean(S.tagState || S.finishedFilter || S.dupFilter || S.aiRecentFilter || (S.mediaId !== REMOVED && S.kindFilter));
+const statesOn = (t = S.tab) => (S.classFilter[t] && S.classFilter[t] !== "all") || Boolean(S.tagState || S.decisionFilter || S.finishedFilter || S.dupFilter || S.aiRecentFilter || (S.mediaId !== REMOVED && S.kindFilter));
// Row 3: what the system knows about each video, in groups split by a thin rule. One pick per group (a second click
// clears it), groups AND together, and each group counts with its own pick left out. 全部 clears row 3 only. The fixed
// groups come first (a 0 stays, dimmed); the chips that exist only sometimes (看完了, 已失效, AI 刚打的) go last and
// leave at 0, so their coming and going moves nothing else.
function stateRowHtml(t = S.tab) {
const removed = S.mediaId === REMOVED;
- const base = removed || t === "read" ? S.items : S.items.filter((it) => stageOf(it) === t);
+ const base = S.items.filter((it) => inStage(it, t));
const cf = S.classFilter[t];
const inClass = (it) => !cf || cf === "all" || classOf(it) === cf;
const cnt = (skip, fn) => base.filter((it) => (skip === "class" || skip === "states" || inClass(it)) && passFilter(it, skip) && fn(it)).length;
@@ -186,10 +183,12 @@ function stateRowHtml(t = S.tab) {
// 所有收藏夹 only, right after 全部 (asked for there); like 已失效 it leaves at 0 unless on.
const dupN = S.mediaId === ALL ? cnt("dup", isDup) : 0;
if (dupN || S.dupFilter) groups.push(group("重复收藏", UI.filterBtn('data-dupfilter title="只看同时在两个以上收藏夹里的视频"', "重复收藏", dupN, S.dupFilter)));
- // 未分析 has no AI verdict to filter by. 阅览 also holds decided videos: they leave the AI classes for 已保留.
+ // The user's decision and the AI's verdict are separate groups: 已保留 + 可清理 finds kept videos the AI said to drop.
+ // 未分析 has no AI verdict to filter by.
+ const decisions = [["undecided", "未决定", ""], ["kept", ACTION_LABEL.keep, KEEP_TIP]];
+ groups.push(group("按决定筛选", decisions.map(([k, label, tip]) => UI.filterBtn(`data-decision="${k}"${tip ? ` title="${esc(tip)}"` : ""}`, label, cnt("decision", (it) => isKept(it) === (k === "kept")), S.decisionFilter === k)).join("")));
if (t !== "none") {
- const classes = [...Object.entries(VERDICTS), ...(t === "read" ? [["kept", ACTION_LABEL.keep]] : [])];
- groups.push(group("按 AI 判断筛选", classes.map(([k, label]) => UI.filterBtn(`data-class-filter="${k}"`, label, cnt("class", (it) => classOf(it) === k), cf === k)).join("")));
+ groups.push(group("按 AI 判断筛选", Object.entries(VERDICTS).map(([k, label]) => UI.filterBtn(`data-class-filter="${k}"`, label, cnt("class", (it) => classOf(it) === k), cf === k)).join("")));
}
groups.push(UI.tagStateGroup((k) => `data-tagstate="${k}"`, (k) => cnt("tagged", (it) => (tagIdsOf(it.bvid).length > 0) === (k === "tagged")), S.tagState));
const seenN = cnt("seen", isFinished);
@@ -209,9 +208,10 @@ function pickState(group, value = "") {
if (group === "all") {
S.classFilter[t] = "all";
S.finishedFilter = S.dupFilter = S.aiRecentFilter = false;
- S.tagState = "";
+ S.tagState = S.decisionFilter = "";
if (S.mediaId !== REMOVED) S.kindFilter = "";
} else if (group === "class") S.classFilter[t] = S.classFilter[t] === value ? "all" : value;
+ else if (group === "decision") S.decisionFilter = S.decisionFilter === value ? "" : value;
else if (group === "kind") S.kindFilter = S.kindFilter === value ? "" : value;
else if (group === "seen") S.finishedFilter = !S.finishedFilter;
else if (group === "dup") S.dupFilter = !S.dupFilter;
@@ -225,7 +225,7 @@ function renderListHeader(list) {
// running a step or a batch keeps the rest selected, so you see what you just ran (DESIGN 筛选和计数).
for (const b of [...S.selected]) {
const it = S.itemMap.get(b);
- if (!it || (t !== "read" && stageOf(it) !== t)) S.selected.delete(b);
+ if (!it || !inStage(it, t)) S.selected.delete(b);
}
const all = S.mediaId === ALL;
// Search, row 3 or row 4 on: how many the tab lists.
@@ -257,10 +257,10 @@ function renderListHeader(list) {
const groupBtn = (cls) => {
const g = ownGroup() && runs.get("group");
if (g) return headBtn("group", `${PILL_BTN.group[g.stopping ? 2 : 1]} ${g.done}/${g.total}`, "primary", g.stopping ? PILL_BTN.group[3] : "");
- const batch = pickBatch(fineOpen(), fineBatchSize(), "coarse");
+ const batch = fineBatch();
const label = groupLabel(batch);
// inTab applies the AI-class chip, which is a filter too.
- const none = S.items.some((it) => stageOf(it) === "coarse" && needsAnalysis(it.bvid)) ? "当前筛选下没有要细看的视频" : "没有要细看的视频";
+ const none = fineOpen().length ? KEPT_NOT_NEXT : S.items.some((it) => inStage(it, "coarse") && needsAnalysis(it.bvid)) ? "当前筛选下没有要细看的视频" : "没有要细看的视频";
return headBtn("group", label, cls, aiBusyReason() || pickReason("选中的都不能字幕细看", batch, "coarse") || (batch.length ? "" : none), "", "", false, true);
};
// Pausing waits for the current batch or video; until then the button says so and takes no second click.
@@ -287,7 +287,7 @@ function renderListHeader(list) {
if (ownStage1()) html += stage1Pause();
else {
const pending = stage1Pending();
- const none = S.items.some((it) => stageOf(it) === "none" && !S.coarseFailed.has(it.bvid)) ? "当前筛选下没有要粗看的视频" : "没有要粗看的视频";
+ const none = S.items.some((it) => inStage(it, "none") && !S.coarseFailed.has(it.bvid)) ? "当前筛选下没有要粗看的视频" : "没有要粗看的视频";
html += headBtn("stage1", stage1Label(pending), "primary", aiBusyReason() || pickReason("选中的都不能标题粗看", pending, "none") || (pending.length ? "" : none), "", "", false, true);
}
} else if (t === "coarse") {
@@ -329,9 +329,11 @@ function renderListHeader(list) {
renderTagButtons();
}
+// 已出分拣范围's 最近取消收藏: every chosen folder's 取消收藏 records, newest first, as [bvid, record, folder]. A video
+// still in another chosen folder is listed too: 重新收藏 puts it back into the folder it left.
function recentUnfavs() {
- return Object.entries(S.decisions)
- .filter(([b, d]) => d.action === "unfav" && d.aid && !S.itemMap.has(b))
+ return Object.entries(S.folderDecisions)
+ .flatMap(([f, map]) => Object.entries(map || {}).filter(([, d]) => d.action === "unfav" && d.aid).map(([b, d]) => [b, d, f]))
.sort((x, y) => y[1].at - x[1].at)
.slice(0, MdgLimits.TRIAGE_RECENT_UNFAV);
}
@@ -341,21 +343,22 @@ function recentUnfavHtml() {
if (!list.length) return "";
const { busy, pending } = fav.state();
const wait = UI.busyText(busy);
+ const from = (f) => `${esc(`「${folderName(f)}」`)}`;
const rows = list
- .map(([b, d]) => {
+ .map(([b, d, f]) => {
const title = esc(d.title || b);
- return `
`;
})
.join("");
- // One line per batch 取消收藏 of two or more still listed, newest first.
- const batches = [...new Set(list.map(([, d]) => d.batch).filter(Boolean))]
- .map((batch) => ({ batch, n: unfavBatchItems(batch).length }))
+ // One line per batch 取消收藏 of two or more still listed, newest first (a batch is one folder's, 所有收藏夹's one per folder).
+ const batches = [...new Map(list.filter(([, d]) => d.batch).map(([, d, f]) => [`${f}:${d.batch}`, { batch: d.batch, f }])).values()]
+ .map((x) => ({ ...x, n: unfavBatchItems(x.batch, x.f).length }))
.filter((x) => x.n > 1);
const batchRows = batches
- .map(({ batch, n }) => {
- const going = unfavBatchItems(batch).some((it) => pending(it.bvid));
- return `
`;
@@ -369,10 +372,10 @@ window.addEventListener("beforeunload", (e) => {
e.returnValue = true;
});
-// The records one batch 取消收藏 left in the open folder, all of them (the list shows only the latest).
-const unfavBatchItems = (batch) =>
- Object.entries(S.decisions)
- .filter(([b, d]) => d.action === "unfav" && d.batch === batch && d.aid && !S.itemMap.has(b))
+// The records one batch 取消收藏 left in folder f, all of them (the list shows only the latest).
+const unfavBatchItems = (batch, f) =>
+ Object.entries(S.folderDecisions[f] || {})
+ .filter(([, d]) => d.action === "unfav" && d.batch === batch && d.aid)
.map(([bvid, d]) => favRef({ bvid, ...d }));
let listDeferred = false;
@@ -384,7 +387,7 @@ function renderList() {
listDeferred = false;
const list = visibleItems();
renderListHeader(list);
- const recent = S.tab === "done" ? recentUnfavHtml() : "";
+ const recent = S.mediaId === REMOVED && (!S.kindFilter || S.kindFilter === "unfav") ? recentUnfavHtml() : "";
if (!S.items.length) {
const empty = S.mediaId === REMOVED ? `
${noVideosText()}
` : S.loadAll?.queue.length ? loadingHtml()
: S.readError ? UI.emptyState("无法读取这个收藏夹", S.readError, REFRESH_EMPTY)
@@ -393,7 +396,7 @@ function renderList() {
return;
}
if (!list.length) {
- const empty = { none: "没有未分析的视频", coarse: "没有粗看完成的视频", fine: "没有细看完成的视频", done: "还没有处理过的视频" };
+ const empty = { none: "没有未分析的视频", coarse: "没有粗看完成的视频", fine: "没有细看完成的视频" };
// 已出分拣范围's kind is its tab (statesOn leaves it out), not a filter.
const kind = S.mediaId === REMOVED && KINDS.find(([k]) => k === S.kindFilter)?.[1];
const text = UI.noMatch(S.query, statesOn() || S.tagFilter.size, "视频") || (kind ? `没有${kind}的视频` : empty[S.tab]) || "这里没有视频";
@@ -414,7 +417,7 @@ function renderList() {
marked = new Set(bvids);
word = ownGroup() ? "本批" : bvids.some((b) => S.selected.has(b)) ? "已选中" : NEXT_BATCH;
}
- const expanded = S.tab === "fine" || S.tab === "read" || S.tab === "done";
+ const expanded = S.tab === "fine" || S.tab === "read";
const failedList = list.filter((it) => failedIn(S.tab, it));
const failed = failedList.length;
const failedHead = failedHeadHtml(failedList);
@@ -444,7 +447,8 @@ const verdictBadge = (b, v, low = v.low) =>
? `${ANALYZING}`
: `${VERDICTS[v.verdict] ? `AI` : ""}${esc(verdictLabel(v.verdict))}${low ? " · 低置信" : ""}`;
const KEEP_TIP = `保留标记${MdgErrors.TEXT.localOnly}`;
-const DONE_TIP = `已保留:${KEEP_TIP}。\n已取消收藏:已从 B站收藏夹移走,最近的操作可按 U 撤销。`;
+// 字幕细看下一批 has none to send but 已保留 ones.
+const KEPT_NOT_NEXT = `已保留的视频不进${NEXT_BATCH},选中后可以细看`;
// A verdict an AI tool wrote over MCP (agent-writes.js), on the card's meta line in place of the basis.
const AGENT_VERDICT = "Agent 判断";
diff --git a/extension/triage/fav-tags.js b/extension/triage/fav-tags.js
index 3e8dec3e..a4b3adb1 100644
--- a/extension/triage/fav-tags.js
+++ b/extension/triage/fav-tags.js
@@ -164,7 +164,7 @@ async function saveCriteria() {
const shown = open.length < stale ? `(当前筛选下列出 ${open.length} 个)` : "";
const ok = await askConfirm(
"判断标准改了",
- `
「粗看完成」里有 ${stale} 个视频是按旧标准粗看的${shown},要按新标准重新粗看吗?
已细看和已处理的视频不动;细看完成页可以另外按新标准重新细看。
`,
+ `
「粗看完成」里有 ${stale} 个视频是按旧标准粗看的${shown},要按新标准重新粗看吗?
已细看的视频不动;细看完成页可以另外按新标准重新细看。
`,
label,
{ reason: why }
);
diff --git a/extension/triage/fav-writes.js b/extension/triage/fav-writes.js
index 7d1b9785..34983c36 100644
--- a/extension/triage/fav-writes.js
+++ b/extension/triage/fav-writes.js
@@ -196,26 +196,26 @@ function cutText(error, stopped, { n = null, left = null, there = "" } = {}) {
if (stopped) return `${stopWords("撤销", { done: n ?? 0, total: (n ?? 0) + (left ?? 1) })},${there ? `未完成的在${there}里` : "可再按 U 撤销"}`;
return "";
}
-// U on a batch 取消收藏 in one folder: back into it. Elsewhere the ones left stay under its 最近取消收藏.
+// U on a batch 取消收藏 in one folder: back into it. Elsewhere the ones left stay in 已出分拣范围's 最近取消收藏.
function unfavManyStep(mediaId, items) {
return favStep(items.length, refavAsk(items.length), async () => {
const { n, rest, error, stopped } = await refavInto(mediaId, items);
const here = S.mediaId === mediaId;
const where = here ? "" : `「${folderName(mediaId)}」`;
- const cut = cutText(error, stopped, { n, left: rest.length, there: here ? "" : `它的${RECENT_UNFAV}` });
+ const cut = cutText(error, stopped, { n, left: rest.length, there: here ? "" : RECENT_UNFAV_AT });
if (cut && here) pushUndo(unfavManyStep(mediaId, rest));
toast(cut ? `${where}${cut}` : `${where}已重新收藏 ${n} 个`, Boolean(error));
return redraw();
});
}
// U on 所有收藏夹's batch 取消收藏: each video back in every folder it left (gone: folder → [{ bvid, aid, ogv }]). Elsewhere
-// the ones left stay under each folder's 最近取消收藏.
+// the ones left stay in 已出分拣范围's 最近取消收藏.
function goneStep(items, gone) {
return favStep(items.length, refavAsk(items.length), async () => {
const { rest, error, stopped } = await refavGone(gone);
const here = S.mediaId === ALL;
const left = items.filter((x) => Object.values(rest).some((list) => list.some((y) => y.bvid === x.bvid)));
- const cut = cutText(error, stopped, { n: items.length - left.length, left: left.length, there: here ? "" : `各收藏夹的${RECENT_UNFAV}` });
+ const cut = cutText(error, stopped, { n: items.length - left.length, left: left.length, there: here ? "" : RECENT_UNFAV_AT });
if (cut && here) pushUndo(goneStep(left, rest));
toast(cut ? `${here ? "" : `「${folderName(ALL)}」`}${cut}` : `已重新收藏 ${items.length} 个`, Boolean(error));
return redraw();
@@ -352,15 +352,15 @@ const fav = {
pushUndo(goneStep(videos.map(favRef), gone));
tell(`已从 ${Object.keys(gone).length} 个收藏夹取消收藏 ${videos.length} 个视频${UNDO}`, `${UNDO}已完成的`);
} else if (done && all) {
- tell(`已从 ${Object.keys(gone).length} 个收藏夹取消收藏,可在各收藏夹的${RECENT_UNFAV}里${REFAV}`, `,已完成的可在各收藏夹的${RECENT_UNFAV}里${REFAV}`);
+ tell(`已从 ${Object.keys(gone).length} 个收藏夹取消收藏,可在${RECENT_UNFAV_AT}里${REFAV}`, `,已完成的可在${RECENT_UNFAV_AT}里${REFAV}`);
} else if (done && S.mediaId === mediaId) {
// By folder, not by view: the records are the folder's, so back in it after a switch the batch is still undoable.
const went = new Set(r.keys);
pushUndo(unfavManyStep(mediaId, list.filter((it) => went.has(it.bvid)).map(favRef)));
tell(`${KIND_NAME.unfav} ${done} 个${UNDO}`, `${UNDO}已完成的`);
} else if (done) {
- // Elsewhere they are saved under their folder and listed in its 最近取消收藏, where they can be re-favorited.
- tell(`「${folderName(mediaId)}」已取消收藏 ${done} 个,可在它的${RECENT_UNFAV}里${REFAV}`, `,已完成的可在它的${RECENT_UNFAV}里${REFAV}`);
+ // Elsewhere they are saved under their folder and listed in 已出分拣范围's 最近取消收藏, where they can be re-favorited.
+ tell(`「${folderName(mediaId)}」已取消收藏 ${done} 个,可在${RECENT_UNFAV_AT}里${REFAV}`, `,已完成的可在${RECENT_UNFAV_AT}里${REFAV}`);
}
render();
},
@@ -443,29 +443,29 @@ const fav = {
render();
},
- // 最近取消收藏's 重新收藏 of one video.
- async refav(bvid) {
- const d = S.decisions[bvid];
+ // 最近取消收藏's 重新收藏 of one video, into the folder it left (mediaId). Back in a chosen folder, it leaves 已出分拣范围.
+ async refav(bvid, mediaId) {
+ const d = S.folderDecisions[mediaId]?.[bvid];
if (!d?.aid || W.busy()) return;
- const mediaId = S.mediaId;
- const { error } = await refavInto(String(mediaId), [favRef({ bvid, ...d })]);
+ const { error } = await refavInto(mediaId, [favRef({ bvid, ...d })]);
if (error) return toast(`重新收藏失败:${error}`, true);
- if (mediaId !== S.mediaId) return;
- toast(`已重新收藏《${d.title || bvid}》`);
- quickSync({ force: true });
+ toast(`已重新收藏《${d.title || bvid}》到「${folderName(mediaId)}」`);
+ if (S.mediaId === REMOVED) await dropRemoved([bvid]);
+ render();
},
- // 最近取消收藏's 这批全部重新收藏: every record one batch 取消收藏 left here.
- async refavBatch(batch) {
- const items = unfavBatchItems(batch);
+ // 最近取消收藏's 这批全部重新收藏: every record one batch 取消收藏 left in mediaId.
+ async refavBatch(batch, mediaId) {
+ const items = unfavBatchItems(batch, mediaId);
if (!items.length || W.busy()) return;
- const mediaId = String(S.mediaId);
const { n, rest, error, stopped } = await refavInto(mediaId, items);
- const where = S.mediaId === mediaId ? "" : `「${folderName(mediaId)}」`;
+ const where = `「${folderName(mediaId)}」`;
if (error) toast(`${where}${cutShort(REFAV, n, error, `,剩余 ${rest.length} 个可再点${MdgErrors.LABEL.retry}`)}`, true);
else if (stopped) toast(`${where}${stopWords(REFAV, { done: n, total: items.length })}`);
else toast(`${where}已重新收藏这批 ${n} 个`);
- if (!where) quickSync({ force: true });
+ const back = new Set(rest.map((it) => it.bvid));
+ if (S.mediaId === REMOVED) await dropRemoved(items.map((it) => it.bvid).filter((b) => !back.has(b)));
+ render();
},
// What the page draws and checks: busy (the running batch's name, "" when none), run (it, once planned: favRun),
diff --git a/extension/triage/triage.html b/extension/triage/triage.html
index be0544fa..927eb044 100644
--- a/extension/triage/triage.html
+++ b/extension/triage/triage.html
@@ -323,13 +323,13 @@
快捷键
-
四步分拣
+
三步分拣
未分析:点「标题粗看下一批」,AI 只看标题和简介,快、省。
-
粗看完成:点「字幕细看下一批」读字幕,给出 AI 总结和要点,拿不准和低置信的排在前面;有把握的也可以直接按 AI 判断批量取消收藏或保留。
-
细看完成:按 AI 判断批量取消收藏或保留,也可以一个个按 D / S。
-
处理完成:取消收藏或保留过的视频;最近取消收藏的可以重新收藏。
+
粗看完成:点「字幕细看下一批」读字幕,给出 AI 总结和要点,拿不准和低置信的排在前面,已保留的不进下一批;有把握的也可以直接按 AI 判断批量取消收藏或保留。
+
细看完成:按 AI 判断批量取消收藏或保留,也可以一个个按 D / S。卡片上的「✦ 重新细看」可以写上侧重再细看一次。
+
页签只看 AI 做到了哪一步。保留的视频留在原来的页签,用「未决定 / 已保留」筛选;卡片上的「✦ 标题粗看」「✦ 字幕细看」只跑这一个。取消收藏的视频去「已出分拣范围」,那里的「最近取消收藏」可以重新收藏。
分拣分两层:第一层是收藏夹的判断标准,AI 按它给出 AI 判断(值得留 / 可清理 / 拿不准),决定留不留;第二层是你的标签,按每个标签的说明给视频分类,决定怎么找。 判断标准在「判断标准」按钮里写,标签说明在「标签管理」里写。
AI 判断是每步里的筛选。判断标准不写时,AI 从收藏夹名和简介推测用途。 每个收藏夹最多 个标签:按 T 逐个打,按 I 让 AI 打标签(AI 每次最多新建 个)。两个上限都在收藏夹设置里改。 AI 只在本页打开时运行。
待播:分拣时按 E(或卡片上的「+ 待播」)把想先看或要核对的视频加入待播,可以跨收藏夹,按加入先后排。已加入的卡片显示「✓ 待播」,再按 E 或点它从待播移除。 点一条在内置播放框里播放,点「移除」从待播移除,「清空」一次移除全部。这几种移除都可以按 U 撤销。 不影响收藏和处理结果。
diff --git a/extension/triage/triage.selftest.js b/extension/triage/triage.selftest.js
index 02bdfacd..f71380a5 100644
--- a/extension/triage/triage.selftest.js
+++ b/extension/triage/triage.selftest.js
@@ -243,18 +243,42 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) =
assert.deepStrictEqual(plain(t.S.decisions), {}, "folder B's decisions are untouched");
assert.strictEqual(t.S.undo.length, 0, "folder B's undo stack gets no entry for folder A");
- // 最近取消收藏 re-favorites a whole batch in one go (all 45, though it lists only the latest), a single one alone.
- openFake("A", [], structuredClone(savedA));
- t.S.decisions.BVsolo = { action: "unfav", at: 1, aid: 7, title: "单个" };
- assert.ok(t.recentUnfavHtml().includes(`data-refav-batch="${savedA.BV0.batch}"`) && t.recentUnfavHtml().includes("批量取消收藏 45 个"));
+ // R3: 处理完成 is gone; 已出分拣范围's 最近取消收藏 lists every chosen folder's records and re-favorites a whole batch
+ // into the folder it left in one go (all 45, though it lists only the latest), a single one alone.
+ const openRecent = (folderDecisions) => {
+ openFake("removed", []);
+ t.S.folderDecisions = folderDecisions;
+ for (const [f, d] of Object.entries(folderDecisions)) store[t.K.decisions(f)] = structuredClone(d);
+ };
+ openRecent({ A: { ...structuredClone(savedA), BVsolo: { action: "unfav", at: 1, aid: 7, title: "单个" } } });
+ assert.ok(t.recentUnfavHtml().includes(`data-refav-batch="${savedA.BV0.batch}" data-folder="A"`) && t.recentUnfavHtml().includes("批量取消收藏 45 个"));
const refavAids = [];
- handlers["triage-refav"] = (m) => (refavAids.push(m.aid), { ok: true });
- await t.fav.refavBatch(savedA.BV0.batch);
+ handlers["triage-refav"] = (m) => (refavAids.push([m.mediaId, m.aid]), { ok: true });
+ await t.fav.refavBatch(savedA.BV0.batch, "A");
assert.strictEqual(refavAids.length, 45, "the batch, not the single one");
- assert.deepStrictEqual(Object.keys(store[t.K.decisions("A")]), [], "the stored records are gone");
- assert.ok(t.S.decisions.BVsolo && toasts.at(-1) === "已重新收藏这批 45 个", toasts.at(-1));
+ assert.ok(refavAids.every(([f]) => f === "A"), "back into the folder it left");
+ assert.deepStrictEqual(Object.keys(store[t.K.decisions("A")]), ["BVsolo"], "the batch's stored records are gone");
+ assert.ok(t.S.folderDecisions.A.BVsolo && toasts.at(-1) === "「A」已重新收藏这批 45 个", toasts.at(-1));
assert.ok(!t.recentUnfavHtml().includes("data-refav-batch"), "no batch line once it is re-favorited");
store[t.K.decisions("A")] = structuredClone(savedA);
+ // A folder no longer draws it (its tabs list no unfavorited video); 已出分拣范围 draws it on 全部 and 已取消收藏 only.
+ {
+ t.el.list.querySelectorAll = () => [];
+ openFake("A", [item(1)], structuredClone(savedA));
+ t.S.tab = "read";
+ t.renderList();
+ assert.ok(!t.el.list.innerHTML.includes("最近取消收藏"), "no 最近取消收藏 in a folder");
+ openRecent({ A: structuredClone(savedA) });
+ t.S.items = [item(1)];
+ t.S.itemMap = new Map([["BV1", t.S.items[0]]]);
+ t.S.tab = "read";
+ for (const [kind, shown] of [["", true], ["unfav", true], ["out", false]]) {
+ t.S.kindFilter = kind;
+ t.renderList();
+ assert.strictEqual(t.el.list.innerHTML.includes("最近取消收藏"), shown, kind);
+ }
+ t.S.kindFilter = "";
+ }
// Back in the folder mid-run with a record read before the batch's last write: nothing written earlier is lost.
openFake("A2", many);
@@ -268,10 +292,10 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) =
assert.strictEqual(t.S.undo.length, 1, "back in its folder, the batch is undoable with U");
// U7: after a reload the unfavorited videos are listed and re-favorited into their own folder.
- openFake("A", many.slice(40), savedA);
+ openRecent({ A: structuredClone(savedA) });
const recent = t.recentUnfavs();
- assert.strictEqual(recent.length, 40);
- assert.ok(t.recentUnfavHtml().includes('data-refav="BV0"'));
+ assert.strictEqual(recent.length, 45);
+ assert.ok(t.recentUnfavHtml().includes('data-refav="BV0" data-folder="A"'));
handlers["triage-refav"] = () => ({ ok: true });
t.syncFolder = async () => true;
// Waits out the settle window (__TRIAGE_SETTLE_MS) and drops a read deferred meanwhile.
@@ -280,14 +304,14 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) =
vm.runInContext("clearTimeout(deferredRead?.timer); deferredRead = null;", ctx);
};
await settled();
- await t.fav.refav("BV7");
+ await t.fav.refav("BV7", "A");
assert.deepStrictEqual(plain(sent.at(-1)), { type: "triage-refav", mediaId: "A", aid: 1007 });
- assert.ok(!("BV7" in store[t.K.decisions("A")]), "re-favorited video leaves the recent list");
+ assert.ok(!("BV7" in store[t.K.decisions("A")]) && !t.recentUnfavHtml().includes('data-refav="BV7"'), "re-favorited video leaves the recent list");
// X10: the folder counts as written, so the read after it waits for B站 to catch up.
assert.ok(t.fav.state().writing("A"), "the read after a 重新收藏 waits for B站 to catch up");
await settled();
const capped = Object.fromEntries(Array.from({ length: 60 }, (_, i) => [`BVx${i}`, { action: "unfav", at: i, aid: i, title: "x" }]));
- openFake("C", [], capped);
+ openRecent({ C: capped });
assert.strictEqual(t.recentUnfavs().length, 50, "the recent list is capped");
assert.strictEqual(t.recentUnfavs()[0][0], "BVx59", "newest first");
@@ -322,11 +346,11 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) =
};
const limitedOnce = (log) => (m) => (log.push(m.aid || m.aids), log.length === 1 ? { ok: false, code: "THROTTLED", error: "412" } : { ok: true });
const refavs = [];
- openFake("R", [], { BV60: { action: "unfav", at: 1, aid: 1060, title: "视频60" } });
+ openRecent({ R: { BV60: { action: "unfav", at: 1, aid: 1060, title: "视频60" } } });
handlers["triage-refav"] = limitedOnce(refavs);
- await t.fav.refav("BV60");
+ await t.fav.refav("BV60", "R");
assert.deepStrictEqual(plain(refavs), [1060, 1060]);
- assert.ok(!("BV60" in store[t.K.decisions("R")]) && toasts.at(-1) === "已重新收藏《视频60》", toasts.at(-1));
+ assert.ok(!("BV60" in store[t.K.decisions("R")]) && toasts.at(-1) === "已重新收藏《视频60》到「R」", toasts.at(-1));
const moves = [];
t.S.allFolders = t.S.folders = [{ id: "R", title: "源" }, { id: "T", title: "目标" }];
t.S.included = ["R", "T"];
@@ -338,7 +362,7 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) =
assert.deepStrictEqual(plain(moves), [[1061, 1062], [1061, 1062]]);
assert.deepStrictEqual(plain([...t.S.selected]), ["BV61", "BV62"], "copied, still listed: they stay selected");
assert.strictEqual(toasts.at(-1), "已复制 2 个到「目标」");
- assert.deepStrictEqual(pills, ["重新收藏中 0/1 · B站限流卡住,0:01 后重试", "复制到「目标」0/2 · B站限流卡住,0:01 后重试"]);
+ assert.deepStrictEqual(pills, ["重新收藏中 0/1(R) · B站限流卡住,0:01 后重试", "复制到「目标」0/2 · B站限流卡住,0:01 后重试"]);
Object.assign(t.S, saved);
t.askTransfer = realAsk;
t.renderStatus = realStatus;
@@ -404,9 +428,9 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) =
handlers["triage-unfav"] = () => ({ ok: true });
await t.fav.unfav("BV2");
await t.fav.unfavMany([three[0]]);
- Object.assign(t.S.decisions, { BV8: { action: "unfav", at: 2, aid: 1008, title: "视频8" }, BV9: { action: "unfav", at: 1, aid: 1009, title: "视频9" } });
+ t.S.folderDecisions = { D: { BV8: { action: "unfav", at: 2, aid: 1008, title: "视频8" }, BV9: { action: "unfav", at: 1, aid: 1009, title: "视频9" } } };
const h = holdNext("triage-refav");
- const running = t.fav.refav("BV8");
+ const running = t.fav.refav("BV8", "D");
await ticks();
let asked = 0;
const savedAsk = t.askConfirm;
@@ -414,7 +438,7 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) =
const [sentBefore, toastsBefore] = [sent.length, toasts.length];
await t.fav.unfavMany([three[2]]);
await t.fav.transfer([three[2]]);
- await t.fav.refav("BV9");
+ await t.fav.refav("BV9", "D");
assert.deepStrictEqual([asked, sent.length, toasts.length], [0, sentBefore, toastsBefore], "nothing asked, sent or told");
await t.undo();
assert.deepStrictEqual([asked, sent.length, t.S.undo.length, toasts.at(-1)], [0, sentBefore, 2, "重新收藏还在跑,等它完成再撤销"]);
@@ -426,7 +450,7 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) =
await t.undo();
assert.deepStrictEqual([t.S.undo.length, toasts.at(-1)], [1, "已重新收藏 1 个"]);
const h2 = holdNext("triage-refav");
- const again = t.fav.refav("BV9");
+ const again = t.fav.refav("BV9", "D");
await ticks();
await t.undo();
assert.deepStrictEqual([t.S.undo.length, toasts.at(-1)], [1, "重新收藏还在跑,等它完成再撤销"]);
@@ -444,7 +468,8 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) =
const running = t.fav.unfavMany(five.slice(0, 3));
await ticks();
const sentBefore = sent.length;
- await t.fav.refav("BV9");
+ t.S.folderDecisions = { H: { BV9: { action: "unfav", at: 1, aid: 1009, title: "视频9" } } };
+ await t.fav.refav("BV9", "H");
await t.fav.unfav("BV43");
assert.strictEqual(sent.length, sentBefore, "nothing sent");
assert.ok(/data-refav="BV9"[^>]* disabled title="批量取消收藏还在跑,等它完成"/.test(t.recentUnfavHtml()), t.recentUnfavHtml());
@@ -554,7 +579,7 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) =
assert.strictEqual(asked[2], "取消收藏 3 个");
assert.deepStrictEqual(calls, [["W1", [1060, 1061], "取消收藏中「夹1」 0/4"], ["W2", [1061, 1062], "取消收藏中「夹2」 2/4"]]);
assert.deepStrictEqual(plain(vids.map((it) => it.folders)), [[], [], ["W3"]]);
- assert.deepStrictEqual(plain(Object.keys(t.S.decisions)), ["BV60", "BV61"], "out of every folder: 处理完成");
+ assert.deepStrictEqual(plain(Object.keys(t.S.decisions)), ["BV60", "BV61"], "out of every folder: unfavorited, in no tab");
assert.deepStrictEqual([Object.keys(store[t.K.decisions("W1")]), Object.keys(store[t.K.decisions("W2")])], [["BV60", "BV61"], ["BV61", "BV62"]]);
// C39: W1's BV61 and W2's BV62 left the video in a chosen folder (W2, W3); W2's BV61 was its last.
const els = (f, b) => store[t.K.decisions(f)][b].elsewhere;
@@ -586,7 +611,7 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) =
await t.undo();
assert.ok(!t.S.kept.BV61 && !t.S.kept.BV62);
// U cut short by a failure: still in 所有收藏夹, the rest is the next U; after another folder opened, the rest stays
- // in each folder's 最近取消收藏 and the open folder gets no step.
+ // in 已出分拣范围's 最近取消收藏 and the open folder gets no step.
for (const away of [false, true]) {
const again = [{ ...item(63), folders: ["W1"] }, { ...item(64), folders: ["W2"] }];
openFake("all", again);
@@ -594,7 +619,7 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) =
await t.fav.unfavMany(again);
handlers["triage-refav"] = (m) => (m.aid === 1064 ? { ok: false, error: "网络错误" } : (away && openFake("E", []), { ok: true }));
await t.undo();
- const want = away ? ["「所有收藏夹」撤销中断(已重新收藏 1 个,剩余 1 个在各收藏夹的最近取消收藏里):网络错误", 0] : ["撤销中断(已重新收藏 1 个,剩余 1 个可再按 U 重试):网络错误", 1];
+ const want = away ? ["「所有收藏夹」撤销中断(已重新收藏 1 个,剩余 1 个在「已出分拣范围」的「最近取消收藏」里):网络错误", 0] : ["撤销中断(已重新收藏 1 个,剩余 1 个可再按 U 重试):网络错误", 1];
assert.deepStrictEqual([toasts.at(-1), t.S.undo.length], want, away ? "elsewhere: told only" : "here: U again");
}
// No folder checked: the dialog's button is off and says why; it is on again for the next dialog.
@@ -690,11 +715,15 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) =
t.S.itemMap.set("BV4", t.S.items[3]);
t.S.basket.push({ bvid: "BV4" });
t.S.selected.clear();
- t.S.selected.add("BV2"); // kept, so not listed in 未分析: a selection hidden there is left out
+ t.S.selected.add("BV2"); // kept, so 未决定 hides it: a selection hidden there is left out
t.S.selected.add("BV3");
Object.assign(t.S, { tab: "none", titleRes: {}, analyses: { BVgone: { status: "done", oneLiner: "一句话", points: ["要点"] } }, notes: { BV3: { text: " 我的笔记 " } }, videoTags: {} });
const scope = (s) => plain(t.writeScopeItems(s, true).map((it) => it.bvid));
+ assert.deepStrictEqual(scope("filter"), ["BV1", "BV2", "BV3"], "kept, BV2 is still in 未分析: the tabs go by AI depth");
+ t.S.decisionFilter = "undecided";
assert.deepStrictEqual([scope("selected"), scope("all"), scope("filter")], [["BV3"], ["BV1", "BV2", "BV3"], ["BV1", "BV3"]]);
+ t.S.decisionFilter = "";
+ t.S.selected.delete("BV2");
// A filter hides part of the selection: the bar counts only what is listed, and the hidden one stays selected.
t.S.selected.add("BV1");
t.S.query = "视频3";
@@ -709,11 +738,16 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) =
const hiddenAll = sight();
assert.ok(hiddenAll.includes("已选中 2 个都被筛选隐藏") && hiddenAll.includes('aria-label="AI 打标签选中的 2 个 (I)"'), hiddenAll);
t.S.query = "";
- // The selection belongs to the tab: the kept BV2 is not in 未分析, so it leaves the selection, and so does one kept now.
+ // The selection belongs to the tab: 保留 leaves a video in the tab of its AI depth, so it stays selected; an
+ // unfavorited one leaves every tab, and the selection.
await t.keepOne("BV1");
t.renderListHeader(t.visibleItems());
+ assert.deepStrictEqual(plain([...t.S.selected]), ["BV3", "BV1"]);
+ assert.ok((t.el.listHeader.innerHTML + t.el.favSel.innerHTML).includes("已选中 2 个") && !(t.el.listHeader.innerHTML + t.el.favSel.innerHTML).includes("被筛选隐藏"), (t.el.listHeader.innerHTML + t.el.favSel.innerHTML));
+ t.S.decisions.BV1 = { action: "unfav", at: 1 };
+ t.renderListHeader(t.visibleItems());
assert.deepStrictEqual(plain([...t.S.selected]), ["BV3"]);
- assert.ok((t.el.listHeader.innerHTML + t.el.favSel.innerHTML).includes("已选中 1 个") && !(t.el.listHeader.innerHTML + t.el.favSel.innerHTML).includes("被筛选隐藏"), (t.el.listHeader.innerHTML + t.el.favSel.innerHTML));
+ t.S.decisions.BV1 = t.S.kept.BV1;
delete t.S.kept.BV1;
assert.deepStrictEqual(plain(t.writeScopeItems("all", false).map((it) => it.bvid)), ["BV1", "BV2", "BV3", "BV4"]);
// 一篇摘录 carries what each video's own note carries (MdgNote.triageExtras), read from storage as the note is: the
@@ -808,7 +842,7 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) =
assert.strictEqual(t.verdictOf({ bvid: "BVnone", invalid: true }).reason, "视频已失效");
// Progress tabs follow which AI steps ran: no 粗看 → 未分析, a 粗看 class or invalid → 粗看完成,
- // a done 细看 → 细看完成, processed → 处理完成. Class and confidence never move a card.
+ // a done 细看 → 细看完成. Class, confidence and the user's decision never move a card.
Object.assign(t.S, { analyses: {}, titleRes: {}, decisions: {}, videoTags: {}, tags: [] });
const st = (patch = {}, it = v) => {
Object.assign(t.S, patch);
@@ -822,15 +856,23 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) =
assert.strictEqual(st({ analyses: { BVv: { status: "error" } }, titleRes: { BVv: { verdict: "unsure" } } }), "coarse", "a failed 细看 stays in 粗看完成 with its retry button");
assert.strictEqual(st({ analyses: {}, titleRes: {} }, { ...v, invalid: true }), "coarse");
assert.strictEqual(t.verdictOf({ ...v, invalid: true }).verdict, "drop", "invalid videos count as 可以删");
- assert.strictEqual(st({ decisions: { BVv: { action: "keep" } } }), "done");
+ assert.strictEqual(st({ decisions: { BVv: { action: "keep" } }, titleRes: { BVv: { verdict: "keep" } } }), "coarse", "保留 leaves a 粗看'd video in 粗看完成");
assert.strictEqual(st({ decisions: {} }, { ...v, invalid: true, bvid: "BVv" }), "coarse");
- // A1: only 取消收藏 / 保留 move a video to 处理完成; tags (T, 批量打标签) and notes do not.
+ // 处理完成 is gone: four tabs, three of them AI depth.
+ assert.deepStrictEqual(plain(vm.runInContext("STAGES", ctx).map(([k]) => k)), ["none", "coarse", "fine"]);
+ assert.ok(!/处理完成/.test(source), "no page code says 处理完成");
+ // A1: tags (T, 批量打标签) and notes move no video either.
const tagged = { tags: [{ id: "t1", name: "x" }], videoTags: { BVv: ["t1"] }, notes: { BVv: { text: "备注", updatedAt: 1 } } };
assert.strictEqual(st({ ...tagged, titleRes: {} }), "none", "a tagged, noted video without 粗看 stays in 未分析");
assert.strictEqual(st({ titleRes: { BVv: { verdict: "unsure", confidence: "high" } } }), "coarse", "tagged 待定 stays in 粗看完成");
assert.strictEqual(st({ analyses: { BVv: { status: "done", verdict: "keep" } } }), "fine", "tagged after 细看 stays in 细看完成");
- assert.strictEqual(st({ decisions: { BVv: { action: "unfav" } } }), "done");
- Object.assign(t.S, { videoTags: {}, tags: [], notes: {} });
+ // An unfavorited video is in no tab (已出分拣范围 has it); 阅览全部 leaves it out too.
+ t.S.decisions = { BVv: { action: "unfav" } };
+ assert.strictEqual(t.stageOf(v), "fine");
+ assert.ok(["none", "coarse", "fine", "read"].every((tab) => !t.inTab(v, tab)), "unfavorited: in no tab");
+ t.S.decisions = { BVv: { action: "keep" } };
+ assert.ok(t.inTab(v, "fine") && t.inTab(v, "read"), "kept: in the tab of its AI depth and in 阅览全部");
+ Object.assign(t.S, { videoTags: {}, tags: [], notes: {}, decisions: {} });
// 细看 batch: first fineBatchSize() open cards of 粗看完成, or the selected ones; 细看完成 buttons follow the selection too.
const pool = Array.from({ length: 12 }, (_, i) => item(200 + i));
@@ -897,10 +939,46 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) =
assert.deepStrictEqual(plain(t.batchList("keep").map((it) => it.bvid)), ["BV200"]);
assert.deepStrictEqual(plain(t.batchList("drop").map((it) => it.bvid)), ["BV200"]);
await t.batchKeep(t.batchList("keep"));
- assert.strictEqual(t.stageOf(pool[0]), "done", "batch 保留 moves it to 处理完成");
+ assert.strictEqual(t.stageOf(pool[0]), "fine", "batch 保留 leaves it in 细看完成");
+ assert.deepStrictEqual(plain(t.visibleItems().map((it) => it.bvid)), ["BV200"], "kept, it still falls under the AI's 拿不准");
+ t.S.selected.clear();
+ assert.deepStrictEqual([t.batchList("keep").length, t.batchList("unsure").length], [0, 0], "verdict batches take only undecided videos");
+ // P2: 未决定 / 已保留 and the AI verdict are separate groups, AND-ed: 已保留 + 可清理 finds kept videos the AI said to drop.
+ t.S.analyses.BV201 = { status: "done", verdict: "drop" };
+ t.S.analyses.BV202 = { status: "done", verdict: "drop" };
+ t.S.decisions.BV201 = { action: "keep", at: 1 };
+ Object.assign(t.S, { decisionFilter: "kept" });
+ t.S.classFilter.fine = "drop";
+ assert.deepStrictEqual(plain(t.visibleItems().map((it) => it.bvid)), ["BV201"]);
+ t.S.decisionFilter = "undecided";
+ assert.deepStrictEqual(plain(t.visibleItems().map((it) => it.bvid)), ["BV202"]);
+ t.S.decisionFilter = "";
+ t.S.classFilter.fine = "all";
+ t.renderListHeader(t.visibleItems());
+ for (const part of [">未决定 1<", ">已保留 2<", ">可清理 2<", ">拿不准 1<", 'data-decision="kept" title="保留标记']) assert.ok(t.el.classFilter.innerHTML.includes(part), part);
+ assert.ok(t.el.classFilter.innerHTML.indexOf("未决定") < t.el.classFilter.innerHTML.indexOf("值得留"), "the decision group comes before the AI verdict");
+ assert.ok((t.el.listHeader.innerHTML + t.el.favSel.innerHTML).includes("取消收藏(AI:可清理)1 个"), "the batch 取消收藏 leaves the kept 可清理 one out");
+ t.pickState("decision", "kept");
+ assert.strictEqual(t.S.decisionFilter, "kept");
+ t.pickState("all");
+ assert.strictEqual(t.S.decisionFilter, "", "全部 clears it");
delete store[t.K.kept];
Object.assign(t.S, { analyses: {}, decisions: {}, kept: {}, classFilter: { coarse: "all", fine: "all" } });
t.S.selected.clear();
+ // P3: a kept video with only 粗看 sits in 粗看完成; 字幕细看下一批 leaves 已保留 out (AI cost), a selection runs it.
+ t.S.tab = "coarse";
+ t.S.titleRes = Object.fromEntries(pool.map((it) => [it.bvid, { verdict: "unsure", confidence: "high" }]));
+ t.S.decisions = { BV200: { action: "keep", at: 1 } };
+ assert.ok(t.inTab(pool[0], "coarse") && vm.runInContext("needsAnalysis", ctx)("BV200"), "kept after 粗看: in 粗看完成 and still 细看-able");
+ assert.ok(!t.nextBatch().includes("BV200") && t.nextBatch().length === 10, "下一批 skips 已保留");
+ t.S.selected.add("BV200");
+ assert.deepStrictEqual(plain(t.nextBatch()), ["BV200"], "selected, a kept video is 细看'd");
+ t.S.selected.clear();
+ t.S.decisions = Object.fromEntries(pool.map((it) => [it.bvid, { action: "keep", at: 1 }]));
+ assert.deepStrictEqual(plain(t.nextBatch()), []);
+ t.renderListHeader(t.visibleItems());
+ assert.ok(t.el.listHeader.innerHTML.includes('disabled title="已保留的视频不进下一批,选中后可以细看"'), t.el.listHeader.innerHTML);
+ Object.assign(t.S, { decisions: {}, titleRes: {} });
// 粗看完成 chips: per-class counts in the tab; a chip narrows the next batch, a selection still wins; each tab keeps its chip.
t.S.tab = "coarse";
t.S.titleRes = Object.fromEntries(pool.map((it, i) => [it.bvid, i < 6 ? { verdict: "unsure", confidence: "high" } : { verdict: i % 2 ? "keep" : "drop", confidence: "low" }]));
@@ -1084,6 +1162,13 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) =
t.S.classFilter.fine = "all";
t.renderListHeader(t.visibleItems());
assert.ok((t.el.listHeader.innerHTML + t.el.favSel.innerHTML).includes("按新标准重新细看下一批 1 个"));
+ // P3: its next batch leaves 已保留 out too; selected, a kept one is redone.
+ t.S.decisions = { BV203: { action: "keep", at: 1 } };
+ assert.deepStrictEqual(plain(vm.runInContext('redoOf("fine")', ctx).batch), [], "下一批 skips the kept stale one");
+ t.S.selected = new Set(["BV203"]);
+ assert.deepStrictEqual(plain(vm.runInContext('redoOf("fine")', ctx).batch.map((it) => it.bvid)), ["BV203"]);
+ t.S.selected.clear();
+ t.S.decisions = {};
// F1: like the step's own button, a selection of stale ones wins. G9: a selection with none stale does not fall back
// to the next batch: the button stays the selection's, disabled with why.
t.S.selected = new Set(["BV204"]);
@@ -1898,7 +1983,7 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) =
assert.deepStrictEqual(Object.keys(store[t.K.kept]), ["BV1"]);
assert.ok(!store[t.K.decisions("A")], "保留 writes no folder record");
t.S.decisions = { ...t.S.kept, ...store[t.K.decisions("B")] };
- assert.strictEqual(t.stageOf(item(1)), "done", "kept in A is kept in B");
+ assert.strictEqual(t.S.decisions.BV1?.action, "keep", "kept in A is kept in B");
await t.undo();
await flush();
assert.deepStrictEqual(plain(store[t.K.kept]), {});
@@ -2077,11 +2162,11 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) =
delete t.S.titleRes.BV1;
}
- // 处理完成 keeps what 细看 found: the one-liner and the points stay on the card.
+ // A kept card in 细看完成 keeps what 细看 found: the one-liner and the points stay on the card.
{
const saved = { tab: t.S.tab, analyses: t.S.analyses };
openFake("A", [item(1)], { BV1: { action: "keep" } });
- Object.assign(t.S, { tab: "done", analyses: { BV1: { status: "done", verdict: "keep", reason: "讲得清楚", oneLiner: "一句话", points: ["要点甲", "要点乙"] } } });
+ Object.assign(t.S, { tab: "fine", analyses: { BV1: { status: "done", verdict: "keep", reason: "讲得清楚", oneLiner: "一句话", points: ["要点甲", "要点乙"] } } });
t.renderList();
for (const part of ["一句话", "要点甲", "要点乙", "讲得清楚", "已保留"]) assert.ok(t.el.list.innerHTML.includes(part), part);
assert.ok(!t.el.list.innerHTML.includes("值得留"), "the decision replaces the verdict label");
@@ -2094,17 +2179,19 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) =
openFake("A", [item(1)]);
}
- // 阅览: a decided video leaves the AI classes for 已保留, the same word its card shows.
+ // 阅览: a decided video keeps its AI class; 已保留 is the decision group's, the same word its card shows.
{
const saved = { tab: t.S.tab, titleRes: t.S.titleRes, classFilter: t.S.classFilter };
openFake("A", [item(1), item(2)], { BV1: { action: "keep" } });
Object.assign(t.S, { tab: "read", titleRes: { BV1: { verdict: "keep" }, BV2: { verdict: "keep" } }, classFilter: { coarse: "all", fine: "all", read: "all" } });
t.renderListHeader(t.visibleItems());
- for (const part of [">全部 2<", ">值得留 1<", ">已保留 1<"]) assert.ok(t.el.classFilter.innerHTML.includes(part), part);
- t.S.classFilter.read = "kept";
+ for (const part of [">全部 2<", ">值得留 2<", ">已保留 1<", ">未决定 1<"]) assert.ok(t.el.classFilter.innerHTML.includes(part), part);
+ assert.ok(!t.el.classFilter.innerHTML.includes('data-class-filter="kept"'), "已保留 is no AI class");
+ t.S.decisionFilter = "kept";
assert.deepStrictEqual(plain(t.visibleItems().map((it) => it.bvid)), ["BV1"]);
+ t.S.decisionFilter = "";
t.S.classFilter.read = "keep";
- assert.deepStrictEqual(plain(t.visibleItems().map((it) => it.bvid)), ["BV2"]);
+ assert.deepStrictEqual(plain(t.visibleItems().map((it) => it.bvid)), ["BV1", "BV2"]);
Object.assign(t.S, saved);
openFake("A", [item(1)]);
}
@@ -2448,9 +2535,9 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) =
const writeRun = hang("write");
assert.strictEqual(leave(), true, "notes export");
await writeRun();
- t.S.decisions.BV9 = { action: "unfav", at: 1, aid: 1009, title: "视频9" };
+ t.S.folderDecisions = { K: { BV9: { action: "unfav", at: 1, aid: 1009, title: "视频9" } } };
const h = holdNext("triage-refav");
- const running = t.fav.refav("BV9");
+ const running = t.fav.refav("BV9", "K");
await ticks();
assert.strictEqual(leave(), true, "a 重新收藏 running");
h.release();
@@ -3236,7 +3323,7 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) =
t.S.selected.clear();
const groups = () => [...row3().matchAll(/role="group" aria-label="([^"]+)"/g)].map((m) => m[1]);
const has = (...parts) => parts.forEach((p) => assert.ok(row3().includes(p), p));
- assert.deepStrictEqual(groups(), ["全部", "按 AI 判断筛选", "按有没有标签", "已失效"], "fixed groups, then 已失效 (conditional) last");
+ assert.deepStrictEqual(groups(), ["全部", "按决定筛选", "按 AI 判断筛选", "按有没有标签", "已失效"], "fixed groups, then 已失效 (conditional) last");
has(">全部 5<", ">值得留 2<", ">可清理 2<", ">拿不准 1<", ">已失效 1<");
assert.ok(row3().startsWith('状态'), "row 3 starts with its name");
t.renderTabs();
@@ -3270,7 +3357,7 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) =
t.S.seenCfg = { on: true, bar: false, mark: true, threshold: 80, style: "badge" };
t.S.seenPct = { BV93: [100, 1] };
t.S.aiRecent = { R: { bvids: ["BV94"] } };
- assert.deepStrictEqual(groups(), ["全部", "按 AI 判断筛选", "按有没有标签", "看完了", "已失效", "AI 刚打的"]);
+ assert.deepStrictEqual(groups(), ["全部", "按决定筛选", "按 AI 判断筛选", "按有没有标签", "看完了", "已失效", "AI 刚打的"]);
t.pickState("class", "unsure");
assert.ok(!row3().includes("data-finishedfilter"), "看完了 at 0 (BV93 is 可清理) is not shown");
t.S.tagFilter.add("rb");
@@ -3279,18 +3366,18 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) =
t.pickState("all");
assert.ok(!t.S.finishedFilter && t.S.classFilter.coarse === "all" && t.S.tagFilter.has("rb"), "全部 clears row 3, not row 4");
t.S.tagFilter.clear();
- // Row 4 counts the videos of the tab, row 3 applied; 未分析 has no AI group; 已保留 is 阅览全部's.
+ // Row 4 counts the videos of the tab, row 3 applied; 未分析 has no AI group; 未决定 / 已保留 is in every tab.
t.pickState("class", "drop");
t.renderTabs();
assert.ok(t.el.tagFilter.innerHTML.includes('甲1') && t.el.tagFilter.innerHTML.includes('乙1'));
t.pickState("all");
t.S.tab = "none";
- assert.deepStrictEqual(groups().filter((g) => g !== "已失效"), ["全部", "按有没有标签"], "未分析: no AI verdict group");
+ assert.deepStrictEqual(groups().filter((g) => g !== "已失效"), ["全部", "按决定筛选", "按有没有标签"], "未分析: no AI verdict group");
t.S.decisions = { BV92: { action: "keep", at: 1 } };
t.S.tab = "read";
- has(">已保留 1<");
+ has(">已保留 1<", ">未决定 5<");
t.S.tab = "coarse";
- assert.ok(!row3().includes("已保留"));
+ has(">已保留 1<", ">未决定 4<");
Object.assign(t.S, { decisions: {}, aiRecent: {}, seenPct: {}, titleRes: {}, tags: [], videoTags: {} });
t.S.seenCfg = { on: false, bar: false, mark: false, threshold: 80, style: "badge" };
}
From 966abbb6d3adfb6683de9b743c3d572a11586e9b Mon Sep 17 00:00:00 2001
From: PathGao <42336971+PathGao@users.noreply.github.com>
Date: Tue, 10 Nov 2026 14:15:55 +0800
Subject: [PATCH 02/17] =?UTF-8?q?=E5=88=86=E6=8B=A3=E5=8F=B0=EF=BC=9A?=
=?UTF-8?q?=E5=8D=A1=E7=89=87=E4=B8=8A=E7=9A=84=20AI=20=E6=8C=89=E9=92=AE?=
=?UTF-8?q?=EF=BC=8C=E9=87=8D=E6=96=B0=E7=BB=86=E7=9C=8B=E5=8F=AF=E4=BB=A5?=
=?UTF-8?q?=E5=86=99=E4=BE=A7=E9=87=8D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 卡片 AI 判断那一行按 AI 做到哪一步给一个按钮:「✦ 标题粗看」「✦ 字幕细看」「✦ 重新细看」,保留过的也有;没有再粗看一次的按钮。和卡片的「重试」一样跑一次,别的 AI 步骤在跑时灰掉写原因。
- 「重新细看」在下面打开一个输入框写侧重(可留空),回车开始、Esc 取消。跳过缓存重新细看,侧重写进提示词并存在细看结果里,卡片灰字写「按「侧重」细看」。新结果回来前旧结果留着。
---
extension/messages.js | 2 +-
extension/triage/fav-ai.js | 32 +++++++++++++---
extension/triage/fav-base.js | 1 +
extension/triage/fav-page.js | 22 ++++++++++-
extension/triage/fav-render.js | 23 ++++++++----
extension/triage/triage-bg.js | 13 ++++---
extension/triage/triage-bg.selftest.js | 14 +++++++
extension/triage/triage.css | 1 +
extension/triage/triage.selftest.js | 52 +++++++++++++++++++++++++-
9 files changed, 139 insertions(+), 21 deletions(-)
diff --git a/extension/messages.js b/extension/messages.js
index 2ad98e17..f4612622 100644
--- a/extension/messages.js
+++ b/extension/messages.js
@@ -91,7 +91,7 @@
"triage-title-get": { from: ["triage/fav-folders.js"], to: TRIAGE_BG, request: ["bvids"], response: data },
"triage-analysis-get": { from: ["triage/fav-folders.js"], to: TRIAGE_BG, request: ["bvids"], response: data },
"triage-classify-titles": { from: ["triage/fav-ai.js"], to: TRIAGE_BG, request: ["items", "criteria", "folder", "aiProviderId"], response: data },
- "triage-analyze": { from: ["triage/fav-ai.js"], to: TRIAGE_BG, request: ["bvid", "force", "criteria", "folder", "aiProviderId"], response: data },
+ "triage-analyze": { from: ["triage/fav-ai.js"], to: TRIAGE_BG, request: ["bvid", "force", "criteria", "folder", "focus", "aiProviderId"], response: data },
"triage-ai-command": { from: ["triage/fav-ai.js"], to: TRIAGE_BG, request: ["instruction", "items", "tags", "maxNewTags", "allowRemove", "aiProviderId"], response: data },
"triage-reading-ai-tag": { from: ["history/history.js"], to: TRIAGE_BG, request: ["instruction", "items", "tags", "maxNewTags", "allowRemove", "aiProviderId"], response: data },
"triage-settings-get": { from: ["history/history.js", "triage/fav-page.js", ...FOLLOW], to: TRIAGE_BG, request: ["aiProviderId"], response: data },
diff --git a/extension/triage/fav-ai.js b/extension/triage/fav-ai.js
index 1a72ebbc..250befa1 100644
--- a/extension/triage/fav-ai.js
+++ b/extension/triage/fav-ai.js
@@ -100,11 +100,14 @@ const needsAnalysis = (b, group = null) => {
return it && !it.invalid && !(own && isUnfaved(it)) && a?.status !== "done" && a?.status !== "error";
};
-// redo: 细看 these again under the current criteria; each keeps its old result until the new one arrives.
-function startGroup(bvids, redo = false) {
+// redo: 细看 these again under the current criteria; each keeps its old result until the new one arrives. name: the
+// run's own name (a card's 重新细看); focus: what the user wants the summary to center on.
+function startGroup(bvids, redo = false, { name = "", focus = "" } = {}) {
if (!bvids.length || S.group) return;
S.group = {
bvids,
+ name,
+ focus,
redo: redo ? new Set(bvids) : null,
mediaId: String(S.mediaId),
items: new Map(bvids.map((b) => [b, S.itemMap.get(b)])),
@@ -115,10 +118,10 @@ function startGroup(bvids, redo = false) {
const groupPending = (group, b) => (group.redo ? group.redo.has(b) : needsAnalysis(b, group));
-async function analyzeOne(bvid, force, { crit, ctx }) {
+async function analyzeOne(bvid, force, { crit, ctx }, focus) {
S.analyzing.add(bvid);
render();
- const r = await send({ type: "triage-analyze", bvid, force, criteria: crit, folder: ctx });
+ const r = await send({ type: "triage-analyze", bvid, force, criteria: crit, folder: ctx, focus });
S.analyzing.delete(bvid);
render();
return r;
@@ -126,7 +129,7 @@ async function analyzeOne(bvid, force, { crit, ctx }) {
async function runGroup() {
const group = S.group;
- const name = runName("fine", Boolean(group.redo));
+ const name = group.name || runName("fine", Boolean(group.redo));
const running = runs.run("group", {
total: group.bvids.filter((b) => groupPending(group, b)).length,
gap: S.settings.triageIntervalSec * 1000,
@@ -139,7 +142,7 @@ async function runGroup() {
const b = group.bvids.find((x) => groupPending(group, x));
return b ? { b, judge: group.judge.get(b) } : null;
},
- act: ({ b, judge }) => analyzeOne(b, Boolean(group.redo), judge),
+ act: ({ b, judge }) => analyzeOne(b, Boolean(group.redo), judge, group.focus),
// An error that is not this video's ends the run and leaves the card as it was.
judge: aiJudge(),
done({ b, judge }, r) {
@@ -198,6 +201,23 @@ function retry(bvid) {
});
}
+// A card's own AI button, whatever the user decided: the next step for this one video (标题粗看, then 字幕细看), and after
+// 细看 a 重新细看 with an optional 侧重, which bypasses the cache. No 重新粗看: the same title gives the same answer, and
+// 按新标准重新粗看 covers a changed 判断标准. A failed card has 重试 instead; an invalid one has no step left.
+const REFINE = "重新细看";
+const CARD_STEP = { coarse: STEP_NAME.coarse, fine: STEP_NAME.fine, refine: REFINE };
+const cardStep = (it) => (S.mediaId === REMOVED || it.invalid || verdictOf(it).failed ? "" : { none: "coarse", coarse: "fine", fine: "refine" }[stageOf(it)]);
+// A run of one through the step's own runner, as the card's 重试. focus: 重新细看's 侧重.
+function runCardStep(bvid, focus = "") {
+ const it = S.itemMap.get(bvid);
+ const step = it && cardStep(it);
+ if (!step || aiBusyReason()) return;
+ if (step === "coarse") return startJudging(CARD_STEP.coarse, "粗看", [it], () => runStage1([it]));
+ if (step === "fine") return startJudging(CARD_STEP.fine, "细看", [it], () => startGroup([bvid]));
+ S.refocus = null;
+ startJudging(REFINE, REFINE, [it], () => startGroup([bvid], true, { name: REFINE, focus: focus.trim() }));
+}
+
// ---------- AI 打标签 (tag-dialogs.js) ----------
// The scopes of ✦ AI 打标签 and 摘录或笔记, drawn 「名字 · N 个」 in both.
const SCOPE_LABEL = { ...UI.scopeLabel, analyzed: "细看过的", all: "整个收藏夹" };
diff --git a/extension/triage/fav-base.js b/extension/triage/fav-base.js
index 4f36f5e0..92303b3c 100644
--- a/extension/triage/fav-base.js
+++ b/extension/triage/fav-base.js
@@ -141,6 +141,7 @@ const S = {
stage1: { mediaId: "" }, // the folder of the last 标题粗看 (runs' "stage1")
coarseFailed: new Map(), // bvid → why its 粗看 failed (this page only, like a failed 细看)
analyzing: new Set(),
+ refocus: null, // a card's open 重新细看 box: { bvid, text }
seenRead: { busy: false, pages: 0, until: 0 }, // the background's read of the watch history (triage_seen_meta)
undo: [], // 收藏夹's steps; a folder switch clears them but an AI tool's (agentSteps)
agentLog: [], // an AI tool's 分拣台 writes over MCP (agent-writes.js), each a U step
diff --git a/extension/triage/fav-page.js b/extension/triage/fav-page.js
index d5889430..194e1048 100644
--- a/extension/triage/fav-page.js
+++ b/extension/triage/fav-page.js
@@ -508,7 +508,17 @@ function bindEvents() {
clearTimeout(noteTimer);
noteTimer = setTimeout(flushNotes, 400);
};
- el.list.addEventListener("input", (e) => !e.isComposing && noteEdit(e));
+ el.list.addEventListener("input", (e) => {
+ if (e.target.matches("[data-refocus]") && S.refocus) S.refocus.text = e.target.value;
+ else if (!e.isComposing) noteEdit(e);
+ });
+ // A 重新细看's 侧重: Enter starts it, Esc closes the box. Never mid-IME.
+ el.list.addEventListener("keydown", (e) => {
+ if (composing(e) || !e.target.matches("[data-refocus]") || !(e.key === "Enter" || e.key === "Escape")) return;
+ e.preventDefault();
+ e.stopPropagation();
+ cardAction(e.key === "Enter" ? "refocus-go" : "refocus-cancel", e.target.closest(".card").dataset.bvid);
+ });
el.list.addEventListener("compositionend", (e) => {
noteEdit(e);
if (listDeferred) setTimeout(renderList);
@@ -792,6 +802,16 @@ function cardAction(act, bvid) {
else if (act === "tag") openPicker(bvid, `.card[data-bvid="${CSS.escape(bvid)}"] [data-act="tag"]`);
else if (act === "basket") toggleBasket(bvid);
else if (act === "retry") retry(bvid);
+ else if (act === "ai-step" && cardStep(it) === "refine") {
+ S.refocus = { bvid, text: "" };
+ renderList();
+ el.list.querySelector(`.card[data-bvid="${CSS.escape(bvid)}"] [data-refocus]`)?.focus();
+ } else if (act === "ai-step") runCardStep(bvid);
+ else if (act === "refocus-go") runCardStep(bvid, S.refocus?.text || "");
+ else if (act === "refocus-cancel") {
+ S.refocus = null;
+ renderList();
+ }
else if (act === "note") {
S.noteOpen.add(bvid);
renderList();
diff --git a/extension/triage/fav-render.js b/extension/triage/fav-render.js
index 2ec50ce7..ff97c066 100644
--- a/extension/triage/fav-render.js
+++ b/extension/triage/fav-render.js
@@ -379,11 +379,13 @@ const unfavBatchItems = (batch, f) =>
.map(([bvid, d]) => favRef({ bvid, ...d }));
let listDeferred = false;
+// The card boxes a user types in: a note, a 重新细看's 侧重.
+const TYPED = "[data-note], [data-refocus]";
// The card a Shift+click picks from: the last one picked by click or X.
let pickAnchor = "";
function renderList() {
// Rebuilding the list mid-IME in a note would drop the composition and leave the raw pinyin; compositionend renders.
- if (MdgTyping.isComposing() && document.activeElement?.closest?.("[data-note]")) return void (listDeferred = true);
+ if (MdgTyping.isComposing() && document.activeElement?.closest?.(TYPED)) return void (listDeferred = true);
listDeferred = false;
const list = visibleItems();
renderListHeader(list);
@@ -421,9 +423,9 @@ function renderList() {
const failedList = list.filter((it) => failedIn(S.tab, it));
const failed = failedList.length;
const failedHead = failedHeadHtml(failedList);
- // Background progress re-renders the list; keep a note being typed in focus.
- const typing = document.activeElement?.closest?.("[data-note]");
- const caret = typing && [typing.closest(".card").dataset.bvid, typing.selectionStart, typing.selectionEnd];
+ // Background progress re-renders the list; keep a note or a 侧重 being typed in focus.
+ const typing = document.activeElement?.closest?.(TYPED);
+ const caret = typing && [typing.closest(".card").dataset.bvid, typing.selectionStart, typing.selectionEnd, typing.matches("[data-note]") ? "[data-note]" : "[data-refocus]"];
const restore = UI.keepScroll(el.list, ".card[data-bvid]");
el.list.innerHTML =
list
@@ -434,7 +436,7 @@ function renderList() {
.join("") + recent;
restore();
if (caret) {
- const box = el.list.querySelector(`.card[data-bvid="${CSS.escape(caret[0])}"] [data-note]`);
+ const box = el.list.querySelector(`.card[data-bvid="${CSS.escape(caret[0])}"] ${caret[3]}`);
box?.focus();
box?.setSelectionRange(caret[1], caret[2]);
}
@@ -447,6 +449,7 @@ const verdictBadge = (b, v, low = v.low) =>
? `${ANALYZING}`
: `${VERDICTS[v.verdict] ? `AI` : ""}${esc(verdictLabel(v.verdict))}${low ? " · 低置信" : ""}`;
const KEEP_TIP = `保留标记${MdgErrors.TEXT.localOnly}`;
+const REFOCUS_HINT = "侧重什么(可留空),比如:只列操作步骤";
// 字幕细看下一批 has none to send but 已保留 ones.
const KEPT_NOT_NEXT = `已保留的视频不进${NEXT_BATCH},选中后可以细看`;
@@ -478,7 +481,7 @@ function cardHtml(it, expanded, mark) {
: ``;
const askBtn = ``;
// The 发布时间 is relative, the date on hover; the rest is text.
- const meta = [esc(it.upper), it.pubdate && UI.agoHtml(it.pubdate), Number.isFinite(it.play) && esc(`▶ ${fmtCount(it.play)}`), ["", "粗看", "细看"][v.stage], done && basisText(a), esc(seenText(it)), it.invalid && KIND_NAME.invalid, it.folders?.length && esc(`收藏夹:${folderNames(it)}`)].filter(Boolean);
+ const meta = [esc(it.upper), it.pubdate && UI.agoHtml(it.pubdate), Number.isFinite(it.play) && esc(`▶ ${fmtCount(it.play)}`), done && a.focus ? esc(`按「${a.focus}」细看`) : ["", "粗看", "细看"][v.stage], done && basisText(a), esc(seenText(it)), it.invalid && KIND_NAME.invalid, it.folders?.length && esc(`收藏夹:${folderNames(it)}`)].filter(Boolean);
const left = removed && [originHtml(it), it.removedAt && `${esc(leftText(it))}`].filter(Boolean).join("");
const verdict = decision ? "" : verdictBadge(b, v);
@@ -494,6 +497,11 @@ function cardHtml(it, expanded, mark) {
const failed = v.failed
? `${ANALYSIS_FAILED}:${esc(v.failed)}`
: "";
+ const step = cardStep(it);
+ const stepBtn = step ? `` : "";
+ const refocus = step === "refine" && S.refocus?.bvid === b
+ ? ``
+ : "";
const chips = tagIdsOf(b)
.map((id) => tagById(id))
@@ -511,7 +519,8 @@ function cardHtml(it, expanded, mark) {
${left ? `
${left}
` : ""}
${meta.join(" · ")}
${body.join("")}
-
${verdict}${esc(v.reason)}${failed}
+
${verdict}${esc(v.reason)}${failed}${stepBtn}
+ ${refocus}
${chips ? `
${chips}
` : ""}
${noteHtml}
diff --git a/extension/triage/triage-bg.js b/extension/triage/triage-bg.js
index 9f02da7f..489d2466 100644
--- a/extension/triage/triage-bg.js
+++ b/extension/triage/triage-bg.js
@@ -198,7 +198,8 @@ function aiSubtitleMissing({ subtitleNeedLogin = false, subtitleFailure = "" } =
// subtitle is "" when the video has none; then comments (the hot comments, numbered) stand in for it, and meta's
// subtitleNeedLogin / subtitleFailure say why (aiSubtitleMissing).
-function triageBuildMessages(meta, subtitle, comments, criteria, folder) {
+// focus: what the user asked this 细看 to center on (a card's 重新细看), "" for none.
+function triageBuildMessages(meta, subtitle, comments, criteria, folder, focus = "") {
const user = [
`标题:${meta.title}`,
`UP主:${meta.upper}`,
@@ -210,7 +211,7 @@ function triageBuildMessages(meta, subtitle, comments, criteria, folder) {
subtitle ? `\n字幕:\n${subtitle}` : `\n${aiSubtitleMissing(meta)}\n热门评论:\n${comments || "无"}`
].join("\n");
return [
- { role: "system", content: triageWithCriteria(TRIAGE_SYSTEM_PROMPT, criteria, folder) },
+ { role: "system", content: triageWithCriteria(TRIAGE_SYSTEM_PROMPT, criteria, folder) + (focus ? `\n\n这次总结的侧重(一句话总结和要点都按它写):${focus}` : "") },
{ role: "user", content: user }
];
}
@@ -659,8 +660,9 @@ async function triageAiSettings() {
};
}
-async function triageAnalyze({ bvid, force, criteria, folder, aiProviderId }) {
+async function triageAnalyze({ bvid, force, criteria, folder, focus, aiProviderId }) {
if (!bvid) throw triageError("缺少 bvid");
+ focus = String(focus ?? "").trim();
const cacheKey = MdgLimits.KEYS.triageAnalysis(bvid);
if (!force) {
const cached = (await chrome.storage.local.get(cacheKey))[cacheKey];
@@ -684,7 +686,7 @@ async function triageAnalyze({ bvid, force, criteria, folder, aiProviderId }) {
const comments = subtitle ? "" : (await site.fetchComments(ref, m, TRIAGE_BILI_IO, TriageUi.DEEP_COMMENTS).catch(() => [])).map((c, i) => `${i + 1}. ${c.message}`).join("\n");
const ai = await triageAiSettings();
- const { content, model } = await triageChat(triageBuildMessages({ ...meta, subtitleNeedLogin: got.needLogin, subtitleFailure: got.failure }, subtitle, comments, criteria, folder), triageMaxTokens("analyze", 1, ai), ai.triageThinking, aiProviderId);
+ const { content, model } = await triageChat(triageBuildMessages({ ...meta, subtitleNeedLogin: got.needLogin, subtitleFailure: got.failure }, subtitle, comments, criteria, folder, focus), triageMaxTokens("analyze", 1, ai), ai.triageThinking, aiProviderId);
const analysis = {
bvid,
status: "done",
@@ -696,7 +698,8 @@ async function triageAnalyze({ bvid, force, criteria, folder, aiProviderId }) {
...triageParseLlm(content),
model,
analyzedAt: Date.now(),
- criteria: String(criteria ?? "").trim()
+ criteria: String(criteria ?? "").trim(),
+ ...(focus && { focus })
};
await chrome.storage.local.set({ [cacheKey]: analysis });
return analysis;
diff --git a/extension/triage/triage-bg.selftest.js b/extension/triage/triage-bg.selftest.js
index 4e350245..de5bfbf0 100644
--- a/extension/triage/triage-bg.selftest.js
+++ b/extension/triage/triage-bg.selftest.js
@@ -114,6 +114,9 @@ assert.ok(both.includes("这个收藏夹叫「纯娱乐」。") && !both.include
const msgs = t.triageBuildMessages({ title: "T", upper: "U", tags: [] }, "", "", "只留干货");
assert.ok(msgs[0].content.includes('"verdict": "keep|drop|unsure"') && msgs[0].content.endsWith("只留干货"));
for (const p of [TITLE_PROMPT, msgs[0].content]) assert.ok(!/标签|tags|新:/.test(p), "粗分/细看 prompts carry no tags");
+// P5: a 重新细看's 侧重 is one more line the user's focus governs; none without it.
+assert.ok(t.triageBuildMessages({ title: "T", upper: "U", tags: [] }, "", "", "只留干货", null, "只列操作步骤")[0].content.endsWith("这次总结的侧重(一句话总结和要点都按它写):只列操作步骤"));
+assert.ok(!msgs[0].content.includes("侧重"));
// form
assert.strictEqual(t.triageForm({ resources: "1:2,3:2", csrf: "x y", privacy: 1 }), "resources=1%3A2%2C3%3A2&csrf=x+y&privacy=1");
@@ -565,6 +568,17 @@ assert.throws(() => t.triageParseCommand('{"new_tags":[', cmdItems, cmdTags, {})
assert.ok(prompt.includes("分区:知识") && prompt.includes("标签:标签") && prompt.includes("发布:2024-05-30") && prompt.includes("第一句\n最后一句"));
assert.ok(prompt.includes("字幕:\n第一句") && !prompt.includes("热门评论") && !calls.some((u) => u.includes("/reply/main")), "with subtitles no comments are fetched or sent");
+ // P5: the 侧重 reaches the system prompt and is stored on the record; without one the record has no focus.
+ {
+ let system = "";
+ const fetch = t.fetch;
+ t.fetch = async (url, init) => (/bilibili\.com|hdslb\.com/.test(url) ? fetch(url, init) : ((system = JSON.parse(init.body).messages[0].content), aiAnswer));
+ const focused = await t.triageAnalyze({ bvid: "BVa", force: true, criteria: "", focus: " 只列操作步骤 " });
+ assert.ok(system.endsWith("这次总结的侧重(一句话总结和要点都按它写):只列操作步骤"), system);
+ assert.deepStrictEqual([focused.focus, store["triage_analysis_BVa"].focus], ["只列操作步骤", "只列操作步骤"]);
+ assert.ok(!("focus" in (await t.triageAnalyze({ bvid: "BVa", force: true, criteria: "", focus: "" }))) && !system.includes("侧重"));
+ t.fetch = fetch;
+ }
// A fetched subtitle that passes the duration guard is cached for the video page.
delete store[subtitleKey];
assert.strictEqual((await analyze()).source, "subtitle");
diff --git a/extension/triage/triage.css b/extension/triage/triage.css
index 2e84a4ff..d0230293 100644
--- a/extension/triage/triage.css
+++ b/extension/triage/triage.css
@@ -482,6 +482,7 @@ button.icon { width: 26px; height: 24px; padding: 0; }
.shell:has(.main.viewing) .side { display: none; }
.verdict-row:empty { display: none; }
+.refocus input { flex: 1; min-width: 0; max-width: 480px; }
.notice {
--w: min(380px, calc(100vw - 32px));
diff --git a/extension/triage/triage.selftest.js b/extension/triage/triage.selftest.js
index f71380a5..b5b08469 100644
--- a/extension/triage/triage.selftest.js
+++ b/extension/triage/triage.selftest.js
@@ -529,6 +529,56 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) =
delete t.S.analyses.BV1;
t.S.undo = [];
}
+ {
+ // P4: a card's own AI button goes by its AI depth, whatever was decided: 标题粗看, 字幕细看, then 重新细看; never a
+ // second 粗看. A failed card has only 重试; while another AI step runs the button is off with why.
+ openFake("R4", [item(1)], { BV1: { action: "keep", at: 1 } });
+ Object.assign(t.S, { titleRes: {}, analyses: {}, refocus: null });
+ const card = () => t.cardHtml(item(1), false, "");
+ const btn = () => /data-act="ai-step"[^>]*>(?:]*><\/span>)?([^<]+).*data-act="ai-step"/.test(card().replace(/\n/g, "")), "in the verdict row");
+ const old = { bvid: "BV1", status: "done", verdict: "keep", reason: "旧理由", oneLiner: "旧总结" };
+ t.S.analyses.BV1 = old;
+ assert.strictEqual(btn(), "重新细看");
+ assert.ok(!card().includes("重新粗看") && !card().includes(">标题粗看<"), "no re-粗看 on a card");
+ t.S.analyses.BV1 = { bvid: "BV1", status: "error", error: "字幕取不到" };
+ assert.ok(btn() === "" && card().includes('data-act="retry"'), "a failed card: 重试 only");
+ t.S.analyses.BV1 = old;
+ t.S.group = { bvids: ["x"], mediaId: "other" };
+ assert.ok(/data-act="ai-step"[^>]* disabled title="字幕细看还在跑,等它完成"/.test(card()), card());
+ t.S.group = null;
+ // A kept card with only 粗看 runs its 字幕细看 as a run of one.
+ delete t.S.analyses.BV1;
+ const asked = [];
+ handlers["triage-analyze"] = (m) => (asked.push(plain(m)), { ok: true, data: { bvid: m.bvid, status: "done", verdict: "keep", oneLiner: "细看过" } });
+ t.cardAction("ai-step", "BV1");
+ await until(() => !t.S.group);
+ assert.deepStrictEqual([asked.length, asked[0].force, t.S.analyses.BV1.oneLiner], [1, false, "细看过"]);
+ // P5: 重新细看 opens a 侧重 box under the verdict row; 开始细看 sends it with force, the old result stays until the
+ // new one arrives, and the record keeps the 侧重, which the meta line names.
+ t.S.analyses.BV1 = old;
+ t.cardAction("ai-step", "BV1");
+ assert.deepStrictEqual(plain(t.S.refocus), { bvid: "BV1", text: "" });
+ assert.ok(card().includes('placeholder="侧重什么(可留空),比如:只列操作步骤"') && card().includes(">开始细看<") && card().includes(">取消<"), card());
+ t.cardAction("refocus-cancel", "BV1");
+ assert.ok(t.S.refocus === null && !card().includes("data-refocus"), "取消 closes it");
+ t.cardAction("ai-step", "BV1");
+ t.S.refocus.text = " 只列操作步骤 ";
+ let release;
+ handlers["triage-analyze"] = (m) => (asked.push(plain(m)), new Promise((r) => (release = () => r({ ok: true, data: { bvid: m.bvid, status: "done", verdict: "keep", oneLiner: "新总结", focus: m.focus } }))));
+ t.cardAction("refocus-go", "BV1");
+ await until(() => release);
+ assert.deepStrictEqual([asked.at(-1).force, asked.at(-1).focus, t.S.refocus, t.activityState().text], [true, "只列操作步骤", null, "重新细看 0/1"]);
+ assert.strictEqual(t.S.analyses.BV1.oneLiner, "旧总结", "the old result stays meanwhile");
+ release();
+ await until(() => !t.S.group);
+ assert.deepStrictEqual([t.S.analyses.BV1.oneLiner, t.S.analyses.BV1.focus, t.activityState().text], ["新总结", "只列操作步骤", "重新细看完成 1 个"]);
+ assert.ok(/class="meta">[^\n]*按「只列操作步骤」细看/.test(card()), card());
+ Object.assign(t.S, { titleRes: {}, analyses: {}, decisions: {}, undo: [] });
+ }
// C39: a 取消收藏 that leaves the video in another chosen folder (by its cached list, 稍后再看 aside) records elsewhere,
// so B站 pages other than a favorites page leave 已取消收藏 off; checked against the folder it started in, even when
@@ -3991,7 +4041,7 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) =
t.el.settingsDialog.close?.();
// a background re-render waits while a note is composing, and runs on compositionend.
t.S.items = [];
- t.document.activeElement = { closest: (s) => (s === "[data-note]" ? note : null) };
+ t.document.activeElement = { closest: (s) => (s.split(", ").includes("[data-note]") ? note : null) };
t.el.list.innerHTML = "before";
doc("compositionstart");
t.renderList();
From c14f2288f1fafc7872372722b34df1e6c28a673c Mon Sep 17 00:00:00 2001
From: PathGao <42336971+PathGao@users.noreply.github.com>
Date: Tue, 10 Nov 2026 14:22:28 +0800
Subject: [PATCH 03/17] =?UTF-8?q?=E5=88=86=E6=8B=A3=E5=8F=B0=EF=BC=9A?=
=?UTF-8?q?=E7=BB=86=E7=9C=8B=E6=B2=A1=E6=9C=89=E5=AD=97=E5=B9=95=E7=9A=84?=
=?UTF-8?q?=E5=8D=A1=E7=89=87=E6=A0=87=E3=80=8C=E6=97=A0=E5=AD=97=E5=B9=95?=
=?UTF-8?q?=E3=80=8D=EF=BC=8C=E7=AC=AC=203=20=E8=A1=8C=E5=8A=A0=E3=80=8C?=
=?UTF-8?q?=E6=97=A0=E5=AD=97=E5=B9=95=E3=80=8D=E7=AD=9B=E9=80=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
细看卡片 AI 判断那一行最前面放灰色标记「无字幕」「未登录无字幕」或「字幕抓取失败」,悬停写原因。灰字那行不再重复说没有字幕,字幕对不上的提醒照旧。第 3 行有细看过、没有字幕的视频时出现「无字幕」筛选。粗看不查字幕,不多请求 B站。
---
extension/triage/fav-base.js | 5 +++++
extension/triage/fav-page.js | 1 +
extension/triage/fav-render.js | 25 +++++++++++++++------
extension/triage/triage.selftest.js | 35 ++++++++++++++++++++++++-----
4 files changed, 53 insertions(+), 13 deletions(-)
diff --git a/extension/triage/fav-base.js b/extension/triage/fav-base.js
index 92303b3c..f26d484d 100644
--- a/extension/triage/fav-base.js
+++ b/extension/triage/fav-base.js
@@ -112,6 +112,7 @@ const S = {
basket: [],
notes: {},
finishedFilter: false, // 看完了
+ noSubFilter: false, // 无字幕
dupFilter: false, // 所有收藏夹 only: 重复收藏
tagState: "", // row 3: "" | "untagged" | "tagged"
decisionFilter: "", // row 3: "" | "undecided" | "kept"
@@ -552,6 +553,9 @@ function kindOf(it) {
return at ? "out" : it.inFolder === null ? "unfav" : "";
}
+// A 细看 that had no subtitle and judged from the intro and comments (粗看 never reads subtitles).
+const noSubtitle = (it) => S.analyses[it.bvid]?.status === "done" && S.analyses[it.bvid].source === "meta";
+
// 重复收藏: in more than one chosen folder (所有收藏夹 only; elsewhere items have no .folders).
const isDup = (it) => it.folders?.length > 1;
@@ -562,6 +566,7 @@ function passFilter(it, skip = "") {
if (on("dup") && S.dupFilter && !isDup(it)) return false;
if (on("decision") && S.decisionFilter && (isKept(it) ? "kept" : "undecided") !== S.decisionFilter) return false;
if (on("seen") && S.finishedFilter && !isFinished(it)) return false;
+ if (on("nosub") && S.noSubFilter && !noSubtitle(it)) return false;
if (on("recent") && S.aiRecentFilter && !aiRecentSet().has(it.bvid)) return false;
if ((S.mediaId === REMOVED || on("kind")) && S.kindFilter && kindOf(it) !== S.kindFilter) return false;
if (on("tagged") && S.tagState && (tagIdsOf(it.bvid).length > 0) !== (S.tagState === "tagged")) return false;
diff --git a/extension/triage/fav-page.js b/extension/triage/fav-page.js
index 194e1048..1eb9d732 100644
--- a/extension/triage/fav-page.js
+++ b/extension/triage/fav-page.js
@@ -382,6 +382,7 @@ function bindEvents() {
else if (b.dataset.classFilter) pickState("class", b.dataset.classFilter);
else if (b.dataset.decision) pickState("decision", b.dataset.decision);
else if (b.matches("[data-finishedfilter]")) pickState("seen");
+ else if (b.matches("[data-nosubfilter]")) pickState("nosub");
else if (b.matches("[data-dupfilter]")) pickState("dup");
else if (b.dataset.tagstate) pickState("tagged", b.dataset.tagstate);
else if (b.dataset.kindfilter) pickState("kind", b.dataset.kindfilter);
diff --git a/extension/triage/fav-render.js b/extension/triage/fav-render.js
index ff97c066..2d7b0afc 100644
--- a/extension/triage/fav-render.js
+++ b/extension/triage/fav-render.js
@@ -167,7 +167,7 @@ function criteriaBtn() {
}
// Whether anything in row 3 is on (全部 is pressed when not).
-const statesOn = (t = S.tab) => (S.classFilter[t] && S.classFilter[t] !== "all") || Boolean(S.tagState || S.decisionFilter || S.finishedFilter || S.dupFilter || S.aiRecentFilter || (S.mediaId !== REMOVED && S.kindFilter));
+const statesOn = (t = S.tab) => (S.classFilter[t] && S.classFilter[t] !== "all") || Boolean(S.tagState || S.decisionFilter || S.finishedFilter || S.noSubFilter || S.dupFilter || S.aiRecentFilter || (S.mediaId !== REMOVED && S.kindFilter));
// Row 3: what the system knows about each video, in groups split by a thin rule. One pick per group (a second click
// clears it), groups AND together, and each group counts with its own pick left out. 全部 clears row 3 only. The fixed
// groups come first (a 0 stays, dimmed); the chips that exist only sometimes (看完了, 已失效, AI 刚打的) go last and
@@ -197,6 +197,8 @@ function stateRowHtml(t = S.tab) {
const invalidN = cnt("kind", (it) => kindOf(it) === "invalid");
const invalidOn = S.kindFilter === "invalid";
if (!removed && (invalidN || invalidOn)) groups.push(group(KIND_NAME.invalid, UI.filterBtn(`data-kindfilter="invalid" class="st-danger" title="只看${KIND_NAME.invalid}的视频"`, KIND_NAME.invalid, invalidN, invalidOn)));
+ const noSubN = cnt("nosub", noSubtitle);
+ if (noSubN || S.noSubFilter) groups.push(group(NO_SUB, UI.filterBtn(`data-nosubfilter title="只看字幕细看时没有字幕、按简介和评论判断的视频"`, NO_SUB, noSubN, S.noSubFilter)));
const recentN = cnt("recent", (it) => aiRecentSet().has(it.bvid));
groups.push(UI.aiRecentChip({ attrs: "data-airecent", xAttrs: "data-airecent-done", n: recentN, on: S.aiRecentFilter, who: "视频" }));
return groups.join("");
@@ -207,13 +209,14 @@ function pickState(group, value = "") {
const t = S.tab;
if (group === "all") {
S.classFilter[t] = "all";
- S.finishedFilter = S.dupFilter = S.aiRecentFilter = false;
+ S.finishedFilter = S.dupFilter = S.aiRecentFilter = S.noSubFilter = false;
S.tagState = S.decisionFilter = "";
if (S.mediaId !== REMOVED) S.kindFilter = "";
} else if (group === "class") S.classFilter[t] = S.classFilter[t] === value ? "all" : value;
else if (group === "decision") S.decisionFilter = S.decisionFilter === value ? "" : value;
else if (group === "kind") S.kindFilter = S.kindFilter === value ? "" : value;
else if (group === "seen") S.finishedFilter = !S.finishedFilter;
+ else if (group === "nosub") S.noSubFilter = !S.noSubFilter;
else if (group === "dup") S.dupFilter = !S.dupFilter;
else if (group === "tagged") S.tagState = S.tagState === value ? "" : value;
else if (group === "recent") S.aiRecentFilter = !S.aiRecentFilter;
@@ -455,12 +458,20 @@ const KEPT_NOT_NEXT = `已保留的视频不进${NEXT_BATCH},选中后可以
// A verdict an AI tool wrote over MCP (agent-writes.js), on the card's meta line in place of the basis.
const AGENT_VERDICT = "Agent 判断";
-// What a 细看 judged from, on the card's meta line, in the video page's words: no subtitle (and why), or a subtitle
-// whose time range may not match the video. Nothing for a matching subtitle.
+// What a 细看 judged from, on the card's meta line, in the video page's words: a subtitle whose time range may not match
+// the video. Nothing for a matching subtitle; no subtitle is the verdict row's 无字幕 badge (noSubBadge).
function basisText(a) {
if (a.source === "agent") return AGENT_VERDICT;
- if (a.source !== "meta") return a.subtitleWarning ? esc(a.subtitleWarning.replace(/。$/, "")) : "";
- return esc(`${MdgErrors.subtitleMissing({ subtitleNeedLogin: a.needLogin, subtitleFailure: a.subtitleFailure })},按简介和评论判断`);
+ return a.source !== "meta" && a.subtitleWarning ? esc(a.subtitleWarning.replace(/。$/, "")) : "";
+}
+// The 无字幕 chip, and the badge a 细看 card without a subtitle leads its verdict row with (why on hover).
+const NO_SUB = "无字幕";
+function noSubBadge(it) {
+ if (!noSubtitle(it)) return "";
+ const a = S.analyses[it.bvid];
+ const label = a.needLogin ? "未登录无字幕" : a.subtitleFailure ? "字幕抓取失败" : NO_SUB;
+ const why = `${MdgErrors.subtitleMissing({ subtitleNeedLogin: a.needLogin, subtitleFailure: a.subtitleFailure })},按简介和评论判断`;
+ return `${label}`;
}
function cardHtml(it, expanded, mark) {
const b = it.bvid;
@@ -519,7 +530,7 @@ function cardHtml(it, expanded, mark) {
${left ? `
` : ""}
${noteHtml}
diff --git a/extension/triage/triage.selftest.js b/extension/triage/triage.selftest.js
index b5b08469..2abbcad3 100644
--- a/extension/triage/triage.selftest.js
+++ b/extension/triage/triage.selftest.js
@@ -713,18 +713,27 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) =
assert.strictEqual(btn(1), '');
t.S.basket = kept;
}
- // F27: a 细看 judged without subtitles says so on the card, and why (the login, a failed download); a subtitle that may
- // not match the video gets the video page's warning.
+ // F27 / P6: a 细看 judged without subtitles says so once, in a gray badge leading the verdict row (before the AI verdict
+ // or the decision), and why on hover (the login, a failed download); a subtitle that may not match the video gets the
+ // video page's warning on the meta line.
{
const meta = () => t.cardHtml(item(1), false, "").match(/