@@ -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+
9871097export 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 ( ) ;
0 commit comments