新增: F-05多模态Phase2a后端(ContentPart+parts字段+provider图片块+truncate防撑爆)
This commit is contained in:
@@ -74,6 +74,58 @@ pub(crate) fn truncate_for_persist(content: &str) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// 落库前对 `ChatMessage.parts` 做截断(F-260614-05 Phase 2a)。
|
||||
///
|
||||
/// - `Text` 片:复用 `truncate_for_persist` 头尾截断语义(单 Text 片 > 50KB 才截)。
|
||||
/// - `Image` 片:base64 通常已 50KB+,落库前替换为占位 `Text` 片
|
||||
/// `<image: base64 已省略, 共 N 字节>`,避免大体量图把对话 JSON 撑爆
|
||||
/// (一张 1MB PNG 的 base64 ≈ 1.3MB 字符串)。原 Image 片仅在内存真相源(ContextManager)
|
||||
/// 保留,重发时仍带图——对齐现有「持久化视图不污染内存真相源」约定。
|
||||
/// - `Image` url 模式(无 base64):url 本身短,原样保留。
|
||||
///
|
||||
/// 返回 None 表示 parts 无需保留(全空或全部被替换且原 parts 仅含图);调用方据此清空 parts。
|
||||
pub(crate) fn truncate_parts_for_persist(parts: &[df_ai::provider::ContentPart]) -> Option<Vec<df_ai::provider::ContentPart>> {
|
||||
use df_ai::provider::ContentPart;
|
||||
let mut out: Vec<ContentPart> = Vec::with_capacity(parts.len());
|
||||
let mut changed = false;
|
||||
for p in parts {
|
||||
match p {
|
||||
ContentPart::Text { text } => {
|
||||
let truncated = truncate_for_persist(text);
|
||||
if truncated.len() != text.len() {
|
||||
changed = true;
|
||||
}
|
||||
out.push(ContentPart::Text { text: truncated });
|
||||
}
|
||||
ContentPart::Image { url, base64, media_type, alt } => {
|
||||
// base64 模式:体量大,替换占位 Text 片
|
||||
if let Some(b) = base64 {
|
||||
let bytes_approx = b.len(); // base64 字符数 ≈ 字节数 * 4/3,粗估
|
||||
let placeholder = format!("<image: base64 已省略, 共约 {} 字符>", bytes_approx);
|
||||
out.push(ContentPart::Text { text: placeholder });
|
||||
changed = true;
|
||||
} else {
|
||||
// url 模式:url 短,原样保留(含 media_type/alt)
|
||||
out.push(ContentPart::Image {
|
||||
url: url.clone(),
|
||||
base64: None,
|
||||
media_type: media_type.clone(),
|
||||
alt: alt.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
None
|
||||
} else if changed {
|
||||
Some(out)
|
||||
} else {
|
||||
// 无变化:返回克隆的原 parts(保持引用语义一致)
|
||||
Some(parts.to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存对话到数据库(按 conv_id 写库,不受 active_conversation_id 切换影响)
|
||||
///
|
||||
/// 写 messages + updated_at + 累加 token 用量 + 首次落库的 model;标题由 ensure_conversation_title 单独生成。
|
||||
@@ -95,6 +147,11 @@ pub(crate) async fn save_conversation(
|
||||
let mut msgs = session.messages.all_messages_clone();
|
||||
for m in &mut msgs {
|
||||
m.content = truncate_for_persist(&m.content);
|
||||
// F-260614-05 Phase 2a: parts(Image base64) 同样截断(替换占位 Text 片),
|
||||
// 防大体量图把对话 JSON 撑爆。仅作用于持久化副本,不污染内存真相源。
|
||||
if let Some(parts) = m.parts.as_ref() {
|
||||
m.parts = truncate_parts_for_persist(parts);
|
||||
}
|
||||
}
|
||||
(
|
||||
serde_json::to_string(&msgs).unwrap_or_else(|_| "[]".to_string()),
|
||||
@@ -269,4 +326,59 @@ mod tests {
|
||||
assert!(result.ends_with('中'));
|
||||
assert!(result.contains("已截断"));
|
||||
}
|
||||
|
||||
// ---------- F-260614-05 Phase 2a truncate_parts_for_persist ----------
|
||||
|
||||
#[test]
|
||||
fn truncate_parts_image_base64_replaced_with_placeholder() {
|
||||
use df_ai::provider::ContentPart;
|
||||
let parts = vec![
|
||||
ContentPart::text("前缀文本"),
|
||||
ContentPart::image_base64("image/png", &"A".repeat(60_000)),
|
||||
ContentPart::text("后缀"),
|
||||
];
|
||||
let out = truncate_parts_for_persist(&parts).expect("应有结果");
|
||||
// Image base64 被替换为占位 Text 片
|
||||
assert!(out.iter().all(|p| !matches!(p, ContentPart::Image { base64: Some(_), .. })));
|
||||
let placeholder = out.iter().find_map(|p| match p {
|
||||
ContentPart::Text { text } if text.contains("base64 已省略") => Some(text.clone()),
|
||||
_ => None,
|
||||
}).expect("应含占位 Text 片");
|
||||
assert!(placeholder.contains("60000"));
|
||||
// 非 base64 的 Text 片保留原值
|
||||
assert!(out.iter().any(|p| matches!(p, ContentPart::Text { text } if text == "前缀文本")));
|
||||
assert!(out.iter().any(|p| matches!(p, ContentPart::Text { text } if text == "后缀")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_parts_image_url_preserved() {
|
||||
use df_ai::provider::ContentPart;
|
||||
// url 模式:url 短,原样保留(含 media_type)
|
||||
let parts = vec![ContentPart::Image {
|
||||
url: Some("https://x/a.png".into()),
|
||||
base64: None,
|
||||
media_type: None,
|
||||
alt: None,
|
||||
}];
|
||||
let out = truncate_parts_for_persist(&parts).expect("应有结果");
|
||||
assert!(matches!(out[0], ContentPart::Image { ref url, .. } if url.as_deref() == Some("https://x/a.png")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_parts_long_text_part_truncated() {
|
||||
use df_ai::provider::ContentPart;
|
||||
// 单 Text 片超阈值 → 头尾截断
|
||||
let long: String = "x".repeat(TRUNCATE_THRESHOLD + 1000);
|
||||
let parts = vec![ContentPart::Text { text: long }];
|
||||
let out = truncate_parts_for_persist(&parts).expect("应有结果");
|
||||
match &out[0] {
|
||||
ContentPart::Text { text } => assert!(text.contains("已截断"), "超长 Text 片应被截断"),
|
||||
_ => panic!("应仍是 Text 片"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_parts_empty_returns_none() {
|
||||
assert_eq!(truncate_parts_for_persist(&[]), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ pub(crate) async fn ensure_conversation_title(
|
||||
.map(|m| ChatMessage {
|
||||
role: m.role.clone(),
|
||||
content: m.content.clone(),
|
||||
parts: None,
|
||||
tool_call_id: None,
|
||||
tool_calls: None,
|
||||
model: None,
|
||||
|
||||
Reference in New Issue
Block a user