返回 DeepSeek-Reasonix
forkTargets.ts
根目录 / desktop / frontend / src / lib / forkTargets.ts
1 // Fork eligibility comes from the host's persisted turn records, never from
2 // checkpoints: a checkpoint knows a message count, while a turn record proves an
3 // atomic commit boundary. One transcript turn maps to its boundary through the
4 // assistant message identity, so live completion, history paging, and cold
5 // restore all resolve the same target.
6
7 import type { ForkAnchorView, ForkCreationView, ForkTargetSetView, ForkTargetView } from "../generated/desktopContract.generated";
8 import { t, type DictKey } from "./i18n";
9
10 export type { ForkTargetSetView, ForkTargetView };
11
12 /** The create-only fork commands the host binds per surface: local tabs and remote ones. */
13 export interface ForkTargetsBindings {
14 ForkTargetsForTab(tabID: string): Promise<ForkTargetSetView>;
15 CreateForkForTab(tabID: string, anchor: ForkAnchorView): Promise<ForkCreationView>;
16 AcknowledgeForkOperation(tabID: string, operationID: string): Promise<void>;
17 }
18
19 /**
20 * Why one transcript turn's fork entry offers no fork. Each state keeps its own
21 * explanation, because the collapsed "unavailable" it replaces could not tell a
22 * running turn from legacy history or from a surface that cannot create a child.
23 */
24 export type ForkBlockReason = "loading" | "turn_open" | "unverifiable" | "active_authority" | "stale_source" | "read_only" | "unsupported" | "creating";
25
26 const FORK_REASON_KEYS: Record<ForkBlockReason, DictKey> = {
27 loading: "chat.branchLoading",
28 turn_open: "chat.branchTurnOpen",
29 unverifiable: "chat.branchUnverifiable",
30 active_authority: "chat.branchActiveAuthority",
31 stale_source: "chat.branchStaleSource",
32 read_only: "chat.branchReadOnly",
33 unsupported: "chat.branchUnsupported",
34 creating: "chat.branchCreating",
35 };
36
37 /** The locale key explaining one block reason, shared by the tooltip and its screen-reader text. */
38 export function forkReasonKey(reason: ForkBlockReason): DictKey {
39 return FORK_REASON_KEYS[reason];
40 }
41
42 /**
43 * The persisted message identity a transcript item key names, or undefined when
44 * the key carries none. Assistant and user items are keyed `m:<messageId>`;
45 * history entries without a message id keep a positional key that names no
46 * durable message, so no boundary can be resolved from it.
47 */
48 export function forkAnswerMessageId(itemKey: string | undefined): string | undefined {
49 return itemKey?.startsWith("m:") ? itemKey.slice(2) : undefined;
50 }
51
52 /**
53 * The persisted boundary for one turn's answer. Identity is the only match: an
54 * array position or a page offset moves to another turn as soon as history is
55 * prepended or a page is reloaded.
56 */
57 export function forkTargetForAnswer(set: ForkTargetSetView | undefined, answerKey: string | undefined): ForkTargetView | undefined {
58 const messageId = forkAnswerMessageId(answerKey);
59 if (!messageId || !set) return undefined;
60 return set.targets.find((target) => target.messageId === messageId);
61 }
62
63 /**
64 * The refusal one target carries, or null when it may start a child. Each reason
65 * the host names keeps its own explanation: a boundary it could not prove and
66 * one it refused as unsafe are different facts, and only the first is an absent
67 * boundary.
68 */
69 export function forkTargetReason(target: ForkTargetView): ForkBlockReason | null {
70 if (target.available) return null;
71 if (target.reason === "turn_open") return "turn_open";
72 return target.reason === "active_authority" ? "active_authority" : "unverifiable";
73 }
74
75 /**
76 * The block reason for one tail node, or null when its target may fork. An
77 * unmatched answer means the source proves boundaries but holds none for this
78 * message: the open turn before its reply exists, or a turn that committed no
79 * reply at all.
80 */
81 export function forkBlockReason(input: {
82 target: ForkTargetView | undefined;
83 loaded: boolean;
84 verifiable: boolean;
85 blocked: ForkBlockReason | null;
86 latest: boolean;
87 }): ForkBlockReason | null {
88 if (input.blocked) return input.blocked;
89 if (!input.loaded) return "loading";
90 if (!input.verifiable) return "unverifiable";
91 if (!input.target) return input.latest ? "turn_open" : "unverifiable";
92 return forkTargetReason(input.target);
93 }
94
95 /** The notice text for a refused create-fork request. */
96 export function forkCreateFailureText(error: unknown, reason?: string): string {
97 const detail = error instanceof Error ? error.message : String(error ?? "");
98 const blockReason: ForkBlockReason | undefined = reason === "history_unverifiable" ? "unverifiable"
99 : reason === "turn_open" || reason === "active_authority" || reason === "stale_source" || reason === "unsupported" ? reason : undefined;
100 return blockReason ? t("chat.branchFailedDetail", { detail: t(forkReasonKey(blockReason)) }) : t("chat.branchFailedDetail", { detail });
101 }
102
102 lines TYPESCRIPT