diff --git a/scripts/shots/mock/core.ts b/scripts/shots/mock/core.ts index ac542204..8143e94b 100644 --- a/scripts/shots/mock/core.ts +++ b/scripts/shots/mock/core.ts @@ -103,7 +103,7 @@ export const CORE: { [N in WebviewEndpoint]: Handler } = { }, Sessions: (req) => sessions(req.limit ?? 200), SessionDetail: (_req, [id]) => ({ session: sessionView(id!) ?? notFound(`Session ${id}`), turns: turns(id!) }), - SessionTranscript: (_req, [id]) => (sessionView(id!) ? transcript(id!) : notFound(`Session ${id}`)), + SessionTranscript: (req, [id]) => (sessionView(id!) ? transcript(id!, req.from_turn ?? 0) : notFound(`Session ${id}`)), SpeedQuote: refuse, SpeedRun: refuse, ReplayQuote: refuse, diff --git a/scripts/shots/mock/traffic.ts b/scripts/shots/mock/traffic.ts index 6d2bd054..c7b8210c 100644 --- a/scripts/shots/mock/traffic.ts +++ b/scripts/shots/mock/traffic.ts @@ -891,18 +891,22 @@ export function turns(id: string): TurnView[] { /** * 会话的对话(`GET /sessions/{id}/transcript`)。截图里不打开「对话」那一页,给一段读得通的: - * 第一轮是用户的话,之后每一轮一句回答。失败、取消的那一轮没有回答,和 core 一样不算缺口 + * 第一轮是用户的话,之后每一轮一句回答。失败、取消的那一轮没有回答,和 core 一样不算缺口。 + * 都已落库,全部轮次都不会再变;`fromTurn` 照 core 的 `from_turn` 只给后面那些 */ -export function transcript(id: string): Transcript { +export function transcript(id: string, fromTurn = 0): Transcript { + const rows = HISTORY.filter((h) => h.session === id); return { session: id, system: null, - turns: HISTORY.filter((h) => h.session === id).map((h, i) => ({ + total_turns: rows.length, + settled_turns: rows.length, + turns: rows.slice(fromTurn).map((h, j) => ({ id: String(h.id), restart: false, system_changed: null, input: - i === 0 + fromTurn + j === 0 ? [{ role: "user", parts: [{ kind: "text", text: L("修复登录页的表单校验", "Fix the form validation on the sign-in page") }] }] : [], output: diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ac2713f3..2a807ed0 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -82,7 +82,7 @@ dependencies = [ "objc2-foundation", "parking_lot", "percent-encoding", - "windows-sys 0.52.0", + "windows-sys 0.59.0", "wl-clipboard-rs", "x11rb", ] @@ -985,7 +985,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.3", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1171,7 +1171,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2661,7 +2661,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3601,7 +3601,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3657,7 +3657,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -4743,7 +4743,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5268,8 +5268,8 @@ dependencies = [ [[package]] name = "tw-api" -version = "0.64.1" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.64.1#1ddda16c1cab86129f2cda6c5c7decc663a51387" +version = "0.65.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.65.0#2f1abbe9af75fd8caf3971b9bd954284f14d182a" dependencies = [ "serde", "serde_json", @@ -5280,20 +5280,22 @@ dependencies = [ [[package]] name = "tw-dialect" -version = "0.64.1" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.64.1#1ddda16c1cab86129f2cda6c5c7decc663a51387" +version = "0.65.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.65.0#2f1abbe9af75fd8caf3971b9bd954284f14d182a" dependencies = [ + "memchr", "serde", "serde_json", ] [[package]] name = "tw-guard" -version = "0.64.1" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.64.1#1ddda16c1cab86129f2cda6c5c7decc663a51387" +version = "0.65.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.65.0#2f1abbe9af75fd8caf3971b9bd954284f14d182a" dependencies = [ "base64 0.22.1", "bytes", + "memchr", "regex", "serde", "serde_json", @@ -5305,8 +5307,8 @@ dependencies = [ [[package]] name = "tw-link" -version = "0.64.1" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.64.1#1ddda16c1cab86129f2cda6c5c7decc663a51387" +version = "0.65.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.65.0#2f1abbe9af75fd8caf3971b9bd954284f14d182a" dependencies = [ "serde", "serde_json", @@ -5332,8 +5334,8 @@ dependencies = [ [[package]] name = "tw-types" -version = "0.64.1" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.64.1#1ddda16c1cab86129f2cda6c5c7decc663a51387" +version = "0.65.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.65.0#2f1abbe9af75fd8caf3971b9bd954284f14d182a" dependencies = [ "serde", "ts-rs", @@ -5341,9 +5343,10 @@ dependencies = [ [[package]] name = "tw-watch" -version = "0.64.1" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.64.1#1ddda16c1cab86129f2cda6c5c7decc663a51387" +version = "0.65.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.65.0#2f1abbe9af75fd8caf3971b9bd954284f14d182a" dependencies = [ + "libc", "notify", "thiserror 2.0.21", "tokio", @@ -5351,8 +5354,8 @@ dependencies = [ [[package]] name = "tw-yaml" -version = "0.64.1" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.64.1#1ddda16c1cab86129f2cda6c5c7decc663a51387" +version = "0.65.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.65.0#2f1abbe9af75fd8caf3971b9bd954284f14d182a" dependencies = [ "saphyr-parser", "thiserror 2.0.21", @@ -5816,7 +5819,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 09703cbe..ec48785e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -26,12 +26,12 @@ license = "MIT" # twcore 二进制必须和这里编译进去的协议镜像来自同一个 core 版本 —— 打包脚本正是 # 从 tw-api 锁到的 tag 去取二进制的(见 scripts/fetch-core.sh)。 [workspace.dependencies] -tw-api = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.64.1" } -tw-types = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.64.1" } -tw-yaml = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.64.1" } -tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.64.1" } -tw-watch = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.64.1" } -tw-link = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.64.1" } +tw-api = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.65.0" } +tw-types = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.65.0" } +tw-yaml = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.65.0" } +tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.65.0" } +tw-watch = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.65.0" } +tw-link = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.65.0" } [lib] name = "thinkwatch_lite_lib" diff --git a/src-tauri/crates/tw-scan/src/sources.rs b/src-tauri/crates/tw-scan/src/sources.rs index e3c7b043..6bcea267 100644 --- a/src-tauri/crates/tw-scan/src/sources.rs +++ b/src-tauri/crates/tw-scan/src/sources.rs @@ -16,7 +16,7 @@ //! | 全盘搜 `.claude/` | **不扫** | //! //! 最后一条是硬约束,不是偷懒。项目级的 `.claude/` 散落全盘,无界的 -//! FSEvents 监听既是性能问题,也和「空闲时接近零」的目标直接 +//! 文件监听既是性能问题,也和「空闲时接近零」的目标直接 //! 冲突。而且一个用户 clone 过的仓库可能有几百个,其中绝大多数他这辈子 //! 都不会再打开 —— 为它们持续烧 CPU 换不到任何东西。 diff --git a/src-tauri/crates/tw-scan/src/watch.rs b/src-tauri/crates/tw-scan/src/watch.rs index c54643f5..2798f645 100644 --- a/src-tauri/crates/tw-scan/src/watch.rs +++ b/src-tauri/crates/tw-scan/src/watch.rs @@ -14,7 +14,7 @@ //! # 范围仍然是那几个目录 //! //! 监听的目录集合就是 [`crate::sources`] 划定的那一批,一个不多。无界的 -//! FSEvents 监听既是性能问题,也和「空闲时接近零」的目标冲突。 +//! 文件监听既是性能问题,也和「空闲时接近零」的目标冲突。 //! //! **盯目录不盯文件、不递归、去抖**,和配置文件的监听是同一份([`tw_watch`])。 @@ -222,7 +222,8 @@ pub fn watch_plan(plan: &Plan) -> Result<(Watch, tokio::sync::mpsc::Receiver<()> /// 根不算(盯它等于什么都盯)。 /// /// home 以外的(换过位置的目录)**只看上一层**:那个目录自己都不在的话,再往上找就要 -/// 盯到 `/Volumes` 这种地方去 —— macOS 上盯一个目录,其实是盯它底下的一整棵树。 +/// 盯到 `/Volumes` 这种地方去。macOS 上平时用 kqueue,只看那个目录自己的条目;但 fd +/// 名额不够、退回 FSEvents 时,盯一个目录就是盯它底下的一整棵树(见 `tw_watch`)。 fn nearest_dir(path: &Path, home: &Path) -> Option { let inside = path.starts_with(home); path.ancestors() diff --git a/src/generated/tw-api.ts b/src/generated/tw-api.ts index ce5baa4f..4aaa1045 100644 --- a/src/generated/tw-api.ts +++ b/src/generated/tw-api.ts @@ -1,6 +1,6 @@ // Generated by tw-api (`tw_api::ts::export_all`). Do not edit by hand. -export const CONTROL_API_VERSION = 40; +export const CONTROL_API_VERSION = 41; /** * 一个账号上游登的是哪个账号。 @@ -966,7 +966,14 @@ success_rate?: number | null, * 按快慢、成败算出的系数,乘在权重([`Self::weight`])上:大于 1 分得多,小于 1 * 分得少,没有样本的那一项算 1。只在 `balance_by` 不是 `weights` 的 `load-balance` 里有 */ -balance_factor?: number | null, }; +balance_factor?: number | null, +/** + * 照此刻的数字轮下去,它分到的新对话占这个组的多少,0 到 1:有效权重(权重 × 系数) + * 除以这一轮参加的各家之和,和数据面挑排头是同一份数字。只在 `load-balance` 里有, + * 同一个组里有份额的各家加起来是 1。熔断着、冷却着、并发数满着的这一轮不参加,没有 + * 份额 —— 全都这样时都参加 + */ +share?: number | null, }; /** * 一次试算的结论。 @@ -4572,6 +4579,9 @@ samples: number, }; * 太大的只留开头,没存下来的也有。读不到的地方,那一轮的 `gaps` 说出来。 * * **已脱敏**,和请求详情里的正文同一套打码。图片只说类型和大小,从不带数据。 + * + * **可以只要后面的几轮**([`TranscriptQuery::from_turn`]):会话开着时每来一轮重读一次, + * 前面不会再变的那些(`settled_turns`)不必再传一遍。 */ export type Transcript = { session: string, /** @@ -4581,7 +4591,22 @@ export type Transcript = { session: string, */ system: string | null, /** - * 和 [`SessionDetail::turns`] 同样的请求,同样的顺序 + * 这次会话一共几轮,就是 [`SessionDetail::turns`] 有几条。给了 + * [`TranscriptQuery::from_turn`] 时,`turns` 是其中从那一轮起的那些 + */ +total_turns: number, +/** + * 开头这么多轮不会再变了:它们的正文都落了盘(或者不会再来),后面的请求不会再改写 + * 它们,这次会话里还在跑的请求落库时也不会排到它们前面。**下一次从这里要起** + * (`from_turn = settled_turns`),留着手上的前这么多轮,换掉后面的。 + * + * 后面那几轮还会变:最后一轮的回答可能还没落盘、工具调用的号会被下一轮换成客户端 + * 记下的那个、一个开始得早结束得晚的请求会插到它们中间 + */ +settled_turns: number, +/** + * 和 [`SessionDetail::turns`] 同样的请求,同样的顺序。给了 + * [`TranscriptQuery::from_turn`] 的,只有从那一轮起的那些 */ turns: Array, }; @@ -4600,6 +4625,16 @@ export type TranscriptMessage = { role: TranscriptRole, parts: Array }; SessionDetail: { req: null; res: SessionDetail }; - SessionTranscript: { req: null; res: Transcript }; + SessionTranscript: { req: TranscriptQuery; res: Transcript }; SpeedQuote: { req: SpeedRunRequest; res: SpeedQuote }; SpeedRun: { req: SpeedRunRequest; res: Array }; ReplayQuote: { req: ReplayRequest; res: ReplayQuote }; diff --git a/src/routing/DryRunDialog.tsx b/src/routing/DryRunDialog.tsx index 54d279c8..c89c6f44 100644 --- a/src/routing/DryRunDialog.tsx +++ b/src/routing/DryRunDialog.tsx @@ -49,7 +49,7 @@ import { FormItem } from "@/upstreams/parts"; import { api } from "./api"; import { dryRunText } from "./DryRunDialog.i18n"; import { ModelInput, onOpenFocus } from "./fields"; -import { DIALECTS, balanceShares, usersOf } from "./model"; +import { DIALECTS, usersOf } from "./model"; import { KeyIcon, TargetIcon } from "./parts"; import { routingText } from "./routing.i18n"; import { modelViaOf, type ModelVia } from "./target"; @@ -378,7 +378,6 @@ function Result({ */ const short = r.outcome === "intercepted"; const candidates = r.candidate_models; - const shares = balanceShares(r); const target = r.outcome === "route" ? (r.via_group ?? candidates[0]?.provider ?? null) : null; // 这一趟经过的路:密钥 → 路由 → 规则 → 去向。和路由图同一套标志 @@ -483,7 +482,7 @@ function Result({ {c} {cv.sent_model && via && } {cv.weight != null && ( - + )} {open && ( diff --git a/src/routing/model.test.ts b/src/routing/model.test.ts index 2d01b305..61d45ff5 100644 --- a/src/routing/model.test.ts +++ b/src/routing/model.test.ts @@ -1,10 +1,9 @@ import { describe, expect, it } from "vitest"; import { setLang } from "@/i18n"; -import type { ConditionView, DryRunCandidate, GroupView, RouteView, RuleView } from "@/types"; +import type { ConditionView, GroupView, RouteView, RuleView } from "@/types"; import { addOnsText, balanceNotes, - balanceShares, blankPinned, blankRule, canLift, @@ -300,23 +299,4 @@ describe("轮询组的比例和分配依据", () => { "Round robin (7 : 3 · By reliability)", ); }); - - it("试算的占比按权重 × 系数分,熔断着的不参加", () => { - const c = (provider: string, weight: number | null, balance_factor: number | null = null): DryRunCandidate => ({ - provider, - weight, - balance_factor, - }); - expect(balanceShares({ candidate_models: [c("a", 7), c("b", 3)], circuit_open: [] })).toEqual([0.7, 0.3]); - const auto = balanceShares({ candidate_models: [c("a", 2, 2.25), c("b", 1, 0.5)], circuit_open: [] }); - expect(auto[0]).toBeCloseTo(0.9); - expect(auto[1]).toBeCloseTo(0.1); - expect(balanceShares({ candidate_models: [c("a", 1), c("b", 1), c("c", 2)], circuit_open: ["c"] })).toEqual([ - 0.5, 0.5, 0, - ]); - // 全都熔断着时都算:网关照样一家家试 - expect(balanceShares({ candidate_models: [c("a", 3), c("b", 1)], circuit_open: ["a", "b"] })).toEqual([0.75, 0.25]); - // 不是轮询组:没有权重,也就没有占比 - expect(balanceShares({ candidate_models: [c("a", null)], circuit_open: [] })).toEqual([null]); - }); }); diff --git a/src/routing/model.ts b/src/routing/model.ts index 30a9d522..2c0a46a3 100644 --- a/src/routing/model.ts +++ b/src/routing/model.ts @@ -5,7 +5,7 @@ * 能不能用、条件写得对不对,最后由 core 说;这里只做对话框里需要实时给出 * 的那几件事:保存按钮旁边缺什么、哪条规则被兜底挡住。 */ -import type { ClientView, ConditionField, ConditionView, Dialect, DryRunResult, GroupKind, GroupView, KnownModel, PinnedModel, ProviderView, RouteView, RuleInput, RuleView } from "@/types"; +import type { ClientView, ConditionField, ConditionView, Dialect, GroupKind, GroupView, KnownModel, PinnedModel, ProviderView, RouteView, RuleInput, RuleView } from "@/types"; import { textOf } from "@/i18n"; import { ALL_UPSTREAMS, balanceByLabel, conditionName, groupKindLabel, targetLabel } from "@/labels"; import { protocolLabel } from "@/upstreams/labels"; @@ -435,22 +435,6 @@ export function strategyText(g: Pick): (number | null)[] { - const members = r.candidate_models.filter((c) => c.weight != null); - const sitOut = members.every((c) => r.circuit_open.includes(c.provider)) ? [] : r.circuit_open; - const eff = (c: (typeof members)[number]) => - sitOut.includes(c.provider) ? 0 : (c.weight ?? 0) * (c.balance_factor ?? 1); - const total = members.reduce((a, c) => a + eff(c), 0); - return r.candidate_models.map((c) => (c.weight == null ? null : total > 0 ? eff(c) / total : 0)); -} - /** 去向的说明:策略组的策略与成员,或上游的协议 */ export function describeTarget( name: string, diff --git a/src/traffic/Conversation.tsx b/src/traffic/Conversation.tsx index cf515912..9739015a 100644 --- a/src/traffic/Conversation.tsx +++ b/src/traffic/Conversation.tsx @@ -39,6 +39,8 @@ import { conversationText } from "./Conversation.i18n"; import { turnCost } from "./costCell"; import { allLost, + askFrom, + extendTranscript, argsPreview, blocksOf, clip, @@ -103,6 +105,19 @@ function remember(key: string) { while (recent.length > KEEP) forget(recent.shift()!); } +/** + * 取这次会话的对话:手上有一份就只要不会再变的那几轮之后的(`askFrom`),接到后面; + * 接不上(前面的请求过期被删了)就整段重取。长会话每落一轮不必再把几 MB 传一遍。 + */ +async function fetchTranscript(id: string, prev: Transcript | undefined): Promise { + const from = askFrom(prev, id); + if (from > 0) { + const joined = extendTranscript(prev, await call("SessionTranscript", { from_turn: from }, id), from); + if (joined) return joined; + } + return call("SessionTranscript", {}, id); +} + /** 轮次头上的几项,从会话详情里那一轮来(`TurnView`) */ interface Head { at: number | null; @@ -155,7 +170,7 @@ export function Conversation({ useEffect(() => remember(key), [key]); /** 上一次取到的那一份:重取回来的轮次没变就换回它(`keepTurns`) */ const prev = useRef(undefined); - const r = useResource(key, async () => keepTurns(prev.current, await call("SessionTranscript", null, id)), { + const r = useResource(key, async () => keepTurns(prev.current, await fetchTranscript(id, prev.current)), { deps: [turns.length, turns[turns.length - 1]?.id ?? null], }); prev.current = r.data; diff --git a/src/traffic/transcript.test.ts b/src/traffic/transcript.test.ts index 4db18028..2cd457ad 100644 --- a/src/traffic/transcript.test.ts +++ b/src/traffic/transcript.test.ts @@ -8,6 +8,8 @@ import { clip, failureLine, items, + askFrom, + extendTranscript, keepTurns, missingWhy, notesOf, @@ -303,6 +305,8 @@ describe("重新取回来的对话", () => { const base: Transcript = { session: "s1", system: "sys", + total_turns: 2, + settled_turns: 1, turns: [turn("1", { input: [msg("user", text("问"))], output: [text("答")] }), turn("2", { output: [text("又答")] })], }; const fresh = (): Transcript => JSON.parse(JSON.stringify(base)) as Transcript; @@ -455,3 +459,37 @@ describe("失败的那一轮写什么", () => { expect(failureLine(undefined)).toBeNull(); }); }); + +describe("只取新的轮次", () => { + const t = (id: string) => turn(id, { output: [text(id)] }); + const have: Transcript = { session: "s1", system: "sys", total_turns: 3, settled_turns: 2, turns: [t("1"), t("2"), t("3")] }; + + it("从不会再变的那几轮之后要;别的会话、没有手上那份时要整段", () => { + expect(askFrom(have, "s1")).toBe(2); + expect(askFrom(have, "s2")).toBe(0); + expect(askFrom(undefined, "s1")).toBe(0); + // 手上的比 core 说的少(不该发生):只从手上有的那么多轮之后要 + expect(askFrom({ ...have, turns: [t("1")] }, "s1")).toBe(1); + }); + + it("前面几轮照旧、后面换成新取回来的", () => { + const tail: Transcript = { session: "s1", system: "sys", total_turns: 4, settled_turns: 3, turns: [t("3"), t("4")] }; + const got = extendTranscript(have, tail, 2)!; + expect(got.turns.map((x) => String(x.id))).toEqual(["1", "2", "3", "4"]); + expect(got.turns[0]).toBe(have.turns[0]); + expect(got.settled_turns).toBe(3); + expect(got.total_turns).toBe(4); + }); + + it("接不上的要整段重取", () => { + const tail: Transcript = { session: "s1", system: "sys", total_turns: 1, settled_turns: 1, turns: [] }; + expect(extendTranscript(have, tail, 2)).toBeNull(); + expect(extendTranscript(have, { ...tail, session: "s2", total_turns: 5 }, 2)).toBeNull(); + expect(extendTranscript(undefined, tail, 2)).toBeNull(); + }); + + it("从 0 起要的就是整段", () => { + const whole: Transcript = { ...have, turns: [t("9")] }; + expect(extendTranscript(have, whole, 0)).toBe(whole); + }); +}); diff --git a/src/traffic/transcript.ts b/src/traffic/transcript.ts index e02a6c04..d0ce9b0e 100644 --- a/src/traffic/transcript.ts +++ b/src/traffic/transcript.ts @@ -369,6 +369,27 @@ function partSize(p: TranscriptPart): number { } } +/** + * 这一次从第几轮起要:手上那一份前 `settled_turns` 轮不会再变(core 说的),从那儿往后要。 + * 手上没有、或者是别的会话的,就要整段(0)。 + */ +export function askFrom(prev: Transcript | undefined, session: string): number { + if (!prev || prev.session !== session) return 0; + return Math.min(prev.settled_turns, prev.turns.length); +} + +/** + * 把从第 `from` 轮起取回来的后半段接到手上那一份后面:前 `from` 轮照旧,后面换成新的。 + * + * **接不上就是 null**,要整段重取:会话对不上,或者 core 那边总轮数已经不到 `from` + * (前面的请求过了保留期限被删了)。 + */ +export function extendTranscript(prev: Transcript | undefined, next: Transcript, from: number): Transcript | null { + if (from === 0) return next; + if (!prev || prev.session !== next.session || next.total_turns < from || prev.turns.length < from) return null; + return { ...next, turns: [...prev.turns.slice(0, from), ...next.turns] }; +} + /** * 重新取回来的对话里,**没变的轮次换回原来那个对象**。 *