新增: 媒体引用化存储与消息溯源

- 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:
lxy
2026-10-11 07:33:18 +08:00
parent 8aefe3254c
commit 18e50c5af7
23 changed files with 1542 additions and 105 deletions
Generated
+17 -1
View File
@@ -1039,7 +1039,7 @@ dependencies = [
[[package]] [[package]]
name = "devflow" name = "devflow"
version = "0.1.1" version = "0.1.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@@ -1059,7 +1059,10 @@ dependencies = [
"df-workflow", "df-workflow",
"futures", "futures",
"futures-util", "futures-util",
"hex",
"hmac",
"htmd", "htmd",
"httpdate",
"keyring", "keyring",
"larksuite-oapi-sdk-rs", "larksuite-oapi-sdk-rs",
"percent-encoding", "percent-encoding",
@@ -1067,6 +1070,8 @@ dependencies = [
"reqwest 0.12.28", "reqwest 0.12.28",
"serde", "serde",
"serde_json", "serde_json",
"sha1 0.10.6",
"sha2 0.10.9",
"sys-locale", "sys-locale",
"tauri", "tauri",
"tauri-build", "tauri-build",
@@ -1097,6 +1102,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
"base64 0.22.1",
"bytes", "bytes",
"df-ai-core", "df-ai-core",
"df-types", "df-types",
@@ -1299,6 +1305,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [ dependencies = [
"block-buffer 0.10.4", "block-buffer 0.10.4",
"crypto-common 0.1.7", "crypto-common 0.1.7",
"subtle",
] ]
[[package]] [[package]]
@@ -2173,6 +2180,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest 0.10.7",
]
[[package]] [[package]]
name = "htmd" name = "htmd"
version = "0.5.5" version = "0.5.5"
+6 -6
View File
@@ -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 { 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 { 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 { 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 { 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 { 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 片)。 /// 多模态 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 { 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 判定走多模态分支)。 /// 是否含图片片(供 provider 判定走多模态分支)。
+21
View File
@@ -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`/ /// ⚠️ **迁移耦合点**:下方各字段名(`role`/`content`/`parts`/`tool_call_id`/
@@ -85,6 +101,11 @@ pub struct ChatMessage {
pub id: Option<String>, pub id: Option<String>,
pub role: MessageRole, pub role: MessageRole,
pub content: String, pub content: String,
/// 消息来源(#958 溯源):消息从哪个渠道/端点进入会话。None=桌面本端(既有语义,
/// 老数据反序列化为 None 零回归);Some=外部渠道(飞书 message_id 可跳原消息追溯,
/// 多端混发可区分来源端)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<MessageSource>,
/// 多模态内容片。 /// 多模态内容片。
/// ///
/// `None`/空 → 纯文本消息(绝大多数场景,content 即全部载荷)。 /// `None`/空 → 纯文本消息(绝大多数场景,content 即全部载荷)。
+1
View File
@@ -12,6 +12,7 @@ tokio = { workspace = true, features = ["sync", "time"] }
async-trait = { workspace = true } async-trait = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
base64 = "0.22"
# HTTP + 流式 # HTTP + 流式
reqwest = { version = "0.12", features = ["stream", "json", "rustls-tls"] } reqwest = { version = "0.12", features = ["stream", "json", "rustls-tls"] }
+59 -1
View File
@@ -288,9 +288,11 @@ impl AnthropicCompatProvider {
/// 单个 ContentPart → Anthropic content block(text/image)。 /// 单个 ContentPart → Anthropic content block(text/image)。
/// - Text 片 → {type:text, text} /// - Text 片 → {type:text, text}
/// - Image 片 → {type:image, source:{type:base64, media_type, data}} /// - 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。 /// match 取得 p 所有权后直接 move media_type/base64,避免大 base64 clone。
fn content_part_to_block(p: crate::provider::ContentPart) -> serde_json::Value { fn content_part_to_block(p: crate::provider::ContentPart) -> serde_json::Value {
let p = crate::image_ref::inline_local_image_part(p);
match p { match p {
crate::provider::ContentPart::Text { text } => serde_json::json!({ crate::provider::ContentPart::Text { text } => serde_json::json!({
"type": "text", "type": "text",
@@ -427,6 +429,22 @@ impl AnthropicCompatProvider {
.and_then(|m| m.get("role").and_then(|r| r.as_str())) .and_then(|m| m.get("role").and_then(|r| r.as_str()))
.unwrap_or(""); .unwrap_or("");
if first_role == "user" { 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; return;
} }
// 空 Vec(异常会话经 sanitize 清空)或首条非 user → 补 user 占位: // 空 Vec(异常会话经 sanitize 清空)或首条非 user → 补 user 占位:
@@ -1163,6 +1181,46 @@ mod tests {
assert_eq!(content[1]["source"]["data"], "iVBOR"); 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 仍是字符串简写(无图不数组化) /// 纯文本 user 消息 → content 仍是字符串简写(无图不数组化)
#[test] #[test]
fn anthropic_convert_text_only_user_remains_string() { fn anthropic_convert_text_only_user_remains_string() {
+122
View File
@@ -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(_), .. }));
}
}
+5
View File
@@ -33,6 +33,8 @@ mod model_probe_helpers;
pub mod plan_hint; pub mod plan_hint;
pub mod openai_compat; pub mod openai_compat;
pub mod openai_helpers; pub mod openai_helpers;
// 本地图片引用展开(#958 图片入站引用化):消息存 file:// 引用,发送前单点展开 base64
pub mod image_ref;
// Phase 0 (B 路线): Plan-driven 规划骨架(维度 0.8 Plan/SubTask 结构 + 0.7 规划校验/Kahn 分层)。 // Phase 0 (B 路线): Plan-driven 规划骨架(维度 0.8 Plan/SubTask 结构 + 0.7 规划校验/Kahn 分层)。
// 纯函数模块(零 IO 零状态)。依据 docs/02-架构设计/单对话并行多轮-{设计,Phase0落地路线图}-2026-06-20.md。 // 纯函数模块(零 IO 零状态)。依据 docs/02-架构设计/单对话并行多轮-{设计,Phase0落地路线图}-2026-06-20.md。
// Phase 1(plan_hint 接入主 loop)/Phase 2(planning)/Phase 3(并行 execution) 接入后续。 // Phase 1(plan_hint 接入主 loop)/Phase 2(planning)/Phase 3(并行 execution) 接入后续。
@@ -50,6 +52,9 @@ pub mod router;
pub mod retry; pub mod retry;
pub mod sse_parser; pub mod sse_parser;
pub mod namespace_store; pub mod namespace_store;
// P1 工具结果瘦身(上下文根治专项 2026-10-06 设计 §二):超阈值工具结果正文落盘 + 句柄。
// 与 src-tauri 审计侧 D2a(tool_result_offload) 同构;base 未注入时降级为纯摘要(零行为变化)。
pub mod tool_offload;
use provider::LlmProvider; use provider::LlmProvider;
use reqwest::Client; use reqwest::Client;
+53
View File
@@ -118,10 +118,12 @@ impl OpenAICompatProvider {
// 多模态 content(须在 move m.tool_calls 之前算,借用 m)。 // 多模态 content(须在 move m.tool_calls 之前算,借用 m)。
// 含图消息走 content 数组(text/image_url);纯文本走字符串简写 // 含图消息走 content 数组(text/image_url);纯文本走字符串简写
// (保持与现有纯文本端点零回归)。image_url 支持 data URI(base64)与 http(s) URL。 // (保持与现有纯文本端点零回归)。image_url 支持 data URI(base64)与 http(s) URL。
// 本地 file:// 引用(#958)先展开为 base64 内嵌——OpenAI 端点不认本地路径
let content = if m.has_image() { let content = if m.has_image() {
let parts: Vec<serde_json::Value> = m let parts: Vec<serde_json::Value> = m
.flattened_parts() .flattened_parts()
.into_iter() .into_iter()
.map(crate::image_ref::inline_local_image_part)
.map(|p| match p { .map(|p| match p {
crate::provider::ContentPart::Text { text } => serde_json::json!({ crate::provider::ContentPart::Text { text } => serde_json::json!({
"type": "text", "type": "text",
@@ -1020,6 +1022,57 @@ mod tests {
assert_eq!(arr[2]["image_url"]["url"], "https://x/a.png"); 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 仍是字符串简写(无图不数组化,对齐纯文本端点兼容) /// 纯文本消息 → content 仍是字符串简写(无图不数组化,对齐纯文本端点兼容)
#[test] #[test]
fn openai_convert_text_only_remains_string() { fn openai_convert_text_only_remains_string() {
+15 -9
View File
@@ -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")?, prompt_cache_miss_tokens: row.get("prompt_cache_miss_tokens")?,
reasoning_tokens: row.get("reasoning_tokens")?, reasoning_tokens: row.get("reasoning_tokens")?,
is_estimated: row.get("is_estimated")?, 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, (id, conversation_id, seq, role, content, parts, tool_call_id,
tool_calls, model, status, reasoning_content, timestamp, created_at, tool_calls, model, status, reasoning_content, timestamp, created_at,
prompt_tokens, completion_tokens, 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)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)", 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)?; .map_err(storage_err)?;
for rec in &records { for rec in &records {
@@ -97,7 +98,7 @@ impl AiMessageRepo {
rec.parts, rec.tool_call_id, rec.tool_calls, rec.model, rec.status, rec.parts, rec.tool_call_id, rec.tool_calls, rec.model, rec.status,
rec.reasoning_content, rec.timestamp, rec.created_at, rec.reasoning_content, rec.timestamp, rec.created_at,
rec.prompt_tokens, rec.completion_tokens, 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)?; .map_err(storage_err)?;
} }
@@ -124,7 +125,7 @@ impl AiMessageRepo {
tool_calls, model, status, reasoning_content, timestamp, created_at, tool_calls, model, status, reasoning_content, timestamp, created_at,
prompt_tokens, completion_tokens, prompt_tokens, completion_tokens,
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_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", FROM ai_messages WHERE conversation_id = ?1 ORDER BY seq ASC",
) )
.map_err(storage_err)?; .map_err(storage_err)?;
@@ -171,13 +172,13 @@ impl AiMessageRepo {
"SELECT id, conversation_id, seq, role, content, parts, tool_call_id, "SELECT id, conversation_id, seq, role, content, parts, tool_call_id,
tool_calls, model, status, reasoning_content, timestamp, created_at, tool_calls, model, status, reasoning_content, timestamp, created_at,
prompt_tokens, completion_tokens, 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" FROM ai_messages WHERE conversation_id = ?1 AND seq < ?2 ORDER BY seq DESC LIMIT ?3"
} else { } else {
"SELECT id, conversation_id, seq, role, content, parts, tool_call_id, "SELECT id, conversation_id, seq, role, content, parts, tool_call_id,
tool_calls, model, status, reasoning_content, timestamp, created_at, tool_calls, model, status, reasoning_content, timestamp, created_at,
prompt_tokens, completion_tokens, 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" FROM ai_messages WHERE conversation_id = ?1 ORDER BY seq DESC LIMIT ?2"
}; };
let mut stmt = guard.prepare(sql).map_err(storage_err)?; 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, (id, conversation_id, seq, role, content, parts, tool_call_id,
tool_calls, model, status, reasoning_content, timestamp, created_at, tool_calls, model, status, reasoning_content, timestamp, created_at,
prompt_tokens, completion_tokens, 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)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)", 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)?; .map_err(storage_err)?;
for rec in &records { for rec in &records {
@@ -306,7 +307,7 @@ impl AiMessageRepo {
rec.parts, rec.tool_call_id, rec.tool_calls, rec.model, rec.status, rec.parts, rec.tool_call_id, rec.tool_calls, rec.model, rec.status,
rec.reasoning_content, rec.timestamp, rec.created_at, rec.reasoning_content, rec.timestamp, rec.created_at,
rec.prompt_tokens, rec.completion_tokens, 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)?; .map_err(storage_err)?;
} }
@@ -381,6 +382,7 @@ mod tests {
prompt_cache_miss_tokens: None, prompt_cache_miss_tokens: None,
reasoning_tokens: None, reasoning_tokens: None,
is_estimated: None, is_estimated: None,
source: None,
} }
} }
@@ -544,6 +546,7 @@ mod tests {
prompt_cache_miss_tokens: None, prompt_cache_miss_tokens: None,
reasoning_tokens: None, reasoning_tokens: None,
is_estimated: None, is_estimated: None,
source: None,
}, },
AiMessageRecord { AiMessageRecord {
id: "new_1".into(), id: "new_1".into(),
@@ -565,6 +568,7 @@ mod tests {
prompt_cache_miss_tokens: None, prompt_cache_miss_tokens: None,
reasoning_tokens: None, reasoning_tokens: None,
is_estimated: None, is_estimated: None,
source: None,
}, },
]; ];
repo.replace_conversation("conv", records).await.expect("replace"); repo.replace_conversation("conv", records).await.expect("replace");
@@ -638,6 +642,7 @@ mod tests {
prompt_cache_miss_tokens: None, prompt_cache_miss_tokens: None,
reasoning_tokens: None, reasoning_tokens: None,
is_estimated: None, is_estimated: None,
source: None,
}], }],
) )
.await .await
@@ -680,6 +685,7 @@ mod tests {
prompt_cache_miss_tokens: None, prompt_cache_miss_tokens: None,
reasoning_tokens: None, reasoning_tokens: None,
is_estimated: None, is_estimated: None,
source: None,
}; };
repo.replace_conversation("c", vec![rec()]).await.expect("1st"); repo.replace_conversation("c", vec![rec()]).await.expect("1st");
repo.replace_conversation("c", vec![rec()]).await.expect("2nd"); repo.replace_conversation("c", vec![rec()]).await.expect("2nd");
+17 -2
View File
@@ -64,7 +64,8 @@ pub fn run(conn: &Connection) -> Result<()> {
// conversation_checkpoints.token_total 语义回填。 // conversation_checkpoints.token_total 语义回填。
// V51 = 待发箱 ai_outbox 表(定时消息与待发箱持久化 P0 地基):队列/定时统一实体, // V51 = 待发箱 ai_outbox 表(定时消息与待发箱持久化 P0 地基):队列/定时统一实体,
// 调度器按 status+due_at 索引到点派发。纯新增表,可逆=DROP TABLE ai_outbox。 // 调度器按 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), (1, migrate_v1),
(2, migrate_v2), (2, migrate_v2),
(3, migrate_v3), (3, migrate_v3),
@@ -122,6 +123,8 @@ pub fn run(conn: &Connection) -> Result<()> {
(50, migrate_v50), (50, migrate_v50),
// V51 = 待发箱 ai_outbox 表(定时消息与待发箱持久化 P0 地基)。 // V51 = 待发箱 ai_outbox 表(定时消息与待发箱持久化 P0 地基)。
(51, migrate_v51), (51, migrate_v51),
// V52 = ai_messages 加 source 列(#958 消息溯源:飞书等外部渠道原始消息 ID)。
(52, migrate_v52),
]; ];
for (version, migrate_fn) in steps { for (version, migrate_fn) in steps {
@@ -1529,7 +1532,7 @@ fn migrate_v50(conn: &Connection) -> Result<()> {
/// 调度器启动时 reset_stale_sending 复位为 pending。 /// 调度器启动时 reset_stale_sending 复位为 pending。
/// 迁移链顶点版本(单一真值来源):**新增迁移时必须同步 +1**,否则下方迁移测试会失败 /// 迁移链顶点版本(单一真值来源):**新增迁移时必须同步 +1**,否则下方迁移测试会失败
/// (测试用它断言「全量迁移后 schema_version == 顶点」「重跑仍到顶点」——既防链中断,也免多处写死)。 /// (测试用它断言「全量迁移后 schema_version == 顶点」「重跑仍到顶点」——既防链中断,也免多处写死)。
pub const LATEST_SCHEMA_VERSION: i32 = 51; pub const LATEST_SCHEMA_VERSION: i32 = 52;
fn migrate_v51(conn: &Connection) -> Result<()> { fn migrate_v51(conn: &Connection) -> Result<()> {
if !table_exists(conn, "ai_outbox") { if !table_exists(conn, "ai_outbox") {
@@ -1569,6 +1572,18 @@ fn migrate_v51(conn: &Connection) -> Result<()> {
Ok(()) 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 内逐条消息的 /// 回填 conversation_checkpoints.token_total:按 snapshot JSON 内逐条消息的
/// prompt_tokens+completion_tokens 求真实总量。 /// prompt_tokens+completion_tokens 求真实总量。
/// ///
+3
View File
@@ -479,6 +479,9 @@ pub struct AiMessageRecord {
/// 本轮 prompt 是否估算值(round_usage.prompt_tokens==0 → estimated_prompt 兜底打标)。 /// 本轮 prompt 是否估算值(round_usage.prompt_tokens==0 → estimated_prompt 兜底打标)。
/// reload 逐条回显「估算」标注;老消息 NULL → None(向前兼容)。 /// reload 逐条回显「估算」标注;老消息 NULL → None(向前兼容)。
pub is_estimated: Option<bool>, pub is_estimated: Option<bool>,
/// 消息来源 JSON(#958 溯源,MessageSource 序列化;V52 加列)。
/// NULL=桌面本端(既有语义);非空=外部渠道(飞书 message_id 等,可跳原消息追溯)
pub source: Option<String>,
} }
/// 待发箱记录(ai_outbox 表,V51)。 /// 待发箱记录(ai_outbox 表,V51)。
+26
View File
@@ -716,3 +716,29 @@
- 拍板点:① 是否引入「非终态 tool_result」这一语义(默认关可回退);② grace 秒默认值;③ 后台期间会话态展示(Generating 还是「后台执行中」)。 - 拍板点:① 是否引入「非终态 tool_result」这一语义(默认关可回退);② grace 秒默认值;③ 后台期间会话态展示(Generating 还是「后台执行中」)。
937. [ ] **对话内创建定时/延时任务(2026-10-10 用户报)**:在对话中直接创建「定时执行/延时执行」的任务(如「1 小时后跑 X」)。**需设计**:与既有 Tasks/工作流(advance_task、run_workflow)的关系、调度落点(与 935 共用调度器)、无人值守引擎(unattended)是否复用、UI 呈现(任务卡带定时徽标)。设计落 docs 后再实施。 937. [ ] **对话内创建定时/延时任务(2026-10-10 用户报)**:在对话中直接创建「定时执行/延时执行」的任务(如「1 小时后跑 X」)。**需设计**:与既有 Tasks/工作流(advance_task、run_workflow)的关系、调度落点(与 935 共用调度器)、无人值守引擎(unattended)是否复用、UI 呈现(任务卡带定时徽标)。设计落 docs 后再实施。
943. [ ] **ai-chat UI 检查:待发送队列横幅(2026-10-10 用户截图实证+代码核验)**——位置 `AiChat.vue:79-113`(样式 965-1050):
- **UI-1 全文不可辨**(中):`.ai-queue-text`(1005-1011)单行 ellipsis 且**无 title** → 长路径+指令完全无法看全;修:补 `:title="item.text"` 或两行 clamp。
- **UI-2 视觉层级倒挂**(低):横幅 accent 底色+多操作,比主输入区还醒目;待发是次要动作,建议降噪(中性底色/收起为一条计数)。
- **UI-3 操作语义重叠**(低):「回填(108)」与「编辑(110)」并存——回填=取出放回输入框,编辑=行内编辑,功能高度重叠;建议二合一。
- **UI-4 清空无确认**(中):`clearQueue`(84)直接清空全部待发,**无二次确认**;对比「强制发送」有 confirm(`tryForceSend`,533);误点即丢 10 条。修:加 confirmDialog。
- **UI-5 无上下文预期**(低):有等待时长(queueItemWait ✓)但无「预计还要等多久/当前生成还剩多少」;信息可后置,仅登记。
945. [ ] **ai-chat UI 检查:工具卡头部截断(2026-10-10 截图实证)**——`ToolCard.vue:27/:367`:头部摘要 `resultSummary` 有 `:title` 悬停,但 `summaryTitle` **只取 `args.path`**——`run_command` 类悬停看不到命令内容,头部命令文本又单行截断(446-450) → 截图实证「$ …命令成功 (19s)」完全不可辨。修:`summaryTitle` 按工具类型取关键参数(run_command→command;无 path 工具→首参)。
947. [ ] **ai-chat UI 检查:token 统计常驻每条消息(2026-10-10 截图实证)**——每条带 reasoning 的消息都挂思考 pill,右端 token 小字(`roundTokenText`,MessageList)逐条重复「55.4k in(估算) 974 c…」且截断显示不全 → 元信息噪音化。修(方向,需拍板):① pill 悬停才显 token;② 仅最新一显,历史轮收进轨迹卡统计;③ 截断点保底(最少显全「974 out」段)。
949. [ ] **强制发送后发送按钮失态(2026-10-10 用户实测报)**:队列「强制发送」后,后端在新 loop 正常生成(内容照常流出),但发送按钮回到普通「发送」态而非「停止」态。**根因方向(已定位待实施)**:force_send 复位旧 generating 时 emit 旧任务 AiCompleted(`chat.rs` 复位分支)——前端迟到收尾仲裁(useAiLifecycleEvents:60,IPC 查后端 generating)存在 ① 事件到达 vs 新 loop 占用 generating 的竞态窗口(查到 false 即误判真收尾);② AiTextDelta 无条件累加所以内容照流、streaming 却被翻 false,内容与按钮态脱钩正好吻合现象。修(三选一或组合,实施时细查 emit 顺序):① 后端把旧终态 emit 挪到新 loop 占用 generating 之后(消竞态);② 前端 AiConvStateChanged/相关收敛路径全纳入仲裁;③ 自愈兜底:AiHeartbeat 到达时若后端 generating=true 而前端 streaming=false → 置回 true。验收:强制发送后按钮全程「停止」态直至收尾。
951. [ ] **消息聚合形式优化(2026-10-10 用户提出)**:进一步聚合消息展示粒度——当前「结果/过程分离」是一桶一张轨迹卡(每轮一张);长任务(截图 20 轮)仍会有 N 张卡。方向(待拍板):① 任务级聚合=一次发送的全程收敛为**单张过程卡**(内部分轮导航/折叠),气泡只留最终结果;② 维持每轮一卡但加强轮间视觉折叠(连续轮默认收进「+N 轮」);③ 先上已实现的「结果/过程分离」新版实测再定(可能已够)。依赖:新版换装后实测再拍板粒度。
953. [ ] **主对话 max_tokens 截断无 UI 提示 + 续写整段重发(2026-10-10 用户新版实测截图,魔芋渠道接入会话)**:模型输出 921 out 戛然而止(finish_reason=length),气泡**无任何截断标记**——用户只能问「卡了吗」;追问后模型答「上一轮被截断,接着整理完整」却**从头发一遍**,同一方案在列表里重复两大段(token 翻倍+滚动负担)。代码核验:后端有专门截断标志(stream_recv.rs:105 truncated_by_max_tokens,openai_helpers.rs:341 finish_reason=length),但仅子代理完成卡消费(AgentFinishedStatus truncated);主对话气泡零消费。修(三层):①**UI 提示**:truncated_by_max_tokens 穿透到主对话气泡——尾部渲染「⚠ 输出因长度上限截断」弱提示条(附「继续输出」按钮=注入续写指令,复用 16 纠偏注入通道);②**行为**:续写注入让模型从断点续(上下文半截已在,提示「从中断处继续,勿重复已输出内容」),防整段重发;③**配置面**:921 out 即断疑似该渠道 max_tokens 上限小或默认 max_tokens 偏小,查 model_configs 该渠道 max_tokens 配置。验收:截断气泡有可见提示;继续输出不重复已发内容。
- **③ 配置面结论(2026-10-10 核验)**:`model_configs` 全部 `max_tokens=None`(ds-flash/glm/moyu 系 8 条)→ 请求不带输出上限,断点取决于上游默认;921 out 即断=该渠道模型输出上限小,且当前配置结构**无法干预**(model_configs 不支持 max_tokens 字段)→ 能力缺口:给 ModelConfig 增 max_tokens 可选字段+请求构造透传(涉 crates/df-ai,待解禁)。另:`agentic/mod.rs:2271` 截断后已有「以上内容可能不完整;可要求继续输出」system 提示(**只给模型,UI 不可见**)——模型能答「上轮被截断」正源于此,953① 的用户可见提示仍必要。旁证:DB 中 ctx=8192 哨兵大量存在,用户实测 56.7k in 未被卡 → J2 哨兵不采信守卫生效 ✓。
955. [ ] **DSML 工具调用标记泄漏为可见文本(2026-10-10 用户实测,魔芋渠道 ds-flash)**:模型输出 `<||DSML|| invoke name="run_command">`、`<||DSML|| parameter name="command" string="true">cd E:\wk-lab\devflow; npx vue-tsc...` 等 DeepSeek 文本协议工具调用标记,整段泄漏为可见文本(工具卡不渲染、命令裸露)。**根因(全仓 grep 取证)**:Rust/前端**均无 DSML 解析代码**——DevFlow 流解析只认原生 `tool_calls` delta;魔芋渠道的 DeepSeek v3.1+ 在该渠道不走原生 function calling 而是把工具调用以 DSML 特殊标记写进 content 文本(上游行为),无人解析即泄漏;且截断(921 out)发生在标记中间时,残缺 DSML 必然泄漏。**修(三层)**:①流解析层:openai_helpers/stream_recv 检测 content 中的 DSML invoke/parameter 标记 → 解析出 name/parameters 构造 ToolCallDraft(与原生 tool_calls 同路,工具卡正常渲染+真实执行);②渲染兜底:MessageItem/Markdown 渲染前剥离未闭合 DSML 残段(防截断泄漏);③**模型/渠道规避**:该行为是 DeepSeek v3.1+ 渠道侧特性,魔宇渠道换原生 function-calling 模型或升级渠道适配可根治。验收:DSML 工具调用正常出卡并执行;截断残段不泄漏。**注意**:全仓引入 DSML 解析是新能力,实施前按「新能力引入评估纪律」走设计档。
957. [ ] **审计决策者键补齐(2026-10-10 Wave G 发现)**:审计决策者列后端还有 5 个值渲染裸 key:`auto_retry_guard`(audit/approval.rs:185)、`auto_resolve_fail`(audit/mod.rs:750)、`boundary-dirs`(audit/mod.rs:909)、`boundary_auto`(audit/mod.rs:1449/1517)、`sub_agent_denied`(audit/mod.rs:116)——auditLog.ts 的 decided 段只有 8 键,缺这 5 个。修:i18n zh/en 补 5 键(值按语义直译);其中 boundary-dirs/sub_agent_denied 语义为拒绝,不要回退到既有 auto 键。
958. [x] **飞书通道图片入站能力(2026-10-11 用户发图实测,回「暂仅支持文本」提示卡)**:用户给 DevFlow 飞书 bot 发图,只收到降级提示——**勘察结论(AI-Ops 10-11,链路+可行性已核)**:非故障,系有意兜底(feishu_channel.rs:387,#25 静默丢修复);桌面端 ai-chat 图片链路**已完整**(ChatInput 粘贴/上传 → ContentPart::Image → chat.rs:920 has_image 自动转 vision 端点,anthropic 侧 url→base64 预拉已有),缺口**仅在飞书入站**。修(四件):①`feishu_channel.rs::handle_message` 增 image 分支(现 `message_type != "text"` 一刀切回提示卡);②拉图:飞书 im/v1/messages/{message_id}/resources/{file_key}?type=image 下载(file_key 从 image 消息 content JSON 取),SDK 无现成方法则手写 GET(租户 access_token 复用既有 client);③组装:字节→base64→ContentPart::Image+可选 caption 文本拼 user 消息,走既有 spawn 会话链路(vision 路由零改动);④开放平台侧:应用补 im:resource 只读权限。**注意**:①权限申领是对外变更(飞书后台),先拍板;②大图限流(base64 膨胀,建议 >10MB 拒收提示);③文件/PDF 等其他媒体不在本条范围。验收:发图给 bot,AI 能看到图并按图回答;非图媒体仍回现有提示卡。**【10-11 AI-Ops 实施完成(用户令「直接推进」),待换装+权限开通】**:①②③已全部落地 feishu_channel.rs 单文件——类型三分流(text/image/其余提示卡,image_key 取自 SDK 归一化 msg.resources 零手解析)/`download_feishu_image`(走 channel_messaging().download_message_resource 便捷方法,与 reply/send 同风格)/魔数判 media_type(sniff_image_mime 四格式,未知拒收防端点 400)+10MB 限流/引导词 IMAGE_INBOUND_PROMPT 喂模型(飞书 image 独立类型无法带文字)/白名单后下载(未授权不烧流量)+占位发票前失败早退/注入旁路排除图片(inject 只收文本,诚实落受理守卫)。验证:cargo check -p devflow 0 error + feishu_channel 测试 23 passed(含新 image_mime_sniff_four_formats)。**待两件**:①换装生效(重启应用,等令);②飞书开放平台给应用补「获取与上传图片或文件资源」权限(im:message.resource:readonly,后台操作,未开通时下载失败会回可读错误卡)。**【10-11 追加:引用化存储改造(用户指令「下载或转文件存储引用,信息存储只存一个引用,模型使用按需转换」)】**:base64 内嵌版升级为落盘引用版——图片字节落盘 `%APPDATA%/top.1216.devflow/media/<chat_id>/<uuid>.<ext>`(save_media_file,按会话分目录,不主动 GC),消息只存 `file://` 引用(ContentPart.url,base64 空),发送请求时 df-ai 新模块 image_ref.rs 单点展开(`inline_local_image_part`:读盘→base64;缺失降级 Text「[图片文件缺失]」不炸请求)——openai_compat(map 链插入)+anthropic_compat(content_part_to_block 入口)两臂接入,provider 转换层单点覆盖全部请求路径(主会话/子代理/标题/压缩+历史重放),零契约变更(url 字段语义扩展)。桌面粘贴图 base64 内嵌同族膨胀,后续批接同一机制(登记待办)。验证:df-ai 全量 **578 passed 0 failed**(新增 image_ref 3 测+openai/anthropic file:// 端到端各 1)+devflow check 0 error+feishu_channel 23 passed。**【10-11 第二轮:媒体域全面重构完成(用户令「推」+补1-补7),全链落地】**:①media_store.rs 新模块(媒体域单一归属,防臃肿)——hash 去重落盘(sha256 前 16hex,同内容同文件)+阿里云 OSS 双写(PUT Header 签名手写,失败降级 file:// 不阻塞)+文件名=`<hash16>[_<清洗原始名>]<ext>`(u-claw safeAttachName 模式:hash 保去重+原始名保可读,路径穿越清洗);②下载防线(对齐 u-claw):3 次指数退避+120s 超时+半写残件不留盘;③spawn offload 统一收口(send+resend 两处,vision 判定后):桌面粘贴图 base64→落盘引用,SQLite 不进大 base64;④消息溯源 V52(ai_messages.source 列,MessageSource{channel,message_id,chat_id,sender}),spawn 第 11 参穿透,飞书传真实 msg_id(可跳原消息追溯,多端混发可区分);⑤出站 [交付] 回传(u-claw replyLocalFiles 同模式):报告写 [交付] 路径行→自动上传飞书(图片→图片消息/其余→文件消息)+敏感名拦截(.env/id_rsa/token 等 8 特征);⑥引用统一走 url 字段(https=OSS 跨设备可解析/file://=本机 image_ref 展开),不自造 scheme。验证:cargo check --workspace 0 error+df-storage 214+12 绿/df-ai 578 绿/devflow 832 绿(新增 media_store 4+delivery 3+V52 迁移)。**待**:换装生效+飞书后台 im:resource 权限+media-oss-config KV 手配(设置面板后续批)。
959. [ ] **占用期消息必达·收件箱续投(2026-10-11 u-claw 对照,补6)**:用户在 bot 任务占用期发的消息,devflow 现状=QUEUE_BUSY_MSG 拒绝+用户须重发;u-claw 已进化为 pending 收件箱落盘+scanner 自动续投+卡片原地刷新「已进入待投队列(无需重发)」——重试责任不在用户。修:受理守卫拒绝路径改落盘收件箱(ai_outbox 表 V51 已有,复用)+原地 PATCH 提示;空闲自动续投+结果回推。验收:任务占用期发消息不丢,任务完成自动处理并回推结果。
960. [ ] **连续消息聚合防抖「已收 N 条无补充即处理」(2026-10-11 u-claw 对照,补6)**:u-claw 连发消息聚合「📥 已收 %d 条,%ds 内无补充即开始处理」防抖后再统一处理;devflow 每条立即受理(连续消息第二条起被占用守卫拒)。修:飞书入站短窗防抖(聚合期间到达的同 chat 消息合并为一次任务,文本拼接+附件清单合并),配合 959 收件箱。设计先行(与队列/注入语义联动,防双头)。
961. [ ] **桌面轮次交互定位重定义(2026-10-11 用户拍板,补7 三条)**:①轨迹卡片收敛:除主回复气泡外交互收敛到一张「有动态+可折叠」的轨迹卡(现有轮次折叠类型),**修缺陷:正生成时卡片展开不了**;②轮次语义纠正:现「压缩后即新轮次」定位错——正确定义=**一次信息发起=一个任务轮次,中途补充信息(inject)仍属同一轮,全部完成后新发起才是新轮**;③轮次编号稳定化:现编号不稳定(刚是 5 一会下面又出现 1)——应固定为「该对话的第 1 轮/第 2 轮」持久编号(与消息 seq/轮边界锚点绑定,不随视图压缩/折叠漂移)。涉及:MessageList 折叠模型+轮次归属模型(压缩边界 vs 发起边界)+编号生成规则。设计先行,拆批实施。
+6
View File
@@ -82,6 +82,12 @@ futures-util = "0.3"
# 使用 rustls-tls(非 native-tls),避免 Windows SChannel 同步阻塞 tokio 工作线程 # 使用 rustls-tls(非 native-tls),避免 Windows SChannel 同步阻塞 tokio 工作线程
# 致 BUG-2026-07-17(aichat 流式调用永久 hang,外层 tokio timeout 亦无法推进计时器)。 # 致 BUG-2026-07-17(aichat 流式调用永久 hang,外层 tokio timeout 亦无法推进计时器)。
reqwest = { version = "0.12", default-features = false, features = ["json", "gzip", "brotli", "rustls-tls", "stream"] } reqwest = { version = "0.12", default-features = false, features = ["json", "gzip", "brotli", "rustls-tls", "stream"] }
# media_store(#958 引用化):hash 去重文件名(sha2+hex)+ 阿里云 OSS PUT 上传签名(hmac+sha1)
sha2 = "0.10"
hex = "0.4"
hmac = "0.12"
sha1 = "0.10"
httpdate = "1"
# fetch_url AI 工具:URL → markdown 文档嗅探(GET HTML → htmd 转 markdown → 去噪音 + 截断)。 # fetch_url AI 工具:URL → markdown 文档嗅探(GET HTML → htmd 转 markdown → 去噪音 + 截断)。
# 替代 http_request 拿原始 HTML(噪声大、爆 token)。turndown.js 移植,只读 GET,与 http_request 共享 SSRF 防护。 # 替代 http_request 拿原始 HTML(噪声大、爆 token)。turndown.js 移植,只读 GET,与 http_request 共享 SSRF 防护。
htmd = "0.5" htmd = "0.5"
+82 -21
View File
@@ -695,6 +695,7 @@ pub async fn ai_chat_send(
parts, parts,
mention_spans, mention_spans,
true, true,
None, // #958:标题/内部路径无外部来源
) )
.await .await
.map(|_| "ok".to_string()) .map(|_| "ok".to_string())
@@ -772,6 +773,8 @@ async fn reply_status_summary(
prompt_cache_miss_tokens: 0, prompt_cache_miss_tokens: 0,
reasoning_tokens: 0, reasoning_tokens: 0,
is_estimated: false, is_estimated: false,
// 零 token 收敛信号,非截断(看板 953;枚举新字段,Rust 字面量强制显式赋值)。
truncated_by_max_tokens: false,
incomplete: None, incomplete: None,
incomplete_reason: None, incomplete_reason: None,
conversation_id: Some(target), conversation_id: Some(target),
@@ -804,6 +807,8 @@ pub(crate) async fn spawn_agentic_session(
parts: Option<Vec<ContentPart>>, parts: Option<Vec<ContentPart>>,
mention_spans: Option<Vec<MentionSpanDto>>, mention_spans: Option<Vec<MentionSpanDto>>,
pin_active: bool, pin_active: bool,
// #958 溯源:外部渠道(飞书等)原始消息定位,None=桌面本端。随 user 消息落库
source: Option<df_ai::provider::MessageSource>,
) -> Result<String, String> { ) -> Result<String, String> {
// 获取活跃提供商(只读,失败可直接返回,不影响生成标志) // 获取活跃提供商(只读,失败可直接返回,不影响生成标志)
let provider_config = super::super::prompt::get_active_provider(state).await?; let provider_config = super::super::prompt::get_active_provider(state).await?;
@@ -820,6 +825,19 @@ pub(crate) async fn spawn_agentic_session(
other => (other, false), other => (other, false),
}; };
// offload(#958 引用化,vision 判定后):存活图片片 base64 → media_store 落盘引用
// (hash 去重,OSS 配置在则 https 引用跨设备可解析)。桌面粘贴图/前端上传在此统一收口
// ——消息只存引用,SQLite 不进大 base64,历史重放零负担。落盘失败保 base64 片不丢图。
// 在锁前做(与上方预拉同窗口,不持锁 I/O)
let parts = match parts {
Some(ps) if !ps.is_empty() => {
let oss_cfg = crate::media_store::read_oss_config(&state.db).await;
let store = crate::media_store::MediaStore::new(state.data_dir.clone(), oss_cfg);
Some(crate::media_store::offload_image_parts(ps, &store).await)
}
other => other,
};
// 纯发图(无文本无技能)且模型非视觉:降级后无任何有效内容,原「[图片已忽略…]」占位串 // 纯发图(无文本无技能)且模型非视觉:降级后无任何有效内容,原「[图片已忽略…]」占位串
// 会被当 user 消息喂模型(回复跑偏且浪费一轮 LLM 调用)。此处直接拒绝发送,错误经前端 // 会被当 user 消息喂模型(回复跑偏且浪费一轮 LLM 调用)。此处直接拒绝发送,错误经前端
// 发送失败通道透出;图文混合(有文本/技能)维持占位后缀现状。置于锁与消息 push 之前, // 发送失败通道透出;图文混合(有文本/技能)维持占位后缀现状。置于锁与消息 push 之前,
@@ -917,9 +935,13 @@ pub(crate) async fn spawn_agentic_session(
// F-260614-05 Phase 2c: parts 非空走 user_parts(多模态),空/None 走 user()(纯文本零回归)。 // F-260614-05 Phase 2c: parts 非空走 user_parts(多模态),空/None 走 user()(纯文本零回归)。
// user_parts 内部把 parts 挂到 ChatMessage.parts,provider 在 has_image() 为真时转 vision 端点。 // user_parts 内部把 parts 挂到 ChatMessage.parts,provider 在 has_image() 为真时转 vision 端点。
if let Some(ps) = parts.as_ref().filter(|p| !p.is_empty()) { if let Some(ps) = parts.as_ref().filter(|p| !p.is_empty()) {
conv.messages.push(ChatMessage::user_parts(&user_content, ps.clone())); let mut m = ChatMessage::user_parts(&user_content, ps.clone());
m.source = source.clone();
conv.messages.push(m);
} else { } else {
conv.messages.push(ChatMessage::user(&user_content)); let mut m = ChatMessage::user(&user_content);
m.source = source.clone();
conv.messages.push(m);
} }
// 新用户轮把本请求钉扎为 WorkingContext 常驻目标(截断 200 字符,与 agentic G1 目标钉扎 GOAL_MAX_CHARS 同量级)。 // 新用户轮把本请求钉扎为 WorkingContext 常驻目标(截断 200 字符,与 agentic G1 目标钉扎 GOAL_MAX_CHARS 同量级)。
// 喂原文 message 而非落库变换文本 user_content:begin_turn 契约是"本条用户请求", // 喂原文 message 而非落库变换文本 user_content:begin_turn 契约是"本条用户请求",
@@ -1105,6 +1127,7 @@ pub(crate) fn orchestration_session_spawner() -> df_nodes::ai_agent_node::SpawnF
None, None,
None, None,
false, false,
None, // #958:编排路径无外部来源
) )
.await .await
{ {
@@ -2267,6 +2290,16 @@ pub async fn ai_chat_force_send(
other => (other, false), other => (other, false),
}; };
// offload(#958 引用化,同 ai_chat_send):重发路径同样收口 base64→引用
let parts = match parts {
Some(ps) if !ps.is_empty() => {
let oss_cfg = crate::media_store::read_oss_config(&state.db).await;
let store = crate::media_store::MediaStore::new(state.data_dir.clone(), oss_cfg);
Some(crate::media_store::offload_image_parts(ps, &store).await)
}
other => other,
};
// 纯发图(无文本无技能)且模型非视觉:同 spawn_agentic_session 的拒绝闸(占位串不喂模型), // 纯发图(无文本无技能)且模型非视觉:同 spawn_agentic_session 的拒绝闸(占位串不喂模型),
// 置于锁与消息 push 之前,拒绝零副作用。 // 置于锁与消息 push 之前,拒绝零副作用。
if degraded && message.trim().is_empty() && skill.is_none() { if degraded && message.trim().is_empty() && skill.is_none() {
@@ -2400,25 +2433,6 @@ pub async fn ai_chat_force_send(
session.conv(&conv_id).stop_flag.store(false, std::sync::atomic::Ordering::SeqCst); session.conv(&conv_id).stop_flag.store(false, std::sync::atomic::Ordering::SeqCst);
} }
// 通知前端目标 conv 旧生成已结束(若先前在生成;emit 在锁外,避免持锁调 runtime emit)
if let Some(ref cid) = old_conv_id {
let ev = AiChatEvent::AiCompleted {
total_tokens: 0,
prompt_tokens: 0,
completion_tokens: 0,
prompt_cache_hit_tokens: 0,
prompt_cache_miss_tokens: 0,
reasoning_tokens: 0,
// 零 token 收敛信号,非估算(无真实 LLM 调用)。
is_estimated: false,
incomplete: None,
incomplete_reason: None,
conversation_id: Some(cid.clone()),
pinned_goals: old_pinned_goals.clone(),
};
emit_ai_chat_event(&app, ev);
}
// system prompt 构建(照 ai_chat_send):augmentation 注入 + 知识注入顺序一致。 // system prompt 构建(照 ai_chat_send):augmentation 注入 + 知识注入顺序一致。
let _tool_defs = state.ai_tools.tool_definitions(); let _tool_defs = state.ai_tools.tool_definitions();
let lang = language.unwrap_or_else(|| "zh-CN".to_string()); let lang = language.unwrap_or_else(|| "zh-CN".to_string());
@@ -2478,9 +2492,50 @@ pub async fn ai_chat_force_send(
sorted.first().map(|p| p.to_string_lossy().to_string()) sorted.first().map(|p| p.to_string_lossy().to_string())
}; };
// 看板 949 后端消竞态:旧任务终态 emit 挪到「新 loop 占用 generating 之后」。
// 原 emit 位于「复位+占用」与 spawn 之间:前端迟到收尾仲裁(useAiLifecycleEvents
// cleanupTerminatedConversation)收到旧 AiCompleted 时查 ai_is_generating,若新 loop 尚未
// spawn / 入口未迁移 Generating 会读到 false → 误判真收尾 → setStreaming(false),新 loop
// 内容照流(AiTextDelta 无条件累加)但发送按钮失态。此处 spawn 后短轮询等新 loop 入口
// GeneratingGuard 迁移 Generating(同步迁移,正常毫秒级),占用成功或 2s 到期后再 emit;
// 到期未占用也 emit(旧任务确已终局,前端按真收尾收敛语义仍正确,只是失去仲裁保护)。
let occupied_probe = conv_id.clone();
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, volatile_tail, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, conv_model_override, new_epoch, h3_watch_dir).await; run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, volatile_tail, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, conv_model_override, new_epoch, h3_watch_dir).await;
}); });
let occupied = {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
loop {
if state.conv_states.is_active(&occupied_probe) {
break true;
}
if std::time::Instant::now() >= deadline {
break false;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
};
tracing::debug!(conv_id = %occupied_probe, occupied, "[ai] force_send 旧终态 emit 前新 loop 占用检查");
// 通知前端目标 conv 旧生成已结束(若先前在生成;emit 在锁外,避免持锁调 runtime emit)
if let Some(ref cid) = old_conv_id {
let ev = AiChatEvent::AiCompleted {
total_tokens: 0,
prompt_tokens: 0,
completion_tokens: 0,
prompt_cache_hit_tokens: 0,
prompt_cache_miss_tokens: 0,
reasoning_tokens: 0,
// 零 token 收敛信号,非估算(无真实 LLM 调用)。
is_estimated: false,
// 零 token 收敛信号,非截断(看板 953;枚举新字段,Rust 字面量强制显式赋值)。
truncated_by_max_tokens: false,
incomplete: None,
incomplete_reason: None,
conversation_id: Some(cid.clone()),
pinned_goals: old_pinned_goals.clone(),
};
emit_ai_chat_event(&app, ev);
}
Ok("ok".to_string()) Ok("ok".to_string())
} }
@@ -2545,6 +2600,8 @@ pub async fn ai_chat_stop(
prompt_cache_hit_tokens: 0, prompt_cache_miss_tokens: 0, reasoning_tokens: 0, prompt_cache_hit_tokens: 0, prompt_cache_miss_tokens: 0, reasoning_tokens: 0,
// 零 token 收敛信号,非估算(无真实 LLM 调用)。 // 零 token 收敛信号,非估算(无真实 LLM 调用)。
is_estimated: false, is_estimated: false,
// 零 token 收敛信号,非截断(看板 953;枚举新字段,Rust 字面量强制显式赋值)。
truncated_by_max_tokens: false,
incomplete: None, incomplete: None,
incomplete_reason: None, incomplete_reason: None,
conversation_id: Some(target.clone()), conversation_id: Some(target.clone()),
@@ -2666,6 +2723,8 @@ pub async fn ai_chat_stop(
reasoning_tokens: 0, reasoning_tokens: 0,
// 零 token 收敛信号,非估算(无真实 LLM 调用)。 // 零 token 收敛信号,非估算(无真实 LLM 调用)。
is_estimated: false, is_estimated: false,
// 零 token 收敛信号,非截断(看板 953;枚举新字段,Rust 字面量强制显式赋值)。
truncated_by_max_tokens: false,
incomplete: None, incomplete: None,
incomplete_reason: None, incomplete_reason: None,
conversation_id: conv_id.clone(), conversation_id: conv_id.clone(),
@@ -2864,6 +2923,8 @@ pub async fn ai_stop_loop(
reasoning_tokens: 0, reasoning_tokens: 0,
// 零 token 收敛信号,非估算(无真实 LLM 调用)。 // 零 token 收敛信号,非估算(无真实 LLM 调用)。
is_estimated: false, is_estimated: false,
// 零 token 收敛信号,非截断(看板 953;枚举新字段,Rust 字面量强制显式赋值)。
truncated_by_max_tokens: false,
incomplete: None, incomplete: None,
incomplete_reason: None, incomplete_reason: None,
conversation_id: Some(conversation_id), conversation_id: Some(conversation_id),
@@ -79,6 +79,11 @@ pub fn record_to_message(rec: &AiMessageRecord) -> ChatMessage {
.as_deref() .as_deref()
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.and_then(|s| serde_json::from_str(s).ok()); .and_then(|s| serde_json::from_str(s).ok());
let source = rec
.source
.as_deref()
.filter(|s| !s.is_empty())
.and_then(|s| serde_json::from_str(s).ok());
let tool_calls = rec let tool_calls = rec
.tool_calls .tool_calls
.as_deref() .as_deref()
@@ -107,6 +112,7 @@ pub fn record_to_message(rec: &AiMessageRecord) -> ChatMessage {
prompt_cache_miss_tokens: rec.prompt_cache_miss_tokens, prompt_cache_miss_tokens: rec.prompt_cache_miss_tokens,
reasoning_tokens: rec.reasoning_tokens, reasoning_tokens: rec.reasoning_tokens,
is_estimated: rec.is_estimated, is_estimated: rec.is_estimated,
source,
} }
} }
@@ -137,6 +143,10 @@ pub fn message_to_record(
.as_ref() .as_ref()
.map(|s| s.as_db_str().to_string()) .map(|s| s.as_db_str().to_string())
.unwrap_or_else(|| "active".to_string()); .unwrap_or_else(|| "active".to_string());
let source = msg
.source
.as_ref()
.map(|s| serde_json::to_string(s).unwrap_or_default());
AiMessageRecord { AiMessageRecord {
id, id,
conversation_id: conversation_id.to_string(), conversation_id: conversation_id.to_string(),
@@ -157,6 +167,7 @@ pub fn message_to_record(
prompt_cache_miss_tokens: msg.prompt_cache_miss_tokens, prompt_cache_miss_tokens: msg.prompt_cache_miss_tokens,
reasoning_tokens: msg.reasoning_tokens, reasoning_tokens: msg.reasoning_tokens,
is_estimated: msg.is_estimated, is_estimated: msg.is_estimated,
source,
} }
} }
@@ -364,6 +375,7 @@ pub(crate) async fn recover_parked_inner(
None, // parts:纯文本 None, // parts:纯文本
None, // mention_spans None, // mention_spans
true, // pin_active:恢复动作即用户当前意图 true, // pin_active:恢复动作即用户当前意图
None, // #958:恢复路径无外部来源
) )
.await .await
} }
@@ -922,6 +934,8 @@ pub async fn ai_conversation_delete(
let zero_usage = df_ai::provider::TokenUsage::default(); let zero_usage = df_ai::provider::TokenUsage::default();
crate::commands::ai::agentic::emit_ai_completed_once( crate::commands::ai::agentic::emit_ai_completed_once(
&app, &conversation_id, &zero_usage, false, &app, &conversation_id, &zero_usage, false,
// 会话删除终态补发,非截断(看板 953)。
false,
Some(true), None, Some("deleted"), true, Some(true), None, Some("deleted"), true,
&[], false, &[], false,
).await; ).await;
@@ -1176,6 +1190,7 @@ mod tests {
id: Some("msg_test_1".into()), id: Some("msg_test_1".into()),
role: MessageRole::User, role: MessageRole::User,
content: "你好".into(), content: "你好".into(),
source: None,
parts: None, parts: None,
tool_call_id: None, tool_call_id: None,
tool_calls: None, tool_calls: None,
@@ -788,6 +788,7 @@ mod tests {
id: None, id: None,
role, role,
content: content.to_string(), content: content.to_string(),
source: None,
parts: None, parts: None,
tool_call_id: None, tool_call_id: None,
tool_calls: None, tool_calls: None,
+372 -10
View File
@@ -10,8 +10,9 @@
//! - 单会话单轮一条:同会话同轮只派一条(串行保序),会话忙则本轮跳过(不丢,下轮再取) //! - 单会话单轮一条:同会话同轮只派一条(串行保序),会话忙则本轮跳过(不丢,下轮再取)
//! - 开关默认关:未配置 `df-outbox-enabled` 时循环只读配置空转,零行为 //! - 开关默认关:未配置 `df-outbox-enabled` 时循环只读配置空转,零行为
//! //!
//! ⚠️ P0 地基:调度器与派发链已就位,但 **未接线**(lib.rs setup 挂载 + agentic 收敛出口钩子), //! ⚠️ P0 地基 + P1 IPC 命令族均已就位,但 **未接线**(lib.rs setup 挂载 + generate_handler 注册
//! 故本模块暂整体 allow(dead_code)。接线清单见交付说明,不在此文件内改 lib.rs。 //! + agentic 收敛出口钩子),故本模块暂整体 allow(dead_code)。接线清单见交付说明,
//! 不在此文件内改 lib.rs。
//! //!
//! 配置面(Settings KV,通用 settings_get/settings_set IPC 可读写): //! 配置面(Settings KV,通用 settings_get/settings_set IPC 可读写):
//! - `df-outbox-enabled` bool("true"/"1" 为开),默认 **false**(地基默认关,可观测空转) //! - `df-outbox-enabled` bool("true"/"1" 为开),默认 **false**(地基默认关,可观测空转)
@@ -23,10 +24,11 @@ use std::collections::HashSet;
use std::sync::OnceLock; use std::sync::OnceLock;
use std::time::Duration; use std::time::Duration;
use tauri::{AppHandle, Emitter, Manager}; use tauri::{AppHandle, Emitter, Manager, State};
use df_ai::provider::ContentPart; use df_ai::provider::ContentPart;
use df_storage::crud::OutboxRepo; use df_storage::crud::{OutboxRepo, STATUS_CANCELED, STATUS_FAILED, STATUS_PENDING, STATUS_SENDING, STATUS_SENT};
use df_storage::models::AiOutboxRecord;
use df_types::augmentation::MentionSpanDto; use df_types::augmentation::MentionSpanDto;
use crate::commands::ai::commands::chat::spawn_agentic_session; use crate::commands::ai::commands::chat::spawn_agentic_session;
@@ -195,11 +197,33 @@ async fn run_tick(app: &AppHandle, cfg: &OutboxConfig) {
continue; continue;
} }
} }
// 派发 + 结果落状态收口在 dispatch_claimed(调度循环与 send_now 共用一条派发链)。
if dispatch_claimed(app, &state, &repo, cfg.max_attempts, item)
.await
.is_ok()
{
dispatched.insert(conv);
}
}
}
/// 派发单条已 claim 的项:spawn 正典链 + 按受理结果落状态。调度循环(run_tick)与
/// 立即派发(ai_outbox_send_now)共用,保证两条入口的失败分流/事件/通知语义一致。
///
/// Ok=受理成功(已 mark_sent);Err=受理失败的原始错误串(状态已按永久/瞬时落好)。
async fn dispatch_claimed(
app: &AppHandle,
state: &AppState,
repo: &OutboxRepo,
max_attempts: i64,
item: AiOutboxRecord,
) -> Result<String, String> {
let conv = item.conversation_id.clone();
let parts = parse_parts(item.parts_json.as_deref()); let parts = parse_parts(item.parts_json.as_deref());
let spans = parse_spans(item.spans_json.as_deref()); let spans = parse_spans(item.spans_json.as_deref());
let result = spawn_agentic_session( let result = spawn_agentic_session(
app, app,
&state, state,
item.text.clone(), item.text.clone(),
None, // language 沿用默认 None, // language 沿用默认
item.skill.clone(), // /技能名 item.skill.clone(), // /技能名
@@ -208,16 +232,17 @@ async fn run_tick(app: &AppHandle, cfg: &OutboxConfig) {
parts, parts,
spans, spans,
false, // pin_active=false:不劫持用户活跃面板 false, // pin_active=false:不劫持用户活跃面板
None, // #958:待发箱消息原桌面发起(来源已在原消息),续投不重标
) )
.await; .await;
match result { match result {
Ok(_conv_id) => { Ok(conv_id) => {
// Ok=受理成功即终态:轮次内失败(流断/工具错)由既有 loop 自愈,不回流 outbox。 // Ok=受理成功即终态:轮次内失败(流断/工具错)由既有 loop 自愈,不回流 outbox。
let _ = repo.mark_sent(&item.id).await; let _ = repo.mark_sent(&item.id).await;
dispatched.insert(conv.clone());
emit(app, "ai-outbox-sent", &item.id, &conv, None, None); emit(app, "ai-outbox-sent", &item.id, &conv, None, None);
emit(app, "ai-outbox-changed", &item.id, &conv, None, None); emit(app, "ai-outbox-changed", &item.id, &conv, None, None);
tracing::info!("[outbox] 派发 {} → 会话 {conv}", item.id); tracing::info!("[outbox] 派发 {} → 会话 {conv}", item.id);
Ok(conv_id)
} }
Err(err) => { Err(err) => {
if is_permanent_spawn_error(&err) { if is_permanent_spawn_error(&err) {
@@ -234,7 +259,7 @@ async fn run_tick(app: &AppHandle, cfg: &OutboxConfig) {
} else { } else {
// 瞬时失败:回退 pending 保留下轮再试(网络超时/会话忙竞态天然重试)。 // 瞬时失败:回退 pending 保留下轮再试(网络超时/会话忙竞态天然重试)。
let attempts = repo.revert_pending(&item.id, Some(&err)).await.unwrap_or(0); let attempts = repo.revert_pending(&item.id, Some(&err)).await.unwrap_or(0);
if attempts >= cfg.max_attempts { if attempts >= max_attempts {
let _ = repo.mark_failed(&item.id, &err).await; let _ = repo.mark_failed(&item.id, &err).await;
emit(app, "ai-outbox-failed", &item.id, &conv, Some(&err), Some(false)); emit(app, "ai-outbox-failed", &item.id, &conv, Some(&err), Some(false));
notify_session_event( notify_session_event(
@@ -253,7 +278,7 @@ async fn run_tick(app: &AppHandle, cfg: &OutboxConfig) {
); );
} }
} }
} Err(err)
} }
} }
} }
@@ -308,7 +333,283 @@ fn emit(
} }
// ============================================================ // ============================================================
// 单测(纯函数:配置解析/永久失败判据) // IPC 命令族(P1 应用层)
// ⚠️ 未注册:generate_handler 接线清单见交付说明(不在此文件内改 lib.rs)。
// Repo 经 State<AppState> 现构(与调度器 outbox_repo() 同源,最小改动不加 state 字段)。
// ============================================================
/// IPC 返回的待发项视图。字段与 `AiOutboxRecord` 同形(snake_case 原样透传,
/// 前端 TS interface 按同名对齐),仅收窄为对外可见面(隐去 parts/spans 原始 JSON)。
#[derive(Debug, Clone, serde::Serialize)]
pub struct AiOutboxItem {
pub id: String,
pub conversation_id: String,
pub kind: String,
pub text: String,
pub skill: Option<String>,
pub model_override: Option<String>,
pub due_at: Option<i64>,
pub status: String,
pub attempts: i64,
pub last_error: Option<String>,
pub created_at: i64,
pub updated_at: i64,
pub sent_at: Option<i64>,
}
impl From<AiOutboxRecord> for AiOutboxItem {
fn from(r: AiOutboxRecord) -> Self {
Self {
id: r.id,
conversation_id: r.conversation_id,
kind: r.kind,
text: r.text,
skill: r.skill,
model_override: r.model_override,
due_at: r.due_at,
status: r.status,
attempts: r.attempts,
last_error: r.last_error,
created_at: r.created_at,
updated_at: r.updated_at,
sent_at: r.sent_at,
}
}
}
// ── 参数校验(纯函数,单测直接覆盖;命令体只做校验→落库→事件三步) ──
/// text 非空(enqueue / update_text 共用;空白串视为空)。
fn validate_text(text: &str) -> Result<(), String> {
if text.trim().is_empty() {
return Err("消息内容不能为空".to_string());
}
Ok(())
}
/// conversation_id 非空(设计 §3.1 目标会话 NOT NULL,composer 处确定,后端不猜 active)。
fn validate_conv_id(conv: &str) -> Result<(), String> {
if conv.trim().is_empty() {
return Err("目标会话不能为空".to_string());
}
Ok(())
}
/// enqueue 的 due_at 必须严格晚于当前时刻(定时项到点即发,过去/当下时刻无意义)。
/// 注意:ai_outbox_reschedule **不做**此限制——改期到过去=「下轮立即派发」语义,属合法操作。
fn validate_due_at_future(due_at: i64, now_ms: i64) -> Result<(), String> {
if due_at <= now_ms {
return Err(format!("定时时间必须晚于当前时刻(due_at={due_at}, now={now_ms})"));
}
Ok(())
}
/// kind 由 due_at 有无唯一推导(queue=尽快 / scheduled=定时),单一推导点防裸串漂移。
fn kind_for_due(due_at: Option<i64>) -> &'static str {
if due_at.is_some() {
df_storage::crud::KIND_SCHEDULED
} else {
df_storage::crud::KIND_QUEUE
}
}
/// send_now 状态守卫:pending 直接派、failed 先重入队再派;在途/终态拒绝并说明原因。
fn ensure_sendable(status: &str) -> Result<(), String> {
match status {
STATUS_PENDING | STATUS_FAILED => Ok(()),
STATUS_SENDING => Err("该项正在发送中,请稍后".to_string()),
STATUS_SENT => Err("该项已发送成功,无需重复发送".to_string()),
STATUS_CANCELED => Err("该项已取消,无法发送".to_string()),
other => Err(format!("未知状态,拒绝派发: {other}")),
}
}
/// cancel 状态守卫:sent(已发出去的消息)不可「取消」,canceled 幂等拒绝;其余可取消。
fn ensure_cancellable(status: &str) -> Result<(), String> {
match status {
STATUS_SENT => Err("该项已发送成功,无法取消".to_string()),
STATUS_CANCELED => Err("该项已处于取消状态".to_string()),
_ => Ok(()),
}
}
/// 入队一条待发/定时消息。`due_at`=Some(epoch ms,必须未来)→ 定时项;None → 尽快队列项。
/// 返回新项 id。成功即唤醒调度器(尽快项可被立刻取走)并 emit ai-outbox-changed。
#[tauri::command]
pub async fn ai_outbox_enqueue(
app: AppHandle,
state: State<'_, AppState>,
text: String,
due_at: Option<i64>,
skill: Option<String>,
model_override: Option<String>,
conversation_id: String,
) -> Result<String, String> {
validate_text(&text)?;
validate_conv_id(&conversation_id)?;
let now = df_types::now_millis();
if let Some(due) = due_at {
validate_due_at_future(due, now)?;
}
let id = df_types::types::new_id();
let record = AiOutboxRecord {
id: id.clone(),
conversation_id: conversation_id.clone(),
kind: kind_for_due(due_at).to_string(),
text,
skill,
model_override,
// P1 定时入口纯文本;图片项待后续(parts 落库链已就绪,enqueue 参数面再扩)。
parts_json: None,
spans_json: None,
due_at,
status: STATUS_PENDING.to_string(),
attempts: 0,
last_error: None,
// created_at/updated_at 由 Repo.insert 以落库时刻覆盖,此处仅占位。
created_at: now,
updated_at: now,
sent_at: None,
};
OutboxRepo::new(&state.db)
.insert(record)
.await
.map_err(|e| format!("待发项写入失败: {e}"))?;
// 尽快项/到期项即时唤醒调度器(周期 tick 仅兜底);事件供各窗口视图刷新。
notify().notify_one();
emit(&app, "ai-outbox-changed", &id, &conversation_id, None, None);
Ok(id)
}
/// 列出待发项。`conv_id`=Some → 该会话;None → 跨会话全量(定时列表抽屉数据源)。
/// 恒含终态(前端显 pending|sent|failed|canceled 状态徽标)。
#[tauri::command]
pub async fn ai_outbox_list(
state: State<'_, AppState>,
conv_id: Option<String>,
) -> Result<Vec<AiOutboxItem>, String> {
let repo = OutboxRepo::new(&state.db);
let records = match conv_id {
Some(conv) if !conv.trim().is_empty() => repo.list_by_conv(&conv, true).await,
_ => repo.list_all(true).await,
};
let records = records.map_err(|e| format!("待发项查询失败: {e}"))?;
Ok(records.into_iter().map(AiOutboxItem::from).collect())
}
/// 取消待发项。已发送/已取消的项拒绝(幂等不假报成功)。
#[tauri::command]
pub async fn ai_outbox_cancel(
app: AppHandle,
state: State<'_, AppState>,
id: String,
) -> Result<bool, String> {
let repo = OutboxRepo::new(&state.db);
let item = repo
.get_by_id(&id)
.await
.map_err(|e| format!("待发项查询失败: {e}"))?
.ok_or_else(|| format!("待发项不存在: {id}"))?;
ensure_cancellable(&item.status)?;
let ok = repo.cancel(&id).await.map_err(|e| format!("取消失败: {e}"))?;
if ok {
emit(&app, "ai-outbox-changed", &id, &item.conversation_id, None, None);
}
Ok(ok)
}
/// 改期。允许过去时刻(=下轮立即派发语义,与 enqueue 的未来约束不同,见 validate_due_at_future 注)。
#[tauri::command]
pub async fn ai_outbox_reschedule(
app: AppHandle,
state: State<'_, AppState>,
id: String,
due_at: i64,
) -> Result<bool, String> {
let repo = OutboxRepo::new(&state.db);
let item = repo
.get_by_id(&id)
.await
.map_err(|e| format!("待发项查询失败: {e}"))?
.ok_or_else(|| format!("待发项不存在: {id}"))?;
let ok = repo
.reschedule(&id, Some(due_at))
.await
.map_err(|e| format!("改期失败: {e}"))?;
if ok {
// 改期到已到点的新时刻可被立即取走,唤醒调度器。
notify().notify_one();
emit(&app, "ai-outbox-changed", &id, &item.conversation_id, None, None);
}
Ok(ok)
}
/// 编辑待发文本。终态(sent/canceled)项拒绝——历史记录不可改写。
#[tauri::command]
pub async fn ai_outbox_update_text(
app: AppHandle,
state: State<'_, AppState>,
id: String,
text: String,
) -> Result<bool, String> {
validate_text(&text)?;
let repo = OutboxRepo::new(&state.db);
let item = repo
.get_by_id(&id)
.await
.map_err(|e| format!("待发项查询失败: {e}"))?
.ok_or_else(|| format!("待发项不存在: {id}"))?;
if item.status == STATUS_SENT || item.status == STATUS_CANCELED {
return Err(format!("该项已终态({}),不可编辑", item.status));
}
let ok = repo
.update_text(&id, &text)
.await
.map_err(|e| format!("编辑失败: {e}"))?;
if ok {
emit(&app, "ai-outbox-changed", &id, &item.conversation_id, None, None);
}
Ok(ok)
}
/// 立即派发一次:状态守卫 →(failed 先重入队)→ CAS claim → 走与调度器同一条派发链
/// (dispatch_claimed,失败分流/事件/通知语义完全一致)。
/// 显式用户动作,不受 `df-outbox-enabled` 开关约束(开关只管后台自动调度)。
#[tauri::command]
pub async fn ai_outbox_send_now(
app: AppHandle,
state: State<'_, AppState>,
id: String,
) -> Result<(), String> {
let repo = OutboxRepo::new(&state.db);
let item = repo
.get_by_id(&id)
.await
.map_err(|e| format!("待发项查询失败: {e}"))?
.ok_or_else(|| format!("待发项不存在: {id}"))?;
ensure_sendable(&item.status)?;
if item.status == STATUS_FAILED {
let requeued = repo.requeue(&id).await.map_err(|e| format!("重入队失败: {e}"))?;
if !requeued {
return Err("重入队失败(状态已变化),请刷新后重试".to_string());
}
}
if !repo.claim(&id).await.map_err(|e| format!("占取失败: {e}"))? {
return Err("该项已被调度占取,请稍后刷新".to_string());
}
// 占取后重取最新记录(sending 态),交给共用派发链落状态。
let fresh = repo
.get_by_id(&id)
.await
.map_err(|e| format!("待发项查询失败: {e}"))?
.ok_or_else(|| format!("待发项不存在: {id}"))?;
let max_attempts = read_config(&app).await.max_attempts;
dispatch_claimed(&app, &state, &repo, max_attempts, fresh).await?;
Ok(())
}
// ============================================================
// 单测(纯函数:配置解析/永久失败判据/命令参数校验与错误映射)
// ============================================================ // ============================================================
#[cfg(test)] #[cfg(test)]
@@ -368,4 +669,65 @@ mod tests {
fn status_constant_single_source() { fn status_constant_single_source() {
assert_eq!(df_storage::crud::STATUS_PENDING, "pending"); assert_eq!(df_storage::crud::STATUS_PENDING, "pending");
} }
// ── P1 IPC 命令族:参数校验与错误映射(命令体的纯函数面) ──
/// enqueue/update_text 的 text 校验:空白串拒绝且错误信息明确,非空放行。
#[test]
fn validate_text_rejects_blank_with_message() {
assert!(validate_text("").is_err());
assert!(validate_text(" \n\t ").is_err());
assert!(validate_text("跑一下构建").is_ok());
let err = validate_text(" ").unwrap_err();
assert!(err.contains("不能为空"), "错误信息应可直读: {err}");
}
/// enqueue 的 conversation_id 校验:空/空白拒绝(目标会话 NOT NULL,后端不猜 active)。
#[test]
fn validate_conv_id_rejects_blank() {
assert!(validate_conv_id("").is_err());
assert!(validate_conv_id(" ").is_err());
assert!(validate_conv_id("conv-abc").is_ok());
}
/// enqueue 的 due_at 校验:过去/当下时刻拒绝,严格未来放行;
/// reschedule 特意不走此校验(过去时刻=立即派发语义),故仅 enqueue 面约束。
#[test]
fn validate_due_at_future_rejects_past_and_present() {
let now = 1_000_000;
assert!(validate_due_at_future(now - 1, now).is_err(), "过去时刻拒绝");
assert!(validate_due_at_future(now, now).is_err(), "当下时刻(非严格未来)拒绝");
assert!(validate_due_at_future(now + 1, now).is_ok());
let err = validate_due_at_future(42, 43).unwrap_err();
assert!(err.contains("必须晚于当前时刻"), "错误信息应可直读: {err}");
}
/// kind 单一推导点:due_at 有无唯一决定 scheduled/queue(防两处裸串漂移)。
#[test]
fn kind_for_due_maps_by_presence() {
assert_eq!(kind_for_due(Some(9_999_999_999_999)), df_storage::crud::KIND_SCHEDULED);
assert_eq!(kind_for_due(None), df_storage::crud::KIND_QUEUE);
}
/// send_now 状态守卫错误映射:pending/failed 可派;在途与各终态逐一拒绝且信息可直读。
#[test]
fn sendable_guard_maps_status_errors() {
assert!(ensure_sendable(STATUS_PENDING).is_ok());
assert!(ensure_sendable(STATUS_FAILED).is_ok(), "failed 经 requeue 后可手动重派");
assert!(ensure_sendable(STATUS_SENDING).is_err());
assert!(ensure_sendable(STATUS_SENT).is_err());
assert!(ensure_sendable(STATUS_CANCELED).is_err());
assert!(ensure_sendable("bogus").is_err(), "未知状态保守拒绝");
assert!(ensure_sendable(STATUS_SENT).unwrap_err().contains("已发送成功"));
}
/// cancel 状态守卫:sent 不可取消(消息已出)、canceled 幂等拒绝;pending/failed/sending 可取消。
#[test]
fn cancellable_guard_blocks_terminal_only() {
assert!(ensure_cancellable(STATUS_PENDING).is_ok());
assert!(ensure_cancellable(STATUS_SENDING).is_ok(), "在途也允许取消意图(取消落 canceled)");
assert!(ensure_cancellable(STATUS_FAILED).is_ok(), "失败项可放弃取消");
assert!(ensure_cancellable(STATUS_SENT).is_err());
assert!(ensure_cancellable(STATUS_CANCELED).is_err());
}
} }
+1
View File
@@ -78,6 +78,7 @@ pub(crate) async fn ensure_conversation_title(
id: None, // 标题摘要派生消息:不落库不溯源,无需分配 ID(对齐老消息 None 语义) id: None, // 标题摘要派生消息:不落库不溯源,无需分配 ID(对齐老消息 None 语义)
role: m.role.clone(), role: m.role.clone(),
content: m.content.clone(), content: m.content.clone(),
source: None,
parts: None, parts: None,
tool_call_id: None, tool_call_id: None,
tool_calls: None, tool_calls: None,
+2
View File
@@ -1300,6 +1300,7 @@ async fn recover_one_parked(app: &AppHandle) -> Result<String, String> {
None, None,
None, None,
false, false,
None, // #958:引擎路径无外部来源
) )
.await .await
.map(|_| conv_id) .map(|_| conv_id)
@@ -1363,6 +1364,7 @@ async fn claim_and_spawn(app: &AppHandle, cfg: &EngineConfig) -> Result<(), Stri
None, None,
None, None,
false, // pin_active=false 不劫持活跃面板 false, // pin_active=false 不劫持活跃面板
None, // #958:引擎路径无外部来源
) )
.await .await
{ {
+1
View File
@@ -231,6 +231,7 @@ async fn fire(app: &AppHandle, entry: &WakeEntry) -> bool {
None, None,
None, None,
false, // pin_active=false:唤醒不劫持用户活跃面板 false, // pin_active=false:唤醒不劫持用户活跃面板
None, // #958:唤醒路径无外部来源
) )
.await .await
{ {
+357 -9
View File
@@ -22,6 +22,7 @@ use serde::Deserialize;
use df_storage::crud::SettingsRepo; use df_storage::crud::SettingsRepo;
use df_types::types::new_id; use df_types::types::new_id;
use df_ai::provider::ContentPart;
/// app_settings 键:飞书渠道配置(与设置面板 FeishuPanel 同键契约) /// app_settings 键:飞书渠道配置(与设置面板 FeishuPanel 同键契约)
const KV_FEISHU_CONFIG: &str = "feishu-config"; const KV_FEISHU_CONFIG: &str = "feishu-config";
@@ -371,6 +372,103 @@ fn parse_command(text: &str) -> Option<(String, String)> {
Some((cmd, tail)) Some((cmd, tail))
} }
/// 图片消息喂模型的任务语境(#958):飞书 image 为独立消息类型无法带文字,
/// 用引导词让 vision 模型知道「这是用户发来的图,按图作答」
const IMAGE_INBOUND_PROMPT: &str = "(用户通过飞书发来一张图片,请查看图片并按图片内容处理)";
/// 图片入站大小上限(#958):base64 转换膨胀 4/3,vision 端点普遍接受 ≤20MB 内嵌图,
/// 10MB 原图留足余量;超限引导用户压缩,防超大图烧 token 与转码内存
const MAX_IMAGE_BYTES: usize = 10 * 1024 * 1024;
/// 下载飞书图片消息资源并落盘,返回本地引用 ContentPart(#958 引用化存储)。
/// 存储面只存 `file://` 引用(base64 不进 SQLite,防大图随历史全量重放膨胀),
/// 发送请求时由 df-ai::image_ref 单点按需读盘转 base64(模型使用按需转换)。
/// 走 channel_messaging 便捷方法(与 reply/send 同风格),租户 token 由 client 自管。
/// media_type 由字节魔数判定(下载响应无可靠 MIME;飞书图床为 png/jpeg/webp/gif),
/// 未知格式拒收——错误 media_type 会被 vision 端点 400,宁早拒不晚炸。
async fn download_feishu_image(
client: &LarkClient,
store: &crate::media_store::MediaStore,
message_id: &str,
file_key: &str,
) -> Result<ContentPart, String> {
// 下载带防线(对齐 u-claw fetchResource 经验):3 次指数退避(1s/4s)+单次 120s 超时
// ——飞书资源接口偶发抖动,无重试时用户收到一次性失败;超时防接口挂起拖死受理链。
// 空响应与错误同待遇进重试(可能为服务端瞬时异常),耗尽后报最后一次原因
let resp = {
let mut last_err = String::from("下载失败: 未知错误");
let mut out = None;
for attempt in 0u32..3 {
if attempt > 0 {
tokio::time::sleep(std::time::Duration::from_secs(1 << (attempt - 1))).await;
}
let dl = tokio::time::timeout(
std::time::Duration::from_secs(120),
client.channel_messaging().download_message_resource(
message_id,
file_key,
"image",
&RequestOption::default(),
),
)
.await;
match dl {
Ok(Ok(r)) if !r.data.is_empty() => {
out = Some(r);
break;
}
Ok(Ok(_)) => last_err = "图片内容为空".into(),
Ok(Err(e)) => last_err = format!("下载失败: {e}"),
Err(_) => last_err = "下载超时(120s)".into(),
}
}
match out {
Some(r) => r,
None => return Err(last_err),
}
};
if resp.data.len() > MAX_IMAGE_BYTES {
return Err(format!(
"图片超过 10MB 上限(约 {}MB),请压缩后重发",
resp.data.len() / 1024 / 1024
));
}
let media_type = sniff_image_mime(&resp.data)
.ok_or_else(|| "不支持的图片格式(仅支持 png/jpeg/webp/gif)".to_string())?;
// 存储决策收口 media_store:hash 去重落盘,OSS 配置在则双写并引用 https(跨设备可解析),
// 失败降级本机 file://(不阻塞)。本函数只管飞书协议(下载+防线),不管存储。
// 飞书 image 消息无原始文件名(None=纯 hash 名)
let url = store.save(&resp.data, media_type, None).await?;
Ok(ContentPart::Image {
url: Some(url),
base64: None,
media_type: Some(media_type.into()),
alt: None,
})
}
/// 读取媒体 OSS 配置(KV `media-oss-config`;未配置/解析失败 → None=纯本地模式)
async fn read_media_oss_config(
db: &df_storage::db::Database,
) -> Option<crate::media_store::OssConfig> {
crate::media_store::read_oss_config(db).await
}
/// 图片格式魔数判定(#958):PNG/JPEG/GIF/WEBP 四态,与飞书图床常见格式对齐
fn sniff_image_mime(b: &[u8]) -> Option<&'static str> {
if b.starts_with(&[0x89, b'P', b'N', b'G']) {
Some("image/png")
} else if b.starts_with(&[0xFF, 0xD8, 0xFF]) {
Some("image/jpeg")
} else if b.starts_with(b"GIF8") {
Some("image/gif")
} else if b.len() > 12 && b.starts_with(b"RIFF") && &b[8..12] == b"WEBP" {
Some("image/webp")
} else {
None
}
}
/// 入站文本:群聊需 @bot;白名单校验;/new 重置;其余走 ai-chat 会话链路 /// 入站文本:群聊需 @bot;白名单校验;/new 重置;其余走 ai-chat 会话链路
async fn handle_message( async fn handle_message(
ctx: Arc<Ctx>, ctx: Arc<Ctx>,
@@ -378,23 +476,47 @@ async fn handle_message(
) { ) {
// D3:任何入站消息都是事件管道存活证据,先于一切过滤更新活性时间戳 // D3:任何入站消息都是事件管道存活证据,先于一切过滤更新活性时间戳
ctx.last_alive.store(now_secs(), Ordering::SeqCst); ctx.last_alive.store(now_secs(), Ordering::SeqCst);
if msg.message_type != "text" { // 类型分流(#958 图片入站):text 原样;image 走下载装配(vision ContentPart);
// #25 非文本消息静默丢修复:回提示卡(D7 媒体能力前的兜底),不再零反馈。 // 其余类型维持 #25 提示卡。image 消息 SDK 归一化已把 image_key 放入
// 补替代路径引导:用户发图/文件往往是想让 AI 处理内容,直接告知改用文字描述 // msg.resources(ChannelResource),无需自行解析 content JSON。
let image_key = if msg.message_type == "image" {
match msg.resources.iter().find(|r| r.resource_type == "image") {
Some(r) => Some(r.file_key.clone()),
None => {
let _ = reply_markdown_card( let _ = reply_markdown_card(
&ctx.client, &ctx.client,
&msg.message_id, &msg.message_id,
"📎 暂仅支持文本消息,图片/文件等媒体能力规划中。\n\n如需处理图片/文件内容,请用文字描述你的需求(如「把这段文字翻译成英文:…」)。", "⚠️ 图片消息缺少 image_key,无法处理。",
) )
.await; .await;
return; return;
} }
}
} else if msg.message_type != "text" {
// #25 非文本消息静默丢修复:回提示卡(D7 媒体能力前的兜底),不再零反馈。
// 补替代路径引导:用户发文件等往往是想让 AI 处理内容,直接告知改用文字描述
let _ = reply_markdown_card(
&ctx.client,
&msg.message_id,
"📎 暂支持文本与图片消息,文件/语音等其他类型规划中。\n\n如需处理文件内容,请用文字描述你的需求(如「把这段文字翻译成英文:…」)。",
)
.await;
return;
} else {
None
};
if msg.chat_type == "group" && !msg.mentioned_bot { if msg.chat_type == "group" && !msg.mentioned_bot {
return; return;
} }
// 图片消息不带文字(飞书 image 为独立消息类型),喂固定引导词交代任务语境;
// msg.text 的 markdown 图链([图片](image_key))对模型无意义,不喂
let text = if image_key.is_some() {
IMAGE_INBOUND_PROMPT.to_string()
} else {
let text = msg.text.clone().unwrap_or_default().trim().to_string(); let text = msg.text.clone().unwrap_or_default().trim().to_string();
// 飞书 @ 占位清洗(如 @_user_1):@ 语义已由 mentioned_bot 承载,占位串只污染文本 // 飞书 @ 占位清洗(如 @_user_1):@ 语义已由 mentioned_bot 承载,占位串只污染文本
let text = strip_mention_placeholders(&text); strip_mention_placeholders(&text)
};
if text.is_empty() { if text.is_empty() {
return; return;
} }
@@ -427,6 +549,30 @@ async fn handle_message(
return; return;
} }
// 图片入站(#958):下载消息资源→落盘→引用片。置于白名单后(未授权不下载,
// 不替白名单外用户烧流量)、占位发票前(失败早退,无占位卡需原地更新)
let parts = match &image_key {
Some(key) => {
let state = ctx.app.state::<crate::state::AppState>();
let oss_cfg = read_media_oss_config(&state.db).await;
let store = crate::media_store::MediaStore::new(state.data_dir.clone(), oss_cfg);
match download_feishu_image(&ctx.client, &store, &message_id, key).await {
Ok(part) => Some(vec![part]),
Err(e) => {
feishu_log(&format!("图片下载失败 chat={chat_id}: {e}"));
let _ = reply_markdown_card(
&ctx.client,
&message_id,
&format!("⚠️ 图片下载失败:{e}\n\n请重发,或直接用文字描述你的需求。"),
)
.await;
return;
}
}
}
None => None,
};
// 命令通路(#30 容错):全角/大小写/附言归一后识别,仅命令词走控制面,任务原文不动。 // 命令通路(#30 容错):全角/大小写/附言归一后识别,仅命令词走控制面,任务原文不动。
// 顺序约束:命令与审批决议须先于注入旁路——conv 活跃时这两类文本否则会被当补充 // 顺序约束:命令与审批决议须先于注入旁路——conv 活跃时这两类文本否则会被当补充
// 指令注入任务;未知命令词落回常规任务路径,零行为变更 // 指令注入任务;未知命令词落回常规任务路径,零行为变更
@@ -578,7 +724,9 @@ async fn handle_message(
let state = ctx.app.state::<crate::state::AppState>(); let state = ctx.app.state::<crate::state::AppState>();
let conv_id_now = ctx.chat_map.lock().await.get(&chat_id).cloned(); let conv_id_now = ctx.chat_map.lock().await.get(&chat_id).cloned();
if let Some(cid) = conv_id_now { if let Some(cid) = conv_id_now {
if state.conv_states.is_active(&cid) { // 图片消息不注入:inject_runtime_message 只收文本,注入则图被丢、模型只见
// 引导词空跑;落常规路径由受理守卫拦,语义诚实(任务占用中,稍后重发)
if image_key.is_none() && state.conv_states.is_active(&cid) {
match crate::commands::ai::commands::chat::inject_runtime_message(&state, &cid, &text).await { match crate::commands::ai::commands::chat::inject_runtime_message(&state, &cid, &text).await {
Ok(true) => { Ok(true) => {
let _ = reply_markdown_card(&ctx.client, &message_id, "✅ 补充指令已注入运行中任务,下一轮生效").await; let _ = reply_markdown_card(&ctx.client, &message_id, "✅ 补充指令已注入运行中任务,下一轮生效").await;
@@ -654,9 +802,16 @@ async fn handle_message(
None, None,
None, None,
Some(conv_id.clone()), Some(conv_id.clone()),
None, parts, // #958 图片入站:图片消息带 vision 片,文本消息 None;vision 能力判定/降级在 spawn 内
None, None,
false, // 不劫持用户活跃面板(编排节点同款语义) false, // 不劫持用户活跃面板(编排节点同款语义)
// #958 溯源:飞书原始消息定位随 user 消息落库,可跳原消息/区分多端来源
Some(df_ai::provider::MessageSource {
channel: "feishu".into(),
message_id: message_id.clone(),
chat_id: Some(chat_id.clone()),
sender: if open_id.is_empty() { None } else { Some(open_id.clone()) },
}),
) )
.await .await
{ {
@@ -717,10 +872,12 @@ async fn handle_message(
let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel::<()>(); let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel::<()>();
cancel_watchers().lock().await.insert(conv_id.clone(), cancel_tx); cancel_watchers().lock().await.insert(conv_id.clone(), cancel_tx);
let conv_watched = conv_id.clone(); let conv_watched = conv_id.clone();
let watched_chat = chat_id.clone();
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
run_completion_watcher( run_completion_watcher(
ctx, ctx,
conv_id, conv_id,
watched_chat,
ack, ack,
inflight_guard, inflight_guard,
cancel_rx, cancel_rx,
@@ -745,6 +902,145 @@ struct CardFrame {
terminal: bool, terminal: bool,
} }
// ── 出站文件回传(#958,u-claw replyLocalFiles 同模式) ──
// 约定:AI 报告中写 `[交付] <绝对路径>` 行,渠道自动把文件上传飞书回发——
// 图片走图片消息、其余走文件消息(飞书原生附件体验,免用户去桌面端翻)。
// 正文中提及路径但无标记行=叙述,不发送(防止口述路径误上传)。
/// 提取报告中全部 `[交付] <路径>` 行的路径(纯函数)。容错:全半角冒号后空格可选,
/// 路径含空格整行取(行内唯一路径假设,与 AI 约定一致)
fn extract_delivery_paths(text: &str) -> Vec<String> {
text.lines()
.filter_map(|line| {
let line = line.trim();
let rest = line.strip_prefix("[交付]")?;
let path = rest.trim().trim_start_matches(':').trim_start_matches(':').trim();
(!path.is_empty()).then(|| path.to_string())
})
.collect()
}
/// 敏感文件拦截(防御纵深,对齐 u-claw replyBlocked):名字命中凭据/密钥特征即拒,
/// AI 被诱导交付敏感文件时兜底。返回 Some(原因)=拦截
fn delivery_blocked(path: &str) -> Option<&'static str> {
let lower = path.to_lowercase();
const SENSITIVE: [&str; 8] = [
".env", "id_rsa", "credential", "password", "secret", "token", "api_key", "apikey",
];
SENSITIVE
.iter()
.find(|k| lower.contains(**k))
.map(|_| "敏感文件(凭据/密钥特征)")
}
/// 扩展名 → 飞书 file_type(上传接口必填;对齐飞书开放平台枚举)
fn feishu_file_type(ext: &str) -> &'static str {
match ext {
"pdf" => "pdf",
"xls" | "xlsx" | "csv" => "xls",
"doc" | "docx" | "rtf" => "doc",
"ppt" | "pptx" => "ppt",
"mp4" | "mov" | "avi" | "mkv" => "mp4",
"mp3" | "wav" | "m4a" | "aac" | "flac" => "mp3",
_ => "stream",
}
}
/// 上传回传全部交付文件。单文件失败只提示不中断(部分成功优于整体失败);
/// 文件不存在静默跳过并记日志(过滤残余误匹配,"写了路径文件不在"经日志可查)
async fn reply_deliveries(client: &LarkClient, chat_id: &str, text: &str) {
for path in extract_delivery_paths(text) {
if let Some(why) = delivery_blocked(&path) {
feishu_log(&format!("回传跳过({why}): {path}"));
send_text(client, chat_id, &format!("⚠️ 已拦截交付请求:{path}({why})")).await;
continue;
}
let p = std::path::Path::new(&path);
match tokio::fs::metadata(p).await {
Ok(m) if m.is_file() => {}
_ => {
feishu_log(&format!("回传跳过(文件不存在或为目录): {path}"));
continue;
}
}
let bytes = match tokio::fs::read(p).await {
Ok(b) => b,
Err(e) => {
feishu_log(&format!("回传读取失败({path}): {e}"));
continue;
}
};
let opt = &RequestOption::default();
let ext = p
.extension()
.map(|e| e.to_string_lossy().to_lowercase())
.unwrap_or_default();
let is_image = matches!(ext.as_str(), "png" | "jpg" | "jpeg" | "gif" | "webp");
let sent: Result<(), String> = if is_image {
// 图片:上传 image → image 消息直发(直发不做 reply,占位卡已是回复链,
// 附件直发即可,避免嵌套回复噪声)
match client.im().image.create("message", bytes, opt).await {
Ok(resp) => match resp.data.as_ref().and_then(|d| d.image_key.clone()) {
Some(key) => {
let input = SendInput {
msg_type: Some("image".into()),
image_key: Some(key),
chat_id: Some(chat_id.to_string()),
..Default::default()
};
client
.channel_messaging()
.send(&input, opt)
.await
.map(|_| ())
.map_err(|e| format!("发送失败: {e}"))
}
None => Err("响应缺 image_key".into()),
},
Err(e) => Err(format!("图片上传失败: {e}")),
}
} else {
// 文件:上传 file → file 消息直发(飞书好友互发文件同款体验)
let name = p
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "file".into());
match client
.im()
.file
.create(feishu_file_type(&ext), &name, None, bytes, opt)
.await
{
Ok(resp) => match resp.data.as_ref().and_then(|d| d.file_key.clone()) {
Some(key) => {
let input = SendInput {
msg_type: Some("file".into()),
file_key: Some(key),
chat_id: Some(chat_id.to_string()),
..Default::default()
};
client
.channel_messaging()
.send(&input, opt)
.await
.map(|_| ())
.map_err(|e| format!("发送失败: {e}"))
}
None => Err("响应缺 file_key".into()),
},
Err(e) => Err(format!("文件上传失败: {e}")),
}
};
if let Err(e) = sent {
feishu_log(&format!("回传失败({path}): {e}"));
let base = path.rsplit(['/', '\\']).next().unwrap_or(path.as_str());
send_text(client, chat_id, &format!("⚠️ 文件回传失败:{base}({e})")).await;
} else {
feishu_log(&format!("回传完成: {path}"));
}
}
}
/// 完成事件订阅任务(#14 两段式):只做裁决——消费 ai_event_bus 的 AiAgentFinished(本 conv) /// 完成事件订阅任务(#14 两段式):只做裁决——消费 ai_event_bus 的 AiAgentFinished(本 conv)
/// 决定终版文本,经帧通道交给预览任务原地落卡(占位卡唯一写者是预览任务,受理卡/终版/阶段 /// 决定终版文本,经帧通道交给预览任务原地落卡(占位卡唯一写者是预览任务,受理卡/终版/阶段
/// 提示全部经帧通道按序落卡)。引擎层 deliver_completion_report_if_waited 是广播单一发出点。 /// 提示全部经帧通道按序落卡)。引擎层 deliver_completion_report_if_waited 是广播单一发出点。
@@ -755,6 +1051,7 @@ struct CardFrame {
async fn run_completion_watcher( async fn run_completion_watcher(
ctx: Arc<Ctx>, ctx: Arc<Ctx>,
conv_id: String, conv_id: String,
chat_id: String,
ack: String, ack: String,
// #20:计数生命周期跟任务走,guard 持有至 watcher 退出 // #20:计数生命周期跟任务走,guard 持有至 watcher 退出
_inflight: InflightGuard, _inflight: InflightGuard,
@@ -855,6 +1152,10 @@ async fn run_completion_watcher(
} }
}; };
feishu_log(&format!("watcher 终版 conv={conv_id} len={}", frame.text.len())); feishu_log(&format!("watcher 终版 conv={conv_id} len={}", frame.text.len()));
// #958 出站回传(u-claw replyLocalFiles 同模式):报告内 [交付] 标记行的本地文件
// 自动上传飞书(图片→图片消息/其余→文件消息),用户在飞书直接收原生附件。
// 终版文本中的标记行本身保留(AI 写路径也是叙述的一部分,卡内可见)
reply_deliveries(&ctx.client, &chat_id, &frame.text).await;
let _ = frame_tx.send(frame).await; let _ = frame_tx.send(frame).await;
let _ = stream_task.await; // 等预览任务落卡终版并退出,再释放 inflight let _ = stream_task.await; // 等预览任务落卡终版并退出,再释放 inflight
return; return;
@@ -1958,10 +2259,57 @@ async fn save_chat_map(db: &df_storage::db::Database, map: &HashMap<String, Stri
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
heartbeat_due, is_terminal_push, md_to_lark_friendly, msg_after_acceptance, parse_command, delivery_blocked, extract_delivery_paths, feishu_file_type, heartbeat_due,
strip_mention_placeholders, stream_preview, is_terminal_push, md_to_lark_friendly, msg_after_acceptance, parse_command,
sniff_image_mime, strip_mention_placeholders, stream_preview,
}; };
#[test]
fn delivery_paths_extract_only_marked_lines() {
// #958 出站回传:只认 [交付] 标记行,叙述路径不发送;全半角冒号容错
let text = "方案如下。\n已生成图表:\n[交付] E:\\out\\chart.png\n另见 E:\\ref\\说明.docx\n[交付]:/home/u/报表 最终.xlsx\n[交付]";
assert_eq!(
extract_delivery_paths(text),
vec!["E:\\out\\chart.png".to_string(), "/home/u/报表 最终.xlsx".to_string()]
);
assert!(extract_delivery_paths("没有标记").is_empty());
}
#[test]
fn delivery_blocked_catches_credential_names() {
assert!(delivery_blocked("E:\\proj\\.env").is_some());
assert!(delivery_blocked("/home/u/id_rsa").is_some());
assert!(delivery_blocked("E:\\app\\API_KEY.txt").is_some());
assert_eq!(delivery_blocked("E:\\out\\chart.png"), None);
assert_eq!(delivery_blocked("E:\\out\\report.pdf"), None);
}
#[test]
fn feishu_file_type_maps_common_exts() {
assert_eq!(feishu_file_type("pdf"), "pdf");
assert_eq!(feishu_file_type("xlsx"), "xls");
assert_eq!(feishu_file_type("docx"), "doc");
assert_eq!(feishu_file_type("mp4"), "mp4");
assert_eq!(feishu_file_type("zip"), "stream");
assert_eq!(feishu_file_type(""), "stream");
}
#[test]
fn image_mime_sniff_four_formats() {
// #958:四格式魔数命中 + 未知格式拒收(宁早拒不晚炸 vision 端点 400)
assert_eq!(sniff_image_mime(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A]), Some("image/png"));
assert_eq!(sniff_image_mime(&[0xFF, 0xD8, 0xFF, 0xE0]), Some("image/jpeg"));
assert_eq!(sniff_image_mime(b"GIF89a"), Some("image/gif"));
assert_eq!(
sniff_image_mime(b"RIFF\x24\x08\x00\x00WEBPVP8 "),
Some("image/webp")
);
assert_eq!(sniff_image_mime(b"<html>"), None);
assert_eq!(sniff_image_mime(&[]), None);
// RIFF 但非 WEBP(avi/wav 等)不误判
assert_eq!(sniff_image_mime(b"RIFF\x24\x08\x00\x00AVI "), None);
}
#[test] #[test]
fn command_case_and_tail_tolerance() { fn command_case_and_tail_tolerance() {
// 30:/new 大小写与附言容错,附言不烧 LLM // 30:/new 大小写与附言容错,附言不烧 LLM
+5
View File
@@ -2,6 +2,8 @@
mod commands; mod commands;
mod state; mod state;
// 媒体存储域(#958 引用化):hash 去重落盘/OSS 双写/引用形态,飞书与桌面粘贴图共用
mod media_store;
// Phase3: 桌面内嵌 HTTP MCP server + 托盘常驻 + 单实例 // Phase3: 桌面内嵌 HTTP MCP server + 托盘常驻 + 单实例
mod mcp; mod mcp;
// 本地工具代理统一网关:内嵌 axum 监听 127.0.0.1:8899,路由到 mysql/ssh/mongo/redis 4 代理端口 // 本地工具代理统一网关:内嵌 axum 监听 127.0.0.1:8899,路由到 mysql/ssh/mongo/redis 4 代理端口
@@ -72,6 +74,9 @@ pub fn run() {
crate::commands::ai::tools::spawn_agent::init_app_handle(app.handle().clone()); crate::commands::ai::tools::spawn_agent::init_app_handle(app.handle().clone());
// D2a 工具结果外置根目录注入(app_data 下 tool-results/;未注入时直通降级) // D2a 工具结果外置根目录注入(app_data 下 tool-results/;未注入时直通降级)
commands::ai::tool_result_offload::init_base(data_dir.clone()); commands::ai::tool_result_offload::init_base(data_dir.clone());
// P1 上下文侧工具结果外置(同根目录,tool-results/ 内以 `ctx-` 前缀区分)。
// 未注入时 extract_key_info 退化为纯摘要 = 改造前行为(零回归)。
df_ai::tool_offload::init_base(data_dir.clone());
// 飞书直连渠道:配置启用时起长连接(读 feishu-config;未配置/disabled 静默跳过) // 飞书直连渠道:配置启用时起长连接(读 feishu-config;未配置/disabled 静默跳过)
feishu_channel::start_if_enabled(app.handle().clone()); feishu_channel::start_if_enabled(app.handle().clone());
+309
View File
@@ -0,0 +1,309 @@
//! 媒体存储域(#958 图片入站引用化 + 跨设备文件服务)
//!
//! 单一归属:媒体字节 → 存储引用(`ContentPart::Image.url`)的全部决策收在本模块——
//! hash 去重落盘(同内容同文件,转发/重发不重复存储)、可选 OSS 双写(跨设备可解析)、
//! 引用形态选择(oss `https://` / 本机 `file://`)。飞书协议(下载/上传回传)留 feishu_channel,
//! 本模块零协议依赖。
//!
//! 引用设计:不自造 scheme——`https://`(OSS)是 OpenAI 端点原生直传格式,anthropic 侧
//! 已有 url→base64 预拉;`file://` 由 df-ai::image_ref 发送前展开为 base64。展开按 url
//! 形态自然分流,无自定义协议。
//!
//! OSS 失败语义:上传失败不阻塞——本机快照已落盘,引用降级 `file://`(本机可解析,
//! 跨设备解析降级为「图片文件缺失」文本,诚实不炸请求)。
use df_ai::provider::ContentPart;
use serde::Deserialize;
/// OSS 配置(app_settings KV `media-oss-config` JSON;缺省=纯本地模式)。
/// public_base_url 为空时取 `https://{bucket}.{endpoint}`(bucket 私有时需自备 CDN 域)
#[derive(Debug, Clone, Deserialize)]
pub struct OssConfig {
pub bucket: String,
/// 如 oss-cn-hangzhou.aliyuncs.com
pub endpoint: String,
pub access_key_id: String,
pub access_key_secret: String,
#[serde(default)]
pub public_base_url: String,
}
/// app_settings 键:媒体 OSS 配置(KV JSON,设置面板后续批;当前可手配)
pub const KV_MEDIA_OSS_CONFIG: &str = "media-oss-config";
/// 读 OSS 配置(KV;未配置/解析失败 → None=纯本地模式)。媒体域唯一读取口,
/// 飞书入站与 spawn offload 共用
pub async fn read_oss_config(db: &df_storage::db::Database) -> Option<OssConfig> {
let repo = df_storage::crud::SettingsRepo::new(db);
let raw = repo.get(KV_MEDIA_OSS_CONFIG).await.ok().flatten()?;
serde_json::from_str(&raw).ok()
}
/// base64 图片片 → 落盘引用片(spawn 统一 offload,#958 引用化):
/// 桌面粘贴图/前端上传在此收口,消息只存引用,SQLite 不进大 base64 字符串
/// (历史重放不再全量携带)。落盘失败保原 base64 片(不丢图,仅失去引用化收益);
/// 引用片/文本片原样透传。media_type 缺失的片无法规范落盘,原样保留
pub async fn offload_image_parts(parts: Vec<ContentPart>, store: &MediaStore) -> Vec<ContentPart> {
use base64::Engine as _;
let mut out = Vec::with_capacity(parts.len());
for p in parts {
out.push(match p {
ContentPart::Image { url, base64: Some(b64), media_type, alt } => {
let decoded = base64::engine::general_purpose::STANDARD
.decode(b64.as_bytes())
.ok()
.filter(|b| !b.is_empty());
match (decoded, media_type.clone()) {
(Some(bytes), Some(mt)) => match store.save(&bytes, &mt, None).await {
Ok(url) => ContentPart::Image {
url: Some(url),
base64: None,
media_type,
alt,
},
Err(e) => {
tracing::warn!(err = %e, "图片落盘失败,保留 base64 片");
ContentPart::Image { url, base64: Some(b64), media_type, alt }
}
},
// 解码失败/空图/无 media_type:原样透传,provider 层按既有语义处置
_ => ContentPart::Image { url, base64: Some(b64), media_type, alt },
}
}
other => other,
});
}
out
}
/// 媒体存储。`oss` 为 None 时纯本地模式(引用全部 file://)
pub struct MediaStore {
data_dir: std::path::PathBuf,
oss: Option<OssConfig>,
}
impl MediaStore {
pub fn new(data_dir: std::path::PathBuf, oss: Option<OssConfig>) -> Self {
Self { data_dir, oss }
}
/// 落盘并返回引用 url。本地快照必落(hash 去重,同内容幂等);OSS 配置在则上传,
/// 成功引用 `https://`(跨设备可解析),失败降级 `file://`(不阻塞,本机零损)。
///
/// `original_name`:原始文件名(文件消息带,图片消息通常 None)——缀在 hash 后保
/// 目录可读,清洗路径穿越字符防越界;None/空则纯 hash 名
pub async fn save(
&self,
bytes: &[u8],
media_type: &str,
original_name: Option<&str>,
) -> Result<String, String> {
let key = content_key(bytes, media_type, original_name);
let dir = self.data_dir.join("media");
tokio::fs::create_dir_all(&dir)
.await
.map_err(|e| format!("创建媒体目录失败: {e}"))?;
let path = dir.join(&key);
// 存在即命中(hash 去重):跳过重写,读侧幂等
if !tokio::fs::try_exists(&path).await.unwrap_or(false) {
if let Err(e) = tokio::fs::write(&path, bytes).await {
// 半写残件不留盘(对齐 u-claw fetchResource 防线)
tokio::fs::remove_file(&path).await.ok();
return Err(format!("媒体写入失败: {e}"));
}
}
match &self.oss {
Some(cfg) => match upload_to_oss(cfg, &key, bytes, media_type).await {
Ok(()) => Ok(public_url(cfg, &key)),
Err(e) => {
tracing::warn!(key = %key, err = %e, "OSS 上传失败,引用降级本机 file://");
Ok(local_ref(&path))
}
},
None => Ok(local_ref(&path)),
}
}
}
/// 内容寻址文件名:`<sha256前16hex>[_<清洗原始名>]<ext>`(对齐 u-claw safeAttachName
/// 模式)——hash 前缀保去重(同内容必同文件)且排序聚簇,原始名缀后保目录可读。
/// 平铺不分会话目录:会话归属经 DB 消息引用反查,文件系统不编码会话(否则同图跨会话
/// 重复存储,去重失效);个人桌面应用媒体量级百~千,hash 前缀平铺可管理,分桶待量级上来再说
fn content_key(bytes: &[u8], media_type: &str, original_name: Option<&str>) -> String {
use sha2::Digest as _;
let digest = hex::encode(&sha2::Sha256::digest(bytes)[..8]);
// 扩展名:原始名自带 ext 优先(保真),否则按 media_type 规范化
let name = original_name.map(sanitize_name).filter(|s| !s.is_empty());
let ext = name
.as_deref()
.and_then(|n| {
std::path::Path::new(n)
.extension()
.map(|e| format!(".{}", e.to_string_lossy()))
})
.unwrap_or_else(|| ext_of(media_type).to_string());
match name {
Some(n) => format!("{digest}_{n}"),
None => format!("{digest}{ext}"),
}
}
/// 原始文件名清洗(防路径穿越/过长):/ \ .. → _,截 60 字符
fn sanitize_name(name: &str) -> String {
let cleaned: String = name
.replace('/', "_")
.replace('\\', "_")
.replace("..", "_")
.chars()
.take(60)
.collect();
cleaned.trim().to_string()
}
fn ext_of(media_type: &str) -> &'static str {
match media_type {
"image/png" => ".png",
"image/jpeg" => ".jpg",
"image/gif" => ".gif",
"image/webp" => ".webp",
_ => ".bin",
}
}
/// 本机引用(file:// + 原生绝对路径;df-ai::image_ref 展开端同前缀约定)
fn local_ref(path: &std::path::Path) -> String {
format!("file://{}", path.display())
}
/// OSS 公网引用:自定义 CDN 域优先,缺省桶域名
fn public_url(cfg: &OssConfig, key: &str) -> String {
if cfg.public_base_url.is_empty() {
format!("https://{}.{}/{}", cfg.bucket, cfg.endpoint, key)
} else {
format!("{}/{}", cfg.public_base_url.trim_end_matches('/'), key)
}
}
/// 阿里云 OSS PUT 上传(Header 签名版,无需 SDK):
/// Signature = base64(hmac-sha1(SK, "PUT\n\n{ctype}\n{date}\n/{bucket}/{key}"))
async fn upload_to_oss(
cfg: &OssConfig,
key: &str,
bytes: &[u8],
content_type: &str,
) -> Result<(), String> {
use hmac::Mac as _;
let url = format!("https://{}.{}/{}", cfg.bucket, cfg.endpoint, key);
let date = httpdate::fmt_http_date(std::time::SystemTime::now());
let resource = format!("/{}/{}", cfg.bucket, key);
let string_to_sign = format!("PUT\n\n{content_type}\n{date}\n{resource}");
let mut mac = hmac::Hmac::<sha1::Sha1>::new_from_slice(cfg.access_key_secret.as_bytes())
.map_err(|e| format!("OSS 签名初始化失败: {e}"))?;
mac.update(string_to_sign.as_bytes());
let sig = base64_sign(&mac.finalize().into_bytes());
let client = reqwest::Client::new();
let resp = client
.put(&url)
.header("Date", &date)
.header("Content-Type", content_type)
.header(
"Authorization",
format!("OSS {}:{sig}", cfg.access_key_id),
)
.body(bytes.to_vec())
.timeout(std::time::Duration::from_secs(60))
.send()
.await
.map_err(|e| format!("OSS 请求失败: {e}"))?;
if resp.status().is_success() {
Ok(())
} else {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
Err(format!("OSS 上传失败 {status}: {}", truncate_body(&body)))
}
}
/// hmac 输出 → 标准 base64(签名编码)
fn base64_sign(digest: &[u8]) -> String {
use base64::Engine as _;
base64::engine::general_purpose::STANDARD.encode(digest)
}
/// 错误体截断(防 XML 错误页刷日志)
fn truncate_body(s: &str) -> &str {
match s.char_indices().nth(200) {
Some((i, _)) => &s[..i],
None => s,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn save_dedupes_by_content_and_refs_local() {
let dir = std::env::temp_dir().join(format!("df-media-test-{}", df_types::types::new_id()));
let store = MediaStore::new(dir.clone(), None);
let url1 = store.save(b"\x89PNG same", "image/png", None).await.unwrap();
let url2 = store.save(b"\x89PNG same", "image/png", None).await.unwrap();
let url3 = store.save(b"\x89PNG other", "image/png", None).await.unwrap();
// 同内容同引用(hash 去重),不同内容不同引用
assert_eq!(url1, url2);
assert_ne!(url1, url3);
assert!(url1.starts_with("file://"), "纯本地模式引用 file://, 实际 {url1}");
let path = std::path::Path::new(&url1["file://".len()..]);
assert_eq!(tokio::fs::read(path).await.unwrap(), b"\x89PNG same");
tokio::fs::remove_dir_all(&dir).await.ok();
}
#[tokio::test]
async fn original_name_kept_readable_and_sanitized() {
let dir = std::env::temp_dir().join(format!("df-media-test-{}", df_types::types::new_id()));
let store = MediaStore::new(dir.clone(), None);
let url = store
.save(b"file-bytes", "application/octet-stream", Some("../报表/最终 版本.xlsx"))
.await
.unwrap();
let name = &url["file://".len()..];
let fname = std::path::Path::new(name).file_name().unwrap().to_string_lossy();
assert!(fname.contains('_'), "hash 与原始名以 _ 分隔: {fname}");
assert!(!fname.contains("..") && !fname.contains('/'), "路径穿越字符已清洗: {fname}");
assert!(fname.ends_with(".xlsx"), "原始名自带 ext 优先: {fname}");
// 同内容同名 → 同文件(去重不因名字破坏)
let url2 = store
.save(b"file-bytes", "application/octet-stream", Some("../报表/最终 版本.xlsx"))
.await
.unwrap();
assert_eq!(url, url2);
tokio::fs::remove_dir_all(&dir).await.ok();
}
#[test]
fn content_key_stable_and_ext_aware() {
let k1 = content_key(b"abc", "image/png", None);
let k2 = content_key(b"abc", "image/png", None);
let k3 = content_key(b"abc", "image/jpeg", None);
assert_eq!(k1, k2);
assert_ne!(k1, k3, "同字节不同 media_type 应不同文件(png/jpg 不混)");
assert!(k1.ends_with(".png") && k1.len() == 16 + 4);
}
#[test]
fn public_url_prefers_cdn_domain() {
let cfg = OssConfig {
bucket: "b".into(),
endpoint: "oss-cn-hangzhou.aliyuncs.com".into(),
access_key_id: "ak".into(),
access_key_secret: "sk".into(),
public_base_url: "https://cdn.example.com/".into(),
};
assert_eq!(public_url(&cfg, "k.png"), "https://cdn.example.com/k.png");
let cfg2 = OssConfig { public_base_url: String::new(), ..cfg };
assert_eq!(
public_url(&cfg2, "k.png"),
"https://b.oss-cn-hangzhou.aliyuncs.com/k.png"
);
}
}