From a451dc841480ed2633f9e79177b0882545ddf1c8 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:47:25 +0800 Subject: [PATCH 1/3] Traffic: abort a running request or a whole session; Settings: no-response timeout (core protocol 43) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core now gives up on an upstream that sends no content for `failover.idle_timeout_secs` (default 300) and can abort running requests (ThinkWatch-Core#303). This pins the six core crates to that PR's head until it is tagged. - Traffic: a running request's row menu gets "Abort request" (not for WebSocket turns, which core cannot abort on their own). A session with running requests gets "Abort session" in its panel header and in its group row menu, behind a confirmation that shows a failure in place. Aborted requests read "Aborted" in grey (not a fault), in the table and the drawer. - Request drawer: the attempt chain labels `idle_timeout` as "No response · 300 s" (core's sentence on hover) and `aborted` in grey; a request that failed on the timeout reads "No response". An idle timeout now counts as an upstream failure in the failover note, as core counts it. - Settings › Failover: "No-response timeout" (30–3600 s, 300 by default, written out) replaces the start-wait row and the "move on when the start times out" switch. - Chinese for the new core codes; the removed ones are gone. Screenshot mock and oracle JSON follow the new protocol. Co-Authored-By: Claude Opus 5.5 --- scripts/shots/core/en/overview.json | 5 +- scripts/shots/core/en/status.json | 4 +- scripts/shots/core/zh/overview.json | 5 +- scripts/shots/core/zh/status.json | 4 +- scripts/shots/mock/core.ts | 2 + src-tauri/Cargo.lock | 14 +-- src-tauri/Cargo.toml | 12 +-- src-tauri/src/call.rs | 2 + src-tauri/src/clients/desktop_rule.rs | 4 +- src-tauri/src/notices/fixtures/snapshot.json | 3 +- src/control.ts | 2 + src/events.test.ts | 9 ++ src/generated/tw-api.ts | 40 +++++--- src/i18n/core.zh.json | 7 +- src/labels.i18n.ts | 11 ++- src/labels.test.ts | 37 ++++++-- src/labels.ts | 34 +++++-- src/requestRouting.test.ts | 19 ++-- src/requestRouting.ts | 8 +- src/settings/FailoverSection.i18n.ts | 21 ++--- src/settings/FailoverSection.tsx | 77 ++++----------- src/settings/failover.test.ts | 23 ++--- src/traffic/PanelHeader.tsx | 4 + src/traffic/RequestTable.tsx | 28 +++++- src/traffic/SessionPanel.tsx | 18 ++++ src/traffic/SessionRow.tsx | 12 ++- src/traffic/Traffic.i18n.tsx | 3 + src/traffic/TrafficPage.tsx | 11 +++ src/traffic/abort.i18n.ts | 23 +++++ src/traffic/abort.test.ts | 10 ++ src/traffic/abort.tsx | 98 ++++++++++++++++++++ src/traffic/drawer/RequestDrawer.i18n.tsx | 10 +- src/traffic/drawer/RequestDrawer.tsx | 13 ++- src/traffic/drawer/Routing.tsx | 13 ++- src/traffic/drawer/Timeline.tsx | 7 +- src/types.ts | 6 ++ 36 files changed, 425 insertions(+), 174 deletions(-) create mode 100644 src/traffic/abort.i18n.ts create mode 100644 src/traffic/abort.test.ts create mode 100644 src/traffic/abort.tsx diff --git a/scripts/shots/core/en/overview.json b/scripts/shots/core/en/overview.json index f80c2019..69468c7f 100644 --- a/scripts/shots/core/en/overview.json +++ b/scripts/shots/core/en/overview.json @@ -66,14 +66,13 @@ "default_route": "default", "failover": { "failures_to_pause": 3, + "idle_timeout_secs": 300, "max_pause_secs": 600, - "next_on_slow_start": false, "no_balance_pause_secs": 1800, "pause_secs": 60, "quota_pause_secs": 3600, "rate_limit_max_pause_secs": 3600, - "slot_wait_secs": 30, - "stream_start_wait_secs": 15 + "slot_wait_secs": 30 }, "groups": [ { diff --git a/scripts/shots/core/en/status.json b/scripts/shots/core/en/status.json index a5b7f59b..692be7e9 100644 --- a/scripts/shots/core/en/status.json +++ b/scripts/shots/core/en/status.json @@ -1,5 +1,5 @@ { - "api_version": 38, + "api_version": 43, "clients": 4, "config_path": "", "gateway_addr": null, @@ -14,5 +14,5 @@ "reachable": [] }, "uptime_secs": 0, - "version": "0.62.0" + "version": "0.66.0" } diff --git a/scripts/shots/core/zh/overview.json b/scripts/shots/core/zh/overview.json index f64795d9..b96bb7e8 100644 --- a/scripts/shots/core/zh/overview.json +++ b/scripts/shots/core/zh/overview.json @@ -66,14 +66,13 @@ "default_route": "default", "failover": { "failures_to_pause": 3, + "idle_timeout_secs": 300, "max_pause_secs": 600, - "next_on_slow_start": false, "no_balance_pause_secs": 1800, "pause_secs": 60, "quota_pause_secs": 3600, "rate_limit_max_pause_secs": 3600, - "slot_wait_secs": 30, - "stream_start_wait_secs": 15 + "slot_wait_secs": 30 }, "groups": [ { diff --git a/scripts/shots/core/zh/status.json b/scripts/shots/core/zh/status.json index a5b7f59b..692be7e9 100644 --- a/scripts/shots/core/zh/status.json +++ b/scripts/shots/core/zh/status.json @@ -1,5 +1,5 @@ { - "api_version": 38, + "api_version": 43, "clients": 4, "config_path": "", "gateway_addr": null, @@ -14,5 +14,5 @@ "reachable": [] }, "uptime_secs": 0, - "version": "0.62.0" + "version": "0.66.0" } diff --git a/scripts/shots/mock/core.ts b/scripts/shots/mock/core.ts index a1d78f25..b5b991ad 100644 --- a/scripts/shots/mock/core.ts +++ b/scripts/shots/mock/core.ts @@ -104,6 +104,8 @@ 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!, req.from_turn ?? 0) : notFound(`Session ${id}`)), + AbortRequest: refuse, + AbortSession: refuse, SpeedQuote: refuse, SpeedRun: refuse, ReplayQuote: refuse, diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index bc03c7da..7ff2ea01 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5269,7 +5269,7 @@ dependencies = [ [[package]] name = "tw-api" version = "0.66.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.66.0#476cfafce2c8c519c5230ee8f4c1b3c5b938650c" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=2bd8fb4caa1ae5bcfae9c31f6585cbdbb3e3a90f#2bd8fb4caa1ae5bcfae9c31f6585cbdbb3e3a90f" dependencies = [ "serde", "serde_json", @@ -5281,7 +5281,7 @@ dependencies = [ [[package]] name = "tw-dialect" version = "0.66.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.66.0#476cfafce2c8c519c5230ee8f4c1b3c5b938650c" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=2bd8fb4caa1ae5bcfae9c31f6585cbdbb3e3a90f#2bd8fb4caa1ae5bcfae9c31f6585cbdbb3e3a90f" dependencies = [ "memchr", "serde", @@ -5291,7 +5291,7 @@ dependencies = [ [[package]] name = "tw-guard" version = "0.66.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.66.0#476cfafce2c8c519c5230ee8f4c1b3c5b938650c" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=2bd8fb4caa1ae5bcfae9c31f6585cbdbb3e3a90f#2bd8fb4caa1ae5bcfae9c31f6585cbdbb3e3a90f" dependencies = [ "base64 0.22.1", "bytes", @@ -5308,7 +5308,7 @@ dependencies = [ [[package]] name = "tw-link" version = "0.66.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.66.0#476cfafce2c8c519c5230ee8f4c1b3c5b938650c" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=2bd8fb4caa1ae5bcfae9c31f6585cbdbb3e3a90f#2bd8fb4caa1ae5bcfae9c31f6585cbdbb3e3a90f" dependencies = [ "serde", "serde_json", @@ -5335,7 +5335,7 @@ dependencies = [ [[package]] name = "tw-types" version = "0.66.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.66.0#476cfafce2c8c519c5230ee8f4c1b3c5b938650c" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=2bd8fb4caa1ae5bcfae9c31f6585cbdbb3e3a90f#2bd8fb4caa1ae5bcfae9c31f6585cbdbb3e3a90f" dependencies = [ "serde", "ts-rs", @@ -5344,7 +5344,7 @@ dependencies = [ [[package]] name = "tw-watch" version = "0.66.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.66.0#476cfafce2c8c519c5230ee8f4c1b3c5b938650c" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=2bd8fb4caa1ae5bcfae9c31f6585cbdbb3e3a90f#2bd8fb4caa1ae5bcfae9c31f6585cbdbb3e3a90f" dependencies = [ "libc", "notify", @@ -5355,7 +5355,7 @@ dependencies = [ [[package]] name = "tw-yaml" version = "0.66.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.66.0#476cfafce2c8c519c5230ee8f4c1b3c5b938650c" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=2bd8fb4caa1ae5bcfae9c31f6585cbdbb3e3a90f#2bd8fb4caa1ae5bcfae9c31f6585cbdbb3e3a90f" dependencies = [ "saphyr-parser", "thiserror 2.0.21", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6e86d09d..cd94574d 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.66.0" } -tw-types = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.66.0" } -tw-yaml = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.66.0" } -tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.66.0" } -tw-watch = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.66.0" } -tw-link = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.66.0" } +tw-api = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "2bd8fb4caa1ae5bcfae9c31f6585cbdbb3e3a90f" } +tw-types = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "2bd8fb4caa1ae5bcfae9c31f6585cbdbb3e3a90f" } +tw-yaml = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "2bd8fb4caa1ae5bcfae9c31f6585cbdbb3e3a90f" } +tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "2bd8fb4caa1ae5bcfae9c31f6585cbdbb3e3a90f" } +tw-watch = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "2bd8fb4caa1ae5bcfae9c31f6585cbdbb3e3a90f" } +tw-link = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "2bd8fb4caa1ae5bcfae9c31f6585cbdbb3e3a90f" } [lib] name = "thinkwatch_lite_lib" diff --git a/src-tauri/src/call.rs b/src-tauri/src/call.rs index dc1062a4..6c7e6ed2 100644 --- a/src-tauri/src/call.rs +++ b/src-tauri/src/call.rs @@ -76,9 +76,11 @@ webview_endpoints![ History, HistorySearch, RequestDetail, + AbortRequest, Sessions, SessionDetail, SessionTranscript, + AbortSession, // 测速、回放、试路由 SpeedQuote, SpeedRun, diff --git a/src-tauri/src/clients/desktop_rule.rs b/src-tauri/src/clients/desktop_rule.rs index 3a1fd3be..a42527d7 100644 --- a/src-tauri/src/clients/desktop_rule.rs +++ b/src-tauri/src/clients/desktop_rule.rs @@ -477,8 +477,8 @@ mod tests { "security": {"redact": "observe", "inspect_tools": "observe", "content": "observe"}, "retention": {"body_days": 7, "row_days": 90, "body_max_bytes": 0, "body_bytes_now": 0}, "failover": {"failures_to_pause": 3, "pause_secs": 60, "max_pause_secs": 600, "no_balance_pause_secs": 1800, - "quota_pause_secs": 3600, "rate_limit_max_pause_secs": 3600, "stream_start_wait_secs": 15, - "next_on_slow_start": false, "slot_wait_secs": 30} + "quota_pause_secs": 3600, "rate_limit_max_pause_secs": 3600, "idle_timeout_secs": 300, + "slot_wait_secs": 30} }) } diff --git a/src-tauri/src/notices/fixtures/snapshot.json b/src-tauri/src/notices/fixtures/snapshot.json index eddfc802..e5ec6140 100644 --- a/src-tauri/src/notices/fixtures/snapshot.json +++ b/src-tauri/src/notices/fixtures/snapshot.json @@ -175,8 +175,7 @@ "no_balance_pause_secs": 1800, "quota_pause_secs": 3600, "rate_limit_max_pause_secs": 3600, - "stream_start_wait_secs": 15, - "next_on_slow_start": false, + "idle_timeout_secs": 300, "slot_wait_secs": 30 }, "price_sheets": [] diff --git a/src/control.ts b/src/control.ts index db7af63b..5fb785ba 100644 --- a/src/control.ts +++ b/src/control.ts @@ -41,9 +41,11 @@ export const WEBVIEW_ENDPOINTS = [ "History", "HistorySearch", "RequestDetail", + "AbortRequest", "Sessions", "SessionDetail", "SessionTranscript", + "AbortSession", "SpeedQuote", "SpeedRun", "ReplayQuote", diff --git a/src/events.test.ts b/src/events.test.ts index 60c5ea8a..16f4a39c 100644 --- a/src/events.test.ts +++ b/src/events.test.ts @@ -50,6 +50,15 @@ describe("从事件缝出一行", () => { expect(rows.get(1)?.model).toBe("claude-sonnet-4-5"); }); + /** 跑在 WebSocket 连接上的那一行说得出来:它不能单独中止(`abortable`) */ + it("开始事件说得出是不是 WebSocket", () => { + const rows = new Map(); + applyEvent(rows, started({ method: "WS", path: "/v1/responses" })); + applyEvent(rows, started({ id: 2 })); + expect(rows.get(1)?.ws).toBe(true); + expect(rows.get(2)?.ws).toBeUndefined(); + }); + /** 会话是网关在开始的那一刻定的:正在跑的那一条已经在它的会话里,不用等落库 */ it("开始就带着会话", () => { const rows = new Map(); diff --git a/src/generated/tw-api.ts b/src/generated/tw-api.ts index da78ce8e..534594b0 100644 --- a/src/generated/tw-api.ts +++ b/src/generated/tw-api.ts @@ -1,6 +1,18 @@ // Generated by tw-api (`tw_api::ts::export_all`). Do not edit by hand. -export const CONTROL_API_VERSION = 42; +export const CONTROL_API_VERSION = 43; + +/** + * 手动中止(`POST /request/{id}/abort`、`POST /sessions/{id}/abort`)叫停了哪几个请求。 + * + * **叫停是立刻的,结局随后到**:每个请求照常报一条 [`Event::RequestFailed`](`source` 是 + * [`FailureSource::Aborted`]),通常在这条响应之前或紧跟着。叫停之后才跑完的不改。 + */ +export type Aborted = { +/** + * 叫停的请求号,开始得早的在前 + */ +requests: Array, }; /** * 一个账号上游登的是哪个账号。 @@ -219,10 +231,10 @@ suggestions: Array, }; /** * 尝试链里一跳的结果。 */ -export type AttemptOutcome = "served" | "status" | "error" | "estimated" | "slow_start"; +export type AttemptOutcome = "served" | "status" | "error" | "estimated" | "idle_timeout" | "aborted"; /** - * 放弃了的一跳([`AttemptOutcome::SlowStart`])上游可能已经收了钱的输入。 + * 放弃了的一跳([`AttemptOutcome::IdleTimeout`])上游可能已经收了钱的输入。 * * 上游在流开头报了的(Anthropic 的 `message_start`)是它报的数;没报的只有 `input`,是网关 * 估的(`estimated`,和 [`Event::RequestStarted`] 的 `input_estimate` 同一个数)。**输出不知道**: @@ -266,11 +278,12 @@ outcome: AttemptOutcome, */ status?: number | null, /** - * `error` 时的说明。和这一跳报给客户端的那条错误是同一句。`slow_start` 时说等了多久 + * `error` 时的说明。和这一跳报给客户端的那条错误是同一句。`idle_timeout` 时说等了多久, + * `aborted` 时是 `gw.request.aborted` */ error?: Msg | null, ms: number, /** - * 放弃了的这一跳(`slow_start`)上游可能已经收了钱的输入(见 [`AttemptUsage`])。估不 + * 放弃了的这一跳(`idle_timeout`)上游可能已经收了钱的输入(见 [`AttemptUsage`])。估不 * 出来的(请求解不开)没有。别的结果都没有:接下请求的那一跳的用量在结局里 */ usage?: AttemptUsage | null, @@ -1501,7 +1514,7 @@ plugin_name: string, request_id?: number | null, message: Msg, at_ms: number, } | { "kind": "events_dropped", id: number, count: number, at_ms: number, }; /** - * 上游失败之后停用多久、流开头最多等多久。和配置的 `failover` 一一对应, + * 上游失败之后停用多久、多久没有内容就不再等。和配置的 `failover` 一一对应, * 没写的是默认值 —— **界面显示的就是真在用的数**,不是「空 = 默认」。 */ export type FailoverView = { @@ -1530,13 +1543,10 @@ quota_pause_secs: number, */ rate_limit_max_pause_secs: number, /** - * 流式回答的开头最多等多少秒 - */ -stream_start_wait_secs: number, -/** - * 等过 `stream_start_wait_secs` 还没有内容就换下一家(最后一家照常等) + * 上游多少秒没有内容就不再等它(无响应超时):从请求发出去起算,每来一段内容重新 + * 计时。客户端还什么都没收到时换下一家,已经收到一部分的报错收尾 */ -next_on_slow_start: boolean, +idle_timeout_secs: number, /** * 一个请求合计最多等多少秒:等密钥的分钟、小时上限空出名额,和等满着(`max_concurrent`) * 的上游空出位置,共用这一段。0 是不等 @@ -1547,7 +1557,7 @@ slot_wait_secs: number, }; * 一个请求失败在哪一方。和 HTTP 响应里的 `x-thinkwatch-error` 同一个词表, * 另外多一个 `internal`。 */ -export type FailureSource = "auth" | "config" | "upstream" | "request" | "rate_limited" | "denied" | "internal"; +export type FailureSource = "auth" | "config" | "upstream" | "request" | "rate_limited" | "denied" | "aborted" | "internal"; /** * 新建或修改一个策略组时交过来的定义。 @@ -4935,10 +4945,12 @@ export const ENDPOINTS = { TokenRateByProvider: { method: "GET", path: "/token-rate/provider", params: [], format: "json" }, UpstreamHealth: { method: "GET", path: "/upstreams/health", params: [], format: "json" }, RequestDetail: { method: "GET", path: "/request/{id}", params: ["id"], format: "json" }, + AbortRequest: { method: "POST", path: "/request/{id}/abort", params: ["id"], format: "json" }, Fixture: { method: "GET", path: "/request/{id}/fixture", params: ["id"], format: "text" }, Sessions: { method: "GET", path: "/sessions", params: [], format: "json" }, SessionDetail: { method: "GET", path: "/sessions/{id}", params: ["id"], format: "json" }, SessionTranscript: { method: "GET", path: "/sessions/{id}/transcript", params: ["id"], format: "json" }, + AbortSession: { method: "POST", path: "/sessions/{id}/abort", params: ["id"], format: "json" }, SpeedQuote: { method: "POST", path: "/speed/quote", params: [], format: "json" }, SpeedRun: { method: "POST", path: "/speed/run", params: [], format: "json" }, ReplayQuote: { method: "POST", path: "/replay/quote", params: [], format: "json" }, @@ -5058,10 +5070,12 @@ export type Endpoints = { TokenRateByProvider: { req: Window; res: Array }; UpstreamHealth: { req: Window; res: UpstreamHealth }; RequestDetail: { req: null; res: RequestDetail }; + AbortRequest: { req: null; res: Aborted }; Fixture: { req: null; res: string }; Sessions: { req: ListQuery; res: Array }; SessionDetail: { req: null; res: SessionDetail }; SessionTranscript: { req: TranscriptQuery; res: Transcript }; + AbortSession: { req: null; res: Aborted }; SpeedQuote: { req: SpeedRunRequest; res: SpeedQuote }; SpeedRun: { req: SpeedRunRequest; res: Array }; ReplayQuote: { req: ReplayRequest; res: ReplayQuote }; diff --git a/src/i18n/core.zh.json b/src/i18n/core.zh.json index a372f0a3..2cf681fb 100644 --- a/src/i18n/core.zh.json +++ b/src/i18n/core.zh.json @@ -434,7 +434,9 @@ "gw.upstream.status": "上游「{upstream}」返回 {status}。", "gw.upstream.status_message": "上游「{upstream}」返回 {status}:{message}", "gw.upstream.stream_opening_error": "上游「{upstream}」开始回答后、给出任何内容之前报错({kind}):{message}", - "gw.slow_start": "上游「{upstream}」在 {secs} 秒内没有返回内容,请求已转到下一个上游。", + "gw.upstream.idle_timeout": "上游「{upstream}」在 {secs} 秒内没有发出内容。", + "gw.upstream.idle_timeout_mid_stream": "上游「{upstream}」回答到一半后停止发送内容,{secs} 秒内没有新的内容。", + "gw.request.aborted": "请求已被手动中止。", "gw.upstream.stream_error": "上游「{upstream}」在回答过程中报错:{message}", "gw.upstream.stream_exception": "上游「{upstream}」以 {kind} 结束了响应流:{message}", "gw.upstream.eventstream_broken": "上游「{upstream}」发来了损坏的 AWS eventstream 帧:{detail}", @@ -473,6 +475,7 @@ "gw.listen.no_such_nic": "本机没有名为 {name} 的网卡,现有网卡:{available}。", "gw.listen.nic_offline": "网卡 {name} 当前未连接网络,请检查网线或 Wi-Fi 连接。", "control.request_not_found": "未找到第 {id} 号请求。", + "control.request_not_running": "第 {id} 号请求不在进行中。", "// ── security:安全页的规则与档位 ──────────────────────────────────": "", "security.unknown_guard": "「{guard}」不是一项防护,只能是 redact、inspect_tools 或 content。", "security.unknown_rule": "没有名为「{rule}」的内置规则。", @@ -486,6 +489,7 @@ "security.pattern_empty": "匹配内容为空。", "security.no_action_of_its_own": "{guard:guard}的规则不单独设处置,命中后的处理由档位决定。", "control.session_not_found": "未找到会话 {id}。", + "control.session_not_running": "会话 {id} 没有正在进行的请求。", "control.client_unknown": "未知的客户端「{client}」。", "control.store_off": "请求记录未启动。", "control.store_unavailable": "请求记录不可用,数据库无法打开或磁盘出错,转发不受影响。", @@ -670,7 +674,6 @@ "config.rule_label_bad": "自定义出站脱敏规则「{name}」的占位符名称为「{label}」,须以大写字母开头,由 1 到 24 个大写字母、数字或下划线组成。", "config.unknown_rule": "security.{guard} 中的「{rule}」不是内置规则。", "config.failover_range": "failover.{field} 为 {value},须在 {min} 到 {max} 之间。", - "config.slow_start_too_short": "已开启 failover.next_on_slow_start,而 failover.stream_start_wait_secs 为 {secs}。该值须至少为 {min},否则正常的回答会在开始之前被切断。", "config.plugin.bad_id": "插件 ID「{plugin}」写法有误:只能使用小写字母、数字和连字符,1 到 {max} 个字符。", "config.plugin.duplicate": "插件 ID「{plugin}」重复。", "config.plugin.file": "插件「{plugin}」的文件为 {file},应为 plugins/{plugin}.js。", diff --git a/src/labels.i18n.ts b/src/labels.i18n.ts index 5877c3a8..cc210fd8 100644 --- a/src/labels.i18n.ts +++ b/src/labels.i18n.ts @@ -73,8 +73,11 @@ export const labelsText = messages( noResponse: "未收到响应", estimated: "本地估算", estimatedAfter: (status: number) => `${status} · 本地估算`, - /** 开头等过了时限还没有内容,换了下一个上游 */ - slowStart: "开头超时", + /** 等到无响应超时还没有内容,放弃了这个上游 */ + idleTimeout: "无响应超时", + idleTimeoutSecs: (secs: string) => `无响应超时 · ${secs} 秒`, + /** 在界面上手动中止 */ + aborted: "手动中止", /** 选定上游之后被规则拒绝的那一跳:没有发给这个上游 */ deniedHop: (rule: string) => `未发送 · 被规则「${rule}」拒绝`, // 没有上游接下的请求,在「上游」的位置上写的那一句:规则拒绝、没有可用的上游、 @@ -220,7 +223,9 @@ export const labelsText = messages( noResponse: "No response received", estimated: "Estimated locally", estimatedAfter: (status: number) => `${status} · Estimated locally`, - slowStart: "Start timed out", + idleTimeout: "No response", + idleTimeoutSecs: (secs: string) => `No response · ${secs} s`, + aborted: "Aborted", deniedHop: (rule: string) => `Not sent · denied by rule “${rule}”`, // 流量表「上游」那一列放得下的长度:再长就折成两行 notSent: { diff --git a/src/labels.test.ts b/src/labels.test.ts index 26f60d46..557127c7 100644 --- a/src/labels.test.ts +++ b/src/labels.test.ts @@ -5,6 +5,7 @@ import { PROBES, attemptText, conditionName, + failureKind, conditionText, mismatchText, setText, @@ -68,11 +69,17 @@ describe("规则的条件与改写", () => { }); describe("尝试链里的一跳", () => { - const slow = { + const quiet = { provider: "anthropic", - outcome: "slow_start" as const, - error: { code: "gw.slow_start", args: { upstream: "anthropic", secs: "30" }, text: "" }, - ms: 30_004, + outcome: "idle_timeout" as const, + error: { code: "gw.upstream.idle_timeout", args: { upstream: "anthropic", secs: "300" }, text: "" }, + ms: 300_004, + }; + const aborted = { + provider: "anthropic", + outcome: "aborted" as const, + error: { code: "gw.request.aborted", text: "" }, + ms: 1_200, }; const busy = { provider: "anthropic", @@ -82,19 +89,21 @@ describe("尝试链里的一跳", () => { skipped: "busy" as const, }; - it("开头超时、满着跳过:短名,悬停是 core 的原话", () => { - expect(attemptText(slow)).toEqual({ - text: "开头超时", + it("无响应超时、满着跳过:短名,悬停是 core 的原话;手动中止是灰的", () => { + expect(attemptText(quiet)).toEqual({ + text: "无响应超时 · 300 秒", ok: false, - tip: "上游「anthropic」在 30 秒内没有返回内容,请求已转到下一个上游。", + tip: "上游「anthropic」在 300 秒内没有发出内容。", }); + expect(attemptText(aborted)).toEqual({ text: "手动中止", ok: false, idle: true, tip: null }); expect(attemptText(busy)).toEqual({ text: "并发已满", ok: false, tip: "上游「anthropic」已有 2 个请求在进行,达到其并发上限(max_concurrent)。", }); setLang("en"); - expect(attemptText(slow).text).toBe("Start timed out"); + expect(attemptText(quiet).text).toBe("No response · 300 s"); + expect(attemptText(aborted).text).toBe("Aborted"); expect(attemptText(busy).text).toBe("At its concurrency limit"); }); @@ -106,3 +115,13 @@ describe("尝试链里的一跳", () => { }); }); }); + +describe("失败里要单独说的两种", () => { + it("手动中止和无响应超时按码认,别的失败照常", () => { + expect(failureKind({ code: "gw.request.aborted", text: "" })).toBe("aborted"); + expect(failureKind({ code: "gw.upstream.idle_timeout", args: { upstream: "a", secs: "300" }, text: "" })).toBe("idle"); + expect(failureKind({ code: "gw.upstream.idle_timeout_mid_stream", text: "" })).toBe("idle"); + expect(failureKind({ code: "gw.upstream.status", text: "" })).toBeNull(); + expect(failureKind(undefined)).toBeNull(); + }); +}); diff --git a/src/labels.ts b/src/labels.ts index 7bca04f1..3fef9c86 100644 --- a/src/labels.ts +++ b/src/labels.ts @@ -11,6 +11,7 @@ import { coreText } from "@/i18n/core.i18n"; import { usd, type AttemptView, + type Msg, type BalanceBy, type ConditionView, type ConfigOrigin, @@ -156,11 +157,26 @@ export function setText(s: SetView): string { } } +/** 手动中止的请求报的码(core 的 `tw_api::ABORTED`)。码是契约 */ +export const ABORTED = "gw.request.aborted"; +/** 上游没有内容、等到无响应超时的两种码:一个字都没给,和答到一半停住 */ +const IDLE_TIMEOUT = ["gw.upstream.idle_timeout", "gw.upstream.idle_timeout_mid_stream"]; + +/** + * 失败的请求里要单独说的两种:在界面上手动中止的(不是故障,和取消一样是灰的),和上游 + * 没有内容超时的。别的失败是 `null`,照常写「失败」 + */ +export function failureKind(error: Msg | null | undefined): "aborted" | "idle" | null { + if (!error) return null; + if (error.code === ABORTED) return "aborted"; + return IDLE_TIMEOUT.includes(error.code) ? "idle" : null; +} + /** - * 尝试链里的一跳。`ok` 决定颜色。`tip`:短名后面悬停说的那一句(core 说的原话); - * 字本身就是那一句的没有 + * 尝试链里的一跳。`ok` 决定颜色,`idle` 是不算故障的那种(手动中止,灰色)。`tip`:短名后面 + * 悬停说的那一句(core 说的原话);字本身就是那一句的没有 */ -export function attemptText(a: AttemptView): { text: string; ok: boolean; tip: string | null } { +export function attemptText(a: AttemptView): { text: string; ok: boolean; idle?: boolean; tip: string | null } { const t = textOf(labelsText); const said = a.error ? coreText(a.error) : null; // 没有发出去的一跳:这家满着,换了下一家。短名说原因,悬停是 core 那一句(几个请求在 @@ -177,9 +193,15 @@ export function attemptText(a: AttemptView): { text: string; ok: boolean; tip: s // 数 token 由网关自己估:上游不是这种格式(没问过它),或者问过、它没实现这个接口 case "estimated": return { text: a.status == null ? t.estimated : t.estimatedAfter(a.status), ok: true, tip: null }; - // 等过了开头的时限还没有内容,放弃了这一家、换了下一家。悬停说等了多久 - case "slow_start": - return { text: t.slowStart, ok: false, tip: said }; + // 等到无响应超时还没有内容,放弃了这一家(换了下一家,或者没有下一家了)。短名带着 + // 等了多少秒,悬停是 core 的原话 + case "idle_timeout": { + const secs = a.error?.args?.secs; + return { text: secs ? t.idleTimeoutSecs(secs) : t.idleTimeout, ok: false, tip: said }; + } + // 在等上游的时候被手动中止:不是上游的问题 + case "aborted": + return { text: t.aborted, ok: false, idle: true, tip: null }; default: return { text: said ?? t.noResponse, ok: false, tip: null }; } diff --git a/src/requestRouting.test.ts b/src/requestRouting.test.ts index 7da7dd3e..5da245b9 100644 --- a/src/requestRouting.test.ts +++ b/src/requestRouting.test.ts @@ -36,12 +36,12 @@ const busy = (provider: string, queued?: number): AttemptView => ({ skipped: "busy", queued_ms: queued ?? null, }); -/** 开头超时、放弃了的一跳 */ -const slow = (provider: string): AttemptView => ({ +/** 无响应超时、放弃了的一跳 */ +const quiet = (provider: string): AttemptView => ({ provider, - outcome: "slow_start", - error: { code: "gw.slow_start", args: { upstream: provider, secs: "30" }, text: "" }, - ms: 30_004, + outcome: "idle_timeout", + error: { code: "gw.upstream.idle_timeout", args: { upstream: provider, secs: "300" }, text: "" }, + ms: 300_004, usage: { input: 48_210, cache_read: 0, cache_write: 0, estimated: true }, }); @@ -118,9 +118,12 @@ describe("路由那一页", () => { expect(f.hops.map((h) => h.denied)).toEqual([false, false]); }); - it("开头超时、满着跳过的不说成失败:换过上游,说前几次尝试没有接下", () => { - const f = routingFacts(row({ attempts: [slow("anthropic"), served("openrouter")] }, { provider: "openrouter" }), false)!; - expect(f.note).toEqual({ kind: "switched", count: 1 }); + it("无响应超时是上游的失败:说故障转移", () => { + const f = routingFacts(row({ attempts: [quiet("anthropic"), served("openrouter")] }, { provider: "openrouter" }), false)!; + expect(f.note).toEqual({ kind: "failover", failed: 1 }); + }); + + it("满着跳过的不说成失败:换过上游,说前几次尝试没有接下", () => { const g = routingFacts( row({ attempts: [busy("anthropic"), overloaded("openrouter"), served("deepseek")] }, { provider: "deepseek" }), false, diff --git a/src/requestRouting.ts b/src/requestRouting.ts index 7de5fa39..d21347a4 100644 --- a/src/requestRouting.ts +++ b/src/requestRouting.ts @@ -57,8 +57,8 @@ export interface Hop { * 尝试链下面那一句。只说字面上成立的事: * * · `failover`:前 `failed` 个上游失败,换到了下一个(最后一跳的结果在它自己那一行) - * · `switched`:前 `count` 跳没有接下它,换到了下一跳。其中有满着跳过的、或者开头超时 - * 放弃的 —— 那两种不是上游的失败,不说「失败」(每一跳为什么没接下在它自己那一行) + * · `switched`:前 `count` 跳没有接下它,换到了下一跳。其中有满着跳过的 —— 那不是上游的 + * 失败,不说「失败」(每一跳为什么没接下在它自己那一行) * · `limited`:这把网关密钥的用量上限拒绝了它:没有发往任何上游 * · `busy`:剩下的上游都满着,等过了也没空出位置。`tried` 是在那之前真的发出去、没成的 * 几跳;0 就是没有发往任何上游 @@ -159,8 +159,8 @@ export function routingFacts( ? { kind: "failover_denied", failed: n - 1, rule: deniedBy } : { kind: "denied_after_pick", rule: deniedBy }; } else if (n > 1) { - // 满着跳过、开头超时放弃都不是上游的失败 - const plain = attempts.slice(0, -1).every((a) => !skippedHop(a) && a.outcome !== "slow_start"); + // 满着跳过不是上游的失败(无响应超时是) + const plain = attempts.slice(0, -1).every((a) => !skippedHop(a)); note = plain ? { kind: "failover", failed: n - 1 } : { kind: "switched", count: n - 1 }; } diff --git a/src/settings/FailoverSection.i18n.ts b/src/settings/FailoverSection.i18n.ts index ec929c9d..e6478294 100644 --- a/src/settings/FailoverSection.i18n.ts +++ b/src/settings/FailoverSection.i18n.ts @@ -11,7 +11,7 @@ export const failoverText = messages( no_balance_pause_secs: "余额不足", quota_pause_secs: "额度用完", rate_limit_max_pause_secs: "限流", - stream_start_wait_secs: "等待回答开头", + idle_timeout_secs: "无响应超时", slot_wait_secs: "最多等待空位", }, what: { @@ -21,18 +21,16 @@ export const failoverText = messages( no_balance_pause_secs: "上游报告余额不足时暂停的时长。", quota_pause_secs: "上游报告额度用完、但未给出重置时间时暂停的时长。给出重置时间的,暂停到重置为止。", rate_limit_max_pause_secs: "上游限流时按其要求的等待时间暂停,最长为此值。", - stream_start_wait_secs: "流式回答在第一段内容到达前报错时,请求交给下一个上游。等待超过此时长后不再等待。", + idle_timeout_secs: + "上游连续这么久没有发出内容即放弃:尚未向客户端发出内容时转到下一个上游,已经发出时以错误结束。默认 300 秒。", slot_wait_secs: "上游并发已满或密钥的分钟、小时上限用满时,请求合计最多等待的时长。0 表示不等待。", }, - nextOnSlowStart: "开头超时时转到下一个上游", - nextOnSlowStartWhat: "最后一个上游照常等待。开启时,等待时长宜在 30 秒以上。", times: "次", secs: "秒", badCount: "须为 1 到 100 之间的整数。", badSecs: "须为 1 到 604800 之间的整数。", badMax: "须为整数,不小于暂停时长,不超过 604800。", - badWait: "须为 1 到 120 之间的整数。", - badSlowStartWait: "开启「开头超时时转到下一个上游」时,须为 5 到 120 之间的整数。", + badIdle: "须为 30 到 3600 之间的整数。", badSlotWait: "须为 0 到 300 之间的整数。", saveFailed: "未能保存", }, @@ -47,7 +45,7 @@ export const failoverText = messages( no_balance_pause_secs: "Insufficient balance", quota_pause_secs: "Quota used up", rate_limit_max_pause_secs: "Rate limit", - stream_start_wait_secs: "Wait for the answer to start", + idle_timeout_secs: "No-response timeout", slot_wait_secs: "Wait for a free slot at most", }, what: { @@ -59,19 +57,16 @@ export const failoverText = messages( quota_pause_secs: "The pause when the upstream reports its quota used up without saying when it resets. When it does, the pause lasts until then.", rate_limit_max_pause_secs: "A rate-limited upstream is paused for the wait it asks for, at most this long.", - stream_start_wait_secs: - "An error before the first content of a streamed answer sends the request to the next upstream. After this long, the wait ends.", + idle_timeout_secs: + "An upstream that sends no content for this long is given up on: the request moves to the next upstream if nothing has reached the client yet, and ends with an error otherwise. 300 s by default.", slot_wait_secs: "Total time a request waits for a full upstream or a key's per-minute or per-hour limit. 0 means no wait.", }, - nextOnSlowStart: "Move to the next upstream when the start times out", - nextOnSlowStartWhat: "The last upstream keeps waiting. With this on, a wait of 30 s or more is advisable.", times: "times", secs: "s", badCount: "A whole number from 1 to 100.", badSecs: "A whole number from 1 to 604800.", badMax: "A whole number, not less than the pause and at most 604800.", - badWait: "A whole number from 1 to 120.", - badSlowStartWait: "With “Move to the next upstream when the start times out” on, a whole number from 5 to 120.", + badIdle: "A whole number from 30 to 3600.", badSlotWait: "A whole number from 0 to 300.", saveFailed: "Not saved", }, diff --git a/src/settings/FailoverSection.tsx b/src/settings/FailoverSection.tsx index adc8b3c1..898c0475 100644 --- a/src/settings/FailoverSection.tsx +++ b/src/settings/FailoverSection.tsx @@ -1,6 +1,5 @@ import { useState, type ReactNode } from "react"; import { Banner } from "@/ui/banner"; -import { Switch } from "@/ui/switch"; import { useText } from "@/i18n"; import { errorText } from "@/i18n/core.i18n"; import { patchConfig } from "@/patch"; @@ -11,16 +10,15 @@ import { failoverText } from "./FailoverSection.i18n"; /** 停用时长最多写多少秒:一周(core 的 `MAX_PAUSE_SECS`) */ const MAX_PAUSE = 7 * 24 * 3600; -/** 流开头最多等多少秒(core 的 `MAX_STREAM_START_WAIT_SECS`) */ -const MAX_WAIT = 120; -/** 开着「开头超时转到下一个上游」时,流开头至少等多少秒(core 的 `MIN_SLOW_START_WAIT_SECS`) */ -const MIN_SLOW_START_WAIT = 5; +/** 无响应超时最少、最多写多少秒(core 的 `MIN_IDLE_TIMEOUT_SECS`、`MAX_IDLE_TIMEOUT_SECS`) */ +const MIN_IDLE = 30; +const MAX_IDLE = 3600; /** 等空位最多写多少秒(core 的 `MAX_SLOT_WAIT_SECS`) */ const MAX_SLOT_WAIT = 300; -/** 这一节里的数字格子。开关(`next_on_slow_start`)另记 */ -type Field = Exclude; -export type Draft = Record & { next_on_slow_start: boolean }; +/** 这一节里的格子,全是数字 */ +type Field = keyof FailoverView; +export type Draft = Record; /** 表单里的顺序 */ const FIELDS: Field[] = [ @@ -30,25 +28,21 @@ const FIELDS: Field[] = [ "no_balance_pause_secs", "quota_pause_secs", "rate_limit_max_pause_secs", - "stream_start_wait_secs", + "idle_timeout_secs", "slot_wait_secs", ]; /** 导出给测试用 */ -export const draftOf = (f: FailoverView): Draft => ({ - ...(Object.fromEntries(FIELDS.map((k) => [k, String(f[k])])) as Record), - next_on_slow_start: f.next_on_slow_start, -}); +export const draftOf = (f: FailoverView): Draft => + Object.fromEntries(FIELDS.map((k) => [k, String(f[k])])) as Draft; -const same = (a: Draft, b: Draft) => - FIELDS.every((k) => a[k] === b[k]) && a.next_on_slow_start === b.next_on_slow_start; +const same = (a: Draft, b: Draft) => FIELDS.every((k) => a[k] === b[k]); /** * 每一格填得对不对,范围和 core 的校验一样。 * * **没动过的格不查**(和日志保留一样):它就是配置里现在的值,保存时也不发。只有上限 - * 例外 —— 起点改大了,没动过的上限也可能跟着不对了。等回答开头的秒数也一样:打开 - * 「开头超时时转到下一个上游」之后它至少要 5 秒,没动过的也要重查。导出给测试用。 + * 例外 —— 起点改大了,没动过的上限也可能跟着不对了。导出给测试用。 */ export function checks(draft: Draft, saved: Draft): Record { const secs = (k: Field) => draft[k] === saved[k] || intIn(draft[k], 1, MAX_PAUSE); @@ -62,21 +56,17 @@ export function checks(draft: Draft, saved: Draft): Record { no_balance_pause_secs: secs("no_balance_pause_secs"), quota_pause_secs: secs("quota_pause_secs"), rate_limit_max_pause_secs: secs("rate_limit_max_pause_secs"), - stream_start_wait_secs: - (draft.stream_start_wait_secs === saved.stream_start_wait_secs && - draft.next_on_slow_start === saved.next_on_slow_start) || - intIn(draft.stream_start_wait_secs, draft.next_on_slow_start ? MIN_SLOW_START_WAIT : 1, MAX_WAIT), + idle_timeout_secs: + draft.idle_timeout_secs === saved.idle_timeout_secs || intIn(draft.idle_timeout_secs, MIN_IDLE, MAX_IDLE), slot_wait_secs: draft.slot_wait_secs === saved.slot_wait_secs || intIn(draft.slot_wait_secs, 0, MAX_SLOT_WAIT), }; } /** - * 上游失败之后停用多久、流式回答的开头最多等多久、上游满着时最多等多久。 + * 上游失败之后停用多久、多久没有内容就不再等它(无响应超时)、上游满着时最多等多久。 * - * 默认值显式写在格子里(概览给的就是真在用的数),不用「留空 = 默认」。 - * - * **「开头超时时转到下一个上游」和等开头的秒数是一件事**:开关挂在那一行底下,不另起 - * 一行 —— 它说的就是那个秒数到了之后怎么办。 + * 默认值显式写在格子里(概览给的就是真在用的数),不用「留空 = 默认」;无响应超时的说明 + * 里另写默认的 300 秒。 */ export function FailoverSection({ failover, @@ -104,8 +94,6 @@ export function FailoverSection({ path: `/failover/${k}`, value: Number(draft[k]), })); - if (draft.next_on_slow_start !== saved.next_on_slow_start) - ops.push({ op: "replace", path: "/failover/next_on_slow_start", value: draft.next_on_slow_start }); setBusy(true); setError(null); try { @@ -126,7 +114,7 @@ export function FailoverSection({ }; const bad = (msg: string) => {msg}; - const row = (k: Field, unit: string, what: string, badText: string, more?: ReactNode) => ( + const row = (k: Field, unit: string, what: ReactNode, badText: string) => ( } - > - {more} - + /> ); return ( @@ -163,32 +149,7 @@ export function FailoverSection({ {row("no_balance_pause_secs", t.secs, t.what.no_balance_pause_secs, t.badSecs)} {row("quota_pause_secs", t.secs, t.what.quota_pause_secs, t.badSecs)} {row("rate_limit_max_pause_secs", t.secs, t.what.rate_limit_max_pause_secs, t.badSecs)} - {row( - "stream_start_wait_secs", - t.secs, - t.what.stream_start_wait_secs, - draft.next_on_slow_start ? t.badSlowStartWait : t.badWait, - // 和上面那一行排成同一个样子:说明在左、开关在右,中间不画分隔线 -
-
- -
{t.nextOnSlowStartWhat}
-
-
- { - setError(null); - setDraft((d) => ({ ...d, next_on_slow_start: c === true })); - }} - /> -
-
, - )} + {row("idle_timeout_secs", t.secs, t.what.idle_timeout_secs, t.badIdle)} {row("slot_wait_secs", t.secs, t.what.slot_wait_secs, t.badSlotWait)} { expect(allOk(checks(saved, saved))).toBe(true); }); - it("停用时长和次数不能是 0,开头最多等 120 秒", () => { + it("停用时长和次数不能是 0", () => { const saved = draftOf(failover); expect(checks({ ...saved, pause_secs: "0" }, saved).pause_secs).toBe(false); expect(checks({ ...saved, failures_to_pause: "0" }, saved).failures_to_pause).toBe(false); - expect(checks({ ...saved, stream_start_wait_secs: "121" }, saved).stream_start_wait_secs).toBe(false); expect(checks({ ...saved, quota_pause_secs: "1.5" }, saved).quota_pause_secs).toBe(false); }); @@ -48,15 +46,12 @@ describe("故障转移", () => { expect(checks({ ...saved, slot_wait_secs: "" }, saved).slot_wait_secs).toBe(false); }); - it("开头超时转到下一个上游:开着时开头至少等 5 秒,没动过的秒数也重查", () => { - const saved = draftOf({ ...failover, stream_start_wait_secs: 3 }); - expect(saved.next_on_slow_start).toBe(false); - // 关着时 3 秒是合法的 - expect(checks(saved, saved).stream_start_wait_secs).toBe(true); - // 打开开关,秒数没动也不行 - const on = { ...saved, next_on_slow_start: true }; - expect(checks(on, saved).stream_start_wait_secs).toBe(false); - expect(checks({ ...on, stream_start_wait_secs: "5" }, saved).stream_start_wait_secs).toBe(true); - expect(checks({ ...on, stream_start_wait_secs: "30" }, saved).stream_start_wait_secs).toBe(true); + it("无响应超时:默认 300 秒,30 到 3600 之间的整数", () => { + const saved = draftOf(failover); + expect(saved.idle_timeout_secs).toBe("300"); + for (const ok of ["30", "600", "3600"]) + expect(checks({ ...saved, idle_timeout_secs: ok }, saved).idle_timeout_secs).toBe(true); + for (const bad of ["29", "3601", "0", "", "90.5"]) + expect(checks({ ...saved, idle_timeout_secs: bad }, saved).idle_timeout_secs).toBe(false); }); }); diff --git a/src/traffic/PanelHeader.tsx b/src/traffic/PanelHeader.tsx index ebb3ea8c..e461ff20 100644 --- a/src/traffic/PanelHeader.tsx +++ b/src/traffic/PanelHeader.tsx @@ -27,6 +27,7 @@ export function PanelHeader({ title, meta, onClose, + actions, tabs, children, className, @@ -35,6 +36,8 @@ export function PanelHeader({ /** 标题后面淡一档的一小段(时刻) */ meta?: ReactNode; onClose: () => void; + /** 标题这一行右边、关闭钮之前的几个按钮(`size="sm"`)。写成字,不用图标 */ + actions?: ReactNode; /** 贴着底边的一排标签 */ tabs?: ReactNode; /** 第二行:这一条的要点 */ @@ -57,6 +60,7 @@ export function PanelHeader({ )} {meta && {meta}} + {actions && {actions}} + ) + } tabs={ {t.tabSummary} diff --git a/src/traffic/SessionRow.tsx b/src/traffic/SessionRow.tsx index 146297f6..a70d2fb7 100644 --- a/src/traffic/SessionRow.tsx +++ b/src/traffic/SessionRow.tsx @@ -1,7 +1,7 @@ import { memo, type ReactNode } from "react"; import { ChevronRightIcon } from "lucide-react"; import { cn } from "@/lib/utils"; -import { useText } from "@/i18n"; +import { textOf, useText } from "@/i18n"; import { notSent } from "@/requestRouting"; import type { RequestRow, SessionView } from "@/types"; import { Button } from "@/ui/button"; @@ -17,6 +17,7 @@ import { SessionCost } from "./SessionCost"; import { sessionsText } from "./Sessions.i18n"; import { compact as short, span as dur, whenMinute as when } from "@/format"; import { RowActions } from "./TableMenus"; +import { abortText } from "./abort.i18n"; import { tallyOf, type Cursor, type Group, type Tally } from "./grouping"; import { capped, NAME_PX, textWidth, type Col, type Widths } from "./virtual"; @@ -105,13 +106,20 @@ export function sessionItems( id: string, open: boolean, t: Text, - on: { openIt: () => void; toggle: () => void }, + on: { openIt: () => void; toggle: () => void; abort?: () => void }, ): MenuItems { return [ { kind: "item", label: t.openSession, onSelect: on.openIt }, { kind: "item", label: open ? t.collapseSession : t.expandSession, onSelect: on.toggle }, { kind: "sep" }, { kind: "item", label: t.copySessionId, onSelect: () => void copyText(id) }, + // 组里有在跑的请求时才有。确认由页面弹 + ...(on.abort + ? ([ + { kind: "sep" }, + { kind: "item", label: textOf(abortText).abortSession, danger: true, onSelect: on.abort }, + ] as const) + : []), ]; } diff --git a/src/traffic/Traffic.i18n.tsx b/src/traffic/Traffic.i18n.tsx index 2b0bdd5d..864b5cb9 100644 --- a/src/traffic/Traffic.i18n.tsx +++ b/src/traffic/Traffic.i18n.tsx @@ -134,6 +134,8 @@ export const trafficText = messages( // 状态一列 failed: "失败", cancelled: "已取消", + /** 在界面上手动中止的 */ + aborted: "手动中止", // 上游一列的徽标和它们的悬浮说明 /** 客户端写的是别名,按这家上游换成了它的模型名。模型那一列照旧是客户端写的名称 */ @@ -276,6 +278,7 @@ export const trafficText = messages( failed: "Failed", cancelled: "Canceled", + aborted: "Aborted", aliasTip: (model: string, upstream: string, sent: string) => `${model} is an alias; the model sent to ${upstream} is ${sent}.`, diff --git a/src/traffic/TrafficPage.tsx b/src/traffic/TrafficPage.tsx index 0770d203..631c01af 100644 --- a/src/traffic/TrafficPage.tsx +++ b/src/traffic/TrafficPage.tsx @@ -30,6 +30,7 @@ import { groupAt, groupBySession, isAt, lines, step, visible, type Cursor, type import { mergeFound, useHistorySearch, type HistorySearch } from "./historySearch"; import { RequestTable, type RequestTableHandle } from "./RequestTable"; import { SessionSheet } from "./SessionPanel"; +import { AbortSessionDialog, abortable } from "./abort"; import { TrafficSummary } from "./TrafficSummary"; import { trafficText } from "./Traffic.i18n"; import type { TrafficView } from "./view"; @@ -153,6 +154,13 @@ export default function TrafficPage({ const [open, setOpen] = useState(null); /** 右侧开着的那次会话。请求可以叠在它上面,见 `SessionSheet` */ const [openSession, setOpenSession] = useState(null); + /** 要中止的那次会话(确认框开着)。组头的菜单和会话详情的头上都能叫它 */ + const [stopping, setStopping] = useState(null); + /** 那次会话此刻在跑、能中止的有几个:确认框里说 */ + const stoppingCount = useMemo( + () => (stopping === null ? 0 : allRows.filter((r) => r.session === stopping && abortable(r)).length), + [allRows, stopping], + ); /** 那次会话在表里的行(不看筛选):库里的详情还没有的那几轮从这里补 */ const openSessionRows = useMemo( () => (openSession === null ? [] : allRows.filter((r) => r.session === openSession)), @@ -554,6 +562,7 @@ export default function TrafficPage({ selectedSession={openSession} onToggleGroup={toggleGroup} onOpenSession={openSessionPanel} + onAbortSession={setStopping} /> )} @@ -571,7 +580,9 @@ export default function TrafficPage({ rows={openSessionRows} onClose={() => setOpenSession(null)} onOpenTurn={setOpen} + onAbort={setStopping} /> + setStopping(null)} /> ); } diff --git a/src/traffic/abort.i18n.ts b/src/traffic/abort.i18n.ts new file mode 100644 index 00000000..185b43c8 --- /dev/null +++ b/src/traffic/abort.i18n.ts @@ -0,0 +1,23 @@ +import { messages } from "@/i18n"; + +export const abortText = messages( + { + abortRequest: "中止请求", + abortSession: "中止会话", + title: "中止会话", + body: (n: number) => `此会话中正在进行的 ${n} 个请求将立即停止,客户端会收到错误。已完成的请求不受影响。`, + confirm: "中止", + failed: "未能中止", + }, + { + abortRequest: "Abort request", + abortSession: "Abort session", + title: "Abort session", + body: (n: number) => + n === 1 + ? "The request in progress in this session stops at once, and its client receives an error. Finished requests are not affected." + : `The ${n} requests in progress in this session stop at once, and their clients receive an error. Finished requests are not affected.`, + confirm: "Abort", + failed: "Not aborted", + }, +); diff --git a/src/traffic/abort.test.ts b/src/traffic/abort.test.ts new file mode 100644 index 00000000..01f717a2 --- /dev/null +++ b/src/traffic/abort.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; +import { abortable } from "./abort"; + +describe("能不能中止", () => { + it("只有在跑的、不在 WebSocket 连接上的", () => { + expect(abortable({ state: "in_flight" })).toBe(true); + expect(abortable({ state: "in_flight", ws: true })).toBe(false); + for (const state of ["done", "failed", "cancelled"] as const) expect(abortable({ state })).toBe(false); + }); +}); diff --git a/src/traffic/abort.tsx b/src/traffic/abort.tsx new file mode 100644 index 00000000..26fa0492 --- /dev/null +++ b/src/traffic/abort.tsx @@ -0,0 +1,98 @@ +import { useState } from "react"; +import { call } from "@/control"; +import { textOf, useText } from "@/i18n"; +import { commonText } from "@/i18n/common.i18n"; +import { errorText } from "@/i18n/core.i18n"; +import type { RequestRow } from "@/types"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogConfirm, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/ui/alert-dialog"; +import { Banner } from "@/ui/banner"; +import { focusSelf } from "@/ui/dialog-focus"; +import { notify } from "@/ui/notify"; +import { abortText } from "./abort.i18n"; + +/** + * 这一行能不能手动中止:还在跑,而且不是跑在 WebSocket 连接上的(那种跟着连接走,core 不 + * 单独中止它) + */ +export const abortable = (r: Pick): boolean => r.state === "in_flight" && !r.ws; + +/** + * 中止一个在跑的请求。**不弹确认**:菜单里点的就是这一条,叫停之后它照常从事件流上收场 + * (那一行变成「手动中止」),用不着再报一次成功。叫停不成(它刚好跑完了)说一声 + */ +export async function abortRequest(id: number): Promise { + try { + await call("AbortRequest", null, id); + } catch (e) { + notify.error(e, textOf(abortText).failed); + } +} + +/** + * 中止一次会话里所有在跑的请求之前的确认。`session` 为 `null` 时关着;`running` 是此刻在跑、 + * 能中止的有几个。按下之后对话框留着、按钮转圈,叫停了才关;叫停不成,原因写在对话框里 + */ +export function AbortSessionDialog({ + session, + running, + onClose, +}: { + session: string | null; + running: number; + onClose: () => void; +}) { + const t = useText(abortText); + const common = useText(commonText); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + + const close = () => { + setError(null); + onClose(); + }; + async function run(id: string) { + setPending(true); + setError(null); + try { + await call("AbortSession", null, id); + close(); + } catch (e) { + setError(errorText(e)); + } finally { + setPending(false); + } + } + + return ( + !o && !pending && close()}> + + + {t.title} + {t.body(Math.max(running, 1))} + + + {error} + + + {common.cancel} + session !== null && void run(session)} + > + {t.confirm} + + + + + ); +} diff --git a/src/traffic/drawer/RequestDrawer.i18n.tsx b/src/traffic/drawer/RequestDrawer.i18n.tsx index 4f31e508..71b9f3d5 100644 --- a/src/traffic/drawer/RequestDrawer.i18n.tsx +++ b/src/traffic/drawer/RequestDrawer.i18n.tsx @@ -62,6 +62,10 @@ export const requestDrawerText = messages( /** 头上那一项:失败了。原因写在「时间线」的状态那一行 */ failed: "失败", cancelledShort: "已取消", + /** 在界面上手动中止的 */ + aborted: "手动中止", + /** 上游等到无响应超时还没有内容(一个字都没给,或者答到一半停住) */ + idleTimeout: "无响应超时", cancelled: "已取消:客户端在响应结束前断开连接", bytes: "字节", /** 顶上那一排数字 */ @@ -100,7 +104,7 @@ export const requestDrawerText = messages( attempts: "尝试链", /** 这一跳等空位等了多久(上游满着)。秒数已经按一位小数写好 */ queued: (s: string) => `排队 ${s} 秒`, - /** 放弃了的一跳(开头超时)下面那一行:上游没报用量时,网关估的输入 */ + /** 放弃了的一跳(无响应超时)下面那一行:上游没报用量时,网关估的输入 */ abandonedEstimate: (n: string) => `输入约 ${n} token`, mayBeBilled: "上游可能已计费", mayBeBilledTip: "上游是否收取这部分费用无法得知,此请求的费用不含这部分。", @@ -118,7 +122,7 @@ export const requestDrawerText = messages( deniedAfterPick: (rule: string) => `选定上游后,规则「${rule}」拒绝了此请求,未发往任何上游。`, deniedBeforePick: (rule: string) => `选定上游之前,规则「${rule}」已拒绝此请求,未发往任何上游。`, unavailable: "规则选中的上游均无法服务此请求,未发往任何上游。", - /** 前面几跳里有满着跳过的、开头超时放弃的:它们不是上游的失败 */ + /** 前面几跳里有满着跳过的:它们不是上游的失败 */ switched: (n: number) => `已自动切换上游:前 ${n} 次尝试未接下此请求。`, limited: "网关密钥已达到用量上限,此请求未发往任何上游。", busy: "上游均已达到并发上限,等待期间没有空出位置,此请求未发往任何上游。", @@ -231,6 +235,8 @@ export const requestDrawerText = messages( inProgress: "In progress", failed: "Failed", cancelledShort: "Canceled", + aborted: "Aborted", + idleTimeout: "No response", cancelled: "Canceled: the client disconnected before the response finished", bytes: "Bytes", tokens: "Tokens", diff --git a/src/traffic/drawer/RequestDrawer.tsx b/src/traffic/drawer/RequestDrawer.tsx index 9869a7d4..a1b477a2 100644 --- a/src/traffic/drawer/RequestDrawer.tsx +++ b/src/traffic/drawer/RequestDrawer.tsx @@ -13,7 +13,7 @@ import { StatusLabel, type StatusTone } from "@/ui/status-dot"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/ui/tabs"; import { Tip } from "@/ui/tip"; import { KeyLabel, keyText } from "@/KeyLabel"; -import { notSentText, probeLabel } from "@/labels"; +import { failureKind, notSentText, probeLabel } from "@/labels"; import { notSent } from "@/requestRouting"; import type { CoreEvent, HistoryRow, RequestDetail } from "@/types"; import { NotSentIcon } from "../cells"; @@ -238,7 +238,9 @@ function Detail({ id, onClose }: { id: number; onClose: () => void }) { */ function HeadStatus({ r, state }: { r: HistoryRow; state: DrawerState }) { const t = useText(requestDrawerText); - const tone = TONE[statusTone(r.status ?? undefined, state)]; + // 手动中止的不是故障:和取消一样是灰的 + const tone = + state === "failed" && failureKind(r.error) === "aborted" ? "idle" : TONE[statusTone(r.status ?? undefined, state)]; return ( {statusText(r, state, t)} @@ -253,7 +255,12 @@ function statusText( t: (typeof requestDrawerText)["zh"], ): string { if (state === "in_flight") return r.status != null ? `${r.status} · ${t.inProgress}` : t.inProgress; - if (state === "failed") return r.status != null ? `${r.status} · ${t.failed}` : t.failed; + if (state === "failed") { + // 手动中止、上游没有内容超时的另有说法。超时的等了多久在尝试链和失败的那一句里 + const kind = failureKind(r.error); + const word = kind === "aborted" ? t.aborted : kind === "idle" ? t.idleTimeout : t.failed; + return r.status != null && kind !== "aborted" ? `${r.status} · ${word}` : word; + } if (state === "cancelled") return t.cancelledShort; return r.status != null ? String(r.status) : "—"; } diff --git a/src/traffic/drawer/Routing.tsx b/src/traffic/drawer/Routing.tsx index 7ac700d0..8b4a8bbd 100644 --- a/src/traffic/drawer/Routing.tsx +++ b/src/traffic/drawer/Routing.tsx @@ -106,9 +106,14 @@ export function Routing({ r, plugins, running }: { r: HistoryRow; plugins: Plugi ) : ( /* **失败的原因要留着** —— 一条说「试过 A → B → C」的链和一条还说清 - 每一跳为什么失败的链,排查价值差得远。短名(开头超时、并发已满)悬停 + 每一跳为什么失败的链,排查价值差得远。短名(无响应超时、并发已满)悬停 是 core 的原话 */ - + // 手动中止的那一跳不是故障:灰的,和取消一样 + {outcome.tip ? ( {outcome.text} @@ -127,7 +132,7 @@ export function Routing({ r, plugins, running }: { r: HistoryRow; plugins: Plugi {/* 没有发出的那一跳没有耗时可言 */} {unsent ? "—" : ms(a.ms)} - {/* 放弃了的这一跳(开头超时)上游可能已经按输入收了钱:不在这个请求的费用里 */} + {/* 放弃了的这一跳(无响应超时)上游可能已经按输入收了钱:不在这个请求的费用里 */} {a.usage && } ); @@ -145,7 +150,7 @@ export function Routing({ r, plugins, running }: { r: HistoryRow; plugins: Plugi } /** - * 放弃了的一跳(开头超时)上游可能已经收了钱的输入,写在那一跳下面一行。上游在流开头 + * 放弃了的一跳(无响应超时)上游可能已经收了钱的输入,写在那一跳下面一行。上游在流开头 * 报了的写它报的几种 token;没报的是网关估的输入,写「约」。**输出不知道**,不写。 * * 这部分不进这个请求的费用:上游收没收、收了多少,网关看不到。悬停说这一点 diff --git a/src/traffic/drawer/Timeline.tsx b/src/traffic/drawer/Timeline.tsx index 1eb73675..a126c766 100644 --- a/src/traffic/drawer/Timeline.tsx +++ b/src/traffic/drawer/Timeline.tsx @@ -6,7 +6,7 @@ import { ms, size, tokens as tokenPair } from "@/format"; import { AnimatedNumber } from "@/ui/motion"; import { Tip } from "@/ui/tip"; import { KeyLabel } from "@/KeyLabel"; -import { appLabel, formatLabel, notSentText } from "@/labels"; +import { appLabel, failureKind, formatLabel, notSentText } from "@/labels"; import { notSent } from "@/requestRouting"; import { ActionBadge, byCodepoints, EventDetail, ruleName, whereOf } from "@/security/labels"; import { pluginName } from "@/plugins/defaults"; @@ -184,7 +184,10 @@ export function Timeline({ d, state }: { d: RequestDetail; state: DrawerState }) label={t.status} value={ r.error ? ( - {coreText(r.error)} + // 手动中止的不标红:和客户端断开一样,不是上游出了错 + + {coreText(r.error)} + ) : r.cancelled ? ( // 不是失败,不标红:上游没有出错,是客户端先断开了 diff --git a/src/types.ts b/src/types.ts index 045b12b5..8b3e3b32 100644 --- a/src/types.ts +++ b/src/types.ts @@ -90,6 +90,11 @@ export interface RequestRow { /** 改写了参数的规则,按求值的顺序。没有就没有这一项 */ rewrittenBy?: string[]; path: string; + /** + * 跑在 WebSocket 连接上(Responses 的一轮、整条连接一行的那种)。**这种请求不能单独中止**: + * 它跟着那条连接走(core 不登记它,见 `POST /request/{id}/abort`)。只有开始事件说得出来 + */ + ws?: boolean; atMs: number; /** * 进行中的行也要立刻画出来 —— 流式请求可能要跑几分钟。 @@ -180,6 +185,7 @@ export function applyEvent(rows: Map, ev: CoreEvent): void { // WebSocket 这类认不出模型的请求发的是空串,当作「不知道」 model: ev.model || undefined, path: ev.path, + ws: ev.method === "WS" || undefined, atMs: ev.at_ms, state: "in_flight", session: ev.session ?? undefined, From 4a11deb9d88980e42e49ba9f14260aba08bb66da Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:18:04 +0800 Subject: [PATCH 2/3] Pin core to ThinkWatch-Core#303's new head: streams answer early with keep-alives Core now holds a streamed answer's headers for at most 15 s, then sends 200 and SSE keep-alive comments while failover carries on in the body (ThinkWatch-Core#303). No change to the protocol or to any text here. Co-Authored-By: Claude Opus 5.5 --- src-tauri/Cargo.lock | 14 +++++++------- src-tauri/Cargo.toml | 12 ++++++------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7ff2ea01..f71a142c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5269,7 +5269,7 @@ dependencies = [ [[package]] name = "tw-api" version = "0.66.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=2bd8fb4caa1ae5bcfae9c31f6585cbdbb3e3a90f#2bd8fb4caa1ae5bcfae9c31f6585cbdbb3e3a90f" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=696a12fbf9afd20b5e25128edf7a1f0fd986bbe3#696a12fbf9afd20b5e25128edf7a1f0fd986bbe3" dependencies = [ "serde", "serde_json", @@ -5281,7 +5281,7 @@ dependencies = [ [[package]] name = "tw-dialect" version = "0.66.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=2bd8fb4caa1ae5bcfae9c31f6585cbdbb3e3a90f#2bd8fb4caa1ae5bcfae9c31f6585cbdbb3e3a90f" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=696a12fbf9afd20b5e25128edf7a1f0fd986bbe3#696a12fbf9afd20b5e25128edf7a1f0fd986bbe3" dependencies = [ "memchr", "serde", @@ -5291,7 +5291,7 @@ dependencies = [ [[package]] name = "tw-guard" version = "0.66.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=2bd8fb4caa1ae5bcfae9c31f6585cbdbb3e3a90f#2bd8fb4caa1ae5bcfae9c31f6585cbdbb3e3a90f" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=696a12fbf9afd20b5e25128edf7a1f0fd986bbe3#696a12fbf9afd20b5e25128edf7a1f0fd986bbe3" dependencies = [ "base64 0.22.1", "bytes", @@ -5308,7 +5308,7 @@ dependencies = [ [[package]] name = "tw-link" version = "0.66.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=2bd8fb4caa1ae5bcfae9c31f6585cbdbb3e3a90f#2bd8fb4caa1ae5bcfae9c31f6585cbdbb3e3a90f" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=696a12fbf9afd20b5e25128edf7a1f0fd986bbe3#696a12fbf9afd20b5e25128edf7a1f0fd986bbe3" dependencies = [ "serde", "serde_json", @@ -5335,7 +5335,7 @@ dependencies = [ [[package]] name = "tw-types" version = "0.66.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=2bd8fb4caa1ae5bcfae9c31f6585cbdbb3e3a90f#2bd8fb4caa1ae5bcfae9c31f6585cbdbb3e3a90f" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=696a12fbf9afd20b5e25128edf7a1f0fd986bbe3#696a12fbf9afd20b5e25128edf7a1f0fd986bbe3" dependencies = [ "serde", "ts-rs", @@ -5344,7 +5344,7 @@ dependencies = [ [[package]] name = "tw-watch" version = "0.66.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=2bd8fb4caa1ae5bcfae9c31f6585cbdbb3e3a90f#2bd8fb4caa1ae5bcfae9c31f6585cbdbb3e3a90f" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=696a12fbf9afd20b5e25128edf7a1f0fd986bbe3#696a12fbf9afd20b5e25128edf7a1f0fd986bbe3" dependencies = [ "libc", "notify", @@ -5355,7 +5355,7 @@ dependencies = [ [[package]] name = "tw-yaml" version = "0.66.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=2bd8fb4caa1ae5bcfae9c31f6585cbdbb3e3a90f#2bd8fb4caa1ae5bcfae9c31f6585cbdbb3e3a90f" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=696a12fbf9afd20b5e25128edf7a1f0fd986bbe3#696a12fbf9afd20b5e25128edf7a1f0fd986bbe3" dependencies = [ "saphyr-parser", "thiserror 2.0.21", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index cd94574d..9ce3cffa 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", rev = "2bd8fb4caa1ae5bcfae9c31f6585cbdbb3e3a90f" } -tw-types = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "2bd8fb4caa1ae5bcfae9c31f6585cbdbb3e3a90f" } -tw-yaml = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "2bd8fb4caa1ae5bcfae9c31f6585cbdbb3e3a90f" } -tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "2bd8fb4caa1ae5bcfae9c31f6585cbdbb3e3a90f" } -tw-watch = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "2bd8fb4caa1ae5bcfae9c31f6585cbdbb3e3a90f" } -tw-link = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "2bd8fb4caa1ae5bcfae9c31f6585cbdbb3e3a90f" } +tw-api = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "696a12fbf9afd20b5e25128edf7a1f0fd986bbe3" } +tw-types = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "696a12fbf9afd20b5e25128edf7a1f0fd986bbe3" } +tw-yaml = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "696a12fbf9afd20b5e25128edf7a1f0fd986bbe3" } +tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "696a12fbf9afd20b5e25128edf7a1f0fd986bbe3" } +tw-watch = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "696a12fbf9afd20b5e25128edf7a1f0fd986bbe3" } +tw-link = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "696a12fbf9afd20b5e25128edf7a1f0fd986bbe3" } [lib] name = "thinkwatch_lite_lib" From bcdbed6cd69bd076e1e6dfca8269df360f42336c Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Fri, 9 Oct 2026 21:32:22 +0800 Subject: [PATCH 3/3] 2026.10.11: pin core v0.67.0 Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- release-notes/2026.10.11.md | 22 ++++++++++++++++++++++ src-tauri/Cargo.lock | 30 +++++++++++++++--------------- src-tauri/Cargo.toml | 14 +++++++------- src-tauri/tauri.conf.json | 2 +- 5 files changed, 46 insertions(+), 24 deletions(-) create mode 100644 release-notes/2026.10.11.md diff --git a/package.json b/package.json index b55600e8..3dd08fd7 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "thinkwatch-lite", "private": true, "description": "Desktop app for a local AI API gateway on macOS, Windows and Linux", - "version": "2026.10.10", + "version": "2026.10.11", "type": "module", "packageManager": "pnpm@11.13.0", "scripts": { diff --git a/release-notes/2026.10.11.md b/release-notes/2026.10.11.md new file mode 100644 index 00000000..ea4f2650 --- /dev/null +++ b/release-notes/2026.10.11.md @@ -0,0 +1,22 @@ +**Upgrade notes:** +- The bundled core is now 0.67.0, with control-plane protocol 43. A remote server must be upgraded to core 0.67.0 together with the app: this version does not connect to a server on 0.66.0, and 2026.10.10 and earlier do not connect to 0.67.0. +- **The request history and stored payloads are cleared on the first start.** +- The failover settings for a slow-starting stream are replaced by the no-response timeout. A configuration that still has `failover.next_on_slow_start` or `failover.stream_start_wait_secs` makes the gateway start in safe mode; the repair it offers deletes them. +- Requests converted for Claude now use prompt caching: cache writes are billed at the cache-write rate and reads at the much lower cache-read rate, and both are priced and shown with the request. + +**No response and abort:** +- **Settings › Failover** has a **No-response timeout**, 300 seconds by default. When an upstream sends no content for that long, the request moves to the next upstream; when none is left, the client gets a timeout error instead of waiting indefinitely. After an answer has started, it ends with an error the client understands. Keep-alive pings do not count as content. It replaces the switch for moving on when a stream is slow to start. +- A running request can be stopped with **Abort request** in its menu on the Traffic page, and every running request of a session with **Abort session**. The request's details show **Aborted** or **No response** with the time waited. + +**Codex:** +- Through a route that converts to Claude, GLM, DeepSeek or another format, Codex keeps all its tools. For some models Codex declares its tools in a way that was dropped, and it then reported that it could not read files or run commands. Its history, compaction of long sessions and instructions in the middle of a conversation also arrive now. +- Requests converted for Claude are cached, so the turns of a long session cost much less. + +**Upstreams:** +- Models can also be added by hand in an upstream's dialog, in **Models**, for any upstream and while creating one. They are marked **Manual** and saved with the upstream. + +**Traffic:** +- Long upstream, model and key names no longer widen the table: they are cut with an ellipsis and shown in full on hover, also in the row menus. + +**ChatGPT account:** +- Signing in on another device works again. OpenAI changed the format of a value in its sign-in answer, which stopped the sign-in with an error. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f71a142c..c2f83ddd 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4766,7 +4766,7 @@ dependencies = [ [[package]] name = "thinkwatch-lite" -version = "2026.10.10" +version = "2026.10.11" dependencies = [ "anyhow", "block2", @@ -5268,8 +5268,8 @@ dependencies = [ [[package]] name = "tw-api" -version = "0.66.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=696a12fbf9afd20b5e25128edf7a1f0fd986bbe3#696a12fbf9afd20b5e25128edf7a1f0fd986bbe3" +version = "0.67.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.67.0#e7c02338b003eb7f47c019b697d628655045df9f" dependencies = [ "serde", "serde_json", @@ -5280,8 +5280,8 @@ dependencies = [ [[package]] name = "tw-dialect" -version = "0.66.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=696a12fbf9afd20b5e25128edf7a1f0fd986bbe3#696a12fbf9afd20b5e25128edf7a1f0fd986bbe3" +version = "0.67.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.67.0#e7c02338b003eb7f47c019b697d628655045df9f" dependencies = [ "memchr", "serde", @@ -5290,8 +5290,8 @@ dependencies = [ [[package]] name = "tw-guard" -version = "0.66.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=696a12fbf9afd20b5e25128edf7a1f0fd986bbe3#696a12fbf9afd20b5e25128edf7a1f0fd986bbe3" +version = "0.67.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.67.0#e7c02338b003eb7f47c019b697d628655045df9f" dependencies = [ "base64 0.22.1", "bytes", @@ -5307,8 +5307,8 @@ dependencies = [ [[package]] name = "tw-link" -version = "0.66.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=696a12fbf9afd20b5e25128edf7a1f0fd986bbe3#696a12fbf9afd20b5e25128edf7a1f0fd986bbe3" +version = "0.67.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.67.0#e7c02338b003eb7f47c019b697d628655045df9f" dependencies = [ "serde", "serde_json", @@ -5334,8 +5334,8 @@ dependencies = [ [[package]] name = "tw-types" -version = "0.66.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=696a12fbf9afd20b5e25128edf7a1f0fd986bbe3#696a12fbf9afd20b5e25128edf7a1f0fd986bbe3" +version = "0.67.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.67.0#e7c02338b003eb7f47c019b697d628655045df9f" dependencies = [ "serde", "ts-rs", @@ -5343,8 +5343,8 @@ dependencies = [ [[package]] name = "tw-watch" -version = "0.66.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=696a12fbf9afd20b5e25128edf7a1f0fd986bbe3#696a12fbf9afd20b5e25128edf7a1f0fd986bbe3" +version = "0.67.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.67.0#e7c02338b003eb7f47c019b697d628655045df9f" dependencies = [ "libc", "notify", @@ -5354,8 +5354,8 @@ dependencies = [ [[package]] name = "tw-yaml" -version = "0.66.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=696a12fbf9afd20b5e25128edf7a1f0fd986bbe3#696a12fbf9afd20b5e25128edf7a1f0fd986bbe3" +version = "0.67.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.67.0#e7c02338b003eb7f47c019b697d628655045df9f" dependencies = [ "saphyr-parser", "thiserror 2.0.21", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9ce3cffa..2fa93e88 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "thinkwatch-lite" -version = "2026.10.10" +version = "2026.10.11" edition = "2024" # **这个数决定依赖能升到哪一版。**edition 2024 的解析器只挑声明的 Rust # 版本编得动的依赖:写 1.85 的时候,`cargo update` 一直停在旧的 time 和 @@ -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", rev = "696a12fbf9afd20b5e25128edf7a1f0fd986bbe3" } -tw-types = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "696a12fbf9afd20b5e25128edf7a1f0fd986bbe3" } -tw-yaml = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "696a12fbf9afd20b5e25128edf7a1f0fd986bbe3" } -tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "696a12fbf9afd20b5e25128edf7a1f0fd986bbe3" } -tw-watch = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "696a12fbf9afd20b5e25128edf7a1f0fd986bbe3" } -tw-link = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "696a12fbf9afd20b5e25128edf7a1f0fd986bbe3" } +tw-api = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.67.0" } +tw-types = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.67.0" } +tw-yaml = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.67.0" } +tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.67.0" } +tw-watch = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.67.0" } +tw-link = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.67.0" } [lib] name = "thinkwatch_lite_lib" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 0e65da91..3214a0c3 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ThinkWatch Lite", - "version": "2026.10.10", + "version": "2026.10.11", "identifier": "app.thinkwatch.lite", "build": { "beforeDevCommand": "pnpm dev",