@rexvane/piweb 0.3.19 → 0.3.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.next/BUILD_ID +1 -1
- package/.next/app-path-routes-manifest.json +4 -4
- package/.next/build-manifest.json +3 -3
- package/.next/prerender-manifest.json +4 -4
- package/.next/react-loadable-manifest.json +4 -4
- package/.next/required-server-files.js +2 -1
- package/.next/required-server-files.json +2 -1
- package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_global-error.html +1 -1
- package/.next/server/app/_global-error.rsc +1 -1
- package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_not-found.html +1 -1
- package/.next/server/app/_not-found.rsc +2 -2
- package/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
- package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/api/agent/[id]/events/route.js +3 -3
- package/.next/server/app/api/agent/[id]/events/route.js.nft.json +1 -1
- package/.next/server/app/api/agent/[id]/route.js +1 -1
- package/.next/server/app/api/agent/[id]/route.js.nft.json +1 -1
- package/.next/server/app/api/agent/[id]/tree/route.js +1 -1
- package/.next/server/app/api/agent/[id]/tree/route.js.nft.json +1 -1
- package/.next/server/app/api/agent/new/route.js +1 -1
- package/.next/server/app/api/agent/new/route.js.nft.json +1 -1
- package/.next/server/app/api/growth/route.js +1 -1
- package/.next/server/app/api/growth/route.js.nft.json +1 -1
- package/.next/server/app/api/models/route.js +1 -1
- package/.next/server/app/api/models/route.js.nft.json +1 -1
- package/.next/server/app/api/plugins/route.js +1 -1
- package/.next/server/app/api/plugins/route.js.nft.json +1 -1
- package/.next/server/app/api/prompts/route.js +1 -1
- package/.next/server/app/api/prompts/route.js.nft.json +1 -1
- package/.next/server/app/api/remote/route.js +2 -2
- package/.next/server/app/api/security/route.js +1 -1
- package/.next/server/app/api/security/route.js.nft.json +1 -1
- package/.next/server/app/api/sessions/[id]/export/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/export/route.js.nft.json +1 -1
- package/.next/server/app/api/sessions/[id]/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/route.js.nft.json +1 -1
- package/.next/server/app/api/sessions/route.js +1 -1
- package/.next/server/app/api/sessions/route.js.nft.json +1 -1
- package/.next/server/app/api/skills/route.js +2 -2
- package/.next/server/app/api/skills/route.js.nft.json +1 -1
- package/.next/server/app/api/update/route.js +2 -2
- package/.next/server/app/api/update/route.js.nft.json +1 -1
- package/.next/server/app/api/version/route.js +1 -1
- package/.next/server/app/api/workspaces/route.js +1 -1
- package/.next/server/app/api/workspaces/route.js.nft.json +1 -1
- package/.next/server/app/index.html +1 -1
- package/.next/server/app/index.rsc +4 -4
- package/.next/server/app/index.segments/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/index.segments/_full.segment.rsc +4 -4
- package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/login/page_client-reference-manifest.js +1 -1
- package/.next/server/app/login.html +1 -1
- package/.next/server/app/login.rsc +3 -3
- package/.next/server/app/login.segments/_full.segment.rsc +3 -3
- package/.next/server/app/login.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +3 -3
- package/.next/server/app/page.js +1 -1
- package/.next/server/app/page.js.nft.json +1 -1
- package/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/server/app-paths-manifest.json +4 -4
- package/.next/server/chunks/421.js +1 -0
- package/.next/server/chunks/496.js +1 -1
- package/.next/server/chunks/662.js +2 -0
- package/.next/server/chunks/686.js +1 -1
- package/.next/server/chunks/807.js +1 -1
- package/.next/server/chunks/924.js +2 -2
- package/.next/server/instrumentation.js +1 -1
- package/.next/server/instrumentation.js.nft.json +1 -1
- package/.next/server/middleware-build-manifest.js +1 -1
- package/.next/server/middleware-react-loadable-manifest.js +1 -1
- package/.next/server/pages/404.html +1 -1
- package/.next/server/pages/500.html +1 -1
- package/.next/server/server-reference-manifest.json +1 -1
- package/.next/static/chunks/4686-882531aef1d2e250.js +1 -0
- package/.next/static/chunks/{4778.e4c9ed093a413bc4.js → 4778.305dc6abb9889627.js} +1 -1
- package/.next/static/chunks/839.bfd2db36483faa3e.js +1 -0
- package/.next/static/chunks/8552.25c39ef4c995eba0.js +1 -0
- package/.next/static/chunks/app/page-c04af72e549695bd.js +1 -0
- package/.next/static/chunks/webpack-9b4bb574e5817654.js +1 -0
- package/.next/static/css/ea27b9e30f76303f.css +1 -0
- package/README.md +14 -5
- package/next.config.ts +4 -2
- package/package.json +1 -1
- package/scripts/release.mjs +1 -1
- package/src/app/api/agent/[id]/events/route.ts +42 -13
- package/src/app/api/remote/route.ts +12 -2
- package/src/app/api/update/route.ts +18 -2
- package/src/app/globals.css +89 -0
- package/src/components/AppShell.tsx +7 -3
- package/src/components/ChatInput.tsx +1 -0
- package/src/components/ChatWindow.tsx +105 -81
- package/src/components/ContextMeter.tsx +31 -0
- package/src/components/DrawerPanel.tsx +19 -26
- package/src/components/ExtensionUI.tsx +8 -2
- package/src/components/MarkdownBlock.tsx +333 -37
- package/src/components/RemoteSection.tsx +41 -8
- package/src/components/SettingsPanel.tsx +122 -53
- package/src/components/chat/chat-scroll.ts +18 -0
- package/src/components/update/CopyCommand.tsx +34 -0
- package/src/components/update/UpdateNotice.tsx +130 -0
- package/src/components/update/update-store.ts +155 -0
- package/src/hooks/usePiWeb.ts +42 -25
- package/src/i18n.tsx +82 -10
- package/src/instrumentation-node.ts +3 -0
- package/src/lib/agent-manager.ts +100 -57
- package/src/lib/chat/event-stream.ts +113 -0
- package/src/lib/chat/stream-settings.ts +192 -0
- package/src/lib/remote/mobile-proxy.ts +8 -2
- package/src/lib/remote/supervisor.ts +18 -8
- package/src/lib/types.ts +7 -0
- package/src/lib/update/startup-check.ts +117 -0
- package/src/lib/update-service.ts +23 -3
- package/.next/server/chunks/131.js +0 -1
- package/.next/server/chunks/243.js +0 -1
- package/.next/static/chunks/4686-e3825f3b6a384aa1.js +0 -1
- package/.next/static/chunks/839.7403c6bdace4c56c.js +0 -1
- package/.next/static/chunks/8552.6bfe3e101250b20f.js +0 -1
- package/.next/static/chunks/app/page-2e425ef83fba0782.js +0 -1
- package/.next/static/chunks/webpack-bd2b9202ecbcdcaa.js +0 -1
- package/.next/static/css/7391c42e1ae617f9.css +0 -1
- /package/.next/static/{JDGSR-TIbk9qZWcUNYIX3 → sNX5GQkOWuK5YEID1hT-C}/_buildManifest.js +0 -0
- /package/.next/static/{JDGSR-TIbk9qZWcUNYIX3 → sNX5GQkOWuK5YEID1hT-C}/_ssgManifest.js +0 -0
|
@@ -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);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { useI18n } from "@/i18n";
|
|
5
|
+
import { copyText } from "@/lib/clipboard";
|
|
6
|
+
|
|
7
|
+
/** 要用户自己在终端执行的命令:等宽显示 + 复制按钮(设置行和右下角提示条共用) */
|
|
8
|
+
export function CopyCommand({ command }: { command: string }) {
|
|
9
|
+
const { t } = useI18n();
|
|
10
|
+
const [copied, setCopied] = useState<"" | "ok" | "fail">("");
|
|
11
|
+
return (
|
|
12
|
+
<span className="flex min-w-0 items-center gap-1.5">
|
|
13
|
+
<code
|
|
14
|
+
className="min-w-0 flex-1"
|
|
15
|
+
style={{ fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--dsw-label-primary)", whiteSpace: "nowrap", overflowX: "auto" }}
|
|
16
|
+
>
|
|
17
|
+
{command}
|
|
18
|
+
</code>
|
|
19
|
+
<button
|
|
20
|
+
type="button"
|
|
21
|
+
className="btn-outline flex-none"
|
|
22
|
+
style={{ height: 22, padding: "0 8px", fontSize: 11, color: copied === "fail" ? "var(--dsw-danger)" : undefined }}
|
|
23
|
+
onClick={() => {
|
|
24
|
+
void copyText(command).then((ok) => {
|
|
25
|
+
setCopied(ok ? "ok" : "fail");
|
|
26
|
+
setTimeout(() => setCopied(""), 1500);
|
|
27
|
+
});
|
|
28
|
+
}}
|
|
29
|
+
>
|
|
30
|
+
{copied === "ok" ? t.updateCopied : copied === "fail" ? t.copyFailed : t.copy}
|
|
31
|
+
</button>
|
|
32
|
+
</span>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 右下角的更新提示条:每次服务启动提醒一次(按启动标识记在 localStorage,刷新、多开标签页不重复)。
|
|
5
|
+
*
|
|
6
|
+
* 只列启动检查时就有新版本的那几项:
|
|
7
|
+
* - 能直接更新的点「更新」,按 PiWeb → Pi 的顺序逐个装(同一时间只能有一个更新在跑);
|
|
8
|
+
* - Windows 上的 npm 安装 PiWeb 不能替换运行中的自己,给命令和复制按钮;
|
|
9
|
+
* - 装好后提示手动重启,进程不自己重启。
|
|
10
|
+
* 状态和设置里的版本行共用(update-store),在哪边点都一样。
|
|
11
|
+
*/
|
|
12
|
+
import { useEffect, useState } from "react";
|
|
13
|
+
import { CopyCommand } from "@/components/update/CopyCommand";
|
|
14
|
+
import { loadUpdates, runUpdate, UPDATE_TARGETS, useUpdates, type TargetUpdate, type UpdateTarget } from "@/components/update/update-store";
|
|
15
|
+
import { useI18n } from "@/i18n";
|
|
16
|
+
|
|
17
|
+
/** 已经关掉过的那次启动的标识 */
|
|
18
|
+
export const UPDATE_NOTICE_KEY = "piweb.updateNotice";
|
|
19
|
+
|
|
20
|
+
const LABEL: Record<UpdateTarget, string> = { piweb: "PiWeb", pi: "Pi" };
|
|
21
|
+
|
|
22
|
+
export function UpdateNotice() {
|
|
23
|
+
const { t } = useI18n();
|
|
24
|
+
const updates = useUpdates();
|
|
25
|
+
/** 读过 localStorage 之后才知道这次启动是否已经关掉过(服务端渲染没有 localStorage) */
|
|
26
|
+
const [dismissed, setDismissed] = useState<string | null | undefined>(undefined);
|
|
27
|
+
/** 启动检查时就有新版本的项:提示条只管这些,之后它们变成更新中 / 已更新 / 失败也留在条里 */
|
|
28
|
+
const [offered, setOffered] = useState<UpdateTarget[] | null>(null);
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
try {
|
|
32
|
+
setDismissed(localStorage.getItem(UPDATE_NOTICE_KEY));
|
|
33
|
+
} catch {
|
|
34
|
+
setDismissed(null);
|
|
35
|
+
}
|
|
36
|
+
void loadUpdates();
|
|
37
|
+
}, []);
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (offered === null && updates.loaded) setOffered(UPDATE_TARGETS.filter((target) => updates.targets[target].phase === "available"));
|
|
41
|
+
}, [offered, updates]);
|
|
42
|
+
|
|
43
|
+
if (dismissed === undefined || !offered?.length || !updates.startId || dismissed === updates.startId) return null;
|
|
44
|
+
const startId = updates.startId;
|
|
45
|
+
const items = offered.map((target) => ({ target, ...updates.targets[target] }));
|
|
46
|
+
const updatable = items.filter((item) => !item.command && (item.phase === "available" || item.phase === "error"));
|
|
47
|
+
const updating = items.some((item) => item.phase === "updating");
|
|
48
|
+
const anyUpdated = items.some((item) => item.phase === "updated");
|
|
49
|
+
const dismiss = () => {
|
|
50
|
+
try {
|
|
51
|
+
localStorage.setItem(UPDATE_NOTICE_KEY, startId);
|
|
52
|
+
} catch {
|
|
53
|
+
/* 存不下就只关这一页 */
|
|
54
|
+
}
|
|
55
|
+
setDismissed(startId);
|
|
56
|
+
};
|
|
57
|
+
const updateAll = async () => {
|
|
58
|
+
// 一个一个来:服务端同一时间只允许一个更新;前一个失败(比如有会话在跑)就停下
|
|
59
|
+
for (const item of updatable) {
|
|
60
|
+
if (!(await runUpdate(item.target))) break;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
const title = updating ? t.updateNoticeUpdatingTitle : anyUpdated && !updatable.length ? t.updateNoticeDoneTitle : t.updateNoticeTitle;
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div
|
|
67
|
+
role="status"
|
|
68
|
+
className="pointer-events-auto flex flex-col gap-2 rounded-xl px-3 py-2.5"
|
|
69
|
+
style={{ fontSize: 12.5, background: "var(--dsw-glass-popover)", border: "0.5px solid var(--dsw-border-l2)", boxShadow: "var(--dsw-elevation-soft)", borderLeft: "3px solid var(--dsw-accent)" }}
|
|
70
|
+
data-testid="update-notice"
|
|
71
|
+
>
|
|
72
|
+
<span style={{ fontWeight: 600, color: "var(--dsw-label-primary)" }}>{title}</span>
|
|
73
|
+
{items.map((item) => (
|
|
74
|
+
<div key={item.target} className="flex flex-col gap-1">
|
|
75
|
+
<span className="flex items-center gap-1.5" style={{ color: "var(--dsw-label-secondary)" }}>
|
|
76
|
+
<span style={{ color: "var(--dsw-label-primary)" }}>{LABEL[item.target]}</span>
|
|
77
|
+
<ItemStatus item={item} />
|
|
78
|
+
</span>
|
|
79
|
+
{item.command && item.phase === "available" ? (
|
|
80
|
+
<span className="flex flex-col gap-1">
|
|
81
|
+
<span style={{ fontSize: 11.5, color: "var(--dsw-label-tertiary)" }}>{t.updateRunCommandHint}</span>
|
|
82
|
+
<CopyCommand command={item.command} />
|
|
83
|
+
</span>
|
|
84
|
+
) : null}
|
|
85
|
+
</div>
|
|
86
|
+
))}
|
|
87
|
+
{anyUpdated && !updating ? <span style={{ fontSize: 11.5, color: "var(--dsw-label-tertiary)" }}>{t.updateRestartHint}</span> : null}
|
|
88
|
+
{!updating ? (
|
|
89
|
+
<div className="flex justify-end gap-2">
|
|
90
|
+
<button type="button" className="btn-outline" style={{ height: 26, padding: "0 12px", fontSize: 12 }} onClick={dismiss}>
|
|
91
|
+
{anyUpdated || !updatable.length ? t.updateNoticeOk : t.updateNoticeLater}
|
|
92
|
+
</button>
|
|
93
|
+
{updatable.length ? (
|
|
94
|
+
<button type="button" className="btn-primary-white" style={{ height: 26, padding: "0 12px", fontSize: 12 }} onClick={() => void updateAll()}>
|
|
95
|
+
{t.updateNoticeUpdate}
|
|
96
|
+
</button>
|
|
97
|
+
) : null}
|
|
98
|
+
</div>
|
|
99
|
+
) : null}
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function ItemStatus({ item }: { item: TargetUpdate }) {
|
|
105
|
+
const { t } = useI18n();
|
|
106
|
+
if (item.phase === "updating") {
|
|
107
|
+
return (
|
|
108
|
+
<span className="flex items-center gap-1.5" style={{ color: "var(--dsw-label-tertiary)" }}>
|
|
109
|
+
<span className="h-3 w-3 flex-none animate-spin rounded-full border-2 border-current border-t-transparent" />
|
|
110
|
+
{t.updatingNow}
|
|
111
|
+
</span>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
if (item.phase === "updated") return <span style={{ color: "var(--dsw-success)" }}>{t.updateNoticeUpdated.replace("{v}", item.latest)}</span>;
|
|
115
|
+
if (item.phase === "error") {
|
|
116
|
+
// npm / 构建的报错可能很长:只露三行,完整内容放在悬停提示里
|
|
117
|
+
return (
|
|
118
|
+
<span className="line-clamp-3 min-w-0 break-all" style={{ color: "var(--dsw-danger)" }} title={item.error}>
|
|
119
|
+
{item.busy ? t.updateBusy : `${t.updateFailed}: ${item.error}`}
|
|
120
|
+
</span>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
return <span>{versionChange(item, t.updateCommits)}</span>;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** 「0.3.18 → 0.3.20」;Git 检出版本号没变、只有新提交时写提交数 */
|
|
127
|
+
export function versionChange(item: Pick<TargetUpdate, "current" | "latest" | "behind">, commitsText: string): string {
|
|
128
|
+
if (item.latest === item.current && item.behind > 0) return commitsText.replace("{n}", String(item.behind));
|
|
129
|
+
return `${item.current} → ${item.latest}`;
|
|
130
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 更新状态的前端单例:设置里的两行「PiWeb 版本 / Pi 版本」和右下角提示条共用一份,
|
|
5
|
+
* 在一处点了更新,另一处同步显示进度和结果。
|
|
6
|
+
*
|
|
7
|
+
* 页面打开时取一次启动检查的结果(GET /api/update,服务启动时已经在查),
|
|
8
|
+
* 不用先点「检查更新」就知道有没有新版本。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type UpdateTarget = "piweb" | "pi";
|
|
12
|
+
export type UpdatePhase = "idle" | "checking" | "available" | "updating" | "latest" | "updated" | "error";
|
|
13
|
+
|
|
14
|
+
export interface TargetUpdate {
|
|
15
|
+
phase: UpdatePhase;
|
|
16
|
+
current: string;
|
|
17
|
+
latest: string;
|
|
18
|
+
/** Git 检出:main 上落后的提交数(版本号没变时用它说明「有新提交」) */
|
|
19
|
+
behind: number;
|
|
20
|
+
/** 要用户自己执行的命令(Windows 上的 npm 安装 PiWeb 不能替换运行中的自己);空串表示能直接更新 */
|
|
21
|
+
command: string;
|
|
22
|
+
error: string;
|
|
23
|
+
/** 失败是因为有会话正在运行(409) */
|
|
24
|
+
busy: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface UpdateState {
|
|
28
|
+
/** 启动检查的结果取回来了(成功或失败都算) */
|
|
29
|
+
loaded: boolean;
|
|
30
|
+
/** 本次服务启动的标识,提示条按它做到「每次启动提醒一次」;关掉启动检查时为 null */
|
|
31
|
+
startId: string | null;
|
|
32
|
+
targets: Record<UpdateTarget, TargetUpdate>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const UPDATE_TARGETS: readonly UpdateTarget[] = ["piweb", "pi"];
|
|
36
|
+
|
|
37
|
+
interface CheckData {
|
|
38
|
+
current: string;
|
|
39
|
+
latest: string;
|
|
40
|
+
canUpdate: boolean;
|
|
41
|
+
behind?: number;
|
|
42
|
+
command?: string;
|
|
43
|
+
updatedTo?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const blank = (): TargetUpdate => ({ phase: "idle", current: "", latest: "", behind: 0, command: "", error: "", busy: false });
|
|
47
|
+
const initial = (): UpdateState => ({ loaded: false, startId: null, targets: { piweb: blank(), pi: blank() } });
|
|
48
|
+
|
|
49
|
+
let state: UpdateState = initial();
|
|
50
|
+
let loading: Promise<void> | null = null;
|
|
51
|
+
const listeners = new Set<() => void>();
|
|
52
|
+
|
|
53
|
+
function emit(): void {
|
|
54
|
+
for (const listener of listeners) listener();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function patch(target: UpdateTarget, next: Partial<TargetUpdate>): void {
|
|
58
|
+
state = { ...state, targets: { ...state.targets, [target]: { ...state.targets[target], ...next } } };
|
|
59
|
+
emit();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function fromCheck(check: CheckData): Partial<TargetUpdate> {
|
|
63
|
+
const base = { current: check.current, behind: check.behind ?? 0, error: "", busy: false };
|
|
64
|
+
if (check.updatedTo) return { ...base, phase: "updated", latest: check.updatedTo, command: "" };
|
|
65
|
+
if (check.canUpdate) return { ...base, phase: "available", latest: check.latest, command: check.command ?? "" };
|
|
66
|
+
return { ...base, phase: "latest", latest: check.latest, command: "" };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
class UpdateRequestError extends Error {
|
|
70
|
+
busy: boolean;
|
|
71
|
+
constructor(message: string, busy: boolean) {
|
|
72
|
+
super(message);
|
|
73
|
+
this.busy = busy;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function post(target: UpdateTarget, action: "check" | "update") {
|
|
78
|
+
const response = await fetch("/api/update", {
|
|
79
|
+
method: "POST",
|
|
80
|
+
headers: { "Content-Type": "application/json" },
|
|
81
|
+
body: JSON.stringify({ target, action }),
|
|
82
|
+
});
|
|
83
|
+
const json = await response.json();
|
|
84
|
+
if (!json.success) throw new UpdateRequestError(String(json.error ?? response.status), response.status === 409);
|
|
85
|
+
return json.data;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function failure(error: unknown): Partial<TargetUpdate> {
|
|
89
|
+
return { phase: "error", error: error instanceof Error ? error.message : String(error), busy: error instanceof UpdateRequestError && error.busy };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 取启动检查的结果;每个页面只取一次(设置面板反复开关不重复请求) */
|
|
93
|
+
export function loadUpdates(): Promise<void> {
|
|
94
|
+
loading ??= (async () => {
|
|
95
|
+
try {
|
|
96
|
+
const response = await fetch("/api/update", { cache: "no-store" });
|
|
97
|
+
const json = await response.json();
|
|
98
|
+
if (!json.success) throw new Error(String(json.error));
|
|
99
|
+
const targets = { ...state.targets };
|
|
100
|
+
for (const target of UPDATE_TARGETS) {
|
|
101
|
+
const result = json.data?.results?.[target];
|
|
102
|
+
// 已经手动查过 / 正在更新的不覆盖;启动检查失败的项留在 idle,照旧手动检查
|
|
103
|
+
if (targets[target].phase !== "idle" || !result || "error" in result) continue;
|
|
104
|
+
targets[target] = { ...targets[target], ...fromCheck(result) };
|
|
105
|
+
}
|
|
106
|
+
state = { loaded: true, startId: json.data?.startId ?? null, targets };
|
|
107
|
+
} catch {
|
|
108
|
+
state = { ...state, loaded: true };
|
|
109
|
+
}
|
|
110
|
+
emit();
|
|
111
|
+
})();
|
|
112
|
+
return loading;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function checkUpdate(target: UpdateTarget): Promise<void> {
|
|
116
|
+
patch(target, { phase: "checking", error: "", busy: false });
|
|
117
|
+
try {
|
|
118
|
+
patch(target, fromCheck(await post(target, "check")));
|
|
119
|
+
} catch (error) {
|
|
120
|
+
patch(target, failure(error));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** 更新一项;成功返回 true。装好后要用户手动重启,进程不自己重启 */
|
|
125
|
+
export async function runUpdate(target: UpdateTarget): Promise<boolean> {
|
|
126
|
+
patch(target, { phase: "updating", error: "", busy: false });
|
|
127
|
+
try {
|
|
128
|
+
const data = await post(target, "update");
|
|
129
|
+
patch(target, { phase: "updated", latest: data?.version || state.targets[target].latest, command: "" });
|
|
130
|
+
return true;
|
|
131
|
+
} catch (error) {
|
|
132
|
+
patch(target, failure(error));
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function subscribe(listener: () => void): () => void {
|
|
138
|
+
listeners.add(listener);
|
|
139
|
+
return () => {
|
|
140
|
+
listeners.delete(listener);
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const getState = () => state;
|
|
145
|
+
|
|
146
|
+
export function useUpdates(): UpdateState {
|
|
147
|
+
return useSyncExternalStore(subscribe, getState, getState);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 仅供测试:回到页面刚打开时的状态 */
|
|
151
|
+
export function resetUpdates(): void {
|
|
152
|
+
state = initial();
|
|
153
|
+
loading = null;
|
|
154
|
+
emit();
|
|
155
|
+
}
|