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 `
  • ${title}${esc(fmtTime(d.at))} - ${pending(b) ? `` : ``}
  • `; + return `
  • ${title}${from(f)}${esc(fmtTime(d.at))} + ${pending(b) ? `` : ``}
  • `; }) .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 `

    ${esc(fmtTime(batch))} ${UNFAV_MANY} ${n} 个${going ? `` : ``}

    `; + .map(({ batch, f, n }) => { + const going = unfavBatchItems(batch, f).some((it) => pending(it.bvid)); + return `

    ${esc(fmtTime(batch))} ${UNFAV_MANY} ${n} 个${from(f)}${going ? `` : ``}

    `; }) .join(""); return `

    ${RECENT_UNFAV} ${list.length}

    ${batchRows}
    `; @@ -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 @@

    快捷键

    -

    四步分拣

    +

    三步分拣

    1. 未分析:点「标题粗看下一批」,AI 只看标题和简介,快、省。
    2. -
    3. 粗看完成:点「字幕细看下一批」读字幕,给出 AI 总结和要点,拿不准和低置信的排在前面;有把握的也可以直接按 AI 判断批量取消收藏或保留。
    4. -
    5. 细看完成:按 AI 判断批量取消收藏或保留,也可以一个个按 D / S。
    6. -
    7. 处理完成:取消收藏或保留过的视频;最近取消收藏的可以重新收藏。
    8. +
    9. 粗看完成:点「字幕细看下一批」读字幕,给出 AI 总结和要点,拿不准和低置信的排在前面,已保留的不进下一批;有把握的也可以直接按 AI 判断批量取消收藏或保留。
    10. +
    11. 细看完成:按 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 ? `
    ${left}
    ` : ""}
    ${meta.join(" · ")}
    ${body.join("")} -
    ${verdict}${esc(v.reason)}${failed}${stepBtn}
    +
    ${noSubBadge(it)}${verdict}${esc(v.reason)}${failed}${stepBtn}
    ${refocus} ${chips ? `
    ${chips}
    ` : ""} ${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(/
    ([^]*?)<\/div>/)[1]; + const row = () => t.cardHtml(item(1), false, "").match(/
    ([^]*?)<\/div>/)[1]; t.S.analyses.BV1 = { status: "done", source: "subtitle", verdict: "keep" }; - assert.ok(!meta().includes("字幕")); + assert.ok(!meta().includes("字幕") && !row().includes("字幕")); t.S.analyses.BV1 = { status: "done", source: "meta", verdict: "keep" }; - assert.ok(meta().includes("视频没有字幕,按简介和评论判断"), meta()); + assert.ok(row().startsWith('无字幕未登录无字幕'), row()); t.S.analyses.BV1 = { status: "done", source: "meta", verdict: "keep", subtitleFailure: "网络断了" }; - assert.ok(meta().includes("字幕抓取失败:网络断了,按简介和评论判断") && !meta().includes("没有字幕"), meta()); + assert.ok(row().startsWith('字幕抓取失败'), row()); + t.S.decisions.BV1 = { action: "keep", at: 1 }; + assert.ok(row().startsWith(' Object.fromEntries(Object.entries(d).filter(([, v]) = Object.assign(t.S, { decisions: {}, aiRecent: {}, seenPct: {}, titleRes: {}, tags: [], videoTags: {} }); t.S.seenCfg = { on: false, bar: false, mark: false, threshold: 80, style: "badge" }; } + // P6: 无字幕 is a conditional chip counting the 细看 videos without a subtitle: gone at 0 unless on, cleared by 全部. + { + openFake("N", [item(1), item(2), item(3)]); + Object.assign(t.S, { titleRes: {}, analyses: { BV1: { status: "done", source: "meta", verdict: "keep" }, BV2: { status: "done", source: "subtitle", verdict: "keep" } }, tab: "fine", classFilter: { coarse: "all", fine: "all", read: "all" } }); + const groups = () => [...row3().matchAll(/role="group" aria-label="([^"]+)"/g)].map((m) => m[1]); + assert.ok(row3().includes(">无字幕 1<") && groups().at(-1) === "无字幕", groups().join()); + t.pickState("nosub"); + assert.deepStrictEqual(plain(t.visibleItems().map((it) => it.bvid)), ["BV1"]); + t.pickState("all"); + assert.strictEqual(t.S.noSubFilter, false, "全部 clears it"); + t.S.tab = "none"; + assert.ok(!row3().includes("无字幕"), "at 0 it is not shown"); + Object.assign(t.S, { analyses: {} }); + } { // Overlapping read-modify-writes of one record each read it before the other wrote; neither change may be lost. From e7ed173a2c946a7828ecc330fa9067515e854809 Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:31:40 +0800 Subject: [PATCH 04/17] =?UTF-8?q?=E5=88=86=E6=8B=A3=E5=8F=B0=EF=BC=9A?= =?UTF-8?q?=E3=80=8C=E6=9C=AA=E6=89=93=E6=A0=87=E7=AD=BE=20=C2=B7=20?= =?UTF-8?q?=E5=B7=B2=E6=89=93=E6=A0=87=E7=AD=BE=E3=80=8D=E6=8C=AA=E5=88=B0?= =?UTF-8?q?=E6=A0=87=E7=AD=BE=E8=A1=8C=EF=BC=8C=E6=A0=87=E7=AD=BE=E8=A1=8C?= =?UTF-8?q?=E6=9C=89=E8=87=AA=E5=B7=B1=E7=9A=84=E3=80=8C=E5=85=A8=E9=83=A8?= =?UTF-8?q?=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 收藏夹和关注一样:第 4 行(标签)开头是「全部」,接着「未打标签 · 已打标签」(视频投稿是「UP 未打标签 · UP 已打标签」),再是标签。第 4 行的「全部」只清空第 4 行,第 3 行的「全部」只清空第 3 行。视频投稿的第 3 行只剩这一组,所以不再画第 3 行。 --- extension/triage/fav-base.js | 8 +++-- extension/triage/fav-page.js | 9 ++++-- extension/triage/fav-render.js | 25 +++++++++------ extension/triage/follow-core.js | 20 ++++++------ extension/triage/follow.js | 48 ++++++++++++++--------------- extension/triage/follow.selftest.js | 25 ++++++++++++--- extension/triage/shared.js | 8 +++-- extension/triage/shared.selftest.js | 5 ++- extension/triage/triage.selftest.js | 26 +++++++++++----- 9 files changed, 111 insertions(+), 63 deletions(-) diff --git a/extension/triage/fav-base.js b/extension/triage/fav-base.js index f26d484d..1ae9d5da 100644 --- a/extension/triage/fav-base.js +++ b/extension/triage/fav-base.js @@ -114,7 +114,7 @@ const S = { finishedFilter: false, // 看完了 noSubFilter: false, // 无字幕 dupFilter: false, // 所有收藏夹 only: 重复收藏 - tagState: "", // row 3: "" | "untagged" | "tagged" + tagState: "", // row 4: "" | "untagged" | "tagged" decisionFilter: "", // row 3: "" | "undecided" | "kept" aiRecent: {}, sortBy: {}, // triage_sort @@ -560,9 +560,11 @@ const noSubtitle = (it) => S.analyses[it.bvid]?.status === "done" && S.analyses[ const isDup = (it) => it.folders?.length > 1; // skip leaves one filter group out, so that group's own counts never hide its siblings: a row-3 group ("seen", "recent", -// "kind", "tagged"), "states" (all of row 3) or "tags" (row 4). 已出分拣范围's kind is its tab, never skipped. +// "kind"), "states" (all of row 3), a row-4 group ("tagged", "tags") or "tagrow" (all of row 4). 已出分拣范围's kind is +// its tab, never skipped. +const ROW4 = new Set(["tagged", "tags"]); function passFilter(it, skip = "") { - const on = (g) => skip !== g && (skip !== "states" || g === "tags"); + const on = (g) => skip !== g && !(skip === (ROW4.has(g) ? "tagrow" : "states")); 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; diff --git a/extension/triage/fav-page.js b/extension/triage/fav-page.js index 1eb9d732..6f1edf12 100644 --- a/extension/triage/fav-page.js +++ b/extension/triage/fav-page.js @@ -372,7 +372,7 @@ function bindEvents() { if (!f || !passFilter(f)) S.focused = ""; fromTop(); }); - // Row 3: one pick per group, a second click clears it; 全部 clears the whole row (not row 4's tags). + // Row 3: one pick per group, a second click clears it; 全部 clears the whole row (not row 4). el.classFilter.addEventListener("click", (e) => { const b = e.target.closest("button"); if (!b) return; @@ -384,13 +384,18 @@ function bindEvents() { 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); else if (b.matches("[data-airecent]")) pickState("recent"); else return; fromTop(); }); + // Row 4: its 全部 clears row 4 only (not row 3); 未打标签 / 已打标签 one pick; the tags AND together. el.tagFilter.addEventListener("click", (e) => { + const state = e.target.closest("[data-tagstate], [data-tagrow-all]"); + if (state) { + pickState(state.dataset.tagstate ? "tagged" : "tagrow", state.dataset.tagstate); + return fromTop(); + } const btn = e.target.closest("[data-tagfilter]"); if (!btn) return; const ids = btn.dataset.tagfilter.split(","); diff --git a/extension/triage/fav-render.js b/extension/triage/fav-render.js index 2d7b0afc..d7ea5761 100644 --- a/extension/triage/fav-render.js +++ b/extension/triage/fav-render.js @@ -111,13 +111,16 @@ function renderTabs() { el.tabs.innerHTML = S.mediaId === REMOVED ? kindTabs() : steps.join(``) + `` + tab("read", READ_TAB, "read-tab", c.read); - // 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). + // Row 4: its own 全部 and 未打标签 / 已打标签, then 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, the group's own pick + // left out). 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)); - el.tagFilter.innerHTML = UI.labeledRow("标签", chips + const cnt = (skip) => here.filter((it) => inTab(it, S.tab) && passFilter(it, skip)); + const counted = cnt("tags").map((it) => tagIdsOf(it.bvid)); + const head = UI.tagRowHead({ allAttrs: "data-tagrow-all", allN: cnt("tagrow").length, on: tagRowOn(), attrs: (k) => `data-tagstate="${k}"`, n: (k) => cnt("tagged").filter((it) => (tagIdsOf(it.bvid).length > 0) === (k === "tagged")).length, cur: S.tagState }); + el.tagFilter.innerHTML = UI.labeledRow("标签", head + chips .map((c) => UI.tagFilterChip(`data-tagfilter="${esc(c.ids.join(","))}"`, { name: c.name, color: c.color, on: c.ids.some((id) => S.tagFilter.has(id)), n: counted.filter((ids) => c.ids.some((id) => ids.includes(id))).length, what: "视频" })) .join("")); renderTagButtons(); @@ -167,7 +170,9 @@ 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.noSubFilter || S.dupFilter || S.aiRecentFilter || (S.mediaId !== REMOVED && S.kindFilter)); +const statesOn = (t = S.tab) => (S.classFilter[t] && S.classFilter[t] !== "all") || Boolean(S.decisionFilter || S.finishedFilter || S.noSubFilter || S.dupFilter || S.aiRecentFilter || (S.mediaId !== REMOVED && S.kindFilter)); +// Whether row 4 narrows the list (its 全部 is pressed when not). +const tagRowOn = () => Boolean(S.tagState || S.tagFilter.size); // 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 @@ -190,7 +195,6 @@ function stateRowHtml(t = S.tab) { if (t !== "none") { 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); if ((S.seenCfg.mark && seenN) || S.finishedFilter) groups.push(group("看完了", UI.filterBtn('data-finishedfilter title="只看 B站历史记录里看完了的视频"', "看完了", seenN, S.finishedFilter))); // 已出分拣范围 has 已失效 as a tab instead. @@ -210,8 +214,11 @@ function pickState(group, value = "") { if (group === "all") { S.classFilter[t] = "all"; S.finishedFilter = S.dupFilter = S.aiRecentFilter = S.noSubFilter = false; - S.tagState = S.decisionFilter = ""; + S.decisionFilter = ""; if (S.mediaId !== REMOVED) S.kindFilter = ""; + } else if (group === "tagrow") { + S.tagState = ""; + S.tagFilter.clear(); } 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; @@ -232,7 +239,7 @@ function renderListHeader(list) { } const all = S.mediaId === ALL; // Search, row 3 or row 4 on: how many the tab lists. - el.searchCount.textContent = UI.resultCount(S.query.trim() || statesOn(t) || S.tagFilter.size, list.length); + el.searchCount.textContent = UI.resultCount(S.query.trim() || statesOn(t) || tagRowOn(), list.length); const { run: favRun, busy: favBusy } = fav.state(); // none: why the selection's buttons are off (UI.selbar: filters hide the whole selection); they count all of it then. const batchBtn = (route, verdict = "", none = "") => { @@ -404,7 +411,7 @@ function renderList() { 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]) || "这里没有视频"; + const text = UI.noMatch(S.query, statesOn() || tagRowOn(), "视频") || (kind ? `没有${kind}的视频` : empty[S.tab]) || "这里没有视频"; el.list.innerHTML = `

    ${text}

    ${recent}`; return; } diff --git a/extension/triage/follow-core.js b/extension/triage/follow-core.js index 68005aa7..4c64fe5f 100644 --- a/extension/triage/follow-core.js +++ b/extension/triage/follow-core.js @@ -114,10 +114,10 @@ // only for a move. [[id, name]]. const groupTargets = (D, from, how) => [...(how === "move" ? [0] : []), SPECIAL, ...(D.groups || []).map((g) => g.id)].filter((id) => id !== from).map((id) => [id, groupName(id, D)]); - // Row 3's 未打标签 / 已打标签 (f.tagState) and row 4's tags (f.tags, AND) on an UP's tag ids. skip leaves one out - // ("tagged" or "tags"), for that group's own counts. + // Row 4: 未打标签 / 已打标签 (f.tagState) and the tags (f.tags, AND) on an UP's tag ids. skip leaves one out ("tagged" + // or "tags"), for that group's own counts, or both ("tagrow"), for row 4's 全部. const tagPass = (ids, f, skip = "") => - (skip === "tagged" || !f.tagState || (ids.length > 0) === (f.tagState === "tagged")) && (skip === "tags" || [...(f.tags || [])].every((id) => ids.includes(id))); + (skip === "tagged" || skip === "tagrow" || !f.tagState || (ids.length > 0) === (f.tagState === "tagged")) && (skip === "tags" || skip === "tagrow" || [...(f.tags || [])].every((id) => ids.includes(id))); // The UPs of a left-column item, before 更新状态 / search: the follow list without 已出分拣范围; that item is the // unfollowed (newest first), then the followed that are closed or cleared. @@ -137,9 +137,9 @@ }; // What the list shows: f = { side, q, sort, dir, kind (已出分拣范围's tab), source: "" | "bili" | "app" (已取消关注), and row 3: status, - // tagState, recent (Set while 「AI 刚打的」 is on), recentAll (the whole batch, for its count); row 4: tags }. - // Row 3's groups AND together and each counts with its own pick left out: counts[status], tagged / untagged, recent, - // source[bili | app] (已取消关注's row 3 group), "" (row 3 cleared) and tags[id] (row 4's picks left out). Search and the + // recent (Set while 「AI 刚打的」 is on), recentAll (the whole batch, for its count); row 4: tagState, tags }. + // Each group ANDs with the rest and counts with its own pick left out: counts[status], recent, source[bili | app] + // (已取消关注's row 3 group), "" (row 3 cleared), tagged / untagged, tags[id] and tagAll (row 4 cleared). Search and the // sidebar apply to all. have = the tags the item (and 已出分拣范围's tab) carries before search and row 3: row 4 lists them. function visibleUps(D, rows, f) { const words = searchWords(f.q); @@ -152,7 +152,7 @@ const u = rows.get(m); return (skip === "status" || !f.status || u.status === f.status) && (skip === "source" || !f.source || u.gone?.source === f.source) && (skip === "recent" || !f.recent || f.recent.has(m)) && tagPass(u.tagIds, f, skip); }; - const counts = { "": base.filter((m) => tagPass(rows.get(m).tagIds, { tags: f.tags })).length, tagged: 0, untagged: 0, recent: 0, source: { bili: 0, app: 0 }, tags: {}, have: [...new Set(here.flatMap((m) => rows.get(m).tagIds))] }; + const counts = { "": base.filter((m) => tagPass(rows.get(m).tagIds, f)).length, tagAll: base.filter((m) => pass(m, "tagrow")).length, tagged: 0, untagged: 0, recent: 0, source: { bili: 0, app: 0 }, tags: {}, have: [...new Set(here.flatMap((m) => rows.get(m).tagIds))] }; for (const m of base) { const u = rows.get(m); if (pass(m, "status")) counts[u.status] = (counts[u.status] || 0) + 1; @@ -218,12 +218,12 @@ } // A feed video under f = { side, q, tagState, tags } (rows 3 and 4 go by the video's UP); skip as in tagPass. const feedPass = (it, D, f, skip = "") => feedMatch(it, D, f.side) && hasWords(`${it.title}\n${it.name}`, searchWords(f.q)) && tagPass(liveTags(it.mid, D), f, skip); - // Row 3's counts for 视频投稿, as visibleUps's: "" (row 3 cleared), tagged / untagged (by the UP), tags[id], have. + // Row 4's counts for 视频投稿, as visibleUps's: tagAll (row 4 cleared), tagged / untagged (by the UP), tags[id], have. function feedCounts(items, D, f) { - const c = { "": 0, tagged: 0, untagged: 0, tags: {}, have: [...new Set(items.filter((it) => feedMatch(it, D, f.side)).flatMap((it) => liveTags(it.mid, D)))] }; + const c = { tagAll: 0, tagged: 0, untagged: 0, tags: {}, have: [...new Set(items.filter((it) => feedMatch(it, D, f.side)).flatMap((it) => liveTags(it.mid, D)))] }; for (const it of items) { const ids = liveTags(it.mid, D); - if (feedPass(it, D, { ...f, tagState: "" })) c[""]++; + if (feedPass(it, D, f, "tagrow")) c.tagAll++; if (feedPass(it, D, f, "tagged")) c[ids.length ? "tagged" : "untagged"]++; if (feedPass(it, D, f, "tags")) for (const id of ids) c.tags[id] = (c.tags[id] || 0) + 1; } diff --git a/extension/triage/follow.js b/extension/triage/follow.js index 884c309d..ac32d654 100644 --- a/extension/triage/follow.js +++ b/extension/triage/follow.js @@ -72,7 +72,7 @@ const F = { anchor: "", // the UP a Shift+click picks from: the last one picked recentFilter: false, - tagState: "", // row 3: "" | "untagged" | "tagged" (in 视频投稿 the video's UP) + tagState: "", // row 4: "" | "untagged" | "tagged" (in 视频投稿 the video's UP) tags: new Set(), // row 4: tag ids, AND; both tabs loaded: false, order: null, // the UP 主 cards' order (mids) kept while a refresh runs (renderUps), null otherwise @@ -412,23 +412,21 @@ const sideSelect = () => ``; -// Row 3's 未打标签 / 已打标签 (prefix 「UP 」 in 视频投稿, where it goes by the video's UP). -const tagStateGroup = (counts, prefix = "") => - UI.tagStateGroup((k) => `data-fw-tagstate="${k}"`, (k) => counts[k], F.tagState, prefix); -// Row 3 of UP 主: 全部 | 更新状态 | 未打标签 已打标签 | ✦ AI 刚打的 (only while there is one). 待查 always shows. +// Row 3 of UP 主: 全部 | 更新状态 | ✦ AI 刚打的 (only while there is one). 待查 always shows. function stateRowUps(counts) { - const anyOn = F.status || F.tagState || F.recentFilter; + const anyOn = F.status || F.recentFilter; const out = [ UI.stateGroup(ALL, UI.filterBtn("data-fw-all", ALL, counts[""], !anyOn)), - UI.stateGroup("更新状态", STATUS.slice(1).map(([id, label]) => UI.filterBtn(`data-status="${id}"${id === "stale" ? ` title="慢更或断更 · 待查:${cfg.slowDays} 天里没在视频投稿里出现,查完投稿才知道是哪种"` : ""}`, label, counts[id] || 0, F.status === id)).join("")), - tagStateGroup(counts) + UI.stateGroup("更新状态", STATUS.slice(1).map(([id, label]) => UI.filterBtn(`data-status="${id}"${id === "stale" ? ` title="慢更或断更 · 待查:${cfg.slowDays} 天里没在视频投稿里出现,查完投稿才知道是哪种"` : ""}`, label, counts[id] || 0, F.status === id)).join("")) ]; out.push(UI.aiRecentChip({ attrs: 'data-fw="recent"', xAttrs: 'data-fw="recent-done"', n: counts.recent, on: F.recentFilter, who: WHO })); return out.join(""); } -// Row 4: the tags UP 主 (or videos) on this tab have, or that are on, as 收藏夹's; each counts the listed ones -// (0, dimmed, when row 3 or search leave none); several = AND. -const tagRowHtml = ({ tags: counts, have }) => +// Row 4, as 收藏夹's: its own 全部 and 未打标签 / 已打标签 (prefix 「UP 」 in 视频投稿, where it goes by the video's UP), +// then the tags UP 主 (or videos) on this tab have, or that are on; each counts the listed ones (0, dimmed, when row 3 or +// search leave none); several = AND. +const tagRowHtml = ({ tags: counts, have, tagAll, tagged, untagged }) => + UI.tagRowHead({ allAttrs: "data-fw-tagall", allN: tagAll, on: F.tagState || F.tags.size, attrs: (k) => `data-fw-tagstate="${k}"`, n: (k) => ({ tagged, untagged })[k], cur: F.tagState, prefix: F.tab === "feed" ? "UP " : "" }) + D.tags .filter((t) => have.includes(t.id) || F.tags.has(t.id)) .map((t) => UI.tagFilterChip(`data-fw-tag="${esc(t.id)}"`, { name: t.name, color: t.color, on: F.tags.has(t.id), n: counts[t.id] || 0, what: F.tab === "feed" ? `${WHO}的视频` : WHO })) @@ -448,11 +446,10 @@ function renderUps() { shown = list; // Switching between groups keeps the selection (into or out of 已出分拣范围 clears it); the ones not listed count as 被筛选隐藏. for (const m of [...F.sel]) if (!rows.has(m)) F.sel.delete(m); - // 已出分拣范围's row 3: 全部 | 在哪取消关注 (on 全部 and 已取消关注) | 未打标签 已打标签. + // 已出分拣范围's row 3: 全部 | 在哪取消关注 (on 全部 and 已取消关注). const seg = out - ? [UI.stateGroup(ALL, UI.filterBtn("data-fw-all", ALL, counts[""], !F.source && !F.tagState)), - (F.kind === "" || F.kind === "gone") && UI.stateGroup("在哪取消关注", Object.entries(SOURCE).map(([id, label]) => UI.filterBtn(`data-source="${id}"`, label, counts.source[id], F.source === id)).join("")), - tagStateGroup(counts)].filter(Boolean).join("") + ? [UI.stateGroup(ALL, UI.filterBtn("data-fw-all", ALL, counts[""], !F.source)), + (F.kind === "" || F.kind === "gone") && UI.stateGroup("在哪取消关注", Object.entries(SOURCE).map(([id, label]) => UI.filterBtn(`data-source="${id}"`, label, counts.source[id], F.source === id)).join(""))].filter(Boolean).join("") : stateRowUps(counts); // 全选 heads the right-hand buttons, as in 收藏夹: it acts on the UP 主 listed now. 已出分拣范围 adds 清理, as 收藏夹's. const shows = Boolean(D.list || out); @@ -630,12 +627,13 @@ function renderFeed() { E.sort.innerHTML = UI.sortControl({ sorts: FEED_SORTS, sort: F.fsort, selectAttr: 'data-fw="fsort"', btn: F.fsort === "random" && { attrs: 'data-fw="reshuffle"', label: "再随机一次", icon: "↻" } }); E.tools.hidden = false; renderAiButton(); - // Row 3: 全部 | UP 未打标签 UP 已打标签; no count before the first page arrives. Row 4 counts videos. No 全选 here. + // No row 3 here: its only group, UP 未打标签 / UP 已打标签, is row 4's. Row 4 counts videos, none before the first page + // arrives. No 全选 here. const c = feedCounts(items, D, feedF()); const n = (k) => (items.length ? c[k] : null); - E.bar.innerHTML = UI.labeledRow("状态", UI.stateGroup(ALL, UI.filterBtn("data-fw-all", ALL, n(""), !F.tagState)) + tagStateGroup({ tagged: n("tagged"), untagged: n("untagged") }, "UP ")); + E.bar.innerHTML = ""; E.barR.innerHTML = runSlot(); - E.tagRow.innerHTML = UI.labeledRow("标签", tagRowHtml(c)); + E.tagRow.innerHTML = UI.labeledRow("标签", tagRowHtml({ ...c, tagAll: n("tagAll"), tagged: n("tagged"), untagged: n("untagged") })); const list = feedList(items, D, feedF(), pick.keep); E.qCount.textContent = UI.resultCount(F.fq.trim() || filtersOn(), list.length); const restore = UI.keepScroll(E.list, ".fw-video[data-bvid]"); @@ -1430,19 +1428,21 @@ main.addEventListener("click", async (e) => { } const s = t.closest("[data-side]"); if (s) return pickSide(s.dataset.side); - // Row 3: one pick per group, a second click clears it; 全部 clears the row (not row 4's tags). Row 4: AND. + // Row 3: one pick per group, a second click clears it; 全部 clears the row (not row 4). Row 4: its own 全部 clears it + // (not row 3); 未打标签 / 已打标签 one pick; the tags AND. const st = t.closest("[data-status]"); if (st) { F.status = F.status === st.dataset.status ? "" : st.dataset.status; return fromTop(); } if (t.closest("[data-fw-all]")) { - // 视频投稿's row 3 is only 未打标签 / 已打标签: UP 主's 更新状态 and AI 刚打的 stay. + F.status = F.source = ""; + F.recentFilter = false; + return fromTop(); + } + if (t.closest("[data-fw-tagall]")) { F.tagState = ""; - if (F.tab !== "feed") { - F.status = F.source = ""; - F.recentFilter = false; - } + F.tags.clear(); return fromTop(); } const ts = t.closest("[data-fw-tagstate]"); diff --git a/extension/triage/follow.selftest.js b/extension/triage/follow.selftest.js index 182139a8..b4283e35 100644 --- a/extension/triage/follow.selftest.js +++ b/extension/triage/follow.selftest.js @@ -97,7 +97,11 @@ const base = () => ({ list: null, last: null, content: {}, people: {}, tags: [], assert.deepStrictEqual(v({ q: "原理" }).list, ["2"], "search reads the sign too"); const c = v({ status: "active" }); assert.deepStrictEqual(c.list, ["1", "3"]); - assert.deepStrictEqual(c.counts, { "": 4, active: 2, slow: 1, stale: 1, tagged: 1, untagged: 1, recent: 0, source: { bili: 0, app: 0 }, tags: { t1: 1 }, have: ["t1"] }, "status counts ignore the status pick; the other groups apply it"); + assert.deepStrictEqual(c.counts, { "": 4, tagAll: 2, active: 2, slow: 1, stale: 1, tagged: 1, untagged: 1, recent: 0, source: { bili: 0, app: 0 }, tags: { t1: 1 }, have: ["t1"] }, "status counts ignore the status pick; the other groups apply it"); + // 2026-11-10: 未打标签 / 已打标签 is row 4's. Row 3's 全部 ("") counts with row 4 applied; row 4's (tagAll) with row 3 + // applied and row 4 cleared. + const r4 = v({ status: "active", tagState: "tagged", tags: new Set(["t1"]) }).counts; + assert.deepStrictEqual([r4[""], r4.tagAll], [2, 2]); // 已出分拣范围: 在哪取消关注 is a row 3 group. 全部 counts with it cleared; its chips count with search and row 4 applied. const out = v({ side: "out", source: "app" }); assert.deepStrictEqual([out.list, out.counts[""], out.counts.source], [["8"], 2, { bili: 1, app: 1 }]); @@ -474,7 +478,8 @@ assert.ok(source.includes("for (const m of [...F.sel]) if (!rows.has(m)) F.sel.d assert.deepStrictEqual(r.list.sort(), ["1", "2"], "活跃 AND 已打标签"); assert.deepStrictEqual([r.counts.active, r.counts.slow, r.counts.stale || 0], [2, 1, 0], "更新状态 counts keep 已打标签, drop their own pick"); assert.deepStrictEqual([r.counts.tagged, r.counts.untagged], [2, 0], "未打标签 / 已打标签 keep 活跃, drop their own pick"); - assert.deepStrictEqual([r.counts[""], r.counts.recent], [5, 1], "全部 = row 3 cleared; AI 刚打的 counted under the other picks"); + assert.deepStrictEqual([r.counts[""], r.counts.recent], [3, 1], "row 3's 全部 = row 3 cleared, row 4's 已打标签 applied; AI 刚打的 counted under the other picks"); + assert.strictEqual(r.counts.tagAll, 2, "row 4's 全部 = row 4 cleared, 活跃 applied"); assert.deepStrictEqual(r.counts.tags, { a: 2, b: 1 }, "row 4 counts the listed UP 主"); r = v({ tags: new Set(["a", "b"]), recentAll: all }); assert.deepStrictEqual(r.list, ["2"], "row 4: AND"); @@ -490,7 +495,7 @@ assert.ok(source.includes("for (const m of [...F.sel]) if (!rows.has(m)) F.sel.d // 视频投稿: row 3 by the video's UP; counts as above. const items = [{ bvid: "x", mid: "1", title: "甲" }, { bvid: "y", mid: "2", title: "乙" }, { bvid: "z", mid: "4", title: "丙" }]; const c = plain(t.feedCounts(items, D, { side: "all", q: "", tagState: "tagged", tags: new Set(["b"]) })); - assert.deepStrictEqual(c, { "": 1, tagged: 1, untagged: 0, tags: { a: 2, b: 1 }, have: ["a", "b"] }); + assert.deepStrictEqual(c, { tagAll: 3, tagged: 1, untagged: 0, tags: { a: 2, b: 1 }, have: ["a", "b"] }, "视频投稿 has no row 3: row 4's 全部 counts with row 4 cleared"); } // B12: the sidebar item is remembered with the rest of the view and checked against the data on load. @@ -662,7 +667,7 @@ const p0Throttled = () => vm.runInContext("MdgErrors.TEXT.biliThrottled", ctx); } // P1 (DESIGN 分拣台): row 4 lists the tags the tab's UP 主 / videos carry, row 3 aside (0 when it leaves none of them), - // as 收藏夹's; into or out of 已出分拣范围 clears 更新状态 and AI 刚打的 too; 视频投稿's 全部 clears only its own row 3; a + // as 收藏夹's; into or out of 已出分拣范围 clears 更新状态 and AI 刚打的 too; row 4's 全部 clears row 4 only and row 3's row 3 only; a // card click makes the card current. { const p = await followPage({ store: page({ follow_tags: [{ id: "t1", name: "科普", color: "#111" }], follow_tag_map: { u1: ["t1"] } }), handlers: { "follow-feed": () => feed } }); @@ -683,9 +688,19 @@ const p0Throttled = () => vm.runInContext("MdgErrors.TEXT.biliThrottled", ctx); F.tagState = "untagged"; p.FW.render(); assert.ok(E.tagRow.innerHTML.includes(chip), `视频投稿: ${E.tagRow.innerHTML}`); + assert.ok(E.bar.innerHTML === "" && /^标签<\/span>')); assert.strictEqual(count(PS.follow, /UI\.labeledRow\("标签", /g), 2, "关注 row 4, UP 主 and 视频投稿"); assert.strictEqual(count(PS.triage, /row-label/g), 0, "分拣台 draws no label of its own"); } diff --git a/extension/triage/triage.selftest.js b/extension/triage/triage.selftest.js index 2abbcad3..b5b1a8dc 100644 --- a/extension/triage/triage.selftest.js +++ b/extension/triage/triage.selftest.js @@ -3382,23 +3382,35 @@ 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(); assert.ok(t.el.tagFilter.innerHTML.startsWith('标签'), "row 4 starts with its name"); assert.ok((t.el.listHeader.innerHTML + t.el.favSel.innerHTML).startsWith('
    `; 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)}`), 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 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 left = removed && [originHtml(it), it.removedAt && `${esc(leftText(it))}`].filter(Boolean).join(""); const verdict = decision ? "" : verdictBadge(b, v); @@ -517,9 +514,6 @@ function cardHtml(it, expanded, mark) { : ""; const step = cardStep(it); const stepBtn = step ? `` : ""; - const refocus = step === "refine" && S.refocus?.bvid === b - ? `
    ` - : ""; const chips = tagIdsOf(b) .map((id) => tagById(id)) @@ -538,7 +532,6 @@ function cardHtml(it, expanded, mark) {
    ${meta.join(" · ")}
    ${body.join("")}
    ${noSubBadge(it)}${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 489d2466..9f02da7f 100644 --- a/extension/triage/triage-bg.js +++ b/extension/triage/triage-bg.js @@ -198,8 +198,7 @@ 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). -// focus: what the user asked this 细看 to center on (a card's 重新细看), "" for none. -function triageBuildMessages(meta, subtitle, comments, criteria, folder, focus = "") { +function triageBuildMessages(meta, subtitle, comments, criteria, folder) { const user = [ `标题:${meta.title}`, `UP主:${meta.upper}`, @@ -211,7 +210,7 @@ function triageBuildMessages(meta, subtitle, comments, criteria, folder, focus = subtitle ? `\n字幕:\n${subtitle}` : `\n${aiSubtitleMissing(meta)}\n热门评论:\n${comments || "无"}` ].join("\n"); return [ - { role: "system", content: triageWithCriteria(TRIAGE_SYSTEM_PROMPT, criteria, folder) + (focus ? `\n\n这次总结的侧重(一句话总结和要点都按它写):${focus}` : "") }, + { role: "system", content: triageWithCriteria(TRIAGE_SYSTEM_PROMPT, criteria, folder) }, { role: "user", content: user } ]; } @@ -660,9 +659,8 @@ async function triageAiSettings() { }; } -async function triageAnalyze({ bvid, force, criteria, folder, focus, aiProviderId }) { +async function triageAnalyze({ bvid, force, criteria, folder, 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]; @@ -686,7 +684,7 @@ async function triageAnalyze({ bvid, force, criteria, folder, focus, aiProviderI 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, focus), triageMaxTokens("analyze", 1, ai), ai.triageThinking, aiProviderId); + 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 analysis = { bvid, status: "done", @@ -698,8 +696,7 @@ async function triageAnalyze({ bvid, force, criteria, folder, focus, aiProviderI ...triageParseLlm(content), model, analyzedAt: Date.now(), - criteria: String(criteria ?? "").trim(), - ...(focus && { focus }) + criteria: String(criteria ?? "").trim() }; 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 de5bfbf0..4e350245 100644 --- a/extension/triage/triage-bg.selftest.js +++ b/extension/triage/triage-bg.selftest.js @@ -114,9 +114,6 @@ 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"); @@ -568,17 +565,6 @@ 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 46b6e6be..241dab6f 100644 --- a/extension/triage/triage.css +++ b/extension/triage/triage.css @@ -484,7 +484,6 @@ 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.html b/extension/triage/triage.html index 927eb044..ef6982b0 100644 --- a/extension/triage/triage.html +++ b/extension/triage/triage.html @@ -327,7 +327,7 @@

    三步分拣

    1. 未分析:点「标题粗看下一批」,AI 只看标题和简介,快、省。
    2. 粗看完成:点「字幕细看下一批」读字幕,给出 AI 总结和要点,拿不准和低置信的排在前面,已保留的不进下一批;有把握的也可以直接按 AI 判断批量取消收藏或保留。
    3. -
    4. 细看完成:按 AI 判断批量取消收藏或保留,也可以一个个按 D / S。卡片上的「✦ 重新细看」可以写上侧重再细看一次。
    5. +
    6. 细看完成:按 AI 判断批量取消收藏或保留,也可以一个个按 D / S。卡片上的「✦ 重新细看」把这一个再细看一次。

    页签只看 AI 做到了哪一步。保留的视频留在原来的页签,用「未决定 / 已保留」筛选;卡片上的「✦ 标题粗看」「✦ 字幕细看」只跑这一个。取消收藏的视频去「已出分拣范围」,那里的「最近取消收藏」可以重新收藏。

    分拣分两层:第一层是收藏夹的判断标准,AI 按它给出 AI 判断(值得留 / 可清理 / 拿不准),决定留不留;第二层是你的标签,按每个标签的说明给视频分类,决定怎么找。
    判断标准在「判断标准」按钮里写,标签说明在「标签管理」里写。

    diff --git a/extension/triage/triage.selftest.js b/extension/triage/triage.selftest.js index b5b1a8dc..6de1ef8b 100644 --- a/extension/triage/triage.selftest.js +++ b/extension/triage/triage.selftest.js @@ -533,7 +533,7 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = // 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 }); + Object.assign(t.S, { titleRes: {}, analyses: {} }); const card = () => t.cardHtml(item(1), false, ""); const btn = () => /data-act="ai-step"[^>]*>(?:]*><\/span>)?([^<]+) Object.fromEntries(Object.entries(d).filter(([, v]) = 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. + // 重新细看 runs right away with force (no 侧重 box, 2026-11-10 用户定); the old result stays until the new one arrives. 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"); + handlers["triage-analyze"] = (m) => (asked.push(plain(m)), new Promise((r) => (release = () => r({ ok: true, data: { bvid: m.bvid, status: "done", verdict: "keep", oneLiner: "新总结" } })))); + t.cardAction("ai-step", "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.ok(!card().includes(" !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()); + assert.deepStrictEqual([t.S.analyses.BV1.oneLiner, t.activityState().text], ["新总结", "重新细看完成 1 个"]); Object.assign(t.S, { titleRes: {}, analyses: {}, decisions: {}, undo: [] }); } From 767c000feec890c8282373323ed8ede8fa1db852 Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Tue, 10 Nov 2026 15:07:54 +0800 Subject: [PATCH 09/17] =?UTF-8?q?=E5=88=86=E6=8B=A3=E5=8F=B0=EF=BC=9A?= =?UTF-8?q?=E6=9C=AA=E5=88=86=E6=9E=90=E7=9A=84=E5=8D=A1=E7=89=87=E4=B8=8D?= =?UTF-8?q?=E6=94=BE=E3=80=8C=E6=A0=87=E9=A2=98=E7=B2=97=E7=9C=8B=E3=80=8D?= =?UTF-8?q?=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 粗看一次请求带 20 个标题才省,单个跑用「标题粗看选中的 N 个」。 --- docs/DESIGN.md | 2 +- docs/TRIAGE.md | 2 +- extension/triage/fav-ai.js | 11 +++++------ extension/triage/triage.html | 2 +- extension/triage/triage.selftest.js | 6 +++--- 5 files changed, 11 insertions(+), 12 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 680f4cc0..9645c438 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -187,7 +187,7 @@ AI 打标签的建议在应用或放弃前一直存着,刷新或重开分拣 - 标签行(第 4 行):只列当前页签里有视频(或 UP 主)带着的标签,打开的标签照样显示。每个标签带数量:收藏夹数当前页签的视频,关注的「UP 主」数 UP 主,「视频投稿」数视频。多选是「并且」,数量不算第 4 行自己的选择。摘读记录的标签行一样,只列列出的记录带着的标签和打开的标签。 - 页签只看 AI 做到了哪一步:未分析、粗看完成、细看完成,再加阅览全部。保留不换页签:保留的视频留在它 AI 做到的那一步。取消收藏的视频不在任何页签里(阅览全部也没有),去「已出分拣范围」;刚取消的还能按 U 撤销。2026-11-10 用户定:去掉「处理完成」。为什么:以前保留就进处理完成,只做过标题粗看就保留的视频再也不能字幕细看,只剩一句 20 字以内的粗看理由。 - 「字幕细看下一批」和「按新标准重新细看下一批」跳过已保留的视频(省 AI 的钱);选中的照跑,保留没保留都一样。下一批只剩已保留的时按钮灰掉,写「已保留的视频不进下一批,选中后可以细看」。2026-11-10 用户定。 -- 卡片 AI 判断那一行有一个 AI 按钮,按 AI 做到哪一步给,决定过的卡片也有:未分析给「✦ 标题粗看」,粗看完成给「✦ 字幕细看」,细看完成给「✦ 重新细看」。没有再粗看一次的按钮。为什么:同一个标题再粗看结果一样,判断标准改了有「按新标准重新粗看」。按钮和卡片的「重试」一样只跑这一个,进度胶囊和完成提示照常;别的 AI 步骤在跑时灰掉写原因。分析失败的卡片只有「重试」。2026-11-10 用户定。 +- 卡片 AI 判断那一行有一个 AI 按钮,按 AI 做到哪一步给,决定过的卡片也有:粗看完成给「✦ 字幕细看」,细看完成给「✦ 重新细看」。未分析的卡片没有,也没有再粗看一次的按钮。为什么:粗看一次请求带 20 个标题才省,单个跑不划算,用「标题粗看选中的 N 个」就行;同一个标题再粗看结果一样,判断标准改了有「按新标准重新粗看」。按钮和卡片的「重试」一样只跑这一个,进度胶囊和完成提示照常;别的 AI 步骤在跑时灰掉写原因。分析失败的卡片只有「重试」。2026-11-10 用户定。 - 「✦ 重新细看」点了直接跳过缓存再细看这一个,不问侧重。新结果回来之前旧结果留着,失败也留着。为什么:判断标准在提示词里排在默认说明前面,整个收藏夹要换摘要写法就改判断标准再「按新标准重新细看」;只关心这一个视频的某个方面,用卡片上的「问 AI」。每个视频各存一份侧重,重跑时带不带它又成了新问题。2026-11-10 用户定(试过「侧重」输入框后删掉)。 - 「标题粗看」和「字幕细看」点一次跑一批:没有选中时按钮写「标题粗看下一批 N 个」「字幕细看下一批 N 个」;有选中时只跑选中的,写「标题粗看选中的 N 个」「字幕细看选中的 N 个」,选中的比一批多时写「标题粗看选中的前 N 个」「字幕细看选中的前 N 个」。按钮留在第 3 行原位,不进选中栏。开跑时选中不变,跑完离开这个页签的卡片才离开选中。一批多少在收藏夹设置里:「标题粗看每批数量」默认 100、最多 1000(粗看用的信息少),「字幕细看每批数量」默认 10、最多 50;都取整数,超出范围改回范围内,留空用默认值。粗看一次 AI 请求固定带 20 个标题,不是设置。「按新标准重新粗看 / 细看」和失败的「重试」按同样的规则:选中的(只算要重做的)优先,否则下一批,按钮写「按新标准重新粗看下一批 N 个」「按新标准重新细看选中的 N 个」这类。有选中、但选中的都不能跑这一步(已失效、分析失败、已经做过…,或都被筛选隐藏)时,按钮不改跑下一批:照样写「标题粗看选中的 N 个」这类(N 是选中的个数),灰掉并写「选中的都被筛选隐藏了」或「选中的都不能标题粗看」「选中的都不能字幕细看」「选中的都不用按新标准重新粗看」「选中的都不能重试」。为什么:有选中时跑了没选的,会让人以为跑的是选中的。失败组头的「全部重试 / 重试选中的 N 个」随点选马上变,写的就是点了会跑的。改了判断标准后,只要「粗看完成」里有按旧标准粗看的就弹「判断标准改了」,不管筛选:正文写总数(被筛掉一部分时补「当前筛选下列出 M 个」),按钮和第 3 行的「按新标准重新粗看」同一批、同样的字,当前筛选下没有可跑的就灰掉写原因。标题粗看或字幕细看正在跑时不弹,改成提示「有 N 个视频是按旧标准粗看的,等字幕细看跑完,点「按新标准重新粗看」就能更新」。为什么:那时按钮点不了,弹一个只能取消的框没用。 - 「全选」「标题粗看下一批 N 个」里的 N 只算你现在看得见的,也就是经过搜索和筛选之后剩下的。被筛掉的视频不受影响,它们的选中状态也保留。选中只属于当前页签:换页签清空选中。跑一步、保留、取消收藏、移动 / 复制都不改选中,好让你看到刚做过的是哪些;只有因此离开这个页签的视频才离开选中。关注也一样:取消关注、重新关注、移到别的分组以后,不在左栏这一项(或「已出分拣范围」这个页签)里的 UP 主才离开选中;换左栏的项不清空选中,没列出的算「被筛选隐藏」。摘读记录页的搜索也照这条:搜不到的保留选中,选中栏的「下载选中的 N 个 .md」「删除选中的 N 个的 AI 对话」的 N 是列出的选中个数,点了只处理它们;选中的都被搜索或筛选隐藏时 N 是选中的个数,选中栏的按钮都灰掉写「选中的都被筛选隐藏了」。「清空全部 AI 对话」以后,只剩 AI 总结或备注、还列着的视频照样选中,删掉的才离开选中。 diff --git a/docs/TRIAGE.md b/docs/TRIAGE.md index a48a5557..9ffaff13 100644 --- a/docs/TRIAGE.md +++ b/docs/TRIAGE.md @@ -30,7 +30,7 @@ - **保留**只在 MoonDigest 里标记,B站收藏夹不变,视频留在原来的页签。**取消收藏**会真的在 B站取消,按 U 可以撤销;取消收藏的视频不在这些页签里,去「已出分拣范围」。 - 第 3 行(状态)有两组筛选,可以一起用:「未决定 / 已保留」看你的决定,「值得留 / 可清理 / 拿不准」只看 AI 判断。比如「已保留」加「可清理」,找出你保留了、AI 却说可以清的。第 4 行(标签)开头是它自己的「全部」和「未打标签 / 已打标签」。两行的「全部」各清各的。 - 「字幕细看下一批」跳过已保留的视频,省 AI 的钱;想细看已保留的,选中再点。 -- 每张卡片 AI 判断那一行有一个按钮,只跑这一个视频:没分析过的是 **✦ 标题粗看**,粗看过的是 **✦ 字幕细看**,细看过的是 **✦ 重新细看**。重新细看跳过缓存再跑一次,用在某次摘要明显不好时;想让整个收藏夹换个写法,改判断标准再「按新标准重新细看」,只想问这一个视频,按 Q 问 AI。 +- 每张卡片 AI 判断那一行有一个按钮,只跑这一个视频:粗看过的是 **✦ 字幕细看**,细看过的是 **✦ 重新细看**。重新细看跳过缓存再跑一次,用在某次摘要明显不好时;想让整个收藏夹换个写法,改判断标准再「按新标准重新细看」,只想问这一个视频,按 Q 问 AI。 - 细看时视频没有字幕,AI 按简介和热门评论判断,卡片上标灰色的「无字幕」(没登录 B站时是「未登录无字幕」,字幕下载失败是「字幕抓取失败」,悬停看原因)。第 3 行的「无字幕」筛选能把它们挑出来。 - 粗看、细看、批量取消收藏和移动在切换收藏夹后继续跑,进度显示在进度胶囊里。关掉分拣台页面就暂停。 - 标题粗看和字幕细看有选中时只跑选中的(最多一批);选中的都不能跑时按钮灰掉,不改跑下一批。AI 回复出错的视频标「分析失败」排在最后,可以逐个或整批重试;Key、余额、连不上这类 AI 平台的问题会停下并提示去设置。 diff --git a/extension/triage/fav-ai.js b/extension/triage/fav-ai.js index a8a6f396..5db46fcb 100644 --- a/extension/triage/fav-ai.js +++ b/extension/triage/fav-ai.js @@ -200,18 +200,17 @@ function retry(bvid) { }); } -// A card's own AI button, whatever the user decided: the next step for this one video (标题粗看, then 字幕细看), and after -// 细看 a 重新细看, 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. +// A card's own AI button, whatever the user decided: 字幕细看 after 粗看, 重新细看 (bypassing the cache) after 细看. None +// before 粗看: 标题粗看 is cheap only in batches of 20 titles, so it stays a batch step. 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)]); +const CARD_STEP = { fine: STEP_NAME.fine, refine: REFINE }; +const cardStep = (it) => (S.mediaId === REMOVED || it.invalid || verdictOf(it).failed ? "" : { coarse: "fine", fine: "refine" }[stageOf(it)] || ""); // A run of one through the step's own runner, as the card's 重试. function runCardStep(bvid) { 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])); startJudging(REFINE, REFINE, [it], () => startGroup([bvid], true, REFINE)); } diff --git a/extension/triage/triage.html b/extension/triage/triage.html index ef6982b0..93d643dc 100644 --- a/extension/triage/triage.html +++ b/extension/triage/triage.html @@ -329,7 +329,7 @@

    三步分拣

  • 粗看完成:点「字幕细看下一批」读字幕,给出 AI 总结和要点,拿不准和低置信的排在前面,已保留的不进下一批;有把握的也可以直接按 AI 判断批量取消收藏或保留。
  • 细看完成:按 AI 判断批量取消收藏或保留,也可以一个个按 D / S。卡片上的「✦ 重新细看」把这一个再细看一次。
  • -

    页签只看 AI 做到了哪一步。保留的视频留在原来的页签,用「未决定 / 已保留」筛选;卡片上的「✦ 标题粗看」「✦ 字幕细看」只跑这一个。取消收藏的视频去「已出分拣范围」,那里的「最近取消收藏」可以重新收藏。

    +

    页签只看 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 6de1ef8b..b3394283 100644 --- a/extension/triage/triage.selftest.js +++ b/extension/triage/triage.selftest.js @@ -530,13 +530,13 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = 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. + // P4: a card's own AI button goes by its AI depth, whatever was decided: none before 粗看 (a batch step only), then + // 字幕细看, 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: {} }); 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"); From 89afbcf49d6265d812a4c48fc9b8853b5b29ad9a Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Tue, 10 Nov 2026 15:25:20 +0800 Subject: [PATCH 10/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=E8=A1=8C=E3=80=8C=E5=85=A8=E9=83=A8=E3=80=8D?= =?UTF-8?q?=E6=8C=AA=E5=88=B0=E6=9C=80=E5=89=8D=E9=9D=A2=EF=BC=8C=E5=92=8C?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E8=A1=8C=E3=80=81=E6=A0=87=E7=AD=BE=E8=A1=8C?= =?UTF-8?q?=E7=9A=84=E3=80=8C=E5=85=A8=E9=83=A8=E3=80=8D=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=EF=BC=9B=E6=A0=87=E7=AD=BE=E8=A1=8C=E7=9A=84=E6=9C=AA=E6=89=93?= =?UTF-8?q?=E6=A0=87=E7=AD=BE=20=C2=B7=20=E5=B7=B2=E6=89=93=E6=A0=87?= =?UTF-8?q?=E7=AD=BE=E7=94=A8=E7=8A=B6=E6=80=81=E6=8C=89=E9=92=AE=E7=9A=84?= =?UTF-8?q?=E6=A0=B7=E5=AD=90=EF=BC=8C=E5=92=8C=E6=A0=87=E7=AD=BE=E4=B9=8B?= =?UTF-8?q?=E9=97=B4=E5=8A=A0=E7=AB=96=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「阅览全部」改叫「全部」。摘读记录和关注的标签行同样生效。 --- docs/DESIGN.md | 4 ++-- docs/TRIAGE.md | 6 +++--- extension/triage/fav-base.js | 2 +- extension/triage/fav-render.js | 3 ++- extension/triage/triage.css | 12 ++++++++---- extension/triage/triage.selftest.js | 4 ++-- 6 files changed, 18 insertions(+), 13 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 9645c438..523dc957 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -141,7 +141,7 @@ AI 打标签的建议在应用或放弃前一直存着,刷新或重开分拣 左栏底部 ⚙ 收藏夹设置 / 关注设置 收藏夹 -第 2 行 未分析 → 粗看完成 → 细看完成 | 阅览全部 +第 2 行 全部 | 未分析 → 粗看完成 → 细看完成 第 3 行 状态 全部 | 未决定 已保留 | 值得留 可清理 拿不准 | 看完了 | 已失效 | 无字幕 | ✦ AI 刚打的 × (所有收藏夹:全部 | 重复收藏 | 未决定 …) 左栏 收藏夹列表 @@ -185,7 +185,7 @@ AI 打标签的建议在应用或放弃前一直存着,刷新或重开分拣 - 第 3 行每组的数量不算本组自己的选择,但算别的组、第 4 行、搜索和左栏。固定的组数量为 0 时变淡,不隐藏;左栏也一样。条件片(看完了、已失效、AI 刚打的)数量为 0 时隐藏,开着的时候照样显示,方便关掉。「全部」的数量是清空第 3 行以后的。 - 标签行(第 4 行):只列当前页签里有视频(或 UP 主)带着的标签,打开的标签照样显示。每个标签带数量:收藏夹数当前页签的视频,关注的「UP 主」数 UP 主,「视频投稿」数视频。多选是「并且」,数量不算第 4 行自己的选择。摘读记录的标签行一样,只列列出的记录带着的标签和打开的标签。 -- 页签只看 AI 做到了哪一步:未分析、粗看完成、细看完成,再加阅览全部。保留不换页签:保留的视频留在它 AI 做到的那一步。取消收藏的视频不在任何页签里(阅览全部也没有),去「已出分拣范围」;刚取消的还能按 U 撤销。2026-11-10 用户定:去掉「处理完成」。为什么:以前保留就进处理完成,只做过标题粗看就保留的视频再也不能字幕细看,只剩一句 20 字以内的粗看理由。 +- 页签行第一个是「全部」(以前叫「阅览全部」,排在最后),后面是未分析、粗看完成、细看完成,只看 AI 做到了哪一步。三行的「全部」上下对齐:页签本来就从第 3、4 行第一个按钮的位置开始,左边是收起头部的按钮,所以不加行名。2026-11-10 用户定。保留不换页签:保留的视频留在它 AI 做到的那一步。取消收藏的视频不在任何页签里(「全部」也没有),去「已出分拣范围」;刚取消的还能按 U 撤销。2026-11-10 用户定:去掉「处理完成」。为什么:以前保留就进处理完成,只做过标题粗看就保留的视频再也不能字幕细看,只剩一句 20 字以内的粗看理由。 - 「字幕细看下一批」和「按新标准重新细看下一批」跳过已保留的视频(省 AI 的钱);选中的照跑,保留没保留都一样。下一批只剩已保留的时按钮灰掉,写「已保留的视频不进下一批,选中后可以细看」。2026-11-10 用户定。 - 卡片 AI 判断那一行有一个 AI 按钮,按 AI 做到哪一步给,决定过的卡片也有:粗看完成给「✦ 字幕细看」,细看完成给「✦ 重新细看」。未分析的卡片没有,也没有再粗看一次的按钮。为什么:粗看一次请求带 20 个标题才省,单个跑不划算,用「标题粗看选中的 N 个」就行;同一个标题再粗看结果一样,判断标准改了有「按新标准重新粗看」。按钮和卡片的「重试」一样只跑这一个,进度胶囊和完成提示照常;别的 AI 步骤在跑时灰掉写原因。分析失败的卡片只有「重试」。2026-11-10 用户定。 - 「✦ 重新细看」点了直接跳过缓存再细看这一个,不问侧重。新结果回来之前旧结果留着,失败也留着。为什么:判断标准在提示词里排在默认说明前面,整个收藏夹要换摘要写法就改判断标准再「按新标准重新细看」;只关心这一个视频的某个方面,用卡片上的「问 AI」。每个视频各存一份侧重,重跑时带不带它又成了新问题。2026-11-10 用户定(试过「侧重」输入框后删掉)。 diff --git a/docs/TRIAGE.md b/docs/TRIAGE.md index 9ffaff13..9865ccee 100644 --- a/docs/TRIAGE.md +++ b/docs/TRIAGE.md @@ -16,9 +16,9 @@ - **标签**属于各自的收藏夹,每个收藏夹默认最多 10 个(收藏夹设置里可改,1–50),一个视频可以打多个。标签那一行的「标签管理」里改名、写说明、删除,**✦ AI 打标签** 用一句话让 AI 给一批视频打标签(每次默认最多新建 5 个,收藏夹设置里可改),你检查后再应用。改标签和应用都能按 U 撤销,你之后又改过的视频不动。 - 两层互不影响:打标签不算处理,AI 判断也不改你的标签。 -## 三个步骤和阅览全部 +## 全部和三个步骤 -页签只表示 AI 看到了哪一步,和你决定了没有无关。 +页签行开头是「全部」,后面三个页签只表示 AI 看到了哪一步,和你决定了没有无关。 | 步骤 | 里面是什么 | 你做什么 | |---|---|---| @@ -26,7 +26,7 @@ | 粗看完成 | AI 看过标题的 | 拿不准的点 **✦ 字幕细看下一批**(读字幕,每批默认 10 个,最多 50)。有把握的可以直接保留或取消收藏 | | 细看完成 | AI 读过字幕的,判断最可靠 | 看 AI 总结和要点,一个个或批量保留、取消收藏 | -- **阅览全部**:整个收藏夹一页看完。 +- **全部**:整个收藏夹一页看完。 - **保留**只在 MoonDigest 里标记,B站收藏夹不变,视频留在原来的页签。**取消收藏**会真的在 B站取消,按 U 可以撤销;取消收藏的视频不在这些页签里,去「已出分拣范围」。 - 第 3 行(状态)有两组筛选,可以一起用:「未决定 / 已保留」看你的决定,「值得留 / 可清理 / 拿不准」只看 AI 判断。比如「已保留」加「可清理」,找出你保留了、AI 却说可以清的。第 4 行(标签)开头是它自己的「全部」和「未打标签 / 已打标签」。两行的「全部」各清各的。 - 「字幕细看下一批」跳过已保留的视频,省 AI 的钱;想细看已保留的,选中再点。 diff --git a/extension/triage/fav-base.js b/extension/triage/fav-base.js index 3e255181..67808f0e 100644 --- a/extension/triage/fav-base.js +++ b/extension/triage/fav-base.js @@ -17,7 +17,7 @@ const STAGES = [ ["coarse", "粗看完成"], ["fine", "细看完成"] ]; -const READ_TAB = "阅览全部"; +const READ_TAB = "全部"; const NEXT_BATCH = "下一批"; // a step's button and the cards it will send // The two AI steps' names, and a run's name: the step, or its redo under a changed 判断标准 (按新标准重新粗看). const STEP_NAME = { coarse: MdgErrors.LABEL.coarseRead, fine: MdgErrors.LABEL.fineRead }; diff --git a/extension/triage/fav-render.js b/extension/triage/fav-render.js index 7ef5dabf..ded36732 100644 --- a/extension/triage/fav-render.js +++ b/extension/triage/fav-render.js @@ -108,8 +108,9 @@ function renderTabs() { UI.setSearchScope(el.searchInput, folderName(S.mediaId), tabName); // 已出分拣范围 has no steps; its tabs are why the videos left (kindOf), 全部 first for the ones not checked yet. const kindTabs = () => UI.kindTabs("data-kindtab", [["", "全部"], ...KINDS].map(([kind, label]) => [kind, label, kind ? S.items.filter((it) => kindOf(it) === kind).length : S.items.length]), S.kindFilter); + // 全部 leads, right above row 3's and row 4's 全部 (2026-11-10 用户定). el.tabs.innerHTML = S.mediaId === REMOVED ? kindTabs() : - steps.join(``) + `` + tab("read", READ_TAB, "read-tab", c.read); + tab("read", READ_TAB, "read-tab", c.read) + `` + steps.join(``); // Row 4: its own 全部 and 未打标签 / 已打标签, then 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, the group's own pick diff --git a/extension/triage/triage.css b/extension/triage/triage.css index 241dab6f..0094f16b 100644 --- a/extension/triage/triage.css +++ b/extension/triage/triage.css @@ -291,7 +291,11 @@ select { height: 28px; max-width: 240px; } .state-row { display: flex; flex-wrap: wrap; align-items: center; row-gap: 8px; min-width: 0; } .state-row > .seg { position: relative; flex: none; flex-wrap: wrap; row-gap: 8px; max-width: 100%; } .state-row > .seg:not(:last-child) { margin-right: 17px; } -.state-row > .seg:not(:last-child)::after { content: ""; position: absolute; top: 5px; right: -9px; height: 16px; border-left: 1px solid var(--border-strong); } +.state-row > .seg:not(:last-child)::after, .tagfilter > .seg::after { content: ""; position: absolute; top: 5px; right: -9px; height: 16px; border-left: 1px solid var(--border-strong); } +/* The tag row's head (全部, 未打标签 / 已打标签) is state, not tags: row 3's unfilled pills, each group ruled off from + what follows (the tags, 4px apart, so 13px + 4px = row 3's 17px). */ +.tagfilter > .seg { position: relative; flex: none; margin-right: 13px; } +.tagfilter > .seg:last-child::after { display: none; } .seg button.st-danger:not([aria-pressed="true"]) { color: var(--danger); } /* 「AI 刚打的」 and its × (不再标出,标签不变) read as one pill. */ .state-row .ai-recent { gap: 0; } @@ -315,9 +319,9 @@ select { height: 28px; max-width: 240px; } .seg button:hover { background: var(--border); } .seg button[aria-pressed="true"] { background: var(--text); color: var(--bg); font-weight: 600; } /* Row 3's pills sit on the white card with no fill; only the picked one is solid. */ -.state-row .seg { gap: 2px; } -.state-row .seg button:not([aria-pressed="true"]), .state-row .ai-recent > .ai-recent-x { background: none; } -.state-row .seg button:not([aria-pressed="true"]):hover, .state-row .ai-recent > .ai-recent-x:hover { background: var(--field-bg); } +:is(.state-row, .tagfilter) .seg { gap: 2px; } +:is(.state-row, .tagfilter) .seg button:not([aria-pressed="true"]), .state-row .ai-recent > .ai-recent-x { background: none; } +:is(.state-row, .tagfilter) .seg button:not([aria-pressed="true"]):hover, .state-row .ai-recent > .ai-recent-x:hover { background: var(--field-bg); } /* A count of 0 stays, dimmed; only chips that exist for a condition (看完了, 已失效, AI 刚打的) leave at 0. */ .seg button.zero:not([aria-pressed="true"]), .side-item.zero:not(.on) { opacity: 0.5; } .tagfilter { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; padding-top: 1px; } diff --git a/extension/triage/triage.selftest.js b/extension/triage/triage.selftest.js index b3394283..7e7345e8 100644 --- a/extension/triage/triage.selftest.js +++ b/extension/triage/triage.selftest.js @@ -917,12 +917,12 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = 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 细看完成"); - // An unfavorited video is in no tab (已出分拣范围 has it); 阅览全部 leaves it out too. + // 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 阅览全部"); + 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. From c9370edc7a8b6413f2d042222d1a170601882465 Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:27:01 +0800 Subject: [PATCH 11/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=E6=94=B9=E6=88=90=E5=92=8C=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E8=A1=8C=E4=B8=80=E6=A0=B7=E7=9A=84=E6=8C=89=E9=92=AE=EF=BC=8C?= =?UTF-8?q?=E4=BF=9D=E7=95=99=E6=AD=A5=E9=AA=A4=E4=B9=8B=E9=97=B4=E7=9A=84?= =?UTF-8?q?=E7=AE=AD=E5=A4=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/DESIGN.md | 4 ++-- extension/triage/triage.css | 24 +++++++++++------------- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 523dc957..161a985d 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -185,7 +185,7 @@ AI 打标签的建议在应用或放弃前一直存着,刷新或重开分拣 - 第 3 行每组的数量不算本组自己的选择,但算别的组、第 4 行、搜索和左栏。固定的组数量为 0 时变淡,不隐藏;左栏也一样。条件片(看完了、已失效、AI 刚打的)数量为 0 时隐藏,开着的时候照样显示,方便关掉。「全部」的数量是清空第 3 行以后的。 - 标签行(第 4 行):只列当前页签里有视频(或 UP 主)带着的标签,打开的标签照样显示。每个标签带数量:收藏夹数当前页签的视频,关注的「UP 主」数 UP 主,「视频投稿」数视频。多选是「并且」,数量不算第 4 行自己的选择。摘读记录的标签行一样,只列列出的记录带着的标签和打开的标签。 -- 页签行第一个是「全部」(以前叫「阅览全部」,排在最后),后面是未分析、粗看完成、细看完成,只看 AI 做到了哪一步。三行的「全部」上下对齐:页签本来就从第 3、4 行第一个按钮的位置开始,左边是收起头部的按钮,所以不加行名。2026-11-10 用户定。保留不换页签:保留的视频留在它 AI 做到的那一步。取消收藏的视频不在任何页签里(「全部」也没有),去「已出分拣范围」;刚取消的还能按 U 撤销。2026-11-10 用户定:去掉「处理完成」。为什么:以前保留就进处理完成,只做过标题粗看就保留的视频再也不能字幕细看,只剩一句 20 字以内的粗看理由。 +- 页签行第一个是「全部」(以前叫「阅览全部」,排在最后),后面是未分析、粗看完成、细看完成,只看 AI 做到了哪一步。三行的「全部」上下对齐:页签本来就从第 3、4 行第一个按钮的位置开始,左边是收起头部的按钮,所以不加行名。页签和第 3、4 行用同一种按钮(没选中的没有底色,选中的实心),不再是文字加粉色下划线;「全部」后面一道竖线,三个步骤之间留「→」。为什么:三行看起来是同一种筛选更好懂;箭头说明 AI 只往前走,不会退回上一步。2026-11-10 用户定。保留不换页签:保留的视频留在它 AI 做到的那一步。取消收藏的视频不在任何页签里(「全部」也没有),去「已出分拣范围」;刚取消的还能按 U 撤销。2026-11-10 用户定:去掉「处理完成」。为什么:以前保留就进处理完成,只做过标题粗看就保留的视频再也不能字幕细看,只剩一句 20 字以内的粗看理由。 - 「字幕细看下一批」和「按新标准重新细看下一批」跳过已保留的视频(省 AI 的钱);选中的照跑,保留没保留都一样。下一批只剩已保留的时按钮灰掉,写「已保留的视频不进下一批,选中后可以细看」。2026-11-10 用户定。 - 卡片 AI 判断那一行有一个 AI 按钮,按 AI 做到哪一步给,决定过的卡片也有:粗看完成给「✦ 字幕细看」,细看完成给「✦ 重新细看」。未分析的卡片没有,也没有再粗看一次的按钮。为什么:粗看一次请求带 20 个标题才省,单个跑不划算,用「标题粗看选中的 N 个」就行;同一个标题再粗看结果一样,判断标准改了有「按新标准重新粗看」。按钮和卡片的「重试」一样只跑这一个,进度胶囊和完成提示照常;别的 AI 步骤在跑时灰掉写原因。分析失败的卡片只有「重试」。2026-11-10 用户定。 - 「✦ 重新细看」点了直接跳过缓存再细看这一个,不问侧重。新结果回来之前旧结果留着,失败也留着。为什么:判断标准在提示词里排在默认说明前面,整个收藏夹要换摘要写法就改判断标准再「按新标准重新细看」;只关心这一个视频的某个方面,用卡片上的「问 AI」。每个视频各存一份侧重,重跑时带不带它又成了新问题。2026-11-10 用户定(试过「侧重」输入框后删掉)。 @@ -322,7 +322,7 @@ AI 打标签的建议在应用或放弃前一直存着,刷新或重开分拣 | 颜色 | token | 用在哪 | |---|---|---| -| 粉 | `--accent` | 你现在在哪、看到哪:当前页签、当前筛选、左栏的当前项(左边一道粉线,字仍是 `--text`)、正在播放的那张卡片;封面上的观看进度条和「✓ 看完了」 | +| 粉 | `--accent` | 你现在在哪、看到哪:当前筛选、左栏的当前项(左边一道粉线,字仍是 `--text`)、正在播放的那张卡片;封面上的观看进度条和「✓ 看完了」 | | 蓝(实心) | `--action` | 下一步该做什么。每个页面只能有一个实心蓝按钮。第一次用时的入口也是它:收藏夹的「选择收藏夹」、关注的「刷新」 | | 浅蓝(蓝框 + 浅蓝底 + 蓝字) | `--action-border` / `--action-bg` / `--link` | 正常在跑的进度胶囊。它可以和实心蓝按钮同时出现:长任务在跑时,进度和「下一步」一样要你注意,而且那个蓝按钮本身就变成了「暂停粗看 N/M」这样的进度 | | 蓝框 + 蓝底 | `--action` | 已选中的卡片(卡片上没有勾) | diff --git a/extension/triage/triage.css b/extension/triage/triage.css index 0094f16b..462928e2 100644 --- a/extension/triage/triage.css +++ b/extension/triage/triage.css @@ -251,22 +251,21 @@ select { height: 28px; max-width: 240px; } .step-actions button { height: 28px; padding: 0 12px; } .step-actions .primary { border: 0; } -/* ---------- steps: text tabs, a pink underline on the open one, counts faint; 阅览 sits apart on the right ---------- */ +/* ---------- steps: pills like row 3's (2026-11-10 用户定), 全部 first and ruled off, the AI steps joined by → (the AI + only moves forward) ---------- */ .tabs:empty { display: none; } -/* Row 2: the tabs keep their underline; search and the mode's buttons sit on the right of the same line. */ +/* Row 2: the tabs; search and the mode's buttons sit on the right of the same line. */ .tabrow { display: flex; flex-wrap: wrap; align-items: center; column-gap: 24px; } .row-tools { display: flex; align-items: center; gap: 2px; margin-left: auto; padding: 6px 0; } /* While it reads, 刷新 gives its place to the progress pill: one thing on screen, not two. */ .row-tools:has(.activity:not([hidden])) .act-btn[aria-busy="true"] { display: none; } -.tabs { display: flex; flex: 0 1 auto; min-width: 0; align-items: center; gap: 20px; } -.tabs button { position: relative; display: flex; align-items: center; gap: 6px; height: 40px; padding: 0; border: 0; border-radius: 0; background: none; color: var(--text-2); font-size: var(--fs-sm); } -.tabs button:hover { background: none; color: var(--text); } -.tabs button[aria-selected="true"] { color: var(--text); font-weight: 600; } -.tabs button[aria-selected="true"]::after { content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 2px; border-radius: 2px; background: var(--accent); } -.tabs .count { color: var(--faint); font: 11px var(--font-mono); } -.tabs .step.zero:not([aria-selected="true"]) { color: var(--faint); } -.tabs .arrow { margin: 0 -12px; color: var(--border-strong); font-size: var(--fs-xs); } -.tabs .tab-sep { flex: 0 0 4px; height: 16px; border-left: 1px solid var(--border-strong); } +.tabs { display: flex; flex: 0 1 auto; min-width: 0; min-height: 40px; align-items: center; gap: 2px; } +.tabs button { display: flex; align-items: center; gap: 4px; height: 26px; padding: 0 12px; border: 0; border-radius: var(--radius-pill); background: none; color: var(--text-2); font-variant-numeric: tabular-nums; } +.tabs button:hover { background: var(--field-bg); } +.tabs button[aria-selected="true"] { background: var(--text); color: var(--bg); font-weight: 600; } +.tabs .step.zero:not([aria-selected="true"]) { opacity: 0.5; } +.tabs .arrow { margin: 0 2px; color: var(--faint); font-size: var(--fs-xs); } +.tabs .tab-sep { flex: 0 0 1px; height: 16px; margin: 0 6px; border-left: 1px solid var(--border-strong); } /* ---------- row 3: state pills in groups | 全选 and the step's buttons. Row 4: my tags | 标签管理 ✦ AI 打标签 ---------- Both rows: filters on the left, their buttons top-right, so the two right ends line up. */ @@ -666,10 +665,9 @@ dialog:has(.settings-ai:not([hidden])) { width: min(800px, calc(100% - 32px)); } .tabs button, .tabs .arrow { flex: none; } /* The fold toggle and the tabs share a line; search and the buttons go under it, the search takes what is left. The rule under that line spans the whole row, under the toggle too: the row draws it (top: the tabs' 40px), and the tabs - keep a clear 1px border so the line stays as tall. z-index -1 keeps it under the selected tab's underline. */ + keep a clear 1px border so the line stays as tall. */ .tabrow .tabs { flex: 1 1 0; border-bottom: 1px solid transparent; } .tabrow::after { content: ""; position: absolute; left: 0; right: 0; top: 40px; z-index: -1; height: 1px; background: var(--border); } - .tabs button[aria-selected="true"]::after { bottom: -1px; } .row-tools { width: 100%; padding: 8px 0; } /* Row 2 is two lines here and its rule sits under the tabs: row 3 gets its own above it. */ .stagebar { border-top: 1px solid var(--border); } From 220540dc291184cd8b625bf9660566fd16a3c7aa Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Tue, 10 Nov 2026 17:57:58 +0800 Subject: [PATCH 12/17] =?UTF-8?q?triage:=20clear=20=E6=97=A0=E5=AD=97?= =?UTF-8?q?=E5=B9=95=20on=20folder=20switch,=20batch=20unfav=20selected=20?= =?UTF-8?q?kept=20videos,=20row=203=20buttons=20wrap=20whole?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/DESIGN.md | 3 ++- docs/TRIAGE.md | 1 + extension/triage/fav-folders.js | 2 +- extension/triage/fav-page.js | 2 +- extension/triage/fav-render.js | 5 +++-- extension/triage/fav-writes.js | 8 +++++--- extension/triage/triage.css | 12 +++++++----- extension/triage/triage.selftest.js | 10 ++++++++-- 8 files changed, 28 insertions(+), 15 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 161a985d..a46beffa 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -187,6 +187,7 @@ AI 打标签的建议在应用或放弃前一直存着,刷新或重开分拣 - 标签行(第 4 行):只列当前页签里有视频(或 UP 主)带着的标签,打开的标签照样显示。每个标签带数量:收藏夹数当前页签的视频,关注的「UP 主」数 UP 主,「视频投稿」数视频。多选是「并且」,数量不算第 4 行自己的选择。摘读记录的标签行一样,只列列出的记录带着的标签和打开的标签。 - 页签行第一个是「全部」(以前叫「阅览全部」,排在最后),后面是未分析、粗看完成、细看完成,只看 AI 做到了哪一步。三行的「全部」上下对齐:页签本来就从第 3、4 行第一个按钮的位置开始,左边是收起头部的按钮,所以不加行名。页签和第 3、4 行用同一种按钮(没选中的没有底色,选中的实心),不再是文字加粉色下划线;「全部」后面一道竖线,三个步骤之间留「→」。为什么:三行看起来是同一种筛选更好懂;箭头说明 AI 只往前走,不会退回上一步。2026-11-10 用户定。保留不换页签:保留的视频留在它 AI 做到的那一步。取消收藏的视频不在任何页签里(「全部」也没有),去「已出分拣范围」;刚取消的还能按 U 撤销。2026-11-10 用户定:去掉「处理完成」。为什么:以前保留就进处理完成,只做过标题粗看就保留的视频再也不能字幕细看,只剩一句 20 字以内的粗看理由。 - 「字幕细看下一批」和「按新标准重新细看下一批」跳过已保留的视频(省 AI 的钱);选中的照跑,保留没保留都一样。下一批只剩已保留的时按钮灰掉,写「已保留的视频不进下一批,选中后可以细看」。2026-11-10 用户定。 +- 批量取消收藏:第 3 行按 AI 判断的按钮只取未决定的视频;选中的已保留视频照样能批量取消收藏(「已保留 + 可清理」全选后一次删)。2026-11-10 用户定。为什么:不选中时一键按 AI 判断删,不该连你明确保留的一起删;选中就是明确的意思。 - 卡片 AI 判断那一行有一个 AI 按钮,按 AI 做到哪一步给,决定过的卡片也有:粗看完成给「✦ 字幕细看」,细看完成给「✦ 重新细看」。未分析的卡片没有,也没有再粗看一次的按钮。为什么:粗看一次请求带 20 个标题才省,单个跑不划算,用「标题粗看选中的 N 个」就行;同一个标题再粗看结果一样,判断标准改了有「按新标准重新粗看」。按钮和卡片的「重试」一样只跑这一个,进度胶囊和完成提示照常;别的 AI 步骤在跑时灰掉写原因。分析失败的卡片只有「重试」。2026-11-10 用户定。 - 「✦ 重新细看」点了直接跳过缓存再细看这一个,不问侧重。新结果回来之前旧结果留着,失败也留着。为什么:判断标准在提示词里排在默认说明前面,整个收藏夹要换摘要写法就改判断标准再「按新标准重新细看」;只关心这一个视频的某个方面,用卡片上的「问 AI」。每个视频各存一份侧重,重跑时带不带它又成了新问题。2026-11-10 用户定(试过「侧重」输入框后删掉)。 - 「标题粗看」和「字幕细看」点一次跑一批:没有选中时按钮写「标题粗看下一批 N 个」「字幕细看下一批 N 个」;有选中时只跑选中的,写「标题粗看选中的 N 个」「字幕细看选中的 N 个」,选中的比一批多时写「标题粗看选中的前 N 个」「字幕细看选中的前 N 个」。按钮留在第 3 行原位,不进选中栏。开跑时选中不变,跑完离开这个页签的卡片才离开选中。一批多少在收藏夹设置里:「标题粗看每批数量」默认 100、最多 1000(粗看用的信息少),「字幕细看每批数量」默认 10、最多 50;都取整数,超出范围改回范围内,留空用默认值。粗看一次 AI 请求固定带 20 个标题,不是设置。「按新标准重新粗看 / 细看」和失败的「重试」按同样的规则:选中的(只算要重做的)优先,否则下一批,按钮写「按新标准重新粗看下一批 N 个」「按新标准重新细看选中的 N 个」这类。有选中、但选中的都不能跑这一步(已失效、分析失败、已经做过…,或都被筛选隐藏)时,按钮不改跑下一批:照样写「标题粗看选中的 N 个」这类(N 是选中的个数),灰掉并写「选中的都被筛选隐藏了」或「选中的都不能标题粗看」「选中的都不能字幕细看」「选中的都不用按新标准重新粗看」「选中的都不能重试」。为什么:有选中时跑了没选的,会让人以为跑的是选中的。失败组头的「全部重试 / 重试选中的 N 个」随点选马上变,写的就是点了会跑的。改了判断标准后,只要「粗看完成」里有按旧标准粗看的就弹「判断标准改了」,不管筛选:正文写总数(被筛掉一部分时补「当前筛选下列出 M 个」),按钮和第 3 行的「按新标准重新粗看」同一批、同样的字,当前筛选下没有可跑的就灰掉写原因。标题粗看或字幕细看正在跑时不弹,改成提示「有 N 个视频是按旧标准粗看的,等字幕细看跑完,点「按新标准重新粗看」就能更新」。为什么:那时按钮点不了,弹一个只能取消的框没用。 @@ -364,7 +365,7 @@ B站 页面上的 AI 判断照上表:值得留绿、可清理红、拿不准 - 第 3、4 行开头是行名「状态」「标签」:浅灰小字(12px、`--faint`),不是胶囊也不是按钮,宽度固定,所以两行第一个胶囊上下对齐,换行的胶囊也缩进对齐。760 及以下照样和第一个胶囊在同一行。 - 四行合成一张白卡(`--surface` 底、`--border` 描边、`--radius-lg` 圆角),卡片放在灰色页面底上,上面留 14px(600 及以下 8px)。卡里左右各留 16px。第 1 行一行写完:封面(原比例 16:10,40px 高)、名字 · 数量、在 B站打开、读取时间。第 2 行 40px 高,页签 13px,数量用等宽字;搜索框和按钮 28px 高。第 3、4 行上下各留 8px,列表上面也留 8px,第 2 行和第 3 行下面各有一道 `--border` 细线(1024 及以下第 2 行是两行,细线画在第 3 行上面)。卡里不再加色带或框。 -- 第 3 行和第 4 行右边的按钮要右对齐。有左栏时(宽于 760),筛选片按组换行,一组放不下才在组内换行,右边按钮钉在第一行右上。没有左栏时(760 及以下),筛选片占满整行,按钮另起一行靠右,正好在第 4 行按钮上方。 +- 第 3 行和第 4 行右边的按钮要右对齐。第 3 行的筛选片和右边按钮并排放得下就同一行;放不下时按钮整组另起一行靠右,筛选片留在一行,不再被挤成一组一行(不用横向滑动:滑走的筛选片和按钮看不见,鼠标也不好滑)。筛选片自己一行都放不下时才按组换行。2026-11-10 用户定。 - 1024 宽时,第 2 行的搜索框和按钮换到页签下面一行。720 宽时没有左栏:左栏变成顶栏的下拉框(选项带数量),⚙ 放到第 2 行最后。 - 头部收起:第 2 行最前面有一个按钮,展开时是 ▴「收起头部」,收起时是 ▾「展开头部」,只有它收起和展开头部。收起后是一条细栏,只剩「名字 · 数量」、第 2 行的页签和右边的搜索 / 刷新 / 导出,没有封面,细栏自己是一张圆角白卡,上面留的空和展开时一样。滚动、回到顶上、焦点进到头部都不改它;切换收藏夹、页签和模式也不改。两种模式各记各的,重开分拣台后照旧;切到另一种模式时,它的收起状态当场生效,和重开分拣台一样不播动画。列表滚动后头部下沿出现阴影(展开时在第 4 行下面,收起时在细栏下面),滚回顶上就消失,说明卡片在头部底下;阴影不改收起状态。列表从头部底下穿过,收起和展开只是平移和淡出,卡片不跳;内置播放框的上沿跟着头部的下沿走。收起和展开用 0.15 秒,系统设了减少动态效果就不动画。760 及以下一样。选中栏和弹窗不管头部收没收,窗口矮也不特殊处理。不设快捷键:它是偶尔才改的布局开关,不是分拣时一直要按的。 - 内置播放框(点卡片后在右边打开的 B站 页面)没有顶栏。从上往下是:收藏夹的「视频标签」行、「UP 标签」行,然后是 B站 页面。关闭是播放框右上角骑在边框上的圆形 ×(`#viewerCloseBtn`,一半在框外,tooltip「关闭播放(Esc)」),或者按 Esc。标签行右边留出空,× 不压住标签。760 及以下内置播放框占掉列表的位置,× 还在同一个角上。 diff --git a/docs/TRIAGE.md b/docs/TRIAGE.md index 9865ccee..2f229684 100644 --- a/docs/TRIAGE.md +++ b/docs/TRIAGE.md @@ -30,6 +30,7 @@ - **保留**只在 MoonDigest 里标记,B站收藏夹不变,视频留在原来的页签。**取消收藏**会真的在 B站取消,按 U 可以撤销;取消收藏的视频不在这些页签里,去「已出分拣范围」。 - 第 3 行(状态)有两组筛选,可以一起用:「未决定 / 已保留」看你的决定,「值得留 / 可清理 / 拿不准」只看 AI 判断。比如「已保留」加「可清理」,找出你保留了、AI 却说可以清的。第 4 行(标签)开头是它自己的「全部」和「未打标签 / 已打标签」。两行的「全部」各清各的。 - 「字幕细看下一批」跳过已保留的视频,省 AI 的钱;想细看已保留的,选中再点。 +- 第 3 行按 AI 判断的批量按钮(如「取消收藏(AI:可清理)」)只动未决定的视频,不碰你保留过的;想取消收藏已保留的,选中再点「取消收藏选中的 N 个」。 - 每张卡片 AI 判断那一行有一个按钮,只跑这一个视频:粗看过的是 **✦ 字幕细看**,细看过的是 **✦ 重新细看**。重新细看跳过缓存再跑一次,用在某次摘要明显不好时;想让整个收藏夹换个写法,改判断标准再「按新标准重新细看」,只想问这一个视频,按 Q 问 AI。 - 细看时视频没有字幕,AI 按简介和热门评论判断,卡片上标灰色的「无字幕」(没登录 B站时是「未登录无字幕」,字幕下载失败是「字幕抓取失败」,悬停看原因)。第 3 行的「无字幕」筛选能把它们挑出来。 - 粗看、细看、批量取消收藏和移动在切换收藏夹后继续跑,进度显示在进度胶囊里。关掉分拣台页面就暂停。 diff --git a/extension/triage/fav-folders.js b/extension/triage/fav-folders.js index 7f595821..f97bbc74 100644 --- a/extension/triage/fav-folders.js +++ b/extension/triage/fav-folders.js @@ -224,7 +224,7 @@ async function openFolder(mediaId) { S.tagFilter.clear(); S.finishedFilter = S.dupFilter = false; S.tagState = S.decisionFilter = ""; - S.aiRecentFilter = false; + S.aiRecentFilter = S.noSubFilter = false; S.kindFilter = ""; S.undo = agentSteps(); S.focused = ""; diff --git a/extension/triage/fav-page.js b/extension/triage/fav-page.js index ef35010a..065f7f56 100644 --- a/extension/triage/fav-page.js +++ b/extension/triage/fav-page.js @@ -427,7 +427,7 @@ function bindEvents() { const batch = nextBatch(); const items = batch.map((b) => S.itemMap.get(b)); startJudging(groupLabel(items), "细看", items, () => startGroup(batch)); - } else if (act === "batch-unfav") fav.unfavMany(batchList(btn.dataset.verdict || null)); + } else if (act === "batch-unfav") fav.unfavMany(batchList(btn.dataset.verdict || null, "unfav")); else if (act === "batch-keep") batchKeep(batchList(btn.dataset.verdict || null)); else if (act === "criteria") openCriteria(); else if (act === "write") stopWrite(); diff --git a/extension/triage/fav-render.js b/extension/triage/fav-render.js index ded36732..2c2658b7 100644 --- a/extension/triage/fav-render.js +++ b/extension/triage/fav-render.js @@ -247,11 +247,12 @@ function renderListHeader(list) { const run = favRun?.job === "unfav" ? favRun : null; if (route === "unfav" && run && !runWhere(run)) return headBtn("batch-unfav", runLine(run), "danger", "正在取消收藏", "", "", true); // A selection none of which this can take still counts the selection (as 粗看's), disabled with the reason. - const can = none ? S.selected.size : batchList(verdict || null).length; + const can = none ? S.selected.size : batchList(verdict || null, route).length; const n = can || (verdict ? 0 : selectedIn(visibleItems()).length); const verb = route === "unfav" ? "取消收藏" : "保留"; // One batch 取消收藏 at a time: while another folder's runs, this one waits. - const reason = none || (!can ? (verdict ? `这里没有 AI 判断为${VERDICTS[verdict]}、还没处理的视频` : "选中的都已经处理过了") + const empty = verdict ? `这里没有 AI 判断为${VERDICTS[verdict]}、未决定的视频` : route === "unfav" ? "选中的都已取消收藏" : "选中的都已保留"; + const reason = none || (!can ? empty : route === "unfav" && run ? UI.busyText(`「${folderName(run.mediaId)}」的${UNFAV_MANY}`) : route === "unfav" ? UI.busyText(favBusy) : ""); return headBtn(`batch-${route}`, verdict ? `${verb}(AI:${VERDICTS[verdict]})${n} 个` : `${verb}选中的 ${n} 个`, route === "unfav" ? "danger" : "", reason, verdict, route === "keep" ? KEEP_TIP : ""); diff --git a/extension/triage/fav-writes.js b/extension/triage/fav-writes.js index 34983c36..9ba2f27c 100644 --- a/extension/triage/fav-writes.js +++ b/extension/triage/fav-writes.js @@ -572,9 +572,11 @@ function undo() { // Batch buttons act on the selected cards listed (none when filters hide them all), otherwise on every card with this // verdict (none without one). -// Not processed, nor waiting in a running B站 write (a batch 取消收藏 or 移动 will decide it). -function batchList(verdict) { - const list = visibleItems().filter((it) => !isProcessed(it.bvid) && !W.pending(it.bvid)); +// Undecided videos only, except that a selected 保留 one can still be 取消收藏'd: a verdict-wide 取消收藏 never takes what +// you kept. Neither takes one waiting in a running B站 write. +function batchList(verdict, route = "keep") { + const open = (b) => (route === "unfav" && S.selected.size ? S.decisions[b]?.action !== "unfav" : !isProcessed(b)); + const list = visibleItems().filter((it) => open(it.bvid) && !W.pending(it.bvid)); return S.selected.size ? selectedIn(list) : verdict == null ? [] : list.filter((it) => verdictOf(it).verdict === verdict); } diff --git a/extension/triage/triage.css b/extension/triage/triage.css index 462928e2..62033857 100644 --- a/extension/triage/triage.css +++ b/extension/triage/triage.css @@ -269,13 +269,17 @@ select { height: 28px; max-width: 240px; } /* ---------- row 3: state pills in groups | 全选 and the step's buttons. Row 4: my tags | 标签管理 ✦ AI 打标签 ---------- Both rows: filters on the left, their buttons top-right, so the two right ends line up. */ -.stagebar, .tagbar { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; column-gap: 16px; row-gap: 8px; } +.tagbar { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; column-gap: 16px; row-gap: 8px; } +/* Row 3 keeps its pills on one line: when pills and buttons do not fit side by side, the buttons drop to their own line + at the right instead of squeezing the pills into a column. */ +.stagebar { display: flex; flex-wrap: wrap; align-items: flex-start; column-gap: 16px; row-gap: 8px; } +.stagebar .class-filter { flex: 0 1 auto; } /* Each row reads as one line left to right: more room between rows 2, 3 and 4, and a thin rule under row 3 as under row 2 (the tabs). */ .stagebar, .tagbar { padding-top: 8px; padding-bottom: 8px; } /* 关注's row 3 with nothing in it (视频投稿 outside a refresh): no empty band. */ .fw-bar:has(#fwBar:empty):has(#fwRunSlot:empty) { display: none; } -.stagebar .list-header { grid-column: 2; margin-left: 0; } +.stagebar .list-header { flex: none; max-width: 100%; margin-left: auto; } .stagebar .step-actions { flex-wrap: nowrap; } .stagebar .class-filter, .tagbar .tagfilter { min-height: 28px; } .tagbar > :last-child { grid-column: 2; } @@ -300,10 +304,8 @@ select { height: 28px; max-width: 240px; } .state-row .ai-recent { gap: 0; } .state-row .ai-recent > button[aria-pressed] { border-radius: var(--radius-pill) 0 0 var(--radius-pill); } .state-row .ai-recent > .ai-recent-x { height: 26px; padding: 0 9px 0 6px; border: 0; border-left: 1px solid var(--surface); border-radius: 0 var(--radius-pill) var(--radius-pill) 0; background: var(--field-bg); color: var(--muted); } -/* No sidebar: row 3's pills take the whole line; its buttons go under them at the right, just above row 4's. */ +/* Too narrow even for the buttons alone: they wrap among themselves. */ @media (max-width: 760px) { - .stagebar { grid-template-columns: minmax(0, 1fr); } - .stagebar .list-header { grid-column: 1; justify-self: end; } .stagebar .step-actions { flex-wrap: wrap; } } /* 全选 (shared.js selectAllBox): a checkbox drawn in a button. Empty box, blue bar = some, blue ✓ = all listed. */ diff --git a/extension/triage/triage.selftest.js b/extension/triage/triage.selftest.js index 7e7345e8..d379277b 100644 --- a/extension/triage/triage.selftest.js +++ b/extension/triage/triage.selftest.js @@ -649,7 +649,7 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = assert.deepStrictEqual(plain([...t.S.selected]), ["BV61", "BV62"], "阅览 still lists them: 保留 keeps them selected"); // K1: none of the selection left to keep: the count is still the selection's, off with why. t.renderListHeader(t.visibleItems()); - assert.ok(/aria-label="保留选中的 2 个"[^>]*disabled title="选中的都已经处理过了"/.test(t.el.favSel.innerHTML), t.el.favSel.innerHTML); + assert.ok(/aria-label="保留选中的 2 个"[^>]*disabled title="选中的都已保留"/.test(t.el.favSel.innerHTML), t.el.favSel.innerHTML); 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 @@ -993,7 +993,11 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = 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"); + assert.deepStrictEqual([t.batchList("keep").length, t.batchList("unsure").length], [0, 0], "verdict 保留 batches take only undecided videos"); + assert.strictEqual(t.batchList("unsure", "unfav").length, 0, "a verdict-wide 取消收藏 leaves kept videos alone"); + t.S.selected.add("BV200"); + assert.deepStrictEqual(plain(t.batchList("unsure", "unfav").map((it) => it.bvid)), ["BV200"], "a selected kept video can be 取消收藏'd in a batch"); + t.S.selected.clear(); // 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" }; @@ -2642,8 +2646,10 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = t.el.folderSelect = { value: "", querySelector: () => null }; store.triage_snapshot_2 = { bvids: ["BV2"], ids: ["BV2"], items: [item(2)], intro: "简介" }; handlers["triage-folder-ids"] = () => ({ ok: true, data: { bvids: ["BV2"] } }); + t.S.noSubFilter = true; await t.openFolder("2"); await new Promise((r) => setImmediate(r)); + assert.strictEqual(t.S.noSubFilter, false, "another folder starts without the 无字幕 filter"); assert.deepStrictEqual([synced, plain(t.S.items.map((it) => it.bvid)), t.S.folderIntro["2"]], [0, ["BV2"], "简介"]); handlers["triage-folder-ids"] = () => ({ ok: true, data: { bvids: ["BV2", "BV8"] } }); await t.openFolder("2"); From 9ac6de4d1611944f8c951076bffafefca3eacad5 Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Tue, 10 Nov 2026 18:05:55 +0800 Subject: [PATCH 13/17] =?UTF-8?q?triage:=20=E6=97=A0=E5=AD=97=E5=B9=95=20c?= =?UTF-8?q?hip=20right=20after=20the=20AI=20verdict=20group?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/DESIGN.md | 4 ++-- extension/triage/fav-render.js | 5 +++-- extension/triage/triage.selftest.js | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index a46beffa..94bc98d4 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -142,7 +142,7 @@ AI 打标签的建议在应用或放弃前一直存着,刷新或重开分拣 收藏夹 第 2 行 全部 | 未分析 → 粗看完成 → 细看完成 -第 3 行 状态 全部 | 未决定 已保留 | 值得留 可清理 拿不准 | 看完了 | 已失效 | 无字幕 | ✦ AI 刚打的 × +第 3 行 状态 全部 | 未决定 已保留 | 值得留 可清理 拿不准 | 无字幕 | 看完了 | 已失效 | ✦ AI 刚打的 × (所有收藏夹:全部 | 重复收藏 | 未决定 …) 左栏 收藏夹列表 @@ -162,7 +162,7 @@ AI 打标签的建议在应用或放弃前一直存着,刷新或重开分拣 - 收藏夹第 3 行有两组互相独立的单选:「未决定 / 已保留」看你的决定,「值得留 / 可清理 / 拿不准」只看 AI 判断,不管你决定了没有。「已保留」加「可清理」就是保留了、但 AI 说可以清的。每个页签都有这两组;「未分析」没有 AI 判断,不画第二组(不是 0,是不可能有)。卡片照旧:决定过的卡片显示决定,不显示 AI 判断。2026-11-10 用户定。为什么:以前决定会顶替 AI 判断,保留过的就筛不出 AI 说可以清的。 - 第 3 行按 AI 判断的批量按钮(「取消收藏(AI:可清理)N 个」这类)只算未决定的视频。 - 「待查」的全称「慢更或断更 · 待查」写在 tooltip 里。 -- 只是有时才有的条件片(看完了、已失效、无字幕、AI 刚打的)放在第 3 行最后,平时的布局不会因为它们出现或消失而挪动。例外:「重复收藏」(在几个勾选的收藏夹里都有的视频)只在「所有收藏夹」里有,单独一组放在「全部」后面;和已失效一样,数量为 0 时隐藏(开着时照样显示)。「视频投稿」还没有观看进度,所以没有「看完了」。 +- 只是有时才有的条件片(看完了、已失效、AI 刚打的)放在第 3 行最后,平时的布局不会因为它们出现或消失而挪动。例外:「无字幕」紧跟在 AI 判断那组后面,它说的是这个判断只凭简介和评论(2026-11-10 用户定)。例外:「重复收藏」(在几个勾选的收藏夹里都有的视频)只在「所有收藏夹」里有,单独一组放在「全部」后面;和已失效一样,数量为 0 时隐藏(开着时照样显示)。「视频投稿」还没有观看进度,所以没有「看完了」。 - 「全选」放在第 3 行右边按钮组的最前面,条件片出现或消失时它不动。「视频投稿」没有全选。「所有收藏夹」和具体收藏夹一样有全选,选的是列出的视频,不分收藏夹。选中栏里不放全选。第 3 行没有「多选」开关:点卡片就是选中(见下面的「卡片」)。 - 排序放在第 1 行右边。「视频投稿」的排序只有「更新时间」(默认,B站 给的顺序,新的在前)和「随机」。「更新时间」没有方向按钮:视频投稿往下一直能加载,没有最旧的一头可以倒过来。「随机」打乱已加载的视频;之后滚动加载的每一页单独打乱,接在最下面,已经显示的卡片不动。「随机」时方向按钮的位置换成「↻」(悬停写「再随机一次」),点了重新打乱全部已加载的并回到顶部;为什么:下拉框里再选一次「随机」不算改动,没有别的办法重新打乱。换回「更新时间」恢复 B站 的顺序,不重新读取。两个页签的排序都随视图记住。「已出分拣范围」时标题写「已出分拣范围 · N」。 - 搜索框放在第 2 行,两种模式各记各的搜索词。提示文字(和读屏用的 aria-label)要写出搜索范围,比如收藏夹的「在「想学的技能」 · 未分析里搜」、关注的「在「硬核科普」 · 活跃里搜」。开了搜索或任何筛选时,搜索框旁边显示「N 个结果」。 diff --git a/extension/triage/fav-render.js b/extension/triage/fav-render.js index 2c2658b7..b0fe0aa2 100644 --- a/extension/triage/fav-render.js +++ b/extension/triage/fav-render.js @@ -196,14 +196,15 @@ function stateRowHtml(t = S.tab) { if (t !== "none") { 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(""))); } + // Right after the verdict it qualifies: judged from the intro and comments only. + 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 seenN = cnt("seen", isFinished); if ((S.seenCfg.mark && seenN) || S.finishedFilter) groups.push(group("看完了", UI.filterBtn('data-finishedfilter title="只看 B站历史记录里看完了的视频"', "看完了", seenN, S.finishedFilter))); // 已出分拣范围 has 已失效 as a tab instead. 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(""); diff --git a/extension/triage/triage.selftest.js b/extension/triage/triage.selftest.js index d379277b..970cd1b9 100644 --- a/extension/triage/triage.selftest.js +++ b/extension/triage/triage.selftest.js @@ -3455,7 +3455,7 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = openFake("N", [item(1), item(2), item(3)]); Object.assign(t.S, { titleRes: {}, analyses: { BV1: { status: "done", source: "meta", verdict: "keep" }, BV2: { status: "done", source: "subtitle", verdict: "keep" } }, tab: "fine", classFilter: { coarse: "all", fine: "all", read: "all" } }); const groups = () => [...row3().matchAll(/role="group" aria-label="([^"]+)"/g)].map((m) => m[1]); - assert.ok(row3().includes(">无字幕 1<") && groups().at(-1) === "无字幕", groups().join()); + assert.ok(row3().includes(">无字幕 1<") && groups()[groups().indexOf("按 AI 判断筛选") + 1] === "无字幕", groups().join()); t.pickState("nosub"); assert.deepStrictEqual(plain(t.visibleItems().map((it) => it.bvid)), ["BV1"]); t.pickState("all"); From 08d4a5955ef90db6c5e2cd6fe9bae509e21cf775 Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Tue, 10 Nov 2026 18:25:16 +0800 Subject: [PATCH 14/17] =?UTF-8?q?triage:=20row=203=20is=20one=20pick:=20?= =?UTF-8?q?=E5=B7=B2=E4=BF=9D=E7=95=99=20|=20=E5=BE=85=E5=86=B3=E5=AE=9A?= =?UTF-8?q?=20with=20the=20AI=20verdicts=20boxed=20after=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/DESIGN.md | 10 +++--- docs/TRIAGE.md | 4 +-- extension/triage/fav-base.js | 13 ++++--- extension/triage/fav-folders.js | 4 +-- extension/triage/fav-page.js | 1 - extension/triage/fav-render.js | 28 +++++++-------- extension/triage/triage.css | 2 ++ extension/triage/triage.html | 2 +- extension/triage/triage.selftest.js | 55 +++++++++++++++-------------- 9 files changed, 58 insertions(+), 61 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 94bc98d4..1bd91307 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -142,8 +142,8 @@ AI 打标签的建议在应用或放弃前一直存着,刷新或重开分拣 收藏夹 第 2 行 全部 | 未分析 → 粗看完成 → 细看完成 -第 3 行 状态 全部 | 未决定 已保留 | 值得留 可清理 拿不准 | 无字幕 | 看完了 | 已失效 | ✦ AI 刚打的 × - (所有收藏夹:全部 | 重复收藏 | 未决定 …) +第 3 行 状态 全部 | 已保留 | 待决定 (值得留 可清理 拿不准) | 无字幕 | 看完了 | 已失效 | ✦ AI 刚打的 × + (所有收藏夹:全部 | 重复收藏 | 已保留 …) 左栏 收藏夹列表 关注 · UP 主 @@ -159,8 +159,8 @@ AI 打标签的建议在应用或放弃前一直存着,刷新或重开分拣 - 收藏夹和关注的第 4 行开头是自己的「全部」,接着「未打标签 / 已打标签」(视频投稿按视频的 UP 算,写「UP 未打标签 / UP 已打标签」),再是标签。收藏夹按卡片上显示的标签算:「所有收藏夹」里只算视频所在收藏夹的标签。2026-11-10 用户定:以前这组在第 3 行。为什么:有没有标签是你自己打的,和标签放一行。 - 第 3、4 行用细竖线分组。组内单选(标签片多选,「并且」),再点一次已选中的就取消;组和组之间、两行之间都是「并且」。第 3 行的「全部」只清空第 3 行,第 4 行的「全部」只清空第 4 行(有没有标签和标签片),都不碰左栏。两个「全部」样子一样,所在行什么都没开时是按下的。2026-11-10 用户定。 -- 收藏夹第 3 行有两组互相独立的单选:「未决定 / 已保留」看你的决定,「值得留 / 可清理 / 拿不准」只看 AI 判断,不管你决定了没有。「已保留」加「可清理」就是保留了、但 AI 说可以清的。每个页签都有这两组;「未分析」没有 AI 判断,不画第二组(不是 0,是不可能有)。卡片照旧:决定过的卡片显示决定,不显示 AI 判断。2026-11-10 用户定。为什么:以前决定会顶替 AI 判断,保留过的就筛不出 AI 说可以清的。 -- 第 3 行按 AI 判断的批量按钮(「取消收藏(AI:可清理)N 个」这类)只算未决定的视频。 +- 收藏夹第 3 行的「已保留」「待决定」和三个 AI 判断是同一个单选。保留过的视频只算「已保留」,不再算 AI 判断,和卡片一致(保留过的卡片不显示 AI 判断)。「待决定」是还没保留的视频,三个 AI 判断是它的细分,套在它后面的细框里,个数加起来等于它(「全部」和「未分析」里还有 AI 没看过的,所以会少)。「未分析」没有 AI 判断,只有「已保留」「待决定」。2026-11-10 用户定。为什么:人工判断过就不需要 AI 判断;上一版两组能叠加,「已保留 + 可清理」这种组合没有意义。框而不用括号或箭头:箭头在页签行表示 AI 往前走,括号像文字。 +- 第 3 行按 AI 判断的批量按钮(「取消收藏(AI:可清理)N 个」这类)只算待决定的视频。 - 「待查」的全称「慢更或断更 · 待查」写在 tooltip 里。 - 只是有时才有的条件片(看完了、已失效、AI 刚打的)放在第 3 行最后,平时的布局不会因为它们出现或消失而挪动。例外:「无字幕」紧跟在 AI 判断那组后面,它说的是这个判断只凭简介和评论(2026-11-10 用户定)。例外:「重复收藏」(在几个勾选的收藏夹里都有的视频)只在「所有收藏夹」里有,单独一组放在「全部」后面;和已失效一样,数量为 0 时隐藏(开着时照样显示)。「视频投稿」还没有观看进度,所以没有「看完了」。 - 「全选」放在第 3 行右边按钮组的最前面,条件片出现或消失时它不动。「视频投稿」没有全选。「所有收藏夹」和具体收藏夹一样有全选,选的是列出的视频,不分收藏夹。选中栏里不放全选。第 3 行没有「多选」开关:点卡片就是选中(见下面的「卡片」)。 @@ -187,7 +187,7 @@ AI 打标签的建议在应用或放弃前一直存着,刷新或重开分拣 - 标签行(第 4 行):只列当前页签里有视频(或 UP 主)带着的标签,打开的标签照样显示。每个标签带数量:收藏夹数当前页签的视频,关注的「UP 主」数 UP 主,「视频投稿」数视频。多选是「并且」,数量不算第 4 行自己的选择。摘读记录的标签行一样,只列列出的记录带着的标签和打开的标签。 - 页签行第一个是「全部」(以前叫「阅览全部」,排在最后),后面是未分析、粗看完成、细看完成,只看 AI 做到了哪一步。三行的「全部」上下对齐:页签本来就从第 3、4 行第一个按钮的位置开始,左边是收起头部的按钮,所以不加行名。页签和第 3、4 行用同一种按钮(没选中的没有底色,选中的实心),不再是文字加粉色下划线;「全部」后面一道竖线,三个步骤之间留「→」。为什么:三行看起来是同一种筛选更好懂;箭头说明 AI 只往前走,不会退回上一步。2026-11-10 用户定。保留不换页签:保留的视频留在它 AI 做到的那一步。取消收藏的视频不在任何页签里(「全部」也没有),去「已出分拣范围」;刚取消的还能按 U 撤销。2026-11-10 用户定:去掉「处理完成」。为什么:以前保留就进处理完成,只做过标题粗看就保留的视频再也不能字幕细看,只剩一句 20 字以内的粗看理由。 - 「字幕细看下一批」和「按新标准重新细看下一批」跳过已保留的视频(省 AI 的钱);选中的照跑,保留没保留都一样。下一批只剩已保留的时按钮灰掉,写「已保留的视频不进下一批,选中后可以细看」。2026-11-10 用户定。 -- 批量取消收藏:第 3 行按 AI 判断的按钮只取未决定的视频;选中的已保留视频照样能批量取消收藏(「已保留 + 可清理」全选后一次删)。2026-11-10 用户定。为什么:不选中时一键按 AI 判断删,不该连你明确保留的一起删;选中就是明确的意思。 +- 批量取消收藏:第 3 行按 AI 判断的按钮只取待决定的视频;选中的已保留视频照样能批量取消收藏(筛「已保留」后全选再删)。2026-11-10 用户定。为什么:不选中时一键按 AI 判断删,不该连你明确保留的一起删;选中就是明确的意思。 - 卡片 AI 判断那一行有一个 AI 按钮,按 AI 做到哪一步给,决定过的卡片也有:粗看完成给「✦ 字幕细看」,细看完成给「✦ 重新细看」。未分析的卡片没有,也没有再粗看一次的按钮。为什么:粗看一次请求带 20 个标题才省,单个跑不划算,用「标题粗看选中的 N 个」就行;同一个标题再粗看结果一样,判断标准改了有「按新标准重新粗看」。按钮和卡片的「重试」一样只跑这一个,进度胶囊和完成提示照常;别的 AI 步骤在跑时灰掉写原因。分析失败的卡片只有「重试」。2026-11-10 用户定。 - 「✦ 重新细看」点了直接跳过缓存再细看这一个,不问侧重。新结果回来之前旧结果留着,失败也留着。为什么:判断标准在提示词里排在默认说明前面,整个收藏夹要换摘要写法就改判断标准再「按新标准重新细看」;只关心这一个视频的某个方面,用卡片上的「问 AI」。每个视频各存一份侧重,重跑时带不带它又成了新问题。2026-11-10 用户定(试过「侧重」输入框后删掉)。 - 「标题粗看」和「字幕细看」点一次跑一批:没有选中时按钮写「标题粗看下一批 N 个」「字幕细看下一批 N 个」;有选中时只跑选中的,写「标题粗看选中的 N 个」「字幕细看选中的 N 个」,选中的比一批多时写「标题粗看选中的前 N 个」「字幕细看选中的前 N 个」。按钮留在第 3 行原位,不进选中栏。开跑时选中不变,跑完离开这个页签的卡片才离开选中。一批多少在收藏夹设置里:「标题粗看每批数量」默认 100、最多 1000(粗看用的信息少),「字幕细看每批数量」默认 10、最多 50;都取整数,超出范围改回范围内,留空用默认值。粗看一次 AI 请求固定带 20 个标题,不是设置。「按新标准重新粗看 / 细看」和失败的「重试」按同样的规则:选中的(只算要重做的)优先,否则下一批,按钮写「按新标准重新粗看下一批 N 个」「按新标准重新细看选中的 N 个」这类。有选中、但选中的都不能跑这一步(已失效、分析失败、已经做过…,或都被筛选隐藏)时,按钮不改跑下一批:照样写「标题粗看选中的 N 个」这类(N 是选中的个数),灰掉并写「选中的都被筛选隐藏了」或「选中的都不能标题粗看」「选中的都不能字幕细看」「选中的都不用按新标准重新粗看」「选中的都不能重试」。为什么:有选中时跑了没选的,会让人以为跑的是选中的。失败组头的「全部重试 / 重试选中的 N 个」随点选马上变,写的就是点了会跑的。改了判断标准后,只要「粗看完成」里有按旧标准粗看的就弹「判断标准改了」,不管筛选:正文写总数(被筛掉一部分时补「当前筛选下列出 M 个」),按钮和第 3 行的「按新标准重新粗看」同一批、同样的字,当前筛选下没有可跑的就灰掉写原因。标题粗看或字幕细看正在跑时不弹,改成提示「有 N 个视频是按旧标准粗看的,等字幕细看跑完,点「按新标准重新粗看」就能更新」。为什么:那时按钮点不了,弹一个只能取消的框没用。 diff --git a/docs/TRIAGE.md b/docs/TRIAGE.md index 2f229684..727df8b3 100644 --- a/docs/TRIAGE.md +++ b/docs/TRIAGE.md @@ -28,9 +28,9 @@ - **全部**:整个收藏夹一页看完。 - **保留**只在 MoonDigest 里标记,B站收藏夹不变,视频留在原来的页签。**取消收藏**会真的在 B站取消,按 U 可以撤销;取消收藏的视频不在这些页签里,去「已出分拣范围」。 -- 第 3 行(状态)有两组筛选,可以一起用:「未决定 / 已保留」看你的决定,「值得留 / 可清理 / 拿不准」只看 AI 判断。比如「已保留」加「可清理」,找出你保留了、AI 却说可以清的。第 4 行(标签)开头是它自己的「全部」和「未打标签 / 已打标签」。两行的「全部」各清各的。 +- 第 3 行(状态)的「已保留」是你保留过的;「待决定」是还没保留的,后面框里的「值得留 / 可清理 / 拿不准」是 AI 对它们的判断。一次选一个。保留过的视频不再按 AI 判断分。第 4 行(标签)开头是它自己的「全部」和「未打标签 / 已打标签」。两行的「全部」各清各的。 - 「字幕细看下一批」跳过已保留的视频,省 AI 的钱;想细看已保留的,选中再点。 -- 第 3 行按 AI 判断的批量按钮(如「取消收藏(AI:可清理)」)只动未决定的视频,不碰你保留过的;想取消收藏已保留的,选中再点「取消收藏选中的 N 个」。 +- 第 3 行按 AI 判断的批量按钮(如「取消收藏(AI:可清理)」)只动待决定的视频,不碰你保留过的;想取消收藏已保留的,选中再点「取消收藏选中的 N 个」。 - 每张卡片 AI 判断那一行有一个按钮,只跑这一个视频:粗看过的是 **✦ 字幕细看**,细看过的是 **✦ 重新细看**。重新细看跳过缓存再跑一次,用在某次摘要明显不好时;想让整个收藏夹换个写法,改判断标准再「按新标准重新细看」,只想问这一个视频,按 Q 问 AI。 - 细看时视频没有字幕,AI 按简介和热门评论判断,卡片上标灰色的「无字幕」(没登录 B站时是「未登录无字幕」,字幕下载失败是「字幕抓取失败」,悬停看原因)。第 3 行的「无字幕」筛选能把它们挑出来。 - 粗看、细看、批量取消收藏和移动在切换收藏夹后继续跑,进度显示在进度胶囊里。关掉分拣台页面就暂停。 diff --git a/extension/triage/fav-base.js b/extension/triage/fav-base.js index 67808f0e..c09f544b 100644 --- a/extension/triage/fav-base.js +++ b/extension/triage/fav-base.js @@ -115,7 +115,6 @@ const S = { noSubFilter: false, // 无字幕 dupFilter: false, // 所有收藏夹 only: 重复收藏 tagState: "", // row 4: "" | "untagged" | "tagged" - decisionFilter: "", // row 3: "" | "undecided" | "kept" aiRecent: {}, sortBy: {}, // triage_sort aiRecentFilter: false, // AI 刚打的 @@ -132,7 +131,7 @@ const S = { thinkingToggle: false }, tab: "none", - classFilter: { coarse: "all", fine: "all", read: "all" }, // each tab keeps its own AI-class chip; a folder switch resets them + classFilter: { none: "all", coarse: "all", fine: "all", read: "all" }, // each tab's one pick of 已保留 / 待决定 / an AI verdict; a folder switch resets them tagFilter: new Set(), query: "", focused: "", @@ -565,7 +564,6 @@ const ROW4 = new Set(["tagged", "tags"]); function passFilter(it, skip = "") { const on = (g) => skip !== g && !(skip === (ROW4.has(g) ? "tagrow" : "states")); 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; @@ -596,12 +594,13 @@ function stageOf(it) { const inStage = (it, tab) => !isUnfaved(it) && (tab === "read" || stageOf(it) === tab); function inTab(it, tab) { if (!inStage(it, tab)) return false; - const f = S.classFilter[tab]; - return !f || f === "all" || classOf(it) === f; + return inClass(it, S.classFilter[tab]); } +const inClass = (it, f) => !f || f === "all" || (f === "undecided" ? !isKept(it) : classOf(it) === f); -// 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; +// The row-3 chip a card falls under: once kept it leaves the AI verdicts, as its card no longer shows one. +const UNDECIDED = "待决定"; +const classOf = (it) => (isKept(it) ? "kept" : 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 细看. diff --git a/extension/triage/fav-folders.js b/extension/triage/fav-folders.js index f97bbc74..9853bc4d 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.decisionFilter = ""; + S.tagState = ""; S.aiRecentFilter = S.noSubFilter = false; S.kindFilter = ""; S.undo = agentSteps(); @@ -244,7 +244,7 @@ async function openFolder(mediaId) { // A failed first read (limit, offline) still leaves the last folder's step and chips behind; a later folder owns the page. if (!ok && !run.alive) return; S.tab = removed ? "read" : currentStage(stageCounts()); - S.classFilter = { coarse: "all", fine: "all", read: "all" }; + S.classFilter = { none: "all", coarse: "all", fine: "all", read: "all" }; S.focused = visibleItems()[0]?.bvid || ""; render(); } diff --git a/extension/triage/fav-page.js b/extension/triage/fav-page.js index 065f7f56..1f78f9f9 100644 --- a/extension/triage/fav-page.js +++ b/extension/triage/fav-page.js @@ -380,7 +380,6 @@ 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-nosubfilter]")) pickState("nosub"); else if (b.matches("[data-dupfilter]")) pickState("dup"); diff --git a/extension/triage/fav-render.js b/extension/triage/fav-render.js index b0fe0aa2..6d2d298d 100644 --- a/extension/triage/fav-render.js +++ b/extension/triage/fav-render.js @@ -171,7 +171,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.decisionFilter || S.finishedFilter || S.noSubFilter || S.dupFilter || S.aiRecentFilter || (S.mediaId !== REMOVED && S.kindFilter)); +const statesOn = (t = S.tab) => (S.classFilter[t] && S.classFilter[t] !== "all") || Boolean(S.finishedFilter || S.noSubFilter || S.dupFilter || S.aiRecentFilter || (S.mediaId !== REMOVED && S.kindFilter)); // Whether row 4 narrows the list (its 全部 is pressed when not). const tagRowOn = () => Boolean(S.tagState || S.tagFilter.size); // Row 3: what the system knows about each video, in groups split by a thin rule. One pick per group (a second click @@ -182,21 +182,19 @@ function stateRowHtml(t = S.tab) { const removed = S.mediaId === REMOVED; 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; + const cnt = (skip, fn) => base.filter((it) => (skip === "class" || skip === "states" || inClass(it, cf)) && passFilter(it, skip) && fn(it)).length; const group = UI.stateGroup; const groups = [group("全部", UI.filterBtn("data-states-all", "全部", cnt("states", () => true), !statesOn(t)))]; // 所有收藏夹 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))); - // 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") { - 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(""))); - } - // Right after the verdict it qualifies: judged from the intro and comments only. + // One pick in row 3's first groups. A kept video has no AI verdict here, so the verdicts split 待决定 and sit inside its + // group, boxed (.sub). 未分析 has no verdicts. + const chip = (k, label, tip = "") => UI.filterBtn(`data-class-filter="${k}"${tip ? ` title="${esc(tip)}"` : ""}`, label, cnt("class", (it) => inClass(it, k)), cf === k); + const verdicts = t === "none" ? "" : `${Object.entries(VERDICTS).map(([k, label]) => chip(k, label)).join("")}`; + groups.push(group(ACTION_LABEL.keep, chip("kept", ACTION_LABEL.keep, KEEP_TIP))); + groups.push(group(UNDECIDED, chip("undecided", UNDECIDED, "还没保留的视频,后面按 AI 判断再分") + verdicts)); + // Right after the verdicts it qualifies: judged from the intro and comments only. 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 seenN = cnt("seen", isFinished); @@ -216,13 +214,11 @@ function pickState(group, value = "") { if (group === "all") { S.classFilter[t] = "all"; S.finishedFilter = S.dupFilter = S.aiRecentFilter = S.noSubFilter = false; - S.decisionFilter = ""; if (S.mediaId !== REMOVED) S.kindFilter = ""; } else if (group === "tagrow") { S.tagState = ""; S.tagFilter.clear(); } 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; @@ -252,7 +248,7 @@ function renderListHeader(list) { const n = can || (verdict ? 0 : selectedIn(visibleItems()).length); const verb = route === "unfav" ? "取消收藏" : "保留"; // One batch 取消收藏 at a time: while another folder's runs, this one waits. - const empty = verdict ? `这里没有 AI 判断为${VERDICTS[verdict]}、未决定的视频` : route === "unfav" ? "选中的都已取消收藏" : "选中的都已保留"; + const empty = verdict ? `这里没有 AI 判断为${VERDICTS[verdict]}、待决定的视频` : route === "unfav" ? "选中的都已取消收藏" : "选中的都已保留"; const reason = none || (!can ? empty : route === "unfav" && run ? UI.busyText(`「${folderName(run.mediaId)}」的${UNFAV_MANY}`) : route === "unfav" ? UI.busyText(favBusy) : ""); @@ -315,8 +311,8 @@ function renderListHeader(list) { const staleN = staleFine().length; if (staleN) html += critBtn + redoBtn("redo-fine", "fine", staleN); if (!picked) { - if (f === "all" || f === "drop") html += batchBtn("unfav", "drop"); - if (f === "all" || f === "keep") html += batchBtn("keep", "keep"); + if (f === "all" || f === "undecided" || f === "drop") html += batchBtn("unfav", "drop"); + if (f === "all" || f === "undecided" || f === "keep") html += batchBtn("keep", "keep"); } } else if (t === "read" && S.mediaId === REMOVED) { const c = S.removedCheck; diff --git a/extension/triage/triage.css b/extension/triage/triage.css index 62033857..7aee1ba3 100644 --- a/extension/triage/triage.css +++ b/extension/triage/triage.css @@ -301,6 +301,8 @@ select { height: 28px; max-width: 240px; } .tagfilter > .seg:last-child::after { display: none; } .seg button.st-danger:not([aria-pressed="true"]) { color: var(--danger); } /* 「AI 刚打的」 and its × (不再标出,标签不变) read as one pill. */ +/* 待决定's AI verdicts: a faint box right after it, so they read as its parts rather than a group of their own. */ +.state-row .sub { display: inline-flex; gap: 2px; margin-left: 2px; padding: 0 1px; border: 1px solid var(--border); border-radius: var(--radius-pill); } .state-row .ai-recent { gap: 0; } .state-row .ai-recent > button[aria-pressed] { border-radius: var(--radius-pill) 0 0 var(--radius-pill); } .state-row .ai-recent > .ai-recent-x { height: 26px; padding: 0 9px 0 6px; border: 0; border-left: 1px solid var(--surface); border-radius: 0 var(--radius-pill) var(--radius-pill) 0; background: var(--field-bg); color: var(--muted); } diff --git a/extension/triage/triage.html b/extension/triage/triage.html index 93d643dc..bb6b8cef 100644 --- a/extension/triage/triage.html +++ b/extension/triage/triage.html @@ -329,7 +329,7 @@

    三步分拣

  • 粗看完成:点「字幕细看下一批」读字幕,给出 AI 总结和要点,拿不准和低置信的排在前面,已保留的不进下一批;有把握的也可以直接按 AI 判断批量取消收藏或保留。
  • 细看完成:按 AI 判断批量取消收藏或保留,也可以一个个按 D / S。卡片上的「✦ 重新细看」把这一个再细看一次。
  • -

    页签只看 AI 做到了哪一步。保留的视频留在原来的页签,用「未决定 / 已保留」筛选;卡片上的「✦ 字幕细看」「✦ 重新细看」只跑这一个。取消收藏的视频去「已出分拣范围」,那里的「最近取消收藏」可以重新收藏。

    +

    页签只看 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 970cd1b9..0ff2853d 100644 --- a/extension/triage/triage.selftest.js +++ b/extension/triage/triage.selftest.js @@ -766,14 +766,14 @@ 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 未决定 hides it: 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"; + t.S.classFilter.none = "undecided"; assert.deepStrictEqual([scope("selected"), scope("all"), scope("filter")], [["BV3"], ["BV1", "BV2", "BV3"], ["BV1", "BV3"]]); - t.S.decisionFilter = ""; + t.S.classFilter.none = "all"; 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"); @@ -991,32 +991,35 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = assert.deepStrictEqual(plain(t.batchList("drop").map((it) => it.bvid)), ["BV200"]); await t.batchKeep(t.batchList("keep")); 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 拿不准"); + assert.deepStrictEqual(plain(t.visibleItems().map((it) => it.bvid)), [], "kept, it leaves the AI's 拿不准"); + t.S.classFilter.fine = "kept"; + assert.deepStrictEqual(plain(t.visibleItems().map((it) => it.bvid)), ["BV200"], "and is under 已保留"); t.S.selected.clear(); assert.deepStrictEqual([t.batchList("keep").length, t.batchList("unsure").length], [0, 0], "verdict 保留 batches take only undecided videos"); assert.strictEqual(t.batchList("unsure", "unfav").length, 0, "a verdict-wide 取消收藏 leaves kept videos alone"); t.S.selected.add("BV200"); assert.deepStrictEqual(plain(t.batchList("unsure", "unfav").map((it) => it.bvid)), ["BV200"], "a selected kept video can be 取消收藏'd in a batch"); t.S.selected.clear(); - // P2: 未决定 / 已保留 and the AI verdict are separate groups, AND-ed: 已保留 + 可清理 finds kept videos the AI said to drop. + // 已保留, 待决定 and the AI verdicts are one pick: a kept video has no verdict here, the verdicts split 待决定. 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"], "可清理 lists only the undecided one"); + t.S.classFilter.fine = "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"); + const row = t.el.classFilter.innerHTML; + for (const part of [">待决定 1<", ">已保留 2<", ">可清理 1<", ">拿不准 0<", 'data-class-filter="kept" title="保留标记']) assert.ok(row.includes(part), part); + assert.ok(row.indexOf("已保留") < row.indexOf("待决定") && row.indexOf("待决定") < row.indexOf('class="sub"') && row.indexOf('class="sub"') < row.indexOf("值得留"), "已保留, then 待决定 with its verdicts boxed after it"); 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("class", "kept"); + assert.strictEqual(t.S.classFilter.fine, "kept"); + t.pickState("class", "drop"); + assert.strictEqual(t.S.classFilter.fine, "drop", "a verdict replaces 已保留"); t.pickState("all"); - assert.strictEqual(t.S.decisionFilter, "", "全部 clears it"); + assert.strictEqual(t.S.classFilter.fine, "all", "全部 clears it"); delete store[t.K.kept]; Object.assign(t.S, { analyses: {}, decisions: {}, kept: {}, classFilter: { coarse: "all", fine: "all" } }); t.S.selected.clear(); @@ -2234,19 +2237,17 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = openFake("A", [item(1)]); } - // 阅览: a decided video keeps its AI class; 已保留 is the decision group's, the same word its card shows. + // 全部: a kept video is under 已保留 only, 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<", ">值得留 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"; + for (const part of [">全部 2<", ">值得留 1<", ">已保留 1<", ">待决定 1<"]) assert.ok(t.el.classFilter.innerHTML.includes(part), part); + t.S.classFilter.read = "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)), ["BV1", "BV2"]); + assert.deepStrictEqual(plain(t.visibleItems().map((it) => it.bvid)), ["BV2"], "a kept video leaves the AI verdicts"); Object.assign(t.S, saved); openFake("A", [item(1)]); } @@ -3380,7 +3381,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(); @@ -3426,7 +3427,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"); @@ -3435,18 +3436,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 in every tab. + // Row 4 counts the videos of the tab, row 3 applied; 未分析 has no AI verdicts; 已保留 / 待决定 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 verdicts"); t.S.decisions = { BV92: { action: "keep", at: 1 } }; t.S.tab = "read"; - has(">已保留 1<", ">未决定 5<"); + has(">已保留 1<", ">待决定 5<"); t.S.tab = "coarse"; - has(">已保留 1<", ">未决定 4<"); + 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" }; } @@ -3455,7 +3456,7 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = openFake("N", [item(1), item(2), item(3)]); Object.assign(t.S, { titleRes: {}, analyses: { BV1: { status: "done", source: "meta", verdict: "keep" }, BV2: { status: "done", source: "subtitle", verdict: "keep" } }, tab: "fine", classFilter: { coarse: "all", fine: "all", read: "all" } }); const groups = () => [...row3().matchAll(/role="group" aria-label="([^"]+)"/g)].map((m) => m[1]); - assert.ok(row3().includes(">无字幕 1<") && groups()[groups().indexOf("按 AI 判断筛选") + 1] === "无字幕", groups().join()); + assert.ok(row3().includes(">无字幕 1<") && groups()[groups().indexOf("待决定里按 AI 判断") + 1] === "无字幕", groups().join()); t.pickState("nosub"); assert.deepStrictEqual(plain(t.visibleItems().map((it) => it.bvid)), ["BV1"]); t.pickState("all"); From 17f3593f376a65fb61bea542f5e351326262489c Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Tue, 10 Nov 2026 18:30:30 +0800 Subject: [PATCH 15/17] =?UTF-8?q?triage:=20the=20three=20=E5=85=A8?= =?UTF-8?q?=E9=83=A8=20pills=20share=20a=20min=20width=20so=20the=20rules?= =?UTF-8?q?=20after=20them=20line=20up?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- extension/triage/triage.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/extension/triage/triage.css b/extension/triage/triage.css index 7aee1ba3..f60ba62e 100644 --- a/extension/triage/triage.css +++ b/extension/triage/triage.css @@ -263,6 +263,9 @@ select { height: 28px; max-width: 240px; } .tabs button { display: flex; align-items: center; gap: 4px; height: 26px; padding: 0 12px; border: 0; border-radius: var(--radius-pill); background: none; color: var(--text-2); font-variant-numeric: tabular-nums; } .tabs button:hover { background: var(--field-bg); } .tabs button[aria-selected="true"] { background: var(--text); color: var(--bg); font-weight: 600; } +/* The three rows' 全部 count different things (the folder, the tab), so their pills differ in width: one min width, text + left, keeps the rules after them in a column up to three digits. */ +#tabs [data-tab="read"], :is(.state-row, .tagfilter) > .seg[aria-label="全部"] > button { min-width: 78px; justify-content: flex-start; text-align: left; } .tabs .step.zero:not([aria-selected="true"]) { opacity: 0.5; } .tabs .arrow { margin: 0 2px; color: var(--faint); font-size: var(--fs-xs); } .tabs .tab-sep { flex: 0 0 1px; height: 16px; margin: 0 6px; border-left: 1px solid var(--border-strong); } From 4182cf72ec68e1dc4db945fda8ef6ccf769601cd Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Tue, 10 Nov 2026 18:36:41 +0800 Subject: [PATCH 16/17] =?UTF-8?q?=E5=88=86=E6=8B=A3=E5=8F=B0=EF=BC=9A?= =?UTF-8?q?=E6=B8=85=E6=8E=89=E3=80=8C=E9=98=85=E8=A7=88=E3=80=8D=E6=97=A7?= =?UTF-8?q?=E7=A7=B0=E3=80=81=E6=B2=A1=E4=BA=BA=E7=94=A8=E7=9A=84=20tagSta?= =?UTF-8?q?teGroup=20=E5=AF=BC=E5=87=BA=EF=BC=8C=E5=85=B3=E6=B3=A8?= =?UTF-8?q?=E5=B8=AE=E5=8A=A9=E5=92=8C=E6=96=87=E6=A1=A3=E8=B7=9F=E4=B8=8A?= =?UTF-8?q?=E6=A0=87=E7=AD=BE=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/DESIGN.md | 4 ++-- docs/TRIAGE.md | 2 +- extension/triage/fav-base.js | 4 ++-- extension/triage/fav-export.js | 2 +- extension/triage/fav-render.js | 2 +- extension/triage/shared.js | 2 +- extension/triage/triage.html | 4 ++-- 7 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 1bd91307..8b801a8f 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -183,7 +183,7 @@ AI 打标签的建议在应用或放弃前一直存着,刷新或重开分拣 #### 筛选和计数 -- 第 3 行每组的数量不算本组自己的选择,但算别的组、第 4 行、搜索和左栏。固定的组数量为 0 时变淡,不隐藏;左栏也一样。条件片(看完了、已失效、AI 刚打的)数量为 0 时隐藏,开着的时候照样显示,方便关掉。「全部」的数量是清空第 3 行以后的。 +- 第 3 行每组的数量不算本组自己的选择,但算别的组、第 4 行、搜索和左栏。固定的组数量为 0 时变淡,不隐藏;左栏也一样。条件片(无字幕、看完了、已失效、AI 刚打的)数量为 0 时隐藏,开着的时候照样显示,方便关掉。「全部」的数量是清空第 3 行以后的。 - 标签行(第 4 行):只列当前页签里有视频(或 UP 主)带着的标签,打开的标签照样显示。每个标签带数量:收藏夹数当前页签的视频,关注的「UP 主」数 UP 主,「视频投稿」数视频。多选是「并且」,数量不算第 4 行自己的选择。摘读记录的标签行一样,只列列出的记录带着的标签和打开的标签。 - 页签行第一个是「全部」(以前叫「阅览全部」,排在最后),后面是未分析、粗看完成、细看完成,只看 AI 做到了哪一步。三行的「全部」上下对齐:页签本来就从第 3、4 行第一个按钮的位置开始,左边是收起头部的按钮,所以不加行名。页签和第 3、4 行用同一种按钮(没选中的没有底色,选中的实心),不再是文字加粉色下划线;「全部」后面一道竖线,三个步骤之间留「→」。为什么:三行看起来是同一种筛选更好懂;箭头说明 AI 只往前走,不会退回上一步。2026-11-10 用户定。保留不换页签:保留的视频留在它 AI 做到的那一步。取消收藏的视频不在任何页签里(「全部」也没有),去「已出分拣范围」;刚取消的还能按 U 撤销。2026-11-10 用户定:去掉「处理完成」。为什么:以前保留就进处理完成,只做过标题粗看就保留的视频再也不能字幕细看,只剩一句 20 字以内的粗看理由。 - 「字幕细看下一批」和「按新标准重新细看下一批」跳过已保留的视频(省 AI 的钱);选中的照跑,保留没保留都一样。下一批只剩已保留的时按钮灰掉,写「已保留的视频不进下一批,选中后可以细看」。2026-11-10 用户定。 @@ -228,7 +228,7 @@ AI 打标签的建议在应用或放弃前一直存着,刷新或重开分拣 - 收藏夹和关注都有「未打标签 / 已打标签」,在第 4 行。为什么:要能只挑出没标签的,再用 ✦ AI 打标签补上。以前收藏夹没有,理由是收藏夹的标签只是收藏夹里的细分,但这样就挑不出漏打的视频。 - 「已出分拣范围」的「全部」和「已取消收藏」页签下面列「最近取消收藏」:每个勾选收藏夹的取消收藏记录,新的在前,最多 50 条,每条写从哪个收藏夹取消的,「重新收藏」放回那个收藏夹;整批取消收藏的有一行「这批全部重新收藏」。2026-11-10:以前在「处理完成」里,那个页签去掉后挪到这里。 - 第 3 行组内单选,组和组同时生效。 -- 只是有时才有的条件片(看完了、已失效、AI 刚打的)放第 3 行最后,数量为 0 时隐藏;固定的组为 0 时变淡。为什么:平时的排版不跳动。 +- 只是有时才有的条件片(看完了、已失效、AI 刚打的)放第 3 行最后,数量为 0 时隐藏(「无字幕」也是条件片,但紧跟 AI 判断);固定的组为 0 时变淡。为什么:平时的排版不跳动。 - 「待查」一直显示,0 时变淡。为什么:以前它一消失,后面的片就跟着挪位。 - 「全选」放在右边按钮组最前面,是三态勾选。为什么:条件片出现或消失时它不动。 - 多选不用开关:点卡片就是选中,点标题才是播放。为什么:分拣时选比看多,第一次选不用去找藏着的勾选框。 diff --git a/docs/TRIAGE.md b/docs/TRIAGE.md index 727df8b3..bc01ee0a 100644 --- a/docs/TRIAGE.md +++ b/docs/TRIAGE.md @@ -40,7 +40,7 @@ ## 选中和批量操作 - 点卡片(标题和按钮以外的地方)或按 X 选中,再点一次取消;或勾「状态」行的「全选 N 个」(再勾一次取消)。点标题是在分拣台里播放。Shift 点一张选中从上一次点的那张到这张的一段;按 Esc 或「清空选中」全部取消。⌘ / Ctrl 点标题照样在 B站打开。关注的 UP 主卡片也一样。 -- 选中只属于当前这一页,切到别的步骤或阅览就清空。跑一步或批量操作以后选中不变,只有因此离开这一页的才不再选中。被搜索或筛选藏起来的选中不算进操作,选中条上会写「另有 N 个被筛选隐藏」(都被藏起来时写「已选中 N 个」和「都被筛选隐藏」);选中的都被藏起来时,按钮照样对着选中、写选中的个数,灰掉并写「选中的都被筛选隐藏了」。 +- 选中只属于当前这一页,切到别的页签就清空。跑一步或批量操作以后选中不变,只有因此离开这一页的才不再选中。被搜索或筛选藏起来的选中不算进操作,选中条上会写「另有 N 个被筛选隐藏」(都被藏起来时写「已选中 N 个」和「都被筛选隐藏」);选中的都被藏起来时,按钮照样对着选中、写选中的个数,灰掉并写「选中的都被筛选隐藏了」。 - 选中后可以保留、取消收藏,或 **移动 / 复制** 到你 B站账号里的任意收藏夹,也可以新建一个(可设私密)。 - 移到没勾选的收藏夹时,视频进「已出分拣范围」并标出去向。之后在收藏夹设置里勾选那个收藏夹,它们会自动回来。 - **「导出」菜单**:「摘录或笔记…」的范围选当前筛选、选中或整个收藏夹(待播不参与导出),格式选一篇摘录(带每个视频的备注)或逐个视频笔记(字幕加 AI 总结),可以复制、下载或写入 Obsidian。也能导出 JSON 完整备份(不含密钥和待播)和当前收藏夹的 CSV 表格。 diff --git a/extension/triage/fav-base.js b/extension/triage/fav-base.js index c09f544b..2ea3b260 100644 --- a/extension/triage/fav-base.js +++ b/extension/triage/fav-base.js @@ -11,7 +11,7 @@ const THROTTLES = { }; const SYNC_MIN_GAP_MS = 60 * 1000; // 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. +// filters inside a tab, never a tab. 全部 sits apart before them. const STAGES = [ ["none", "未分析"], ["coarse", "粗看完成"], @@ -589,7 +589,7 @@ function stageOf(it) { return "none"; } -// A tab's videos before rows 3 and 4. 阅览 (and 已出分拣范围) is every video, whatever its step. A video unfavorited +// 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) { diff --git a/extension/triage/fav-export.js b/extension/triage/fav-export.js index d20b7b41..9a4d4133 100644 --- a/extension/triage/fav-export.js +++ b/extension/triage/fav-export.js @@ -12,7 +12,7 @@ function writeScopeItems(scope = el.writeScope.value, notes = el.writeFormat?.va return notes ? list.filter((it) => !it.invalid) : list; } -// The 阅览 tab opens it preset; a running notes export keeps its own choice. +// The 全部 tab opens it preset; a running notes export keeps its own choice. function openWrite({ scope, format } = {}) { if (!writeRunning() && scope) [el.writeScope.value, el.writeFormat.value] = [scope, format]; // A notes run, running, finished or stopped by Obsidian, keeps its progress and failures here until the next export. diff --git a/extension/triage/fav-render.js b/extension/triage/fav-render.js index 6d2d298d..11822f52 100644 --- a/extension/triage/fav-render.js +++ b/extension/triage/fav-render.js @@ -285,7 +285,7 @@ function renderListHeader(list) { // A selection, listed or not, decides alone: no AI-verdict batches beside it (all hidden: its buttons say why). const picked = S.selected.size; const f = S.classFilter[t]; - // html is what this step does (阅览 mixes 粗看 guesses with 细看 conclusions: no class-wide batch, only the selection); + // html is what this step does (全部 mixes 粗看 guesses with 细看 conclusions: no class-wide batch, only the selection); // the selection's buttons go to its floating bar (selActs). let html = ""; // 所有收藏夹 has no one 判断标准 to show: its runs ask for each folder's first (askAllCriteria). diff --git a/extension/triage/shared.js b/extension/triage/shared.js index a0911e84..c695c2b8 100644 --- a/extension/triage/shared.js +++ b/extension/triage/shared.js @@ -879,5 +879,5 @@ }; } - globalThis.TriageUi = { https, gearBtn, keepScroll, UP_RECENT, upTitles, AI_RECENT, AI_RECENT_CHECK, aiRef, esc, pad, dayText, fmtDate, fmtAgo, agoHtml, setSearchScope, confirmList, pickTitle, fmtDuration, fmtCount, cleanTagName, plainClick, cardPlayClick, cardPickClick, pickCard, img, toCsv, cardTagChip, untagStep, changedFields, syncedText, headMeta, titleHtml, AI_SPARK, byValue, dirWords, sortControl, filterBtn, labeledRow, tagFilterChip, kindTabs, originChip, stateGroup, tagStateGroup, tagRowHead, aiRecentChip, searchBox, bindSearch, resultCount, rowButtons, menuItem, BACKUP_ITEM, CLEAN_BACKUP, reasonAttrs, setReason, WARN_DOT, selectAllState, selectAllBox, toggleAll, aiTagsLabel, READING_AI_CHARS, setActivity, BILI_STUCK, waitText, BILI_HOLD_MS, WRITE_STOPPING, busyText, syncPill, setSync, bindSync, tagButtons, sideFoot, actBtn, cleanBtn, HIDDEN_ALL, selbar, askConfirm, askTransfer, sideItem, emptyState, refreshEmpty, noMatch, fillSetRows, fillNumHints, numBox, NUM_SETTINGS, TITLES_PER_REQUEST, INTRO_CHARS, DEEP_COMMENTS, autoMaxTokens, numSetting, clampNumber, intIn, clampNumbers, mergeAiBatch, aiChanges, aiProposalOk, previewId, aiTally, nextTagColor, TAG_RULE_MAX, AI_TAGS_PENDING, tagNameError, deleteTagAsk, editedTag, withoutTag, tagUses, undoAsk, tagRowHtml, sp, tagsWord, AI_RECENT_RULES, aiRecentUndo, tagPlusBtn, viewerLine, viewerKeyFrom, headFold, FOLLOW_WRITE_GAP_MS, AI_TAGS_DONE, aiTagsFailed, AI_TAG_GONE, AI_APPLY_FAILED, NEW_TAG_MAX_ZERO, aiApplied, aiHistoryAdd, GROUP_NAME_MAX, groupNameError, UNDO, untagged, searchScope, cardCleanBtn, REFRESHING, stopWords, upWho, withFeedInProgress, UP_GONE_CODES, FOLLOW_SPECIAL, FEED_CACHE_MS, aiRoomHint, scopeLabel, batchesText, videoUrl, FAV_PAGE_SIZE, removedName, transferVerb: TRANSFER_VERB, tagManage: TAG_MANAGE }; + globalThis.TriageUi = { https, gearBtn, keepScroll, UP_RECENT, upTitles, AI_RECENT, AI_RECENT_CHECK, aiRef, esc, pad, dayText, fmtDate, fmtAgo, agoHtml, setSearchScope, confirmList, pickTitle, fmtDuration, fmtCount, cleanTagName, plainClick, cardPlayClick, cardPickClick, pickCard, img, toCsv, cardTagChip, untagStep, changedFields, syncedText, headMeta, titleHtml, AI_SPARK, byValue, dirWords, sortControl, filterBtn, labeledRow, tagFilterChip, kindTabs, originChip, stateGroup, tagRowHead, aiRecentChip, searchBox, bindSearch, resultCount, rowButtons, menuItem, BACKUP_ITEM, CLEAN_BACKUP, reasonAttrs, setReason, WARN_DOT, selectAllState, selectAllBox, toggleAll, aiTagsLabel, READING_AI_CHARS, setActivity, BILI_STUCK, waitText, BILI_HOLD_MS, WRITE_STOPPING, busyText, syncPill, setSync, bindSync, tagButtons, sideFoot, actBtn, cleanBtn, HIDDEN_ALL, selbar, askConfirm, askTransfer, sideItem, emptyState, refreshEmpty, noMatch, fillSetRows, fillNumHints, numBox, NUM_SETTINGS, TITLES_PER_REQUEST, INTRO_CHARS, DEEP_COMMENTS, autoMaxTokens, numSetting, clampNumber, intIn, clampNumbers, mergeAiBatch, aiChanges, aiProposalOk, previewId, aiTally, nextTagColor, TAG_RULE_MAX, AI_TAGS_PENDING, tagNameError, deleteTagAsk, editedTag, withoutTag, tagUses, undoAsk, tagRowHtml, sp, tagsWord, AI_RECENT_RULES, aiRecentUndo, tagPlusBtn, viewerLine, viewerKeyFrom, headFold, FOLLOW_WRITE_GAP_MS, AI_TAGS_DONE, aiTagsFailed, AI_TAG_GONE, AI_APPLY_FAILED, NEW_TAG_MAX_ZERO, aiApplied, aiHistoryAdd, GROUP_NAME_MAX, groupNameError, UNDO, untagged, searchScope, cardCleanBtn, REFRESHING, stopWords, upWho, withFeedInProgress, UP_GONE_CODES, FOLLOW_SPECIAL, FEED_CACHE_MS, aiRoomHint, scopeLabel, batchesText, videoUrl, FAV_PAGE_SIZE, removedName, transferVerb: TRANSFER_VERB, tagManage: TAG_MANAGE }; })(); diff --git a/extension/triage/triage.html b/extension/triage/triage.html index bb6b8cef..40d613f5 100644 --- a/extension/triage/triage.html +++ b/extension/triage/triage.html @@ -301,8 +301,8 @@

    标签管理