@chenacc/agentbridge 0.2.4 → 0.2.6

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.6",
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,28 @@ 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
+ // A streaming row changes on every chunk, so it must never be cached — caching it
336
+ // froze the collapsed text at the first batch while the answer kept growing.
337
+ const stable = event.kind !== "message.delta";
338
+ const cached = stable ? cachedEntry(options.cache, event) : null;
339
+ if (cached) return cached;
340
+ const content = renderMessageMarkdown(String(text ?? ""), options.math ? { math: options.math } : undefined);
341
+ const entry = {
342
+ key: entryKey(event),
343
+ className,
344
+ 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>`,
345
+ };
346
+ return stable ? rememberEntry(options.cache, event, entry) : entry;
347
+ }
348
+
310
349
  /** Turn a session's events into the list of transcript entries to display. */
311
350
  export function buildEntries(relevant, options = {}) {
312
351
  const statusLabel = options.statusLabel ?? ((kind) => DEFAULT_STATUS_LABELS[kind] ?? kind);
@@ -321,6 +360,14 @@ export function buildEntries(relevant, options = {}) {
321
360
  settledInputs.set(event.payload.requestId, event.payload.outcome ?? "answered");
322
361
  }
323
362
  }
363
+ // Only the last assistant message of a turn is the answer; everything before it is
364
+ // the work that produced it.
365
+ const finalAssistantOfTurn = new Map();
366
+ for (const event of relevant) {
367
+ if (event.kind === "message.completed" && event.payload?.role === "assistant") {
368
+ finalAssistantOfTurn.set(event.turnId, event.eventId ?? entryKey(event));
369
+ }
370
+ }
324
371
 
325
372
  const renderedDeltas = new Set();
326
373
  const entries = [];
@@ -328,7 +375,15 @@ export function buildEntries(relevant, options = {}) {
328
375
  if (event.kind === "message.delta") {
329
376
  if (completedTurns.has(event.turnId) || renderedDeltas.has(event.turnId)) continue;
330
377
  renderedDeltas.add(event.turnId);
331
- entries.push(entryFor(event, "Agent 正在回复", textByTurn.get(event.turnId) ?? "", "assistant", true, settings));
378
+ // Live text is progress, so it stays collapsed until the answer itself lands.
379
+ entries.push(collapsedEntry(
380
+ event,
381
+ "Agent 正在回复",
382
+ "正在生成回答…(点开可看实时输出)",
383
+ textByTurn.get(event.turnId) ?? "",
384
+ "assistant streaming",
385
+ settings,
386
+ ));
332
387
  } else if (event.kind === "message.completed") {
333
388
  // An assistant message that carried only tool calls has no text. Rendering it makes
334
389
  // an empty "Agent" bubble between the tool rows is pure noise, so skip it — unless it
@@ -336,7 +391,20 @@ export function buildEntries(relevant, options = {}) {
336
391
  const messageText = String(event.payload.text ?? "");
337
392
  const messageAttachments = Array.isArray(event.payload.attachments) ? event.payload.attachments : [];
338
393
  if (!messageText.trim() && messageAttachments.length === 0) continue;
339
- entries.push(entryFor(event, event.payload.role === "user" ? "你" : "Agent", messageText, event.payload.role, true, settings));
394
+ if (event.payload.role === "user") {
395
+ entries.push(entryFor(event, "你", messageText, "user", true, settings));
396
+ continue;
397
+ }
398
+ const isFinal = finalAssistantOfTurn.get(event.turnId) === (event.eventId ?? entryKey(event));
399
+ if (isFinal || messageAttachments.length > 0) {
400
+ // The answer, and anything carrying an attachment: both are content the reader
401
+ // came for, so they render in full.
402
+ entries.push(entryFor(event, "Agent", messageText, "assistant", true, settings));
403
+ } else {
404
+ const lead = messageText.trim().split(/\r?\n/).find((line) => line.trim()) ?? "";
405
+ const summary = lead.length > 48 ? `中间过程 · ${lead.slice(0, 48)}…` : `中间过程 · ${lead || "已处理"}`;
406
+ entries.push(collapsedEntry(event, "Agent", summary, messageText, "assistant step", settings));
407
+ }
340
408
  } else if (event.kind === "tool.started" || event.kind === "tool.completed") {
341
409
  const label = event.kind.endsWith("started") ? "工具开始" : "工具完成";
342
410
  const detail = event.payload.command ? String(event.payload.command) : `${event.payload.type ?? "tool"}${event.payload.status ? ` · ${event.payload.status}` : ""}`;