@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
@@ -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
- function UpdateControl({ target }: { target: "piweb" | "pi" }) {
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 [phase, setPhase] = useState<UpdatePhase>("idle");
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
- const post = async (action: "check" | "update") => {
495
- const r = await fetch("/api/update", {
496
- method: "POST",
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 check()}
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
- <code style={{ fontSize: 11, color: "var(--dsw-label-primary)", whiteSpace: "nowrap", overflowX: "auto", maxWidth: "100%" }}>{command}</code>
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 run()}>
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 <span style={{ fontSize: 12, color: "var(--dsw-success)", maxWidth: 260, textAlign: "right" }}>{t.updatedTo.replace("{v}", latest)}</span>;
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
+ }