@raingor/pi-web-switch 0.4.3 → 0.5.0
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.
- package/dist/index.html +4 -4
- package/index.html +2 -2
- package/package.json +5 -3
- package/public/manifest.webmanifest +2 -2
- package/public/sw.js +51 -51
- package/server/pi-reader.ts +326 -19
- package/src/App.tsx +2 -0
- package/src/components/dashboard/DashboardPage.tsx +341 -27
- package/src/components/layout/AppShell.tsx +45 -13
- package/src/components/layout/Sidebar.tsx +78 -74
- package/src/components/providers/ProvidersModelsPage.tsx +125 -131
- package/src/components/sessions/MemoryPage.tsx +32 -12
- package/src/components/sessions/SessionsPage.tsx +18 -4
- package/src/components/settings/SettingsPage.tsx +6 -1
- package/src/components/speedtest/ModelSpeedTestPage.tsx +429 -0
- package/src/components/ui/EmptyState.tsx +7 -6
- package/src/components/ui/Modal.tsx +33 -25
- package/src/components/ui/StatCard.tsx +10 -13
- package/src/data/builtin-providers.test.ts +109 -0
- package/src/data/builtin-providers.ts +67 -44
- package/src/data/model-catalog.test.ts +122 -0
- package/src/data/model-catalog.ts +697 -478
- package/src/index.css +624 -210
- package/src/lib/translations/en.ts +88 -9
- package/src/lib/translations/ja.ts +88 -9
- package/src/lib/translations/zh-CN.ts +88 -9
- package/src/lib/translations/zh-TW.ts +87 -9
- package/src/main.tsx +99 -22
- package/vite.config.ts +62 -137
- package/src/data/mock-config.ts +0 -247
- 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
|
|
263
|
-
const selected =
|
|
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 &&
|
|
267
|
-
setSelectedId(customProviders[0]?.id ??
|
|
262
|
+
if (!selected && !adding && customProviders.length > 0) {
|
|
263
|
+
setSelectedId(customProviders[0]?.id ?? null);
|
|
268
264
|
}
|
|
269
|
-
}, [selected, adding,
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
810
|
-
|
|
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
|
|
816
|
-
const
|
|
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:
|
|
831
|
-
maxTokens:
|
|
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="
|
|
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
|
-
|
|
1387
|
-
|
|
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
|
-
</
|
|
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="
|
|
1419
|
+
size="xl"
|
|
1445
1420
|
>
|
|
1446
1421
|
{editModel && (
|
|
1447
1422
|
<ModelForm
|
|
1448
1423
|
initial={editModel}
|
|
1449
1424
|
onSubmit={(form) => {
|
|
1450
|
-
|
|
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="
|
|
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
|
-
|
|
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
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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 ??
|
|
1786
|
-
onChange={(e) =>
|
|
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
|
|
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="
|
|
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="
|
|
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>
|
|
@@ -212,7 +212,7 @@ function EntryCard({
|
|
|
212
212
|
|
|
213
213
|
return (
|
|
214
214
|
<div
|
|
215
|
-
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"
|
|
216
216
|
style={{ backgroundColor: "var(--card-bg)", borderColor: "var(--card-border)" }}
|
|
217
217
|
>
|
|
218
218
|
<p
|
|
@@ -222,7 +222,7 @@ function EntryCard({
|
|
|
222
222
|
{renderInline(entry.text, q)}
|
|
223
223
|
</p>
|
|
224
224
|
{/* Hover actions: copy / delete */}
|
|
225
|
-
<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">
|
|
226
226
|
<button
|
|
227
227
|
onClick={handleCopy}
|
|
228
228
|
className="rounded p-1 transition-colors hover:bg-black/10"
|
|
@@ -268,10 +268,10 @@ function MemoryFileSection({
|
|
|
268
268
|
setOpenOverrides((prev) => ({ ...prev, [key]: !currentOpen }));
|
|
269
269
|
|
|
270
270
|
return (
|
|
271
|
-
<div>
|
|
271
|
+
<div className="memory-file-section">
|
|
272
272
|
{/* File header */}
|
|
273
273
|
<div
|
|
274
|
-
className="flex items-center gap-2 pb-3"
|
|
274
|
+
className="memory-file-header flex items-center gap-2 pb-3"
|
|
275
275
|
style={{ borderBottom: "1px solid var(--card-border)" }}
|
|
276
276
|
>
|
|
277
277
|
<Icon className="h-4 w-4" style={{ color }} />
|
|
@@ -348,7 +348,7 @@ function MemoryFileSection({
|
|
|
348
348
|
|
|
349
349
|
{/* Timeline entries */}
|
|
350
350
|
{open && (
|
|
351
|
-
<div className="space-y-1.5 pl-2">
|
|
351
|
+
<div className="memory-timeline space-y-1.5 pl-2">
|
|
352
352
|
{dateEntries.map((entry, idx) => (
|
|
353
353
|
<EntryCard
|
|
354
354
|
key={`${dateKey}-${idx}`}
|
|
@@ -459,11 +459,14 @@ export function MemoryPage() {
|
|
|
459
459
|
}
|
|
460
460
|
|
|
461
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;
|
|
462
464
|
|
|
463
465
|
return (
|
|
464
|
-
<div className="space-y-6">
|
|
465
|
-
<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">
|
|
466
468
|
<div>
|
|
469
|
+
<div className="page-kicker"><span /> {t("memory.knowledge_synced")}</div>
|
|
467
470
|
<h1 className="text-2xl font-bold" style={{ color: "var(--page-text)" }}>{t("memory.title")}</h1>
|
|
468
471
|
<p className="mt-1 text-sm" style={{ color: "var(--muted-text)" }}>
|
|
469
472
|
{t("memory.summary", String(totalEntries), String(parsed.length))}
|
|
@@ -481,15 +484,29 @@ export function MemoryPage() {
|
|
|
481
484
|
</div>
|
|
482
485
|
|
|
483
486
|
{parsed.length === 0 ? (
|
|
484
|
-
<div className="
|
|
485
|
-
<
|
|
487
|
+
<div className="memory-empty-state">
|
|
488
|
+
<div className="memory-empty-radar"><Brain className="h-7 w-7" /></div>
|
|
486
489
|
<p className="mt-4 text-sm" style={{ color: "var(--muted-text)" }}>{t("memory.no_memory")}</p>
|
|
487
490
|
<p className="text-xs mt-1" style={{ color: "var(--subtle-text)" }}>{t("memory.no_memory_desc")}</p>
|
|
488
491
|
</div>
|
|
489
492
|
) : (
|
|
490
493
|
<>
|
|
491
|
-
|
|
492
|
-
|
|
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">
|
|
493
510
|
<Search
|
|
494
511
|
className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2"
|
|
495
512
|
style={{ color: "var(--subtle-text)" }}
|
|
@@ -506,6 +523,7 @@ export function MemoryPage() {
|
|
|
506
523
|
color: "var(--page-text)",
|
|
507
524
|
}}
|
|
508
525
|
/>
|
|
526
|
+
</div>
|
|
509
527
|
</div>
|
|
510
528
|
|
|
511
529
|
{visible.length === 0 ? (
|
|
@@ -513,8 +531,9 @@ export function MemoryPage() {
|
|
|
513
531
|
{t("memory.no_results")}
|
|
514
532
|
</p>
|
|
515
533
|
) : (
|
|
516
|
-
<div className="
|
|
534
|
+
<div className="memory-sections">
|
|
517
535
|
{visible.map(({ file, entries }) => (
|
|
536
|
+
<div key={file.filename} id={`memory-${file.filename}`} className="memory-file-panel tech-panel">
|
|
518
537
|
<MemoryFileSection
|
|
519
538
|
key={file.filename}
|
|
520
539
|
file={file}
|
|
@@ -522,6 +541,7 @@ export function MemoryPage() {
|
|
|
522
541
|
q={q}
|
|
523
542
|
onDeleteEntry={(entry) => setDeleteTarget({ filename: file.filename, entry })}
|
|
524
543
|
/>
|
|
544
|
+
</div>
|
|
525
545
|
))}
|
|
526
546
|
</div>
|
|
527
547
|
)}
|