@raingor/pi-web-switch 0.4.1 → 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 +18 -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/trayIconTemplate.png +0 -0
- package/server/pi-reader.ts +470 -31
- package/src/App.tsx +0 -2
- package/src/components/dashboard/DashboardPage.tsx +45 -13
- package/src/components/layout/Sidebar.tsx +15 -6
- package/src/components/providers/ProvidersModelsPage.tsx +56 -4
- package/src/components/sessions/SessionsPage.tsx +36 -2
- package/src/components/settings/SettingsPage.tsx +72 -0
- package/src/lib/translations/en.ts +20 -58
- package/src/lib/translations/ja.ts +20 -58
- package/src/lib/translations/zh-CN.ts +20 -58
- package/src/lib/translations/zh-TW.ts +20 -58
- package/src/main.tsx +29 -5
- package/src/types/index.ts +2 -0
- package/vite.config.ts +106 -8
- package/server/agent-session-manager.ts +0 -827
- package/server/chat-api-plugin.ts +0 -488
- package/src/components/chat/ChatInput.tsx +0 -863
- package/src/components/chat/ChatPage.tsx +0 -617
- package/src/components/chat/ChatWindow.tsx +0 -338
- package/src/components/chat/MessageView.tsx +0 -595
- package/src/hooks/useAgentSession.ts +0 -1104
|
@@ -67,9 +67,10 @@ interface UsageRangeData {
|
|
|
67
67
|
totalCost: number;
|
|
68
68
|
totalRequests: number;
|
|
69
69
|
}[];
|
|
70
|
+
notice?: "no-config" | "api-error";
|
|
70
71
|
}
|
|
71
72
|
|
|
72
|
-
type SourceKey = "all" | "pi" | "cindy-pi" | "claude" | "codex" | "opencode" | "gemini" | "grok";
|
|
73
|
+
type SourceKey = "all" | "pi" | "cindy-pi" | "claude" | "codex" | "opencode" | "gemini" | "grok" | "atomcode" | "copilot";
|
|
73
74
|
type RangeKey = "today" | "7d" | "30d" | "custom";
|
|
74
75
|
type TabKey = "log" | "provider" | "model";
|
|
75
76
|
type SortDir = "asc" | "desc";
|
|
@@ -116,21 +117,35 @@ function formatCostShort(n: number): string {
|
|
|
116
117
|
}
|
|
117
118
|
|
|
118
119
|
function formatDateShort(dateStr: string): string {
|
|
119
|
-
|
|
120
|
-
|
|
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
|
+
});
|
|
121
129
|
}
|
|
122
130
|
|
|
123
|
-
function
|
|
124
|
-
|
|
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());
|
|
125
139
|
}
|
|
126
140
|
|
|
127
141
|
/** Previous period of equal length, for period-over-period trends. */
|
|
128
142
|
function getPrevRange(range: RangeKey): { from: string; to: string } | null {
|
|
129
|
-
const now = new Date();
|
|
130
143
|
const shift = (days: number) => {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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")}`;
|
|
134
149
|
};
|
|
135
150
|
if (range === "today") return { from: shift(1), to: shift(1) };
|
|
136
151
|
if (range === "7d") return { from: shift(13), to: shift(7) };
|
|
@@ -290,7 +305,7 @@ export function DashboardPage() {
|
|
|
290
305
|
|
|
291
306
|
const customInvalid = range === "custom" && !!customFrom && !!customTo && customFrom > customTo;
|
|
292
307
|
|
|
293
|
-
const fetchData = useCallback(() => {
|
|
308
|
+
const fetchData = useCallback((force = false) => {
|
|
294
309
|
if (!initialized || customInvalid) return;
|
|
295
310
|
let baseUrl = "/api/pi/usage-range";
|
|
296
311
|
if (source === "all") baseUrl = "/api/pi/all-usage-range";
|
|
@@ -300,7 +315,10 @@ export function DashboardPage() {
|
|
|
300
315
|
else if (source === "opencode") baseUrl = "/api/pi/opencode-usage-range";
|
|
301
316
|
else if (source === "gemini") baseUrl = "/api/pi/gemini-usage-range";
|
|
302
317
|
else if (source === "grok") baseUrl = "/api/pi/grok-usage-range";
|
|
303
|
-
|
|
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" : ""}`;
|
|
304
322
|
if (range === "custom" && customFrom) {
|
|
305
323
|
url += `&from=${customFrom}&to=${customTo || customFrom}`;
|
|
306
324
|
}
|
|
@@ -342,7 +360,7 @@ export function DashboardPage() {
|
|
|
342
360
|
return () => clearInterval(id);
|
|
343
361
|
}, [autoRefresh, refreshInterval, fetchData]);
|
|
344
362
|
|
|
345
|
-
const today =
|
|
363
|
+
const today = cnTodayStr();
|
|
346
364
|
|
|
347
365
|
// Chart data: hourly for "today", daily for 7d/30d/custom
|
|
348
366
|
const rawBreakdown = range === "today" ? data?.hourlyBreakdown : data?.dailyBreakdown;
|
|
@@ -408,6 +426,8 @@ export function DashboardPage() {
|
|
|
408
426
|
{ key: "opencode" as SourceKey, label: "dashboard.source_opencode", icon: "🔷" },
|
|
409
427
|
{ key: "gemini" as SourceKey, label: "dashboard.source_gemini", icon: "✨" },
|
|
410
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: "🐙" },
|
|
411
431
|
]).map((s) => (
|
|
412
432
|
<button
|
|
413
433
|
key={s.key}
|
|
@@ -432,6 +452,18 @@ export function DashboardPage() {
|
|
|
432
452
|
{data ? t("dashboard.requests_count", String(data.requestLog.length), formatCost(data.totalCost, currency)) : ""}
|
|
433
453
|
{lastUpdated && <span className="ml-2">· {t("dashboard.last_updated", lastUpdated)}</span>}
|
|
434
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
|
+
)}
|
|
435
467
|
</div>
|
|
436
468
|
<div className="flex items-center gap-2">
|
|
437
469
|
<button
|
|
@@ -444,7 +476,7 @@ export function DashboardPage() {
|
|
|
444
476
|
{currency}
|
|
445
477
|
</button>
|
|
446
478
|
<button
|
|
447
|
-
onClick={() => { fetchData(); }}
|
|
479
|
+
onClick={() => { fetchData(true); }}
|
|
448
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"
|
|
449
481
|
style={{ borderColor: "var(--card-border)", color: "var(--muted-text)", backgroundColor: "var(--card-bg)" }}
|
|
450
482
|
title={t("dashboard.refresh_now")}
|
|
@@ -4,10 +4,9 @@ import {
|
|
|
4
4
|
Settings,
|
|
5
5
|
History,
|
|
6
6
|
Brain,
|
|
7
|
-
Globe,
|
|
8
7
|
Plug,
|
|
9
8
|
Users,
|
|
10
|
-
|
|
9
|
+
Globe,
|
|
11
10
|
} from "lucide-react";
|
|
12
11
|
import { cn } from "@/lib/utils";
|
|
13
12
|
import { useTranslation, LANGUAGES } from "@/lib/i18n";
|
|
@@ -15,7 +14,6 @@ import { useState } from "react";
|
|
|
15
14
|
|
|
16
15
|
const navItems = [
|
|
17
16
|
{ to: "/", icon: LayoutDashboard, key: "nav.dashboard" },
|
|
18
|
-
{ to: "/chat", icon: MessageSquare, key: "nav.chat" },
|
|
19
17
|
{ to: "/sessions", icon: History, key: "nav.sessions" },
|
|
20
18
|
{ to: "/memory", icon: Brain, key: "nav.memory" },
|
|
21
19
|
{ to: "/providers", icon: Plug, key: "nav.providers_models" },
|
|
@@ -35,10 +33,21 @@ export function Sidebar() {
|
|
|
35
33
|
borderColor: "var(--sidebar-border)",
|
|
36
34
|
}}
|
|
37
35
|
>
|
|
38
|
-
{/* 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. */}
|
|
39
40
|
<div
|
|
40
|
-
className="flex items-center gap-3 border-b px-6
|
|
41
|
-
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
|
+
}}
|
|
42
51
|
>
|
|
43
52
|
<img src="/pi.svg" alt="pi-switch" className="h-9 w-9 rounded-lg" />
|
|
44
53
|
<div>
|
|
@@ -115,11 +115,11 @@ interface ParsedImport {
|
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
const IMPORT_LABEL_RE =
|
|
118
|
-
/(?<![\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;
|
|
119
119
|
|
|
120
120
|
function importField(label: string): "name" | "baseUrl" | "apiKey" | "models" {
|
|
121
121
|
const l = label.toLowerCase();
|
|
122
|
-
if (/^(apikey|api_key|api-key|
|
|
122
|
+
if (/^(apikey|api_key|api-key|keys?|token|secret|密钥|金鑰)$/.test(l)) return "apiKey";
|
|
123
123
|
if (/^(baseurl|base_url|base-url|url|endpoint|地址|接口)$/.test(l)) return "baseUrl";
|
|
124
124
|
if (/^(provider|name|名称|名稱|供应商|供應商)$/.test(l)) return "name";
|
|
125
125
|
return "models";
|
|
@@ -658,6 +658,9 @@ function ProviderDetail({ provider, onDelete, onDuplicate, onRenamed }: { provid
|
|
|
658
658
|
const [supportsDeveloperRole, setSupportsDeveloperRole] = useState(
|
|
659
659
|
provider.compat?.supportsDeveloperRole ?? false
|
|
660
660
|
);
|
|
661
|
+
const [supportsFinishReason, setSupportsFinishReason] = useState(
|
|
662
|
+
provider.compat?.supportsFinishReason ?? true
|
|
663
|
+
);
|
|
661
664
|
const [saveState, setSaveState] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
|
662
665
|
|
|
663
666
|
// ─── Quick add (inline, one-liner) ───
|
|
@@ -857,7 +860,7 @@ function ProviderDetail({ provider, onDelete, onDuplicate, onRenamed }: { provid
|
|
|
857
860
|
const urlInvalid = baseUrl.trim() !== "" && !isValidHttpUrl(baseUrl.trim());
|
|
858
861
|
|
|
859
862
|
const dirty =
|
|
860
|
-
(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))) ||
|
|
861
864
|
(!isCustom && (baseUrl !== (provider.baseUrl ?? "") || api !== (provider.api ?? "openai-completions"))) ||
|
|
862
865
|
apiKey !== savedKey;
|
|
863
866
|
|
|
@@ -870,7 +873,7 @@ function ProviderDetail({ provider, onDelete, onDuplicate, onRenamed }: { provid
|
|
|
870
873
|
baseUrl: baseUrl || undefined,
|
|
871
874
|
api,
|
|
872
875
|
apiKey: apiKey || undefined,
|
|
873
|
-
compat: { supportsDeveloperRole },
|
|
876
|
+
compat: { ...provider.compat, supportsDeveloperRole, supportsFinishReason },
|
|
874
877
|
};
|
|
875
878
|
// pi's model picker shows the provider key, not the display name, so
|
|
876
879
|
// rename the key too when the name changes (references get rewritten).
|
|
@@ -1050,6 +1053,21 @@ function ProviderDetail({ provider, onDelete, onDuplicate, onRenamed }: { provid
|
|
|
1050
1053
|
</label>
|
|
1051
1054
|
</div>
|
|
1052
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
|
+
|
|
1053
1071
|
{/* Save / Test / Feedback row */}
|
|
1054
1072
|
<div className="flex flex-wrap items-center gap-3">
|
|
1055
1073
|
{dirty && (
|
|
@@ -1742,6 +1760,23 @@ function ModelForm({ initial, onSubmit, onCancel }: ModelFormProps) {
|
|
|
1742
1760
|
onChange={(e) => setContextWindow(parseInt(e.target.value) || DEFAULT_CONTEXT_WINDOW)}
|
|
1743
1761
|
className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
|
|
1744
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>
|
|
1745
1780
|
</div>
|
|
1746
1781
|
<div>
|
|
1747
1782
|
<label className="block text-xs font-medium text-gray-400">{t("models.max_tokens")}</label>
|
|
@@ -1751,6 +1786,23 @@ function ModelForm({ initial, onSubmit, onCancel }: ModelFormProps) {
|
|
|
1751
1786
|
onChange={(e) => setMaxTokens(parseInt(e.target.value) || DEFAULT_MAX_TOKENS)}
|
|
1752
1787
|
className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
|
|
1753
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>
|
|
1754
1806
|
</div>
|
|
1755
1807
|
</div>
|
|
1756
1808
|
|
|
@@ -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, ArchiveX,
|
|
7
7
|
} from "lucide-react";
|
|
8
8
|
import { Modal } from "@/components/ui/Modal";
|
|
9
9
|
|
|
@@ -466,7 +466,7 @@ function findParentProjectPath(node: TreeNode): string {
|
|
|
466
466
|
|
|
467
467
|
export function SessionsPage() {
|
|
468
468
|
const { t } = useTranslation();
|
|
469
|
-
const { initialized } = useConfigStore();
|
|
469
|
+
const { initialized, settings } = useConfigStore();
|
|
470
470
|
const [tab, setTab] = useState<"sessions" | "trash">("sessions");
|
|
471
471
|
const [groups, setGroups] = useState<ProjectGroup[]>([]);
|
|
472
472
|
const [trash, setTrash] = useState<TrashEntry[]>([]);
|
|
@@ -480,6 +480,7 @@ export function SessionsPage() {
|
|
|
480
480
|
const [selectedTrash, setSelectedTrash] = useState<Set<string>>(new Set());
|
|
481
481
|
const [purgeTarget, setPurgeTarget] = useState<"batch" | TrashEntry | null>(null);
|
|
482
482
|
const [purging, setPurging] = useState(false);
|
|
483
|
+
const [expiring, setExpiring] = useState(false);
|
|
483
484
|
// Preview modal state
|
|
484
485
|
const [previewTarget, setPreviewTarget] = useState<SessionInfo | null>(null);
|
|
485
486
|
const [preview, setPreview] = useState<{ messages: PreviewMessage[]; total: number } | null>(null);
|
|
@@ -575,6 +576,27 @@ export function SessionsPage() {
|
|
|
575
576
|
loadAll();
|
|
576
577
|
};
|
|
577
578
|
|
|
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
|
+
|
|
578
600
|
const openPreview = (session: SessionInfo) => {
|
|
579
601
|
setPreviewTarget(session);
|
|
580
602
|
setPreview(null);
|
|
@@ -701,6 +723,18 @@ function countExpandableNodes(nodes: TreeNode[]): number {
|
|
|
701
723
|
<RefreshCw className={refreshing ? "h-3.5 w-3.5 animate-spin" : "h-3.5 w-3.5"} />
|
|
702
724
|
{t("sessions.refresh")}
|
|
703
725
|
</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
|
+
)}
|
|
704
738
|
</div>
|
|
705
739
|
</div>
|
|
706
740
|
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
CloudDownload,
|
|
28
28
|
RefreshCw,
|
|
29
29
|
ZoomIn,
|
|
30
|
+
KeyRound,
|
|
30
31
|
} from "lucide-react";
|
|
31
32
|
|
|
32
33
|
type SettingsTab = "appearance" | "models" | "advanced";
|
|
@@ -116,6 +117,37 @@ export function SettingsPage() {
|
|
|
116
117
|
const [updateError, setUpdateError] = useState(false);
|
|
117
118
|
const [applying, setApplying] = useState(false);
|
|
118
119
|
const [applyMessage, setApplyMessage] = useState<{ ok: number; failNames: string[] } | null>(null);
|
|
120
|
+
// GitHub Copilot usage API config (username + classic PAT).
|
|
121
|
+
const [copilotCfg, setCopilotCfg] = useState<{ username: string; token: string }>({ username: "", token: "" });
|
|
122
|
+
const [copilotSaving, setCopilotSaving] = useState(false);
|
|
123
|
+
const [copilotMsg, setCopilotMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
|
124
|
+
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
fetch("/api/pi/copilot-config")
|
|
127
|
+
.then((r) => r.json())
|
|
128
|
+
.then((cfg: { username?: string; token?: string }) =>
|
|
129
|
+
setCopilotCfg({ username: cfg.username ?? "", token: cfg.token ?? "" })
|
|
130
|
+
)
|
|
131
|
+
.catch(() => {});
|
|
132
|
+
}, []);
|
|
133
|
+
|
|
134
|
+
const handleSaveCopilot = async () => {
|
|
135
|
+
setCopilotSaving(true);
|
|
136
|
+
setCopilotMsg(null);
|
|
137
|
+
try {
|
|
138
|
+
const res = await fetch("/api/pi/copilot-config", {
|
|
139
|
+
method: "POST",
|
|
140
|
+
headers: { "Content-Type": "application/json" },
|
|
141
|
+
body: JSON.stringify(copilotCfg),
|
|
142
|
+
});
|
|
143
|
+
const { success } = (await res.json()) as { success: boolean };
|
|
144
|
+
setCopilotMsg({ ok: !!success, text: success ? t("settings.copilot_saved") : t("settings.copilot_save_failed") });
|
|
145
|
+
} catch {
|
|
146
|
+
setCopilotMsg({ ok: false, text: t("settings.copilot_save_failed") });
|
|
147
|
+
} finally {
|
|
148
|
+
setCopilotSaving(false);
|
|
149
|
+
}
|
|
150
|
+
};
|
|
119
151
|
|
|
120
152
|
const handleCheckUpdates = async () => {
|
|
121
153
|
setCheckingUpdates(true);
|
|
@@ -563,6 +595,46 @@ export function SettingsPage() {
|
|
|
563
595
|
})()}
|
|
564
596
|
</Card>
|
|
565
597
|
|
|
598
|
+
<Card icon={KeyRound} title={t("settings.copilot_title")} desc={t("settings.copilot_desc")}>
|
|
599
|
+
<div className="max-w-md space-y-3">
|
|
600
|
+
<div>
|
|
601
|
+
<label className="mb-1 block text-xs text-gray-500">{t("settings.copilot_username")}</label>
|
|
602
|
+
<input
|
|
603
|
+
type="text"
|
|
604
|
+
value={copilotCfg.username}
|
|
605
|
+
onChange={(e) => setCopilotCfg({ ...copilotCfg, username: e.target.value })}
|
|
606
|
+
placeholder="octocat"
|
|
607
|
+
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-white placeholder-gray-600"
|
|
608
|
+
/>
|
|
609
|
+
</div>
|
|
610
|
+
<div>
|
|
611
|
+
<label className="mb-1 block text-xs text-gray-500">{t("settings.copilot_token")}</label>
|
|
612
|
+
<input
|
|
613
|
+
type="password"
|
|
614
|
+
value={copilotCfg.token}
|
|
615
|
+
onChange={(e) => setCopilotCfg({ ...copilotCfg, token: e.target.value })}
|
|
616
|
+
placeholder="ghp_…"
|
|
617
|
+
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-white placeholder-gray-600"
|
|
618
|
+
/>
|
|
619
|
+
</div>
|
|
620
|
+
<div className="flex items-center gap-3">
|
|
621
|
+
<button
|
|
622
|
+
onClick={handleSaveCopilot}
|
|
623
|
+
disabled={copilotSaving}
|
|
624
|
+
className="flex items-center gap-1.5 rounded-lg border border-gray-700 bg-gray-800 px-4 py-1.5 text-sm text-gray-300 hover:bg-gray-700 disabled:opacity-60"
|
|
625
|
+
>
|
|
626
|
+
{copilotSaving ? t("settings.copilot_saving") : t("settings.copilot_save")}
|
|
627
|
+
</button>
|
|
628
|
+
{copilotMsg && (
|
|
629
|
+
<span className={cn("text-xs", copilotMsg.ok ? "text-emerald-400" : "text-red-400")}>
|
|
630
|
+
{copilotMsg.text}
|
|
631
|
+
</span>
|
|
632
|
+
)}
|
|
633
|
+
</div>
|
|
634
|
+
<p className="text-[11px] leading-relaxed text-gray-500">{t("settings.copilot_token_hint")}</p>
|
|
635
|
+
</div>
|
|
636
|
+
</Card>
|
|
637
|
+
|
|
566
638
|
<Card icon={Package} title={t("settings.packages")}>
|
|
567
639
|
{(settings?.packages ?? []).length > 0 && (
|
|
568
640
|
<div className="mb-4 flex flex-wrap gap-1.5">
|
|
@@ -64,7 +64,6 @@ const en: Record<string, string> = {
|
|
|
64
64
|
|
|
65
65
|
"nav.sessions": "Sessions",
|
|
66
66
|
"nav.memory": "Memory",
|
|
67
|
-
"nav.chat": "Chat",
|
|
68
67
|
"nav.settings": "Settings",
|
|
69
68
|
"app.subtitle": "Configuration Manager",
|
|
70
69
|
"app.version": "pi-switch v0.1.0",
|
|
@@ -110,6 +109,10 @@ const en: Record<string, string> = {
|
|
|
110
109
|
"dashboard.source_opencode": "OpenCode",
|
|
111
110
|
"dashboard.source_gemini": "Gemini",
|
|
112
111
|
"dashboard.source_grok": "Grok",
|
|
112
|
+
"dashboard.source_atomcode": "AtomCode",
|
|
113
|
+
"dashboard.source_copilot": "Copilot",
|
|
114
|
+
"dashboard.copilot_not_configured": "GitHub Copilot not configured — add username & token in Settings → Advanced",
|
|
115
|
+
"dashboard.copilot_api_error": "Failed to fetch Copilot usage from GitHub API — check token and billing platform",
|
|
113
116
|
"dashboard.requests_count": "{0} requests · {1} total cost",
|
|
114
117
|
"dashboard.range.today": "Today",
|
|
115
118
|
"dashboard.range.7d": "7 Days",
|
|
@@ -200,6 +203,8 @@ const en: Record<string, string> = {
|
|
|
200
203
|
"compat.desc": "Override default behavior for OpenAI-compatible providers that don't support certain features.",
|
|
201
204
|
"compat.supports_developer_role": "Supports developer role",
|
|
202
205
|
"compat.supports_developer_role_desc": "Send system prompt as 'developer' role (for reasoning models). Disable if the API only accepts 'system' role.",
|
|
206
|
+
"compat.supports_finish_reason": "Strict finish_reason check",
|
|
207
|
+
"compat.supports_finish_reason_desc": "Require streamed responses to include finish_reason, otherwise fail. Disable to infer the stop reason automatically when the upstream drops the stream (common with free models).",
|
|
203
208
|
"compat.supports_reasoning_effort": "Supports reasoning_effort",
|
|
204
209
|
"compat.supports_reasoning_effort_desc": "Send reasoning_effort parameter. Disable if the API does not support it.",
|
|
205
210
|
|
|
@@ -237,6 +242,11 @@ const en: Record<string, string> = {
|
|
|
237
242
|
"sessions.delete_error": "Error deleting session",
|
|
238
243
|
"sessions.load_failed": "Failed to load sessions",
|
|
239
244
|
"sessions.refresh": "Refresh",
|
|
245
|
+
"sessions.auto_expire": "Auto-Expire",
|
|
246
|
+
"sessions.auto_expire_tooltip": "Move sessions inactive for {0} days to trash",
|
|
247
|
+
"sessions.auto_expire_success": "Moved {0} expired sessions to trash",
|
|
248
|
+
"sessions.auto_expire_none": "No expired sessions found",
|
|
249
|
+
"sessions.auto_expire_error": "Auto-expire failed: {0}",
|
|
240
250
|
"sessions.expand_all": "Expand All",
|
|
241
251
|
"sessions.collapse_all": "Collapse All",
|
|
242
252
|
"sessions.more_count": "{0} more",
|
|
@@ -302,6 +312,15 @@ const en: Record<string, string> = {
|
|
|
302
312
|
"settings.ui_zoom_desc": "Scale the entire interface by percentage (50%–200%)",
|
|
303
313
|
"settings.ui_zoom_reset": "Reset",
|
|
304
314
|
"settings.enabled_models_desc": "Click a tag to enable or disable a model",
|
|
315
|
+
"settings.copilot_title": "GitHub Copilot",
|
|
316
|
+
"settings.copilot_desc": "Track Copilot usage from the GitHub billing API (premium requests / AI credits)",
|
|
317
|
+
"settings.copilot_username": "GitHub username",
|
|
318
|
+
"settings.copilot_token": "Token (classic PAT)",
|
|
319
|
+
"settings.copilot_save": "Save",
|
|
320
|
+
"settings.copilot_saving": "Saving…",
|
|
321
|
+
"settings.copilot_saved": "Saved — usage will refresh on next dashboard load",
|
|
322
|
+
"settings.copilot_save_failed": "Save failed",
|
|
323
|
+
"settings.copilot_token_hint": "Create a classic personal access token at github.com/settings/tokens (fine-grained tokens are not supported by the billing usage endpoints). Copilot usage only appears for accounts on the enhanced billing platform.",
|
|
305
324
|
"settings.updates_title": "Update Check",
|
|
306
325
|
"settings.updates_desc": "Check npm for new versions of pi core and installed extensions.",
|
|
307
326
|
"settings.check_updates": "Check for Updates",
|
|
@@ -350,63 +369,6 @@ const en: Record<string, string> = {
|
|
|
350
369
|
"loading.error_title": "Failed to Load Configuration",
|
|
351
370
|
"loading.retry": "Retry",
|
|
352
371
|
|
|
353
|
-
// Chat
|
|
354
|
-
"chat.sessions": "Sessions",
|
|
355
|
-
"chat.new_session": "New session",
|
|
356
|
-
"chat.loading": "Loading...",
|
|
357
|
-
"chat.no_sessions": "No sessions yet. Click + to start a new chat.",
|
|
358
|
-
"chat.welcome_title": "Welcome to Pi Chat",
|
|
359
|
-
"chat.welcome_desc": "Select a session from the sidebar, or click + to start a new chat session in a project directory.",
|
|
360
|
-
"chat.select_cwd": "Select Working Directory",
|
|
361
|
-
"chat.cancel": "Cancel",
|
|
362
|
-
"chat.start_chat": "Start Chat",
|
|
363
|
-
"chat.delete_confirm": "Delete this session? This cannot be undone.",
|
|
364
|
-
"chat.delete": "Delete",
|
|
365
|
-
"chat.loading_session": "Loading session...",
|
|
366
|
-
"chat.thinking": "Thinking...",
|
|
367
|
-
"chat.running_tool": "Running tool...",
|
|
368
|
-
"chat.running_command": "Running command...",
|
|
369
|
-
"chat.send_message": "Send a message... (/ for commands, ! for bash)",
|
|
370
|
-
"chat.type_to_steer": "Type to steer...",
|
|
371
|
-
"chat.compacting": "Compacting context...",
|
|
372
|
-
"chat.compaction_failed": "Compaction failed: {0}",
|
|
373
|
-
"chat.retrying": "Retrying ({0}/{1}){2}",
|
|
374
|
-
"chat.steering_queued": "{0} steering, {1} follow-up queued",
|
|
375
|
-
"chat.recall": "Recall",
|
|
376
|
-
"chat.default_tools": "Default tools",
|
|
377
|
-
"chat.all_tools": "All tools",
|
|
378
|
-
"chat.no_tools": "No tools",
|
|
379
|
-
"chat.auto": "Auto",
|
|
380
|
-
"chat.no_model": "No model",
|
|
381
|
-
"chat.no_models_available": "No models available. Check your model configuration.",
|
|
382
|
-
"chat.search_models": "Search models...",
|
|
383
|
-
"chat.no_models_found": "No models found",
|
|
384
|
-
"chat.models_count": "{0} models",
|
|
385
|
-
"chat.models_filtered": "{0} of {1} models",
|
|
386
|
-
"chat.attach_image": "Attach image",
|
|
387
|
-
"chat.tool_preset": "Tool preset",
|
|
388
|
-
"chat.compact_context": "Compact context",
|
|
389
|
-
"chat.stop": "Stop",
|
|
390
|
-
"chat.send": "Send",
|
|
391
|
-
"chat.fork_from_here": "Fork from here",
|
|
392
|
-
"chat.show_less": "Show less",
|
|
393
|
-
"chat.show_more": "Show {0} more characters",
|
|
394
|
-
"chat.tool_call": "tool call",
|
|
395
|
-
"chat.thinking_collapsed": "Thinking (collapsed)",
|
|
396
|
-
"chat.thinking_label": "Thinking",
|
|
397
|
-
"chat.result": "result",
|
|
398
|
-
"chat.error_label": "(error)",
|
|
399
|
-
"chat.exit_code": "exit",
|
|
400
|
-
"chat.msg_count": "{0} msg",
|
|
401
|
-
"chat.untitled": "Untitled",
|
|
402
|
-
"chat.just_now": "just now",
|
|
403
|
-
"chat.min_ago": "{0}m ago",
|
|
404
|
-
"chat.hr_ago": "{0}h ago",
|
|
405
|
-
"chat.day_ago": "{0}d ago",
|
|
406
|
-
"chat.invalid_directory": "Invalid directory",
|
|
407
|
-
"chat.expand_all": "Expand all",
|
|
408
|
-
"chat.collapse_all": "Collapse all",
|
|
409
|
-
|
|
410
372
|
// Language Switcher
|
|
411
373
|
"language.label": "Language",
|
|
412
374
|
"language.en": "English",
|