From 1d084b7c6e3effeca63e403008e5c5fc95a37538 Mon Sep 17 00:00:00 2001 From: zhubby Date: Mon, 27 Apr 2026 22:56:37 +0800 Subject: [PATCH] fix(dingtalk): stream reasoning into AI card template Add a dedicated reasoning template key for DingTalk AI card streams while preserving the existing content stream behavior and guarding problematic streaming frames. Closes #231 Made-with: Cursor --- docs/src/channels/dingtalk.md | 11 ++ docs/src/configration/fields.md | 54 +++++++ klaw-channel/CHANGELOG.md | 14 ++ klaw-channel/src/dingtalk/client.rs | 14 +- klaw-channel/src/dingtalk/config.rs | 2 + klaw-channel/src/dingtalk/mod.rs | 214 +++++++++++++++++++++++----- klaw-channel/src/dingtalk/tests.rs | 207 ++++++++++++++++++++++++++- klaw-channel/src/manager.rs | 1 + klaw-config/CHANGELOG.md | 10 ++ klaw-config/README.md | 1 + klaw-config/src/lib.rs | 7 + klaw-config/src/tests.rs | 81 ++++++++++- klaw-config/src/validate.rs | 14 ++ klaw-gui/CHANGELOG.md | 6 + klaw-gui/src/panels/channel.rs | 13 ++ 15 files changed, 605 insertions(+), 44 deletions(-) diff --git a/docs/src/channels/dingtalk.md b/docs/src/channels/dingtalk.md index bdc59672..40776405 100644 --- a/docs/src/channels/dingtalk.md +++ b/docs/src/channels/dingtalk.md @@ -34,6 +34,10 @@ client_id = "your-app-key" client_secret = "your-app-secret" bot_title = "Klaw" show_reasoning = false +stream_output = false +stream_template_id = "" +stream_content_key = "content" +stream_reasoning_key = "reasoning" allowlist = ["USER123", "*"] [channels.dingtalk.proxy] @@ -51,14 +55,21 @@ url = "http://proxy.example.com:8080" | `client_secret` | `string` | 是 | 钉钉应用 AppSecret | - | | `bot_title` | `string` | 否 | 机器人显示名称 | `"Klaw"` | | `show_reasoning` | `bool` | 否 | 响应中是否展示推理过程 | `false` | +| `stream_output` | `bool` | 否 | 是否使用钉钉 AI 卡片模板流式刷新回复 | `false` | +| `stream_template_id` | `string` | 条件必填 | `stream_output=true` 时使用的钉钉 AI 卡片模板 ID | `""` | +| `stream_content_key` | `string` | 条件必填 | AI 卡片模板中承载正文的变量名 | `"content"` | +| `stream_reasoning_key` | `string` | 条件必填 | AI 卡片模板中承载 reasoning 的变量名 | `"reasoning"` | | `allowlist` | `string[]` | 否 | 发送者白名单(`*` 表示允许所有) | `[]`(允许所有) | | `proxy.enabled` | `bool` | 否 | 是否启用代理 | `false` | | `proxy.url` | `string` | 条件必填 | 代理地址 | - | 配置校验: - `client_id` 和 `client_secret` 不能为空 +- `stream_output=true` 时 `stream_template_id`、`stream_content_key`、`stream_reasoning_key` 均不能为空,且正文变量名不能与 reasoning 变量名相同 - `proxy.enabled=true` 时 `proxy.url` 必填 +流式 AI 卡片模板需要在钉钉侧声明 `stream_content_key` 与 `stream_reasoning_key` 对应的变量。开启 `show_reasoning` 时,正文变量只刷新模型正文,reasoning 变量按全量覆盖方式刷新模型返回的 reasoning,避免正文与独立 reasoning 区块重复展示。 + ## 连接建立流程 DingTalk 渠道使用 WebSocket 长连接协议,连接建立流程如下: diff --git a/docs/src/configration/fields.md b/docs/src/configration/fields.md index 5a7be518..fa2d64db 100644 --- a/docs/src/configration/fields.md +++ b/docs/src/configration/fields.md @@ -363,6 +363,10 @@ client_id = "your-app-key" client_secret = "your-app-secret" bot_title = "Klaw" show_reasoning = false +stream_output = false +stream_template_id = "" +stream_content_key = "content" +stream_reasoning_key = "reasoning" allowlist = ["USER123", "*"] ``` @@ -442,6 +446,56 @@ channels.dingtalk.bot_title = "我的助手" channels.dingtalk.show_reasoning = true ``` +当 `stream_output=true` 且钉钉 AI 卡片模板包含独立 reasoning 变量时,开启该项会把模型流式返回的 reasoning 写入 `stream_reasoning_key` 指定的模板变量;正文变量不会再重复拼接 reasoning 块。 + +#### `channels.dingtalk[].stream_output` + +**类型**: `boolean` +**默认值**: `false` +**必填**: 否 + +是否允许 DingTalk 渠道使用 AI 卡片模板流式刷新回复。 + +```toml +channels.dingtalk.stream_output = true +``` + +#### `channels.dingtalk[].stream_template_id` + +**类型**: `string` +**默认值**: `""` +**必填**: `stream_output=true` 时必填 + +钉钉 AI 卡片模板 ID。 + +```toml +channels.dingtalk.stream_template_id = "template-1.schema" +``` + +#### `channels.dingtalk[].stream_content_key` + +**类型**: `string` +**默认值**: `"content"` +**必填**: `stream_output=true` 时必填 + +钉钉 AI 卡片模板中承载正文内容的变量名。 + +```toml +channels.dingtalk.stream_content_key = "content" +``` + +#### `channels.dingtalk[].stream_reasoning_key` + +**类型**: `string` +**默认值**: `"reasoning"` +**必填**: `stream_output=true` 时必填 + +钉钉 AI 卡片模板中承载 reasoning 内容的变量名。该变量会用全量覆盖方式刷新,适合每轮 reasoning 不同的展示块;不能与 `stream_content_key` 相同。 + +```toml +channels.dingtalk.stream_reasoning_key = "reasoning" +``` + #### `channels.dingtalk[].allowlist` **类型**: `array` diff --git a/klaw-channel/CHANGELOG.md b/klaw-channel/CHANGELOG.md index 9e765a04..c9cf3fd1 100644 --- a/klaw-channel/CHANGELOG.md +++ b/klaw-channel/CHANGELOG.md @@ -1,5 +1,19 @@ # CHANGELOG +## 2026-04-27 + +### Changed + +- `dingtalk` AI 卡片流式输出在开启 `show_reasoning` 时会把正文与 reasoning 拆分到独立模板变量,并对 reasoning 变量执行全量刷新,避免正文重复拼接 reasoning 块 + +### Fixed + +- `dingtalk` AI 卡片流式输出不再对初始空正文或空 reasoning 调用 streaming update,避免触发钉钉接口的空 `content` 错误;清空已有 reasoning 时改用非空占位内容刷新模板变量 +- `dingtalk` AI 卡片流式输出在单次更新内容超过钉钉 streaming 接口限制时会提前停止流式卡片并回退最终 Markdown 回复,避免接口返回 `unknownError` +- `dingtalk` AI 卡片创建 payload 恢复为只初始化正文变量,reasoning 变量仅通过后续 streaming update 写入,避免额外初始变量影响既有 `content` 流式通道 +- `dingtalk` AI 卡片流式更新现在会在发送前补齐未闭合的 Markdown fenced code block,避免中间帧 Markdown 语法不完整时钉钉服务端转换失败 +- `dingtalk` AI 卡片收尾时若同时刷新正文与 reasoning,现在只让最后一个 streaming key 携带 `isFinalize=true`,避免先 finalize 正文导致后续 reasoning 流仍未收尾时卡片状态异常 + ## 2026-04-22 ### Changed diff --git a/klaw-channel/src/dingtalk/client.rs b/klaw-channel/src/dingtalk/client.rs index 6000e39d..d650b3e8 100644 --- a/klaw-channel/src/dingtalk/client.rs +++ b/klaw-channel/src/dingtalk/client.rs @@ -75,10 +75,18 @@ pub(super) fn build_proactive_markdown_payload( } pub(super) fn build_ai_card_card_data(content_key: &str, content: &str) -> Value { + build_ai_card_card_data_from_pairs([(content_key, content)]) +} + +pub(super) fn build_ai_card_card_data_from_pairs<'a>( + pairs: impl IntoIterator, +) -> Value { + let card_param_map = pairs + .into_iter() + .map(|(key, content)| (key.trim().to_string(), Value::String(content.to_string()))) + .collect::>(); serde_json::json!({ - "cardParamMap": { - content_key.trim(): content, - } + "cardParamMap": card_param_map }) } diff --git a/klaw-channel/src/dingtalk/config.rs b/klaw-channel/src/dingtalk/config.rs index 345af3b9..08873e7b 100644 --- a/klaw-channel/src/dingtalk/config.rs +++ b/klaw-channel/src/dingtalk/config.rs @@ -15,6 +15,7 @@ pub struct DingtalkChannelConfig { pub stream_output: bool, pub stream_template_id: String, pub stream_content_key: String, + pub stream_reasoning_key: String, pub allowlist: Vec, pub local_attachments: LocalAttachmentConfig, pub proxy: DingtalkProxyConfig, @@ -31,6 +32,7 @@ impl Default for DingtalkChannelConfig { stream_output: false, stream_template_id: String::new(), stream_content_key: "content".to_string(), + stream_reasoning_key: "reasoning".to_string(), allowlist: Vec::new(), local_attachments: LocalAttachmentConfig::default(), proxy: DingtalkProxyConfig::default(), diff --git a/klaw-channel/src/dingtalk/mod.rs b/klaw-channel/src/dingtalk/mod.rs index 7b48b5fc..f11025d6 100644 --- a/klaw-channel/src/dingtalk/mod.rs +++ b/klaw-channel/src/dingtalk/mod.rs @@ -49,6 +49,7 @@ const WS_STALL_TIMEOUT: Duration = Duration::from_secs(35); const EVENT_DEDUP_TTL: Duration = Duration::from_secs(60 * 60); const EVENT_DEDUP_MAX_ENTRIES: usize = 20_000; const DINGTALK_STREAM_UPDATE_INTERVAL: Duration = Duration::from_millis(400); +const DINGTALK_STREAM_MAX_CONTENT_BYTES: usize = 1024; /// Returns `true` when shutdown was requested during the delay. async fn wait_reconnect_delay_or_shutdown( @@ -116,15 +117,23 @@ struct DingtalkStreamWriter { chat_id: String, template_id: String, content_key: String, + reasoning_key: String, show_reasoning: bool, out_track_id: String, - last_rendered: Option, + last_content: Option, + last_reasoning: Option, last_update_at: Option, card_sent: bool, stream_failed: bool, saw_special_card: bool, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct DingtalkStreamSnapshotParts { + content: String, + reasoning: String, +} + impl DingtalkStreamWriter { fn new( client: DingtalkApiClient, @@ -134,6 +143,7 @@ impl DingtalkStreamWriter { chat_id: String, template_id: String, content_key: String, + reasoning_key: String, show_reasoning: bool, ) -> Self { Self { @@ -144,9 +154,11 @@ impl DingtalkStreamWriter { chat_id, template_id, content_key, + reasoning_key, show_reasoning, out_track_id: Uuid::new_v4().to_string(), - last_rendered: None, + last_content: None, + last_reasoning: None, last_update_at: None, card_sent: false, stream_failed: false, @@ -158,6 +170,94 @@ impl DingtalkStreamWriter { !self.template_id.trim().is_empty() } + fn stream_reasoning_enabled(&self) -> bool { + self.show_reasoning + && !self.reasoning_key.trim().is_empty() + && self.reasoning_key.trim() != self.content_key.trim() + } + + fn stream_snapshot_parts(&self, output: &ChannelResponse) -> DingtalkStreamSnapshotParts { + let content = render_agent_output(output, false, OutputRenderStyle::Markdown); + let reasoning = self + .show_reasoning + .then(|| { + output + .reasoning + .as_deref() + .unwrap_or_default() + .trim() + .to_string() + }) + .unwrap_or_default(); + DingtalkStreamSnapshotParts { content, reasoning } + } + + fn should_stream_reasoning_update( + &self, + parts: &DingtalkStreamSnapshotParts, + force: bool, + finalize: bool, + ) -> bool { + if !self.stream_reasoning_enabled() { + return false; + } + if parts.reasoning.is_empty() && self.last_reasoning.is_none() { + return false; + } + self.last_reasoning.as_deref() != Some(parts.reasoning.as_str()) || force || finalize + } + + fn should_stream_content_update( + &self, + parts: &DingtalkStreamSnapshotParts, + force: bool, + finalize: bool, + ) -> bool { + if parts.content.is_empty() && self.last_content.is_none() { + return false; + } + self.last_content.as_deref() != Some(parts.content.as_str()) || force || finalize + } + + fn stream_api_content(content: &str) -> String { + if content.is_empty() { + return " ".to_string(); + } + let fence_count = content + .lines() + .filter(|line| line.trim_start().starts_with("```")) + .count(); + if fence_count % 2 == 1 { + format!("{content}\n```") + } else { + content.to_string() + } + } + + fn validate_stream_api_content(key: &str, content: &str) -> Result<(), String> { + let content_bytes = content.as_bytes().len(); + if content_bytes > DINGTALK_STREAM_MAX_CONTENT_BYTES { + return Err(format!( + "streaming content for key '{}' exceeds dingtalk streaming limit: {} > {} bytes", + key.trim(), + content_bytes, + DINGTALK_STREAM_MAX_CONTENT_BYTES + )); + } + Ok(()) + } + + fn content_finalize_flag(will_stream_reasoning: bool, finalize: bool) -> bool { + finalize && !will_stream_reasoning + } + + fn build_initial_card_data(&self) -> serde_json::Value { + // Preserve the pre-reasoning AI card creation shape. DingTalk accepts + // subsequent streaming updates by key; adding extra initial stream keys + // can make the primary content stream fail with an opaque 500. + build_ai_card_card_data(&self.content_key, "") + } + async fn begin(&mut self) { if !self.enabled() || self.stream_failed || self.card_sent { return; @@ -185,7 +285,7 @@ impl DingtalkStreamWriter { return; } }; - let card_data = build_ai_card_card_data(&self.content_key, ""); + let card_data = self.build_initial_card_data(); if let Err(err) = self .client .create_and_deliver_ai_card( @@ -220,34 +320,33 @@ impl DingtalkStreamWriter { self.saw_special_card = true; return; } - let rendered = - render_agent_output(output, self.show_reasoning, OutputRenderStyle::Markdown); - if rendered.trim().is_empty() { - if let Some(rendered) = self - .last_rendered - .clone() - .filter(|value| !value.trim().is_empty()) - { - let _ = self.flush_rendered(rendered, true, true).await; - } - return; - } - let _ = self.flush_rendered(rendered, true, true).await; + let parts = self.stream_snapshot_parts(output); + let _ = self.flush_stream_parts(parts, true, true).await; } - async fn flush_rendered( + async fn flush_stream_parts( &mut self, - rendered: String, + parts: DingtalkStreamSnapshotParts, force: bool, finalize: bool, ) -> ChannelResult<()> { - if !self.enabled() || rendered.trim().is_empty() { + if !self.enabled() { return Ok(()); } if self.stream_failed { return Ok(()); } - if self.last_rendered.as_deref() == Some(rendered.as_str()) && !force && !finalize { + let stream_reasoning = self.stream_reasoning_enabled(); + let should_stream_content = self.should_stream_content_update(&parts, force, finalize); + let should_stream_reasoning = self.should_stream_reasoning_update(&parts, force, finalize); + if parts.content.trim().is_empty() + && (!stream_reasoning || parts.reasoning.trim().is_empty()) + && self.last_content.is_none() + && self.last_reasoning.is_none() + { + return Ok(()); + } + if !should_stream_content && !should_stream_reasoning { return Ok(()); } let should_flush = finalize @@ -256,7 +355,12 @@ impl DingtalkStreamWriter { || self .last_update_at .is_none_or(|instant| instant.elapsed() >= DINGTALK_STREAM_UPDATE_INTERVAL); - self.last_rendered = Some(rendered.clone()); + if should_stream_content || !parts.content.is_empty() { + self.last_content = Some(parts.content.clone()); + } + if should_stream_reasoning || !parts.reasoning.is_empty() { + self.last_reasoning = Some(parts.reasoning.clone()); + } if !should_flush { return Ok(()); } @@ -273,23 +377,57 @@ impl DingtalkStreamWriter { out_track_id = self.out_track_id.as_str(), force, finalize, - content_chars = rendered.chars().count(), + content_chars = parts.content.chars().count(), + reasoning_chars = parts.reasoning.chars().count(), "updating dingtalk ai card stream snapshot" ); let access_token = self .client .fetch_access_token(&self.client_id, &self.client_secret) .await?; - self.client - .stream_ai_card( - &access_token, - &self.out_track_id, - &self.content_key, - &rendered, - finalize, - false, - ) - .await?; + if should_stream_content { + let content = Self::stream_api_content(&parts.content); + Self::validate_stream_api_content(&self.content_key, &content)?; + let content_finalize = Self::content_finalize_flag(should_stream_reasoning, finalize); + self.client + .stream_ai_card( + &access_token, + &self.out_track_id, + &self.content_key, + &content, + content_finalize, + false, + ) + .await + .map_err(|err| { + format!( + "dingtalk ai card streaming update failed for key '{}' ({} bytes): {err}", + self.content_key.trim(), + content.as_bytes().len() + ) + })?; + } + if should_stream_reasoning { + let reasoning = Self::stream_api_content(&parts.reasoning); + Self::validate_stream_api_content(&self.reasoning_key, &reasoning)?; + self.client + .stream_ai_card( + &access_token, + &self.out_track_id, + &self.reasoning_key, + &reasoning, + finalize, + false, + ) + .await + .map_err(|err| { + format!( + "dingtalk ai card streaming update failed for key '{}' ({} bytes): {err}", + self.reasoning_key.trim(), + reasoning.as_bytes().len() + ) + })?; + } self.last_update_at = Some(Instant::now()); Ok(()) } @@ -300,9 +438,8 @@ impl DingtalkStreamWriter { return Ok(()); } - let rendered = - render_agent_output(&output, self.show_reasoning, OutputRenderStyle::Markdown); - match self.flush_rendered(rendered, false, false).await { + let parts = self.stream_snapshot_parts(&output); + match self.flush_stream_parts(parts, false, false).await { Ok(()) => Ok(()), Err(err) => { self.stream_failed = true; @@ -323,7 +460,8 @@ impl ChannelStreamWriter for DingtalkStreamWriter { match event { ChannelStreamEvent::Snapshot(output) => self.write_snapshot(output).await, ChannelStreamEvent::Clear => { - self.last_rendered = None; + self.last_content = None; + self.last_reasoning = None; Ok(()) } } @@ -344,6 +482,7 @@ impl DingtalkChannel { stream_output: config.stream_output, stream_template_id: config.stream_template_id, stream_content_key: config.stream_content_key, + stream_reasoning_key: config.stream_reasoning_key, allowlist: config.allowlist, local_attachments, proxy: DingtalkProxyConfig { @@ -661,6 +800,7 @@ impl Channel for DingtalkChannel { stream_output: self.config.stream_output, stream_template_id: self.config.stream_template_id.clone(), stream_content_key: self.config.stream_content_key.clone(), + stream_reasoning_key: self.config.stream_reasoning_key.clone(), allowlist: self.config.allowlist.clone(), proxy: klaw_config::DingtalkProxyConfig { enabled: self.config.proxy.enabled, @@ -696,6 +836,7 @@ impl DingtalkChannel { inbound.chat_id.clone(), self.config.stream_template_id.clone(), self.config.stream_content_key.clone(), + self.config.stream_reasoning_key.clone(), self.config.show_reasoning, ); debug!( @@ -703,6 +844,7 @@ impl DingtalkChannel { session_webhook = inbound.session_webhook.as_str(), template_id = self.config.stream_template_id.as_str(), content_key = self.config.stream_content_key.as_str(), + reasoning_key = self.config.stream_reasoning_key.as_str(), "submitting dingtalk request with ai card streaming enabled" ); writer.begin().await; diff --git a/klaw-channel/src/dingtalk/tests.rs b/klaw-channel/src/dingtalk/tests.rs index e5f359b2..87179216 100644 --- a/klaw-channel/src/dingtalk/tests.rs +++ b/klaw-channel/src/dingtalk/tests.rs @@ -1,14 +1,14 @@ use super::{ - DingtalkStreamWriter, + DINGTALK_STREAM_MAX_CONTENT_BYTES, DingtalkStreamSnapshotParts, DingtalkStreamWriter, attachments::{ build_unsupported_file_attachment_markdown, infer_dingtalk_file_type, supported_dingtalk_file_type, }, client::{ DingtalkApiClient, ProactiveChatTargetKind, build_ai_card_card_data, - build_create_and_deliver_ai_card_payload, build_proactive_markdown_payload, - build_streaming_ai_card_payload, ensure_ai_card_delivery_success, - proactive_chat_target_kind, + build_ai_card_card_data_from_pairs, build_create_and_deliver_ai_card_payload, + build_proactive_markdown_payload, build_streaming_ai_card_payload, + ensure_ai_card_delivery_success, proactive_chat_target_kind, }, error::{DingtalkApiError, is_session_webhook_session_not_found_error}, parsing::{ @@ -956,6 +956,53 @@ fn build_ai_card_card_data_uses_default_ai_template_keys() { ); } +#[test] +fn build_ai_card_card_data_from_pairs_initializes_multiple_template_keys() { + let payload = build_ai_card_card_data_from_pairs([("content", ""), ("reasoning", "")]); + assert_eq!( + payload + .pointer("/cardParamMap/content") + .and_then(serde_json::Value::as_str), + Some("") + ); + assert_eq!( + payload + .pointer("/cardParamMap/reasoning") + .and_then(serde_json::Value::as_str), + Some("") + ); +} + +#[test] +fn dingtalk_stream_writer_initial_card_data_preserves_legacy_content_shape() { + let writer = DingtalkStreamWriter::new( + DingtalkApiClient::with_base_urls( + &super::DingtalkProxyConfig::default(), + "http://127.0.0.1:9", + "http://127.0.0.1:9", + ) + .expect("client"), + "client-id".to_string(), + "client-secret".to_string(), + "robot-1".to_string(), + "staff-1".to_string(), + "template-1.schema".to_string(), + "content".to_string(), + "reasoning".to_string(), + true, + ); + + let payload = writer.build_initial_card_data(); + + assert_eq!( + payload + .pointer("/cardParamMap/content") + .and_then(serde_json::Value::as_str), + Some("") + ); + assert!(payload.pointer("/cardParamMap/reasoning").is_none()); +} + #[test] fn build_create_and_deliver_ai_card_payload_targets_private_chat() { let payload = build_create_and_deliver_ai_card_payload( @@ -1131,6 +1178,7 @@ async fn dingtalk_stream_writer_marks_special_cards_without_network_calls() { "staff-1".to_string(), "template-1.schema".to_string(), "content".to_string(), + "reasoning".to_string(), false, ); let output = ChannelResponse { @@ -1164,6 +1212,156 @@ async fn dingtalk_stream_writer_marks_special_cards_without_network_calls() { assert!(!writer.stream_failed); } +#[test] +fn dingtalk_stream_writer_splits_content_and_reasoning_template_values() { + let writer = DingtalkStreamWriter::new( + DingtalkApiClient::with_base_urls( + &super::DingtalkProxyConfig::default(), + "http://127.0.0.1:9", + "http://127.0.0.1:9", + ) + .expect("client"), + "client-id".to_string(), + "client-secret".to_string(), + "robot-1".to_string(), + "staff-1".to_string(), + "template-1.schema".to_string(), + "content".to_string(), + "reasoning".to_string(), + true, + ); + let output = ChannelResponse { + content: "hello".to_string(), + reasoning: Some("thinking".to_string()), + metadata: BTreeMap::new(), + attachments: Vec::new(), + }; + + let parts = writer.stream_snapshot_parts(&output); + + assert_eq!(parts.content, "hello"); + assert_eq!(parts.reasoning, "thinking"); + assert!(!parts.content.contains("Reasoning")); +} + +#[test] +fn dingtalk_stream_writer_does_not_stream_initial_empty_reasoning() { + let writer = DingtalkStreamWriter::new( + DingtalkApiClient::with_base_urls( + &super::DingtalkProxyConfig::default(), + "http://127.0.0.1:9", + "http://127.0.0.1:9", + ) + .expect("client"), + "client-id".to_string(), + "client-secret".to_string(), + "robot-1".to_string(), + "staff-1".to_string(), + "template-1.schema".to_string(), + "content".to_string(), + "reasoning".to_string(), + true, + ); + let parts = DingtalkStreamSnapshotParts { + content: "hello".to_string(), + reasoning: String::new(), + }; + + assert!(!writer.should_stream_reasoning_update(&parts, false, false)); +} + +#[test] +fn dingtalk_stream_writer_does_not_stream_initial_empty_content() { + let writer = DingtalkStreamWriter::new( + DingtalkApiClient::with_base_urls( + &super::DingtalkProxyConfig::default(), + "http://127.0.0.1:9", + "http://127.0.0.1:9", + ) + .expect("client"), + "client-id".to_string(), + "client-secret".to_string(), + "robot-1".to_string(), + "staff-1".to_string(), + "template-1.schema".to_string(), + "content".to_string(), + "reasoning".to_string(), + true, + ); + let parts = DingtalkStreamSnapshotParts { + content: String::new(), + reasoning: "thinking".to_string(), + }; + + assert!(!writer.should_stream_content_update(&parts, false, false)); + assert!(writer.should_stream_reasoning_update(&parts, false, false)); +} + +#[test] +fn dingtalk_stream_writer_uses_non_empty_placeholder_when_clearing_reasoning() { + assert_eq!(DingtalkStreamWriter::stream_api_content(""), " "); + assert_eq!( + DingtalkStreamWriter::stream_api_content("thinking"), + "thinking" + ); +} + +#[test] +fn dingtalk_stream_writer_closes_unfinished_markdown_code_fence() { + let content = "Here is code:\n```rust\nfn main() {"; + + assert_eq!( + DingtalkStreamWriter::stream_api_content(content), + "Here is code:\n```rust\nfn main() {\n```" + ); +} + +#[test] +fn dingtalk_stream_writer_finalizes_only_last_streaming_key() { + assert!(!DingtalkStreamWriter::content_finalize_flag(true, true)); + assert!(DingtalkStreamWriter::content_finalize_flag(false, true)); + assert!(!DingtalkStreamWriter::content_finalize_flag(true, false)); +} + +#[test] +fn dingtalk_stream_writer_rejects_streaming_content_over_dingtalk_limit() { + let large_content = "a".repeat(DINGTALK_STREAM_MAX_CONTENT_BYTES + 1); + let err = DingtalkStreamWriter::validate_stream_api_content("content", &large_content) + .expect_err("oversized streaming update should fail before calling dingtalk"); + + assert!(err.contains("content")); + assert!(err.contains("exceeds dingtalk streaming limit")); +} + +#[tokio::test(flavor = "current_thread")] +async fn dingtalk_stream_writer_clear_resets_content_and_reasoning_cache() { + let mut writer = DingtalkStreamWriter::new( + DingtalkApiClient::with_base_urls( + &super::DingtalkProxyConfig::default(), + "http://127.0.0.1:9", + "http://127.0.0.1:9", + ) + .expect("client"), + "client-id".to_string(), + "client-secret".to_string(), + "robot-1".to_string(), + "staff-1".to_string(), + "template-1.schema".to_string(), + "content".to_string(), + "reasoning".to_string(), + true, + ); + writer.last_content = Some("hello".to_string()); + writer.last_reasoning = Some("thinking".to_string()); + + ChannelStreamWriter::write(&mut writer, ChannelStreamEvent::Clear) + .await + .expect("clear should succeed"); + + assert_eq!(writer.last_content, None); + assert_eq!(writer.last_reasoning, None); +} + #[tokio::test(flavor = "current_thread")] async fn dingtalk_stream_writer_falls_back_after_stream_failure() { let mut writer = DingtalkStreamWriter::new( @@ -1179,6 +1377,7 @@ async fn dingtalk_stream_writer_falls_back_after_stream_failure() { "staff-1".to_string(), "template-1.schema".to_string(), "content".to_string(), + "reasoning".to_string(), false, ); let output = ChannelResponse { diff --git a/klaw-channel/src/manager.rs b/klaw-channel/src/manager.rs index 22849221..93c4fc5c 100644 --- a/klaw-channel/src/manager.rs +++ b/klaw-channel/src/manager.rs @@ -862,6 +862,7 @@ mod tests { stream_output: false, stream_template_id: String::new(), stream_content_key: "content".to_string(), + stream_reasoning_key: "reasoning".to_string(), allowlist: Vec::new(), proxy: Default::default(), }) diff --git a/klaw-config/CHANGELOG.md b/klaw-config/CHANGELOG.md index d43615e9..2b10eec4 100644 --- a/klaw-config/CHANGELOG.md +++ b/klaw-config/CHANGELOG.md @@ -1,5 +1,15 @@ # CHANGELOG +## 2026-04-27 + +### Added + +- `channels.dingtalk[].stream_reasoning_key` 配置项,默认 `reasoning`,用于指定钉钉 AI 卡片模板中承载 reasoning 的变量名 + +### Changed + +- DingTalk 流式配置校验现在要求正文变量名与 reasoning 变量名非空且不能相同,避免 AI 卡片模板变量互相覆盖 + ## 2026-04-26 ### Changed diff --git a/klaw-config/README.md b/klaw-config/README.md index c35be30b..754ea813 100644 --- a/klaw-config/README.md +++ b/klaw-config/README.md @@ -31,6 +31,7 @@ - `channels.telegram[].stream_output` / `channels.dingtalk[].stream_output`:是否允许 channel 侧尝试增量输出。 - `channels.dingtalk[].stream_template_id`:当 `stream_output=true` 时必填,用于指定钉钉 AI 卡片模板 ID。 - `channels.dingtalk[].stream_content_key`:钉钉 AI 卡片模板里承载正文的参数名,默认 `content`。 +- `channels.dingtalk[].stream_reasoning_key`:钉钉 AI 卡片模板里承载 reasoning 的参数名,默认 `reasoning`。 - `voice.enabled`:是否启用 voice runtime 能力;只有它与 `tools.voice.enabled` 同时为 `true` 时,模型侧 `voice` tool 才会注册。 - `voice.stt_provider`:当前 STT provider,支持 `deepgram` / `assemblyai`。 - `voice.tts_provider`:当前 TTS provider,当前支持 `elevenlabs`。 diff --git a/klaw-config/src/lib.rs b/klaw-config/src/lib.rs index 8562d050..04c61e47 100644 --- a/klaw-config/src/lib.rs +++ b/klaw-config/src/lib.rs @@ -359,6 +359,8 @@ pub struct DingtalkConfig { pub stream_template_id: String, #[serde(default = "default_dingtalk_stream_content_key")] pub stream_content_key: String, + #[serde(default = "default_dingtalk_stream_reasoning_key")] + pub stream_reasoning_key: String, #[serde(default)] pub allowlist: Vec, #[serde(default)] @@ -377,6 +379,7 @@ impl Default for DingtalkConfig { stream_output: false, stream_template_id: String::new(), stream_content_key: default_dingtalk_stream_content_key(), + stream_reasoning_key: default_dingtalk_stream_reasoning_key(), allowlist: Vec::new(), proxy: DingtalkProxyConfig::default(), } @@ -406,6 +409,10 @@ fn default_dingtalk_stream_content_key() -> String { "content".to_string() } +fn default_dingtalk_stream_reasoning_key() -> String { + "reasoning".to_string() +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TelegramConfig { pub id: String, diff --git a/klaw-config/src/tests.rs b/klaw-config/src/tests.rs index 9173f6a4..2bed7f73 100644 --- a/klaw-config/src/tests.rs +++ b/klaw-config/src/tests.rs @@ -94,7 +94,7 @@ fn parse_default_template_succeeds() { assert!(template.contains("auto_index = false")); assert!(!template.contains("index_on_startup")); assert_eq!(parsed.knowledge.obsidian.max_excerpt_length, 400); - assert_eq!(parsed.knowledge.retrieval.top_k, 5); + assert_eq!(parsed.knowledge.retrieval.top_k, 10); assert_eq!(parsed.knowledge.retrieval.rerank_candidates, 20); assert_eq!(parsed.knowledge.retrieval.graph_hops, 1); assert!(!parsed.models.enabled); @@ -709,6 +709,7 @@ allowlist = ["u123", "u456"] assert!(account.stream_output); assert_eq!(account.stream_template_id, "template-1.schema"); assert_eq!(account.stream_content_key, "content"); + assert_eq!(account.stream_reasoning_key, "reasoning"); assert_eq!( account.allowlist, vec!["u123".to_string(), "u456".to_string()] @@ -717,6 +718,33 @@ allowlist = ["u123", "u456"] assert!(account.proxy.url.is_empty()); } +#[test] +fn parse_dingtalk_channel_config_allows_custom_reasoning_stream_key() { + let raw = r#" +model_provider = "openai" + +[model_providers.openai] +base_url = "https://api.openai.com/v1" +wire_api = "chat_completions" +default_model = "gpt-4o-mini" +env_key = "OPENAI_API_KEY" + +[[channels.dingtalk]] +id = "ops" +client_id = "ding-client" +client_secret = "ding-secret" +stream_output = true +stream_template_id = "template-1.schema" +stream_content_key = "answer" +stream_reasoning_key = "thinking" +"#; + + let parsed: AppConfig = toml::from_str(raw).expect("dingtalk config should parse"); + let account = &parsed.channels.dingtalk[0]; + assert_eq!(account.stream_content_key, "answer"); + assert_eq!(account.stream_reasoning_key, "thinking"); +} + #[test] fn parse_telegram_channel_config_succeeds() { let raw = r#" @@ -869,6 +897,7 @@ fn validate_fails_when_dingtalk_channel_ids_duplicate() { stream_output: false, stream_template_id: String::new(), stream_content_key: "content".to_string(), + stream_reasoning_key: "reasoning".to_string(), allowlist: vec![], proxy: DingtalkProxyConfig::default(), }, @@ -882,6 +911,7 @@ fn validate_fails_when_dingtalk_channel_ids_duplicate() { stream_output: false, stream_template_id: String::new(), stream_content_key: "content".to_string(), + stream_reasoning_key: "reasoning".to_string(), allowlist: vec![], proxy: DingtalkProxyConfig::default(), }, @@ -904,6 +934,7 @@ fn validate_fails_when_enabled_dingtalk_channel_missing_secret() { stream_output: false, stream_template_id: String::new(), stream_content_key: "content".to_string(), + stream_reasoning_key: "reasoning".to_string(), allowlist: vec![], proxy: DingtalkProxyConfig::default(), }]; @@ -925,6 +956,7 @@ fn validate_fails_when_enabled_dingtalk_proxy_missing_url() { stream_output: false, stream_template_id: String::new(), stream_content_key: "content".to_string(), + stream_reasoning_key: "reasoning".to_string(), allowlist: vec![], proxy: DingtalkProxyConfig { enabled: true, @@ -949,6 +981,7 @@ fn validate_fails_when_streaming_dingtalk_channel_missing_template_id() { stream_output: true, stream_template_id: String::new(), stream_content_key: "content".to_string(), + stream_reasoning_key: "reasoning".to_string(), allowlist: vec![], proxy: DingtalkProxyConfig::default(), }]; @@ -957,6 +990,50 @@ fn validate_fails_when_streaming_dingtalk_channel_missing_template_id() { assert!(format!("{err}").contains("channels.dingtalk.stream_template_id")); } +#[test] +fn validate_fails_when_streaming_dingtalk_reasoning_key_is_empty() { + let mut cfg = AppConfig::default(); + cfg.channels.dingtalk = vec![DingtalkConfig { + id: "ops".to_string(), + enabled: true, + client_id: "client-a".to_string(), + client_secret: "secret-a".to_string(), + bot_title: "Ops".to_string(), + show_reasoning: true, + stream_output: true, + stream_template_id: "template-1.schema".to_string(), + stream_content_key: "content".to_string(), + stream_reasoning_key: String::new(), + allowlist: vec![], + proxy: DingtalkProxyConfig::default(), + }]; + + let err = validate(&cfg).expect_err("should fail"); + assert!(format!("{err}").contains("channels.dingtalk.stream_reasoning_key")); +} + +#[test] +fn validate_fails_when_streaming_dingtalk_keys_match() { + let mut cfg = AppConfig::default(); + cfg.channels.dingtalk = vec![DingtalkConfig { + id: "ops".to_string(), + enabled: true, + client_id: "client-a".to_string(), + client_secret: "secret-a".to_string(), + bot_title: "Ops".to_string(), + show_reasoning: true, + stream_output: true, + stream_template_id: "template-1.schema".to_string(), + stream_content_key: "content".to_string(), + stream_reasoning_key: "content".to_string(), + allowlist: vec![], + proxy: DingtalkProxyConfig::default(), + }]; + + let err = validate(&cfg).expect_err("should fail"); + assert!(format!("{err}").contains("stream_content_key and stream_reasoning_key")); +} + #[test] fn validate_fails_when_enabled_dingtalk_proxy_has_invalid_scheme() { let mut cfg = AppConfig::default(); @@ -970,6 +1047,7 @@ fn validate_fails_when_enabled_dingtalk_proxy_has_invalid_scheme() { stream_output: false, stream_template_id: String::new(), stream_content_key: "content".to_string(), + stream_reasoning_key: "reasoning".to_string(), allowlist: vec![], proxy: DingtalkProxyConfig { enabled: true, @@ -994,6 +1072,7 @@ fn validate_fails_when_dingtalk_local_attachment_allowlist_is_relative() { stream_output: false, stream_template_id: String::new(), stream_content_key: "content".to_string(), + stream_reasoning_key: "reasoning".to_string(), allowlist: vec![], proxy: DingtalkProxyConfig::default(), }]; diff --git a/klaw-config/src/validate.rs b/klaw-config/src/validate.rs index 52e800ac..084f372a 100644 --- a/klaw-config/src/validate.rs +++ b/klaw-config/src/validate.rs @@ -554,6 +554,20 @@ fn validate_channels(channels: &ChannelsConfig) -> Result<(), ConfigError> { &account.stream_template_id, "channels.dingtalk.stream_template_id", )?; + require_non_empty( + &account.stream_content_key, + "channels.dingtalk.stream_content_key", + )?; + require_non_empty( + &account.stream_reasoning_key, + "channels.dingtalk.stream_reasoning_key", + )?; + if account.stream_content_key.trim() == account.stream_reasoning_key.trim() { + return Err(ConfigError::InvalidConfig( + "channels.dingtalk.stream_content_key and stream_reasoning_key must differ" + .to_string(), + )); + } } if account.proxy.enabled { require_non_empty(&account.proxy.url, "channels.dingtalk.proxy.url")?; diff --git a/klaw-gui/CHANGELOG.md b/klaw-gui/CHANGELOG.md index 023a6865..4be3acf4 100644 --- a/klaw-gui/CHANGELOG.md +++ b/klaw-gui/CHANGELOG.md @@ -1,5 +1,11 @@ # CHANGELOG +## 2026-04-27 + +### Added + +- DingTalk channel 编辑表单新增 `Stream Reasoning Key` 输入项,用于配置 AI 卡片模板中承载 reasoning 的变量名 + ## 2026-04-26 ### Changed diff --git a/klaw-gui/src/panels/channel.rs b/klaw-gui/src/panels/channel.rs index c400ddea..c9beef6f 100644 --- a/klaw-gui/src/panels/channel.rs +++ b/klaw-gui/src/panels/channel.rs @@ -31,6 +31,7 @@ struct DingtalkForm { stream_output: bool, stream_template_id: String, stream_content_key: String, + stream_reasoning_key: String, allowlist_input: ArrayEditor, proxy_enabled: bool, proxy_url: String, @@ -50,6 +51,7 @@ impl DingtalkForm { stream_output: default.stream_output, stream_template_id: default.stream_template_id, stream_content_key: default.stream_content_key, + stream_reasoning_key: default.stream_reasoning_key, allowlist_input: ArrayEditor::new("Allowlist"), proxy_enabled: default.proxy.enabled, proxy_url: default.proxy.url, @@ -68,6 +70,7 @@ impl DingtalkForm { stream_output: account.stream_output, stream_template_id: account.stream_template_id.clone(), stream_content_key: account.stream_content_key.clone(), + stream_reasoning_key: account.stream_reasoning_key.clone(), allowlist_input: ArrayEditor::from_vec("Allowlist", &account.allowlist), proxy_enabled: account.proxy.enabled, proxy_url: account.proxy.url.clone(), @@ -97,6 +100,7 @@ impl DingtalkForm { stream_output: self.stream_output, stream_template_id: self.stream_template_id.trim().to_string(), stream_content_key: self.stream_content_key.trim().to_string(), + stream_reasoning_key: self.stream_reasoning_key.trim().to_string(), allowlist: self.allowlist_input.to_vec(), proxy: DingtalkProxyConfig { enabled: self.proxy_enabled, @@ -915,6 +919,10 @@ impl ChannelPanel { ui.label("Stream Content Key"); ui.text_edit_singleline(&mut form.stream_content_key); ui.end_row(); + + ui.label("Stream Reasoning Key"); + ui.text_edit_singleline(&mut form.stream_reasoning_key); + ui.end_row(); } ui.label("Proxy Enabled"); @@ -1456,6 +1464,7 @@ mod tests { form.stream_output = true; form.stream_template_id = "template-1.schema".to_string(); form.stream_content_key = "content".to_string(); + form.stream_reasoning_key = "reasoning".to_string(); let updated = ChannelPanel::apply_dingtalk_form(config, &form).expect("should apply"); @@ -1465,6 +1474,10 @@ mod tests { "template-1.schema" ); assert_eq!(updated.channels.dingtalk[0].stream_content_key, "content"); + assert_eq!( + updated.channels.dingtalk[0].stream_reasoning_key, + "reasoning" + ); } #[test]