新增: 媒体引用化存储与消息溯源
- media_store 模块: sha256 去重落盘 + 阿里云 OSS 双写(签名手写), OSS 失败降级本机引用 - 飞书图片入站: image 消息下载装配 vision 片, 3 次退避+120s 超时+残件清理防线 - 出站回传: 报告 [交付] 路径行自动上传飞书原生附件, 凭据/密钥名拦截兜底 - 溯源 V52: ai_messages.source 列, 飞书 msg_id/chat_id/发送者随消息落库 - spawn offload: 桌面粘贴图 base64 统一转落盘引用, SQLite 不进大字符串 - 引用统一 url 字段(https/file://)不自造 scheme, image_ref 发送前按需展开
This commit is contained in:
@@ -42,25 +42,25 @@ impl ContentPart {
|
||||
|
||||
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, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) }
|
||||
Self { id: Some(new_message_id()), role: MessageRole::System, content: content.into(), parts: None, source: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: 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, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) }
|
||||
Self { id: Some(new_message_id()), role: MessageRole::User, content: content.into(), parts: None, source: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: 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, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) }
|
||||
Self { id: Some(new_message_id()), role: MessageRole::Assistant, content: content.into(), parts: None, source: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: 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, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) }
|
||||
Self { id: Some(new_message_id()), role: MessageRole::Assistant, content: content.into(), parts: None, source: None, tool_call_id: None, tool_calls: Some(tool_calls), model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: 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, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) }
|
||||
Self { id: Some(new_message_id()), role: MessageRole::Tool, content: content.into(), source: None, parts: None, tool_call_id: Some(call_id.into()), tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: 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, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) }
|
||||
Self { id: Some(new_message_id()), role: MessageRole::User, content: content.into(), parts: Some(parts), source: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) }
|
||||
}
|
||||
|
||||
/// 是否含图片片(供 provider 判定走多模态分支)。
|
||||
|
||||
@@ -68,6 +68,22 @@ pub enum ContentPart {
|
||||
},
|
||||
}
|
||||
|
||||
/// 消息来源(#958 溯源):外部渠道进入会话的原始消息定位。序列化为 JSON 随消息
|
||||
/// 落库(ai_messages.source 列);桌面本端消息恒 None 不落
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct MessageSource {
|
||||
/// 渠道标识:"feishu"(后续可扩 openapi/webhook 等)
|
||||
pub channel: String,
|
||||
/// 渠道侧原始消息 ID(飞书 om_/oc_ 前缀,可跳原消息追溯)
|
||||
pub message_id: String,
|
||||
/// 渠道侧会话/群 ID(可选,群聊定位)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub chat_id: Option<String>,
|
||||
/// 发送者标识(飞书 open_id,可选)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub sender: Option<String>,
|
||||
}
|
||||
|
||||
/// 聊天消息
|
||||
///
|
||||
/// ⚠️ **迁移耦合点**:下方各字段名(`role`/`content`/`parts`/`tool_call_id`/
|
||||
@@ -85,6 +101,11 @@ pub struct ChatMessage {
|
||||
pub id: Option<String>,
|
||||
pub role: MessageRole,
|
||||
pub content: String,
|
||||
/// 消息来源(#958 溯源):消息从哪个渠道/端点进入会话。None=桌面本端(既有语义,
|
||||
/// 老数据反序列化为 None 零回归);Some=外部渠道(飞书 message_id 可跳原消息追溯,
|
||||
/// 多端混发可区分来源端)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source: Option<MessageSource>,
|
||||
/// 多模态内容片。
|
||||
///
|
||||
/// `None`/空 → 纯文本消息(绝大多数场景,content 即全部载荷)。
|
||||
|
||||
@@ -12,6 +12,7 @@ tokio = { workspace = true, features = ["sync", "time"] }
|
||||
async-trait = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
base64 = "0.22"
|
||||
|
||||
# HTTP + 流式
|
||||
reqwest = { version = "0.12", features = ["stream", "json", "rustls-tls"] }
|
||||
|
||||
@@ -288,9 +288,11 @@ impl AnthropicCompatProvider {
|
||||
/// 单个 ContentPart → Anthropic content block(text/image)。
|
||||
/// - Text 片 → {type:text, text}
|
||||
/// - Image 片 → {type:image, source:{type:base64, media_type, data}}
|
||||
/// base64 内嵌;url 模式当前不可达,兜底发空 data + warn(保留原行为)。
|
||||
/// base64 内嵌;本地 file:// 引用(#958)先展开为 base64,http(s) url 模式
|
||||
/// 当前不可达,兜底发空 data + warn(保留原行为)。
|
||||
/// match 取得 p 所有权后直接 move media_type/base64,避免大 base64 clone。
|
||||
fn content_part_to_block(p: crate::provider::ContentPart) -> serde_json::Value {
|
||||
let p = crate::image_ref::inline_local_image_part(p);
|
||||
match p {
|
||||
crate::provider::ContentPart::Text { text } => serde_json::json!({
|
||||
"type": "text",
|
||||
@@ -427,6 +429,22 @@ impl AnthropicCompatProvider {
|
||||
.and_then(|m| m.get("role").and_then(|r| r.as_str()))
|
||||
.unwrap_or("");
|
||||
if first_role == "user" {
|
||||
// 首条 user 但 content 为空串(会话压缩/续发残留,典型 text(0B)):原样发出会被
|
||||
// precheck "user content 空" 拦下而整请求失败。与上方补占位同思路——就地填
|
||||
// 占位文案(空串无信息可丢),上游实测接受非空占位,precheck 亦放行。
|
||||
let empty_str = messages[0]
|
||||
.get("content")
|
||||
.and_then(|c| c.as_str())
|
||||
.map(|s| s.is_empty())
|
||||
.unwrap_or(false);
|
||||
if empty_str {
|
||||
warn!(
|
||||
msg_count = messages.len(),
|
||||
"ensure_leading_user: 首条 user content 为空串,填占位(防 precheck 拦截)"
|
||||
);
|
||||
messages[0]["content"] =
|
||||
serde_json::json!("(continued from previous context)");
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 空 Vec(异常会话经 sanitize 清空)或首条非 user → 补 user 占位:
|
||||
@@ -1163,6 +1181,46 @@ mod tests {
|
||||
assert_eq!(content[1]["source"]["data"], "iVBOR");
|
||||
}
|
||||
|
||||
/// 本地 file:// 引用(#958)在转换层展开为 base64 source;缺失降级 text 块不炸请求
|
||||
#[test]
|
||||
fn anthropic_convert_expands_local_file_ref() {
|
||||
use base64::Engine as _;
|
||||
let _convert = convert_lock();
|
||||
let dir = std::env::temp_dir().join("df-ai-anthropic-ref-test");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("ref.png");
|
||||
std::fs::write(&path, b"\x89PNG ok").unwrap();
|
||||
|
||||
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
|
||||
let req = CompletionRequest {
|
||||
model: "claude-3-5-sonnet".into(),
|
||||
messages: vec![ChatMessage::user_parts(
|
||||
"看图",
|
||||
vec![crate::provider::ContentPart::Image {
|
||||
url: Some(format!("file://{}", path.display())),
|
||||
base64: None,
|
||||
media_type: Some("image/png".into()),
|
||||
alt: None,
|
||||
}],
|
||||
)],
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
stream: false,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
let body = provider.convert_request(req);
|
||||
let user_msg = body
|
||||
.messages
|
||||
.iter()
|
||||
.find(|m| m.get("role").and_then(|r| r.as_str()) == Some("user"))
|
||||
.expect("应有 user 消息");
|
||||
let content = user_msg.get("content").and_then(|c| c.as_array()).expect("user content 应为数组");
|
||||
assert_eq!(content[1]["source"]["data"], base64::engine::general_purpose::STANDARD.encode(b"\x89PNG ok"));
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
/// 纯文本 user 消息 → content 仍是字符串简写(无图不数组化)
|
||||
#[test]
|
||||
fn anthropic_convert_text_only_user_remains_string() {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
//! 本地图片引用展开(#958 图片入站引用化)
|
||||
//!
|
||||
//! 消息持久化只存引用(ContentPart::Image.url = `file://<绝对路径>`,base64 空),
|
||||
//! 发送请求前在此统一展开为 base64 内嵌——存储面瘦 referencing(一张 5MB 图不再以
|
||||
//! 6.7MB base64 字符串进 SQLite 随历史全量重放),转换面按需读盘(SSD 毫秒级,
|
||||
//! 对比 base64 内嵌的每请求全量携带,磁盘读便宜得多)。
|
||||
//!
|
||||
//! 展开点是 provider 转换层的入口:主会话/子代理/标题/压缩等全部请求路径在此
|
||||
//! 单点覆盖,调用方(飞书入站/桌面粘贴图)只管落盘产引用,不关心 provider 差异。
|
||||
//!
|
||||
//! 失败语义:引用文件丢失/不可读时降级为 Text 片「[图片文件缺失: path]」——
|
||||
//! 诚实告知模型图不可用,不让请求 400(空 base64 会被两个协议端点拒绝)。
|
||||
|
||||
use crate::provider::ContentPart;
|
||||
use base64::Engine as _;
|
||||
|
||||
/// `file://` 引用前缀。内部引用标识(两端自洽即可,不进端点):落盘方拼
|
||||
/// `file://<原生绝对路径>`,展开方 strip 后按原生路径读(Windows 盘符路径直接可用)。
|
||||
pub const LOCAL_FILE_SCHEME: &str = "file://";
|
||||
|
||||
/// 判定 Image 片是否为本地引用(base64 空 + url 带本地 scheme)
|
||||
fn is_local_ref(url: Option<&str>, base64: Option<&str>) -> bool {
|
||||
base64.is_none() && url.is_some_and(|u| u.starts_with(LOCAL_FILE_SCHEME))
|
||||
}
|
||||
|
||||
/// 把消息内的本地图片引用展开为 base64 内嵌片;非本地片原样透传。
|
||||
/// 文件缺失/不可读时该 Image 片降级为 Text 片(请求不炸,模型得到可读的缺失说明)。
|
||||
pub fn inline_local_image_parts(parts: Vec<ContentPart>) -> Vec<ContentPart> {
|
||||
parts.into_iter().map(inline_local_image_part).collect()
|
||||
}
|
||||
|
||||
/// 单片版(provider 转换臂逐片 map 用)
|
||||
pub fn inline_local_image_part(p: ContentPart) -> ContentPart {
|
||||
match p {
|
||||
ContentPart::Image { url, base64, media_type, alt }
|
||||
if is_local_ref(url.as_deref(), base64.as_deref()) =>
|
||||
{
|
||||
let path = url.unwrap();
|
||||
match std::fs::read(&path[LOCAL_FILE_SCHEME.len()..]) {
|
||||
Ok(bytes) => ContentPart::Image {
|
||||
url: None,
|
||||
base64: Some(base64::engine::general_purpose::STANDARD.encode(bytes)),
|
||||
media_type,
|
||||
alt,
|
||||
},
|
||||
Err(_) => ContentPart::Text {
|
||||
text: format!("[图片文件缺失: {}]", &path[LOCAL_FILE_SCHEME.len()..]),
|
||||
},
|
||||
}
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn local_ref_expands_to_base64() {
|
||||
let dir = std::env::temp_dir().join("df-ai-image-ref-test");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("expands.png");
|
||||
std::fs::write(&path, b"\x89PNG fake-bytes").unwrap();
|
||||
|
||||
let out = inline_local_image_parts(vec![ContentPart::Image {
|
||||
url: Some(format!("file://{}", path.display())),
|
||||
base64: None,
|
||||
media_type: Some("image/png".into()),
|
||||
alt: None,
|
||||
}]);
|
||||
match &out[0] {
|
||||
ContentPart::Image { url, base64, media_type, .. } => {
|
||||
assert!(url.is_none());
|
||||
let b = base64.as_deref().expect("base64 应已填充");
|
||||
assert_eq!(b, base64::engine::general_purpose::STANDARD.encode(b"\x89PNG fake-bytes"));
|
||||
assert_eq!(media_type.as_deref(), Some("image/png"));
|
||||
}
|
||||
other => panic!("应保持 Image 片, 实际 {other:?}"),
|
||||
}
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_file_degrades_to_text_not_empty_base64() {
|
||||
let out = inline_local_image_parts(vec![ContentPart::Image {
|
||||
url: Some("file:///nonexistent/df-ai-no-such.png".into()),
|
||||
base64: None,
|
||||
media_type: Some("image/png".into()),
|
||||
alt: None,
|
||||
}]);
|
||||
match &out[0] {
|
||||
ContentPart::Text { text } => {
|
||||
assert!(text.contains("[图片文件缺失"), "应降级为缺失说明, 实际 {text}");
|
||||
assert!(text.contains("df-ai-no-such.png"));
|
||||
}
|
||||
other => panic!("缺失文件应降级 Text 片, 实际 {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_local_parts_pass_through() {
|
||||
let passthrough = vec![
|
||||
ContentPart::text("hello"),
|
||||
ContentPart::image_base64("image/png", "aGVsbG8="),
|
||||
ContentPart::image_url("https://x/a.png"),
|
||||
// base64 非空的 file:// 不是引用(优先内嵌),原样透传
|
||||
ContentPart::Image {
|
||||
url: Some("file:///x.png".into()),
|
||||
base64: Some("aGVsbG8=".into()),
|
||||
media_type: Some("image/png".into()),
|
||||
alt: None,
|
||||
},
|
||||
];
|
||||
let out = inline_local_image_parts(passthrough);
|
||||
assert_eq!(out.len(), 4);
|
||||
assert!(matches!(&out[0], ContentPart::Text { .. }));
|
||||
assert!(matches!(&out[1], ContentPart::Image { base64: Some(_), .. }));
|
||||
assert!(matches!(&out[2], ContentPart::Image { url: Some(u), .. } if u.starts_with("https://")));
|
||||
assert!(matches!(&out[3], ContentPart::Image { base64: Some(_), .. }));
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,8 @@ mod model_probe_helpers;
|
||||
pub mod plan_hint;
|
||||
pub mod openai_compat;
|
||||
pub mod openai_helpers;
|
||||
// 本地图片引用展开(#958 图片入站引用化):消息存 file:// 引用,发送前单点展开 base64
|
||||
pub mod image_ref;
|
||||
// Phase 0 (B 路线): Plan-driven 规划骨架(维度 0.8 Plan/SubTask 结构 + 0.7 规划校验/Kahn 分层)。
|
||||
// 纯函数模块(零 IO 零状态)。依据 docs/02-架构设计/单对话并行多轮-{设计,Phase0落地路线图}-2026-06-20.md。
|
||||
// Phase 1(plan_hint 接入主 loop)/Phase 2(planning)/Phase 3(并行 execution) 接入后续。
|
||||
@@ -50,6 +52,9 @@ pub mod router;
|
||||
pub mod retry;
|
||||
pub mod sse_parser;
|
||||
pub mod namespace_store;
|
||||
// P1 工具结果瘦身(上下文根治专项 2026-10-06 设计 §二):超阈值工具结果正文落盘 + 句柄。
|
||||
// 与 src-tauri 审计侧 D2a(tool_result_offload) 同构;base 未注入时降级为纯摘要(零行为变化)。
|
||||
pub mod tool_offload;
|
||||
|
||||
use provider::LlmProvider;
|
||||
use reqwest::Client;
|
||||
|
||||
@@ -118,10 +118,12 @@ impl OpenAICompatProvider {
|
||||
// 多模态 content(须在 move m.tool_calls 之前算,借用 m)。
|
||||
// 含图消息走 content 数组(text/image_url);纯文本走字符串简写
|
||||
// (保持与现有纯文本端点零回归)。image_url 支持 data URI(base64)与 http(s) URL。
|
||||
// 本地 file:// 引用(#958)先展开为 base64 内嵌——OpenAI 端点不认本地路径
|
||||
let content = if m.has_image() {
|
||||
let parts: Vec<serde_json::Value> = m
|
||||
.flattened_parts()
|
||||
.into_iter()
|
||||
.map(crate::image_ref::inline_local_image_part)
|
||||
.map(|p| match p {
|
||||
crate::provider::ContentPart::Text { text } => serde_json::json!({
|
||||
"type": "text",
|
||||
@@ -1020,6 +1022,57 @@ mod tests {
|
||||
assert_eq!(arr[2]["image_url"]["url"], "https://x/a.png");
|
||||
}
|
||||
|
||||
/// 本地 file:// 引用(#958)在转换层展开为 data URI;引用缺失降级 Text 片不炸请求
|
||||
#[test]
|
||||
fn openai_convert_expands_local_file_ref() {
|
||||
use base64::Engine as _;
|
||||
let dir = std::env::temp_dir().join("df-ai-openai-ref-test");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("ref.png");
|
||||
std::fs::write(&path, b"\x89PNG ok").unwrap();
|
||||
|
||||
let provider = OpenAICompatProvider::new("https://api.openai.com", "k", "gpt-4o");
|
||||
let req = CompletionRequest {
|
||||
model: "gpt-4o".into(),
|
||||
messages: vec![
|
||||
ChatMessage::user_parts(
|
||||
"看图",
|
||||
vec![crate::provider::ContentPart::Image {
|
||||
url: Some(format!("file://{}", path.display())),
|
||||
base64: None,
|
||||
media_type: Some("image/png".into()),
|
||||
alt: None,
|
||||
}],
|
||||
),
|
||||
ChatMessage::user_parts(
|
||||
"坏引用",
|
||||
vec![crate::provider::ContentPart::Image {
|
||||
url: Some("file:///nonexistent/ref-missing.png".into()),
|
||||
base64: None,
|
||||
media_type: Some("image/png".into()),
|
||||
alt: None,
|
||||
}],
|
||||
),
|
||||
],
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
stream: false,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
let out = provider.convert_request(req);
|
||||
let good = &out.messages[0];
|
||||
let arr = good.content.as_array().expect("含图 → 数组");
|
||||
assert_eq!(arr[1]["image_url"]["url"], format!("data:image/png;base64,{}", base64::engine::general_purpose::STANDARD.encode(b"\x89PNG ok")));
|
||||
|
||||
let bad = &out.messages[1];
|
||||
let arr = bad.content.as_array().expect("降级片仍在数组");
|
||||
assert_eq!(arr[1]["type"], "text");
|
||||
assert!(arr[1]["text"].as_str().unwrap().contains("[图片文件缺失"));
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
/// 纯文本消息 → content 仍是字符串简写(无图不数组化,对齐纯文本端点兼容)
|
||||
#[test]
|
||||
fn openai_convert_text_only_remains_string() {
|
||||
|
||||
@@ -47,6 +47,7 @@ fn ai_message_from_row(row: &Row<'_>) -> std::result::Result<AiMessageRecord, ru
|
||||
prompt_cache_miss_tokens: row.get("prompt_cache_miss_tokens")?,
|
||||
reasoning_tokens: row.get("reasoning_tokens")?,
|
||||
is_estimated: row.get("is_estimated")?,
|
||||
source: row.get("source")?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -87,8 +88,8 @@ impl AiMessageRepo {
|
||||
(id, conversation_id, seq, role, content, parts, tool_call_id,
|
||||
tool_calls, model, status, reasoning_content, timestamp, created_at,
|
||||
prompt_tokens, completion_tokens,
|
||||
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens, is_estimated)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)",
|
||||
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens, is_estimated, source)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
for rec in &records {
|
||||
@@ -97,7 +98,7 @@ impl AiMessageRepo {
|
||||
rec.parts, rec.tool_call_id, rec.tool_calls, rec.model, rec.status,
|
||||
rec.reasoning_content, rec.timestamp, rec.created_at,
|
||||
rec.prompt_tokens, rec.completion_tokens,
|
||||
rec.prompt_cache_hit_tokens, rec.prompt_cache_miss_tokens, rec.reasoning_tokens, rec.is_estimated
|
||||
rec.prompt_cache_hit_tokens, rec.prompt_cache_miss_tokens, rec.reasoning_tokens, rec.is_estimated, rec.source
|
||||
])
|
||||
.map_err(storage_err)?;
|
||||
}
|
||||
@@ -124,7 +125,7 @@ impl AiMessageRepo {
|
||||
tool_calls, model, status, reasoning_content, timestamp, created_at,
|
||||
prompt_tokens, completion_tokens,
|
||||
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens,
|
||||
is_estimated
|
||||
is_estimated, source
|
||||
FROM ai_messages WHERE conversation_id = ?1 ORDER BY seq ASC",
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
@@ -171,13 +172,13 @@ impl AiMessageRepo {
|
||||
"SELECT id, conversation_id, seq, role, content, parts, tool_call_id,
|
||||
tool_calls, model, status, reasoning_content, timestamp, created_at,
|
||||
prompt_tokens, completion_tokens,
|
||||
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens, is_estimated
|
||||
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens, is_estimated, source
|
||||
FROM ai_messages WHERE conversation_id = ?1 AND seq < ?2 ORDER BY seq DESC LIMIT ?3"
|
||||
} else {
|
||||
"SELECT id, conversation_id, seq, role, content, parts, tool_call_id,
|
||||
tool_calls, model, status, reasoning_content, timestamp, created_at,
|
||||
prompt_tokens, completion_tokens,
|
||||
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens, is_estimated
|
||||
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens, is_estimated, source
|
||||
FROM ai_messages WHERE conversation_id = ?1 ORDER BY seq DESC LIMIT ?2"
|
||||
};
|
||||
let mut stmt = guard.prepare(sql).map_err(storage_err)?;
|
||||
@@ -296,8 +297,8 @@ impl AiMessageRepo {
|
||||
(id, conversation_id, seq, role, content, parts, tool_call_id,
|
||||
tool_calls, model, status, reasoning_content, timestamp, created_at,
|
||||
prompt_tokens, completion_tokens,
|
||||
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens, is_estimated)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)",
|
||||
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens, is_estimated, source)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
for rec in &records {
|
||||
@@ -306,7 +307,7 @@ impl AiMessageRepo {
|
||||
rec.parts, rec.tool_call_id, rec.tool_calls, rec.model, rec.status,
|
||||
rec.reasoning_content, rec.timestamp, rec.created_at,
|
||||
rec.prompt_tokens, rec.completion_tokens,
|
||||
rec.prompt_cache_hit_tokens, rec.prompt_cache_miss_tokens, rec.reasoning_tokens, rec.is_estimated
|
||||
rec.prompt_cache_hit_tokens, rec.prompt_cache_miss_tokens, rec.reasoning_tokens, rec.is_estimated, rec.source
|
||||
])
|
||||
.map_err(storage_err)?;
|
||||
}
|
||||
@@ -381,6 +382,7 @@ mod tests {
|
||||
prompt_cache_miss_tokens: None,
|
||||
reasoning_tokens: None,
|
||||
is_estimated: None,
|
||||
source: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -544,6 +546,7 @@ mod tests {
|
||||
prompt_cache_miss_tokens: None,
|
||||
reasoning_tokens: None,
|
||||
is_estimated: None,
|
||||
source: None,
|
||||
},
|
||||
AiMessageRecord {
|
||||
id: "new_1".into(),
|
||||
@@ -565,6 +568,7 @@ mod tests {
|
||||
prompt_cache_miss_tokens: None,
|
||||
reasoning_tokens: None,
|
||||
is_estimated: None,
|
||||
source: None,
|
||||
},
|
||||
];
|
||||
repo.replace_conversation("conv", records).await.expect("replace");
|
||||
@@ -638,6 +642,7 @@ mod tests {
|
||||
prompt_cache_miss_tokens: None,
|
||||
reasoning_tokens: None,
|
||||
is_estimated: None,
|
||||
source: None,
|
||||
}],
|
||||
)
|
||||
.await
|
||||
@@ -680,6 +685,7 @@ mod tests {
|
||||
prompt_cache_miss_tokens: None,
|
||||
reasoning_tokens: None,
|
||||
is_estimated: None,
|
||||
source: None,
|
||||
};
|
||||
repo.replace_conversation("c", vec![rec()]).await.expect("1st");
|
||||
repo.replace_conversation("c", vec![rec()]).await.expect("2nd");
|
||||
|
||||
@@ -64,7 +64,8 @@ pub fn run(conn: &Connection) -> Result<()> {
|
||||
// conversation_checkpoints.token_total 语义回填。
|
||||
// V51 = 待发箱 ai_outbox 表(定时消息与待发箱持久化 P0 地基):队列/定时统一实体,
|
||||
// 调度器按 status+due_at 索引到点派发。纯新增表,可逆=DROP TABLE ai_outbox。
|
||||
let steps: [(i32, fn(&Connection) -> Result<()>); 51] = [
|
||||
// V52 = ai_messages.source 列(#958 消息溯源:外部渠道原始消息 ID JSON,NULL=桌面本端)。
|
||||
let steps: [(i32, fn(&Connection) -> Result<()>); 52] = [
|
||||
(1, migrate_v1),
|
||||
(2, migrate_v2),
|
||||
(3, migrate_v3),
|
||||
@@ -122,6 +123,8 @@ pub fn run(conn: &Connection) -> Result<()> {
|
||||
(50, migrate_v50),
|
||||
// V51 = 待发箱 ai_outbox 表(定时消息与待发箱持久化 P0 地基)。
|
||||
(51, migrate_v51),
|
||||
// V52 = ai_messages 加 source 列(#958 消息溯源:飞书等外部渠道原始消息 ID)。
|
||||
(52, migrate_v52),
|
||||
];
|
||||
|
||||
for (version, migrate_fn) in steps {
|
||||
@@ -1529,7 +1532,7 @@ fn migrate_v50(conn: &Connection) -> Result<()> {
|
||||
/// 调度器启动时 reset_stale_sending 复位为 pending。
|
||||
/// 迁移链顶点版本(单一真值来源):**新增迁移时必须同步 +1**,否则下方迁移测试会失败
|
||||
/// (测试用它断言「全量迁移后 schema_version == 顶点」「重跑仍到顶点」——既防链中断,也免多处写死)。
|
||||
pub const LATEST_SCHEMA_VERSION: i32 = 51;
|
||||
pub const LATEST_SCHEMA_VERSION: i32 = 52;
|
||||
|
||||
fn migrate_v51(conn: &Connection) -> Result<()> {
|
||||
if !table_exists(conn, "ai_outbox") {
|
||||
@@ -1569,6 +1572,18 @@ fn migrate_v51(conn: &Connection) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V52: ai_messages 加 source 列(#958 消息溯源)。TEXT JSON(MessageSource 序列化),
|
||||
/// NULL=桌面本端。纯加列零回退风险(老数据恒 NULL=既有语义)。
|
||||
fn migrate_v52(conn: &Connection) -> Result<()> {
|
||||
if !column_exists(conn, "ai_messages", "source") {
|
||||
conn.execute("ALTER TABLE ai_messages ADD COLUMN source TEXT", [])?;
|
||||
tracing::info!("v52: ai_messages 加 source 列(消息溯源)");
|
||||
}
|
||||
conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [52])?;
|
||||
tracing::info!("迁移 v52 完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 回填 conversation_checkpoints.token_total:按 snapshot JSON 内逐条消息的
|
||||
/// prompt_tokens+completion_tokens 求真实总量。
|
||||
///
|
||||
|
||||
@@ -479,6 +479,9 @@ pub struct AiMessageRecord {
|
||||
/// 本轮 prompt 是否估算值(round_usage.prompt_tokens==0 → estimated_prompt 兜底打标)。
|
||||
/// reload 逐条回显「估算」标注;老消息 NULL → None(向前兼容)。
|
||||
pub is_estimated: Option<bool>,
|
||||
/// 消息来源 JSON(#958 溯源,MessageSource 序列化;V52 加列)。
|
||||
/// NULL=桌面本端(既有语义);非空=外部渠道(飞书 message_id 等,可跳原消息追溯)
|
||||
pub source: Option<String>,
|
||||
}
|
||||
|
||||
/// 待发箱记录(ai_outbox 表,V51)。
|
||||
|
||||
Reference in New Issue
Block a user