新增: anthropic 思维链解析入库与思考卡展示
- 流式与非流式 thinking 块接入 reasoning 通道 - 签名与密文块合规静默,请求侧零改动 - 历史映射透传 reasoning_content - 思考卡数据源真推理文本优先
This commit is contained in:
@@ -636,8 +636,9 @@ impl LlmProvider for AnthropicCompatProvider {
|
|||||||
return AttemptOutcome::Fatal(format!("响应解析失败: {}", e));
|
return AttemptOutcome::Fatal(format!("响应解析失败: {}", e));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// content 块中拼接 text,收集 tool_use
|
// content 块中拼接 text,收集 tool_use,累积 thinking
|
||||||
let mut text = String::new();
|
let mut text = String::new();
|
||||||
|
let mut reasoning = String::new();
|
||||||
let mut tool_calls: Vec<ToolCall> = Vec::new();
|
let mut tool_calls: Vec<ToolCall> = Vec::new();
|
||||||
// CR-空 id:按 tool_use 块在数组中的顺序计数(仅 tool_use 递增),用于 fallback index。
|
// CR-空 id:按 tool_use 块在数组中的顺序计数(仅 tool_use 递增),用于 fallback index。
|
||||||
// 用独立计数器而非 for enumerate,避免 text/unknown 块占用 index 致 fallback 编号跳号。
|
// 用独立计数器而非 for enumerate,避免 text/unknown 块占用 index 致 fallback 编号跳号。
|
||||||
@@ -649,6 +650,15 @@ impl LlmProvider for AnthropicCompatProvider {
|
|||||||
text.push_str(&t);
|
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" => {
|
"tool_use" => {
|
||||||
// CR-空 id:原逻辑空 id 直接 continue 跳过整个块(丢工具调用)。
|
// CR-空 id:原逻辑空 id 直接 continue 跳过整个块(丢工具调用)。
|
||||||
// 改为兜底:id 非空原样,空 → `gen_anthropic_{idx}` fallback(DRY 共用
|
// 改为兜底:id 非空原样,空 → `gen_anthropic_{idx}` fallback(DRY 共用
|
||||||
@@ -688,7 +698,8 @@ impl LlmProvider for AnthropicCompatProvider {
|
|||||||
model: resp.model,
|
model: resp.model,
|
||||||
usage,
|
usage,
|
||||||
tool_calls: if tool_calls.is_empty() { None } else { Some(tool_calls) },
|
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);
|
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避免残缺响应被当正常完成入库,
|
/// error 事件 → error=Some + finished=false避免残缺响应被当正常完成入库,
|
||||||
/// 由 stream_llm 识别 error 非空发 AiError + 丢弃残缺,与 OpenAI 路径 Err 一致)
|
/// 由 stream_llm 识别 error 非空发 AiError + 丢弃残缺,与 OpenAI 路径 Err 一致)
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -72,13 +72,16 @@ pub(crate) struct AnthropicResponse {
|
|||||||
pub usage: AnthropicUsage,
|
pub usage: AnthropicUsage,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 响应 content 块(text 或 tool_use)
|
/// 响应 content 块(text / tool_use / thinking)
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub(crate) struct AnthropicContentBlock {
|
pub(crate) struct AnthropicContentBlock {
|
||||||
#[serde(rename = "type")]
|
#[serde(rename = "type")]
|
||||||
pub block_type: String,
|
pub block_type: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub text: Option<String>,
|
pub text: Option<String>,
|
||||||
|
/// thinking 块推理正文(端点侧开启扩展思考时出现;本侧请求未启用 thinking,一般缺省)
|
||||||
|
#[serde(default)]
|
||||||
|
pub thinking: Option<String>,
|
||||||
/// tool_use 块字段
|
/// tool_use 块字段
|
||||||
pub id: Option<String>,
|
pub id: Option<String>,
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
@@ -119,7 +122,8 @@ pub(crate) const DEFAULT_MAX_TOKENS: u32 = 4096;
|
|||||||
/// 按 `type` 字段分发:
|
/// 按 `type` 字段分发:
|
||||||
/// - `message_start` → 用 `message.usage.input_tokens` 初始化累加器(output 置 0)。
|
/// - `message_start` → 用 `message.usage.input_tokens` 初始化累加器(output 置 0)。
|
||||||
/// - `message_delta` → **output_tokens 是累计值(非增量)**,直接覆盖 `completion_tokens` 并重算 `total`。
|
/// - `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。
|
/// - `content_block_start` (tool_use) → 工具块开始,带 id+name。
|
||||||
/// - `message_stop` → 返回 `finished=true` 终态 chunk,`usage` 取自累加器(`take()`)。
|
/// - `message_stop` → 返回 `finished=true` 终态 chunk,`usage` 取自累加器(`take()`)。
|
||||||
/// - `error` → 返回 `finished=true` 终态空 chunk。
|
/// - `error` → 返回 `finished=true` 终态空 chunk。
|
||||||
@@ -182,6 +186,17 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
|
|||||||
// 文本增量
|
// 文本增量
|
||||||
"content_block_delta" => {
|
"content_block_delta" => {
|
||||||
if let Some(delta) = v.get("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") {
|
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();
|
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 };
|
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
|
// 工具块开始:带 id + name
|
||||||
"content_block_start" => {
|
"content_block_start" => {
|
||||||
|
// thinking / redacted_thinking 块起点无正文要记(正文随后经 thinking_delta 流出;
|
||||||
|
// redacted_thinking 为密文不可解析),落入末尾统一空 chunk 即安静跳过,不告警。
|
||||||
if let Some(cb) = v.get("content_block") {
|
if let Some(cb) = v.get("content_block") {
|
||||||
if cb.get("type").and_then(|t| t.as_str()) == Some("tool_use") {
|
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;
|
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
|
id: MessageId
|
||||||
role: 'user' | 'assistant' | 'tool' | 'system'
|
role: 'user' | 'assistant' | 'tool' | 'system'
|
||||||
content: string
|
content: string
|
||||||
|
/**
|
||||||
|
* 推理过程文本(DeepSeek reasoning_content / Anthropic thinking 同通道,后端落库
|
||||||
|
* ai_messages.reasoning_content)。undefined=该轮无思考内容。历史消息 reload 时
|
||||||
|
* 由 useAiConversations parseConvMessages 透传;思考 pill 以此为数据源,content 兜底。
|
||||||
|
*/
|
||||||
|
reasoning_content?: string
|
||||||
isError?: boolean
|
isError?: boolean
|
||||||
/**
|
/**
|
||||||
* 流式重试过渡标记(AiStreamRetry 推送的过渡气泡,前端内存专用不落库)。
|
* 流式重试过渡标记(AiStreamRetry 推送的过渡气泡,前端内存专用不落库)。
|
||||||
|
|||||||
@@ -758,9 +758,11 @@ function toggleThink(id: string): void {
|
|||||||
expandedThinkIds.value = next
|
expandedThinkIds.value = next
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 轮次思考纯文本(压平 markdown 换行;空内容回退「过程记录」,纯工具轮也有锚点) */
|
/** 轮次思考纯文本:reasoning_content 优先(DeepSeek/Anthropic thinking 同通道),content 兜底;
|
||||||
|
* 压平 markdown 换行;两者皆空回退「过程记录」占位(纯工具轮也有锚点) */
|
||||||
function thinkPlainText(m: AiMessage): string {
|
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 承载全文,此处只管单行) */
|
/** pill 单行文本:纯文本截断 + 空轮回退文案(title 承载全文,此处只管单行) */
|
||||||
function thinkSnippet(m: AiMessage): string {
|
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}`,
|
id: (m.id && m.id !== '') ? m.id : `${fallbackPrefix}-${i}`,
|
||||||
role: m.role,
|
role: m.role,
|
||||||
content: m.content || '',
|
content: m.content || '',
|
||||||
|
// 推理过程透传(DeepSeek/Anthropic thinking 同通道落库字段),undefined 保持缺省
|
||||||
|
reasoning_content: m.reasoning_content || undefined,
|
||||||
model: m.model,
|
model: m.model,
|
||||||
// 用后端持久化的真实时间;老数据无 timestamp 字段回退 Date.now()
|
// 用后端持久化的真实时间;老数据无 timestamp 字段回退 Date.now()
|
||||||
timestamp: typeof m.timestamp === 'number' ? m.timestamp : Date.now(),
|
timestamp: typeof m.timestamp === 'number' ? m.timestamp : Date.now(),
|
||||||
|
|||||||
Reference in New Issue
Block a user