新增: 消息级溯源 P0 地基(ChatMessage.id + ai_messages 拆表 + 迁移 + Repo)
依据消息拆分存储设计 + 消息级溯源设计 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
This commit is contained in:
@@ -16,7 +16,7 @@ use async_trait::async_trait;
|
||||
// 使 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::now_millis_i64;
|
||||
use crate::types::{new_message_id, now_millis_i64};
|
||||
|
||||
impl ContentPart {
|
||||
pub fn text(text: impl Into<String>) -> Self {
|
||||
@@ -42,25 +42,25 @@ impl ContentPart {
|
||||
|
||||
impl ChatMessage {
|
||||
pub fn system(content: impl Into<String>) -> Self {
|
||||
Self { 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()) }
|
||||
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 { 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()) }
|
||||
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 { 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()) }
|
||||
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 { 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()) }
|
||||
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 { 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()) }
|
||||
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 { 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()) }
|
||||
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 判定走多模态分支)。
|
||||
@@ -263,6 +263,7 @@ mod tests {
|
||||
#[test]
|
||||
fn contentpart_struct_literal_compat() {
|
||||
let m = ChatMessage {
|
||||
id: None,
|
||||
role: MessageRole::User,
|
||||
content: "字面量构造".into(),
|
||||
parts: None,
|
||||
@@ -314,6 +315,7 @@ mod tests {
|
||||
#[test]
|
||||
fn chat_message_reasoning_content_roundtrip() {
|
||||
let m = ChatMessage {
|
||||
id: None,
|
||||
role: MessageRole::Assistant,
|
||||
content: "answer".to_string(),
|
||||
parts: None,
|
||||
@@ -331,6 +333,74 @@ mod tests {
|
||||
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() {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
//! `df_ai_core::provider::*` / `df_ai::provider::*` 保持不变(编译期验证)。
|
||||
|
||||
use std::pin::Pin;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use futures::Stream;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -68,8 +69,20 @@ pub enum ContentPart {
|
||||
}
|
||||
|
||||
/// 聊天消息
|
||||
///
|
||||
/// ⚠️ **迁移耦合点**:下方各字段名(`role`/`content`/`parts`/`tool_call_id`/
|
||||
/// `tool_calls`/`model`/`status`/`reasoning_content`/`timestamp`)被
|
||||
/// `df-storage/src/migrations.rs` 的 `migrate_v21` 硬编码提取(裸 JSON 反序列化,
|
||||
/// 因 df-storage 不依赖 df-ai-core)。**改字段名必须同步更新迁移函数**,否则老库
|
||||
/// 迁移漏数据。详见消息拆分存储设计 §4.2.3 迁移耦合点。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatMessage {
|
||||
/// 消息全局唯一 ID。用于消息级溯源(source_ref / audit message_id / idea source)。
|
||||
/// 构造时由 `new_message_id()` 生成;老 JSON 反序列化为 None(向前兼容)。
|
||||
/// 消息拆分存储(F-260619-03)后,此 ID 即 `ai_messages.id` 列主键。
|
||||
/// 临时/派生消息(如 title 摘要)可显式赋 None(不溯源不落库)。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
pub role: MessageRole,
|
||||
pub content: String,
|
||||
/// 多模态内容片(F-260614-05 Phase 2a)。
|
||||
@@ -112,6 +125,19 @@ pub(crate) fn now_millis_i64() -> i64 {
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// ChatMessage.id 生成器:全局唯一 + 单调递增 + 零依赖。
|
||||
///
|
||||
/// 格式 `msg_{ts_ms}_{counter}` —— 时间戳(毫秒)+ 进程内 AtomicU64 计数器双保险。
|
||||
/// 决策:不用 ULID crate(workspace 无此依赖,df-ai-core 也不依赖 df-types::new_id),
|
||||
/// 沿用 `now_millis_i64` 内联模式;AtomicU64 保证并发安全(多线程构造消息 ID 不冲突),
|
||||
/// 计数器即便时间戳回拨(系统时钟调整)也单调。可读性好(含时间戳,便于排查)。
|
||||
pub(crate) fn new_message_id() -> String {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let ts = now_millis_i64();
|
||||
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
format!("msg_{}_{n}", ts)
|
||||
}
|
||||
|
||||
/// 消息角色
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
|
||||
Reference in New Issue
Block a user