@rexvane/piweb 0.3.9 → 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 +9 -9
- package/.next/build-manifest.json +2 -2
- package/.next/prerender-manifest.json +3 -3
- 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 +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 +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 +9 -9
- 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/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/686-97c833615d1565c5.js +1 -0
- package/.next/static/chunks/app/page-19e37fe240ae3e20.js +1 -0
- package/README.md +4 -3
- package/package.json +1 -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/hooks/usePiWeb.ts +14 -10
- package/src/i18n.tsx +8 -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/.next/static/chunks/686-7861c776412c335c.js +0 -1
- package/.next/static/chunks/app/page-8ef773ce62ce3aaa.js +0 -1
- /package/.next/static/{tU0v6KuZoq5gHtJ8zOyb1 → CLy0QZWDpEExcItT1yudl}/_buildManifest.js +0 -0
- /package/.next/static/{tU0v6KuZoq5gHtJ8zOyb1 → CLy0QZWDpEExcItT1yudl}/_ssgManifest.js +0 -0
|
@@ -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
|
/>
|
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
|
// 侧栏
|
|
@@ -551,6 +555,10 @@ const en: Dict = {
|
|
|
551
555
|
error: "Error",
|
|
552
556
|
copy: "Copy",
|
|
553
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",
|
|
554
562
|
export: "Export",
|
|
555
563
|
settings: "Settings",
|
|
556
564
|
newChat: "New session",
|
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;
|
|
@@ -99,21 +99,38 @@ export function classifyMessageChars(
|
|
|
99
99
|
return out;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
/**
|
|
102
|
+
/** 删除文件的命令:rm / Remove-Item / del / trash 等,且至少带一个非选项参数 */
|
|
103
|
+
const DELETE_COMMANDS = new Set(["rm", "remove-item", "ri", "del", "erase", "unlink", "trash"]);
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 单条命令是否是删除。只看该段的第一个词,所以必须按 ; && || 先切段——
|
|
107
|
+
* `cd src && rm main.py` 这种链式写法以前整条都判不出来。
|
|
108
|
+
*/
|
|
109
|
+
function isDeleteSegment(segment: string): boolean {
|
|
110
|
+
const tokens = segment.trim().split(/\s+/).filter(Boolean);
|
|
111
|
+
if (!tokens.length) return false;
|
|
112
|
+
const base = (tokens[0].split(/[\\/]/).pop() ?? tokens[0]).toLowerCase().replace(/\.(exe|ps1|bat|cmd)$/, "");
|
|
113
|
+
// git rm <path>:删除动作在第二个词
|
|
114
|
+
if (base === "git") return tokens[1]?.toLowerCase() === "rm" && tokens.slice(2).some((tok) => !tok.startsWith("-"));
|
|
115
|
+
if (!DELETE_COMMANDS.has(base)) return false;
|
|
116
|
+
return tokens.slice(1).some((tok) => !tok.startsWith("-"));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** bash/PowerShell 删除文件的命令判定(支持链式命令,逐段判断,避免误判整条) */
|
|
103
120
|
export function isDeleteCommand(command: string): boolean {
|
|
104
|
-
|
|
105
|
-
const base = first.split(/[\\/]/).pop() ?? first;
|
|
106
|
-
const name = base.toLowerCase().replace(/\.(exe|ps1|bat|cmd)$/, "");
|
|
107
|
-
if (!["rm", "remove-item", "ri", "del", "erase", "unlink", "trash"].includes(name)) return false;
|
|
108
|
-
const rest = command.trim().split(/\s+/).slice(1);
|
|
109
|
-
return rest.some((tok) => !tok.startsWith("-"));
|
|
121
|
+
return splitTopLevel(command).some(isDeleteSegment);
|
|
110
122
|
}
|
|
111
123
|
|
|
112
124
|
/** 从删除命令里提取第一个目标路径(去引号;相对路径解析交给展示层) */
|
|
113
125
|
export function deleteTargetOf(command: string): string {
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
126
|
+
for (const segment of splitTopLevel(command)) {
|
|
127
|
+
if (!isDeleteSegment(segment)) continue;
|
|
128
|
+
const tokens = segment.trim().match(/"[^"]*"|'[^']*'|\S+/g) ?? [];
|
|
129
|
+
const skip = tokens[0]?.toLowerCase() === "git" ? 2 : 1;
|
|
130
|
+
const target = tokens.slice(skip).find((tok) => !tok.startsWith("-"));
|
|
131
|
+
if (target) return target.replace(/^["']|["']$/g, "");
|
|
132
|
+
}
|
|
133
|
+
return "";
|
|
117
134
|
}
|
|
118
135
|
|
|
119
136
|
/** 按顶层的 ; && || 切分(引号内不切) */
|
package/src/lib/types.ts
CHANGED
|
@@ -142,6 +142,8 @@ export type WebEvent =
|
|
|
142
142
|
}
|
|
143
143
|
| { type: "extension_ui_resolved"; id: string; ts: number }
|
|
144
144
|
| { type: "error"; message: string; ts: number }
|
|
145
|
+
/** 自动重试:流程内通知,界面聚合成一条「重试 n/max」,不作为错误条目累积 */
|
|
146
|
+
| { type: "retry"; attempt: number; maxAttempts: number; message: string; ts: number }
|
|
145
147
|
/** 项目生长:记录了一步(工具结束 / 回合结束 / 外部修改 / 会话基线) */
|
|
146
148
|
| { type: "growth"; step: GrowthStep; ts: number }
|
|
147
149
|
/** 项目生长:改盘类工具运行期间目录监听看到的新路径(累计;空数组 = 清空) */
|