cli-claw-kit 0.0.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.
- package/LICENSE +21 -0
- package/README.md +245 -0
- package/config/default-groups.json +1 -0
- package/config/global-agents-md.template.md +37 -0
- package/config/mount-allowlist.json +11 -0
- package/container/Dockerfile +160 -0
- package/container/agent-runner/dist/.tsbuildinfo +1 -0
- package/container/agent-runner/dist/agent-definitions.js +22 -0
- package/container/agent-runner/dist/channel-prefixes.js +16 -0
- package/container/agent-runner/dist/codex-config.js +29 -0
- package/container/agent-runner/dist/image-detector.js +96 -0
- package/container/agent-runner/dist/index.js +2587 -0
- package/container/agent-runner/dist/mcp-tools.js +1076 -0
- package/container/agent-runner/dist/stream-event.types.js +5 -0
- package/container/agent-runner/dist/stream-processor.js +867 -0
- package/container/agent-runner/dist/types.js +6 -0
- package/container/agent-runner/dist/utils.js +115 -0
- package/container/agent-runner/package.json +36 -0
- package/container/agent-runner/prompts/security-rules.md +31 -0
- package/container/agent-runner/src/agent-definitions.ts +27 -0
- package/container/agent-runner/src/channel-prefixes.ts +16 -0
- package/container/agent-runner/src/codex-config.ts +40 -0
- package/container/agent-runner/src/image-detector.ts +116 -0
- package/container/agent-runner/src/index.ts +3107 -0
- package/container/agent-runner/src/mcp-tools.ts +1295 -0
- package/container/agent-runner/src/stream-event.types.ts +10 -0
- package/container/agent-runner/src/stream-processor.ts +932 -0
- package/container/agent-runner/src/types.ts +75 -0
- package/container/agent-runner/src/utils.ts +114 -0
- package/container/agent-runner/tsconfig.json +17 -0
- package/container/build.sh +28 -0
- package/container/entrypoint.sh +64 -0
- package/container/skills/agent-browser/SKILL.md +159 -0
- package/container/skills/install-skill/SKILL.md +64 -0
- package/container/skills/post-test-cleanup/SKILL.md +121 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/agent-output-parser.js +459 -0
- package/dist/app-root.js +52 -0
- package/dist/assistant-meta-footer.js +1 -0
- package/dist/auth.js +91 -0
- package/dist/billing.js +694 -0
- package/dist/channel-prefixes.js +16 -0
- package/dist/cli.js +86 -0
- package/dist/commands.js +79 -0
- package/dist/config.js +120 -0
- package/dist/container-runner.js +981 -0
- package/dist/daily-summary.js +210 -0
- package/dist/db.js +3683 -0
- package/dist/dingtalk.js +1347 -0
- package/dist/feishu-markdown-style.js +97 -0
- package/dist/feishu-streaming-card.js +1875 -0
- package/dist/feishu.js +1628 -0
- package/dist/file-manager.js +270 -0
- package/dist/group-queue.js +1070 -0
- package/dist/group-runtime.js +35 -0
- package/dist/host-workspace-cwd.js +85 -0
- package/dist/im-channel.js +384 -0
- package/dist/im-command-utils.js +142 -0
- package/dist/im-downloader.js +45 -0
- package/dist/im-manager.js +527 -0
- package/dist/im-utils.js +53 -0
- package/dist/image-detector.js +96 -0
- package/dist/index.js +5828 -0
- package/dist/logger.js +22 -0
- package/dist/mcp-utils.js +66 -0
- package/dist/message-attachments.js +69 -0
- package/dist/message-notifier.js +36 -0
- package/dist/middleware/auth.js +85 -0
- package/dist/mount-security.js +315 -0
- package/dist/permissions.js +67 -0
- package/dist/project-memory.js +6 -0
- package/dist/provider-pool.js +189 -0
- package/dist/qq.js +826 -0
- package/dist/reset-admin.js +42 -0
- package/dist/routes/admin.js +543 -0
- package/dist/routes/agent-definitions.js +241 -0
- package/dist/routes/agents.js +533 -0
- package/dist/routes/auth.js +675 -0
- package/dist/routes/billing.js +490 -0
- package/dist/routes/browse.js +210 -0
- package/dist/routes/bug-report.js +387 -0
- package/dist/routes/config.js +1868 -0
- package/dist/routes/files.js +671 -0
- package/dist/routes/groups.js +1367 -0
- package/dist/routes/mcp-servers.js +320 -0
- package/dist/routes/memory.js +523 -0
- package/dist/routes/monitor.js +307 -0
- package/dist/routes/skills.js +777 -0
- package/dist/routes/tasks.js +509 -0
- package/dist/routes/usage.js +64 -0
- package/dist/routes/workspace-config.js +458 -0
- package/dist/runtime-build.js +112 -0
- package/dist/runtime-command-handler.js +189 -0
- package/dist/runtime-command-registry.js +1 -0
- package/dist/runtime-config.js +1777 -0
- package/dist/runtime-identity.js +52 -0
- package/dist/schemas.js +590 -0
- package/dist/script-runner.js +64 -0
- package/dist/sdk-query.js +82 -0
- package/dist/skill-utils.js +145 -0
- package/dist/sqlite-compat.js +19 -0
- package/dist/stream-event.types.js +5 -0
- package/dist/streaming-runtime-meta.js +29 -0
- package/dist/task-scheduler.js +695 -0
- package/dist/task-utils.js +13 -0
- package/dist/telegram-pairing.js +59 -0
- package/dist/telegram.js +897 -0
- package/dist/terminal-manager.js +307 -0
- package/dist/tool-step-display.js +1 -0
- package/dist/types.js +1 -0
- package/dist/utils.js +85 -0
- package/dist/web-context.js +161 -0
- package/dist/web.js +1377 -0
- package/dist/wechat-crypto.js +182 -0
- package/dist/wechat.js +589 -0
- package/dist/workspace-runtime-reset.js +35 -0
- package/package.json +107 -0
- package/shared/assistant-meta-footer.ts +127 -0
- package/shared/channel-prefixes.ts +16 -0
- package/shared/dist/assistant-meta-footer.d.ts +29 -0
- package/shared/dist/assistant-meta-footer.js +85 -0
- package/shared/dist/channel-prefixes.d.ts +4 -0
- package/shared/dist/channel-prefixes.js +16 -0
- package/shared/dist/image-detector.d.ts +20 -0
- package/shared/dist/image-detector.js +96 -0
- package/shared/dist/runtime-command-registry.d.ts +38 -0
- package/shared/dist/runtime-command-registry.js +185 -0
- package/shared/dist/stream-event.d.ts +65 -0
- package/shared/dist/stream-event.js +8 -0
- package/shared/dist/tool-step-display.d.ts +4 -0
- package/shared/dist/tool-step-display.js +11 -0
- package/shared/image-detector.ts +116 -0
- package/shared/runtime-command-registry.ts +252 -0
- package/shared/stream-event.ts +67 -0
- package/shared/tool-step-display.ts +21 -0
- package/shared/tsconfig.json +24 -0
- package/web/dist/assets/BillingPage-B1wBR_o-.js +52 -0
- package/web/dist/assets/ChatPage-6GBZ9nXN.css +32 -0
- package/web/dist/assets/ChatPage-BOJcXtaj.js +161 -0
- package/web/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/web/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/web/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/web/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/web/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/web/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/web/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/web/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/web/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/web/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/web/dist/assets/SettingsPage-DoY7FoZ_.js +153 -0
- package/web/dist/assets/ShareImageDialog-C1ga8b7l.js +22 -0
- package/web/dist/assets/TasksPage-CRivnNsx.js +14 -0
- package/web/dist/assets/_basePickBy-Bf-bSoS9.js +1 -0
- package/web/dist/assets/_baseUniq-zAOaCuKw.js +1 -0
- package/web/dist/assets/arc-Dm9mVQ9U.js +1 -0
- package/web/dist/assets/architectureDiagram-2XIMDMQ5-BLmzX1wr.js +36 -0
- package/web/dist/assets/band-CquvqAHh.js +1 -0
- package/web/dist/assets/blockDiagram-WCTKOSBZ-B9pcqm3j.js +132 -0
- package/web/dist/assets/c4Diagram-IC4MRINW-Cytx1q3b.js +10 -0
- package/web/dist/assets/channel-BOVj73LR.js +1 -0
- package/web/dist/assets/channel-meta-CQD0Pei-.js +41 -0
- package/web/dist/assets/chunk-4BX2VUAB-0ToDr6RE.js +1 -0
- package/web/dist/assets/chunk-55IACEB6-DQDjnXfS.js +1 -0
- package/web/dist/assets/chunk-FMBD7UC4-Di8ABm6c.js +15 -0
- package/web/dist/assets/chunk-JSJVCQXG-BZQN6rnX.js +1 -0
- package/web/dist/assets/chunk-KX2RTZJC-zBbcpaN_.js +1 -0
- package/web/dist/assets/chunk-NQ4KR5QH-BCrLoU88.js +220 -0
- package/web/dist/assets/chunk-QZHKN3VN-Bqk8juan.js +1 -0
- package/web/dist/assets/chunk-WL4C6EOR-D2YX-MHY.js +189 -0
- package/web/dist/assets/classDiagram-VBA2DB6C-DUUoMyaK.js +1 -0
- package/web/dist/assets/classDiagram-v2-RAHNMMFH-DUUoMyaK.js +1 -0
- package/web/dist/assets/clone-BmaCesfa.js +1 -0
- package/web/dist/assets/cose-bilkent-S5V4N54A-CTsv6qQA.js +1 -0
- package/web/dist/assets/cytoscape.esm-BQaXIfA_.js +331 -0
- package/web/dist/assets/dagre-KLK3FWXG-Ci4Jh9nu.js +4 -0
- package/web/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/web/dist/assets/diagram-E7M64L7V-BFRnfTI2.js +24 -0
- package/web/dist/assets/diagram-IFDJBPK2-B7Zhnp0b.js +43 -0
- package/web/dist/assets/diagram-P4PSJMXO-BVyP7nwq.js +24 -0
- package/web/dist/assets/erDiagram-INFDFZHY-NorKdTOF.js +70 -0
- package/web/dist/assets/error-CGD5mp5f.js +1 -0
- package/web/dist/assets/flowDiagram-PKNHOUZH-Ch97nABF.js +162 -0
- package/web/dist/assets/ganttDiagram-A5KZAMGK-BQ2pLWsy.js +292 -0
- package/web/dist/assets/gitGraphDiagram-K3NZZRJ6-bcvnBsD2.js +65 -0
- package/web/dist/assets/graph-CeAEckur.js +1 -0
- package/web/dist/assets/index-CPnL1_qC.js +768 -0
- package/web/dist/assets/index-DVevCbcO.css +10 -0
- package/web/dist/assets/infoDiagram-LFFYTUFH-CcsrFdj-.js +2 -0
- package/web/dist/assets/init-Dmth1JHB.js +1 -0
- package/web/dist/assets/ishikawaDiagram-PHBUUO56-1upyMfHN.js +70 -0
- package/web/dist/assets/journeyDiagram-4ABVD52K-CKUi-V0c.js +139 -0
- package/web/dist/assets/kanban-definition-K7BYSVSG-DOnQwXfL.js +89 -0
- package/web/dist/assets/layout-BmMMqTnJ.js +1 -0
- package/web/dist/assets/linear-DiaJloY5.js +1 -0
- package/web/dist/assets/mermaid.core-BWLV1B2v.js +254 -0
- package/web/dist/assets/mindmap-definition-YRQLILUH-BeAKHVWP.js +68 -0
- package/web/dist/assets/ordinal-DILIJJjt.js +1 -0
- package/web/dist/assets/pieDiagram-SKSYHLDU-DfiMSfWo.js +30 -0
- package/web/dist/assets/quadrantDiagram-337W2JSQ-wZxZOJxd.js +7 -0
- package/web/dist/assets/requirementDiagram-Z7DCOOCP-BK4HHm17.js +73 -0
- package/web/dist/assets/sankeyDiagram-WA2Y5GQK-BX6t2avX.js +10 -0
- package/web/dist/assets/sequenceDiagram-2WXFIKYE-BPQlkbAa.js +145 -0
- package/web/dist/assets/sheet-rI0FfB1g.js +6 -0
- package/web/dist/assets/sliders-horizontal-CuijWFNK.js +6 -0
- package/web/dist/assets/sparkles-BsMYXJoT.js +11 -0
- package/web/dist/assets/square-0CqMX1Q3.js +11 -0
- package/web/dist/assets/stateDiagram-RAJIS63D-DxkV0Vwd.js +1 -0
- package/web/dist/assets/stateDiagram-v2-FVOUBMTO-qLYoiOPe.js +1 -0
- package/web/dist/assets/step-D51IIHGA.js +1 -0
- package/web/dist/assets/tasks-D8JjBTwx.js +1 -0
- package/web/dist/assets/time-O8zIGux3.js +1 -0
- package/web/dist/assets/timeline-definition-YZTLITO2-kNp1DyFc.js +61 -0
- package/web/dist/assets/treemap-KZPCXAKY-CkrClVhk.js +162 -0
- package/web/dist/assets/utils-KGAn0XTg.js +11 -0
- package/web/dist/assets/vennDiagram-LZ73GAT5-CgdzEZz4.js +34 -0
- package/web/dist/assets/xychartDiagram-JWTSCODW-DfYGPfNB.js +7 -0
- package/web/dist/assets/zap-_hKJYy7J.js +6 -0
- package/web/dist/favicon.svg +332 -0
- package/web/dist/fonts/AlibabaPuHuiTi-3-55-Regular.woff2 +0 -0
- package/web/dist/fonts/AlibabaPuHuiTi-3-65-Medium.woff2 +0 -0
- package/web/dist/fonts/AlibabaPuHuiTi-3-75-SemiBold.woff2 +0 -0
- package/web/dist/fonts/DMSans-latin-ext.woff2 +0 -0
- package/web/dist/fonts/DMSans-latin.woff2 +0 -0
- package/web/dist/icons/README.md +20 -0
- package/web/dist/icons/apple-touch-icon-180.png +0 -0
- package/web/dist/icons/icon-128.png +0 -0
- package/web/dist/icons/icon-144.png +0 -0
- package/web/dist/icons/icon-152.png +0 -0
- package/web/dist/icons/icon-192.png +0 -0
- package/web/dist/icons/icon-192.svg +332 -0
- package/web/dist/icons/icon-384.png +0 -0
- package/web/dist/icons/icon-48.png +0 -0
- package/web/dist/icons/icon-512-maskable.png +0 -0
- package/web/dist/icons/icon-512.png +0 -0
- package/web/dist/icons/icon-512.svg +332 -0
- package/web/dist/icons/icon-72.png +0 -0
- package/web/dist/icons/icon-96.png +0 -0
- package/web/dist/icons/loading-logo.svg +332 -0
- package/web/dist/icons/logo-1024.png +0 -0
- package/web/dist/icons/logo-icon.svg +332 -0
- package/web/dist/icons/logo-text.svg +332 -0
- package/web/dist/index.html +30 -0
- package/web/dist/manifest.webmanifest +1 -0
- package/web/dist/registerSW.js +1 -0
- package/web/dist/sw.js +1 -0
- package/web/dist/workbox-08d6266a.js +1 -0
|
@@ -0,0 +1,1875 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import { logger } from './logger.js';
|
|
3
|
+
import { optimizeMarkdownStyle } from './feishu-markdown-style.js';
|
|
4
|
+
import { formatAssistantMetaFooter, } from './assistant-meta-footer.js';
|
|
5
|
+
import { getModelPresets, getReasoningEffortPresets, supportsReasoningEffort, } from './runtime-command-registry.js';
|
|
6
|
+
import { formatToolStepLine } from './tool-step-display.js';
|
|
7
|
+
/**
|
|
8
|
+
* Scan text for fenced code block ranges (``` ... ```).
|
|
9
|
+
*/
|
|
10
|
+
function findCodeBlockRanges(text) {
|
|
11
|
+
const ranges = [];
|
|
12
|
+
const regex = /^```(\w*)\s*$/gm;
|
|
13
|
+
let match;
|
|
14
|
+
let openMatch = null;
|
|
15
|
+
let openLang = '';
|
|
16
|
+
while ((match = regex.exec(text)) !== null) {
|
|
17
|
+
if (!openMatch) {
|
|
18
|
+
openMatch = match;
|
|
19
|
+
openLang = match[1] || '';
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
ranges.push({
|
|
23
|
+
open: openMatch.index,
|
|
24
|
+
close: match.index + match[0].length,
|
|
25
|
+
lang: openLang,
|
|
26
|
+
});
|
|
27
|
+
openMatch = null;
|
|
28
|
+
openLang = '';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// Unclosed code block — treat from open to end of text
|
|
32
|
+
if (openMatch) {
|
|
33
|
+
ranges.push({
|
|
34
|
+
open: openMatch.index,
|
|
35
|
+
close: text.length,
|
|
36
|
+
lang: openLang,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
return ranges;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Check if a position falls inside any code block range.
|
|
43
|
+
* Returns the range if found, null otherwise.
|
|
44
|
+
*/
|
|
45
|
+
function findContainingBlock(pos, ranges) {
|
|
46
|
+
for (const r of ranges) {
|
|
47
|
+
if (pos > r.open && pos < r.close)
|
|
48
|
+
return r;
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Split text respecting fenced code block boundaries.
|
|
54
|
+
* Unlike splitAtParagraphs(), this never truncates inside a code block
|
|
55
|
+
* without properly closing/reopening the fence.
|
|
56
|
+
*/
|
|
57
|
+
function splitCodeBlockSafe(text, maxLen) {
|
|
58
|
+
if (text.length <= maxLen)
|
|
59
|
+
return [text];
|
|
60
|
+
const chunks = [];
|
|
61
|
+
let remaining = text;
|
|
62
|
+
while (remaining.length > maxLen) {
|
|
63
|
+
// Recompute ranges on current remaining text each iteration.
|
|
64
|
+
// This handles synthetic reopeners correctly since all positions
|
|
65
|
+
// are relative to `remaining`, not the original text.
|
|
66
|
+
const ranges = findCodeBlockRanges(remaining);
|
|
67
|
+
// Find a split point around maxLen
|
|
68
|
+
let idx = remaining.lastIndexOf('\n\n', maxLen);
|
|
69
|
+
if (idx < maxLen * 0.3)
|
|
70
|
+
idx = remaining.lastIndexOf('\n', maxLen);
|
|
71
|
+
if (idx < maxLen * 0.3)
|
|
72
|
+
idx = maxLen;
|
|
73
|
+
const block = findContainingBlock(idx, ranges);
|
|
74
|
+
if (block) {
|
|
75
|
+
// Split point is inside a code block
|
|
76
|
+
if (block.open > 0 && block.open > maxLen * 0.3) {
|
|
77
|
+
// Retreat to just before the code block opening
|
|
78
|
+
const retreatIdx = remaining.lastIndexOf('\n', block.open);
|
|
79
|
+
idx = retreatIdx > maxLen * 0.3 ? retreatIdx : block.open;
|
|
80
|
+
chunks.push(remaining.slice(0, idx).trimEnd());
|
|
81
|
+
remaining = remaining.slice(idx).replace(/^\n+/, '');
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
// Block starts too early to retreat — split inside but close/reopen fence
|
|
85
|
+
const chunk = remaining.slice(0, idx).trimEnd() + '\n```';
|
|
86
|
+
chunks.push(chunk);
|
|
87
|
+
const reopener = '```' + block.lang + '\n';
|
|
88
|
+
remaining = reopener + remaining.slice(idx).replace(/^\n/, '');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
chunks.push(remaining.slice(0, idx).trimEnd());
|
|
93
|
+
remaining = remaining.slice(idx).replace(/^\n+/, '');
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (remaining)
|
|
97
|
+
chunks.push(remaining);
|
|
98
|
+
return chunks;
|
|
99
|
+
}
|
|
100
|
+
const CARD_MD_LIMIT = 4000;
|
|
101
|
+
const CARD_SIZE_LIMIT = 25 * 1024; // Feishu limit ~30KB, 5KB safety margin
|
|
102
|
+
// ─── Legacy Card Builder (Fallback) ──────────────────────────
|
|
103
|
+
function splitAtParagraphs(text, maxLen) {
|
|
104
|
+
if (text.length <= maxLen)
|
|
105
|
+
return [text];
|
|
106
|
+
const chunks = [];
|
|
107
|
+
let remaining = text;
|
|
108
|
+
while (remaining.length > maxLen) {
|
|
109
|
+
let idx = remaining.lastIndexOf('\n\n', maxLen);
|
|
110
|
+
if (idx < maxLen * 0.3)
|
|
111
|
+
idx = remaining.lastIndexOf('\n', maxLen);
|
|
112
|
+
if (idx < maxLen * 0.3)
|
|
113
|
+
idx = maxLen;
|
|
114
|
+
chunks.push(remaining.slice(0, idx).trim());
|
|
115
|
+
remaining = remaining.slice(idx).trim();
|
|
116
|
+
}
|
|
117
|
+
if (remaining)
|
|
118
|
+
chunks.push(remaining);
|
|
119
|
+
return chunks;
|
|
120
|
+
}
|
|
121
|
+
function extractTitleAndBody(text) {
|
|
122
|
+
const lines = text.split('\n');
|
|
123
|
+
let title = '';
|
|
124
|
+
let bodyStartIdx = 0;
|
|
125
|
+
for (let i = 0; i < lines.length; i++) {
|
|
126
|
+
if (!lines[i].trim())
|
|
127
|
+
continue;
|
|
128
|
+
if (/^#{1,3}\s+/.test(lines[i])) {
|
|
129
|
+
title = lines[i].replace(/^#+\s*/, '').trim();
|
|
130
|
+
bodyStartIdx = i + 1;
|
|
131
|
+
}
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
const body = lines.slice(bodyStartIdx).join('\n').trim();
|
|
135
|
+
if (!title) {
|
|
136
|
+
const firstLine = (lines.find((l) => l.trim()) || '')
|
|
137
|
+
.replace(/[*_`#\[\]]/g, '')
|
|
138
|
+
.trim();
|
|
139
|
+
title =
|
|
140
|
+
firstLine.length > 40
|
|
141
|
+
? firstLine.slice(0, 37) + '...'
|
|
142
|
+
: firstLine || 'Reply';
|
|
143
|
+
}
|
|
144
|
+
return { title, body };
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Build the content elements shared by both Legacy and Schema 2.0 card builders.
|
|
148
|
+
* Splits long text, handles `---` section dividers, and extracts the title.
|
|
149
|
+
* Applies optimizeMarkdownStyle() for proper Feishu rendering.
|
|
150
|
+
*/
|
|
151
|
+
function buildCardContent(text, splitFn, overrideTitle) {
|
|
152
|
+
const { title: extractedTitle, body } = extractTitleAndBody(text);
|
|
153
|
+
const title = overrideTitle || extractedTitle;
|
|
154
|
+
// Apply Markdown optimization for Feishu card rendering
|
|
155
|
+
const rawContent = body || text.trim();
|
|
156
|
+
const contentToRender = optimizeMarkdownStyle(rawContent, 2);
|
|
157
|
+
const elements = [];
|
|
158
|
+
if (contentToRender.length > CARD_MD_LIMIT) {
|
|
159
|
+
for (const chunk of splitFn(contentToRender, CARD_MD_LIMIT)) {
|
|
160
|
+
elements.push({
|
|
161
|
+
tag: 'markdown',
|
|
162
|
+
content: chunk,
|
|
163
|
+
text_size: 'normal_text',
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
else if (contentToRender) {
|
|
168
|
+
// Keep --- as markdown content instead of using { tag: 'hr' }
|
|
169
|
+
// because Schema 2.0 (CardKit) does not support the hr tag.
|
|
170
|
+
elements.push({
|
|
171
|
+
tag: 'markdown',
|
|
172
|
+
content: contentToRender,
|
|
173
|
+
text_size: 'normal_text',
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
if (elements.length === 0) {
|
|
177
|
+
elements.push({
|
|
178
|
+
tag: 'markdown',
|
|
179
|
+
content: text.trim() || '...',
|
|
180
|
+
text_size: 'normal_text',
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
return { title, contentElements: elements };
|
|
184
|
+
}
|
|
185
|
+
// ─── Interrupt Button Element ────────────────────────────────
|
|
186
|
+
/** Schema 1.0: `action` container wrapping a button (used by legacy message.patch path) */
|
|
187
|
+
const INTERRUPT_BUTTON = {
|
|
188
|
+
tag: 'action',
|
|
189
|
+
actions: [
|
|
190
|
+
{
|
|
191
|
+
tag: 'button',
|
|
192
|
+
text: { tag: 'plain_text', content: '⏹ 中断回复' },
|
|
193
|
+
type: 'danger',
|
|
194
|
+
value: { action: 'interrupt_stream' },
|
|
195
|
+
},
|
|
196
|
+
],
|
|
197
|
+
};
|
|
198
|
+
/** Schema 2.0: standalone button (CardKit rejects `tag: 'action'` in v2 cards) */
|
|
199
|
+
const INTERRUPT_BUTTON_V2 = {
|
|
200
|
+
tag: 'button',
|
|
201
|
+
text: { tag: 'plain_text', content: '⏹ 中断回复' },
|
|
202
|
+
type: 'danger',
|
|
203
|
+
value: { action: 'interrupt_stream' },
|
|
204
|
+
};
|
|
205
|
+
function buildRuntimeSelectElement(options) {
|
|
206
|
+
return {
|
|
207
|
+
tag: 'select_static',
|
|
208
|
+
placeholder: {
|
|
209
|
+
tag: 'plain_text',
|
|
210
|
+
content: options.placeholder,
|
|
211
|
+
},
|
|
212
|
+
value: {
|
|
213
|
+
action: options.action,
|
|
214
|
+
},
|
|
215
|
+
options: options.values.map((value) => ({
|
|
216
|
+
text: {
|
|
217
|
+
tag: 'plain_text',
|
|
218
|
+
content: value,
|
|
219
|
+
},
|
|
220
|
+
value,
|
|
221
|
+
})),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
function buildRuntimeControlElements(runtimeIdentity) {
|
|
225
|
+
const agentType = runtimeIdentity?.agentType;
|
|
226
|
+
if (agentType !== 'claude' && agentType !== 'codex')
|
|
227
|
+
return [];
|
|
228
|
+
const modelPlaceholder = runtimeIdentity?.model
|
|
229
|
+
? `模型: ${runtimeIdentity.model}`
|
|
230
|
+
: '切换模型';
|
|
231
|
+
const elements = [
|
|
232
|
+
buildRuntimeSelectElement({
|
|
233
|
+
action: 'set_runtime_model',
|
|
234
|
+
placeholder: modelPlaceholder,
|
|
235
|
+
values: getModelPresets(agentType),
|
|
236
|
+
}),
|
|
237
|
+
];
|
|
238
|
+
if (supportsReasoningEffort(agentType)) {
|
|
239
|
+
elements.push(buildRuntimeSelectElement({
|
|
240
|
+
action: 'set_runtime_effort',
|
|
241
|
+
placeholder: runtimeIdentity?.reasoningEffort
|
|
242
|
+
? `思考强度: ${runtimeIdentity.reasoningEffort}`
|
|
243
|
+
: '切换思考强度',
|
|
244
|
+
values: getReasoningEffortPresets(),
|
|
245
|
+
}));
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
elements.push({
|
|
249
|
+
tag: 'markdown',
|
|
250
|
+
content: '思考强度: 当前 runtime 不支持',
|
|
251
|
+
text_size: 'notation',
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
return elements;
|
|
255
|
+
}
|
|
256
|
+
// ─── Streaming Mode Constants ─────────────────────────────────
|
|
257
|
+
const ELEMENT_IDS = {
|
|
258
|
+
AUX_BEFORE: 'aux_before',
|
|
259
|
+
MAIN_CONTENT: 'main_content',
|
|
260
|
+
AUX_AFTER: 'aux_after',
|
|
261
|
+
INTERRUPT_BTN: 'interrupt_btn',
|
|
262
|
+
STATUS_NOTE: 'status_note',
|
|
263
|
+
};
|
|
264
|
+
const STREAMING_CONFIG = {
|
|
265
|
+
print_frequency_ms: { default: 50 },
|
|
266
|
+
print_step: { default: 2 },
|
|
267
|
+
print_strategy: 'fast',
|
|
268
|
+
};
|
|
269
|
+
const MAX_STREAMING_CONTENT = 100000; // cardElement.content() supports 100K chars
|
|
270
|
+
// ─── Auxiliary State & Builder ────────────────────────────────
|
|
271
|
+
const MAX_THINKING_CHARS = 800;
|
|
272
|
+
const MAX_RECENT_EVENTS = 5;
|
|
273
|
+
const MAX_TOOL_DISPLAY = 5;
|
|
274
|
+
const MAX_TODO_DISPLAY = 10;
|
|
275
|
+
const MAX_TOOL_SUMMARY_CHARS = 60;
|
|
276
|
+
const MAX_ELEMENT_CHARS = 4000;
|
|
277
|
+
const MAX_COMPLETED_TOOL_AGE = 30000; // 30s — purge completed tools after this
|
|
278
|
+
function buildCollapsiblePanel(title, body) {
|
|
279
|
+
return {
|
|
280
|
+
tag: 'collapsible_panel',
|
|
281
|
+
expanded: false,
|
|
282
|
+
border: { color: 'grey-300', corner_radius: '6px' },
|
|
283
|
+
header: {
|
|
284
|
+
title: {
|
|
285
|
+
tag: 'plain_text',
|
|
286
|
+
text_color: 'grey',
|
|
287
|
+
text_size: 'notation',
|
|
288
|
+
content: title,
|
|
289
|
+
},
|
|
290
|
+
icon: { tag: 'standard_icon', token: 'right_outlined', color: 'grey' },
|
|
291
|
+
icon_position: 'right',
|
|
292
|
+
icon_expanded_angle: 90,
|
|
293
|
+
},
|
|
294
|
+
elements: [{ tag: 'markdown', content: body, text_size: 'notation' }],
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Build auxiliary markdown elements for the streaming card.
|
|
299
|
+
* Returns elements to insert before and after the main text content.
|
|
300
|
+
*/
|
|
301
|
+
function buildAuxiliaryElements(aux) {
|
|
302
|
+
return buildAuxiliaryElementsForState(aux, 'streaming');
|
|
303
|
+
}
|
|
304
|
+
function buildAuxiliaryElementsForState(aux, state) {
|
|
305
|
+
const before = [];
|
|
306
|
+
const after = [];
|
|
307
|
+
const isStreamingLayout = state === 'streaming';
|
|
308
|
+
// ① System Status
|
|
309
|
+
if (aux.systemStatus) {
|
|
310
|
+
before.push({
|
|
311
|
+
tag: 'markdown',
|
|
312
|
+
content: `⏳ ${aux.systemStatus}`.slice(0, MAX_ELEMENT_CHARS),
|
|
313
|
+
text_size: 'notation',
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
// ② Thinking
|
|
317
|
+
if (aux.thinkingText) {
|
|
318
|
+
const truncated = aux.thinkingText.length > MAX_THINKING_CHARS
|
|
319
|
+
? '...' + aux.thinkingText.slice(-(MAX_THINKING_CHARS - 3))
|
|
320
|
+
: aux.thinkingText;
|
|
321
|
+
if (isStreamingLayout) {
|
|
322
|
+
const quoted = truncated
|
|
323
|
+
.split('\n')
|
|
324
|
+
.map((l) => `> ${l}`)
|
|
325
|
+
.join('\n');
|
|
326
|
+
before.push({
|
|
327
|
+
tag: 'markdown',
|
|
328
|
+
content: `💭 **${aux.isThinking ? 'Reasoning...' : 'Reasoning'}**\n${quoted}`.slice(0, MAX_ELEMENT_CHARS),
|
|
329
|
+
text_size: 'notation',
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
before.push(buildCollapsiblePanel('💭 Thinking', truncated.slice(0, MAX_ELEMENT_CHARS)));
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
else if (aux.isThinking) {
|
|
337
|
+
before.push({
|
|
338
|
+
tag: 'markdown',
|
|
339
|
+
content: '💭 **Thinking...**',
|
|
340
|
+
text_size: 'notation',
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
// ③ Active Tools (running first, then recent completed, max MAX_TOOL_DISPLAY)
|
|
344
|
+
const running = [];
|
|
345
|
+
const completed = [];
|
|
346
|
+
for (const [id, tc] of aux.toolCalls) {
|
|
347
|
+
if (tc.status === 'running')
|
|
348
|
+
running.push([id, tc]);
|
|
349
|
+
else
|
|
350
|
+
completed.push([id, tc]);
|
|
351
|
+
}
|
|
352
|
+
// Show running tools first, fill remaining slots with latest completed
|
|
353
|
+
const display = [
|
|
354
|
+
...running,
|
|
355
|
+
...completed.slice(-Math.max(0, MAX_TOOL_DISPLAY - running.length)),
|
|
356
|
+
].slice(0, MAX_TOOL_DISPLAY);
|
|
357
|
+
if (display.length > 0) {
|
|
358
|
+
const lines = display.map(([, tc]) => {
|
|
359
|
+
const summary = tc.toolInputSummary
|
|
360
|
+
? tc.toolInputSummary.length > MAX_TOOL_SUMMARY_CHARS
|
|
361
|
+
? tc.toolInputSummary.slice(0, MAX_TOOL_SUMMARY_CHARS) + '...'
|
|
362
|
+
: tc.toolInputSummary
|
|
363
|
+
: undefined;
|
|
364
|
+
return formatToolStepLine(tc.name, summary);
|
|
365
|
+
});
|
|
366
|
+
if (isStreamingLayout) {
|
|
367
|
+
before.push({
|
|
368
|
+
tag: 'markdown',
|
|
369
|
+
content: lines.join('\n').slice(0, MAX_ELEMENT_CHARS),
|
|
370
|
+
text_size: 'notation',
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
else {
|
|
374
|
+
before.push(buildCollapsiblePanel(`${display.length} steps`, lines.join('\n').slice(0, MAX_ELEMENT_CHARS)));
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
// ④ Hook Status
|
|
378
|
+
if (aux.activeHook) {
|
|
379
|
+
before.push({
|
|
380
|
+
tag: 'markdown',
|
|
381
|
+
content: `🔗 Hook: ${aux.activeHook.hookName || aux.activeHook.hookEvent}`,
|
|
382
|
+
text_size: 'notation',
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
// ⑤ Todo Progress
|
|
386
|
+
if (aux.todos && aux.todos.length > 0) {
|
|
387
|
+
const total = aux.todos.length;
|
|
388
|
+
const done = aux.todos.filter((t) => t.status === 'completed').length;
|
|
389
|
+
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
|
|
390
|
+
const header = `📋 **${done}/${total} (${pct}%)**`;
|
|
391
|
+
const items = aux.todos.slice(0, MAX_TODO_DISPLAY).map((t) => {
|
|
392
|
+
const icon = t.status === 'completed'
|
|
393
|
+
? '✅'
|
|
394
|
+
: t.status === 'in_progress'
|
|
395
|
+
? '⏳'
|
|
396
|
+
: '○';
|
|
397
|
+
return `${icon} ${t.content}`;
|
|
398
|
+
});
|
|
399
|
+
const extra = total > MAX_TODO_DISPLAY ? `\n... +${total - MAX_TODO_DISPLAY} 项` : '';
|
|
400
|
+
before.push({
|
|
401
|
+
tag: 'markdown',
|
|
402
|
+
content: `${header}\n${items.join('\n')}${extra}`.slice(0, MAX_ELEMENT_CHARS),
|
|
403
|
+
text_size: 'notation',
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
return { before, after };
|
|
407
|
+
}
|
|
408
|
+
// ─── Legacy Card Builder (Fallback) ──────────────────────────
|
|
409
|
+
function buildStreamingCard(text, state, footerNote, runtimeIdentity) {
|
|
410
|
+
const { title, contentElements: elements } = buildCardContent(text, splitAtParagraphs);
|
|
411
|
+
const noteMap = {
|
|
412
|
+
streaming: '⏳ 生成中...',
|
|
413
|
+
completed: '',
|
|
414
|
+
aborted: '⚠️ 已中断',
|
|
415
|
+
};
|
|
416
|
+
const headerTemplate = {
|
|
417
|
+
streaming: 'wathet',
|
|
418
|
+
completed: 'indigo',
|
|
419
|
+
aborted: 'orange',
|
|
420
|
+
};
|
|
421
|
+
if (state === 'streaming') {
|
|
422
|
+
elements.push(INTERRUPT_BUTTON);
|
|
423
|
+
}
|
|
424
|
+
elements.push(...buildRuntimeControlElements(runtimeIdentity));
|
|
425
|
+
if (noteMap[state]) {
|
|
426
|
+
elements.push({
|
|
427
|
+
tag: 'note',
|
|
428
|
+
elements: [{ tag: 'plain_text', content: noteMap[state] }],
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
if (footerNote) {
|
|
432
|
+
elements.push({
|
|
433
|
+
tag: 'note',
|
|
434
|
+
elements: [{ tag: 'plain_text', content: footerNote }],
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
return {
|
|
438
|
+
config: { wide_screen_mode: true },
|
|
439
|
+
header: {
|
|
440
|
+
title: { tag: 'plain_text', content: title },
|
|
441
|
+
template: headerTemplate[state],
|
|
442
|
+
},
|
|
443
|
+
elements,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
const SCHEMA2_NOTE_MAP = {
|
|
447
|
+
streaming: '⏳ 生成中...',
|
|
448
|
+
completed: '',
|
|
449
|
+
aborted: '⚠️ 已中断',
|
|
450
|
+
frozen: '',
|
|
451
|
+
};
|
|
452
|
+
function buildSchema2Card(text, state, titlePrefix = '', overrideTitle, auxiliaryState, footerNote, runtimeIdentity) {
|
|
453
|
+
const { title, contentElements } = buildCardContent(text, splitCodeBlockSafe, overrideTitle);
|
|
454
|
+
const displayTitle = titlePrefix ? `${titlePrefix}${title}` : title;
|
|
455
|
+
// Build final elements array with auxiliary sections
|
|
456
|
+
const elements = [];
|
|
457
|
+
if (auxiliaryState) {
|
|
458
|
+
const { before, after } = buildAuxiliaryElementsForState(auxiliaryState, state);
|
|
459
|
+
elements.push(...before);
|
|
460
|
+
elements.push(...contentElements);
|
|
461
|
+
elements.push(...after);
|
|
462
|
+
}
|
|
463
|
+
else {
|
|
464
|
+
elements.push(...contentElements);
|
|
465
|
+
}
|
|
466
|
+
if (state === 'streaming') {
|
|
467
|
+
elements.push(INTERRUPT_BUTTON_V2);
|
|
468
|
+
}
|
|
469
|
+
elements.push(...buildRuntimeControlElements(runtimeIdentity));
|
|
470
|
+
if (SCHEMA2_NOTE_MAP[state]) {
|
|
471
|
+
elements.push({
|
|
472
|
+
tag: 'markdown',
|
|
473
|
+
content: SCHEMA2_NOTE_MAP[state],
|
|
474
|
+
text_size: 'notation',
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
if (footerNote) {
|
|
478
|
+
elements.push({
|
|
479
|
+
tag: 'markdown',
|
|
480
|
+
content: '---',
|
|
481
|
+
text_size: 'notation',
|
|
482
|
+
});
|
|
483
|
+
elements.push({
|
|
484
|
+
tag: 'markdown',
|
|
485
|
+
content: `*${footerNote}*`,
|
|
486
|
+
text_size: 'notation',
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
return {
|
|
490
|
+
schema: '2.0',
|
|
491
|
+
config: {
|
|
492
|
+
wide_screen_mode: true,
|
|
493
|
+
summary: { content: displayTitle },
|
|
494
|
+
},
|
|
495
|
+
body: { elements },
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
// ─── Usage Note Formatter ─────────────────────────────────────
|
|
499
|
+
// ─── Streaming Mode Card Builder ──────────────────────────────
|
|
500
|
+
function buildStreamingModeCard(initialText, runtimeIdentity) {
|
|
501
|
+
const { title } = extractTitleAndBody(initialText);
|
|
502
|
+
const displayTitle = title || '...';
|
|
503
|
+
return {
|
|
504
|
+
schema: '2.0',
|
|
505
|
+
config: {
|
|
506
|
+
wide_screen_mode: true,
|
|
507
|
+
summary: { content: displayTitle },
|
|
508
|
+
streaming_mode: true,
|
|
509
|
+
streaming_config: STREAMING_CONFIG,
|
|
510
|
+
},
|
|
511
|
+
body: {
|
|
512
|
+
elements: [
|
|
513
|
+
{
|
|
514
|
+
tag: 'markdown',
|
|
515
|
+
content: '',
|
|
516
|
+
element_id: ELEMENT_IDS.AUX_BEFORE,
|
|
517
|
+
text_size: 'notation',
|
|
518
|
+
},
|
|
519
|
+
{
|
|
520
|
+
tag: 'markdown',
|
|
521
|
+
content: initialText || '...',
|
|
522
|
+
element_id: ELEMENT_IDS.MAIN_CONTENT,
|
|
523
|
+
text_size: 'normal_text',
|
|
524
|
+
},
|
|
525
|
+
{
|
|
526
|
+
tag: 'markdown',
|
|
527
|
+
content: '',
|
|
528
|
+
element_id: ELEMENT_IDS.AUX_AFTER,
|
|
529
|
+
text_size: 'notation',
|
|
530
|
+
},
|
|
531
|
+
{
|
|
532
|
+
tag: 'button',
|
|
533
|
+
text: { tag: 'plain_text', content: '⏹ 中断回复' },
|
|
534
|
+
type: 'danger',
|
|
535
|
+
value: { action: 'interrupt_stream' },
|
|
536
|
+
element_id: ELEMENT_IDS.INTERRUPT_BTN,
|
|
537
|
+
},
|
|
538
|
+
...buildRuntimeControlElements(runtimeIdentity),
|
|
539
|
+
{
|
|
540
|
+
tag: 'markdown',
|
|
541
|
+
content: '⏳ 生成中...',
|
|
542
|
+
element_id: ELEMENT_IDS.STATUS_NOTE,
|
|
543
|
+
text_size: 'notation',
|
|
544
|
+
},
|
|
545
|
+
],
|
|
546
|
+
},
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Serialize auxiliary element array into a single markdown string.
|
|
551
|
+
* Reuses output from buildAuxiliaryElements().
|
|
552
|
+
*/
|
|
553
|
+
function serializeAuxContent(elements) {
|
|
554
|
+
return elements
|
|
555
|
+
.map((e) => e.content || '')
|
|
556
|
+
.filter(Boolean)
|
|
557
|
+
.join('\n\n');
|
|
558
|
+
}
|
|
559
|
+
// ─── Flush Controller ─────────────────────────────────────────
|
|
560
|
+
class FlushController {
|
|
561
|
+
timer = null;
|
|
562
|
+
lastFlushTime = 0;
|
|
563
|
+
lastFlushedLength = 0;
|
|
564
|
+
pendingFlush = null;
|
|
565
|
+
/** Minimum interval between flushes (ms) */
|
|
566
|
+
minInterval;
|
|
567
|
+
/** Minimum text change to trigger a flush (chars) */
|
|
568
|
+
minDelta;
|
|
569
|
+
constructor(minInterval = 1200, minDelta = 50) {
|
|
570
|
+
this.minInterval = minInterval;
|
|
571
|
+
this.minDelta = minDelta;
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Schedule a flush. If a flush is already pending, replace it.
|
|
575
|
+
* The flush function will be called after the minimum interval.
|
|
576
|
+
*/
|
|
577
|
+
schedule(currentLength, flushFn) {
|
|
578
|
+
// Check text change threshold
|
|
579
|
+
if (currentLength - this.lastFlushedLength < this.minDelta) {
|
|
580
|
+
// Still schedule in case no more text comes (ensure eventual flush)
|
|
581
|
+
if (!this.timer) {
|
|
582
|
+
this.pendingFlush = flushFn;
|
|
583
|
+
this.timer = setTimeout(() => {
|
|
584
|
+
this.timer = null;
|
|
585
|
+
this.executeFlush();
|
|
586
|
+
}, this.minInterval);
|
|
587
|
+
}
|
|
588
|
+
else {
|
|
589
|
+
this.pendingFlush = flushFn;
|
|
590
|
+
}
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
// Enough text change — schedule or execute
|
|
594
|
+
this.pendingFlush = flushFn;
|
|
595
|
+
const elapsed = Date.now() - this.lastFlushTime;
|
|
596
|
+
if (elapsed >= this.minInterval) {
|
|
597
|
+
// Can flush immediately
|
|
598
|
+
this.clearTimer();
|
|
599
|
+
this.executeFlush();
|
|
600
|
+
}
|
|
601
|
+
else if (!this.timer) {
|
|
602
|
+
// Schedule for remaining interval
|
|
603
|
+
this.timer = setTimeout(() => {
|
|
604
|
+
this.timer = null;
|
|
605
|
+
this.executeFlush();
|
|
606
|
+
}, this.minInterval - elapsed);
|
|
607
|
+
}
|
|
608
|
+
// else: timer already running, will pick up pendingFlush
|
|
609
|
+
}
|
|
610
|
+
/** Force flush immediately (for complete/abort) */
|
|
611
|
+
async forceFlush(flushFn) {
|
|
612
|
+
this.clearTimer();
|
|
613
|
+
this.pendingFlush = flushFn;
|
|
614
|
+
await this.executeFlush();
|
|
615
|
+
}
|
|
616
|
+
async executeFlush() {
|
|
617
|
+
const fn = this.pendingFlush;
|
|
618
|
+
this.pendingFlush = null;
|
|
619
|
+
if (!fn)
|
|
620
|
+
return;
|
|
621
|
+
this.lastFlushTime = Date.now();
|
|
622
|
+
try {
|
|
623
|
+
await fn();
|
|
624
|
+
}
|
|
625
|
+
catch (err) {
|
|
626
|
+
logger.debug({ err }, 'FlushController: flush failed');
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
markFlushed(length) {
|
|
630
|
+
this.lastFlushedLength = length;
|
|
631
|
+
}
|
|
632
|
+
clearTimer() {
|
|
633
|
+
if (this.timer) {
|
|
634
|
+
clearTimeout(this.timer);
|
|
635
|
+
this.timer = null;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
dispose() {
|
|
639
|
+
this.clearTimer();
|
|
640
|
+
this.pendingFlush = null;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
// ─── CardKit Backend ──────────────────────────────────────────
|
|
644
|
+
function quickHash(data) {
|
|
645
|
+
return createHash('md5').update(data).digest('hex');
|
|
646
|
+
}
|
|
647
|
+
class CardKitBackend {
|
|
648
|
+
cardId = null;
|
|
649
|
+
_messageId = null;
|
|
650
|
+
sequence = 0;
|
|
651
|
+
lastContentHash = '';
|
|
652
|
+
client;
|
|
653
|
+
constructor(client) {
|
|
654
|
+
this.client = client;
|
|
655
|
+
}
|
|
656
|
+
get messageId() {
|
|
657
|
+
return this._messageId;
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* Create a CardKit card instance.
|
|
661
|
+
* Returns the card_id for subsequent updates.
|
|
662
|
+
*/
|
|
663
|
+
async createCard(cardJson) {
|
|
664
|
+
const resp = await this.client.cardkit.v1.card.create({
|
|
665
|
+
data: {
|
|
666
|
+
type: 'card_json',
|
|
667
|
+
data: JSON.stringify(cardJson),
|
|
668
|
+
},
|
|
669
|
+
});
|
|
670
|
+
const cardId = resp?.data?.card_id;
|
|
671
|
+
if (!cardId) {
|
|
672
|
+
const code = resp?.code;
|
|
673
|
+
const msg = resp?.msg;
|
|
674
|
+
throw new Error(`CardKit card.create returned no card_id (code=${code}, msg=${msg})`);
|
|
675
|
+
}
|
|
676
|
+
this.cardId = cardId;
|
|
677
|
+
this.sequence = 1;
|
|
678
|
+
this.lastContentHash = quickHash(JSON.stringify(cardJson));
|
|
679
|
+
logger.debug({ cardId }, 'CardKit card created');
|
|
680
|
+
return cardId;
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* Send the card as a message (referencing card_id).
|
|
684
|
+
* Returns the message_id.
|
|
685
|
+
*/
|
|
686
|
+
async sendCard(chatId, replyToMsgId) {
|
|
687
|
+
if (!this.cardId) {
|
|
688
|
+
throw new Error('Cannot sendCard before createCard');
|
|
689
|
+
}
|
|
690
|
+
const content = JSON.stringify({
|
|
691
|
+
type: 'card',
|
|
692
|
+
data: { card_id: this.cardId },
|
|
693
|
+
});
|
|
694
|
+
let resp;
|
|
695
|
+
if (replyToMsgId) {
|
|
696
|
+
resp = await this.client.im.message.reply({
|
|
697
|
+
path: { message_id: replyToMsgId },
|
|
698
|
+
data: { content, msg_type: 'interactive' },
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
else {
|
|
702
|
+
resp = await this.client.im.v1.message.create({
|
|
703
|
+
params: { receive_id_type: 'chat_id' },
|
|
704
|
+
data: {
|
|
705
|
+
receive_id: chatId,
|
|
706
|
+
msg_type: 'interactive',
|
|
707
|
+
content,
|
|
708
|
+
},
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
const messageId = resp?.data?.message_id;
|
|
712
|
+
if (!messageId) {
|
|
713
|
+
throw new Error('No message_id in sendCard response');
|
|
714
|
+
}
|
|
715
|
+
this._messageId = messageId;
|
|
716
|
+
return messageId;
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* Update the card via CardKit card.update with sequence-based optimistic locking.
|
|
720
|
+
* Skips if content hash is unchanged.
|
|
721
|
+
*/
|
|
722
|
+
async updateCard(cardJson) {
|
|
723
|
+
if (!this.cardId)
|
|
724
|
+
return;
|
|
725
|
+
const dataStr = JSON.stringify(cardJson);
|
|
726
|
+
const hash = quickHash(dataStr);
|
|
727
|
+
if (hash === this.lastContentHash)
|
|
728
|
+
return; // no change
|
|
729
|
+
this.sequence++;
|
|
730
|
+
await this.client.cardkit.v1.card.update({
|
|
731
|
+
path: { card_id: this.cardId },
|
|
732
|
+
data: {
|
|
733
|
+
card: { type: 'card_json', data: dataStr },
|
|
734
|
+
sequence: this.sequence,
|
|
735
|
+
},
|
|
736
|
+
});
|
|
737
|
+
this.lastContentHash = hash;
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* Adopt an existing card_id + messageId (for degradation from streaming mode).
|
|
741
|
+
*/
|
|
742
|
+
adoptCard(cardId, messageId, sequence) {
|
|
743
|
+
this.cardId = cardId;
|
|
744
|
+
this._messageId = messageId;
|
|
745
|
+
this.sequence = sequence;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
// ─── Streaming Mode Backend ───────────────────────────────────
|
|
749
|
+
class StreamingModeBackend {
|
|
750
|
+
cardId = null;
|
|
751
|
+
_messageId = null;
|
|
752
|
+
sequence = 0;
|
|
753
|
+
lastMainHash = '';
|
|
754
|
+
lastAuxBeforeHash = '';
|
|
755
|
+
lastAuxAfterHash = '';
|
|
756
|
+
client;
|
|
757
|
+
constructor(client) {
|
|
758
|
+
this.client = client;
|
|
759
|
+
}
|
|
760
|
+
get messageId() {
|
|
761
|
+
return this._messageId;
|
|
762
|
+
}
|
|
763
|
+
getCardId() {
|
|
764
|
+
return this.cardId;
|
|
765
|
+
}
|
|
766
|
+
getSequence() {
|
|
767
|
+
return this.sequence;
|
|
768
|
+
}
|
|
769
|
+
nextSequence() {
|
|
770
|
+
return ++this.sequence;
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* Create a CardKit card instance with streaming_mode enabled.
|
|
774
|
+
*/
|
|
775
|
+
async createCard(cardJson) {
|
|
776
|
+
const resp = await this.client.cardkit.v1.card.create({
|
|
777
|
+
data: {
|
|
778
|
+
type: 'card_json',
|
|
779
|
+
data: JSON.stringify(cardJson),
|
|
780
|
+
},
|
|
781
|
+
});
|
|
782
|
+
const cardId = resp?.data?.card_id;
|
|
783
|
+
if (!cardId) {
|
|
784
|
+
const code = resp?.code;
|
|
785
|
+
const msg = resp?.msg;
|
|
786
|
+
throw new Error(`Streaming card.create returned no card_id (code=${code}, msg=${msg})`);
|
|
787
|
+
}
|
|
788
|
+
this.cardId = cardId;
|
|
789
|
+
this.sequence = 1;
|
|
790
|
+
logger.debug({ cardId }, 'Streaming mode card created');
|
|
791
|
+
return cardId;
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* Send the card as a message. Returns message_id.
|
|
795
|
+
*/
|
|
796
|
+
async sendCard(chatId, replyToMsgId) {
|
|
797
|
+
if (!this.cardId)
|
|
798
|
+
throw new Error('Cannot sendCard before createCard');
|
|
799
|
+
const content = JSON.stringify({
|
|
800
|
+
type: 'card',
|
|
801
|
+
data: { card_id: this.cardId },
|
|
802
|
+
});
|
|
803
|
+
let resp;
|
|
804
|
+
if (replyToMsgId) {
|
|
805
|
+
resp = await this.client.im.message.reply({
|
|
806
|
+
path: { message_id: replyToMsgId },
|
|
807
|
+
data: { content, msg_type: 'interactive' },
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
else {
|
|
811
|
+
resp = await this.client.im.v1.message.create({
|
|
812
|
+
params: { receive_id_type: 'chat_id' },
|
|
813
|
+
data: { receive_id: chatId, msg_type: 'interactive', content },
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
const messageId = resp?.data?.message_id;
|
|
817
|
+
if (!messageId)
|
|
818
|
+
throw new Error('No message_id in streaming sendCard response');
|
|
819
|
+
this._messageId = messageId;
|
|
820
|
+
return messageId;
|
|
821
|
+
}
|
|
822
|
+
/**
|
|
823
|
+
* Stream text content via cardElement.content() — platform renders typewriter effect.
|
|
824
|
+
* MD5 dedup to avoid redundant pushes.
|
|
825
|
+
* Auto-retries once on streaming timeout/closed errors.
|
|
826
|
+
*/
|
|
827
|
+
async streamContent(text) {
|
|
828
|
+
if (!this.cardId)
|
|
829
|
+
return;
|
|
830
|
+
// Truncate at 100K char limit (hint at end, slice adjusted for hint length)
|
|
831
|
+
const truncHint = `\n\n> ⚠️ 输出已截断(超过 ${MAX_STREAMING_CONTENT} 字符)`;
|
|
832
|
+
const content = text.length > MAX_STREAMING_CONTENT
|
|
833
|
+
? text.slice(0, MAX_STREAMING_CONTENT - truncHint.length) + truncHint
|
|
834
|
+
: text;
|
|
835
|
+
const hash = quickHash(content);
|
|
836
|
+
if (hash === this.lastMainHash)
|
|
837
|
+
return;
|
|
838
|
+
try {
|
|
839
|
+
await this.client.cardkit.v1.cardElement.content({
|
|
840
|
+
path: { card_id: this.cardId, element_id: ELEMENT_IDS.MAIN_CONTENT },
|
|
841
|
+
data: { content, sequence: this.nextSequence() },
|
|
842
|
+
});
|
|
843
|
+
this.lastMainHash = hash;
|
|
844
|
+
}
|
|
845
|
+
catch (err) {
|
|
846
|
+
const code = err?.code ?? err?.response?.data?.code;
|
|
847
|
+
// 200850 = streaming timeout, 300309 = streaming closed
|
|
848
|
+
if (code === 200850 || code === 300309) {
|
|
849
|
+
logger.info({ code, cardId: this.cardId }, 'Streaming mode expired, re-enabling');
|
|
850
|
+
await this.enableStreamingMode();
|
|
851
|
+
// Retry once
|
|
852
|
+
await this.client.cardkit.v1.cardElement.content({
|
|
853
|
+
path: { card_id: this.cardId, element_id: ELEMENT_IDS.MAIN_CONTENT },
|
|
854
|
+
data: { content, sequence: this.nextSequence() },
|
|
855
|
+
});
|
|
856
|
+
this.lastMainHash = hash;
|
|
857
|
+
}
|
|
858
|
+
else {
|
|
859
|
+
throw err;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* Update an auxiliary element via cardElement.update() — instant replacement.
|
|
865
|
+
*/
|
|
866
|
+
async updateAuxiliary(elementId, content) {
|
|
867
|
+
if (!this.cardId)
|
|
868
|
+
return;
|
|
869
|
+
const hash = quickHash(content);
|
|
870
|
+
const hashField = elementId === ELEMENT_IDS.AUX_BEFORE
|
|
871
|
+
? 'lastAuxBeforeHash'
|
|
872
|
+
: 'lastAuxAfterHash';
|
|
873
|
+
if (hash === this[hashField])
|
|
874
|
+
return;
|
|
875
|
+
const element = JSON.stringify({
|
|
876
|
+
tag: 'markdown',
|
|
877
|
+
content,
|
|
878
|
+
element_id: elementId,
|
|
879
|
+
text_size: 'notation',
|
|
880
|
+
});
|
|
881
|
+
await this.client.cardkit.v1.cardElement.update({
|
|
882
|
+
path: { card_id: this.cardId, element_id: elementId },
|
|
883
|
+
data: { element, sequence: this.nextSequence() },
|
|
884
|
+
});
|
|
885
|
+
this[hashField] = hash;
|
|
886
|
+
}
|
|
887
|
+
/**
|
|
888
|
+
* Enable streaming mode via card.settings().
|
|
889
|
+
*/
|
|
890
|
+
async enableStreamingMode() {
|
|
891
|
+
if (!this.cardId)
|
|
892
|
+
return;
|
|
893
|
+
await this.client.cardkit.v1.card.settings({
|
|
894
|
+
path: { card_id: this.cardId },
|
|
895
|
+
data: {
|
|
896
|
+
settings: JSON.stringify({
|
|
897
|
+
config: {
|
|
898
|
+
streaming_mode: true,
|
|
899
|
+
streaming_config: STREAMING_CONFIG,
|
|
900
|
+
},
|
|
901
|
+
}),
|
|
902
|
+
sequence: this.nextSequence(),
|
|
903
|
+
},
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
/**
|
|
907
|
+
* Disable streaming mode via card.settings().
|
|
908
|
+
*/
|
|
909
|
+
async disableStreamingMode() {
|
|
910
|
+
if (!this.cardId)
|
|
911
|
+
return;
|
|
912
|
+
await this.client.cardkit.v1.card.settings({
|
|
913
|
+
path: { card_id: this.cardId },
|
|
914
|
+
data: {
|
|
915
|
+
settings: JSON.stringify({
|
|
916
|
+
config: { streaming_mode: false },
|
|
917
|
+
}),
|
|
918
|
+
sequence: this.nextSequence(),
|
|
919
|
+
},
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
/**
|
|
923
|
+
* Full card update (used for final state after disabling streaming).
|
|
924
|
+
*/
|
|
925
|
+
async updateCardFull(cardJson) {
|
|
926
|
+
if (!this.cardId)
|
|
927
|
+
return;
|
|
928
|
+
await this.client.cardkit.v1.card.update({
|
|
929
|
+
path: { card_id: this.cardId },
|
|
930
|
+
data: {
|
|
931
|
+
card: { type: 'card_json', data: JSON.stringify(cardJson) },
|
|
932
|
+
sequence: this.nextSequence(),
|
|
933
|
+
},
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
// ─── Multi-Card Manager ───────────────────────────────────────
|
|
938
|
+
class MultiCardManager {
|
|
939
|
+
cards = [];
|
|
940
|
+
client;
|
|
941
|
+
chatId;
|
|
942
|
+
replyToMsgId;
|
|
943
|
+
onCardCreated;
|
|
944
|
+
cardIndex = 0;
|
|
945
|
+
MAX_ELEMENTS = 45; // safety margin (Feishu limit ~50)
|
|
946
|
+
constructor(client, chatId, replyToMsgId, onCardCreated) {
|
|
947
|
+
this.client = client;
|
|
948
|
+
this.chatId = chatId;
|
|
949
|
+
this.replyToMsgId = replyToMsgId;
|
|
950
|
+
this.onCardCreated = onCardCreated;
|
|
951
|
+
}
|
|
952
|
+
getCardCount() {
|
|
953
|
+
return this.cards.length;
|
|
954
|
+
}
|
|
955
|
+
/**
|
|
956
|
+
* Create the first card and send it as a message.
|
|
957
|
+
* Returns the initial messageId.
|
|
958
|
+
*/
|
|
959
|
+
async initialize(initialText, runtimeIdentity) {
|
|
960
|
+
const card = new CardKitBackend(this.client);
|
|
961
|
+
const cardJson = buildSchema2Card(initialText, 'streaming', '', undefined, undefined, undefined, runtimeIdentity);
|
|
962
|
+
await card.createCard(cardJson);
|
|
963
|
+
const messageId = await card.sendCard(this.chatId, this.replyToMsgId);
|
|
964
|
+
this.cards.push(card);
|
|
965
|
+
this.cardIndex = 0;
|
|
966
|
+
return messageId;
|
|
967
|
+
}
|
|
968
|
+
/**
|
|
969
|
+
* Adopt an existing card (for degradation from streaming mode, avoids creating a new message).
|
|
970
|
+
*/
|
|
971
|
+
adoptExistingCard(card) {
|
|
972
|
+
this.cards.push(card);
|
|
973
|
+
this.cardIndex = 0;
|
|
974
|
+
}
|
|
975
|
+
/**
|
|
976
|
+
* Commit content: update the current card, auto-splitting if needed.
|
|
977
|
+
*/
|
|
978
|
+
async commitContent(text, state, auxiliaryState, footerNote, runtimeIdentity) {
|
|
979
|
+
const titlePrefix = this.cardIndex > 0 ? '(续) ' : '';
|
|
980
|
+
// Estimate element count: content + auxiliary + fixed elements
|
|
981
|
+
const { contentElements } = buildCardContent(text, splitCodeBlockSafe);
|
|
982
|
+
const auxCount = auxiliaryState
|
|
983
|
+
? (() => {
|
|
984
|
+
const { before, after } = buildAuxiliaryElementsForState(auxiliaryState, state);
|
|
985
|
+
return before.length + after.length;
|
|
986
|
+
})()
|
|
987
|
+
: 0;
|
|
988
|
+
const runtimeControlCount = buildRuntimeControlElements(runtimeIdentity).length;
|
|
989
|
+
const fixedCount = (state === 'streaming' ? 1 : 0) + // button
|
|
990
|
+
runtimeControlCount + // runtime controls
|
|
991
|
+
(SCHEMA2_NOTE_MAP[state] ? 1 : 0) + // note
|
|
992
|
+
(footerNote ? 1 : 0); // footer
|
|
993
|
+
const totalElements = contentElements.length + auxCount + fixedCount;
|
|
994
|
+
if (totalElements > this.MAX_ELEMENTS && state === 'streaming') {
|
|
995
|
+
// Need to split: freeze current card and create a new one
|
|
996
|
+
await this.splitToNewCard(text, runtimeIdentity);
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
// Normal update on current card
|
|
1000
|
+
const currentCard = this.cards[this.cards.length - 1];
|
|
1001
|
+
if (!currentCard)
|
|
1002
|
+
return;
|
|
1003
|
+
const cardJson = buildSchema2Card(text, state, titlePrefix, undefined, auxiliaryState, footerNote, runtimeIdentity);
|
|
1004
|
+
// Byte size check (Feishu limit ~30KB, use 25KB safety margin)
|
|
1005
|
+
const cardSize = Buffer.byteLength(JSON.stringify(cardJson), 'utf-8');
|
|
1006
|
+
if (cardSize > CARD_SIZE_LIMIT && state === 'streaming') {
|
|
1007
|
+
await this.splitToNewCard(text, runtimeIdentity);
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
await currentCard.updateCard(cardJson);
|
|
1011
|
+
}
|
|
1012
|
+
/**
|
|
1013
|
+
* Split content across cards when element limit is reached.
|
|
1014
|
+
*/
|
|
1015
|
+
async splitToNewCard(text, runtimeIdentity) {
|
|
1016
|
+
const currentCard = this.cards[this.cards.length - 1];
|
|
1017
|
+
if (!currentCard)
|
|
1018
|
+
return;
|
|
1019
|
+
// Extract title once so all sub-cards share the same title
|
|
1020
|
+
const { title: consistentTitle } = extractTitleAndBody(text);
|
|
1021
|
+
// Determine how much content the current card can hold
|
|
1022
|
+
const maxChunksPerCard = this.MAX_ELEMENTS - 3; // reserve for fixed elements
|
|
1023
|
+
const chunks = splitCodeBlockSafe(text, CARD_MD_LIMIT);
|
|
1024
|
+
// Content for the current (frozen) card
|
|
1025
|
+
const frozenChunks = chunks.slice(0, maxChunksPerCard);
|
|
1026
|
+
const frozenText = frozenChunks.join('\n\n');
|
|
1027
|
+
const titlePrefix = this.cardIndex > 0 ? '(续) ' : '';
|
|
1028
|
+
// Freeze current card with consistent title
|
|
1029
|
+
const frozenCard = buildSchema2Card(frozenText, 'frozen', titlePrefix, consistentTitle, undefined, undefined, runtimeIdentity);
|
|
1030
|
+
await currentCard.updateCard(frozenCard);
|
|
1031
|
+
// Create new card for remaining content
|
|
1032
|
+
this.cardIndex++;
|
|
1033
|
+
const newTitlePrefix = '(续) ';
|
|
1034
|
+
const remainingChunks = chunks.slice(maxChunksPerCard);
|
|
1035
|
+
const remainingText = remainingChunks.join('\n\n');
|
|
1036
|
+
const newCard = new CardKitBackend(this.client);
|
|
1037
|
+
const newCardJson = buildSchema2Card(remainingText || '...', 'streaming', newTitlePrefix, consistentTitle, undefined, undefined, runtimeIdentity);
|
|
1038
|
+
await newCard.createCard(newCardJson);
|
|
1039
|
+
// New card is sent as a fresh message (not reply)
|
|
1040
|
+
const newMessageId = await newCard.sendCard(this.chatId);
|
|
1041
|
+
this.cards.push(newCard);
|
|
1042
|
+
// Register the new card's messageId for interrupt button routing
|
|
1043
|
+
this.onCardCreated?.(newMessageId);
|
|
1044
|
+
}
|
|
1045
|
+
getAllMessageIds() {
|
|
1046
|
+
return this.cards
|
|
1047
|
+
.map((c) => c.messageId)
|
|
1048
|
+
.filter((id) => id !== null);
|
|
1049
|
+
}
|
|
1050
|
+
getLatestMessageId() {
|
|
1051
|
+
for (let i = this.cards.length - 1; i >= 0; i--) {
|
|
1052
|
+
if (this.cards[i].messageId)
|
|
1053
|
+
return this.cards[i].messageId;
|
|
1054
|
+
}
|
|
1055
|
+
return null;
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
// ─── Streaming Card Controller ────────────────────────────────
|
|
1059
|
+
export class StreamingCardController {
|
|
1060
|
+
state = 'idle';
|
|
1061
|
+
messageId = null;
|
|
1062
|
+
accumulatedText = '';
|
|
1063
|
+
flushCtrl;
|
|
1064
|
+
patchFailCount = 0;
|
|
1065
|
+
maxPatchFailures = 2;
|
|
1066
|
+
client;
|
|
1067
|
+
chatId;
|
|
1068
|
+
replyToMsgId;
|
|
1069
|
+
onFallback;
|
|
1070
|
+
onCardCreated;
|
|
1071
|
+
// CardKit mode
|
|
1072
|
+
useCardKit = false;
|
|
1073
|
+
multiCard = null;
|
|
1074
|
+
// Streaming mode (Level 0)
|
|
1075
|
+
streamingBackend = null;
|
|
1076
|
+
textFlushCtrl = null;
|
|
1077
|
+
auxFlushCtrl = null;
|
|
1078
|
+
lastAuxSnapshot = '';
|
|
1079
|
+
// Streaming state
|
|
1080
|
+
thinking = false;
|
|
1081
|
+
thinkingText = '';
|
|
1082
|
+
toolCalls = new Map();
|
|
1083
|
+
startTime = 0;
|
|
1084
|
+
backendMode = 'v1';
|
|
1085
|
+
// Auxiliary display state
|
|
1086
|
+
systemStatus = null;
|
|
1087
|
+
activeHook = null;
|
|
1088
|
+
todos = null;
|
|
1089
|
+
recentEvents = [];
|
|
1090
|
+
stateVersion = 0;
|
|
1091
|
+
footerRuntimeIdentity = null;
|
|
1092
|
+
footerTokenUsage = null;
|
|
1093
|
+
constructor(opts) {
|
|
1094
|
+
this.client = opts.client;
|
|
1095
|
+
this.chatId = opts.chatId;
|
|
1096
|
+
this.replyToMsgId = opts.replyToMsgId;
|
|
1097
|
+
this.onFallback = opts.onFallback;
|
|
1098
|
+
this.onCardCreated = opts.onCardCreated;
|
|
1099
|
+
this.flushCtrl = new FlushController();
|
|
1100
|
+
}
|
|
1101
|
+
get currentState() {
|
|
1102
|
+
return this.state;
|
|
1103
|
+
}
|
|
1104
|
+
get currentMessageId() {
|
|
1105
|
+
if (this.streamingBackend)
|
|
1106
|
+
return this.streamingBackend.messageId;
|
|
1107
|
+
if (this.multiCard)
|
|
1108
|
+
return this.multiCard.getLatestMessageId();
|
|
1109
|
+
return this.messageId;
|
|
1110
|
+
}
|
|
1111
|
+
isActive() {
|
|
1112
|
+
return this.state === 'streaming' || this.state === 'creating';
|
|
1113
|
+
}
|
|
1114
|
+
/**
|
|
1115
|
+
* Get all messageIds across all cards (for multi-card cleanup).
|
|
1116
|
+
*/
|
|
1117
|
+
getAllMessageIds() {
|
|
1118
|
+
if (this.streamingBackend?.messageId)
|
|
1119
|
+
return [this.streamingBackend.messageId];
|
|
1120
|
+
if (this.multiCard)
|
|
1121
|
+
return this.multiCard.getAllMessageIds();
|
|
1122
|
+
return this.messageId ? [this.messageId] : [];
|
|
1123
|
+
}
|
|
1124
|
+
/**
|
|
1125
|
+
* Signal that the agent is in thinking state (before text arrives).
|
|
1126
|
+
*/
|
|
1127
|
+
setThinking() {
|
|
1128
|
+
this.thinking = true;
|
|
1129
|
+
if (this.state === 'idle') {
|
|
1130
|
+
// Create card immediately with thinking placeholder
|
|
1131
|
+
this.state = 'creating';
|
|
1132
|
+
this.createInitialCard().catch((err) => {
|
|
1133
|
+
logger.warn({ err, chatId: this.chatId }, 'Streaming card: initial create failed (thinking), will use fallback');
|
|
1134
|
+
this.state = 'error';
|
|
1135
|
+
this.onFallback?.();
|
|
1136
|
+
});
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
/**
|
|
1140
|
+
* Signal that a tool has started executing.
|
|
1141
|
+
*/
|
|
1142
|
+
startTool(toolId, toolName) {
|
|
1143
|
+
this.toolCalls.set(toolId, {
|
|
1144
|
+
name: toolName,
|
|
1145
|
+
status: 'running',
|
|
1146
|
+
startTime: Date.now(),
|
|
1147
|
+
});
|
|
1148
|
+
this.stateVersion++;
|
|
1149
|
+
if (this.state === 'streaming') {
|
|
1150
|
+
this.backendMode === 'streaming'
|
|
1151
|
+
? this.scheduleAuxFlush()
|
|
1152
|
+
: this.schedulePatch();
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
/**
|
|
1156
|
+
* Signal that a tool has finished executing.
|
|
1157
|
+
*/
|
|
1158
|
+
endTool(toolId, isError) {
|
|
1159
|
+
const tc = this.toolCalls.get(toolId);
|
|
1160
|
+
if (tc) {
|
|
1161
|
+
tc.status = isError ? 'error' : 'complete';
|
|
1162
|
+
this.stateVersion++;
|
|
1163
|
+
this.purgeOldTools();
|
|
1164
|
+
if (this.state === 'streaming') {
|
|
1165
|
+
this.backendMode === 'streaming'
|
|
1166
|
+
? this.scheduleAuxFlush()
|
|
1167
|
+
: this.schedulePatch();
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
/**
|
|
1172
|
+
* Purge completed/error tools older than MAX_COMPLETED_TOOL_AGE to prevent unbounded growth.
|
|
1173
|
+
*/
|
|
1174
|
+
purgeOldTools() {
|
|
1175
|
+
const cutoff = Date.now() - MAX_COMPLETED_TOOL_AGE;
|
|
1176
|
+
for (const [id, tc] of this.toolCalls) {
|
|
1177
|
+
if (tc.status !== 'running' && tc.startTime < cutoff) {
|
|
1178
|
+
this.toolCalls.delete(id);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
/**
|
|
1183
|
+
* Append thinking text (accumulated, tail-truncated at MAX_THINKING_CHARS).
|
|
1184
|
+
*/
|
|
1185
|
+
appendThinking(text) {
|
|
1186
|
+
this.thinkingText += text;
|
|
1187
|
+
if (this.thinkingText.length > MAX_THINKING_CHARS) {
|
|
1188
|
+
this.thinkingText =
|
|
1189
|
+
'...' + this.thinkingText.slice(-(MAX_THINKING_CHARS - 3));
|
|
1190
|
+
}
|
|
1191
|
+
this.thinking = true;
|
|
1192
|
+
this.stateVersion++;
|
|
1193
|
+
if (this.state === 'idle') {
|
|
1194
|
+
this.state = 'creating';
|
|
1195
|
+
this.createInitialCard().catch((err) => {
|
|
1196
|
+
logger.warn({ err, chatId: this.chatId }, 'Streaming card: initial create failed (thinking), will use fallback');
|
|
1197
|
+
this.state = 'error';
|
|
1198
|
+
this.onFallback?.();
|
|
1199
|
+
});
|
|
1200
|
+
}
|
|
1201
|
+
else if (this.state === 'streaming') {
|
|
1202
|
+
this.backendMode === 'streaming'
|
|
1203
|
+
? this.scheduleAuxFlush()
|
|
1204
|
+
: this.schedulePatch();
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
/**
|
|
1208
|
+
* Set or clear system status text (e.g. "上下文压缩中").
|
|
1209
|
+
*/
|
|
1210
|
+
setSystemStatus(status) {
|
|
1211
|
+
this.systemStatus = status;
|
|
1212
|
+
this.stateVersion++;
|
|
1213
|
+
if (this.state === 'streaming') {
|
|
1214
|
+
this.backendMode === 'streaming'
|
|
1215
|
+
? this.scheduleAuxFlush()
|
|
1216
|
+
: this.schedulePatch();
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
/**
|
|
1220
|
+
* Set or clear active hook state.
|
|
1221
|
+
*/
|
|
1222
|
+
setHook(hook) {
|
|
1223
|
+
this.activeHook = hook;
|
|
1224
|
+
this.stateVersion++;
|
|
1225
|
+
if (this.state === 'streaming') {
|
|
1226
|
+
this.backendMode === 'streaming'
|
|
1227
|
+
? this.scheduleAuxFlush()
|
|
1228
|
+
: this.schedulePatch();
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
/**
|
|
1232
|
+
* Set the todo list for progress panel display.
|
|
1233
|
+
*/
|
|
1234
|
+
setTodos(todos) {
|
|
1235
|
+
this.todos = todos;
|
|
1236
|
+
this.stateVersion++;
|
|
1237
|
+
if (this.state === 'streaming') {
|
|
1238
|
+
this.backendMode === 'streaming'
|
|
1239
|
+
? this.scheduleAuxFlush()
|
|
1240
|
+
: this.schedulePatch();
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
/**
|
|
1244
|
+
* Push a recent event to the call trace log (FIFO, max MAX_RECENT_EVENTS).
|
|
1245
|
+
* Does NOT trigger schedulePatch — piggybacks on other events.
|
|
1246
|
+
*/
|
|
1247
|
+
pushRecentEvent(text) {
|
|
1248
|
+
this.recentEvents.push({ text });
|
|
1249
|
+
if (this.recentEvents.length > MAX_RECENT_EVENTS) {
|
|
1250
|
+
this.recentEvents = this.recentEvents.slice(-MAX_RECENT_EVENTS);
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
setRuntimeIdentity(identity) {
|
|
1254
|
+
const nextIdentity = identity ?? null;
|
|
1255
|
+
const unchanged = this.footerRuntimeIdentity?.agentType === nextIdentity?.agentType &&
|
|
1256
|
+
this.footerRuntimeIdentity?.model === nextIdentity?.model &&
|
|
1257
|
+
this.footerRuntimeIdentity?.reasoningEffort ===
|
|
1258
|
+
nextIdentity?.reasoningEffort &&
|
|
1259
|
+
this.footerRuntimeIdentity?.supportsReasoningEffort ===
|
|
1260
|
+
nextIdentity?.supportsReasoningEffort;
|
|
1261
|
+
if (unchanged)
|
|
1262
|
+
return;
|
|
1263
|
+
this.footerRuntimeIdentity = nextIdentity;
|
|
1264
|
+
this.stateVersion++;
|
|
1265
|
+
if (this.state === 'streaming') {
|
|
1266
|
+
this.backendMode === 'streaming'
|
|
1267
|
+
? this.scheduleAuxFlush()
|
|
1268
|
+
: this.schedulePatch();
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
/**
|
|
1272
|
+
* Update a tool's input summary (displayed as parameter hint).
|
|
1273
|
+
*/
|
|
1274
|
+
updateToolSummary(toolId, summary) {
|
|
1275
|
+
const tc = this.toolCalls.get(toolId);
|
|
1276
|
+
if (tc) {
|
|
1277
|
+
tc.toolInputSummary = summary;
|
|
1278
|
+
this.stateVersion++;
|
|
1279
|
+
if (this.state === 'streaming') {
|
|
1280
|
+
this.backendMode === 'streaming'
|
|
1281
|
+
? this.scheduleAuxFlush()
|
|
1282
|
+
: this.schedulePatch();
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
/**
|
|
1287
|
+
* Get tool info by ID (for building call trace text).
|
|
1288
|
+
*/
|
|
1289
|
+
getToolInfo(toolId) {
|
|
1290
|
+
const tc = this.toolCalls.get(toolId);
|
|
1291
|
+
return tc ? { name: tc.name } : undefined;
|
|
1292
|
+
}
|
|
1293
|
+
/**
|
|
1294
|
+
* Append text to the streaming card.
|
|
1295
|
+
* Creates the card on first call, then patches on subsequent calls.
|
|
1296
|
+
*/
|
|
1297
|
+
append(text) {
|
|
1298
|
+
this.accumulatedText = text;
|
|
1299
|
+
this.thinking = false; // Text arrived, no longer just thinking
|
|
1300
|
+
if (this.state === 'idle') {
|
|
1301
|
+
this.state = 'creating';
|
|
1302
|
+
this.createInitialCard().catch((err) => {
|
|
1303
|
+
logger.warn({ err, chatId: this.chatId }, 'Streaming card: initial create failed, will use fallback');
|
|
1304
|
+
this.state = 'error';
|
|
1305
|
+
this.onFallback?.();
|
|
1306
|
+
});
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
if (this.state === 'streaming') {
|
|
1310
|
+
this.backendMode === 'streaming'
|
|
1311
|
+
? this.scheduleTextFlush()
|
|
1312
|
+
: this.schedulePatch();
|
|
1313
|
+
}
|
|
1314
|
+
// If 'creating', the text will be picked up after creation completes
|
|
1315
|
+
}
|
|
1316
|
+
/**
|
|
1317
|
+
* Complete the streaming card with final text.
|
|
1318
|
+
*/
|
|
1319
|
+
async complete(finalText) {
|
|
1320
|
+
await this.finalize(finalText, 'completed');
|
|
1321
|
+
}
|
|
1322
|
+
/**
|
|
1323
|
+
* Finalize the streaming card in an error / aborted state while preserving
|
|
1324
|
+
* the final text as the visible body content.
|
|
1325
|
+
*/
|
|
1326
|
+
async fail(finalText) {
|
|
1327
|
+
await this.finalize(finalText, 'aborted');
|
|
1328
|
+
}
|
|
1329
|
+
async finalize(finalText, finalState) {
|
|
1330
|
+
if (this.state !== 'streaming' && this.state !== 'creating')
|
|
1331
|
+
return;
|
|
1332
|
+
const prevState = this.state;
|
|
1333
|
+
this.accumulatedText = finalText;
|
|
1334
|
+
this.state = finalState;
|
|
1335
|
+
this.flushCtrl.dispose();
|
|
1336
|
+
this.textFlushCtrl?.dispose();
|
|
1337
|
+
this.auxFlushCtrl?.dispose();
|
|
1338
|
+
try {
|
|
1339
|
+
if (this.backendMode === 'streaming' && this.streamingBackend) {
|
|
1340
|
+
await this.finalizeStreamingCard(finalState);
|
|
1341
|
+
}
|
|
1342
|
+
else if (this.messageId || this.multiCard) {
|
|
1343
|
+
await this.patchCard(finalState);
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
catch (err) {
|
|
1347
|
+
// Revert state so abort() doesn't bail on the terminal-state check
|
|
1348
|
+
this.state = prevState;
|
|
1349
|
+
throw err;
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
/**
|
|
1353
|
+
* Patch a finalized card to append a usage note at the bottom.
|
|
1354
|
+
* Called AFTER complete()/fail() because agent-runner may emit usage after
|
|
1355
|
+
* the visible final text has already been rendered.
|
|
1356
|
+
*/
|
|
1357
|
+
async patchUsageNote(usage) {
|
|
1358
|
+
const nextUsage = {
|
|
1359
|
+
inputTokens: usage.inputTokens,
|
|
1360
|
+
outputTokens: usage.outputTokens,
|
|
1361
|
+
costUSD: usage.costUSD,
|
|
1362
|
+
durationMs: usage.durationMs,
|
|
1363
|
+
numTurns: usage.numTurns,
|
|
1364
|
+
};
|
|
1365
|
+
const unchanged = this.footerTokenUsage?.inputTokens === nextUsage.inputTokens &&
|
|
1366
|
+
this.footerTokenUsage?.outputTokens === nextUsage.outputTokens &&
|
|
1367
|
+
this.footerTokenUsage?.costUSD === nextUsage.costUSD &&
|
|
1368
|
+
this.footerTokenUsage?.durationMs === nextUsage.durationMs &&
|
|
1369
|
+
this.footerTokenUsage?.numTurns === nextUsage.numTurns;
|
|
1370
|
+
if (unchanged)
|
|
1371
|
+
return;
|
|
1372
|
+
this.footerTokenUsage = nextUsage;
|
|
1373
|
+
// Some runtimes emit usage before the final completed/aborted card patch.
|
|
1374
|
+
// Cache the usage immediately so complete()/finalize() can still render
|
|
1375
|
+
// the footer on the finished card.
|
|
1376
|
+
if (this.state !== 'completed' && this.state !== 'aborted')
|
|
1377
|
+
return;
|
|
1378
|
+
const finalState = this.state;
|
|
1379
|
+
try {
|
|
1380
|
+
if (this.backendMode === 'streaming' && this.streamingBackend) {
|
|
1381
|
+
const cardJson = buildSchema2Card(this.accumulatedText, finalState, '', undefined, this.getAuxiliaryState(), this.getFooterNote(), this.footerRuntimeIdentity);
|
|
1382
|
+
// Skip if card was split during finalization — rebuilding a single card
|
|
1383
|
+
// would overwrite the first card with full text while continuation cards remain.
|
|
1384
|
+
const cardSize = Buffer.byteLength(JSON.stringify(cardJson), 'utf-8');
|
|
1385
|
+
if (cardSize > CARD_SIZE_LIMIT)
|
|
1386
|
+
return;
|
|
1387
|
+
await this.streamingBackend.updateCardFull(cardJson);
|
|
1388
|
+
}
|
|
1389
|
+
else if (this.messageId || this.multiCard) {
|
|
1390
|
+
// For CardKit v1 / legacy: skip if multiCard has split content
|
|
1391
|
+
if (this.multiCard && this.multiCard.getCardCount() > 1)
|
|
1392
|
+
return;
|
|
1393
|
+
await this.patchCard(finalState);
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
catch (err) {
|
|
1397
|
+
logger.debug({ err, chatId: this.chatId }, 'Streaming card: patchUsageNote failed (non-fatal)');
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
/**
|
|
1401
|
+
* Abort the streaming card (e.g., user interrupted).
|
|
1402
|
+
*/
|
|
1403
|
+
async abort(reason) {
|
|
1404
|
+
if (this.state === 'completed' || this.state === 'aborted')
|
|
1405
|
+
return;
|
|
1406
|
+
const wasActive = this.isActive();
|
|
1407
|
+
this.state = 'aborted';
|
|
1408
|
+
this.flushCtrl.dispose();
|
|
1409
|
+
this.textFlushCtrl?.dispose();
|
|
1410
|
+
this.auxFlushCtrl?.dispose();
|
|
1411
|
+
if (reason) {
|
|
1412
|
+
this.accumulatedText += `\n\n---\n*${reason}*`;
|
|
1413
|
+
}
|
|
1414
|
+
if (this.backendMode === 'streaming' &&
|
|
1415
|
+
this.streamingBackend &&
|
|
1416
|
+
wasActive) {
|
|
1417
|
+
try {
|
|
1418
|
+
await this.finalizeStreamingCard('aborted');
|
|
1419
|
+
}
|
|
1420
|
+
catch (err) {
|
|
1421
|
+
logger.debug({ err, chatId: this.chatId }, 'Streaming card: abort finalize failed');
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
else if ((this.messageId || this.multiCard) && wasActive) {
|
|
1425
|
+
try {
|
|
1426
|
+
await this.patchCard('aborted');
|
|
1427
|
+
}
|
|
1428
|
+
catch (err) {
|
|
1429
|
+
logger.debug({ err, chatId: this.chatId }, 'Streaming card: abort patch failed');
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
dispose() {
|
|
1434
|
+
this.flushCtrl.dispose();
|
|
1435
|
+
this.textFlushCtrl?.dispose();
|
|
1436
|
+
this.auxFlushCtrl?.dispose();
|
|
1437
|
+
}
|
|
1438
|
+
// ─── Internal Methods ──────────────────────────────────
|
|
1439
|
+
async createInitialCard() {
|
|
1440
|
+
const initialText = this.accumulatedText || (this.thinking ? '' : '...');
|
|
1441
|
+
// ── Level 0: Try streaming mode (cardElement.content typewriter) ──
|
|
1442
|
+
try {
|
|
1443
|
+
const backend = new StreamingModeBackend(this.client);
|
|
1444
|
+
const cardJson = buildStreamingModeCard(initialText, this.footerRuntimeIdentity);
|
|
1445
|
+
await backend.createCard(cardJson);
|
|
1446
|
+
const messageId = await backend.sendCard(this.chatId, this.replyToMsgId);
|
|
1447
|
+
this.streamingBackend = backend;
|
|
1448
|
+
this.messageId = messageId;
|
|
1449
|
+
this.backendMode = 'streaming';
|
|
1450
|
+
this.useCardKit = true;
|
|
1451
|
+
this.startTime = Date.now();
|
|
1452
|
+
// Streaming mode: 300ms text flush, 800ms aux flush
|
|
1453
|
+
this.textFlushCtrl = new FlushController(300, 30);
|
|
1454
|
+
this.auxFlushCtrl = new FlushController(800, 0);
|
|
1455
|
+
this.maxPatchFailures = 3;
|
|
1456
|
+
logger.debug({ chatId: this.chatId, messageId, mode: 'streaming' }, 'Streaming card created via streaming mode');
|
|
1457
|
+
this.finishCardCreation();
|
|
1458
|
+
return;
|
|
1459
|
+
}
|
|
1460
|
+
catch (streamingErr) {
|
|
1461
|
+
logger.info({ err: streamingErr, chatId: this.chatId }, 'Streaming mode unavailable, falling back to CardKit v1');
|
|
1462
|
+
this.streamingBackend = null;
|
|
1463
|
+
}
|
|
1464
|
+
// ── Level 1: Try CardKit v1 full-update (card.update with full JSON) ──
|
|
1465
|
+
try {
|
|
1466
|
+
this.multiCard = new MultiCardManager(this.client, this.chatId, this.replyToMsgId, this.onCardCreated);
|
|
1467
|
+
const messageId = await this.multiCard.initialize(initialText, this.footerRuntimeIdentity);
|
|
1468
|
+
this.messageId = messageId;
|
|
1469
|
+
this.backendMode = 'v1';
|
|
1470
|
+
this.useCardKit = true;
|
|
1471
|
+
this.startTime = Date.now();
|
|
1472
|
+
// CardKit v1 mode: 1000ms interval, bump failure tolerance
|
|
1473
|
+
this.flushCtrl.dispose();
|
|
1474
|
+
this.flushCtrl = new FlushController(1000, 50);
|
|
1475
|
+
this.maxPatchFailures = 3;
|
|
1476
|
+
logger.debug({ chatId: this.chatId, messageId, mode: 'cardkit-v1' }, 'Streaming card created via CardKit v1');
|
|
1477
|
+
}
|
|
1478
|
+
catch (v1Err) {
|
|
1479
|
+
// ── Level 2: Legacy message.create + message.patch ──
|
|
1480
|
+
logger.info({ err: v1Err, chatId: this.chatId }, 'CardKit full-update unavailable, falling back to message.patch');
|
|
1481
|
+
this.multiCard = null;
|
|
1482
|
+
this.useCardKit = false;
|
|
1483
|
+
this.backendMode = 'legacy';
|
|
1484
|
+
this.startTime = Date.now();
|
|
1485
|
+
await this.createLegacyCard(initialText);
|
|
1486
|
+
return;
|
|
1487
|
+
}
|
|
1488
|
+
// Handle state changes during await (same logic for both paths)
|
|
1489
|
+
this.finishCardCreation();
|
|
1490
|
+
}
|
|
1491
|
+
async createLegacyCard(initialText) {
|
|
1492
|
+
const card = buildStreamingCard(initialText, 'streaming', undefined, this.footerRuntimeIdentity);
|
|
1493
|
+
const content = JSON.stringify(card);
|
|
1494
|
+
try {
|
|
1495
|
+
let resp;
|
|
1496
|
+
if (this.replyToMsgId) {
|
|
1497
|
+
resp = await this.client.im.message.reply({
|
|
1498
|
+
path: { message_id: this.replyToMsgId },
|
|
1499
|
+
data: { content, msg_type: 'interactive' },
|
|
1500
|
+
});
|
|
1501
|
+
}
|
|
1502
|
+
else {
|
|
1503
|
+
resp = await this.client.im.v1.message.create({
|
|
1504
|
+
params: { receive_id_type: 'chat_id' },
|
|
1505
|
+
data: {
|
|
1506
|
+
receive_id: this.chatId,
|
|
1507
|
+
msg_type: 'interactive',
|
|
1508
|
+
content,
|
|
1509
|
+
},
|
|
1510
|
+
});
|
|
1511
|
+
}
|
|
1512
|
+
this.messageId = resp?.data?.message_id || null;
|
|
1513
|
+
if (!this.messageId) {
|
|
1514
|
+
throw new Error('No message_id in response');
|
|
1515
|
+
}
|
|
1516
|
+
logger.debug({ chatId: this.chatId, messageId: this.messageId, mode: 'legacy' }, 'Streaming card created via legacy path');
|
|
1517
|
+
this.finishCardCreation();
|
|
1518
|
+
}
|
|
1519
|
+
catch (err) {
|
|
1520
|
+
this.state = 'error';
|
|
1521
|
+
throw err;
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
finishCardCreation() {
|
|
1525
|
+
// Check if state changed while we were awaiting the API call.
|
|
1526
|
+
if (this.state !== 'creating') {
|
|
1527
|
+
const finalState = this.state;
|
|
1528
|
+
logger.debug({ chatId: this.chatId, messageId: this.messageId, finalState }, 'Streaming card created but state already changed, patching to final');
|
|
1529
|
+
if (this.backendMode === 'streaming' && this.streamingBackend) {
|
|
1530
|
+
this.finalizeStreamingCard(finalState).catch((err) => {
|
|
1531
|
+
logger.debug({ err, chatId: this.chatId }, 'Failed to finalize streaming card after late creation');
|
|
1532
|
+
});
|
|
1533
|
+
}
|
|
1534
|
+
else {
|
|
1535
|
+
this.patchCard(finalState).catch((err) => {
|
|
1536
|
+
logger.debug({ err, chatId: this.chatId }, 'Failed to patch to final state after late creation');
|
|
1537
|
+
});
|
|
1538
|
+
}
|
|
1539
|
+
return;
|
|
1540
|
+
}
|
|
1541
|
+
this.state = 'streaming';
|
|
1542
|
+
if (this.messageId) {
|
|
1543
|
+
this.onCardCreated?.(this.messageId);
|
|
1544
|
+
}
|
|
1545
|
+
// If text accumulated while creating, schedule a flush/patch
|
|
1546
|
+
if (this.accumulatedText.length > 3) {
|
|
1547
|
+
this.backendMode === 'streaming'
|
|
1548
|
+
? this.scheduleTextFlush()
|
|
1549
|
+
: this.schedulePatch();
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
schedulePatch() {
|
|
1553
|
+
if (this.patchFailCount >= this.maxPatchFailures) {
|
|
1554
|
+
logger.info({ chatId: this.chatId, useCardKit: this.useCardKit }, 'Streaming card: too many patch failures, falling back');
|
|
1555
|
+
this.state = 'error';
|
|
1556
|
+
this.flushCtrl.dispose();
|
|
1557
|
+
this.onFallback?.();
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1560
|
+
// Use effectiveLength so FlushController detects non-text state changes
|
|
1561
|
+
// (thinking, tool status, system status, etc.)
|
|
1562
|
+
const effectiveLength = this.accumulatedText.length + this.stateVersion * 1000;
|
|
1563
|
+
this.flushCtrl.schedule(effectiveLength, async () => {
|
|
1564
|
+
await this.patchCard('streaming');
|
|
1565
|
+
});
|
|
1566
|
+
}
|
|
1567
|
+
getFooterNote() {
|
|
1568
|
+
return (formatAssistantMetaFooter({
|
|
1569
|
+
runtimeIdentity: this.footerRuntimeIdentity,
|
|
1570
|
+
tokenUsage: this.footerTokenUsage,
|
|
1571
|
+
}) || undefined);
|
|
1572
|
+
}
|
|
1573
|
+
getAuxiliaryState() {
|
|
1574
|
+
return {
|
|
1575
|
+
thinkingText: this.thinkingText,
|
|
1576
|
+
isThinking: this.thinking,
|
|
1577
|
+
toolCalls: this.toolCalls,
|
|
1578
|
+
systemStatus: this.systemStatus,
|
|
1579
|
+
activeHook: this.activeHook,
|
|
1580
|
+
todos: this.todos,
|
|
1581
|
+
recentEvents: this.recentEvents,
|
|
1582
|
+
};
|
|
1583
|
+
}
|
|
1584
|
+
// ─── Streaming Mode Methods ──────────────────────────────
|
|
1585
|
+
/**
|
|
1586
|
+
* Schedule a text content flush for streaming mode.
|
|
1587
|
+
* Falls back to schedulePatch() if streaming backend is not available.
|
|
1588
|
+
*/
|
|
1589
|
+
scheduleTextFlush() {
|
|
1590
|
+
if (!this.streamingBackend || !this.textFlushCtrl) {
|
|
1591
|
+
this.schedulePatch();
|
|
1592
|
+
return;
|
|
1593
|
+
}
|
|
1594
|
+
this.textFlushCtrl.schedule(this.accumulatedText.length, async () => {
|
|
1595
|
+
try {
|
|
1596
|
+
await this.streamingBackend.streamContent(this.accumulatedText);
|
|
1597
|
+
this.textFlushCtrl.markFlushed(this.accumulatedText.length);
|
|
1598
|
+
this.patchFailCount = 0;
|
|
1599
|
+
}
|
|
1600
|
+
catch (err) {
|
|
1601
|
+
this.patchFailCount++;
|
|
1602
|
+
logger.debug({
|
|
1603
|
+
err,
|
|
1604
|
+
chatId: this.chatId,
|
|
1605
|
+
failCount: this.patchFailCount,
|
|
1606
|
+
mode: 'streaming',
|
|
1607
|
+
}, 'Streaming content push failed');
|
|
1608
|
+
if (this.patchFailCount >= this.maxPatchFailures) {
|
|
1609
|
+
this.degradeToV1();
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
});
|
|
1613
|
+
}
|
|
1614
|
+
/**
|
|
1615
|
+
* Schedule an auxiliary content flush for streaming mode.
|
|
1616
|
+
* Falls back to schedulePatch() if streaming backend is not available.
|
|
1617
|
+
*/
|
|
1618
|
+
scheduleAuxFlush() {
|
|
1619
|
+
if (!this.streamingBackend || !this.auxFlushCtrl) {
|
|
1620
|
+
this.schedulePatch();
|
|
1621
|
+
return;
|
|
1622
|
+
}
|
|
1623
|
+
this.auxFlushCtrl.schedule(this.stateVersion * 1000, async () => {
|
|
1624
|
+
// Recalculate aux state inside callback to avoid stale closures
|
|
1625
|
+
const auxState = this.getAuxiliaryState();
|
|
1626
|
+
const { before, after } = buildAuxiliaryElements(auxState);
|
|
1627
|
+
const auxBefore = serializeAuxContent(before);
|
|
1628
|
+
const auxAfter = serializeAuxContent(after);
|
|
1629
|
+
const snapshot = auxBefore + '||' + auxAfter;
|
|
1630
|
+
if (snapshot === this.lastAuxSnapshot)
|
|
1631
|
+
return;
|
|
1632
|
+
try {
|
|
1633
|
+
await this.streamingBackend.updateAuxiliary(ELEMENT_IDS.AUX_BEFORE, auxBefore);
|
|
1634
|
+
await this.streamingBackend.updateAuxiliary(ELEMENT_IDS.AUX_AFTER, auxAfter);
|
|
1635
|
+
this.lastAuxSnapshot = snapshot;
|
|
1636
|
+
}
|
|
1637
|
+
catch (err) {
|
|
1638
|
+
// Auxiliary update failures do NOT count toward degradation
|
|
1639
|
+
logger.debug({ err, chatId: this.chatId, mode: 'streaming' }, 'Streaming auxiliary update failed (non-critical)');
|
|
1640
|
+
}
|
|
1641
|
+
});
|
|
1642
|
+
}
|
|
1643
|
+
/**
|
|
1644
|
+
* Degrade from streaming mode to v1 full-update mode.
|
|
1645
|
+
*/
|
|
1646
|
+
degradeToV1() {
|
|
1647
|
+
logger.warn({ chatId: this.chatId }, 'Streaming mode: degrading to v1 full-update');
|
|
1648
|
+
// Save card_id and sequence from streaming backend before clearing
|
|
1649
|
+
const existingCardId = this.streamingBackend.getCardId();
|
|
1650
|
+
const existingSeq = this.streamingBackend.getSequence();
|
|
1651
|
+
// Try to disable streaming mode gracefully (fire and forget)
|
|
1652
|
+
this.streamingBackend?.disableStreamingMode().catch(() => { });
|
|
1653
|
+
this.backendMode = 'v1';
|
|
1654
|
+
this.streamingBackend = null;
|
|
1655
|
+
this.textFlushCtrl?.dispose();
|
|
1656
|
+
this.textFlushCtrl = null;
|
|
1657
|
+
this.auxFlushCtrl?.dispose();
|
|
1658
|
+
this.auxFlushCtrl = null;
|
|
1659
|
+
this.patchFailCount = 0;
|
|
1660
|
+
// Set up v1 flush controller
|
|
1661
|
+
this.flushCtrl.dispose();
|
|
1662
|
+
this.flushCtrl = new FlushController(1000, 50);
|
|
1663
|
+
// Adopt the existing streaming card into a CardKitBackend (reuses card_id, no new message)
|
|
1664
|
+
const adoptedCard = new CardKitBackend(this.client);
|
|
1665
|
+
adoptedCard.adoptCard(existingCardId, this.messageId, existingSeq);
|
|
1666
|
+
this.multiCard = new MultiCardManager(this.client, this.chatId, this.replyToMsgId, this.onCardCreated);
|
|
1667
|
+
this.multiCard.adoptExistingCard(adoptedCard);
|
|
1668
|
+
// Schedule an immediate patch to sync the current state
|
|
1669
|
+
this.schedulePatch();
|
|
1670
|
+
}
|
|
1671
|
+
/**
|
|
1672
|
+
* Finalize a streaming card: disable streaming mode, then set final state.
|
|
1673
|
+
*/
|
|
1674
|
+
async finalizeStreamingCard(finalState) {
|
|
1675
|
+
const backend = this.streamingBackend;
|
|
1676
|
+
try {
|
|
1677
|
+
// 1. Disable streaming mode (allows header/button changes)
|
|
1678
|
+
await backend.disableStreamingMode();
|
|
1679
|
+
// 2. Build final card with optimizeMarkdownStyle
|
|
1680
|
+
const footerNote = this.getFooterNote();
|
|
1681
|
+
const auxiliaryState = this.getAuxiliaryState();
|
|
1682
|
+
const cardJson = buildSchema2Card(this.accumulatedText, finalState, '', undefined, auxiliaryState, footerNote);
|
|
1683
|
+
const cardSize = Buffer.byteLength(JSON.stringify(cardJson), 'utf-8');
|
|
1684
|
+
if (cardSize <= CARD_SIZE_LIMIT) {
|
|
1685
|
+
// 3a. Single card fits
|
|
1686
|
+
await backend.updateCardFull(cardJson);
|
|
1687
|
+
}
|
|
1688
|
+
else {
|
|
1689
|
+
// 3b. Too large for single card — split on finalize
|
|
1690
|
+
await this.splitOnFinalize(finalState);
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
catch (err) {
|
|
1694
|
+
logger.debug({ err, chatId: this.chatId }, 'Streaming finalize failed, trying truncated fallback');
|
|
1695
|
+
// Fallback: truncate and try once more
|
|
1696
|
+
try {
|
|
1697
|
+
const truncated = this.accumulatedText.slice(0, 20000);
|
|
1698
|
+
const fallbackCard = buildSchema2Card(truncated + '\n\n> ⚠️ 输出已截断', finalState, '', undefined, this.getAuxiliaryState(), this.getFooterNote(), this.footerRuntimeIdentity);
|
|
1699
|
+
await backend.updateCardFull(fallbackCard);
|
|
1700
|
+
}
|
|
1701
|
+
catch (fallbackErr) {
|
|
1702
|
+
logger.debug({ err: fallbackErr, chatId: this.chatId }, 'Streaming finalize truncated fallback also failed');
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
/**
|
|
1707
|
+
* Split content into multiple cards on finalize (only when streaming card content exceeds CARD_SIZE_LIMIT).
|
|
1708
|
+
* The first card (existing streaming card) gets frozen, subsequent cards are new.
|
|
1709
|
+
*/
|
|
1710
|
+
async splitOnFinalize(finalState) {
|
|
1711
|
+
const backend = this.streamingBackend;
|
|
1712
|
+
const { title } = extractTitleAndBody(this.accumulatedText);
|
|
1713
|
+
const chunks = splitCodeBlockSafe(this.accumulatedText, CARD_MD_LIMIT);
|
|
1714
|
+
const footerNote = this.getFooterNote();
|
|
1715
|
+
const auxiliaryState = this.getAuxiliaryState();
|
|
1716
|
+
// How many chunks fit in the first card?
|
|
1717
|
+
const MAX_ELEMENTS_PER_CARD = 45;
|
|
1718
|
+
const fixedElements = 2; // note + margin
|
|
1719
|
+
const maxChunksFirst = MAX_ELEMENTS_PER_CARD - fixedElements;
|
|
1720
|
+
const firstChunks = chunks.slice(0, maxChunksFirst);
|
|
1721
|
+
const firstText = firstChunks.join('\n\n');
|
|
1722
|
+
// Use finalState if all content fits in the first card, otherwise freeze
|
|
1723
|
+
const firstCardState = chunks.length <= maxChunksFirst ? finalState : 'frozen';
|
|
1724
|
+
const frozenCard = buildSchema2Card(firstText, firstCardState, '', title, auxiliaryState, chunks.length <= maxChunksFirst ? footerNote : undefined, this.footerRuntimeIdentity);
|
|
1725
|
+
await backend.updateCardFull(frozenCard);
|
|
1726
|
+
// Create continuation cards
|
|
1727
|
+
let remaining = chunks.slice(maxChunksFirst);
|
|
1728
|
+
let includeAuxiliaryInContinuation = false;
|
|
1729
|
+
while (remaining.length > 0) {
|
|
1730
|
+
const batch = remaining.slice(0, maxChunksFirst);
|
|
1731
|
+
remaining = remaining.slice(maxChunksFirst);
|
|
1732
|
+
const batchText = batch.join('\n\n');
|
|
1733
|
+
const state = remaining.length === 0 ? finalState : 'frozen';
|
|
1734
|
+
const contCard = new CardKitBackend(this.client);
|
|
1735
|
+
const contCardJson = buildSchema2Card(batchText, state, '(续) ', title, includeAuxiliaryInContinuation ? auxiliaryState : undefined, remaining.length === 0 ? footerNote : undefined, this.footerRuntimeIdentity);
|
|
1736
|
+
await contCard.createCard(contCardJson);
|
|
1737
|
+
const newMsgId = await contCard.sendCard(this.chatId);
|
|
1738
|
+
this.onCardCreated?.(newMsgId);
|
|
1739
|
+
includeAuxiliaryInContinuation = false;
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
async patchCard(displayState, footerNote) {
|
|
1743
|
+
if (this.useCardKit && this.multiCard) {
|
|
1744
|
+
// CardKit v1 path — pass auxiliary state for rich display
|
|
1745
|
+
const auxState = this.getAuxiliaryState();
|
|
1746
|
+
try {
|
|
1747
|
+
await this.multiCard.commitContent(this.accumulatedText, displayState, auxState, footerNote || this.getFooterNote(), this.footerRuntimeIdentity);
|
|
1748
|
+
this.flushCtrl.markFlushed(this.accumulatedText.length);
|
|
1749
|
+
this.patchFailCount = 0;
|
|
1750
|
+
}
|
|
1751
|
+
catch (err) {
|
|
1752
|
+
this.patchFailCount++;
|
|
1753
|
+
logger.debug({
|
|
1754
|
+
err,
|
|
1755
|
+
chatId: this.chatId,
|
|
1756
|
+
failCount: this.patchFailCount,
|
|
1757
|
+
mode: 'cardkit',
|
|
1758
|
+
}, 'CardKit card update failed');
|
|
1759
|
+
throw err;
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
else {
|
|
1763
|
+
// Legacy message.patch path (no auxiliary content)
|
|
1764
|
+
if (!this.messageId)
|
|
1765
|
+
return;
|
|
1766
|
+
const card = buildStreamingCard(this.accumulatedText, displayState, footerNote || this.getFooterNote(), this.footerRuntimeIdentity);
|
|
1767
|
+
const content = JSON.stringify(card);
|
|
1768
|
+
try {
|
|
1769
|
+
await this.client.im.v1.message.patch({
|
|
1770
|
+
path: { message_id: this.messageId },
|
|
1771
|
+
data: { content },
|
|
1772
|
+
});
|
|
1773
|
+
this.flushCtrl.markFlushed(this.accumulatedText.length);
|
|
1774
|
+
this.patchFailCount = 0;
|
|
1775
|
+
}
|
|
1776
|
+
catch (err) {
|
|
1777
|
+
this.patchFailCount++;
|
|
1778
|
+
logger.debug({
|
|
1779
|
+
err,
|
|
1780
|
+
chatId: this.chatId,
|
|
1781
|
+
failCount: this.patchFailCount,
|
|
1782
|
+
mode: 'legacy',
|
|
1783
|
+
}, 'Streaming card patch failed');
|
|
1784
|
+
throw err;
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
// ─── MessageId → ChatJid Mapping ─────────────────────────────
|
|
1790
|
+
// Reverse lookup for card callback: given a Feishu messageId from a button click,
|
|
1791
|
+
// find which chatJid (streaming session) it belongs to.
|
|
1792
|
+
const messageIdToChatJid = new Map();
|
|
1793
|
+
/**
|
|
1794
|
+
* Register a messageId → chatJid mapping for card callback routing.
|
|
1795
|
+
*/
|
|
1796
|
+
export function registerMessageIdMapping(messageId, chatJid) {
|
|
1797
|
+
messageIdToChatJid.set(messageId, chatJid);
|
|
1798
|
+
}
|
|
1799
|
+
/**
|
|
1800
|
+
* Resolve a chatJid from a Feishu messageId.
|
|
1801
|
+
*/
|
|
1802
|
+
export function resolveJidByMessageId(messageId) {
|
|
1803
|
+
return messageIdToChatJid.get(messageId);
|
|
1804
|
+
}
|
|
1805
|
+
/**
|
|
1806
|
+
* Remove a messageId mapping.
|
|
1807
|
+
*/
|
|
1808
|
+
export function unregisterMessageId(messageId) {
|
|
1809
|
+
messageIdToChatJid.delete(messageId);
|
|
1810
|
+
}
|
|
1811
|
+
// ─── Streaming Session Registry ───────────────────────────────
|
|
1812
|
+
// Global registry for tracking active streaming sessions.
|
|
1813
|
+
// Used by shutdown hooks to abort all active sessions.
|
|
1814
|
+
const activeSessions = new Map();
|
|
1815
|
+
/**
|
|
1816
|
+
* Register a streaming session for a chatJid.
|
|
1817
|
+
* Replaces any existing session for the same chatJid.
|
|
1818
|
+
*/
|
|
1819
|
+
export function registerStreamingSession(chatJid, session) {
|
|
1820
|
+
const existing = activeSessions.get(chatJid);
|
|
1821
|
+
if (existing && existing.isActive()) {
|
|
1822
|
+
// Abort (not just dispose) so the old card shows "已中断" instead of stuck "生成中..."
|
|
1823
|
+
existing.abort('新的回复已开始').catch(() => { });
|
|
1824
|
+
}
|
|
1825
|
+
activeSessions.set(chatJid, session);
|
|
1826
|
+
}
|
|
1827
|
+
/**
|
|
1828
|
+
* Remove a streaming session from the registry.
|
|
1829
|
+
* Also cleans up all messageId → chatJid mappings (including multi-card).
|
|
1830
|
+
*/
|
|
1831
|
+
export function unregisterStreamingSession(chatJid) {
|
|
1832
|
+
const session = activeSessions.get(chatJid);
|
|
1833
|
+
if (session) {
|
|
1834
|
+
for (const msgId of session.getAllMessageIds()) {
|
|
1835
|
+
unregisterMessageId(msgId);
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
activeSessions.delete(chatJid);
|
|
1839
|
+
}
|
|
1840
|
+
/**
|
|
1841
|
+
* Get the active streaming session for a chatJid.
|
|
1842
|
+
*/
|
|
1843
|
+
export function getStreamingSession(chatJid) {
|
|
1844
|
+
return activeSessions.get(chatJid);
|
|
1845
|
+
}
|
|
1846
|
+
/**
|
|
1847
|
+
* Check if there's an active streaming session for a chatJid.
|
|
1848
|
+
*/
|
|
1849
|
+
export function hasActiveStreamingSession(chatJid) {
|
|
1850
|
+
const session = activeSessions.get(chatJid);
|
|
1851
|
+
return session?.isActive() ?? false;
|
|
1852
|
+
}
|
|
1853
|
+
/**
|
|
1854
|
+
* Abort all active streaming sessions.
|
|
1855
|
+
* Called during graceful shutdown.
|
|
1856
|
+
*/
|
|
1857
|
+
export async function abortAllStreamingSessions(reason = '服务维护中') {
|
|
1858
|
+
const promises = [];
|
|
1859
|
+
for (const [chatJid, session] of activeSessions.entries()) {
|
|
1860
|
+
if (session.isActive()) {
|
|
1861
|
+
promises.push(session.abort(reason).catch((err) => {
|
|
1862
|
+
logger.debug({ err, chatJid }, 'Failed to abort streaming session during shutdown');
|
|
1863
|
+
}));
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
await Promise.allSettled(promises);
|
|
1867
|
+
// Clean up messageId → chatJid mappings before clearing sessions
|
|
1868
|
+
for (const session of activeSessions.values()) {
|
|
1869
|
+
for (const msgId of session.getAllMessageIds()) {
|
|
1870
|
+
unregisterMessageId(msgId);
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
activeSessions.clear();
|
|
1874
|
+
logger.info({ count: promises.length }, 'All streaming sessions aborted');
|
|
1875
|
+
}
|