新增: F-05多模态Phase2a后端(ContentPart+parts字段+provider图片块+truncate防撑爆)

This commit is contained in:
2026-06-17 03:09:10 +08:00
parent 71718c1cd8
commit e3cd44802a
5 changed files with 496 additions and 8 deletions

View File

@@ -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);
}
}