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