@soimy/dingtalk 3.5.3 → 3.6.0
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/index.ts +7 -0
- package/openclaw.plugin.json +104 -0
- package/package.json +1 -1
- package/src/card/card-markdown-image-reroute.ts +106 -0
- package/src/card/card-run-registry.ts +54 -1
- package/src/card/card-stop-handler.ts +10 -20
- package/src/card/card-template.ts +14 -3
- package/src/card/statusline-renderer.ts +94 -0
- package/src/card-draft-controller.ts +245 -52
- package/src/card-service.ts +368 -8
- package/src/channel.ts +19 -1081
- package/src/config-schema.ts +19 -0
- package/src/config.ts +117 -1
- package/src/device-registration.ts +245 -0
- package/src/gateway/channel-gateway.ts +636 -0
- package/src/inbound-handler.ts +147 -24
- package/src/media-utils.ts +6 -0
- package/src/message-utils.ts +124 -16
- package/src/messaging/btw-deliver.ts +85 -0
- package/src/messaging/channel-actions.ts +173 -0
- package/src/messaging/channel-outbound.ts +158 -0
- package/src/onboarding.ts +321 -232
- package/src/platform/channel-status.ts +81 -0
- package/src/reply-strategy-card.ts +373 -64
- package/src/reply-strategy-markdown.ts +1 -1
- package/src/reply-strategy-types.ts +93 -0
- package/src/reply-strategy-with-reaction.ts +1 -1
- package/src/reply-strategy.ts +14 -72
- package/src/run-usage-store.ts +59 -0
- package/src/send-service.ts +115 -3
- package/src/session-state.ts +62 -0
- package/src/targeting/agent-name-matcher.ts +28 -0
- package/src/types.ts +23 -147
package/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { readStringParam } from "openclaw/plugin-sdk/param-readers";
|
|
|
3
3
|
import { dingtalkPlugin } from "./src/channel";
|
|
4
4
|
import { getConfig } from "./src/config";
|
|
5
5
|
import { appendToDoc, createDoc, DocCreateAppendError, listDocs, searchDocs } from "./src/docs-service";
|
|
6
|
+
import { accumulateUsage } from "./src/run-usage-store";
|
|
6
7
|
import { setDingTalkRuntime } from "./src/runtime";
|
|
7
8
|
|
|
8
9
|
type GatewayMethodContext = Pick<
|
|
@@ -81,5 +82,11 @@ export default defineChannelPluginEntry({
|
|
|
81
82
|
setRuntime: setDingTalkRuntime,
|
|
82
83
|
registerFull(api) {
|
|
83
84
|
registerDingTalkDocsGatewayMethods(api);
|
|
85
|
+
|
|
86
|
+
api.on("llm_output", (event: { runId: string; usage?: { input?: number; output?: number; cacheRead?: number; cacheWrite?: number; total?: number } }) => {
|
|
87
|
+
if (event.usage) {
|
|
88
|
+
accumulateUsage(event.runId, event.usage);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
84
91
|
},
|
|
85
92
|
});
|
package/openclaw.plugin.json
CHANGED
|
@@ -253,6 +253,44 @@
|
|
|
253
253
|
"type": "string",
|
|
254
254
|
"description": "Send a follow-up @mention text after card finalization in group chats. Any non-empty string becomes the message body."
|
|
255
255
|
},
|
|
256
|
+
"cardStatusLine": {
|
|
257
|
+
"type": "object",
|
|
258
|
+
"description": "Status line visibility toggles for the AI card footer.",
|
|
259
|
+
"additionalProperties": false,
|
|
260
|
+
"default": { "model": true, "effort": true, "agent": true, "taskTime": false, "tokens": false, "dapiUsage": false },
|
|
261
|
+
"properties": {
|
|
262
|
+
"model": {
|
|
263
|
+
"type": "boolean",
|
|
264
|
+
"default": true,
|
|
265
|
+
"description": "Show model name in AI card status line footer."
|
|
266
|
+
},
|
|
267
|
+
"effort": {
|
|
268
|
+
"type": "boolean",
|
|
269
|
+
"default": true,
|
|
270
|
+
"description": "Show thinking effort level in AI card status line footer."
|
|
271
|
+
},
|
|
272
|
+
"agent": {
|
|
273
|
+
"type": "boolean",
|
|
274
|
+
"default": true,
|
|
275
|
+
"description": "Show agent display name in AI card status line footer."
|
|
276
|
+
},
|
|
277
|
+
"taskTime": {
|
|
278
|
+
"type": "boolean",
|
|
279
|
+
"default": false,
|
|
280
|
+
"description": "Show task elapsed time in AI card status line footer."
|
|
281
|
+
},
|
|
282
|
+
"tokens": {
|
|
283
|
+
"type": "boolean",
|
|
284
|
+
"default": false,
|
|
285
|
+
"description": "Show token usage summary (input/output/cache) in AI card status line footer."
|
|
286
|
+
},
|
|
287
|
+
"dapiUsage": {
|
|
288
|
+
"type": "boolean",
|
|
289
|
+
"default": false,
|
|
290
|
+
"description": "Show DingTalk API call count in AI card status line footer."
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
},
|
|
256
294
|
"showThinkingStream": {
|
|
257
295
|
"type": "boolean",
|
|
258
296
|
"description": "Legacy compatibility field from older card streaming behavior. It is not used by the current runtime and new configs should omit it."
|
|
@@ -498,6 +536,44 @@
|
|
|
498
536
|
"cardAtSender": {
|
|
499
537
|
"type": "string",
|
|
500
538
|
"description": "Send a follow-up @mention text after card finalization in group chats. Any non-empty string becomes the message body."
|
|
539
|
+
},
|
|
540
|
+
"cardStatusLine": {
|
|
541
|
+
"type": "object",
|
|
542
|
+
"description": "Status line visibility toggles for the AI card footer.",
|
|
543
|
+
"additionalProperties": false,
|
|
544
|
+
"default": { "model": true, "effort": true, "agent": true, "taskTime": false, "tokens": false, "dapiUsage": false },
|
|
545
|
+
"properties": {
|
|
546
|
+
"model": {
|
|
547
|
+
"type": "boolean",
|
|
548
|
+
"default": true,
|
|
549
|
+
"description": "Show model name in AI card status line footer."
|
|
550
|
+
},
|
|
551
|
+
"effort": {
|
|
552
|
+
"type": "boolean",
|
|
553
|
+
"default": true,
|
|
554
|
+
"description": "Show thinking effort level in AI card status line footer."
|
|
555
|
+
},
|
|
556
|
+
"agent": {
|
|
557
|
+
"type": "boolean",
|
|
558
|
+
"default": true,
|
|
559
|
+
"description": "Show agent display name in AI card status line footer."
|
|
560
|
+
},
|
|
561
|
+
"taskTime": {
|
|
562
|
+
"type": "boolean",
|
|
563
|
+
"default": false,
|
|
564
|
+
"description": "Show task elapsed time in AI card status line footer."
|
|
565
|
+
},
|
|
566
|
+
"tokens": {
|
|
567
|
+
"type": "boolean",
|
|
568
|
+
"default": false,
|
|
569
|
+
"description": "Show token usage summary (input/output/cache) in AI card status line footer."
|
|
570
|
+
},
|
|
571
|
+
"dapiUsage": {
|
|
572
|
+
"type": "boolean",
|
|
573
|
+
"default": false,
|
|
574
|
+
"description": "Show DingTalk API call count in AI card status line footer."
|
|
575
|
+
}
|
|
576
|
+
}
|
|
501
577
|
}
|
|
502
578
|
}
|
|
503
579
|
}
|
|
@@ -678,6 +754,34 @@
|
|
|
678
754
|
"label": "Card Mention Follow-Up",
|
|
679
755
|
"help": "Send a follow-up @mention text after card finalization in group chats. Any non-empty string becomes the message body."
|
|
680
756
|
},
|
|
757
|
+
"cardStatusLine": {
|
|
758
|
+
"label": "Status Line",
|
|
759
|
+
"help": "Configure which information segments appear in the AI card footer status line."
|
|
760
|
+
},
|
|
761
|
+
"cardStatusLine.model": {
|
|
762
|
+
"label": "Model Name",
|
|
763
|
+
"help": "Show the LLM model name in the AI card footer status line."
|
|
764
|
+
},
|
|
765
|
+
"cardStatusLine.effort": {
|
|
766
|
+
"label": "Thinking Effort",
|
|
767
|
+
"help": "Show the thinking effort level (e.g. high, medium) in the AI card footer."
|
|
768
|
+
},
|
|
769
|
+
"cardStatusLine.agent": {
|
|
770
|
+
"label": "Agent Name",
|
|
771
|
+
"help": "Show the agent display name in the AI card footer."
|
|
772
|
+
},
|
|
773
|
+
"cardStatusLine.taskTime": {
|
|
774
|
+
"label": "Task Time",
|
|
775
|
+
"help": "Show elapsed task time in the AI card footer."
|
|
776
|
+
},
|
|
777
|
+
"cardStatusLine.tokens": {
|
|
778
|
+
"label": "Token Usage",
|
|
779
|
+
"help": "Show a compact token usage summary (input/output/cache) in the AI card footer."
|
|
780
|
+
},
|
|
781
|
+
"cardStatusLine.dapiUsage": {
|
|
782
|
+
"label": "API Calls",
|
|
783
|
+
"help": "Show the DingTalk API call count in the AI card footer."
|
|
784
|
+
},
|
|
681
785
|
"showThinkingStream": {
|
|
682
786
|
"label": "Legacy Show Thinking Stream",
|
|
683
787
|
"help": "Deprecated compatibility field from older card streaming behavior. The current runtime ignores it."
|
package/package.json
CHANGED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
export type MarkdownImageUrlClassification = "public" | "local" | "unsupported";
|
|
2
|
+
|
|
3
|
+
export interface MarkdownImageCandidate {
|
|
4
|
+
alt: string;
|
|
5
|
+
url: string;
|
|
6
|
+
raw: string;
|
|
7
|
+
classification: MarkdownImageUrlClassification;
|
|
8
|
+
start: number;
|
|
9
|
+
end: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const MARKDOWN_IMAGE_RE = /!\[([^\]]*)\]\(([^)]+)\)/g;
|
|
13
|
+
const PRIVATE_HOST_RE = /^(?:localhost|127\.0\.0\.1|10\.\d+\.\d+\.\d+|192\.168\.\d+\.\d+|172\.(?:1[6-9]|2\d|3[0-1])\.\d+\.\d+)$/;
|
|
14
|
+
|
|
15
|
+
function isLikelyPlainRelativePath(url: string): boolean {
|
|
16
|
+
return !/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(url) && !url.startsWith("//");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isLikelyLocalPath(url: string): boolean {
|
|
20
|
+
return url.startsWith("./") || url.startsWith("../") || url.startsWith("/") || isLikelyPlainRelativePath(url);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function safeFileNameFromUrl(url: string): string {
|
|
24
|
+
const trimmed = url.trim();
|
|
25
|
+
if (!trimmed) {
|
|
26
|
+
return "";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (trimmed.startsWith("file://")) {
|
|
30
|
+
const withoutScheme = trimmed.slice("file://".length);
|
|
31
|
+
const segments = withoutScheme.split("/").filter(Boolean);
|
|
32
|
+
return segments.at(-1) ?? "";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (isLikelyLocalPath(trimmed)) {
|
|
36
|
+
const segments = trimmed.split(/[\\/]/).filter(Boolean);
|
|
37
|
+
return segments.at(-1) ?? "";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const parsed = new URL(trimmed);
|
|
42
|
+
const segments = parsed.pathname.split("/").filter(Boolean);
|
|
43
|
+
return segments.at(-1) ?? "";
|
|
44
|
+
} catch {
|
|
45
|
+
return "";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function classifyMarkdownImageUrl(url: string): MarkdownImageUrlClassification {
|
|
50
|
+
const trimmed = url.trim();
|
|
51
|
+
if (!trimmed) {
|
|
52
|
+
return "unsupported";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (trimmed.startsWith("file://") || isLikelyLocalPath(trimmed)) {
|
|
56
|
+
return "local";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const parsed = new URL(trimmed);
|
|
61
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
62
|
+
return "unsupported";
|
|
63
|
+
}
|
|
64
|
+
if (PRIVATE_HOST_RE.test(parsed.hostname)) {
|
|
65
|
+
return "local";
|
|
66
|
+
}
|
|
67
|
+
return "public";
|
|
68
|
+
} catch {
|
|
69
|
+
return "unsupported";
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function buildImagePlaceholderText(input: { alt: string; url: string }): string {
|
|
74
|
+
const alt = input.alt.trim();
|
|
75
|
+
if (alt) {
|
|
76
|
+
return `见下图${alt}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const fileName = safeFileNameFromUrl(input.url);
|
|
80
|
+
if (fileName) {
|
|
81
|
+
return `见下图${fileName}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return "见下图图片";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function extractMarkdownImageCandidates(text: string): MarkdownImageCandidate[] {
|
|
88
|
+
const candidates: MarkdownImageCandidate[] = [];
|
|
89
|
+
|
|
90
|
+
for (const match of text.matchAll(MARKDOWN_IMAGE_RE)) {
|
|
91
|
+
const raw = match[0] ?? "";
|
|
92
|
+
const alt = match[1] ?? "";
|
|
93
|
+
const url = match[2] ?? "";
|
|
94
|
+
const start = match.index ?? 0;
|
|
95
|
+
candidates.push({
|
|
96
|
+
alt,
|
|
97
|
+
url,
|
|
98
|
+
raw,
|
|
99
|
+
classification: classifyMarkdownImageUrl(url),
|
|
100
|
+
start,
|
|
101
|
+
end: start + raw.length,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return candidates;
|
|
106
|
+
}
|
|
@@ -61,6 +61,8 @@ export function registerCardRun(
|
|
|
61
61
|
agentId: string;
|
|
62
62
|
ownerUserId?: string;
|
|
63
63
|
card?: AICardInstance;
|
|
64
|
+
/** Override registeredAt timestamp (useful for tests). */
|
|
65
|
+
registeredAt?: number;
|
|
64
66
|
},
|
|
65
67
|
): void {
|
|
66
68
|
const trimmed = outTrackId.trim();
|
|
@@ -74,7 +76,7 @@ export function registerCardRun(
|
|
|
74
76
|
agentId: params.agentId,
|
|
75
77
|
ownerUserId: params.ownerUserId,
|
|
76
78
|
card: params.card,
|
|
77
|
-
registeredAt: Date.now(),
|
|
79
|
+
registeredAt: params.registeredAt ?? Date.now(),
|
|
78
80
|
});
|
|
79
81
|
ensureSweepTimer();
|
|
80
82
|
}
|
|
@@ -90,6 +92,57 @@ export function resolveCardRun(outTrackId: string): CardRunRecord | null {
|
|
|
90
92
|
return records.get(outTrackId.trim()) ?? null;
|
|
91
93
|
}
|
|
92
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Find the most recently registered card run for a given account + conversation.
|
|
97
|
+
* Uses case-insensitive match of the conversationId within sessionKey.
|
|
98
|
+
*
|
|
99
|
+
* @param accountId - The DingTalk account ID
|
|
100
|
+
* @param conversationId - The target conversation ID (matches within sessionKey)
|
|
101
|
+
* @param options - Optional filtering criteria
|
|
102
|
+
* @param options.ownerUserId - If provided, only return runs owned by this user
|
|
103
|
+
*/
|
|
104
|
+
export function resolveCardRunByConversation(
|
|
105
|
+
accountId: string,
|
|
106
|
+
conversationId: string,
|
|
107
|
+
options?: { ownerUserId?: string },
|
|
108
|
+
): CardRunRecord | null {
|
|
109
|
+
const lowerCid = conversationId.toLowerCase();
|
|
110
|
+
const targetOwner = options?.ownerUserId;
|
|
111
|
+
let latest: CardRunRecord | null = null;
|
|
112
|
+
for (const record of records.values()) {
|
|
113
|
+
if (record.accountId !== accountId) { continue; }
|
|
114
|
+
if (!record.sessionKey.toLowerCase().includes(lowerCid)) { continue; }
|
|
115
|
+
// If ownerUserId filter is specified, only match runs owned by that user
|
|
116
|
+
if (targetOwner !== undefined && record.ownerUserId !== targetOwner) { continue; }
|
|
117
|
+
if (!latest || record.registeredAt > latest.registeredAt) {
|
|
118
|
+
latest = record;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return latest;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Find the most recently registered card run for a given account + owner.
|
|
126
|
+
* This is a fallback query when conversationId is unavailable (e.g., sessionKey=-).
|
|
127
|
+
*
|
|
128
|
+
* @param accountId - The DingTalk account ID
|
|
129
|
+
* @param ownerUserId - The DingTalk userId of the card owner
|
|
130
|
+
*/
|
|
131
|
+
export function resolveCardRunByOwner(
|
|
132
|
+
accountId: string,
|
|
133
|
+
ownerUserId: string,
|
|
134
|
+
): CardRunRecord | null {
|
|
135
|
+
let latest: CardRunRecord | null = null;
|
|
136
|
+
for (const record of records.values()) {
|
|
137
|
+
if (record.accountId !== accountId) { continue; }
|
|
138
|
+
if (record.ownerUserId !== ownerUserId) { continue; }
|
|
139
|
+
if (!latest || record.registeredAt > latest.registeredAt) {
|
|
140
|
+
latest = record;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return latest;
|
|
144
|
+
}
|
|
145
|
+
|
|
93
146
|
export function markCardRunStopRequested(outTrackId: string): void {
|
|
94
147
|
const record = records.get(outTrackId.trim());
|
|
95
148
|
if (record && !record.stopRequestedAt) {
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
|
2
|
-
import {
|
|
3
|
-
import { finishStoppedAICard, hideCardStopButton } from "../card-service";
|
|
2
|
+
import { finalizeStoppedAICard } from "../card-service";
|
|
4
3
|
import { dispatchDingTalkCardStopCommand } from "../command/card-stop-command";
|
|
5
4
|
import type { DingTalkConfig, Logger } from "../types";
|
|
6
5
|
import { AICardStatus } from "../types";
|
|
@@ -31,7 +30,9 @@ export async function stopCardRun(params: {
|
|
|
31
30
|
return { ok: true, status: "already-stopped", lastContent: record.card?.lastStreamedContent };
|
|
32
31
|
}
|
|
33
32
|
|
|
34
|
-
const lastContent = record.card?.lastStreamedContent;
|
|
33
|
+
const lastContent = record.controller?.getRenderedContent?.() || record.card?.lastStreamedContent;
|
|
34
|
+
const lastBlockListJson =
|
|
35
|
+
record.controller?.getRenderedBlocks?.() || record.card?.lastBlockListJson;
|
|
35
36
|
|
|
36
37
|
markCardRunStopRequested(params.outTrackId);
|
|
37
38
|
record.controller?.stop();
|
|
@@ -64,13 +65,14 @@ export async function stopCardRun(params: {
|
|
|
64
65
|
}
|
|
65
66
|
}
|
|
66
67
|
|
|
67
|
-
// --- Phase 2: Finalize card via
|
|
68
|
+
// --- Phase 2: Finalize card via instances API so V2 blockList/content stay consistent ---
|
|
68
69
|
if (record.card) {
|
|
69
|
-
const stoppedContent = lastContent
|
|
70
|
-
? `${lastContent}\n\n---\n*⏹️ 已停止*`
|
|
71
|
-
: "⏹️ 已停止";
|
|
72
70
|
try {
|
|
73
|
-
await
|
|
71
|
+
await finalizeStoppedAICard(record.card, {
|
|
72
|
+
reason: "⏹️ 已停止",
|
|
73
|
+
previousContent: lastContent,
|
|
74
|
+
previousBlockListJson: lastBlockListJson,
|
|
75
|
+
}, params.log);
|
|
74
76
|
} catch (error) {
|
|
75
77
|
params.log?.warn?.(
|
|
76
78
|
`[${params.accountId}] [DingTalk][CardStop] failed to finalize stopped card: ${error instanceof Error ? error.message : String(error)}`,
|
|
@@ -78,17 +80,5 @@ export async function stopCardRun(params: {
|
|
|
78
80
|
}
|
|
79
81
|
}
|
|
80
82
|
|
|
81
|
-
// --- Phase 3: Hide stop button (with retry, consistent with finishAICard path) ---
|
|
82
|
-
if (params.config) {
|
|
83
|
-
try {
|
|
84
|
-
const token = await getAccessToken(params.config, params.log);
|
|
85
|
-
await hideCardStopButton(params.outTrackId, token, params.config);
|
|
86
|
-
} catch (error) {
|
|
87
|
-
params.log?.debug?.(
|
|
88
|
-
`[${params.accountId}] [DingTalk][CardStop] non-critical: failed to hide stop button: ${error instanceof Error ? error.message : String(error)}`,
|
|
89
|
-
);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
83
|
return { ok: true, status: nativeStopStatus ?? "stopped-pending", lastContent };
|
|
94
84
|
}
|
|
@@ -1,20 +1,31 @@
|
|
|
1
1
|
/** Card variable value that shows the stop button. */
|
|
2
|
-
export const STOP_ACTION_VISIBLE =
|
|
2
|
+
export const STOP_ACTION_VISIBLE = true;
|
|
3
3
|
/** Card variable value that hides the stop button. */
|
|
4
|
-
export const STOP_ACTION_HIDDEN =
|
|
4
|
+
export const STOP_ACTION_HIDDEN = false;
|
|
5
5
|
|
|
6
6
|
export const BUILTIN_DINGTALK_CARD_TEMPLATE_ID =
|
|
7
|
-
process.env.DINGTALK_CARD_TEMPLATE_ID || "
|
|
7
|
+
process.env.DINGTALK_CARD_TEMPLATE_ID || "675cde2f-f526-40cb-b828-f5b2b57b8b77.schema";
|
|
8
8
|
export const BUILTIN_DINGTALK_CARD_CONTENT_KEY = "content";
|
|
9
|
+
export const BUILTIN_DINGTALK_CARD_BLOCK_LIST_KEY = "blockList";
|
|
10
|
+
export const BUILTIN_DINGTALK_CARD_COPY_CONTENT_KEY = "copy_content";
|
|
9
11
|
|
|
10
12
|
export interface DingTalkCardTemplateContract {
|
|
11
13
|
templateId: string;
|
|
12
14
|
contentKey: string;
|
|
15
|
+
/** V2: key for the streaming markdown field (same as contentKey). */
|
|
16
|
+
streamingKey: string;
|
|
17
|
+
/** V2: key for the block-list loopArray variable. */
|
|
18
|
+
blockListKey: string;
|
|
19
|
+
/** V2: key for the plain-text copy content variable (String type for card copy action). */
|
|
20
|
+
copyContentKey: string;
|
|
13
21
|
}
|
|
14
22
|
|
|
15
23
|
/** Frozen singleton — no allocation on every call. */
|
|
16
24
|
export const DINGTALK_CARD_TEMPLATE: Readonly<DingTalkCardTemplateContract> = Object.freeze({
|
|
17
25
|
templateId: BUILTIN_DINGTALK_CARD_TEMPLATE_ID,
|
|
18
26
|
contentKey: BUILTIN_DINGTALK_CARD_CONTENT_KEY,
|
|
27
|
+
streamingKey: BUILTIN_DINGTALK_CARD_CONTENT_KEY,
|
|
28
|
+
blockListKey: BUILTIN_DINGTALK_CARD_BLOCK_LIST_KEY,
|
|
29
|
+
copyContentKey: BUILTIN_DINGTALK_CARD_COPY_CONTENT_KEY,
|
|
19
30
|
});
|
|
20
31
|
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
export function formatTokenCount(n: number): string {
|
|
2
|
+
if (n >= 1_000_000) {
|
|
3
|
+
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
4
|
+
}
|
|
5
|
+
if (n >= 1_000) {
|
|
6
|
+
return `${(n / 1_000).toFixed(1)}k`;
|
|
7
|
+
}
|
|
8
|
+
return String(n);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function formatDuration(seconds: number): string {
|
|
12
|
+
if (seconds < 60) {
|
|
13
|
+
return `${seconds}s`;
|
|
14
|
+
}
|
|
15
|
+
const m = Math.floor(seconds / 60);
|
|
16
|
+
const s = seconds % 60;
|
|
17
|
+
return `${m}m ${s}s`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface StatusLineData {
|
|
21
|
+
model?: string;
|
|
22
|
+
effort?: string;
|
|
23
|
+
agent?: string;
|
|
24
|
+
taskTime?: number;
|
|
25
|
+
inputTokens?: number;
|
|
26
|
+
outputTokens?: number;
|
|
27
|
+
cacheRead?: number;
|
|
28
|
+
dapi_usage?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface StatusLineConfig {
|
|
32
|
+
cardStatusLine?: {
|
|
33
|
+
model?: boolean;
|
|
34
|
+
effort?: boolean;
|
|
35
|
+
agent?: boolean;
|
|
36
|
+
taskTime?: boolean;
|
|
37
|
+
tokens?: boolean;
|
|
38
|
+
dapiUsage?: boolean;
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type SegmentKey = "model" | "effort" | "agent" | "tokens" | "taskTime" | "dapiUsage";
|
|
43
|
+
|
|
44
|
+
interface Segment {
|
|
45
|
+
key: SegmentKey;
|
|
46
|
+
defaultOn: boolean;
|
|
47
|
+
render: (d: StatusLineData) => string | undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function renderTokenSegment(data: StatusLineData): string | undefined {
|
|
51
|
+
const parts: string[] = [];
|
|
52
|
+
if (typeof data.inputTokens === "number") {
|
|
53
|
+
let s = `↑${formatTokenCount(data.inputTokens)}`;
|
|
54
|
+
if (typeof data.cacheRead === "number" && data.cacheRead > 0) {
|
|
55
|
+
s += `(C:${formatTokenCount(data.cacheRead)})`;
|
|
56
|
+
}
|
|
57
|
+
parts.push(s);
|
|
58
|
+
}
|
|
59
|
+
if (typeof data.outputTokens === "number") {
|
|
60
|
+
parts.push(`↓${formatTokenCount(data.outputTokens)}`);
|
|
61
|
+
}
|
|
62
|
+
return parts.length > 0 ? parts.join(" ") : undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const SEGMENTS: Segment[] = [
|
|
66
|
+
{ key: "model", defaultOn: true, render: (d) => d.model || undefined },
|
|
67
|
+
{ key: "effort", defaultOn: true, render: (d) => d.effort || undefined },
|
|
68
|
+
{ key: "agent", defaultOn: true, render: (d) => d.agent || undefined },
|
|
69
|
+
{ key: "tokens", defaultOn: false, render: renderTokenSegment },
|
|
70
|
+
{ key: "taskTime", defaultOn: false, render: (d) => typeof d.taskTime === "number" ? formatDuration(d.taskTime) : undefined },
|
|
71
|
+
{ key: "dapiUsage", defaultOn: false, render: (d) => typeof d.dapi_usage === "number" ? `DAPI+${d.dapi_usage}` : undefined },
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
const SEGMENTS_PER_LINE = 3;
|
|
75
|
+
|
|
76
|
+
function resolveSegmentEnabled(seg: Segment, config: StatusLineConfig): boolean {
|
|
77
|
+
const value = config.cardStatusLine?.[seg.key];
|
|
78
|
+
return typeof value === "boolean" ? value : seg.defaultOn;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function renderStatusLine(data: StatusLineData, config: StatusLineConfig): string {
|
|
82
|
+
const rendered = SEGMENTS
|
|
83
|
+
.filter((seg) => resolveSegmentEnabled(seg, config))
|
|
84
|
+
.map((seg) => seg.render(data))
|
|
85
|
+
.filter(Boolean) as string[];
|
|
86
|
+
|
|
87
|
+
if (rendered.length === 0) { return ""; }
|
|
88
|
+
|
|
89
|
+
const lines: string[] = [];
|
|
90
|
+
for (let i = 0; i < rendered.length; i += SEGMENTS_PER_LINE) {
|
|
91
|
+
lines.push(rendered.slice(i, i + SEGMENTS_PER_LINE).join(" | "));
|
|
92
|
+
}
|
|
93
|
+
return lines.join("\n");
|
|
94
|
+
}
|