@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
@@ -7,13 +7,33 @@
7
7
  * 带着 lowlight/highlight.js 的常用语言集,实测占首屏 JS 的 472KB(全部首屏 JS 1272KB),
8
8
  * 而 Hero 页(还没开会话)根本用不到它。ChatWindow 用 next/dynamic 引用本模块,
9
9
  * 首屏不再下载这 472KB;AppShell 在空闲时预取,真正打开会话时通常已经就绪。
10
+ *
11
+ * 流式输出版(v7):
12
+ * - 显示样式可配(见 lib/chat/stream-settings):平滑 / 打字机 / 渐显 / 即时,速度五档、可选打字光标;
13
+ * 设置里改完下一帧生效,正在流的那条回答不用重开。
14
+ * - 逐帧推进:把「已到达的完整文本」和「已显示的文本」分开,每帧只把显示进度往前推一小步,
15
+ * 服务端 token 再来得快、再成串到达,界面也是匀速长出来的,不再一跳一跳。
16
+ * - 增量切分 + 分块记忆化:「已稳定的完整块」按 key 记忆化,扫描进度也复用上一帧,
17
+ * 每帧代价只与「新增的那几个字」成正比(旧版每帧重扫全文才是长回答的卡顿来源)。
18
+ * - 代码围栏内的 tail 用纯文本渲染,等闭合后再交给 highlight.js 高亮一次,
19
+ * 避免流式代码块每帧重新高亮。
20
+ * - 贴底听对话区的(见 chat/chat-scroll);回答结束后先把还没放出来的字放完,再换成整段渲染。
10
21
  */
11
- import { memo, useEffect, useRef, useState } from "react";
22
+ import { memo, useContext, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
12
23
  import ReactMarkdown from "react-markdown";
13
24
  import rehypeHighlight from "rehype-highlight";
14
25
  import remarkGfm from "remark-gfm";
26
+ import { ChatScrollContext } from "@/components/chat/chat-scroll";
15
27
  import { IconCheckOutline14, IconCopyOutline16 } from "@/components/icons";
16
28
  import { copyText } from "@/lib/clipboard";
29
+ import {
30
+ getServerStreamSettings,
31
+ getStreamSettings,
32
+ nextRevealEnd,
33
+ streamStepMs,
34
+ subscribeStreamSettings,
35
+ type StreamSettings,
36
+ } from "@/lib/chat/stream-settings";
17
37
  import { useI18n } from "@/i18n";
18
38
 
19
39
  type MarkdownTextNode = { value?: unknown; children?: MarkdownTextNode[] };
@@ -84,51 +104,327 @@ function SafeImage({ src, alt, title }: { src?: unknown; alt?: string; title?: s
84
104
  );
85
105
  }
86
106
 
107
+ /**
108
+ * 只有 Markdown 内容、没有 .md 外层容器的纯内容组件。
109
+ * 流式分块时,多个块共用同一个 .md 容器(外边距规则因此照旧生效),
110
+ * 每块各自记忆化:只要 text 没变就不会重新解析。
111
+ */
112
+ const MarkdownContent = memo(function MarkdownContent({ text }: { text: string }) {
113
+ return (
114
+ <ReactMarkdown
115
+ remarkPlugins={[remarkGfm]}
116
+ rehypePlugins={[[rehypeHighlight, { detect: false, ignoreMissing: true }]]}
117
+ components={{
118
+ a: ({ node: _node, ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" />,
119
+ img: ({ src, alt, title }) => <SafeImage src={src} alt={alt} title={title} />,
120
+ pre: ({ node, children, ...props }) => (
121
+ <div className="md-code-block">
122
+ <div className="md-code-copy"><CopyButton text={markdownNodeText(node)} /></div>
123
+ <pre {...props}>{children}</pre>
124
+ </div>
125
+ ),
126
+ }}
127
+ >
128
+ {text}
129
+ </ReactMarkdown>
130
+ );
131
+ });
132
+
87
133
  export const MarkdownBlock = memo(function MarkdownBlock({ text }: { text: string }) {
88
134
  return (
89
135
  <div className="md">
90
- <ReactMarkdown
91
- remarkPlugins={[remarkGfm]}
92
- rehypePlugins={[[rehypeHighlight, { detect: false, ignoreMissing: true }]]}
93
- components={{
94
- a: ({ node: _node, ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" />,
95
- img: ({ src, alt, title }) => <SafeImage src={src} alt={alt} title={title} />,
96
- pre: ({ node, children, ...props }) => (
97
- <div className="md-code-block">
98
- <div className="md-code-copy"><CopyButton text={markdownNodeText(node)} /></div>
99
- <pre {...props}>{children}</pre>
100
- </div>
101
- ),
102
- }}
103
- >
104
- {text}
105
- </ReactMarkdown>
136
+ <MarkdownContent text={text} />
106
137
  </div>
107
138
  );
108
139
  });
109
140
 
110
- /** 流式正文的节流渲染(见 ChatWindow 的说明):这里只负责延迟把稳定文本交给 MarkdownBlock */
111
- const STREAM_MARKDOWN_THROTTLE_MS = 100;
141
+ // ---------- 流式渲染 ----------
142
+
143
+ /** 列表项(含有序列表);用于避免在松散列表内部的空行处切块 */
144
+ const LIST_ITEM_RE = /^ {0,3}(?:[-*+]|\d+[.)])\s/;
145
+ /** 围栏行:标记 + 其后的内容(开头行是语言等信息,闭合行只能是空白) */
146
+ const FENCE_RE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
147
+ /** 缩进起头的行:上面列表项的续行,或缩进代码块——都和前面连着,不从这里切 */
148
+ const INDENTED_RE = /^[ \t]+\S/;
149
+
150
+ interface FenceState {
151
+ inFence: boolean;
152
+ char: string;
153
+ length: number;
154
+ }
155
+
156
+ /**
157
+ * 按 CommonMark 推进围栏状态,返回这一行开启 / 闭合了围栏,或者都不是。
158
+ * 闭合行必须是同一种字符、不短于开头、后面没有别的内容:围栏里出现 "```js" 或更短的 "```"
159
+ * 都还是代码。反引号开头行的信息里不能再有反引号,否则那一行只是普通文本。
160
+ */
161
+ function advanceFence(state: FenceState, line: string): "open" | "close" | null {
162
+ const fence = FENCE_RE.exec(line);
163
+ if (!fence) return null;
164
+ const [, marker, rest] = fence;
165
+ if (!state.inFence) {
166
+ if (marker[0] === "`" && rest.includes("`")) return null;
167
+ state.inFence = true;
168
+ state.char = marker[0];
169
+ state.length = marker.length;
170
+ return "open";
171
+ }
172
+ if (marker[0] === state.char && marker.length >= state.length && rest.trim() === "") {
173
+ state.inFence = false;
174
+ return "close";
175
+ }
176
+ return null;
177
+ }
178
+
179
+ export interface StreamingSplit {
180
+ /** 已稳定的完整块:文本不再变,各自记忆化,不会跟着尾巴一起重新解析 */
181
+ blocks: string[];
182
+ /** 正在生成的尾巴 */
183
+ tail: string;
184
+ /** 尾巴是否落在未闭合的代码围栏里 */
185
+ tailInFence: boolean;
186
+ }
187
+
188
+ /**
189
+ * 增量切分器:流式文本只会往后长,已经扫过的行不可能再改变判定,所以只扫新增的行。
190
+ * 每帧代价与「新增文本」成正比,长回答不会随长度增长而越来越卡(旧版每帧重扫全文)。
191
+ *
192
+ * 切分规则:
193
+ * - 只在代码围栏之外的空行处切:空行是天然的块边界,两侧各自都是完整可解析的 Markdown;
194
+ * - 还在列表里、下一行又是列表项(松散列表)时不切,保持有序列表序号连续。列表项下面缩进的
195
+ * 续段不算离开列表;空行后顶格的非列表行才算;
196
+ * - 下一行缩进起头时不切:那是上面列表项的续段(或缩进代码块),切开会被当成独立段落;
197
+ * - 半行(还没换行符)不急着判:半行 "2." 看着不像列表项,写完 "2. 乙" 才是,判早了会
198
+ * 把松散列表切开、序号重排。只有「再长也不会改变判定」时才就地判,否则等下一行写完。
199
+ */
200
+ export function createStreamingSplitter(): { update: (text: string) => StreamingSplit } {
201
+ let prevText = "";
202
+ let scanPos = 0;
203
+ let fence: FenceState = { inFence: false, char: "", length: 0 };
204
+ let segStart = 0;
205
+ /** 最近一次顶格内容是列表项(还在列表里) */
206
+ let inList = false;
207
+ let prevBlank = true;
208
+ let blocks: string[] = [];
209
+
210
+ const reset = () => {
211
+ prevText = "";
212
+ scanPos = 0;
213
+ fence = { inFence: false, char: "", length: 0 };
214
+ segStart = 0;
215
+ inList = false;
216
+ prevBlank = true;
217
+ blocks = [];
218
+ };
219
+
220
+ return {
221
+ update(text: string): StreamingSplit {
222
+ // 被整体替换(编辑重发、切会话)时重头扫;正常流式是前缀延长
223
+ if (!text.startsWith(prevText)) reset();
224
+ prevText = text;
225
+ while (scanPos < text.length) {
226
+ const nl = text.indexOf("\n", scanPos);
227
+ // 半行不判:等换行到了再判(围栏行、空行边界都可能被这半行改变)
228
+ if (nl === -1) break;
229
+ const line = text.slice(scanPos, nl);
230
+ const wasInFence = fence.inFence;
231
+ const fenceStep = advanceFence(fence, line);
232
+ const blank = line.trim() === "";
233
+ if (!fence.inFence && blank) {
234
+ const nextStart = nl + 1;
235
+ const nextNl = text.indexOf("\n", nextStart);
236
+ const nextLine = text.slice(nextStart, nextNl === -1 ? text.length : nextNl);
237
+ const nextIndented = INDENTED_RE.test(nextLine);
238
+ // 下一行是否已经写完,或者「再长也不会改变判定」:缩进起头的永远不切;
239
+ // 不在列表里时,非空的下一行写成什么都要切
240
+ const decidable =
241
+ nextNl !== -1 ||
242
+ (nextStart < text.length && nextLine.trim() !== "" && (nextIndented || !inList));
243
+ if (!decidable) break; // scanPos 留在空行处,等下一行写完再判
244
+ const keepTogether = nextIndented || (inList && LIST_ITEM_RE.test(nextLine));
245
+ if (!keepTogether && nextLine.trim() !== "") {
246
+ const seg = text.slice(segStart, nextStart);
247
+ if (seg.trim()) blocks.push(seg);
248
+ segStart = nextStart;
249
+ }
250
+ }
251
+ // 列表状态只看围栏外的行:列表项进入列表;顶格的围栏、或空行后顶格的普通行离开列表
252
+ if (!wasInFence && !blank) {
253
+ if (LIST_ITEM_RE.test(line)) inList = true;
254
+ else if (fenceStep === "open" ? !INDENTED_RE.test(line) : prevBlank && !INDENTED_RE.test(line)) inList = false;
255
+ }
256
+ if (!wasInFence || !fence.inFence) prevBlank = blank;
257
+ scanPos = nl + 1;
258
+ }
259
+ return { blocks: blocks.slice(), tail: text.slice(segStart), tailInFence: fence.inFence };
260
+ },
261
+ };
262
+ }
263
+
264
+ /**
265
+ * 把落在未闭合围栏里的尾巴拆成「围栏前的 Markdown」和「围栏里的代码」。
266
+ * 开头那行(```python)不能当代码露出来;正在敲、还没换行的闭合行也不露。
267
+ */
268
+ export function splitOpenFence(tail: string): { before: string; lang: string; code: string } {
269
+ const state: FenceState = { inFence: false, char: "", length: 0 };
270
+ let openAt = -1;
271
+ let codeAt = 0;
272
+ let lang = "";
273
+ let pos = 0;
274
+ while (pos < tail.length) {
275
+ const nl = tail.indexOf("\n", pos);
276
+ // 开头行一定是完整行:切分器只按完整行判围栏
277
+ if (nl === -1) break;
278
+ const line = tail.slice(pos, nl);
279
+ if (advanceFence(state, line) === "open") {
280
+ openAt = pos;
281
+ codeAt = nl + 1;
282
+ lang = (FENCE_RE.exec(line)?.[2] ?? "").trim().split(/\s+/)[0] ?? "";
283
+ }
284
+ pos = nl + 1;
285
+ }
286
+ if (openAt === -1 || !state.inFence) return { before: tail, lang: "", code: "" };
287
+ let code = tail.slice(codeAt);
288
+ const partial = code.slice(code.lastIndexOf("\n") + 1);
289
+ const marker = partial.trimStart();
290
+ if (marker && partial.length - marker.length <= 3 && marker[0] === state.char && /^(`+|~+)\s*$/.test(marker)) {
291
+ code = code.slice(0, code.length - partial.length);
292
+ }
293
+ return { before: tail.slice(0, openAt), lang, code };
294
+ }
295
+
296
+ /** 一次性切分(冷启动 / 测试用);流式循环里请复用 createStreamingSplitter 的扫描进度 */
297
+ export function splitStreamingMarkdown(text: string): StreamingSplit {
298
+ return createStreamingSplitter().update(text);
299
+ }
300
+
301
+ /** 流式偏好:设置面板一改,这里立刻换样式(正在流的那条回答不用重开) */
302
+ function useStreamSettings(): StreamSettings {
303
+ return useSyncExternalStore(subscribeStreamSettings, getStreamSettings, getServerStreamSettings);
304
+ }
305
+
306
+ interface StreamView extends StreamingSplit {
307
+ /** 当前显示出来的部分(始终是目标文本的前缀) */
308
+ shown: string;
309
+ }
310
+
311
+ /**
312
+ * live:回答还在流。结束后(live 变 false)组件不急着换掉——先按原节奏把还没放出来的字放完,
313
+ * 追平后再渲染成整段 Markdown(和历史消息同一种渲染,脚注、引用式链接这类跨块语法也就对了)。
314
+ * 一结束就整段替换的话,打字机这类慢样式积压的一两百字会一口吐出来。
315
+ */
316
+ export const StreamingMarkdownBlock = memo(function StreamingMarkdownBlock({ text, live = true }: { text: string; live?: boolean }) {
317
+ const settings = useStreamSettings();
318
+ const scroll = useContext(ChatScrollContext);
319
+ const splitterRef = useRef<{ update: (text: string) => StreamingSplit } | null>(null);
320
+ if (!splitterRef.current) splitterRef.current = createStreamingSplitter();
321
+ const [view, setView] = useState<StreamView>(() => ({ shown: text, ...splitterRef.current!.update(text) }));
322
+ const viewRef = useRef(view);
323
+ const targetRef = useRef(text);
324
+ const settingsRef = useRef(settings);
325
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
326
+
327
+ const commit = (shown: string) => {
328
+ const next: StreamView = { shown, ...splitterRef.current!.update(shown) };
329
+ viewRef.current = next;
330
+ setView(next);
331
+ };
332
+
333
+ const stopTimer = () => {
334
+ if (timerRef.current !== null) clearTimeout(timerRef.current);
335
+ timerRef.current = null;
336
+ };
112
337
 
113
- export const StreamingMarkdownBlock = memo(function StreamingMarkdownBlock({ text }: { text: string }) {
114
- const [shown, setShown] = useState(text);
115
- const latest = useRef(text);
116
- const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
338
+ /** 一帧:只把显示进度往前推一点。用 setTimeout 而非 rAF:后台标签页 / 测试环境同样确定。 */
339
+ const step = () => {
340
+ timerRef.current = null;
341
+ const target = targetRef.current;
342
+ const shown = viewRef.current.shown;
343
+ if (shown === target) return; // 追平,收工(下次文本变化再启动)
344
+ if (!target.startsWith(shown)) {
345
+ commit(target); // 目标被整体替换(编辑重发):直接跟上,不再逐帧
346
+ return;
347
+ }
348
+ const current = settingsRef.current;
349
+ commit(target.slice(0, nextRevealEnd(target, shown.length, current)));
350
+ timerRef.current = setTimeout(step, streamStepMs(current.speed));
351
+ };
352
+
353
+ /** 起节拍(已经在跑就不动);「即时」不排队,直接追平 */
354
+ const start = () => {
355
+ if (timerRef.current !== null) return;
356
+ const current = settingsRef.current;
357
+ if (current.style === "instant") {
358
+ if (viewRef.current.shown !== targetRef.current) commit(targetRef.current);
359
+ return;
360
+ }
361
+ timerRef.current = setTimeout(step, streamStepMs(current.speed));
362
+ };
363
+
364
+ // 目标变化:更新目标,节拍没在跑就起一个。已有节拍时不重置计时器(防抖会让正文冻住)
365
+ useEffect(() => {
366
+ targetRef.current = text;
367
+ if (text !== viewRef.current.shown) start();
368
+ }, [text]);
369
+
370
+ // 偏好变化即时生效:切成「即时」就停表追平,切回别的样式就继续起节拍
117
371
  useEffect(() => {
118
- latest.current = text;
119
- // 节流而不是防抖:计时器只在空闲时才设,期间到来的新文本只更新 latest、不重置计时。
120
- // 若每次变化都清掉重来,文本一直在流(约 16ms 一次)时计时器永远到不了点,正文就冻住了。
121
- if (text === shown || timer.current) return;
122
- timer.current = setTimeout(() => {
123
- timer.current = null;
124
- setShown(latest.current);
125
- }, STREAM_MARKDOWN_THROTTLE_MS);
126
- }, [text, shown]);
127
- useEffect(() => () => {
128
- if (timer.current) clearTimeout(timer.current);
129
- timer.current = null;
130
- }, []);
131
- return <MarkdownBlock text={shown} />;
372
+ settingsRef.current = settings;
373
+ if (viewRef.current.shown === targetRef.current) return;
374
+ if (settings.style === "instant") {
375
+ stopTimer();
376
+ commit(targetRef.current);
377
+ return;
378
+ }
379
+ start();
380
+ }, [settings]);
381
+
382
+ useEffect(() => () => stopTimer(), []);
383
+
384
+ const settled = !live && view.shown === text;
385
+
386
+ // 逐帧推进会让内容往下长:提交后(绘制前)跟着贴底,界面不再跳动。
387
+ // 贴不贴听对话区的(往上翻过、大纲正在跳转都会松开),这里不另量距离、不另设阈值;
388
+ // 追平那一帧和换成整段渲染那一帧同样会变高,也要贴。
389
+ useLayoutEffect(() => {
390
+ if (scroll?.shouldPin()) scroll.pin();
391
+ }, [view, settled, scroll]);
392
+
393
+ if (settled) return <MarkdownBlock text={text} />;
394
+
395
+ const { shown, blocks, tail, tailInFence } = view;
396
+ // 尾巴常驻同一个 key 的容器:块升级时尾巴原地更新而不是整棵重挂——
397
+ // 重挂会让渐显动画在「已经显示过的内容」上重播一遍,正是输出时闪烁几下的来源。
398
+ // 围栏未闭合时用与最终渲染相同的结构(md-code-block > pre > code,含复制按钮,同样不折行),
399
+ // 闭合升级只有语法着色的差别,没有布局跳变;开头的 ``` 行不进代码。
400
+ let tailNode: React.ReactNode;
401
+ if (tailInFence) {
402
+ const { before, lang, code } = splitOpenFence(tail);
403
+ tailNode = (
404
+ <>
405
+ {before.trim() ? <MarkdownContent text={before} /> : null}
406
+ <div className="md-code-block">
407
+ <div className="md-code-copy"><CopyButton text={code} /></div>
408
+ <pre><code className={lang ? `hljs language-${lang}` : undefined}>{code}</code></pre>
409
+ </div>
410
+ </>
411
+ );
412
+ } else {
413
+ tailNode = <MarkdownContent text={tail} />;
414
+ }
415
+
416
+ return (
417
+ <div
418
+ className={settings.caret ? "md pw-stream pw-stream-caret" : "md pw-stream"}
419
+ data-stream-style={settings.style}
420
+ data-stream-pending={shown === text ? undefined : ""}
421
+ >
422
+ {blocks.map((block, i) => <MarkdownContent key={i} text={block} />)}
423
+ {tail
424
+ ? <div className="pw-stream-tail" key="tail">{tailNode}</div>
425
+ : null}
426
+ </div>
427
+ );
132
428
  });
133
429
 
134
430
  export default MarkdownBlock;
@@ -9,9 +9,13 @@
9
9
  *
10
10
  * 公开地址由 Cloudflare 随机分配且每次启动都变;拿到地址后还要十几秒才在全球 DNS 生效,
11
11
  * 所以状态里区分「已生成」和「已生效」,没生效时按钮区域给出说明而不是假装好了。
12
+ *
13
+ * 入口链接带启动令牌:手机打开就直接登录,不用再去终端里抄令牌。屏幕上只显示裸地址,
14
+ * 令牌只在链接和复制出来的文本里——截图、共享屏幕时不会露出来。
12
15
  */
13
16
  import { useCallback, useEffect, useRef, useState } from "react";
14
17
  import { useI18n } from "@/i18n";
18
+ import { copyText } from "@/lib/clipboard";
15
19
 
16
20
  interface LanState {
17
21
  running: boolean;
@@ -36,6 +40,14 @@ interface RemoteStatus {
36
40
  blocked: "auth" | "dev" | null;
37
41
  lan: LanState;
38
42
  public: PublicState;
43
+ /** 本次启动的令牌;认证没开时为 null */
44
+ loginToken: string | null;
45
+ }
46
+
47
+ /** 带令牌的登录链接。令牌只在根路径的 GET 上被换成 Cookie(见 proxy.ts),所以拼成 <地址>/?token=… */
48
+ export function loginLink(url: string, token: string | null | undefined): string {
49
+ if (!token) return url;
50
+ return `${url.replace(/\/+$/, "")}/?token=${encodeURIComponent(token)}`;
39
51
  }
40
52
 
41
53
  /** 状态点:绿=在跑,蓝=过渡中,灰=没开 */
@@ -56,6 +68,33 @@ function Row({ title, hint, children }: { title: string; hint?: string; children
56
68
  );
57
69
  }
58
70
 
71
+ /** 一个入口地址:显示裸地址,链接和复制的都是带令牌的版本 */
72
+ function EntryLink({ url, token }: { url: string; token: string | null | undefined }) {
73
+ const { t } = useI18n();
74
+ const [copied, setCopied] = useState<"" | "ok" | "fail">("");
75
+ useEffect(() => {
76
+ if (!copied) return;
77
+ const timer = setTimeout(() => setCopied(""), 2_000);
78
+ return () => clearTimeout(timer);
79
+ }, [copied]);
80
+ const href = loginLink(url, token);
81
+ return (
82
+ <div className="flex items-center gap-2">
83
+ <a href={href} target="_blank" rel="noreferrer" className="font-mono hover:underline" style={{ fontSize: 12 }}>
84
+ {url}
85
+ </a>
86
+ <button
87
+ type="button"
88
+ className="btn-outline"
89
+ style={{ height: 24, fontSize: 12, paddingInline: 8 }}
90
+ onClick={() => void copyText(href).then((ok) => setCopied(ok ? "ok" : "fail"))}
91
+ >
92
+ {copied === "ok" ? t.remoteLinkCopied : copied === "fail" ? t.copyFailed : t.copy}
93
+ </button>
94
+ </div>
95
+ );
96
+ }
97
+
59
98
  export function RemoteSection() {
60
99
  const { t } = useI18n();
61
100
  const [status, setStatus] = useState<RemoteStatus | null>(null);
@@ -168,9 +207,7 @@ export function RemoteSection() {
168
207
  </span>
169
208
  </div>
170
209
  {(lan?.urls ?? []).map((url) => (
171
- <a key={url} href={url} target="_blank" rel="noreferrer" className="font-mono hover:underline" style={{ fontSize: 12 }}>
172
- {url}
173
- </a>
210
+ <EntryLink key={url} url={url} token={status?.loginToken} />
174
211
  ))}
175
212
  {lan?.error ? <div style={{ fontSize: 12, color: "var(--dsw-danger)" }}>{lan.error}</div> : null}
176
213
  </div>
@@ -185,11 +222,7 @@ export function RemoteSection() {
185
222
  {pub?.running ? (pub.ready ? t.remoteRunning : t.remotePending) : t.remoteStopped}
186
223
  </span>
187
224
  </div>
188
- {pub?.url ? (
189
- <a href={pub.url} target="_blank" rel="noreferrer" className="font-mono hover:underline" style={{ fontSize: 12 }}>
190
- {pub.url}
191
- </a>
192
- ) : null}
225
+ {pub?.url ? <EntryLink url={pub.url} token={status?.loginToken} /> : null}
193
226
  <div className="flex items-center gap-2">
194
227
  {pub?.running ? (
195
228
  <button className="btn-outline" style={{ height: 30, fontSize: 13 }} disabled={busy} onClick={() => void act("stop")}>