@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.
@@ -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 d = new Date(timestamp);
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
- const d = new Date(dateStr + "T00:00:00");
120
- return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
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 localDateStr(d: Date): string {
124
- return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
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
- const d = new Date(now);
132
- d.setDate(d.getDate() - days);
133
- return localDateStr(d);
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
- let url = `${baseUrl}?range=${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" : ""}`;
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 = new Date().toISOString().split("T")[0];
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
- MessageSquare,
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|key|token|secret|密钥|金鑰|baseurl|base_url|base-url|url|endpoint|地址|接口|provider|name|名称|名稱|供应商|供應商|model_ids?|modelids?|models?|模型)\s*[::](?!\/\/)/gi;
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|key|token|secret|密钥|金鑰)$/.test(l)) return "apiKey";
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*([^>\s]+)\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({