diff --git a/extension/entry-matrix.selftest.js b/extension/entry-matrix.selftest.js index d2ea2a7..2002f6d 100644 --- a/extension/entry-matrix.selftest.js +++ b/extension/entry-matrix.selftest.js @@ -170,7 +170,8 @@ const ALLOW = [ // 3c: the gone video's sentence; whether 重试 shows is remedies'. ["content.js", "MdgSites.isBiliVideoGone(state.readCode) ? MdgErrors.LABEL.videoGone", "说法:视频已失效"], ["sidepanel.js", "MdgSites.isBiliVideoGone(resp?.code) ? MdgErrors.LABEL.videoGone", "说法:视频已失效"], - ["sidepanel.js", "const gone = !ok && MdgSites.isBiliVideoGone(response?.code);", "说法:VIDEO_GONE(只能查看,不能继续提问)"], + ["sidepanel.js", "const gone = MdgSites.isBiliVideoGone(response?.code);", "说法:VIDEO_GONE(只能查看,不能继续提问)"], + ["sidepanel.js", "MdgSites.isBiliVideoGone(failed?.code) ? VIDEO_GONE", "说法:VIDEO_GONE(读取失败存在 view().failed,静默读取也只能查看)"], ["sidepanel.js", "MdgSites.isBiliVideoGone(response?.code) ? null : undefined", "已失效的视频补全标题时不再重查,不显示错误"] ]; const used = ALLOW.map(() => 0); diff --git a/extension/sidepanel-state.js b/extension/sidepanel-state.js index 76b01ea..54f2eb7 100644 --- a/extension/sidepanel-state.js +++ b/extension/sidepanel-state.js @@ -181,7 +181,8 @@ // live: the active tab (context is its payload, or the placeholder of a video followed from its URL alone). // conv: the shown conversation. pinned is its own context's description once it has one (saved, opened from a - // reference, or asked in); resolved is that context loaded; failed: loading it failed, so it is no longer pending. + // reference, or asked in); resolved is that context loaded; failed: loading it failed, so it is no longer pending: + // { code (the read's error code), text (said under it; "" when the load was silent) }. // fill: its AI 总结 prompt is still to fill. unanswered: the last question when its reply failed or stopped before a // word came, kept only to draw it again. convs (MdgCommon.latest): the shown conversation's run; showing another one // ends it, so a load or a drawing begun on the one before can tell. @@ -192,7 +193,7 @@ function create() { const s = { live: { tabUrl: "", context: null, key: "", error: "", lastVideoUrl: "" }, - conv: { id: "", pinned: null, messages: [], resolved: null, failed: false, fill: false, unanswered: null, autoSend: null }, + conv: { id: "", pinned: null, messages: [], resolved: null, failed: null, fill: false, unanswered: null, autoSend: null }, convs: MdgCommon.latest(), turn: idleTurn(), detached: new Map(), @@ -229,7 +230,7 @@ function view(s) { const context = shown(s); const key = shownKey(s); - const { id, pinned, messages, resolved, unanswered } = s.conv; + const { id, pinned, messages, resolved, failed, unanswered } = s.conv; const target = shownPartUrl(s); return { context, @@ -239,6 +240,7 @@ pinned, messages, resolved, + failed, run: s.convs.current, unanswered, turn: s.turn.phase, @@ -292,7 +294,7 @@ effects.push({ type: "draft" }); } s.convs.next(); - s.conv = { id: "", pinned: null, messages: [], resolved: null, failed: false, fill: false, unanswered: null, autoSend: null }; + s.conv = { id: "", pinned: null, messages: [], resolved: null, failed: null, fill: false, unanswered: null, autoSend: null }; effects.push(...reattach(s, conversation)); if (conversation) { s.conv.id = conversation.id; @@ -604,15 +606,15 @@ case "GRANT_DENIED": s.conv.autoSend = null; return []; - // A conversation's own context loaded (payload) or failed; a load started for an earlier conversation (its run - // ended) is ignored. + // A conversation's own context loaded (payload) or failed (code, text: conv.failed); a load started for an + // earlier conversation (its run ended) is ignored. case "CONTEXT_RESOLVED": { if (!event.run?.alive || !s.conv.pinned) { return []; } if (!event.payload) { const wasPending = Boolean(shown(s)?.pending); - s.conv.failed = true; + s.conv.failed = { code: event.code, text: event.text || "" }; return [{ type: "resolveFailed", wasPending }]; } const payload = event.payload; @@ -625,6 +627,7 @@ contextRef: buildConversationContextRef(payload) }; s.conv.resolved = payload; + s.conv.failed = null; return [{ type: "resolved" }]; } case "OPEN": diff --git a/extension/sidepanel-state.selftest.js b/extension/sidepanel-state.selftest.js index a052217..4ec6a76 100644 --- a/extension/sidepanel-state.selftest.js +++ b/extension/sidepanel-state.selftest.js @@ -252,6 +252,20 @@ function panel({ tab = video("BV1aa"), list = [] } = {}) { p.dispatch({ type: "OPEN", id: "h" }); assert.deepStrictEqual(plain(p.dispatch({ type: "CONTEXT_RESOLVED", run: p.view().run, payload: null })), [{ type: "resolveFailed", wasPending: true }]); assert.strictEqual(p.view().context.pending, false); + // The failure is the conversation's: kept for redraws, a silent one without words (still gone); one for the + // conversation shown before is ignored, and showing another conversation or a load that works clears it. + assert.strictEqual(p.view().failed.text, ""); + const hRun = p.view().run; + p.dispatch({ type: "CONTEXT_RESOLVED", run: hRun, payload: null, code: -404, text: "" }); + assert.deepStrictEqual(plain(p.view().failed), { code: -404, text: "" }); + p.dispatch({ type: "OPEN", id: "g" }); + assert.strictEqual(p.view().failed, null); + p.dispatch({ type: "CONTEXT_RESOLVED", run: hRun, payload: null, code: -404, text: "gone" }); + assert.strictEqual(p.view().failed, null, "stale"); + p.dispatch({ type: "CONTEXT_RESOLVED", run: p.view().run, payload: null, code: "THROTTLED", text: "限流" }); + assert.deepStrictEqual(plain(p.view().failed), { code: "THROTTLED", text: "限流" }); + p.dispatch({ type: "CONTEXT_RESOLVED", run: p.view().run, payload: video("BV1gg") }); + assert.strictEqual(p.view().failed, null); // The tab's own video: the live context, nothing to resolve. const q = panel({ list: [saved("a", video("BV1aa"))] }); q.dispatch({ type: "NEW" }); diff --git a/extension/sidepanel.js b/extension/sidepanel.js index ba03427..d2d11a0 100644 --- a/extension/sidepanel.js +++ b/extension/sidepanel.js @@ -324,9 +324,9 @@ const REPLY_RUNNING = "上一个回答还在生成,结束后才能继续问"; // Why the shown conversation cannot be asked in now: a web page not read, a video that is gone, or its last reply // still running out of reach. function readOnlyReason() { - const { nonVideo, context, replyDetached } = state.view(); + const { nonVideo, context, replyDetached, failed } = state.view(); if (replyDetached) return REPLY_RUNNING; - return nonVideo ? pageReason(context) : contextFailure()?.gone ? VIDEO_GONE : ""; + return nonVideo ? pageReason(context) : MdgSites.isBiliVideoGone(failed?.code) ? VIDEO_GONE : ""; } // Why a web page cannot be asked about, by background.js readWebPage's pageAccess; "" once its main text is read. A page @@ -1619,9 +1619,9 @@ function renderConversationMessages() { els.messages.appendChild(node); appendReplyEnd(node, { ...unanswered, raw: "", saved: false }); } - const failed = nonVideo ? pageReason(state.view().context) : contextFailure()?.text; + const failed = nonVideo ? pageReason(state.view().context) : state.view().failed?.text; if (failed) { - showConversationContextError(failed, nonVideo ? null : contextFailure().remedy); + showConversationContextError(failed, nonVideo ? null : contextRemedy()); } renderFollowups(); drawnRun = run; @@ -1685,11 +1685,6 @@ async function ensureCurrentContextForSend() { return true; } -// The shown conversation's own video failed to load: { run (its conversation's), text (said under it, again at each -// redraw; "" when the load was silent), gone (deleted or hidden on B站: the conversation is only read) }. -let lastContextFailure = null; -const contextFailure = () => (lastContextFailure?.run.alive ? lastContextFailure : null); - // Loads the shown conversation's own context (state decides when: resolve effects). A load the conversation was // switched away from meanwhile does nothing. notice: the 正在加载… line it takes down when done. async function hydratePinnedConversationContext({ silent = false, notice = null } = {}) { @@ -1716,16 +1711,15 @@ async function hydratePinnedConversationContext({ silent = false, notice = null error: error?.message || String(error || "") })); const ok = Boolean(response?.ok && response.payload); - const [result] = state.dispatch({ type: "CONTEXT_RESOLVED", run, payload: ok ? response.payload : null }); + // Deleted or hidden on B站 (as the 分拣台 and 摘读记录 tell it): no retry will bring it back, and the conversation is + // only read. Else a send tries again. + const gone = MdgSites.isBiliVideoGone(response?.code); + const failure = ok ? {} : { code: response?.code, text: silent ? "" : gone ? VIDEO_GONE : `读取原${sourceKind()}失败:${MdgErrors.text(response)}` }; + const [result] = state.dispatch({ type: "CONTEXT_RESOLVED", run, payload: ok ? response.payload : null, ...failure }); notice?.remove(); if (!result) { return false; } - // Deleted or hidden on B站 (as the 分拣台 and 摘读记录 tell it): no retry will bring it back. Else a send tries again. - const gone = !ok && MdgSites.isBiliVideoGone(response?.code); - const text = gone ? VIDEO_GONE : `读取原${sourceKind()}失败:${MdgErrors.text(response)}`; - const remedy = !ok && MdgErrors.remedies(response?.code).includes("retry") ? contextRemedy(contextRef) : null; - lastContextFailure = ok ? null : { run, text: silent ? "" : text, gone, remedy }; updateContextChip(); syncSendUi(); if (ok) { @@ -1736,15 +1730,18 @@ async function hydratePinnedConversationContext({ silent = false, notice = null suggestionsNode?.replaceChildren(); } if (!silent) { - showConversationContextError(text, lastContextFailure.remedy); + showConversationContextError(failure.text, contextRemedy()); } return false; } -// What helps a saved conversation whose video failed to load (not gone): a site read only from its page (YouTube) -// needs that page open, so 打开视频 opens it in this tab as the chip does; any other gets 重试 (B站限流, the network). -// Either reads it again, and takes the error down once it loads. -function contextRemedy(contextRef) { +// What helps the shown conversation whose video failed to load (by its code; nothing when gone): a site read only +// from its page (YouTube) needs that page open, so 打开视频 opens it in this tab as the chip does; any other gets 重试 +// (B站限流, the network). Either reads it again, and takes the error down once it loads. +function contextRemedy() { + const { failed, pinned } = state.view(); + if (!MdgErrors.remedies(failed.code).includes("retry")) return null; + const { contextRef } = pinned; const again = async () => { if (await hydratePinnedConversationContext()) removeCenteredState(); }; diff --git a/extension/sidepanel.selftest.js b/extension/sidepanel.selftest.js index b4cfe07..c7336f3 100644 --- a/extension/sidepanel.selftest.js +++ b/extension/sidepanel.selftest.js @@ -580,13 +580,13 @@ const tick = () => new Promise((r) => setImmediate(r)); // J7: a failure that may pass (a limit, the network) gets 重试, as the tab's own read does; a gone video none. The // 重试 reads it again and takes the error down once it loads. assert.deepStrictEqual(remedies, ["", "重试", "重试"]); - const failure = vm.runInContext("contextFailure()", ctx); - assert.strictEqual(failure.remedy.label, "重试", "a redraw shows it again"); + const remedy = vm.runInContext("contextRemedy()", ctx); + assert.strictEqual(remedy.label, "重试", "a redraw shows it again"); let centered = 0; const realRemove = ctx.removeCenteredState; ctx.removeCenteredState = () => centered++; ctx.resolveConversationContext = async () => ({ ok: true, payload: video }); - await failure.remedy.run(); + await remedy.run(); assert.deepStrictEqual([centered, view().resolved?.videoId], [1, video.videoId]); ctx.removeCenteredState = realRemove; // J12: a YouTube video reads only from its page: the remedy opens it (in this tab, as the chip does), then reads. @@ -601,7 +601,7 @@ const tick = () => new Promise((r) => setImmediate(r)); const steps = []; ctx.openCurrentContextUrl = async () => steps.push("open"); ctx.resolveConversationContext = async () => (steps.push("read"), { ok: false, error: "x" }); - await vm.runInContext("contextFailure()", ctx).remedy.run(); + await vm.runInContext("contextRemedy()", ctx).run(); assert.deepStrictEqual(steps, ["open", "read"]); ctx.openCurrentContextUrl = realOpen; errors.length = remedies.length = 0;