@rexvane/piweb 0.3.9 → 0.3.11

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.
Files changed (96) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-path-routes-manifest.json +10 -10
  3. package/.next/build-manifest.json +3 -3
  4. package/.next/prerender-manifest.json +3 -3
  5. package/.next/react-loadable-manifest.json +2 -2
  6. package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  7. package/.next/server/app/_global-error.html +1 -1
  8. package/.next/server/app/_global-error.rsc +1 -1
  9. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  10. package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  11. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  12. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  13. package/.next/server/app/_not-found.html +1 -1
  14. package/.next/server/app/_not-found.rsc +2 -2
  15. package/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  16. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  17. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  18. package/.next/server/app/api/agent/[id]/events/route.js +2 -2
  19. package/.next/server/app/api/agent/[id]/events/route.js.nft.json +1 -1
  20. package/.next/server/app/api/agent/[id]/route.js +1 -1
  21. package/.next/server/app/api/agent/[id]/route.js.nft.json +1 -1
  22. package/.next/server/app/api/agent/[id]/tree/route.js +1 -1
  23. package/.next/server/app/api/agent/[id]/tree/route.js.nft.json +1 -1
  24. package/.next/server/app/api/agent/new/route.js +1 -1
  25. package/.next/server/app/api/agent/new/route.js.nft.json +1 -1
  26. package/.next/server/app/api/growth/route.js +1 -1
  27. package/.next/server/app/api/growth/route.js.nft.json +1 -1
  28. package/.next/server/app/api/models/route.js +1 -1
  29. package/.next/server/app/api/models/route.js.nft.json +1 -1
  30. package/.next/server/app/api/plugins/route.js +1 -1
  31. package/.next/server/app/api/plugins/route.js.nft.json +1 -1
  32. package/.next/server/app/api/security/route.js +1 -1
  33. package/.next/server/app/api/security/route.js.nft.json +1 -1
  34. package/.next/server/app/api/sessions/[id]/export/route.js +1 -1
  35. package/.next/server/app/api/sessions/[id]/export/route.js.nft.json +1 -1
  36. package/.next/server/app/api/sessions/[id]/route.js +1 -1
  37. package/.next/server/app/api/sessions/[id]/route.js.nft.json +1 -1
  38. package/.next/server/app/api/sessions/route.js +1 -1
  39. package/.next/server/app/api/sessions/route.js.nft.json +1 -1
  40. package/.next/server/app/api/skills/route.js +2 -2
  41. package/.next/server/app/api/skills/route.js.nft.json +1 -1
  42. package/.next/server/app/api/update/route.js +2 -2
  43. package/.next/server/app/api/update/route.js.nft.json +1 -1
  44. package/.next/server/app/api/version/route.js +1 -1
  45. package/.next/server/app/api/workspaces/route.js +1 -1
  46. package/.next/server/app/api/workspaces/route.js.nft.json +1 -1
  47. package/.next/server/app/index.html +1 -1
  48. package/.next/server/app/index.rsc +4 -4
  49. package/.next/server/app/index.segments/__PAGE__.segment.rsc +4 -4
  50. package/.next/server/app/index.segments/_full.segment.rsc +4 -4
  51. package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  52. package/.next/server/app/login/page_client-reference-manifest.js +1 -1
  53. package/.next/server/app/login.html +1 -1
  54. package/.next/server/app/login.rsc +3 -3
  55. package/.next/server/app/login.segments/_full.segment.rsc +3 -3
  56. package/.next/server/app/login.segments/_tree.segment.rsc +2 -2
  57. package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +3 -3
  58. package/.next/server/app/page.js +1 -1
  59. package/.next/server/app/page_client-reference-manifest.js +1 -1
  60. package/.next/server/app-paths-manifest.json +10 -10
  61. package/.next/server/chunks/229.js +1 -0
  62. package/.next/server/chunks/496.js +1 -1
  63. package/.next/server/chunks/686.js +2 -2
  64. package/.next/server/middleware-build-manifest.js +1 -1
  65. package/.next/server/middleware-react-loadable-manifest.js +1 -1
  66. package/.next/server/pages/404.html +1 -1
  67. package/.next/server/pages/500.html +1 -1
  68. package/.next/server/server-reference-manifest.json +1 -1
  69. package/.next/static/chunks/127.c31e688e156605e3.js +1 -0
  70. package/.next/static/chunks/686-9f15e4e370d26815.js +1 -0
  71. package/.next/static/chunks/app/page-708cd3ee4cfe8af5.js +1 -0
  72. package/.next/static/chunks/webpack-9d84b382d4dec27a.js +1 -0
  73. package/.next/static/css/b2d35abc828d4713.css +1 -0
  74. package/README.md +4 -3
  75. package/package.json +1 -1
  76. package/src/app/api/workspaces/route.ts +4 -0
  77. package/src/components/AppShell.tsx +47 -41
  78. package/src/components/ChatWindow.tsx +118 -8
  79. package/src/components/SettingsPanel.tsx +127 -2
  80. package/src/hooks/usePiWeb.ts +27 -11
  81. package/src/i18n.tsx +52 -0
  82. package/src/lib/agent-manager.ts +77 -3
  83. package/src/lib/event-batcher.ts +62 -0
  84. package/src/lib/models-service.ts +430 -19
  85. package/src/lib/process-format.ts +27 -10
  86. package/src/lib/provider-usage.ts +91 -0
  87. package/src/lib/session-retention.ts +119 -0
  88. package/src/lib/types.ts +2 -0
  89. package/.next/server/chunks/780.js +0 -1
  90. package/.next/static/chunks/18.8b025ee3303c3dc7.js +0 -1
  91. package/.next/static/chunks/686-7861c776412c335c.js +0 -1
  92. package/.next/static/chunks/app/page-8ef773ce62ce3aaa.js +0 -1
  93. package/.next/static/chunks/webpack-eebc3d300017fa47.js +0 -1
  94. package/.next/static/css/080ec3dba8907e83.css +0 -1
  95. /package/.next/static/{tU0v6KuZoq5gHtJ8zOyb1 → 222KLUIvefiGf7M0Ov-Qz}/_buildManifest.js +0 -0
  96. /package/.next/static/{tU0v6KuZoq5gHtJ8zOyb1 → 222KLUIvefiGf7M0Ov-Qz}/_ssgManifest.js +0 -0
@@ -35,7 +35,8 @@ import { useI18n } from "@/i18n";
35
35
  import { applyPebrelTheme, loadPebrelTheme, loadThemeMode, type PebrelTheme, type ThemeMode } from "@/lib/theme";
36
36
  import { modelDraftFromConfig, serializeProviderDraft, validateModelDrafts, type ModelDraft } from "@/lib/model-draft";
37
37
  import { customApiOptions } from "@/lib/provider-display";
38
- import type { ProviderUsage, ProviderView } from "@/lib/models-service";
38
+ import { quotaWindowKind, supportsUsageProbe, type BalanceAmount, type BalanceDetailKey, type ProviderUsage, type QuotaWindow, type XaiUsage } from "@/lib/provider-usage";
39
+ import type { ProviderView } from "@/lib/models-service";
39
40
  import type { ToolPreset } from "@/lib/types";
40
41
 
41
42
  type Section = "general" | "models" | "tools" | "skills" | "plugins";
@@ -886,7 +887,7 @@ function ModelsSection() {
886
887
  }, [oauthProvider, load]);
887
888
 
888
889
  // xAI 订阅配额(x-ratelimit-* 响应头;后端 5 分钟缓存,force 跳过)
889
- const [xaiUsage, setXaiUsage] = useState<ProviderUsage | null>(null);
890
+ const [xaiUsage, setXaiUsage] = useState<XaiUsage | null>(null);
890
891
  const [usageBusy, setUsageBusy] = useState(false);
891
892
  const [usageError, setUsageError] = useState(false);
892
893
  const fetchUsage = useCallback(async (force: boolean) => {
@@ -904,6 +905,106 @@ function ModelsSection() {
904
905
  }
905
906
  }, []);
906
907
 
908
+ // 窗口制/余额制配额:各提供商走各自的只读端点(后端 5 分钟缓存,force 跳过)
909
+ const [providerUsages, setProviderUsages] = useState<Record<string, ProviderUsage | null>>({});
910
+ const [usageBusyId, setUsageBusyId] = useState<string | null>(null);
911
+ const [usageFailedIds, setUsageFailedIds] = useState<Record<string, boolean>>({});
912
+ const fetchProviderUsage = useCallback(async (providerId: string, force: boolean) => {
913
+ setUsageBusyId(providerId);
914
+ setUsageFailedIds((prev) => ({ ...prev, [providerId]: false }));
915
+ try {
916
+ const r = await fetch("/api/models", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "providerUsage", providerId, force }) });
917
+ const j = await r.json();
918
+ if (!r.ok || !j.success) throw new Error(j.error || "usage probe failed");
919
+ setProviderUsages((prev) => ({ ...prev, [providerId]: j.data.usage ?? null }));
920
+ } catch {
921
+ setUsageFailedIds((prev) => ({ ...prev, [providerId]: true }));
922
+ } finally {
923
+ setUsageBusyId((current) => (current === providerId ? null : current));
924
+ }
925
+ }, []);
926
+
927
+ // 这些端点都是只读探测、不消耗额度,登录后自动取一次;xAI 那条会发一次可能计费的请求,仍然只手动触发
928
+ const requestedUsage = useRef(new Set<string>());
929
+ useEffect(() => {
930
+ for (const p of providers) {
931
+ if (!p.authReady || !supportsUsageProbe(p.id) || p.id === "xai" || requestedUsage.current.has(p.id)) continue;
932
+ requestedUsage.current.add(p.id);
933
+ void fetchProviderUsage(p.id, false);
934
+ }
935
+ }, [providers, fetchProviderUsage]);
936
+
937
+ // 窗口时长 → 标签:7 天叫「周限」、30 天叫「月限」,更短的按小时/天如实标注
938
+ const quotaWindowLabel = (seconds: number): string => {
939
+ const kind = quotaWindowKind(seconds);
940
+ if (kind === "weekly") return t.usageWindowWeekly;
941
+ if (kind === "monthly") return t.usageWindowMonthly;
942
+ return (kind === "hours" ? t.usageWindowHours : t.usageWindowDays).replace("{n}", String(Math.round(seconds / (kind === "hours" ? 3600 : 86400))));
943
+ };
944
+ // 服务端不给周期时(Copilot 的额度类别、OpenCode 的 rolling)按名字标注
945
+ const quotaWindowName = (name: string): string => {
946
+ if (name === "premium") return t.usageWindowPremium;
947
+ if (name === "chat") return t.usageWindowChat;
948
+ if (name === "completions") return t.usageWindowCompletions;
949
+ if (name === "rolling") return t.usageWindowRolling;
950
+ return titleCase(name);
951
+ };
952
+ const quotaVariantLabel = (variant: string): string => {
953
+ if (variant === "opus") return t.usageVariantOpus;
954
+ if (variant === "code") return t.usageVariantCode;
955
+ return titleCase(variant);
956
+ };
957
+ const quotaWindowHeading = (w: QuotaWindow): string => {
958
+ if (w.limitWindowSeconds !== undefined) return `${quotaWindowLabel(w.limitWindowSeconds)}${w.variant ? ` (${quotaVariantLabel(w.variant)})` : ""}`;
959
+ return w.name ? quotaWindowName(w.name) : "";
960
+ };
961
+
962
+ // 余额明细项的标签由界面翻译,服务端只给语义 key
963
+ const balanceDetailLabel = (key: BalanceDetailKey): string => {
964
+ if (key === "granted") return t.usageBalanceGranted;
965
+ if (key === "toppedUp") return t.usageBalanceToppedUp;
966
+ if (key === "voucher") return t.usageBalanceVoucher;
967
+ if (key === "cash") return t.usageBalanceCash;
968
+ if (key === "used") return t.usageBalanceUsed;
969
+ return t.usageBalanceLimit;
970
+ };
971
+ const fmtAmount = (value: BalanceAmount): string => `${value.currency === "USD" ? "$" : value.currency === "CNY" ? "¥" : ""}${value.amount}${value.currency && value.currency !== "USD" && value.currency !== "CNY" ? ` ${value.currency}` : ""}`;
972
+
973
+ // 一个提供商的配额行:窗口制显示每个窗口的剩余与重置,余额制显示主余额与明细
974
+ const renderProviderUsage = (providerId: string) => {
975
+ const usage = providerUsages[providerId];
976
+ const busy = usageBusyId === providerId;
977
+ const failed = usageFailedIds[providerId];
978
+ return (
979
+ <>
980
+ <div className="flex items-center gap-2">
981
+ {!usage && <span>{failed ? t.usageProbeFailed : busy ? t.queryingQuota : providerId === "xai" ? t.usageXaiDisclosure : t.usageReadOnlyDisclosure}</span>}
982
+ {usage?.kind === "quota" && usage.planType && <span>{t.usagePlan} {titleCase(usage.planType)}</span>}
983
+ {usage?.kind === "balance" && usage.primary && (
984
+ <span>{usage.primaryKey === "used" ? t.usageBalanceUsed : t.usageQuotaRemaining} <span style={{ color: "var(--dsw-label-secondary)" }}>{fmtAmount(usage.primary)}</span></span>
985
+ )}
986
+ <button type="button" className="pw-chip" disabled={busy} title={providerId === "xai" ? t.usageXaiDisclosure : t.usageReadOnlyDisclosure} onClick={() => void fetchProviderUsage(providerId, true)}>
987
+ {busy ? t.usageProbing : usage ? t.usageRefresh : t.usageProbe}
988
+ </button>
989
+ </div>
990
+ {usage?.kind === "balance" && usage.details.length > 0 && (
991
+ <span>{usage.details.map((detail) => `${balanceDetailLabel(detail.key)} ${fmtAmount(detail)}`).join(" · ")}</span>
992
+ )}
993
+ {usage?.kind === "quota" && usage.windows.length === 0 && <span>{t.usageQuotaRemaining} —</span>}
994
+ {usage?.kind === "quota" && usage.windows.map((w, index) => (
995
+ <div key={`${w.limitWindowSeconds ?? w.name ?? "window"}-${w.variant ?? ""}-${index}`} className="flex items-center gap-2">
996
+ <span style={{ width: 84, flex: "none" }}>{quotaWindowHeading(w)}</span>
997
+ <span style={{ width: 92, flex: "none" }}>{t.usageQuotaRemaining} {w.remainingPercent}%</span>
998
+ <span>{t.usageReset} {fmtQuotaReset(w.resetAt)}</span>
999
+ <div className="h-1 overflow-hidden rounded-full" style={{ width: 120, flex: "none", background: "var(--dsw-border-l2)" }}>
1000
+ <div className="h-full rounded-full" style={{ width: `${w.remainingPercent}%`, background: quotaToneColor(w.remainingPercent) }} />
1001
+ </div>
1002
+ </div>
1003
+ ))}
1004
+ </>
1005
+ );
1006
+ };
1007
+
907
1008
  const notify = (ok: boolean, msg?: string) => {
908
1009
  setToast({ ok, msg: msg ?? (ok ? t.toastSaved : t.toastError) });
909
1010
  setTimeout(() => setToast(null), 2400);
@@ -1201,6 +1302,12 @@ function ModelsSection() {
1201
1302
  </div>
1202
1303
  )}
1203
1304
 
1305
+ {supportsUsageProbe(p.id) && p.id !== "xai" && p.authReady && (
1306
+ <div className="mt-1.5 flex flex-col gap-1.5 px-4" style={{ fontSize: 11.5, color: "var(--dsw-label-tertiary)" }}>
1307
+ {renderProviderUsage(p.id)}
1308
+ </div>
1309
+ )}
1310
+
1204
1311
  {editingThis && (
1205
1312
  <div className="mt-2 rounded-2xl p-4" style={{ border: "0.5px solid var(--dsw-border-l2)" }}>
1206
1313
  {isCustom ? (
@@ -1501,6 +1608,24 @@ function fmtQuotaTokens(value: number): string {
1501
1608
  return value.toLocaleString();
1502
1609
  }
1503
1610
 
1611
+ /** 套餐值来自服务端声明(如 "pro"/"plus"),首字母大写后直接跟在标签后显示。 */
1612
+ function titleCase(value: string): string {
1613
+ return value.length === 0 ? value : value[0].toUpperCase() + value.slice(1);
1614
+ }
1615
+
1616
+ function fmtQuotaReset(value: string | undefined): string {
1617
+ if (!value) return "—";
1618
+ const date = new Date(value);
1619
+ return Number.isNaN(date.getTime()) ? "—" : date.toLocaleString();
1620
+ }
1621
+
1622
+ /** 剩余越少越警示:<30% 红、≤60% 黄、其余常规。 */
1623
+ function quotaToneColor(percent: number): string {
1624
+ if (percent < 30) return "var(--dsw-danger)";
1625
+ if (percent <= 60) return "var(--dsw-warning)";
1626
+ return "var(--dsw-success)";
1627
+ }
1628
+
1504
1629
  function Field({ label, children }: { label: string; children: React.ReactNode }) {
1505
1630
  return (
1506
1631
  <div>
@@ -4,6 +4,7 @@
4
4
  * 中央状态:会话列表轮询、当前会话 SSE 订阅、事件折叠(快照→增量)、命令发送。
5
5
  */
6
6
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
7
+ import { createEventBatcher, type EventBatcher } from "@/lib/event-batcher";
7
8
  import type {
8
9
  GrowthStep,
9
10
  SessionSummary,
@@ -242,9 +243,15 @@ export const foldPiWebEvent = (state: PiWebState, evt: WebEvent): PiWebState =>
242
243
  case "growth_error":
243
244
  s.growth = { ...s.growth, pending: [], error: evt.message };
244
245
  return s;
246
+ case "retry":
247
+ // 只保留最后一次尝试:重试过程在界面上是「重试 3/5」这一条,而不是五行报错
248
+ s.retryNotice = evt.maxAttempts
249
+ ? `重试 ${evt.attempt}/${evt.maxAttempts}${evt.message ? `:${evt.message}` : ""}`
250
+ : `重试 ${evt.attempt}${evt.message ? `:${evt.message}` : ""}`;
251
+ return s;
245
252
  case "error":
246
- // 自动重试属流程内通知:随消息流显示、流结束清除,不走 6 秒错误条
247
- if (/^自动重试/.test(evt.message)) s.retryNotice = evt.message;
253
+ // 旧服务端把自动重试塞在 error 里:同样按通知处理,不占错误条
254
+ if (/^(自动重试|重试)/.test(evt.message)) s.retryNotice = evt.message;
248
255
  else s.error = evt.message;
249
256
  return s;
250
257
  default:
@@ -328,6 +335,8 @@ export function usePiWeb() {
328
335
  const [currentId, setCurrentId] = useState<string | null>(null);
329
336
  const [currentPath, setCurrentPath] = useState<string | null>(null);
330
337
  const [state, setState] = useState<PiWebState>(emptyState);
338
+ /** 当前 SSE 连接的增量批量器:按帧合并事件,避免每个 token 一次整树渲染 */
339
+ const batcherRef = useRef<EventBatcher<WebEvent> | null>(null);
331
340
  const [models, setModels] = useState<{ providers: any[]; models: any[] } | null>(null);
332
341
  const [addedWorkspaces, setAddedWorkspaces] = useState<string[]>([]);
333
342
  const [removedWorkspaces, setRemovedWorkspaces] = useState<string[]>([]);
@@ -631,6 +640,11 @@ export function usePiWeb() {
631
640
  if (esRef.current === null) connect();
632
641
  }, delay);
633
642
  };
643
+ // 流式事件按帧合并:token 速率再高也只每 16ms 渲染一次(见 event-batcher)
644
+ const batcher = createEventBatcher<WebEvent>({
645
+ flush: (events) => setState((s) => events.reduce((acc, evt) => foldPiWebEvent(acc, evt), s)),
646
+ });
647
+ batcherRef.current = batcher;
634
648
  es.onmessage = (e) => {
635
649
  let parsed: any;
636
650
  try {
@@ -640,6 +654,8 @@ export function usePiWeb() {
640
654
  return;
641
655
  }
642
656
  if (parsed.type === "snapshot" && parsed.snapshot) {
657
+ // 快照是权威状态:之前缓冲的增量已经过时,直接丢弃
658
+ batcher.cancel();
643
659
  retryDelayMs = 2000;
644
660
  const snap = parsed.snapshot as WebSnapshot;
645
661
  setSnapshotEpoch((epoch) => epoch + 1);
@@ -661,13 +677,15 @@ export function usePiWeb() {
661
677
  growth: { ...prev.growth, error: snap.growthError ?? null },
662
678
  }));
663
679
  } else {
664
- setState((s) => foldPiWebEvent(s, parsed as WebEvent));
680
+ batcher.push(parsed as WebEvent);
665
681
  }
666
682
  };
667
683
  };
668
684
  let retryTimer: ReturnType<typeof setTimeout> | undefined;
669
685
  connect();
670
686
  return () => {
687
+ batcherRef.current?.cancel();
688
+ batcherRef.current = null;
671
689
  if (retryTimer) clearTimeout(retryTimer);
672
690
  esRef.current?.close();
673
691
  esRef.current = null;
@@ -847,18 +865,16 @@ export function usePiWeb() {
847
865
  presetRestorePromiseRef.current = restore;
848
866
  }, [currentId, snapshotEpoch, state.connected, state.snapshot, sendCommand]);
849
867
 
850
- // 会话可见性规则:当前会话豁免归档过滤——归档当前会话不关闭、
851
- // 主列表保持可见可聊,已归档区也不显示它(取消归档前菜单按真实归档态切换)。
868
+ // 会话可见性规则:归档即离开工作区列表——包括当前会话(归档后切到相邻会话或空态),
869
+ // 归档区显示全部已归档会话。
852
870
  const visibleSessions = useMemo(() => {
853
871
  const set = new Set(archivedSessionPaths.map(pathKey));
854
- const currentKey = currentPath ? pathKey(currentPath) : null;
855
- return sessions.filter((s) => !set.has(pathKey(s.path)) || (currentKey != null && pathKey(s.path) === currentKey));
856
- }, [sessions, archivedSessionPaths, currentPath]);
872
+ return sessions.filter((s) => !set.has(pathKey(s.path)));
873
+ }, [sessions, archivedSessionPaths]);
857
874
  const archivedSessions = useMemo(() => {
858
875
  const set = new Set(archivedSessionPaths.map(pathKey));
859
- const currentKey = currentPath ? pathKey(currentPath) : null;
860
- return sessions.filter((s) => set.has(pathKey(s.path)) && !(currentKey != null && pathKey(s.path) === currentKey));
861
- }, [sessions, archivedSessionPaths, currentPath]);
876
+ return sessions.filter((s) => set.has(pathKey(s.path)));
877
+ }, [sessions, archivedSessionPaths]);
862
878
 
863
879
  return {
864
880
  sessions: visibleSessions,
package/src/i18n.tsx CHANGED
@@ -19,6 +19,10 @@ const zh = {
19
19
  error: "出错",
20
20
  copy: "复制",
21
21
  copyFailed: "复制失败",
22
+ editMessage: "编辑这条消息",
23
+ sendEdit: "重新发送",
24
+ editWhileRunning: "会话正在运行,先停止再编辑",
25
+ editMessageFailed: "编辑重发失败",
22
26
  export: "导出",
23
27
  settings: "设置",
24
28
  // 侧栏
@@ -206,6 +210,28 @@ const zh = {
206
210
  usageProbing: "检测中…",
207
211
  usageProbeDisclosure: "检测会向 xAI 发送一次可能计费的最小请求。",
208
212
  usageProbeFailed: "配额检测失败,未获得用量信息。",
213
+ usagePlan: "套餐",
214
+ usageQuotaRemaining: "额度剩余",
215
+ queryingQuota: "正在查询额度…",
216
+ usageReadOnlyDisclosure: "读取提供商的只读用量端点,不消耗额度。",
217
+ usageXaiDisclosure: "会向 xAI 发出一次 1 token 的请求以读取限流头,会计入用量。",
218
+ usageReset: "刷新时间",
219
+ usageWindowWeekly: "周限",
220
+ usageWindowMonthly: "月限",
221
+ usageWindowHours: "{n} 小时",
222
+ usageWindowDays: "{n} 天",
223
+ usageWindowRolling: "滚动窗口",
224
+ usageWindowChat: "对话",
225
+ usageWindowCompletions: "补全",
226
+ usageWindowPremium: "高级请求",
227
+ usageVariantOpus: "Opus",
228
+ usageVariantCode: "代码",
229
+ usageBalanceGranted: "赠金",
230
+ usageBalanceToppedUp: "充值",
231
+ usageBalanceVoucher: "代金券",
232
+ usageBalanceCash: "现金",
233
+ usageBalanceUsed: "已用",
234
+ usageBalanceLimit: "上限",
209
235
  oauthWait: "等待授权…",
210
236
  oauthCancel: "取消登录",
211
237
  noSessions: "暂无会话",
@@ -551,6 +577,10 @@ const en: Dict = {
551
577
  error: "Error",
552
578
  copy: "Copy",
553
579
  copyFailed: "Copy failed",
580
+ editMessage: "Edit this message",
581
+ sendEdit: "Send again",
582
+ editWhileRunning: "This session is running; stop it before editing",
583
+ editMessageFailed: "Edit and resend failed",
554
584
  export: "Export",
555
585
  settings: "Settings",
556
586
  newChat: "New session",
@@ -737,6 +767,28 @@ const en: Dict = {
737
767
  usageProbing: "Checking…",
738
768
  usageProbeDisclosure: "Checking sends one minimal request to xAI that may be billed.",
739
769
  usageProbeFailed: "Quota check failed; no usage data was returned.",
770
+ usagePlan: "Plan",
771
+ usageQuotaRemaining: "Remaining quota",
772
+ queryingQuota: "Querying quota…",
773
+ usageReadOnlyDisclosure: "Reads the provider's read-only usage endpoint; consumes no quota.",
774
+ usageXaiDisclosure: "Sends one 1-token request to xAI to read the rate-limit headers; counts against your usage.",
775
+ usageReset: "Resets",
776
+ usageWindowWeekly: "Weekly",
777
+ usageWindowMonthly: "Monthly",
778
+ usageWindowHours: "{n}h",
779
+ usageWindowDays: "{n}d",
780
+ usageWindowRolling: "Rolling",
781
+ usageWindowChat: "Chat",
782
+ usageWindowCompletions: "Completions",
783
+ usageWindowPremium: "Premium requests",
784
+ usageVariantOpus: "Opus",
785
+ usageVariantCode: "Code",
786
+ usageBalanceGranted: "Granted",
787
+ usageBalanceToppedUp: "Topped up",
788
+ usageBalanceVoucher: "Voucher",
789
+ usageBalanceCash: "Cash",
790
+ usageBalanceUsed: "Used",
791
+ usageBalanceLimit: "Limit",
740
792
  oauthWait: "Waiting for authorization…",
741
793
  oauthCancel: "Cancel login",
742
794
  noSessions: "No sessions yet",
@@ -2,9 +2,11 @@
2
2
  * agent-manager:AgentSession 池 + pi 事件 → Web 事件翻译 + SSE 订阅管理。
3
3
  * 打开会话即惰性冷启动(createAgentSession),空闲回收,事件先快照后增量。
4
4
  */
5
- import { existsSync } from "node:fs";
5
+ import { existsSync, readFileSync, statSync } from "node:fs";
6
6
  import { randomUUID } from "node:crypto";
7
+ import path from "node:path";
7
8
  import type { AgentSession, AgentSessionEvent } from "@earendil-works/pi-coding-agent";
9
+ import { generateUnifiedPatch } from "@earendil-works/pi-coding-agent";
8
10
  import {
9
11
  getResourceLoader,
10
12
  createSessionResourceLoader,
@@ -28,6 +30,7 @@ import { createExtensionUiBridge, type ExtensionUiBridge } from "./extension-ui"
28
30
  import { createGrowthTracker, GROWTH_TRACKER_VERSION, type GrowthTracker } from "./growth-tracker";
29
31
  import { userTurnsFromEntries } from "./growth-turns";
30
32
  import { TrajLedger, buildTrajectoryFromEntries, toTrajTokens } from "./trajectory";
33
+ import { startArchivedSessionRetention } from "./session-retention";
31
34
  import { sanitizeToolOutput } from "./text-sanitize";
32
35
  import { BoundaryError } from "./path-security";
33
36
  import { estimateTokensOf } from "./process-format";
@@ -182,6 +185,51 @@ function patchOf(details: unknown): string | undefined {
182
185
  return typeof patch === "string" && patch.trim() ? patch.slice(0, 60_000) : undefined;
183
186
  }
184
187
 
188
+ /**
189
+ * write 工具不产 patch(SDK 的 write.js 里没有 patch 字段),覆盖已有文件时前端只能把
190
+ * 全部内容当新增行——删掉的行永远不会标红。这里在工具开始执行(文件还没被写)时读一份
191
+ * 旧内容,结束时用 SDK 的 generateUnifiedPatch 生成和 edit 同格式的 patch,
192
+ * 于是「新增绿 / 删除红」在两条写入路径上一致。新文件没有旧内容,仍按新增行展示。
193
+ */
194
+ const WRITE_BASE_MAX_BYTES = 512 * 1024;
195
+ const writeBases = new Map<string, string>();
196
+
197
+ function writeArgsOf(args: unknown): { target: string; content: string | undefined } {
198
+ const a = args as { path?: unknown; file_path?: unknown; content?: unknown } | undefined;
199
+ const target = typeof a?.path === "string" ? a.path : typeof a?.file_path === "string" ? a.file_path : "";
200
+ return { target, content: typeof a?.content === "string" ? a.content : undefined };
201
+ }
202
+
203
+ export function captureWriteBase(cwd: string, sessionPath: string, toolCallId: string, args: unknown): void {
204
+ const { target } = writeArgsOf(args);
205
+ if (!target) return;
206
+ try {
207
+ const abs = path.isAbsolute(target) ? target : path.resolve(cwd, target);
208
+ const stat = statSync(abs);
209
+ if (!stat.isFile() || stat.size > WRITE_BASE_MAX_BYTES) return;
210
+ const previous = readFileSync(abs, "utf8");
211
+ if (previous.includes("\u0000")) return; // 二进制不做行级 diff
212
+ if (writeBases.size > 256) writeBases.clear();
213
+ writeBases.set(`${sessionPath}\u0000${toolCallId}`, previous);
214
+ } catch {
215
+ /* 新文件或读不到:没有旧内容,走全绿兜底 */
216
+ }
217
+ }
218
+
219
+ export function takeWritePatch(cwd: string, sessionPath: string, toolCallId: string, args: unknown): string | undefined {
220
+ const key = `${sessionPath}\u0000${toolCallId}`;
221
+ const previous = writeBases.get(key);
222
+ writeBases.delete(key);
223
+ if (previous === undefined) return undefined;
224
+ const { target, content } = writeArgsOf(args);
225
+ if (content === undefined || content === previous) return undefined;
226
+ try {
227
+ return generateUnifiedPatch(target, previous, content).slice(0, 60_000);
228
+ } catch {
229
+ return undefined;
230
+ }
231
+ }
232
+
185
233
  function coercePartial(p: unknown): string | undefined {
186
234
  if (p == null) return undefined;
187
235
  if (typeof p === "string") return p.slice(-4000);
@@ -426,6 +474,8 @@ function translate(m: Managed, evt: AgentSessionEvent): void {
426
474
  break;
427
475
  }
428
476
  case "tool_execution_start":
477
+ // write 覆盖前先留一份旧内容,结束时才能给出真实的新增/删除 diff
478
+ if (evt.toolName?.toLowerCase() === "write") captureWriteBase(m.cwd, m.sessionPath, evt.toolCallId, evt.args);
429
479
  publish(m, {
430
480
  type: "tool",
431
481
  id: evt.toolCallId,
@@ -460,7 +510,7 @@ function translate(m: Managed, evt: AgentSessionEvent): void {
460
510
  result: result.text || undefined,
461
511
  isError: evt.isError,
462
512
  encodingLoss: result.encodingLoss || undefined,
463
- patch: patchOf((evt as any).result?.details),
513
+ patch: patchOf((evt as any).result?.details) ?? (evt.toolName?.toLowerCase() === "write" ? takeWritePatch(m.cwd, m.sessionPath, evt.toolCallId, (evt as any).args) : undefined),
464
514
  ts: now,
465
515
  });
466
516
  publishUsage(m);
@@ -514,7 +564,15 @@ function translate(m: Managed, evt: AgentSessionEvent): void {
514
564
  else publish(m, { type: "model", thinkingLevel: (evt as any).level, ts: now });
515
565
  break;
516
566
  case "auto_retry_start":
517
- publish(m, { type: "error", message: `自动重试 ${(evt as any).attempt}/${(evt as any).maxAttempts}:${(evt as any).errorMessage ?? ""}`, ts: now });
567
+ // 重试是流程内通知:发一条结构化 retry 事件(界面聚合成「重试 n/max」一行),
568
+ // 不再走 error 通道,免得每次尝试都多出一条报错。
569
+ publish(m, {
570
+ type: "retry",
571
+ attempt: Number((evt as any).attempt) || 0,
572
+ maxAttempts: Number((evt as any).maxAttempts) || 0,
573
+ message: String((evt as any).errorMessage ?? ""),
574
+ ts: now,
575
+ });
518
576
  break;
519
577
  default:
520
578
  break;
@@ -881,6 +939,11 @@ export function disposeSession(m: Managed): void {
881
939
  sessions.delete(m.sessionPath);
882
940
  }
883
941
 
942
+ /**
943
+ * 让某个会话立刻收工:中断正在跑的回合并回收 agent。
944
+ * 归档用它——归档后会话从工作区消失,若它的回合还在后台跑,用户看不见却仍在消耗 token。
945
+ * 只影响这一个会话,其它会话的运行不受影响(LRU/空闲回收同样跳过 busy 的会话)。
946
+ */
884
947
  export async function disposeSessionPath(sessionPath: string): Promise<void> {
885
948
  const m = sessions.get(sessionPath);
886
949
  if (!m) return;
@@ -907,6 +970,17 @@ export function reap(): void {
907
970
 
908
971
  setInterval(reap, 60_000).unref?.();
909
972
 
973
+ /** 归档会话的保留期清理:只删「已归档且长期无活动」的会话;正在使用的会话一律跳过 */
974
+ function isSessionInUse(sessionPath: string): boolean {
975
+ const key = process.platform === "win32" ? sessionPath.toLowerCase() : sessionPath;
976
+ for (const managed of sessions.keys()) {
977
+ if ((process.platform === "win32" ? managed.toLowerCase() : managed) === key) return true;
978
+ }
979
+ return false;
980
+ }
981
+
982
+ startArchivedSessionRetention({ isActive: isSessionInUse, log: (message) => console.log(message) });
983
+
910
984
  // ---------- 资源清单(技能 / 提示模板 / 扩展命令 / 信任 / 诊断) ----------
911
985
 
912
986
  type ResourceBundle = Pick<WebSnapshot, "skills" | "promptTemplates" | "extensionCommands" | "projectTrust" | "resourceDiagnostics">;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * 事件批量器:把高频流式事件合并成"每帧一次"的批量更新。
3
+ *
4
+ * 服务端按 token 推 delta,客户端原本每个事件一次 setState → 一次整树渲染。
5
+ * 合并后一次渲染里按顺序折叠整批事件,token 速率再高也只有每 16ms 一次渲染,
6
+ * 主线程不再被切碎(点击、滚动因此变顺)。
7
+ */
8
+ export interface EventBatcher<T> {
9
+ /** 入队;达到上限立刻冲刷,避免极端情况下无限堆积 */
10
+ push(event: T): void;
11
+ flush(): void;
12
+ /** 丢弃未处理的事件(例如快照到达:旧事件已经过时) */
13
+ cancel(): void;
14
+ size(): number;
15
+ }
16
+
17
+ export function createEventBatcher<T>({
18
+ flush,
19
+ schedule = (run) => {
20
+ const id = setTimeout(run, 16);
21
+ return () => clearTimeout(id);
22
+ },
23
+ limit = 400,
24
+ }: {
25
+ flush: (events: T[]) => void;
26
+ schedule?: (run: () => void) => () => void;
27
+ limit?: number;
28
+ }): EventBatcher<T> {
29
+ let pending: T[] = [];
30
+ let cancelScheduled: (() => void) | null = null;
31
+
32
+ const stopScheduled = () => {
33
+ if (!cancelScheduled) return;
34
+ cancelScheduled();
35
+ cancelScheduled = null;
36
+ };
37
+
38
+ const flushNow = () => {
39
+ stopScheduled();
40
+ if (!pending.length) return;
41
+ const batch = pending;
42
+ pending = [];
43
+ flush(batch);
44
+ };
45
+
46
+ return {
47
+ push(event) {
48
+ pending.push(event);
49
+ if (pending.length >= limit) {
50
+ flushNow();
51
+ return;
52
+ }
53
+ cancelScheduled ??= schedule(flushNow);
54
+ },
55
+ flush: flushNow,
56
+ cancel() {
57
+ stopScheduled();
58
+ pending = [];
59
+ },
60
+ size: () => pending.length,
61
+ };
62
+ }