新增: 消息级溯源 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:
2026-06-19 19:24:02 +08:00
parent 44d1c6a00c
commit e981c1492a
10 changed files with 953 additions and 16 deletions

View File

@@ -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() {

View File

@@ -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")]