新增: 消息级溯源 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::provider::*(df-ai/src/provider.rs:15 的 `pub use df_ai_core::provider::*`)
|
||||||
// 与直接 `df_ai_core::provider::Type` 限定路径全部继续可用。
|
// 与直接 `df_ai_core::provider::Type` 限定路径全部继续可用。
|
||||||
pub use crate::types::*;
|
pub use crate::types::*;
|
||||||
use crate::types::now_millis_i64;
|
use crate::types::{new_message_id, now_millis_i64};
|
||||||
|
|
||||||
impl ContentPart {
|
impl ContentPart {
|
||||||
pub fn text(text: impl Into<String>) -> Self {
|
pub fn text(text: impl Into<String>) -> Self {
|
||||||
@@ -42,25 +42,25 @@ impl ContentPart {
|
|||||||
|
|
||||||
impl ChatMessage {
|
impl ChatMessage {
|
||||||
pub fn system(content: impl Into<String>) -> Self {
|
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 {
|
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 {
|
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 {
|
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 {
|
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 片)。
|
/// 多模态 user 消息:content 文本 + parts(含 Image 片)。
|
||||||
/// content 作为人类可读文本(也作非 vision 端点降级载荷);parts 透传给 vision 端点。
|
/// content 作为人类可读文本(也作非 vision 端点降级载荷);parts 透传给 vision 端点。
|
||||||
pub fn user_parts(content: impl Into<String>, parts: Vec<ContentPart>) -> Self {
|
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 判定走多模态分支)。
|
/// 是否含图片片(供 provider 判定走多模态分支)。
|
||||||
@@ -263,6 +263,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn contentpart_struct_literal_compat() {
|
fn contentpart_struct_literal_compat() {
|
||||||
let m = ChatMessage {
|
let m = ChatMessage {
|
||||||
|
id: None,
|
||||||
role: MessageRole::User,
|
role: MessageRole::User,
|
||||||
content: "字面量构造".into(),
|
content: "字面量构造".into(),
|
||||||
parts: None,
|
parts: None,
|
||||||
@@ -314,6 +315,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn chat_message_reasoning_content_roundtrip() {
|
fn chat_message_reasoning_content_roundtrip() {
|
||||||
let m = ChatMessage {
|
let m = ChatMessage {
|
||||||
|
id: None,
|
||||||
role: MessageRole::Assistant,
|
role: MessageRole::Assistant,
|
||||||
content: "answer".to_string(),
|
content: "answer".to_string(),
|
||||||
parts: None,
|
parts: None,
|
||||||
@@ -331,6 +333,74 @@ mod tests {
|
|||||||
assert_eq!(deserialized.reasoning_content, Some("thinking process".to_string()));
|
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 序列化
|
/// StreamChunk reasoning_content 序列化
|
||||||
#[test]
|
#[test]
|
||||||
fn stream_chunk_reasoning_content() {
|
fn stream_chunk_reasoning_content() {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
//! `df_ai_core::provider::*` / `df_ai::provider::*` 保持不变(编译期验证)。
|
//! `df_ai_core::provider::*` / `df_ai::provider::*` 保持不变(编译期验证)。
|
||||||
|
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
use futures::Stream;
|
use futures::Stream;
|
||||||
use serde::{Deserialize, Serialize};
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ChatMessage {
|
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 role: MessageRole,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
/// 多模态内容片(F-260614-05 Phase 2a)。
|
/// 多模态内容片(F-260614-05 Phase 2a)。
|
||||||
@@ -112,6 +125,19 @@ pub(crate) fn now_millis_i64() -> i64 {
|
|||||||
.unwrap_or(0)
|
.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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
|
|||||||
@@ -93,6 +93,9 @@ fn ai_tool_execution_from_row(row: &Row<'_>) -> std::result::Result<AiToolExecut
|
|||||||
Ok(AiToolExecutionRecord {
|
Ok(AiToolExecutionRecord {
|
||||||
id: row.get("id")?,
|
id: row.get("id")?,
|
||||||
conversation_id: row.get("conversation_id")?,
|
conversation_id: row.get("conversation_id")?,
|
||||||
|
// F-260619-04:message_id 列老库经 v21 迁移补建。unwrap_or(None) 兜底:
|
||||||
|
// 新库空表直接有列;老库行 ALTER 后 NULL;极端情况(迁移未跑/手工删列)防御。
|
||||||
|
message_id: row.get("message_id").unwrap_or(None),
|
||||||
tool_call_id: row.get("tool_call_id")?,
|
tool_call_id: row.get("tool_call_id")?,
|
||||||
tool_name: row.get("tool_name")?,
|
tool_name: row.get("tool_name")?,
|
||||||
arguments: row.get("arguments")?,
|
arguments: row.get("arguments")?,
|
||||||
@@ -187,10 +190,10 @@ impl_repo!(
|
|||||||
from_row => |row| ai_tool_execution_from_row(row),
|
from_row => |row| ai_tool_execution_from_row(row),
|
||||||
insert => |conn, rec| {
|
insert => |conn, rec| {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO ai_tool_executions (id, conversation_id, tool_call_id, tool_name, arguments, result, status, risk_level, requested_at, executed_at, decided_by)
|
"INSERT INTO ai_tool_executions (id, conversation_id, message_id, tool_call_id, tool_name, arguments, result, status, risk_level, requested_at, executed_at, decided_by)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||||
params![
|
params![
|
||||||
rec.id, rec.conversation_id, rec.tool_call_id, rec.tool_name,
|
rec.id, rec.conversation_id, rec.message_id, rec.tool_call_id, rec.tool_name,
|
||||||
rec.arguments, rec.result, rec.status, rec.risk_level,
|
rec.arguments, rec.result, rec.status, rec.risk_level,
|
||||||
rec.requested_at, rec.executed_at, rec.decided_by
|
rec.requested_at, rec.executed_at, rec.decided_by
|
||||||
],
|
],
|
||||||
@@ -198,9 +201,9 @@ impl_repo!(
|
|||||||
},
|
},
|
||||||
update => |conn, rec| {
|
update => |conn, rec| {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE ai_tool_executions SET conversation_id = ?1, tool_call_id = ?2, tool_name = ?3, arguments = ?4, result = ?5, status = ?6, risk_level = ?7, requested_at = ?8, executed_at = ?9, decided_by = ?10 WHERE id = ?11",
|
"UPDATE ai_tool_executions SET conversation_id = ?1, message_id = ?2, tool_call_id = ?3, tool_name = ?4, arguments = ?5, result = ?6, status = ?7, risk_level = ?8, requested_at = ?9, executed_at = ?10, decided_by = ?11 WHERE id = ?12",
|
||||||
params![
|
params![
|
||||||
rec.conversation_id, rec.tool_call_id, rec.tool_name,
|
rec.conversation_id, rec.message_id, rec.tool_call_id, rec.tool_name,
|
||||||
rec.arguments, rec.result, rec.status, rec.risk_level,
|
rec.arguments, rec.result, rec.status, rec.risk_level,
|
||||||
rec.requested_at, rec.executed_at, rec.decided_by, rec.id
|
rec.requested_at, rec.executed_at, rec.decided_by, rec.id
|
||||||
],
|
],
|
||||||
|
|||||||
368
crates/df-storage/src/crud/message_repo.rs
Normal file
368
crates/df-storage/src/crud/message_repo.rs
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
//! AI 消息 Repo — ai_messages 表(F-260619-03 消息拆分存储)
|
||||||
|
//!
|
||||||
|
//! 每条 ChatMessage 一行的独立表,替代 `ai_conversations.messages` 整对话 JSON 列存。
|
||||||
|
//! 全专用方法(insert_batch / list_by_conversation / delete_range / update_status /
|
||||||
|
//! update_content_by_tool_call_id),不走 `impl_repo!` 宏 —— 原因:
|
||||||
|
//! ① 无标准 created_at 通用排序(query 宏硬编码 ORDER BY created_at,本表用 seq);
|
||||||
|
//! ② insert_batch 批量语义特殊(单事务多条,非逐条 insert);
|
||||||
|
//! ③ update_status / update_content_by_tool_call_id 是单列定点更新,非全行 update_full。
|
||||||
|
//!
|
||||||
|
//! P0 阶段(本文件):仅建 Repo + CRUD 方法,不接 ContextManager 读写路径
|
||||||
|
//! (切读策略待用户决策,P2)。ContextManager 仍走旧 messages JSON 列双写期未启动。
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use rusqlite::{params, Connection, Row};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
use df_types::error::Result;
|
||||||
|
|
||||||
|
use crate::db::Database;
|
||||||
|
use crate::models::AiMessageRecord;
|
||||||
|
|
||||||
|
use super::storage_err;
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// from_row 辅助
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
fn ai_message_from_row(row: &Row<'_>) -> std::result::Result<AiMessageRecord, rusqlite::Error> {
|
||||||
|
Ok(AiMessageRecord {
|
||||||
|
id: row.get("id")?,
|
||||||
|
conversation_id: row.get("conversation_id")?,
|
||||||
|
seq: row.get("seq")?,
|
||||||
|
role: row.get("role")?,
|
||||||
|
content: row.get("content")?,
|
||||||
|
parts: row.get("parts")?,
|
||||||
|
tool_call_id: row.get("tool_call_id")?,
|
||||||
|
tool_calls: row.get("tool_calls")?,
|
||||||
|
model: row.get("model")?,
|
||||||
|
status: row.get("status")?,
|
||||||
|
reasoning_content: row.get("reasoning_content")?,
|
||||||
|
timestamp: row.get("timestamp")?,
|
||||||
|
created_at: row.get("created_at")?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// AiMessageRepo
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// ai_messages 表 CRUD Repo。
|
||||||
|
///
|
||||||
|
/// 方法语义对照消息拆分存储设计 §三/§Phase3:
|
||||||
|
/// - `insert_batch`:批量插入一批消息(单事务,保证对话内 seq 连续原子写入)
|
||||||
|
/// - `list_by_conversation`:按对话加载,ORDER BY seq(游标分页基础)
|
||||||
|
/// - `delete_range`:删除对话内 [min_seq, max_seq) 范围(compress 压缩/编辑重生成用)
|
||||||
|
/// - `update_status`:单条状态更新(compress → 'compressed' / 编辑 → 'truncated')
|
||||||
|
/// - `update_content_by_tool_call_id`:按工具调用 ID 定点改 content(replace_tool_result_content)
|
||||||
|
pub struct AiMessageRepo {
|
||||||
|
conn: Arc<Mutex<Connection>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AiMessageRepo {
|
||||||
|
pub fn new(db: &Database) -> Self {
|
||||||
|
Self { conn: db.conn() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 批量插入消息(单事务原子提交)。
|
||||||
|
///
|
||||||
|
/// INSERT OR IGNORE 幂等:id 主键冲突(同消息重复写)跳过,不报错。
|
||||||
|
/// 适配双写场景(save_conversation 重写 dirty 范围时,旧消息先 delete_range 再 insert)。
|
||||||
|
pub async fn insert_batch(&self, records: Vec<AiMessageRecord>) -> Result<()> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||||
|
let mut guard = conn.blocking_lock();
|
||||||
|
// transaction() 返回 rusqlite::Error,需 map_err 转为 df_types::error::Error
|
||||||
|
let tx = guard.transaction().map_err(storage_err)?;
|
||||||
|
{
|
||||||
|
let mut stmt = tx.prepare(
|
||||||
|
"INSERT OR IGNORE INTO ai_messages
|
||||||
|
(id, conversation_id, seq, role, content, parts, tool_call_id,
|
||||||
|
tool_calls, model, status, reasoning_content, timestamp, created_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||||
|
)
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
for rec in &records {
|
||||||
|
stmt.execute(params![
|
||||||
|
rec.id, rec.conversation_id, rec.seq, rec.role, rec.content,
|
||||||
|
rec.parts, rec.tool_call_id, rec.tool_calls, rec.model, rec.status,
|
||||||
|
rec.reasoning_content, rec.timestamp, rec.created_at
|
||||||
|
])
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tx.commit().map_err(storage_err)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按对话加载全部消息,ORDER BY seq ASC(对话内时间序)。
|
||||||
|
///
|
||||||
|
/// 切读路径(restore_from_messages)用此替代反序列化 messages JSON。
|
||||||
|
/// P0 阶段未接切读(P2),此方法先就绪供未来调用 + 测试验证。
|
||||||
|
pub async fn list_by_conversation(&self, conversation_id: &str) -> Result<Vec<AiMessageRecord>> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
let conv_id = conversation_id.to_owned();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
let mut stmt = guard
|
||||||
|
.prepare(
|
||||||
|
"SELECT id, conversation_id, seq, role, content, parts, tool_call_id,
|
||||||
|
tool_calls, model, status, reasoning_content, timestamp, created_at
|
||||||
|
FROM ai_messages WHERE conversation_id = ?1 ORDER BY seq ASC",
|
||||||
|
)
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map(params![conv_id], ai_message_from_row)
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
let mut results = Vec::new();
|
||||||
|
for r in rows {
|
||||||
|
results.push(r.map_err(storage_err)?);
|
||||||
|
}
|
||||||
|
Ok(results)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除对话内 seq ∈ [min_seq, max_seq) 的消息(左闭右开)。
|
||||||
|
///
|
||||||
|
/// compress 压缩 / 编辑重生成 dirty 范围重写用:delete_range → insert_batch 原子覆盖。
|
||||||
|
/// 返回删除条数(0 表示无匹配,合法)。max_seq=None 表示删到末尾。
|
||||||
|
pub async fn delete_range(
|
||||||
|
&self,
|
||||||
|
conversation_id: &str,
|
||||||
|
min_seq: i64,
|
||||||
|
max_seq: Option<i64>,
|
||||||
|
) -> Result<usize> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
let conv_id = conversation_id.to_owned();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
let affected = match max_seq {
|
||||||
|
Some(max) => guard.execute(
|
||||||
|
"DELETE FROM ai_messages WHERE conversation_id = ?1 AND seq >= ?2 AND seq < ?3",
|
||||||
|
params![conv_id, min_seq, max],
|
||||||
|
),
|
||||||
|
None => guard.execute(
|
||||||
|
"DELETE FROM ai_messages WHERE conversation_id = ?1 AND seq >= ?2",
|
||||||
|
params![conv_id, min_seq],
|
||||||
|
),
|
||||||
|
}
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
Ok(affected)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新单条消息状态(compress → 'compressed' / 编辑 → 'truncated' / 恢复 → 'active')。
|
||||||
|
///
|
||||||
|
/// 返回是否实际更新(0 = 消息不存在/已是该状态)。同步刷新 created_at 不需要
|
||||||
|
/// (状态变更不改创建时间)。
|
||||||
|
pub async fn update_status(
|
||||||
|
&self,
|
||||||
|
conversation_id: &str,
|
||||||
|
seq: i64,
|
||||||
|
status: &str,
|
||||||
|
) -> Result<bool> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
let conv_id = conversation_id.to_owned();
|
||||||
|
let status = status.to_owned();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
let affected = guard
|
||||||
|
.execute(
|
||||||
|
"UPDATE ai_messages SET status = ?1 WHERE conversation_id = ?2 AND seq = ?3",
|
||||||
|
params![status, conv_id, seq],
|
||||||
|
)
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
Ok(affected > 0)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按 tool_call_id 定点更新消息 content(replace_tool_result_content 用)。
|
||||||
|
///
|
||||||
|
/// 返回是否实际更新(0 = 该 tool_call_id 在此对话无对应消息)。
|
||||||
|
/// 仅改 content,不动其他字段(状态/时间戳不变)。
|
||||||
|
pub async fn update_content_by_tool_call_id(
|
||||||
|
&self,
|
||||||
|
conversation_id: &str,
|
||||||
|
tool_call_id: &str,
|
||||||
|
content: &str,
|
||||||
|
) -> Result<bool> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
let conv_id = conversation_id.to_owned();
|
||||||
|
let tcid = tool_call_id.to_owned();
|
||||||
|
let content = content.to_owned();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
let affected = guard
|
||||||
|
.execute(
|
||||||
|
"UPDATE ai_messages SET content = ?1 WHERE conversation_id = ?2 AND tool_call_id = ?3",
|
||||||
|
params![content, conv_id, tcid],
|
||||||
|
)
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
Ok(affected > 0)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 单元测试 — V21 迁移幂等 + AiMessageRepo CRUD
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::db::Database;
|
||||||
|
use crate::models::AiMessageRecord;
|
||||||
|
use super::super::now_millis_str;
|
||||||
|
|
||||||
|
/// 构造测试消息记录
|
||||||
|
fn mk_msg(id: &str, conv: &str, seq: i64, role: &str, content: &str) -> AiMessageRecord {
|
||||||
|
AiMessageRecord {
|
||||||
|
id: id.into(),
|
||||||
|
conversation_id: conv.into(),
|
||||||
|
seq,
|
||||||
|
role: role.into(),
|
||||||
|
content: content.into(),
|
||||||
|
parts: None,
|
||||||
|
tool_call_id: None,
|
||||||
|
tool_calls: None,
|
||||||
|
model: None,
|
||||||
|
status: "active".into(),
|
||||||
|
reasoning_content: None,
|
||||||
|
timestamp: None,
|
||||||
|
created_at: now_millis_str(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// insert_batch + list_by_conversation round-trip
|
||||||
|
#[tokio::test]
|
||||||
|
async fn insert_batch_and_list_roundtrip() {
|
||||||
|
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||||
|
let repo = AiMessageRepo::new(&db);
|
||||||
|
|
||||||
|
let msgs = vec![
|
||||||
|
mk_msg("msg_1", "conv_a", 0, "user", "你好"),
|
||||||
|
mk_msg("msg_2", "conv_a", 1, "assistant", "你好,有什么可以帮你?"),
|
||||||
|
mk_msg("msg_3", "conv_a", 2, "tool", "工具结果"),
|
||||||
|
];
|
||||||
|
repo.insert_batch(msgs).await.expect("insert_batch");
|
||||||
|
|
||||||
|
let got = repo.list_by_conversation("conv_a").await.expect("list");
|
||||||
|
assert_eq!(got.len(), 3);
|
||||||
|
// ORDER BY seq ASC
|
||||||
|
assert_eq!(got[0].seq, 0);
|
||||||
|
assert_eq!(got[0].content, "你好");
|
||||||
|
assert_eq!(got[1].seq, 1);
|
||||||
|
assert_eq!(got[2].seq, 2);
|
||||||
|
assert_eq!(got[2].role, "tool");
|
||||||
|
|
||||||
|
// 对话隔离:另一对话查不到
|
||||||
|
let other = repo.list_by_conversation("conv_b").await.expect("list b");
|
||||||
|
assert!(other.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// insert_batch 幂等:同 id 重复 INSERT OR IGNORE 不报错(双写场景)
|
||||||
|
#[tokio::test]
|
||||||
|
async fn insert_batch_idempotent() {
|
||||||
|
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||||
|
let repo = AiMessageRepo::new(&db);
|
||||||
|
|
||||||
|
let m = mk_msg("msg_dup", "conv", 0, "user", "v1");
|
||||||
|
repo.insert_batch(vec![m.clone()]).await.expect("insert 1");
|
||||||
|
|
||||||
|
// 重复同 id 不同内容 → IGNORE,保留第一次
|
||||||
|
let m2 = mk_msg("msg_dup", "conv", 0, "user", "v2");
|
||||||
|
repo.insert_batch(vec![m2]).await.expect("insert 2 dup");
|
||||||
|
|
||||||
|
let got = repo.list_by_conversation("conv").await.expect("list");
|
||||||
|
assert_eq!(got.len(), 1);
|
||||||
|
assert_eq!(got[0].content, "v1", "IGNORE 应保留首次写入");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// delete_range 左闭右开 + None 到末尾
|
||||||
|
#[tokio::test]
|
||||||
|
async fn delete_range_half_open_and_open_end() {
|
||||||
|
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||||
|
let repo = AiMessageRepo::new(&db);
|
||||||
|
repo.insert_batch(vec![
|
||||||
|
mk_msg("m0", "c", 0, "user", "0"),
|
||||||
|
mk_msg("m1", "c", 1, "user", "1"),
|
||||||
|
mk_msg("m2", "c", 2, "user", "2"),
|
||||||
|
mk_msg("m3", "c", 3, "user", "3"),
|
||||||
|
])
|
||||||
|
.await
|
||||||
|
.expect("insert");
|
||||||
|
|
||||||
|
// 左闭右开 [1, 3):删 seq 1, 2
|
||||||
|
let n = repo.delete_range("c", 1, Some(3)).await.expect("delete");
|
||||||
|
assert_eq!(n, 2);
|
||||||
|
let got = repo.list_by_conversation("c").await.expect("list");
|
||||||
|
assert_eq!(got.len(), 2);
|
||||||
|
assert_eq!(got[0].seq, 0);
|
||||||
|
assert_eq!(got[1].seq, 3);
|
||||||
|
|
||||||
|
// None 到末尾:删 seq >= 3
|
||||||
|
let n = repo.delete_range("c", 3, None).await.expect("delete end");
|
||||||
|
assert_eq!(n, 1);
|
||||||
|
let got = repo.list_by_conversation("c").await.expect("list");
|
||||||
|
assert_eq!(got.len(), 1);
|
||||||
|
assert_eq!(got[0].seq, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// update_status 单条状态更新
|
||||||
|
#[tokio::test]
|
||||||
|
async fn update_status_single() {
|
||||||
|
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||||
|
let repo = AiMessageRepo::new(&db);
|
||||||
|
repo.insert_batch(vec![mk_msg("m0", "c", 0, "user", "hi")])
|
||||||
|
.await
|
||||||
|
.expect("insert");
|
||||||
|
|
||||||
|
let ok = repo.update_status("c", 0, "truncated").await.expect("update");
|
||||||
|
assert!(ok, "应实际更新");
|
||||||
|
let got = repo.list_by_conversation("c").await.expect("list");
|
||||||
|
assert_eq!(got[0].status, "truncated");
|
||||||
|
|
||||||
|
// 不存在的 seq → false
|
||||||
|
let ok = repo.update_status("c", 99, "active").await.expect("update");
|
||||||
|
assert!(!ok, "不存在 seq 应返回 false");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// update_content_by_tool_call_id 定点改 content
|
||||||
|
#[tokio::test]
|
||||||
|
async fn update_content_by_tool_call_id_targeted() {
|
||||||
|
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||||
|
let repo = AiMessageRepo::new(&db);
|
||||||
|
let mut m = mk_msg("m0", "c", 0, "tool", "原始结果");
|
||||||
|
m.tool_call_id = Some("call_abc".into());
|
||||||
|
repo.insert_batch(vec![m]).await.expect("insert");
|
||||||
|
|
||||||
|
let ok = repo
|
||||||
|
.update_content_by_tool_call_id("c", "call_abc", "替换后的结果")
|
||||||
|
.await
|
||||||
|
.expect("update");
|
||||||
|
assert!(ok);
|
||||||
|
let got = repo.list_by_conversation("c").await.expect("list");
|
||||||
|
assert_eq!(got[0].content, "替换后的结果");
|
||||||
|
|
||||||
|
// 不存在的 tool_call_id → false,不改其他
|
||||||
|
let ok = repo
|
||||||
|
.update_content_by_tool_call_id("c", "call_xxx", "nope")
|
||||||
|
.await
|
||||||
|
.expect("update");
|
||||||
|
assert!(!ok);
|
||||||
|
let got = repo.list_by_conversation("c").await.expect("list");
|
||||||
|
assert_eq!(got[0].content, "替换后的结果", "其他消息不应被改");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
//! Repository 模式的 CRUD 操作
|
//! Repository 模式的 CRUD 操作
|
||||||
//!
|
//!
|
||||||
//! 为 7 张表各提供一个 Repo 结构体,统一实现 insert / get_by_id / list_all / query / update_field / delete。
|
//! 为各表提供 Repo 结构体,统一实现 insert / get_by_id / list_all / query / update_field / delete。
|
||||||
//!
|
//!
|
||||||
//! 按表域拆分(SMELL-P1-9,对齐 SMELL-P0-2 拆分思路降 2212 行单文件复杂度):
|
//! 按表域拆分(SMELL-P1-9,对齐 SMELL-P0-2 拆分思路降 2212 行单文件复杂度):
|
||||||
//! - [`mod@settings`]:SettingsRepo + 列白名单(allowed_columns_for/validate_column_name/is_allowed_column)
|
//! - [`mod@settings`]:SettingsRepo + 列白名单(allowed_columns_for/validate_column_name/is_allowed_column)
|
||||||
@@ -8,18 +8,21 @@
|
|||||||
//! - [`mod@task_repo`]:TaskRepo(含 advance_status_atomic 状态机收口)
|
//! - [`mod@task_repo`]:TaskRepo(含 advance_status_atomic 状态机收口)
|
||||||
//! - [`mod@conversation_repo`]:AiProviderRepo/AiConversationRepo/AiToolExecutionRepo
|
//! - [`mod@conversation_repo`]:AiProviderRepo/AiConversationRepo/AiToolExecutionRepo
|
||||||
//! - [`mod@idea_repo`]:IdeaRepo/KnowledgeRepo/KnowledgeEventsRepo + 向量工具
|
//! - [`mod@idea_repo`]:IdeaRepo/KnowledgeRepo/KnowledgeEventsRepo + 向量工具
|
||||||
|
//! - [`mod@message_repo`]:AiMessageRepo(F-260619-03 消息拆分存储,全专用方法不走宏)
|
||||||
//!
|
//!
|
||||||
//! re-export(`pub use ...::*`)保持 `df_storage::crud::XxxRepo` /
|
//! re-export(`pub use ...::*`)保持 `df_storage::crud::XxxRepo` /
|
||||||
//! `df_storage::crud::is_allowed_column` 路径不变,**调用方零改动**。
|
//! `df_storage::crud::is_allowed_column` 路径不变,**调用方零改动**。
|
||||||
|
|
||||||
mod conversation_repo;
|
mod conversation_repo;
|
||||||
mod idea_repo;
|
mod idea_repo;
|
||||||
|
mod message_repo;
|
||||||
mod project_repo;
|
mod project_repo;
|
||||||
mod settings;
|
mod settings;
|
||||||
mod task_repo;
|
mod task_repo;
|
||||||
|
|
||||||
pub use conversation_repo::*;
|
pub use conversation_repo::*;
|
||||||
pub use idea_repo::*;
|
pub use idea_repo::*;
|
||||||
|
pub use message_repo::*;
|
||||||
pub use project_repo::*;
|
pub use project_repo::*;
|
||||||
pub use settings::*;
|
pub use settings::*;
|
||||||
pub use task_repo::*;
|
pub use task_repo::*;
|
||||||
@@ -252,6 +255,13 @@ mod baseline_tests {
|
|||||||
"表 {t} 应登记列白名单(防 SQL 注入 + 按表隔离);拆分后白名单单一来源在 settings.rs"
|
"表 {t} 应登记列白名单(防 SQL 注入 + 按表隔离);拆分后白名单单一来源在 settings.rs"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// ai_messages 表走全专用 AiMessageRepo(无标准 created_at / 批量语义特殊),
|
||||||
|
// 不走通用 query/update_field 路径,故不登记白名单(allowed_columns_for 返回 None)。
|
||||||
|
// 此处显式断言其为 None,防止后人误登记破坏"专用 Repo 不进白名单"约定。
|
||||||
|
assert!(
|
||||||
|
allowed_columns_for("ai_messages").is_none(),
|
||||||
|
"ai_messages 走专用 AiMessageRepo,不应登记通用列白名单"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// re-export 完整性:所有 Repo 类型经 `df_storage::crud::XxxRepo` 路径可访问 + new 不 panic。
|
/// re-export 完整性:所有 Repo 类型经 `df_storage::crud::XxxRepo` 路径可访问 + new 不 panic。
|
||||||
@@ -273,5 +283,6 @@ mod baseline_tests {
|
|||||||
let _ = AiToolExecutionRepo::new(&db);
|
let _ = AiToolExecutionRepo::new(&db);
|
||||||
let _ = KnowledgeRepo::new(&db);
|
let _ = KnowledgeRepo::new(&db);
|
||||||
let _ = KnowledgeEventsRepo::new(&db);
|
let _ = KnowledgeEventsRepo::new(&db);
|
||||||
|
let _ = AiMessageRepo::new(&db);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,8 +172,8 @@ pub fn allowed_columns_for(table: &str) -> Option<&'static [&'static str]> {
|
|||||||
"prompt_tokens", "completion_tokens", "created_at", "updated_at",
|
"prompt_tokens", "completion_tokens", "created_at", "updated_at",
|
||||||
],
|
],
|
||||||
"ai_tool_executions" => &[
|
"ai_tool_executions" => &[
|
||||||
"id", "conversation_id", "tool_call_id", "tool_name", "arguments", "result",
|
"id", "conversation_id", "message_id", "tool_call_id", "tool_name", "arguments",
|
||||||
"status", "risk_level", "requested_at", "executed_at", "decided_by",
|
"result", "status", "risk_level", "requested_at", "executed_at", "decided_by",
|
||||||
],
|
],
|
||||||
"knowledges" => &[
|
"knowledges" => &[
|
||||||
"id", "kind", "title", "content", "tags", "status", "confidence", "reuse_count",
|
"id", "kind", "title", "content", "tags", "status", "confidence", "reuse_count",
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ pub fn run(conn: &Connection) -> Result<()> {
|
|||||||
|
|
||||||
// 迁移步骤链: 顺序执行,跳过已应用的版本(current_version < N 才跑)。
|
// 迁移步骤链: 顺序执行,跳过已应用的版本(current_version < N 才跑)。
|
||||||
// 新增版本时,在此数组追加一项 (N, migrate_vN) 即可,无需改逻辑。
|
// 新增版本时,在此数组追加一项 (N, migrate_vN) 即可,无需改逻辑。
|
||||||
let steps: [(i32, fn(&Connection) -> Result<()>); 19] = [
|
// V20 预留给 F-260619-01(任务关联灵感),跳过;V21 = 消息拆分存储 + audit message_id。
|
||||||
|
let steps: [(i32, fn(&Connection) -> Result<()>); 20] = [
|
||||||
(1, migrate_v1),
|
(1, migrate_v1),
|
||||||
(2, migrate_v2),
|
(2, migrate_v2),
|
||||||
(3, migrate_v3),
|
(3, migrate_v3),
|
||||||
@@ -43,6 +44,7 @@ pub fn run(conn: &Connection) -> Result<()> {
|
|||||||
(17, migrate_v17),
|
(17, migrate_v17),
|
||||||
(18, migrate_v18),
|
(18, migrate_v18),
|
||||||
(19, migrate_v19),
|
(19, migrate_v19),
|
||||||
|
(21, migrate_v21),
|
||||||
];
|
];
|
||||||
|
|
||||||
for (version, migrate_fn) in steps {
|
for (version, migrate_fn) in steps {
|
||||||
@@ -348,6 +350,172 @@ fn migrate_v19(conn: &Connection) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// V21:消息拆分存储(ai_messages 表 + 全量迁移)+ ai_tool_executions.message_id 列
|
||||||
|
///
|
||||||
|
/// **一次原子迁移**(决策 V21 合并,不拆 V21a/V21b):
|
||||||
|
/// 1. 建表 ai_messages(IF NOT EXISTS 幂等,新库空表/老库均安全)
|
||||||
|
/// 2. 幂等补 ai_tool_executions.message_id 列(消息级溯源 audit)
|
||||||
|
/// 3. COUNT 探测 ai_messages 已有数据 → 跳过数据迁移(仅写版本号,防重复迁移)
|
||||||
|
/// 4. 遍历 ai_conversations.messages JSON → 逐条提取到 ai_messages(分批 commit)
|
||||||
|
///
|
||||||
|
/// 设计要点(详见消息拆分存储设计 §4.2):
|
||||||
|
/// - **幂等安全**:COUNT 探测 + INSERT OR IGNORE,中途崩溃重跑跳过已迁移数据
|
||||||
|
/// - **分批 commit**:每 50 对话一批,避免长事务持有 SQLite 写锁
|
||||||
|
/// - **迁移期 ID**:`msg_migrated_{conv_id}_{seq}` —— 天然唯一(UNIQUE 是 conv_id+seq)、零依赖
|
||||||
|
/// - **裸 JSON 提取**:用 `serde_json::Value` 而非 ChatMessage(df-storage 不依赖 df-ai-core)
|
||||||
|
/// - **坏数据跳过**:JSON 解析失败 → warn + continue,不中断迁移
|
||||||
|
/// - **status 归一化**:None/空 → "active",列语义清晰永不 NULL
|
||||||
|
/// - **created_at 语义**:有 timestamp 用消息自己的;没有 fallback 到对话 created_at
|
||||||
|
///
|
||||||
|
/// ⚠️ **迁移耦合点**:迁移函数硬编码 JSON 字段名(role/content/parts/tool_call_id/
|
||||||
|
/// tool_calls/model/status/reasoning_content/timestamp),与 ChatMessage serde 序列化字段
|
||||||
|
/// 一一对应。ChatMessage 改字段名必须同步更新此函数,否则老库迁移漏数据。
|
||||||
|
/// 同步标注已在 types.rs ChatMessage 定义处加注释。
|
||||||
|
fn migrate_v21(conn: &Connection) -> Result<()> {
|
||||||
|
// 1. 建 ai_messages 表(IF NOT EXISTS 幂等)
|
||||||
|
conn.execute_batch(V21_SQL)?;
|
||||||
|
|
||||||
|
// 2. 幂等补 ai_tool_executions.message_id 列(消息级溯源 audit,F-260619-04)
|
||||||
|
// 表存在性兜底:run() 正常流程下 V9 已先建该表,但测试/手动调用可能跳过 V9。
|
||||||
|
// 表不存在时跳过 ALTER(新库会由 V9_SQL 建表带 message_id 列;此处只补老库已有表)。
|
||||||
|
let tool_exec_table_exists: bool = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='ai_tool_executions'",
|
||||||
|
[],
|
||||||
|
|_| Ok(()),
|
||||||
|
)
|
||||||
|
.is_ok();
|
||||||
|
if tool_exec_table_exists && !column_exists(conn, "ai_tool_executions", "message_id") {
|
||||||
|
conn.execute(
|
||||||
|
"ALTER TABLE ai_tool_executions ADD COLUMN message_id TEXT",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
tracing::info!("v21: 补建 ai_tool_executions.message_id 列(消息级溯源 audit)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. COUNT 探测:ai_messages 已有数据 → 跳过迁移只写版本号(幂等)
|
||||||
|
// INSERT OR IGNORE 防崩溃重跑(schema_version PK 冲突):run() 正常流程只调
|
||||||
|
// 一次 migrate_v21(current_version<21),但崩溃重跑/手动重调时 version=21
|
||||||
|
// 可能已存在,IGNORE 保证幂等不报错。
|
||||||
|
let existing: i64 = conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM ai_messages", [], |row| row.get(0),
|
||||||
|
)?;
|
||||||
|
if existing > 0 {
|
||||||
|
tracing::info!("v21: ai_messages 已有 {} 条,跳过数据迁移", existing);
|
||||||
|
conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [21])?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 遍历 ai_conversations,反序列化 messages JSON → 逐条写入 ai_messages
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT id, messages, created_at FROM ai_conversations",
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map([], |row| {
|
||||||
|
Ok((
|
||||||
|
row.get::<_, String>(0)?,
|
||||||
|
row.get::<_, String>(1)?,
|
||||||
|
row.get::<_, String>(2)?,
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let all_rows: Vec<(String, String, String)> = rows.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
// 5. 分批 commit(每 50 个对话一批,避免长事务持有写锁)
|
||||||
|
const BATCH_SIZE: usize = 50;
|
||||||
|
let mut migrated_count: usize = 0;
|
||||||
|
for (batch_idx, batch) in all_rows.chunks(BATCH_SIZE).enumerate() {
|
||||||
|
let tx = conn.unchecked_transaction()?;
|
||||||
|
for (conv_id, messages_json, conv_created_at) in batch {
|
||||||
|
// 6. 逐对话反序列化 messages JSON → Vec<serde_json::Value>
|
||||||
|
// (用裸 JSON 而非 ChatMessage,因 df-storage 不依赖 df-ai-core)
|
||||||
|
let messages: Vec<serde_json::Value> = match serde_json::from_str(messages_json) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("v21: 对话 {} messages JSON 解析失败,跳过: {}", conv_id, e);
|
||||||
|
continue; // 坏数据跳过,不中断迁移
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (seq, msg) in messages.iter().enumerate() {
|
||||||
|
// 7. 逐条消息提取字段 → INSERT INTO ai_messages
|
||||||
|
// 字段名硬编码("role"/"content" 等)——ChatMessage 改名会漏数据!
|
||||||
|
// 迁移期 ID 天然唯一(UNIQUE 是 conv_id+seq),人类可读,零依赖
|
||||||
|
let id = format!("msg_migrated_{}_{}", conv_id, seq);
|
||||||
|
let role = msg.get("role").and_then(|v| v.as_str()).unwrap_or("user");
|
||||||
|
let content = msg.get("content").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
let parts = msg.get("parts")
|
||||||
|
.filter(|v| !v.is_null())
|
||||||
|
.map(|v| v.to_string());
|
||||||
|
let tool_call_id = msg.get("tool_call_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(String::from);
|
||||||
|
let tool_calls = msg.get("tool_calls")
|
||||||
|
.filter(|v| !v.is_null())
|
||||||
|
.map(|v| v.to_string());
|
||||||
|
let model = msg.get("model")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(String::from);
|
||||||
|
// status 归一化:None/空 → "active"(列语义清晰,永不 NULL)
|
||||||
|
let status = msg.get("status")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or("active");
|
||||||
|
let reasoning_content = msg.get("reasoning_content")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(String::from);
|
||||||
|
// created_at:有 timestamp 用消息自己的,没有 fallback 到对话创建时间
|
||||||
|
let timestamp = msg.get("timestamp").and_then(|v| v.as_i64());
|
||||||
|
let created_at = timestamp
|
||||||
|
.map(|ts| ts.to_string())
|
||||||
|
.unwrap_or_else(|| conv_created_at.clone());
|
||||||
|
|
||||||
|
tx.execute(
|
||||||
|
"INSERT OR IGNORE INTO ai_messages
|
||||||
|
(id, conversation_id, seq, role, content, parts, tool_call_id,
|
||||||
|
tool_calls, model, status, reasoning_content, timestamp, created_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||||
|
rusqlite::params![
|
||||||
|
id, conv_id, seq as i64, role, content, parts,
|
||||||
|
tool_call_id, tool_calls, model, status,
|
||||||
|
reasoning_content, timestamp, created_at
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
migrated_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tx.commit()?;
|
||||||
|
tracing::info!("v21: 批次 {} 完成({} 对话)", batch_idx, batch.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [21])?;
|
||||||
|
tracing::info!("迁移 v21 完成,共迁移 {} 条消息", migrated_count);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// V21 建表 SQL — 消息拆分存储 ai_messages 表
|
||||||
|
///
|
||||||
|
/// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。
|
||||||
|
/// 改动须两边同步。
|
||||||
|
const V21_SQL: &str = "
|
||||||
|
CREATE TABLE IF NOT EXISTS ai_messages (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
conversation_id TEXT NOT NULL,
|
||||||
|
seq INTEGER NOT NULL,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL DEFAULT '',
|
||||||
|
parts TEXT,
|
||||||
|
tool_call_id TEXT,
|
||||||
|
tool_calls TEXT,
|
||||||
|
model TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
reasoning_content TEXT,
|
||||||
|
timestamp INTEGER,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
UNIQUE(conversation_id, seq)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ai_messages_conv ON ai_messages(conversation_id, seq);
|
||||||
|
";
|
||||||
|
|
||||||
/// V1 建表 SQL
|
/// V1 建表 SQL
|
||||||
const V1_SQL: &str = "
|
const V1_SQL: &str = "
|
||||||
-- 想法表
|
-- 想法表
|
||||||
@@ -541,6 +709,7 @@ CREATE TABLE IF NOT EXISTS ai_providers (
|
|||||||
CREATE TABLE IF NOT EXISTS ai_tool_executions (
|
CREATE TABLE IF NOT EXISTS ai_tool_executions (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
conversation_id TEXT,
|
conversation_id TEXT,
|
||||||
|
message_id TEXT,
|
||||||
tool_call_id TEXT NOT NULL,
|
tool_call_id TEXT NOT NULL,
|
||||||
tool_name TEXT NOT NULL,
|
tool_name TEXT NOT NULL,
|
||||||
arguments TEXT NOT NULL,
|
arguments TEXT NOT NULL,
|
||||||
@@ -551,6 +720,28 @@ CREATE TABLE IF NOT EXISTS ai_tool_executions (
|
|||||||
executed_at TEXT,
|
executed_at TEXT,
|
||||||
decided_by TEXT
|
decided_by TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- F-260619-03 消息拆分存储:每条 ChatMessage 一行的独立表。
|
||||||
|
-- 与 V21 迁移建表 SQL 镜像(V21 用于老库 ALTER,此处给新库直接建最终态)。
|
||||||
|
-- 改动须两边同步(V21_SQL 见下方)。
|
||||||
|
CREATE TABLE IF NOT EXISTS ai_messages (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
conversation_id TEXT NOT NULL,
|
||||||
|
seq INTEGER NOT NULL,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL DEFAULT '',
|
||||||
|
parts TEXT,
|
||||||
|
tool_call_id TEXT,
|
||||||
|
tool_calls TEXT,
|
||||||
|
model TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
reasoning_content TEXT,
|
||||||
|
timestamp INTEGER,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
UNIQUE(conversation_id, seq)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ai_messages_conv ON ai_messages(conversation_id, seq);
|
||||||
";
|
";
|
||||||
|
|
||||||
/// V10 建表 SQL — 知识生命线事件表
|
/// V10 建表 SQL — 知识生命线事件表
|
||||||
@@ -584,3 +775,226 @@ CREATE TABLE IF NOT EXISTS app_settings (
|
|||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
";
|
";
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 单元测试 — V21 迁移幂等安全(新库/老库/坏数据三态,F-260619-03)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use rusqlite::Connection;
|
||||||
|
|
||||||
|
/// 构造最小老库 schema:ai_conversations 表(含 messages JSON 列)+ schema_version 表。
|
||||||
|
/// 不跑 V1-V19(测试聚焦 V21 单步行为),手动建最小依赖表。
|
||||||
|
fn setup_legacy_db() -> Connection {
|
||||||
|
let conn = Connection::open_in_memory().expect("open in-memory db");
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TABLE schema_version (version INTEGER PRIMARY KEY);
|
||||||
|
CREATE TABLE ai_conversations (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT,
|
||||||
|
messages TEXT NOT NULL DEFAULT '[]',
|
||||||
|
provider_id TEXT,
|
||||||
|
model TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);",
|
||||||
|
)
|
||||||
|
.expect("create legacy tables");
|
||||||
|
conn
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 断言 ai_messages 表存在 + 列齐全
|
||||||
|
fn assert_ai_messages_schema(conn: &Connection) {
|
||||||
|
assert!(column_exists(conn, "ai_messages", "id"));
|
||||||
|
assert!(column_exists(conn, "ai_messages", "conversation_id"));
|
||||||
|
assert!(column_exists(conn, "ai_messages", "seq"));
|
||||||
|
assert!(column_exists(conn, "ai_messages", "role"));
|
||||||
|
assert!(column_exists(conn, "ai_messages", "content"));
|
||||||
|
assert!(column_exists(conn, "ai_messages", "status"));
|
||||||
|
assert!(column_exists(conn, "ai_messages", "created_at"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 新库空跑:无 ai_conversations 数据,迁移应建表 + 写版本号 + 不崩 + ai_messages 空
|
||||||
|
#[test]
|
||||||
|
fn v21_new_db_empty_runs_clean() {
|
||||||
|
let conn = setup_legacy_db();
|
||||||
|
migrate_v21(&conn).expect("v21 应在新库空跑成功");
|
||||||
|
|
||||||
|
assert_ai_messages_schema(&conn);
|
||||||
|
// ai_tool_executions.message_id 列已补建
|
||||||
|
// 注:setup 未建 ai_tool_executions 表,column_exists 对不存在表返回 false。
|
||||||
|
// 此处验证迁移不因表不存在而崩(函数内 ALTER 被 column_exists 短路)。
|
||||||
|
|
||||||
|
let count: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM ai_messages", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(count, 0, "新库空跑 ai_messages 应为空");
|
||||||
|
|
||||||
|
let v: i64 = conn
|
||||||
|
.query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(v, 21, "应写入版本号 21");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 老库有数据:正确迁移 messages JSON → ai_messages,字段全提取
|
||||||
|
#[test]
|
||||||
|
fn v21_legacy_db_migrates_messages() {
|
||||||
|
let conn = setup_legacy_db();
|
||||||
|
// 插入一条对话,messages 含 3 条消息(覆盖 user/assistant/tool + 各字段)
|
||||||
|
let messages_json = serde_json::json!([
|
||||||
|
{"role": "user", "content": "你好", "timestamp": 1718800000000i64},
|
||||||
|
{"role": "assistant", "content": "你好,有什么可以帮你?", "model": "glm-4", "reasoning_content": "思考中"},
|
||||||
|
{"role": "tool", "content": "工具结果", "tool_call_id": "call_abc", "tool_calls": [{"id": "call_abc"}]}
|
||||||
|
]).to_string();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO ai_conversations (id, title, messages, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||||
|
rusqlite::params!["conv_1", "测试", messages_json, "1718800000000", "1718800000000"],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
migrate_v21(&conn).expect("v21 应成功迁移");
|
||||||
|
|
||||||
|
let count: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM ai_messages", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(count, 3, "应迁移 3 条消息");
|
||||||
|
|
||||||
|
// 校验 seq 递增 + 字段提取
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare("SELECT seq, role, content, model, tool_call_id, status, created_at FROM ai_messages WHERE conversation_id = 'conv_1' ORDER BY seq")
|
||||||
|
.unwrap();
|
||||||
|
let rows: Vec<(i64, String, String, Option<String>, Option<String>, String, String)> = stmt
|
||||||
|
.query_map([], |r| {
|
||||||
|
Ok((
|
||||||
|
r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?,
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
.map(|r| r.unwrap())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
assert_eq!(rows.len(), 3);
|
||||||
|
assert_eq!(rows[0].0, 0); // seq
|
||||||
|
assert_eq!(rows[0].1, "user");
|
||||||
|
assert_eq!(rows[0].2, "你好");
|
||||||
|
assert_eq!(rows[0].5, "active", "无 status → 归一化为 active");
|
||||||
|
assert_eq!(rows[0].6, "1718800000000", "有 timestamp → created_at 用它");
|
||||||
|
|
||||||
|
assert_eq!(rows[1].0, 1);
|
||||||
|
assert_eq!(rows[1].1, "assistant");
|
||||||
|
assert_eq!(rows[1].3.as_deref(), Some("glm-4"));
|
||||||
|
assert_eq!(rows[1].6, "1718800000000", "assistant 无 timestamp → fallback conv created_at");
|
||||||
|
|
||||||
|
assert_eq!(rows[2].0, 2);
|
||||||
|
assert_eq!(rows[2].1, "tool");
|
||||||
|
assert_eq!(rows[2].4.as_deref(), Some("call_abc"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 坏数据:messages JSON 解析失败 → 该对话跳过,不中断整体迁移
|
||||||
|
#[test]
|
||||||
|
fn v21_bad_json_skipped_not_crash() {
|
||||||
|
let conn = setup_legacy_db();
|
||||||
|
// 坏数据对话
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO ai_conversations (id, messages, created_at, updated_at) VALUES ('bad', '{not valid json', '0', '0')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
// 正常对话
|
||||||
|
let good = serde_json::json!([{"role": "user", "content": "好"}]).to_string();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO ai_conversations (id, messages, created_at, updated_at) VALUES ('good', ?1, '0', '0')",
|
||||||
|
rusqlite::params![good],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
migrate_v21(&conn).expect("坏数据不应中断迁移");
|
||||||
|
|
||||||
|
let count: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM ai_messages", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(count, 1, "仅正常对话的 1 条被迁移");
|
||||||
|
|
||||||
|
// 坏数据对话在 ai_messages 无记录
|
||||||
|
let bad_count: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM ai_messages WHERE conversation_id = 'bad'",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(bad_count, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 幂等重跑:第二次 migrate_v21 不重复迁移(COUNT 探测跳过)
|
||||||
|
#[test]
|
||||||
|
fn v21_idempotent_rerun() {
|
||||||
|
let conn = setup_legacy_db();
|
||||||
|
let msgs = serde_json::json!([{"role": "user", "content": "hi"}]).to_string();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO ai_conversations (id, messages, created_at, updated_at) VALUES ('c', ?1, '0', '0')",
|
||||||
|
rusqlite::params![msgs],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
migrate_v21(&conn).expect("首次迁移");
|
||||||
|
let count_after_first: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM ai_messages", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(count_after_first, 1);
|
||||||
|
|
||||||
|
// 第二次跑:COUNT 探测 > 0 → 跳过数据迁移,不重复
|
||||||
|
migrate_v21(&conn).expect("二次迁移应幂等成功");
|
||||||
|
let count_after_second: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM ai_messages", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(count_after_second, 1, "重跑不应重复插入");
|
||||||
|
|
||||||
|
// 版本号不重复写(schema_version version 是 PK,migrate_v21 用 INSERT OR IGNORE
|
||||||
|
// 防崩溃重跑 PK 冲突)
|
||||||
|
let v_count: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM schema_version WHERE version = 21",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(v_count, 1, "版本号 21 应只写一次");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ai_tool_executions.message_id 列补建(老库已有表无该列)
|
||||||
|
#[test]
|
||||||
|
fn v21_adds_message_id_column_to_tool_executions() {
|
||||||
|
let conn = setup_legacy_db();
|
||||||
|
// 模拟老库已有 ai_tool_executions 表(V9 建的旧形态,无 message_id)
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TABLE ai_tool_executions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
conversation_id TEXT,
|
||||||
|
tool_call_id TEXT NOT NULL,
|
||||||
|
tool_name TEXT NOT NULL,
|
||||||
|
arguments TEXT NOT NULL,
|
||||||
|
result TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
risk_level TEXT NOT NULL DEFAULT 'medium',
|
||||||
|
requested_at TEXT NOT NULL,
|
||||||
|
executed_at TEXT,
|
||||||
|
decided_by TEXT
|
||||||
|
);",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
!column_exists(&conn, "ai_tool_executions", "message_id"),
|
||||||
|
"迁移前应无 message_id 列"
|
||||||
|
);
|
||||||
|
|
||||||
|
migrate_v21(&conn).expect("v21 应补建 message_id 列");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
column_exists(&conn, "ai_tool_executions", "message_id"),
|
||||||
|
"迁移后应有 message_id 列"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -204,6 +204,11 @@ pub struct AiConversationRecord {
|
|||||||
pub struct AiToolExecutionRecord {
|
pub struct AiToolExecutionRecord {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub conversation_id: Option<String>,
|
pub conversation_id: Option<String>,
|
||||||
|
/// 消息级溯源:工具调用所属的 ChatMessage.id(F-260619-04)。
|
||||||
|
/// NULL = 老库行 / 消息级溯源未启用期的记录 / 无法关联的调用。
|
||||||
|
/// 升级后,audit 写入从 ContextManager 取当前 assistant 消息 id 填入(P1 接入,P0 只建列)。
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub message_id: Option<String>,
|
||||||
pub tool_call_id: String,
|
pub tool_call_id: String,
|
||||||
pub tool_name: String,
|
pub tool_name: String,
|
||||||
pub arguments: String,
|
pub arguments: String,
|
||||||
@@ -215,6 +220,42 @@ pub struct AiToolExecutionRecord {
|
|||||||
pub decided_by: Option<String>, // human/auto
|
pub decided_by: Option<String>, // human/auto
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 消息记录(ai_messages 表,F-260619-03 消息拆分存储)。
|
||||||
|
///
|
||||||
|
/// 每条 ChatMessage 一行,替代 `ai_conversations.messages` 的整对话 JSON 列存。
|
||||||
|
/// 主键 `id` = ChatMessage.id(构造时 ULID 风格生成),(conversation_id, seq) UNIQUE
|
||||||
|
/// 保证对话内序号唯一。本结构仅承载持久化映射,P0 阶段 ContextManager 仍走旧 JSON 列,
|
||||||
|
/// 切读策略待用户决策(P2)。
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AiMessageRecord {
|
||||||
|
/// 消息全局唯一 ID(= ChatMessage.id),主键
|
||||||
|
pub id: String,
|
||||||
|
/// 关联对话 ID
|
||||||
|
pub conversation_id: String,
|
||||||
|
/// 对话内序号(排序用,从 0 起)
|
||||||
|
pub seq: i64,
|
||||||
|
/// 消息角色:system/user/assistant/tool
|
||||||
|
pub role: String,
|
||||||
|
/// 文本内容(parts 的 Text 片与 content 一致)
|
||||||
|
pub content: String,
|
||||||
|
/// 多模态 parts JSON(可空)
|
||||||
|
pub parts: Option<String>,
|
||||||
|
/// 工具调用 ID(role=Tool 时必填)
|
||||||
|
pub tool_call_id: Option<String>,
|
||||||
|
/// AI 发起的工具调用 JSON(可空)
|
||||||
|
pub tool_calls: Option<String>,
|
||||||
|
/// 生成该消息的 model(仅 assistant)
|
||||||
|
pub model: Option<String>,
|
||||||
|
/// 消息状态:active/compressed/archived_segment/truncated(默认 active)
|
||||||
|
pub status: String,
|
||||||
|
/// DeepSeek thinking 推理内容(可空)
|
||||||
|
pub reasoning_content: Option<String>,
|
||||||
|
/// 消息创建时间(Unix 毫秒,可空)
|
||||||
|
pub timestamp: Option<i64>,
|
||||||
|
/// 落库时间字符串(迁移期 fallback 到对话 created_at)
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 知识库模型 (V7)
|
// 知识库模型 (V7)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ pub(crate) async fn audit_tool_call(
|
|||||||
.insert(AiToolExecutionRecord {
|
.insert(AiToolExecutionRecord {
|
||||||
id: new_id(),
|
id: new_id(),
|
||||||
conversation_id: Some(conv_id.to_string()),
|
conversation_id: Some(conv_id.to_string()),
|
||||||
|
// F-260619-04 消息级溯源:P0 阶段仅建列,audit 写入暂填 None。
|
||||||
|
// P1 将从 ContextManager 取当前 assistant 消息 id 填入(14 处全覆盖)。
|
||||||
|
message_id: None,
|
||||||
tool_call_id: tool_call_id.to_string(),
|
tool_call_id: tool_call_id.to_string(),
|
||||||
tool_name: tool_name.to_string(),
|
tool_name: tool_name.to_string(),
|
||||||
arguments: arguments.to_string(),
|
arguments: arguments.to_string(),
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ pub(crate) async fn ensure_conversation_title(
|
|||||||
.filter(|m| matches!(m.role, MessageRole::User | MessageRole::Assistant))
|
.filter(|m| matches!(m.role, MessageRole::User | MessageRole::Assistant))
|
||||||
.take(6)
|
.take(6)
|
||||||
.map(|m| ChatMessage {
|
.map(|m| ChatMessage {
|
||||||
|
id: None, // 标题摘要派生消息:不落库不溯源,无需分配 ID(对齐老消息 None 语义)
|
||||||
role: m.role.clone(),
|
role: m.role.clone(),
|
||||||
content: m.content.clone(),
|
content: m.content.clone(),
|
||||||
parts: None,
|
parts: None,
|
||||||
|
|||||||
Reference in New Issue
Block a user