From 1f5fbac5ad89534ba7fbebad47b72013c0bb6b1a Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:47:49 +0800 Subject: [PATCH 1/2] Upstream models: set specs, prices and aliases from the model table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The edit dialog's Models section showed an unknown context window ("—") and "Unpriced" for a model but offered no way to change either; specs and aliases were only reachable from hover buttons in the list's model popover, and prices from the Billing section. These are now entry points where the problem is shown. They open the existing dialogs, so price sheets and aliases work exactly as before. - Models section (editing an upstream that is already in the config): the context window cell opens Model specs ("Unknown · Set" when unknown); the price cell opens the price sheet dialog ("Unpriced · Set price"); a row menu (the ⋯ button and right-click, same items) has Specs…, Set price…, Add alias…, and Remove for models added by hand; aliases listing a model are marked after its name. Unchecked models can be set up too. - Below the table: the unpriced note gets a Set price button; a grey line says how many enabled models have an unknown context window and that clients taken over fall back to their own default. - Set price goes the same way as Billing's: a custom sheet is edited with the model added as an override; on the default sheet a new sheet is created, now prefilled with the upstream's name and with the upstream preselected as its user, so it switches at once and the next Set price edits that sheet instead of creating another. - Model specs and new-alias dialogs report the written config version, so the edit dialog keeps saving against the latest one. - List: the Models cell shows a dropdown arrow; the popover (now 30rem) has an always-visible row menu instead of hover-only buttons, marks unpriced models and unknown context windows, and its footer reads "Manage models…". Co-Authored-By: Claude Opus 5.5 --- src/aliases/AliasDialog.tsx | 14 +- src/upstreams/ModelSpecDialog.tsx | 13 +- src/upstreams/ModelsPanel.i18n.ts | 14 +- src/upstreams/ModelsPanel.tsx | 86 ++++++------ src/upstreams/ModelsSection.i18n.ts | 39 +++++- src/upstreams/ModelsSection.test.ts | 28 +++- src/upstreams/ModelsSection.tsx | 195 ++++++++++++++++++++++++---- src/upstreams/PriceSheetDialog.tsx | 11 +- src/upstreams/UpstreamDialog.tsx | 86 +++++++++++- src/upstreams/UpstreamTable.tsx | 31 ++++- src/upstreams/UpstreamsPage.tsx | 13 +- src/upstreams/upstreamForm.test.ts | 19 +++ src/upstreams/upstreamForm.ts | 14 ++ 13 files changed, 461 insertions(+), 102 deletions(-) diff --git a/src/aliases/AliasDialog.tsx b/src/aliases/AliasDialog.tsx index 2b31c7e7..8814fcf2 100644 --- a/src/aliases/AliasDialog.tsx +++ b/src/aliases/AliasDialog.tsx @@ -49,7 +49,8 @@ const MAX_SUGGESTIONS = 50; * * 红字是 core 说的问题,不能保存;黄字是要留意的,可以保存。 * - * 挂载方:别名标签(新建、编辑),上游模型弹窗(「起别名…」,`initial.models` 是那个模型)。 + * 挂载方:别名标签(新建、编辑),上游模型弹窗和编辑上游对话框的「模型」一节(「起别名…」, + * `initial.models` 是那个模型)。 */ export function AliasDialog(props: { open: boolean; @@ -58,7 +59,8 @@ export function AliasDialog(props: { editing?: string; /** 新建时预填。编辑时不用给:模型按原名从 core 读 */ initial?: Partial; - onSaved?(): void; + /** 写完了。`version`:写完之后的配置版本(编辑上游对话框里打开的,那边接着用它) */ + onSaved?(version: string): void; }) { const { open, onOpenChange, editing } = props; return ( @@ -89,7 +91,7 @@ function Body({ }: { editing?: string; initial?: Partial; - onSaved?(): void; + onSaved?(version: string): void; onClose: () => void; }) { const t = useText(aliasDialogText); @@ -246,18 +248,20 @@ function Body({ setError(null); const body: AliasSave = { alias: { name: trimmed, models }, base_version: base }; try { + let version: string; if (editing != null) { const w = await api.updateAlias(editing, body); + version = w.version; const refs = refsText(w.renamed_in, t); if (trimmed !== editing && refs) notify.success(t.renamed(editing, trimmed), t.renamedIn(refs)); } else { - await api.createAlias(body); + version = (await api.createAlias(body)).version; } // 列着别名的几处(别名标签、模型建议、上游模型弹窗的别名标记) invalidate("aliases"); invalidate("known-models"); invalidate("upstream-models:"); - onSaved?.(); + onSaved?.(version); onClose(); } catch (e) { setError(errorText(e)); diff --git a/src/upstreams/ModelSpecDialog.tsx b/src/upstreams/ModelSpecDialog.tsx index 0c8714b9..8b452d9a 100644 --- a/src/upstreams/ModelSpecDialog.tsx +++ b/src/upstreams/ModelSpecDialog.tsx @@ -31,8 +31,8 @@ import { DialogError, FormItem } from "./parts"; * 留空是什么意思,看占位就知道。推理、图片输入是三段:价目表 / 支持 / 不支持,价目表怎么说 * 写在下面一行。 * - * 挂载方:上游表「模型」一格的弹窗(「规格…」)。对话框挂在弹窗外面:弹窗一收起,里面的 - * 东西就卸掉了。 + * 挂载方:上游表「模型」一格的弹窗(「规格…」)、编辑上游对话框的「模型」一节(上下文窗口 + * 那一格、行菜单的「规格…」)。对话框挂在弹窗外面:弹窗一收起,里面的东西就卸掉了。 */ export function ModelSpecDialog({ open, @@ -49,7 +49,8 @@ export function ModelSpecDialog({ row: ModelRow | null; /** 概览里的配置版本。**只取打开那一刻的**,见 `Body` 的 `base` */ configVersion: string; - onSaved?: () => void; + /** 写完了。`version`:写完之后的配置版本(编辑上游对话框里打开的,那边接着用它) */ + onSaved?: (version: string) => void; }) { return ( @@ -81,7 +82,7 @@ function Body({ row: ModelRow; configVersion: string; onClose: () => void; - onSaved?: () => void; + onSaved?: (version: string) => void; }) { const t = useText(modelSpecDialogText); const c = useText(commonText); @@ -110,7 +111,7 @@ function Body({ setSaving(true); setError(null); try { - await api.setModelSpec({ + const w = await api.setModelSpec({ provider, model: row.id, ...spec, @@ -120,7 +121,7 @@ function Body({ invalidate(modelsKey(provider)); invalidate("aliases"); invalidate("known-models"); - onSaved?.(); + onSaved?.(w.version); onClose(); } catch (e) { setError(errorText(e)); diff --git a/src/upstreams/ModelsPanel.i18n.ts b/src/upstreams/ModelsPanel.i18n.ts index 7b9a8354..494160f6 100644 --- a/src/upstreams/ModelsPanel.i18n.ts +++ b/src/upstreams/ModelsPanel.i18n.ts @@ -15,7 +15,7 @@ export const modelsPanelText = messages( filter: "筛选模型", notEnabled: (n: number) => `不在启用范围内 ${n} 个`, noMatch: "无匹配的模型", - editScope: "调整启用范围…", + editScope: "管理模型…", countAll: (total: number) => `${total} 个`, countSome: (total: number, enabled: number) => `${total} 个,已启用 ${enabled} 个`, listed: (count: string) => `上游列出 ${count}`, @@ -32,9 +32,12 @@ export const modelsPanelText = messages( aliasTitle: (alias: string) => `别名 ${alias} 列着这个模型:客户端用 ${alias} 请求时可以发往这个模型`, makeAlias: "起别名…", specs: "规格…", + setPrice: "设置价格…", + unpriced: "未定价", + unknown: "未知", + actionsOf: (id: string) => `${id} 的操作`, manualSpecs: "手动规格", remove: "移除", - removeLabel: (id: string) => `移除手动添加的模型 ${id}`, removed: (id: string) => `已移除手动添加的模型 ${id}`, manualContext: (n: string) => `上下文窗口 ${n}`, manualOutput: (n: string) => `输出上限 ${n}`, @@ -58,7 +61,7 @@ export const modelsPanelText = messages( filter: "Filter models", notEnabled: (n: number) => `${n} not enabled`, noMatch: "No matching models", - editScope: "Adjust enabled models…", + editScope: "Manage models…", countAll: (total: number) => (total === 1 ? "1 model" : `${total} models`), countSome: (total: number, enabled: number) => `${total === 1 ? "1 model" : `${total} models`}, ${enabled} enabled`, @@ -76,9 +79,12 @@ export const modelsPanelText = messages( aliasTitle: (alias: string) => `Alias ${alias} lists this model: requests for ${alias} can go to it`, makeAlias: "Add alias…", specs: "Specs…", + setPrice: "Set price…", + unpriced: "Unpriced", + unknown: "Unknown", + actionsOf: (id: string) => `Actions for ${id}`, manualSpecs: "Manual specs", remove: "Remove", - removeLabel: (id: string) => `Remove ${id}, added by hand`, removed: (id: string) => `Removed ${id}, added by hand`, manualContext: (n: string) => `context window ${n}`, manualOutput: (n: string) => `max output ${n}`, diff --git a/src/upstreams/ModelsPanel.tsx b/src/upstreams/ModelsPanel.tsx index 4a2f04b6..69637fb3 100644 --- a/src/upstreams/ModelsPanel.tsx +++ b/src/upstreams/ModelsPanel.tsx @@ -7,6 +7,7 @@ import { invalidate, useResource } from "@/lib/resource"; import { Badge } from "@/ui/badge"; import { Button } from "@/ui/button"; import { InputGroup, InputGroupAddon, InputGroupInput } from "@/ui/input-group"; +import { RowMenu, RowMenuButton, type MenuItems } from "@/ui/row-menu"; import { Skeleton } from "@/ui/skeleton"; import { Spinner } from "@/ui/spinner"; import { StatusLabel } from "@/ui/status-dot"; @@ -28,20 +29,21 @@ const FILTER_FROM = 10; * 点开「模型」一格看到的东西:这一家有哪些模型,是怎么知道的,没拿到的话 * 原因和下一步。 * - * **启用范围在编辑对话框里改**,和上游其余设置走同一条路,这里给的是去那儿的入口。 - * 手动添加的模型(上游能服务、却没列进清单的)在这里加、在这里移除:「添加模型…」 - * 打开添加的对话框(`onAdd`),手动添加的那几行标「手动」,悬停给「移除」—— 可撤销, + * **启用范围在编辑对话框里改**,和上游其余设置走同一条路,这里给的是去那儿的入口(「管理 + * 模型…」)。手动添加的模型(上游能服务、却没列进清单的)在这里加、在这里移除:「添加模型…」 + * 打开添加的对话框(`onAdd`),手动添加的那几行标「手动」,行菜单里有「移除」—— 可撤销, * 不弹确认。上游也列了的手动模型照样标着、照样能移除:手动清单里有什么,这里一样不藏。 * * 清单按上游缓存(`upstream-models:<名字>`):再点开一次先画上一次的,后台再读。 * 概览里这一家的获取状态一变(开始问了、问完了)就重读 —— 开着面板等它问完, * 也能看到结果。读的是 core 记下的答案,不联网。 * - * 列进了别名的模型,名字后面标出别名;悬停一行给「起别名…」(`onAlias`),打开新建别名的 - * 对话框,这个模型已经列为上游模型。 + * 列进了别名的模型,名字后面标出别名。右边是单价(按量计费时,没有就标「未定价」)和上下文 + * 窗口(不知道写「未知」);在这一家手写过规格的,名字后面标「手动规格」,悬停说是哪几项。 * - * 右边的数是上下文窗口:在这一家手写过规格(上下文窗口或输出上限)的,名字后面标「手动 - * 规格」,悬停说是哪几项。悬停一行还给「规格…」(`onSpec`),打开手写规格的对话框。 + * 每行行尾「…」常显,和右键是同一份菜单:「规格…」(`onSpec`,手写规格的对话框)、「设置 + * 价格…」(`onPrice`,价目表对话框)、「起别名…」(`onAlias`,新建别名的对话框,这个模型已经 + * 列为上游模型),手动添加的另有「移除」。和编辑对话框「模型」一节的菜单一样。 */ export function ModelsPanel({ p, @@ -51,6 +53,7 @@ export function ModelsPanel({ onAdd, onAlias, onSpec, + onPrice, onChanged, }: { p: ProviderView; @@ -66,6 +69,8 @@ export function ModelsPanel({ onAlias?: (model: string) => void; /** 手写这个模型的规格。不给就不出「规格…」 */ onSpec?: (model: ModelRow) => void; + /** 为这个模型设置价格(价目表对话框)。按量计费时才出「设置价格…」 */ + onPrice?: (model: string) => void; /** 改了配置之后(移除了手动添加的模型):让概览重读 */ onChanged?: () => void; }) { @@ -238,6 +243,7 @@ export function ModelsPanel({ perToken={perToken} onAlias={onAlias} onSpec={onSpec} + onPrice={onPrice} onRemove={(row) => void remove(row)} /> ))} @@ -266,6 +272,7 @@ export function ModelsPanel({ perToken={perToken} onAlias={onAlias} onSpec={onSpec} + onPrice={onPrice} onRemove={(row) => void remove(row)} /> ))} @@ -316,12 +323,14 @@ function Row({ perToken, onAlias, onSpec, + onPrice, onRemove, }: { m: ModelRow; perToken: boolean; onAlias?: (model: string) => void; onSpec?: (model: ModelRow) => void; + onPrice?: (model: string) => void; /** 从手动添加的模型里移除。只给手动添加的那几行 */ onRemove?: (model: ModelRow) => void; }) { @@ -330,8 +339,6 @@ function Row({ perToken && m.price ? `$${perMillion(m.price.input)} / $${perMillion(m.price.output)}${m.estimated ? t.estimated : ""}` : null; - const removable = m.manual && onRemove; - const actions = onAlias || onSpec || removable; /** 手写了哪几项:「上下文窗口 128k」「输出上限 16k」「支持推理」「不支持图片输入」 */ const manual = [ m.context_window_source === "manual" && m.context_window ? t.manualContext(compact(m.context_window)) : null, @@ -341,11 +348,21 @@ function Row({ m.reasoning_source === "manual" && m.reasoning != null ? t.manualReasoning(m.reasoning) : null, m.image_input_source === "manual" && m.image_input != null ? t.manualImageInput(m.image_input) : null, ].filter((x): x is string => x !== null); - return ( + // 右键和行尾「…」共用这一份 + const items: MenuItems = [ + ...(onSpec ? [{ kind: "item" as const, label: t.specs, onSelect: () => onSpec(m) }] : []), + ...(perToken && onPrice ? [{ kind: "item" as const, label: t.setPrice, onSelect: () => onPrice(m.id) }] : []), + ...(onAlias ? [{ kind: "item" as const, label: t.makeAlias, onSelect: () => onAlias(m.id) }] : []), + ]; + if (m.manual && onRemove) { + if (items.length > 0) items.push({ kind: "sep" }); + items.push({ kind: "item", label: t.remove, onSelect: () => onRemove(m) }); + } + const row = (
0 && "hover:bg-muted/60 focus-within:bg-muted/60 data-[state=open]:bg-muted/60", !m.enabled && "text-muted-foreground", )} > @@ -362,7 +379,6 @@ function Row({ {t.aliasMark(a)} ))} - {/* 放在名字这一边,不放在数旁边:悬停时右边让给按钮,这些标记和它们的说明还看得见 */} {m.manual && } {hasManual(m) && ( )} - {/* 悬停时这一格让给「规格…」「起别名…」:叠在同一个位置,行高不跳 */} - - {price && ( + + {price ? ( {price} + ) : ( + perToken && {t.unpriced} )} - {m.context_window ? compact(m.context_window) : ""} + {m.context_window ? compact(m.context_window) : t.unknown} - {actions && ( - - {onSpec && ( - - )} - {onAlias && ( - - )} - {removable && ( - - )} + {items.length > 0 ? ( + + + ) : ( + // 没有菜单的行也让出那一列,数和别的行对齐 + )}
); + return items.length > 0 ? {row} : row; } /** 清单还没读到:几行和模型行一样高的占位 */ diff --git a/src/upstreams/ModelsSection.i18n.ts b/src/upstreams/ModelsSection.i18n.ts index 1429fe1c..60d3cd65 100644 --- a/src/upstreams/ModelsSection.i18n.ts +++ b/src/upstreams/ModelsSection.i18n.ts @@ -28,14 +28,27 @@ export const modelsSectionText = messages( context: "上下文窗口", pricing: "定价", actions: "操作", + actionsOf: (id: string) => `${id} 的操作`, remove: "移除", - removeLabel: (id: string) => `移除手动添加的模型 ${id}`, + specs: "规格…", + specsOf: (id: string) => `设置 ${id} 的规格`, + setPrice: "设置价格…", + setPriceShort: "设置价格", + priceOf: (id: string) => `设置 ${id} 的价格`, + makeAlias: "起别名…", + aliasMark: (alias: string) => `别名 ${alias}`, + aliasTitle: (alias: string) => `别名 ${alias} 列着这个模型:客户端用 ${alias} 请求时可以发往这个模型`, + unknown: "未知", + fill: "填写", + manualContextTitle: "此上游手动填写的值,优先于价目表", noMatch: "无匹配的模型", priced: "已定价", pricedEstimated: "已定价(估算)", unpriced: "未定价", unpricedNote: (n: number, sheet: string) => - `${n} 个已启用的模型在${sheet}中未定价,这些模型的请求无法计算费用。可在「计费」中为其设置价格。`, + `${n} 个已启用的模型在${sheet}中未定价,这些模型的请求无法计算费用。`, + inBilling: "可在「计费」中为其设置价格。", + unknownNote: (n: number) => `${n} 个已启用的模型上下文窗口未知,接管的客户端按自身默认值处理。`, }, { title: "Model list", @@ -63,15 +76,31 @@ export const modelsSectionText = messages( context: "Context window", pricing: "Price", actions: "Actions", + actionsOf: (id: string) => `Actions for ${id}`, remove: "Remove", - removeLabel: (id: string) => `Remove ${id}, added by hand`, + specs: "Specs…", + specsOf: (id: string) => `Set specs for ${id}`, + setPrice: "Set price…", + setPriceShort: "Set price", + priceOf: (id: string) => `Set the price of ${id}`, + makeAlias: "Add alias…", + aliasMark: (alias: string) => `alias ${alias}`, + aliasTitle: (alias: string) => `Alias ${alias} lists this model: requests for ${alias} can go to it`, + unknown: "Unknown", + fill: "Set", + manualContextTitle: "Set by hand for this upstream; takes precedence over the price table", noMatch: "No matching models", priced: "Priced", pricedEstimated: "Priced (estimated)", unpriced: "Unpriced", unpricedNote: (n: number, sheet: string) => n === 1 - ? `1 enabled model is unpriced in ${sheet}, so the cost of its requests cannot be calculated. Prices can be set in Billing.` - : `${n} enabled models are unpriced in ${sheet}, so the cost of their requests cannot be calculated. Prices can be set in Billing.`, + ? `1 enabled model is unpriced in ${sheet}, so the cost of its requests cannot be calculated.` + : `${n} enabled models are unpriced in ${sheet}, so the cost of their requests cannot be calculated.`, + inBilling: " Prices can be set in Billing.", + unknownNote: (n: number) => + n === 1 + ? "1 enabled model has an unknown context window; clients that are taken over use their own default." + : `${n} enabled models have an unknown context window; clients that are taken over use their own default.`, }, ); diff --git a/src/upstreams/ModelsSection.test.ts b/src/upstreams/ModelsSection.test.ts index 043f4e39..b3183e9b 100644 --- a/src/upstreams/ModelsSection.test.ts +++ b/src/upstreams/ModelsSection.test.ts @@ -1,7 +1,7 @@ import { beforeAll, describe, expect, it } from "vitest"; import { setLang } from "@/i18n"; -import type { ModelRow } from "@/types"; -import { catalogOf, modelCount } from "./ModelsSection"; +import type { ModelRow, ResolvedPrice } from "@/types"; +import { catalogOf, modelCount, unknownContext } from "./ModelsSection"; // 断言按中文写:不随跑测试那台机器的系统语言变 beforeAll(() => setLang("zh")); @@ -55,4 +55,28 @@ describe("编辑对话框的模型一节", () => { setLang("zh"); } }); + + it("每一行按模型 ID 记下:别名标记、规格对话框照它", () => { + const c = catalogOf({ + provider: "relay", + source: "discovered", + status: "listed", + fetching: false, + models: [row({ id: "glm-5", aliases: ["glm"] }), row({ id: "kimi-k3" })], + }); + expect(c.rows?.["glm-5"]?.aliases).toEqual(["glm"]); + expect(Object.keys(c.rows ?? {})).toEqual(["glm-5", "kimi-k3"]); + }); + + it("上下文窗口未知:手写的没有、价目表里也没有;价格还没查回来的不算", () => { + const price = (model: string, max?: number): ResolvedPrice => ({ model, estimated: false, max_input_tokens: max }); + const prices = { + a: price("a", 200_000), + b: price("b"), + c: price("c"), + }; + // c 手写过;d 还没查回来 + expect(unknownContext(["a", "b", "c", "d"], prices, { c: 1_000_000 })).toEqual(["b"]); + expect(unknownContext(["a", "b"], prices, undefined)).toEqual(["b"]); + }); }); diff --git a/src/upstreams/ModelsSection.tsx b/src/upstreams/ModelsSection.tsx index 8e137357..8bd0a9f0 100644 --- a/src/upstreams/ModelsSection.tsx +++ b/src/upstreams/ModelsSection.tsx @@ -1,5 +1,6 @@ import { useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react"; -import { CircleAlertIcon, RefreshCwIcon, SearchIcon } from "lucide-react"; +import { CircleAlertIcon, InfoIcon, RefreshCwIcon, SearchIcon } from "lucide-react"; +import { AliasMark } from "@/aliases/AliasMark"; import { cn } from "@/lib/utils"; import { compact, whenMinute } from "@/format"; import { Badge } from "@/ui/badge"; @@ -7,6 +8,7 @@ import { Button } from "@/ui/button"; import { Checkbox } from "@/ui/checkbox"; import { InputGroup, InputGroupAddon, InputGroupInput } from "@/ui/input-group"; import { rowMotion, usePresentList } from "@/ui/motion"; +import { RowMenu, RowMenuButton, type MenuItems } from "@/ui/row-menu"; import { Segmented } from "@/ui/segmented"; import { Spinner } from "@/ui/spinner"; import { TableSkeleton } from "@/ui/states"; @@ -20,7 +22,7 @@ import { TableRow, } from "@/ui/table"; import { textOf, useText } from "@/i18n"; -import type { ModelListStatus, ModelSource, Msg, ProviderModelsView, ResolvedPrice } from "@/types"; +import type { ModelListStatus, ModelRow, ModelSource, Msg, ProviderModelsView, ResolvedPrice } from "@/types"; import { coreText } from "@/i18n/core.i18n"; import { modelSourceLabel } from "./labels"; import { type ManualAdd, type ManualEntry, ManualModelList, ManualTag } from "./ManualModelInput"; @@ -50,6 +52,37 @@ export interface ModelCatalog { * 上下文窗口一列照它写,和模型弹窗里是同一个数 */ manualContext?: Record; + /** + * core 记下的每一行(上游列出的和已保存的手动添加的),按模型 ID。别名标记、规格对话框 + * 都照它 + */ + rows?: Record; +} + +/** + * 在「模型」一节里给一个模型设置规格、价格、别名。**只是入口**:打开的是现有的模型规格、 + * 价目表、新建别名对话框,规则和别处一样。编辑已有的上游时才有 —— 新建的上游还不在配置 + * 里,规格和别名没处写 + */ +export interface ModelActions { + /** 打开模型规格对话框 */ + spec: (model: string) => void; + /** 为这几个模型设置价格:打开价目表对话框,与「计费」一节的「设置价格」同一条路 */ + price: (models: string[]) => void; + /** 打开新建别名对话框,上游模型列入这个模型 */ + alias: (model: string) => void; +} + +/** + * 已启用的模型里上下文窗口不知道的:这一家没手写、价目表里也没有。价格(连同价目表里的 + * 上下文窗口)还没查回来的不算 —— 那时说不知道是冤枉它 + */ +export function unknownContext( + enabled: readonly string[], + prices: Record, + manual: Record | undefined, +): string[] { + return enabled.filter((m) => prices[m] !== undefined && (manual?.[m] ?? prices[m]!.max_input_tokens) == null); } /** core 记下的那一份 */ @@ -66,6 +99,7 @@ export function catalogOf(v: ProviderModelsView): ModelCatalog { m.context_window_source === "manual" && m.context_window != null ? [[m.id, m.context_window]] : [], ), ), + rows: Object.fromEntries(v.models.map((m) => [m.id, m])), }; } @@ -87,9 +121,14 @@ export function modelCount(hasList: boolean, listed: number, added: number): str * `ManualModelInput`)。**那张表在自己的框里滚**,框按剩下的高度收放;剩下的太矮时框留着 * 几行高,由对话框中间那一块去滚。 * - * 手动添加的那几行标「手动」、行尾「移除」。加和移除都只改表单,和别的设置一起保存。 + * 手动添加的那几行标「手动」,行菜单里有「移除」。加和移除都只改表单,和别的设置一起保存。 * 指定了启用范围时,新加的一并勾上(`withManualAdded`)。 * + * 编辑已有的上游时(`actions`),上下文窗口、定价两格就是按钮:不知道的写「未知 · 填写」, + * 未定价的写「未定价 · 设置价格」。行尾「…」和右键是同一份菜单:规格…、设置价格…、 + * 起别名…。**这些都只是入口**,打开的是现有的对话框,在那边保存、立即生效,和这个对话框的 + * 「保存」无关。模型名后面标出列着它的别名。 + * * 输入框的状态(`entry`)放在对话框里:切到别的分节再保存,输入框里没按回车的也要先加上、 * 写错了不存(见 `UpstreamDialog` 的 `save`)。 */ @@ -106,6 +145,7 @@ export function ModelsSection({ loading, refreshing, onRefresh, + actions, }: { /** 顶上那一行:新建时检测通过的结果,或者登录的账号。不给就写清单的来历 */ head?: ReactNode; @@ -125,6 +165,8 @@ export function ModelsSection({ loading: boolean; refreshing: boolean; onRefresh: () => void; + /** 设置规格、价格、别名的入口。不给就只读 */ + actions?: ModelActions; }) { const t = useText(modelsSectionText); const shared = useText(manualModelInputText); @@ -153,7 +195,10 @@ export function ModelsSection({ const patterns = form.scope === "some" ? form.scopeList.filter((p) => p.includes("*")) : []; const enabled = models.filter((m) => inScope(form, m)); const unpriced = perToken ? enabled.filter((m) => prices[m] && !prices[m].price) : []; + const unknown = known ? unknownContext(enabled, prices, catalog?.manualContext) : []; const anyManual = union.some((m) => m.manual); + /** 行尾「…」那一列:有入口,或者有手动添加的(菜单里有「移除」) */ + const menuColumn = !!actions || anyManual; const added = union.filter((m) => m.manual && !m.listed).length; const count = pending ? null : modelCount(hasList, union.length - added, added); const source: ModelSource = hasList ? "discovered" : form.manualModels.length > 0 ? "manual" : "none"; @@ -198,6 +243,22 @@ export function ModelsSection({ set({ scope: "some", scopeList: models.filter((m) => current.has(m)) }); } + /** 一行的菜单。右键和行尾「…」共用 */ + function menuOf(m: { id: string; manual: boolean; listed: boolean }): MenuItems { + const items: MenuItems = actions + ? [ + { kind: "item", label: t.specs, onSelect: () => actions.spec(m.id) }, + ...(perToken ? [{ kind: "item" as const, label: t.setPrice, onSelect: () => actions.price([m.id]) }] : []), + { kind: "item", label: t.makeAlias, onSelect: () => actions.alias(m.id) }, + ] + : []; + if (m.manual) { + if (items.length > 0) items.push({ kind: "sep" }); + items.push({ kind: "item", label: t.remove, onSelect: () => set(withManualRemoved(form, m.id, m.listed)) }); + } + return items; + } + const allShownOn = live.length > 0 && live.every((id) => inScope(form, id)); const someShownOn = live.some((id) => inScope(form, id)); const note = known && !hasList ? noListNote(catalog) : null; @@ -303,7 +364,7 @@ export function ModelsSection({ {t.modelId} {t.context} {perToken && {t.pricing}} - {anyManual && ( + {menuColumn && ( {t.actions} @@ -314,8 +375,11 @@ export function ModelsSection({ {shown.map(({ item: m, key, presence }) => { const on = inScope(form, m.id); const price = prices[m.id]; - const ctx = catalog?.manualContext?.[m.id] ?? price?.max_input_tokens; - return ( + const manualCtx = catalog?.manualContext?.[m.id]; + const ctx = manualCtx ?? price?.max_input_tokens; + const aliases = catalog?.rows?.[m.id]?.aliases ?? []; + const items = menuOf(m); + const row = ( {known && ( @@ -328,14 +392,50 @@ export function ModelsSection({ {m.id} + {aliases.map((a) => ( + + {t.aliasMark(a)} + + ))} {m.manual && } - {ctx ? compact(ctx) : "—"} + + {actions ? ( + actions.spec(m.id)} + label={t.specsOf(m.id)} + title={manualCtx != null ? t.manualContextTitle : undefined} + > + {ctx ? ( + {compact(ctx)} + ) : ( + + )} + + ) : ctx ? ( + compact(ctx) + ) : ( + "—" + )} + {perToken && ( {!price ? ( — + ) : actions ? ( + actions.price([m.id])} label={t.priceOf(m.id)}> + {price.price ? ( + price.estimated ? t.pricedEstimated : t.priced + ) : ( + + )} + ) : price.price ? ( {price.estimated ? t.pricedEstimated : t.priced} @@ -345,24 +445,20 @@ export function ModelsSection({ )} )} - {anyManual && ( + {menuColumn && ( - {m.manual && ( - - )} + {items.length > 0 && } )} ); + return items.length > 0 ? ( + + {row} + + ) : ( + row + ); })} @@ -372,15 +468,70 @@ export function ModelsSection({ {unpriced.length > 0 && ( -

+

- {t.unpricedNote(unpriced.length, sheetLabel)} + + {t.unpricedNote(unpriced.length, sheetLabel)} + {!actions && t.inBilling} + + {actions && ( + + )} +
+ )} + {unknown.length > 0 && ( +

+ + {t.unknownNote(unknown.length)}

)} ); } +/** + * 上下文窗口、定价两格里的按钮:整格悬停变底色(和上游表「模型」一格一样),不画下划线。 + * 按钮保持箭头光标(应用的约定) + */ +function CellButton({ + onClick, + label, + title, + children, +}: { + onClick: () => void; + label: string; + title?: string; + children: ReactNode; +}) { + return ( + + ); +} + +/** 「未知 · 填写」「未定价 · 设置价格」:先说现状,再说能做什么 */ +function Gap({ state, action, warn = false }: { state: string; action: string; warn?: boolean }) { + return ( + <> + {state} + + · + + {action} + + ); +} + /** * 没从上游拿到清单时,表上面那一句:为什么,以及手动添加是下一步。获取失败用琥珀色 —— * 连接本身可能有问题;上游本来就不提供清单是灰的 diff --git a/src/upstreams/PriceSheetDialog.tsx b/src/upstreams/PriceSheetDialog.tsx index 3d438301..1d7d9f1a 100644 --- a/src/upstreams/PriceSheetDialog.tsx +++ b/src/upstreams/PriceSheetDialog.tsx @@ -48,8 +48,11 @@ import { priceSheetDialogText } from "./PriceSheetDialog.i18n"; import { defaultSheetUsers } from "./PriceSheetTable"; export type PriceSheetDialogMode = - /** `prefill`:从「无法计价」那条提示进来,带着要设价格的模型和上游 */ - | { kind: "create"; prefill?: { models: string[]; usedBy: string[] } } + /** + * `prefill`:从「无法计价」那条提示、上游的「设置价格」进来,带着要设价格的模型和上游。 + * `name`:预填的名称(那个上游的名字,没被占用时) + */ + | { kind: "create"; prefill?: { models: string[]; usedBy: string[]; name?: string } } /** `add`:打开后为这几个模型各加一条覆盖(从上游对话框的「设置价格」进来) */ | { kind: "edit"; name: string; add?: string[] } | { kind: "duplicate"; from: string } @@ -127,7 +130,9 @@ export function PriceSheetDialog({ const [base] = useState(configVersion); const original = mode.kind === "edit" ? (ov.price_sheets.find((s) => s.name === mode.name) ?? null) : null; - const [name, setName] = useState(mode.kind === "draft" ? mode.sheet.name : ""); + const [name, setName] = useState( + mode.kind === "draft" ? mode.sheet.name : mode.kind === "create" ? (mode.prefill?.name ?? "") : "", + ); const [multiplier, setMultiplier] = useState(mode.kind === "draft" ? multiplierText(mode.sheet.multiplier) : "1.00"); const [overrides, setOverrides] = useState>(mode.kind === "draft" ? mode.sheet.models : {}); const [ratios, setRatios] = useState>({}); diff --git a/src/upstreams/UpstreamDialog.tsx b/src/upstreams/UpstreamDialog.tsx index e6c96cd5..f5ea648a 100644 --- a/src/upstreams/UpstreamDialog.tsx +++ b/src/upstreams/UpstreamDialog.tsx @@ -19,6 +19,7 @@ import { notify } from "@/ui/notify"; import type { BedrockDraft, ImportProposal, + ModelRow, Overview, ProviderPreview, ProviderTestResult, @@ -27,6 +28,7 @@ import type { SheetRef, ZaiFamily, } from "@/types"; +import { AliasDialog } from "@/aliases/AliasDialog"; import { useAccountLogin, type AccountLogin, type LoginAccount } from "./accountLogin"; import { AccountLine, AccountPanel, AccountSummary } from "./AccountPanel"; import { api } from "./api"; @@ -43,7 +45,8 @@ import { import { coreText, errorText, planLabel, plain, protocolLabel, shortUrl } from "./labels"; import { useManualEntry } from "./ManualModelInput"; import { addToList, unionModels } from "./manualModels"; -import { ModelsSection, catalogOf, type ModelCatalog } from "./ModelsSection"; +import { ModelSpecDialog } from "./ModelSpecDialog"; +import { ModelsSection, catalogOf, type ModelActions, type ModelCatalog } from "./ModelsSection"; import { DialogError, FormItem, ProviderTile, StepNav } from "./parts"; import { CUSTOM, ZAI_ENDPOINTS, accountKind, presetById } from "./presets"; import type { Relogin } from "./ReloginDialog"; @@ -67,6 +70,7 @@ import { inScope, modelsMissing, newSheetName, + sheetPrefill, retarget, sheetMissing, sheetToCreate, @@ -265,6 +269,10 @@ export function UpstreamDialog({ | { kind: "sheet"; name: string | null; add?: string[] } /** 导入链接带来的新价目表:改的是草稿,不写配置 */ | { kind: "draft-sheet"; add?: string[] } + /** 「模型」一节里那一行的规格 */ + | { kind: "spec"; row: ModelRow } + /** 「模型」一节里给这个模型起别名 */ + | { kind: "alias"; model: string } >(null); /** @@ -485,6 +493,39 @@ export function UpstreamDialog({ } const original = saved?.name ?? null; + + /** + * 为这几个模型设置价格:「计费」一节和「模型」一节共用。导入链接带来的新价目表还没建:改 + * 草稿;选了自定义价目表:编辑它,这几个模型各加一条覆盖;用的是默认价目表:新建一张 + * (见下面 `sheet` 那个对话框的预填) + */ + function priceModels(models: string[]) { + if (draftSheet) setNested({ kind: "draft-sheet", add: models }); + else setNested({ kind: "sheet", name: form.pricing || null, add: models }); + } + + /** 写了规格、别名之后:清单按已保存的这一家重读(别名标记、手写的上下文窗口跟着变) */ + function reloadCatalog() { + if (!saved || connectionChanged(form, saved)) return; + api + .providerModels(saved.name) + .then((v) => setCatalog(catalogOf(v))) + .catch((e) => setError(errorText(e))); + } + + /** + * 「模型」一节里设置规格、价格、别名的入口。**已在配置里的上游才有**:规格和别名按已保存的 + * 名字写;新建、还没登录建出来的上游没处写 + */ + const modelActions: ModelActions | undefined = + saved && ready + ? { + spec: (model) => setNested({ kind: "spec", row: catalog?.rows?.[model] ?? bareRow(model) }), + price: priceModels, + alias: (model) => setNested({ kind: "alias", model }), + } + : undefined; + const missing = section === "connection" ? signedIn @@ -836,6 +877,7 @@ export function UpstreamDialog({ loading={catalogLoading || (testing && catalog == null)} refreshing={refreshing || testing} onRefresh={refreshModels} + actions={modelActions} /> )} {section === "billing" && ( @@ -849,11 +891,7 @@ export function UpstreamDialog({ onNewSheet={() => setNested({ kind: "sheet", name: null })} onEditSheet={(name) => setNested({ kind: "sheet", name })} onEditNewSheet={() => setNested({ kind: "draft-sheet" })} - onPriceModels={(models) => - draftSheet - ? setNested({ kind: "draft-sheet", add: models }) - : setNested({ kind: "sheet", name: form.pricing || null, add: models }) - } + onPriceModels={priceModels} /> )} @@ -961,7 +999,7 @@ export function UpstreamDialog({ mode={ nested.name ? { kind: "edit", name: nested.name, add: nested.add } - : { kind: "create", prefill: nested.add ? { models: nested.add, usedBy: [] } : undefined } + : { kind: "create", prefill: nested.add ? sheetPrefill(nested.add, original, ov.price_sheets) : undefined } } ov={ov} configVersion={base} @@ -1005,6 +1043,32 @@ export function UpstreamDialog({ }} /> )} + {saved && ( + !o && setNested(null)} + provider={saved.name} + row={nested?.kind === "spec" ? nested.row : null} + configVersion={base} + onSaved={(version) => { + setBase(version); + reloadCatalog(); + onChanged(); + }} + /> + )} + {nested?.kind === "alias" && ( + !o && setNested(null)} + initial={{ models: [nested.model] }} + onSaved={(version) => { + setBase(version); + reloadCatalog(); + onChanged(); + }} + /> + )}
); @@ -1067,6 +1131,14 @@ function withModelsAndBilling(adopted: UpstreamForm, f: UpstreamForm): UpstreamF }; } +/** + * 清单里还没有的模型(手动添加了、还没保存)在规格对话框里的那一行:什么都不知道,占位都 + * 写「价目表中没有」 + */ +function bareRow(id: string): ModelRow { + return { id, enabled: true, estimated: false, aliases: [], manual: true, listed: false }; +} + /** 链接写的上下文窗口在模型表里先显示出来(创建后写进规格)。core 记着手写的照旧优先 */ function withLinkContext(c: ModelCatalog, specs: ImportProposal["specs"]): ModelCatalog { const fromLink = Object.fromEntries( diff --git a/src/upstreams/UpstreamTable.tsx b/src/upstreams/UpstreamTable.tsx index f5353722..562bb2bd 100644 --- a/src/upstreams/UpstreamTable.tsx +++ b/src/upstreams/UpstreamTable.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { ChevronDownIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import type { Resource } from "@/lib/resource"; import { Badge } from "@/ui/badge"; @@ -53,6 +54,8 @@ export interface UpstreamActions { refreshBalance: (name: string) => void; /** 打开编辑对话框的「模型」一节:启用范围、手动添加的模型 */ editModels: (name: string) => void; + /** 为这家的这几个模型设置价格:打开价目表对话框(用着自定义价目表就编辑它,否则新建) */ + setPrices: (name: string, models: string[]) => void; /** ChatGPT 账号上游:编辑对话框的「账号」一节(额度与重置卡) */ account: (name: string) => void; /** 流量页,只看经这个上游的请求 */ @@ -162,6 +165,7 @@ export function UpstreamTable({ configVersion={configVersion} onChanged={onChanged} onEdit={() => actions.editModels(p.name)} + onPrice={(model) => actions.setPrices(p.name, [model])} /> @@ -411,12 +415,15 @@ function ModelsCell({ configVersion, onChanged, onEdit, + onPrice, }: { p: ProviderView; busy: boolean; configVersion: string; onChanged: () => void; onEdit: () => void; + /** 为这个模型设置价格(价目表对话框,挂在页面上) */ + onPrice: (model: string) => void; }) { const t = useText(upstreamTableText); const l = useText(labelsText); @@ -445,7 +452,17 @@ function ModelsCell({ > {fetching && } - {face.count ?? "—"} + + {face.count ?? "—"} + {/* 平时就带着箭头:看得出这一格能点开,展开时朝上 */} + + {face.note && ( @@ -453,7 +470,13 @@ function ModelsCell({ )} - + e.stopPropagation()} + > { + setOpen(false); + onPrice(model); + }} /> diff --git a/src/upstreams/UpstreamsPage.tsx b/src/upstreams/UpstreamsPage.tsx index 9826e443..d4fbb9b7 100644 --- a/src/upstreams/UpstreamsPage.tsx +++ b/src/upstreams/UpstreamsPage.tsx @@ -40,7 +40,7 @@ import { ProxyDialog, type ProxyDialogMode } from "./ProxyDialog"; import { ProxyTable, type ProxyCheck } from "./ProxyTable"; import { LinkTestDialog, SpeedTestDialog, TestConnectionDialog } from "./TestDialogs"; import { UpstreamDialog, type UpstreamDialogMode } from "./UpstreamDialog"; -import { formFromView, toInput } from "./upstreamForm"; +import { formFromView, sheetPrefill, toInput } from "./upstreamForm"; import { upstreamsPageText } from "./UpstreamsPage.i18n"; import { CostFigure } from "@/CostFigure"; import { UpstreamTable, problemsOf } from "./UpstreamTable"; @@ -469,6 +469,17 @@ export default function UpstreamsPage({ refreshBalance, editModels: (name) => setDialog({ kind: "upstream", mode: { kind: "edit", name, section: "models" } }), + // 用着自定义价目表:编辑它,这几个模型各加一条覆盖;用着默认的:新建一张,由这个 + // 上游使用(之后再设价格编辑的就是这一张) + setPrices: (name, models) => { + const pricing = ov.providers.find((x) => x.name === name)?.pricing; + setDialog({ + kind: "sheet", + mode: pricing + ? { kind: "edit", name: pricing, add: models } + : { kind: "create", prefill: sheetPrefill(models, name, ov.price_sheets) }, + }); + }, account: (name) => setDialog({ kind: "upstream", mode: { kind: "edit", name, section: "account" } }), traffic: (name) => nav.open("requests", { filter: { provider: name } }), diff --git a/src/upstreams/upstreamForm.test.ts b/src/upstreams/upstreamForm.test.ts index 53c25ee6..961f7f0f 100644 --- a/src/upstreams/upstreamForm.test.ts +++ b/src/upstreams/upstreamForm.test.ts @@ -18,6 +18,7 @@ import { modelsMissing, oauthKept, retarget, + sheetPrefill, signedInKey, siteLocked, toInput, @@ -828,3 +829,21 @@ describe("高级设置自动展开", () => { expect(advancedNonDefault(formFromView(view({ headers: [], proxy: "hk", on_proxy_fail: "direct" })))).toBe(true); }); }); + +describe("设置价格时新建价目表的预填", () => { + it("已在配置里的上游:由它使用,名称用它的名字", () => { + expect(sheetPrefill(["glm-5"], "relay", [{ name: "office" }])).toEqual({ + models: ["glm-5"], + usedBy: ["relay"], + name: "relay", + }); + }); + + it("名字被别的价目表占了:名称留空由人起,照样由它使用", () => { + expect(sheetPrefill(["glm-5"], "relay", [{ name: "relay" }])).toEqual({ models: ["glm-5"], usedBy: ["relay"] }); + }); + + it("还没保存的上游不在配置里:不预选、不预填名称", () => { + expect(sheetPrefill(["glm-5"], null, [])).toEqual({ models: ["glm-5"], usedBy: [] }); + }); +}); diff --git a/src/upstreams/upstreamForm.ts b/src/upstreams/upstreamForm.ts index cc7537d5..91e27800 100644 --- a/src/upstreams/upstreamForm.ts +++ b/src/upstreams/upstreamForm.ts @@ -644,6 +644,20 @@ export function sheetMissing(f: UpstreamForm, sheets: readonly { name: string }[ return null; } +/** + * 「设置价格」新建价目表时预填什么:要设价格的模型,**这个上游**(已在配置里时)—— 建好就由它 + * 使用,下次再设价格编辑的就是这一张,不会再新建;名称用上游的名字(没被占用时,占用了就留空 + * 由人起)。还没保存的上游不在配置里,用不了:留给对话框「保存」时选上 + */ +export function sheetPrefill( + models: string[], + upstream: string | null, + sheets: readonly { name: string }[], +): { models: string[]; usedBy: string[]; name?: string } { + if (!upstream) return { models, usedBy: [] }; + return { models, usedBy: [upstream], name: sheets.some((s) => s.name === upstream) ? undefined : upstream }; +} + /** * 这一家是 Bedrock:协议选的就是它,或者自动识别、地址是标准的 Bedrock 地址(core 按 * 同一个规矩认)。**不等识别结果**:那是异步的,换地址的一瞬间会闪一下 From 963ae8cc1b7bb986a72946b8f1111b3688aa376e Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:05:22 +0800 Subject: [PATCH 2/2] 2026.10.21: release notes and version Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- release-notes/2026.10.21.md | 12 ++++++++++++ src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 5 files changed, 16 insertions(+), 4 deletions(-) create mode 100644 release-notes/2026.10.21.md diff --git a/package.json b/package.json index 44747469..4b901342 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.20", + "version": "2026.10.21", "type": "module", "packageManager": "pnpm@11.13.0", "scripts": { diff --git a/release-notes/2026.10.21.md b/release-notes/2026.10.21.md new file mode 100644 index 00000000..8da7957b --- /dev/null +++ b/release-notes/2026.10.21.md @@ -0,0 +1,12 @@ +**Upgrade notes:** +- The bundled core stays at 0.74.0 and the control-plane protocol at 48. Nothing is cleared; the request history, stored payloads and settings carry over. + +**Upstreams:** +- In Edit upstream > Models, the context window and price cells open the existing dialogs. An unknown context window offers Set, which opens Model specs; an unpriced model offers Set price, which opens the price sheet dialog. Models that are not enabled can be set up too. +- Each model row has a menu, from the "..." button at the end of the row or a right-click: Specs, Set price, Add alias, and Remove for models added by hand. Aliases that list a model are shown after its name. +- Below the model table, a line counts the enabled models whose context window is unknown; clients that are taken over use their own default for those. The note about unpriced models has a Set price button. +- Set price works like the one in Billing. On the default price sheet it creates a new sheet named after the upstream, and the upstream uses it from then on, so the next Set price edits that sheet; on a custom sheet it adds the model as an override. +- In the upstream list, the model count shows a dropdown arrow. The model popover marks unpriced models and unknown context windows, has the same row menu instead of buttons that only appeared on hover, and its footer link is now Manage models. + +**Traffic:** +- In a request's Content tab, a streamed answer now stays on screen when the request ends, until the stored copy has been written. It could show as not saved until the request was opened again. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 26ee7323..0bf20eb2 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4832,7 +4832,7 @@ dependencies = [ [[package]] name = "thinkwatch-lite" -version = "2026.10.20" +version = "2026.10.21" dependencies = [ "anyhow", "block2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3aaf9379..84641a32 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "thinkwatch-lite" -version = "2026.10.20" +version = "2026.10.21" edition = "2024" # **这个数决定依赖能升到哪一版。**edition 2024 的解析器只挑声明的 Rust # 版本编得动的依赖:写 1.85 的时候,`cargo update` 一直停在旧的 time 和 diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 24da550c..bf04a9c0 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.20", + "version": "2026.10.21", "identifier": "app.thinkwatch.lite", "build": { "beforeDevCommand": "pnpm dev",