@rexvane/piweb 0.3.20 → 0.3.21
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 +5 -5
- package/.next/build-manifest.json +3 -3
- package/.next/prerender-manifest.json +4 -4
- package/.next/react-loadable-manifest.json +4 -4
- package/.next/required-server-files.js +2 -1
- package/.next/required-server-files.json +2 -1
- 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 +3 -3
- 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/prompts/route.js +1 -1
- package/.next/server/app/api/prompts/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.js.nft.json +1 -1
- package/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/server/app-paths-manifest.json +5 -5
- package/.next/server/chunks/421.js +1 -0
- package/.next/server/chunks/496.js +1 -1
- package/.next/server/chunks/662.js +2 -0
- package/.next/server/chunks/686.js +1 -1
- package/.next/server/chunks/807.js +1 -1
- package/.next/server/instrumentation.js +1 -1
- package/.next/server/instrumentation.js.nft.json +1 -1
- 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/4686-882531aef1d2e250.js +1 -0
- package/.next/static/chunks/{4778.e4c9ed093a413bc4.js → 4778.305dc6abb9889627.js} +1 -1
- package/.next/static/chunks/839.bfd2db36483faa3e.js +1 -0
- package/.next/static/chunks/8552.25c39ef4c995eba0.js +1 -0
- package/.next/static/chunks/app/page-c04af72e549695bd.js +1 -0
- package/.next/static/chunks/{webpack-3614a8337843456b.js → webpack-9b4bb574e5817654.js} +1 -1
- package/.next/static/css/ea27b9e30f76303f.css +1 -0
- package/README.md +14 -5
- package/next.config.ts +4 -2
- package/package.json +1 -1
- package/scripts/release.mjs +1 -1
- package/src/app/api/agent/[id]/events/route.ts +42 -13
- package/src/app/api/update/route.ts +18 -2
- package/src/app/globals.css +89 -0
- package/src/components/AppShell.tsx +7 -3
- package/src/components/ChatInput.tsx +1 -0
- package/src/components/ChatWindow.tsx +105 -81
- package/src/components/ContextMeter.tsx +31 -0
- package/src/components/DrawerPanel.tsx +19 -26
- package/src/components/ExtensionUI.tsx +8 -2
- package/src/components/MarkdownBlock.tsx +333 -37
- package/src/components/SettingsPanel.tsx +122 -53
- package/src/components/chat/chat-scroll.ts +18 -0
- package/src/components/update/CopyCommand.tsx +34 -0
- package/src/components/update/UpdateNotice.tsx +130 -0
- package/src/components/update/update-store.ts +155 -0
- package/src/hooks/usePiWeb.ts +42 -25
- package/src/i18n.tsx +74 -4
- package/src/instrumentation-node.ts +3 -0
- package/src/lib/agent-manager.ts +100 -57
- package/src/lib/chat/event-stream.ts +113 -0
- package/src/lib/chat/stream-settings.ts +192 -0
- package/src/lib/types.ts +7 -0
- package/src/lib/update/startup-check.ts +117 -0
- package/src/lib/update-service.ts +23 -3
- package/.next/server/chunks/131.js +0 -1
- package/.next/server/chunks/243.js +0 -1
- package/.next/static/chunks/4686-b1e9fa876d0ea671.js +0 -1
- package/.next/static/chunks/839.d3fe337a9117c325.js +0 -1
- package/.next/static/chunks/8552.6bfe3e101250b20f.js +0 -1
- package/.next/static/chunks/app/page-2e425ef83fba0782.js +0 -1
- package/.next/static/css/7391c42e1ae617f9.css +0 -1
- /package/.next/static/{_Y3Lq0lI5RJ1tFSOnDi0L → sNX5GQkOWuK5YEID1hT-C}/_buildManifest.js +0 -0
- /package/.next/static/{_Y3Lq0lI5RJ1tFSOnDi0L → sNX5GQkOWuK5YEID1hT-C}/_ssgManifest.js +0 -0
package/src/lib/agent-manager.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { existsSync, readFileSync, statSync } from "node:fs";
|
|
|
6
6
|
import { randomUUID } from "node:crypto";
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import type { AgentSession, AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
|
9
|
-
import { generateUnifiedPatch } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { formatSkillsForPrompt, generateUnifiedPatch } from "@earendil-works/pi-coding-agent";
|
|
10
10
|
import {
|
|
11
11
|
getResourceLoader,
|
|
12
12
|
createSessionResourceLoader,
|
|
@@ -63,6 +63,10 @@ interface Managed {
|
|
|
63
63
|
session: AgentSession | null;
|
|
64
64
|
creating: Promise<AgentSession> | null;
|
|
65
65
|
subscribers: Set<(seq: number, json: string) => void>;
|
|
66
|
+
/** 长轮询的浏览器在两次请求之间没有订阅,到这个时间点之前仍算有人在看(见 holdViewer) */
|
|
67
|
+
viewerGraceUntil?: number;
|
|
68
|
+
/** 本轮已排队推送上下文分项(publishContextBreakdown 合并同一轮的多次触发) */
|
|
69
|
+
breakdownQueued?: boolean;
|
|
66
70
|
buffer: { seq: number; json: string }[];
|
|
67
71
|
seq: number;
|
|
68
72
|
/**
|
|
@@ -579,6 +583,7 @@ function translate(m: Managed, evt: AgentSessionEvent): void {
|
|
|
579
583
|
ts: now,
|
|
580
584
|
});
|
|
581
585
|
publishUsage(m);
|
|
586
|
+
publishContextBreakdown(m);
|
|
582
587
|
if ((evt as any).reason === "manual") {
|
|
583
588
|
publish(m, { type: "status", isStreaming: isManagedBusy(m), state: isManagedBusy(m) ? "running" : "idle", ts: now });
|
|
584
589
|
if (m.resourceReloadPending) queueMicrotask(() => void reloadManagedResources(m));
|
|
@@ -733,45 +738,46 @@ function loadContextResources(cwd: string, loader: AgentSession["resourceLoader"
|
|
|
733
738
|
];
|
|
734
739
|
}
|
|
735
740
|
|
|
741
|
+
const SKILLS_SECTION_START = "The following skills provide specialized instructions";
|
|
742
|
+
const SKILLS_SECTION_END = "</available_skills>";
|
|
743
|
+
|
|
736
744
|
/**
|
|
737
745
|
* 上下文占用的服务端估算分项。systemPrompt 是整段拼接(正文 + memory + skills + 工具列表),
|
|
738
746
|
* 所以 Memory / Skills 从资源加载器与 systemPrompt 的资源段差分得出:
|
|
739
747
|
* SDK 的拼接顺序固定为 正文 → context files → skills → 工具列表,按内容定位切块。
|
|
748
|
+
*
|
|
749
|
+
* 冷会话(只浏览历史、不冷启动 agent)拿不到组装后的 system prompt 和工具定义,这三项为 0、标 cold;
|
|
750
|
+
* Memory / Skills / Compacted / Buffer 不依赖 agent,照常从发现缓存、会话文件和设置里算,
|
|
751
|
+
* 不能因为没启动就整块归零(那样弹窗只剩消息几类)。
|
|
740
752
|
*/
|
|
741
|
-
async function buildContextBreakdown(
|
|
742
|
-
|
|
743
|
-
session: AgentSession | null,
|
|
744
|
-
resources: ContextResource[],
|
|
745
|
-
): Promise<ContextBreakdown> {
|
|
753
|
+
async function buildContextBreakdown(m: Managed, resources: ContextResource[]): Promise<ContextBreakdown> {
|
|
754
|
+
const session = m.session;
|
|
746
755
|
const breakdown: ContextBreakdown = {
|
|
747
756
|
systemPrompt: 0, systemTools: 0, customTools: 0, memory: 0, skills: 0, compacted: 0, autoCompactBuffer: 0,
|
|
757
|
+
...(session ? {} : { cold: true }),
|
|
748
758
|
};
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
const memoryText = resources.filter((r) => r.source === "project").map((r) => r.content).join("\n\n");
|
|
754
|
-
breakdown.memory = estimateTokensOf(memoryText);
|
|
755
|
-
// Skills:从资源加载器取技能清单,按 SDK 的 formatSkillsForPrompt 段落定位。
|
|
756
|
-
// 该段落没有 Markdown 标题(原文以 "The following skills provide specialized instructions…" 开头,
|
|
757
|
-
// 结尾是 </available_skills>),以前按 "## Skills" 找永远找不到,技能段一直是 0。
|
|
759
|
+
// Memory:AGENTS.md 等注入文件(contextResources 的 project 部分)
|
|
760
|
+
const memoryText = resources.filter((r) => r.source === "project").map((r) => r.content).join("\n\n");
|
|
761
|
+
breakdown.memory = estimateTokensOf(memoryText);
|
|
762
|
+
if (session) {
|
|
758
763
|
try {
|
|
759
|
-
const
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
+
const prompt = session.systemPrompt ?? "";
|
|
765
|
+
// Skills:按 SDK 的 formatSkillsForPrompt 段落定位。该段落没有 Markdown 标题(以
|
|
766
|
+
// "The following skills provide specialized instructions…" 开头、</available_skills> 结尾),
|
|
767
|
+
// 以前按 "## Skills" 找永远找不到,技能段一直是 0。
|
|
768
|
+
try {
|
|
769
|
+
if (session.resourceLoader.getSkills().skills.length && prompt) {
|
|
770
|
+
const start = prompt.indexOf(SKILLS_SECTION_START);
|
|
771
|
+
const end = prompt.indexOf(SKILLS_SECTION_END);
|
|
772
|
+
if (start >= 0 && end > start) breakdown.skills = estimateTokensOf(prompt.slice(start, end + SKILLS_SECTION_END.length));
|
|
773
|
+
}
|
|
774
|
+
} catch {
|
|
775
|
+
/* loader 不可用则跳过 */
|
|
764
776
|
}
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
const estimatedSections = breakdown.memory + breakdown.skills;
|
|
770
|
-
breakdown.systemPrompt = Math.max(0, estimateTokensOf(prompt) - estimatedSections);
|
|
771
|
-
// 工具:builtin 与扩展注册的分开统计(schema + description + guidelines 序列化估算)
|
|
772
|
-
try {
|
|
773
|
-
const tools = session.getAllTools();
|
|
774
|
-
for (const tool of tools) {
|
|
777
|
+
// System Prompt 正文 = 全文减去资源段与 skills 段的近似差分,下限 0
|
|
778
|
+
breakdown.systemPrompt = Math.max(0, estimateTokensOf(prompt) - breakdown.memory - breakdown.skills);
|
|
779
|
+
// 工具:builtin 与扩展注册的分开统计(schema + description + guidelines 序列化估算)
|
|
780
|
+
for (const tool of session.getAllTools()) {
|
|
775
781
|
const size = estimateTokensOf(JSON.stringify({
|
|
776
782
|
name: tool.name,
|
|
777
783
|
description: tool.description,
|
|
@@ -782,38 +788,61 @@ async function buildContextBreakdown(
|
|
|
782
788
|
else breakdown.customTools += size;
|
|
783
789
|
}
|
|
784
790
|
} catch {
|
|
785
|
-
/* getAllTools
|
|
791
|
+
/* systemPrompt / getAllTools 不可用则保持 0 */
|
|
786
792
|
}
|
|
787
|
-
|
|
793
|
+
} else {
|
|
794
|
+
// 冷会话:技能段用发现缓存里的清单按 SDK 同一个格式化函数估算(启动后改用实际 prompt 里的那段)
|
|
788
795
|
try {
|
|
789
|
-
const
|
|
790
|
-
|
|
791
|
-
const entry = entries[i] as { type?: string; summary?: string };
|
|
792
|
-
if (entry?.type === "compaction" && typeof entry.summary === "string") {
|
|
793
|
-
breakdown.compacted = estimateTokensOf(entry.summary);
|
|
794
|
-
break;
|
|
795
|
-
}
|
|
796
|
-
}
|
|
796
|
+
const loader = peekResourceLoader(m.cwd) ?? getResourceLoader(m.cwd);
|
|
797
|
+
breakdown.skills = estimateTokensOf(formatSkillsForPrompt(loader.getSkills().skills).trim());
|
|
797
798
|
} catch {
|
|
798
|
-
/*
|
|
799
|
+
/* 发现缓存还没好则跳过 */
|
|
799
800
|
}
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
801
|
+
}
|
|
802
|
+
// 最新一次压缩的摘要(会话文件里就有,冷热都能算)
|
|
803
|
+
try {
|
|
804
|
+
const entries = m.sm.getEntries() as unknown as { type?: string; summary?: string }[];
|
|
805
|
+
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
|
806
|
+
const entry = entries[i];
|
|
807
|
+
if (entry?.type === "compaction" && typeof entry.summary === "string") {
|
|
808
|
+
breakdown.compacted = estimateTokensOf(entry.summary);
|
|
809
|
+
break;
|
|
810
|
+
}
|
|
806
811
|
}
|
|
807
812
|
} catch {
|
|
808
|
-
/*
|
|
813
|
+
/* entries 不可用则跳过 */
|
|
814
|
+
}
|
|
815
|
+
// auto-compact 预留 = compaction.reserveTokens(启用时)
|
|
816
|
+
try {
|
|
817
|
+
const settings = await getPiSettings();
|
|
818
|
+
if (settings.compaction.enabled) breakdown.autoCompactBuffer = settings.compaction.reserveTokens;
|
|
819
|
+
} catch {
|
|
820
|
+
/* 设置读取失败则保持 0 */
|
|
809
821
|
}
|
|
810
822
|
return breakdown;
|
|
811
823
|
}
|
|
812
824
|
|
|
813
|
-
/**
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
825
|
+
/**
|
|
826
|
+
* 分项变了就推一次(会话启动、工具 / 资源重载、压缩之后)。分项以前只随快照下发:
|
|
827
|
+
* 打开冷会话时是 0,之后会话启动了弹窗也一直停在那份 0 上,直到刷新页面。
|
|
828
|
+
* 同一轮里多次触发(启动时 publishTools + publishResources)合并成一次。
|
|
829
|
+
*/
|
|
830
|
+
function publishContextBreakdown(m: Managed): void {
|
|
831
|
+
if (m.breakdownQueued || m.disposed) return;
|
|
832
|
+
m.breakdownQueued = true;
|
|
833
|
+
queueMicrotask(() => {
|
|
834
|
+
m.breakdownQueued = false;
|
|
835
|
+
if (m.disposed) return;
|
|
836
|
+
let resources: ContextResource[] = [];
|
|
837
|
+
try {
|
|
838
|
+
resources = loadContextResources(m.cwd, m.session?.resourceLoader);
|
|
839
|
+
} catch {
|
|
840
|
+
/* 读不到注入文件就按空算 Memory */
|
|
841
|
+
}
|
|
842
|
+
void buildContextBreakdown(m, resources).then((breakdown) => {
|
|
843
|
+
if (!m.disposed) publish(m, { type: "context_breakdown", breakdown, ts: Date.now() });
|
|
844
|
+
}, () => undefined);
|
|
845
|
+
});
|
|
817
846
|
}
|
|
818
847
|
|
|
819
848
|
/** resources 已经算过(快照)就传进来,免得资源发现结果再读一遍 */
|
|
@@ -877,7 +906,7 @@ export async function ensureSession(m: Managed): Promise<AgentSession> {
|
|
|
877
906
|
installSessionPolicy(m, session);
|
|
878
907
|
applyToolPolicy(m, session);
|
|
879
908
|
session.subscribe((evt) => { if (!m.disposed) translate(m, evt); });
|
|
880
|
-
const ui = createExtensionUiBridge({ publish: (evt) => publish(m, evt), hasViewers: () => !m.disposed && m
|
|
909
|
+
const ui = createExtensionUiBridge({ publish: (evt) => publish(m, evt), hasViewers: () => !m.disposed && hasViewers(m) });
|
|
881
910
|
m.ui = ui;
|
|
882
911
|
await session.bindExtensions({
|
|
883
912
|
mode: "rpc",
|
|
@@ -989,6 +1018,7 @@ function shutdownSession(session: AgentSession): Promise<void> {
|
|
|
989
1018
|
|
|
990
1019
|
function publishTools(m: Managed, session: AgentSession): void {
|
|
991
1020
|
publish(m, { type: "tools", active: listActiveTools(session), all: listAllToolNames(session), toolPreset: m.toolPreset, customActiveTools: m.customActiveTools ?? null, ts: Date.now() });
|
|
1021
|
+
publishContextBreakdown(m);
|
|
992
1022
|
}
|
|
993
1023
|
|
|
994
1024
|
/** SDK reload 自动启用扩展工具,注册新工具也会刷新全集;统一在激活边界收敛到服务端选择。 */
|
|
@@ -1086,13 +1116,13 @@ export async function disposeSessionPath(sessionPath: string): Promise<void> {
|
|
|
1086
1116
|
export function reap(): void {
|
|
1087
1117
|
const now = Date.now();
|
|
1088
1118
|
for (const m of [...sessions.values()]) {
|
|
1089
|
-
if (m
|
|
1119
|
+
if (hasViewers(m) || now - m.lastActive <= IDLE_MS) continue;
|
|
1090
1120
|
if (!m.creating && !isManagedBusy(m)) disposeSession(m);
|
|
1091
1121
|
}
|
|
1092
1122
|
const active = [...sessions.values()].filter((m) => m.session);
|
|
1093
1123
|
if (active.length > MAX_ACTIVE) {
|
|
1094
1124
|
const evictable = active
|
|
1095
|
-
.filter((m) => !m.creating && !isManagedBusy(m) && m
|
|
1125
|
+
.filter((m) => !m.creating && !isManagedBusy(m) && !hasViewers(m))
|
|
1096
1126
|
.sort((a, b) => a.lastActive - b.lastActive);
|
|
1097
1127
|
for (const m of evictable.slice(0, active.length - MAX_ACTIVE)) disposeSession(m);
|
|
1098
1128
|
}
|
|
@@ -1166,6 +1196,7 @@ function collectResources(m: Managed): ResourceBundle {
|
|
|
1166
1196
|
|
|
1167
1197
|
export function publishResources(m: Managed): void {
|
|
1168
1198
|
publish(m, { type: "resources", ...collectResources(m), ts: Date.now() });
|
|
1199
|
+
publishContextBreakdown(m);
|
|
1169
1200
|
}
|
|
1170
1201
|
|
|
1171
1202
|
/**
|
|
@@ -1198,7 +1229,7 @@ export async function reloadSessionsForCwd(cwd?: string): Promise<number> {
|
|
|
1198
1229
|
if (cwd && key(m.cwd) !== key(cwd)) continue;
|
|
1199
1230
|
if (!m.session) {
|
|
1200
1231
|
// 冷会话没有扩展运行时可重建,但页面可能开着:把新清单(模板 / 技能 / 信任)推过去
|
|
1201
|
-
if (m
|
|
1232
|
+
if (hasViewers(m)) publishResources(m);
|
|
1202
1233
|
continue;
|
|
1203
1234
|
}
|
|
1204
1235
|
if (m.creating || isManagedBusy(m)) {
|
|
@@ -1359,7 +1390,7 @@ export async function buildSnapshot(m: Managed): Promise<WebSnapshot> {
|
|
|
1359
1390
|
// 服务端可得的上下文分项(估算;失败不影响快照)
|
|
1360
1391
|
let contextBreakdown: ContextBreakdown | undefined;
|
|
1361
1392
|
try {
|
|
1362
|
-
contextBreakdown = await buildContextBreakdown(m
|
|
1393
|
+
contextBreakdown = await buildContextBreakdown(m, contextResources);
|
|
1363
1394
|
} catch {
|
|
1364
1395
|
contextBreakdown = undefined;
|
|
1365
1396
|
}
|
|
@@ -1446,6 +1477,18 @@ export function unsubscribe(m: Managed, send: (seq: number, json: string) => voi
|
|
|
1446
1477
|
m.subscribers.delete(send);
|
|
1447
1478
|
}
|
|
1448
1479
|
|
|
1480
|
+
/**
|
|
1481
|
+
* 长轮询(快速隧道)的一次请求结束时调用:下一次请求到来前的这段空档仍算有人在看,
|
|
1482
|
+
* 否则扩展弹窗会被当成无人值守直接取默认值,会话也可能被当成没人开着回收掉。
|
|
1483
|
+
*/
|
|
1484
|
+
export function holdViewer(m: Managed, ms: number): void {
|
|
1485
|
+
m.viewerGraceUntil = Math.max(m.viewerGraceUntil ?? 0, Date.now() + ms);
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
function hasViewers(m: Managed): boolean {
|
|
1489
|
+
return m.subscribers.size > 0 || (m.viewerGraceUntil ?? 0) > Date.now();
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1449
1492
|
// ---------- 命令执行 ----------
|
|
1450
1493
|
|
|
1451
1494
|
export interface CommandResult {
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话事件的长轮询通道(浏览器端)。
|
|
3
|
+
*
|
|
4
|
+
* Cloudflare 快速隧道(远程连接的公网入口)不支持 SSE:流式响应被攒成 128KB 一块才转发,
|
|
5
|
+
* 不满一块的尾巴一直卡着——打开会话等不到完整快照(一直转圈),聊天也不流式。
|
|
6
|
+
* 隧道下改成一次次短请求:服务端把这一批事件发完就结束响应(结束时隧道一定吐干净),
|
|
7
|
+
* 浏览器带着 Last-Event-ID 立刻再来一次,漏掉的事件由服务端从环形缓冲补发。
|
|
8
|
+
* 线上格式仍是 SSE 文本,服务端两种模式共用一套写法。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** 只有快速隧道需要轮询;局域网、本机、用户自己的反向代理都照旧走 SSE */
|
|
12
|
+
export function usesEventPolling(hostname: string): boolean {
|
|
13
|
+
return hostname.toLowerCase().endsWith(".trycloudflare.com");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SseFrame {
|
|
17
|
+
id?: string;
|
|
18
|
+
event?: string;
|
|
19
|
+
data: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 解析一段完整的 SSE 文本(只认本项目用到的 id / event / data,注释行跳过) */
|
|
23
|
+
export function parseSseText(text: string): SseFrame[] {
|
|
24
|
+
const frames: SseFrame[] = [];
|
|
25
|
+
for (const block of text.split(/\r?\n\r?\n/)) {
|
|
26
|
+
const frame: SseFrame = { data: "" };
|
|
27
|
+
const data: string[] = [];
|
|
28
|
+
for (const line of block.split(/\r?\n/)) {
|
|
29
|
+
if (!line || line.startsWith(":")) continue;
|
|
30
|
+
const colon = line.indexOf(":");
|
|
31
|
+
const field = colon === -1 ? line : line.slice(0, colon);
|
|
32
|
+
let value = colon === -1 ? "" : line.slice(colon + 1);
|
|
33
|
+
if (value.startsWith(" ")) value = value.slice(1);
|
|
34
|
+
if (field === "data") data.push(value);
|
|
35
|
+
else if (field === "id") frame.id = value;
|
|
36
|
+
else if (field === "event") frame.event = value;
|
|
37
|
+
}
|
|
38
|
+
if (!data.length) continue;
|
|
39
|
+
frame.data = data.join("\n");
|
|
40
|
+
frames.push(frame);
|
|
41
|
+
}
|
|
42
|
+
return frames;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface EventPollHandlers {
|
|
46
|
+
/** 一次请求成功返回(断开后恢复时也会再调) */
|
|
47
|
+
onOpen(): void;
|
|
48
|
+
onMessage(data: string): void;
|
|
49
|
+
/** 一次请求失败;之后按退避自动重试 */
|
|
50
|
+
onError(): void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface EventPollOptions {
|
|
54
|
+
fetch?: typeof fetch;
|
|
55
|
+
/** 第 n 次连续失败后等多久再试 */
|
|
56
|
+
backoffMs?: (failures: number) => number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const defaultBackoff = (failures: number) => Math.min(2000 * 2 ** (failures - 1), 30_000);
|
|
60
|
+
|
|
61
|
+
export function pollEventStream(url: string, handlers: EventPollHandlers, { fetch: fetchImpl = fetch, backoffMs = defaultBackoff }: EventPollOptions = {}): { close(): void } {
|
|
62
|
+
const controller = new AbortController();
|
|
63
|
+
let closed = false;
|
|
64
|
+
let lastEventId: string | undefined;
|
|
65
|
+
let open = false;
|
|
66
|
+
let failures = 0;
|
|
67
|
+
const pollUrl = `${url}${url.includes("?") ? "&" : "?"}poll=1`;
|
|
68
|
+
|
|
69
|
+
const sleep = (ms: number) => new Promise<void>((resolve) => {
|
|
70
|
+
const timer = setTimeout(resolve, ms);
|
|
71
|
+
controller.signal.addEventListener("abort", () => { clearTimeout(timer); resolve(); }, { once: true });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
void (async () => {
|
|
75
|
+
while (!closed) {
|
|
76
|
+
let frames: SseFrame[];
|
|
77
|
+
try {
|
|
78
|
+
const response = await fetchImpl(pollUrl, {
|
|
79
|
+
headers: lastEventId ? { "Last-Event-ID": lastEventId } : {},
|
|
80
|
+
cache: "no-store",
|
|
81
|
+
signal: controller.signal,
|
|
82
|
+
});
|
|
83
|
+
if (!response.ok) throw new Error(`events poll failed (${response.status})`);
|
|
84
|
+
frames = parseSseText(await response.text());
|
|
85
|
+
} catch {
|
|
86
|
+
if (closed) return;
|
|
87
|
+
open = false;
|
|
88
|
+
handlers.onError();
|
|
89
|
+
failures += 1;
|
|
90
|
+
await sleep(backoffMs(failures));
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (closed) return;
|
|
94
|
+
failures = 0;
|
|
95
|
+
if (!open) {
|
|
96
|
+
open = true;
|
|
97
|
+
handlers.onOpen();
|
|
98
|
+
}
|
|
99
|
+
for (const frame of frames) {
|
|
100
|
+
if (closed) return;
|
|
101
|
+
handlers.onMessage(frame.data);
|
|
102
|
+
if (frame.id !== undefined) lastEventId = frame.id;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
})();
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
close() {
|
|
109
|
+
closed = true;
|
|
110
|
+
controller.abort();
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 流式文字输出偏好:显示样式 / 推进速度 / 打字光标。
|
|
3
|
+
*
|
|
4
|
+
* 存 localStorage(piweb.streamStyle / piweb.streamSpeed / piweb.streamCaret),
|
|
5
|
+
* 模块级订阅:设置面板一改,正在流的那条回答在下一帧就换样式,不必重开会话;
|
|
6
|
+
* 跨标签页由 storage 事件同步。
|
|
7
|
+
*
|
|
8
|
+
* 推进量是纯函数 nextRevealEnd(target, shownLength, settings):给定目标文本、已显示长度和偏好,
|
|
9
|
+
* 算出这一帧该显示到哪。不变量是「显示内容始终是目标前缀、单调增长、最终追平」——
|
|
10
|
+
* 见 tests/stream-settings.test.ts。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type StreamStyle = "smooth" | "instant" | "typewriter" | "fade";
|
|
14
|
+
|
|
15
|
+
export interface StreamSettings {
|
|
16
|
+
/** 文字出现的方式 */
|
|
17
|
+
style: StreamStyle;
|
|
18
|
+
/** 推进速度档位 1..5(3 = 标准) */
|
|
19
|
+
speed: number;
|
|
20
|
+
/** 打字光标:流式期间在文字末尾闪烁 */
|
|
21
|
+
caret: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 设置面板里的展示顺序(即时排在最后:它是「不要平滑」的退路) */
|
|
25
|
+
export const STREAM_STYLES: readonly StreamStyle[] = ["smooth", "typewriter", "fade", "instant"];
|
|
26
|
+
export const STREAM_SPEED_MIN = 1;
|
|
27
|
+
export const STREAM_SPEED_MAX = 5;
|
|
28
|
+
export const DEFAULT_STREAM_SETTINGS: StreamSettings = { style: "smooth", speed: 3, caret: false };
|
|
29
|
+
|
|
30
|
+
const KEY = {
|
|
31
|
+
style: "piweb.streamStyle",
|
|
32
|
+
speed: "piweb.streamSpeed",
|
|
33
|
+
caret: "piweb.streamCaret",
|
|
34
|
+
} as const;
|
|
35
|
+
|
|
36
|
+
/** 每帧间隔(约 60fps 起步):档位越高越密 */
|
|
37
|
+
const STEP_MS = [24, 20, 16, 12, 8];
|
|
38
|
+
/** 平滑 / 渐显:每帧推进「积压 / 该值」,值越大追赶越缓、手感越匀(突发不再狂奔) */
|
|
39
|
+
const SMOOTH_DIVISOR = [14, 10, 7, 5, 3];
|
|
40
|
+
/** 平滑 / 渐显:每帧至少推进的字符数,保证收尾时也能走完 */
|
|
41
|
+
const SMOOTH_MIN_CHARS = [1, 1, 1, 2, 3];
|
|
42
|
+
/** 平滑 / 渐显:每帧至多推进的字符数——大块突发被摊到更多帧里缓缓出现,不会一口吞掉 */
|
|
43
|
+
const SMOOTH_MAX_CHARS = [140, 110, 90, 70, 50];
|
|
44
|
+
/** 打字机:每帧固定敲出的字符数 */
|
|
45
|
+
const TYPEWRITER_CHARS = [1, 2, 3, 5, 8];
|
|
46
|
+
/** 打字机也要有底线:积压超过约 1.5 秒的量就跟着加速,不出现「回答完了字还在爬」 */
|
|
47
|
+
const TYPEWRITER_CATCHUP_DIVISOR = 90;
|
|
48
|
+
|
|
49
|
+
export function isStreamStyle(value: string | null | undefined): value is StreamStyle {
|
|
50
|
+
return value === "smooth" || value === "instant" || value === "typewriter" || value === "fade";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function clampStreamSpeed(value: number): number {
|
|
54
|
+
if (!Number.isFinite(value)) return DEFAULT_STREAM_SETTINGS.speed;
|
|
55
|
+
return Math.max(STREAM_SPEED_MIN, Math.min(STREAM_SPEED_MAX, Math.round(value)));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 这一档速度下每帧的间隔(毫秒) */
|
|
59
|
+
export function streamStepMs(speed: number): number {
|
|
60
|
+
return STEP_MS[clampStreamSpeed(speed) - 1];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function readStored(): StreamSettings {
|
|
64
|
+
try {
|
|
65
|
+
const style = localStorage.getItem(KEY.style);
|
|
66
|
+
const speed = Number.parseInt(localStorage.getItem(KEY.speed) ?? "", 10);
|
|
67
|
+
const caret = localStorage.getItem(KEY.caret);
|
|
68
|
+
return {
|
|
69
|
+
style: isStreamStyle(style) ? style : DEFAULT_STREAM_SETTINGS.style,
|
|
70
|
+
speed: Number.isFinite(speed) ? clampStreamSpeed(speed) : DEFAULT_STREAM_SETTINGS.speed,
|
|
71
|
+
caret: caret === null ? DEFAULT_STREAM_SETTINGS.caret : caret === "1" || caret === "true",
|
|
72
|
+
};
|
|
73
|
+
} catch {
|
|
74
|
+
// 隐私模式 / localStorage 不可用:这一份内存偏好照样生效
|
|
75
|
+
return DEFAULT_STREAM_SETTINGS;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function loadStreamSettings(): StreamSettings {
|
|
80
|
+
if (typeof window === "undefined") return DEFAULT_STREAM_SETTINGS;
|
|
81
|
+
return readStored();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function persist(settings: StreamSettings): void {
|
|
85
|
+
if (typeof window === "undefined") return;
|
|
86
|
+
try {
|
|
87
|
+
localStorage.setItem(KEY.style, settings.style);
|
|
88
|
+
localStorage.setItem(KEY.speed, String(settings.speed));
|
|
89
|
+
localStorage.setItem(KEY.caret, settings.caret ? "1" : "0");
|
|
90
|
+
} catch {
|
|
91
|
+
/* 配额满 / 被禁用:内存里已经生效 */
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let cached: StreamSettings | null = null;
|
|
96
|
+
const listeners = new Set<() => void>();
|
|
97
|
+
let storageBound = false;
|
|
98
|
+
|
|
99
|
+
/** useSyncExternalStore 的 getSnapshot:同一份引用,只有真改了才换(否则会无限重渲染) */
|
|
100
|
+
export function getStreamSettings(): StreamSettings {
|
|
101
|
+
if (!cached) cached = loadStreamSettings();
|
|
102
|
+
return cached;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 服务端 / 水合首帧没有 localStorage,给默认值 */
|
|
106
|
+
export function getServerStreamSettings(): StreamSettings {
|
|
107
|
+
return DEFAULT_STREAM_SETTINGS;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function bindStorage(): void {
|
|
111
|
+
if (storageBound || typeof window === "undefined") return;
|
|
112
|
+
storageBound = true;
|
|
113
|
+
window.addEventListener("storage", (event) => {
|
|
114
|
+
if (event.key !== null && event.key !== KEY.style && event.key !== KEY.speed && event.key !== KEY.caret) return;
|
|
115
|
+
cached = loadStreamSettings();
|
|
116
|
+
for (const listener of listeners) listener();
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function subscribeStreamSettings(listener: () => void): () => void {
|
|
121
|
+
listeners.add(listener);
|
|
122
|
+
bindStorage();
|
|
123
|
+
return () => {
|
|
124
|
+
listeners.delete(listener);
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 改一项并持久化,返回合并后的完整偏好;正在流的回答下帧就用新值 */
|
|
129
|
+
export function setStreamSettings(patch: Partial<StreamSettings>): StreamSettings {
|
|
130
|
+
const current = getStreamSettings();
|
|
131
|
+
const next: StreamSettings = {
|
|
132
|
+
style: isStreamStyle(patch.style) ? patch.style : current.style,
|
|
133
|
+
speed: patch.speed === undefined ? current.speed : clampStreamSpeed(patch.speed),
|
|
134
|
+
caret: patch.caret === undefined ? current.caret : patch.caret === true,
|
|
135
|
+
};
|
|
136
|
+
cached = next;
|
|
137
|
+
persist(next);
|
|
138
|
+
for (const listener of listeners) listener();
|
|
139
|
+
return next;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 不在 UTF-16 代理对中间切:切在中间会渲染成一个替换字符(半个 emoji)。
|
|
144
|
+
* 端点越界时按文本长度收口。
|
|
145
|
+
*/
|
|
146
|
+
export function safeSliceEnd(text: string, end: number): number {
|
|
147
|
+
if (end <= 0) return 0;
|
|
148
|
+
if (end >= text.length) return text.length;
|
|
149
|
+
const high = text.charCodeAt(end - 1);
|
|
150
|
+
const low = text.charCodeAt(end);
|
|
151
|
+
if (high >= 0xd800 && high <= 0xdbff && low >= 0xdc00 && low <= 0xdfff) return Math.min(end + 1, text.length);
|
|
152
|
+
return end;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 渐显:只在整词处停 —— 从 end 往回找到最近的分隔符,切在它后面;
|
|
157
|
+
* 这一段里没有分隔符(正在敲的词)就照常长,否则会停在原地走不完。
|
|
158
|
+
*/
|
|
159
|
+
function snapToWordEnd(target: string, from: number, end: number): number {
|
|
160
|
+
if (end >= target.length) return target.length;
|
|
161
|
+
for (let i = end - 1; i >= from; i -= 1) {
|
|
162
|
+
if (/\s/.test(target[i])) return i + 1;
|
|
163
|
+
}
|
|
164
|
+
return end;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 这一帧显示到哪个下标(返回值 = 目标文本前缀长度)。
|
|
169
|
+
* - instant:不排队,直接追平;
|
|
170
|
+
* - smooth:自适应 —— 积压越多推得越快,但每帧有上限:大块突发被摊到多帧缓缓出现;
|
|
171
|
+
* - typewriter:固定节奏逐字,但积压过大时跟着加速(上限约 1.5 秒的落后量);
|
|
172
|
+
* - fade:在 smooth 的基础上按整词推进。
|
|
173
|
+
*/
|
|
174
|
+
export function nextRevealEnd(target: string, shownLength: number, settings: StreamSettings): number {
|
|
175
|
+
const total = target.length;
|
|
176
|
+
if (settings.style === "instant") return total;
|
|
177
|
+
const backlog = total - shownLength;
|
|
178
|
+
if (backlog <= 0) return total;
|
|
179
|
+
const level = clampStreamSpeed(settings.speed) - 1;
|
|
180
|
+
let end: number;
|
|
181
|
+
if (settings.style === "typewriter") {
|
|
182
|
+
end = shownLength + Math.max(TYPEWRITER_CHARS[level], Math.ceil(backlog / TYPEWRITER_CATCHUP_DIVISOR));
|
|
183
|
+
} else {
|
|
184
|
+
end = shownLength + Math.min(
|
|
185
|
+
SMOOTH_MAX_CHARS[level],
|
|
186
|
+
Math.max(SMOOTH_MIN_CHARS[level], Math.ceil(backlog / SMOOTH_DIVISOR[level])),
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
if (end > total) end = total;
|
|
190
|
+
if (settings.style === "fade") end = snapToWordEnd(target, shownLength, end);
|
|
191
|
+
return safeSliceEnd(target, end);
|
|
192
|
+
}
|
package/src/lib/types.ts
CHANGED
|
@@ -116,6 +116,8 @@ export type WebEvent =
|
|
|
116
116
|
| { type: "traj"; entry: TrajEntry; ts: number }
|
|
117
117
|
| { type: "name"; name: string; ts: number }
|
|
118
118
|
| { type: "tools"; active: string[]; all: string[]; toolPreset?: ToolPreset; customActiveTools?: string[] | null; ts: number }
|
|
119
|
+
/** 上下文分项变了(会话启动、工具 / 资源重载、压缩之后):替换快照里的 contextBreakdown */
|
|
120
|
+
| { type: "context_breakdown"; breakdown: ContextBreakdown; ts: number }
|
|
119
121
|
| {
|
|
120
122
|
/** 资源(扩展/技能/模板/命令)重载后的新清单;前端据此刷新 / 菜单与设置面板 */
|
|
121
123
|
type: "resources";
|
|
@@ -248,6 +250,11 @@ export interface ContextBreakdown {
|
|
|
248
250
|
compacted: number;
|
|
249
251
|
/** auto-compact 预留(compaction.reserveTokens 设置值;未启用为 0) */
|
|
250
252
|
autoCompactBuffer: number;
|
|
253
|
+
/**
|
|
254
|
+
* 会话还没启动(只浏览历史不冷启动 agent):systemPrompt / systemTools / customTools 拿不到、为 0,
|
|
255
|
+
* 其余几项照常统计。前端据此提示并提供「现在统计」(prepare 启动会话)。
|
|
256
|
+
*/
|
|
257
|
+
cold?: boolean;
|
|
251
258
|
}
|
|
252
259
|
|
|
253
260
|
/** SSE 连接建立时先发的完整快照 */
|