diff --git a/crates/df-ai/src/anthropic_compat.rs b/crates/df-ai/src/anthropic_compat.rs index 589cadc..e063312 100644 --- a/crates/df-ai/src/anthropic_compat.rs +++ b/crates/df-ai/src/anthropic_compat.rs @@ -636,8 +636,9 @@ impl LlmProvider for AnthropicCompatProvider { return AttemptOutcome::Fatal(format!("响应解析失败: {}", e)); } }; - // content 块中拼接 text,收集 tool_use + // content 块中拼接 text,收集 tool_use,累积 thinking let mut text = String::new(); + let mut reasoning = String::new(); let mut tool_calls: Vec = Vec::new(); // CR-空 id:按 tool_use 块在数组中的顺序计数(仅 tool_use 递增),用于 fallback index。 // 用独立计数器而非 for enumerate,避免 text/unknown 块占用 index 致 fallback 编号跳号。 @@ -649,6 +650,15 @@ impl LlmProvider for AnthropicCompatProvider { text.push_str(&t); } } + "thinking" => { + // thinking 块正文回填 reasoning_content 通道(与 DeepSeek 同路入库展示)。 + // 仅入库与展示,绝不回传 anthropic 历史(丢弃 signature 的伪造回传必 400)。 + if let Some(t) = block.thinking { + reasoning.push_str(&t); + } + } + // redacted_thinking 为端点侧密文块,不可解析也不可伪造回传,合规丢弃不告警 + "redacted_thinking" => {} "tool_use" => { // CR-空 id:原逻辑空 id 直接 continue 跳过整个块(丢工具调用)。 // 改为兜底:id 非空原样,空 → `gen_anthropic_{idx}` fallback(DRY 共用 @@ -688,7 +698,8 @@ impl LlmProvider for AnthropicCompatProvider { model: resp.model, usage, tool_calls: if tool_calls.is_empty() { None } else { Some(tool_calls) }, - reasoning_content: None, + // 无 thinking 块时保持 None(语义与 DeepSeek 路径一致,空串不落库) + reasoning_content: if reasoning.is_empty() { None } else { Some(reasoning) }, }) } }) @@ -963,6 +974,31 @@ mod tests { assert!(!c.finished); } + /// thinking_delta → reasoning_content 增量(不混入正文 delta);signature_delta 安静忽略。 + /// 推理正文走 reasoning_content 通道与 DeepSeek 同路入库展示。 + #[test] + fn anthropic_thinking_delta_accumulates_reasoning() { + let mut acc: Option = None; + let think1 = r#"{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"先查"}}"#; + let c1 = apply_anthropic_event(think1, &mut acc); + assert_eq!(c1.reasoning_content.as_deref(), Some("先查"), "thinking_delta 应产出 reasoning_content"); + assert_eq!(c1.delta, "", "思考增量不得混入正文 delta"); + assert!(!c1.finished); + let think2 = r#"{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"用户意图"}}"#; + let c2 = apply_anthropic_event(think2, &mut acc); + assert_eq!(c2.reasoning_content.as_deref(), Some("用户意图")); + // signature_delta:thinking 块签名字段,本侧无处消费,安静跳过(空 chunk 不告警) + let sig = r#"{"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"sig-abc"}}"#; + let c3 = apply_anthropic_event(sig, &mut acc); + assert!(c3.reasoning_content.is_none()); + assert!(c3.delta.is_empty()); + assert!(!c3.finished); + // 思考块之后正文照常 + let c4 = apply_anthropic_event(&text_delta("答案"), &mut acc); + assert_eq!(c4.delta, "答案"); + assert!(c4.reasoning_content.is_none()); + } + /// error 事件 → error=Some + finished=false避免残缺响应被当正常完成入库, /// 由 stream_llm 识别 error 非空发 AiError + 丢弃残缺,与 OpenAI 路径 Err 一致) #[test] diff --git a/crates/df-ai/src/anthropic_helpers.rs b/crates/df-ai/src/anthropic_helpers.rs index 40251cb..c3fe395 100644 --- a/crates/df-ai/src/anthropic_helpers.rs +++ b/crates/df-ai/src/anthropic_helpers.rs @@ -72,13 +72,16 @@ pub(crate) struct AnthropicResponse { pub usage: AnthropicUsage, } -/// 响应 content 块(text 或 tool_use) +/// 响应 content 块(text / tool_use / thinking) #[derive(Debug, Deserialize)] pub(crate) struct AnthropicContentBlock { #[serde(rename = "type")] pub block_type: String, #[serde(default)] pub text: Option, + /// thinking 块推理正文(端点侧开启扩展思考时出现;本侧请求未启用 thinking,一般缺省) + #[serde(default)] + pub thinking: Option, /// tool_use 块字段 pub id: Option, pub name: Option, @@ -119,7 +122,8 @@ pub(crate) const DEFAULT_MAX_TOKENS: u32 = 4096; /// 按 `type` 字段分发: /// - `message_start` → 用 `message.usage.input_tokens` 初始化累加器(output 置 0)。 /// - `message_delta` → **output_tokens 是累计值(非增量)**,直接覆盖 `completion_tokens` 并重算 `total`。 -/// - `content_block_delta` (text_delta/input_json_delta) → 文本/工具入参增量。 +/// - `content_block_delta` (text_delta/input_json_delta/thinking_delta) → 文本/工具入参/思考增量, +/// signature_delta 安静忽略。 /// - `content_block_start` (tool_use) → 工具块开始,带 id+name。 /// - `message_stop` → 返回 `finished=true` 终态 chunk,`usage` 取自累加器(`take()`)。 /// - `error` → 返回 `finished=true` 终态空 chunk。 @@ -182,6 +186,17 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option { if let Some(delta) = v.get("delta") { + // thinking 增量:推理正文走 reasoning_content 通道(与 DeepSeek 同路入库展示), + // 不混入正文 delta。本侧请求未启用 thinking,此分支只为不丢端点侧自行产出的思考。 + if delta.get("type").and_then(|t| t.as_str()) == Some("thinking_delta") { + let text = delta.get("thinking").and_then(|t| t.as_str()).unwrap_or("").to_string(); + return StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: Some(text) }; + } + // signature_delta:thinking 块签名字段,仅回传校验用。DevFlow 从不把 thinking 回传 + // anthropic 历史(丢弃 signature 后伪造回传必 400),签名无处消费,安静忽略。 + if delta.get("type").and_then(|t| t.as_str()) == Some("signature_delta") { + return StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None }; + } if delta.get("type").and_then(|t| t.as_str()) == Some("text_delta") { let text = delta.get("text").and_then(|t| t.as_str()).unwrap_or("").to_string(); return StreamChunk { delta: text, finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None }; @@ -209,6 +224,8 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option { + // thinking / redacted_thinking 块起点无正文要记(正文随后经 thinking_delta 流出; + // redacted_thinking 为密文不可解析),落入末尾统一空 chunk 即安静跳过,不告警。 if let Some(cb) = v.get("content_block") { if cb.get("type").and_then(|t| t.as_str()) == Some("tool_use") { let idx = v.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as u32; diff --git a/src/api/types.ts b/src/api/types.ts index c2bc93b..532e74d 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -527,6 +527,12 @@ export interface AiMessage { id: MessageId role: 'user' | 'assistant' | 'tool' | 'system' content: string + /** + * 推理过程文本(DeepSeek reasoning_content / Anthropic thinking 同通道,后端落库 + * ai_messages.reasoning_content)。undefined=该轮无思考内容。历史消息 reload 时 + * 由 useAiConversations parseConvMessages 透传;思考 pill 以此为数据源,content 兜底。 + */ + reasoning_content?: string isError?: boolean /** * 流式重试过渡标记(AiStreamRetry 推送的过渡气泡,前端内存专用不落库)。 diff --git a/src/components/ai/MessageList.vue b/src/components/ai/MessageList.vue index 944a431..cce20e3 100644 --- a/src/components/ai/MessageList.vue +++ b/src/components/ai/MessageList.vue @@ -758,9 +758,11 @@ function toggleThink(id: string): void { expandedThinkIds.value = next } -/** 轮次思考纯文本(压平 markdown 换行;空内容回退「过程记录」,纯工具轮也有锚点) */ +/** 轮次思考纯文本:reasoning_content 优先(DeepSeek/Anthropic thinking 同通道),content 兜底; + * 压平 markdown 换行;两者皆空回退「过程记录」占位(纯工具轮也有锚点) */ function thinkPlainText(m: AiMessage): string { - return (m.content || '').replace(/\s+/g, ' ').trim() + const src = m.reasoning_content || m.content || '' + return src.replace(/\s+/g, ' ').trim() } /** pill 单行文本:纯文本截断 + 空轮回退文案(title 承载全文,此处只管单行) */ function thinkSnippet(m: AiMessage): string { diff --git a/src/composables/ai/useAiConversations.ts b/src/composables/ai/useAiConversations.ts index 366d7a3..87d3150 100644 --- a/src/composables/ai/useAiConversations.ts +++ b/src/composables/ai/useAiConversations.ts @@ -176,6 +176,8 @@ function parseConvMessages(rawMsgs: any[], fallbackPrefix: string): AiMessage[] id: (m.id && m.id !== '') ? m.id : `${fallbackPrefix}-${i}`, role: m.role, content: m.content || '', + // 推理过程透传(DeepSeek/Anthropic thinking 同通道落库字段),undefined 保持缺省 + reasoning_content: m.reasoning_content || undefined, model: m.model, // 用后端持久化的真实时间;老数据无 timestamp 字段回退 Date.now() timestamp: typeof m.timestamp === 'number' ? m.timestamp : Date.now(),