@rexvane/piweb 0.3.20 → 0.3.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.next/BUILD_ID +1 -1
- package/.next/app-path-routes-manifest.json +5 -5
- package/.next/build-manifest.json +3 -3
- package/.next/prerender-manifest.json +4 -4
- package/.next/react-loadable-manifest.json +4 -4
- package/.next/required-server-files.js +2 -1
- package/.next/required-server-files.json +2 -1
- package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_global-error.html +1 -1
- package/.next/server/app/_global-error.rsc +1 -1
- package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_not-found.html +1 -1
- package/.next/server/app/_not-found.rsc +2 -2
- package/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
- package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/api/agent/[id]/events/route.js +3 -3
- package/.next/server/app/api/agent/[id]/events/route.js.nft.json +1 -1
- package/.next/server/app/api/agent/[id]/route.js +1 -1
- package/.next/server/app/api/agent/[id]/route.js.nft.json +1 -1
- package/.next/server/app/api/agent/[id]/tree/route.js +1 -1
- package/.next/server/app/api/agent/[id]/tree/route.js.nft.json +1 -1
- package/.next/server/app/api/agent/new/route.js +1 -1
- package/.next/server/app/api/agent/new/route.js.nft.json +1 -1
- package/.next/server/app/api/growth/route.js +1 -1
- package/.next/server/app/api/growth/route.js.nft.json +1 -1
- package/.next/server/app/api/models/route.js +1 -1
- package/.next/server/app/api/models/route.js.nft.json +1 -1
- package/.next/server/app/api/plugins/route.js +1 -1
- package/.next/server/app/api/plugins/route.js.nft.json +1 -1
- package/.next/server/app/api/prompts/route.js +1 -1
- package/.next/server/app/api/prompts/route.js.nft.json +1 -1
- package/.next/server/app/api/security/route.js +1 -1
- package/.next/server/app/api/security/route.js.nft.json +1 -1
- package/.next/server/app/api/sessions/[id]/export/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/export/route.js.nft.json +1 -1
- package/.next/server/app/api/sessions/[id]/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/route.js.nft.json +1 -1
- package/.next/server/app/api/sessions/route.js +1 -1
- package/.next/server/app/api/sessions/route.js.nft.json +1 -1
- package/.next/server/app/api/skills/route.js +2 -2
- package/.next/server/app/api/skills/route.js.nft.json +1 -1
- package/.next/server/app/api/update/route.js +2 -2
- package/.next/server/app/api/update/route.js.nft.json +1 -1
- package/.next/server/app/api/version/route.js +1 -1
- package/.next/server/app/api/workspaces/route.js +1 -1
- package/.next/server/app/api/workspaces/route.js.nft.json +1 -1
- package/.next/server/app/index.html +1 -1
- package/.next/server/app/index.rsc +4 -4
- package/.next/server/app/index.segments/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/index.segments/_full.segment.rsc +4 -4
- package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/login/page_client-reference-manifest.js +1 -1
- package/.next/server/app/login.html +1 -1
- package/.next/server/app/login.rsc +3 -3
- package/.next/server/app/login.segments/_full.segment.rsc +3 -3
- package/.next/server/app/login.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +3 -3
- package/.next/server/app/page.js +1 -1
- package/.next/server/app/page.js.nft.json +1 -1
- package/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/server/app-paths-manifest.json +5 -5
- package/.next/server/chunks/421.js +1 -0
- package/.next/server/chunks/496.js +1 -1
- package/.next/server/chunks/662.js +2 -0
- package/.next/server/chunks/686.js +1 -1
- package/.next/server/chunks/807.js +1 -1
- package/.next/server/instrumentation.js +1 -1
- package/.next/server/instrumentation.js.nft.json +1 -1
- package/.next/server/middleware-build-manifest.js +1 -1
- package/.next/server/middleware-react-loadable-manifest.js +1 -1
- package/.next/server/pages/404.html +1 -1
- package/.next/server/pages/500.html +1 -1
- package/.next/server/server-reference-manifest.json +1 -1
- package/.next/static/chunks/4686-882531aef1d2e250.js +1 -0
- package/.next/static/chunks/{4778.e4c9ed093a413bc4.js → 4778.305dc6abb9889627.js} +1 -1
- package/.next/static/chunks/839.bfd2db36483faa3e.js +1 -0
- package/.next/static/chunks/8552.25c39ef4c995eba0.js +1 -0
- package/.next/static/chunks/app/page-c04af72e549695bd.js +1 -0
- package/.next/static/chunks/{webpack-3614a8337843456b.js → webpack-9b4bb574e5817654.js} +1 -1
- package/.next/static/css/ea27b9e30f76303f.css +1 -0
- package/README.md +14 -5
- package/next.config.ts +4 -2
- package/package.json +1 -1
- package/scripts/release.mjs +1 -1
- package/src/app/api/agent/[id]/events/route.ts +42 -13
- package/src/app/api/update/route.ts +18 -2
- package/src/app/globals.css +89 -0
- package/src/components/AppShell.tsx +7 -3
- package/src/components/ChatInput.tsx +1 -0
- package/src/components/ChatWindow.tsx +105 -81
- package/src/components/ContextMeter.tsx +31 -0
- package/src/components/DrawerPanel.tsx +19 -26
- package/src/components/ExtensionUI.tsx +8 -2
- package/src/components/MarkdownBlock.tsx +333 -37
- package/src/components/SettingsPanel.tsx +122 -53
- package/src/components/chat/chat-scroll.ts +18 -0
- package/src/components/update/CopyCommand.tsx +34 -0
- package/src/components/update/UpdateNotice.tsx +130 -0
- package/src/components/update/update-store.ts +155 -0
- package/src/hooks/usePiWeb.ts +42 -25
- package/src/i18n.tsx +74 -4
- package/src/instrumentation-node.ts +3 -0
- package/src/lib/agent-manager.ts +100 -57
- package/src/lib/chat/event-stream.ts +113 -0
- package/src/lib/chat/stream-settings.ts +192 -0
- package/src/lib/types.ts +7 -0
- package/src/lib/update/startup-check.ts +117 -0
- package/src/lib/update-service.ts +23 -3
- package/.next/server/chunks/131.js +0 -1
- package/.next/server/chunks/243.js +0 -1
- package/.next/static/chunks/4686-b1e9fa876d0ea671.js +0 -1
- package/.next/static/chunks/839.d3fe337a9117c325.js +0 -1
- package/.next/static/chunks/8552.6bfe3e101250b20f.js +0 -1
- package/.next/static/chunks/app/page-2e425ef83fba0782.js +0 -1
- package/.next/static/css/7391c42e1ae617f9.css +0 -1
- /package/.next/static/{_Y3Lq0lI5RJ1tFSOnDi0L → sNX5GQkOWuK5YEID1hT-C}/_buildManifest.js +0 -0
- /package/.next/static/{_Y3Lq0lI5RJ1tFSOnDi0L → sNX5GQkOWuK5YEID1hT-C}/_ssgManifest.js +0 -0
|
@@ -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
|
-
<
|
|
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
|
-
|
|
111
|
-
|
|
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
|
-
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}, [
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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;
|
|
@@ -32,9 +32,21 @@ import {
|
|
|
32
32
|
type CustomProviderSetup,
|
|
33
33
|
} from "@/components/ProviderSetupModal";
|
|
34
34
|
import { RemoteSection } from "@/components/RemoteSection";
|
|
35
|
+
import { CopyCommand } from "@/components/update/CopyCommand";
|
|
36
|
+
import { checkUpdate, loadUpdates, runUpdate, useUpdates, type UpdateTarget } from "@/components/update/update-store";
|
|
35
37
|
import { useI18n } from "@/i18n";
|
|
36
38
|
import { applyPebrelTheme, loadPebrelTheme, loadThemeMode, type PebrelTheme, type ThemeMode } from "@/lib/theme";
|
|
37
39
|
import { applyFontChoice, CUSTOM_PREFIX, FONT_PRESETS, loadFontChoice } from "@/lib/font";
|
|
40
|
+
import {
|
|
41
|
+
DEFAULT_STREAM_SETTINGS,
|
|
42
|
+
getStreamSettings,
|
|
43
|
+
setStreamSettings,
|
|
44
|
+
STREAM_SPEED_MAX,
|
|
45
|
+
STREAM_SPEED_MIN,
|
|
46
|
+
STREAM_STYLES,
|
|
47
|
+
type StreamSettings,
|
|
48
|
+
type StreamStyle,
|
|
49
|
+
} from "@/lib/chat/stream-settings";
|
|
38
50
|
import { modelDraftFromConfig, serializeProviderDraft, validateModelDrafts, type ModelDraft } from "@/lib/model-draft";
|
|
39
51
|
import { customApiOptions } from "@/lib/provider-display";
|
|
40
52
|
import { quotaWindowKind, supportsUsageProbe, type BalanceAmount, type BalanceDetailKey, type ProviderUsage, type QuotaWindow, type XaiUsage } from "@/lib/provider-usage";
|
|
@@ -174,6 +186,7 @@ function GeneralSection({
|
|
|
174
186
|
const [themeMode, setThemeMode] = useState<ThemeMode>("system");
|
|
175
187
|
const [fontSize, setFontSize] = useState(14);
|
|
176
188
|
const [chatFontSize, setChatFontSize] = useState(14);
|
|
189
|
+
const [stream, setStream] = useState<StreamSettings>(DEFAULT_STREAM_SETTINGS);
|
|
177
190
|
const [enterBehavior, setEnterBehavior] = useState<EnterBehavior>("steer");
|
|
178
191
|
const [piSettings, setPiSettings] = useState<{ compaction: { enabled: boolean; reserveTokens: number; keepRecentTokens: number }; retry: { enabled: boolean; maxRetries: number; baseDelayMs: number } } | null>(null);
|
|
179
192
|
const [versions, setVersions] = useState<{ piWeb: string; piEngine: string } | null>(null);
|
|
@@ -222,6 +235,8 @@ function GeneralSection({
|
|
|
222
235
|
if (!Number.isNaN(fs)) setFontSize(fs);
|
|
223
236
|
const chatFs = parseInt(localStorage.getItem("piweb.chatFontSize") ?? "14", 10);
|
|
224
237
|
if (!Number.isNaN(chatFs)) setChatFontSize(chatFs);
|
|
238
|
+
// 流式输出偏好与主题/字体同源(localStorage),同样等挂载后再读,避免水合不一致
|
|
239
|
+
setStream(getStreamSettings());
|
|
225
240
|
setEnterBehavior(getEnterBehavior());
|
|
226
241
|
void loadRemoteSettings();
|
|
227
242
|
}, []);
|
|
@@ -254,6 +269,9 @@ function GeneralSection({
|
|
|
254
269
|
document.documentElement.style.setProperty("--piweb-chat-font-size", `${v}px`);
|
|
255
270
|
};
|
|
256
271
|
|
|
272
|
+
/** 流式输出:样式 / 速度 / 光标。改完即持久化,正在流出的回答下一帧就换(订阅在 MarkdownBlock 里) */
|
|
273
|
+
const pickStream = (patch: Partial<StreamSettings>) => setStream(setStreamSettings(patch));
|
|
274
|
+
|
|
257
275
|
const pickEnter = (v: EnterBehavior) => {
|
|
258
276
|
setEnterBehavior(v);
|
|
259
277
|
saveEnterBehavior(v);
|
|
@@ -316,6 +334,20 @@ function GeneralSection({
|
|
|
316
334
|
{ id: "pebrel", label: t.themePebrel, light: "#F3F4F6", dark: "#0F111A", accent: "#52A8FF" },
|
|
317
335
|
];
|
|
318
336
|
|
|
337
|
+
const streamStyleText: Record<StreamStyle, { label: string; desc: string }> = {
|
|
338
|
+
smooth: { label: t.streamStyleSmooth, desc: t.streamStyleSmoothDesc },
|
|
339
|
+
typewriter: { label: t.streamStyleTypewriter, desc: t.streamStyleTypewriterDesc },
|
|
340
|
+
fade: { label: t.streamStyleFade, desc: t.streamStyleFadeDesc },
|
|
341
|
+
instant: { label: t.streamStyleInstant, desc: t.streamStyleInstantDesc },
|
|
342
|
+
};
|
|
343
|
+
const streamSpeedText: Record<number, string> = {
|
|
344
|
+
1: t.streamSpeed1,
|
|
345
|
+
2: t.streamSpeed2,
|
|
346
|
+
3: t.streamSpeed3,
|
|
347
|
+
4: t.streamSpeed4,
|
|
348
|
+
5: t.streamSpeed5,
|
|
349
|
+
};
|
|
350
|
+
|
|
319
351
|
return (
|
|
320
352
|
<div className="flex flex-col gap-6">
|
|
321
353
|
{(settingsLoading || settingsError) && (
|
|
@@ -405,6 +437,79 @@ function GeneralSection({
|
|
|
405
437
|
<Row title={t.fontMonoFamily} desc={t.fontMonoFamilyDesc}>
|
|
406
438
|
<FontFamilyPicker scope="mono" />
|
|
407
439
|
</Row>
|
|
440
|
+
<RowColumn title={t.streamStyleTitle} desc={t.streamStyleDesc}>
|
|
441
|
+
<div className="flex w-full flex-wrap gap-2">
|
|
442
|
+
{STREAM_STYLES.map((id) => {
|
|
443
|
+
const active = stream.style === id;
|
|
444
|
+
return (
|
|
445
|
+
<button
|
|
446
|
+
key={id}
|
|
447
|
+
type="button"
|
|
448
|
+
className="flex flex-col items-start gap-1 text-left transition-colors"
|
|
449
|
+
style={{
|
|
450
|
+
flex: "1 1 150px",
|
|
451
|
+
padding: "12px 16px",
|
|
452
|
+
borderRadius: 14,
|
|
453
|
+
border: `0.5px solid ${active ? "var(--dsw-border-strong)" : "var(--dsw-border-l4)"}`,
|
|
454
|
+
background: active ? "var(--dsw-module-platform)" : "transparent",
|
|
455
|
+
color: "var(--dsw-label-primary)",
|
|
456
|
+
lineHeight: "18px",
|
|
457
|
+
}}
|
|
458
|
+
aria-pressed={active}
|
|
459
|
+
onClick={() => pickStream({ style: id })}
|
|
460
|
+
>
|
|
461
|
+
<span className="flex items-center gap-1.5" style={{ fontSize: 14 }}>
|
|
462
|
+
{active ? <IconCheckOutline14 size={13} /> : null}
|
|
463
|
+
{streamStyleText[id].label}
|
|
464
|
+
</span>
|
|
465
|
+
<span style={{ fontSize: 11.5, color: "var(--dsw-label-caption)" }}>{streamStyleText[id].desc}</span>
|
|
466
|
+
</button>
|
|
467
|
+
);
|
|
468
|
+
})}
|
|
469
|
+
</div>
|
|
470
|
+
</RowColumn>
|
|
471
|
+
<RowColumn title={t.streamSpeedTitle} desc={t.streamSpeedDesc}>
|
|
472
|
+
<div className="flex w-full flex-wrap gap-2">
|
|
473
|
+
{Array.from({ length: STREAM_SPEED_MAX - STREAM_SPEED_MIN + 1 }, (_, i) => STREAM_SPEED_MIN + i).map((level) => {
|
|
474
|
+
const active = stream.speed === level;
|
|
475
|
+
return (
|
|
476
|
+
<button
|
|
477
|
+
key={level}
|
|
478
|
+
type="button"
|
|
479
|
+
className="transition-colors disabled:opacity-50"
|
|
480
|
+
// 即时样式不排队,速度对它没有意义:置灰,免得以为调了没反应
|
|
481
|
+
disabled={stream.style === "instant"}
|
|
482
|
+
style={{
|
|
483
|
+
flex: "1 1 84px",
|
|
484
|
+
padding: "10px 8px",
|
|
485
|
+
borderRadius: 12,
|
|
486
|
+
border: `0.5px solid ${active ? "var(--dsw-border-strong)" : "var(--dsw-border-l4)"}`,
|
|
487
|
+
background: active ? "var(--dsw-module-platform)" : "transparent",
|
|
488
|
+
color: active ? "var(--dsw-label-primary)" : "var(--dsw-label-secondary)",
|
|
489
|
+
fontSize: 13,
|
|
490
|
+
}}
|
|
491
|
+
aria-pressed={active}
|
|
492
|
+
onClick={() => pickStream({ speed: level })}
|
|
493
|
+
>
|
|
494
|
+
{streamSpeedText[level]}
|
|
495
|
+
</button>
|
|
496
|
+
);
|
|
497
|
+
})}
|
|
498
|
+
</div>
|
|
499
|
+
</RowColumn>
|
|
500
|
+
<Row title={t.streamCaretTitle} desc={t.streamCaretDesc}>
|
|
501
|
+
<button
|
|
502
|
+
type="button"
|
|
503
|
+
className="relative h-5 w-9 flex-none rounded-full transition-colors"
|
|
504
|
+
style={{ background: stream.caret ? "var(--dsw-accent)" : "var(--dsw-border-l3)" }}
|
|
505
|
+
role="switch"
|
|
506
|
+
aria-checked={stream.caret}
|
|
507
|
+
aria-label={t.streamCaretTitle}
|
|
508
|
+
onClick={() => pickStream({ caret: !stream.caret })}
|
|
509
|
+
>
|
|
510
|
+
<span className="absolute top-0.5 h-4 w-4 rounded-full bg-white transition-all" style={{ left: stream.caret ? 18 : 2 }} />
|
|
511
|
+
</button>
|
|
512
|
+
</Row>
|
|
408
513
|
<Row title={t.enterBehavior} desc={t.enterBehaviorDesc}>
|
|
409
514
|
<SelectOption
|
|
410
515
|
value={enterBehavior}
|
|
@@ -479,61 +584,20 @@ function VersionLink({ href, value }: { href: string; value?: string }) {
|
|
|
479
584
|
);
|
|
480
585
|
}
|
|
481
586
|
|
|
482
|
-
type UpdatePhase = "idle" | "checking" | "available" | "updating" | "latest" | "updated" | "error";
|
|
483
|
-
|
|
484
587
|
/** 检查更新 / 更新控件:npm 安装的 piweb 走 npm install -g @rexvane/piweb@latest(服务端固定参数),
|
|
485
|
-
* Git 检出仍走 git pull + 本地构建;pi 走 npm install @latest
|
|
486
|
-
|
|
588
|
+
* Git 检出仍走 git pull + 本地构建;pi 走 npm install @latest(npm 安装只换依赖、不重建)。
|
|
589
|
+
* 状态和右下角提示条共用(update-store):启动检查的结果一到,这里直接显示「更新到 vX」或「已是最新」。 */
|
|
590
|
+
function UpdateControl({ target }: { target: UpdateTarget }) {
|
|
487
591
|
const { t } = useI18n();
|
|
488
|
-
const
|
|
489
|
-
const [latest, setLatest] = useState("");
|
|
490
|
-
const [command, setCommand] = useState("");
|
|
491
|
-
const [error, setError] = useState("");
|
|
592
|
+
const { phase, latest, command, error, busy: sessionBusy } = useUpdates().targets[target];
|
|
492
593
|
const busy = phase === "checking" || phase === "updating";
|
|
493
594
|
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
headers: { "Content-Type": "application/json" },
|
|
498
|
-
body: JSON.stringify({ target, action }),
|
|
499
|
-
});
|
|
500
|
-
return r.json();
|
|
501
|
-
};
|
|
502
|
-
|
|
503
|
-
const check = async () => {
|
|
504
|
-
setPhase("checking");
|
|
505
|
-
setError("");
|
|
506
|
-
try {
|
|
507
|
-
const j = await post("check");
|
|
508
|
-
if (!j.success) throw new Error(j.error);
|
|
509
|
-
if (j.data.canUpdate) {
|
|
510
|
-
setLatest(j.data.latest);
|
|
511
|
-
setCommand(j.data.command ?? "");
|
|
512
|
-
setPhase("available");
|
|
513
|
-
} else {
|
|
514
|
-
setPhase("latest");
|
|
515
|
-
}
|
|
516
|
-
} catch (e: any) {
|
|
517
|
-
setError(String(e?.message ?? e));
|
|
518
|
-
setPhase("error");
|
|
519
|
-
}
|
|
520
|
-
};
|
|
521
|
-
|
|
522
|
-
const run = async () => {
|
|
523
|
-
setPhase("updating");
|
|
524
|
-
try {
|
|
525
|
-
const j = await post("update");
|
|
526
|
-
if (!j.success) throw new Error(j.error);
|
|
527
|
-
setLatest(j.data.version ?? latest);
|
|
528
|
-
setPhase("updated");
|
|
529
|
-
} catch (e: any) {
|
|
530
|
-
setError(String(e?.message ?? e));
|
|
531
|
-
setPhase("error");
|
|
532
|
-
}
|
|
533
|
-
};
|
|
595
|
+
useEffect(() => {
|
|
596
|
+
void loadUpdates();
|
|
597
|
+
}, []);
|
|
534
598
|
|
|
535
599
|
if (phase === "idle" || phase === "latest" || phase === "error") {
|
|
536
|
-
const label = phase === "latest" ? t.upToDate : phase === "error" ? t.updateFailed : t.checkUpdate;
|
|
600
|
+
const label = phase === "latest" ? t.upToDate : phase === "error" ? (sessionBusy ? t.updateBusy : t.updateFailed) : t.checkUpdate;
|
|
537
601
|
const color = phase === "latest" ? "var(--dsw-label-tertiary)" : phase === "error" ? "var(--dsw-danger)" : "var(--dsw-label-primary)";
|
|
538
602
|
return (
|
|
539
603
|
<button
|
|
@@ -542,7 +606,7 @@ function UpdateControl({ target }: { target: "piweb" | "pi" }) {
|
|
|
542
606
|
style={{ height: 26, padding: "0 10px", fontSize: 12, color }}
|
|
543
607
|
title={error || undefined}
|
|
544
608
|
disabled={busy}
|
|
545
|
-
onClick={() => void
|
|
609
|
+
onClick={() => void checkUpdate(target)}
|
|
546
610
|
>
|
|
547
611
|
{label}
|
|
548
612
|
</button>
|
|
@@ -553,19 +617,24 @@ function UpdateControl({ target }: { target: "piweb" | "pi" }) {
|
|
|
553
617
|
if (command) {
|
|
554
618
|
return (
|
|
555
619
|
<span className="flex flex-col items-end gap-0.5" style={{ maxWidth: 300 }}>
|
|
556
|
-
<
|
|
620
|
+
<CopyCommand command={command} />
|
|
557
621
|
<span style={{ fontSize: 11, color: "var(--dsw-label-tertiary)", textAlign: "right" }}>{t.updateManualHint}</span>
|
|
558
622
|
</span>
|
|
559
623
|
);
|
|
560
624
|
}
|
|
561
625
|
return (
|
|
562
|
-
<button type="button" className="btn-primary-white" style={{ height: 26, padding: "0 12px", fontSize: 12 }} onClick={() => void
|
|
626
|
+
<button type="button" className="btn-primary-white" style={{ height: 26, padding: "0 12px", fontSize: 12 }} onClick={() => void runUpdate(target)}>
|
|
563
627
|
{t.updateTo.replace("{v}", latest)}
|
|
564
628
|
</button>
|
|
565
629
|
);
|
|
566
630
|
}
|
|
567
631
|
if (phase === "updated") {
|
|
568
|
-
return
|
|
632
|
+
return (
|
|
633
|
+
<span className="flex flex-col items-end gap-0.5" style={{ maxWidth: 280, textAlign: "right" }}>
|
|
634
|
+
<span style={{ fontSize: 12, color: "var(--dsw-success)" }}>{t.updatedTo.replace("{v}", latest)}</span>
|
|
635
|
+
<span style={{ fontSize: 11, color: "var(--dsw-label-tertiary)" }}>{t.updateRestartHint}</span>
|
|
636
|
+
</span>
|
|
637
|
+
);
|
|
569
638
|
}
|
|
570
639
|
return (
|
|
571
640
|
<span className="flex items-center gap-1.5" style={{ fontSize: 12, color: "var(--dsw-label-tertiary)" }}>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { createContext } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 对话区的贴底状态,交给流式正文用。
|
|
5
|
+
*
|
|
6
|
+
* 流式正文每帧往下长一点,长完要不要跟着滚到底,只能听对话区的:它按 96px 判断贴底、
|
|
7
|
+
* 用户往上翻(或大纲 / 搜索在平滑跳转)就松开。正文自己再量一遍距离、另设阈值,
|
|
8
|
+
* 两边说法不一,就会把已经翻上去的人拽回来、把刚起步的平滑跳转打断。
|
|
9
|
+
* 不在对话区里(没有 Provider)就不贴底。
|
|
10
|
+
*/
|
|
11
|
+
export interface ChatScroll {
|
|
12
|
+
/** 现在是否贴着底部(读的是对话区的同一个 ref,不另做判断) */
|
|
13
|
+
shouldPin(): boolean;
|
|
14
|
+
/** 立刻贴到底部 */
|
|
15
|
+
pin(): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const ChatScrollContext = createContext<ChatScroll | null>(null);
|