@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.
- package/.next/BUILD_ID +1 -1
- package/.next/app-path-routes-manifest.json +10 -10
- package/.next/build-manifest.json +3 -3
- package/.next/prerender-manifest.json +3 -3
- package/.next/react-loadable-manifest.json +2 -2
- package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_global-error.html +1 -1
- package/.next/server/app/_global-error.rsc +1 -1
- package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_not-found.html +1 -1
- package/.next/server/app/_not-found.rsc +2 -2
- package/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
- package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/api/agent/[id]/events/route.js +2 -2
- package/.next/server/app/api/agent/[id]/events/route.js.nft.json +1 -1
- package/.next/server/app/api/agent/[id]/route.js +1 -1
- package/.next/server/app/api/agent/[id]/route.js.nft.json +1 -1
- package/.next/server/app/api/agent/[id]/tree/route.js +1 -1
- package/.next/server/app/api/agent/[id]/tree/route.js.nft.json +1 -1
- package/.next/server/app/api/agent/new/route.js +1 -1
- package/.next/server/app/api/agent/new/route.js.nft.json +1 -1
- package/.next/server/app/api/growth/route.js +1 -1
- package/.next/server/app/api/growth/route.js.nft.json +1 -1
- package/.next/server/app/api/models/route.js +1 -1
- package/.next/server/app/api/models/route.js.nft.json +1 -1
- package/.next/server/app/api/plugins/route.js +1 -1
- package/.next/server/app/api/plugins/route.js.nft.json +1 -1
- package/.next/server/app/api/security/route.js +1 -1
- package/.next/server/app/api/security/route.js.nft.json +1 -1
- package/.next/server/app/api/sessions/[id]/export/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/export/route.js.nft.json +1 -1
- package/.next/server/app/api/sessions/[id]/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/route.js.nft.json +1 -1
- package/.next/server/app/api/sessions/route.js +1 -1
- package/.next/server/app/api/sessions/route.js.nft.json +1 -1
- package/.next/server/app/api/skills/route.js +2 -2
- package/.next/server/app/api/skills/route.js.nft.json +1 -1
- package/.next/server/app/api/update/route.js +2 -2
- package/.next/server/app/api/update/route.js.nft.json +1 -1
- package/.next/server/app/api/version/route.js +1 -1
- package/.next/server/app/api/workspaces/route.js +1 -1
- package/.next/server/app/api/workspaces/route.js.nft.json +1 -1
- package/.next/server/app/index.html +1 -1
- package/.next/server/app/index.rsc +4 -4
- package/.next/server/app/index.segments/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/index.segments/_full.segment.rsc +4 -4
- package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/login/page_client-reference-manifest.js +1 -1
- package/.next/server/app/login.html +1 -1
- package/.next/server/app/login.rsc +3 -3
- package/.next/server/app/login.segments/_full.segment.rsc +3 -3
- package/.next/server/app/login.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +3 -3
- package/.next/server/app/page.js +1 -1
- package/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/server/app-paths-manifest.json +10 -10
- package/.next/server/chunks/229.js +1 -0
- package/.next/server/chunks/496.js +1 -1
- package/.next/server/chunks/686.js +2 -2
- package/.next/server/middleware-build-manifest.js +1 -1
- package/.next/server/middleware-react-loadable-manifest.js +1 -1
- package/.next/server/pages/404.html +1 -1
- package/.next/server/pages/500.html +1 -1
- package/.next/server/server-reference-manifest.json +1 -1
- package/.next/static/chunks/127.c31e688e156605e3.js +1 -0
- package/.next/static/chunks/686-9f15e4e370d26815.js +1 -0
- package/.next/static/chunks/app/page-708cd3ee4cfe8af5.js +1 -0
- package/.next/static/chunks/webpack-9d84b382d4dec27a.js +1 -0
- package/.next/static/css/b2d35abc828d4713.css +1 -0
- package/README.md +4 -3
- package/package.json +1 -1
- package/src/app/api/workspaces/route.ts +4 -0
- package/src/components/AppShell.tsx +47 -41
- package/src/components/ChatWindow.tsx +118 -8
- package/src/components/SettingsPanel.tsx +127 -2
- package/src/hooks/usePiWeb.ts +27 -11
- package/src/i18n.tsx +52 -0
- package/src/lib/agent-manager.ts +77 -3
- package/src/lib/event-batcher.ts +62 -0
- package/src/lib/models-service.ts +430 -19
- package/src/lib/process-format.ts +27 -10
- package/src/lib/provider-usage.ts +91 -0
- package/src/lib/session-retention.ts +119 -0
- package/src/lib/types.ts +2 -0
- package/.next/server/chunks/780.js +0 -1
- package/.next/static/chunks/18.8b025ee3303c3dc7.js +0 -1
- package/.next/static/chunks/686-7861c776412c335c.js +0 -1
- package/.next/static/chunks/app/page-8ef773ce62ce3aaa.js +0 -1
- package/.next/static/chunks/webpack-eebc3d300017fa47.js +0 -1
- package/.next/static/css/080ec3dba8907e83.css +0 -1
- /package/.next/static/{tU0v6KuZoq5gHtJ8zOyb1 → 222KLUIvefiGf7M0Ov-Qz}/_buildManifest.js +0 -0
- /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
|
|
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<
|
|
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>
|
package/src/hooks/usePiWeb.ts
CHANGED
|
@@ -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
|
-
//
|
|
247
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
855
|
-
|
|
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
|
-
|
|
860
|
-
|
|
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",
|
package/src/lib/agent-manager.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|