Skip to content
Closed
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 src/generated/tw-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -627,7 +627,7 @@ unpriced_models?: Array<string>, };
/**
* 路由规则 `when` 里的键。
*/
export type ConditionField = "model" | "client" | "dialect" | "input_tokens" | "max_tokens" | "tool_count" | "intent" | "provider_would_be" | "cache" | "tools" | "image" | "thinking" | "stream";
export type ConditionField = "model" | "client" | "dialect" | "input_tokens" | "max_tokens" | "tool_count" | "intent" | "provider_would_be" | "cache" | "tools" | "image" | "thinking" | "stream" | "time";

/**
* 规则里的一个条件。
Expand Down
2 changes: 2 additions & 0 deletions src/labels.i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export const labelsText = messages(
tool_count: "工具数",
intent: "辅助请求",
provider_would_be: "选定上游",
time: "时段",
},
anyProbe: "任一辅助请求",
/** 一个条件的几个取值之间 */
Expand Down Expand Up @@ -202,6 +203,7 @@ export const labelsText = messages(
tool_count: "Tool count",
intent: "Auxiliary request",
provider_would_be: "Selected upstream",
time: "Time",
},
anyProbe: "Any auxiliary request",
or: " or ",
Expand Down
16 changes: 16 additions & 0 deletions src/labels.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,27 @@ describe("规则的条件与改写", () => {
expect(conditionText({ field: "stream", values: ["false"] })).toBe("Non-streaming");
expect(conditionText({ field: "model", values: ["claude-*", "gpt-*"] })).toBe("Model claude-* or gpt-*");
expect(conditionText({ field: "dialect", values: ["openai-chat"] })).toBe("Client format OpenAI Chat Completions");
expect(conditionText({ field: "time", values: ["mon-fri 09:00-18:00", "22:00-06:00"] })).toBe(
"Time Mon–Fri 09:00–18:00 or 22:00–06:00 next day",
);
expect(mismatchText({ field: "time", want: ["sat,sun 00:00-24:00"], got: "fri 16:42" })).toBe(
"Time must be Sat, Sun 00:00–24:00; actual: Fri 16:42",
);
expect(setText({ field: "model", value: "claude-haiku-4-5" })).toBe(
"Model set to claude-haiku-4-5; the entire prompt cache is invalidated",
);
expect(setText({ field: "max_tokens", value: "4096" })).toBe("max_tokens set to 4096");
});

it("时段条件写成人话;写不对的按原文", () => {
setLang("zh");
expect(conditionText({ field: "time", values: ["mon-fri 09:00-18:00"] })).toBe("时段 周一至周五 09:00–18:00");
expect(mismatchText({ field: "time", want: ["22:00-06:00"], got: "fri 16:42" })).toBe(
"要求时段为 22:00–次日 06:00,实际为 周五 16:42",
);
// 写不对的按原文
expect(conditionText({ field: "time", values: ["whenever"] })).toBe("时段 whenever");
});
});

describe("尝试链里的一跳", () => {
Expand Down
2 changes: 2 additions & 0 deletions src/labels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
} from "./types";
import { PROTOCOLS, skipLabel } from "./upstreams/labels";
import { labelsText } from "./labels.i18n";
import { timeValueText } from "./routing/timeWindow";
import type { NotSent } from "./requestRouting";

/*
Expand Down Expand Up @@ -109,6 +110,7 @@ const COUNTS = new Set(["input_tokens", "max_tokens", "tool_count"]);
function conditionValue(field: string, value: string): string {
if (field === "intent") return value === "assistant_internal" ? textOf(labelsText).anyProbe : probeLabel(value);
if (field === "dialect") return formatLabel(value);
if (field === "time") return timeValueText(value);
return value;
}

Expand Down
6 changes: 5 additions & 1 deletion src/routing/RuleDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import { PinnedModels } from "./PinnedModels";
import { routingText } from "./routing.i18n";
import { ruleDialogText } from "./RuleDialog.i18n";
import { modelHint, type ModelHint } from "./target";
import { TimeCondition } from "./TimeCondition";

/** 目标下拉里「新建策略组…」那一项的值。只活在这个下拉里,不会写进配置 */
const NEW_GROUP = "::new-group";
Expand Down Expand Up @@ -537,12 +538,15 @@ function ConditionRow({
/>
);
break;
case "time":
control = <TimeCondition values={c.values} onChange={(values) => onChange({ ...c, values })} label={name} />;
break;
}
return (
<div className="flex flex-col gap-1">
<div className="flex items-start gap-2">
<span className="w-24 shrink-0 pt-1.5 tw-body text-muted-foreground">{name}</span>
<div className={cn("flex min-w-0 flex-1 flex-wrap items-center gap-2", f.kind === "many" && "pt-1")}>
<div className={cn("flex min-w-0 flex-1 flex-wrap items-center gap-2", (f.kind === "many" || f.kind === "time") && "pt-1")}>
{control}
</div>
{/* 写成字:对话框右上角的 × 是关闭,同一个面板里不能再有一个 × 表示删除 */}
Expand Down
27 changes: 27 additions & 0 deletions src/routing/TimeCondition.i18n.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { messages } from "@/i18n";

export const timeConditionText = messages(
{
/** 星期开关上的字:一个字就够,七个排成一行 */
days: { mon: "一", tue: "二", wed: "三", thu: "四", fri: "五", sat: "六", sun: "日" },
window: (n: number) => `第 ${n} 个时段`,
start: (n: number) => `第 ${n} 个时段的开始时间`,
end: (n: number) => `第 ${n} 个时段的结束时间`,
remove: "移除",
removeWindow: (n: number) => `移除第 ${n} 个时段`,
addWindow: "添加时段",
rawHint: "无法识别的写法,示例:mon-fri 09:00-18:00",
note: "按网关所在机器的本地时间。结束早于开始的为跨夜时段;全天写 00:00–24:00。",
},
{
days: { mon: "Mon", tue: "Tue", wed: "Wed", thu: "Thu", fri: "Fri", sat: "Sat", sun: "Sun" },
window: (n: number) => `Time window ${n}`,
start: (n: number) => `Start of time window ${n}`,
end: (n: number) => `End of time window ${n}`,
remove: "Remove",
removeWindow: (n: number) => `Remove time window ${n}`,
addWindow: "Add time window",
rawHint: "Unrecognised form; example: mon-fri 09:00-18:00",
note: "Local time of the machine the gateway runs on. An end earlier than the start is an overnight window; a whole day is 00:00–24:00.",
},
);
187 changes: 187 additions & 0 deletions src/routing/TimeCondition.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { useState } from "react";
import { PlusIcon } from "lucide-react";
import { Button } from "@/ui/button";
import { Input } from "@/ui/input";
import { useText } from "@/i18n";
import { ToggleChips } from "./fields";
import {
DAY_CODES,
blankTimeWindow,
formatDays,
formatTime,
formatTimeWindow,
parseTime,
parseTimeWindow,
type DayCode,
} from "./timeWindow";
import { timeConditionText } from "./TimeCondition.i18n";
import { timeWindowText } from "./timeWindow.i18n";

/**
* 一个时段在编辑器里的样子。读得懂的值拆成星期和两个时间框;读不懂的(手写进配置的)
* 按原文给一个输入框,照样能改能删。`key` 只给列表用:删掉中间一行,后面几行的输入框不串
*/
type Row =
| { key: string; kind: "picker"; days: boolean[]; start: string; end: string }
| { key: string; kind: "raw"; text: string };

let seq = 0;
function nextKey(): string {
seq += 1;
return `tw${seq}`;
}

function rowOf(value: string): Row {
const w = parseTimeWindow(value);
if (!w) return { key: nextKey(), kind: "raw", text: value };
return { key: nextKey(), kind: "picker", days: w.days, start: formatTime(w.start), end: formatTime(w.end) };
}

function blankRow(): Row {
const w = blankTimeWindow();
return { key: nextKey(), kind: "picker", days: w.days, start: formatTime(w.start), end: formatTime(w.end) };
}

/**
* 一行 → 写进条件的值。合语法的写成规范形;一天都没选、时间写不对的,写成一个**读不回来**
* 的值,让保存按钮旁边说话(`conditionProblem`)—— 不能写成「每天」,那是另一个意思
*/
function valueOf(r: Row): string {
if (r.kind === "raw") return r.text;
const start = parseTime(r.start);
const end = parseTime(r.end);
if (start != null && end != null) {
const v = formatTimeWindow({ days: r.days, start, end });
if (v) return v;
}
return `${formatDays(r.days) || "none"} ${r.start.trim()}-${r.end.trim()}`;
}

/**
* 条件「时段」的编辑器:每个时段一行 —— 七个星期开关(周一在前)、开始、结束;几个时段
* 满足其一即可,和其他多值条件一样。
*
* **行是这里的状态,条件的值从行算出来。**反过来(每次从值解析)的话,时间框里打到一半的
* `9:` 读不回来,整行就会退成原文输入框。打开对话框时从值解析一次,之后只往外写
*/
export function TimeCondition({
values,
onChange,
label,
}: {
values: string[];
onChange: (values: string[]) => void;
/** 读屏读出来的这一组叫什么 */
label: string;
}) {
const t = useText(timeConditionText);
const dt = useText(timeWindowText);
const [rows, setRows] = useState<Row[]>(() => (values.length ? values.map(rowOf) : [blankRow()]));
const update = (next: Row[]) => {
setRows(next);
onChange(next.map(valueOf));
};
const patch = (i: number, p: Partial<Row>) =>
update(rows.map((r, j) => (j === i ? ({ ...r, ...p } as Row) : r)));
const dayOptions = DAY_CODES.map((id) => ({ id, label: t.days[id] }));
return (
<div className="flex w-full flex-col gap-2.5">
{rows.map((r, i) => {
const n = i + 1;
const remove = rows.length > 1 && (
<Button
variant="ghost"
size="xs"
className="shrink-0 text-muted-foreground"
aria-label={t.removeWindow(n)}
onClick={() => update(rows.filter((_, j) => j !== i))}
>
{t.remove}
</Button>
);
if (r.kind === "raw") {
return (
<div key={r.key} className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<Input
aria-label={t.window(n)}
className="font-mono"
value={r.text}
onChange={(e) => patch(i, { text: e.target.value })}
/>
{remove}
</div>
<p className="tw-label text-muted-foreground">{t.rawHint}</p>
</div>
);
}
const start = parseTime(r.start);
const end = parseTime(r.end);
const overnight = start != null && end != null && end < start;
// 两行一个时段:星期一行、时间一行。七个开关加两个时间框在对话框里排不进一行
return (
<div key={r.key} className="flex flex-col items-start gap-1.5">
<ToggleChips
mono={false}
label={`${label} ${n}`}
options={dayOptions}
value={r.days.flatMap((on, k) => (on ? [DAY_CODES[k]!] : []))}
onChange={(u) => {
const on = u(r.days.flatMap((x, k) => (x ? [DAY_CODES[k]!] : [])));
patch(i, { days: DAY_CODES.map((d: DayCode) => on.includes(d)) });
}}
/>
<div className="flex items-center gap-1.5">
<TimeInput
label={t.start(n)}
value={r.start}
onChange={(v) => patch(i, { start: v })}
/>
<span className="text-muted-foreground">–</span>
<TimeInput label={t.end(n)} value={r.end} onChange={(v) => patch(i, { end: v })} />
{overnight && <span className="pl-1 tw-label text-muted-foreground">{dt.nextDay}</span>}
{remove}
</div>
</div>
);
})}
<Button
variant="ghost"
size="sm"
className="-ml-2 self-start text-muted-foreground"
onClick={() => update([...rows, blankRow()])}
>
<PlusIcon />
{t.addWindow}
</Button>
<p className="tw-label text-muted-foreground">{t.note}</p>
</div>
);
}

/** `HH:MM`。离开时写成规范形(`9:00` → `09:00`);写不对的标红,值照样留着 */
function TimeInput({
label,
value,
onChange,
}: {
label: string;
value: string;
onChange: (v: string) => void;
}) {
const minutes = parseTime(value);
return (
<Input
aria-label={label}
aria-invalid={minutes == null || undefined}
className="w-18 text-center font-mono"
inputMode="numeric"
placeholder="09:00"
value={value}
onChange={(e) => onChange(e.target.value)}
onBlur={() => {
if (minutes != null && formatTime(minutes) !== value) onChange(formatTime(minutes));
}}
/>
);
}
3 changes: 3 additions & 0 deletions src/routing/model.i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const modelText = messages(
amountMissing: (name: string) => `填写条件「${name}」的数值`,
amountInvalid: (name: string) => `条件「${name}」的数值有误,示例:200k`,
valueMissing: (name: string) => `填写条件「${name}」的取值`,
timeInvalid: (name: string) => `条件「${name}」须选择至少一天,时间写成 HH:MM,示例:09:00`,
ruleNameMissing: "填写规则名称",
ruleNameTaken: (name: string) => `规则名称「${name}」已存在`,
phaseTwoForward: "含「选定上游」条件的规则不能转发",
Expand Down Expand Up @@ -46,6 +47,8 @@ export const modelText = messages(
amountMissing: (name: string) => `Enter a number for the “${name}” condition`,
amountInvalid: (name: string) => `The number for the “${name}” condition is invalid; example: 200k`,
valueMissing: (name: string) => `Enter a value for the “${name}” condition`,
timeInvalid: (name: string) =>
`The “${name}” condition needs at least one day and times written as HH:MM; example: 09:00`,
ruleNameMissing: "Enter a rule name",
ruleNameTaken: (name: string) => `A rule named “${name}” already exists`,
phaseTwoForward: "Rules with a “Selected upstream” condition cannot forward",
Expand Down
10 changes: 9 additions & 1 deletion src/routing/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ import { protocolLabel } from "@/upstreams/labels";
import { modelText } from "./model.i18n";
import { routingText } from "./routing.i18n";
import { aliasNamed, hasTarget, pinnedOf, pinnedText, targetNameOf } from "./target";
import { blankTimeWindow, formatTimeWindow, parseTimeWindow } from "./timeWindow";

// ---------------------------------------------------------------- 条件

export type CondKind = "glob" | "compare" | "flag" | "one" | "many";
export type CondKind = "glob" | "compare" | "flag" | "one" | "many" | "time";

/** 「添加条件」菜单里的分组。显示的名字见 `condGroupLabel` */
export type CondGroup = "request" | "features" | "source" | "upstream";
Expand All @@ -32,6 +33,7 @@ export const COND_FIELDS: CondField[] = [
{ id: "input_tokens", kind: "compare", group: "request" },
{ id: "max_tokens", kind: "compare", group: "request" },
{ id: "tool_count", kind: "compare", group: "request" },
{ id: "time", kind: "time", group: "request" },
{ id: "cache", kind: "flag", group: "features" },
{ id: "tools", kind: "flag", group: "features" },
{ id: "image", kind: "flag", group: "features" },
Expand Down Expand Up @@ -84,6 +86,8 @@ export function blankCondition(id: ConditionField): ConditionView {
return { field: id, values: [">"] };
case "one":
return { field: id, values: id === "dialect" ? ["anthropic"] : [] };
case "time":
return { field: id, values: [formatTimeWindow(blankTimeWindow())!] };
default:
return { field: id, values: [] };
}
Expand All @@ -102,6 +106,10 @@ export function conditionProblem(c: ConditionView): string | null {
}
case "flag":
return null;
case "time":
if (!values.length) return t.valueMissing(name);
// 对话框里的时段编辑器写出来的一定合语法;写不对的只会是原文编辑的,或者一天都没选的
return values.every((v) => parseTimeWindow(v)) ? null : t.timeInvalid(name);
default:
return values.length ? null : t.valueMissing(name);
}
Expand Down
27 changes: 27 additions & 0 deletions src/routing/timeWindow.i18n.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { messages } from "@/i18n";

export const timeWindowText = messages(
{
days: { mon: "周一", tue: "周二", wed: "周三", thu: "周四", fri: "周五", sat: "周六", sun: "周日" },
/** 连着的几天:`周一至周五` */
dayRange: (from: string, to: string) => `${from}至${to}`,
/** 不连着的几段之间 */
daySep: "、",
/** 同一天里的一段 */
span: (start: string, end: string) => `${start}–${end}`,
/** 结束在次日 */
overnight: (start: string, end: string) => `${start}–次日 ${end}`,
withDays: (days: string, time: string) => `${days} ${time}`,
/** 编辑器里结束时间后面的标记 */
nextDay: "次日",
},
{
days: { mon: "Mon", tue: "Tue", wed: "Wed", thu: "Thu", fri: "Fri", sat: "Sat", sun: "Sun" },
dayRange: (from: string, to: string) => `${from}–${to}`,
daySep: ", ",
span: (start: string, end: string) => `${start}–${end}`,
overnight: (start: string, end: string) => `${start}–${end} next day`,
withDays: (days: string, time: string) => `${days} ${time}`,
nextDay: "next day",
},
);
Loading
Loading