@modelzen/feishu-codex-bridge 0.2.0 → 0.2.1

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.
Files changed (2) hide show
  1. package/dist/cli.js +166 -10
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1961,8 +1961,8 @@ function card(elements, opts = {}) {
1961
1961
  if (opts.streaming) {
1962
1962
  config.streaming_mode = true;
1963
1963
  config.streaming_config = {
1964
- print_frequency_ms: { default: 70 },
1965
- print_step: { default: 1 },
1964
+ print_frequency_ms: { default: 25 },
1965
+ print_step: { default: 6 },
1966
1966
  print_strategy: "fast"
1967
1967
  };
1968
1968
  }
@@ -1984,6 +1984,9 @@ function card(elements, opts = {}) {
1984
1984
  function md(content) {
1985
1985
  return { tag: "markdown", content };
1986
1986
  }
1987
+ function mdStream(content, elementId) {
1988
+ return { tag: "markdown", element_id: elementId, content };
1989
+ }
1987
1990
  function image(imgKey, alt = "") {
1988
1991
  return {
1989
1992
  tag: "img",
@@ -2533,6 +2536,7 @@ function escapeInline2(s) {
2533
2536
  var RC = {
2534
2537
  stop: "run.stop"
2535
2538
  };
2539
+ var ANSWER_EID = "answer";
2536
2540
  var REASONING_MAX = 1500;
2537
2541
  var COLLAPSE_TOOL_THRESHOLD = 3;
2538
2542
  var PROCESS_BODY_BUDGET = 22e3;
@@ -2546,14 +2550,19 @@ function renderRunning(state, rc) {
2546
2550
  const elements = [];
2547
2551
  const reasoning = reasoningContent(state);
2548
2552
  if (reasoning) elements.push(reasoningPanel(reasoning, state.reasoningActive));
2549
- const blocks = rc.showTools === false ? state.blocks.filter((b) => b.kind !== "tool") : state.blocks;
2550
- for (const group of groupBlocks(blocks)) {
2551
- if (group.kind === "text") {
2552
- if (group.content.trim()) elements.push(md(group.content));
2553
- } else {
2554
- elements.push(...renderToolGroup(group.tools, false));
2553
+ const showTools = rc.showTools !== false;
2554
+ const tools = [];
2555
+ const textParts = [];
2556
+ for (const b of state.blocks) {
2557
+ if (b.kind === "tool") {
2558
+ if (showTools) tools.push(b.tool);
2559
+ } else if (b.content.trim()) {
2560
+ textParts.push(b.content);
2555
2561
  }
2556
2562
  }
2563
+ if (tools.length > 0) elements.push(...renderToolGroup(tools, false));
2564
+ const answer = textParts.join("\n\n");
2565
+ if (answer) elements.push(mdStream(answer, ANSWER_EID));
2557
2566
  if (state.footer) elements.push(footerStatus(state.footer));
2558
2567
  if (rc.cardKey) elements.push(actions([button("\u23F9 \u7EC8\u6B62", { a: RC.stop, m: rc.cardKey }, "danger")]));
2559
2568
  return elements;
@@ -2700,16 +2709,105 @@ function truncate4(s, n) {
2700
2709
  }
2701
2710
 
2702
2711
  // src/card/run-card-stream.ts
2703
- var STREAM_THROTTLE_MS = 250;
2712
+ var STREAM_THROTTLE_MS = 150;
2704
2713
  var RunCardStream = class {
2705
2714
  cardId = "";
2706
2715
  _messageId = "";
2707
2716
  seq = 0;
2708
2717
  lastPush = 0;
2709
2718
  lastContent = "";
2719
+ // Per-turn push counters — surfaced via stats() for the stream.timing log.
2720
+ pushCount = 0;
2721
+ cardPushes = 0;
2722
+ // whole-card card.update (structure)
2723
+ elPushes = 0;
2724
+ // element cardElement.content (answer typewriter)
2725
+ totalRttMs = 0;
2726
+ maxRttMs = 0;
2727
+ // Coalesced streaming. The consume loop records the latest {card, answerEid} in
2728
+ // `pending`; a single pump() drains it — NON-BLOCKING, so consuming codex events
2729
+ // never stalls on a round-trip (awaiting each push serially drained the backlog
2730
+ // at one event per ~RTT → "已回完、飞书还在慢慢打字"). The pump pushes only the most
2731
+ // recent snapshot per round-trip: when only the answer text grew it streams that
2732
+ // via cardElement.content (typewriter), otherwise it whole-card updates.
2733
+ pending = null;
2734
+ pumpChannel = null;
2735
+ pumpPromise = null;
2736
+ // Baselines for the pump's route decision (structure unchanged + answer grew?).
2737
+ lastStructureSig = "";
2738
+ lastAnswerText = "";
2710
2739
  get messageId() {
2711
2740
  return this._messageId;
2712
2741
  }
2742
+ /** Push counts (whole-card vs element) + round-trip stats for this card. */
2743
+ stats() {
2744
+ return {
2745
+ pushCount: this.pushCount,
2746
+ cardPushes: this.cardPushes,
2747
+ elPushes: this.elPushes,
2748
+ totalRttMs: this.totalRttMs,
2749
+ maxRttMs: this.maxRttMs
2750
+ };
2751
+ }
2752
+ /**
2753
+ * Record the latest card and ensure the pump is running. Returns immediately;
2754
+ * calls that arrive while a push is in flight collapse into a single push of
2755
+ * the most recent card once that round-trip completes. Use this from the
2756
+ * event-consume loop instead of awaiting {@link streamCard} per event.
2757
+ */
2758
+ streamCoalesced(channel, fullCard, answerEid) {
2759
+ this.pending = { card: fullCard, answerEid };
2760
+ this.pumpChannel = channel;
2761
+ if (!this.pumpPromise) this.pumpPromise = this.pump();
2762
+ }
2763
+ /** Await any in-flight coalesced push so the final streaming frame lands (and
2764
+ * `seq` stays ordered) before the terminal update. Call after the loop ends. */
2765
+ async drain() {
2766
+ if (this.pumpPromise) await this.pumpPromise;
2767
+ }
2768
+ async pump() {
2769
+ try {
2770
+ while (this.pending && this.pumpChannel) {
2771
+ const { card: card2, answerEid } = this.pending;
2772
+ this.pending = null;
2773
+ const t0 = Date.now();
2774
+ const answer = answerEid ? answerContent(card2, answerEid) : null;
2775
+ const sig = structureSig(card2, answerEid);
2776
+ if (answerEid && answer !== null && sig === this.lastStructureSig && answer !== this.lastAnswerText && answer.startsWith(this.lastAnswerText)) {
2777
+ await this.streamElement(this.pumpChannel, answerEid, answer);
2778
+ this.lastAnswerText = answer;
2779
+ } else {
2780
+ await this.streamCard(this.pumpChannel, card2, true);
2781
+ this.lastStructureSig = sig;
2782
+ this.lastAnswerText = answer ?? "";
2783
+ }
2784
+ const gap = STREAM_THROTTLE_MS - (Date.now() - t0);
2785
+ if (this.pending && gap > 0) await new Promise((r) => setTimeout(r, gap));
2786
+ }
2787
+ } finally {
2788
+ this.pumpPromise = null;
2789
+ }
2790
+ }
2791
+ /** Element-level streaming push (cardkit.v1.cardElement.content): the answer
2792
+ * element's accumulated full text. Feishu diffs the prefix and types the delta
2793
+ * per the card's streaming_config. Needs streaming_mode on the card. */
2794
+ async streamElement(channel, elementId, content) {
2795
+ if (!this.cardId) return;
2796
+ const t0 = Date.now();
2797
+ try {
2798
+ await channel.rawClient.cardkit.v1.cardElement.content({
2799
+ path: { card_id: this.cardId, element_id: elementId },
2800
+ data: { content, sequence: ++this.seq, uuid: `e_${this.cardId}_${this.seq}` }
2801
+ });
2802
+ const rtt = Date.now() - t0;
2803
+ this.pushCount++;
2804
+ this.elPushes++;
2805
+ this.totalRttMs += rtt;
2806
+ if (rtt > this.maxRttMs) this.maxRttMs = rtt;
2807
+ } catch (err) {
2808
+ log.fail("card", err, { phase: "run-stream-el", cardId: this.cardId, seq: this.seq });
2809
+ }
2810
+ }
2713
2811
  /** Create the entity from the initial (running) card and send a message
2714
2812
  * referencing it by card_id. Returns the carrier message id. */
2715
2813
  async create(channel, chatId, initialCard, opts) {
@@ -2751,11 +2849,17 @@ var RunCardStream = class {
2751
2849
  if (!force && now - this.lastPush < STREAM_THROTTLE_MS) return;
2752
2850
  this.lastPush = now;
2753
2851
  this.lastContent = data;
2852
+ const t0 = Date.now();
2754
2853
  try {
2755
2854
  await channel.rawClient.cardkit.v1.card.update({
2756
2855
  path: { card_id: this.cardId },
2757
2856
  data: { card: { type: "card_json", data }, sequence: ++this.seq, uuid: `s_${this.cardId}_${this.seq}` }
2758
2857
  });
2858
+ const rtt = Date.now() - t0;
2859
+ this.pushCount++;
2860
+ this.cardPushes++;
2861
+ this.totalRttMs += rtt;
2862
+ if (rtt > this.maxRttMs) this.maxRttMs = rtt;
2759
2863
  } catch (err) {
2760
2864
  log.fail("card", err, { phase: "run-stream", cardId: this.cardId, seq: this.seq });
2761
2865
  }
@@ -2785,6 +2889,21 @@ var RunCardStream = class {
2785
2889
  }
2786
2890
  }
2787
2891
  };
2892
+ function answerContent(card2, eid) {
2893
+ const els = card2.body?.elements;
2894
+ if (!Array.isArray(els)) return null;
2895
+ for (const el of els) {
2896
+ if (el && el.element_id === eid) return typeof el.content === "string" ? el.content : "";
2897
+ }
2898
+ return null;
2899
+ }
2900
+ function structureSig(card2, eid) {
2901
+ const body = card2.body;
2902
+ const els = body?.elements;
2903
+ if (!eid || !Array.isArray(els)) return JSON.stringify(card2);
2904
+ const blanked = els.map((el) => el && el.element_id === eid ? { ...el, content: "" } : el);
2905
+ return JSON.stringify({ ...card2, body: { ...body, elements: blanked } });
2906
+ }
2788
2907
 
2789
2908
  // src/card/outbound-images.ts
2790
2909
  import { readFile as readFile5, stat as stat2 } from "fs/promises";
@@ -5104,11 +5223,29 @@ ${tail}` }, { replyTo: evt.messageId }).catch(() => void 0);
5104
5223
  },
5105
5224
  stopSignal
5106
5225
  );
5226
+ const tStart = Date.now();
5227
+ let firstEvAt = 0;
5228
+ let firstTextAt = 0;
5229
+ let lastEvAt = tStart;
5230
+ let evCount = 0;
5231
+ let textChars = 0;
5107
5232
  for await (const ev of guarded) {
5233
+ const tEv = Date.now();
5234
+ if (!firstEvAt) firstEvAt = tEv;
5235
+ const et = ev.type;
5236
+ if (et === "text_delta") {
5237
+ if (!firstTextAt) firstTextAt = tEv;
5238
+ const d = ev.delta;
5239
+ if (typeof d === "string") textChars += d.length;
5240
+ }
5241
+ lastEvAt = tEv;
5242
+ evCount++;
5108
5243
  render.apply(ev);
5109
5244
  rc.rs = render.snapshot();
5110
- await stream2.streamCard(channel, buildRunCard(rc));
5245
+ stream2.streamCoalesced(channel, buildRunCard(rc), ANSWER_EID);
5111
5246
  }
5247
+ const doneAt = Date.now();
5248
+ await stream2.drain();
5112
5249
  state.interrupt = void 0;
5113
5250
  const killed = interrupted || timedOut;
5114
5251
  if (timedOut) render.timeout(Math.max(1, Math.round(idleMs / 6e4)));
@@ -5129,6 +5266,25 @@ ${tail}` }, { replyTo: evt.messageId }).catch(() => void 0);
5129
5266
  rc.images = await uploadOutboundImages(channel, imgSources, opts.cwd ?? fallbackCwd);
5130
5267
  }
5131
5268
  await stream2.updateCard(channel, buildRunCard(rc));
5269
+ {
5270
+ const terminalAt = Date.now();
5271
+ const st = stream2.stats();
5272
+ log.info("stream", "timing", {
5273
+ firstEv: firstEvAt ? firstEvAt - tStart : -1,
5274
+ firstText: firstTextAt ? firstTextAt - tStart : -1,
5275
+ lastEv: lastEvAt - tStart,
5276
+ done: doneAt - tStart,
5277
+ terminal: terminalAt - tStart,
5278
+ doneToTerminal: terminalAt - doneAt,
5279
+ events: evCount,
5280
+ textChars,
5281
+ pushes: st.pushCount,
5282
+ cardPushes: st.cardPushes,
5283
+ elPushes: st.elPushes,
5284
+ rttAvg: st.pushCount ? Math.round(st.totalRttMs / st.pushCount) : 0,
5285
+ rttMax: st.maxRttMs
5286
+ });
5287
+ }
5132
5288
  runsByCard.delete(cardMsgId);
5133
5289
  promoteCard(finalMsgId, rc);
5134
5290
  for (const fence of fences) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modelzen/feishu-codex-bridge",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Bridge Feishu/Lark messenger with local Codex via app-server (project=group, thread=session)",
5
5
  "type": "module",
6
6
  "bin": {