@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.
- package/dist/index.html +18 -0
- package/index.html +2 -2
- package/package.json +7 -38
- package/public/apple-touch-icon.png +0 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/manifest.webmanifest +2 -2
- package/public/pi.svg +41 -6
- package/public/sw.js +51 -51
- package/server/pi-reader.ts +353 -237
- package/src/App.tsx +2 -0
- package/src/components/dashboard/DashboardPage.tsx +341 -56
- package/src/components/layout/AppShell.tsx +45 -13
- package/src/components/layout/Sidebar.tsx +78 -85
- package/src/components/providers/ProvidersModelsPage.tsx +125 -131
- package/src/components/sessions/MemoryPage.tsx +34 -13
- package/src/components/sessions/SessionsPage.tsx +20 -40
- 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 -14
- package/src/lib/translations/ja.ts +88 -14
- package/src/lib/translations/zh-CN.ts +88 -14
- package/src/lib/translations/zh-TW.ts +87 -14
- package/src/main.tsx +97 -44
- package/src/types/index.ts +0 -1
- package/vite.config.ts +65 -164
- package/dist-electron/main/main.cjs +0 -2453
- package/src/data/mock-config.ts +0 -247
- package/src/data/mock-usage.ts +0 -151
|
@@ -3,7 +3,7 @@ import { useConfigStore } from "@/store/config-store";
|
|
|
3
3
|
import { useTranslation } from "@/lib/i18n";
|
|
4
4
|
import {
|
|
5
5
|
History, MessageSquare, Clock, ChevronDown, ChevronRight, Trash2, AlertTriangle,
|
|
6
|
-
Shield, RefreshCw, Undo2, Eye, Folder, FolderOpen, FileText, Search,
|
|
6
|
+
Shield, RefreshCw, Undo2, Eye, Folder, FolderOpen, FileText, Search,
|
|
7
7
|
} from "lucide-react";
|
|
8
8
|
import { Modal } from "@/components/ui/Modal";
|
|
9
9
|
|
|
@@ -466,12 +466,13 @@ function findParentProjectPath(node: TreeNode): string {
|
|
|
466
466
|
|
|
467
467
|
export function SessionsPage() {
|
|
468
468
|
const { t } = useTranslation();
|
|
469
|
-
const { initialized
|
|
469
|
+
const { initialized } = useConfigStore();
|
|
470
470
|
const [tab, setTab] = useState<"sessions" | "trash">("sessions");
|
|
471
471
|
const [groups, setGroups] = useState<ProjectGroup[]>([]);
|
|
472
472
|
const [trash, setTrash] = useState<TrashEntry[]>([]);
|
|
473
473
|
const [loading, setLoading] = useState(true);
|
|
474
474
|
const [refreshing, setRefreshing] = useState(false);
|
|
475
|
+
const [autoTrashed, setAutoTrashed] = useState(0);
|
|
475
476
|
const [error, setError] = useState<string | null>(null);
|
|
476
477
|
const [filter, setFilter] = useState("");
|
|
477
478
|
const [deleteTarget, setDeleteTarget] = useState<{ session: SessionInfo; groupPath: string } | null>(null);
|
|
@@ -480,7 +481,6 @@ export function SessionsPage() {
|
|
|
480
481
|
const [selectedTrash, setSelectedTrash] = useState<Set<string>>(new Set());
|
|
481
482
|
const [purgeTarget, setPurgeTarget] = useState<"batch" | TrashEntry | null>(null);
|
|
482
483
|
const [purging, setPurging] = useState(false);
|
|
483
|
-
const [expiring, setExpiring] = useState(false);
|
|
484
484
|
// Preview modal state
|
|
485
485
|
const [previewTarget, setPreviewTarget] = useState<SessionInfo | null>(null);
|
|
486
486
|
const [preview, setPreview] = useState<{ messages: PreviewMessage[]; total: number } | null>(null);
|
|
@@ -491,10 +491,16 @@ export function SessionsPage() {
|
|
|
491
491
|
const loadAll = useCallback(() => {
|
|
492
492
|
if (!initialized) return;
|
|
493
493
|
setRefreshing(true);
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
494
|
+
fetch("/api/pi/sessions/auto-trash", { method: "POST" })
|
|
495
|
+
.then((r) => r.json())
|
|
496
|
+
.catch(() => ({ moved: 0 }))
|
|
497
|
+
.then((cleanup) => {
|
|
498
|
+
setAutoTrashed(Number(cleanup?.moved) || 0);
|
|
499
|
+
return Promise.all([
|
|
500
|
+
fetch("/api/pi/sessions").then((r) => r.json()),
|
|
501
|
+
fetch("/api/pi/trash").then((r) => r.json()),
|
|
502
|
+
]);
|
|
503
|
+
})
|
|
498
504
|
.then(([sessionData, trashData]) => {
|
|
499
505
|
setGroups(sessionData);
|
|
500
506
|
setTrash(trashData);
|
|
@@ -576,27 +582,6 @@ export function SessionsPage() {
|
|
|
576
582
|
loadAll();
|
|
577
583
|
};
|
|
578
584
|
|
|
579
|
-
const handleAutoExpire = async () => {
|
|
580
|
-
setExpiring(true);
|
|
581
|
-
try {
|
|
582
|
-
const res = await fetch("/api/pi/session/auto-expire", { method: "POST" });
|
|
583
|
-
const result = await res.json();
|
|
584
|
-
if (result.success) {
|
|
585
|
-
const count = result.expired?.length ?? 0;
|
|
586
|
-
alert(count > 0
|
|
587
|
-
? t("sessions.auto_expire_success", String(count))
|
|
588
|
-
: t("sessions.auto_expire_none"));
|
|
589
|
-
loadAll();
|
|
590
|
-
} else {
|
|
591
|
-
alert(t("sessions.auto_expire_error", result.error || "unknown"));
|
|
592
|
-
}
|
|
593
|
-
} catch {
|
|
594
|
-
alert(t("sessions.auto_expire_error", "network"));
|
|
595
|
-
} finally {
|
|
596
|
-
setExpiring(false);
|
|
597
|
-
}
|
|
598
|
-
};
|
|
599
|
-
|
|
600
585
|
const openPreview = (session: SessionInfo) => {
|
|
601
586
|
setPreviewTarget(session);
|
|
602
587
|
setPreview(null);
|
|
@@ -723,21 +708,16 @@ function countExpandableNodes(nodes: TreeNode[]): number {
|
|
|
723
708
|
<RefreshCw className={refreshing ? "h-3.5 w-3.5 animate-spin" : "h-3.5 w-3.5"} />
|
|
724
709
|
{t("sessions.refresh")}
|
|
725
710
|
</button>
|
|
726
|
-
{tab === "sessions" && groups.length > 0 && (
|
|
727
|
-
<button
|
|
728
|
-
onClick={handleAutoExpire}
|
|
729
|
-
disabled={expiring}
|
|
730
|
-
className="flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors"
|
|
731
|
-
style={{ borderColor: "var(--card-border)", color: "var(--muted-text)", backgroundColor: "var(--card-bg)", opacity: expiring ? 0.6 : 1 }}
|
|
732
|
-
title={t("sessions.auto_expire_tooltip", String(settings?.sessionExpiryDays ?? 7))}
|
|
733
|
-
>
|
|
734
|
-
<ArchiveX className={expiring ? "h-3.5 w-3.5 animate-spin" : "h-3.5 w-3.5"} />
|
|
735
|
-
{t("sessions.auto_expire")}
|
|
736
|
-
</button>
|
|
737
|
-
)}
|
|
738
711
|
</div>
|
|
739
712
|
</div>
|
|
740
713
|
|
|
714
|
+
{autoTrashed > 0 && (
|
|
715
|
+
<div className="flex items-center gap-2 rounded-lg border px-3 py-2 text-xs" style={{ borderColor: "color-mix(in srgb, var(--signal-cyan) 30%, var(--card-border))", color: "var(--signal-cyan)", backgroundColor: "var(--accent-bg)" }}>
|
|
716
|
+
<Trash2 className="h-3.5 w-3.5" />
|
|
717
|
+
{t("sessions.auto_trashed", String(autoTrashed))}
|
|
718
|
+
</div>
|
|
719
|
+
)}
|
|
720
|
+
|
|
741
721
|
{/* Tabs: Sessions / Trash */}
|
|
742
722
|
<div className="flex items-center gap-1 border-b" style={{ borderColor: "var(--card-border)" }}>
|
|
743
723
|
{([
|
|
@@ -200,7 +200,11 @@ export function SettingsPage() {
|
|
|
200
200
|
|
|
201
201
|
// Apply + persist UI zoom (whole-interface percentage scaling).
|
|
202
202
|
useEffect(() => {
|
|
203
|
-
|
|
203
|
+
// Migrate away from the old browser zoom implementation. Keeping the
|
|
204
|
+
// legacy `zoom` property would compound scaling with the new layout-aware
|
|
205
|
+
// transform and make dense pages appear clipped or unexpectedly tiny.
|
|
206
|
+
document.documentElement.style.zoom = "";
|
|
207
|
+
document.documentElement.style.setProperty("--ui-zoom", String(uiZoom / 100));
|
|
204
208
|
localStorage.setItem(UI_ZOOM_KEY, String(uiZoom));
|
|
205
209
|
}, [uiZoom]);
|
|
206
210
|
|
|
@@ -427,6 +431,7 @@ export function SettingsPage() {
|
|
|
427
431
|
max={200}
|
|
428
432
|
step={5}
|
|
429
433
|
value={uiZoom}
|
|
434
|
+
onInput={(e) => setUiZoom(Number(e.currentTarget.value))}
|
|
430
435
|
onChange={(e) => setUiZoom(Number(e.target.value))}
|
|
431
436
|
className="flex-1 accent-blue-500"
|
|
432
437
|
/>
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
import { useMemo, useState, useEffect } from "react";
|
|
2
|
+
import { Gauge, Loader2, Zap, Check, X, RotateCcw, Download } from "lucide-react";
|
|
3
|
+
import { useTranslation } from "@/lib/i18n";
|
|
4
|
+
import { useConfigStore } from "@/store/config-store";
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
import type { Provider } from "@/types";
|
|
7
|
+
|
|
8
|
+
// Model returned by /api/pi/provider-models. Kept local to this page — these
|
|
9
|
+
// are stored separately from the provider's configured/enabled models.
|
|
10
|
+
interface FetchedModel {
|
|
11
|
+
id: string;
|
|
12
|
+
name?: string;
|
|
13
|
+
contextWindow?: number;
|
|
14
|
+
maxTokens?: number;
|
|
15
|
+
reasoning?: boolean;
|
|
16
|
+
vision?: boolean;
|
|
17
|
+
audio?: boolean;
|
|
18
|
+
cost?: { input: number; output: number; cacheRead?: number; cacheWrite?: number };
|
|
19
|
+
source?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Per-model speed-test result.
|
|
23
|
+
interface ModelResult {
|
|
24
|
+
status: "idle" | "testing" | "done";
|
|
25
|
+
runs: number;
|
|
26
|
+
success: number;
|
|
27
|
+
latencies: number[];
|
|
28
|
+
lastMessage?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const RUNS_PER_MODEL = 3; // sequential calls per model to derive a rate
|
|
32
|
+
// Two speed profiles. Slow mode spaces requests out and retries harder to
|
|
33
|
+
// avoid tripping upstream rate limits (HTTP 429).
|
|
34
|
+
const SPEED_PROFILES = {
|
|
35
|
+
normal: { betweenCalls: 600, betweenModels: 800, maxRetries: 2, backoff: 3000 },
|
|
36
|
+
slow: { betweenCalls: 2000, betweenModels: 4000, maxRetries: 4, backoff: 6000 },
|
|
37
|
+
} as const;
|
|
38
|
+
type SpeedMode = keyof typeof SPEED_PROFILES;
|
|
39
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
40
|
+
// LocalStorage key: the speed-test model catalog is kept entirely separate
|
|
41
|
+
// from the app's configured models (models.json / enabledModels).
|
|
42
|
+
const STORE_KEY = "speedtest:model-catalog";
|
|
43
|
+
|
|
44
|
+
function avg(nums: number[]): number {
|
|
45
|
+
if (nums.length === 0) return 0;
|
|
46
|
+
return Math.round(nums.reduce((a, b) => a + b, 0) / nums.length);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Load/save the fetched-model catalog (map of providerId → FetchedModel[]).
|
|
50
|
+
function loadCatalog(): Record<string, FetchedModel[]> {
|
|
51
|
+
try {
|
|
52
|
+
const raw = localStorage.getItem(STORE_KEY);
|
|
53
|
+
return raw ? JSON.parse(raw) : {};
|
|
54
|
+
} catch {
|
|
55
|
+
return {};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function saveCatalog(catalog: Record<string, FetchedModel[]>) {
|
|
59
|
+
try {
|
|
60
|
+
localStorage.setItem(STORE_KEY, JSON.stringify(catalog));
|
|
61
|
+
} catch {
|
|
62
|
+
/* ignore quota errors */
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function ModelSpeedTestPage() {
|
|
67
|
+
const { t } = useTranslation();
|
|
68
|
+
const { allProviders, auth } = useConfigStore();
|
|
69
|
+
|
|
70
|
+
// Resolve a usable API key for a provider (models.json apiKey or auth.json).
|
|
71
|
+
const keyOf = (p: Provider) => p.apiKey ?? auth?.[p.id]?.key ?? "";
|
|
72
|
+
|
|
73
|
+
// Custom providers only (built-in providers are excluded from speed tests).
|
|
74
|
+
// A provider is listed once it has an endpoint — models come from the
|
|
75
|
+
// locally-stored speed-test catalog, not the configured model list.
|
|
76
|
+
const testableProviders = useMemo(
|
|
77
|
+
() => allProviders.filter((p) => p.type === "custom" && (p.baseUrl ?? "").trim() !== ""),
|
|
78
|
+
[allProviders]
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const [selectedId, setSelectedId] = useState<string | null>(
|
|
82
|
+
testableProviders[0]?.id ?? null
|
|
83
|
+
);
|
|
84
|
+
const selected = testableProviders.find((p) => p.id === selectedId) ?? testableProviders[0] ?? null;
|
|
85
|
+
|
|
86
|
+
// Speed-test model catalog, persisted in localStorage.
|
|
87
|
+
const [catalog, setCatalog] = useState<Record<string, FetchedModel[]>>({});
|
|
88
|
+
useEffect(() => { setCatalog(loadCatalog()); }, []);
|
|
89
|
+
const models = selected ? (catalog[selected.id] ?? []) : [];
|
|
90
|
+
|
|
91
|
+
const [results, setResults] = useState<Map<string, ModelResult>>(new Map());
|
|
92
|
+
const [running, setRunning] = useState(false);
|
|
93
|
+
const [speedMode, setSpeedMode] = useState<SpeedMode>("normal");
|
|
94
|
+
const [fetching, setFetching] = useState(false);
|
|
95
|
+
const [fetchError, setFetchError] = useState<string | null>(null);
|
|
96
|
+
const [fetchInfo, setFetchInfo] = useState<string | null>(null);
|
|
97
|
+
|
|
98
|
+
const setResult = (modelId: string, patch: Partial<ModelResult>) => {
|
|
99
|
+
setResults((prev) => {
|
|
100
|
+
const next = new Map(prev);
|
|
101
|
+
const cur = next.get(modelId) ?? { status: "idle", runs: 0, success: 0, latencies: [] };
|
|
102
|
+
next.set(modelId, { ...cur, ...patch });
|
|
103
|
+
return next;
|
|
104
|
+
});
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// One-click: fetch all models from the provider endpoint, store locally.
|
|
108
|
+
const fetchModels = async () => {
|
|
109
|
+
if (!selected || fetching) return;
|
|
110
|
+
setFetching(true);
|
|
111
|
+
setFetchError(null);
|
|
112
|
+
setFetchInfo(null);
|
|
113
|
+
try {
|
|
114
|
+
const res = await fetch("/api/pi/provider-models", {
|
|
115
|
+
method: "POST",
|
|
116
|
+
headers: { "Content-Type": "application/json" },
|
|
117
|
+
body: JSON.stringify({
|
|
118
|
+
baseUrl: (selected.baseUrl ?? "").trim(),
|
|
119
|
+
apiKey: keyOf(selected),
|
|
120
|
+
providerId: selected.id,
|
|
121
|
+
}),
|
|
122
|
+
});
|
|
123
|
+
const data = await res.json();
|
|
124
|
+
if (data.error) {
|
|
125
|
+
setFetchError(data.error);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const fetched: FetchedModel[] = data.models ?? [];
|
|
129
|
+
const next = { ...loadCatalog(), [selected.id]: fetched };
|
|
130
|
+
saveCatalog(next);
|
|
131
|
+
setCatalog(next);
|
|
132
|
+
setResults(new Map()); // stale results no longer match the new list
|
|
133
|
+
setFetchInfo(t("speed_test.fetched_count", String(fetched.length)));
|
|
134
|
+
} catch {
|
|
135
|
+
setFetchError(t("speed_test.fetch_failed"));
|
|
136
|
+
} finally {
|
|
137
|
+
setFetching(false);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const clearModels = () => {
|
|
142
|
+
if (!selected || fetching || running) return;
|
|
143
|
+
const next = { ...loadCatalog() };
|
|
144
|
+
delete next[selected.id];
|
|
145
|
+
saveCatalog(next);
|
|
146
|
+
setCatalog(next);
|
|
147
|
+
setResults(new Map());
|
|
148
|
+
setFetchInfo(null);
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const testModelOnce = async (provider: Provider, model: FetchedModel) => {
|
|
152
|
+
const res = await fetch("/api/pi/model-test", {
|
|
153
|
+
method: "POST",
|
|
154
|
+
headers: { "Content-Type": "application/json" },
|
|
155
|
+
body: JSON.stringify({
|
|
156
|
+
baseUrl: (provider.baseUrl ?? "").trim(),
|
|
157
|
+
modelId: model.id,
|
|
158
|
+
apiKey: keyOf(provider),
|
|
159
|
+
apiType: provider.api ?? undefined,
|
|
160
|
+
}),
|
|
161
|
+
});
|
|
162
|
+
return res.json() as Promise<{ success: boolean; latencyMs?: number; message?: string; status?: number }>;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// Returns true when a result indicates a rate-limit response.
|
|
166
|
+
const isRateLimited = (r: { status?: number; message?: string }) =>
|
|
167
|
+
r.status === 429 || /\b429\b|rate.?limit|too many/i.test(r.message ?? "");
|
|
168
|
+
|
|
169
|
+
const testOneModel = async (provider: Provider, model: FetchedModel) => {
|
|
170
|
+
const profile = SPEED_PROFILES[speedMode];
|
|
171
|
+
setResults((prev) => {
|
|
172
|
+
const next = new Map(prev);
|
|
173
|
+
next.set(model.id, { status: "testing", runs: 0, success: 0, latencies: [] });
|
|
174
|
+
return next;
|
|
175
|
+
});
|
|
176
|
+
let success = 0;
|
|
177
|
+
const latencies: number[] = [];
|
|
178
|
+
let lastMessage: string | undefined;
|
|
179
|
+
for (let i = 0; i < RUNS_PER_MODEL; i++) {
|
|
180
|
+
if (i > 0) await sleep(profile.betweenCalls);
|
|
181
|
+
let data: { success: boolean; latencyMs?: number; message?: string; status?: number };
|
|
182
|
+
let attempt = 0;
|
|
183
|
+
// Retry with backoff specifically on 429 so bursty limits recover.
|
|
184
|
+
// eslint-disable-next-line no-constant-condition
|
|
185
|
+
while (true) {
|
|
186
|
+
try {
|
|
187
|
+
data = await testModelOnce(provider, model);
|
|
188
|
+
} catch {
|
|
189
|
+
data = { success: false, message: "network error" };
|
|
190
|
+
}
|
|
191
|
+
if (data.success || !isRateLimited(data) || attempt >= profile.maxRetries) break;
|
|
192
|
+
attempt++;
|
|
193
|
+
await sleep(profile.backoff * attempt);
|
|
194
|
+
}
|
|
195
|
+
if (data!.success) {
|
|
196
|
+
success++;
|
|
197
|
+
if (typeof data!.latencyMs === "number") latencies.push(data!.latencyMs);
|
|
198
|
+
} else {
|
|
199
|
+
lastMessage = data!.message;
|
|
200
|
+
}
|
|
201
|
+
setResult(model.id, { status: "testing", runs: i + 1, success, latencies, lastMessage });
|
|
202
|
+
}
|
|
203
|
+
setResult(model.id, { status: "done", runs: RUNS_PER_MODEL, success, latencies, lastMessage });
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const runAll = async () => {
|
|
207
|
+
if (!selected || running || models.length === 0) return;
|
|
208
|
+
const profile = SPEED_PROFILES[speedMode];
|
|
209
|
+
setRunning(true);
|
|
210
|
+
setResults(new Map());
|
|
211
|
+
try {
|
|
212
|
+
for (let i = 0; i < models.length; i++) {
|
|
213
|
+
if (i > 0) await sleep(profile.betweenModels);
|
|
214
|
+
const model = models[i];
|
|
215
|
+
if (model) await testOneModel(selected, model);
|
|
216
|
+
}
|
|
217
|
+
} finally {
|
|
218
|
+
setRunning(false);
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const resetResults = () => {
|
|
223
|
+
if (running) return;
|
|
224
|
+
setResults(new Map());
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
return (
|
|
228
|
+
<div className="space-y-6 providers-page">
|
|
229
|
+
<div className="providers-command-header">
|
|
230
|
+
<div>
|
|
231
|
+
<div className="page-kicker"><span /> MODEL BENCHMARK // LATENCY MATRIX</div>
|
|
232
|
+
<h1 className="text-2xl font-bold" style={{ color: "var(--page-text)" }}>
|
|
233
|
+
{t("speed_test.title")}
|
|
234
|
+
</h1>
|
|
235
|
+
<p className="mt-1 text-sm" style={{ color: "var(--muted-text)" }}>
|
|
236
|
+
{t("speed_test.subtitle")}
|
|
237
|
+
</p>
|
|
238
|
+
</div>
|
|
239
|
+
<div className="providers-header-signal"><span /> {t("speed_test.runs_note", String(RUNS_PER_MODEL))}</div>
|
|
240
|
+
</div>
|
|
241
|
+
|
|
242
|
+
{testableProviders.length === 0 ? (
|
|
243
|
+
<div className="tech-panel flex flex-col items-center gap-2 rounded-xl p-10 text-center">
|
|
244
|
+
<Gauge className="h-8 w-8" style={{ color: "var(--muted-text)" }} />
|
|
245
|
+
<h2 className="text-lg font-semibold" style={{ color: "var(--page-text)" }}>{t("speed_test.no_provider")}</h2>
|
|
246
|
+
<p className="text-sm" style={{ color: "var(--muted-text)" }}>{t("speed_test.no_provider_desc")}</p>
|
|
247
|
+
</div>
|
|
248
|
+
) : (
|
|
249
|
+
<div className="providers-console flex overflow-hidden rounded-xl border border-gray-800 bg-gray-900/50">
|
|
250
|
+
{/* Left: provider picker */}
|
|
251
|
+
<div className="provider-rail w-60 shrink-0 border-r border-gray-800 p-3">
|
|
252
|
+
<p className="px-2 pb-2 pt-1 text-xs font-medium uppercase tracking-wider text-gray-500">
|
|
253
|
+
{t("speed_test.providers")} ({testableProviders.length})
|
|
254
|
+
</p>
|
|
255
|
+
<div className="space-y-0.5">
|
|
256
|
+
{testableProviders.map((p) => {
|
|
257
|
+
const count = (catalog[p.id] ?? []).length;
|
|
258
|
+
return (
|
|
259
|
+
<button
|
|
260
|
+
key={p.id}
|
|
261
|
+
disabled={running || fetching}
|
|
262
|
+
onClick={() => setSelectedId(p.id)}
|
|
263
|
+
className={cn(
|
|
264
|
+
"flex w-full items-center justify-between gap-2 rounded-lg border px-3 py-2 text-left text-sm transition-colors disabled:opacity-50",
|
|
265
|
+
selected?.id === p.id
|
|
266
|
+
? "border-blue-500 bg-blue-500/10 text-white"
|
|
267
|
+
: "border-transparent text-gray-300 hover:bg-gray-800 hover:text-white"
|
|
268
|
+
)}
|
|
269
|
+
>
|
|
270
|
+
<span className="min-w-0 flex-1 truncate">{p.name}</span>
|
|
271
|
+
<span className="shrink-0 rounded border border-gray-700 bg-gray-800 px-1.5 py-0.5 text-[10px] font-mono text-gray-400">
|
|
272
|
+
{count}
|
|
273
|
+
</span>
|
|
274
|
+
</button>
|
|
275
|
+
);
|
|
276
|
+
})}
|
|
277
|
+
</div>
|
|
278
|
+
</div>
|
|
279
|
+
|
|
280
|
+
{/* Right: fetch + speed results */}
|
|
281
|
+
<div className="flex-1 space-y-4 p-4">
|
|
282
|
+
{selected && (
|
|
283
|
+
<>
|
|
284
|
+
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
285
|
+
<div>
|
|
286
|
+
<h2 className="text-lg font-semibold text-white">{selected.name}</h2>
|
|
287
|
+
<p className="text-xs text-gray-500">{models.length} {t("speed_test.models")}</p>
|
|
288
|
+
</div>
|
|
289
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
290
|
+
<label className="flex items-center gap-1.5 rounded-lg border border-gray-700 px-2.5 py-2 text-xs text-gray-300">
|
|
291
|
+
<input
|
|
292
|
+
type="checkbox"
|
|
293
|
+
checked={speedMode === "slow"}
|
|
294
|
+
disabled={running || fetching}
|
|
295
|
+
onChange={(e) => setSpeedMode(e.target.checked ? "slow" : "normal")}
|
|
296
|
+
className="rounded border-gray-600 bg-gray-800 text-blue-500"
|
|
297
|
+
/>
|
|
298
|
+
{t("speed_test.slow_mode")}
|
|
299
|
+
</label>
|
|
300
|
+
<button
|
|
301
|
+
onClick={fetchModels}
|
|
302
|
+
disabled={fetching || running}
|
|
303
|
+
className="flex items-center gap-2 rounded-lg border border-gray-700 px-3 py-2 text-sm text-gray-300 transition-colors hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-50"
|
|
304
|
+
>
|
|
305
|
+
{fetching ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}
|
|
306
|
+
{fetching ? t("speed_test.fetching") : t("speed_test.fetch_models")}
|
|
307
|
+
</button>
|
|
308
|
+
<button
|
|
309
|
+
onClick={resetResults}
|
|
310
|
+
disabled={running || fetching || results.size === 0}
|
|
311
|
+
className="flex items-center gap-1.5 rounded-lg border border-gray-700 px-3 py-2 text-sm text-gray-300 transition-colors hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-50"
|
|
312
|
+
>
|
|
313
|
+
<RotateCcw className="h-4 w-4" />
|
|
314
|
+
{t("speed_test.reset")}
|
|
315
|
+
</button>
|
|
316
|
+
<button
|
|
317
|
+
onClick={runAll}
|
|
318
|
+
disabled={running || fetching || models.length === 0}
|
|
319
|
+
className="flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium text-white transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
|
320
|
+
style={{ backgroundColor: "#3b82f6" }}
|
|
321
|
+
>
|
|
322
|
+
{running ? <Loader2 className="h-4 w-4 animate-spin" /> : <Zap className="h-4 w-4" />}
|
|
323
|
+
{running ? t("speed_test.testing") : t("speed_test.run_all")}
|
|
324
|
+
</button>
|
|
325
|
+
</div>
|
|
326
|
+
</div>
|
|
327
|
+
|
|
328
|
+
{fetchError && (
|
|
329
|
+
<div className="flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-sm text-red-300">
|
|
330
|
+
<X className="mt-0.5 h-4 w-4 shrink-0" />{fetchError}
|
|
331
|
+
</div>
|
|
332
|
+
)}
|
|
333
|
+
{fetchInfo && !fetchError && (
|
|
334
|
+
<div className="flex items-center gap-2 rounded-lg border border-emerald-500/30 bg-emerald-500/10 p-3 text-sm text-emerald-300">
|
|
335
|
+
<Check className="h-4 w-4 shrink-0" />{fetchInfo}
|
|
336
|
+
<button onClick={clearModels} className="ml-auto text-xs text-gray-400 underline hover:text-gray-200">
|
|
337
|
+
{t("speed_test.clear")}
|
|
338
|
+
</button>
|
|
339
|
+
</div>
|
|
340
|
+
)}
|
|
341
|
+
|
|
342
|
+
{models.length === 0 ? (
|
|
343
|
+
<div className="flex flex-col items-center gap-2 rounded-lg border border-dashed border-gray-700 p-10 text-center">
|
|
344
|
+
<Download className="h-7 w-7 text-gray-600" />
|
|
345
|
+
<p className="text-sm text-gray-400">{t("speed_test.empty_catalog")}</p>
|
|
346
|
+
<p className="text-xs text-gray-600">{t("speed_test.empty_catalog_desc")}</p>
|
|
347
|
+
</div>
|
|
348
|
+
) : (
|
|
349
|
+
<div className="overflow-hidden rounded-lg border border-gray-800">
|
|
350
|
+
<table className="w-full text-sm">
|
|
351
|
+
<thead>
|
|
352
|
+
<tr className="border-b border-gray-800 text-left text-xs uppercase tracking-wider text-gray-500">
|
|
353
|
+
<th className="px-3 py-2 font-medium">{t("speed_test.col_model")}</th>
|
|
354
|
+
<th className="px-3 py-2 font-medium text-right">{t("speed_test.col_success_rate")}</th>
|
|
355
|
+
<th className="px-3 py-2 font-medium text-right">{t("speed_test.col_avg_latency")}</th>
|
|
356
|
+
<th className="px-3 py-2 font-medium text-right">{t("speed_test.col_range")}</th>
|
|
357
|
+
<th className="px-3 py-2 font-medium">{t("speed_test.col_status")}</th>
|
|
358
|
+
</tr>
|
|
359
|
+
</thead>
|
|
360
|
+
<tbody>
|
|
361
|
+
{models.map((m) => {
|
|
362
|
+
const r = results.get(m.id);
|
|
363
|
+
const rate = r && r.runs > 0 ? Math.round((r.success / r.runs) * 100) : null;
|
|
364
|
+
const avgMs = r ? avg(r.latencies) : 0;
|
|
365
|
+
const minMs = r && r.latencies.length ? Math.min(...r.latencies) : 0;
|
|
366
|
+
const maxMs = r && r.latencies.length ? Math.max(...r.latencies) : 0;
|
|
367
|
+
return (
|
|
368
|
+
<tr key={m.id} className="border-b border-gray-800/60 last:border-0">
|
|
369
|
+
<td className="px-3 py-2">
|
|
370
|
+
<span className="font-mono text-gray-200">{m.id}</span>
|
|
371
|
+
</td>
|
|
372
|
+
<td className="px-3 py-2 text-right">
|
|
373
|
+
{rate === null ? (
|
|
374
|
+
<span className="text-gray-600">—</span>
|
|
375
|
+
) : (
|
|
376
|
+
<span className={cn(
|
|
377
|
+
"font-mono",
|
|
378
|
+
rate >= 100 ? "text-emerald-400" : rate > 0 ? "text-amber-400" : "text-red-400"
|
|
379
|
+
)}>
|
|
380
|
+
{rate}% ({r!.success}/{r!.runs})
|
|
381
|
+
</span>
|
|
382
|
+
)}
|
|
383
|
+
</td>
|
|
384
|
+
<td className="px-3 py-2 text-right font-mono text-gray-300">
|
|
385
|
+
{avgMs > 0 ? `${avgMs} ms` : <span className="text-gray-600">—</span>}
|
|
386
|
+
</td>
|
|
387
|
+
<td className="px-3 py-2 text-right font-mono text-xs text-gray-500">
|
|
388
|
+
{minMs > 0 ? `${minMs}–${maxMs}` : "—"}
|
|
389
|
+
</td>
|
|
390
|
+
<td className="px-3 py-2">
|
|
391
|
+
{!r || r.status === "idle" ? (
|
|
392
|
+
<span className="text-xs text-gray-600">{t("speed_test.pending")}</span>
|
|
393
|
+
) : r.status === "testing" ? (
|
|
394
|
+
<span className="flex items-center gap-1 text-xs text-gray-400">
|
|
395
|
+
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
396
|
+
{t("speed_test.testing")} {r.runs}/{RUNS_PER_MODEL}
|
|
397
|
+
</span>
|
|
398
|
+
) : r.success === r.runs ? (
|
|
399
|
+
<span className="flex items-center gap-1 text-xs text-emerald-400">
|
|
400
|
+
<Check className="h-3.5 w-3.5" />
|
|
401
|
+
{t("speed_test.ok")}
|
|
402
|
+
</span>
|
|
403
|
+
) : r.success > 0 ? (
|
|
404
|
+
<span className="flex items-center gap-1 text-xs text-amber-400">
|
|
405
|
+
<Check className="h-3.5 w-3.5" />
|
|
406
|
+
{t("speed_test.partial")}
|
|
407
|
+
</span>
|
|
408
|
+
) : (
|
|
409
|
+
<span className="flex items-center gap-1 text-xs text-red-400" title={r.lastMessage}>
|
|
410
|
+
<X className="h-3.5 w-3.5" />
|
|
411
|
+
{r.lastMessage ? r.lastMessage.slice(0, 40) : t("speed_test.fail")}
|
|
412
|
+
</span>
|
|
413
|
+
)}
|
|
414
|
+
</td>
|
|
415
|
+
</tr>
|
|
416
|
+
);
|
|
417
|
+
})}
|
|
418
|
+
</tbody>
|
|
419
|
+
</table>
|
|
420
|
+
</div>
|
|
421
|
+
)}
|
|
422
|
+
</>
|
|
423
|
+
)}
|
|
424
|
+
</div>
|
|
425
|
+
</div>
|
|
426
|
+
)}
|
|
427
|
+
</div>
|
|
428
|
+
);
|
|
429
|
+
}
|
|
@@ -10,11 +10,12 @@ interface EmptyStateProps {
|
|
|
10
10
|
|
|
11
11
|
export function EmptyState({ icon, title, description, action, className }: EmptyStateProps) {
|
|
12
12
|
return (
|
|
13
|
-
<div className={cn("
|
|
14
|
-
<div className="
|
|
15
|
-
<
|
|
16
|
-
|
|
17
|
-
{
|
|
13
|
+
<div className={cn("empty-signal", className)}>
|
|
14
|
+
<div className="empty-signal-radar"><span>{icon}</span></div>
|
|
15
|
+
<p className="empty-signal-code">NO SIGNAL // STANDBY</p>
|
|
16
|
+
<h3>{title}</h3>
|
|
17
|
+
{description && <p>{description}</p>}
|
|
18
|
+
{action && <div className="mt-5">{action}</div>}
|
|
18
19
|
</div>
|
|
19
20
|
);
|
|
20
|
-
}
|
|
21
|
+
}
|
|
@@ -1,41 +1,49 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { X } from "lucide-react";
|
|
2
|
+
import { useEffect, type ReactNode } from "react";
|
|
3
|
+
import { createPortal } from "react-dom";
|
|
2
4
|
|
|
3
5
|
interface ModalProps {
|
|
4
6
|
open: boolean;
|
|
5
7
|
onClose: () => void;
|
|
6
8
|
title: string;
|
|
7
9
|
children: ReactNode;
|
|
8
|
-
size?: "sm" | "md" | "lg";
|
|
10
|
+
size?: "sm" | "md" | "lg" | "xl";
|
|
9
11
|
}
|
|
10
12
|
|
|
11
13
|
export function Modal({ open, onClose, title, children, size = "md" }: ModalProps) {
|
|
14
|
+
// Close on Escape for keyboard accessibility.
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (!open) return;
|
|
17
|
+
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
|
18
|
+
window.addEventListener("keydown", onKey);
|
|
19
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
20
|
+
}, [open, onClose]);
|
|
21
|
+
|
|
12
22
|
if (!open) return null;
|
|
13
23
|
|
|
14
|
-
const sizeClasses = {
|
|
15
|
-
sm: "max-w-md",
|
|
16
|
-
md: "max-w-lg",
|
|
17
|
-
lg: "max-w-2xl",
|
|
18
|
-
};
|
|
24
|
+
const sizeClasses = { sm: "max-w-md", md: "max-w-lg", lg: "max-w-2xl", xl: "max-w-4xl" };
|
|
19
25
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
<
|
|
33
|
-
|
|
34
|
-
|
|
26
|
+
// Render into document.body via a portal. The app shell uses
|
|
27
|
+
// `transform: scale(--ui-zoom)`, which turns it into the containing block
|
|
28
|
+
// for position:fixed descendants — so a modal rendered inside it would be
|
|
29
|
+
// positioned relative to the (tall, scrollable) shell instead of the
|
|
30
|
+
// viewport, pushing the dialog off-screen while only the backdrop shows.
|
|
31
|
+
return createPortal(
|
|
32
|
+
<div className="modal-shell">
|
|
33
|
+
<div className="modal-backdrop" onClick={onClose} />
|
|
34
|
+
<div className={`tech-modal ${sizeClasses[size]}`}>
|
|
35
|
+
<div className="modal-header">
|
|
36
|
+
<div>
|
|
37
|
+
<span className="modal-kicker">COMMAND DIALOG</span>
|
|
38
|
+
<h2>{title}</h2>
|
|
39
|
+
</div>
|
|
40
|
+
<button onClick={onClose} className="modal-close" aria-label="Close dialog">
|
|
41
|
+
<X className="h-4 w-4" />
|
|
35
42
|
</button>
|
|
36
43
|
</div>
|
|
37
|
-
<div className="
|
|
44
|
+
<div className="modal-content">{children}</div>
|
|
38
45
|
</div>
|
|
39
|
-
</div
|
|
46
|
+
</div>,
|
|
47
|
+
document.body
|
|
40
48
|
);
|
|
41
|
-
}
|
|
49
|
+
}
|