@chenacc/agentbridge 0.2.4 → 0.2.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chenacc/agentbridge",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "Control local coding agents from a phone through a secure WeChat QR web console.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/public/styles.css CHANGED
@@ -412,6 +412,28 @@ select { appearance: none; background-image: linear-gradient(45deg, transparent
412
412
  .event-entry.tool .event-body { color: var(--ink-2); font-size: 13px; } /* 底板是浅色玻璃,次级文字不能再用 45% 黑 */
413
413
  .event-entry.error .event-body { color: var(--err); }
414
414
 
415
+ /* 中间过程只占一行:说清在用什么工具做什么,细节折进去。手机上一屏能看更多对话。 */
416
+ .tool-line { margin: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
417
+ .event-entry.tool-compact .event-body,
418
+ .event-entry.step .event-body { padding-top: 1px; }
419
+ .step-detail, .tool-output { margin-top: 4px; }
420
+ .step-detail > summary,
421
+ .tool-output > summary {
422
+ cursor: pointer;
423
+ font-size: 12px;
424
+ color: var(--ink-3, var(--ink-2));
425
+ opacity: .82;
426
+ list-style: none;
427
+ }
428
+ .step-detail > summary::-webkit-details-marker,
429
+ .tool-output > summary::-webkit-details-marker { display: none; }
430
+ .step-detail > summary::before,
431
+ .tool-output > summary::before { content: "▸ "; }
432
+ .step-detail[open] > summary::before,
433
+ .tool-output[open] > summary::before { content: "▾ "; }
434
+ .step-detail .message-markdown,
435
+ .tool-output pre { margin-top: 6px; opacity: .9; font-size: 12px; }
436
+
415
437
  /* ---------- 无底板可读性:给字形描边,而不是给文字垫一层遮罩 ---------- */
416
438
  /* 文字层底板保持透明(用户要求),可读性全部来自整页遮罩的地板——**不再给字形描边**:
417
439
  之前那圈 1px + 2px 的白色 text-shadow 会把每个字的边缘糊掉,这正是"文字很模糊"的来源。
@@ -221,26 +221,43 @@ function entryFor(event, label, text, className, supportsMarkdown, { formatMessa
221
221
  }
222
222
 
223
223
  /**
224
- * Tool calls get the two affordances a person actually needs: the command is
225
- * copyable, and a long output collapses instead of pushing the conversation off the
226
- * screen. Both work without JavaScript state — `<details>` is the disclosure.
224
+ * Tool calls get one line that says what is being done, and the raw material
225
+ * behind a disclosure.
226
+ *
227
+ * The phone is read at a glance, on a narrow screen, often while walking: the
228
+ * command and its output are the least useful thing on it. So the row is
229
+ * "调用 Bash · 检查端口占用" and everything else waits behind 详情 — still one tap
230
+ * away for the moment it matters, never in the way.
227
231
  */
232
+ function toolSummary(event) {
233
+ const payload = event.payload ?? {};
234
+ const name = String(payload.type ?? payload.toolName ?? payload.name ?? "工具");
235
+ // "What is it doing" is the title when the Agent gave us one. A raw command is not a
236
+ // summary — the whole point of this row is to keep it out of the reader's way — so the
237
+ // command contributes at most its program name.
238
+ const stated = String(payload.title ?? payload.description ?? "").trim();
239
+ const command = String(payload.command ?? "").trim();
240
+ const program = command ? command.split(/\s+/)[0] : "";
241
+ const purpose = stated
242
+ ? (stated.length > 60 ? `${stated.slice(0, 60)}…` : stated)
243
+ : (program ? `${program} …` : "");
244
+ const suffix = payload.status ? ` · ${payload.status}` : "";
245
+ return { name, purpose, suffix };
246
+ }
247
+
228
248
  function toolEntry(event, label, text, options) {
229
249
  const cached = cachedEntry(options.cache, event);
230
250
  if (cached) return cached;
231
- const body = String(text ?? "");
232
- const long = body.length > 240;
233
- const command = typeof event.payload?.command === "string" ? event.payload.command : "";
234
- const copy = command
235
- ? `<button class="tool-copy" type="button" data-copy-tool="${escapeHtml(body)}">复制命令</button>`
251
+ const body = String(text ?? "").trim();
252
+ const { name, purpose, suffix } = toolSummary(event);
253
+ const headline = purpose ? `调用 ${name} · ${purpose}${suffix}` : `调用 ${name}${suffix}`;
254
+ const details = body
255
+ ? `<details class="tool-output"><summary>详情</summary><pre>${escapeHtml(body)}</pre></details>`
236
256
  : "";
237
- const shown = long
238
- ? `<details class="tool-output"><summary>展开完整输出(${body.length} 字符)</summary><pre>${escapeHtml(body)}</pre></details>`
239
- : `<div class="message-markdown"><p>${escapeHtml(body)}</p></div>`;
240
257
  const entry = {
241
258
  key: entryKey(event),
242
- className: "tool",
243
- html: `<time class="event-time">${formatTime(event.time, options.formatMessageTime)}</time><div class="event-body"><strong>${escapeHtml(label)}</strong>${copy}${shown}</div>`,
259
+ className: "tool tool-compact",
260
+ html: `<time class="event-time">${formatTime(event.time, options.formatMessageTime)}</time><div class="event-body"><strong>${escapeHtml(label)}</strong><p class="tool-line">${escapeHtml(headline)}</p>${details}</div>`,
244
261
  };
245
262
  return rememberEntry(options.cache, event, entry);
246
263
  }
@@ -307,6 +324,25 @@ function inputEntry(event, options, settledOutcome) {
307
324
  return rememberEntry(options.cache, event, entry, cacheKey);
308
325
  }
309
326
 
327
+ /**
328
+ * A row that stays out of the way until asked: the summary line is what the
329
+ * reader needs, the text is one tap down.
330
+ *
331
+ * Intermediate assistant messages are progress, not answers. Rendering them in
332
+ * full turned one request into a wall of overlapping drafts on a phone screen.
333
+ */
334
+ function collapsedEntry(event, label, summary, text, className, options) {
335
+ const cached = cachedEntry(options.cache, event);
336
+ if (cached) return cached;
337
+ const content = renderMessageMarkdown(String(text ?? ""), options.math ? { math: options.math } : undefined);
338
+ const entry = {
339
+ key: entryKey(event),
340
+ className,
341
+ html: `<time class="event-time">${formatTime(event.time, options.formatMessageTime)}</time><div class="event-body"><strong>${escapeHtml(label)}</strong><details class="step-detail"><summary>${escapeHtml(summary)}</summary><div class="message-markdown">${content}</div></details></div>`,
342
+ };
343
+ return rememberEntry(options.cache, event, entry);
344
+ }
345
+
310
346
  /** Turn a session's events into the list of transcript entries to display. */
311
347
  export function buildEntries(relevant, options = {}) {
312
348
  const statusLabel = options.statusLabel ?? ((kind) => DEFAULT_STATUS_LABELS[kind] ?? kind);
@@ -321,6 +357,14 @@ export function buildEntries(relevant, options = {}) {
321
357
  settledInputs.set(event.payload.requestId, event.payload.outcome ?? "answered");
322
358
  }
323
359
  }
360
+ // Only the last assistant message of a turn is the answer; everything before it is
361
+ // the work that produced it.
362
+ const finalAssistantOfTurn = new Map();
363
+ for (const event of relevant) {
364
+ if (event.kind === "message.completed" && event.payload?.role === "assistant") {
365
+ finalAssistantOfTurn.set(event.turnId, event.eventId ?? entryKey(event));
366
+ }
367
+ }
324
368
 
325
369
  const renderedDeltas = new Set();
326
370
  const entries = [];
@@ -328,7 +372,15 @@ export function buildEntries(relevant, options = {}) {
328
372
  if (event.kind === "message.delta") {
329
373
  if (completedTurns.has(event.turnId) || renderedDeltas.has(event.turnId)) continue;
330
374
  renderedDeltas.add(event.turnId);
331
- entries.push(entryFor(event, "Agent 正在回复", textByTurn.get(event.turnId) ?? "", "assistant", true, settings));
375
+ // Live text is progress, so it stays collapsed until the answer itself lands.
376
+ entries.push(collapsedEntry(
377
+ event,
378
+ "Agent 正在回复",
379
+ "正在生成回答…(点开可看实时输出)",
380
+ textByTurn.get(event.turnId) ?? "",
381
+ "assistant streaming",
382
+ settings,
383
+ ));
332
384
  } else if (event.kind === "message.completed") {
333
385
  // An assistant message that carried only tool calls has no text. Rendering it makes
334
386
  // an empty "Agent" bubble between the tool rows is pure noise, so skip it — unless it
@@ -336,7 +388,20 @@ export function buildEntries(relevant, options = {}) {
336
388
  const messageText = String(event.payload.text ?? "");
337
389
  const messageAttachments = Array.isArray(event.payload.attachments) ? event.payload.attachments : [];
338
390
  if (!messageText.trim() && messageAttachments.length === 0) continue;
339
- entries.push(entryFor(event, event.payload.role === "user" ? "你" : "Agent", messageText, event.payload.role, true, settings));
391
+ if (event.payload.role === "user") {
392
+ entries.push(entryFor(event, "你", messageText, "user", true, settings));
393
+ continue;
394
+ }
395
+ const isFinal = finalAssistantOfTurn.get(event.turnId) === (event.eventId ?? entryKey(event));
396
+ if (isFinal || messageAttachments.length > 0) {
397
+ // The answer, and anything carrying an attachment: both are content the reader
398
+ // came for, so they render in full.
399
+ entries.push(entryFor(event, "Agent", messageText, "assistant", true, settings));
400
+ } else {
401
+ const lead = messageText.trim().split(/\r?\n/).find((line) => line.trim()) ?? "";
402
+ const summary = lead.length > 48 ? `中间过程 · ${lead.slice(0, 48)}…` : `中间过程 · ${lead || "已处理"}`;
403
+ entries.push(collapsedEntry(event, "Agent", summary, messageText, "assistant step", settings));
404
+ }
340
405
  } else if (event.kind === "tool.started" || event.kind === "tool.completed") {
341
406
  const label = event.kind.endsWith("started") ? "工具开始" : "工具完成";
342
407
  const detail = event.payload.command ? String(event.payload.command) : `${event.payload.type ?? "tool"}${event.payload.status ? ` · ${event.payload.status}` : ""}`;