pi2web 0.2.8 → 0.2.12
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/README.md +4 -2
- package/dist/activity.js +83 -9
- package/dist/agents.js +62 -3
- package/dist/http/agent-routes.js +9 -1
- package/dist/mail-notifier.js +7 -2
- package/dist/safe-pause.js +100 -0
- package/dist/sdk-backend.js +20 -3
- package/dist/session-performance.js +2 -0
- package/dist/voice.js +2 -2
- package/dist/ws/agent-socket.js +2 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/packages/protocol/openapi.yaml +4 -1
- package/packages/protocol/websocket.schema.json +4 -3
- package/web/activity-view.js +49 -4
- package/web/app.js +164 -30
- package/web/index.html +2 -1
- package/web/styles.css +1 -0
package/README.md
CHANGED
|
@@ -223,8 +223,9 @@ Web 端能力:
|
|
|
223
223
|
|
|
224
224
|
- Workspace 添加、目录浏览、文本文件分页及二进制提示。
|
|
225
225
|
- Workspace 目录右键在交互式 Terminal 中打开;Terminal 与 Agent 显示在同一列表,支持刷新重连、清屏和关闭。
|
|
226
|
-
-
|
|
227
|
-
- Prompt、steer、follow-up 和流式对话。
|
|
226
|
+
- 新建 Agent 时始终创建新 Session;已有 Agent 可在刷新或重启后继续使用。通过 Session 文件恢复对话暂仅支持 API,不在新建弹窗中提供;Agent 列表支持停止与 abort,右键 Archive。
|
|
227
|
+
- Prompt、steer、follow-up 和流式对话。HTTP / WebSocket 发送命令在 Pi 校验并接收消息后即确认,不等待整个回合结束;执行进度继续通过 WebSocket 同步。
|
|
228
|
+
- 手机切后台或网络中断导致发送确认丢失时,显示「送达状态未知」并保留原文供核对,不自动重发或放回输入框。提示在同一页面内切换会话、重连后仍保留,可手动关闭;刷新页面不保留。明确被服务端拒绝的消息仍恢复为草稿。
|
|
228
229
|
- 对话区只保留用户输入、Agent 输出、Thinking、Extension 对话框和错误;Tool call、Session bash 输出、Retry、Summarization retry 等过程事件自动折叠成一张"活动"卡片,卡片标题实时显示最新一项的名称和时间,展开后逐条查看,再点击单条查看内容。
|
|
229
230
|
- 同一次 Tool call 的 start / update / end 合并为一张卡片(参数 + 输出 + 结果),结束后显示 Pi 记录的执行耗时。
|
|
230
231
|
- Tool 自己发起的调用(codemode 脚本、MCP 批量调用)带 `parentToolCallId`,会嵌套显示在发起它的那张卡片里。
|
|
@@ -362,6 +363,7 @@ Web 看板在 `/collab.html`(首页顶部"协作"入口):会话列表与
|
|
|
362
363
|
- `GET /api/v1/agents/:id/mcp` 返回该 Agent cwd 下的 MCP 服务器配置(全局 `mcp.json` 与受信任项目的 `.pi/mcp.json`),`POST` 用 `{name, enabled}` 启用或停用。只有项目被 Pi 显式信任时才会写入项目级覆盖,否则写全局文件。修改在 Agent 下次加载时生效。
|
|
363
364
|
- `GET /api/v1/agents/:id/commands` 返回该 Agent cwd 下可用的 prompt template 和 skill(`{name, description, argumentHint, source}`)。`prompt` 接口本身会展开 `/命令`、skill 和 prompt template。
|
|
364
365
|
- `POST /api/v1/agents/:id/bash` 在 Agent Session 内执行命令(`{command, excludeFromContext}`),输出通过 `bash_execution_update` 事件流式推送并记录到 Session;`POST /api/v1/agents/:id/bash-abort` 取消。
|
|
366
|
+
- `POST /api/v1/agents/:id/pause` 安全暂停:对话不再前进——正在执行的工具跑完、结果写入 Session 后,不再产生新的模型回复或工具调用(Agent 循环的下一轮,以及自动重试、溢出压缩后的重试、排队消息、扩展 `agent_before_settle` 要求的续跑都会被拦下;正在等待的重试退避会被立即取消)。Session 以工具结果(或用户消息)结尾,重启后也不丢失,之后发送新消息即从这里继续;暂停时排队的消息留在队列里,随下一次 prompt 送达。不拦截不推进对话的整理性模型调用(压缩摘要、提示缓存保温;保温仅在 `cacheWarming: idle` 时于暂停后继续,最长 30 分钟)。进度通过 `safe_pause` 事件推送:`requested`、`reached`(已拦下一次模型请求,正在收尾)、`paused`、`cancelled`、`finished`(未到暂停点运行就自行结束);活动状态带 `pauseRequested {since, reached?}`。被暂停运行的 `agent_settled` 带 `paused: true`,邮件通知按“已中断”处理、语音不播报。`DELETE` 同一路径撤回暂停:已到暂停点、运行仍在收尾时(`reached` 之后、`paused` 之前)返回 `PAUSE_ALREADY_REACHED`;没有待生效的暂停时(包括运行已结束)为无操作并返回成功。撤回是否赶上以 `safe_pause` 事件(`cancelled` 或 `paused`)为准。
|
|
365
367
|
- WebSocket `/api/v1/ws`(协议 v2,不做事件回放):`subscribe_all` 先下发每个 Agent 的当前状态,再推送所有 Agent 的概览事件(状态、活动、队列、对话框、通知);`focus` 选定正在查看的 Agent,只为它推送流式文本、时间线条目变化和用户 Shell 输出;`watch` 订阅已展开的运行中工具的输出;`subscribe_collab` 推送协作事件。
|
|
366
368
|
- 会话内容通过 HTTP 时间线读取:`GET /api/v1/agents/:id/timeline?limit&before` 返回条目摘要(正文较小或需要阅读的条目内联),`GET /api/v1/agents/:id/sessions/:sessionId/items/:itemId` 按需返回完整正文;已完成条目永不改变,响应带 `immutable` 缓存头,浏览器可永久缓存。
|
|
367
369
|
|
package/dist/activity.js
CHANGED
|
@@ -22,6 +22,7 @@ const num = (value) => (typeof value === 'number' && Number.isFinite(value) ? va
|
|
|
22
22
|
export class ActivityTracker {
|
|
23
23
|
now;
|
|
24
24
|
state;
|
|
25
|
+
lastMetricsPublish = 0;
|
|
25
26
|
constructor(now = Date.now) {
|
|
26
27
|
this.now = now;
|
|
27
28
|
this.state = { run: null, phase: { kind: 'idle', since: now() }, bash: [], dialogs: [] };
|
|
@@ -32,7 +33,7 @@ export class ActivityTracker {
|
|
|
32
33
|
/** Forget everything, e.g. when the backend is unloaded. Returns whether anything changed. */
|
|
33
34
|
reset() {
|
|
34
35
|
const s = this.state;
|
|
35
|
-
if (!s.run && s.phase.kind === 'idle' && !s.bash.length && !s.dialogs.length)
|
|
36
|
+
if (!s.run && s.phase.kind === 'idle' && !s.bash.length && !s.dialogs.length && !s.pauseRequested)
|
|
36
37
|
return false;
|
|
37
38
|
this.state = { run: null, phase: { kind: 'idle', since: this.now() }, bash: [], dialogs: [] };
|
|
38
39
|
return true;
|
|
@@ -51,26 +52,52 @@ export class ActivityTracker {
|
|
|
51
52
|
const startedRun = !s.run;
|
|
52
53
|
if (startedRun)
|
|
53
54
|
s.run = { startedAt: this.now() };
|
|
55
|
+
if (startedRun)
|
|
56
|
+
s.metrics = { turnStartedAt: this.now(), chars: 0, estimatedTokens: 0 };
|
|
54
57
|
return this.setPhase({ kind: 'waiting_llm' }) || startedRun;
|
|
55
58
|
}
|
|
56
59
|
case 'turn_start':
|
|
57
|
-
|
|
60
|
+
s.metrics = { turnStartedAt: this.now(), chars: 0, estimatedTokens: 0 };
|
|
61
|
+
this.lastMetricsPublish = 0;
|
|
62
|
+
this.setPhase({ kind: 'waiting_llm' });
|
|
63
|
+
// Even when the last tool already moved us to waiting_llm, clients need the cleared metrics.
|
|
64
|
+
return true;
|
|
58
65
|
case 'message_update': {
|
|
59
66
|
const update = e.assistantMessageEvent, type = str(update?.type);
|
|
67
|
+
const at = this.now();
|
|
68
|
+
const firstResponse = !!s.metrics &&
|
|
69
|
+
s.metrics.firstResponseAt === undefined &&
|
|
70
|
+
(type === 'thinking_delta' || type === 'text_delta' || type === 'toolcall_delta');
|
|
71
|
+
if (firstResponse)
|
|
72
|
+
s.metrics.firstResponseAt = at;
|
|
73
|
+
let changed, lateName = false;
|
|
60
74
|
if (type === 'thinking_start' || type === 'thinking_delta')
|
|
61
|
-
|
|
62
|
-
if (type === 'text_start' || type === 'text_delta')
|
|
63
|
-
|
|
64
|
-
if (type === 'toolcall_start' || type === 'toolcall_delta') {
|
|
75
|
+
changed = this.setPhase({ kind: 'thinking' });
|
|
76
|
+
else if (type === 'text_start' || type === 'text_delta')
|
|
77
|
+
changed = this.setPhase({ kind: 'generating' });
|
|
78
|
+
else if (type === 'toolcall_start' || type === 'toolcall_delta') {
|
|
65
79
|
const toolName = str(update.partial?.content?.[update.contentIndex]?.name);
|
|
66
80
|
// The name may only arrive with a later delta: fill it in without restarting the timer.
|
|
67
81
|
if (s.phase.kind === 'preparing_tool' && !s.phase.toolName && toolName) {
|
|
68
82
|
s.phase.toolName = toolName;
|
|
69
|
-
|
|
83
|
+
changed = lateName = true;
|
|
70
84
|
}
|
|
71
|
-
|
|
85
|
+
else
|
|
86
|
+
changed = this.setPhase({ kind: 'preparing_tool', ...(toolName ? { toolName } : {}) });
|
|
72
87
|
}
|
|
73
|
-
|
|
88
|
+
else
|
|
89
|
+
return false;
|
|
90
|
+
// Throughput is per stream: each content block, e.g. each of two consecutive `write` calls,
|
|
91
|
+
// and each phase, so thinking does not dilute the text rate.
|
|
92
|
+
// A late tool name only labels the stream already running; it does not start a new one.
|
|
93
|
+
const cleared = ((changed && !lateName) || type.endsWith('_start')) && this.clearStream();
|
|
94
|
+
const counted = this.countDelta(update);
|
|
95
|
+
// Publish a stream's first count at once; later ones at most once a second.
|
|
96
|
+
if (counted && (counted === 'first' || at - this.lastMetricsPublish >= 1000)) {
|
|
97
|
+
this.lastMetricsPublish = at;
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
return changed || cleared || firstResponse;
|
|
74
101
|
}
|
|
75
102
|
case 'tool_execution_start': {
|
|
76
103
|
// Tools called by other tools run inside their caller's time; show only top-level calls.
|
|
@@ -143,6 +170,23 @@ export class ActivityTracker {
|
|
|
143
170
|
s.dialogs.push({ requestId, title: str(e.title) ?? str(e.message), since: this.now() });
|
|
144
171
|
return true;
|
|
145
172
|
}
|
|
173
|
+
case 'safe_pause':
|
|
174
|
+
if (e.state === 'requested') {
|
|
175
|
+
if (s.pauseRequested)
|
|
176
|
+
return false;
|
|
177
|
+
s.pauseRequested = { since: this.now() };
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
if (e.state === 'reached') {
|
|
181
|
+
if (s.pauseRequested?.reached)
|
|
182
|
+
return false;
|
|
183
|
+
s.pauseRequested = { since: s.pauseRequested?.since ?? this.now(), reached: true };
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
if (!s.pauseRequested)
|
|
187
|
+
return false;
|
|
188
|
+
delete s.pauseRequested;
|
|
189
|
+
return true;
|
|
146
190
|
case 'extension_ui_response': {
|
|
147
191
|
const before = s.dialogs.length;
|
|
148
192
|
s.dialogs = s.dialogs.filter(d => d.requestId !== e.requestId);
|
|
@@ -152,9 +196,39 @@ export class ActivityTracker {
|
|
|
152
196
|
return false;
|
|
153
197
|
}
|
|
154
198
|
}
|
|
199
|
+
/** Adds a thinking, text or tool call delta to the current stream's estimate. Returns whether it counted. */
|
|
200
|
+
/** Restarts the current stream's counters. Returns whether there was anything to clear. */
|
|
201
|
+
clearStream() {
|
|
202
|
+
const metrics = this.state.metrics;
|
|
203
|
+
if (!metrics || (metrics.streamStartedAt === undefined && !metrics.chars))
|
|
204
|
+
return false;
|
|
205
|
+
delete metrics.streamStartedAt;
|
|
206
|
+
metrics.chars = 0;
|
|
207
|
+
metrics.estimatedTokens = 0;
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Adds a thinking, text or tool call delta to the current stream's estimate. Returns `'first'` for
|
|
212
|
+
* the stream's first counted delta, `true` for later ones and `false` if nothing was counted.
|
|
213
|
+
*/
|
|
214
|
+
countDelta(update) {
|
|
215
|
+
const metrics = this.state.metrics, delta = update?.delta;
|
|
216
|
+
if (!metrics || !String(update?.type).endsWith('_delta') || typeof delta !== 'string' || !delta)
|
|
217
|
+
return false;
|
|
218
|
+
const first = metrics.streamStartedAt === undefined;
|
|
219
|
+
if (first)
|
|
220
|
+
metrics.streamStartedAt = this.now();
|
|
221
|
+
// Approximate Latin/code text at 4 chars/token, CJK at 1.5 chars/token.
|
|
222
|
+
const cjk = delta.match(/[\u3400-\u9fff\u3040-\u30ff\uac00-\ud7af]/gu)?.length ?? 0;
|
|
223
|
+
const chars = [...delta].length;
|
|
224
|
+
metrics.chars += chars;
|
|
225
|
+
metrics.estimatedTokens += (chars - cjk) / 4 + cjk / 1.5;
|
|
226
|
+
return first ? 'first' : true;
|
|
227
|
+
}
|
|
155
228
|
endRun(settled = false) {
|
|
156
229
|
const hadRun = !!this.state.run;
|
|
157
230
|
this.state.run = null;
|
|
231
|
+
delete this.state.metrics;
|
|
158
232
|
// Compaction may continue after the run has ended; agent_settled closes it.
|
|
159
233
|
if (this.state.phase.kind === 'compacting' && !settled)
|
|
160
234
|
return hadRun;
|
package/dist/agents.js
CHANGED
|
@@ -57,6 +57,7 @@ export class MockBackend {
|
|
|
57
57
|
entries = [];
|
|
58
58
|
timer;
|
|
59
59
|
queue = { steering: [], followUp: [] };
|
|
60
|
+
pauseRequested = false;
|
|
60
61
|
constructor(agentId, cwd, sessionId = randomUUID(), options = {}) {
|
|
61
62
|
this.options = options;
|
|
62
63
|
this.state = { agentId, sessionId, cwd, status: 'idle' };
|
|
@@ -82,11 +83,12 @@ export class MockBackend {
|
|
|
82
83
|
this.timer = setTimeout(r, 0);
|
|
83
84
|
});
|
|
84
85
|
}
|
|
85
|
-
async prompt(message) {
|
|
86
|
+
async prompt(message, onAccepted) {
|
|
86
87
|
if (this.state.status === 'streaming')
|
|
87
88
|
throw Object.assign(new Error('Agent is busy'), { code: 'AGENT_BUSY' });
|
|
88
89
|
this.state.status = 'streaming';
|
|
89
90
|
this.emit({ type: 'agent_start', message });
|
|
91
|
+
onAccepted?.();
|
|
90
92
|
await this.runTurn();
|
|
91
93
|
const user = { role: 'user', content: message };
|
|
92
94
|
this.emit({ type: 'message_end', message: user });
|
|
@@ -98,6 +100,25 @@ export class MockBackend {
|
|
|
98
100
|
this.append(assistant);
|
|
99
101
|
this.state.status = 'idle';
|
|
100
102
|
this.emit({ type: 'agent_end' });
|
|
103
|
+
// A mock run is a single final answer, so a pending pause never finds a safe point.
|
|
104
|
+
if (this.pauseRequested) {
|
|
105
|
+
this.pauseRequested = false;
|
|
106
|
+
this.emit({ type: 'safe_pause', state: 'finished' });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async requestPause() {
|
|
110
|
+
if (this.state.status !== 'streaming')
|
|
111
|
+
throw Object.assign(new Error('Agent is not running'), { code: 'AGENT_NOT_RUNNING' });
|
|
112
|
+
if (this.pauseRequested)
|
|
113
|
+
return;
|
|
114
|
+
this.pauseRequested = true;
|
|
115
|
+
this.emit({ type: 'safe_pause', state: 'requested' });
|
|
116
|
+
}
|
|
117
|
+
async cancelPause() {
|
|
118
|
+
if (!this.pauseRequested)
|
|
119
|
+
return;
|
|
120
|
+
this.pauseRequested = false;
|
|
121
|
+
this.emit({ type: 'safe_pause', state: 'cancelled' });
|
|
101
122
|
}
|
|
102
123
|
async steer(message) {
|
|
103
124
|
const queued = this.state.status === 'streaming';
|
|
@@ -146,6 +167,10 @@ export class MockBackend {
|
|
|
146
167
|
clearTimeout(this.timer);
|
|
147
168
|
this.state.status = 'idle';
|
|
148
169
|
this.emit({ type: 'agent_end', aborted: true });
|
|
170
|
+
if (this.pauseRequested) {
|
|
171
|
+
this.pauseRequested = false;
|
|
172
|
+
this.emit({ type: 'safe_pause', state: 'finished' });
|
|
173
|
+
}
|
|
149
174
|
}
|
|
150
175
|
}
|
|
151
176
|
async getState() {
|
|
@@ -471,7 +496,29 @@ export class AgentManager {
|
|
|
471
496
|
})();
|
|
472
497
|
return entry.loading;
|
|
473
498
|
}
|
|
474
|
-
|
|
499
|
+
/** Transport acknowledgement: an accepted prompt keeps running under the normal write admission. */
|
|
500
|
+
submitCommand(id, kind, message = '') {
|
|
501
|
+
return new Promise((resolve, reject) => {
|
|
502
|
+
let accepted = false;
|
|
503
|
+
void this.command(id, kind, message, () => {
|
|
504
|
+
accepted = true;
|
|
505
|
+
resolve();
|
|
506
|
+
}).then(resolve, error => {
|
|
507
|
+
if (!accepted)
|
|
508
|
+
return reject(error);
|
|
509
|
+
// The sender already has its acknowledgement. Report execution errors through the existing
|
|
510
|
+
// event stream rather than turning a delivered prompt into a failed send.
|
|
511
|
+
const entry = this.agents.get(id);
|
|
512
|
+
if (entry)
|
|
513
|
+
this.publish(entry, {
|
|
514
|
+
type: 'extension_ui_notify',
|
|
515
|
+
notificationType: 'error',
|
|
516
|
+
message: error instanceof Error ? error.message : String(error)
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
async command(id, kind, message = '', onAccepted) {
|
|
475
522
|
if (kind === 'abort') {
|
|
476
523
|
const entry = this.get(id);
|
|
477
524
|
if (!entry.backend)
|
|
@@ -489,7 +536,9 @@ export class AgentManager {
|
|
|
489
536
|
if (effective === 'prompt')
|
|
490
537
|
entry.promptPending = true;
|
|
491
538
|
try {
|
|
492
|
-
return
|
|
539
|
+
return effective === 'prompt'
|
|
540
|
+
? await backend.prompt(message, onAccepted)
|
|
541
|
+
: await backend[effective === 'follow-up' ? 'followUp' : 'steer'](message);
|
|
493
542
|
}
|
|
494
543
|
finally {
|
|
495
544
|
if (effective === 'prompt' && entry.backend === backend)
|
|
@@ -734,6 +783,16 @@ export class AgentManager {
|
|
|
734
783
|
async runBash(id, command, excludeFromContext = false) {
|
|
735
784
|
return this.writeSession(id, backend => backend.runBash(command, excludeFromContext));
|
|
736
785
|
}
|
|
786
|
+
/** Stop the running turn at its next safe point: after its tools, before the next model request. */
|
|
787
|
+
async requestPause(id) {
|
|
788
|
+
const entry = this.get(id);
|
|
789
|
+
if (!entry.backend)
|
|
790
|
+
throw Object.assign(new Error('Agent is not running'), { code: 'AGENT_NOT_RUNNING' });
|
|
791
|
+
return entry.backend.requestPause();
|
|
792
|
+
}
|
|
793
|
+
async cancelPause(id) {
|
|
794
|
+
return this.get(id).backend?.cancelPause();
|
|
795
|
+
}
|
|
737
796
|
async abortBash(id) {
|
|
738
797
|
const entry = this.get(id);
|
|
739
798
|
if (entry.loading)
|
|
@@ -155,6 +155,14 @@ export async function handleAgentRoutes(ctx, req, res, u, parts) {
|
|
|
155
155
|
ctx.metadata.scheduleAgent(ctx.agents.get(id).record);
|
|
156
156
|
return send(200, { data: result });
|
|
157
157
|
}
|
|
158
|
+
// Safe pause: POST asks the running turn to stop before its next model request; DELETE withdraws that.
|
|
159
|
+
if (parts[4] === 'pause' && (req.method === 'POST' || req.method === 'DELETE')) {
|
|
160
|
+
if (req.method === 'POST')
|
|
161
|
+
await ctx.agents.requestPause(id);
|
|
162
|
+
else
|
|
163
|
+
await ctx.agents.cancelPause(id);
|
|
164
|
+
return send(200, { data: { success: true } });
|
|
165
|
+
}
|
|
158
166
|
if (req.method === 'POST' && parts[4] === 'bash-abort') {
|
|
159
167
|
await ctx.agents.abortBash(id);
|
|
160
168
|
return send(200, { data: { aborted: true } });
|
|
@@ -210,7 +218,7 @@ export async function handleAgentRoutes(ctx, req, res, u, parts) {
|
|
|
210
218
|
ctx.voice?.recordUserPrompt(id, String(b.message ?? ''));
|
|
211
219
|
ctx.sessionNamer?.recordUserPrompt(id, String(b.message ?? ''));
|
|
212
220
|
}
|
|
213
|
-
const disposition = await ctx.agents.
|
|
221
|
+
const disposition = await ctx.agents.submitCommand(id, parts[4], b.message ?? '');
|
|
214
222
|
const record = ctx.agents.get(id).record;
|
|
215
223
|
ctx.metadata.scheduleAgent(record);
|
|
216
224
|
if (parts[4] === 'prompt')
|
package/dist/mail-notifier.js
CHANGED
|
@@ -76,8 +76,13 @@ export class MailNotifier {
|
|
|
76
76
|
this.lastAssistant.delete(agentId);
|
|
77
77
|
if (!this.settings.enabled)
|
|
78
78
|
return;
|
|
79
|
-
// Pi 0.99+ reports whether the run was cancelled; a cancelled run is not a completed task.
|
|
80
|
-
this.queue.push({
|
|
79
|
+
// Pi 0.99+ reports whether the run was cancelled; a cancelled or safely paused run is not a completed task.
|
|
80
|
+
this.queue.push({
|
|
81
|
+
agentId,
|
|
82
|
+
finalOutput,
|
|
83
|
+
context,
|
|
84
|
+
aborted: event.aborted === true || event.paused === true
|
|
85
|
+
});
|
|
81
86
|
if (!this.timer)
|
|
82
87
|
this.scheduleFlush();
|
|
83
88
|
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const error = (message, code) => Object.assign(new Error(message), { code });
|
|
2
|
+
/**
|
|
3
|
+
* Stops a Pi session before the conversation advances: no further model response or tool call
|
|
4
|
+
* once the pause is reached. Tools that are running finish and their results are recorded first,
|
|
5
|
+
* so the transcript ends with tool results (or the user's message) and nothing is lost.
|
|
6
|
+
*
|
|
7
|
+
* A Pi run asks the model again through exactly two entries, and each one is gated:
|
|
8
|
+
* - Gate A, the agent loop's `finishTurn`: the next turn inside the loop.
|
|
9
|
+
* - Gate B, `agent.continue()`: every continuation the session's run controller starts after the
|
|
10
|
+
* loop ends, i.e. auto-retry, overflow-compaction retry, queued messages and extension
|
|
11
|
+
* `agent_before_settle` continuations.
|
|
12
|
+
* Neither gate depends on event timing. Housekeeping model calls that do not advance the
|
|
13
|
+
* conversation (compaction summaries, prompt-cache warming) are not gated.
|
|
14
|
+
*/
|
|
15
|
+
export class SafePause {
|
|
16
|
+
session;
|
|
17
|
+
emit;
|
|
18
|
+
state = 'none';
|
|
19
|
+
/** Set for the agent_settled of a run that ended at the safe point. */
|
|
20
|
+
settledPaused = false;
|
|
21
|
+
constructor(session, emit) {
|
|
22
|
+
this.session = session;
|
|
23
|
+
this.emit = emit;
|
|
24
|
+
const agent = session.agent;
|
|
25
|
+
// Gate A. AgentSession installed its own finishTurn (extension turn_end boundaries); run it first.
|
|
26
|
+
const finishTurn = agent.finishTurn;
|
|
27
|
+
agent.finishTurn = async (turn, signal) => {
|
|
28
|
+
const decision = (await finishTurn?.(turn, signal)) ?? undefined;
|
|
29
|
+
if (this.state === 'none' || decision?.action === 'end')
|
|
30
|
+
return decision;
|
|
31
|
+
// An error or aborted turn ends the loop anyway; a retry has to pass gate B.
|
|
32
|
+
if (turn.message.stopReason === 'error' || turn.message.stopReason === 'aborted')
|
|
33
|
+
return decision;
|
|
34
|
+
// A final answer with nothing queued and no continuation request ends the loop by itself.
|
|
35
|
+
const wouldContinue = turn.toolResults.length > 0 || decision?.action === 'continue' || agent.hasQueuedMessages();
|
|
36
|
+
if (!wouldContinue)
|
|
37
|
+
return decision;
|
|
38
|
+
this.reach();
|
|
39
|
+
return { action: 'end' };
|
|
40
|
+
};
|
|
41
|
+
// Gate B. The run controller calls agent.continue() only after the previous loop has fully
|
|
42
|
+
// ended, so no request is in flight and no tool is running here. Instead of continuing, ask
|
|
43
|
+
// the session to stop its run: abort() first sets the controller's stop flag synchronously,
|
|
44
|
+
// which ends the run before another request. Queued messages stay queued for the next prompt.
|
|
45
|
+
const resume = agent.continue.bind(agent);
|
|
46
|
+
agent.continue = async () => {
|
|
47
|
+
if (this.state === 'none')
|
|
48
|
+
return resume();
|
|
49
|
+
this.reach();
|
|
50
|
+
// abort() waits for the run to settle, which happens only after this call returns.
|
|
51
|
+
void session.abort().catch(() => { });
|
|
52
|
+
};
|
|
53
|
+
session.subscribe(event => {
|
|
54
|
+
if (event.type !== 'agent_settled') {
|
|
55
|
+
this.settledPaused = false;
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const state = this.state;
|
|
59
|
+
this.state = 'none';
|
|
60
|
+
this.settledPaused = state === 'reached';
|
|
61
|
+
if (state === 'reached')
|
|
62
|
+
this.emit('paused');
|
|
63
|
+
else if (state === 'requested')
|
|
64
|
+
this.emit('finished');
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
/** Whether the agent_settled being delivered ends a run that stopped at the safe point. */
|
|
68
|
+
get pausedRunSettled() {
|
|
69
|
+
return this.settledPaused;
|
|
70
|
+
}
|
|
71
|
+
async request() {
|
|
72
|
+
if (!this.session.isStreaming)
|
|
73
|
+
throw error('Agent is not running', 'AGENT_NOT_RUNNING');
|
|
74
|
+
if (this.state !== 'none')
|
|
75
|
+
return;
|
|
76
|
+
this.state = 'requested';
|
|
77
|
+
this.emit('requested');
|
|
78
|
+
// During a retry backoff the next step is the retried model request. isRetrying means Pi has
|
|
79
|
+
// created the retry's abort controller, so cancelling it now takes effect; the run then winds
|
|
80
|
+
// down through the controller, and any continuation it still attempts meets gate B.
|
|
81
|
+
if (this.session.isRetrying) {
|
|
82
|
+
this.reach();
|
|
83
|
+
this.session.abortRetry();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async cancel() {
|
|
87
|
+
if (this.state === 'reached')
|
|
88
|
+
throw error('The run has already stopped at the safe point', 'PAUSE_ALREADY_REACHED');
|
|
89
|
+
if (this.state !== 'requested')
|
|
90
|
+
return;
|
|
91
|
+
this.state = 'none';
|
|
92
|
+
this.emit('cancelled');
|
|
93
|
+
}
|
|
94
|
+
reach() {
|
|
95
|
+
if (this.state === 'reached')
|
|
96
|
+
return;
|
|
97
|
+
this.state = 'reached';
|
|
98
|
+
this.emit('reached');
|
|
99
|
+
}
|
|
100
|
+
}
|
package/dist/sdk-backend.js
CHANGED
|
@@ -2,6 +2,7 @@ import { createAgentSession, DefaultResourceLoader, getAgentDir, SessionManager
|
|
|
2
2
|
import { EventEmitter } from 'node:events';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import { PERFORMANCE_ENTRY, SessionPerformanceTracker } from './session-performance.js';
|
|
5
|
+
import { SafePause } from './safe-pause.js';
|
|
5
6
|
import { createCollabExtension } from './collab/pi-extension.js';
|
|
6
7
|
/** Older Pi builds resolved steer()/followUp() with undefined; treat anything but an explicit `handled` as queued. */
|
|
7
8
|
const normalizeDisposition = (value) => value === 'handled' ? 'handled' : 'queued';
|
|
@@ -12,6 +13,7 @@ export class SdkBackend {
|
|
|
12
13
|
cwd;
|
|
13
14
|
disposed = false;
|
|
14
15
|
events = new EventEmitter();
|
|
16
|
+
safePause;
|
|
15
17
|
pendingUi = new Map();
|
|
16
18
|
constructor(session, agentId, cwd) {
|
|
17
19
|
this.session = session;
|
|
@@ -19,6 +21,14 @@ export class SdkBackend {
|
|
|
19
21
|
this.cwd = cwd;
|
|
20
22
|
const performance = new SessionPerformanceTracker(run => this.session.sessionManager.appendCustomEntry(PERFORMANCE_ENTRY, run));
|
|
21
23
|
this.session.subscribe(event => performance.apply(event));
|
|
24
|
+
this.safePause = new SafePause(session, state => this.events.emit('event', { type: 'safe_pause', state }));
|
|
25
|
+
}
|
|
26
|
+
/** Ask the running turn to stop before its next model request (see SafePause). */
|
|
27
|
+
requestPause() {
|
|
28
|
+
return this.safePause.request();
|
|
29
|
+
}
|
|
30
|
+
cancelPause() {
|
|
31
|
+
return this.safePause.cancel();
|
|
22
32
|
}
|
|
23
33
|
static async create(agentId, cwd, sessionFile, profile = 'default', collabBridge, options = {}) {
|
|
24
34
|
const sessionManager = sessionFile ? SessionManager.open(sessionFile) : SessionManager.create(cwd);
|
|
@@ -56,14 +66,18 @@ export class SdkBackend {
|
|
|
56
66
|
session.dispose();
|
|
57
67
|
throw Object.assign(new Error(modelFallbackMessage), { code: 'PI_MODEL_UNAVAILABLE' });
|
|
58
68
|
}
|
|
69
|
+
return SdkBackend.fromSession(session, agentId, cwd);
|
|
70
|
+
}
|
|
71
|
+
/** Wrap an existing Pi session; tests use it to drive a real session with a scripted model. */
|
|
72
|
+
static async fromSession(session, agentId, cwd) {
|
|
59
73
|
const backend = new SdkBackend(session, agentId, cwd);
|
|
60
74
|
await session.bindExtensions({ uiContext: backend.extensionUi() });
|
|
61
75
|
return backend;
|
|
62
76
|
}
|
|
63
77
|
// Slash commands, skills and prompt templates are expanded by prompt() itself (expandPromptTemplates defaults to true),
|
|
64
78
|
// so `/name args` typed in the web composer behaves exactly like the Pi TUI.
|
|
65
|
-
prompt(message) {
|
|
66
|
-
return this.session.prompt(message);
|
|
79
|
+
prompt(message, onAccepted) {
|
|
80
|
+
return this.session.prompt(message, { preflightResult: onAccepted });
|
|
67
81
|
}
|
|
68
82
|
// Since pi 0.99 steer()/followUp() report whether the message reached the running turn (`handled`)
|
|
69
83
|
// or waits for it (`queued`); the web composer shows that difference instead of guessing.
|
|
@@ -267,7 +281,10 @@ export class SdkBackend {
|
|
|
267
281
|
throw Object.assign(new Error('Extension UI request not found'), { code: 'EXTENSION_REQUEST_NOT_FOUND' });
|
|
268
282
|
}
|
|
269
283
|
subscribe(listener) {
|
|
270
|
-
|
|
284
|
+
// A paused run settles like a finished one; notifications must not report it as completed.
|
|
285
|
+
const fromSession = (event) => listener(event.type === 'agent_settled' && this.safePause.pausedRunSettled
|
|
286
|
+
? { ...event, paused: true }
|
|
287
|
+
: event);
|
|
271
288
|
const unsubscribe = this.session.subscribe(fromSession);
|
|
272
289
|
this.events.on('event', listener);
|
|
273
290
|
return () => {
|
|
@@ -43,6 +43,8 @@ export class SessionPerformanceTracker {
|
|
|
43
43
|
break;
|
|
44
44
|
case 'message_end':
|
|
45
45
|
if (e.message?.role === 'assistant') {
|
|
46
|
+
if (this.model && Number.isFinite(e.message.usage?.output) && e.message.usage.output >= 0)
|
|
47
|
+
this.model.outputTokens = e.message.usage.output;
|
|
46
48
|
this.finish(this.model, at);
|
|
47
49
|
this.model = null;
|
|
48
50
|
}
|
package/dist/voice.js
CHANGED
|
@@ -241,8 +241,8 @@ export class VoiceManager {
|
|
|
241
241
|
const finalOutput = this.lastAssistant.get(agentId), userPrompt = this.lastPrompt.get(agentId) ?? '';
|
|
242
242
|
this.lastAssistant.delete(agentId);
|
|
243
243
|
this.lastPrompt.delete(agentId);
|
|
244
|
-
// A run the user cancelled has no result worth speaking
|
|
245
|
-
if (finalOutput && event.aborted !== true)
|
|
244
|
+
// A run the user cancelled or safely paused has no result worth speaking (`aborted` / `paused`).
|
|
245
|
+
if (finalOutput && event.aborted !== true && event.paused !== true)
|
|
246
246
|
void this.announce(agentId, { userPrompt, finalOutput, ...context });
|
|
247
247
|
}
|
|
248
248
|
}
|
package/dist/ws/agent-socket.js
CHANGED
|
@@ -10,6 +10,7 @@ const OVERVIEW_EVENTS = new Set([
|
|
|
10
10
|
'agent_settled',
|
|
11
11
|
'activity_changed',
|
|
12
12
|
'queue_update',
|
|
13
|
+
'safe_pause',
|
|
13
14
|
'session_info_changed',
|
|
14
15
|
'agent_unloaded',
|
|
15
16
|
'agent_loaded',
|
|
@@ -225,7 +226,7 @@ export function handleAgentSocket(ctx, ws) {
|
|
|
225
226
|
ctx.voice?.recordUserPrompt(m.agentId, String(m.message ?? ''));
|
|
226
227
|
ctx.sessionNamer?.recordUserPrompt(m.agentId, String(m.message ?? ''));
|
|
227
228
|
}
|
|
228
|
-
const disposition = await ctx.agents.
|
|
229
|
+
const disposition = await ctx.agents.submitCommand(m.agentId, m.type, m.message);
|
|
229
230
|
send({ type: 'command_result', requestId: m.requestId, success: true, disposition });
|
|
230
231
|
return;
|
|
231
232
|
}
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi2web",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.12",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "pi2web",
|
|
9
|
-
"version": "0.2.
|
|
9
|
+
"version": "0.2.12",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@earendil-works/chord": "1.1.0",
|
package/package.json
CHANGED
|
@@ -39,11 +39,14 @@ paths:
|
|
|
39
39
|
post: {operationId: transcribeAgentDraft, description: 'When Sotto is enabled, body is 16 kHz mono float32 little-endian PCM (application/octet-stream; max 60 s); otherwise uses configured OpenAI-compatible STT audio. Returns {data:{text}} without submitting to the Agent.', parameters: [{$ref: '#/components/parameters/Id'}], responses: {'200': {$ref: '#/components/responses/Success'}}}
|
|
40
40
|
/agents/{id}/transcribe/ws:
|
|
41
41
|
get: {operationId: streamSottoDraft, description: 'Authenticated WebSocket: start {type:start,format:float32,sample_rate}, binary mono PCM chunks, stop {type:stop}; forwards Sotto ready/partial/final/error messages. Only when Sotto is enabled; never submits to Agent.', parameters: [{$ref: '#/components/parameters/Id'}], responses: {'101': {description: 'WebSocket upgrade'}}}
|
|
42
|
+
/agents/{id}/pause:
|
|
43
|
+
post: {operationId: requestSafePause, description: 'Safe pause: the conversation stops advancing. Running tools finish and their results are recorded, then no further model response or tool call is produced: the next agent-loop turn, auto-retry, overflow-compaction retry, queued messages and extension agent_before_settle continuations are all withheld, and a waiting retry backoff is cancelled. Queued input stays queued for the next prompt. Housekeeping model calls (compaction summaries, prompt-cache warming) are not withheld. Progress is published as safe_pause events (requested, reached, paused, cancelled, finished) and as activity.pauseRequested; the agent_settled of a paused run carries paused: true. Rejects with AGENT_NOT_RUNNING while idle.', parameters: [{$ref: '#/components/parameters/Id'}], responses: {'200': {$ref: '#/components/responses/Success'}}}
|
|
44
|
+
delete: {operationId: cancelSafePause, description: 'Withdraw a pending safe pause. Rejects with PAUSE_ALREADY_REACHED while the run is winding down at the safe point (after reached, before paused). Without a pending pause, including after the run has settled, it is a no-op that succeeds. Whether the withdrawal took effect is reported by the safe_pause event (cancelled or paused).', parameters: [{$ref: '#/components/parameters/Id'}], responses: {'200': {$ref: '#/components/responses/Success'}}}
|
|
42
45
|
/agents/{id}/queue/move:
|
|
43
46
|
post: {operationId: moveQueuedMessage, description: 'Move one unconsumed message to the other queue during a run. Body {from: steering|followUp, index: integer, expected: string}; rejects stale entries.', parameters: [{$ref: '#/components/parameters/Id'}], requestBody: {$ref: '#/components/requestBodies/Json'}, responses: {'200': {$ref: '#/components/responses/Success'}}}
|
|
44
47
|
/agents/{id}/{action}:
|
|
45
48
|
get: {operationId: getAgentResource, parameters: [{$ref: '#/components/parameters/Id'}, {name: action, in: path, required: true, schema: {enum: [state,capabilities,session]}}], responses: {'200': {$ref: '#/components/responses/Success'}}}
|
|
46
|
-
post: {operationId: agentCommand, parameters: [{$ref: '#/components/parameters/Id'}, {name: action, in: path, required: true, schema: {enum: [prompt,steer,follow-up,abort,compact,model,thinking,session-name,navigate,fork,extension-response]}}], requestBody: {$ref: '#/components/requestBodies/Json'}, responses: {'200': {$ref: '#/components/responses/Success'}}}
|
|
49
|
+
post: {operationId: agentCommand, description: 'For prompt/steer/follow-up, success acknowledges acceptance by Pi, not completion of the run. Preflight rejections are HTTP errors; subsequent execution updates and errors use the WebSocket event stream. A lost response does not imply the message was rejected; clients must not automatically resend it.', parameters: [{$ref: '#/components/parameters/Id'}, {name: action, in: path, required: true, schema: {enum: [prompt,steer,follow-up,abort,compact,model,thinking,session-name,navigate,fork,extension-response]}}], requestBody: {$ref: '#/components/requestBodies/Json'}, responses: {'200': {$ref: '#/components/responses/Success'}}}
|
|
47
50
|
/draft:
|
|
48
51
|
post: {operationId: draftText, description: 'Insert text into the currently displayed Web UI input box on this backend, without sending it to an Agent. Body {text, mode?: append|replace, source?}; explicit agent/slot targets are rejected. Pending text is kept in memory until a page acknowledges it.', requestBody: {$ref: '#/components/requestBodies/Json'}, responses: {'200': {$ref: '#/components/responses/Success'}}}
|
|
49
52
|
components:
|
|
@@ -19,9 +19,9 @@
|
|
|
19
19
|
"focus": {"type":"object","additionalProperties":false,"required":["type","agentId"],"properties":{"type":{"const":"focus"},"agentId":{"type":["string","null"]},"limit":{"type":"integer","minimum":1,"maximum":500,"description":"Size of the snapshot's page, 50 by default."}},"description":"View one Agent in detail (null: none). Loads it if needed and answers with focus_state, or focus_error; replaces the previous focus and its watches."},
|
|
20
20
|
"watch": {"type":"object","additionalProperties":false,"required":["type","toolCallId"],"properties":{"type":{"enum":["watch","unwatch"]},"toolCallId":{"type":"string"}},"description":"Start or stop tool_output for a running tool of the focused Agent, e.g. while its card is expanded."},
|
|
21
21
|
"command": {"type":"object","additionalProperties":false,"required":["type","requestId","agentId"],"properties":{"type":{"enum":["prompt","steer","follow-up","abort","clear-queue"]},"requestId":{"type":"string"},"agentId":{"type":"string"},"message":{"type":"string","maxLength":2097152}}},
|
|
22
|
-
"commandResult": {"type":"object","required":["type","success"],"properties":{"type":{"const":"command_result"},"requestId":{"type":"string"},"success":{"type":"boolean"},"error":{"type":"object"},"disposition":{"enum":["handled","queued"],"description":"For steer and follow-up: whether the running turn took the message or it waits in the queue."},"queue":{"$ref":"#/$defs/queue"}}},
|
|
22
|
+
"commandResult": {"type":"object","description":"For prompt/steer/follow-up, success acknowledges acceptance, not completion. A lost acknowledgement must not trigger an automatic resend.","required":["type","success"],"properties":{"type":{"const":"command_result"},"requestId":{"type":"string"},"success":{"type":"boolean"},"error":{"type":"object"},"disposition":{"enum":["handled","queued"],"description":"For steer and follow-up: whether the running turn took the message or it waits in the queue."},"queue":{"$ref":"#/$defs/queue"}}},
|
|
23
23
|
"queue": {"type":"object","required":["steering","followUp"],"description":"Messages waiting for the running turn.","properties":{"steering":{"type":"array","items":{"type":"string"}},"followUp":{"type":"array","items":{"type":"string"}}}},
|
|
24
|
-
"agentEvent": {"type":"object","required":["type","agentId","timestamp","status","event"],"properties":{"type":{"const":"agent_event"},"agentId":{"type":"string"},"timestamp":{"type":"integer","minimum":0,"description":"Epoch milliseconds."},"status":{"$ref":"#/$defs/agentStatus","description":"Agent status after this event."},"event":{"type":"object","required":["type"],"description":"Overview events of every Agent: agent_loaded (its backend started on demand), agent_start, agent_end {willRetry, aborted}, agent_settled, activity_changed {activity}, queue_update, session_info_changed, agent_unloaded, auto_retry_*, summarization_retry_*, extension_ui_*. For the focused Agent also bash_execution_start/update/end (output of user shell commands)."}}},
|
|
24
|
+
"agentEvent": {"type":"object","required":["type","agentId","timestamp","status","event"],"properties":{"type":{"const":"agent_event"},"agentId":{"type":"string"},"timestamp":{"type":"integer","minimum":0,"description":"Epoch milliseconds."},"status":{"$ref":"#/$defs/agentStatus","description":"Agent status after this event."},"event":{"type":"object","required":["type"],"description":"Overview events of every Agent: agent_loaded (its backend started on demand), agent_start, agent_end {willRetry, aborted}, agent_settled, activity_changed {activity}, queue_update, safe_pause {state: requested|reached|paused|cancelled|finished}, session_info_changed, agent_unloaded, auto_retry_*, summarization_retry_*, extension_ui_*. For the focused Agent also bash_execution_start/update/end (output of user shell commands)."}}},
|
|
25
25
|
"focusState": {"type":"object","required":["type","agentId","sessionId","page","live"],"description":"Snapshot of the focused Agent. Every detail message after it is newer.","properties":{"type":{"const":"focus_state"},"agentId":{"type":"string"},"sessionId":{"type":"string"},"page":{"type":"object","required":["items","total","start","hasMore"],"properties":{"items":{"type":"array","items":{"$ref":"#/$defs/timelineItem"}},"total":{"type":"integer"},"start":{"type":"integer"},"hasMore":{"type":"boolean"}}},"live":{"oneOf":[{"type":"null"},{"type":"object","required":["text","thinking"],"properties":{"text":{"type":"string"},"thinking":{"type":"string"}}}],"description":"Text streamed so far for the message being generated."},"dialogRequests":{"type":"array","description":"Pending dialogs, to rebuild their controls in the transcript."}}},
|
|
26
26
|
"focusError": {"type":"object","required":["type","agentId","error"],"properties":{"type":{"const":"focus_error"},"agentId":{"type":"string"},"error":{"type":"object"}}},
|
|
27
27
|
"delta": {"type":"object","required":["type","agentId","kind","delta"],"properties":{"type":{"const":"delta"},"agentId":{"type":"string"},"kind":{"enum":["text","thinking"]},"delta":{"type":"string"}},"description":"More streamed text; adjacent tokens are coalesced."},
|
|
@@ -63,7 +63,8 @@
|
|
|
63
63
|
}
|
|
64
64
|
},
|
|
65
65
|
"bash":{"type":"array","items":{"type":"object","required":["id","since"],"properties":{"id":{"type":"string"},"command":{"type":"string"},"since":{"type":"integer"}}}},
|
|
66
|
-
"dialogs":{"type":"array","items":{"type":"object","required":["requestId","since"],"properties":{"requestId":{"type":"string"},"title":{"type":"string"},"since":{"type":"integer"}}}}
|
|
66
|
+
"dialogs":{"type":"array","items":{"type":"object","required":["requestId","since"],"properties":{"requestId":{"type":"string"},"title":{"type":"string"},"since":{"type":"integer"}}}},
|
|
67
|
+
"pauseRequested":{"type":"object","required":["since"],"description":"A safe pause is pending: the run stops before its next model request. reached: a request was withheld and the run is winding down; it can no longer be withdrawn.","properties":{"since":{"type":"integer"},"reached":{"const":true}}}
|
|
67
68
|
}
|
|
68
69
|
},
|
|
69
70
|
"agentState": {
|
package/web/activity-view.js
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
compacting: ['🗜️', '压缩上下文'],
|
|
14
14
|
stopping: ['⏹️', '正在停止']
|
|
15
15
|
};
|
|
16
|
+
/** Streaming phases that report an estimated throughput, and how their output count reads. */
|
|
17
|
+
const STREAM_COUNT = {thinking: '已思考', generating: '已输出', preparing_tool: '参数'};
|
|
16
18
|
|
|
17
19
|
const isBusy = activity =>
|
|
18
20
|
!!activity &&
|
|
@@ -21,6 +23,10 @@
|
|
|
21
23
|
!!activity.bash?.length ||
|
|
22
24
|
!!activity.dialogs?.length);
|
|
23
25
|
|
|
26
|
+
function formatDuration(ms) {
|
|
27
|
+
return ms < 1000 ? `${Math.round(ms)} ms` : `${(ms / 1000).toFixed(1)} s`;
|
|
28
|
+
}
|
|
29
|
+
|
|
24
30
|
function formatElapsed(ms) {
|
|
25
31
|
const total = Math.max(0, Math.floor(ms / 1000)),
|
|
26
32
|
h = Math.floor(total / 3600),
|
|
@@ -40,6 +46,20 @@
|
|
|
40
46
|
const phase = activity.phase;
|
|
41
47
|
if (phase && phase.kind !== 'idle') {
|
|
42
48
|
const [icon, label] = PHASE_TEXT[phase.kind] || ['•', phase.kind];
|
|
49
|
+
const metrics = activity.metrics;
|
|
50
|
+
const ttft =
|
|
51
|
+
metrics?.firstResponseAt === undefined
|
|
52
|
+
? ''
|
|
53
|
+
: ` · TTFT ${formatDuration(metrics.firstResponseAt - metrics.turnStartedAt)}`;
|
|
54
|
+
// `textStartedAt`: backends before per-phase throughput only measured the text phase.
|
|
55
|
+
const streamStartedAt = metrics?.streamStartedAt ?? metrics?.textStartedAt;
|
|
56
|
+
const streaming =
|
|
57
|
+
STREAM_COUNT[phase.kind] && (metrics?.streamStartedAt !== undefined || phase.kind === 'generating');
|
|
58
|
+
const output = streaming && metrics?.chars ? ` · ${STREAM_COUNT[phase.kind]} ${metrics.chars} 字` : '';
|
|
59
|
+
const speed =
|
|
60
|
+
streaming && streamStartedAt !== undefined
|
|
61
|
+
? {speed: {tokens: metrics.estimatedTokens, since: streamStartedAt}}
|
|
62
|
+
: {};
|
|
43
63
|
if (phase.kind === 'running_tools')
|
|
44
64
|
for (const tool of phase.tools || [])
|
|
45
65
|
parts.push({
|
|
@@ -50,8 +70,9 @@
|
|
|
50
70
|
else if (phase.kind === 'preparing_tool')
|
|
51
71
|
parts.push({
|
|
52
72
|
icon,
|
|
53
|
-
text: `${label}${phase.toolName ? `:${phase.toolName}` : ''}`,
|
|
54
|
-
since: phase.since
|
|
73
|
+
text: `${label}${phase.toolName ? `:${phase.toolName}` : ''}${ttft}${output}`,
|
|
74
|
+
since: phase.since,
|
|
75
|
+
...speed
|
|
55
76
|
});
|
|
56
77
|
else if (phase.kind === 'retrying')
|
|
57
78
|
parts.push({
|
|
@@ -59,8 +80,14 @@
|
|
|
59
80
|
text: `${label}${phase.attempt ? ` ${phase.attempt}/${phase.maxAttempts || '?'}` : ''}${phase.error ? ` · ${phase.error}` : ''}`,
|
|
60
81
|
since: phase.since
|
|
61
82
|
});
|
|
62
|
-
else parts.push({icon, text: label
|
|
83
|
+
else parts.push({icon, text: `${label}${ttft}${output}`, since: phase.since, ...speed});
|
|
63
84
|
}
|
|
85
|
+
if (activity.pauseRequested)
|
|
86
|
+
parts.push({
|
|
87
|
+
icon: '⏸️',
|
|
88
|
+
text: activity.pauseRequested.reached ? '已到暂停点,正在收尾' : '将在下次调用模型前暂停',
|
|
89
|
+
since: activity.pauseRequested.since
|
|
90
|
+
});
|
|
64
91
|
for (const bash of activity.bash || [])
|
|
65
92
|
parts.push({icon: '$', text: bash.command || 'bash', since: bash.since});
|
|
66
93
|
return parts;
|
|
@@ -86,7 +113,17 @@
|
|
|
86
113
|
text.className = 'activity-text';
|
|
87
114
|
text.textContent = `${part.icon} ${part.text}`;
|
|
88
115
|
text.title = part.text;
|
|
89
|
-
node.append(text
|
|
116
|
+
node.append(text);
|
|
117
|
+
if (part.speed) {
|
|
118
|
+
const speed = document.createElement('span');
|
|
119
|
+
speed.className = 'activity-speed';
|
|
120
|
+
speed.dataset.tokens = String(part.speed.tokens);
|
|
121
|
+
speed.dataset.since = String(part.speed.since);
|
|
122
|
+
speed.dataset.backend = backendId || '';
|
|
123
|
+
speed.textContent = speedText(part.speed.tokens, serverNow(backendId) - part.speed.since);
|
|
124
|
+
node.append(speed);
|
|
125
|
+
}
|
|
126
|
+
node.append(elapsed(backendId, part.since));
|
|
90
127
|
return node;
|
|
91
128
|
}
|
|
92
129
|
/** The full activity of the selected Agent: every part plus the run's total time. */
|
|
@@ -110,9 +147,17 @@
|
|
|
110
147
|
if (parts.length > 1) node.append(` +${parts.length - 1}`);
|
|
111
148
|
return node;
|
|
112
149
|
}
|
|
150
|
+
function speedText(tokens, ms) {
|
|
151
|
+
return ms < 1000 ? ' · 估算中' : ` · 约 ${((tokens * 1000) / ms).toFixed(1)} token/s`;
|
|
152
|
+
}
|
|
113
153
|
function tick() {
|
|
114
154
|
for (const el of document.querySelectorAll('.elapsed[data-since]'))
|
|
115
155
|
el.textContent = formatElapsed(serverNow(el.dataset.backend) - Number(el.dataset.since));
|
|
156
|
+
for (const el of document.querySelectorAll('.activity-speed[data-since]'))
|
|
157
|
+
el.textContent = speedText(
|
|
158
|
+
Number(el.dataset.tokens),
|
|
159
|
+
serverNow(el.dataset.backend) - Number(el.dataset.since)
|
|
160
|
+
);
|
|
116
161
|
}
|
|
117
162
|
return {bar, line, tick};
|
|
118
163
|
}
|
package/web/app.js
CHANGED
|
@@ -69,6 +69,8 @@
|
|
|
69
69
|
voiceEnabled: false,
|
|
70
70
|
voiceSttEnabled: false,
|
|
71
71
|
voicePlaybackEnabled: localStorage.rpVoicePlayback === 'true',
|
|
72
|
+
// Per browser: 'enter' sends on Enter (Shift+Enter for a new line), 'ctrl-enter' on Ctrl/⌘+Enter.
|
|
73
|
+
enterSendMode: localStorage.rpEnterSendMode === 'enter' ? 'enter' : 'ctrl-enter',
|
|
72
74
|
voiceAudio: {
|
|
73
75
|
context: null,
|
|
74
76
|
nextTime: 0,
|
|
@@ -87,6 +89,8 @@
|
|
|
87
89
|
mediaBackendId: null,
|
|
88
90
|
/** Messages that failed after the user switched away; restored when that Agent is selected again. */
|
|
89
91
|
failedSends: new Map(),
|
|
92
|
+
/** Lost acknowledgements are notices, not drafts to send again. Kept across focus snapshots. */
|
|
93
|
+
uncertainSends: new Map(),
|
|
90
94
|
mailBackendId: null,
|
|
91
95
|
mailRequest: 0,
|
|
92
96
|
sottoBackendId: null,
|
|
@@ -1300,9 +1304,16 @@
|
|
|
1300
1304
|
nodes = agent ? activityView.bar(state.activities.get(agent.agentId), agent.backendId) : [];
|
|
1301
1305
|
bar.replaceChildren(...nodes);
|
|
1302
1306
|
bar.hidden = !nodes.length;
|
|
1307
|
+
// A pending safe pause can be withdrawn with the same button until a model request was withheld.
|
|
1308
|
+
const pause = agent ? state.activities.get(agent.agentId)?.pauseRequested : null,
|
|
1309
|
+
button = $('safePause');
|
|
1310
|
+
button.textContent = pause?.reached ? '暂停中…' : pause ? '取消暂停' : '安全暂停';
|
|
1311
|
+
button.disabled = !!pause?.reached;
|
|
1303
1312
|
}
|
|
1304
1313
|
function setAgentActivity(agentId, activity) {
|
|
1305
|
-
|
|
1314
|
+
// A pause request outlives the run (agent_end → agent_settled); its safe_pause event clears it.
|
|
1315
|
+
if (window.ActivityView.isBusy(activity) || activity?.pauseRequested)
|
|
1316
|
+
state.activities.set(agentId, activity);
|
|
1306
1317
|
else state.activities.delete(agentId);
|
|
1307
1318
|
sessionKeyboard.setActivity(agentId, activity);
|
|
1308
1319
|
refreshToolCards(agentId);
|
|
@@ -2030,6 +2041,7 @@
|
|
|
2030
2041
|
box.scrollTop = box.scrollHeight;
|
|
2031
2042
|
} else box.innerHTML = '<div class="empty">正在加载…</div>';
|
|
2032
2043
|
if (!sendFocus(true) && !cached) box.innerHTML = '<div class="empty">等待连接 backend…</div>';
|
|
2044
|
+
for (const text of state.uncertainSends.get(agent.agentId) || []) showUncertainSend(agent.agentId, text);
|
|
2033
2045
|
}
|
|
2034
2046
|
/** Ask for a new snapshot, e.g. when the branch changed. */
|
|
2035
2047
|
function refocus() {
|
|
@@ -2046,6 +2058,8 @@
|
|
|
2046
2058
|
timeline.ready = true;
|
|
2047
2059
|
applyLive(message.live);
|
|
2048
2060
|
restoreExtensionRequests(message.agentId, {dialogRequests: message.dialogRequests || []});
|
|
2061
|
+
for (const text of state.uncertainSends.get(message.agentId) || [])
|
|
2062
|
+
showUncertainSend(message.agentId, text);
|
|
2049
2063
|
saveTimelineCache();
|
|
2050
2064
|
box.scrollTop = box.scrollHeight;
|
|
2051
2065
|
}
|
|
@@ -2814,6 +2828,11 @@
|
|
|
2814
2828
|
setAgentActivity(message.agentId, ev.activity);
|
|
2815
2829
|
return;
|
|
2816
2830
|
}
|
|
2831
|
+
if (ev.type === 'safe_pause') {
|
|
2832
|
+
if (selected && ev.state === 'paused') toast('已安全暂停:工具结果已保存,发送消息即可继续');
|
|
2833
|
+
else if (selected && ev.state === 'finished') toast('运行已结束,未到达暂停点');
|
|
2834
|
+
return;
|
|
2835
|
+
}
|
|
2817
2836
|
if (ev.type === 'agent_unloaded') {
|
|
2818
2837
|
setAgentStatus(message.agentId, 'unloaded');
|
|
2819
2838
|
setAgentQueue(message.agentId, null);
|
|
@@ -3321,18 +3340,18 @@
|
|
|
3321
3340
|
if (!workspace) return toast('请先选择 Workspace');
|
|
3322
3341
|
const cwd = $('agentCwd').value.trim() || '.';
|
|
3323
3342
|
const preferenceKey = `rpLastTemplate:${workspace.backendId}`;
|
|
3324
|
-
let
|
|
3325
|
-
|
|
3343
|
+
let templates = [],
|
|
3344
|
+
modelCatalog;
|
|
3326
3345
|
try {
|
|
3327
|
-
[
|
|
3328
|
-
requestBackend(
|
|
3329
|
-
workspace.backendId,
|
|
3330
|
-
`/api/v1/sessions?workspaceId=${encodeURIComponent(workspace.id)}&path=${encodeURIComponent(cwd)}`
|
|
3331
|
-
),
|
|
3346
|
+
[templates, modelCatalog] = await Promise.all([
|
|
3332
3347
|
// Older backends have no templates endpoint; they simply offer Pi's default prompt.
|
|
3333
3348
|
requestBackend(workspace.backendId, '/api/v1/agent-templates')
|
|
3334
3349
|
.then(data => data.templates || [])
|
|
3335
|
-
.catch(() => [])
|
|
3350
|
+
.catch(() => []),
|
|
3351
|
+
requestBackend(
|
|
3352
|
+
workspace.backendId,
|
|
3353
|
+
`/api/v1/agents/models?workspaceId=${encodeURIComponent(workspace.id)}&relativeCwd=${encodeURIComponent(cwd)}`
|
|
3354
|
+
).catch(() => null)
|
|
3336
3355
|
]);
|
|
3337
3356
|
} catch (error) {
|
|
3338
3357
|
return toast(error.message);
|
|
@@ -3341,19 +3360,71 @@
|
|
|
3341
3360
|
'新建 Agent',
|
|
3342
3361
|
body => {
|
|
3343
3362
|
const c = field(body, '工作路径', cwd);
|
|
3344
|
-
const
|
|
3345
|
-
|
|
3346
|
-
const
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
.
|
|
3355
|
-
|
|
3356
|
-
|
|
3363
|
+
const modelLabel = document.createElement('label');
|
|
3364
|
+
modelLabel.textContent = '模型';
|
|
3365
|
+
const modelSelect = document.createElement('select');
|
|
3366
|
+
const renderModels = (catalog, selected = modelSelect.value) => {
|
|
3367
|
+
modelSelect.replaceChildren();
|
|
3368
|
+
const fallback = document.createElement('option');
|
|
3369
|
+
fallback.value = '';
|
|
3370
|
+
fallback.textContent = 'Pi 默认模型';
|
|
3371
|
+
modelSelect.append(fallback);
|
|
3372
|
+
for (const model of catalog?.models || []) {
|
|
3373
|
+
const option = document.createElement('option');
|
|
3374
|
+
option.value = JSON.stringify([model.provider, model.id]);
|
|
3375
|
+
option.textContent = `${model.provider} / ${model.name || model.id}`;
|
|
3376
|
+
modelSelect.append(option);
|
|
3377
|
+
}
|
|
3378
|
+
if (selected && [...modelSelect.options].some(option => option.value === selected))
|
|
3379
|
+
modelSelect.value = selected;
|
|
3380
|
+
else if (catalog?.model) {
|
|
3381
|
+
const current = JSON.stringify([catalog.model.provider, catalog.model.id]);
|
|
3382
|
+
if ([...modelSelect.options].some(option => option.value === current))
|
|
3383
|
+
modelSelect.value = current;
|
|
3384
|
+
}
|
|
3385
|
+
if (selected && modelSelect.value !== selected)
|
|
3386
|
+
toast('所选模型在新工作路径不可用,已改用当前默认模型;请确认选择。');
|
|
3387
|
+
};
|
|
3388
|
+
renderModels(modelCatalog);
|
|
3389
|
+
modelLabel.append(modelSelect);
|
|
3390
|
+
body.append(modelLabel);
|
|
3391
|
+
let modelRequest = 0;
|
|
3392
|
+
const submit = $('modalOk');
|
|
3393
|
+
$('modal').addEventListener(
|
|
3394
|
+
'close',
|
|
3395
|
+
() => {
|
|
3396
|
+
++modelRequest;
|
|
3397
|
+
submit.disabled = false;
|
|
3398
|
+
},
|
|
3399
|
+
{once: true}
|
|
3400
|
+
);
|
|
3401
|
+
c.addEventListener('change', async () => {
|
|
3402
|
+
const request = ++modelRequest;
|
|
3403
|
+
const selected = modelSelect.value;
|
|
3404
|
+
submit.disabled = true;
|
|
3405
|
+
modelSelect.disabled = true;
|
|
3406
|
+
try {
|
|
3407
|
+
const catalog = await requestBackend(
|
|
3408
|
+
workspace.backendId,
|
|
3409
|
+
`/api/v1/agents/models?workspaceId=${encodeURIComponent(workspace.id)}&relativeCwd=${encodeURIComponent(c.value.trim() || '.')}`
|
|
3410
|
+
);
|
|
3411
|
+
if (request === modelRequest) {
|
|
3412
|
+
modelCatalog = catalog;
|
|
3413
|
+
renderModels(catalog, selected);
|
|
3414
|
+
}
|
|
3415
|
+
} catch (error) {
|
|
3416
|
+
if (request === modelRequest) {
|
|
3417
|
+
modelCatalog = null;
|
|
3418
|
+
renderModels(null, '');
|
|
3419
|
+
toast(`读取模型失败:${error.message};模型选择已重置,请确认后创建。`);
|
|
3420
|
+
}
|
|
3421
|
+
} finally {
|
|
3422
|
+
if (request === modelRequest) {
|
|
3423
|
+
modelSelect.disabled = false;
|
|
3424
|
+
submit.disabled = false;
|
|
3425
|
+
}
|
|
3426
|
+
}
|
|
3427
|
+
});
|
|
3357
3428
|
const t = templateSelect(body, templates);
|
|
3358
3429
|
const preferred = globalThis.localStorage?.getItem(preferenceKey);
|
|
3359
3430
|
if (preferred && [...t.options].some(option => option.value === preferred)) t.value = preferred;
|
|
@@ -3371,8 +3442,8 @@
|
|
|
3371
3442
|
t.onchange = () => preview.reset();
|
|
3372
3443
|
return () => ({
|
|
3373
3444
|
cwd: c.value.trim() || '.',
|
|
3374
|
-
|
|
3375
|
-
|
|
3445
|
+
templateId: t.value || undefined,
|
|
3446
|
+
model: modelSelect.value ? JSON.parse(modelSelect.value) : null
|
|
3376
3447
|
});
|
|
3377
3448
|
},
|
|
3378
3449
|
'创建'
|
|
@@ -3382,12 +3453,12 @@
|
|
|
3382
3453
|
const created = await postBackend(workspace.backendId, '/api/v1/agents', {
|
|
3383
3454
|
workspaceId: workspace.id,
|
|
3384
3455
|
relativeCwd: result.cwd,
|
|
3385
|
-
|
|
3386
|
-
|
|
3456
|
+
templateId: result.templateId,
|
|
3457
|
+
...(result.model ? {model: {provider: result.model[0], modelId: result.model[1]}} : {})
|
|
3387
3458
|
});
|
|
3388
3459
|
localStorage.setItem(preferenceKey, result.templateId || '');
|
|
3389
3460
|
const agent = stampNewItem(workspace.backendId, created);
|
|
3390
|
-
const binding =
|
|
3461
|
+
const binding = autoBindNewSession(agent);
|
|
3391
3462
|
await refreshAgents();
|
|
3392
3463
|
await selectAgent(agent);
|
|
3393
3464
|
if (binding) toast(`新 Session 已自动绑定到按键 ${binding.slot + 1}`);
|
|
@@ -3775,7 +3846,7 @@
|
|
|
3775
3846
|
const info = document.createElement('p');
|
|
3776
3847
|
info.className = 'muted';
|
|
3777
3848
|
info.textContent =
|
|
3778
|
-
'
|
|
3849
|
+
'横轴为每轮运行的实际时间;同一时间段的工具调用可能并行。模型耗时包含首响应等待与输出;网络、排队和其他开销可能落在空白时间。实际输出 token 可能包含思考 token。';
|
|
3779
3850
|
body.append(info);
|
|
3780
3851
|
if (!runs.length && !legacyTools.length) {
|
|
3781
3852
|
body.append(el('p', {textContent: '当前分支没有可分析的耗时记录;新运行结束后会记录精确时间。'}));
|
|
@@ -3810,6 +3881,10 @@
|
|
|
3810
3881
|
span.kind === 'model' && span.firstResponse !== undefined
|
|
3811
3882
|
? ` · 首响应 ${performanceTime(span.firstResponse - span.start)} · 响应 ${performanceTime(span.end - span.firstResponse)}`
|
|
3812
3883
|
: '';
|
|
3884
|
+
const tokens =
|
|
3885
|
+
span.kind === 'model' && span.outputTokens !== undefined
|
|
3886
|
+
? ` · 输出 ${span.outputTokens} token${span.firstResponse !== undefined && span.end > span.firstResponse ? ` · 均速 ${((span.outputTokens * 1000) / (span.end - span.firstResponse)).toFixed(1)} token/s` : ''}`
|
|
3887
|
+
: '';
|
|
3813
3888
|
const label =
|
|
3814
3889
|
span.kind === 'model'
|
|
3815
3890
|
? '模型调用'
|
|
@@ -3821,7 +3896,7 @@
|
|
|
3821
3896
|
const row = el('div', {className: 'performance-row'});
|
|
3822
3897
|
const text = el('span', {
|
|
3823
3898
|
className: 'performance-label',
|
|
3824
|
-
textContent: `${label} · +${performanceTime(span.start - run.start)} · ${performanceTime(length)}${first}${span.incomplete ? ' · 未完成' : ''}`
|
|
3899
|
+
textContent: `${label} · +${performanceTime(span.start - run.start)} · ${performanceTime(length)}${first}${tokens}${span.incomplete ? ' · 未完成' : ''}`
|
|
3825
3900
|
});
|
|
3826
3901
|
const track = el('div', {className: 'performance-track'});
|
|
3827
3902
|
const bar = el('i', {className: `performance-${span.kind}`});
|
|
@@ -4126,6 +4201,18 @@
|
|
|
4126
4201
|
}
|
|
4127
4202
|
};
|
|
4128
4203
|
$('workspaces').onchange = () => void selectWorkspace();
|
|
4204
|
+
function applyEnterSendMode() {
|
|
4205
|
+
$('enterSendMode').value = state.enterSendMode;
|
|
4206
|
+
$('input').placeholder = `输入 @ 引用文件,/ 使用命令,! 执行 Shell;${
|
|
4207
|
+
state.enterSendMode === 'enter' ? 'Enter 发送,Shift+Enter 换行' : 'Ctrl/⌘+Enter 发送'
|
|
4208
|
+
}`;
|
|
4209
|
+
}
|
|
4210
|
+
applyEnterSendMode();
|
|
4211
|
+
$('enterSendMode').onchange = () => {
|
|
4212
|
+
state.enterSendMode = $('enterSendMode').value === 'enter' ? 'enter' : 'ctrl-enter';
|
|
4213
|
+
localStorage.rpEnterSendMode = state.enterSendMode;
|
|
4214
|
+
applyEnterSendMode();
|
|
4215
|
+
};
|
|
4129
4216
|
$('agentPageSize').value = String(state.agentPageSize);
|
|
4130
4217
|
$('agentPageSize').onchange = () => {
|
|
4131
4218
|
state.agentPageSize = Number($('agentPageSize').value) || 10;
|
|
@@ -4203,6 +4290,13 @@
|
|
|
4203
4290
|
const id = state.agent.agentId;
|
|
4204
4291
|
Promise.all([postAgent(id, '/abort'), postAgent(id, '/bash-abort')]).catch(e => toast(e.message));
|
|
4205
4292
|
};
|
|
4293
|
+
$('safePause').onclick = () => {
|
|
4294
|
+
if (!state.agent) return;
|
|
4295
|
+
const id = state.agent.agentId,
|
|
4296
|
+
pause = state.activities.get(id)?.pauseRequested;
|
|
4297
|
+
if (pause?.reached) return;
|
|
4298
|
+
requestAgent(id, '/pause', {method: pause ? 'DELETE' : 'POST'}).catch(e => toast(e.message));
|
|
4299
|
+
};
|
|
4206
4300
|
$('stop').onclick = async () => {
|
|
4207
4301
|
if (state.agent && confirm('停止这个 Agent?之后再次使用时会按需启动。')) {
|
|
4208
4302
|
await requestAgent(state.agent.agentId, '', {method: 'DELETE'});
|
|
@@ -4248,6 +4342,25 @@
|
|
|
4248
4342
|
function prependInput(text) {
|
|
4249
4343
|
$('input').value = text + ($('input').value ? `\n${$('input').value}` : '');
|
|
4250
4344
|
}
|
|
4345
|
+
function showUncertainSend(agentId, text) {
|
|
4346
|
+
const card = addCard(
|
|
4347
|
+
'送达状态未知',
|
|
4348
|
+
`连接中断,以下消息可能已送达。请先核对会话和队列,不要直接重复发送。\n\n${text}`,
|
|
4349
|
+
'error',
|
|
4350
|
+
true
|
|
4351
|
+
);
|
|
4352
|
+
const dismiss = document.createElement('button');
|
|
4353
|
+
dismiss.type = 'button';
|
|
4354
|
+
dismiss.textContent = '已核对,关闭提示';
|
|
4355
|
+
dismiss.onclick = () => {
|
|
4356
|
+
const pending = state.uncertainSends.get(agentId) || [],
|
|
4357
|
+
index = pending.indexOf(text);
|
|
4358
|
+
if (index >= 0) pending.splice(index, 1);
|
|
4359
|
+
if (!pending.length) state.uncertainSends.delete(agentId);
|
|
4360
|
+
removeCard(card);
|
|
4361
|
+
};
|
|
4362
|
+
card.details.append(dismiss);
|
|
4363
|
+
}
|
|
4251
4364
|
$('prompt').onsubmit = async event => {
|
|
4252
4365
|
event.preventDefault();
|
|
4253
4366
|
if (!state.agent) return toast('请先创建或选择 Agent');
|
|
@@ -4285,6 +4398,17 @@
|
|
|
4285
4398
|
if (mode !== 'prompt' && sent?.disposition)
|
|
4286
4399
|
toast(sent.disposition === 'queued' ? '已排队,等待当前回合' : '已送达当前回合');
|
|
4287
4400
|
} catch (e) {
|
|
4401
|
+
// A lost response (including a gateway timeout) says nothing about whether Pi accepted the
|
|
4402
|
+
// input. Keep the text for review, but never turn it back into a sendable draft automatically.
|
|
4403
|
+
if (!e.status || e.status === 408 || e.status >= 500) {
|
|
4404
|
+
const pending = state.uncertainSends.get(agentId) || [];
|
|
4405
|
+
pending.push(text);
|
|
4406
|
+
state.uncertainSends.set(agentId, pending);
|
|
4407
|
+
if (state.selectedKind === 'agent' && state.agent?.agentId === agentId)
|
|
4408
|
+
showUncertainSend(agentId, text);
|
|
4409
|
+
else toast('消息送达状态未知;切回该会话后请核对会话和队列');
|
|
4410
|
+
return;
|
|
4411
|
+
}
|
|
4288
4412
|
// The composer is shared: only restore into it while the failed Agent is still selected.
|
|
4289
4413
|
if (state.agent?.agentId === agentId) {
|
|
4290
4414
|
prependInput(text);
|
|
@@ -4297,6 +4421,15 @@
|
|
|
4297
4421
|
}
|
|
4298
4422
|
}
|
|
4299
4423
|
};
|
|
4424
|
+
/**
|
|
4425
|
+
* Whether a keydown in the prompt sends it. Ctrl/⌘+Enter always sends; in 'enter' mode a plain Enter
|
|
4426
|
+
* does too. Enter that confirms an IME composition never sends.
|
|
4427
|
+
*/
|
|
4428
|
+
function isSendKey(event, mode) {
|
|
4429
|
+
if (event.key !== 'Enter' || event.isComposing || event.keyCode === 229) return false;
|
|
4430
|
+
if (event.ctrlKey || event.metaKey) return true;
|
|
4431
|
+
return mode === 'enter' && !event.shiftKey && !event.altKey;
|
|
4432
|
+
}
|
|
4300
4433
|
$('input').onkeydown = event => {
|
|
4301
4434
|
if (event.altKey && event.shiftKey && event.key.toLowerCase() === 'v' && !event.isComposing) {
|
|
4302
4435
|
event.preventDefault();
|
|
@@ -4337,7 +4470,8 @@
|
|
|
4337
4470
|
return;
|
|
4338
4471
|
}
|
|
4339
4472
|
}
|
|
4340
|
-
if ((event
|
|
4473
|
+
if (isSendKey(event, state.enterSendMode)) {
|
|
4474
|
+
event.preventDefault();
|
|
4341
4475
|
$('prompt').requestSubmit();
|
|
4342
4476
|
}
|
|
4343
4477
|
};
|
package/web/index.html
CHANGED
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
<button id="systemPrompt" class="agent-action" title="查看此 Agent 的 System Prompt 与模板">Prompt</button>
|
|
51
51
|
<button id="switchPrompt" class="agent-action" title="仅新会话首次对话前可切换 System Prompt">切换 Prompt</button>
|
|
52
52
|
<button id="compact" class="agent-action">Compact</button>
|
|
53
|
+
<button id="safePause" class="agent-action" title="当前工具执行完后、下一次调用模型前暂停;之后发送消息即可继续">安全暂停</button>
|
|
53
54
|
<button id="stop" class="agent-action">停止</button>
|
|
54
55
|
<button id="abort" class="agent-action danger-action">Abort</button>
|
|
55
56
|
</div>
|
|
@@ -76,7 +77,7 @@
|
|
|
76
77
|
</article>
|
|
77
78
|
</main>
|
|
78
79
|
<div id="contextMenu" class="context-menu" hidden><button id="menuStartAgent">启动 Agent</button><button id="menuOpenTerminal">在 Terminal 中打开</button><button id="menuSetCwd">设为 Agent 工作路径</button><button id="menuMention">在输入框中引用</button></div>
|
|
79
|
-
<dialog id="configDialog"><form method="dialog" id="configForm"><h2>Remote Pi 配置</h2><section class="config-section"><h3>Backend 连接</h3><div id="backendList"></div><div class="config-actions"><button id="connect" type="button">添加 Backend</button><button id="disconnectAll" type="button" hidden>断开全部</button></div><small id="configConnectionStatus" class="muted"></small></section><section class="config-section"><h3>六键 Session 键盘</h3><div class="config-actions"><button id="keyboardConnect" type="button">连接键盘</button><button id="keyboardDisconnect" type="button" hidden>断开键盘</button></div><small id="keyboardStatus" class="muted">未连接 · 需要 Chrome/Edge 和 HTTPS 或 localhost</small><div id="keyboardSlots" class="keyboard-slots" aria-label="键盘按键绑定"></div></section><section class="config-section"><h3>浏览器通知与语音</h3><div class="config-actions"><button id="notifications" type="button" title="启用所有 Agent 的完成通知">启用通知</button><button id="voicePlayback" type="button" title="启用 Agent 语音摘要播放" hidden>启用语音</button></div><small id="browserNotificationStatus" class="muted"></small></section><section class="config-section"><h3>Sotto 语音输入</h3><p class="muted">语音输入始终使用提供当前页面的本机 Backend 配置;本机未启用识别时不可用。音频不会发送给远端 Backend,识别出的文字仍可用于远端会话。</p><label class="toggle-row"><input id="sottoEnabled" type="checkbox">启用 Sotto 语音识别(默认关闭)</label><label>Endpoint(pi2web 服务所在机器的本机地址)<input id="sottoEndpoint" type="url" value="http://127.0.0.1:8766" placeholder="http://127.0.0.1:8766"></label><small id="sottoSettingsStatus" class="muted">使用本机 Sotto 已加载的 ASR 模型;未开启时不显示输入框旁的麦克风。</small><div class="config-actions"><button id="saveSotto" type="button">保存语音输入设置</button></div></section><section class="config-section"><h3>System Prompt 与 Agent 模板</h3><p class="muted">查看、修改 Pi 的全局 System Prompt(<code>~/.pi/agent/SYSTEM.md</code>、<code>APPEND_SYSTEM.md</code>),并管理新建 Agent 时可选择的模板(<code>~/.pi/pi2web/agent-templates/*.md</code>)。作用于当前 Workspace 所在的 Backend。</p><div class="config-actions"><button id="openSystemPrompts" type="button">管理 System Prompt…</button></div></section><section class="config-section"><h3>邮件通知</h3><p id="mailNotificationAvailability" class="muted"></p><label class="toggle-row"><input id="mailNotificationEnabled" type="checkbox">启动邮件通知</label><label>邮件通知聚合时间(秒)<input id="mailAggregationDelay" type="number" min="0" max="86400" step="1" value="0"><small class="muted">首个任务完成后等待这段时间,期间完成的其他任务合并到同一封邮件;0 表示立即发送。</small></label><label class="toggle-row"><input id="mailIncludeResponse" type="checkbox" checked>包含 Agent 回复内容</label><label class="toggle-row"><input id="mailIncludeSessionDetails" type="checkbox" checked>包含 Session 名称和路径</label><label class="toggle-row"><input id="mailCollabEscalations" type="checkbox" checked>协作会话需要人工裁定或停滞时立即发信</label><small class="muted">如果回复内容和 Session 信息都不包含,邮件只会说明有任务完成。</small></section><div class="dialog-actions"><button id="configClose" type="button">关闭</button><button id="configSave" type="button">保存邮件设置</button></div></form></dialog>
|
|
80
|
+
<dialog id="configDialog"><form method="dialog" id="configForm"><h2>Remote Pi 配置</h2><section class="config-section"><h3>Backend 连接</h3><div id="backendList"></div><div class="config-actions"><button id="connect" type="button">添加 Backend</button><button id="disconnectAll" type="button" hidden>断开全部</button></div><small id="configConnectionStatus" class="muted"></small></section><section class="config-section"><h3>六键 Session 键盘</h3><div class="config-actions"><button id="keyboardConnect" type="button">连接键盘</button><button id="keyboardDisconnect" type="button" hidden>断开键盘</button></div><small id="keyboardStatus" class="muted">未连接 · 需要 Chrome/Edge 和 HTTPS 或 localhost</small><div id="keyboardSlots" class="keyboard-slots" aria-label="键盘按键绑定"></div></section><section class="config-section"><h3>浏览器通知与语音</h3><div class="config-actions"><button id="notifications" type="button" title="启用所有 Agent 的完成通知">启用通知</button><button id="voicePlayback" type="button" title="启用 Agent 语音摘要播放" hidden>启用语音</button></div><small id="browserNotificationStatus" class="muted"></small></section><section class="config-section"><h3>输入</h3><label>发送快捷键 <select id="enterSendMode" aria-label="发送快捷键"><option value="ctrl-enter">Ctrl/⌘+Enter 发送,Enter 换行</option><option value="enter">Enter 发送,Shift+Enter 换行</option></select></label><small class="muted">仅保存在本浏览器,立即生效。输入法选词时的 Enter 不会发送。</small></section><section class="config-section"><h3>Sotto 语音输入</h3><p class="muted">语音输入始终使用提供当前页面的本机 Backend 配置;本机未启用识别时不可用。音频不会发送给远端 Backend,识别出的文字仍可用于远端会话。</p><label class="toggle-row"><input id="sottoEnabled" type="checkbox">启用 Sotto 语音识别(默认关闭)</label><label>Endpoint(pi2web 服务所在机器的本机地址)<input id="sottoEndpoint" type="url" value="http://127.0.0.1:8766" placeholder="http://127.0.0.1:8766"></label><small id="sottoSettingsStatus" class="muted">使用本机 Sotto 已加载的 ASR 模型;未开启时不显示输入框旁的麦克风。</small><div class="config-actions"><button id="saveSotto" type="button">保存语音输入设置</button></div></section><section class="config-section"><h3>System Prompt 与 Agent 模板</h3><p class="muted">查看、修改 Pi 的全局 System Prompt(<code>~/.pi/agent/SYSTEM.md</code>、<code>APPEND_SYSTEM.md</code>),并管理新建 Agent 时可选择的模板(<code>~/.pi/pi2web/agent-templates/*.md</code>)。作用于当前 Workspace 所在的 Backend。</p><div class="config-actions"><button id="openSystemPrompts" type="button">管理 System Prompt…</button></div></section><section class="config-section"><h3>邮件通知</h3><p id="mailNotificationAvailability" class="muted"></p><label class="toggle-row"><input id="mailNotificationEnabled" type="checkbox">启动邮件通知</label><label>邮件通知聚合时间(秒)<input id="mailAggregationDelay" type="number" min="0" max="86400" step="1" value="0"><small class="muted">首个任务完成后等待这段时间,期间完成的其他任务合并到同一封邮件;0 表示立即发送。</small></label><label class="toggle-row"><input id="mailIncludeResponse" type="checkbox" checked>包含 Agent 回复内容</label><label class="toggle-row"><input id="mailIncludeSessionDetails" type="checkbox" checked>包含 Session 名称和路径</label><label class="toggle-row"><input id="mailCollabEscalations" type="checkbox" checked>协作会话需要人工裁定或停滞时立即发信</label><small class="muted">如果回复内容和 Session 信息都不包含,邮件只会说明有任务完成。</small></section><div class="dialog-actions"><button id="configClose" type="button">关闭</button><button id="configSave" type="button">保存邮件设置</button></div></form></dialog>
|
|
80
81
|
<dialog id="pairDialog"><form method="dialog" id="pairForm"><h2>连接 Backend</h2><p class="muted">可以添加多个 backend;连接成功后可选择是否将配对码保存到浏览器本地存储。请仅在可信设备上保存。</p><label>短名称<input id="pairName" placeholder="例如 home / lab,显示在 Session 标签上"></label><label>服务地址<input id="pairBase" required></label><label>配对码<input id="pairToken" type="password" autocomplete="off" placeholder="服务端使用 --no-auth 时留空" autofocus></label><div class="dialog-actions"><button id="pairCancel" type="button">取消</button><button id="pairSubmit" type="submit" value="default">连接</button></div></form></dialog>
|
|
81
82
|
<div id="agentContextMenu" class="context-menu" hidden><button id="menuBindKeyboard">绑定键盘按键…</button><button id="menuKeyboardColor">更换按键颜色…</button><button id="menuUnbindKeyboard">取消按键绑定</button><button id="menuArchiveAgent">Archive</button><button id="menuCloseTerminal" hidden>关闭 Terminal</button></div>
|
|
82
83
|
<dialog id="modal"><form method="dialog"><h2 id="modalTitle"></h2><div id="modalBody"></div><div class="dialog-actions"><button id="modalCancel" type="button">取消</button><button id="modalOk" type="submit" value="default">确定</button></div></form></dialog>
|
package/web/styles.css
CHANGED
|
@@ -100,6 +100,7 @@
|
|
|
100
100
|
#activityBar[hidden],.agent-activity[hidden]{display:none}
|
|
101
101
|
.activity-chip{display:inline-flex;align-items:center;gap:6px;min-width:0;max-width:100%}
|
|
102
102
|
.activity-text{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
103
|
+
.activity-speed{flex:0 0 auto;white-space:nowrap;font-variant-numeric:tabular-nums;color:var(--blue)}
|
|
103
104
|
.elapsed{flex:0 0 auto;font-variant-numeric:tabular-nums;color:var(--yellow)}
|
|
104
105
|
.activity-run{margin-left:auto;color:var(--muted);white-space:nowrap}
|
|
105
106
|
.agent-activity{display:flex;align-items:center;gap:6px;margin-top:5px;font-size:11px;color:#c9d1de}
|