修复: AI上下文邻接性收口 + 设置页i18n缺失键

- df-ai 三元组邻接性收口: sanitize/openai_compat 闭合判定由「id 全局存在」收紧为「紧后 tool 连续段覆盖」,夹层序列(后台压缩/用户插话楔入)不再直送 provider 致 400 insufficient tool messages
- 设置页补 settings.loading 缺失键(zh/en),修版本信息返回前渲染字面串
- 看板: 907 接手核查补注(实现口径=剥除非归位,有损)+ 遗留登记 909 + 908 销账
This commit is contained in:
lxy
2026-10-10 00:08:35 +08:00
parent f4ca01f2f2
commit c71d0c4657
5 changed files with 102 additions and 16 deletions
+59 -7
View File
@@ -42,12 +42,28 @@ pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
.filter(|m| m.is_active())
.collect();
// step 1:已闭合的 tool_call_id 集合(role=Tool 消息全部提供过的 id)
let resolved_ids: HashSet<&str> = messages
.iter()
.filter(|m| matches!(m.role, MessageRole::Tool))
.filter_map(|m| m.tool_call_id.as_deref())
.collect();
// step 1:相邻性判定的依据——每个 assistant 头「紧后 tool 连续段」覆盖的 id 集合。
// 口径与 openai_compat::sanitize_openai_triplets 一致(2026-10-09 收紧):
// 协议要求 tool 响应必须紧随其 assistant(tool_calls);原实现按「id 全局存在」判定
// (位置无关),会把被 system/user 隔开的游离响应误判为已闭合,放行畸形序列致 400
// 「insufficient tool messages」。故判定改为「紧后连续段覆盖」——未覆盖的 call 视同
// 未闭合处理(剥除),其游离响应随后由 step3 按 orphaned_ids 丢弃,自动闭环。
let mut adjacency: std::collections::HashMap<usize, HashSet<String>> =
std::collections::HashMap::new();
for i in 0..messages.len() {
if !matches!(messages[i].role, MessageRole::Assistant) {
continue;
}
let mut seg: HashSet<String> = HashSet::new();
let mut j = i + 1;
while j < messages.len() && matches!(messages[j].role, MessageRole::Tool) {
if let Some(id) = messages[j].tool_call_id.as_deref() {
seg.insert(id.to_string());
}
j += 1;
}
adjacency.insert(i, seg);
}
// step 2:判定每个 assistant 头的闭合状态,产出需丢弃的 id 集合
// orphaned_ids:未闭合的 tool_call.id(含占位 id),其 tool_result 要丢
@@ -72,7 +88,7 @@ pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
.iter()
.partition(|c| {
!c.id.starts_with(TOOL_MISSING_PREFIX)
&& resolved_ids.contains(c.id.as_str())
&& adjacency.get(&i).is_some_and(|s| s.contains(&c.id))
});
match (resolved.is_empty(), unresolved.is_empty()) {
@@ -554,6 +570,42 @@ mod tests {
// ── 占位配对完整性(解 400 orphan):反向 orphan 检测 + 出口自愈 ──
/// 邻接性回归(2026-10-09 400 根因):tool 响应与 head 之间被夹层(system/user)隔开时,
/// 闭合判定按「紧后 tool 连续段覆盖」而非「id 全局存在」——被隔开的 call 视同未闭合
/// (剥除/整头丢),其游离响应随 orphaned_ids 一并丢弃,保证发送序列满足「head 后紧邻其
/// 响应」。防 OpenAI 400「insufficient tool messages following tool_calls message」。
#[test]
fn sanitize_strips_calls_when_response_separated_by_interloper() {
// [user] [assistant(c1,c2)] [system 夹层] [tool c1] [tool c2]
let msgs = vec![
ChatMessage::user("go"),
ChatMessage::assistant_with_tools(
"",
vec![
ToolCall::new("c1".to_string(), "t", "{}"),
ToolCall::new("c2".to_string(), "t", "{}"),
],
),
ChatMessage::system("夹层提示"),
ChatMessage::tool_result("c1", "r1"),
ChatMessage::tool_result("c2", "r2"),
];
let out = sanitize_messages(msgs);
// 夹层隔开 → 两 call 均不在紧后连续段 → 无任何带 tool_calls 的 assistant 存活
assert!(
out.iter()
.all(|m| m.tool_calls.as_ref().map_or(true, |c| c.is_empty())),
"被夹层隔开的 call 应被剥除(不得残留 head+tool_calls), 实际 {:?}",
out
);
// 游离响应无头配对 → 丢弃;序列内不得残留 role=Tool
assert!(
out.iter().all(|m| !matches!(m.role, MessageRole::Tool)),
"游离 tool 响应应被丢弃, 实际 {:?}",
out
);
}
#[test]
fn sanitize_heals_reverse_orphan_tool_result_with_placeholder_head() {
// 三元组完整性 P0:反向 orphan(tool_result 无对应 tool_call 头)→ 补占位头自愈(不丢)。
+26 -9
View File
@@ -372,18 +372,34 @@ fn extract_tool_call_ids(msg: &OpenAiMessage) -> Vec<String> {
fn sanitize_openai_triplets(messages: &mut Vec<OpenAiMessage>) {
use std::collections::HashSet;
// step 1:resolved_ids = 所有 tool 消息提供的 tool_call_id(有 result 响应的 id)。
let resolved_ids: HashSet<String> = messages
.iter()
.filter(|m| m.role == "tool")
.filter_map(|m| m.tool_call_id.clone())
.collect();
// step 1:相邻性判定的依据 = 每个 assistant 头「紧后 tool 连续段」覆盖的 id 集合。
// 根因(2026-10-09):原实现按「id 全局存在」判闭合(位置无关),而 OpenAI 协议要求
// tool 响应必须**紧随**其 assistant(tool_calls)。后台压缩摘要在 head 已 push、tool
// 结果未 push 的窗口内可楔入 system(context_lifecycle),被隔开的响应仍被判"已闭合"
// → 畸形序列放行 → 上游 400「insufficient tool messages」。故判定口径由「id 全局存在」
// 收紧为「紧后连续段覆盖」:未覆盖的 call 视同未闭合剥除,其游离响应随后由 step3 丢弃。
let mut adjacency: std::collections::HashMap<usize, HashSet<String>> =
std::collections::HashMap::new();
for i in 0..messages.len() {
if messages[i].role != "assistant" {
continue;
}
let mut seg: HashSet<String> = HashSet::new();
let mut j = i + 1;
while j < messages.len() && messages[j].role == "tool" {
if let Some(id) = messages[j].tool_call_id.as_deref() {
seg.insert(id.to_string());
}
j += 1;
}
adjacency.insert(i, seg);
}
let mut stripped_heads = 0u32;
let mut total_stripped = 0u32;
// step 2:assistant 剥未闭合 tool_call(无对应 tool result 响应)。
for m in messages.iter_mut() {
// step 2:assistant 剥未闭合 tool_call(其响应不在紧后连续段内)。
for (i, m) in messages.iter_mut().enumerate() {
if m.role != "assistant" {
continue;
}
@@ -393,12 +409,13 @@ fn sanitize_openai_triplets(messages: &mut Vec<OpenAiMessage>) {
if calls.is_empty() {
continue;
}
let seg = adjacency.get(&i);
let kept: Vec<serde_json::Value> = calls
.iter()
.filter(|tc| {
tc.get("id")
.and_then(|v| v.as_str())
.is_some_and(|id| resolved_ids.contains(id))
.is_some_and(|id| seg.is_some_and(|s| s.contains(id)))
})
.cloned()
.collect();
+15
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -138,6 +138,7 @@ export default {
sectionAbout: 'ℹ️ About DevFlow',
labelAppVersion: 'Current version',
descAppVersion: 'DevFlow desktop app version (injected at compile time, same source as the installer)',
loading: 'Loading…',
labelCheckUpdate: 'Check for updates',
descCheckUpdate: 'Fetch the latest version info from the release source; a new version prompts download & install (wired in B2)',
checkUpdateAction: 'Check for updates',
+1
View File
@@ -137,6 +137,7 @@ export default {
sectionAbout: 'ℹ️ 关于 DevFlow',
labelAppVersion: '当前版本',
descAppVersion: 'DevFlow 桌面应用版本号(编译期注入,与安装包同源)',
loading: '加载中…',
labelCheckUpdate: '检查更新',
descCheckUpdate: '从发布源拉取最新版本信息;有新版会提示下载安装(B2 接入)',
checkUpdateAction: '检查更新',