Skip to content

Commit 0150205

Browse files
soimyclaude
andcommitted
feat(card): implement hybrid API routing for v2 card template
Route blockList (loopArray) via instances API to avoid 500 errors, while keeping content/taskInfo on streaming API for real-time updates. Adds updateAICardBlockList, streamAICardContent, clearAICardStreamingContent, and commitAICardBlocks with token refresh for long-lived cards. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c9f7316 commit 0150205

File tree

4 files changed

+231
-102
lines changed

4 files changed

+231
-102
lines changed

src/card-service.ts

Lines changed: 141 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -751,7 +751,7 @@ export async function createAICard(
751751
// Status is set to "streaming" via the streaming API immediately after creation.
752752
const cardParamMap = {
753753
config: JSON.stringify({ autoLayout: true, enableForward: true }),
754-
[template.contentKey]: "[]",
754+
[template.blockListKey]: "[]",
755755
stop_action: STOP_ACTION_VISIBLE,
756756
taskInfo: JSON.stringify({ model: "", effort: "", dap_usage: 0, taskTime: 0 }),
757757
content: "",
@@ -855,11 +855,9 @@ export async function createAICard(
855855

856856
clearAICardDegrade(accountId, log);
857857

858-
// Kick the card into streaming mode immediately so the UI shows "输出中" and the
859-
// stop button becomes visible. Without this, the card sits in "创建中" skeleton state
860-
// until the first real content arrives — which may never happen for non-streaming replies.
858+
// kick the card into streaming mode immediately so the UI shows "输出中".
861859
try {
862-
await putAICardStreamingField(aiCardInstance, template.contentKey, "[]", false, log);
860+
await putAICardStreamingField(aiCardInstance, template.streamingKey, "", false, log);
863861
aiCardInstance.state = AICardStatus.INPUTING;
864862
} catch (kickErr: any) {
865863
log?.debug?.(`[DingTalk][AICard] Non-critical: failed to kick card into streaming mode: ${kickErr.message}`);
@@ -901,31 +899,16 @@ export async function streamAICard(
901899
);
902900
return;
903901
}
904-
const template = DINGTALK_CARD_TEMPLATE;
905902

906903
try {
907-
// On finalize, write the copy-action content BEFORE closing the stream.
908-
// Once isFinalize=true is sent, the card may reject further streaming PUTs.
909-
if (finished) {
910-
const plainTextContent = extractAnswerTextFromBlockContent(content);
911-
if (plainTextContent.trim()) {
912-
try {
913-
await putAICardStreamingField(card, template.copyKey, plainTextContent, false, log);
914-
} catch (contentErr: any) {
915-
log?.debug?.(
916-
`[DingTalk][AICard] Non-critical: failed to sync content variable for copy action: ${contentErr.message}`,
917-
);
918-
}
919-
}
920-
}
921-
await putAICardStreamingField(card, template.contentKey, content, finished, log);
922-
card.lastStreamedContent = content;
904+
// HYBRID: Use instances API for blockList (not streaming API)
905+
// Streaming API returns 500 for complex loopArray types
923906
if (finished) {
924-
card.state = AICardStatus.FINISHED;
925-
removePendingCard(card, log);
926-
} else if (card.state === AICardStatus.PROCESSING) {
927-
card.state = AICardStatus.INPUTING;
907+
await commitAICardBlocks(card, content, true, log);
908+
} else {
909+
await updateAICardBlockList(card, content, log);
928910
}
911+
// State changes and lastStreamedContent are handled by the above functions
929912
} catch (err: any) {
930913
card.state = AICardStatus.FAILED;
931914
card.lastUpdated = Date.now();
@@ -984,6 +967,133 @@ export async function streamTaskInfo(
984967
await putAICardStreamingField(card, "taskInfo", JSON.stringify(taskInfo), false, log);
985968
}
986969

970+
/**
971+
* Update blockList via instances API (avoids 500 error for loopArray type).
972+
* This is the primary method for updating structured card content.
973+
*/
974+
export async function updateAICardBlockList(
975+
card: AICardInstance,
976+
blockListJson: string,
977+
log?: Logger,
978+
): Promise<void> {
979+
if (isCardInTerminalState(card.state)) {
980+
log?.debug?.(
981+
`[DingTalk][AICard] Skip blockList update because card already terminal: outTrackId=${card.cardInstanceId} state=${card.state}`,
982+
);
983+
return;
984+
}
985+
const template = DINGTALK_CARD_TEMPLATE;
986+
const tokenAge = Date.now() - card.createdAt;
987+
const tokenRefreshThreshold = 90 * 60 * 1000;
988+
989+
// Refresh token if aged (same logic as putAICardStreamingField)
990+
if (tokenAge > tokenRefreshThreshold && card.config) {
991+
log?.debug?.("[DingTalk][AICard] Token age exceeds threshold, refreshing for blockList update...");
992+
try {
993+
card.accessToken = await getAccessToken(card.config, log);
994+
log?.debug?.("[DingTalk][AICard] Token refreshed successfully for blockList update");
995+
} catch (err: any) {
996+
log?.warn?.(`[DingTalk][AICard] Failed to refresh token for blockList update: ${err.message}`);
997+
}
998+
}
999+
1000+
const token = card.accessToken || (card.config ? await getAccessToken(card.config, log) : "");
1001+
if (!token) {
1002+
throw new Error("No access token available for blockList update");
1003+
}
1004+
1005+
try {
1006+
await updateCardVariables(
1007+
card.outTrackId || card.cardInstanceId,
1008+
{ [template.blockListKey]: blockListJson },
1009+
token,
1010+
card.config ? { bypassProxyForSend: card.config.bypassProxyForSend } : {},
1011+
);
1012+
card.lastStreamedContent = blockListJson;
1013+
card.lastUpdated = Date.now();
1014+
incrementCardDapiCount(card);
1015+
if (card.state === AICardStatus.PROCESSING) {
1016+
card.state = AICardStatus.INPUTING;
1017+
}
1018+
} catch (err: any) {
1019+
log?.error?.(`[DingTalk][AICard] blockList update failed: ${err.message}`);
1020+
if (card.accountId && shouldTriggerAICardDegrade(err)) {
1021+
activateAICardDegrade(
1022+
card.accountId,
1023+
`card.blockList:${err?.response?.status || "unknown"}`,
1024+
card.config,
1025+
log,
1026+
);
1027+
}
1028+
throw err;
1029+
}
1030+
}
1031+
1032+
/**
1033+
* Stream content to the streaming key for real-time preview.
1034+
* Only used when cardRealTimeStream=true for low-latency text display.
1035+
*/
1036+
export async function streamAICardContent(
1037+
card: AICardInstance,
1038+
text: string,
1039+
log?: Logger,
1040+
): Promise<void> {
1041+
const template = DINGTALK_CARD_TEMPLATE;
1042+
await putAICardStreamingField(card, template.streamingKey, text, false, log);
1043+
}
1044+
1045+
/**
1046+
* Clear the streaming content variable at block boundaries.
1047+
* Called before committing new blocks to avoid stale content display.
1048+
*/
1049+
export async function clearAICardStreamingContent(
1050+
card: AICardInstance,
1051+
log?: Logger,
1052+
): Promise<void> {
1053+
const template = DINGTALK_CARD_TEMPLATE;
1054+
try {
1055+
await putAICardStreamingField(card, template.streamingKey, "", false, log);
1056+
} catch (err: any) {
1057+
log?.debug?.(`[DingTalk][AICard] Non-critical: failed to clear streaming content: ${err.message}`);
1058+
}
1059+
}
1060+
1061+
/**
1062+
* Commit blocks with final content sync. Used at finalize time.
1063+
* 1. Sync plain-text content for copy action
1064+
* 2. Update blockList via instances API
1065+
*/
1066+
export async function commitAICardBlocks(
1067+
card: AICardInstance,
1068+
blockListJson: string,
1069+
isFinalize: boolean,
1070+
log?: Logger,
1071+
): Promise<void> {
1072+
const template = DINGTALK_CARD_TEMPLATE;
1073+
1074+
// On finalize, sync plain-text content for copy action BEFORE blockList update
1075+
if (isFinalize) {
1076+
const plainTextContent = extractAnswerTextFromBlockContent(blockListJson);
1077+
if (plainTextContent.trim()) {
1078+
try {
1079+
await putAICardStreamingField(card, template.copyKey, plainTextContent, false, log);
1080+
} catch (contentErr: any) {
1081+
log?.debug?.(
1082+
`[DingTalk][AICard] Non-critical: failed to sync content for copy action: ${contentErr.message}`,
1083+
);
1084+
}
1085+
}
1086+
}
1087+
1088+
// Update blockList via instances API (not streaming API - avoids 500 error)
1089+
await updateAICardBlockList(card, blockListJson, log);
1090+
1091+
if (isFinalize) {
1092+
card.state = AICardStatus.FINISHED;
1093+
removePendingCard(card, log);
1094+
}
1095+
}
1096+
9871097
export async function finishAICard(
9881098
card: AICardInstance,
9891099
content: string,
@@ -1005,8 +1115,8 @@ export async function finishAICard(
10051115
}
10061116
}
10071117

1008-
// Finalize the card stream (isFinalize=true).
1009-
await streamAICard(card, content, true, log);
1118+
// Commit blocks via instances API with content sync
1119+
await commitAICardBlocks(card, content, true, log);
10101120

10111121
// v2 template does not have a stop button in finished state, no need to hide.
10121122

@@ -1037,25 +1147,11 @@ export async function finishStoppedAICard(
10371147
);
10381148
return;
10391149
}
1040-
const template = DINGTALK_CARD_TEMPLATE;
10411150
try {
1042-
// Sync the plain-text content variable BEFORE finalizing the blockList.
1043-
// DingTalk may reject updates after isFinalize=true, so content must come first.
1044-
const plainTextContent = extractAnswerTextFromBlockContent(content);
1045-
if (plainTextContent.trim()) {
1046-
try {
1047-
await putAICardStreamingField(card, template.copyKey, plainTextContent, false, log);
1048-
} catch (contentErr: any) {
1049-
log?.debug?.(
1050-
`[DingTalk][AICard] Non-critical: failed to sync content variable on stop: ${contentErr.message}`,
1051-
);
1052-
}
1053-
}
1054-
// Now finalize the blockList with isFinalize=true.
1055-
await putAICardStreamingField(card, template.contentKey, content, true, log);
1151+
// HYBRID: commit via instances API (not streaming API)
1152+
await commitAICardBlocks(card, content, true, log);
10561153
} finally {
1057-
// Ensure local state is consistent even when the streaming API call fails.
1058-
// The card is logically stopped regardless of whether DingTalk acknowledged it.
1154+
// Ensure local state is consistent even when the API call fails.
10591155
card.lastStreamedContent = content;
10601156
card.state = AICardStatus.STOPPED;
10611157
card.lastUpdated = Date.now();

src/card/card-template.ts

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,42 @@ export const STOP_ACTION_VISIBLE = "true";
33
/** Card variable value that hides the stop button. */
44
export const STOP_ACTION_HIDDEN = "false";
55

6-
/** The v2 template ID that ships with this plugin version. */
7-
const BUILTIN_TEMPLATE_ID = "9a9138ed-35d3-498e-9019-748b59ddc39a.schema";
8-
/** Card variable key for structured block streaming. */
9-
const BUILTIN_CONTENT_KEY = "blockList";
10-
/** Card variable key for the copy action (plain text representation). */
6+
/** The v2 template ID that supports streaming via content key. */
7+
const BUILTIN_TEMPLATE_ID =
8+
process.env.DINGTALK_CARD_TEMPLATE_ID || "5db37f25-ac9e-4250-9c1d-c4ddba6e16e9.schema";
9+
/** Key for blockList updates via instances API (complex loopArray type). */
10+
const BUILTIN_BLOCKLIST_KEY = "blockList";
11+
/** Key for real-time streaming via streaming API (simple string type). */
12+
const BUILTIN_STREAMING_KEY = "content";
13+
/** Key for the plain text copy action variable (same as streaming key). */
1114
const BUILTIN_COPY_KEY = "content";
15+
/** Key for taskInfo streaming via streaming API (simple JSON string type). */
16+
const BUILTIN_TASKINFO_KEY = "taskInfo";
1217

1318
export interface DingTalkCardTemplateContract {
1419
templateId: string;
15-
/** Content key for block streaming (blockList). */
16-
contentKey: string;
20+
/** Key for blockList updates via instances API (complex loopArray type). */
21+
blockListKey: string;
22+
/** Key for real-time streaming via streaming API (simple string type). */
23+
streamingKey: string;
1724
/** Key for the plain text copy action variable. */
1825
copyKey: string;
26+
/** Key for taskInfo streaming (simple JSON string type). */
27+
taskInfoKey: string;
28+
/** @deprecated Use blockListKey instead. Kept for backward compatibility. */
29+
contentKey: string;
1930
}
2031

2132
/** Frozen singleton — no allocation on every call. */
2233
export const DINGTALK_CARD_TEMPLATE: Readonly<DingTalkCardTemplateContract> = Object.freeze({
2334
templateId: BUILTIN_TEMPLATE_ID,
24-
contentKey: BUILTIN_CONTENT_KEY,
35+
blockListKey: BUILTIN_BLOCKLIST_KEY,
36+
streamingKey: BUILTIN_STREAMING_KEY,
2537
copyKey: BUILTIN_COPY_KEY,
38+
taskInfoKey: BUILTIN_TASKINFO_KEY,
39+
contentKey: BUILTIN_BLOCKLIST_KEY, // backward compatibility alias
2640
});
41+
42+
// Legacy exports for backward compatibility
43+
export const BUILTIN_DINGTALK_CARD_TEMPLATE_ID = BUILTIN_TEMPLATE_ID;
44+
export const BUILTIN_DINGTALK_CARD_CONTENT_KEY = BUILTIN_BLOCKLIST_KEY;

tests/unit/card-draft-controller.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ vi.mock("../../src/card-service", async (importOriginal) => {
1010
...actual,
1111
streamAICard: vi.fn(),
1212
updatePendingCardLastContent: vi.fn(),
13+
updateAICardBlockList: vi.fn(),
14+
streamAICardContent: vi.fn(),
15+
clearAICardStreamingContent: vi.fn(),
16+
commitAICardBlocks: vi.fn(),
1317
};
1418
});
1519

0 commit comments

Comments
 (0)