@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.
Files changed (128) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-path-routes-manifest.json +4 -4
  3. package/.next/build-manifest.json +3 -3
  4. package/.next/prerender-manifest.json +4 -4
  5. package/.next/react-loadable-manifest.json +4 -4
  6. package/.next/required-server-files.js +2 -1
  7. package/.next/required-server-files.json +2 -1
  8. package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  9. package/.next/server/app/_global-error.html +1 -1
  10. package/.next/server/app/_global-error.rsc +1 -1
  11. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  12. package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  13. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  14. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  15. package/.next/server/app/_not-found.html +1 -1
  16. package/.next/server/app/_not-found.rsc +2 -2
  17. package/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  18. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  19. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  20. package/.next/server/app/api/agent/[id]/events/route.js +3 -3
  21. package/.next/server/app/api/agent/[id]/events/route.js.nft.json +1 -1
  22. package/.next/server/app/api/agent/[id]/route.js +1 -1
  23. package/.next/server/app/api/agent/[id]/route.js.nft.json +1 -1
  24. package/.next/server/app/api/agent/[id]/tree/route.js +1 -1
  25. package/.next/server/app/api/agent/[id]/tree/route.js.nft.json +1 -1
  26. package/.next/server/app/api/agent/new/route.js +1 -1
  27. package/.next/server/app/api/agent/new/route.js.nft.json +1 -1
  28. package/.next/server/app/api/growth/route.js +1 -1
  29. package/.next/server/app/api/growth/route.js.nft.json +1 -1
  30. package/.next/server/app/api/models/route.js +1 -1
  31. package/.next/server/app/api/models/route.js.nft.json +1 -1
  32. package/.next/server/app/api/plugins/route.js +1 -1
  33. package/.next/server/app/api/plugins/route.js.nft.json +1 -1
  34. package/.next/server/app/api/prompts/route.js +1 -1
  35. package/.next/server/app/api/prompts/route.js.nft.json +1 -1
  36. package/.next/server/app/api/remote/route.js +2 -2
  37. package/.next/server/app/api/security/route.js +1 -1
  38. package/.next/server/app/api/security/route.js.nft.json +1 -1
  39. package/.next/server/app/api/sessions/[id]/export/route.js +1 -1
  40. package/.next/server/app/api/sessions/[id]/export/route.js.nft.json +1 -1
  41. package/.next/server/app/api/sessions/[id]/route.js +1 -1
  42. package/.next/server/app/api/sessions/[id]/route.js.nft.json +1 -1
  43. package/.next/server/app/api/sessions/route.js +1 -1
  44. package/.next/server/app/api/sessions/route.js.nft.json +1 -1
  45. package/.next/server/app/api/skills/route.js +2 -2
  46. package/.next/server/app/api/skills/route.js.nft.json +1 -1
  47. package/.next/server/app/api/update/route.js +2 -2
  48. package/.next/server/app/api/update/route.js.nft.json +1 -1
  49. package/.next/server/app/api/version/route.js +1 -1
  50. package/.next/server/app/api/workspaces/route.js +1 -1
  51. package/.next/server/app/api/workspaces/route.js.nft.json +1 -1
  52. package/.next/server/app/index.html +1 -1
  53. package/.next/server/app/index.rsc +4 -4
  54. package/.next/server/app/index.segments/__PAGE__.segment.rsc +4 -4
  55. package/.next/server/app/index.segments/_full.segment.rsc +4 -4
  56. package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  57. package/.next/server/app/login/page_client-reference-manifest.js +1 -1
  58. package/.next/server/app/login.html +1 -1
  59. package/.next/server/app/login.rsc +3 -3
  60. package/.next/server/app/login.segments/_full.segment.rsc +3 -3
  61. package/.next/server/app/login.segments/_tree.segment.rsc +2 -2
  62. package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +3 -3
  63. package/.next/server/app/page.js +1 -1
  64. package/.next/server/app/page.js.nft.json +1 -1
  65. package/.next/server/app/page_client-reference-manifest.js +1 -1
  66. package/.next/server/app-paths-manifest.json +4 -4
  67. package/.next/server/chunks/421.js +1 -0
  68. package/.next/server/chunks/496.js +1 -1
  69. package/.next/server/chunks/662.js +2 -0
  70. package/.next/server/chunks/686.js +1 -1
  71. package/.next/server/chunks/807.js +1 -1
  72. package/.next/server/chunks/924.js +2 -2
  73. package/.next/server/instrumentation.js +1 -1
  74. package/.next/server/instrumentation.js.nft.json +1 -1
  75. package/.next/server/middleware-build-manifest.js +1 -1
  76. package/.next/server/middleware-react-loadable-manifest.js +1 -1
  77. package/.next/server/pages/404.html +1 -1
  78. package/.next/server/pages/500.html +1 -1
  79. package/.next/server/server-reference-manifest.json +1 -1
  80. package/.next/static/chunks/4686-882531aef1d2e250.js +1 -0
  81. package/.next/static/chunks/{4778.e4c9ed093a413bc4.js → 4778.305dc6abb9889627.js} +1 -1
  82. package/.next/static/chunks/839.bfd2db36483faa3e.js +1 -0
  83. package/.next/static/chunks/8552.25c39ef4c995eba0.js +1 -0
  84. package/.next/static/chunks/app/page-c04af72e549695bd.js +1 -0
  85. package/.next/static/chunks/webpack-9b4bb574e5817654.js +1 -0
  86. package/.next/static/css/ea27b9e30f76303f.css +1 -0
  87. package/README.md +14 -5
  88. package/next.config.ts +4 -2
  89. package/package.json +1 -1
  90. package/scripts/release.mjs +1 -1
  91. package/src/app/api/agent/[id]/events/route.ts +42 -13
  92. package/src/app/api/remote/route.ts +12 -2
  93. package/src/app/api/update/route.ts +18 -2
  94. package/src/app/globals.css +89 -0
  95. package/src/components/AppShell.tsx +7 -3
  96. package/src/components/ChatInput.tsx +1 -0
  97. package/src/components/ChatWindow.tsx +105 -81
  98. package/src/components/ContextMeter.tsx +31 -0
  99. package/src/components/DrawerPanel.tsx +19 -26
  100. package/src/components/ExtensionUI.tsx +8 -2
  101. package/src/components/MarkdownBlock.tsx +333 -37
  102. package/src/components/RemoteSection.tsx +41 -8
  103. package/src/components/SettingsPanel.tsx +122 -53
  104. package/src/components/chat/chat-scroll.ts +18 -0
  105. package/src/components/update/CopyCommand.tsx +34 -0
  106. package/src/components/update/UpdateNotice.tsx +130 -0
  107. package/src/components/update/update-store.ts +155 -0
  108. package/src/hooks/usePiWeb.ts +42 -25
  109. package/src/i18n.tsx +82 -10
  110. package/src/instrumentation-node.ts +3 -0
  111. package/src/lib/agent-manager.ts +100 -57
  112. package/src/lib/chat/event-stream.ts +113 -0
  113. package/src/lib/chat/stream-settings.ts +192 -0
  114. package/src/lib/remote/mobile-proxy.ts +8 -2
  115. package/src/lib/remote/supervisor.ts +18 -8
  116. package/src/lib/types.ts +7 -0
  117. package/src/lib/update/startup-check.ts +117 -0
  118. package/src/lib/update-service.ts +23 -3
  119. package/.next/server/chunks/131.js +0 -1
  120. package/.next/server/chunks/243.js +0 -1
  121. package/.next/static/chunks/4686-e3825f3b6a384aa1.js +0 -1
  122. package/.next/static/chunks/839.7403c6bdace4c56c.js +0 -1
  123. package/.next/static/chunks/8552.6bfe3e101250b20f.js +0 -1
  124. package/.next/static/chunks/app/page-2e425ef83fba0782.js +0 -1
  125. package/.next/static/chunks/webpack-bd2b9202ecbcdcaa.js +0 -1
  126. package/.next/static/css/7391c42e1ae617f9.css +0 -1
  127. /package/.next/static/{JDGSR-TIbk9qZWcUNYIX3 → sNX5GQkOWuK5YEID1hT-C}/_buildManifest.js +0 -0
  128. /package/.next/static/{JDGSR-TIbk9qZWcUNYIX3 → sNX5GQkOWuK5YEID1hT-C}/_ssgManifest.js +0 -0
@@ -4,6 +4,7 @@
4
4
  * 中央状态:会话列表轮询、当前会话 SSE 订阅、事件折叠(快照→增量)、命令发送。
5
5
  */
6
6
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
7
+ import { pollEventStream, usesEventPolling } from "@/lib/chat/event-stream";
7
8
  import { createEventBatcher, type EventBatcher } from "@/lib/event-batcher";
8
9
  import type {
9
10
  GrowthConsentRequest,
@@ -194,6 +195,9 @@ export const foldPiWebEvent = (state: PiWebState, evt: WebEvent): PiWebState =>
194
195
  tools: { active: evt.active, all: evt.all.map((n) => ({ name: n })) },
195
196
  };
196
197
  return s;
198
+ case "context_breakdown":
199
+ if (s.snapshot) s.snapshot = { ...s.snapshot, contextBreakdown: evt.breakdown };
200
+ return s;
197
201
  case "resources":
198
202
  if (s.snapshot) {
199
203
  s.snapshot = {
@@ -350,7 +354,7 @@ export function usePiWeb() {
350
354
  const [orderBy, setOrderByState] = useState<"updated" | "manual">("updated");
351
355
  const [resyncNonce, setResyncNonce] = useState(0);
352
356
  const [snapshotEpoch, setSnapshotEpoch] = useState(0);
353
- const esRef = useRef<EventSource | null>(null);
357
+ const esRef = useRef<{ close(): void } | null>(null);
354
358
  const promptRequestsRef = useRef(new Set<string>());
355
359
  const presetRestoreAttemptRef = useRef<string | null>(null);
356
360
  const presetRestorePromiseRef = useRef<Promise<void> | null>(null);
@@ -674,38 +678,21 @@ export function usePiWeb() {
674
678
  ? { ...current, connected: false }
675
679
  : { ...emptyState(savedToolPreset()), connected: false });
676
680
  let retryDelayMs = 2000;
681
+ const eventsUrl = `/api/agent/${currentId}/events`;
677
682
  const connect = () => {
678
- const es = new EventSource(`/api/agent/${currentId}/events`);
679
- esRef.current = es;
680
- es.onopen = () => {
681
- retryDelayMs = 2000;
682
- setState((s) => ({ ...s, connected: true }));
683
- };
684
- es.onerror = () => {
685
- setState((s) => ({ ...s, connected: false }));
686
- if (esRef.current !== es) return;
687
- // 网络断开时浏览器自己会重连,并带上 Last-Event-ID 让服务端从环形缓冲续放
688
- // (只补漏掉的几条事件)。这时手动重建就等于丢掉续传、每次都拉一份全量快照。
689
- if (es.readyState !== EventSource.CLOSED) return;
690
- // 浏览器对非 200 响应会永久放弃自动重连:这里手动兜底,关掉后延迟重建,
691
- // 重建时会拿到全新快照。
692
- es.close();
693
- esRef.current = null;
694
- const delay = retryDelayMs;
695
- retryDelayMs = Math.min(retryDelayMs * 2, 30_000);
696
- retryTimer = setTimeout(() => {
697
- if (esRef.current === null) connect();
698
- }, delay);
699
- };
700
683
  // 流式事件按帧合并:token 速率再高也只每 16ms 渲染一次(见 event-batcher)
701
684
  const batcher = createEventBatcher<WebEvent>({
702
685
  flush: (events) => setState((s) => events.reduce((acc, evt) => foldPiWebEvent(acc, evt), s)),
703
686
  });
704
687
  batcherRef.current = batcher;
705
- es.onmessage = (e) => {
688
+ const onOpen = () => {
689
+ retryDelayMs = 2000;
690
+ setState((s) => ({ ...s, connected: true }));
691
+ };
692
+ const onMessage = (data: string) => {
706
693
  let parsed: any;
707
694
  try {
708
- parsed = JSON.parse(e.data);
695
+ parsed = JSON.parse(data);
709
696
  } catch {
710
697
  // 单帧损坏(代理截断等)只丢弃该帧,不能让 EventSource 回调抛异常
711
698
  return;
@@ -737,6 +724,36 @@ export function usePiWeb() {
737
724
  batcher.push(parsed as WebEvent);
738
725
  }
739
726
  };
727
+ // Cloudflare 快速隧道不支持 SSE(流被攒成 128KB 一块,快照永远收不全):改用长轮询,
728
+ // 失败退避、Last-Event-ID 续传都在 pollEventStream 里
729
+ if (usesEventPolling(window.location.hostname)) {
730
+ esRef.current = pollEventStream(eventsUrl, {
731
+ onOpen,
732
+ onMessage,
733
+ onError: () => setState((s) => ({ ...s, connected: false })),
734
+ });
735
+ return;
736
+ }
737
+ const es = new EventSource(eventsUrl);
738
+ esRef.current = es;
739
+ es.onopen = onOpen;
740
+ es.onerror = () => {
741
+ setState((s) => ({ ...s, connected: false }));
742
+ if (esRef.current !== es) return;
743
+ // 网络断开时浏览器自己会重连,并带上 Last-Event-ID 让服务端从环形缓冲续放
744
+ // (只补漏掉的几条事件)。这时手动重建就等于丢掉续传、每次都拉一份全量快照。
745
+ if (es.readyState !== EventSource.CLOSED) return;
746
+ // 浏览器对非 200 响应会永久放弃自动重连:这里手动兜底,关掉后延迟重建,
747
+ // 重建时会拿到全新快照。
748
+ es.close();
749
+ esRef.current = null;
750
+ const delay = retryDelayMs;
751
+ retryDelayMs = Math.min(retryDelayMs * 2, 30_000);
752
+ retryTimer = setTimeout(() => {
753
+ if (esRef.current === null) connect();
754
+ }, delay);
755
+ };
756
+ es.onmessage = (e) => onMessage(e.data);
740
757
  };
741
758
  let retryTimer: ReturnType<typeof setTimeout> | undefined;
742
759
  connect();
package/src/i18n.tsx CHANGED
@@ -199,7 +199,7 @@ const zh = {
199
199
  webAuth: "访问认证",
200
200
  webAuthTokenOn: "已启用(启动令牌)",
201
201
  loginTokenTitle: "这个实例用启动令牌登录",
202
- loginTokenDesc: "请打开启动 PiWeb 时终端里打印的登录链接(形如 …/?token=…)。令牌每次重启都会更换;打开一次后浏览器会记住 30 天。",
202
+ loginTokenDesc: "请打开启动 PiWeb 时终端里打印的登录链接(形如 …/?token=…),或在已登录的电脑上打开 设置 → 远程连接,复制带令牌的地址。令牌每次重启都会更换;打开一次后浏览器会记住 30 天。",
203
203
  logout: "退出登录",
204
204
  authMethodApiKey: "API 密钥",
205
205
  oauthDevicePrompt: "请在浏览器中完成授权",
@@ -323,6 +323,9 @@ const zh = {
323
323
  contextCompacted: "Compacted Data",
324
324
  contextAutoCompactBuffer: "Auto-Compact Buffer",
325
325
  contextFreeSpace: "Free Space",
326
+ contextColdHint: "会话还没启动,System Prompt 和工具定义要启动后才算得出",
327
+ contextCountNow: "现在统计",
328
+ contextCounting: "统计中…",
326
329
  // 统计条
327
330
  turns: "轮次",
328
331
  toolCalls: "工具调用",
@@ -417,9 +420,10 @@ const zh = {
417
420
  setRemote: "远程连接",
418
421
  remoteBuiltin: "内置插件",
419
422
  remoteLanTitle: "局域网入口",
420
- remoteLanHint: "同一个 WiFi 下的设备直接打开下面地址即可,手机排版已适配。默认开启。",
423
+ remoteLanHint: "同一个 WiFi 下的设备直接打开下面地址即可,手机排版已适配。默认开启。链接带登录令牌:点「复制」发到手机,打开就直接登录;令牌即凭据,别发给别人。",
421
424
  remotePublicTitle: "公网入口",
422
- remotePublicHint: "开启后任何浏览器都能打开,无需安装客户端。地址由 Cloudflare 随机分配,每次启动都会变。",
425
+ remotePublicHint: "开启后任何浏览器都能打开,无需安装客户端。地址由 Cloudflare 随机分配,每次启动都会变。链接同样带登录令牌。",
426
+ remoteLinkCopied: "已复制(含令牌)",
423
427
  remoteStart: "开启远程连接",
424
428
  remoteStop: "关闭",
425
429
  remoteWorking: "启动中…",
@@ -531,9 +535,9 @@ const zh = {
531
535
  trustAlways: "总是信任",
532
536
  trustNever: "从不信任",
533
537
  versionPiweb: "PiWeb 版本",
534
- versionPiwebDesc: "本 Web 应用的版本,点击打开仓库",
538
+ versionPiwebDesc: "本 Web 应用的版本;启动时自动检查,有新版本在这里直接更新(点版本号打开仓库)",
535
539
  versionPi: "Pi 版本",
536
- versionPiDesc: "pi 智能体内核的版本,点击打开仓库",
540
+ versionPiDesc: "pi 智能体内核的版本;启动时自动检查,有新版本在这里直接更新(点版本号打开仓库)",
537
541
  checkUpdate: "检查更新",
538
542
  checkingUpdate: "检查中…",
539
543
  upToDate: "已是最新",
@@ -542,6 +546,18 @@ const zh = {
542
546
  updatedTo: "已更新到 {v},重启 PiWeb 后生效",
543
547
  updateFailed: "更新失败",
544
548
  updateManualHint: "先停止 piweb,再执行上面的命令(Windows 无法替换运行中的安装目录)",
549
+ updateCopied: "已复制",
550
+ updateBusy: "有会话正在运行,等它结束再更新",
551
+ updateCommits: "main 新增 {n} 个提交",
552
+ updateNoticeTitle: "有新版本",
553
+ updateNoticeUpdatingTitle: "正在更新…",
554
+ updateNoticeDoneTitle: "更新完成",
555
+ updateNoticeUpdate: "更新",
556
+ updateNoticeLater: "稍后",
557
+ updateNoticeOk: "知道了",
558
+ updateNoticeUpdated: "已更新到 {v}",
559
+ updateRunCommandHint: "先停掉 piweb,再在终端运行:",
560
+ updateRestartHint: "重启 PiWeb 后生效:在运行 piweb 的终端按 Ctrl+C,再重新运行 piweb",
545
561
  skillsDesc: "关闭后技能不会出现在系统提示中,仅可通过 /skill:名称 手动调用",
546
562
  confirmDeleteSkill: "确定删除技能“{name}”吗?将删除其所在技能目录,不可恢复。",
547
563
  deleteSkillHint: "删除技能目录",
@@ -652,6 +668,26 @@ const zh = {
652
668
  viewerNotInSnapshot: "此文件不在快照里(超过 1MB 或被排除),显示磁盘上的当前内容",
653
669
  viewerLoadFailed: "加载失败",
654
670
  viewerEmpty: "从左侧树里选择一个文件",
671
+ // 流式输出
672
+ streamStyleTitle: "流式输出样式",
673
+ streamStyleDesc: "回答逐字出现的方式;改完立刻生效,正在流出的回答也一并换",
674
+ streamStyleSmooth: "平滑",
675
+ streamStyleSmoothDesc: "匀速长出,不跳字",
676
+ streamStyleTypewriter: "打字机",
677
+ streamStyleTypewriterDesc: "固定节奏逐字敲出",
678
+ streamStyleFade: "渐显",
679
+ streamStyleFadeDesc: "整词推进,段落柔和淡入",
680
+ streamStyleInstant: "即时",
681
+ streamStyleInstantDesc: "立即显示,不做平滑",
682
+ streamSpeedTitle: "推进速度",
683
+ streamSpeedDesc: "平滑 / 打字机 / 渐显的节奏快慢;即时样式不受影响",
684
+ streamSpeed1: "慢",
685
+ streamSpeed2: "较慢",
686
+ streamSpeed3: "标准",
687
+ streamSpeed4: "快",
688
+ streamSpeed5: "极快",
689
+ streamCaretTitle: "打字光标",
690
+ streamCaretDesc: "流式期间在文字末尾显示闪烁光标",
655
691
  } as const;
656
692
 
657
693
  type Dict = { [K in keyof typeof zh]: string };
@@ -848,7 +884,7 @@ const en: Dict = {
848
884
  webAuth: "Access authentication",
849
885
  webAuthTokenOn: "Enabled (launch token)",
850
886
  loginTokenTitle: "This instance signs in with a launch token",
851
- loginTokenDesc: "Open the login link printed in the terminal when PiWeb started (…/?token=…). The token changes on every restart; once opened, the browser remembers it for 30 days.",
887
+ loginTokenDesc: "Open the login link printed in the terminal when PiWeb started (…/?token=…), or copy a link from Settings → Remote access on a signed-in computer. The token changes on every restart; once opened, the browser remembers it for 30 days.",
852
888
  logout: "Sign out",
853
889
  authMethodApiKey: "API key",
854
890
  oauthDevicePrompt: "Finish authorization in your browser",
@@ -970,6 +1006,9 @@ const en: Dict = {
970
1006
  contextCompacted: "Compacted Data",
971
1007
  contextAutoCompactBuffer: "Auto-Compact Buffer",
972
1008
  contextFreeSpace: "Free Space",
1009
+ contextColdHint: "Session not started yet — System Prompt and tool definitions are counted once it starts",
1010
+ contextCountNow: "Count now",
1011
+ contextCounting: "Counting…",
973
1012
  turns: "turns",
974
1013
  toolCalls: "tool calls",
975
1014
  cost: "cost",
@@ -1059,9 +1098,10 @@ const en: Dict = {
1059
1098
  setRemote: "Remote access",
1060
1099
  remoteBuiltin: "Built-in plugin",
1061
1100
  remoteLanTitle: "Local network",
1062
- remoteLanHint: "Devices on the same Wi-Fi can open the address below; the mobile layout is already applied. On by default.",
1101
+ remoteLanHint: "Devices on the same Wi-Fi can open the address below; the mobile layout is already applied. On by default. Links carry the login token: copy one to your phone and it signs in directly. The token is a credential — don't share it.",
1063
1102
  remotePublicTitle: "Public access",
1064
- remotePublicHint: "Once on, any browser can open it with nothing to install. Cloudflare assigns a random address that changes on every start.",
1103
+ remotePublicHint: "Once on, any browser can open it with nothing to install. Cloudflare assigns a random address that changes on every start. The link carries the login token too.",
1104
+ remoteLinkCopied: "Copied (with token)",
1065
1105
  remoteStart: "Turn on",
1066
1106
  remoteStop: "Turn off",
1067
1107
  remoteWorking: "Starting…",
@@ -1173,9 +1213,9 @@ const en: Dict = {
1173
1213
  trustAlways: "Always trust",
1174
1214
  trustNever: "Never trust",
1175
1215
  versionPiweb: "PiWeb version",
1176
- versionPiwebDesc: "Version of this web app; click to open the repo",
1216
+ versionPiwebDesc: "Version of this web app; checked at startup and updatable right here (click the version to open the repo)",
1177
1217
  versionPi: "Pi version",
1178
- versionPiDesc: "Version of the pi agent engine; click to open the repo",
1218
+ versionPiDesc: "Version of the pi agent engine; checked at startup and updatable right here (click the version to open the repo)",
1179
1219
  checkUpdate: "Check for updates",
1180
1220
  checkingUpdate: "Checking…",
1181
1221
  upToDate: "Up to date",
@@ -1184,6 +1224,18 @@ const en: Dict = {
1184
1224
  updatedTo: "Updated to {v}; restart PiWeb to apply",
1185
1225
  updateFailed: "Update failed",
1186
1226
  updateManualHint: "Stop piweb, then run the command above (Windows cannot replace a running installation)",
1227
+ updateCopied: "Copied",
1228
+ updateBusy: "A session is running; update after it finishes",
1229
+ updateCommits: "{n} new commits on main",
1230
+ updateNoticeTitle: "Update available",
1231
+ updateNoticeUpdatingTitle: "Updating…",
1232
+ updateNoticeDoneTitle: "Update installed",
1233
+ updateNoticeUpdate: "Update",
1234
+ updateNoticeLater: "Later",
1235
+ updateNoticeOk: "Got it",
1236
+ updateNoticeUpdated: "Updated to {v}",
1237
+ updateRunCommandHint: "Stop piweb, then run in a terminal:",
1238
+ updateRestartHint: "Restart PiWeb to apply: press Ctrl+C in the terminal running piweb, then run piweb again",
1187
1239
  skillsDesc: "Disabled skills are hidden from the system prompt and only callable via /skill:name",
1188
1240
  confirmDeleteSkill: "Delete skill \"{name}\"? Its skill directory will be removed and cannot be recovered.",
1189
1241
  deleteSkillHint: "Delete the skill directory",
@@ -1293,6 +1345,26 @@ const en: Dict = {
1293
1345
  viewerNotInSnapshot: "Not in the snapshot (over 1MB or excluded); showing the current content from disk",
1294
1346
  viewerLoadFailed: "Failed to load",
1295
1347
  viewerEmpty: "Pick a file from the tree on the left",
1348
+ // Streaming text
1349
+ streamStyleTitle: "Streaming text",
1350
+ streamStyleDesc: "How the answer appears while it arrives; applies to the stream already running",
1351
+ streamStyleSmooth: "Smooth",
1352
+ streamStyleSmoothDesc: "Even pace, no jumps",
1353
+ streamStyleTypewriter: "Typewriter",
1354
+ streamStyleTypewriterDesc: "Fixed cadence, character by character",
1355
+ streamStyleFade: "Fade in",
1356
+ streamStyleFadeDesc: "Whole words, paragraphs fade in",
1357
+ streamStyleInstant: "Instant",
1358
+ streamStyleInstantDesc: "Show immediately, no smoothing",
1359
+ streamSpeedTitle: "Streaming speed",
1360
+ streamSpeedDesc: "Cadence for Smooth / Typewriter / Fade in; Instant ignores it",
1361
+ streamSpeed1: "Slow",
1362
+ streamSpeed2: "Relaxed",
1363
+ streamSpeed3: "Standard",
1364
+ streamSpeed4: "Fast",
1365
+ streamSpeed5: "Fastest",
1366
+ streamCaretTitle: "Typing caret",
1367
+ streamCaretDesc: "Blinking caret at the end of the text while streaming",
1296
1368
  };
1297
1369
 
1298
1370
  const dicts: Record<string, Dict> = { zh, en };
@@ -12,6 +12,9 @@ export async function registerNode() {
12
12
  const { authEnabled } = await import("./lib/web-auth");
13
13
  if (authEnabled()) (await import("./lib/web-launch-token")).publishLaunchToken();
14
14
 
15
+ // 每次启动检查一次 PiWeb / pi 有没有新版本:不等结果,查到了在终端打一行,页面右下角也会提醒
16
+ void (await import("./lib/update/startup-check")).ensureStartupCheck().catch(() => {});
17
+
15
18
  const { remoteBlocked, remoteLanEnabled, startLan } = await import("./lib/remote/supervisor");
16
19
  if (!remoteLanEnabled()) return;
17
20
 
@@ -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
- cwd: string,
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
- if (!session) return breakdown;
750
- try {
751
- const prompt = session.systemPrompt ?? "";
752
- // Memory:AGENTS.md 等注入文件(contextResources 的 project 部分)
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 { skills } = session.resourceLoader.getSkills();
760
- if (skills.length && prompt) {
761
- const start = prompt.indexOf("The following skills provide specialized instructions");
762
- const end = prompt.indexOf("</available_skills>");
763
- if (start >= 0 && end > start) breakdown.skills = estimateTokensOf(prompt.slice(start, end + "</available_skills>".length));
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
- } catch {
766
- /* loader 不可用则跳过 */
767
- }
768
- // System Prompt 正文 = 全文减去资源段与 skills 段的近似差分,下限 0
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 entries = m_smEntries(session);
790
- for (let i = entries.length - 1; i >= 0; i -= 1) {
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
- /* entries 不可用则跳过 */
799
+ /* 发现缓存还没好则跳过 */
799
800
  }
800
- // auto-compact 预留 = compaction.reserveTokens(启用时)
801
- try {
802
- const settings = await getPiSettings();
803
- if (settings.compaction.enabled) breakdown.autoCompactBuffer = settings.compaction.reserveTokens;
804
- } catch {
805
- /* 设置读取失败则保持 0 */
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
- /* systemPrompt 不可用则保持 0 */
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
- /** SessionManager 的 getEntries 经由 session 暴露的形态(各版本字段名有差异,这里宽松取) */
814
- function m_smEntries(session: AgentSession): unknown[] {
815
- const s = session as unknown as { sm?: { getEntries?: () => unknown[] }; sessionManager?: { getEntries?: () => unknown[] } };
816
- return s.sm?.getEntries?.() ?? s.sessionManager?.getEntries?.() ?? [];
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.subscribers.size > 0 });
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.subscribers.size > 0 || now - m.lastActive <= IDLE_MS) continue;
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.subscribers.size === 0)
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.subscribers.size > 0) publishResources(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.cwd, m.session, contextResources);
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 {