@rexvane/piweb 0.3.8 → 0.3.10
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 +8 -8
- package/.next/build-manifest.json +3 -3
- package/.next/prerender-manifest.json +3 -3
- package/.next/react-loadable-manifest.json +1 -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 +1 -1
- package/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/api/update/route.js +2 -2
- 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 +3 -3
- package/.next/server/app/index.segments/__PAGE__.segment.rsc +3 -3
- package/.next/server/app/index.segments/_full.segment.rsc +3 -3
- package/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- 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 +2 -2
- package/.next/server/app/login.segments/_full.segment.rsc +2 -2
- package/.next/server/app/login.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +2 -2
- package/.next/server/app/page.js +1 -1
- package/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/server/app-paths-manifest.json +8 -8
- package/.next/server/chunks/496.js +1 -1
- package/.next/server/chunks/686.js +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/18.8b025ee3303c3dc7.js +1 -0
- package/.next/static/chunks/686-97c833615d1565c5.js +1 -0
- package/.next/static/chunks/app/page-19e37fe240ae3e20.js +1 -0
- package/.next/static/chunks/{webpack-eb6e3e22d2f9faa2.js → webpack-eebc3d300017fa47.js} +1 -1
- package/README.md +7 -1
- package/package.json +1 -1
- package/scripts/release.mjs +27 -1
- package/src/app/api/workspaces/route.ts +4 -0
- package/src/components/AppShell.tsx +47 -41
- package/src/components/ChatWindow.tsx +75 -7
- package/src/components/SettingsPanel.tsx +13 -1
- package/src/hooks/usePiWeb.ts +14 -10
- package/src/i18n.tsx +10 -0
- package/src/lib/agent-manager.ts +65 -3
- package/src/lib/process-format.ts +27 -10
- package/src/lib/types.ts +2 -0
- package/src/lib/update-service.ts +80 -4
- package/.next/static/chunks/18.ec64a3f85c5103fc.js +0 -1
- package/.next/static/chunks/686-3eeba9445fecdd06.js +0 -1
- package/.next/static/chunks/app/page-8ef773ce62ce3aaa.js +0 -1
- /package/.next/static/{HEI7XSH5vSn7KCIANvzFC → CLy0QZWDpEExcItT1yudl}/_buildManifest.js +0 -0
- /package/.next/static/{HEI7XSH5vSn7KCIANvzFC → CLy0QZWDpEExcItT1yudl}/_ssgManifest.js +0 -0
package/README.md
CHANGED
|
@@ -62,9 +62,15 @@ The package registers only the `piweb` command, so it never conflicts with the o
|
|
|
62
62
|
### Update
|
|
63
63
|
|
|
64
64
|
```bash
|
|
65
|
-
npm
|
|
65
|
+
npm install -g @rexvane/piweb@latest
|
|
66
66
|
```
|
|
67
67
|
|
|
68
|
+
npm replaces the installed version in place, so there is nothing to uninstall first. This is
|
|
69
|
+
the same command the in-app "Check for updates" button runs. `latest` is npm's default tag,
|
|
70
|
+
so `npm install -g @rexvane/piweb` behaves identically; if your registry is a mirror that
|
|
71
|
+
syncs with a delay, add `--registry=https://registry.npmjs.org` to get a release the moment
|
|
72
|
+
it is published. Restart `piweb` afterwards to serve the new version.
|
|
73
|
+
|
|
68
74
|
Or install a specific version: `npm install -g @rexvane/piweb@0.3.4`.
|
|
69
75
|
|
|
70
76
|
### Uninstall
|
package/package.json
CHANGED
package/scripts/release.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { randomUUID } from "node:crypto";
|
|
4
5
|
import { createRequire } from "node:module";
|
|
@@ -7,7 +8,32 @@ import { assertInstallation, assertLocalPath, readProductionBuild, resolveBuildO
|
|
|
7
8
|
import { runCommand } from "./process-runner.mjs";
|
|
8
9
|
import { isMainModule } from "./entrypoint.mjs";
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Root of the PiWeb installation this module belongs to.
|
|
13
|
+
*
|
|
14
|
+
* A Next server bundle substitutes `import.meta.url` with the URL of the SOURCE file on
|
|
15
|
+
* the machine that ran the build, so a published package carries the CI path. That both
|
|
16
|
+
* throws (`fileURLToPath` rejects a POSIX path inside a file URL on Windows —
|
|
17
|
+
* ERR_INVALID_FILE_URL_PATH) and points at a directory that does not exist on the user's
|
|
18
|
+
* machine. The launcher's PI_WEB_ROOT and the working directory are trustworthy; the
|
|
19
|
+
* module URL is consulted only while it still points at a real checkout.
|
|
20
|
+
* @param {{env?: Record<string, string | undefined>, cwd?: string, moduleUrl?: string}} [context]
|
|
21
|
+
* @returns {string}
|
|
22
|
+
*/
|
|
23
|
+
export function resolveReleaseRoot({ env = process.env, cwd = process.cwd(), moduleUrl } = {}) {
|
|
24
|
+
if (env.PI_WEB_ROOT) return path.resolve(env.PI_WEB_ROOT);
|
|
25
|
+
if (typeof moduleUrl === "string") {
|
|
26
|
+
try {
|
|
27
|
+
const fromModule = path.resolve(path.dirname(fileURLToPath(moduleUrl)), "..");
|
|
28
|
+
if (existsSync(path.join(fromModule, "scripts", "release.mjs"))) return fromModule;
|
|
29
|
+
} catch {
|
|
30
|
+
/* a URL for another platform's layout: fall through to the working directory */
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return path.resolve(cwd);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const ROOT = resolveReleaseRoot({ moduleUrl: import.meta.url });
|
|
11
37
|
const LOCK_PATH = `${RELEASES_DIR}/update.lock`;
|
|
12
38
|
const CONFIG_PATH = `${RELEASES_DIR}/tsconfig.json`;
|
|
13
39
|
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
setAlias,
|
|
12
12
|
} from "@/lib/workspace-store";
|
|
13
13
|
import { encodeSessionId } from "@/lib/pi";
|
|
14
|
+
import { disposeSessionPath } from "@/lib/agent-manager";
|
|
14
15
|
import { BoundaryError, resolveSessionPath, resolveWorkspacePath } from "@/lib/path-security";
|
|
15
16
|
|
|
16
17
|
export const dynamic = "force-dynamic";
|
|
@@ -53,6 +54,9 @@ export async function POST(req: Request) {
|
|
|
53
54
|
if (body.action === "archiveSession") {
|
|
54
55
|
if (!body.path) return NextResponse.json({ success: false, error: "missing path" }, { status: 400 });
|
|
55
56
|
const sessionPath = await resolveSessionPath(encodeSessionId(body.path), { allowPending: true });
|
|
57
|
+
// 归档即收工:会话随即从工作区消失,不能让它继续在后台跑(看不见却仍消耗 token)。
|
|
58
|
+
// 只影响这一个会话,其它运行中的会话不受影响。
|
|
59
|
+
await disposeSessionPath(sessionPath);
|
|
56
60
|
const archivedSessions = await archiveSession(sessionPath);
|
|
57
61
|
return NextResponse.json({ success: true, data: { archivedSessions } });
|
|
58
62
|
}
|
|
@@ -14,8 +14,11 @@ import { SessionSidebar } from "@/components/SessionSidebar";
|
|
|
14
14
|
|
|
15
15
|
// 首屏不需要的重组件按需加载(设置面板含供应商配置与代码高亮,轨迹/文件只在打开时才用)
|
|
16
16
|
const SettingsPanel = dynamic(() => import("@/components/SettingsPanel").then((m) => m.SettingsPanel), { ssr: false });
|
|
17
|
-
const TrajectoryView = dynamic(() => import("@/components/TrajectoryView").then((m) => m.TrajectoryView), { ssr: false });
|
|
18
17
|
const TrajInspector = dynamic(() => import("@/components/TrajectoryView").then((m) => m.TrajInspector), { ssr: false });
|
|
18
|
+
|
|
19
|
+
/** 会话/工作区路径比较(Windows 大小写与分隔符差异不该影响判断) */
|
|
20
|
+
const samePath = (a: string, b: string): boolean =>
|
|
21
|
+
a.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase() === b.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
|
|
19
22
|
const ProjectPanel = dynamic(() => import("@/components/ProjectPanel").then((m) => m.ProjectPanel), { ssr: false });
|
|
20
23
|
const FileViewer = dynamic(() => import("@/components/FileViewer").then((m) => m.FileViewer), { ssr: false });
|
|
21
24
|
const GitPanel = dynamic(() => import("@/components/GitPanel").then((m) => m.GitPanel), { ssr: false });
|
|
@@ -132,7 +135,6 @@ export function AppShell() {
|
|
|
132
135
|
const saveHeroDraft = useCallback((update: ChatDraftUpdate) => updateDraft(heroDraftKey, update), [heroDraftKey, updateDraft]);
|
|
133
136
|
const [dragging, setDragging] = useState<"sidebar" | "details" | "project" | null>(null);
|
|
134
137
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
135
|
-
const [tab, setTab] = useState<"chat" | "traj">("chat");
|
|
136
138
|
const [selected, setSelected] = useState<TrajEntry | null>(null);
|
|
137
139
|
const [gitDetailsOpen, setGitDetailsOpen] = useState(false);
|
|
138
140
|
const [heroCwd, setHeroCwd] = useState("");
|
|
@@ -342,17 +344,49 @@ export function AppShell() {
|
|
|
342
344
|
[currentPath, currentId, sendCommand, openSession, resync],
|
|
343
345
|
);
|
|
344
346
|
|
|
347
|
+
/**
|
|
348
|
+
* 原地编辑用户消息后重新发送:服务端在同一会话文件内回到该消息(navigateTree),
|
|
349
|
+
* 再用新文本重新提问——被编辑消息之后的分支作废,模型从这里重新回答。
|
|
350
|
+
* 旧的插话(steering)撤回/编辑/发送不变。
|
|
351
|
+
*/
|
|
352
|
+
const doEditMessage = useCallback(
|
|
353
|
+
async (entryId: string, text: string) => {
|
|
354
|
+
if (!currentId) return;
|
|
355
|
+
if (isStreaming) {
|
|
356
|
+
setError(t.editWhileRunning);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
const moved = await sendCommand({ cmd: "navigate", entryId });
|
|
360
|
+
if (!moved?.success || moved.data?.cancelled) {
|
|
361
|
+
setError(moved?.error ?? t.editMessageFailed);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
// 被编辑消息之后的尾部要立刻从视图里消失,再发新提问
|
|
365
|
+
resync();
|
|
366
|
+
const sent = await sendCommand({ cmd: "prompt", text });
|
|
367
|
+
if (!sent?.success) setError(sent?.error ?? t.editMessageFailed);
|
|
368
|
+
},
|
|
369
|
+
[currentId, isStreaming, resync, sendCommand, setError, t.editMessageFailed, t.editWhileRunning],
|
|
370
|
+
);
|
|
371
|
+
|
|
345
372
|
const doArchiveSession = useCallback(
|
|
346
373
|
async (path: string) => {
|
|
347
374
|
try {
|
|
348
|
-
// dsh 合同:归档不关会话——当前会话被归档后保持打开且在主列表可见
|
|
349
|
-
// (usePiWeb 的可见性规则对 currentPath 豁免),继续可聊。
|
|
350
375
|
await archiveSession(path);
|
|
351
376
|
} catch {
|
|
352
377
|
// usePiWeb exposes the request failure in the shared error banner.
|
|
353
378
|
}
|
|
379
|
+
// 归档后会话立刻离开工作区列表(服务端同时让它收工)。正在看的会话被归档时
|
|
380
|
+
// 必须切走:先取同一工作区的另一个可见会话,没有就回到空态(工作区仍留在侧栏)。
|
|
381
|
+
if (!currentPath || !samePath(path, currentPath)) return;
|
|
382
|
+
const cwd = sessions.find((s) => samePath(s.path, currentPath))?.cwd;
|
|
383
|
+
const next =
|
|
384
|
+
sessions.find((s) => !samePath(s.path, path) && cwd && samePath(s.cwd, cwd)) ??
|
|
385
|
+
sessions.find((s) => !samePath(s.path, path));
|
|
386
|
+
if (next) openSession(next.path);
|
|
387
|
+
else closeSession();
|
|
354
388
|
},
|
|
355
|
-
[archiveSession],
|
|
389
|
+
[archiveSession, closeSession, currentPath, openSession, sessions],
|
|
356
390
|
);
|
|
357
391
|
const doUnarchiveSession = useCallback(
|
|
358
392
|
async (path: string) => {
|
|
@@ -444,7 +478,6 @@ export function AppShell() {
|
|
|
444
478
|
if (name === "compact") void sendCommand({ cmd: "compact", instructions: args || undefined });
|
|
445
479
|
else if (name === "export" && currentId) window.open(`/api/sessions/${currentId}/export?format=jsonl`, "_blank");
|
|
446
480
|
else if (name === "new") {
|
|
447
|
-
setTab("chat");
|
|
448
481
|
closeSession();
|
|
449
482
|
} else if (name === "fork" && currentId) {
|
|
450
483
|
void sendCommand({ cmd: "fork" }).then((r) => {
|
|
@@ -646,14 +679,12 @@ export function AppShell() {
|
|
|
646
679
|
}}
|
|
647
680
|
onNew={() => {
|
|
648
681
|
// 对齐 dsh startSession:新会话默认落在当前会话的工作区(无则取最近工作区)
|
|
649
|
-
setTab("chat");
|
|
650
682
|
const cur = sessions.find((s) => s.path === currentPath)?.cwd;
|
|
651
683
|
setHeroCwd(cur || heroCwd || knownCwds[0] || "");
|
|
652
684
|
closeSession();
|
|
653
685
|
}}
|
|
654
686
|
onNewInWorkspace={(cwd) => {
|
|
655
687
|
// dsh 惰性新建:只预选工作区进草稿态,发送第一条消息才真正创建会话
|
|
656
|
-
setTab("chat");
|
|
657
688
|
setHeroCwd(cwd);
|
|
658
689
|
closeSession();
|
|
659
690
|
}}
|
|
@@ -757,26 +788,11 @@ export function AppShell() {
|
|
|
757
788
|
)}
|
|
758
789
|
</div>
|
|
759
790
|
)}
|
|
760
|
-
|
|
761
|
-
<button className="tab-underline" data-active={tab === "chat"} onClick={() => setTab("chat")}>
|
|
762
|
-
{t.tabChat}
|
|
763
|
-
</button>
|
|
764
|
-
<button
|
|
765
|
-
className="tab-underline"
|
|
766
|
-
data-active={tab === "traj"}
|
|
767
|
-
onClick={() => {
|
|
768
|
-
setTab("traj");
|
|
769
|
-
void sendCommand({ cmd: "prepare" });
|
|
770
|
-
}}
|
|
771
|
-
>
|
|
772
|
-
{t.tabTrajectory}
|
|
773
|
-
</button>
|
|
774
|
-
</div>
|
|
791
|
+
{/* 轨迹页签已移除:轨迹按需查看(对话里的工具行 → 轨迹详情),不再占用主区域 */}
|
|
775
792
|
</div>
|
|
776
793
|
|
|
777
|
-
{/*
|
|
778
|
-
{
|
|
779
|
-
!snapshot ? (
|
|
794
|
+
{/* 内容:只有对话区,轨迹按需在详情栏查看 */}
|
|
795
|
+
{!snapshot ? (
|
|
780
796
|
// 切换会话时快照未到:居中加载指示,避免空白/占位符闪现
|
|
781
797
|
<div className="flex min-h-0 flex-1 items-center justify-center">
|
|
782
798
|
<span
|
|
@@ -813,6 +829,7 @@ export function AppShell() {
|
|
|
813
829
|
onOpenTrajectory={openToolTrajectory}
|
|
814
830
|
onOpenFile={openInEditor}
|
|
815
831
|
onFork={handleFork}
|
|
832
|
+
onEditMessage={doEditMessage}
|
|
816
833
|
/>
|
|
817
834
|
<div className="px-4 pb-3 pt-2">
|
|
818
835
|
<div className="mx-auto w-full" style={{ maxWidth: "var(--dsh-composer-card-max-width)" }}>
|
|
@@ -861,22 +878,11 @@ export function AppShell() {
|
|
|
861
878
|
{Object.entries(state.extensionStatuses).map(([k, v]) => <span key={k}>{v}</span>)}
|
|
862
879
|
</div>
|
|
863
880
|
)}
|
|
864
|
-
|
|
881
|
+
</div>
|
|
865
882
|
</div>
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
<div className="min-h-0 flex-1">
|
|
870
|
-
<TrajectoryView
|
|
871
|
-
entries={snapshot?.trajectory ?? []}
|
|
872
|
-
selected={selected}
|
|
873
|
-
onSelect={(entry) => {
|
|
874
|
-
setSelected(entry);
|
|
875
|
-
setGitDetailsOpen(false);
|
|
876
|
-
}}
|
|
877
|
-
/>
|
|
878
|
-
</div>
|
|
879
|
-
)}
|
|
883
|
+
</div>
|
|
884
|
+
)
|
|
885
|
+
}
|
|
880
886
|
</>
|
|
881
887
|
)}
|
|
882
888
|
</div>
|
|
@@ -193,6 +193,7 @@ function TurnMetaPill({ durationMs, usage, t }: { durationMs: number; usage?: Tr
|
|
|
193
193
|
function MessageActions({
|
|
194
194
|
text,
|
|
195
195
|
onFork,
|
|
196
|
+
onEdit,
|
|
196
197
|
durationMs,
|
|
197
198
|
usage,
|
|
198
199
|
time,
|
|
@@ -200,6 +201,8 @@ function MessageActions({
|
|
|
200
201
|
}: {
|
|
201
202
|
text: string;
|
|
202
203
|
onFork?: () => void | Promise<unknown>;
|
|
204
|
+
/** 用户消息:原地编辑后重新发送(模型从这条消息重新回答) */
|
|
205
|
+
onEdit?: () => void;
|
|
203
206
|
/** 回合墙钟耗时(最终回答时间戳 − 回合首条用户消息时间戳),dsh runMs 的对应物 */
|
|
204
207
|
durationMs?: number;
|
|
205
208
|
usage?: TrajTokens & { cost?: { total?: number } };
|
|
@@ -235,6 +238,11 @@ function MessageActions({
|
|
|
235
238
|
{copied ? <IconCheckOutline14 size={14} /> : <IconCopyOutline16 size={14} />}
|
|
236
239
|
</button>
|
|
237
240
|
{copyFailed && <span role="alert" style={{ fontSize: 11, color: "var(--dsw-danger)" }}>{t.copyFailed}</span>}
|
|
241
|
+
{onEdit && (
|
|
242
|
+
<button className="icon-btn" title={t.editMessage} aria-label={t.editMessage} onClick={onEdit}>
|
|
243
|
+
<IconEditOutline16 size={14} />
|
|
244
|
+
</button>
|
|
245
|
+
)}
|
|
238
246
|
{onFork && (
|
|
239
247
|
<button
|
|
240
248
|
className="icon-btn"
|
|
@@ -456,7 +464,8 @@ const ToolStep = memo(function ToolStep({
|
|
|
456
464
|
const output = state?.result ?? state?.partialResult ?? "";
|
|
457
465
|
const isWriteTool = name.toLowerCase() === "write";
|
|
458
466
|
const diff = useMemo(() => {
|
|
459
|
-
|
|
467
|
+
// 失败的编辑同样渲染 diff:出错信息单独展示,不因为失败就把红绿藏掉
|
|
468
|
+
if (kind !== "write" || !state || state.state !== "done") return null;
|
|
460
469
|
const parsed = parseUnifiedDiff(state.patch || output);
|
|
461
470
|
if (parsed) return parsed;
|
|
462
471
|
// pi 的 write 工具不带 patch:新文件按新增行展示(全绿 +),与 edit 的 diff 口径一致
|
|
@@ -483,11 +492,13 @@ const ToolStep = memo(function ToolStep({
|
|
|
483
492
|
// 删除文件的命令:完成后给红色标记(内容级 diff 不强求)
|
|
484
493
|
const deletedTarget = kind === "cmd" && !running && !failed && isDeleteCommand(fullCommand) ? deleteTargetOf(fullCommand) : "";
|
|
485
494
|
const preview = state ? toolPreview(kind, state, tt, cwd) : { lines: [], hidden: 0 };
|
|
486
|
-
const hasDiff = Boolean(diff && diff.length)
|
|
495
|
+
const hasDiff = Boolean(diff && diff.length);
|
|
487
496
|
const hasOutput = Boolean(output || state?.encodingLoss);
|
|
488
497
|
const canExpand = hasOutput && !hasDiff && !running;
|
|
489
498
|
const duration = state?.startedAt && state?.endedAt ? state.endedAt - state.startedAt : 0;
|
|
490
499
|
const showPreview = Boolean(preview.head) || preview.lines.length > 0;
|
|
500
|
+
// 失败且有 diff 时,出错信息要完整可见(此时 diff 占用了展开位)
|
|
501
|
+
const showFullOutput = hasOutput && (open && canExpand || (failed && hasDiff));
|
|
491
502
|
const diffLines = hasDiff && diff ? (open ? diff : diff.slice(0, DIFF_PREVIEW)) : null;
|
|
492
503
|
const diffHidden = diff && diffLines && !open ? diff.length - diffLines.length : 0;
|
|
493
504
|
const diffStat = hasDiff && diff
|
|
@@ -586,7 +597,7 @@ const ToolStep = memo(function ToolStep({
|
|
|
586
597
|
)}
|
|
587
598
|
</div>
|
|
588
599
|
)}
|
|
589
|
-
{
|
|
600
|
+
{showFullOutput && (
|
|
590
601
|
<div className="pw-output">
|
|
591
602
|
{fullCommand && <div className="cmdline">{fullCommand}</div>}
|
|
592
603
|
{state?.encodingLoss && <p className="mb-2 whitespace-normal" style={{ color: "var(--dsw-warn)" }}>{t.encodingLossWarning}</p>}
|
|
@@ -892,12 +903,56 @@ const FinalAnswer = memo(function FinalAnswer({
|
|
|
892
903
|
});
|
|
893
904
|
|
|
894
905
|
/** 行级 memo:流式期间只有最后一条消息变化,历史行全部跳过重渲染 */
|
|
895
|
-
const UserMessage = memo(function UserMessage({ message }: { message: WebMessage }) {
|
|
906
|
+
const UserMessage = memo(function UserMessage({ message, onEditMessage }: { message: WebMessage; onEditMessage?: (entryId: string, text: string) => void }) {
|
|
907
|
+
const { t } = useI18n();
|
|
896
908
|
const text = message.content
|
|
897
909
|
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
898
910
|
.map((c) => c.text)
|
|
899
911
|
.join("\n");
|
|
900
912
|
const images = message.content.filter((content) => content.type === "image");
|
|
913
|
+
const [editing, setEditing] = useState(false);
|
|
914
|
+
const [draft, setDraft] = useState(text);
|
|
915
|
+
const canEdit = Boolean(onEditMessage && message.id && text);
|
|
916
|
+
if (editing) {
|
|
917
|
+
return (
|
|
918
|
+
<div className="group mt-7 flex w-full flex-col items-end first:mt-0" data-role="user">
|
|
919
|
+
<div className="msg-user-bubble w-full">
|
|
920
|
+
<textarea
|
|
921
|
+
className="w-full resize-none bg-transparent outline-none"
|
|
922
|
+
style={{ minHeight: 68, font: "inherit", color: "inherit" }}
|
|
923
|
+
value={draft}
|
|
924
|
+
aria-label={t.editMessage}
|
|
925
|
+
autoFocus
|
|
926
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
927
|
+
onKeyDown={(e) => {
|
|
928
|
+
if (e.key === "Escape") { e.preventDefault(); setEditing(false); }
|
|
929
|
+
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submit(); }
|
|
930
|
+
}}
|
|
931
|
+
/>
|
|
932
|
+
</div>
|
|
933
|
+
<div className="mt-1.5 flex items-center gap-2">
|
|
934
|
+
<button type="button" className="btn-outline" style={{ height: 26, padding: "0 10px", fontSize: 12 }} onClick={() => setEditing(false)}>
|
|
935
|
+
{t.cancel}
|
|
936
|
+
</button>
|
|
937
|
+
<button
|
|
938
|
+
type="button"
|
|
939
|
+
className="btn-primary-white"
|
|
940
|
+
style={{ height: 26, padding: "0 12px", fontSize: 12 }}
|
|
941
|
+
disabled={!draft.trim() || draft.trim() === text.trim()}
|
|
942
|
+
onClick={submit}
|
|
943
|
+
>
|
|
944
|
+
{t.sendEdit}
|
|
945
|
+
</button>
|
|
946
|
+
</div>
|
|
947
|
+
</div>
|
|
948
|
+
);
|
|
949
|
+
}
|
|
950
|
+
function submit() {
|
|
951
|
+
const next = draft.trim();
|
|
952
|
+
if (!next || next === text.trim() || !onEditMessage || !message.id) return;
|
|
953
|
+
setEditing(false);
|
|
954
|
+
onEditMessage(message.id, next);
|
|
955
|
+
}
|
|
901
956
|
return (
|
|
902
957
|
// 回合边界:用户消息前留 28px(比回合内 8px 大得多),长对话里一眼找到“这一轮从哪开始”
|
|
903
958
|
<div className="group mt-7 flex w-full flex-col items-end first:mt-0" data-role="user">
|
|
@@ -916,8 +971,15 @@ const UserMessage = memo(function UserMessage({ message }: { message: WebMessage
|
|
|
916
971
|
)}
|
|
917
972
|
{/* 用户消息也走 Markdown:贴进来的代码块/列表不再是一坨纯文本 */}
|
|
918
973
|
{text && <div className="msg-user-bubble"><Markdown text={text} /></div>}
|
|
919
|
-
{/*
|
|
920
|
-
{text &&
|
|
974
|
+
{/* 用户消息:复制 + 原地编辑重发(编辑后模型从这条消息重新回答);时钟在图标左侧(dsh clock=start) */}
|
|
975
|
+
{text && (
|
|
976
|
+
<MessageActions
|
|
977
|
+
text={text}
|
|
978
|
+
clockStart={message.timestamp !== undefined}
|
|
979
|
+
time={message.timestamp}
|
|
980
|
+
onEdit={canEdit ? () => { setDraft(text); setEditing(true); } : undefined}
|
|
981
|
+
/>
|
|
982
|
+
)}
|
|
921
983
|
</div>
|
|
922
984
|
);
|
|
923
985
|
});
|
|
@@ -967,6 +1029,7 @@ function TurnBlock({
|
|
|
967
1029
|
outputTokens,
|
|
968
1030
|
workingMessage,
|
|
969
1031
|
onFork,
|
|
1032
|
+
onEditMessage,
|
|
970
1033
|
onInspectTool,
|
|
971
1034
|
onOpenFile,
|
|
972
1035
|
}: {
|
|
@@ -984,6 +1047,8 @@ function TurnBlock({
|
|
|
984
1047
|
outputTokens?: number;
|
|
985
1048
|
workingMessage?: string | null;
|
|
986
1049
|
onFork?: (entryId: string) => void;
|
|
1050
|
+
/** 编辑该回合的用户消息并重新发送(模型从这条消息重新回答) */
|
|
1051
|
+
onEditMessage?: (entryId: string, text: string) => void;
|
|
987
1052
|
onInspectTool?: (toolCallId: string) => void;
|
|
988
1053
|
onOpenFile?: (path: string) => void;
|
|
989
1054
|
}) {
|
|
@@ -1035,7 +1100,7 @@ function TurnBlock({
|
|
|
1035
1100
|
|
|
1036
1101
|
return (
|
|
1037
1102
|
<>
|
|
1038
|
-
{user && <UserMessage message={user} />}
|
|
1103
|
+
{user && <UserMessage message={user} onEditMessage={onEditMessage} />}
|
|
1039
1104
|
{showProcess && (
|
|
1040
1105
|
<div className="pw-turn">
|
|
1041
1106
|
{contextFiles?.map((resource, i) => (
|
|
@@ -1136,6 +1201,7 @@ export function ChatWindow({
|
|
|
1136
1201
|
contextFiles,
|
|
1137
1202
|
cwd,
|
|
1138
1203
|
onFork,
|
|
1204
|
+
onEditMessage,
|
|
1139
1205
|
isStreaming = false,
|
|
1140
1206
|
error,
|
|
1141
1207
|
connected = true,
|
|
@@ -1171,6 +1237,7 @@ export function ChatWindow({
|
|
|
1171
1237
|
trajectory?: TrajEntry[];
|
|
1172
1238
|
onOpenTrajectory?: (toolCallId: string) => void;
|
|
1173
1239
|
/** 在本机编辑器打开工具行涉及的文件 */
|
|
1240
|
+
onEditMessage?: (entryId: string, text: string) => void;
|
|
1174
1241
|
onOpenFile?: (path: string) => void;
|
|
1175
1242
|
}) {
|
|
1176
1243
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
@@ -1254,6 +1321,7 @@ export function ChatWindow({
|
|
|
1254
1321
|
outputTokens={stats?.tokens.output}
|
|
1255
1322
|
workingMessage={workingMessage}
|
|
1256
1323
|
onFork={onFork}
|
|
1324
|
+
onEditMessage={onEditMessage}
|
|
1257
1325
|
onInspectTool={onOpenTrajectory}
|
|
1258
1326
|
onOpenFile={onOpenFile}
|
|
1259
1327
|
/>
|
|
@@ -509,11 +509,13 @@ function VersionLink({ href, value }: { href: string; value?: string }) {
|
|
|
509
509
|
|
|
510
510
|
type UpdatePhase = "idle" | "checking" | "available" | "updating" | "latest" | "updated" | "error";
|
|
511
511
|
|
|
512
|
-
/** 检查更新 / 更新控件:piweb 走
|
|
512
|
+
/** 检查更新 / 更新控件:npm 安装的 piweb 走 npm install -g @rexvane/piweb@latest(服务端固定参数),
|
|
513
|
+
* Git 检出仍走 git pull + 本地构建;pi 走 npm install @latest。 */
|
|
513
514
|
function UpdateControl({ target }: { target: "piweb" | "pi" }) {
|
|
514
515
|
const { t } = useI18n();
|
|
515
516
|
const [phase, setPhase] = useState<UpdatePhase>("idle");
|
|
516
517
|
const [latest, setLatest] = useState("");
|
|
518
|
+
const [command, setCommand] = useState("");
|
|
517
519
|
const [error, setError] = useState("");
|
|
518
520
|
const busy = phase === "checking" || phase === "updating";
|
|
519
521
|
|
|
@@ -534,6 +536,7 @@ function UpdateControl({ target }: { target: "piweb" | "pi" }) {
|
|
|
534
536
|
if (!j.success) throw new Error(j.error);
|
|
535
537
|
if (j.data.canUpdate) {
|
|
536
538
|
setLatest(j.data.latest);
|
|
539
|
+
setCommand(j.data.command ?? "");
|
|
537
540
|
setPhase("available");
|
|
538
541
|
} else {
|
|
539
542
|
setPhase("latest");
|
|
@@ -574,6 +577,15 @@ function UpdateControl({ target }: { target: "piweb" | "pi" }) {
|
|
|
574
577
|
);
|
|
575
578
|
}
|
|
576
579
|
if (phase === "available") {
|
|
580
|
+
// Windows 无法替换运行中的安装目录(npm 会 EBUSY),此时给命令而不是按钮。
|
|
581
|
+
if (command) {
|
|
582
|
+
return (
|
|
583
|
+
<span className="flex flex-col items-end gap-0.5" style={{ maxWidth: 300 }}>
|
|
584
|
+
<code style={{ fontSize: 11, color: "var(--dsw-label-primary)", whiteSpace: "nowrap", overflowX: "auto", maxWidth: "100%" }}>{command}</code>
|
|
585
|
+
<span style={{ fontSize: 11, color: "var(--dsw-label-tertiary)", textAlign: "right" }}>{t.updateManualHint}</span>
|
|
586
|
+
</span>
|
|
587
|
+
);
|
|
588
|
+
}
|
|
577
589
|
return (
|
|
578
590
|
<button type="button" className="btn-primary-white" style={{ height: 26, padding: "0 12px", fontSize: 12 }} onClick={() => void run()}>
|
|
579
591
|
{t.updateTo.replace("{v}", latest)}
|
package/src/hooks/usePiWeb.ts
CHANGED
|
@@ -242,9 +242,15 @@ export const foldPiWebEvent = (state: PiWebState, evt: WebEvent): PiWebState =>
|
|
|
242
242
|
case "growth_error":
|
|
243
243
|
s.growth = { ...s.growth, pending: [], error: evt.message };
|
|
244
244
|
return s;
|
|
245
|
+
case "retry":
|
|
246
|
+
// 只保留最后一次尝试:重试过程在界面上是「重试 3/5」这一条,而不是五行报错
|
|
247
|
+
s.retryNotice = evt.maxAttempts
|
|
248
|
+
? `重试 ${evt.attempt}/${evt.maxAttempts}${evt.message ? `:${evt.message}` : ""}`
|
|
249
|
+
: `重试 ${evt.attempt}${evt.message ? `:${evt.message}` : ""}`;
|
|
250
|
+
return s;
|
|
245
251
|
case "error":
|
|
246
|
-
//
|
|
247
|
-
if (
|
|
252
|
+
// 旧服务端把自动重试塞在 error 里:同样按通知处理,不占错误条
|
|
253
|
+
if (/^(自动重试|重试)/.test(evt.message)) s.retryNotice = evt.message;
|
|
248
254
|
else s.error = evt.message;
|
|
249
255
|
return s;
|
|
250
256
|
default:
|
|
@@ -847,18 +853,16 @@ export function usePiWeb() {
|
|
|
847
853
|
presetRestorePromiseRef.current = restore;
|
|
848
854
|
}, [currentId, snapshotEpoch, state.connected, state.snapshot, sendCommand]);
|
|
849
855
|
|
|
850
|
-
//
|
|
851
|
-
//
|
|
856
|
+
// 会话可见性规则:归档即离开工作区列表——包括当前会话(归档后切到相邻会话或空态),
|
|
857
|
+
// 归档区显示全部已归档会话。
|
|
852
858
|
const visibleSessions = useMemo(() => {
|
|
853
859
|
const set = new Set(archivedSessionPaths.map(pathKey));
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
}, [sessions, archivedSessionPaths, currentPath]);
|
|
860
|
+
return sessions.filter((s) => !set.has(pathKey(s.path)));
|
|
861
|
+
}, [sessions, archivedSessionPaths]);
|
|
857
862
|
const archivedSessions = useMemo(() => {
|
|
858
863
|
const set = new Set(archivedSessionPaths.map(pathKey));
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
}, [sessions, archivedSessionPaths, currentPath]);
|
|
864
|
+
return sessions.filter((s) => set.has(pathKey(s.path)));
|
|
865
|
+
}, [sessions, archivedSessionPaths]);
|
|
862
866
|
|
|
863
867
|
return {
|
|
864
868
|
sessions: visibleSessions,
|
package/src/i18n.tsx
CHANGED
|
@@ -19,6 +19,10 @@ const zh = {
|
|
|
19
19
|
error: "出错",
|
|
20
20
|
copy: "复制",
|
|
21
21
|
copyFailed: "复制失败",
|
|
22
|
+
editMessage: "编辑这条消息",
|
|
23
|
+
sendEdit: "重新发送",
|
|
24
|
+
editWhileRunning: "会话正在运行,先停止再编辑",
|
|
25
|
+
editMessageFailed: "编辑重发失败",
|
|
22
26
|
export: "导出",
|
|
23
27
|
settings: "设置",
|
|
24
28
|
// 侧栏
|
|
@@ -430,6 +434,7 @@ const zh = {
|
|
|
430
434
|
updatingNow: "更新中…",
|
|
431
435
|
updatedTo: "已更新到 {v},重启 PiWeb 后生效",
|
|
432
436
|
updateFailed: "更新失败",
|
|
437
|
+
updateManualHint: "先停止 piweb,再执行上面的命令(Windows 无法替换运行中的安装目录)",
|
|
433
438
|
skillsDesc: "关闭后技能不会出现在系统提示中,仅可通过 /skill:名称 手动调用",
|
|
434
439
|
confirmDeleteSkill: "确定删除技能“{name}”吗?将删除其所在技能目录,不可恢复。",
|
|
435
440
|
deleteSkillHint: "删除技能目录",
|
|
@@ -550,6 +555,10 @@ const en: Dict = {
|
|
|
550
555
|
error: "Error",
|
|
551
556
|
copy: "Copy",
|
|
552
557
|
copyFailed: "Copy failed",
|
|
558
|
+
editMessage: "Edit this message",
|
|
559
|
+
sendEdit: "Send again",
|
|
560
|
+
editWhileRunning: "This session is running; stop it before editing",
|
|
561
|
+
editMessageFailed: "Edit and resend failed",
|
|
553
562
|
export: "Export",
|
|
554
563
|
settings: "Settings",
|
|
555
564
|
newChat: "New session",
|
|
@@ -954,6 +963,7 @@ const en: Dict = {
|
|
|
954
963
|
updatingNow: "Updating…",
|
|
955
964
|
updatedTo: "Updated to {v}; restart PiWeb to apply",
|
|
956
965
|
updateFailed: "Update failed",
|
|
966
|
+
updateManualHint: "Stop piweb, then run the command above (Windows cannot replace a running installation)",
|
|
957
967
|
skillsDesc: "Disabled skills are hidden from the system prompt and only callable via /skill:name",
|
|
958
968
|
confirmDeleteSkill: "Delete skill \"{name}\"? Its skill directory will be removed and cannot be recovered.",
|
|
959
969
|
deleteSkillHint: "Delete the skill directory",
|
package/src/lib/agent-manager.ts
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
* agent-manager:AgentSession 池 + pi 事件 → Web 事件翻译 + SSE 订阅管理。
|
|
3
3
|
* 打开会话即惰性冷启动(createAgentSession),空闲回收,事件先快照后增量。
|
|
4
4
|
*/
|
|
5
|
-
import { existsSync } from "node:fs";
|
|
5
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
6
6
|
import { randomUUID } from "node:crypto";
|
|
7
|
+
import path from "node:path";
|
|
7
8
|
import type { AgentSession, AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { generateUnifiedPatch } from "@earendil-works/pi-coding-agent";
|
|
8
10
|
import {
|
|
9
11
|
getResourceLoader,
|
|
10
12
|
createSessionResourceLoader,
|
|
@@ -182,6 +184,51 @@ function patchOf(details: unknown): string | undefined {
|
|
|
182
184
|
return typeof patch === "string" && patch.trim() ? patch.slice(0, 60_000) : undefined;
|
|
183
185
|
}
|
|
184
186
|
|
|
187
|
+
/**
|
|
188
|
+
* write 工具不产 patch(SDK 的 write.js 里没有 patch 字段),覆盖已有文件时前端只能把
|
|
189
|
+
* 全部内容当新增行——删掉的行永远不会标红。这里在工具开始执行(文件还没被写)时读一份
|
|
190
|
+
* 旧内容,结束时用 SDK 的 generateUnifiedPatch 生成和 edit 同格式的 patch,
|
|
191
|
+
* 于是「新增绿 / 删除红」在两条写入路径上一致。新文件没有旧内容,仍按新增行展示。
|
|
192
|
+
*/
|
|
193
|
+
const WRITE_BASE_MAX_BYTES = 512 * 1024;
|
|
194
|
+
const writeBases = new Map<string, string>();
|
|
195
|
+
|
|
196
|
+
function writeArgsOf(args: unknown): { target: string; content: string | undefined } {
|
|
197
|
+
const a = args as { path?: unknown; file_path?: unknown; content?: unknown } | undefined;
|
|
198
|
+
const target = typeof a?.path === "string" ? a.path : typeof a?.file_path === "string" ? a.file_path : "";
|
|
199
|
+
return { target, content: typeof a?.content === "string" ? a.content : undefined };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function captureWriteBase(cwd: string, sessionPath: string, toolCallId: string, args: unknown): void {
|
|
203
|
+
const { target } = writeArgsOf(args);
|
|
204
|
+
if (!target) return;
|
|
205
|
+
try {
|
|
206
|
+
const abs = path.isAbsolute(target) ? target : path.resolve(cwd, target);
|
|
207
|
+
const stat = statSync(abs);
|
|
208
|
+
if (!stat.isFile() || stat.size > WRITE_BASE_MAX_BYTES) return;
|
|
209
|
+
const previous = readFileSync(abs, "utf8");
|
|
210
|
+
if (previous.includes("\u0000")) return; // 二进制不做行级 diff
|
|
211
|
+
if (writeBases.size > 256) writeBases.clear();
|
|
212
|
+
writeBases.set(`${sessionPath}\u0000${toolCallId}`, previous);
|
|
213
|
+
} catch {
|
|
214
|
+
/* 新文件或读不到:没有旧内容,走全绿兜底 */
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function takeWritePatch(cwd: string, sessionPath: string, toolCallId: string, args: unknown): string | undefined {
|
|
219
|
+
const key = `${sessionPath}\u0000${toolCallId}`;
|
|
220
|
+
const previous = writeBases.get(key);
|
|
221
|
+
writeBases.delete(key);
|
|
222
|
+
if (previous === undefined) return undefined;
|
|
223
|
+
const { target, content } = writeArgsOf(args);
|
|
224
|
+
if (content === undefined || content === previous) return undefined;
|
|
225
|
+
try {
|
|
226
|
+
return generateUnifiedPatch(target, previous, content).slice(0, 60_000);
|
|
227
|
+
} catch {
|
|
228
|
+
return undefined;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
185
232
|
function coercePartial(p: unknown): string | undefined {
|
|
186
233
|
if (p == null) return undefined;
|
|
187
234
|
if (typeof p === "string") return p.slice(-4000);
|
|
@@ -426,6 +473,8 @@ function translate(m: Managed, evt: AgentSessionEvent): void {
|
|
|
426
473
|
break;
|
|
427
474
|
}
|
|
428
475
|
case "tool_execution_start":
|
|
476
|
+
// write 覆盖前先留一份旧内容,结束时才能给出真实的新增/删除 diff
|
|
477
|
+
if (evt.toolName?.toLowerCase() === "write") captureWriteBase(m.cwd, m.sessionPath, evt.toolCallId, evt.args);
|
|
429
478
|
publish(m, {
|
|
430
479
|
type: "tool",
|
|
431
480
|
id: evt.toolCallId,
|
|
@@ -460,7 +509,7 @@ function translate(m: Managed, evt: AgentSessionEvent): void {
|
|
|
460
509
|
result: result.text || undefined,
|
|
461
510
|
isError: evt.isError,
|
|
462
511
|
encodingLoss: result.encodingLoss || undefined,
|
|
463
|
-
patch: patchOf((evt as any).result?.details),
|
|
512
|
+
patch: patchOf((evt as any).result?.details) ?? (evt.toolName?.toLowerCase() === "write" ? takeWritePatch(m.cwd, m.sessionPath, evt.toolCallId, (evt as any).args) : undefined),
|
|
464
513
|
ts: now,
|
|
465
514
|
});
|
|
466
515
|
publishUsage(m);
|
|
@@ -514,7 +563,15 @@ function translate(m: Managed, evt: AgentSessionEvent): void {
|
|
|
514
563
|
else publish(m, { type: "model", thinkingLevel: (evt as any).level, ts: now });
|
|
515
564
|
break;
|
|
516
565
|
case "auto_retry_start":
|
|
517
|
-
|
|
566
|
+
// 重试是流程内通知:发一条结构化 retry 事件(界面聚合成「重试 n/max」一行),
|
|
567
|
+
// 不再走 error 通道,免得每次尝试都多出一条报错。
|
|
568
|
+
publish(m, {
|
|
569
|
+
type: "retry",
|
|
570
|
+
attempt: Number((evt as any).attempt) || 0,
|
|
571
|
+
maxAttempts: Number((evt as any).maxAttempts) || 0,
|
|
572
|
+
message: String((evt as any).errorMessage ?? ""),
|
|
573
|
+
ts: now,
|
|
574
|
+
});
|
|
518
575
|
break;
|
|
519
576
|
default:
|
|
520
577
|
break;
|
|
@@ -881,6 +938,11 @@ export function disposeSession(m: Managed): void {
|
|
|
881
938
|
sessions.delete(m.sessionPath);
|
|
882
939
|
}
|
|
883
940
|
|
|
941
|
+
/**
|
|
942
|
+
* 让某个会话立刻收工:中断正在跑的回合并回收 agent。
|
|
943
|
+
* 归档用它——归档后会话从工作区消失,若它的回合还在后台跑,用户看不见却仍在消耗 token。
|
|
944
|
+
* 只影响这一个会话,其它会话的运行不受影响(LRU/空闲回收同样跳过 busy 的会话)。
|
|
945
|
+
*/
|
|
884
946
|
export async function disposeSessionPath(sessionPath: string): Promise<void> {
|
|
885
947
|
const m = sessions.get(sessionPath);
|
|
886
948
|
if (!m) return;
|