@raingor/pi-web-switch 0.4.1 → 0.4.3
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/index.html +18 -0
- package/package.json +5 -25
- package/pi-package/index.ts +228 -4
- package/public/trayIconTemplate.png +0 -0
- package/server/pi-reader.ts +251 -3
- package/src/App.tsx +0 -2
- package/src/components/dashboard/DashboardPage.tsx +43 -40
- package/src/components/layout/Sidebar.tsx +1 -3
- package/src/components/providers/ProvidersModelsPage.tsx +56 -4
- package/src/components/sessions/MemoryPage.tsx +2 -1
- package/src/components/settings/SettingsPage.tsx +72 -0
- package/src/lib/translations/en.ts +15 -58
- package/src/lib/translations/ja.ts +15 -58
- package/src/lib/translations/zh-CN.ts +15 -58
- package/src/lib/translations/zh-TW.ts +15 -58
- package/src/types/index.ts +1 -0
- package/vite.config.ts +89 -15
- 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
package/server/pi-reader.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { readFileSync, readdirSync, existsSync, statSync, unlinkSync, writeFileS
|
|
|
2
2
|
import { homedir, platform } from "os";
|
|
3
3
|
import { join, resolve, dirname, relative, sep } from "path";
|
|
4
4
|
import { spawnSync } from "child_process";
|
|
5
|
+
import { DatabaseSync } from "node:sqlite";
|
|
5
6
|
|
|
6
7
|
const PI_DIR = join(homedir(), ".pi", "agent");
|
|
7
8
|
|
|
@@ -124,6 +125,29 @@ function getSessionDirs(): string[] {
|
|
|
124
125
|
.filter((dir) => statSync(dir).isDirectory());
|
|
125
126
|
}
|
|
126
127
|
|
|
128
|
+
// Usage stats are bucketed in China time (UTC+8) regardless of the machine's
|
|
129
|
+
// system timezone, so daily totals stay consistent for a Beijing-based user.
|
|
130
|
+
const CN_TZ = "Asia/Shanghai";
|
|
131
|
+
|
|
132
|
+
function cnDateParts(ts: string | number): { date: string; hour: number } {
|
|
133
|
+
const d = new Date(ts);
|
|
134
|
+
if (isNaN(d.getTime())) return { date: "unknown", hour: 0 };
|
|
135
|
+
const date = new Intl.DateTimeFormat("en-CA", {
|
|
136
|
+
timeZone: CN_TZ,
|
|
137
|
+
year: "numeric",
|
|
138
|
+
month: "2-digit",
|
|
139
|
+
day: "2-digit",
|
|
140
|
+
}).format(d); // "2026-08-06"
|
|
141
|
+
const hour = Number(
|
|
142
|
+
new Intl.DateTimeFormat("en-US", {
|
|
143
|
+
timeZone: CN_TZ,
|
|
144
|
+
hour: "2-digit",
|
|
145
|
+
hour12: false,
|
|
146
|
+
}).format(d)
|
|
147
|
+
);
|
|
148
|
+
return { date, hour: hour === 24 ? 0 : hour };
|
|
149
|
+
}
|
|
150
|
+
|
|
127
151
|
function parseSessionFile(filePath: string): UsageRecord[] {
|
|
128
152
|
const records: UsageRecord[] = [];
|
|
129
153
|
try {
|
|
@@ -149,9 +173,7 @@ function parseSessionFile(filePath: string): UsageRecord[] {
|
|
|
149
173
|
if (!usage || !usage.input) continue;
|
|
150
174
|
|
|
151
175
|
const timestamp = obj.timestamp || obj.message.timestamp;
|
|
152
|
-
const
|
|
153
|
-
const date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
154
|
-
const hour = d.getHours();
|
|
176
|
+
const { date, hour } = cnDateParts(timestamp);
|
|
155
177
|
|
|
156
178
|
records.push({
|
|
157
179
|
date,
|
|
@@ -176,7 +198,20 @@ function parseSessionFile(filePath: string): UsageRecord[] {
|
|
|
176
198
|
return records;
|
|
177
199
|
}
|
|
178
200
|
|
|
201
|
+
// Scanned session usage is cached briefly: the same 150MB+ of JSONL gets
|
|
202
|
+
// re-read on every dashboard request otherwise, which stalls the page.
|
|
203
|
+
const USAGE_CACHE_TTL_MS = 30_000;
|
|
204
|
+
let usageCache: { records: UsageRecord[]; at: number } | null = null;
|
|
205
|
+
|
|
206
|
+
/** Drop cached session usage — called when the UI requests a forced refresh. */
|
|
207
|
+
export function clearUsageCache(): void {
|
|
208
|
+
usageCache = null;
|
|
209
|
+
}
|
|
210
|
+
|
|
179
211
|
export function readAllUsage(): UsageRecord[] {
|
|
212
|
+
if (usageCache && Date.now() - usageCache.at < USAGE_CACHE_TTL_MS) {
|
|
213
|
+
return usageCache.records;
|
|
214
|
+
}
|
|
180
215
|
const allRecords: UsageRecord[] = [];
|
|
181
216
|
const dirs = getSessionDirs();
|
|
182
217
|
|
|
@@ -195,6 +230,7 @@ export function readAllUsage(): UsageRecord[] {
|
|
|
195
230
|
|
|
196
231
|
// Sort by date ascending
|
|
197
232
|
allRecords.sort((a, b) => a.date.localeCompare(b.date));
|
|
233
|
+
usageCache = { records: allRecords, at: Date.now() };
|
|
198
234
|
return allRecords;
|
|
199
235
|
}
|
|
200
236
|
|
|
@@ -380,11 +416,211 @@ export function readAllCombinedUsage(): UsageRecord[] {
|
|
|
380
416
|
...readCindyUsage(),
|
|
381
417
|
...readClaudeUsage(),
|
|
382
418
|
...readCodexUsage(),
|
|
419
|
+
...readAtomcodeUsage(),
|
|
420
|
+
...readCopilotUsage(),
|
|
383
421
|
];
|
|
384
422
|
all.sort((a, b) => a.date.localeCompare(b.date));
|
|
385
423
|
return all;
|
|
386
424
|
}
|
|
387
425
|
|
|
426
|
+
// ─── AtomCode Usage ────────────────────────────────────
|
|
427
|
+
|
|
428
|
+
const ATOMCODE_DIR = join(homedir(), ".atomcode");
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Read usage records from AtomCode sessions under ~/.atomcode/sessions.
|
|
432
|
+
* Each JSONL line carries an optional usage object:
|
|
433
|
+
* { prompt, completion, cached } — mapped to input/output/cacheRead.
|
|
434
|
+
* Model id is sniffed from the snapshot system prompt (e.g.
|
|
435
|
+
* "running the deepseek-v4-flash model"), defaulting to "atomcode".
|
|
436
|
+
*/
|
|
437
|
+
export function readAtomcodeUsage(): UsageRecord[] {
|
|
438
|
+
const allRecords: UsageRecord[] = [];
|
|
439
|
+
const sessionsDir = join(ATOMCODE_DIR, "sessions");
|
|
440
|
+
if (!existsSync(sessionsDir)) return allRecords;
|
|
441
|
+
|
|
442
|
+
let sessionDirs: string[] = [];
|
|
443
|
+
try {
|
|
444
|
+
sessionDirs = readdirSync(sessionsDir)
|
|
445
|
+
.map((name) => join(sessionsDir, name))
|
|
446
|
+
.filter((dir) => statSync(dir).isDirectory());
|
|
447
|
+
} catch {
|
|
448
|
+
return allRecords;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
for (const dir of sessionDirs) {
|
|
452
|
+
const files = readdirSync(dir).filter((f) => f.endsWith(".jsonl"));
|
|
453
|
+
for (const file of files) {
|
|
454
|
+
const filePath = join(dir, file);
|
|
455
|
+
try {
|
|
456
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
457
|
+
const model = sniffAtomcodeModel(join(dir, file.replace(/\.jsonl$/, ".snapshot")));
|
|
458
|
+
for (const line of raw.split("\n").filter((l) => l.trim())) {
|
|
459
|
+
try {
|
|
460
|
+
const obj = JSON.parse(line);
|
|
461
|
+
const usage = obj.usage;
|
|
462
|
+
if (!usage || typeof usage.prompt !== "number") continue;
|
|
463
|
+
const { date, hour } = cnDateParts(obj.iso ?? obj.ts ?? "");
|
|
464
|
+
allRecords.push({
|
|
465
|
+
date,
|
|
466
|
+
hour,
|
|
467
|
+
providerId: "atomcode",
|
|
468
|
+
modelId: model,
|
|
469
|
+
inputTokens: usage.prompt ?? 0,
|
|
470
|
+
outputTokens: usage.completion ?? 0,
|
|
471
|
+
cacheReadTokens: usage.cached ?? 0,
|
|
472
|
+
cacheWriteTokens: 0,
|
|
473
|
+
requests: 1,
|
|
474
|
+
cost: 0,
|
|
475
|
+
});
|
|
476
|
+
} catch {
|
|
477
|
+
// skip malformed lines
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
} catch {
|
|
481
|
+
// skip unreadable files
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
allRecords.sort((a, b) => a.date.localeCompare(b.date));
|
|
487
|
+
return allRecords;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Extract the model name from an AtomCode snapshot system prompt. */
|
|
491
|
+
function sniffAtomcodeModel(snapshotPath: string): string {
|
|
492
|
+
try {
|
|
493
|
+
if (!existsSync(snapshotPath)) return "atomcode";
|
|
494
|
+
const txt = readFileSync(snapshotPath, "utf-8");
|
|
495
|
+
const m = txt.match(/running the ([\w.-]+) model/i);
|
|
496
|
+
return m?.[1] ?? "atomcode";
|
|
497
|
+
} catch {
|
|
498
|
+
return "atomcode";
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// ─── Copilot Usage (Local session-store.db) ────────────
|
|
503
|
+
//
|
|
504
|
+
// The Copilot CLI records every assistant turn's token usage in a local
|
|
505
|
+
// SQLite database (~/.copilot/session-store.db, table
|
|
506
|
+
// assistant_usage_events). We read it directly — no GitHub Billing REST
|
|
507
|
+
// API or PAT required, and it works even when the account isn't on the
|
|
508
|
+
// enhanced billing platform.
|
|
509
|
+
|
|
510
|
+
const COPILOT_CONFIG_PATH = join(PI_DIR, "copilot.json");
|
|
511
|
+
|
|
512
|
+
interface CopilotConfig {
|
|
513
|
+
username?: string;
|
|
514
|
+
token?: string;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export function readCopilotConfig(): CopilotConfig {
|
|
518
|
+
try {
|
|
519
|
+
if (!existsSync(COPILOT_CONFIG_PATH)) return {};
|
|
520
|
+
const raw = readFileSync(COPILOT_CONFIG_PATH, "utf-8");
|
|
521
|
+
const parsed = JSON.parse(raw) as CopilotConfig;
|
|
522
|
+
return {
|
|
523
|
+
username: typeof parsed.username === "string" ? parsed.username : undefined,
|
|
524
|
+
token: typeof parsed.token === "string" ? parsed.token : undefined,
|
|
525
|
+
};
|
|
526
|
+
} catch {
|
|
527
|
+
return {};
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
export function writeCopilotConfig(cfg: CopilotConfig): boolean {
|
|
532
|
+
try {
|
|
533
|
+
const clean: CopilotConfig = {
|
|
534
|
+
username: cfg.username?.trim() || undefined,
|
|
535
|
+
token: cfg.token?.trim() || undefined,
|
|
536
|
+
};
|
|
537
|
+
writeFileSync(COPILOT_CONFIG_PATH, JSON.stringify(clean, null, 2), "utf-8");
|
|
538
|
+
return true;
|
|
539
|
+
} catch {
|
|
540
|
+
return false;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const COPILOT_STORE_PATH = join(homedir(), ".copilot", "session-store.db");
|
|
545
|
+
|
|
546
|
+
function copilotNum(v: unknown): number {
|
|
547
|
+
return typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Read all usage events from the local Copilot session-store.db and map them
|
|
552
|
+
* to UsageRecords bucketed in China time (UTC+8) like every other source.
|
|
553
|
+
* Each event = one assistant turn → one request. Local DB has no cost info,
|
|
554
|
+
* so cost is always 0 (token counts are still fully populated).
|
|
555
|
+
*/
|
|
556
|
+
function readCopilotStore(): UsageRecord[] {
|
|
557
|
+
const records: UsageRecord[] = [];
|
|
558
|
+
let db: DatabaseSync | null = null;
|
|
559
|
+
try {
|
|
560
|
+
db = new DatabaseSync(COPILOT_STORE_PATH, { readOnly: true });
|
|
561
|
+
const rows = db
|
|
562
|
+
.prepare(
|
|
563
|
+
`SELECT model, input_tokens, output_tokens, cache_read_tokens,
|
|
564
|
+
cache_write_tokens, created_at
|
|
565
|
+
FROM assistant_usage_events`
|
|
566
|
+
)
|
|
567
|
+
.all() as Array<Record<string, unknown>>;
|
|
568
|
+
|
|
569
|
+
for (const row of rows) {
|
|
570
|
+
const ts = row.created_at;
|
|
571
|
+
if (typeof ts !== "string" || !ts) continue;
|
|
572
|
+
const { date, hour } = cnDateParts(ts);
|
|
573
|
+
records.push({
|
|
574
|
+
date,
|
|
575
|
+
hour,
|
|
576
|
+
providerId: "copilot",
|
|
577
|
+
modelId: typeof row.model === "string" && row.model ? row.model : "copilot",
|
|
578
|
+
inputTokens: copilotNum(row.input_tokens),
|
|
579
|
+
outputTokens: copilotNum(row.output_tokens),
|
|
580
|
+
cacheReadTokens: copilotNum(row.cache_read_tokens),
|
|
581
|
+
cacheWriteTokens: copilotNum(row.cache_write_tokens),
|
|
582
|
+
requests: 1,
|
|
583
|
+
cost: 0,
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
} catch {
|
|
587
|
+
// DB missing, locked by a running Copilot CLI, or node:sqlite not
|
|
588
|
+
// available — report zero usage instead of failing the dashboard.
|
|
589
|
+
} finally {
|
|
590
|
+
try {
|
|
591
|
+
db?.close();
|
|
592
|
+
} catch {
|
|
593
|
+
/* ignore */
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
records.sort((a, b) => a.date.localeCompare(b.date));
|
|
598
|
+
return records;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Brief cache so the dashboard's auto-refresh doesn't re-open the SQLite DB
|
|
602
|
+
// on every request. Reads are cheap (~700 rows), so a short TTL is fine.
|
|
603
|
+
const COPILOT_USAGE_TTL_MS = 30_000;
|
|
604
|
+
let copilotUsageCache: { records: UsageRecord[]; at: number } | null = null;
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Synchronous accessor used by the combined view and the Copilot tab.
|
|
608
|
+
* Returns the latest local Copilot records, re-reading the DB when stale.
|
|
609
|
+
*/
|
|
610
|
+
export function readCopilotUsage(): UsageRecord[] {
|
|
611
|
+
if (copilotUsageCache && Date.now() - copilotUsageCache.at < COPILOT_USAGE_TTL_MS) {
|
|
612
|
+
return copilotUsageCache.records;
|
|
613
|
+
}
|
|
614
|
+
const records = readCopilotStore();
|
|
615
|
+
copilotUsageCache = { records, at: Date.now() };
|
|
616
|
+
return records;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** Drop cached Copilot data — called after config changes so usage refetches. */
|
|
620
|
+
export function clearCopilotCaches(): void {
|
|
621
|
+
copilotUsageCache = null;
|
|
622
|
+
}
|
|
623
|
+
|
|
388
624
|
// ─── Provider-Based Filtering ──────────────────────────
|
|
389
625
|
|
|
390
626
|
/**
|
|
@@ -398,6 +634,16 @@ export interface ProviderFilter {
|
|
|
398
634
|
}
|
|
399
635
|
|
|
400
636
|
export const PROVIDER_FILTERS: ProviderFilter[] = [
|
|
637
|
+
{
|
|
638
|
+
id: "copilot",
|
|
639
|
+
label: "Copilot",
|
|
640
|
+
patterns: [/^copilot$/i],
|
|
641
|
+
},
|
|
642
|
+
{
|
|
643
|
+
id: "atomcode",
|
|
644
|
+
label: "AtomCode",
|
|
645
|
+
patterns: [/^atomcode$/i],
|
|
646
|
+
},
|
|
401
647
|
{
|
|
402
648
|
id: "opencode",
|
|
403
649
|
label: "OpenCode",
|
|
@@ -561,6 +807,8 @@ export function getUsageByRange(records: UsageRecord[], fromDate: string, toDate
|
|
|
561
807
|
totalRequests += r.requests;
|
|
562
808
|
}
|
|
563
809
|
|
|
810
|
+
// totalTokens counts all processed tokens including cached context reads.
|
|
811
|
+
// Cache hits are billed at a lower rate, but they still count as usage.
|
|
564
812
|
const totalTokens = totalInput + totalOutput + totalCacheRead + totalCacheWrite;
|
|
565
813
|
const cacheHitRate = totalTokens > 0 ? ((totalCacheRead + totalCacheWrite) / totalTokens) * 100 : 0;
|
|
566
814
|
|
package/src/App.tsx
CHANGED
|
@@ -6,7 +6,6 @@ import { MemoryPage } from "@/components/sessions/MemoryPage";
|
|
|
6
6
|
import { ProvidersModelsPage } from "@/components/providers/ProvidersModelsPage";
|
|
7
7
|
import { SubagentsPage } from "@/components/subagents/SubagentsPage";
|
|
8
8
|
import { SettingsPage } from "@/components/settings/SettingsPage";
|
|
9
|
-
import { ChatPage } from "@/components/chat/ChatPage";
|
|
10
9
|
|
|
11
10
|
export default function App() {
|
|
12
11
|
return (
|
|
@@ -19,7 +18,6 @@ export default function App() {
|
|
|
19
18
|
<Route path="/providers" element={<ProvidersModelsPage />} />
|
|
20
19
|
<Route path="/models" element={<ProvidersModelsPage />} />
|
|
21
20
|
<Route path="/subagents" element={<SubagentsPage />} />
|
|
22
|
-
<Route path="/chat" element={<ChatPage />} />
|
|
23
21
|
<Route path="/settings" element={<SettingsPage />} />
|
|
24
22
|
</Route>
|
|
25
23
|
</Routes>
|
|
@@ -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;
|
|
@@ -397,33 +415,6 @@ export function DashboardPage() {
|
|
|
397
415
|
|
|
398
416
|
return (
|
|
399
417
|
<div className="space-y-5">
|
|
400
|
-
{/* Source Selector: Pi / Cindy-Pi */}
|
|
401
|
-
<div className="flex items-center gap-1 rounded-lg border p-0.5" style={{ borderColor: "var(--card-border)", backgroundColor: "var(--page-bg)" }}>
|
|
402
|
-
{([
|
|
403
|
-
{ key: "all" as SourceKey, label: "dashboard.source_all", icon: "📊" },
|
|
404
|
-
{ key: "pi" as SourceKey, label: "dashboard.source_pi", icon: "🖥" },
|
|
405
|
-
{ key: "cindy-pi" as SourceKey, label: "dashboard.source_cindy_pi", icon: "🤖" },
|
|
406
|
-
{ key: "claude" as SourceKey, label: "dashboard.source_claude", icon: "🧠" },
|
|
407
|
-
{ key: "codex" as SourceKey, label: "dashboard.source_codex", icon: "⚡" },
|
|
408
|
-
{ key: "opencode" as SourceKey, label: "dashboard.source_opencode", icon: "🔷" },
|
|
409
|
-
{ key: "gemini" as SourceKey, label: "dashboard.source_gemini", icon: "✨" },
|
|
410
|
-
{ key: "grok" as SourceKey, label: "dashboard.source_grok", icon: "🌀" },
|
|
411
|
-
]).map((s) => (
|
|
412
|
-
<button
|
|
413
|
-
key={s.key}
|
|
414
|
-
onClick={() => setSource(s.key)}
|
|
415
|
-
className={cn(
|
|
416
|
-
"rounded-md px-3 py-1.5 text-xs font-medium transition-colors flex items-center gap-1.5",
|
|
417
|
-
source === s.key ? "text-white" : "hover:bg-gray-800/30"
|
|
418
|
-
)}
|
|
419
|
-
style={source === s.key ? { backgroundColor: "#3b82f6", color: "#fff" } : { color: "var(--muted-text)" }}
|
|
420
|
-
>
|
|
421
|
-
<span>{s.icon}</span>
|
|
422
|
-
<span>{t(s.label)}</span>
|
|
423
|
-
</button>
|
|
424
|
-
))}
|
|
425
|
-
</div>
|
|
426
|
-
|
|
427
418
|
{/* Title + Time Range Selector + Currency Toggle */}
|
|
428
419
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
|
429
420
|
<div>
|
|
@@ -432,6 +423,18 @@ export function DashboardPage() {
|
|
|
432
423
|
{data ? t("dashboard.requests_count", String(data.requestLog.length), formatCost(data.totalCost, currency)) : ""}
|
|
433
424
|
{lastUpdated && <span className="ml-2">· {t("dashboard.last_updated", lastUpdated)}</span>}
|
|
434
425
|
</p>
|
|
426
|
+
{data?.notice && (
|
|
427
|
+
<p
|
|
428
|
+
className="mt-1 inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1 text-xs"
|
|
429
|
+
style={{
|
|
430
|
+
borderColor: data.notice === "no-config" ? "#f59e0b" : "#ef4444",
|
|
431
|
+
color: data.notice === "no-config" ? "#f59e0b" : "#f87171",
|
|
432
|
+
backgroundColor: data.notice === "no-config" ? "#f59e0b11" : "#ef444411",
|
|
433
|
+
}}
|
|
434
|
+
>
|
|
435
|
+
{data.notice === "no-config" ? t("dashboard.copilot_not_configured") : t("dashboard.copilot_api_error")}
|
|
436
|
+
</p>
|
|
437
|
+
)}
|
|
435
438
|
</div>
|
|
436
439
|
<div className="flex items-center gap-2">
|
|
437
440
|
<button
|
|
@@ -444,7 +447,7 @@ export function DashboardPage() {
|
|
|
444
447
|
{currency}
|
|
445
448
|
</button>
|
|
446
449
|
<button
|
|
447
|
-
onClick={() => { fetchData(); }}
|
|
450
|
+
onClick={() => { fetchData(true); }}
|
|
448
451
|
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
452
|
style={{ borderColor: "var(--card-border)", color: "var(--muted-text)", backgroundColor: "var(--card-bg)" }}
|
|
450
453
|
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" },
|
|
@@ -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
|
|
|
@@ -60,8 +60,9 @@ function parseMemoryEntries(content: string): MemoryEntry[] {
|
|
|
60
60
|
for (const section of sections) {
|
|
61
61
|
const trimmed = section.trim();
|
|
62
62
|
// Match `<!-- created=DATE, last=DATE -->` at the end
|
|
63
|
+
// Tolerate extra fields after `last=` (e.g. `, project64=...`) before `-->`
|
|
63
64
|
const markerMatch = trimmed.match(
|
|
64
|
-
/<!--\s*created\s*=\s*([^,\s]+)\s*,\s*last\s*=\s*([
|
|
65
|
+
/<!--\s*created\s*=\s*([^,\s>]+)\s*,\s*last\s*=\s*([^,\s>]+)[^>]*-->\s*$/
|
|
65
66
|
);
|
|
66
67
|
if (markerMatch) {
|
|
67
68
|
entries.push({
|