新增: anthropic 思维链解析入库与思考卡展示
- 流式与非流式 thinking 块接入 reasoning 通道 - 签名与密文块合规静默,请求侧零改动 - 历史映射透传 reasoning_content - 思考卡数据源真推理文本优先
This commit is contained in:
@@ -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<ToolCall> = 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<TokenUsage> = 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]
|
||||
|
||||
@@ -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<String>,
|
||||
/// thinking 块推理正文(端点侧开启扩展思考时出现;本侧请求未启用 thinking,一般缺省)
|
||||
#[serde(default)]
|
||||
pub thinking: Option<String>,
|
||||
/// tool_use 块字段
|
||||
pub id: Option<String>,
|
||||
pub name: Option<String>,
|
||||
@@ -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<TokenUs
|
||||
// 文本增量
|
||||
"content_block_delta" => {
|
||||
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<TokenUs
|
||||
}
|
||||
// 工具块开始:带 id + name
|
||||
"content_block_start" => {
|
||||
// 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;
|
||||
|
||||
@@ -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 推送的过渡气泡,前端内存专用不落库)。
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user