pi-web-ui 0.6.1 → 0.7.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 +240 -69
- package/dist/server/index.js +14 -5
- package/dist/server/terminals.js +34 -1
- package/package.json +1 -1
- package/web/dist/assets/index-4xJ6rdKD.css +41 -0
- package/web/dist/assets/{index-DhRzJKW9.js → index-sbaQBdf7.js} +47 -47
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-Lc62BKrm.css +0 -41
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { spawn } from "node:child_process";
|
|
13
13
|
import { existsSync, readFileSync, statSync, writeFileSync, mkdirSync, } from "node:fs";
|
|
14
|
-
import { dirname, join, resolve } from "node:path";
|
|
14
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
16
|
import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, getAgentDir, SessionManager, } from "@earendil-works/pi-coding-agent";
|
|
17
17
|
import { serializeMessage, serializeStreamingMessage, } from "./serialize.js";
|
|
@@ -429,6 +429,18 @@ function extractPartialText(partial) {
|
|
|
429
429
|
}
|
|
430
430
|
return null;
|
|
431
431
|
}
|
|
432
|
+
/**
|
|
433
|
+
* Resolve a workspace-relative path against a root, refusing traversal
|
|
434
|
+
* (".." escapes). Returns { abs, rel } — rel is normalized and slash-
|
|
435
|
+
* separated — or null when the path leaves the workspace.
|
|
436
|
+
*/
|
|
437
|
+
export function workspacePath(root, raw) {
|
|
438
|
+
const abs = resolve(root, raw);
|
|
439
|
+
const rel = relative(root, abs);
|
|
440
|
+
if (rel.startsWith("..") || rel.includes(`${sep}..`))
|
|
441
|
+
return null;
|
|
442
|
+
return { abs, rel };
|
|
443
|
+
}
|
|
432
444
|
/**
|
|
433
445
|
* Persists which workspace each browser client last used + which workspaces it
|
|
434
446
|
* has opened, so a server restart / page reload restores the same project and
|
|
@@ -478,6 +490,42 @@ class ClientStateStore {
|
|
|
478
490
|
this.save();
|
|
479
491
|
}
|
|
480
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
|
+
}
|
|
481
529
|
export class ClientSession {
|
|
482
530
|
clientId;
|
|
483
531
|
cwd;
|
|
@@ -488,8 +536,31 @@ export class ClientSession {
|
|
|
488
536
|
agentDir;
|
|
489
537
|
/** Persisted per-client UI state (last workspace + recent projects). */
|
|
490
538
|
stateStore;
|
|
491
|
-
runtime
|
|
492
|
-
|
|
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
|
+
}
|
|
493
564
|
/** PTY terminals for this client (killed when the last socket detaches). */
|
|
494
565
|
terminals = new TerminalManager((msg) => this.emit(msg));
|
|
495
566
|
/** Web-facing extension UI context (widgets, notifications). */
|
|
@@ -498,48 +569,40 @@ export class ClientSession {
|
|
|
498
569
|
/** Connected sockets for this client (multiple tabs share the session). */
|
|
499
570
|
sinks = new Set();
|
|
500
571
|
pendingNotices = [];
|
|
501
|
-
unsubscribe;
|
|
502
572
|
snapshotTimer = null;
|
|
503
573
|
sessionsTimer = null;
|
|
504
574
|
version = 0;
|
|
505
575
|
/**
|
|
506
|
-
*
|
|
507
|
-
*
|
|
508
|
-
*
|
|
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.
|
|
509
579
|
*/
|
|
510
|
-
msgIds = new Map();
|
|
511
|
-
nextMsgId = 1;
|
|
512
|
-
/** Serialized UiMessage cache — object-reference-stable across snapshots, so
|
|
513
|
-
* the frontend's React.memo can skip unchanged messages entirely. */
|
|
514
|
-
uiMessageCache = new Map();
|
|
515
|
-
/** Reused when the message set didn't change, keeping state.messages
|
|
516
|
-
* reference-stable so the frontend can memoize derived maps. */
|
|
517
|
-
lastMessagesSig = "";
|
|
518
|
-
lastMessagesArray = [];
|
|
519
|
-
queueSteering = 0;
|
|
520
|
-
queueFollowUp = 0;
|
|
521
580
|
disposed = false;
|
|
522
581
|
/** pi-config readiness check, cached briefly so 60ms snapshots don't hit disk. */
|
|
523
582
|
piCheckCache = null;
|
|
524
|
-
constructor(clientId, cwd, sessionDir, agentDir,
|
|
583
|
+
constructor(clientId, cwd, sessionDir, agentDir, stateStore) {
|
|
525
584
|
this.clientId = clientId;
|
|
526
585
|
this.cwd = cwd;
|
|
527
586
|
this.sessionDir = sessionDir;
|
|
528
587
|
this.agentDir = agentDir;
|
|
529
|
-
this.runtime = runtime;
|
|
530
|
-
this.session = runtime.session;
|
|
531
588
|
this.stateStore = stateStore;
|
|
532
589
|
}
|
|
533
590
|
static async create(clientId, cwd, sessionDir, stateStore) {
|
|
534
591
|
const agentDir = process.env.PI_CODING_AGENT_DIR ?? getAgentDir();
|
|
535
|
-
const
|
|
592
|
+
const cs = new ClientSession(clientId, cwd, sessionDir, agentDir, stateStore);
|
|
593
|
+
const runtime = await createAgentSessionRuntime(cs.makeRuntimeFactory(), {
|
|
536
594
|
cwd,
|
|
537
595
|
agentDir,
|
|
538
596
|
// Resume the most recent session for this client's private session dir,
|
|
539
597
|
// or start a fresh one on first visit.
|
|
540
598
|
sessionManager: SessionManager.continueRecent(cwd, sessionDir),
|
|
541
599
|
});
|
|
542
|
-
|
|
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;
|
|
543
606
|
for (const d of runtime.diagnostics) {
|
|
544
607
|
if (d.type !== "info") {
|
|
545
608
|
cs.pendingNotices.push({
|
|
@@ -552,15 +615,42 @@ export class ClientSession {
|
|
|
552
615
|
await cs.bindSession();
|
|
553
616
|
return cs;
|
|
554
617
|
}
|
|
555
|
-
/**
|
|
556
|
-
|
|
557
|
-
|
|
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) {
|
|
558
638
|
return {
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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,
|
|
562
652
|
};
|
|
563
|
-
}
|
|
653
|
+
}
|
|
564
654
|
/** Add a socket to this client's broadcast set; flushes buffered startup notices. */
|
|
565
655
|
attachSink(send) {
|
|
566
656
|
this.sinks.add(send);
|
|
@@ -575,6 +665,9 @@ export class ClientSession {
|
|
|
575
665
|
const statuses = this.webUi.statusSnapshot();
|
|
576
666
|
if (statuses.length > 0)
|
|
577
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();
|
|
578
671
|
}
|
|
579
672
|
detachSink(send) {
|
|
580
673
|
this.sinks.delete(send);
|
|
@@ -590,18 +683,19 @@ export class ClientSession {
|
|
|
590
683
|
for (const sink of [...this.sinks])
|
|
591
684
|
sink(msg);
|
|
592
685
|
}
|
|
593
|
-
/** (Re)attach event plumbing to the
|
|
686
|
+
/** (Re)attach event plumbing to the ACTIVE conversation's session. */
|
|
594
687
|
async bindSession() {
|
|
595
|
-
this.
|
|
596
|
-
|
|
597
|
-
|
|
688
|
+
const conv = this.conv;
|
|
689
|
+
conv.unsubscribe?.();
|
|
690
|
+
conv.session = conv.runtime.session;
|
|
691
|
+
await conv.session.bindExtensions({
|
|
598
692
|
mode: "rpc",
|
|
599
693
|
uiContext: this.webUi,
|
|
600
694
|
onError: (err) => {
|
|
601
695
|
this.emit({ type: "notice", level: "error", text: err.error });
|
|
602
696
|
},
|
|
603
697
|
});
|
|
604
|
-
|
|
698
|
+
conv.unsubscribe = conv.session.subscribe((event) => this.onEvent(conv, event));
|
|
605
699
|
this.scheduleSnapshot();
|
|
606
700
|
this.webUi.refresh();
|
|
607
701
|
this.startWidgetsTimer();
|
|
@@ -615,7 +709,7 @@ export class ClientSession {
|
|
|
615
709
|
this.webUi.refresh();
|
|
616
710
|
}, WIDGET_REFRESH_MS);
|
|
617
711
|
}
|
|
618
|
-
onEvent(event) {
|
|
712
|
+
onEvent(conv, event) {
|
|
619
713
|
switch (event.type) {
|
|
620
714
|
case "bash_execution_update": {
|
|
621
715
|
if (event.id) {
|
|
@@ -641,8 +735,8 @@ export class ClientSession {
|
|
|
641
735
|
break;
|
|
642
736
|
}
|
|
643
737
|
case "queue_update":
|
|
644
|
-
|
|
645
|
-
|
|
738
|
+
conv.queueSteering = event.steering.length;
|
|
739
|
+
conv.queueFollowUp = event.followUp.length;
|
|
646
740
|
break;
|
|
647
741
|
// A run finished or a new entry was persisted — keep the session list fresh
|
|
648
742
|
// (new chat + first message, completed turns, compaction, etc.).
|
|
@@ -655,37 +749,41 @@ export class ClientSession {
|
|
|
655
749
|
}
|
|
656
750
|
this.scheduleSnapshot();
|
|
657
751
|
}
|
|
658
|
-
/** Debounced push of the persisted session list
|
|
752
|
+
/** Debounced push of the persisted session list + open conversations. */
|
|
659
753
|
scheduleSessionsRefresh() {
|
|
660
754
|
if (this.sessionsTimer)
|
|
661
755
|
return;
|
|
662
756
|
this.sessionsTimer = setTimeout(() => {
|
|
663
757
|
this.sessionsTimer = null;
|
|
664
|
-
if (
|
|
665
|
-
|
|
758
|
+
if (this.disposed)
|
|
759
|
+
return;
|
|
760
|
+
this.emitConversations();
|
|
761
|
+
void this.pushSessions();
|
|
666
762
|
}, 800);
|
|
667
763
|
}
|
|
668
764
|
/** Serialize a persisted message with a STABLE id + cached object reference. */
|
|
669
765
|
serializeCached(m) {
|
|
766
|
+
const conv = this.conv;
|
|
670
767
|
const key = m.role === "toolResult"
|
|
671
768
|
? `t:${m.toolCallId}`
|
|
672
769
|
: `${m.role}:${m.timestamp}`;
|
|
673
|
-
let n =
|
|
770
|
+
let n = conv.msgIds.get(key);
|
|
674
771
|
if (n === undefined) {
|
|
675
|
-
n =
|
|
676
|
-
|
|
772
|
+
n = conv.nextMsgId++;
|
|
773
|
+
conv.msgIds.set(key, n);
|
|
677
774
|
}
|
|
678
775
|
const cacheKey = `${key}#${n}`;
|
|
679
|
-
const cached =
|
|
776
|
+
const cached = conv.uiMessageCache.get(cacheKey);
|
|
680
777
|
if (cached)
|
|
681
778
|
return cached;
|
|
682
779
|
const msg = serializeMessage(m, n);
|
|
683
780
|
if (msg)
|
|
684
|
-
|
|
781
|
+
conv.uiMessageCache.set(cacheKey, msg);
|
|
685
782
|
return msg;
|
|
686
783
|
}
|
|
687
784
|
snapshot() {
|
|
688
|
-
const
|
|
785
|
+
const conv = this.conv;
|
|
786
|
+
const state = conv.session.agent.state;
|
|
689
787
|
const model = state.model;
|
|
690
788
|
let stats = {
|
|
691
789
|
totalMessages: 0,
|
|
@@ -718,14 +816,15 @@ export class ClientSession {
|
|
|
718
816
|
// cached (reference-stable) anyway, and a stable array reference lets the
|
|
719
817
|
// frontend memoize derived maps instead of rebuilding them every 60ms.
|
|
720
818
|
const sig = rawMessages.map((m) => m.id).join("\u0001");
|
|
721
|
-
const messages =
|
|
722
|
-
|
|
723
|
-
|
|
819
|
+
const messages = conv.lastMessagesSig === sig ? conv.lastMessagesArray : rawMessages;
|
|
820
|
+
conv.lastMessagesSig = sig;
|
|
821
|
+
conv.lastMessagesArray = rawMessages;
|
|
724
822
|
return {
|
|
725
823
|
clientId: this.clientId,
|
|
726
824
|
cwd: this.cwd,
|
|
727
825
|
sessionId: this.session.sessionId,
|
|
728
826
|
sessionFile: this.session.sessionFile,
|
|
827
|
+
conversationId: this.activeId,
|
|
729
828
|
messages,
|
|
730
829
|
// The in-progress assistant message lives in state.streamingMessage
|
|
731
830
|
// (the SDK only pushes it into state.messages at message_end). Surfacing
|
|
@@ -739,7 +838,7 @@ export class ClientSession {
|
|
|
739
838
|
? { id: model.id, name: model.name, provider: model.provider }
|
|
740
839
|
: null,
|
|
741
840
|
thinkingLevel: state.thinkingLevel,
|
|
742
|
-
queue: { steering:
|
|
841
|
+
queue: { steering: conv.queueSteering, followUp: conv.queueFollowUp },
|
|
743
842
|
errorMessage: state.errorMessage,
|
|
744
843
|
tools: state.tools.map((t) => t.name),
|
|
745
844
|
version: ++this.version,
|
|
@@ -1276,6 +1375,13 @@ export class ClientSession {
|
|
|
1276
1375
|
text: `提示发送失败:${err.message}`,
|
|
1277
1376
|
});
|
|
1278
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
|
+
}
|
|
1279
1385
|
this.flushSnapshot();
|
|
1280
1386
|
}
|
|
1281
1387
|
/**
|
|
@@ -1562,9 +1668,25 @@ export class ClientSession {
|
|
|
1562
1668
|
this.flushSnapshot();
|
|
1563
1669
|
}
|
|
1564
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
|
+
}
|
|
1565
1679
|
try {
|
|
1566
|
-
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;
|
|
1567
1688
|
await this.bindSession();
|
|
1689
|
+
this.emitConversations();
|
|
1568
1690
|
}
|
|
1569
1691
|
catch (err) {
|
|
1570
1692
|
this.emit({
|
|
@@ -1575,6 +1697,44 @@ export class ClientSession {
|
|
|
1575
1697
|
}
|
|
1576
1698
|
this.flushSnapshot();
|
|
1577
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
|
+
}
|
|
1578
1738
|
/** List persisted sessions for this client, newest first. */
|
|
1579
1739
|
/** Push the persisted session list to the client (client-requested). */
|
|
1580
1740
|
async refreshSessions() {
|
|
@@ -1630,6 +1790,12 @@ export class ClientSession {
|
|
|
1630
1790
|
try {
|
|
1631
1791
|
await this.runtime.switchSession(path);
|
|
1632
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();
|
|
1633
1799
|
}
|
|
1634
1800
|
catch (err) {
|
|
1635
1801
|
this.emit({
|
|
@@ -1810,11 +1976,9 @@ export class ClientSession {
|
|
|
1810
1976
|
async readFile(relPath) {
|
|
1811
1977
|
try {
|
|
1812
1978
|
const fs = await import("node:fs/promises");
|
|
1813
|
-
const { resolve, sep, relative } = await import("node:path");
|
|
1814
1979
|
const root = resolve(this.cwd);
|
|
1815
|
-
const
|
|
1816
|
-
|
|
1817
|
-
if (rel.startsWith("..") || rel.includes(`${sep}..`)) {
|
|
1980
|
+
const wp = workspacePath(root, relPath);
|
|
1981
|
+
if (!wp) {
|
|
1818
1982
|
this.emit({
|
|
1819
1983
|
type: "notice",
|
|
1820
1984
|
level: "warning",
|
|
@@ -1822,6 +1986,7 @@ export class ClientSession {
|
|
|
1822
1986
|
});
|
|
1823
1987
|
return;
|
|
1824
1988
|
}
|
|
1989
|
+
const { abs, rel } = wp;
|
|
1825
1990
|
const stat = await fs.stat(abs);
|
|
1826
1991
|
if (!stat.isFile()) {
|
|
1827
1992
|
this.emit({
|
|
@@ -2007,20 +2172,24 @@ export class ClientSession {
|
|
|
2007
2172
|
this.flushSnapshot();
|
|
2008
2173
|
return;
|
|
2009
2174
|
}
|
|
2010
|
-
// Build the new runtime first — only swap on
|
|
2011
|
-
|
|
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(), {
|
|
2012
2178
|
cwd: abs,
|
|
2013
2179
|
agentDir: this.agentDir,
|
|
2014
2180
|
sessionManager: SessionManager.continueRecent(abs, this.sessionDir),
|
|
2015
2181
|
});
|
|
2016
|
-
const
|
|
2017
|
-
|
|
2182
|
+
const conv = this.conv;
|
|
2183
|
+
const oldRuntime = conv.runtime;
|
|
2184
|
+
conv.runtime = newRuntime;
|
|
2185
|
+
conv.session = newRuntime.session;
|
|
2186
|
+
conv.cwd = abs;
|
|
2018
2187
|
this.cwd = abs;
|
|
2019
2188
|
// Remember the new workspace (restore target + recent-project entry).
|
|
2020
2189
|
this.stateStore.remember(this.clientId, abs);
|
|
2021
2190
|
void this.pushProjects();
|
|
2022
|
-
|
|
2023
|
-
|
|
2191
|
+
conv.unsubscribe?.();
|
|
2192
|
+
conv.unsubscribe = undefined;
|
|
2024
2193
|
await this.bindSession();
|
|
2025
2194
|
await oldRuntime.dispose().catch(() => { });
|
|
2026
2195
|
for (const d of newRuntime.diagnostics) {
|
|
@@ -2033,6 +2202,7 @@ export class ClientSession {
|
|
|
2033
2202
|
level: "info",
|
|
2034
2203
|
text: `已切换到工作目录:${abs}`,
|
|
2035
2204
|
});
|
|
2205
|
+
this.emitConversations();
|
|
2036
2206
|
void this.refreshSessions();
|
|
2037
2207
|
void this.listFiles(undefined);
|
|
2038
2208
|
// Commands are per-project (.pi/commands.json in the current cwd).
|
|
@@ -2153,13 +2323,14 @@ export class ClientSession {
|
|
|
2153
2323
|
this.widgetsTimer = null;
|
|
2154
2324
|
}
|
|
2155
2325
|
this.webUi.dispose();
|
|
2156
|
-
this.
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
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
|
+
}
|
|
2163
2334
|
}
|
|
2164
2335
|
}
|
|
2165
2336
|
}
|
package/dist/server/index.js
CHANGED
|
@@ -15,13 +15,13 @@
|
|
|
15
15
|
import { existsSync } from "node:fs";
|
|
16
16
|
import { stat } from "node:fs/promises";
|
|
17
17
|
import { createServer } from "node:http";
|
|
18
|
-
import { basename, dirname, join,
|
|
18
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
19
19
|
import { fileURLToPath } from "node:url";
|
|
20
20
|
import { randomUUID } from "node:crypto";
|
|
21
21
|
import express from "express";
|
|
22
22
|
import { WebSocket, WebSocketServer } from "ws";
|
|
23
23
|
import { VERSION } from "@earendil-works/pi-coding-agent";
|
|
24
|
-
import { AgentService, previewKind } from "./agent-service.js";
|
|
24
|
+
import { AgentService, previewKind, workspacePath } from "./agent-service.js";
|
|
25
25
|
const PORT = Number(process.env.PORT ?? 8787);
|
|
26
26
|
const CWD = resolve(process.env.PI_WEB_CWD ?? process.cwd());
|
|
27
27
|
const DATA_DIR = resolve(process.env.PI_WEB_DATA_DIR ?? join(CWD, ".pi-web"));
|
|
@@ -40,12 +40,18 @@ app.get("/api/health", (_req, res) => {
|
|
|
40
40
|
app.get("/api/file", async (req, res) => {
|
|
41
41
|
try {
|
|
42
42
|
const raw = typeof req.query.path === "string" ? req.query.path : "";
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
// Resolve against the requesting client's workspace (the opened
|
|
44
|
+
// project), not the server's startup cwd — they can differ when the
|
|
45
|
+
// client switched projects or restored a previous workspace. Fall
|
|
46
|
+
// back to the server cwd for requests without a known client.
|
|
47
|
+
const cid = typeof req.query.clientId === "string" ? req.query.clientId : "";
|
|
48
|
+
const cs = cid ? service.get(cid) : undefined;
|
|
49
|
+
const wp = workspacePath(cs?.cwd ?? CWD, raw);
|
|
50
|
+
if (!wp) {
|
|
46
51
|
res.status(400).end("path outside workspace");
|
|
47
52
|
return;
|
|
48
53
|
}
|
|
54
|
+
const abs = wp.abs;
|
|
49
55
|
const name = basename(abs);
|
|
50
56
|
const kind = previewKind(name);
|
|
51
57
|
if (kind !== "image" && kind !== "video") {
|
|
@@ -142,6 +148,9 @@ wss.on("connection", (ws) => {
|
|
|
142
148
|
case "switch_session":
|
|
143
149
|
void cs.switchSession(msg.path);
|
|
144
150
|
break;
|
|
151
|
+
case "switch_conversation":
|
|
152
|
+
void cs.switchConversation(msg.id);
|
|
153
|
+
break;
|
|
145
154
|
case "list_files":
|
|
146
155
|
void cs.listFiles(msg.path);
|
|
147
156
|
break;
|
package/dist/server/terminals.js
CHANGED
|
@@ -192,6 +192,28 @@ function brokenSpawnHelper() {
|
|
|
192
192
|
}
|
|
193
193
|
return "";
|
|
194
194
|
}
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// macOS TCC camera/mic warning (launchd-spawned servers)
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
// TCC attributes camera/mic access to the process chain's "responsible
|
|
199
|
+
// process". When pi-web-ui runs as a launchd LaunchAgent (node ← launchd),
|
|
200
|
+
// the responsible process is node itself — a bare CLI binary with no app
|
|
201
|
+
// bundle / Info.plist / NSCameraUsageDescription — so TCC silently denies
|
|
202
|
+
// camera access (no prompt, nothing to tick in System Settings) and
|
|
203
|
+
// ffmpeg-style grabbers hang on frame capture. The identical command works
|
|
204
|
+
// from a terminal app that already holds the camera grant. Detect the
|
|
205
|
+
// "no GUI ancestor" case (ppid === 1 on macOS) and warn in the terminal.
|
|
206
|
+
const TCC_HINT = [
|
|
207
|
+
"\x1b[33m[提示] 本终端由后台服务(launchd)启动,macOS 隐私权限(相机/麦克风/屏幕录制等)对此类进程不可用。\x1b[0m",
|
|
208
|
+
"\x1b[90m · 需要隐私权限的命令会被系统静默拒绝:不弹授权窗,系统设置里也无法勾选,表现多为卡死或无输出。",
|
|
209
|
+
" · 这类任务请在你自己已授权的前台终端里运行。",
|
|
210
|
+
" · 本终端内可运行不需要隐私权限的命令(如文件处理、网络请求、远程设备流)。",
|
|
211
|
+
" · 若改在前台终端里运行 pi-web-ui,本提示即不再出现。\x1b[0m",
|
|
212
|
+
].join("\r\n") + "\r\n";
|
|
213
|
+
/** True when this server was spawned by launchd (or orphaned) on macOS — no GUI app in the ancestry, so camera/mic TCC grants are unavailable. */
|
|
214
|
+
function launchdSpawnedOnMac() {
|
|
215
|
+
return process.platform === "darwin" && process.ppid === 1;
|
|
216
|
+
}
|
|
195
217
|
/**
|
|
196
218
|
* Owns one or more PTYs for a client. All output is forwarded as
|
|
197
219
|
* `terminal_output` messages through the provided emit (broadcast to every
|
|
@@ -202,6 +224,7 @@ export class TerminalManager {
|
|
|
202
224
|
emit;
|
|
203
225
|
terms = new Map();
|
|
204
226
|
seq = 0;
|
|
227
|
+
tccHintShown = false;
|
|
205
228
|
constructor(emit) {
|
|
206
229
|
this.emit = emit;
|
|
207
230
|
}
|
|
@@ -209,7 +232,16 @@ export class TerminalManager {
|
|
|
209
232
|
create(id, cwd, cols, rows, fallbackCwd) {
|
|
210
233
|
if (this.terms.has(id))
|
|
211
234
|
return;
|
|
212
|
-
this.spawnShell(id, cwd || fallbackCwd, cols, rows, `终端 ${++this.seq}`)
|
|
235
|
+
if (this.spawnShell(id, cwd || fallbackCwd, cols, rows, `终端 ${++this.seq}`)) {
|
|
236
|
+
this.maybeEmitTccHint(id);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/** Warn about unavailable camera/mic TCC grants in a fresh terminal, once per client. */
|
|
240
|
+
maybeEmitTccHint(id) {
|
|
241
|
+
if (this.tccHintShown || !launchdSpawnedOnMac())
|
|
242
|
+
return;
|
|
243
|
+
this.tccHintShown = true;
|
|
244
|
+
this.writeOut(id, TCC_HINT);
|
|
213
245
|
}
|
|
214
246
|
/**
|
|
215
247
|
* Start a shell in the command's directory and run the command in it.
|
|
@@ -247,6 +279,7 @@ export class TerminalManager {
|
|
|
247
279
|
// (the PTY input buffer holds it until the shell is ready).
|
|
248
280
|
this.writeOut(id, "\x1b[2J\x1b[3J\x1b[H");
|
|
249
281
|
this.writeOut(id, `\x1b[90m> ${command}\x1b[0m \x1b[90m(${dir})\x1b[0m\r\n`);
|
|
282
|
+
this.maybeEmitTccHint(id);
|
|
250
283
|
if (command)
|
|
251
284
|
this.input(id, command + "\r");
|
|
252
285
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-web-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Web chat interface for the pi coding agent, powered by the pi SDK (@earendil-works/pi-coding-agent) — one-command run, Docker/systemd/launchd deployable",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|