依据消息拆分存储设计 + 消息级溯源设计 P0(地基,P1 溯源/P2 切读待后续): - df-ai-core ChatMessage 加 id 字段(Option<String> serde 向前兼容)+ new_message_id(AtomicU64+ts 并发安全) - 6 构造器生成 id,老 JSON 无 id → None 兼容 - df-storage V21 一次原子迁移:建 ai_messages 表 + ai_tool_executions.message_id 列 + 全量数据迁移(分批+坏数据容错+COUNT 幂等) - AiMessageRecord + AiMessageRepo(insert_batch/list_by_conversation/delete_range/update_status/update_content_by_tool_call_id) - audit message_id 列补建(conversation_repo/settings 白名单) - src-tauri title.rs/finalize.rs 字面量占位(P1 接真值) 自验: df-storage 45 passed + df-ai-core 28 passed + workspace EXIT 0
440 lines
18 KiB
Rust
440 lines
18 KiB
Rust
//! LLM Provider trait — 统一的 LLM 调用抽象
|
||
//!
|
||
//! 支持 OpenAI 兼容 API(覆盖 OpenAI / GLM / DeepSeek / Claude 兼容模式),
|
||
//! 含 function calling / tool use 能力。
|
||
//!
|
||
//! 本文件含 trait + 类型 impl 块(零 IO)。纯类型定义(CompletionRequest /
|
||
//! ChatMessage / ContentPart 等 `#[derive]` struct/enum)已拆分到 `types.rs`,
|
||
//! 经下方 `pub use crate::types::*` re-export 保持外部路径
|
||
//! `df_ai_core::provider::*` / `df_ai::provider::*` 不变(编译期验证)。
|
||
//! HTTP impl(OpenAICompatProvider / AnthropicCompatProvider)+ 业务逻辑
|
||
//! (ContextManager / AiToolRegistry / build_provider 工厂)留在 df-ai crate。
|
||
|
||
use async_trait::async_trait;
|
||
|
||
// 透明 re-export:types.rs 中所有 pub 类型经此回流到 provider::* glob,
|
||
// 使 df_ai::provider::*(df-ai/src/provider.rs:15 的 `pub use df_ai_core::provider::*`)
|
||
// 与直接 `df_ai_core::provider::Type` 限定路径全部继续可用。
|
||
pub use crate::types::*;
|
||
use crate::types::{new_message_id, now_millis_i64};
|
||
|
||
impl ContentPart {
|
||
pub fn text(text: impl Into<String>) -> Self {
|
||
ContentPart::Text { text: text.into() }
|
||
}
|
||
pub fn image_base64(media_type: impl Into<String>, data: impl Into<String>) -> Self {
|
||
ContentPart::Image {
|
||
url: None,
|
||
base64: Some(data.into()),
|
||
media_type: Some(media_type.into()),
|
||
alt: None,
|
||
}
|
||
}
|
||
pub fn image_url(url: impl Into<String>) -> Self {
|
||
ContentPart::Image { url: Some(url.into()), base64: None, media_type: None, alt: None }
|
||
}
|
||
|
||
/// 是否图片片
|
||
pub fn is_image(&self) -> bool {
|
||
matches!(self, ContentPart::Image { .. })
|
||
}
|
||
}
|
||
|
||
impl ChatMessage {
|
||
pub fn system(content: impl Into<String>) -> Self {
|
||
Self { id: Some(new_message_id()), role: MessageRole::System, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, timestamp: Some(now_millis_i64()) }
|
||
}
|
||
pub fn user(content: impl Into<String>) -> Self {
|
||
Self { id: Some(new_message_id()), role: MessageRole::User, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, timestamp: Some(now_millis_i64()) }
|
||
}
|
||
pub fn assistant(content: impl Into<String>) -> Self {
|
||
Self { id: Some(new_message_id()), role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, timestamp: Some(now_millis_i64()) }
|
||
}
|
||
pub fn assistant_with_tools(content: impl Into<String>, tool_calls: Vec<ToolCall>) -> Self {
|
||
Self { id: Some(new_message_id()), role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: Some(tool_calls), model: None, status: None, reasoning_content: None, timestamp: Some(now_millis_i64()) }
|
||
}
|
||
pub fn tool_result(call_id: impl Into<String>, content: impl Into<String>) -> Self {
|
||
Self { id: Some(new_message_id()), role: MessageRole::Tool, content: content.into(), parts: None, tool_call_id: Some(call_id.into()), tool_calls: None, model: None, status: None, reasoning_content: None, timestamp: Some(now_millis_i64()) }
|
||
}
|
||
|
||
/// 多模态 user 消息:content 文本 + parts(含 Image 片)。
|
||
/// content 作为人类可读文本(也作非 vision 端点降级载荷);parts 透传给 vision 端点。
|
||
pub fn user_parts(content: impl Into<String>, parts: Vec<ContentPart>) -> Self {
|
||
Self { id: Some(new_message_id()), role: MessageRole::User, content: content.into(), parts: Some(parts), tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, timestamp: Some(now_millis_i64()) }
|
||
}
|
||
|
||
/// 是否含图片片(供 provider 判定走多模态分支)。
|
||
pub fn has_image(&self) -> bool {
|
||
self.parts.as_ref().map(|ps| ps.iter().any(|p| p.is_image())).unwrap_or(false)
|
||
}
|
||
|
||
/// parts 若存在则返回引用,否则 None。
|
||
pub fn parts(&self) -> Option<&[ContentPart]> {
|
||
self.parts.as_deref()
|
||
}
|
||
|
||
/// 把 content + parts 拍平为有序 ContentPart 序列:先 content 作 Text 片(非空时),
|
||
/// 再追加 parts(若有)。供 provider 生成 content blocks(保证文本在前、图片在后)。
|
||
pub fn flattened_parts(&self) -> Vec<ContentPart> {
|
||
let mut out: Vec<ContentPart> = Vec::new();
|
||
if !self.content.is_empty() {
|
||
out.push(ContentPart::Text { text: self.content.clone() });
|
||
}
|
||
if let Some(ps) = &self.parts {
|
||
for p in ps {
|
||
out.push(p.clone());
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// 是否处于 active 态(status 为 None 或 "active")。其余状态一律 false。
|
||
///
|
||
/// 正面白名单(F-15 §3.2):仅认 None / "active",新状态
|
||
/// (如阶段2 引入的 "archived_segment" / "compressed")自动落入不 active 分支,
|
||
/// 无需每加一个状态就来这里改。当前取值 None/Some("active")/Some("truncated")
|
||
/// 行为与旧反面排除完全等价(None=true / "active"=true / "truncated"=false)。
|
||
pub fn is_active(&self) -> bool {
|
||
matches!(self.status.as_deref(), None | Some("active"))
|
||
}
|
||
}
|
||
|
||
impl ToolDefinition {
|
||
pub fn function(name: impl Into<String>, description: impl Into<String>, parameters: serde_json::Value) -> Self {
|
||
Self {
|
||
tool_type: "function".into(),
|
||
function: ToolFunction { name: name.into(), description: description.into(), parameters },
|
||
}
|
||
}
|
||
}
|
||
|
||
impl ToolCall {
|
||
pub fn new(id: impl Into<String>, name: impl Into<String>, arguments: impl Into<String>) -> Self {
|
||
Self {
|
||
id: id.into(),
|
||
call_type: "function".into(),
|
||
function: ToolCallFunction { name: name.into(), arguments: arguments.into() },
|
||
}
|
||
}
|
||
}
|
||
|
||
/// LLM Provider trait
|
||
#[async_trait]
|
||
pub trait LlmProvider: Send + Sync {
|
||
/// 同步调用
|
||
async fn complete(&self, request: CompletionRequest) -> anyhow::Result<CompletionResponse>;
|
||
|
||
/// 流式调用(返回异步流)
|
||
async fn stream(
|
||
&self,
|
||
request: CompletionRequest,
|
||
) -> anyhow::Result<StreamResult>;
|
||
|
||
/// 文本嵌入:批量文本 → 语义向量(供知识库向量检索)
|
||
///
|
||
/// 默认实现返回 Err(协议不支持)。OpenAI 兼容协议覆盖实现(/v1/embeddings);
|
||
/// Anthropic 无 embedding API,保持默认。
|
||
async fn embed(&self, _model: &str, _texts: Vec<String>) -> anyhow::Result<Vec<Vec<f32>>> {
|
||
anyhow::bail!("该 Provider 不支持 embedding({})", self.name())
|
||
}
|
||
|
||
/// Provider 名称
|
||
fn name(&self) -> &str;
|
||
|
||
/// 实际请求端点(含 base_url + 关键路径,如 chat completions / messages)。
|
||
/// 默认回落 `name()`,provider 实现覆盖返真实 URL,供 401/网络错误诊断打印
|
||
/// —— 旧路径只能近似打印 provider_type,看不到实际请求端点。
|
||
fn endpoint(&self) -> String {
|
||
self.name().to_string()
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn is_active_whitelist() {
|
||
// F-15 §3.2 正面白名单:仅 None / "active" 为 true,其余一律 false。
|
||
// 零行为变化:None / "active" / "truncated" 与旧反面排除完全等价;
|
||
// "archived_segment" / "compressed"(阶段2 引入,当前代码未赋值)由白名单
|
||
// matches! 只认 None/active 自动落入 false,面向未来验证。
|
||
|
||
// None(构造默认值,向前兼容老 JSON)
|
||
let m = ChatMessage::user("hi");
|
||
assert!(m.is_active(), "None 应 active");
|
||
|
||
// "active"
|
||
let mut m = ChatMessage::user("hi");
|
||
m.status = Some("active".to_string());
|
||
assert!(m.is_active(), "Some(active) 应 active");
|
||
|
||
// "truncated" — 当前取值,与旧实现等价(false)
|
||
let mut m = ChatMessage::user("hi");
|
||
m.status = Some("truncated".to_string());
|
||
assert!(!m.is_active(), "truncated 应不 active");
|
||
|
||
// "archived_segment" — 阶段2 待引入,白名单自动隔离
|
||
let mut m = ChatMessage::user("hi");
|
||
m.status = Some("archived_segment".to_string());
|
||
assert!(!m.is_active(), "archived_segment 应不 active(白名单隔离)");
|
||
|
||
// "compressed" — 阶段2 待引入,白名单自动隔离
|
||
let mut m = ChatMessage::user("hi");
|
||
m.status = Some("compressed".to_string());
|
||
assert!(!m.is_active(), "compressed 应不 active(白名单隔离)");
|
||
}
|
||
|
||
// ---------- F-260614-05 Phase 2a ContentPart ----------
|
||
|
||
/// 老 JSON(无 parts 字段)反序列化时 parts 应为 None(向前兼容)
|
||
#[test]
|
||
fn contentpart_legacy_json_no_parts() {
|
||
let json = r#"{"role":"user","content":"hello"}"#;
|
||
let m: ChatMessage = serde_json::from_str(json).expect("老 JSON 应可反序列化");
|
||
assert_eq!(m.content, "hello");
|
||
assert!(m.parts.is_none(), "老 JSON 无 parts 字段 → None");
|
||
assert!(!m.has_image());
|
||
}
|
||
|
||
/// 新 JSON 带 parts(含 Image 片)反序列化 round-trip
|
||
#[test]
|
||
fn contentpart_with_image_roundtrip() {
|
||
let m = ChatMessage::user_parts(
|
||
"看这张图",
|
||
vec![
|
||
ContentPart::image_base64("image/png", "iVBORw0KGgo="),
|
||
ContentPart::text("说明"),
|
||
],
|
||
);
|
||
let json = serde_json::to_string(&m).expect("序列化");
|
||
// parts 顺序:[image_base64, text] → 首元素是 image
|
||
assert!(json.contains(r#""parts":[{"type":"image""#));
|
||
assert!(json.contains(r#""type":"text""#));
|
||
assert!(json.contains(r#""base64":"iVBORw0KGgo=""#));
|
||
assert!(json.contains(r#""media_type":"image/png""#));
|
||
|
||
let back: ChatMessage = serde_json::from_str(&json).expect("反序列化 round-trip");
|
||
assert_eq!(back.content, "看这张图");
|
||
assert!(back.has_image());
|
||
let parts = back.parts.expect("parts 应存在");
|
||
assert_eq!(parts.len(), 2);
|
||
assert!(parts[0].is_image());
|
||
}
|
||
|
||
/// has_image 判定
|
||
#[test]
|
||
fn contentpart_has_image_detection() {
|
||
assert!(!ChatMessage::user("纯文本").has_image());
|
||
let m = ChatMessage::user_parts("t", vec![ContentPart::text("只文本片")]);
|
||
assert!(!m.has_image(), "仅 Text 片不算 has_image");
|
||
let m = ChatMessage::user_parts(
|
||
"t",
|
||
vec![ContentPart::text("前缀"), ContentPart::image_url("https://x/a.png")],
|
||
);
|
||
assert!(m.has_image());
|
||
}
|
||
|
||
/// flattened_parts:content 非空 → 前置 Text 片 + parts 追加
|
||
#[test]
|
||
fn contentpart_flattened_parts() {
|
||
let m = ChatMessage::user("hi");
|
||
let flat = m.flattened_parts();
|
||
assert_eq!(flat.len(), 1);
|
||
assert_eq!(flat[0], ContentPart::Text { text: "hi".into() });
|
||
|
||
let m = ChatMessage::user_parts(
|
||
"cap",
|
||
vec![ContentPart::image_url("u"), ContentPart::text("尾")],
|
||
);
|
||
let flat = m.flattened_parts();
|
||
assert_eq!(flat.len(), 3);
|
||
assert_eq!(flat[0], ContentPart::Text { text: "cap".into() });
|
||
assert!(flat[1].is_image());
|
||
assert_eq!(flat[2], ContentPart::Text { text: "尾".into() });
|
||
|
||
let mut m = ChatMessage::user("");
|
||
m.parts = Some(vec![ContentPart::text("x")]);
|
||
let flat = m.flattened_parts();
|
||
assert_eq!(flat.len(), 1, "空 content 不应产生空 Text 片");
|
||
}
|
||
|
||
/// 向后兼容:旧代码 ChatMessage 字面量构造仍合法(audit/title/commands 零回归)
|
||
#[test]
|
||
fn contentpart_struct_literal_compat() {
|
||
let m = ChatMessage {
|
||
id: None,
|
||
role: MessageRole::User,
|
||
content: "字面量构造".into(),
|
||
parts: None,
|
||
tool_call_id: None,
|
||
tool_calls: None,
|
||
model: None,
|
||
status: None,
|
||
reasoning_content: None,
|
||
timestamp: None,
|
||
};
|
||
assert_eq!(m.content, "字面量构造");
|
||
assert!(m.parts.is_none());
|
||
}
|
||
|
||
// ---------- DeepSeek reasoning_content ----------
|
||
|
||
/// 有 reasoning_content 时应序列化出来;None 时不出现
|
||
#[test]
|
||
fn completion_request_reasoning_content_serialization() {
|
||
let req = CompletionRequest {
|
||
model: "deepseek-r1".to_string(),
|
||
messages: vec![ChatMessage::user("hello")],
|
||
temperature: None,
|
||
max_tokens: None,
|
||
stream: false,
|
||
tools: None,
|
||
tool_choice: None,
|
||
reasoning_content: Some("let me think...".to_string()),
|
||
};
|
||
let json = serde_json::to_string(&req).unwrap();
|
||
assert!(json.contains("reasoning_content"), "reasoning_content 应出现在 JSON 中, got: {}", json);
|
||
|
||
// None 时不应出现(skip_serializing_if)
|
||
let req_none = CompletionRequest { reasoning_content: None, ..req };
|
||
let json_none = serde_json::to_string(&req_none).unwrap();
|
||
assert!(!json_none.contains("reasoning_content"), "None 的 reasoning_content 不应序列化, got: {}", json_none);
|
||
}
|
||
|
||
/// 所有便捷构造函数默认 reasoning_content 为 None
|
||
#[test]
|
||
fn chat_message_reasoning_content_constructors() {
|
||
assert!(ChatMessage::system("sys").reasoning_content.is_none());
|
||
assert!(ChatMessage::user("hi").reasoning_content.is_none());
|
||
assert!(ChatMessage::assistant("resp").reasoning_content.is_none());
|
||
assert!(ChatMessage::tool_result("call_1", "result").reasoning_content.is_none());
|
||
}
|
||
|
||
/// ChatMessage reasoning_content 序列化 round-trip
|
||
#[test]
|
||
fn chat_message_reasoning_content_roundtrip() {
|
||
let m = ChatMessage {
|
||
id: None,
|
||
role: MessageRole::Assistant,
|
||
content: "answer".to_string(),
|
||
parts: None,
|
||
tool_call_id: None,
|
||
tool_calls: None,
|
||
model: None,
|
||
status: None,
|
||
reasoning_content: Some("thinking process".to_string()),
|
||
timestamp: None,
|
||
};
|
||
let json = serde_json::to_string(&m).unwrap();
|
||
assert!(json.contains("reasoning_content"));
|
||
|
||
let deserialized: ChatMessage = serde_json::from_str(&json).unwrap();
|
||
assert_eq!(deserialized.reasoning_content, Some("thinking process".to_string()));
|
||
}
|
||
|
||
// ---------- F-260619-04 消息级溯源:id 字段 ----------
|
||
|
||
/// 所有便捷构造函数默认生成非 None 的 id(ULID 风格)
|
||
#[test]
|
||
fn chat_message_id_generated_by_constructors() {
|
||
assert!(ChatMessage::system("sys").id.is_some(), "system 应有 id");
|
||
assert!(ChatMessage::user("hi").id.is_some(), "user 应有 id");
|
||
assert!(ChatMessage::assistant("resp").id.is_some(), "assistant 应有 id");
|
||
assert!(
|
||
ChatMessage::assistant_with_tools("r", vec![]).id.is_some(),
|
||
"assistant_with_tools 应有 id"
|
||
);
|
||
assert!(
|
||
ChatMessage::tool_result("call_1", "result").id.is_some(),
|
||
"tool_result 应有 id"
|
||
);
|
||
assert!(
|
||
ChatMessage::user_parts("t", vec![ContentPart::text("x")]).id.is_some(),
|
||
"user_parts 应有 id"
|
||
);
|
||
}
|
||
|
||
/// id 全局唯一性:连续构造 100 条不重复(AtomicU64 计数器保证)
|
||
#[test]
|
||
fn chat_message_id_uniqueness() {
|
||
let mut ids = std::collections::HashSet::new();
|
||
for _ in 0..100 {
|
||
let m = ChatMessage::user("x");
|
||
let id = m.id.expect("构造的消息应有 id");
|
||
assert!(ids.insert(id), "100 条消息 id 应全部唯一");
|
||
}
|
||
}
|
||
|
||
/// id 序列化 round-trip:有 id 时序列化保留,反序列化回来一致
|
||
#[test]
|
||
fn chat_message_id_roundtrip() {
|
||
let m = ChatMessage {
|
||
id: Some("msg_1718800000000_42".to_string()),
|
||
role: MessageRole::Assistant,
|
||
content: "answer".to_string(),
|
||
parts: None,
|
||
tool_call_id: None,
|
||
tool_calls: None,
|
||
model: None,
|
||
status: None,
|
||
reasoning_content: None,
|
||
timestamp: None,
|
||
};
|
||
let json = serde_json::to_string(&m).unwrap();
|
||
assert!(json.contains(r#""id":"msg_1718800000000_42""#), "id 应序列化, got: {}", json);
|
||
|
||
let back: ChatMessage = serde_json::from_str(&json).unwrap();
|
||
assert_eq!(back.id.as_deref(), Some("msg_1718800000000_42"));
|
||
}
|
||
|
||
/// 向前兼容:老 JSON 无 id 字段 → 反序列化为 None(serde default)
|
||
#[test]
|
||
fn chat_message_id_legacy_json_no_id() {
|
||
let json = r#"{"role":"user","content":"hello"}"#;
|
||
let m: ChatMessage = serde_json::from_str(json).expect("老 JSON 应可反序列化");
|
||
assert_eq!(m.content, "hello");
|
||
assert!(m.id.is_none(), "老 JSON 无 id 字段 → None");
|
||
|
||
// 重新序列化:id=None 时不应出现 id 字段(skip_serializing_if)
|
||
let re = serde_json::to_string(&m).unwrap();
|
||
assert!(!re.contains(r#""id""#), "id=None 不应序列化, got: {}", re);
|
||
}
|
||
|
||
/// StreamChunk reasoning_content 序列化
|
||
#[test]
|
||
fn stream_chunk_reasoning_content() {
|
||
let chunk = StreamChunk {
|
||
delta: "text".to_string(),
|
||
finished: false,
|
||
tool_calls: None,
|
||
usage: None,
|
||
error: None,
|
||
reasoning_content: Some("thought".to_string()),
|
||
};
|
||
let json = serde_json::to_string(&chunk).unwrap();
|
||
assert!(json.contains("reasoning_content"));
|
||
|
||
let chunk_none = StreamChunk { reasoning_content: None, ..chunk };
|
||
let json_none = serde_json::to_string(&chunk_none).unwrap();
|
||
assert!(!json_none.contains("reasoning_content"));
|
||
}
|
||
|
||
/// CompletionResponse reasoning_content 序列化
|
||
#[test]
|
||
fn completion_response_reasoning_content() {
|
||
let resp = CompletionResponse {
|
||
text: "ok".to_string(),
|
||
model: "r1".to_string(),
|
||
usage: TokenUsage { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
||
tool_calls: None,
|
||
reasoning_content: Some("r1 thought".to_string()),
|
||
};
|
||
let json = serde_json::to_string(&resp).unwrap();
|
||
assert!(json.contains("reasoning_content"));
|
||
|
||
let deserialized: CompletionResponse = serde_json::from_str(&json).unwrap();
|
||
assert_eq!(deserialized.reasoning_content, Some("r1 thought".to_string()));
|
||
}
|
||
}
|