@soimy/dingtalk 3.5.2 → 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/README.md +6 -23
- package/index.ts +7 -0
- package/openclaw.plugin.json +799 -0
- package/package.json +5 -5
- 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-streaming-mode.ts +30 -0
- package/src/card/card-template.ts +14 -3
- package/src/card/reasoning-answer-split.ts +162 -0
- package/src/card/statusline-renderer.ts +94 -0
- package/src/card-draft-controller.ts +326 -54
- package/src/card-service.ts +479 -8
- package/src/channel.ts +19 -1062
- package/src/config-schema.ts +81 -38
- package/src/config.ts +142 -4
- package/src/device-registration.ts +245 -0
- package/src/gateway/channel-gateway.ts +636 -0
- package/src/inbound-handler.ts +489 -49
- package/src/media-utils.ts +169 -7
- package/src/message-utils.ts +153 -17
- 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/messaging/quoted-file-service.ts +9 -4
- package/src/onboarding.ts +323 -205
- package/src/platform/channel-status.ts +81 -0
- package/src/plugin-sdk-channel-actions-augment.ts +11 -0
- package/src/reply-strategy-card.ts +568 -44
- package/src/reply-strategy-markdown.ts +2 -2
- package/src/reply-strategy-types.ts +93 -0
- package/src/reply-strategy-with-reaction.ts +1 -1
- package/src/reply-strategy.ts +14 -56
- package/src/run-usage-store.ts +59 -0
- package/src/send-service.ts +225 -7
- package/src/session-state.ts +62 -0
- package/src/targeting/agent-name-matcher.ts +28 -0
- package/src/targeting/agent-routing.ts +44 -28
- package/src/types.ts +49 -117
- package/src/utils.ts +25 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@soimy/dingtalk",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.6.0",
|
|
4
4
|
"description": "DingTalk (钉钉) channel plugin for OpenClaw",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bot",
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"vitest": "^3.2.4"
|
|
65
65
|
},
|
|
66
66
|
"peerDependencies": {
|
|
67
|
-
"openclaw": ">=2026.3.
|
|
67
|
+
"openclaw": ">=2026.3.28"
|
|
68
68
|
},
|
|
69
69
|
"peerDependenciesMeta": {
|
|
70
70
|
"openclaw": {
|
|
@@ -73,10 +73,10 @@
|
|
|
73
73
|
},
|
|
74
74
|
"openclaw": {
|
|
75
75
|
"compat": {
|
|
76
|
-
"pluginApi": ">=2026.3.
|
|
76
|
+
"pluginApi": ">=2026.3.28"
|
|
77
77
|
},
|
|
78
78
|
"build": {
|
|
79
|
-
"openclawVersion": "2026.3.
|
|
79
|
+
"openclawVersion": "2026.3.28"
|
|
80
80
|
},
|
|
81
81
|
"extensions": [
|
|
82
82
|
"./index.ts"
|
|
@@ -99,7 +99,7 @@
|
|
|
99
99
|
]
|
|
100
100
|
},
|
|
101
101
|
"install": {
|
|
102
|
-
"minHostVersion": ">=2026.3.
|
|
102
|
+
"minHostVersion": ">=2026.3.28",
|
|
103
103
|
"npmSpec": "@soimy/dingtalk",
|
|
104
104
|
"localPath": ".",
|
|
105
105
|
"defaultChoice": "npm"
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { DingTalkConfig } from "../types";
|
|
2
|
+
|
|
3
|
+
export type CardStreamingMode = "off" | "answer" | "all";
|
|
4
|
+
|
|
5
|
+
// Process-lifetime one-shot warnings are intentional here: a given config key
|
|
6
|
+
// should only emit the deprecation notice once per runtime.
|
|
7
|
+
const warnedLegacyConfigs = new Set<string>();
|
|
8
|
+
|
|
9
|
+
export function resolveCardStreamingMode(
|
|
10
|
+
config: Pick<DingTalkConfig, "cardStreamingMode" | "cardRealTimeStream">,
|
|
11
|
+
): {
|
|
12
|
+
mode: CardStreamingMode;
|
|
13
|
+
usedDeprecatedCardRealTimeStream: boolean;
|
|
14
|
+
} {
|
|
15
|
+
if (config.cardStreamingMode) {
|
|
16
|
+
return { mode: config.cardStreamingMode, usedDeprecatedCardRealTimeStream: false };
|
|
17
|
+
}
|
|
18
|
+
if (config.cardRealTimeStream === true) {
|
|
19
|
+
return { mode: "all", usedDeprecatedCardRealTimeStream: true };
|
|
20
|
+
}
|
|
21
|
+
return { mode: "off", usedDeprecatedCardRealTimeStream: false };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function shouldWarnDeprecatedCardRealTimeStreamOnce(configKey: string): boolean {
|
|
25
|
+
if (warnedLegacyConfigs.has(configKey)) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
warnedLegacyConfigs.add(configKey);
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
@@ -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,162 @@
|
|
|
1
|
+
export interface CardReasoningAnswerSplit {
|
|
2
|
+
reasoningText?: string;
|
|
3
|
+
answerText?: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const THINKING_TAG_RE = /<\s*(\/?)\s*(?:think(?:ing)?|thought|antthinking)\b[^<>]*>/gi;
|
|
7
|
+
|
|
8
|
+
function isWrappedReasoningLine(line: string): boolean {
|
|
9
|
+
const trimmed = line.trim();
|
|
10
|
+
return trimmed.startsWith("_") && trimmed.endsWith("_") && trimmed.length >= 3;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function cleanReasoningLine(line: string): string {
|
|
14
|
+
const trimmed = line.trim();
|
|
15
|
+
if (!trimmed) {
|
|
16
|
+
return "";
|
|
17
|
+
}
|
|
18
|
+
return trimmed.replace(/^_/, "").replace(/_$/, "").trim();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function joinAnswerParts(parts: string[]): string | undefined {
|
|
22
|
+
const joined = parts.map((part) => part.trim()).filter(Boolean).join("\n\n").trim();
|
|
23
|
+
return joined || undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function hasStructuredMarkdown(text: string): boolean {
|
|
27
|
+
const trimmed = text.trim();
|
|
28
|
+
if (!trimmed) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
return (
|
|
32
|
+
trimmed.includes("**")
|
|
33
|
+
|| trimmed.includes("```")
|
|
34
|
+
|| /(^|\n)\s*(?:[-*+]\s|#{1,6}\s|>\s|\d+\.\s)/.test(trimmed)
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function splitTopLevelReasoningPrefix(text: string): CardReasoningAnswerSplit | null {
|
|
39
|
+
const markerIndex = text.indexOf("Reasoning:");
|
|
40
|
+
if (markerIndex < 0) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const before = text.slice(0, markerIndex).trim();
|
|
45
|
+
if (before && hasStructuredMarkdown(before)) {
|
|
46
|
+
return {
|
|
47
|
+
answerText: text,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const trailing = text.slice(markerIndex + "Reasoning:".length);
|
|
52
|
+
const lines = trailing.split("\n");
|
|
53
|
+
const reasoningLines: string[] = [];
|
|
54
|
+
let started = false;
|
|
55
|
+
let remainderIndex = -1;
|
|
56
|
+
|
|
57
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
58
|
+
const line = lines[index];
|
|
59
|
+
const trimmed = line.trim();
|
|
60
|
+
|
|
61
|
+
if (!started) {
|
|
62
|
+
if (!trimmed) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (!isWrappedReasoningLine(trimmed)) {
|
|
66
|
+
return {
|
|
67
|
+
answerText: text,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
started = true;
|
|
71
|
+
reasoningLines.push(cleanReasoningLine(trimmed));
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!trimmed) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (isWrappedReasoningLine(trimmed)) {
|
|
80
|
+
reasoningLines.push(cleanReasoningLine(trimmed));
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
remainderIndex = index;
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (reasoningLines.length === 0) {
|
|
89
|
+
return {
|
|
90
|
+
answerText: text,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const after = remainderIndex >= 0 ? lines.slice(remainderIndex).join("\n").trim() : "";
|
|
95
|
+
return {
|
|
96
|
+
reasoningText: reasoningLines.join("\n").trim() || undefined,
|
|
97
|
+
answerText: joinAnswerParts([before, after]),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function splitTopLevelThinkingTags(text: string): CardReasoningAnswerSplit | null {
|
|
102
|
+
if (!THINKING_TAG_RE.test(text)) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
THINKING_TAG_RE.lastIndex = 0;
|
|
106
|
+
|
|
107
|
+
let reasoning = "";
|
|
108
|
+
let answer = "";
|
|
109
|
+
let lastIndex = 0;
|
|
110
|
+
let inThinking = false;
|
|
111
|
+
|
|
112
|
+
for (const match of text.matchAll(THINKING_TAG_RE)) {
|
|
113
|
+
const matchIndex = match.index ?? 0;
|
|
114
|
+
const segment = text.slice(lastIndex, matchIndex);
|
|
115
|
+
if (inThinking) {
|
|
116
|
+
reasoning += segment;
|
|
117
|
+
} else {
|
|
118
|
+
answer += segment;
|
|
119
|
+
}
|
|
120
|
+
inThinking = match[1] !== "/";
|
|
121
|
+
lastIndex = matchIndex + match[0].length;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const tail = text.slice(lastIndex);
|
|
125
|
+
if (inThinking) {
|
|
126
|
+
reasoning += tail;
|
|
127
|
+
} else {
|
|
128
|
+
answer += tail;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const cleanedReasoning = reasoning.trim();
|
|
132
|
+
if (!cleanedReasoning) {
|
|
133
|
+
return {
|
|
134
|
+
answerText: text,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
reasoningText: cleanedReasoning,
|
|
140
|
+
answerText: answer.trim() || undefined,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function splitCardReasoningAnswerText(text?: string): CardReasoningAnswerSplit {
|
|
145
|
+
if (typeof text !== "string") {
|
|
146
|
+
return {};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const prefixed = splitTopLevelReasoningPrefix(text);
|
|
150
|
+
if (prefixed) {
|
|
151
|
+
return prefixed;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const tagged = splitTopLevelThinkingTags(text);
|
|
155
|
+
if (tagged) {
|
|
156
|
+
return tagged;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
answerText: text,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
@@ -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
|
+
}
|