pi-web-ui 0.6.2 → 0.8.0
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/dist/server/agent-service.js +224 -64
- package/dist/server/index.js +101 -7
- package/dist/server/terminals.js +34 -1
- package/package.json +1 -1
- package/web/dist/assets/index-C0OZd0yM.js +119 -0
- package/web/dist/assets/index-DgQ2t1cr.css +41 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-C24Ja_UP.js +0 -119
- package/web/dist/assets/index-Lc62BKrm.css +0 -41
|
@@ -490,6 +490,42 @@ class ClientStateStore {
|
|
|
490
490
|
this.save();
|
|
491
491
|
}
|
|
492
492
|
}
|
|
493
|
+
/** Cap on simultaneously open conversations (each keeps a full runtime alive). */
|
|
494
|
+
const MAX_OPEN_CONVERSATIONS = 8;
|
|
495
|
+
const DEFAULT_CONV_TITLE = "新对话";
|
|
496
|
+
/** First user text in a session, truncated for the conversation list. */
|
|
497
|
+
function conversationTitle(session) {
|
|
498
|
+
try {
|
|
499
|
+
for (const m of session.agent.state.messages) {
|
|
500
|
+
if (m.role !== "user")
|
|
501
|
+
continue;
|
|
502
|
+
const content = m.content;
|
|
503
|
+
let text = "";
|
|
504
|
+
if (typeof content === "string") {
|
|
505
|
+
text = content;
|
|
506
|
+
}
|
|
507
|
+
else if (Array.isArray(content)) {
|
|
508
|
+
for (const p of content) {
|
|
509
|
+
if (p &&
|
|
510
|
+
typeof p === "object" &&
|
|
511
|
+
p.type === "text" &&
|
|
512
|
+
typeof p.text === "string") {
|
|
513
|
+
text = p.text;
|
|
514
|
+
break;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
const trimmed = text.trim().replace(/\s+/g, " ");
|
|
519
|
+
if (trimmed.length > 0) {
|
|
520
|
+
return trimmed.length > 30 ? `${trimmed.slice(0, 30)}…` : trimmed;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
catch {
|
|
525
|
+
// best-effort
|
|
526
|
+
}
|
|
527
|
+
return DEFAULT_CONV_TITLE;
|
|
528
|
+
}
|
|
493
529
|
export class ClientSession {
|
|
494
530
|
clientId;
|
|
495
531
|
cwd;
|
|
@@ -500,8 +536,31 @@ export class ClientSession {
|
|
|
500
536
|
agentDir;
|
|
501
537
|
/** Persisted per-client UI state (last workspace + recent projects). */
|
|
502
538
|
stateStore;
|
|
503
|
-
runtime
|
|
504
|
-
|
|
539
|
+
/** Open conversations — each owns its OWN runtime, so starting a new chat
|
|
540
|
+
* or switching chats never interrupts an in-flight run. `runtime` and
|
|
541
|
+
* `session` accessors below target the ACTIVE conversation. */
|
|
542
|
+
convs = new Map();
|
|
543
|
+
activeId = "";
|
|
544
|
+
convSeq = 0;
|
|
545
|
+
/** One ModelRuntime shared by all conversations — the model chosen in the
|
|
546
|
+
* top bar applies to every chat, not just the one that set it. Seeded by
|
|
547
|
+
* the first conversation and reused by later ones. */
|
|
548
|
+
sharedModelRuntime;
|
|
549
|
+
/** The active conversation (all session operations target it). */
|
|
550
|
+
get conv() {
|
|
551
|
+
const conv = this.convs.get(this.activeId);
|
|
552
|
+
if (!conv)
|
|
553
|
+
throw new Error("no active conversation");
|
|
554
|
+
return conv;
|
|
555
|
+
}
|
|
556
|
+
/** Runtime of the active conversation. */
|
|
557
|
+
get runtime() {
|
|
558
|
+
return this.conv.runtime;
|
|
559
|
+
}
|
|
560
|
+
/** Session of the active conversation. */
|
|
561
|
+
get session() {
|
|
562
|
+
return this.conv.session;
|
|
563
|
+
}
|
|
505
564
|
/** PTY terminals for this client (killed when the last socket detaches). */
|
|
506
565
|
terminals = new TerminalManager((msg) => this.emit(msg));
|
|
507
566
|
/** Web-facing extension UI context (widgets, notifications). */
|
|
@@ -510,48 +569,40 @@ export class ClientSession {
|
|
|
510
569
|
/** Connected sockets for this client (multiple tabs share the session). */
|
|
511
570
|
sinks = new Set();
|
|
512
571
|
pendingNotices = [];
|
|
513
|
-
unsubscribe;
|
|
514
572
|
snapshotTimer = null;
|
|
515
573
|
sessionsTimer = null;
|
|
516
574
|
version = 0;
|
|
517
575
|
/**
|
|
518
|
-
*
|
|
519
|
-
*
|
|
520
|
-
*
|
|
576
|
+
* Per-conversation serialization caches (stable message ids, UiMessage
|
|
577
|
+
* object cache, message-array signature, queue counts) live inside each
|
|
578
|
+
* Conversation — see Conversation above.
|
|
521
579
|
*/
|
|
522
|
-
msgIds = new Map();
|
|
523
|
-
nextMsgId = 1;
|
|
524
|
-
/** Serialized UiMessage cache — object-reference-stable across snapshots, so
|
|
525
|
-
* the frontend's React.memo can skip unchanged messages entirely. */
|
|
526
|
-
uiMessageCache = new Map();
|
|
527
|
-
/** Reused when the message set didn't change, keeping state.messages
|
|
528
|
-
* reference-stable so the frontend can memoize derived maps. */
|
|
529
|
-
lastMessagesSig = "";
|
|
530
|
-
lastMessagesArray = [];
|
|
531
|
-
queueSteering = 0;
|
|
532
|
-
queueFollowUp = 0;
|
|
533
580
|
disposed = false;
|
|
534
581
|
/** pi-config readiness check, cached briefly so 60ms snapshots don't hit disk. */
|
|
535
582
|
piCheckCache = null;
|
|
536
|
-
constructor(clientId, cwd, sessionDir, agentDir,
|
|
583
|
+
constructor(clientId, cwd, sessionDir, agentDir, stateStore) {
|
|
537
584
|
this.clientId = clientId;
|
|
538
585
|
this.cwd = cwd;
|
|
539
586
|
this.sessionDir = sessionDir;
|
|
540
587
|
this.agentDir = agentDir;
|
|
541
|
-
this.runtime = runtime;
|
|
542
|
-
this.session = runtime.session;
|
|
543
588
|
this.stateStore = stateStore;
|
|
544
589
|
}
|
|
545
590
|
static async create(clientId, cwd, sessionDir, stateStore) {
|
|
546
591
|
const agentDir = process.env.PI_CODING_AGENT_DIR ?? getAgentDir();
|
|
547
|
-
const
|
|
592
|
+
const cs = new ClientSession(clientId, cwd, sessionDir, agentDir, stateStore);
|
|
593
|
+
const runtime = await createAgentSessionRuntime(cs.makeRuntimeFactory(), {
|
|
548
594
|
cwd,
|
|
549
595
|
agentDir,
|
|
550
596
|
// Resume the most recent session for this client's private session dir,
|
|
551
597
|
// or start a fresh one on first visit.
|
|
552
598
|
sessionManager: SessionManager.continueRecent(cwd, sessionDir),
|
|
553
599
|
});
|
|
554
|
-
|
|
600
|
+
// First conversation = the resumed session; it also seeds the shared
|
|
601
|
+
// ModelRuntime that every later conversation reuses.
|
|
602
|
+
cs.sharedModelRuntime = runtime.services.modelRuntime;
|
|
603
|
+
const conv = cs.makeConversation(runtime);
|
|
604
|
+
cs.convs.set(conv.id, conv);
|
|
605
|
+
cs.activeId = conv.id;
|
|
555
606
|
for (const d of runtime.diagnostics) {
|
|
556
607
|
if (d.type !== "info") {
|
|
557
608
|
cs.pendingNotices.push({
|
|
@@ -564,15 +615,42 @@ export class ClientSession {
|
|
|
564
615
|
await cs.bindSession();
|
|
565
616
|
return cs;
|
|
566
617
|
}
|
|
567
|
-
/**
|
|
568
|
-
|
|
569
|
-
|
|
618
|
+
/**
|
|
619
|
+
* Factory for cwd-bound runtimes. All conversations share ONE ModelRuntime
|
|
620
|
+
* (the model choice is client-wide), so later conversations reuse the
|
|
621
|
+
* instance created with the first one.
|
|
622
|
+
*/
|
|
623
|
+
makeRuntimeFactory() {
|
|
624
|
+
return async ({ cwd: effectiveCwd, sessionManager }) => {
|
|
625
|
+
const services = await createAgentSessionServices({
|
|
626
|
+
cwd: effectiveCwd,
|
|
627
|
+
modelRuntime: this.sharedModelRuntime,
|
|
628
|
+
});
|
|
629
|
+
return {
|
|
630
|
+
...(await createAgentSessionFromServices({ services, sessionManager })),
|
|
631
|
+
services,
|
|
632
|
+
diagnostics: services.diagnostics,
|
|
633
|
+
};
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
/** Wrap a fresh runtime as a new conversation record. */
|
|
637
|
+
makeConversation(runtime) {
|
|
570
638
|
return {
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
639
|
+
id: `c${++this.convSeq}`,
|
|
640
|
+
title: conversationTitle(runtime.session),
|
|
641
|
+
runtime,
|
|
642
|
+
session: runtime.session,
|
|
643
|
+
cwd: runtime.cwd,
|
|
644
|
+
createdAt: Date.now(),
|
|
645
|
+
msgIds: new Map(),
|
|
646
|
+
nextMsgId: 1,
|
|
647
|
+
uiMessageCache: new Map(),
|
|
648
|
+
lastMessagesSig: "",
|
|
649
|
+
lastMessagesArray: [],
|
|
650
|
+
queueSteering: 0,
|
|
651
|
+
queueFollowUp: 0,
|
|
574
652
|
};
|
|
575
|
-
}
|
|
653
|
+
}
|
|
576
654
|
/** Add a socket to this client's broadcast set; flushes buffered startup notices. */
|
|
577
655
|
attachSink(send) {
|
|
578
656
|
this.sinks.add(send);
|
|
@@ -587,6 +665,9 @@ export class ClientSession {
|
|
|
587
665
|
const statuses = this.webUi.statusSnapshot();
|
|
588
666
|
if (statuses.length > 0)
|
|
589
667
|
send({ type: "statuses", statuses });
|
|
668
|
+
// Reconnect: push the open-conversation list so the left panel shows
|
|
669
|
+
// every chat (a fresh socket never got the newChat/switch pushes).
|
|
670
|
+
this.emitConversations();
|
|
590
671
|
}
|
|
591
672
|
detachSink(send) {
|
|
592
673
|
this.sinks.delete(send);
|
|
@@ -602,18 +683,19 @@ export class ClientSession {
|
|
|
602
683
|
for (const sink of [...this.sinks])
|
|
603
684
|
sink(msg);
|
|
604
685
|
}
|
|
605
|
-
/** (Re)attach event plumbing to the
|
|
686
|
+
/** (Re)attach event plumbing to the ACTIVE conversation's session. */
|
|
606
687
|
async bindSession() {
|
|
607
|
-
this.
|
|
608
|
-
|
|
609
|
-
|
|
688
|
+
const conv = this.conv;
|
|
689
|
+
conv.unsubscribe?.();
|
|
690
|
+
conv.session = conv.runtime.session;
|
|
691
|
+
await conv.session.bindExtensions({
|
|
610
692
|
mode: "rpc",
|
|
611
693
|
uiContext: this.webUi,
|
|
612
694
|
onError: (err) => {
|
|
613
695
|
this.emit({ type: "notice", level: "error", text: err.error });
|
|
614
696
|
},
|
|
615
697
|
});
|
|
616
|
-
|
|
698
|
+
conv.unsubscribe = conv.session.subscribe((event) => this.onEvent(conv, event));
|
|
617
699
|
this.scheduleSnapshot();
|
|
618
700
|
this.webUi.refresh();
|
|
619
701
|
this.startWidgetsTimer();
|
|
@@ -627,7 +709,7 @@ export class ClientSession {
|
|
|
627
709
|
this.webUi.refresh();
|
|
628
710
|
}, WIDGET_REFRESH_MS);
|
|
629
711
|
}
|
|
630
|
-
onEvent(event) {
|
|
712
|
+
onEvent(conv, event) {
|
|
631
713
|
switch (event.type) {
|
|
632
714
|
case "bash_execution_update": {
|
|
633
715
|
if (event.id) {
|
|
@@ -653,8 +735,8 @@ export class ClientSession {
|
|
|
653
735
|
break;
|
|
654
736
|
}
|
|
655
737
|
case "queue_update":
|
|
656
|
-
|
|
657
|
-
|
|
738
|
+
conv.queueSteering = event.steering.length;
|
|
739
|
+
conv.queueFollowUp = event.followUp.length;
|
|
658
740
|
break;
|
|
659
741
|
// A run finished or a new entry was persisted — keep the session list fresh
|
|
660
742
|
// (new chat + first message, completed turns, compaction, etc.).
|
|
@@ -667,37 +749,41 @@ export class ClientSession {
|
|
|
667
749
|
}
|
|
668
750
|
this.scheduleSnapshot();
|
|
669
751
|
}
|
|
670
|
-
/** Debounced push of the persisted session list
|
|
752
|
+
/** Debounced push of the persisted session list + open conversations. */
|
|
671
753
|
scheduleSessionsRefresh() {
|
|
672
754
|
if (this.sessionsTimer)
|
|
673
755
|
return;
|
|
674
756
|
this.sessionsTimer = setTimeout(() => {
|
|
675
757
|
this.sessionsTimer = null;
|
|
676
|
-
if (
|
|
677
|
-
|
|
758
|
+
if (this.disposed)
|
|
759
|
+
return;
|
|
760
|
+
this.emitConversations();
|
|
761
|
+
void this.pushSessions();
|
|
678
762
|
}, 800);
|
|
679
763
|
}
|
|
680
764
|
/** Serialize a persisted message with a STABLE id + cached object reference. */
|
|
681
765
|
serializeCached(m) {
|
|
766
|
+
const conv = this.conv;
|
|
682
767
|
const key = m.role === "toolResult"
|
|
683
768
|
? `t:${m.toolCallId}`
|
|
684
769
|
: `${m.role}:${m.timestamp}`;
|
|
685
|
-
let n =
|
|
770
|
+
let n = conv.msgIds.get(key);
|
|
686
771
|
if (n === undefined) {
|
|
687
|
-
n =
|
|
688
|
-
|
|
772
|
+
n = conv.nextMsgId++;
|
|
773
|
+
conv.msgIds.set(key, n);
|
|
689
774
|
}
|
|
690
775
|
const cacheKey = `${key}#${n}`;
|
|
691
|
-
const cached =
|
|
776
|
+
const cached = conv.uiMessageCache.get(cacheKey);
|
|
692
777
|
if (cached)
|
|
693
778
|
return cached;
|
|
694
779
|
const msg = serializeMessage(m, n);
|
|
695
780
|
if (msg)
|
|
696
|
-
|
|
781
|
+
conv.uiMessageCache.set(cacheKey, msg);
|
|
697
782
|
return msg;
|
|
698
783
|
}
|
|
699
784
|
snapshot() {
|
|
700
|
-
const
|
|
785
|
+
const conv = this.conv;
|
|
786
|
+
const state = conv.session.agent.state;
|
|
701
787
|
const model = state.model;
|
|
702
788
|
let stats = {
|
|
703
789
|
totalMessages: 0,
|
|
@@ -730,14 +816,15 @@ export class ClientSession {
|
|
|
730
816
|
// cached (reference-stable) anyway, and a stable array reference lets the
|
|
731
817
|
// frontend memoize derived maps instead of rebuilding them every 60ms.
|
|
732
818
|
const sig = rawMessages.map((m) => m.id).join("\u0001");
|
|
733
|
-
const messages =
|
|
734
|
-
|
|
735
|
-
|
|
819
|
+
const messages = conv.lastMessagesSig === sig ? conv.lastMessagesArray : rawMessages;
|
|
820
|
+
conv.lastMessagesSig = sig;
|
|
821
|
+
conv.lastMessagesArray = rawMessages;
|
|
736
822
|
return {
|
|
737
823
|
clientId: this.clientId,
|
|
738
824
|
cwd: this.cwd,
|
|
739
825
|
sessionId: this.session.sessionId,
|
|
740
826
|
sessionFile: this.session.sessionFile,
|
|
827
|
+
conversationId: this.activeId,
|
|
741
828
|
messages,
|
|
742
829
|
// The in-progress assistant message lives in state.streamingMessage
|
|
743
830
|
// (the SDK only pushes it into state.messages at message_end). Surfacing
|
|
@@ -751,7 +838,7 @@ export class ClientSession {
|
|
|
751
838
|
? { id: model.id, name: model.name, provider: model.provider }
|
|
752
839
|
: null,
|
|
753
840
|
thinkingLevel: state.thinkingLevel,
|
|
754
|
-
queue: { steering:
|
|
841
|
+
queue: { steering: conv.queueSteering, followUp: conv.queueFollowUp },
|
|
755
842
|
errorMessage: state.errorMessage,
|
|
756
843
|
tools: state.tools.map((t) => t.name),
|
|
757
844
|
version: ++this.version,
|
|
@@ -1288,6 +1375,13 @@ export class ClientSession {
|
|
|
1288
1375
|
text: `提示发送失败:${err.message}`,
|
|
1289
1376
|
});
|
|
1290
1377
|
}
|
|
1378
|
+
// Name the conversation after its first user prompt.
|
|
1379
|
+
const conv = this.conv;
|
|
1380
|
+
if (conv.title === DEFAULT_CONV_TITLE && text.trim()) {
|
|
1381
|
+
const trimmed = text.trim().replace(/\s+/g, " ");
|
|
1382
|
+
conv.title = trimmed.length > 30 ? `${trimmed.slice(0, 30)}…` : trimmed;
|
|
1383
|
+
this.emitConversations();
|
|
1384
|
+
}
|
|
1291
1385
|
this.flushSnapshot();
|
|
1292
1386
|
}
|
|
1293
1387
|
/**
|
|
@@ -1574,9 +1668,25 @@ export class ClientSession {
|
|
|
1574
1668
|
this.flushSnapshot();
|
|
1575
1669
|
}
|
|
1576
1670
|
async newChat() {
|
|
1671
|
+
if (this.convs.size >= MAX_OPEN_CONVERSATIONS) {
|
|
1672
|
+
this.emit({
|
|
1673
|
+
type: "notice",
|
|
1674
|
+
level: "warning",
|
|
1675
|
+
text: `打开的对话已达上限(${MAX_OPEN_CONVERSATIONS} 个),请先切换或重载页面关闭`,
|
|
1676
|
+
});
|
|
1677
|
+
return;
|
|
1678
|
+
}
|
|
1577
1679
|
try {
|
|
1578
|
-
await this.
|
|
1680
|
+
const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(), {
|
|
1681
|
+
cwd: this.cwd,
|
|
1682
|
+
agentDir: this.agentDir,
|
|
1683
|
+
sessionManager: SessionManager.create(this.cwd, this.sessionDir),
|
|
1684
|
+
});
|
|
1685
|
+
const conv = this.makeConversation(runtime);
|
|
1686
|
+
this.convs.set(conv.id, conv);
|
|
1687
|
+
this.activeId = conv.id;
|
|
1579
1688
|
await this.bindSession();
|
|
1689
|
+
this.emitConversations();
|
|
1580
1690
|
}
|
|
1581
1691
|
catch (err) {
|
|
1582
1692
|
this.emit({
|
|
@@ -1587,6 +1697,44 @@ export class ClientSession {
|
|
|
1587
1697
|
}
|
|
1588
1698
|
this.flushSnapshot();
|
|
1589
1699
|
}
|
|
1700
|
+
/** Switch the ACTIVE conversation without interrupting any other chat. */
|
|
1701
|
+
async switchConversation(id) {
|
|
1702
|
+
if (!this.convs.has(id) || id === this.activeId)
|
|
1703
|
+
return;
|
|
1704
|
+
this.activeId = id;
|
|
1705
|
+
this.cwd = this.conv.cwd;
|
|
1706
|
+
this.webUi.refresh();
|
|
1707
|
+
this.emitConversations();
|
|
1708
|
+
// Workspace-bound panels (session list / file tree / commands) follow
|
|
1709
|
+
// the active conversation's cwd.
|
|
1710
|
+
void this.refreshSessions();
|
|
1711
|
+
void this.listFiles(undefined);
|
|
1712
|
+
void this.listCommands();
|
|
1713
|
+
this.flushSnapshot();
|
|
1714
|
+
}
|
|
1715
|
+
/** Push the open-conversation list to the client. */
|
|
1716
|
+
emitConversations() {
|
|
1717
|
+
const conversations = [];
|
|
1718
|
+
for (const conv of this.convs.values()) {
|
|
1719
|
+
let messageCount = 0;
|
|
1720
|
+
let isStreaming = false;
|
|
1721
|
+
try {
|
|
1722
|
+
messageCount = conv.session.getSessionStats().totalMessages;
|
|
1723
|
+
isStreaming = conv.session.isStreaming;
|
|
1724
|
+
}
|
|
1725
|
+
catch {
|
|
1726
|
+
// session being replaced — report defaults
|
|
1727
|
+
}
|
|
1728
|
+
conversations.push({
|
|
1729
|
+
id: conv.id,
|
|
1730
|
+
title: conv.title,
|
|
1731
|
+
cwd: conv.cwd,
|
|
1732
|
+
messageCount,
|
|
1733
|
+
isStreaming,
|
|
1734
|
+
});
|
|
1735
|
+
}
|
|
1736
|
+
this.emit({ type: "conversations", conversations, activeId: this.activeId });
|
|
1737
|
+
}
|
|
1590
1738
|
/** List persisted sessions for this client, newest first. */
|
|
1591
1739
|
/** Push the persisted session list to the client (client-requested). */
|
|
1592
1740
|
async refreshSessions() {
|
|
@@ -1642,6 +1790,12 @@ export class ClientSession {
|
|
|
1642
1790
|
try {
|
|
1643
1791
|
await this.runtime.switchSession(path);
|
|
1644
1792
|
await this.bindSession();
|
|
1793
|
+
// The resumed session carries its own cwd — sync it into the ACTIVE
|
|
1794
|
+
// conversation (other open conversations are untouched).
|
|
1795
|
+
this.conv.cwd = this.runtime.cwd;
|
|
1796
|
+
this.cwd = this.runtime.cwd;
|
|
1797
|
+
this.conv.title = conversationTitle(this.runtime.session);
|
|
1798
|
+
this.emitConversations();
|
|
1645
1799
|
}
|
|
1646
1800
|
catch (err) {
|
|
1647
1801
|
this.emit({
|
|
@@ -2018,20 +2172,24 @@ export class ClientSession {
|
|
|
2018
2172
|
this.flushSnapshot();
|
|
2019
2173
|
return;
|
|
2020
2174
|
}
|
|
2021
|
-
// Build the new runtime first — only swap on
|
|
2022
|
-
|
|
2175
|
+
// Build the new runtime first — only swap the ACTIVE conversation on
|
|
2176
|
+
// success (other open conversations keep their own cwd + runtime).
|
|
2177
|
+
const newRuntime = await createAgentSessionRuntime(this.makeRuntimeFactory(), {
|
|
2023
2178
|
cwd: abs,
|
|
2024
2179
|
agentDir: this.agentDir,
|
|
2025
2180
|
sessionManager: SessionManager.continueRecent(abs, this.sessionDir),
|
|
2026
2181
|
});
|
|
2027
|
-
const
|
|
2028
|
-
|
|
2182
|
+
const conv = this.conv;
|
|
2183
|
+
const oldRuntime = conv.runtime;
|
|
2184
|
+
conv.runtime = newRuntime;
|
|
2185
|
+
conv.session = newRuntime.session;
|
|
2186
|
+
conv.cwd = abs;
|
|
2029
2187
|
this.cwd = abs;
|
|
2030
2188
|
// Remember the new workspace (restore target + recent-project entry).
|
|
2031
2189
|
this.stateStore.remember(this.clientId, abs);
|
|
2032
2190
|
void this.pushProjects();
|
|
2033
|
-
|
|
2034
|
-
|
|
2191
|
+
conv.unsubscribe?.();
|
|
2192
|
+
conv.unsubscribe = undefined;
|
|
2035
2193
|
await this.bindSession();
|
|
2036
2194
|
await oldRuntime.dispose().catch(() => { });
|
|
2037
2195
|
for (const d of newRuntime.diagnostics) {
|
|
@@ -2044,6 +2202,7 @@ export class ClientSession {
|
|
|
2044
2202
|
level: "info",
|
|
2045
2203
|
text: `已切换到工作目录:${abs}`,
|
|
2046
2204
|
});
|
|
2205
|
+
this.emitConversations();
|
|
2047
2206
|
void this.refreshSessions();
|
|
2048
2207
|
void this.listFiles(undefined);
|
|
2049
2208
|
// Commands are per-project (.pi/commands.json in the current cwd).
|
|
@@ -2164,13 +2323,14 @@ export class ClientSession {
|
|
|
2164
2323
|
this.widgetsTimer = null;
|
|
2165
2324
|
}
|
|
2166
2325
|
this.webUi.dispose();
|
|
2167
|
-
this.
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2326
|
+
for (const conv of this.convs.values()) {
|
|
2327
|
+
conv.unsubscribe?.();
|
|
2328
|
+
try {
|
|
2329
|
+
await conv.runtime.dispose();
|
|
2330
|
+
}
|
|
2331
|
+
catch {
|
|
2332
|
+
// best effort
|
|
2333
|
+
}
|
|
2174
2334
|
}
|
|
2175
2335
|
}
|
|
2176
2336
|
}
|
package/dist/server/index.js
CHANGED
|
@@ -12,12 +12,13 @@
|
|
|
12
12
|
* PI_WEB_DATA_DIR where per-client session dirs are stored (default: <cwd>/.pi-web)
|
|
13
13
|
* PI_CODING_AGENT_DIR pi config dir (auth/models/skills) — passed to the SDK
|
|
14
14
|
*/
|
|
15
|
-
import { existsSync } from "node:fs";
|
|
16
|
-
import { stat } from "node:fs/promises";
|
|
15
|
+
import { createWriteStream, existsSync } from "node:fs";
|
|
16
|
+
import { mkdir, stat } from "node:fs/promises";
|
|
17
17
|
import { createServer } from "node:http";
|
|
18
|
-
import { basename, dirname, join, resolve } from "node:path";
|
|
18
|
+
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
19
19
|
import { fileURLToPath } from "node:url";
|
|
20
20
|
import { randomUUID } from "node:crypto";
|
|
21
|
+
import { pipeline } from "node:stream/promises";
|
|
21
22
|
import express from "express";
|
|
22
23
|
import { WebSocket, WebSocketServer } from "ws";
|
|
23
24
|
import { VERSION } from "@earendil-works/pi-coding-agent";
|
|
@@ -32,10 +33,15 @@ app.get("/api/health", (_req, res) => {
|
|
|
32
33
|
res.json({ ok: true, piVersion: VERSION, cwd: CWD, pid: process.pid });
|
|
33
34
|
});
|
|
34
35
|
/**
|
|
35
|
-
* Stream a workspace file
|
|
36
|
-
*
|
|
36
|
+
* Stream a workspace file over HTTP.
|
|
37
|
+
*
|
|
38
|
+
* Media preview (no download param): only image/video kinds are served —
|
|
37
39
|
* text goes over the WebSocket, and exe/jar/etc. are never exposed here.
|
|
38
40
|
* express's sendFile handles Range requests, so video seeking works.
|
|
41
|
+
*
|
|
42
|
+
* Download (?download=1): any file kind is served with
|
|
43
|
+
* Content-Disposition: attachment so the browser saves it instead of
|
|
44
|
+
* rendering. Path is validated against the workspace root either way.
|
|
39
45
|
*/
|
|
40
46
|
app.get("/api/file", async (req, res) => {
|
|
41
47
|
try {
|
|
@@ -54,7 +60,8 @@ app.get("/api/file", async (req, res) => {
|
|
|
54
60
|
const abs = wp.abs;
|
|
55
61
|
const name = basename(abs);
|
|
56
62
|
const kind = previewKind(name);
|
|
57
|
-
|
|
63
|
+
const isDownload = req.query.download === "1";
|
|
64
|
+
if (!isDownload && kind !== "image" && kind !== "video") {
|
|
58
65
|
res.status(400).end("not a previewable media file");
|
|
59
66
|
return;
|
|
60
67
|
}
|
|
@@ -63,12 +70,96 @@ app.get("/api/file", async (req, res) => {
|
|
|
63
70
|
res.status(400).end("not a file");
|
|
64
71
|
return;
|
|
65
72
|
}
|
|
66
|
-
|
|
73
|
+
if (isDownload) {
|
|
74
|
+
// res.download sets Content-Disposition: attachment and RFC 5987
|
|
75
|
+
// filename* encoding for non-ASCII names.
|
|
76
|
+
res.download(abs, name);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
res.sendFile(abs);
|
|
80
|
+
}
|
|
67
81
|
}
|
|
68
82
|
catch {
|
|
69
83
|
res.status(404).end("not found");
|
|
70
84
|
}
|
|
71
85
|
});
|
|
86
|
+
/**
|
|
87
|
+
* Save an uploaded file into the client's workspace (drag & drop from the OS
|
|
88
|
+
* file manager). Query params:
|
|
89
|
+
* clientId client session — resolves the workspace root (falls back to CWD)
|
|
90
|
+
* destDir workspace-relative target directory ("" = workspace root)
|
|
91
|
+
* Request body is the raw file bytes (streamed to disk); headers:
|
|
92
|
+
* X-File-Name original file name (URI-encoded)
|
|
93
|
+
* X-File-Rel-Path optional path of the file within the drop (URI-encoded)
|
|
94
|
+
* — its directory part is recreated under destDir, so
|
|
95
|
+
* dropping a whole folder keeps its structure. The final
|
|
96
|
+
* segment is used as the file name.
|
|
97
|
+
* Paths are validated against the workspace root; existing files get a
|
|
98
|
+
* "name (1).ext" suffix instead of being overwritten.
|
|
99
|
+
*/
|
|
100
|
+
app.post("/api/upload", async (req, res) => {
|
|
101
|
+
const fail = (status, error) => res.status(status).json({ ok: false, error });
|
|
102
|
+
try {
|
|
103
|
+
const cid = typeof req.query.clientId === "string" ? req.query.clientId : "";
|
|
104
|
+
const rawDest = typeof req.query.destDir === "string" ? req.query.destDir : "";
|
|
105
|
+
const cs = cid ? service.get(cid) : undefined;
|
|
106
|
+
const wp = workspacePath(cs?.cwd ?? CWD, rawDest);
|
|
107
|
+
if (!wp) {
|
|
108
|
+
fail(400, "目标目录不在工作区内");
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const nameRaw = typeof req.headers["x-file-name"] === "string"
|
|
112
|
+
? decodeURIComponent(req.headers["x-file-name"])
|
|
113
|
+
: "";
|
|
114
|
+
const relRaw = typeof req.headers["x-file-rel-path"] === "string"
|
|
115
|
+
? decodeURIComponent(req.headers["x-file-rel-path"])
|
|
116
|
+
: "";
|
|
117
|
+
// Sanitize every path segment: strip separators/traversal, drop empties.
|
|
118
|
+
const clean = (s) => {
|
|
119
|
+
const seg = basename(s).replace(/[\\/]/g, "");
|
|
120
|
+
return seg && seg !== "." && seg !== ".." ? seg : null;
|
|
121
|
+
};
|
|
122
|
+
const relDirs = relRaw
|
|
123
|
+
.split("/")
|
|
124
|
+
.map(clean)
|
|
125
|
+
.filter((s) => s !== null);
|
|
126
|
+
const name = clean(relDirs.length > 0 ? relDirs[relDirs.length - 1] : nameRaw);
|
|
127
|
+
if (!name) {
|
|
128
|
+
fail(400, "无效文件名");
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
// Ensure the destination directory exists (and is a directory).
|
|
132
|
+
const destAbs = join(wp.abs, ...relDirs.slice(0, -1));
|
|
133
|
+
const destSt = await stat(wp.abs).catch(() => null);
|
|
134
|
+
if (destSt && !destSt.isDirectory()) {
|
|
135
|
+
fail(400, "目标不是目录");
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
await mkdir(destAbs, { recursive: true });
|
|
139
|
+
// Never overwrite: "name (1).ext", "name (2).ext", …
|
|
140
|
+
const ext = extname(name);
|
|
141
|
+
const stem = name.slice(0, name.length - ext.length);
|
|
142
|
+
let finalName = name;
|
|
143
|
+
for (let i = 1; existsSync(join(destAbs, finalName)); i++) {
|
|
144
|
+
finalName = `${stem} (${i})${ext}`;
|
|
145
|
+
}
|
|
146
|
+
const finalAbs = join(destAbs, finalName);
|
|
147
|
+
// Stream the request body straight to disk.
|
|
148
|
+
let size = 0;
|
|
149
|
+
const out = createWriteStream(finalAbs);
|
|
150
|
+
req.on("data", (chunk) => {
|
|
151
|
+
size += chunk.length;
|
|
152
|
+
});
|
|
153
|
+
await pipeline(req, out);
|
|
154
|
+
const relOut = [rawDest, ...relDirs.slice(0, -1), finalName]
|
|
155
|
+
.filter(Boolean)
|
|
156
|
+
.join("/");
|
|
157
|
+
res.json({ ok: true, path: relOut, name: finalName, size });
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
fail(500, "上传失败");
|
|
161
|
+
}
|
|
162
|
+
});
|
|
72
163
|
// Production: serve the built frontend from web/dist. Resolve relative to this
|
|
73
164
|
// module so it works when installed as a package (global/npx/Docker), not just
|
|
74
165
|
// from the repo root. In dev, Vite serves the UI on :5173 and proxies /ws.
|
|
@@ -148,6 +239,9 @@ wss.on("connection", (ws) => {
|
|
|
148
239
|
case "switch_session":
|
|
149
240
|
void cs.switchSession(msg.path);
|
|
150
241
|
break;
|
|
242
|
+
case "switch_conversation":
|
|
243
|
+
void cs.switchConversation(msg.id);
|
|
244
|
+
break;
|
|
151
245
|
case "list_files":
|
|
152
246
|
void cs.listFiles(msg.path);
|
|
153
247
|
break;
|