lark-remote 0.5.6 → 0.5.8
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/CHANGELOG.md +46 -6
- package/README.en.md +2 -2
- package/README.md +2 -2
- package/dist/bridge/index.d.ts +63 -1
- package/dist/bridge/index.js +147 -14
- package/dist/bridge/queue-manager.js +41 -58
- package/dist/card/approval-render.js +2 -1
- package/dist/card/bash-card-session.d.ts +0 -1
- package/dist/card/bash-card-session.js +0 -8
- package/dist/card/bash-renderer.js +2 -1
- package/dist/card/card-budget.d.ts +37 -0
- package/dist/card/card-budget.js +143 -24
- package/dist/card/card-session.d.ts +3 -13
- package/dist/card/card-session.js +4 -18
- package/dist/card/card-shared.d.ts +11 -0
- package/dist/card/card-shared.js +19 -13
- package/dist/card/run-card-session.d.ts +0 -1
- package/dist/card/run-card-session.js +0 -6
- package/dist/card/run-renderer.d.ts +18 -0
- package/dist/card/run-renderer.js +330 -62
- package/dist/card/run-state.d.ts +26 -0
- package/dist/card/run-state.js +45 -0
- package/dist/card/text-truncate.d.ts +3 -5
- package/dist/card/text-truncate.js +3 -5
- package/dist/card/time.d.ts +14 -0
- package/dist/card/time.js +30 -0
- package/dist/card/tool-render.d.ts +15 -2
- package/dist/card/tool-render.js +42 -82
- package/dist/common/text-width.d.ts +24 -0
- package/dist/common/text-width.js +93 -0
- package/dist/common/tool-summary.d.ts +19 -0
- package/dist/common/tool-summary.js +171 -0
- package/dist/common/turn-failure.d.ts +13 -0
- package/dist/common/turn-failure.js +18 -0
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.js +1 -1
- package/dist/connector/index.js +1 -1
- package/dist/index.js +66 -13
- package/dist/order/index.d.ts +2 -0
- package/dist/order/index.js +2 -0
- package/dist/platform/command.js +1 -1
- package/dist/platform/shell.d.ts +2 -2
- package/dist/platform/spawn.d.ts +1 -1
- package/dist/platform/spawn.js +2 -2
- package/dist/platform/types.d.ts +1 -1
- package/dist/router/card-helpers.d.ts +18 -16
- package/dist/router/card-helpers.js +88 -46
- package/dist/router/config/codex.js +2 -3
- package/dist/router/config/common/model-patch.d.ts +8 -0
- package/dist/router/config/common/model-patch.js +13 -0
- package/dist/router/config/common/render.js +30 -77
- package/dist/router/config/dsh.js +2 -1
- package/dist/router/config/kimi.js +3 -3
- package/dist/router/index.d.ts +9 -11
- package/dist/router/index.js +165 -356
- package/dist/router/order-exec-dispatch.js +2 -1
- package/dist/runner/claude/session.d.ts +1 -1
- package/dist/runner/claude/session.js +3 -3
- package/dist/runner/common/defaults.d.ts +2 -0
- package/dist/runner/common/defaults.js +3 -0
- package/dist/runner/common/jsonrpc/connection-manager.js +1 -1
- package/dist/runner/common/jsonrpc/transport.d.ts +1 -1
- package/dist/runner/common/jsonrpc/transport.js +3 -3
- package/dist/runner/common/spawning-runner.d.ts +1 -1
- package/dist/runner/common/spawning-runner.js +2 -2
- package/dist/runner/dsh/auth.js +2 -1
- package/dist/runner/dsh/client.d.ts +100 -3
- package/dist/runner/dsh/client.js +290 -6
- package/dist/runner/dsh/exit-cancel.d.ts +32 -0
- package/dist/runner/dsh/exit-cancel.js +72 -0
- package/dist/runner/dsh/inflight.d.ts +62 -0
- package/dist/runner/dsh/inflight.js +140 -0
- package/dist/runner/dsh/runner.d.ts +90 -4
- package/dist/runner/dsh/runner.js +436 -18
- package/dist/runner/dsh/translator.d.ts +1 -1
- package/dist/runner/dsh/translator.js +103 -1
- package/dist/runner/dsh/types.d.ts +60 -0
- package/dist/runner/types.d.ts +95 -2
- package/dist/runner/types.js +9 -0
- package/dist/session/claude/session-index.d.ts +0 -4
- package/dist/session/claude/session-index.js +0 -8
- package/dist/session/claude/sessions.js +2 -2
- package/dist/session/dsh/sessions.js +157 -17
- package/dist/update/install.js +1 -1
- package/package.json +1 -1
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
import { terminalToColor, terminalToLabel, newSessionButton, stopButton, compactButton, agentDisplayName, } from './card-shared.js';
|
|
1
|
+
import { terminalToColor, terminalToLabel, newSessionButton, stopButton, compactButton, cardButton, agentDisplayName, } from './card-shared.js';
|
|
2
2
|
import { collapsibleMarkdownPanel, markdownDiv } from './collapsible.js';
|
|
3
3
|
import { toolBodyMd, toolHeaderText } from './tool-render.js';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { truncateMarkdownTables, CARD_BUDGET_BYTES } from './text-truncate.js';
|
|
5
|
+
import { truncateUtf8 } from '../common/truncate.js';
|
|
6
|
+
import { countCardElements, MAX_CARD_ELEMENTS } from './card-budget.js';
|
|
7
|
+
import { displayWidth } from '../common/text-width.js';
|
|
8
|
+
import { isContentFilterFailure } from '../common/turn-failure.js';
|
|
9
|
+
import { formatClock, formatTimestamp } from './time.js';
|
|
6
10
|
import { formatUsageStats } from '../router/usage-format.js';
|
|
7
11
|
import { renderApprovalArea } from './approval-render.js';
|
|
8
12
|
const REASONING_BYTES = 4_500;
|
|
@@ -228,25 +232,97 @@ function groupBlocks(blocks) {
|
|
|
228
232
|
return groups;
|
|
229
233
|
}
|
|
230
234
|
/**
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
235
|
+
* 工具块 = **原生折叠面板**(与 thinking/plan 同一交互)。
|
|
236
|
+
*
|
|
237
|
+
* 早先试过"一行 + 服务端「详情」按钮":折叠不省字节所以正文不入 payload,展开
|
|
238
|
+
* 只能回服务端重出卡。代价是手机上要多点一次、按钮还占掉标题宽度(长 URL 被挤
|
|
239
|
+
* 成两行,2026-10-19 反馈)。现在改成折叠面板 + **按工具数量缩放的正文截断**:
|
|
240
|
+
* 正文始终随卡下发(面板折叠时不显示),点标题即展开,无往返、无按钮。
|
|
241
|
+
*
|
|
242
|
+
* 展开态:运行中/失败的默认展开(用户在等结果、错误要立刻可见),其余折叠。
|
|
234
243
|
*/
|
|
235
|
-
function renderTool(tool,
|
|
236
|
-
const
|
|
237
|
-
const body = toolBodyMd(tool);
|
|
244
|
+
function renderTool(tool, outputMax) {
|
|
245
|
+
const body = toolBodyMd(tool, { outputMax });
|
|
238
246
|
return collapsibleMarkdownPanel({
|
|
239
247
|
title: toolTitle(tool),
|
|
240
|
-
expanded:
|
|
241
|
-
border,
|
|
248
|
+
expanded: tool.status === 'running' || tool.status === 'error',
|
|
249
|
+
border: tool.status === 'error' ? 'red' : 'grey',
|
|
242
250
|
content: body || '_无输出_',
|
|
243
251
|
textSize: 'notation',
|
|
244
252
|
});
|
|
245
253
|
}
|
|
254
|
+
/**
|
|
255
|
+
* 每个工具的正文输出预算:工具越多,单个工具留的字符越少。
|
|
256
|
+
*
|
|
257
|
+
* 24 个工具(`MAX_BLOCKS`,也是上限)× 1200 字符的正文会把卡片顶到 28KB 以上
|
|
258
|
+
* (实测 13 个 1200 字符的面板就开始走 skeleton 降级)。这里按总量反推:正文
|
|
259
|
+
* 合计约 8KB,单个工具 160–1200 字符——够看清前几行输出,又不会让整卡超预算。
|
|
260
|
+
*/
|
|
261
|
+
function toolOutputBudget(state) {
|
|
262
|
+
let count = 0;
|
|
263
|
+
for (const block of state.blocks)
|
|
264
|
+
if (block.kind === 'tool')
|
|
265
|
+
count += 1;
|
|
266
|
+
return Math.max(160, Math.min(1200, Math.floor(8000 / Math.max(1, count))));
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* 工具面板标题:`✅ **bash** — npm test (21:23)`。
|
|
270
|
+
*
|
|
271
|
+
* 标题连同时间戳一起收进 `HEADER_DISPLAY_MAX`(见 tool-render)——手机上折叠
|
|
272
|
+
* 面板的标题只有一行可用,超了就会断在 URL/命令中间。时间戳用 `HH:mm` 而不是
|
|
273
|
+
* 完整日期:同一张卡里的时间戳只需要区分先后。
|
|
274
|
+
*/
|
|
246
275
|
function toolTitle(tool) {
|
|
247
|
-
const
|
|
248
|
-
const title = toolHeaderText(tool);
|
|
249
|
-
return
|
|
276
|
+
const clock = formatClock(tool.completedAt ?? tool.startedAt);
|
|
277
|
+
const title = toolHeaderText(tool, clock ? ` (${clock})` : '');
|
|
278
|
+
return title;
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* 内容组的元素成本(口径 = 递归 `elements` 数组成员,与飞书实测判据一致)。
|
|
282
|
+
*
|
|
283
|
+
* 工具是折叠面板:面板 1 + 内部 div 1 = 2 个元素,与展开态无关(折叠跟展开的
|
|
284
|
+
* payload 一样,这正是"折叠不省字节"的含义——所以靠截断控制大小,而不是靠
|
|
285
|
+
* 不发送正文)。
|
|
286
|
+
*/
|
|
287
|
+
const TOOL_PANEL_ELEMENTS = 2;
|
|
288
|
+
const PANEL_ELEMENTS = 2;
|
|
289
|
+
const TEXT_BLOCK_ELEMENTS = 1;
|
|
290
|
+
/**
|
|
291
|
+
* 内容区(blocks)可用的元素预算 = 总预算 − 固定开销预留。
|
|
292
|
+
*
|
|
293
|
+
* 固定开销:状态行 1 + summary 1 + 操作行 4 + 省略提示/通知若干,实测约 10;
|
|
294
|
+
* 预留 20 留出通知与提示的波动空间。
|
|
295
|
+
*/
|
|
296
|
+
const CONTENT_ELEMENT_BUDGET = MAX_CARD_ELEMENTS - 20;
|
|
297
|
+
/** 单个内容组的元素成本(保守:宁多不少)。 */
|
|
298
|
+
function groupElementCost(group) {
|
|
299
|
+
if (group.kind === 'tool')
|
|
300
|
+
return TOOL_PANEL_ELEMENTS;
|
|
301
|
+
if (group.kind === 'text')
|
|
302
|
+
return TEXT_BLOCK_ELEMENTS;
|
|
303
|
+
return PANEL_ELEMENTS;
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* 元素预算内从最新往回选择可见的内容组。
|
|
307
|
+
*
|
|
308
|
+
* 为什么在正常路径里先裁剪而不是整卡降级:降级档(degraded)只保留最后 3 个
|
|
309
|
+
* 工具 + 2 个思考块,对一次跑了几十步的 run 是灾难性的信息损失;元素超限的
|
|
310
|
+
* 成因只是"元素多",砍掉最旧的几步就能同时保住元素预算和最新内容的完整渲染。
|
|
311
|
+
* 被省略的数量由调用方渲染成 `_已省略 N 个早期步骤_`(与 omittedBlocks 同款提示)。
|
|
312
|
+
*/
|
|
313
|
+
function selectVisibleGroups(groups) {
|
|
314
|
+
if (groups.length === 0)
|
|
315
|
+
return { visible: groups, omitted: 0 };
|
|
316
|
+
let used = 0;
|
|
317
|
+
let start = groups.length;
|
|
318
|
+
for (let i = groups.length - 1; i >= 0; i--) {
|
|
319
|
+
const cost = groupElementCost(groups[i]);
|
|
320
|
+
if (start < groups.length && used + cost > CONTENT_ELEMENT_BUDGET)
|
|
321
|
+
break;
|
|
322
|
+
used += cost;
|
|
323
|
+
start = i;
|
|
324
|
+
}
|
|
325
|
+
return { visible: groups.slice(start), omitted: start };
|
|
250
326
|
}
|
|
251
327
|
const DEGRADED_TIER = {
|
|
252
328
|
thinkingKeep: DEGRADED_THINKING_KEEP,
|
|
@@ -331,6 +407,7 @@ function assembleRunCard(state, options, elements) {
|
|
|
331
407
|
*/
|
|
332
408
|
function buildFallbackElements(state, options, tier) {
|
|
333
409
|
const elements = [];
|
|
410
|
+
const outputMax = toolOutputBudget(state);
|
|
334
411
|
elements.push(statusRow(state));
|
|
335
412
|
elements.push(...omittedBlocksHint(state));
|
|
336
413
|
// 单遍分桶:thinking / tool(保持原相对顺序)
|
|
@@ -403,7 +480,7 @@ function buildFallbackElements(state, options, tier) {
|
|
|
403
480
|
}
|
|
404
481
|
else if (group.kind === 'tool') {
|
|
405
482
|
// Show tool individually (each tool is independent now)
|
|
406
|
-
elements.push(renderTool(group.tool,
|
|
483
|
+
elements.push(renderTool(group.tool, outputMax));
|
|
407
484
|
}
|
|
408
485
|
}
|
|
409
486
|
if (elements.length === 0 && tier === EXTREME_TIER) {
|
|
@@ -429,25 +506,91 @@ function buildDegradedElements(state, options) {
|
|
|
429
506
|
function buildExtremeFallbackElements(state, options) {
|
|
430
507
|
return buildFallbackElements(state, options, EXTREME_TIER);
|
|
431
508
|
}
|
|
432
|
-
/**
|
|
509
|
+
/**
|
|
510
|
+
* 状态行内容:**一行、一个 div、窄屏不换行**。
|
|
511
|
+
*
|
|
512
|
+
* 设计约束(2026-10-19 手机端实测):旧版把「状态标签」和「耗时/指标」放进
|
|
513
|
+
* `column_set` 的 auto + grow 两列,窄屏上 auto 列被挤到只剩两个字宽,
|
|
514
|
+
* "运行中" 被拆成 "运行 / 中"。列式布局的可用宽度取决于对端渲染,服务端无法
|
|
515
|
+
* 保证;所以这里改成单 div,段间用 " · " 分隔——换行只会发生在分隔符处,
|
|
516
|
+
* 而且整行控制在 ~26 个半角宽度内,320px 宽的手机也能放下。
|
|
517
|
+
*
|
|
518
|
+
* 状态标签(运行中/已完成…)不再重复:header 已经写了(`🔧 dsh · 调用工具`
|
|
519
|
+
* / `✅ dsh · 已完成`),终态 summary 还有一次。
|
|
520
|
+
*
|
|
521
|
+
* 终态也保留状态行:耗时定格,DSH 的步数/上下文投影展示最后一次快照
|
|
522
|
+
* (2026-10-21 用户要求)。summary 的最终 usage 与状态行并存。
|
|
523
|
+
*
|
|
524
|
+
* 导出供窄屏守卫测试断言分段宽度(与 `estimateCardBytes` 同一惯例)。
|
|
525
|
+
*/
|
|
526
|
+
export function statusLine(state) {
|
|
527
|
+
const parts = [];
|
|
528
|
+
// 等待人工决策是「现在卡在哪儿」的唯一线索,值得在状态行重复一次 header
|
|
529
|
+
// (窄屏上用户往往只扫这一行)。其余活动词(思考中/输出中)不重复,header 有。
|
|
530
|
+
if (hasPendingApproval(state) && state.terminal === 'running')
|
|
531
|
+
parts.push('✋ 等待审批');
|
|
532
|
+
const elapsed = elapsedLabel(state);
|
|
533
|
+
if (elapsed)
|
|
534
|
+
parts.push(`⏱ ${elapsed}`);
|
|
535
|
+
// 运行期投影在终态也保留(2026-10-21 用户要求):DSH 卡片完成时,耗时/步数/
|
|
536
|
+
// 上下文百分比是这一跑留下的最后快照,撤掉后用户只能从 summary 的累计口径
|
|
537
|
+
// 重新推算。summary 仍展示最终 usage,两者口径不同、并存不冲突。
|
|
538
|
+
const live = state.liveStatus;
|
|
539
|
+
if (live) {
|
|
540
|
+
if (live.steps !== undefined)
|
|
541
|
+
parts.push(`🧰 ${live.steps} 步`);
|
|
542
|
+
if (live.contextUsed !== undefined &&
|
|
543
|
+
live.contextLimit !== undefined &&
|
|
544
|
+
live.contextLimit > 0) {
|
|
545
|
+
// 官方口径:projectedTokens ?? pressureTokens,取整封顶 100;`~` 表示投影值。
|
|
546
|
+
const pct = Math.min(100, Math.round((live.contextUsed / live.contextLimit) * 100));
|
|
547
|
+
parts.push(`📊 ~${pct}%`);
|
|
548
|
+
}
|
|
549
|
+
else if (live.contextUsed !== undefined) {
|
|
550
|
+
parts.push(`📊 ${formatTokens(live.contextUsed)}`);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
if (parts.length === 0) {
|
|
554
|
+
// 没有任何可用指标时退回状态词(骨架卡/测试构造的状态没有时间戳)。
|
|
555
|
+
return statusTagLabel(state.terminal);
|
|
556
|
+
}
|
|
557
|
+
return parts.join(' · ');
|
|
558
|
+
}
|
|
559
|
+
/** 耗时文案:`12s` / `2m13s` / `1h04m` / `2d3h`。空 = 无时间戳。 */
|
|
560
|
+
function elapsedLabel(state) {
|
|
561
|
+
if (state.startedAt === undefined)
|
|
562
|
+
return '';
|
|
563
|
+
const end = state.finishedAt ?? Date.now();
|
|
564
|
+
const ms = end - state.startedAt;
|
|
565
|
+
if (!Number.isFinite(ms) || ms < 0)
|
|
566
|
+
return '';
|
|
567
|
+
const totalSeconds = Math.floor(ms / 1000);
|
|
568
|
+
if (totalSeconds < 60)
|
|
569
|
+
return `${totalSeconds}s`;
|
|
570
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
571
|
+
if (minutes < 60)
|
|
572
|
+
return `${minutes}m${String(totalSeconds % 60).padStart(2, '0')}s`;
|
|
573
|
+
const hours = Math.floor(minutes / 60);
|
|
574
|
+
if (hours < 24)
|
|
575
|
+
return `${hours}h${String(minutes % 60).padStart(2, '0')}m`;
|
|
576
|
+
return `${Math.floor(hours / 24)}d${String(hours % 24).padStart(2, '0')}h`;
|
|
577
|
+
}
|
|
578
|
+
/** token 数量的紧凑写法:`950` / `12K` / `1.2M`。 */
|
|
579
|
+
function formatTokens(tokens) {
|
|
580
|
+
if (tokens < 1000)
|
|
581
|
+
return String(tokens);
|
|
582
|
+
if (tokens < 1_000_000)
|
|
583
|
+
return `${Math.round(tokens / 1000)}K`;
|
|
584
|
+
return `${(tokens / 1_000_000).toFixed(1)}M`;
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* 状态行元素(full/degraded/extreme/skeleton 共用)。
|
|
588
|
+
*
|
|
589
|
+
* 单 div + `notation` 字号:列式布局在窄屏会把短标签挤成竖排(手机端实测),
|
|
590
|
+
* 单行文本的换行点只在 " · " 处,可预期。
|
|
591
|
+
*/
|
|
433
592
|
function statusRow(state) {
|
|
434
|
-
|
|
435
|
-
const durationInfo = buildDurationInfo(state);
|
|
436
|
-
return {
|
|
437
|
-
tag: 'column_set',
|
|
438
|
-
columns: [
|
|
439
|
-
{
|
|
440
|
-
tag: 'column',
|
|
441
|
-
width: 'auto',
|
|
442
|
-
elements: [{ tag: 'div', text: { content: statusLabel, tag: 'lark_md' } }],
|
|
443
|
-
},
|
|
444
|
-
{
|
|
445
|
-
tag: 'column',
|
|
446
|
-
width: 'grow',
|
|
447
|
-
elements: [{ tag: 'div', text: { content: durationInfo, tag: 'lark_md' } }],
|
|
448
|
-
},
|
|
449
|
-
],
|
|
450
|
-
};
|
|
593
|
+
return markdownDiv(statusLine(state), 'notation');
|
|
451
594
|
}
|
|
452
595
|
/**
|
|
453
596
|
* Minimal skeleton card — the final safety net when even the extreme fallback
|
|
@@ -551,11 +694,14 @@ function describePanelGroup(group, prepared) {
|
|
|
551
694
|
}
|
|
552
695
|
/** 导出供测试断言估算精度(估算 ≈ 实际渲染字节,见 tests/anchor/run-card/run-card.test.ts)。 */
|
|
553
696
|
export function estimateCardBytes(state, options = {}, shared) {
|
|
554
|
-
const finalized = state.terminal !== 'running';
|
|
555
697
|
// renderRunCard 传入共享 groups/prepared 时直接复用(截断只做一次);
|
|
556
698
|
// 独立调用(测试/预算探针)时自建,行为与原先一致。
|
|
557
699
|
const groups = shared?.groups ?? groupBlocks(state.blocks);
|
|
558
700
|
const prepared = shared?.prepared ?? prepareGroupContent(groups);
|
|
701
|
+
// 与 buildChronologicalContent 同源:估算必须测量"将要渲染的内容",而正常
|
|
702
|
+
// 路径会按节点预算省略最旧的组(不模拟这一步会系统性高估)。
|
|
703
|
+
const { visible, omitted } = selectVisibleGroups(groups);
|
|
704
|
+
const outputMax = toolOutputBudget(state);
|
|
559
705
|
// 卡片外壳直接实测 assembleRunCard 空元素产物(schema/config/header/body 结构
|
|
560
706
|
// 与正式渲染完全一致);status/summary/操作行同样实测渲染结构。
|
|
561
707
|
// 注:审批区(approvalArea)不参与估算——待审批通常在途且体积小,超预算由
|
|
@@ -566,11 +712,15 @@ export function estimateCardBytes(state, options = {}, shared) {
|
|
|
566
712
|
measureJson(actionRow(state, options));
|
|
567
713
|
// 块内容:与 buildChronologicalContent 同源(describePanelGroup 单源)
|
|
568
714
|
let renderedAny = false;
|
|
569
|
-
|
|
715
|
+
if (omitted > 0) {
|
|
716
|
+
renderedAny = true;
|
|
717
|
+
total += measureJson(markdownDiv(`_💡 已省略 ${omitted} 个早期步骤_`));
|
|
718
|
+
}
|
|
719
|
+
for (const group of visible) {
|
|
570
720
|
if (group.kind === 'tool') {
|
|
571
721
|
renderedAny = true;
|
|
572
722
|
// tool 直接实测真实渲染元素(复用 renderTool,output ≤1200 字符,物化廉价)
|
|
573
|
-
total += measureJson(renderTool(group.tool,
|
|
723
|
+
total += measureJson(renderTool(group.tool, outputMax));
|
|
574
724
|
continue;
|
|
575
725
|
}
|
|
576
726
|
if (group.kind === 'text') {
|
|
@@ -636,14 +786,15 @@ export function renderRunCard(state, options = {}) {
|
|
|
636
786
|
...approvalArea(state),
|
|
637
787
|
];
|
|
638
788
|
const card = assembleRunCard(state, options, elements);
|
|
639
|
-
// Check budget — stringify
|
|
640
|
-
|
|
789
|
+
// Check budget — stringify 兜底(估算可能低估,仍保留安全网)。
|
|
790
|
+
// 两个维度都必须过:字节(28KB)与元素总数(飞书 11310,实测 203 节点上限)。
|
|
791
|
+
if (withinRunCardBudget(card))
|
|
641
792
|
return card;
|
|
642
793
|
// 超预算 → 继续走 degraded(不 return,fallthrough)
|
|
643
794
|
}
|
|
644
|
-
// 估算 ≥ 阈值 或
|
|
795
|
+
// 估算 ≥ 阈值 或 完整卡超预算 — 走 degraded 渲染
|
|
645
796
|
const degradedCard = assembleRunCard(state, options, buildDegradedElements(state, options));
|
|
646
|
-
if (
|
|
797
|
+
if (withinRunCardBudget(degradedCard)) {
|
|
647
798
|
return degradedCard;
|
|
648
799
|
}
|
|
649
800
|
// Extreme fallback: even degraded exceeds budget
|
|
@@ -651,7 +802,7 @@ export function renderRunCard(state, options = {}) {
|
|
|
651
802
|
// Final safety net: extreme fallback 是最后一层,但高转义内容(反斜杠 4× 膨胀)
|
|
652
803
|
// 可能让 extreme 产物本身 >28KB。补上 stringify 兜底(与 normal/degraded 对称):
|
|
653
804
|
// 超预算则降级到结构性保证 ≤28KB 的 skeleton 卡,避免飞书 11310 整卡不可用。
|
|
654
|
-
if (
|
|
805
|
+
if (withinRunCardBudget(extremeCard)) {
|
|
655
806
|
return extremeCard;
|
|
656
807
|
}
|
|
657
808
|
const skeletonCard = assembleRunCard(state, options, buildSkeletonElements(state, options));
|
|
@@ -675,16 +826,34 @@ export function renderRunCard(state, options = {}) {
|
|
|
675
826
|
};
|
|
676
827
|
return truncatedSkeleton;
|
|
677
828
|
}
|
|
829
|
+
/**
|
|
830
|
+
* Run 卡片的双预算检查:UTF-8 字节 ≤ 28KB **且** 元素数 ≤ `MAX_CARD_ELEMENTS`。
|
|
831
|
+
*
|
|
832
|
+
* 为什么两个维度都要查:字节超限来自内容长(截断即可),元素超限来自元素多
|
|
833
|
+
* (必须删元素)。一张只有几十个短文本的卡片可以远低于 28KB 却把飞书的元素
|
|
834
|
+
* 总数撑爆(实测 24 行工具 + 24 个展开正文 = 103 个元素 → 11310),只查字节
|
|
835
|
+
* 的安全网抓不到它。
|
|
836
|
+
*/
|
|
837
|
+
function withinRunCardBudget(card) {
|
|
838
|
+
if (countCardElements(card) > MAX_CARD_ELEMENTS)
|
|
839
|
+
return false;
|
|
840
|
+
return Buffer.byteLength(JSON.stringify(card), 'utf8') <= CARD_BUDGET_BYTES;
|
|
841
|
+
}
|
|
678
842
|
/** Build content in chronological order using groupBlocks to interleave thinking/text/tools. */
|
|
679
843
|
function buildChronologicalContent(state, prepared, groups) {
|
|
680
844
|
const elements = [];
|
|
681
845
|
// finalized = 非 running(含 finalizing:主结果已出,thinking 折叠)
|
|
682
846
|
const finalized = state.terminal !== 'running';
|
|
847
|
+
// 节点预算内从最新往回选择可见组:超限时只省略最旧的几步,不整卡降级。
|
|
848
|
+
const { visible, omitted } = selectVisibleGroups(groups);
|
|
849
|
+
const outputMax = toolOutputBudget(state);
|
|
850
|
+
if (omitted > 0)
|
|
851
|
+
elements.push(markdownDiv(`_💡 已省略 ${omitted} 个早期步骤_`));
|
|
683
852
|
// renderRunCard 传入同一 groups 数组时直接复用(prepared 的键是组对象
|
|
684
853
|
// 引用,重建数组会让 prepared.get 永远 miss,退化为二次截断)。
|
|
685
854
|
// 面板组描述经 describePanelGroup 与估算共享同一份模板。
|
|
686
855
|
// notices 作为普通消息按到达时间插入内容流(有内容时不再沉到 summary)。
|
|
687
|
-
const contentItems = mergeNoticesIntoContent(
|
|
856
|
+
const contentItems = mergeNoticesIntoContent(visible, state.notices);
|
|
688
857
|
for (const item of contentItems) {
|
|
689
858
|
if (item.kind === 'notice') {
|
|
690
859
|
elements.push(renderNoticeRow(item.notice));
|
|
@@ -692,7 +861,7 @@ function buildChronologicalContent(state, prepared, groups) {
|
|
|
692
861
|
}
|
|
693
862
|
const group = item;
|
|
694
863
|
if (group.kind === 'tool') {
|
|
695
|
-
elements.push(renderTool(group.tool,
|
|
864
|
+
elements.push(renderTool(group.tool, outputMax));
|
|
696
865
|
continue;
|
|
697
866
|
}
|
|
698
867
|
if (group.kind === 'text') {
|
|
@@ -737,7 +906,16 @@ function buildSummaryContent(state, options = {}) {
|
|
|
737
906
|
return elements;
|
|
738
907
|
}
|
|
739
908
|
if (state.terminal === 'error') {
|
|
740
|
-
|
|
909
|
+
// 服务方内容审核截断(多为误报)不是「程序出错」:照抄英文内部报文会让
|
|
910
|
+
// 用户以为代码或配置坏了。文案说清三件事——谁拦的、改动还在、下一步。
|
|
911
|
+
if (isContentFilterFailure(state.errorMsg)) {
|
|
912
|
+
elements.push(markdownDiv('⚠️ **服务方内容审核拦截**\n\n' +
|
|
913
|
+
'模型输出被上游安全策略截断(`content_filter`),多数是误报,不是代码或配置问题。\n' +
|
|
914
|
+
'本回合已中止;已写入磁盘的改动都还在。重试仍被拦截时,可换模型或稍后再试。'));
|
|
915
|
+
}
|
|
916
|
+
else {
|
|
917
|
+
elements.push(markdownDiv(`⚠️ **运行出错**\n\n${state.errorMsg ?? '未知错误'}`));
|
|
918
|
+
}
|
|
741
919
|
}
|
|
742
920
|
else if (state.terminal === 'interrupted') {
|
|
743
921
|
// 审批超时(approval_expired 已标记)是「无人响应被自动取消」,不是用户
|
|
@@ -788,28 +966,118 @@ function buildSummaryContent(state, options = {}) {
|
|
|
788
966
|
reasoningTokens: state.reasoningTokens,
|
|
789
967
|
}, { showResult: true, result });
|
|
790
968
|
elements.push(markdownDiv(usageStatsStr + empty));
|
|
969
|
+
// 后台任务:跨 turn 生命周期,必须显式告知并可逐个停止。
|
|
970
|
+
elements.push(...buildBackgroundJobsSection(state));
|
|
791
971
|
}
|
|
792
972
|
return elements;
|
|
793
973
|
}
|
|
794
|
-
/**
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
974
|
+
/** 终态卡最多列几个后台任务(多出来的只报数)。 */
|
|
975
|
+
const MAX_BACKGROUND_JOB_LINES = 3;
|
|
976
|
+
/**
|
|
977
|
+
* 命令在手机上可直接内联的显示宽度上限(半角单位,CJK/emoji 记 2)。
|
|
978
|
+
*
|
|
979
|
+
* 手机内容区约 320–360px,36 个半角单位约一行;再长会折成"每个词一行"。
|
|
980
|
+
* 超过这个宽度改用代码块整段展示(完整、可读),而不是截断。
|
|
981
|
+
*/
|
|
982
|
+
const BACKGROUND_JOB_INLINE_MAX = 36;
|
|
983
|
+
/**
|
|
984
|
+
* 代码块再长到这个程度才折叠:约 180 个半角宽度或 6 行。
|
|
985
|
+
*
|
|
986
|
+
* 低于阈值直接展示完整命令(用户一眼能看到,不用点);只有确实很长的
|
|
987
|
+
* 脚本/链式命令才收进折叠面板,避免一张卡被一条命令占满。
|
|
988
|
+
*/
|
|
989
|
+
const BACKGROUND_JOB_FOLD_DISPLAY_MAX = 180;
|
|
990
|
+
const BACKGROUND_JOB_FOLD_LINES = 6;
|
|
991
|
+
/**
|
|
992
|
+
* 任务状态标签:目前 DSH 只会给 running/stopping,其他值按运行中处理。
|
|
993
|
+
*/
|
|
994
|
+
function backgroundJobStatusLabel(status) {
|
|
995
|
+
return status === 'stopping' ? '停止中' : '运行中';
|
|
996
|
+
}
|
|
997
|
+
/**
|
|
998
|
+
* 生成能包住任意反引号串的代码围栏。命令里的 shell 反引号不能替换成 `'`
|
|
999
|
+
* (那是改内容),只能选比内容里最长反引号串更长的围栏。
|
|
1000
|
+
*/
|
|
1001
|
+
function codeFenceFor(content) {
|
|
1002
|
+
let longest = 0;
|
|
1003
|
+
let run = 0;
|
|
1004
|
+
for (const ch of content) {
|
|
1005
|
+
if (ch === '`') {
|
|
1006
|
+
run += 1;
|
|
1007
|
+
longest = Math.max(longest, run);
|
|
1008
|
+
}
|
|
1009
|
+
else {
|
|
1010
|
+
run = 0;
|
|
1011
|
+
}
|
|
801
1012
|
}
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
1013
|
+
return '`'.repeat(Math.max(3, longest + 1));
|
|
1014
|
+
}
|
|
1015
|
+
/**
|
|
1016
|
+
* 后台任务小节:命令独占一行,停止按钮单独一行。
|
|
1017
|
+
*
|
|
1018
|
+
* 布局约束(2026-10-21 手机端反馈):
|
|
1019
|
+
* - 命令 + 按钮不再并排(grow + auto):窄屏下 auto 列会被挤成「🟥…」,
|
|
1020
|
+
* 命令也被截断。按钮现在独占一个单列 column_set,宽度由按钮自身决定。
|
|
1021
|
+
* - 命令不 truncate:短命令/单行直接内联;中等长度的多行命令用代码块
|
|
1022
|
+
* 整段展示;只有超长命令(> 180 显示宽度或 > 6 行)才进折叠面板。
|
|
1023
|
+
* 面板折叠只影响视觉,JSON 里始终带全文。
|
|
1024
|
+
* - 含反引号的命令不走行内代码(无法安全包含反引号),改走代码块;
|
|
1025
|
+
* 围栏长度按内容自动加长,命令原文不改写。
|
|
1026
|
+
*
|
|
1027
|
+
* 按钮 payload 只带 runId/jobId:sessionId 与 cwd 由 bridge 的快照解析,卡片
|
|
1028
|
+
* 上少放一个可变字段就少一处不一致。
|
|
1029
|
+
*/
|
|
1030
|
+
function buildBackgroundJobsSection(state) {
|
|
1031
|
+
const jobs = state.backgroundJobs ?? [];
|
|
1032
|
+
if (jobs.length === 0)
|
|
1033
|
+
return [];
|
|
1034
|
+
const elements = [
|
|
1035
|
+
markdownDiv(`🧵 **后台任务** ${jobs.length} 个仍在运行`),
|
|
1036
|
+
markdownDiv('_本轮结束后继续,可逐个停止_', 'notation'),
|
|
1037
|
+
];
|
|
1038
|
+
jobs.slice(0, MAX_BACKGROUND_JOB_LINES).forEach((job, index) => {
|
|
1039
|
+
const label = job.label.trim();
|
|
1040
|
+
const lineCount = label === '' ? 0 : label.split(/\r?\n/).length;
|
|
1041
|
+
const inline = label.length > 0 &&
|
|
1042
|
+
lineCount === 1 &&
|
|
1043
|
+
!label.includes('`') &&
|
|
1044
|
+
displayWidth(label) <= BACKGROUND_JOB_INLINE_MAX;
|
|
1045
|
+
if (inline) {
|
|
1046
|
+
elements.push(markdownDiv(`· \`${label}\``));
|
|
1047
|
+
}
|
|
1048
|
+
else {
|
|
1049
|
+
// 多行/超宽/含反引号:代码块保留完整命令;只有超长才折叠。
|
|
1050
|
+
const fence = codeFenceFor(label);
|
|
1051
|
+
const content = `${fence}\n${label}\n${fence}`;
|
|
1052
|
+
const fold = lineCount > BACKGROUND_JOB_FOLD_LINES ||
|
|
1053
|
+
displayWidth(label) > BACKGROUND_JOB_FOLD_DISPLAY_MAX;
|
|
1054
|
+
elements.push(fold
|
|
1055
|
+
? collapsibleMarkdownPanel({
|
|
1056
|
+
title: `🧵 任务 ${index + 1} · ${backgroundJobStatusLabel(job.status)} · 点击展开完整命令`,
|
|
1057
|
+
expanded: false,
|
|
1058
|
+
border: 'grey',
|
|
1059
|
+
content,
|
|
1060
|
+
textSize: 'notation',
|
|
1061
|
+
})
|
|
1062
|
+
: markdownDiv(content));
|
|
1063
|
+
}
|
|
1064
|
+
elements.push({
|
|
1065
|
+
tag: 'column_set',
|
|
1066
|
+
columns: [
|
|
1067
|
+
{
|
|
1068
|
+
tag: 'column',
|
|
1069
|
+
width: 'auto',
|
|
1070
|
+
elements: [
|
|
1071
|
+
cardButton('⏹ 停止', { cmd: 'job.kill', runId: state.runId, jobId: job.id }, { size: 'small', type: 'danger' }),
|
|
1072
|
+
],
|
|
1073
|
+
},
|
|
1074
|
+
],
|
|
1075
|
+
});
|
|
1076
|
+
});
|
|
1077
|
+
if (jobs.length > MAX_BACKGROUND_JOB_LINES) {
|
|
1078
|
+
elements.push(markdownDiv(`_…另有 ${jobs.length - MAX_BACKGROUND_JOB_LINES} 个任务_`));
|
|
1079
|
+
}
|
|
1080
|
+
return elements;
|
|
813
1081
|
}
|
|
814
1082
|
/** Status tag label */
|
|
815
1083
|
function statusTagLabel(terminal) {
|
package/dist/card/run-state.d.ts
CHANGED
|
@@ -72,6 +72,10 @@ export interface RunState {
|
|
|
72
72
|
runId: string;
|
|
73
73
|
terminal: RunTerminal;
|
|
74
74
|
footer: RunFooter;
|
|
75
|
+
/** 本 run 开始时间(epoch ms):状态行的「⏱ 12s」由它算出。 */
|
|
76
|
+
startedAt?: number;
|
|
77
|
+
/** 本 run 结束时间(epoch ms):终态后耗时定格,不再随渲染时刻增长。 */
|
|
78
|
+
finishedAt?: number;
|
|
75
79
|
blocks: RunBlock[];
|
|
76
80
|
sessionId?: string;
|
|
77
81
|
resultSubtype?: 'success' | 'error' | 'interrupted';
|
|
@@ -135,6 +139,28 @@ export interface RunState {
|
|
|
135
139
|
* 终态渲染(2026-08-15 事故:标记被消费后卡片误报「已被用户终止」)。
|
|
136
140
|
*/
|
|
137
141
|
approvalExpired?: boolean;
|
|
142
|
+
/**
|
|
143
|
+
* 运行期状态投影(DSH `session/control` 的 contextPressure / sessionStats)。
|
|
144
|
+
*
|
|
145
|
+
* 只在 running/finalizing 渲染成状态行的一条(终态卡有权威 summary,不再
|
|
146
|
+
* 重复)。字段缺省 = 该维度暂无数据。
|
|
147
|
+
*/
|
|
148
|
+
liveStatus?: {
|
|
149
|
+
contextUsed?: number;
|
|
150
|
+
contextLimit?: number;
|
|
151
|
+
steps?: number;
|
|
152
|
+
toolMs?: number;
|
|
153
|
+
};
|
|
154
|
+
/**
|
|
155
|
+
* run 结束时仍在后台运行的任务。跨 turn 生命周期:卡片必须说清楚"这一轮
|
|
156
|
+
* 结束了,但还有东西在跑",并给出逐个停止的入口。
|
|
157
|
+
*/
|
|
158
|
+
backgroundJobs?: Array<{
|
|
159
|
+
id: string;
|
|
160
|
+
label: string;
|
|
161
|
+
status?: string;
|
|
162
|
+
progress?: string;
|
|
163
|
+
}>;
|
|
138
164
|
}
|
|
139
165
|
export interface FinishMeta {
|
|
140
166
|
resultSubtype?: 'success' | 'error' | 'interrupted';
|
package/dist/card/run-state.js
CHANGED
|
@@ -8,6 +8,8 @@ export function createInitialRunState(runId) {
|
|
|
8
8
|
runId,
|
|
9
9
|
terminal: 'running',
|
|
10
10
|
footer: 'thinking',
|
|
11
|
+
// 状态行的耗时口径:从 run 状态建立那一刻算起(卡片首帧即开始计时)。
|
|
12
|
+
startedAt: Date.now(),
|
|
11
13
|
blocks: [],
|
|
12
14
|
};
|
|
13
15
|
}
|
|
@@ -23,6 +25,10 @@ export function reduceRunState(state, event) {
|
|
|
23
25
|
}
|
|
24
26
|
if (event.type === 'system')
|
|
25
27
|
return reduceSystemEvent(state, event);
|
|
28
|
+
if (event.type === 'run_status')
|
|
29
|
+
return reduceRunStatusEvent(state, event);
|
|
30
|
+
if (event.type === 'background_jobs')
|
|
31
|
+
return reduceBackgroundJobsEvent(state, event);
|
|
26
32
|
if (event.type === 'result')
|
|
27
33
|
return reduceResultEvent(state, event);
|
|
28
34
|
if (event.type === 'assistant')
|
|
@@ -320,6 +326,42 @@ function reduceResultEvent(state, event) {
|
|
|
320
326
|
: {}),
|
|
321
327
|
};
|
|
322
328
|
}
|
|
329
|
+
/**
|
|
330
|
+
* 后台任务名单 → RunState.backgroundJobs。
|
|
331
|
+
*
|
|
332
|
+
* 整份替换而非合并:事件本身就是"收尾那一刻仍在跑的完整名单",合并会把上一
|
|
333
|
+
* 轮已结束的任务留下来。用户点「停止」成功后由 bridge 直接从 state 里摘掉,
|
|
334
|
+
* 不再走事件(那时已经没有 run 在消费流了)。
|
|
335
|
+
*/
|
|
336
|
+
function reduceBackgroundJobsEvent(state, event) {
|
|
337
|
+
if (event.type !== 'background_jobs')
|
|
338
|
+
return state;
|
|
339
|
+
return { ...state, backgroundJobs: event.jobs };
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* 运行期状态投影 → 状态行。
|
|
343
|
+
*
|
|
344
|
+
* 每帧只带一个投影 key,所以只合并"有值"的字段:用 undefined 覆盖会把另一
|
|
345
|
+
* 维度(上一步算出的上下文水位)擦掉。
|
|
346
|
+
*/
|
|
347
|
+
function reduceRunStatusEvent(state, event) {
|
|
348
|
+
if (event.type !== 'run_status')
|
|
349
|
+
return state;
|
|
350
|
+
const prev = state.liveStatus ?? {};
|
|
351
|
+
const live = {
|
|
352
|
+
...prev,
|
|
353
|
+
...(event.contextUsed === undefined ? {} : { contextUsed: event.contextUsed }),
|
|
354
|
+
...(event.contextLimit === undefined ? {} : { contextLimit: event.contextLimit }),
|
|
355
|
+
...(event.steps === undefined ? {} : { steps: event.steps }),
|
|
356
|
+
...(event.toolMs === undefined ? {} : { toolMs: event.toolMs }),
|
|
357
|
+
};
|
|
358
|
+
// 窗口上限同时写进 state:终态 summary 的百分比与状态条同源,即使
|
|
359
|
+
// result.usage 没带上限(DSH 的会话读取端不产出)也算得出来。
|
|
360
|
+
const next = { ...state, liveStatus: live };
|
|
361
|
+
if (live.contextLimit !== undefined)
|
|
362
|
+
next.contextLimit = live.contextLimit;
|
|
363
|
+
return next;
|
|
364
|
+
}
|
|
323
365
|
/** Handle assistant thinking/text/tool_use content blocks. */
|
|
324
366
|
function reduceAssistantEvent(state, event) {
|
|
325
367
|
if (event.type !== 'assistant')
|
|
@@ -577,6 +619,9 @@ export function finishRun(state, terminal, meta = {}) {
|
|
|
577
619
|
...state,
|
|
578
620
|
terminal,
|
|
579
621
|
footer: null,
|
|
622
|
+
// 耗时定格:终态之后再渲染(补 token 统计、点「详情」重渲染)都沿用同一
|
|
623
|
+
// 结束时刻,否则每次 patch 的数字都会变大。
|
|
624
|
+
finishedAt: state.finishedAt ?? Date.now(),
|
|
580
625
|
blocks: markThinkingInactive(state.blocks),
|
|
581
626
|
...sanitizeMeta(meta),
|
|
582
627
|
...(derivedReason !== undefined ? { interruptedReason: derivedReason } : {}),
|
|
@@ -2,17 +2,15 @@
|
|
|
2
2
|
* UTF-8 byte-safe text truncation primitives.
|
|
3
3
|
*
|
|
4
4
|
* The core `fitUtf8` / `truncateUtf8` / `DEFAULT_TRUNCATE_SUFFIX` primitives
|
|
5
|
-
* live in `src/common/truncate.ts
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* live in `src/common/truncate.ts`; this module only adds the card-specific
|
|
6
|
+
* markdown-table helpers. Card callers import the truncation primitives
|
|
7
|
+
* directly from common (no re-export shim).
|
|
8
8
|
*
|
|
9
9
|
* Why byte-safe: Feishu card size limits are measured in UTF-8 bytes, and a
|
|
10
10
|
* naive `str.slice(0, n)` can split a multi-byte CJK/emoji codepoint, yielding
|
|
11
11
|
* invalid UTF-8. `fitUtf8` iterates by Unicode codepoint (via `Array.from` /
|
|
12
12
|
* `for...of`) so it never breaks a surrogate pair or a multi-byte sequence.
|
|
13
13
|
*/
|
|
14
|
-
import { DEFAULT_TRUNCATE_SUFFIX, truncateUtf8 } from '../common/truncate.js';
|
|
15
|
-
export { DEFAULT_TRUNCATE_SUFFIX, truncateUtf8 };
|
|
16
14
|
/** Feishu 11310 limit: max markdown tables per card. */
|
|
17
15
|
export declare const FEISHU_MAX_TABLES = 5;
|
|
18
16
|
/**
|
|
@@ -2,17 +2,15 @@
|
|
|
2
2
|
* UTF-8 byte-safe text truncation primitives.
|
|
3
3
|
*
|
|
4
4
|
* The core `fitUtf8` / `truncateUtf8` / `DEFAULT_TRUNCATE_SUFFIX` primitives
|
|
5
|
-
* live in `src/common/truncate.ts
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* live in `src/common/truncate.ts`; this module only adds the card-specific
|
|
6
|
+
* markdown-table helpers. Card callers import the truncation primitives
|
|
7
|
+
* directly from common (no re-export shim).
|
|
8
8
|
*
|
|
9
9
|
* Why byte-safe: Feishu card size limits are measured in UTF-8 bytes, and a
|
|
10
10
|
* naive `str.slice(0, n)` can split a multi-byte CJK/emoji codepoint, yielding
|
|
11
11
|
* invalid UTF-8. `fitUtf8` iterates by Unicode codepoint (via `Array.from` /
|
|
12
12
|
* `for...of`) so it never breaks a surrogate pair or a multi-byte sequence.
|
|
13
13
|
*/
|
|
14
|
-
import { DEFAULT_TRUNCATE_SUFFIX, truncateUtf8 } from '../common/truncate.js';
|
|
15
|
-
export { DEFAULT_TRUNCATE_SUFFIX, truncateUtf8 };
|
|
16
14
|
/** Feishu 11310 limit: max markdown tables per card. */
|
|
17
15
|
export const FEISHU_MAX_TABLES = 5;
|
|
18
16
|
/**
|