@rexvane/piweb 0.3.19 → 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 +4 -4
- 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/remote/route.js +2 -2
- 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 +4 -4
- 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/chunks/924.js +2 -2
- 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-9b4bb574e5817654.js +1 -0
- 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/remote/route.ts +12 -2
- 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/RemoteSection.tsx +41 -8
- 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 +82 -10
- 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/remote/mobile-proxy.ts +8 -2
- package/src/lib/remote/supervisor.ts +18 -8
- 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-e3825f3b6a384aa1.js +0 -1
- package/.next/static/chunks/839.7403c6bdace4c56c.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/chunks/webpack-bd2b9202ecbcdcaa.js +0 -1
- package/.next/static/css/7391c42e1ae617f9.css +0 -1
- /package/.next/static/{JDGSR-TIbk9qZWcUNYIX3 → sNX5GQkOWuK5YEID1hT-C}/_buildManifest.js +0 -0
- /package/.next/static/{JDGSR-TIbk9qZWcUNYIX3 → sNX5GQkOWuK5YEID1hT-C}/_ssgManifest.js +0 -0
|
@@ -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
|
+
}
|
|
@@ -79,7 +79,11 @@ export interface MobileProxyOptions {
|
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
export function createMobileProxy({ targetPort, targetHost = "127.0.0.1", css = null, js = null, log = () => {} }: MobileProxyOptions): http.Server {
|
|
82
|
-
|
|
82
|
+
// 转回 PiWeb 的这一跳必须直连。设了 NODE_USE_ENV_PROXY=1 时,Node 默认的 globalAgent 会把它交给
|
|
83
|
+
// HTTP_PROXY,代理再按原样转发的 Host(局域网地址:入口端口)连回本入口——自己转给自己,直到本机端口耗尽,
|
|
84
|
+
// 手机和公网隧道只能看到 502。自建的 Agent 不读代理环境变量;参数照抄 Node 的 globalAgent。
|
|
85
|
+
const agent = new http.Agent({ keepAlive: true, scheduling: "lifo", timeout: 5000 });
|
|
86
|
+
const server = http.createServer((req, res) => {
|
|
83
87
|
// 保留原 Host / Origin 原样转发:PiWeb 的 isSafeHost 与 isSafeOrigin 都要看它们,
|
|
84
88
|
// 改写成回环反而会让写操作被判成跨源。
|
|
85
89
|
const headers: http.OutgoingHttpHeaders = { ...req.headers };
|
|
@@ -89,7 +93,7 @@ export function createMobileProxy({ targetPort, targetHost = "127.0.0.1", css =
|
|
|
89
93
|
headers["accept-encoding"] = "identity";
|
|
90
94
|
}
|
|
91
95
|
|
|
92
|
-
const upstream = http.request({ host: targetHost, port: targetPort, method: req.method, path: req.url ?? "/", headers }, (up) => {
|
|
96
|
+
const upstream = http.request({ host: targetHost, port: targetPort, method: req.method, path: req.url ?? "/", headers, agent }, (up) => {
|
|
93
97
|
const type = String(up.headers["content-type"] ?? "");
|
|
94
98
|
if (!styled || !type.includes("text/html")) {
|
|
95
99
|
res.writeHead(up.statusCode ?? 502, up.headers);
|
|
@@ -146,4 +150,6 @@ export function createMobileProxy({ targetPort, targetHost = "127.0.0.1", css =
|
|
|
146
150
|
});
|
|
147
151
|
req.pipe(upstream);
|
|
148
152
|
});
|
|
153
|
+
server.on("close", () => agent.destroy());
|
|
154
|
+
return server;
|
|
149
155
|
}
|
|
@@ -66,14 +66,15 @@ export function createUrlCatcher(pattern: RegExp = PUBLIC_URL_PATTERN) {
|
|
|
66
66
|
*
|
|
67
67
|
* 只看 DNS 不够:域名解析出来之后边缘还可能要几秒才把请求转到这条隧道上。
|
|
68
68
|
* 所以这里发一次 HEAD,把「能连上并拿到 HTTP 响应」当作就绪标准;
|
|
69
|
-
* 4xx
|
|
69
|
+
* 4xx 也算就绪(说明请求已经打到我们的上游了)。5xx 不算:边缘还没接上这条隧道时 Cloudflare 自己回
|
|
70
|
+
* 530(错误 1033),上游连不上时回 502——它们也是 HTTP 响应,当成就绪就会让界面显示「就绪」而手机打开是错误页。
|
|
70
71
|
*/
|
|
71
72
|
function probeTunnel(url: string): Promise<boolean> {
|
|
72
73
|
return new Promise((resolve) => {
|
|
73
74
|
const target = new URL(url);
|
|
74
75
|
const request = https.request({ host: target.hostname, path: "/api/health", method: "HEAD", timeout: 8_000 }, (response) => {
|
|
75
76
|
response.resume();
|
|
76
|
-
resolve(
|
|
77
|
+
resolve((response.statusCode ?? 0) < 500);
|
|
77
78
|
});
|
|
78
79
|
request.on("timeout", () => { request.destroy(); resolve(false); });
|
|
79
80
|
request.on("error", () => resolve(false));
|
|
@@ -224,15 +225,24 @@ export function remoteLanPort(env: Record<string, string | undefined> = process.
|
|
|
224
225
|
return Number.isInteger(value) && value >= 1 && value <= 65535 ? value : DEFAULT_LAN_PORT;
|
|
225
226
|
}
|
|
226
227
|
|
|
227
|
-
/**
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
228
|
+
/** 虚拟机、WSL、容器的虚拟网卡:地址也是私网段,但同一 WiFi 的手机连不到,排到最后 */
|
|
229
|
+
const VIRTUAL_ADAPTER = /vmware|virtualbox|vboxnet|vethernet|hyper-v|wsl|docker|^br-|^veth/i;
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* 所有非回环 IPv4 —— 手机该用哪个地址,UI 直接列出来,真实网卡排在前面。
|
|
233
|
+
* 198.18.0.0/15 是测试保留段,Clash / Mihomo 等代理的 TUN 网卡用它,手机永远连不上,直接不列。
|
|
234
|
+
*/
|
|
235
|
+
export function lanAddresses(interfaces: NodeJS.Dict<os.NetworkInterfaceInfo[]> = os.networkInterfaces()): string[] {
|
|
236
|
+
const real: string[] = [];
|
|
237
|
+
const virtual: string[] = [];
|
|
238
|
+
for (const [name, entries] of Object.entries(interfaces)) {
|
|
231
239
|
for (const entry of entries ?? []) {
|
|
232
|
-
if (entry.family
|
|
240
|
+
if (entry.family !== "IPv4" || entry.internal || entry.address.startsWith("169.254.")) continue;
|
|
241
|
+
if (/^198\.1[89]\./.test(entry.address)) continue;
|
|
242
|
+
(VIRTUAL_ADAPTER.test(name) ? virtual : real).push(entry.address);
|
|
233
243
|
}
|
|
234
244
|
}
|
|
235
|
-
return
|
|
245
|
+
return [...real, ...virtual];
|
|
236
246
|
}
|
|
237
247
|
|
|
238
248
|
export function remoteStatus(): RemoteStatus {
|
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 连接建立时先发的完整快照 */
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 启动时检查一次更新:PiWeb 和 pi 各查一次,结果留在内存里给设置页和右下角提示条用,
|
|
3
|
+
* 有新版本时在终端打一行。离线、registry 不通都不吭声——设置里还能手动检查。
|
|
4
|
+
*
|
|
5
|
+
* 状态挂 globalThis:instrumentation 和路由是不同的 bundle,模块级变量不共享(同 remote/supervisor)。
|
|
6
|
+
* 每次服务启动换一个 startId,页面据此做到「每次启动提醒一次」。
|
|
7
|
+
* 手动检查、更新成功后改写这份缓存:刷新页面不会又显示「更新到 vX」。
|
|
8
|
+
* PI_WEB_UPDATE_CHECK=0 关掉启动检查(不联网),设置里手动检查不受影响。
|
|
9
|
+
*/
|
|
10
|
+
import { randomUUID } from "node:crypto";
|
|
11
|
+
import { checkForUpdate, type UpdateCheck, type UpdateTarget } from "../update-service";
|
|
12
|
+
|
|
13
|
+
/** 一项的结果:检查结果(更新过就带上 updatedTo),或检查失败的原因 */
|
|
14
|
+
export type StartupResult = (UpdateCheck & { updatedTo?: string }) | { error: string };
|
|
15
|
+
|
|
16
|
+
export interface StartupUpdates {
|
|
17
|
+
/** 本次服务启动的标识;关掉启动检查时为 null */
|
|
18
|
+
startId: string | null;
|
|
19
|
+
results: Partial<Record<UpdateTarget, StartupResult>>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface StartupState {
|
|
23
|
+
startId: string;
|
|
24
|
+
results: Partial<Record<UpdateTarget, StartupResult>>;
|
|
25
|
+
pending: Promise<void> | null;
|
|
26
|
+
started: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface StartupOptions {
|
|
30
|
+
check?: (target: UpdateTarget) => Promise<UpdateCheck>;
|
|
31
|
+
log?: (line: string) => void;
|
|
32
|
+
env?: Record<string, string | undefined>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const STATE_KEY = "piweb.update.startup";
|
|
36
|
+
const TARGETS: readonly UpdateTarget[] = ["piweb", "pi"];
|
|
37
|
+
const LABEL: Record<UpdateTarget, string> = { piweb: "PiWeb", pi: "Pi" };
|
|
38
|
+
|
|
39
|
+
function state(): StartupState {
|
|
40
|
+
const store = globalThis as Record<PropertyKey, unknown>;
|
|
41
|
+
const key = Symbol.for(STATE_KEY);
|
|
42
|
+
const existing = store[key] as StartupState | undefined;
|
|
43
|
+
if (existing) return existing;
|
|
44
|
+
const created: StartupState = { startId: randomUUID(), results: {}, pending: null, started: false };
|
|
45
|
+
store[key] = created;
|
|
46
|
+
return created;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function startupCheckEnabled(env: Record<string, string | undefined> = process.env): boolean {
|
|
50
|
+
return env.PI_WEB_UPDATE_CHECK !== "0";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 终端里那一行;没有新版本时为 null */
|
|
54
|
+
export function updateNoticeLine(results: StartupUpdates["results"]): string | null {
|
|
55
|
+
const parts: string[] = [];
|
|
56
|
+
let manual: string | undefined;
|
|
57
|
+
for (const target of TARGETS) {
|
|
58
|
+
const result = results[target];
|
|
59
|
+
if (!result || "error" in result || !result.canUpdate) continue;
|
|
60
|
+
// Git 检出版本号没变、只是 main 上有新提交时,写提交数而不是「0.3.20 → 0.3.20」
|
|
61
|
+
parts.push(result.latest === result.current && result.behind ? `${LABEL[target]}(main 新增 ${result.behind} 个提交)` : `${LABEL[target]} ${result.current} → ${result.latest}`);
|
|
62
|
+
if (!result.selfUpdate && result.command) manual = result.command;
|
|
63
|
+
}
|
|
64
|
+
if (!parts.length) return null;
|
|
65
|
+
const line = `[更新] 有新版本:${parts.join("、")}。打开 设置 → 通用 点「更新」`;
|
|
66
|
+
return manual ? `${line};PiWeb 要先停掉 piweb 再运行:${manual}` : line;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 启动检查(只跑一次);返回检查完之后的状态快照。关掉时返回 null,不联网。
|
|
71
|
+
* instrumentation 在启动时调它,GET /api/update 也调它(页面先到时由页面触发,结果一样只查一次)。
|
|
72
|
+
*/
|
|
73
|
+
export async function ensureStartupCheck({ check = checkForUpdate, log = console.log, env = process.env }: StartupOptions = {}): Promise<StartupUpdates | null> {
|
|
74
|
+
if (!startupCheckEnabled(env)) return null;
|
|
75
|
+
const current = state();
|
|
76
|
+
if (!current.started) {
|
|
77
|
+
current.started = true;
|
|
78
|
+
current.pending = (async () => {
|
|
79
|
+
await Promise.all(TARGETS.map(async (target) => {
|
|
80
|
+
try {
|
|
81
|
+
const result = await check(target);
|
|
82
|
+
// 检查期间已经手动更新过的项不覆盖
|
|
83
|
+
if (!current.results[target]) current.results[target] = result;
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (!current.results[target]) current.results[target] = { error: error instanceof Error ? error.message : String(error) };
|
|
86
|
+
}
|
|
87
|
+
}));
|
|
88
|
+
const line = updateNoticeLine(current.results);
|
|
89
|
+
if (line) log(line);
|
|
90
|
+
})();
|
|
91
|
+
}
|
|
92
|
+
await current.pending;
|
|
93
|
+
return snapshot();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function snapshot(): StartupUpdates {
|
|
97
|
+
const current = state();
|
|
98
|
+
return { startId: current.startId, results: { ...current.results } };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 手动检查的结果覆盖缓存 */
|
|
102
|
+
export function recordCheck(target: UpdateTarget, check: UpdateCheck): void {
|
|
103
|
+
state().results[target] = check;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** 更新成功:记下装好的版本,页面刷新后显示「已更新,重启生效」而不是又让人更新一次 */
|
|
107
|
+
export function markUpdated(target: UpdateTarget, version: string): void {
|
|
108
|
+
const current = state();
|
|
109
|
+
const previous = current.results[target];
|
|
110
|
+
const base = previous && !("error" in previous) ? previous : { current: "", latest: version, canUpdate: false, selfUpdate: true };
|
|
111
|
+
current.results[target] = { ...base, latest: version || base.latest, canUpdate: false, updatedTo: version || base.latest };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 仅供测试:清掉挂在 globalThis 上的状态 */
|
|
115
|
+
export function resetStartupCheck(): void {
|
|
116
|
+
delete (globalThis as Record<PropertyKey, unknown>)[Symbol.for(STATE_KEY)];
|
|
117
|
+
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import fs from "node:fs/promises";
|
|
7
7
|
import path from "node:path";
|
|
8
|
-
import { installPiUpdate, PI_PACKAGE, prepareRelease } from "../../scripts/release.mjs";
|
|
8
|
+
import { installPiUpdate, PI_AI_PACKAGE, PI_PACKAGE, prepareRelease } from "../../scripts/release.mjs";
|
|
9
9
|
import { assertInstallation, readProductionBuild } from "../../scripts/build-output.mjs";
|
|
10
10
|
import { runCommand } from "../../scripts/process-runner.mjs";
|
|
11
11
|
import { isNewer } from "./semver";
|
|
@@ -153,14 +153,34 @@ export function createUpdateService({ root = APP_ROOT, run = runCommand, version
|
|
|
153
153
|
return { ...readProductionBuild(installation, ".next"), version, needsRestart: true };
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
/**
|
|
157
|
+
* 只换 npm 成品包里的 pi 依赖:成品包没有 devDependencies,跑不了 typecheck / 构建,也不需要——
|
|
158
|
+
* pi 两个包都在 serverExternalPackages 里,运行时从这里的 node_modules 加载,重启即用上新版。
|
|
159
|
+
* Windows 也能做:运行中的服务没加载 pi 的原生模块,pi 目录也不是服务的工作目录,不会 EBUSY。
|
|
160
|
+
* 图标不用同步:成品包自带 public/provider-icons,清单也和 pi 版本无关。
|
|
161
|
+
*/
|
|
162
|
+
async function installPiIntoPackage(installation: string, assertIdle: () => Promise<void>): Promise<UpdateResult> {
|
|
163
|
+
await assertIdle();
|
|
164
|
+
await run("npm", ["install", "--save-exact", `${PI_PACKAGE}@latest`, `${PI_AI_PACKAGE}@latest`, "--omit=dev", "--no-audit", "--no-fund"], {
|
|
165
|
+
cwd: installation, env: commandEnv, timeoutMs: 600_000,
|
|
166
|
+
});
|
|
167
|
+
await assertIdle();
|
|
168
|
+
assertInstallation(installation);
|
|
169
|
+
const version = await readPackageVersion(path.join(installation, "node_modules", PI_PACKAGE, "package.json"));
|
|
170
|
+
return { ...readProductionBuild(installation, ".next"), version, needsRestart: true };
|
|
171
|
+
}
|
|
172
|
+
|
|
156
173
|
async function runUpdate(target: UpdateTarget, { assertIdle = async () => {} }: { assertIdle?: () => Promise<void> } = {}): Promise<UpdateResult> {
|
|
157
174
|
assertTarget(target);
|
|
158
175
|
if (updateRunning) throw new UpdateBusyError("an update is already running");
|
|
159
176
|
updateRunning = true;
|
|
160
177
|
try {
|
|
161
178
|
const installation = await checkedRoot();
|
|
162
|
-
// npm 安装:装发布好的成品即可——不需要 Git,也不需要本地构建(成品包自带 .next
|
|
163
|
-
|
|
179
|
+
// npm 安装:装发布好的成品即可——不需要 Git,也不需要本地构建(成品包自带 .next);
|
|
180
|
+
// 单独更新 pi 只换依赖,同样不重建。
|
|
181
|
+
if (isNpmInstall(installation)) {
|
|
182
|
+
return target === "piweb" ? await installFromRegistry(installation, assertIdle) : await installPiIntoPackage(installation, assertIdle);
|
|
183
|
+
}
|
|
164
184
|
let version = "";
|
|
165
185
|
const release = await prepareRelease({
|
|
166
186
|
root: installation, env, run,
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
"use strict";exports.id=131,exports.ids=[131],exports.modules={6512:(a,b,c)=>{c.d(b,{Kt:()=>f,W8:()=>e});let d=["off","minimal","low","medium","high","xhigh","max"];function e(a){return a.reasoning?d.filter(b=>{let c=a.thinkingLevelMap?.[b];return null!==c&&("xhigh"!==b&&"max"!==b||void 0!==c)}):["off"]}function f(a,b){let c=e(a);if(c.includes(b))return b;let f=d.indexOf(b);if(-1===f)return c[0]??"off";for(let a=f;a<d.length;a++){let b=d[a];if(c.includes(b))return b}for(let a=f-1;a>=0;a--){let b=d[a];if(c.includes(b))return b}return c[0]??"off"}}};
|