@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
|
@@ -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
|
+
}
|
package/src/hooks/usePiWeb.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* 中央状态:会话列表轮询、当前会话 SSE 订阅、事件折叠(快照→增量)、命令发送。
|
|
5
5
|
*/
|
|
6
6
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
7
|
+
import { pollEventStream, usesEventPolling } from "@/lib/chat/event-stream";
|
|
7
8
|
import { createEventBatcher, type EventBatcher } from "@/lib/event-batcher";
|
|
8
9
|
import type {
|
|
9
10
|
GrowthConsentRequest,
|
|
@@ -194,6 +195,9 @@ export const foldPiWebEvent = (state: PiWebState, evt: WebEvent): PiWebState =>
|
|
|
194
195
|
tools: { active: evt.active, all: evt.all.map((n) => ({ name: n })) },
|
|
195
196
|
};
|
|
196
197
|
return s;
|
|
198
|
+
case "context_breakdown":
|
|
199
|
+
if (s.snapshot) s.snapshot = { ...s.snapshot, contextBreakdown: evt.breakdown };
|
|
200
|
+
return s;
|
|
197
201
|
case "resources":
|
|
198
202
|
if (s.snapshot) {
|
|
199
203
|
s.snapshot = {
|
|
@@ -350,7 +354,7 @@ export function usePiWeb() {
|
|
|
350
354
|
const [orderBy, setOrderByState] = useState<"updated" | "manual">("updated");
|
|
351
355
|
const [resyncNonce, setResyncNonce] = useState(0);
|
|
352
356
|
const [snapshotEpoch, setSnapshotEpoch] = useState(0);
|
|
353
|
-
const esRef = useRef<
|
|
357
|
+
const esRef = useRef<{ close(): void } | null>(null);
|
|
354
358
|
const promptRequestsRef = useRef(new Set<string>());
|
|
355
359
|
const presetRestoreAttemptRef = useRef<string | null>(null);
|
|
356
360
|
const presetRestorePromiseRef = useRef<Promise<void> | null>(null);
|
|
@@ -674,38 +678,21 @@ export function usePiWeb() {
|
|
|
674
678
|
? { ...current, connected: false }
|
|
675
679
|
: { ...emptyState(savedToolPreset()), connected: false });
|
|
676
680
|
let retryDelayMs = 2000;
|
|
681
|
+
const eventsUrl = `/api/agent/${currentId}/events`;
|
|
677
682
|
const connect = () => {
|
|
678
|
-
const es = new EventSource(`/api/agent/${currentId}/events`);
|
|
679
|
-
esRef.current = es;
|
|
680
|
-
es.onopen = () => {
|
|
681
|
-
retryDelayMs = 2000;
|
|
682
|
-
setState((s) => ({ ...s, connected: true }));
|
|
683
|
-
};
|
|
684
|
-
es.onerror = () => {
|
|
685
|
-
setState((s) => ({ ...s, connected: false }));
|
|
686
|
-
if (esRef.current !== es) return;
|
|
687
|
-
// 网络断开时浏览器自己会重连,并带上 Last-Event-ID 让服务端从环形缓冲续放
|
|
688
|
-
// (只补漏掉的几条事件)。这时手动重建就等于丢掉续传、每次都拉一份全量快照。
|
|
689
|
-
if (es.readyState !== EventSource.CLOSED) return;
|
|
690
|
-
// 浏览器对非 200 响应会永久放弃自动重连:这里手动兜底,关掉后延迟重建,
|
|
691
|
-
// 重建时会拿到全新快照。
|
|
692
|
-
es.close();
|
|
693
|
-
esRef.current = null;
|
|
694
|
-
const delay = retryDelayMs;
|
|
695
|
-
retryDelayMs = Math.min(retryDelayMs * 2, 30_000);
|
|
696
|
-
retryTimer = setTimeout(() => {
|
|
697
|
-
if (esRef.current === null) connect();
|
|
698
|
-
}, delay);
|
|
699
|
-
};
|
|
700
683
|
// 流式事件按帧合并:token 速率再高也只每 16ms 渲染一次(见 event-batcher)
|
|
701
684
|
const batcher = createEventBatcher<WebEvent>({
|
|
702
685
|
flush: (events) => setState((s) => events.reduce((acc, evt) => foldPiWebEvent(acc, evt), s)),
|
|
703
686
|
});
|
|
704
687
|
batcherRef.current = batcher;
|
|
705
|
-
|
|
688
|
+
const onOpen = () => {
|
|
689
|
+
retryDelayMs = 2000;
|
|
690
|
+
setState((s) => ({ ...s, connected: true }));
|
|
691
|
+
};
|
|
692
|
+
const onMessage = (data: string) => {
|
|
706
693
|
let parsed: any;
|
|
707
694
|
try {
|
|
708
|
-
parsed = JSON.parse(
|
|
695
|
+
parsed = JSON.parse(data);
|
|
709
696
|
} catch {
|
|
710
697
|
// 单帧损坏(代理截断等)只丢弃该帧,不能让 EventSource 回调抛异常
|
|
711
698
|
return;
|
|
@@ -737,6 +724,36 @@ export function usePiWeb() {
|
|
|
737
724
|
batcher.push(parsed as WebEvent);
|
|
738
725
|
}
|
|
739
726
|
};
|
|
727
|
+
// Cloudflare 快速隧道不支持 SSE(流被攒成 128KB 一块,快照永远收不全):改用长轮询,
|
|
728
|
+
// 失败退避、Last-Event-ID 续传都在 pollEventStream 里
|
|
729
|
+
if (usesEventPolling(window.location.hostname)) {
|
|
730
|
+
esRef.current = pollEventStream(eventsUrl, {
|
|
731
|
+
onOpen,
|
|
732
|
+
onMessage,
|
|
733
|
+
onError: () => setState((s) => ({ ...s, connected: false })),
|
|
734
|
+
});
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
const es = new EventSource(eventsUrl);
|
|
738
|
+
esRef.current = es;
|
|
739
|
+
es.onopen = onOpen;
|
|
740
|
+
es.onerror = () => {
|
|
741
|
+
setState((s) => ({ ...s, connected: false }));
|
|
742
|
+
if (esRef.current !== es) return;
|
|
743
|
+
// 网络断开时浏览器自己会重连,并带上 Last-Event-ID 让服务端从环形缓冲续放
|
|
744
|
+
// (只补漏掉的几条事件)。这时手动重建就等于丢掉续传、每次都拉一份全量快照。
|
|
745
|
+
if (es.readyState !== EventSource.CLOSED) return;
|
|
746
|
+
// 浏览器对非 200 响应会永久放弃自动重连:这里手动兜底,关掉后延迟重建,
|
|
747
|
+
// 重建时会拿到全新快照。
|
|
748
|
+
es.close();
|
|
749
|
+
esRef.current = null;
|
|
750
|
+
const delay = retryDelayMs;
|
|
751
|
+
retryDelayMs = Math.min(retryDelayMs * 2, 30_000);
|
|
752
|
+
retryTimer = setTimeout(() => {
|
|
753
|
+
if (esRef.current === null) connect();
|
|
754
|
+
}, delay);
|
|
755
|
+
};
|
|
756
|
+
es.onmessage = (e) => onMessage(e.data);
|
|
740
757
|
};
|
|
741
758
|
let retryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
742
759
|
connect();
|
package/src/i18n.tsx
CHANGED
|
@@ -323,6 +323,9 @@ const zh = {
|
|
|
323
323
|
contextCompacted: "Compacted Data",
|
|
324
324
|
contextAutoCompactBuffer: "Auto-Compact Buffer",
|
|
325
325
|
contextFreeSpace: "Free Space",
|
|
326
|
+
contextColdHint: "会话还没启动,System Prompt 和工具定义要启动后才算得出",
|
|
327
|
+
contextCountNow: "现在统计",
|
|
328
|
+
contextCounting: "统计中…",
|
|
326
329
|
// 统计条
|
|
327
330
|
turns: "轮次",
|
|
328
331
|
toolCalls: "工具调用",
|
|
@@ -532,9 +535,9 @@ const zh = {
|
|
|
532
535
|
trustAlways: "总是信任",
|
|
533
536
|
trustNever: "从不信任",
|
|
534
537
|
versionPiweb: "PiWeb 版本",
|
|
535
|
-
versionPiwebDesc: "本 Web
|
|
538
|
+
versionPiwebDesc: "本 Web 应用的版本;启动时自动检查,有新版本在这里直接更新(点版本号打开仓库)",
|
|
536
539
|
versionPi: "Pi 版本",
|
|
537
|
-
versionPiDesc: "pi
|
|
540
|
+
versionPiDesc: "pi 智能体内核的版本;启动时自动检查,有新版本在这里直接更新(点版本号打开仓库)",
|
|
538
541
|
checkUpdate: "检查更新",
|
|
539
542
|
checkingUpdate: "检查中…",
|
|
540
543
|
upToDate: "已是最新",
|
|
@@ -543,6 +546,18 @@ const zh = {
|
|
|
543
546
|
updatedTo: "已更新到 {v},重启 PiWeb 后生效",
|
|
544
547
|
updateFailed: "更新失败",
|
|
545
548
|
updateManualHint: "先停止 piweb,再执行上面的命令(Windows 无法替换运行中的安装目录)",
|
|
549
|
+
updateCopied: "已复制",
|
|
550
|
+
updateBusy: "有会话正在运行,等它结束再更新",
|
|
551
|
+
updateCommits: "main 新增 {n} 个提交",
|
|
552
|
+
updateNoticeTitle: "有新版本",
|
|
553
|
+
updateNoticeUpdatingTitle: "正在更新…",
|
|
554
|
+
updateNoticeDoneTitle: "更新完成",
|
|
555
|
+
updateNoticeUpdate: "更新",
|
|
556
|
+
updateNoticeLater: "稍后",
|
|
557
|
+
updateNoticeOk: "知道了",
|
|
558
|
+
updateNoticeUpdated: "已更新到 {v}",
|
|
559
|
+
updateRunCommandHint: "先停掉 piweb,再在终端运行:",
|
|
560
|
+
updateRestartHint: "重启 PiWeb 后生效:在运行 piweb 的终端按 Ctrl+C,再重新运行 piweb",
|
|
546
561
|
skillsDesc: "关闭后技能不会出现在系统提示中,仅可通过 /skill:名称 手动调用",
|
|
547
562
|
confirmDeleteSkill: "确定删除技能“{name}”吗?将删除其所在技能目录,不可恢复。",
|
|
548
563
|
deleteSkillHint: "删除技能目录",
|
|
@@ -653,6 +668,26 @@ const zh = {
|
|
|
653
668
|
viewerNotInSnapshot: "此文件不在快照里(超过 1MB 或被排除),显示磁盘上的当前内容",
|
|
654
669
|
viewerLoadFailed: "加载失败",
|
|
655
670
|
viewerEmpty: "从左侧树里选择一个文件",
|
|
671
|
+
// 流式输出
|
|
672
|
+
streamStyleTitle: "流式输出样式",
|
|
673
|
+
streamStyleDesc: "回答逐字出现的方式;改完立刻生效,正在流出的回答也一并换",
|
|
674
|
+
streamStyleSmooth: "平滑",
|
|
675
|
+
streamStyleSmoothDesc: "匀速长出,不跳字",
|
|
676
|
+
streamStyleTypewriter: "打字机",
|
|
677
|
+
streamStyleTypewriterDesc: "固定节奏逐字敲出",
|
|
678
|
+
streamStyleFade: "渐显",
|
|
679
|
+
streamStyleFadeDesc: "整词推进,段落柔和淡入",
|
|
680
|
+
streamStyleInstant: "即时",
|
|
681
|
+
streamStyleInstantDesc: "立即显示,不做平滑",
|
|
682
|
+
streamSpeedTitle: "推进速度",
|
|
683
|
+
streamSpeedDesc: "平滑 / 打字机 / 渐显的节奏快慢;即时样式不受影响",
|
|
684
|
+
streamSpeed1: "慢",
|
|
685
|
+
streamSpeed2: "较慢",
|
|
686
|
+
streamSpeed3: "标准",
|
|
687
|
+
streamSpeed4: "快",
|
|
688
|
+
streamSpeed5: "极快",
|
|
689
|
+
streamCaretTitle: "打字光标",
|
|
690
|
+
streamCaretDesc: "流式期间在文字末尾显示闪烁光标",
|
|
656
691
|
} as const;
|
|
657
692
|
|
|
658
693
|
type Dict = { [K in keyof typeof zh]: string };
|
|
@@ -971,6 +1006,9 @@ const en: Dict = {
|
|
|
971
1006
|
contextCompacted: "Compacted Data",
|
|
972
1007
|
contextAutoCompactBuffer: "Auto-Compact Buffer",
|
|
973
1008
|
contextFreeSpace: "Free Space",
|
|
1009
|
+
contextColdHint: "Session not started yet — System Prompt and tool definitions are counted once it starts",
|
|
1010
|
+
contextCountNow: "Count now",
|
|
1011
|
+
contextCounting: "Counting…",
|
|
974
1012
|
turns: "turns",
|
|
975
1013
|
toolCalls: "tool calls",
|
|
976
1014
|
cost: "cost",
|
|
@@ -1175,9 +1213,9 @@ const en: Dict = {
|
|
|
1175
1213
|
trustAlways: "Always trust",
|
|
1176
1214
|
trustNever: "Never trust",
|
|
1177
1215
|
versionPiweb: "PiWeb version",
|
|
1178
|
-
versionPiwebDesc: "Version of this web app; click to open the repo",
|
|
1216
|
+
versionPiwebDesc: "Version of this web app; checked at startup and updatable right here (click the version to open the repo)",
|
|
1179
1217
|
versionPi: "Pi version",
|
|
1180
|
-
versionPiDesc: "Version of the pi agent engine; click to open the repo",
|
|
1218
|
+
versionPiDesc: "Version of the pi agent engine; checked at startup and updatable right here (click the version to open the repo)",
|
|
1181
1219
|
checkUpdate: "Check for updates",
|
|
1182
1220
|
checkingUpdate: "Checking…",
|
|
1183
1221
|
upToDate: "Up to date",
|
|
@@ -1186,6 +1224,18 @@ const en: Dict = {
|
|
|
1186
1224
|
updatedTo: "Updated to {v}; restart PiWeb to apply",
|
|
1187
1225
|
updateFailed: "Update failed",
|
|
1188
1226
|
updateManualHint: "Stop piweb, then run the command above (Windows cannot replace a running installation)",
|
|
1227
|
+
updateCopied: "Copied",
|
|
1228
|
+
updateBusy: "A session is running; update after it finishes",
|
|
1229
|
+
updateCommits: "{n} new commits on main",
|
|
1230
|
+
updateNoticeTitle: "Update available",
|
|
1231
|
+
updateNoticeUpdatingTitle: "Updating…",
|
|
1232
|
+
updateNoticeDoneTitle: "Update installed",
|
|
1233
|
+
updateNoticeUpdate: "Update",
|
|
1234
|
+
updateNoticeLater: "Later",
|
|
1235
|
+
updateNoticeOk: "Got it",
|
|
1236
|
+
updateNoticeUpdated: "Updated to {v}",
|
|
1237
|
+
updateRunCommandHint: "Stop piweb, then run in a terminal:",
|
|
1238
|
+
updateRestartHint: "Restart PiWeb to apply: press Ctrl+C in the terminal running piweb, then run piweb again",
|
|
1189
1239
|
skillsDesc: "Disabled skills are hidden from the system prompt and only callable via /skill:name",
|
|
1190
1240
|
confirmDeleteSkill: "Delete skill \"{name}\"? Its skill directory will be removed and cannot be recovered.",
|
|
1191
1241
|
deleteSkillHint: "Delete the skill directory",
|
|
@@ -1295,6 +1345,26 @@ const en: Dict = {
|
|
|
1295
1345
|
viewerNotInSnapshot: "Not in the snapshot (over 1MB or excluded); showing the current content from disk",
|
|
1296
1346
|
viewerLoadFailed: "Failed to load",
|
|
1297
1347
|
viewerEmpty: "Pick a file from the tree on the left",
|
|
1348
|
+
// Streaming text
|
|
1349
|
+
streamStyleTitle: "Streaming text",
|
|
1350
|
+
streamStyleDesc: "How the answer appears while it arrives; applies to the stream already running",
|
|
1351
|
+
streamStyleSmooth: "Smooth",
|
|
1352
|
+
streamStyleSmoothDesc: "Even pace, no jumps",
|
|
1353
|
+
streamStyleTypewriter: "Typewriter",
|
|
1354
|
+
streamStyleTypewriterDesc: "Fixed cadence, character by character",
|
|
1355
|
+
streamStyleFade: "Fade in",
|
|
1356
|
+
streamStyleFadeDesc: "Whole words, paragraphs fade in",
|
|
1357
|
+
streamStyleInstant: "Instant",
|
|
1358
|
+
streamStyleInstantDesc: "Show immediately, no smoothing",
|
|
1359
|
+
streamSpeedTitle: "Streaming speed",
|
|
1360
|
+
streamSpeedDesc: "Cadence for Smooth / Typewriter / Fade in; Instant ignores it",
|
|
1361
|
+
streamSpeed1: "Slow",
|
|
1362
|
+
streamSpeed2: "Relaxed",
|
|
1363
|
+
streamSpeed3: "Standard",
|
|
1364
|
+
streamSpeed4: "Fast",
|
|
1365
|
+
streamSpeed5: "Fastest",
|
|
1366
|
+
streamCaretTitle: "Typing caret",
|
|
1367
|
+
streamCaretDesc: "Blinking caret at the end of the text while streaming",
|
|
1298
1368
|
};
|
|
1299
1369
|
|
|
1300
1370
|
const dicts: Record<string, Dict> = { zh, en };
|
|
@@ -12,6 +12,9 @@ export async function registerNode() {
|
|
|
12
12
|
const { authEnabled } = await import("./lib/web-auth");
|
|
13
13
|
if (authEnabled()) (await import("./lib/web-launch-token")).publishLaunchToken();
|
|
14
14
|
|
|
15
|
+
// 每次启动检查一次 PiWeb / pi 有没有新版本:不等结果,查到了在终端打一行,页面右下角也会提醒
|
|
16
|
+
void (await import("./lib/update/startup-check")).ensureStartupCheck().catch(() => {});
|
|
17
|
+
|
|
15
18
|
const { remoteBlocked, remoteLanEnabled, startLan } = await import("./lib/remote/supervisor");
|
|
16
19
|
if (!remoteLanEnabled()) return;
|
|
17
20
|
|