@raingor/pi-web-switch 0.4.2 → 0.4.4

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.
Files changed (37) hide show
  1. package/dist/index.html +18 -0
  2. package/index.html +2 -2
  3. package/package.json +7 -38
  4. package/public/apple-touch-icon.png +0 -0
  5. package/public/icon-192.png +0 -0
  6. package/public/icon-512.png +0 -0
  7. package/public/manifest.webmanifest +2 -2
  8. package/public/pi.svg +41 -6
  9. package/public/sw.js +51 -51
  10. package/server/pi-reader.ts +353 -237
  11. package/src/App.tsx +2 -0
  12. package/src/components/dashboard/DashboardPage.tsx +341 -56
  13. package/src/components/layout/AppShell.tsx +45 -13
  14. package/src/components/layout/Sidebar.tsx +78 -85
  15. package/src/components/providers/ProvidersModelsPage.tsx +125 -131
  16. package/src/components/sessions/MemoryPage.tsx +34 -13
  17. package/src/components/sessions/SessionsPage.tsx +20 -40
  18. package/src/components/settings/SettingsPage.tsx +6 -1
  19. package/src/components/speedtest/ModelSpeedTestPage.tsx +429 -0
  20. package/src/components/ui/EmptyState.tsx +7 -6
  21. package/src/components/ui/Modal.tsx +33 -25
  22. package/src/components/ui/StatCard.tsx +10 -13
  23. package/src/data/builtin-providers.test.ts +109 -0
  24. package/src/data/builtin-providers.ts +67 -44
  25. package/src/data/model-catalog.test.ts +122 -0
  26. package/src/data/model-catalog.ts +697 -478
  27. package/src/index.css +624 -210
  28. package/src/lib/translations/en.ts +88 -14
  29. package/src/lib/translations/ja.ts +88 -14
  30. package/src/lib/translations/zh-CN.ts +88 -14
  31. package/src/lib/translations/zh-TW.ts +87 -14
  32. package/src/main.tsx +97 -44
  33. package/src/types/index.ts +0 -1
  34. package/vite.config.ts +65 -164
  35. package/dist-electron/main/main.cjs +0 -2453
  36. package/src/data/mock-config.ts +0 -247
  37. package/src/data/mock-usage.ts +0 -151
@@ -6,7 +6,7 @@ import { Modal } from "@/components/ui/Modal";
6
6
  import { formatTokens, cn, formatCost, USD_TO_CNY } from "@/lib/utils";
7
7
  import { useCurrency } from "@/lib/currency";
8
8
  import type { ApiType, CustomProviderConfig, Model, Provider } from "@/types";
9
- import { searchCatalog, catalogToModel, guessModelMeta } from "@/data/model-catalog";
9
+ import { MODEL_CATALOG, searchCatalog, catalogToModel, catalogEntryId, findCatalogEntry, guessModelMeta } from "@/data/model-catalog";
10
10
  import {
11
11
  Plus,
12
12
  Trash2,
@@ -27,8 +27,6 @@ import {
27
27
  Download,
28
28
  SquareCheck,
29
29
  Copy,
30
- ChevronDown,
31
- ChevronUp,
32
30
  Sparkles,
33
31
  Mic,
34
32
  Wand2,
@@ -73,6 +71,14 @@ function isValidHttpUrl(value: string): boolean {
73
71
  const DEFAULT_CONTEXT_WINDOW = 262144;
74
72
  const DEFAULT_MAX_TOKENS = 32768;
75
73
 
74
+ // Last-resort output cap when neither the catalog nor the id heuristic knows one.
75
+ function fallbackMaxTokens(contextWindow?: number): number {
76
+ if (!contextWindow) return DEFAULT_MAX_TOKENS;
77
+ if (contextWindow >= 1_000_000) return 65_536;
78
+ if (contextWindow >= 200_000) return 32_768;
79
+ return 8192;
80
+ }
81
+
76
82
  // Sanitize to a config-safe id: letters (any script), digits and hyphens.
77
83
  // pi shows this key verbatim in its model picker badge, so keep it readable.
78
84
  function sanitizeProviderId(name: string): string {
@@ -241,32 +247,22 @@ export function ProvidersModelsPage() {
241
247
  const { allProviders, auth, modelsJson, removeCustomProvider } = useConfigStore();
242
248
  const hasKey = (p: Provider) => p.hasAuth || !!p.apiKey || !!auth?.[p.id]?.key;
243
249
 
244
- const builtinProviders = allProviders
245
- .filter((p) => p.type === "builtin")
246
- .sort((a, b) => {
247
- const aKey = hasKey(a) ? 1 : 0;
248
- const bKey = hasKey(b) ? 1 : 0;
249
- if (aKey !== bKey) return bKey - aKey;
250
- return a.name.localeCompare(b.name);
251
- });
252
250
  const customProviders = allProviders.filter((p) => p.type === "custom");
253
251
 
254
252
  const [selectedId, setSelectedId] = useState<string | null>(null);
255
253
  const [adding, setAdding] = useState(false);
256
254
  const [importing, setImporting] = useState(false);
257
255
  const [importBump, setImportBump] = useState(0);
258
- const [builtinExpanded, setBuiltinExpanded] = useState(false);
259
256
  const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
260
257
  const [deleteError, setDeleteError] = useState(false);
261
258
 
262
- // Keep a valid selection (default: first custom, else first builtin)
263
- const selected = allProviders.find((p) => p.id === selectedId) ?? null;
264
- const visibleBuiltinProviders = builtinExpanded ? builtinProviders : builtinProviders.slice(0, 10);
259
+ // Keep a valid custom-provider selection.
260
+ const selected = customProviders.find((p) => p.id === selectedId) ?? null;
265
261
  useEffect(() => {
266
- if (!selected && !adding && allProviders.length > 0) {
267
- setSelectedId(customProviders[0]?.id ?? allProviders[0]?.id ?? null);
262
+ if (!selected && !adding && customProviders.length > 0) {
263
+ setSelectedId(customProviders[0]?.id ?? null);
268
264
  }
269
- }, [selected, adding, allProviders, customProviders]);
265
+ }, [selected, adding, customProviders]);
270
266
 
271
267
  const handleAddProvider = async (id: string, cfg: CustomProviderConfig): Promise<boolean> => {
272
268
  const ok = await useConfigStore.getState().addCustomProvider(id, cfg);
@@ -328,64 +324,26 @@ export function ProvidersModelsPage() {
328
324
  };
329
325
 
330
326
  return (
331
- <div className="space-y-6">
332
- <div>
327
+ <div className="providers-page space-y-6">
328
+ <div className="providers-command-header">
329
+ <div>
330
+ <div className="page-kicker"><span /> ROUTING FABRIC // CONFIGURATION</div>
333
331
  <h1 className="text-2xl font-bold" style={{ color: "var(--page-text)" }}>
334
332
  {t("nav.providers_models")}
335
333
  </h1>
336
334
  <p className="mt-1 text-sm" style={{ color: "var(--muted-text)" }}>
337
335
  {t("providers_models.subtitle")}
338
336
  </p>
337
+ </div>
338
+ <div className="providers-header-signal"><span /> {t("providers_models.config_sync_ready")}</div>
339
339
  </div>
340
340
 
341
341
  <EnabledModelsPanel />
342
342
 
343
- <div className="flex overflow-hidden rounded-xl border border-gray-800 bg-gray-900/50">
343
+ <div className="providers-console flex overflow-hidden rounded-xl border border-gray-800 bg-gray-900/50">
344
344
  {/* ─── Left: Provider List ─────────────────────── */}
345
- <div className="w-60 shrink-0 border-r border-gray-800 p-3">
346
- {builtinProviders.length > 0 && (
347
- <>
348
- <div className="flex items-center justify-between px-2 pb-2 pt-1">
349
- <p className="text-xs font-medium uppercase tracking-wider text-gray-500">
350
- {t("providers.builtin")} ({builtinProviders.length})
351
- </p>
352
- </div>
353
- <div className="space-y-0.5">
354
- {visibleBuiltinProviders.map((p) => (
355
- <ProviderListItem
356
- key={p.id}
357
- provider={p}
358
- active={!adding && selectedId === p.id}
359
- hasKey={hasKey(p)}
360
- onClick={() => {
361
- setAdding(false);
362
- setSelectedId(p.id);
363
- }}
364
- />
365
- ))}
366
- </div>
367
- {builtinProviders.length > 10 && (
368
- <button
369
- onClick={() => setBuiltinExpanded(!builtinExpanded)}
370
- className="mt-1 flex w-full items-center justify-center gap-1 rounded-lg py-1.5 text-xs text-gray-500 transition-colors hover:bg-gray-800/70 hover:text-gray-300"
371
- >
372
- {builtinExpanded ? (
373
- <>
374
- <ChevronUp className="h-3.5 w-3.5" />
375
- {t("providers_models.collapse")}
376
- </>
377
- ) : (
378
- <>
379
- <ChevronDown className="h-3.5 w-3.5" />
380
- {t("providers_models.expand", String(builtinProviders.length - 10))}
381
- </>
382
- )}
383
- </button>
384
- )}
385
- </>
386
- )}
387
-
388
- <p className="px-2 pb-2 pt-4 text-xs font-medium uppercase tracking-wider text-gray-500">
345
+ <div className="provider-rail w-60 shrink-0 border-r border-gray-800 p-3">
346
+ <p className="px-2 pb-2 pt-1 text-xs font-medium uppercase tracking-wider text-gray-500">
389
347
  {t("providers_models.custom_providers")}
390
348
  </p>
391
349
  <div className="space-y-0.5">
@@ -426,7 +384,7 @@ export function ProvidersModelsPage() {
426
384
  </div>
427
385
 
428
386
  {/* ─── Right: Provider Detail / Add Form ───────── */}
429
- <div className="min-w-0 flex-1 p-6">
387
+ <div className="provider-detail min-w-0 flex-1 p-6">
430
388
  {adding ? (
431
389
  <AddProviderForm onSubmit={handleAddProvider} onCancel={() => setAdding(false)} />
432
390
  ) : selected ? (
@@ -519,10 +477,8 @@ function ProviderListItem({
519
477
  <button
520
478
  onClick={onClick}
521
479
  className={cn(
522
- "flex w-full items-center gap-2 rounded-lg border px-3 py-2 text-left text-sm transition-colors",
523
- active
524
- ? "border-gray-600 bg-gray-800 text-white"
525
- : "border-transparent text-gray-300 hover:bg-gray-800/60"
480
+ "provider-list-item flex w-full items-center gap-2 rounded-lg border px-3 py-2 text-left text-sm transition-colors",
481
+ active ? "is-active border-gray-600 bg-gray-800 text-white" : "is-inactive border-transparent text-gray-300 hover:bg-gray-800/60"
526
482
  )}
527
483
  >
528
484
  {provider.type === "custom" ? (
@@ -682,6 +638,8 @@ function ProviderDetail({ provider, onDelete, onDuplicate, onRenamed }: { provid
682
638
 
683
639
  const existingModelIds = new Set(provider.models.map((m) => m.id));
684
640
  const availableModels = fetchedModels.filter((m) => !existingModelIds.has(m.id));
641
+ const [searchQuery, setSearchQuery] = useState("");
642
+ const filteredModels = availableModels.filter((m) => m.id.toLowerCase().includes(searchQuery.toLowerCase()));
685
643
  const isSelected = (id: string) => fetchSelected.has(id);
686
644
  const allSelected = availableModels.length > 0 && availableModels.every((m) => isSelected(m.id));
687
645
 
@@ -794,9 +752,10 @@ function ProviderDetail({ provider, onDelete, onDuplicate, onRenamed }: { provid
794
752
  const id = quickId.trim();
795
753
  if (!id) { setQuickHint(null); return; }
796
754
  const g = guessModelMeta(id);
755
+ const exact = findCatalogEntry(id);
797
756
  setQuickHint(
798
757
  g.source === "catalog" && g.matched
799
- ? t("models.detected_catalog", g.matched)
758
+ ? t("models.detected_catalog_meta", g.matched, formatTokens(exact?.contextWindow ?? g.contextWindow ?? 0), formatTokens(exact?.maxTokens ?? g.maxTokens ?? 0))
800
759
  : g.source === "heuristic"
801
760
  ? t("models.detected_heuristic")
802
761
  : null
@@ -806,29 +765,39 @@ function ProviderDetail({ provider, onDelete, onDuplicate, onRenamed }: { provid
806
765
  const handleQuickAdd = async () => {
807
766
  const id = quickId.trim();
808
767
  if (!id) return;
809
- // Don't duplicate
810
- if (provider.models.some((m) => m.id === id)) {
768
+ const g = guessModelMeta(id);
769
+ const match = findCatalogEntry(id);
770
+ const existing = provider.models.find((model) => model.id === id);
771
+ if (existing) {
772
+ // Re-entering a known model id acts as metadata repair. This fixes
773
+ // models previously added with generic fallback limits without forcing
774
+ // the user to delete and recreate the model or overwriting a custom
775
+ // provider-specific price.
776
+ if (match) {
777
+ updateModel(provider.id, id, {
778
+ name: match.name ?? existing.name,
779
+ reasoning: match.reasoning ?? existing.reasoning,
780
+ input: match.input ?? existing.input,
781
+ contextWindow: match.contextWindow,
782
+ maxTokens: match.maxTokens,
783
+ cost: existing.cost ?? match.cost,
784
+ });
785
+ }
786
+ const list = settings?.enabledModels ?? [];
787
+ await updateSettings({ enabledModels: Array.from(new Set([...list, `${provider.id}/${id}`])) });
811
788
  setQuickId("");
812
789
  setQuickHint(null);
813
790
  return;
814
791
  }
815
- const g = guessModelMeta(id);
816
- const cw = g.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
817
- const mt = g.contextWindow
818
- ? (g.contextWindow >= 1_000_000 ? 65536 : g.contextWindow >= 200_000 ? 32768 : 8192)
819
- : DEFAULT_MAX_TOKENS;
820
- // Pull full cost/name from catalog if available
821
- const entries = searchCatalog(id, 5);
822
- const match = g.source === "catalog"
823
- ? entries.find((e) => e.patterns.some((p) => id.toLowerCase().includes(p.toLowerCase())))
824
- : undefined;
792
+ const cw = match?.contextWindow ?? g.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
793
+ const mt = match?.maxTokens ?? g.maxTokens ?? fallbackMaxTokens(cw);
825
794
  const model: Model = {
826
795
  id,
827
796
  name: match?.name,
828
797
  reasoning: g.reasoning ?? false,
829
798
  input: g.input ?? ["text"],
830
- contextWindow: match?.contextWindow ?? cw,
831
- maxTokens: match?.maxTokens ?? mt,
799
+ contextWindow: cw,
800
+ maxTokens: mt,
832
801
  cost: match?.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
833
802
  enabled: true,
834
803
  };
@@ -917,10 +886,7 @@ function ProviderDetail({ provider, onDelete, onDuplicate, onRenamed }: { provid
917
886
  : provider.models;
918
887
 
919
888
  // Look up the catalog family for a model id (for sorting/grouping)
920
- const familyOf = (id: string): string => {
921
- const hit = searchCatalog(id, 1)[0];
922
- return hit?.family ?? "—";
923
- };
889
+ const familyOf = (id: string): string => findCatalogEntry(id)?.family ?? "—";
924
890
 
925
891
  const visibleModels = useMemo(() => {
926
892
  const arr = [...baseModels];
@@ -1039,7 +1005,7 @@ function ProviderDetail({ provider, onDelete, onDuplicate, onRenamed }: { provid
1039
1005
  </div>
1040
1006
 
1041
1007
  {/* Developer Role Support */}
1042
- <div className="flex items-center gap-2">
1008
+ <div className="provider-compat-row flex items-center gap-2">
1043
1009
  <input
1044
1010
  id="supports-developer-role"
1045
1011
  type="checkbox"
@@ -1047,14 +1013,14 @@ function ProviderDetail({ provider, onDelete, onDuplicate, onRenamed }: { provid
1047
1013
  onChange={(e) => setSupportsDeveloperRole(e.target.checked)}
1048
1014
  className="rounded border-gray-600 bg-gray-800 text-blue-500"
1049
1015
  />
1050
- <label htmlFor="supports-developer-role" className="text-sm text-gray-400">
1016
+ <label htmlFor="supports-developer-role" className="provider-compat-label text-sm text-gray-400">
1051
1017
  <span>{t("compat.supports_developer_role")}</span>
1052
- <span className="ml-2 text-xs text-gray-500">{t("compat.supports_developer_role_desc")}</span>
1018
+ <span className="provider-compat-description ml-2 text-xs text-gray-500">{t("compat.supports_developer_role_desc")}</span>
1053
1019
  </label>
1054
1020
  </div>
1055
1021
 
1056
1022
  {/* Finish Reason Support */}
1057
- <div className="flex items-center gap-2">
1023
+ <div className="provider-compat-row flex items-center gap-2">
1058
1024
  <input
1059
1025
  id="supports-finish-reason"
1060
1026
  type="checkbox"
@@ -1062,9 +1028,9 @@ function ProviderDetail({ provider, onDelete, onDuplicate, onRenamed }: { provid
1062
1028
  onChange={(e) => setSupportsFinishReason(e.target.checked)}
1063
1029
  className="rounded border-gray-600 bg-gray-800 text-blue-500"
1064
1030
  />
1065
- <label htmlFor="supports-finish-reason" className="text-sm text-gray-400">
1031
+ <label htmlFor="supports-finish-reason" className="provider-compat-label text-sm text-gray-400">
1066
1032
  <span>{t("compat.supports_finish_reason")}</span>
1067
- <span className="ml-2 text-xs text-gray-500">{t("compat.supports_finish_reason_desc")}</span>
1033
+ <span className="provider-compat-description ml-2 text-xs text-gray-500">{t("compat.supports_finish_reason_desc")}</span>
1068
1034
  </label>
1069
1035
  </div>
1070
1036
 
@@ -1169,7 +1135,7 @@ function ProviderDetail({ provider, onDelete, onDuplicate, onRenamed }: { provid
1169
1135
  return (
1170
1136
  <div
1171
1137
  key={m.id}
1172
- className="flex items-center gap-2 rounded-lg border border-gray-700 bg-gray-900/70 px-3 py-2.5"
1138
+ className="provider-model-row flex items-center gap-2 rounded-lg border border-gray-700 bg-gray-900/70 px-3 py-2.5"
1173
1139
  >
1174
1140
  <button
1175
1141
  onClick={() => toggleModelEnabled(m.id)}
@@ -1350,7 +1316,7 @@ function ProviderDetail({ provider, onDelete, onDuplicate, onRenamed }: { provid
1350
1316
  open={fetchOpen}
1351
1317
  onClose={() => { setFetchOpen(false); setFetchedModels([]); setFetchSelected(new Set()); setFetchError(null); setFetchImported(null); }}
1352
1318
  title={t("providers_models.fetch_title")}
1353
- size="lg"
1319
+ size="xl"
1354
1320
  >
1355
1321
  <div className="space-y-4">
1356
1322
  <p className="text-sm text-gray-400">{t("providers_models.fetch_desc", provider.name)}</p>
@@ -1383,14 +1349,23 @@ function ProviderDetail({ provider, onDelete, onDuplicate, onRenamed }: { provid
1383
1349
  </button>
1384
1350
  </div>
1385
1351
  <div className="max-h-72 overflow-y-auto space-y-1.5 rounded-lg border border-gray-800 p-3">
1386
- {availableModels.map((m) => (
1387
- <label key={m.id} className="flex items-center gap-3 rounded-lg px-2 py-2 transition-colors hover:bg-gray-800 cursor-pointer">
1352
+ <div className="relative mt-2">
1353
+ <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
1354
+ <input
1355
+ type="text"
1356
+ placeholder="Search models..."
1357
+ value={searchQuery}
1358
+ onChange={(e) => setSearchQuery(e.target.value)}
1359
+ className="w-full rounded-lg border border-gray-700 bg-gray-800 py-2 pl-9 pr-3 text-sm text-white"
1360
+ />
1361
+ </div>
1362
+ {filteredModels.map((m) => (
1363
+ <div key={m.id} className="flex items-center gap-3 rounded-lg px-2 py-2 transition-colors hover:bg-gray-800 cursor-pointer" onClick={() => toggleSelect(m.id)}>
1388
1364
  <div className="flex h-5 w-5 shrink-0 items-center justify-center rounded border"
1389
1365
  style={{ backgroundColor: isSelected(m.id) ? "#3b82f6" : "transparent", borderColor: isSelected(m.id) ? "#3b82f6" : "#4b5563" }}
1390
1366
  >
1391
1367
  {isSelected(m.id) && <SquareCheck className="h-4 w-4 text-white" />}
1392
1368
  </div>
1393
- <input type="checkbox" checked={isSelected(m.id)} onChange={() => toggleSelect(m.id)} className="sr-only" />
1394
1369
  <span className="min-w-0 flex-1 truncate font-mono text-sm text-gray-200">{m.id}</span>
1395
1370
  {m.reasoning && (
1396
1371
  <span className="flex shrink-0 items-center rounded border border-purple-500/40 bg-purple-500/10 px-1.5 py-0.5 text-[10px] text-purple-400">
@@ -1411,7 +1386,7 @@ function ProviderDetail({ provider, onDelete, onDuplicate, onRenamed }: { provid
1411
1386
  {m.cost && (m.cost.input || m.cost.output) ? (
1412
1387
  <span className="rounded border border-gray-700 bg-gray-800 px-1.5 py-0.5 text-[10px] text-gray-500 font-mono">${m.cost.input}/${m.cost.output}</span>
1413
1388
  ) : null}
1414
- </label>
1389
+ </div>
1415
1390
  ))}
1416
1391
  </div>
1417
1392
  </>
@@ -1441,13 +1416,16 @@ function ProviderDetail({ provider, onDelete, onDuplicate, onRenamed }: { provid
1441
1416
  open={!!editModel}
1442
1417
  onClose={() => setEditModel(null)}
1443
1418
  title={`${t("models.edit_model")}: ${editModel?.name || editModel?.id}`}
1444
- size="lg"
1419
+ size="xl"
1445
1420
  >
1446
1421
  {editModel && (
1447
1422
  <ModelForm
1448
1423
  initial={editModel}
1449
1424
  onSubmit={(form) => {
1450
- updateModel(provider.id, editModel.id, form);
1425
+ // Apply default maxTokens if left empty
1426
+ const patched: Partial<Model> = { ...form };
1427
+ if (patched.maxTokens === undefined) patched.maxTokens = DEFAULT_MAX_TOKENS;
1428
+ updateModel(provider.id, editModel.id, patched);
1451
1429
  setEditModel(null);
1452
1430
  }}
1453
1431
  onCancel={() => setEditModel(null)}
@@ -1460,7 +1438,7 @@ function ProviderDetail({ provider, onDelete, onDuplicate, onRenamed }: { provid
1460
1438
  open={showAddModel}
1461
1439
  onClose={() => setShowAddModel(false)}
1462
1440
  title={`${t("models.add_model")} — ${provider.name}`}
1463
- size="lg"
1441
+ size="xl"
1464
1442
  >
1465
1443
  <ModelForm
1466
1444
  onSubmit={(form) => {
@@ -1555,26 +1533,23 @@ function ModelForm({ initial, onSubmit, onCancel }: ModelFormProps) {
1555
1533
  const guess = guessModelMeta(id);
1556
1534
  if (guess.source === "default") { setDetectHint(null); return; }
1557
1535
 
1536
+ // The catalog is authoritative when it has an entry: maxTokens there is the
1537
+ // vendor-documented output cap. Resolving it via findCatalogEntry (best
1538
+ // score) instead of a loose searchCatalog+includes scan avoids a broad
1539
+ // family prefix winning over the exact model.
1540
+ const match = findCatalogEntry(id);
1541
+
1558
1542
  setForm((prev) => {
1559
1543
  const next = { ...prev };
1560
1544
  if (guess.contextWindow && !wasTouched("contextWindow")) next.contextWindow = guess.contextWindow;
1561
- if (!wasTouched("maxTokens")) {
1562
- // Use reasonable maxTokens per context family when detected
1563
- next.maxTokens = guess.contextWindow
1564
- ? (guess.contextWindow >= 1_000_000 ? 65536 : guess.contextWindow >= 200_000 ? 32768 : 8192)
1565
- : DEFAULT_MAX_TOKENS;
1545
+ if (!wasTouched("maxTokens") || next.maxTokens === undefined) {
1546
+ next.maxTokens = match?.maxTokens ?? guess.maxTokens ?? fallbackMaxTokens(guess.contextWindow);
1566
1547
  }
1567
1548
  if (guess.reasoning !== undefined && !wasTouched("reasoning")) next.reasoning = guess.reasoning;
1568
1549
  if (guess.input && !wasTouched("input")) next.input = [...guess.input];
1569
- // Catalog match → also set name + cost
1570
- if (guess.source === "catalog") {
1571
- // Find the matched catalog entry for name + cost
1572
- const entries = searchCatalog(id, 5);
1573
- const match = entries.find((e) => e.patterns.some((p) => id.toLowerCase().includes(p.toLowerCase())));
1574
- if (match) {
1575
- if (!wasTouched("name")) next.name = match.name;
1576
- if (match.cost && !wasTouched("cost")) next.cost = { ...match.cost };
1577
- }
1550
+ if (match) {
1551
+ if (!wasTouched("name")) next.name = match.name;
1552
+ if (match.cost && !wasTouched("cost")) next.cost = { ...match.cost };
1578
1553
  }
1579
1554
  return next;
1580
1555
  });
@@ -1589,8 +1564,11 @@ function ModelForm({ initial, onSubmit, onCancel }: ModelFormProps) {
1589
1564
  // ── Catalog picker (add-mode only) ──
1590
1565
  const [pickerOpen, setPickerOpen] = useState(false);
1591
1566
  const [pickerQuery, setPickerQuery] = useState("");
1567
+ // No limit: an empty query must list the whole catalog, otherwise families
1568
+ // ordered late in the array (GLM, Kimi, Doubao, Llama, Grok) are unreachable
1569
+ // without typing.
1592
1570
  const pickerResults = useMemo(
1593
- () => searchCatalog(pickerQuery, 30),
1571
+ () => searchCatalog(pickerQuery, MODEL_CATALOG.length),
1594
1572
  [pickerQuery]
1595
1573
  );
1596
1574
  const applyPreset = (entry: ReturnType<typeof searchCatalog>[number]) => {
@@ -1599,7 +1577,7 @@ function ModelForm({ initial, onSubmit, onCancel }: ModelFormProps) {
1599
1577
  touchedRef.current = new Set();
1600
1578
  setForm((prev) => ({ ...prev, ...preset }));
1601
1579
  setPickerOpen(false);
1602
- setDetectHint(t("models.detected_catalog", entry.name ?? entry.patterns[0] ?? ""));
1580
+ setDetectHint(t("models.detected_catalog", entry.name ?? catalogEntryId(entry)));
1603
1581
  };
1604
1582
 
1605
1583
  // Apply a quick template (Claude-style, GPT-style, Reasoning, Local small)
@@ -1622,7 +1600,7 @@ function ModelForm({ initial, onSubmit, onCancel }: ModelFormProps) {
1622
1600
  const setId = (v: string) => { touch("id"); setForm((p) => ({ ...p, id: v })); };
1623
1601
  const setName = (v: string) => { touch("name"); setForm((p) => ({ ...p, name: v })); };
1624
1602
  const setContextWindow = (v: number) => { touch("contextWindow"); setForm((p) => ({ ...p, contextWindow: v })); };
1625
- const setMaxTokens = (v: number) => { touch("maxTokens"); setForm((p) => ({ ...p, maxTokens: v })); };
1603
+ const setMaxTokens = (v: number | undefined) => { touch("maxTokens"); setForm((p) => ({ ...p, maxTokens: v })); };
1626
1604
  const setReasoning = (v: boolean) => { touch("reasoning"); setForm((p) => ({ ...p, reasoning: v })); };
1627
1605
  const setImage = (v: boolean) => {
1628
1606
  touch("input");
@@ -1674,7 +1652,18 @@ function ModelForm({ initial, onSubmit, onCancel }: ModelFormProps) {
1674
1652
  key={kind}
1675
1653
  type="button"
1676
1654
  onClick={() => applyTemplate(kind)}
1677
- className="rounded-md border border-gray-700 bg-gray-800/70 px-2 py-1 text-[11px] text-gray-400 hover:bg-gray-700 hover:text-gray-200"
1655
+ className="rounded-md border px-2 py-1 text-[11px] transition-colors"
1656
+ style={{
1657
+ borderColor: "var(--card-border)",
1658
+ backgroundColor: "var(--card-bg-solid)",
1659
+ color: "var(--muted-text)",
1660
+ }}
1661
+ onMouseEnter={(e) => {
1662
+ (e.currentTarget as HTMLButtonElement).style.color = "var(--page-text)";
1663
+ }}
1664
+ onMouseLeave={(e) => {
1665
+ (e.currentTarget as HTMLButtonElement).style.color = "var(--muted-text)";
1666
+ }}
1678
1667
  >
1679
1668
  {t(key)}
1680
1669
  </button>
@@ -1701,14 +1690,14 @@ function ModelForm({ initial, onSubmit, onCancel }: ModelFormProps) {
1701
1690
  )}
1702
1691
  {pickerResults.map((e) => (
1703
1692
  <button
1704
- key={e.patterns[0]}
1693
+ key={catalogEntryId(e)}
1705
1694
  type="button"
1706
1695
  onClick={() => applyPreset(e)}
1707
1696
  className="flex w-full items-center gap-3 border-b border-gray-800 px-3 py-2 text-left hover:bg-gray-800 last:border-0"
1708
1697
  >
1709
1698
  <span className="min-w-0 flex-1">
1710
1699
  <span className="block truncate text-sm text-gray-200">{e.name}</span>
1711
- <span className="block truncate font-mono text-[11px] text-gray-500">{e.patterns[0]}</span>
1700
+ <span className="block truncate font-mono text-[11px] text-gray-500">{catalogEntryId(e)}</span>
1712
1701
  </span>
1713
1702
  {e.reasoning && <Brain className="h-3.5 w-3.5 shrink-0 text-purple-400" aria-label="reasoning" />}
1714
1703
  {e.input?.includes("image") && <ImageIcon className="h-3.5 w-3.5 shrink-0 text-blue-400" aria-label="vision" />}
@@ -1761,7 +1750,7 @@ function ModelForm({ initial, onSubmit, onCancel }: ModelFormProps) {
1761
1750
  className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
1762
1751
  />
1763
1752
  <div className="mt-1 flex flex-wrap gap-1">
1764
- {[32_768, 128_000, 200_000, 1_000_000].map((v) => (
1753
+ {[32_768, 65_536, 131_072, 262_144, 524_288, 1_000_000].map((v) => (
1765
1754
  <button
1766
1755
  key={v}
1767
1756
  type="button"
@@ -1782,12 +1771,17 @@ function ModelForm({ initial, onSubmit, onCancel }: ModelFormProps) {
1782
1771
  <label className="block text-xs font-medium text-gray-400">{t("models.max_tokens")}</label>
1783
1772
  <input
1784
1773
  type="number"
1785
- value={form.maxTokens ?? DEFAULT_MAX_TOKENS}
1786
- onChange={(e) => setMaxTokens(parseInt(e.target.value) || DEFAULT_MAX_TOKENS)}
1774
+ value={form.maxTokens ?? ""}
1775
+ onChange={(e) => {
1776
+ const v = e.target.value;
1777
+ setMaxTokens(v === "" ? undefined : parseInt(v) || undefined);
1778
+ }}
1779
+ placeholder={String(DEFAULT_MAX_TOKENS)}
1787
1780
  className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
1788
1781
  />
1782
+ <p className="mt-0.5 text-[10px] text-gray-500">{t("models.max_tokens_hint", String(DEFAULT_MAX_TOKENS))}</p>
1789
1783
  <div className="mt-1 flex flex-wrap gap-1">
1790
- {[4096, 8192, 16_384, 32_768, 65_536, 131_072].map((v) => (
1784
+ {[4096, 8192, 16_384, 32_768, 65_536].map((v) => (
1791
1785
  <button
1792
1786
  key={v}
1793
1787
  type="button"
@@ -2011,7 +2005,7 @@ function AddProviderForm({
2011
2005
  {models.map((m) => (
2012
2006
  <div
2013
2007
  key={m.id}
2014
- className="flex items-center gap-2 rounded-lg border border-gray-700 bg-gray-900/70 px-3 py-2.5"
2008
+ className="provider-model-row flex items-center gap-2 rounded-lg border border-gray-700 bg-gray-900/70 px-3 py-2.5"
2015
2009
  >
2016
2010
  <Box className="h-4 w-4 shrink-0 text-gray-500" />
2017
2011
  <span className="min-w-0 flex-1 truncate font-mono text-sm text-gray-200">
@@ -2069,7 +2063,7 @@ function AddProviderForm({
2069
2063
  open={showAddModel}
2070
2064
  onClose={() => setShowAddModel(false)}
2071
2065
  title={t("models.add_model")}
2072
- size="lg"
2066
+ size="xl"
2073
2067
  >
2074
2068
  <ModelForm
2075
2069
  onSubmit={(form) => {
@@ -2284,7 +2278,7 @@ function ImportProviderModal({
2284
2278
  open={open}
2285
2279
  onClose={() => { reset(); onClose(); }}
2286
2280
  title={t("providers_models.import_title")}
2287
- size="lg"
2281
+ size="xl"
2288
2282
  >
2289
2283
  <div className="space-y-4">
2290
2284
  <p className="text-sm text-gray-500">{t("providers_models.import_desc")}</p>
@@ -60,8 +60,9 @@ function parseMemoryEntries(content: string): MemoryEntry[] {
60
60
  for (const section of sections) {
61
61
  const trimmed = section.trim();
62
62
  // Match `<!-- created=DATE, last=DATE -->` at the end
63
+ // Tolerate extra fields after `last=` (e.g. `, project64=...`) before `-->`
63
64
  const markerMatch = trimmed.match(
64
- /<!--\s*created\s*=\s*([^,\s]+)\s*,\s*last\s*=\s*([^>\s]+)\s*-->\s*$/
65
+ /<!--\s*created\s*=\s*([^,\s>]+)\s*,\s*last\s*=\s*([^,\s>]+)[^>]*-->\s*$/
65
66
  );
66
67
  if (markerMatch) {
67
68
  entries.push({
@@ -211,7 +212,7 @@ function EntryCard({
211
212
 
212
213
  return (
213
214
  <div
214
- className="group relative rounded-lg border px-3.5 py-2.5"
215
+ className="memory-entry-card group relative rounded-lg border px-3.5 py-2.5"
215
216
  style={{ backgroundColor: "var(--card-bg)", borderColor: "var(--card-border)" }}
216
217
  >
217
218
  <p
@@ -221,7 +222,7 @@ function EntryCard({
221
222
  {renderInline(entry.text, q)}
222
223
  </p>
223
224
  {/* Hover actions: copy / delete */}
224
- <div className="absolute right-2 top-2 flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
225
+ <div className="memory-entry-actions absolute right-2 top-2 flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
225
226
  <button
226
227
  onClick={handleCopy}
227
228
  className="rounded p-1 transition-colors hover:bg-black/10"
@@ -267,10 +268,10 @@ function MemoryFileSection({
267
268
  setOpenOverrides((prev) => ({ ...prev, [key]: !currentOpen }));
268
269
 
269
270
  return (
270
- <div>
271
+ <div className="memory-file-section">
271
272
  {/* File header */}
272
273
  <div
273
- className="flex items-center gap-2 pb-3"
274
+ className="memory-file-header flex items-center gap-2 pb-3"
274
275
  style={{ borderBottom: "1px solid var(--card-border)" }}
275
276
  >
276
277
  <Icon className="h-4 w-4" style={{ color }} />
@@ -347,7 +348,7 @@ function MemoryFileSection({
347
348
 
348
349
  {/* Timeline entries */}
349
350
  {open && (
350
- <div className="space-y-1.5 pl-2">
351
+ <div className="memory-timeline space-y-1.5 pl-2">
351
352
  {dateEntries.map((entry, idx) => (
352
353
  <EntryCard
353
354
  key={`${dateKey}-${idx}`}
@@ -458,11 +459,14 @@ export function MemoryPage() {
458
459
  }
459
460
 
460
461
  const totalEntries = parsed.reduce((s, p) => s + p.entries.length, 0);
462
+ const totalChars = parsed.reduce((sum, item) => sum + item.file.content.length, 0);
463
+ const latestFile = [...parsed].sort((a, b) => (b.file.updatedAt || "").localeCompare(a.file.updatedAt || ""))[0]?.file;
461
464
 
462
465
  return (
463
- <div className="space-y-6">
464
- <div className="flex items-start justify-between">
466
+ <div className="memory-page space-y-6">
467
+ <div className="memory-command-header flex items-start justify-between">
465
468
  <div>
469
+ <div className="page-kicker"><span /> {t("memory.knowledge_synced")}</div>
466
470
  <h1 className="text-2xl font-bold" style={{ color: "var(--page-text)" }}>{t("memory.title")}</h1>
467
471
  <p className="mt-1 text-sm" style={{ color: "var(--muted-text)" }}>
468
472
  {t("memory.summary", String(totalEntries), String(parsed.length))}
@@ -480,15 +484,29 @@ export function MemoryPage() {
480
484
  </div>
481
485
 
482
486
  {parsed.length === 0 ? (
483
- <div className="flex flex-col items-center justify-center py-12">
484
- <Brain className="h-12 w-12" style={{ color: "var(--subtle-text)" }} />
487
+ <div className="memory-empty-state">
488
+ <div className="memory-empty-radar"><Brain className="h-7 w-7" /></div>
485
489
  <p className="mt-4 text-sm" style={{ color: "var(--muted-text)" }}>{t("memory.no_memory")}</p>
486
490
  <p className="text-xs mt-1" style={{ color: "var(--subtle-text)" }}>{t("memory.no_memory_desc")}</p>
487
491
  </div>
488
492
  ) : (
489
493
  <>
490
- {/* Search */}
491
- <div className="relative max-w-md">
494
+ <div className="memory-readout-grid">
495
+ <div className="tech-panel memory-readout-card"><span>{t("memory.files_indexed")}</span><strong>{parsed.length}</strong><small>{t("memory.summary", String(totalEntries), String(parsed.length))}</small></div>
496
+ <div className="tech-panel memory-readout-card"><span>{t("memory.entry_count")}</span><strong>{totalEntries}</strong><small>{formatDate(latestFile?.updatedAt || "")}</small></div>
497
+ <div className="tech-panel memory-readout-card"><span>{t("memory.text_volume")}</span><strong>{totalChars > 1000 ? `${(totalChars / 1000).toFixed(1)}K` : totalChars}</strong><small>{t("memory.characters_indexed")}</small></div>
498
+ </div>
499
+
500
+ <div className="memory-toolbar tech-panel">
501
+ <div className="memory-file-jump">
502
+ <span className="memory-toolbar-label">{t("memory.channels")}</span>
503
+ {parsed.map(({ file, entries }) => {
504
+ const Icon = FILE_ICONS[file.filename] || FileText;
505
+ return <a key={file.filename} href={`#memory-${file.filename}`} className="memory-file-chip"><Icon className="h-3.5 w-3.5" />{file.filename}<b>{entries.length}</b></a>;
506
+ })}
507
+ </div>
508
+ {/* Search */}
509
+ <div className="relative memory-search">
492
510
  <Search
493
511
  className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2"
494
512
  style={{ color: "var(--subtle-text)" }}
@@ -505,6 +523,7 @@ export function MemoryPage() {
505
523
  color: "var(--page-text)",
506
524
  }}
507
525
  />
526
+ </div>
508
527
  </div>
509
528
 
510
529
  {visible.length === 0 ? (
@@ -512,8 +531,9 @@ export function MemoryPage() {
512
531
  {t("memory.no_results")}
513
532
  </p>
514
533
  ) : (
515
- <div className="space-y-8">
534
+ <div className="memory-sections">
516
535
  {visible.map(({ file, entries }) => (
536
+ <div key={file.filename} id={`memory-${file.filename}`} className="memory-file-panel tech-panel">
517
537
  <MemoryFileSection
518
538
  key={file.filename}
519
539
  file={file}
@@ -521,6 +541,7 @@ export function MemoryPage() {
521
541
  q={q}
522
542
  onDeleteEntry={(entry) => setDeleteTarget({ filename: file.filename, entry })}
523
543
  />
544
+ </div>
524
545
  ))}
525
546
  </div>
526
547
  )}