@raingor/pi-web-switch 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -67,8 +67,10 @@ interface UsageRangeData {
67
67
  totalCost: number;
68
68
  totalRequests: number;
69
69
  }[];
70
+ notice?: "no-config" | "api-error";
70
71
  }
71
72
 
73
+ type SourceKey = "all" | "pi" | "cindy-pi" | "claude" | "codex" | "opencode" | "gemini" | "grok" | "atomcode" | "copilot";
72
74
  type RangeKey = "today" | "7d" | "30d" | "custom";
73
75
  type TabKey = "log" | "provider" | "model";
74
76
  type SortDir = "asc" | "desc";
@@ -115,21 +117,35 @@ function formatCostShort(n: number): string {
115
117
  }
116
118
 
117
119
  function formatDateShort(dateStr: string): string {
118
- const d = new Date(dateStr + "T00:00:00");
119
- return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
120
+ // Parse as a China-time (UTC+8) calendar date and format in that timezone.
121
+ const [y, m, dNum] = dateStr.split("-").map(Number);
122
+ if (!y || !m || !dNum) return dateStr;
123
+ const d = new Date(Date.UTC(y, m - 1, dNum));
124
+ return d.toLocaleDateString("en-US", {
125
+ month: "short",
126
+ day: "numeric",
127
+ timeZone: "Asia/Shanghai",
128
+ });
120
129
  }
121
130
 
122
- function localDateStr(d: Date): string {
123
- return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
131
+ function cnTodayStr(): string {
132
+ // "YYYY-MM-DD" in China time (UTC+8), independent of system timezone.
133
+ return new Intl.DateTimeFormat("en-CA", {
134
+ timeZone: "Asia/Shanghai",
135
+ year: "numeric",
136
+ month: "2-digit",
137
+ day: "2-digit",
138
+ }).format(new Date());
124
139
  }
125
140
 
126
141
  /** Previous period of equal length, for period-over-period trends. */
127
142
  function getPrevRange(range: RangeKey): { from: string; to: string } | null {
128
- const now = new Date();
129
143
  const shift = (days: number) => {
130
- const d = new Date(now);
131
- d.setDate(d.getDate() - days);
132
- return localDateStr(d);
144
+ // Start from China-time "today" and shift by whole days using UTC math.
145
+ const [y, m, dNum] = cnTodayStr().split("-").map(Number);
146
+ const t = Date.UTC(y ?? 0, (m ?? 1) - 1, dNum ?? 1) - days * 86400000;
147
+ const d = new Date(t);
148
+ return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
133
149
  };
134
150
  if (range === "today") return { from: shift(1), to: shift(1) };
135
151
  if (range === "7d") return { from: shift(13), to: shift(7) };
@@ -270,6 +286,7 @@ export function DashboardPage() {
270
286
  const { t, lang } = useTranslation();
271
287
  const { currency, toggle: toggleCurrency } = useCurrency();
272
288
  const { initialized } = useConfigStore();
289
+ const [source, setSource] = useState<SourceKey>("pi");
273
290
  const [range, setRange] = useState<RangeKey>("today");
274
291
  const [customFrom, setCustomFrom] = useState("");
275
292
  const [customTo, setCustomTo] = useState("");
@@ -288,9 +305,20 @@ export function DashboardPage() {
288
305
 
289
306
  const customInvalid = range === "custom" && !!customFrom && !!customTo && customFrom > customTo;
290
307
 
291
- const fetchData = useCallback(() => {
308
+ const fetchData = useCallback((force = false) => {
292
309
  if (!initialized || customInvalid) return;
293
- let url = "/api/pi/usage-range?range=" + range;
310
+ let baseUrl = "/api/pi/usage-range";
311
+ if (source === "all") baseUrl = "/api/pi/all-usage-range";
312
+ else if (source === "cindy-pi") baseUrl = "/api/pi/cindy-usage-range";
313
+ else if (source === "claude") baseUrl = "/api/pi/claude-usage-range";
314
+ else if (source === "codex") baseUrl = "/api/pi/codex-usage-range";
315
+ else if (source === "opencode") baseUrl = "/api/pi/opencode-usage-range";
316
+ else if (source === "gemini") baseUrl = "/api/pi/gemini-usage-range";
317
+ else if (source === "grok") baseUrl = "/api/pi/grok-usage-range";
318
+ else if (source === "atomcode") baseUrl = "/api/pi/atomcode-usage-range";
319
+ else if (source === "copilot") baseUrl = "/api/pi/copilot-usage-range";
320
+ // force=true adds refresh=1 so the API rescan bypasses its 30s session cache
321
+ let url = `${baseUrl}?range=${range}${force ? "&refresh=1" : ""}`;
294
322
  if (range === "custom" && customFrom) {
295
323
  url += `&from=${customFrom}&to=${customTo || customFrom}`;
296
324
  }
@@ -308,19 +336,22 @@ export function DashboardPage() {
308
336
  // Previous period of equal length → period-over-period trend on stat cards
309
337
  const prev = getPrevRange(range);
310
338
  if (prev) {
311
- fetch(`/api/pi/usage-range?range=custom&from=${prev.from}&to=${prev.to}`)
339
+ fetch(`${baseUrl}?range=custom&from=${prev.from}&to=${prev.to}`)
312
340
  .then((r) => r.json())
313
341
  .then((p) => setPrevTotals({ tokens: p.totalTokens ?? 0, cost: p.totalCost ?? 0 }))
314
342
  .catch(() => setPrevTotals(null));
315
343
  } else {
316
344
  setPrevTotals(null);
317
345
  }
318
- }, [initialized, range, customFrom, customTo, customInvalid]);
346
+ }, [initialized, source, range, customFrom, customTo, customInvalid]);
319
347
 
320
348
  useEffect(() => { fetchData(); }, [fetchData]);
321
349
 
322
- // Reset request-log pagination when the queried range changes
323
- useEffect(() => { setLogPage(1); }, [range, customFrom, customTo]);
350
+ // Reset request-log pagination when the queried range or source changes
351
+ useEffect(() => { setLogPage(1); }, [range, customFrom, customTo, source]);
352
+
353
+ // Reset loading state when source changes
354
+ useEffect(() => { setLoading(true); }, [source]);
324
355
 
325
356
  // Auto-refresh with configurable interval (seconds)
326
357
  useEffect(() => {
@@ -329,7 +360,7 @@ export function DashboardPage() {
329
360
  return () => clearInterval(id);
330
361
  }, [autoRefresh, refreshInterval, fetchData]);
331
362
 
332
- const today = new Date().toISOString().split("T")[0];
363
+ const today = cnTodayStr();
333
364
 
334
365
  // Chart data: hourly for "today", daily for 7d/30d/custom
335
366
  const rawBreakdown = range === "today" ? data?.hourlyBreakdown : data?.dailyBreakdown;
@@ -384,6 +415,35 @@ export function DashboardPage() {
384
415
 
385
416
  return (
386
417
  <div className="space-y-5">
418
+ {/* Source Selector: Pi / Cindy-Pi */}
419
+ <div className="flex items-center gap-1 rounded-lg border p-0.5" style={{ borderColor: "var(--card-border)", backgroundColor: "var(--page-bg)" }}>
420
+ {([
421
+ { key: "all" as SourceKey, label: "dashboard.source_all", icon: "📊" },
422
+ { key: "pi" as SourceKey, label: "dashboard.source_pi", icon: "🖥" },
423
+ { key: "cindy-pi" as SourceKey, label: "dashboard.source_cindy_pi", icon: "🤖" },
424
+ { key: "claude" as SourceKey, label: "dashboard.source_claude", icon: "🧠" },
425
+ { key: "codex" as SourceKey, label: "dashboard.source_codex", icon: "⚡" },
426
+ { key: "opencode" as SourceKey, label: "dashboard.source_opencode", icon: "🔷" },
427
+ { key: "gemini" as SourceKey, label: "dashboard.source_gemini", icon: "✨" },
428
+ { key: "grok" as SourceKey, label: "dashboard.source_grok", icon: "🌀" },
429
+ { key: "atomcode" as SourceKey, label: "dashboard.source_atomcode", icon: "⚛️" },
430
+ { key: "copilot" as SourceKey, label: "dashboard.source_copilot", icon: "🐙" },
431
+ ]).map((s) => (
432
+ <button
433
+ key={s.key}
434
+ onClick={() => setSource(s.key)}
435
+ className={cn(
436
+ "rounded-md px-3 py-1.5 text-xs font-medium transition-colors flex items-center gap-1.5",
437
+ source === s.key ? "text-white" : "hover:bg-gray-800/30"
438
+ )}
439
+ style={source === s.key ? { backgroundColor: "#3b82f6", color: "#fff" } : { color: "var(--muted-text)" }}
440
+ >
441
+ <span>{s.icon}</span>
442
+ <span>{t(s.label)}</span>
443
+ </button>
444
+ ))}
445
+ </div>
446
+
387
447
  {/* Title + Time Range Selector + Currency Toggle */}
388
448
  <div className="flex items-center justify-between flex-wrap gap-3">
389
449
  <div>
@@ -392,6 +452,18 @@ export function DashboardPage() {
392
452
  {data ? t("dashboard.requests_count", String(data.requestLog.length), formatCost(data.totalCost, currency)) : ""}
393
453
  {lastUpdated && <span className="ml-2">· {t("dashboard.last_updated", lastUpdated)}</span>}
394
454
  </p>
455
+ {data?.notice && (
456
+ <p
457
+ className="mt-1 inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1 text-xs"
458
+ style={{
459
+ borderColor: data.notice === "no-config" ? "#f59e0b" : "#ef4444",
460
+ color: data.notice === "no-config" ? "#f59e0b" : "#f87171",
461
+ backgroundColor: data.notice === "no-config" ? "#f59e0b11" : "#ef444411",
462
+ }}
463
+ >
464
+ {data.notice === "no-config" ? t("dashboard.copilot_not_configured") : t("dashboard.copilot_api_error")}
465
+ </p>
466
+ )}
395
467
  </div>
396
468
  <div className="flex items-center gap-2">
397
469
  <button
@@ -404,7 +476,7 @@ export function DashboardPage() {
404
476
  {currency}
405
477
  </button>
406
478
  <button
407
- onClick={() => { fetchData(); }}
479
+ onClick={() => { fetchData(true); }}
408
480
  className="flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-gray-800/30"
409
481
  style={{ borderColor: "var(--card-border)", color: "var(--muted-text)", backgroundColor: "var(--card-bg)" }}
410
482
  title={t("dashboard.refresh_now")}
@@ -1,18 +1,25 @@
1
- import { Outlet } from "react-router-dom";
1
+ import { Outlet, useLocation } from "react-router-dom";
2
2
  import { Sidebar } from "./Sidebar";
3
3
 
4
4
  export function AppShell() {
5
+ const location = useLocation();
6
+ const isFullHeightPage = location.pathname.startsWith("/chat");
7
+
5
8
  return (
6
9
  <div className="flex h-screen overflow-hidden">
7
10
  <Sidebar />
8
11
  <main
9
- className="flex-1 overflow-y-auto"
12
+ className={isFullHeightPage ? "flex-1 overflow-hidden" : "flex-1 overflow-y-auto"}
10
13
  style={{ backgroundColor: "var(--page-bg)" }}
11
14
  >
12
- <div className="mx-auto max-w-7xl px-8 py-8">
15
+ {isFullHeightPage ? (
13
16
  <Outlet />
14
- </div>
17
+ ) : (
18
+ <div className="mx-auto max-w-7xl px-8 py-8">
19
+ <Outlet />
20
+ </div>
21
+ )}
15
22
  </main>
16
23
  </div>
17
24
  );
18
- }
25
+ }
@@ -4,9 +4,9 @@ import {
4
4
  Settings,
5
5
  History,
6
6
  Brain,
7
- Globe,
8
7
  Plug,
9
8
  Users,
9
+ Globe,
10
10
  } from "lucide-react";
11
11
  import { cn } from "@/lib/utils";
12
12
  import { useTranslation, LANGUAGES } from "@/lib/i18n";
@@ -33,10 +33,21 @@ export function Sidebar() {
33
33
  borderColor: "var(--sidebar-border)",
34
34
  }}
35
35
  >
36
- {/* Logo */}
36
+ {/* Logo — pt-12 leaves room for the macOS traffic-light buttons in the
37
+ immersive (hiddenInset) title bar so they don't overlap the logo.
38
+ WebkitAppRegion: drag restores window dragging since the native
39
+ title bar is hidden. */}
37
40
  <div
38
- className="flex items-center gap-3 border-b px-6 py-5"
39
- style={{ borderColor: "var(--sidebar-border)" }}
41
+ className="flex items-center gap-3 border-b px-6 pt-12 pb-5"
42
+ style={{
43
+ borderColor: "var(--sidebar-border)",
44
+ userSelect: "none",
45
+ WebkitUserSelect: "none",
46
+ // Electron-only: lets the user drag the frameless (hiddenInset) window
47
+ // from the sidebar header. TypeScript doesn't know the non-standard
48
+ // -webkit-app-region property, so cast it explicitly.
49
+ ...({ WebkitAppRegion: "drag" } as React.CSSProperties),
50
+ }}
40
51
  >
41
52
  <img src="/pi.svg" alt="pi-switch" className="h-9 w-9 rounded-lg" />
42
53
  <div>
@@ -73,19 +73,20 @@ function isValidHttpUrl(value: string): boolean {
73
73
  const DEFAULT_CONTEXT_WINDOW = 262144;
74
74
  const DEFAULT_MAX_TOKENS = 32768;
75
75
 
76
- // Sanitize to a config-safe id: lowercase letters, digits and hyphens only
76
+ // Sanitize to a config-safe id: letters (any script), digits and hyphens.
77
+ // pi shows this key verbatim in its model picker badge, so keep it readable.
77
78
  function sanitizeProviderId(name: string): string {
78
79
  return name
79
80
  .trim()
80
81
  .toLowerCase()
81
82
  .replace(/\s+/g, "-")
82
- .replace(/[^a-z0-9-]/g, "")
83
+ .replace(/[^\p{L}\p{N}-]/gu, "")
83
84
  .replace(/-+/g, "-")
84
85
  .replace(/^-|-$/g, "");
85
86
  }
86
87
 
87
- // Non-ASCII names (e.g. 中文) sanitize to "" — fall back to the endpoint hostname
88
- // so the provider still gets a valid id while keeping the original display name.
88
+ // Empty or symbol-only names fall back to the endpoint hostname so the
89
+ // provider still gets a valid id while keeping the original display name.
89
90
  function deriveProviderId(name: string, baseUrl: string): string {
90
91
  const fromName = sanitizeProviderId(name);
91
92
  if (fromName || !name.trim()) return fromName;
@@ -114,11 +115,11 @@ interface ParsedImport {
114
115
  }
115
116
 
116
117
  const IMPORT_LABEL_RE =
117
- /(?<![\w/.\-])(apikey|api_key|api-key|key|token|secret|密钥|金鑰|baseurl|base_url|base-url|url|endpoint|地址|接口|provider|name|名称|名稱|供应商|供應商|model_ids?|modelids?|models?|模型)\s*[::](?!\/\/)/gi;
118
+ /(?<![\w/.\-])(apikey|api_key|api-key|keys?|token|secret|密钥|金鑰|baseurl|base_url|base-url|url|endpoint|地址|接口|provider|name|名称|名稱|供应商|供應商|model_ids?|modelids?|models?|模型)\s*[::](?!\/\/)/gi;
118
119
 
119
120
  function importField(label: string): "name" | "baseUrl" | "apiKey" | "models" {
120
121
  const l = label.toLowerCase();
121
- if (/^(apikey|api_key|api-key|key|token|secret|密钥|金鑰)$/.test(l)) return "apiKey";
122
+ if (/^(apikey|api_key|api-key|keys?|token|secret|密钥|金鑰)$/.test(l)) return "apiKey";
122
123
  if (/^(baseurl|base_url|base-url|url|endpoint|地址|接口)$/.test(l)) return "baseUrl";
123
124
  if (/^(provider|name|名称|名稱|供应商|供應商)$/.test(l)) return "name";
124
125
  return "models";
@@ -294,15 +295,19 @@ export function ProvidersModelsPage() {
294
295
  // builtin provider's model list so duplicates keep their models.
295
296
  const sourceProvider = allProviders.find((p) => p.id === id);
296
297
  const sourceModels = existing?.models ?? sourceProvider?.models ?? [];
298
+ const sourceName = sourceProvider?.name ?? id;
299
+ // Derive the new key from the display name so pi's model picker badge
300
+ // matches what the user sees in this UI.
301
+ const baseId = sanitizeProviderId(sourceName) || id;
297
302
  const suffix = "-copy";
298
- let newId = sanitizeProviderId(id + suffix);
303
+ let newId = sanitizeProviderId(baseId + suffix);
299
304
  // Ensure uniqueness against existing provider ids
300
305
  const taken = new Set(allProviders.map((p) => p.id));
301
306
  let i = 2;
302
- while (taken.has(newId)) newId = sanitizeProviderId(`${id}-copy${i++}`);
307
+ while (taken.has(newId)) newId = sanitizeProviderId(`${baseId}-copy${i++}`);
303
308
  // Copy config but clear apiKey; carry models + headers + overrides
304
309
  const cfg: CustomProviderConfig = {
305
- name: `${sourceProvider?.name ?? id} (copy)`,
310
+ name: `${sourceName} (copy)`,
306
311
  baseUrl: existing?.baseUrl ?? sourceProvider?.baseUrl,
307
312
  api: existing?.api ?? sourceProvider?.api,
308
313
  headers: existing?.headers,
@@ -430,6 +435,7 @@ export function ProvidersModelsPage() {
430
435
  provider={selected}
431
436
  onDelete={() => setDeleteConfirm(selected.id)}
432
437
  onDuplicate={() => handleDuplicateProvider(selected.id)}
438
+ onRenamed={(newId) => setSelectedId(newId)}
433
439
  />
434
440
  ) : (
435
441
  <div className="flex h-40 items-center justify-center text-sm text-gray-500">
@@ -597,7 +603,7 @@ function TestConnectionButton({ baseUrl, apiKey }: { baseUrl: string; apiKey?: s
597
603
 
598
604
  // ─── Provider Detail Panel ────────────────────────────────
599
605
 
600
- function ProviderDetail({ provider, onDelete, onDuplicate }: { provider: Provider; onDelete: () => void; onDuplicate: () => void }) {
606
+ function ProviderDetail({ provider, onDelete, onDuplicate, onRenamed }: { provider: Provider; onDelete: () => void; onDuplicate: () => void; onRenamed: (newId: string) => void }) {
601
607
  const { t } = useTranslation();
602
608
  const { currency } = useCurrency();
603
609
  const {
@@ -605,6 +611,7 @@ function ProviderDetail({ provider, onDelete, onDuplicate }: { provider: Provide
605
611
  settings,
606
612
  updateSettings,
607
613
  updateCustomProvider,
614
+ renameCustomProvider,
608
615
  setProviderAuth,
609
616
  removeProviderAuth,
610
617
  addModel,
@@ -651,6 +658,9 @@ function ProviderDetail({ provider, onDelete, onDuplicate }: { provider: Provide
651
658
  const [supportsDeveloperRole, setSupportsDeveloperRole] = useState(
652
659
  provider.compat?.supportsDeveloperRole ?? false
653
660
  );
661
+ const [supportsFinishReason, setSupportsFinishReason] = useState(
662
+ provider.compat?.supportsFinishReason ?? true
663
+ );
654
664
  const [saveState, setSaveState] = useState<"idle" | "saving" | "saved" | "error">("idle");
655
665
 
656
666
  // ─── Quick add (inline, one-liner) ───
@@ -850,7 +860,7 @@ function ProviderDetail({ provider, onDelete, onDuplicate }: { provider: Provide
850
860
  const urlInvalid = baseUrl.trim() !== "" && !isValidHttpUrl(baseUrl.trim());
851
861
 
852
862
  const dirty =
853
- (isCustom && (providerName !== (provider.name ?? "") || baseUrl !== (provider.baseUrl ?? "") || api !== (provider.api ?? "openai-completions") || supportsDeveloperRole !== (provider.compat?.supportsDeveloperRole ?? true))) ||
863
+ (isCustom && (providerName !== (provider.name ?? "") || baseUrl !== (provider.baseUrl ?? "") || api !== (provider.api ?? "openai-completions") || supportsDeveloperRole !== (provider.compat?.supportsDeveloperRole ?? true) || supportsFinishReason !== (provider.compat?.supportsFinishReason ?? true))) ||
854
864
  (!isCustom && (baseUrl !== (provider.baseUrl ?? "") || api !== (provider.api ?? "openai-completions"))) ||
855
865
  apiKey !== savedKey;
856
866
 
@@ -858,13 +868,24 @@ function ProviderDetail({ provider, onDelete, onDuplicate }: { provider: Provide
858
868
  setSaveState("saving");
859
869
  let ok = true;
860
870
  if (isCustom) {
861
- ok = await updateCustomProvider(provider.id, {
871
+ const cfgPatch = {
862
872
  name: providerName || undefined,
863
873
  baseUrl: baseUrl || undefined,
864
874
  api,
865
875
  apiKey: apiKey || undefined,
866
- compat: { supportsDeveloperRole },
867
- });
876
+ compat: { ...provider.compat, supportsDeveloperRole, supportsFinishReason },
877
+ };
878
+ // pi's model picker shows the provider key, not the display name, so
879
+ // rename the key too when the name changes (references get rewritten).
880
+ const nameChanged = providerName !== (provider.name ?? "");
881
+ const newId = nameChanged ? sanitizeProviderId(providerName) : "";
882
+ const taken = new Set(useConfigStore.getState().allProviders.map((p) => p.id));
883
+ if (newId && newId !== provider.id && !taken.has(newId)) {
884
+ ok = await renameCustomProvider(provider.id, newId, cfgPatch);
885
+ if (ok) onRenamed(newId);
886
+ } else {
887
+ ok = await updateCustomProvider(provider.id, cfgPatch);
888
+ }
868
889
  } else {
869
890
  // Builtin providers: baseUrl / api are persisted as a models.json
870
891
  // override (so the user can point them at a proxy/gateway), while the
@@ -1032,6 +1053,21 @@ function ProviderDetail({ provider, onDelete, onDuplicate }: { provider: Provide
1032
1053
  </label>
1033
1054
  </div>
1034
1055
 
1056
+ {/* Finish Reason Support */}
1057
+ <div className="flex items-center gap-2">
1058
+ <input
1059
+ id="supports-finish-reason"
1060
+ type="checkbox"
1061
+ checked={supportsFinishReason}
1062
+ onChange={(e) => setSupportsFinishReason(e.target.checked)}
1063
+ className="rounded border-gray-600 bg-gray-800 text-blue-500"
1064
+ />
1065
+ <label htmlFor="supports-finish-reason" className="text-sm text-gray-400">
1066
+ <span>{t("compat.supports_finish_reason")}</span>
1067
+ <span className="ml-2 text-xs text-gray-500">{t("compat.supports_finish_reason_desc")}</span>
1068
+ </label>
1069
+ </div>
1070
+
1035
1071
  {/* Save / Test / Feedback row */}
1036
1072
  <div className="flex flex-wrap items-center gap-3">
1037
1073
  {dirty && (
@@ -1724,6 +1760,23 @@ function ModelForm({ initial, onSubmit, onCancel }: ModelFormProps) {
1724
1760
  onChange={(e) => setContextWindow(parseInt(e.target.value) || DEFAULT_CONTEXT_WINDOW)}
1725
1761
  className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
1726
1762
  />
1763
+ <div className="mt-1 flex flex-wrap gap-1">
1764
+ {[32_768, 128_000, 200_000, 1_000_000].map((v) => (
1765
+ <button
1766
+ key={v}
1767
+ type="button"
1768
+ onClick={() => setContextWindow(v)}
1769
+ className={cn(
1770
+ "rounded border px-1.5 py-0.5 text-[10px] font-mono transition-colors",
1771
+ (form.contextWindow ?? DEFAULT_CONTEXT_WINDOW) === v
1772
+ ? "border-blue-500 bg-blue-500/20 text-blue-300"
1773
+ : "border-gray-700 bg-gray-800 text-gray-400 hover:border-gray-500 hover:text-gray-200"
1774
+ )}
1775
+ >
1776
+ {formatTokens(v)}
1777
+ </button>
1778
+ ))}
1779
+ </div>
1727
1780
  </div>
1728
1781
  <div>
1729
1782
  <label className="block text-xs font-medium text-gray-400">{t("models.max_tokens")}</label>
@@ -1733,6 +1786,23 @@ function ModelForm({ initial, onSubmit, onCancel }: ModelFormProps) {
1733
1786
  onChange={(e) => setMaxTokens(parseInt(e.target.value) || DEFAULT_MAX_TOKENS)}
1734
1787
  className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
1735
1788
  />
1789
+ <div className="mt-1 flex flex-wrap gap-1">
1790
+ {[4096, 8192, 16_384, 32_768, 65_536, 131_072].map((v) => (
1791
+ <button
1792
+ key={v}
1793
+ type="button"
1794
+ onClick={() => setMaxTokens(v)}
1795
+ className={cn(
1796
+ "rounded border px-1.5 py-0.5 text-[10px] font-mono transition-colors",
1797
+ (form.maxTokens ?? DEFAULT_MAX_TOKENS) === v
1798
+ ? "border-blue-500 bg-blue-500/20 text-blue-300"
1799
+ : "border-gray-700 bg-gray-800 text-gray-400 hover:border-gray-500 hover:text-gray-200"
1800
+ )}
1801
+ >
1802
+ {formatTokens(v)}
1803
+ </button>
1804
+ ))}
1805
+ </div>
1736
1806
  </div>
1737
1807
  </div>
1738
1808