Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
12 changes: 12 additions & 0 deletions release-notes/2026.10.21.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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 和
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
14 changes: 9 additions & 5 deletions src/aliases/AliasDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ const MAX_SUGGESTIONS = 50;
*
* 红字是 core 说的问题,不能保存;黄字是要留意的,可以保存。
*
* 挂载方:别名标签(新建、编辑),上游模型弹窗(「起别名…」,`initial.models` 是那个模型)。
* 挂载方:别名标签(新建、编辑),上游模型弹窗和编辑上游对话框的「模型」一节(「起别名…」,
* `initial.models` 是那个模型)。
*/
export function AliasDialog(props: {
open: boolean;
Expand All @@ -58,7 +59,8 @@ export function AliasDialog(props: {
editing?: string;
/** 新建时预填。编辑时不用给:模型按原名从 core 读 */
initial?: Partial<AliasInput>;
onSaved?(): void;
/** 写完了。`version`:写完之后的配置版本(编辑上游对话框里打开的,那边接着用它) */
onSaved?(version: string): void;
}) {
const { open, onOpenChange, editing } = props;
return (
Expand Down Expand Up @@ -89,7 +91,7 @@ function Body({
}: {
editing?: string;
initial?: Partial<AliasInput>;
onSaved?(): void;
onSaved?(version: string): void;
onClose: () => void;
}) {
const t = useText(aliasDialogText);
Expand Down Expand Up @@ -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));
Expand Down
13 changes: 7 additions & 6 deletions src/upstreams/ModelSpecDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ import { DialogError, FormItem } from "./parts";
* 留空是什么意思,看占位就知道。推理、图片输入是三段:价目表 / 支持 / 不支持,价目表怎么说
* 写在下面一行。
*
* 挂载方:上游表「模型」一格的弹窗(「规格…」)。对话框挂在弹窗外面:弹窗一收起,里面的
* 东西就卸掉了。
* 挂载方:上游表「模型」一格的弹窗(「规格…」)、编辑上游对话框的「模型」一节(上下文窗口
* 那一格、行菜单的「规格…」)。对话框挂在弹窗外面:弹窗一收起,里面的东西就卸掉了。
*/
export function ModelSpecDialog({
open,
Expand All @@ -49,7 +49,8 @@ export function ModelSpecDialog({
row: ModelRow | null;
/** 概览里的配置版本。**只取打开那一刻的**,见 `Body` 的 `base` */
configVersion: string;
onSaved?: () => void;
/** 写完了。`version`:写完之后的配置版本(编辑上游对话框里打开的,那边接着用它) */
onSaved?: (version: string) => void;
}) {
return (
<Dialog open={open && row !== null} onOpenChange={onOpenChange}>
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -110,7 +111,7 @@ function Body({
setSaving(true);
setError(null);
try {
await api.setModelSpec({
const w = await api.setModelSpec({
provider,
model: row.id,
...spec,
Expand All @@ -120,7 +121,7 @@ function Body({
invalidate(modelsKey(provider));
invalidate("aliases");
invalidate("known-models");
onSaved?.();
onSaved?.(w.version);
onClose();
} catch (e) {
setError(errorText(e));
Expand Down
14 changes: 10 additions & 4 deletions src/upstreams/ModelsPanel.i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
Expand All @@ -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}`,
Expand All @@ -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`,
Expand All @@ -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}`,
Expand Down
86 changes: 41 additions & 45 deletions src/upstreams/ModelsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -28,20 +29,21 @@ const FILTER_FROM = 10;
* 点开「模型」一格看到的东西:这一家有哪些模型,是怎么知道的,没拿到的话
* 原因和下一步。
*
* **启用范围在编辑对话框里改**,和上游其余设置走同一条路,这里给的是去那儿的入口。
* 手动添加的模型(上游能服务、却没列进清单的)在这里加、在这里移除:「添加模型…」
* 打开添加的对话框(`onAdd`),手动添加的那几行标「手动」,悬停给「移除」—— 可撤销,
* **启用范围在编辑对话框里改**,和上游其余设置走同一条路,这里给的是去那儿的入口(「管理
* 模型…」)。手动添加的模型(上游能服务、却没列进清单的)在这里加、在这里移除:「添加模型…」
* 打开添加的对话框(`onAdd`),手动添加的那几行标「手动」,行菜单里有「移除」—— 可撤销,
* 不弹确认。上游也列了的手动模型照样标着、照样能移除:手动清单里有什么,这里一样不藏。
*
* 清单按上游缓存(`upstream-models:<名字>`):再点开一次先画上一次的,后台再读。
* 概览里这一家的获取状态一变(开始问了、问完了)就重读 —— 开着面板等它问完,
* 也能看到结果。读的是 core 记下的答案,不联网。
*
* 列进了别名的模型,名字后面标出别名;悬停一行给「起别名…」(`onAlias`),打开新建别名的
* 对话框,这个模型已经列为上游模型。
* 列进了别名的模型,名字后面标出别名。右边是单价(按量计费时,没有就标「未定价」)和上下文
* 窗口(不知道写「未知」);在这一家手写过规格的,名字后面标「手动规格」,悬停说是哪几项。
*
* 右边的数是上下文窗口:在这一家手写过规格(上下文窗口或输出上限)的,名字后面标「手动
* 规格」,悬停说是哪几项。悬停一行还给「规格…」(`onSpec`),打开手写规格的对话框。
* 每行行尾「…」常显,和右键是同一份菜单:「规格…」(`onSpec`,手写规格的对话框)、「设置
* 价格…」(`onPrice`,价目表对话框)、「起别名…」(`onAlias`,新建别名的对话框,这个模型已经
* 列为上游模型),手动添加的另有「移除」。和编辑对话框「模型」一节的菜单一样。
*/
export function ModelsPanel({
p,
Expand All @@ -51,6 +53,7 @@ export function ModelsPanel({
onAdd,
onAlias,
onSpec,
onPrice,
onChanged,
}: {
p: ProviderView;
Expand All @@ -66,6 +69,8 @@ export function ModelsPanel({
onAlias?: (model: string) => void;
/** 手写这个模型的规格。不给就不出「规格…」 */
onSpec?: (model: ModelRow) => void;
/** 为这个模型设置价格(价目表对话框)。按量计费时才出「设置价格…」 */
onPrice?: (model: string) => void;
/** 改了配置之后(移除了手动添加的模型):让概览重读 */
onChanged?: () => void;
}) {
Expand Down Expand Up @@ -238,6 +243,7 @@ export function ModelsPanel({
perToken={perToken}
onAlias={onAlias}
onSpec={onSpec}
onPrice={onPrice}
onRemove={(row) => void remove(row)}
/>
))}
Expand Down Expand Up @@ -266,6 +272,7 @@ export function ModelsPanel({
perToken={perToken}
onAlias={onAlias}
onSpec={onSpec}
onPrice={onPrice}
onRemove={(row) => void remove(row)}
/>
))}
Expand Down Expand Up @@ -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;
}) {
Expand All @@ -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,
Expand All @@ -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 = (
<div
className={cn(
"group/model relative flex items-center gap-2 rounded-md px-1.5 py-1",
actions && "hover:bg-muted/60 focus-within:bg-muted/60",
"group/model flex items-center gap-2 rounded-md py-1 pr-0.5 pl-1.5",
items.length > 0 && "hover:bg-muted/60 focus-within:bg-muted/60 data-[state=open]:bg-muted/60",
!m.enabled && "text-muted-foreground",
)}
>
Expand All @@ -362,7 +379,6 @@ function Row({
<span className="truncate">{t.aliasMark(a)}</span>
</AliasMark>
))}
{/* 放在名字这一边,不放在数旁边:悬停时右边让给按钮,这些标记和它们的说明还看得见 */}
{m.manual && <ManualTag listed={m.listed} />}
{hasManual(m) && (
<Badge
Expand All @@ -373,49 +389,29 @@ function Row({
{t.manualSpecs}
</Badge>
)}
{/* 悬停时这一格让给「规格…」「起别名…」:叠在同一个位置,行高不跳 */}
<span
className={cn(
"ml-auto flex shrink-0 items-baseline gap-2",
actions && "group-focus-within/model:invisible group-hover/model:invisible",
)}
>
{price && (
<span className="ml-auto flex shrink-0 items-baseline gap-2">
{price ? (
<span className="tw-num tw-label text-muted-foreground" title={t.priceTitle}>
{price}
</span>
) : (
perToken && <span className="tw-label text-warning">{t.unpriced}</span>
)}
<span className="w-10 text-right tw-num tw-label text-muted-foreground" title={t.context}>
{m.context_window ? compact(m.context_window) : ""}
{m.context_window ? compact(m.context_window) : t.unknown}
</span>
</span>
{actions && (
<span className="absolute top-1/2 right-1.5 flex -translate-y-1/2 gap-1 opacity-0 group-hover/model:opacity-100 has-focus-visible:opacity-100">
{onSpec && (
<Button size="xs" variant="outline" className="h-5 bg-popover" onClick={() => onSpec(m)}>
{t.specs}
</Button>
)}
{onAlias && (
<Button size="xs" variant="outline" className="h-5 bg-popover" onClick={() => onAlias(m.id)}>
{t.makeAlias}
</Button>
)}
{removable && (
<Button
size="xs"
variant="outline"
className="h-5 bg-popover"
aria-label={t.removeLabel(m.id)}
onClick={() => onRemove(m)}
>
{t.remove}
</Button>
)}
{items.length > 0 ? (
<span className="-my-0.5 shrink-0">
<RowMenuButton items={items} label={t.actionsOf(m.id)} />
</span>
) : (
// 没有菜单的行也让出那一列,数和别的行对齐
<span aria-hidden className="w-6 shrink-0" />
)}
</div>
);
return items.length > 0 ? <RowMenu items={items}>{row}</RowMenu> : row;
}

/** 清单还没读到:几行和模型行一样高的占位 */
Expand Down
Loading
Loading