修复: 模型停用无效根因 + 上下文归位/压缩守卫
- 模型停用无效根因: get_active_provider 两条路径均不校验 enabled(显式活跃直返/默认只挑 is_default) → 收敛为启用集合解析,无启用 provider 明确报错 - 压缩/标题兜底模型不校验 enabled → 新增 resolve_fallback_model 纯函数(title 复用同一实现,单一源无双轨) - 上下文多夹层归位修复: gather_tool_responses 聚拢该头全部响应(此前第二个夹层之后的响应仍被判未闭合剥除) - 压缩后台任务加内容版本哨兵(取首条消息 id,否掉 len:压缩期间 loop 正常 append 会致守卫永不生效) + panic 兜底复位标志 - 902 超时类失败与其它 Fatal 区分停靠文案(点继续可续跑) - 子代理事件按 conv 改挂(route_scope) + 运行期事件推父会话视图 - 飞书 13a 真根因: run_stream_refresher 死代码(提前 drop)+静默钟被心跳重置 → 帧通道+仅内容事件重置 - 项目详情文件查看: F22 目录不存在误显非 Git 仓库 / F24 模块为仓库子目录时 git 徽标全失效(scope_git_path 按 show-prefix 剥前缀) - 更新徽章通道错配(Tauri 事件 vs window CustomEvent 同名不同通道,徽章此前永不触发) - ProjectCard 卡片根 keydown 顶替内嵌按钮(补 e.target 守卫) - i18n 坏键 ai.aiTool.rejectedHint → aiTool.rejectedHint(双缺会渲染裸 key) - miniapp 三处: 会话列表刷新漏网/看门狗节流致新一轮无兜底/newConversation 漏清看门狗
This commit is contained in:
@@ -348,6 +348,9 @@ function clearAllApprovalTimers(): void {
|
|||||||
function clearWatchdog(): void {
|
function clearWatchdog(): void {
|
||||||
if (streamTimer) clearTimeout(streamTimer)
|
if (streamTimer) clearTimeout(streamTimer)
|
||||||
streamTimer = null
|
streamTimer = null
|
||||||
|
// 复位节流时间戳:resetWatchdog 的 5s 节流假定「已有 timer 在跑」,而此处刚清空 timer。
|
||||||
|
// 不复位则「停止/报错后 5s 内重发」会因节流跳过 arm → 新一轮无看门狗(中途断则 generating 永卡)。
|
||||||
|
lastWatchdogReset = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 已注册事件监听标志(防多次订阅 wsClient) */
|
/** 已注册事件监听标志(防多次订阅 wsClient) */
|
||||||
@@ -1374,6 +1377,7 @@ function newConversation(): void {
|
|||||||
tokenUsage.value = null // 新会话清 token(对齐 switchConversation)
|
tokenUsage.value = null // 新会话清 token(对齐 switchConversation)
|
||||||
activeModel.value = null // 新会话复位模型选择(新会话无 per_conv 模型,走默认路由)
|
activeModel.value = null // 新会话复位模型选择(新会话无 per_conv 模型,走默认路由)
|
||||||
pendingApprovals.value.splice(0, pendingApprovals.value.length); clearAllApprovalTimers()
|
pendingApprovals.value.splice(0, pendingApprovals.value.length); clearAllApprovalTimers()
|
||||||
|
clearWatchdog() // 与 switchConversation 对齐:清残留看门狗,防新一轮 send 沿用旧 timer(超时点偏移)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import { useAiChat } from '@/composables/useAiChat'
|
|||||||
import type { MentionSpan, MiniContentPart } from '@/types/relay'
|
import type { MentionSpan, MiniContentPart } from '@/types/relay'
|
||||||
import type { ProjectRecord, TaskRecord, IdeaRecord, AiToolCallInfo, ChatMessage, TokenUsage } from '@/types/events'
|
import type { ProjectRecord, TaskRecord, IdeaRecord, AiToolCallInfo, ChatMessage, TokenUsage } from '@/types/events'
|
||||||
|
|
||||||
|
/** 调试日志门控(对齐 useAiChat/ws.ts:import.meta.env.DEV,生产零 bridge 成本) */
|
||||||
|
const DEBUG = import.meta.env.DEV
|
||||||
|
|
||||||
// 组件内联(绕过微信工具 3.15.2 组件解析缓存 bug):独立组件恒报 Component not found,
|
// 组件内联(绕过微信工具 3.15.2 组件解析缓存 bug):独立组件恒报 Component not found,
|
||||||
// inline 进页面后 chat/index.json usingComponents 空,工具无需解析 components/,问题消失。
|
// inline 进页面后 chat/index.json usingComponents 空,工具无需解析 components/,问题消失。
|
||||||
// MdView/MentionInput 源保留(src/components/ 下,工具 bug 修复后可拆回)。
|
// MdView/MentionInput 源保留(src/components/ 下,工具 bug 修复后可拆回)。
|
||||||
@@ -512,11 +515,11 @@ function onMentionClose(): void {
|
|||||||
* 保留原有守卫:空文本忽略 / generating 中忽略 / 关闭联想浮层。
|
* 保留原有守卫:空文本忽略 / generating 中忽略 / 关闭联想浮层。
|
||||||
*/
|
*/
|
||||||
function handleSend(): void {
|
function handleSend(): void {
|
||||||
console.log('[v2:send] enter text=', JSON.stringify(inputText.value), 'gen=', generating.value, 'imgs=', pendingImages.value.length)
|
if (DEBUG) console.log('[v2:send] enter text=', JSON.stringify(inputText.value), 'gen=', generating.value, 'imgs=', pendingImages.value.length)
|
||||||
const text = inputText.value.trim()
|
const text = inputText.value.trim()
|
||||||
const hasImage = pendingImages.value.length > 0
|
const hasImage = pendingImages.value.length > 0
|
||||||
if (!text && !hasImage) {
|
if (!text && !hasImage) {
|
||||||
console.log('[v2:send] 空文本且无图片,忽略')
|
if (DEBUG) console.log('[v2:send] 空文本且无图片,忽略')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (generating.value) {
|
if (generating.value) {
|
||||||
|
|||||||
@@ -12,11 +12,15 @@ const ai = useAiChat()
|
|||||||
*/
|
*/
|
||||||
const conversations = ref<Conversation[]>([])
|
const conversations = ref<Conversation[]>([])
|
||||||
/**
|
/**
|
||||||
* 监听 ai.conversations.length 变化(比 deep watch 更可靠)。
|
* 监听 ai.conversations 整体替换(比 deep watch 更可靠,也比只盯 length 完整)。
|
||||||
* 用 `[...ai.conversations]` 创建普通数组赋值给 ref,确保每次变化都触发模板更新。
|
* 用 `[...ai.conversations]` 创建普通数组赋值给 ref,确保每次变化都触发模板更新。
|
||||||
|
*
|
||||||
|
* 为什么监听 ref 而非 length:device 回推 AiConversationList 是「整体替换」且条数常不变
|
||||||
|
* (改名后条数不变),只盯 length 时 watch 回调不触发 → 本地副本滞留旧标题。
|
||||||
|
* 监听 ref 则在赋值(含同长度替换)时即触发刷新。
|
||||||
*/
|
*/
|
||||||
watch(() => ai.conversations.value.length, () => {
|
watch(ai.conversations, (list) => {
|
||||||
conversations.value = [...ai.conversations.value]
|
conversations.value = [...(list || [])]
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
/** 点击会话项切换 */
|
/** 点击会话项切换 */
|
||||||
|
|||||||
@@ -580,7 +580,7 @@ impl ContextManager {
|
|||||||
/// 摘要/警示仍紧邻本轮内容对 LLM 可见。返回 `true` 表示走了避让插入(会置
|
/// 摘要/警示仍紧邻本轮内容对 LLM 可见。返回 `true` 表示走了避让插入(会置
|
||||||
/// `needs_full_rewrite`,需 DB 全量重写),供调用方记日志。
|
/// `needs_full_rewrite`,需 DB 全量重写),供调用方记日志。
|
||||||
pub fn push_system_anchor(&mut self, message: ChatMessage) -> bool {
|
pub fn push_system_anchor(&mut self, message: ChatMessage) -> bool {
|
||||||
if self.ends_with_dangling_tool_call_head() {
|
if self.tail_is_dangling_head() {
|
||||||
let idx = self.messages.len() - 1;
|
let idx = self.messages.len() - 1;
|
||||||
self.insert_at(idx, message);
|
self.insert_at(idx, message);
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
@@ -596,7 +596,7 @@ impl ContextManager {
|
|||||||
|
|
||||||
/// 末条是否为「悬空 tool_call 头」(assistant 带 tool_calls 且其 tool 响应尚未回填)。
|
/// 末条是否为「悬空 tool_call 头」(assistant 带 tool_calls 且其 tool 响应尚未回填)。
|
||||||
/// 供 [`Self::push_system_anchor`] 判定避让位置:末条即头,说明头已入而结果未回。
|
/// 供 [`Self::push_system_anchor`] 判定避让位置:末条即头,说明头已入而结果未回。
|
||||||
fn ends_with_dangling_tool_call_head(&self) -> bool {
|
fn tail_is_dangling_head(&self) -> bool {
|
||||||
self.messages
|
self.messages
|
||||||
.last()
|
.last()
|
||||||
.is_some_and(|m| m.group == MessageGroup::ToolCallHead)
|
.is_some_and(|m| m.group == MessageGroup::ToolCallHead)
|
||||||
|
|||||||
@@ -45,8 +45,9 @@ pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
|||||||
// step 0.5(相邻性归位,909②):把楔入 assistant(tool_calls) 头与其 tool 响应之间的异类消息
|
// step 0.5(相邻性归位,909②):把楔入 assistant(tool_calls) 头与其 tool 响应之间的异类消息
|
||||||
// (后台压缩摘要 / 用户插话 / 警示 system)整体移到该组响应**之后**——只重排不删内容。
|
// (后台压缩摘要 / 用户插话 / 警示 system)整体移到该组响应**之后**——只重排不删内容。
|
||||||
// 归位后三元组恢复相邻,step1 的邻接判定即可正确识别为已闭合,不再走「未邻接即剥除」而**保住
|
// 归位后三元组恢复相邻,step1 的邻接判定即可正确识别为已闭合,不再走「未邻接即剥除」而**保住
|
||||||
// 工具轮**(避免丢结果致模型重跑/误判)。真正无法归位者(头无任何响应)仍由 step2/3 剥除兜底。
|
// 工具轮**(避免丢结果致模型重跑/误判)。对同一头的多段夹层同样生效(全部响应聚拢到头后紧邻)。
|
||||||
let messages = reorder_interlopers_after_tool_run(messages);
|
// 真正无法归位者(头无任何响应)仍由 step2/3 剥除兜底。
|
||||||
|
let messages = gather_tool_responses(messages);
|
||||||
|
|
||||||
// step 1:相邻性判定的依据——每个 assistant 头「紧后 tool 连续段」覆盖的 id 集合。
|
// step 1:相邻性判定的依据——每个 assistant 头「紧后 tool 连续段」覆盖的 id 集合。
|
||||||
// 口径与 openai_compat::sanitize_openai_triplets 一致(2026-10-09 收紧):
|
// 口径与 openai_compat::sanitize_openai_triplets 一致(2026-10-09 收紧):
|
||||||
@@ -203,17 +204,25 @@ pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
|||||||
ensure_sequence_legal(after_reverse_orphan)
|
ensure_sequence_legal(after_reverse_orphan)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 相邻性归位(909②,view-only):assistant 头与其 tool 响应之间夹有异类消息时,把夹层整体移到
|
/// 相邻性归位(909②,view-only):把 assistant 头的**全部** tool 响应聚拢到头后紧邻,夹层整体后移。
|
||||||
/// 该头「紧后连续 tool 段」之后。
|
|
||||||
///
|
///
|
||||||
/// 根因:协议不变式要求「tool 响应必须紧随其 assistant(tool_calls)」。后台压缩摘要在 head 已入、
|
/// 根因:协议不变式要求「tool 响应必须紧随其 assistant(tool_calls)」。后台压缩摘要在 head 已入、
|
||||||
/// 结果未回的窗口内楔入,或用户在执行期插话,都会产生 `[head, 异类, tool…]`。原逻辑对此按「未邻接
|
/// 结果未回的窗口内楔入,或用户在执行期插话,都会产生 `[head, 异类, tool…]`。原逻辑对此按「未邻接
|
||||||
/// = 未闭合」剥除头与其响应,**丢掉整轮工具交互**(有损)。本步改为**归位**——夹层后移,头与其响应
|
/// = 未闭合」剥除头与其响应,**丢掉整轮工具交互**(有损)。本步改为**归位**——夹层后移,头与其响应
|
||||||
/// 恢复相邻,工具轮保留;夹层内容亦不丢。仅处理「单头 + 夹层 + 紧随该头的连续 tool 段」这一可确定
|
/// 恢复相邻,工具轮保留;夹层内容亦不丢。
|
||||||
/// 归位的形态;遇到下一个 tool_call 头即停止收集(不跨单元搬运),其余畸形仍由 step2/3 剥除兜底。
|
///
|
||||||
|
/// 覆盖**多夹层**:同一头的响应之间又插了第二个夹层时(如 `[head(c1,c2), 异类, tool(c1), 异类,
|
||||||
|
/// tool(c2)]`),只后移首夹层会让后段 tool 仍不与头相邻 → step1 邻接判定只认前段 → 后段 call 被
|
||||||
|
/// 判未闭合而剥除(tool 一并丢)。本实现把「响应集合耗尽前出现的**所有**非 Tool 消息」整体移到
|
||||||
|
/// **该头最后一个响应之后**,头后紧邻其全部响应(保序),夹层随后。
|
||||||
|
///
|
||||||
|
/// 边界:遇下一个 tool_call 头即停(不跨单元搬运);该头响应集合(其 tool_calls 的 id)全部命中
|
||||||
|
/// 即停(不牵动响应之后的无关消息)。无法归位者(头无响应)仍由 step2/3 剥除兜底。
|
||||||
///
|
///
|
||||||
/// 幂等:归位后序列已相邻,再跑本函数无夹层可移,输出不变。
|
/// 幂等:归位后序列已相邻,再跑本函数无夹层可移,输出不变。
|
||||||
fn reorder_interlopers_after_tool_run(msgs: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
fn gather_tool_responses(msgs: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
fn is_tool_call_head(m: &ChatMessage) -> bool {
|
fn is_tool_call_head(m: &ChatMessage) -> bool {
|
||||||
matches!(m.role, MessageRole::Assistant)
|
matches!(m.role, MessageRole::Assistant)
|
||||||
&& m.tool_calls.as_ref().is_some_and(|c| !c.is_empty())
|
&& m.tool_calls.as_ref().is_some_and(|c| !c.is_empty())
|
||||||
@@ -226,21 +235,36 @@ fn reorder_interlopers_after_tool_run(msgs: Vec<ChatMessage>) -> Vec<ChatMessage
|
|||||||
let mut i = 0usize;
|
let mut i = 0usize;
|
||||||
while i < msgs.len() {
|
while i < msgs.len() {
|
||||||
if is_tool_call_head(&msgs[i]) {
|
if is_tool_call_head(&msgs[i]) {
|
||||||
// 收集头与首个 tool 之间的夹层(遇下一个 tool_call 头即停,不跨单元)
|
// 该头全部 call id —— 用于判定它的响应集合是否已耗尽
|
||||||
|
let call_ids: HashSet<String> = msgs[i]
|
||||||
|
.tool_calls
|
||||||
|
.as_ref()
|
||||||
|
.map(|c| c.iter().map(|c| c.id.clone()).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
// 从头后扫到「下一个 tool_call 头」或「该头响应集合耗尽」为止,分流响应与夹层
|
||||||
let mut j = i + 1;
|
let mut j = i + 1;
|
||||||
|
let mut responses: Vec<ChatMessage> = Vec::new();
|
||||||
let mut interlopers: Vec<ChatMessage> = Vec::new();
|
let mut interlopers: Vec<ChatMessage> = Vec::new();
|
||||||
while j < msgs.len() && !is_tool(&msgs[j]) && !is_tool_call_head(&msgs[j]) {
|
let mut seen: HashSet<String> = HashSet::new();
|
||||||
|
while j < msgs.len() && !is_tool_call_head(&msgs[j]) {
|
||||||
|
if is_tool(&msgs[j]) {
|
||||||
|
if let Some(id) = msgs[j].tool_call_id.as_deref() {
|
||||||
|
seen.insert(id.to_string());
|
||||||
|
}
|
||||||
|
responses.push(msgs[j].clone());
|
||||||
|
} else {
|
||||||
interlopers.push(msgs[j].clone());
|
interlopers.push(msgs[j].clone());
|
||||||
j += 1;
|
|
||||||
}
|
}
|
||||||
let run_start = j;
|
|
||||||
while j < msgs.len() && is_tool(&msgs[j]) {
|
|
||||||
j += 1;
|
j += 1;
|
||||||
|
// 该头响应集合已全部命中 → 不再牵动响应之后的无关消息(保持边界语义)
|
||||||
|
if !call_ids.is_empty() && call_ids.iter().all(|id| seen.contains(id)) {
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
// 有夹层且紧随其后确有 tool 段 → 头 + 响应在前,夹层后移(只重排)
|
}
|
||||||
if !interlopers.is_empty() && run_start < j {
|
// 有夹层且确有响应 → 头 + 全部响应在前,夹层整体后移(只重排)
|
||||||
|
if !interlopers.is_empty() && !responses.is_empty() {
|
||||||
out.push(msgs[i].clone());
|
out.push(msgs[i].clone());
|
||||||
out.extend(msgs[run_start..j].iter().cloned());
|
out.extend(responses);
|
||||||
out.extend(interlopers);
|
out.extend(interlopers);
|
||||||
i = j;
|
i = j;
|
||||||
continue;
|
continue;
|
||||||
@@ -694,6 +718,66 @@ mod tests {
|
|||||||
assert_eq!(sig(&once), sig(&twice), "归位应幂等(角色序与内容序不变)");
|
assert_eq!(sig(&once), sig(&twice), "归位应幂等(角色序与内容序不变)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 多夹层归位:同一头的响应之间又插了第二个夹层时,头与该头的**全部**响应必须聚拢相邻
|
||||||
|
/// (旧实现只后移首夹层,后段 tool 仍不与头相邻 → step1 判后段 call 未闭合 → 剥除并丢其 tool)。
|
||||||
|
#[test]
|
||||||
|
fn sanitize_reorders_multiple_interlopers_to_keep_tool_round() {
|
||||||
|
// [user] [assistant(c1,c2)] [system 夹层X] [tool c1] [system 夹层Y] [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("夹层X"),
|
||||||
|
ChatMessage::tool_result("c1", "r1"),
|
||||||
|
ChatMessage::system("夹层Y"),
|
||||||
|
ChatMessage::tool_result("c2", "r2"),
|
||||||
|
];
|
||||||
|
let out = sanitize_messages(msgs);
|
||||||
|
// 零丢失:6 条全在(归位只重排,不删内容)
|
||||||
|
assert_eq!(out.len(), 6, "多夹层归位不得丢内容, 实际 {:?}", out);
|
||||||
|
// 头保留其两个 tool_call(工具轮不丢)
|
||||||
|
let head_idx = out
|
||||||
|
.iter()
|
||||||
|
.position(|m| matches!(m.role, MessageRole::Assistant))
|
||||||
|
.expect("head 应保留");
|
||||||
|
assert_eq!(
|
||||||
|
out[head_idx].tool_calls.as_ref().map(|c| c.len()),
|
||||||
|
Some(2),
|
||||||
|
"头应保留两个 tool_call, 实际 {:?}",
|
||||||
|
out
|
||||||
|
);
|
||||||
|
// 头后紧邻其全部响应(保序 c1,c2),协议不变式成立
|
||||||
|
assert!(
|
||||||
|
matches!(out[head_idx + 1].role, MessageRole::Tool)
|
||||||
|
&& out[head_idx + 1].tool_call_id.as_deref() == Some("c1")
|
||||||
|
&& matches!(out[head_idx + 2].role, MessageRole::Tool)
|
||||||
|
&& out[head_idx + 2].tool_call_id.as_deref() == Some("c2"),
|
||||||
|
"head 后应紧邻其全部响应 c1,c2, 实际 {:?}",
|
||||||
|
out
|
||||||
|
);
|
||||||
|
// 两条夹层内容都保留(后移到响应之后)
|
||||||
|
assert!(
|
||||||
|
out.iter()
|
||||||
|
.any(|m| matches!(m.role, MessageRole::System) && m.content == "夹层X")
|
||||||
|
&& out.iter()
|
||||||
|
.any(|m| matches!(m.role, MessageRole::System) && m.content == "夹层Y"),
|
||||||
|
"两条夹层内容不得丢, 实际 {:?}",
|
||||||
|
out
|
||||||
|
);
|
||||||
|
|
||||||
|
// 幂等:再跑一次结果不变
|
||||||
|
let twice = sanitize_messages(out.clone());
|
||||||
|
let sig = |v: &[ChatMessage]| -> Vec<String> {
|
||||||
|
v.iter().map(|m| format!("{:?}|{}", m.role, m.content)).collect()
|
||||||
|
};
|
||||||
|
assert_eq!(sig(&out), sig(&twice), "多夹层归位应幂等(角色序与内容序不变)");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sanitize_heals_reverse_orphan_tool_result_with_placeholder_head() {
|
fn sanitize_heals_reverse_orphan_tool_result_with_placeholder_head() {
|
||||||
// 三元组完整性 P0:反向 orphan(tool_result 无对应 tool_call 头)→ 补占位头自愈(不丢)。
|
// 三元组完整性 P0:反向 orphan(tool_result 无对应 tool_call 头)→ 补占位头自愈(不丢)。
|
||||||
|
|||||||
Generated
-3438
File diff suppressed because it is too large
Load Diff
@@ -98,6 +98,7 @@ pub async fn cleanup_expired_approvals(app: &AppHandle, state: &AppState, conv_i
|
|||||||
&tc_id,
|
&tc_id,
|
||||||
"rejected",
|
"rejected",
|
||||||
Some(format!("审批超时({}分钟)自动取消", timeout_minutes)),
|
Some(format!("审批超时({}分钟)自动取消", timeout_minutes)),
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ use std::sync::Arc;
|
|||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
use df_ai::context::ContextManager;
|
||||||
use df_ai::context_helpers::{extract_keyword_summary, CompressedSummary};
|
use df_ai::context_helpers::{extract_keyword_summary, CompressedSummary};
|
||||||
use df_ai::provider::ChatMessage;
|
use df_ai::provider::ChatMessage;
|
||||||
use df_storage::models::AiProviderRecord;
|
use df_storage::models::AiProviderRecord;
|
||||||
@@ -59,6 +60,32 @@ const PROTECT_COUNT: usize = 6;
|
|||||||
/// 排障/对比用:置 false 即可观察无兜底时的裁剪效果。
|
/// 排障/对比用:置 false 即可观察无兜底时的裁剪效果。
|
||||||
const KEYWORD_FALLBACK_ENABLED: bool = true;
|
const KEYWORD_FALLBACK_ENABLED: bool = true;
|
||||||
|
|
||||||
|
/// 会话消息列表的内容版本哨兵(939⑪):首条消息的全局唯一 id(空列表 = None)。
|
||||||
|
///
|
||||||
|
/// 压缩**触发时**快照本值,后台 task **提交前**重新读取比对:相等才允许提交。
|
||||||
|
/// 首条消息 id 由 `new_message_id()` 全局唯一——loop 只 append(不改首条),而 `clear()`
|
||||||
|
/// 重建后首条必换新 id,故它是「上下文是否被整体替换」的准确且零成本哨兵。
|
||||||
|
/// 不用 len:loop 后台压缩期间正常 append 会使 len 增长,以 len 相等为守卫会误判版本
|
||||||
|
/// 不符致后台压缩永不生效。
|
||||||
|
fn head_id_of(messages: &mut ContextManager) -> Option<String> {
|
||||||
|
messages.messages_mut().first().and_then(|t| t.message.id.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 内容版本是否仍匹配(939⑪):快照与当前首条 id 相等才允许提交。
|
||||||
|
fn version_matches(snapshot: &Option<String>, current: &Option<String>) -> bool {
|
||||||
|
snapshot == current
|
||||||
|
}
|
||||||
|
|
||||||
|
/// panic 兜底复位(939⑫):清 is_compressing 防其永真致压缩永久停摆。
|
||||||
|
///
|
||||||
|
/// conv 已删(per_conv 无条目)跳过——标志随 conv 销毁无意义(对齐各正常出口语义)。
|
||||||
|
async fn reset_compressing_after_panic(session_arc: &Arc<Mutex<AiSession>>, conv_id: &str) {
|
||||||
|
let mut session = session_arc.lock().await;
|
||||||
|
if session.per_conv.contains_key(conv_id) {
|
||||||
|
session.conv(conv_id).messages.set_compressing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 自动压缩(智能裁剪)——在 build_for_request 之前预处理。
|
/// 自动压缩(智能裁剪)——在 build_for_request 之前预处理。
|
||||||
///
|
///
|
||||||
/// **L0-3(2026-08-09)后台化**:压缩 LLM 调用不再同步阻塞本轮 loop(非流式 LLM 最长 60s)。
|
/// **L0-3(2026-08-09)后台化**:压缩 LLM 调用不再同步阻塞本轮 loop(非流式 LLM 最长 60s)。
|
||||||
@@ -114,7 +141,7 @@ pub(super) async fn maybe_auto_compress(
|
|||||||
emit_ai_chat_event(app_handle, AiChatEvent::AiCompressing {
|
emit_ai_chat_event(app_handle, AiChatEvent::AiCompressing {
|
||||||
conversation_id: Some(conv_id.to_string()),
|
conversation_id: Some(conv_id.to_string()),
|
||||||
});
|
});
|
||||||
let (mut active_msgs, lang) = {
|
let (mut active_msgs, lang, head_id) = {
|
||||||
let mut session = session_arc.lock().await;
|
let mut session = session_arc.lock().await;
|
||||||
let conv = session.conv(&conv_id);
|
let conv = session.conv(&conv_id);
|
||||||
conv.messages.set_compressing(true);
|
conv.messages.set_compressing(true);
|
||||||
@@ -123,6 +150,9 @@ pub(super) async fn maybe_auto_compress(
|
|||||||
// 切片前按当时实际长度钳制,防 index out of bounds panic
|
// 切片前按当时实际长度钳制,防 index out of bounds panic
|
||||||
// (后续 compress_old_messages 内部自带 min(len) 钳制,口径一致)。
|
// (后续 compress_old_messages 内部自带 min(len) 钳制,口径一致)。
|
||||||
let msgs = conv.messages.messages_mut();
|
let msgs = conv.messages.messages_mut();
|
||||||
|
// 939⑪ 内容版本哨兵:快照触发时首条消息 id,后台 task 提交前比对
|
||||||
|
// (clear() 重建后闭包内提交会落到新上下文,靠此守卫丢弃)。
|
||||||
|
let head_id = msgs.first().and_then(|t| t.message.id.clone());
|
||||||
let cut = protect_start.min(msgs.len());
|
let cut = protect_start.min(msgs.len());
|
||||||
let active_msgs: Vec<ChatMessage> = msgs[..cut]
|
let active_msgs: Vec<ChatMessage> = msgs[..cut]
|
||||||
.iter()
|
.iter()
|
||||||
@@ -131,7 +161,7 @@ pub(super) async fn maybe_auto_compress(
|
|||||||
.collect();
|
.collect();
|
||||||
let lang = conv.agent_language.clone()
|
let lang = conv.agent_language.clone()
|
||||||
.unwrap_or_else(|| "zh-CN".to_string());
|
.unwrap_or_else(|| "zh-CN".to_string());
|
||||||
(active_msgs, lang)
|
(active_msgs, lang, head_id)
|
||||||
};
|
};
|
||||||
|
|
||||||
// T2-修复(方案A):展开 namespace 引用为真实内容,再喂压缩 LLM/关键词摘要。
|
// T2-修复(方案A):展开 namespace 引用为真实内容,再喂压缩 LLM/关键词摘要。
|
||||||
@@ -203,7 +233,15 @@ pub(super) async fn maybe_auto_compress(
|
|||||||
let conv_id = conv_id.to_string();
|
let conv_id = conv_id.to_string();
|
||||||
let provider_config = provider_config.clone();
|
let provider_config = provider_config.clone();
|
||||||
let llm_concurrency = llm_concurrency.clone();
|
let llm_concurrency = llm_concurrency.clone();
|
||||||
|
// 939⑫ panic 兜底句柄:内层 async move 会消费 session_arc/conv_id,panic 分支
|
||||||
|
// 需另持一份用于复位 is_compressing(防其永真致压缩永久停摆)。
|
||||||
|
let panic_session = session_arc.clone();
|
||||||
|
let panic_conv_id = conv_id.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
// 939⑫ 整个后台 body 包 catch_unwind(复用 mod.rs::unwind_to_result 薄包装):
|
||||||
|
// panic 以 Err 返回 → 复位 is_compressing + warn,防压缩永久停摆。依赖
|
||||||
|
// panic=unwind(tokio 默认);panic=abort 构建下退化为进程终止(尽力兜底)。
|
||||||
|
let outcome = super::unwind_to_result(async move {
|
||||||
// 后台重建 provider(secret::build_provider_for 三步打包:resolve→ensure→build)。
|
// 后台重建 provider(secret::build_provider_for 三步打包:resolve→ensure→build)。
|
||||||
// 失败 → 同 LLM 失败降级:复位 is_compressing + 关键词摘要兜底(标 compressed 释放
|
// 失败 → 同 LLM 失败降级:复位 is_compressing + 关键词摘要兜底(标 compressed 释放
|
||||||
// token + 插关键词),保持「失败→兜底→扣 token」语义——否则 history_tokens 未降,
|
// token + 插关键词),保持「失败→兜底→扣 token」语义——否则 history_tokens 未降,
|
||||||
@@ -227,6 +265,7 @@ pub(super) async fn maybe_auto_compress(
|
|||||||
&keyword_fallback,
|
&keyword_fallback,
|
||||||
pre_compress_tokens,
|
pre_compress_tokens,
|
||||||
"压缩 provider 重建失败",
|
"压缩 provider 重建失败",
|
||||||
|
&head_id,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
return;
|
return;
|
||||||
@@ -256,6 +295,16 @@ pub(super) async fn maybe_auto_compress(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let conv = session.conv(&conv_id);
|
let conv = session.conv(&conv_id);
|
||||||
|
// 939⑪ 内容版本守卫:提交前比对触发时快照,不符(期间 clear() 重建致
|
||||||
|
// 上下文整体替换)→ 丢弃压缩结果。不复位 is_compressing(clear 已复位;
|
||||||
|
// 若新会话已启新压缩,此处复位会误伤新压缩信令)。
|
||||||
|
if !version_matches(&head_id, &head_id_of(&mut conv.messages)) {
|
||||||
|
tracing::warn!(
|
||||||
|
conv_id = %conv_id,
|
||||||
|
"[ai] 压缩完成但上下文版本已变(疑似 clear 重建),丢弃旧摘要"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let _compressed = conv.messages.compress_old_messages(protect_start);
|
let _compressed = conv.messages.compress_old_messages(protect_start);
|
||||||
// NL 摘要尾部追加(而非 insert_at(0) 头部插入):头部插入会位移整条
|
// NL 摘要尾部追加(而非 insert_at(0) 头部插入):头部插入会位移整条
|
||||||
// 前缀字节,使 DeepSeek 等自动前缀缓存的 provider 全部缓存失效
|
// 前缀字节,使 DeepSeek 等自动前缀缓存的 provider 全部缓存失效
|
||||||
@@ -284,7 +333,11 @@ pub(super) async fn maybe_auto_compress(
|
|||||||
// 单次锁内 check+mutate(防 conv 在两次锁间被删致惰性重建污染新 conv)。
|
// 单次锁内 check+mutate(防 conv 在两次锁间被删致惰性重建污染新 conv)。
|
||||||
let mut session = session_arc.lock().await;
|
let mut session = session_arc.lock().await;
|
||||||
if session.per_conv.contains_key(&conv_id) {
|
if session.per_conv.contains_key(&conv_id) {
|
||||||
session.conv(&conv_id).messages.set_compressing(false);
|
let conv = session.conv(&conv_id);
|
||||||
|
// 939⑪ 版本守卫:上下文已被整体替换 → 不复位(否则误伤新会话信令)。
|
||||||
|
if version_matches(&head_id, &head_id_of(&mut conv.messages)) {
|
||||||
|
conv.messages.set_compressing(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -316,10 +369,21 @@ pub(super) async fn maybe_auto_compress(
|
|||||||
&keyword_fallback,
|
&keyword_fallback,
|
||||||
pre_compress_tokens,
|
pre_compress_tokens,
|
||||||
"压缩失败",
|
"压缩失败",
|
||||||
|
&head_id,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}) // 闭 catch_unwind 内 async move
|
||||||
|
.await;
|
||||||
|
if let Err(msg) = outcome {
|
||||||
|
reset_compressing_after_panic(&panic_session, &panic_conv_id).await;
|
||||||
|
tracing::warn!(
|
||||||
|
conv_id = %panic_conv_id,
|
||||||
|
error = %msg,
|
||||||
|
"[ai] 后台压缩 task panic,已复位 is_compressing(防压缩永久停摆)"
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -341,6 +405,10 @@ pub(super) async fn maybe_auto_compress(
|
|||||||
///
|
///
|
||||||
/// conv 已删(per_conv 无条目)→ 直接返回(标志随 conv 销毁无需复位;对齐原 return 语义:
|
/// conv 已删(per_conv 无条目)→ 直接返回(标志随 conv 销毁无需复位;对齐原 return 语义:
|
||||||
/// 防惰性重建污染新 conv)。
|
/// 防惰性重建污染新 conv)。
|
||||||
|
///
|
||||||
|
/// **内容版本守卫(939⑪)**:`head_id` 为触发时快照的首条消息 id;提交前比对不符(期间
|
||||||
|
/// `clear()` 重建致上下文整体替换)→ 直接返回:既不标 compressed / 不插旧关键词摘要污染
|
||||||
|
/// 新上下文,也不复位 is_compressing(clear 已复位;复位会误伤新会话可能已启的新压缩信令)。
|
||||||
async fn keyword_fallback_commit(
|
async fn keyword_fallback_commit(
|
||||||
session_arc: &Arc<Mutex<AiSession>>,
|
session_arc: &Arc<Mutex<AiSession>>,
|
||||||
conv_id: &str,
|
conv_id: &str,
|
||||||
@@ -348,6 +416,7 @@ async fn keyword_fallback_commit(
|
|||||||
keyword_fallback: &str,
|
keyword_fallback: &str,
|
||||||
pre_tokens: u32,
|
pre_tokens: u32,
|
||||||
log_head: &str,
|
log_head: &str,
|
||||||
|
head_id: &Option<String>,
|
||||||
) {
|
) {
|
||||||
let inserted = {
|
let inserted = {
|
||||||
let mut session = session_arc.lock().await;
|
let mut session = session_arc.lock().await;
|
||||||
@@ -355,6 +424,15 @@ async fn keyword_fallback_commit(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let conv = session.conv(conv_id);
|
let conv = session.conv(conv_id);
|
||||||
|
// 939⑪ 内容版本守卫:上下文已被整体替换 → 丢弃兜底提交(标志亦不复位)。
|
||||||
|
if !version_matches(head_id, &head_id_of(&mut conv.messages)) {
|
||||||
|
tracing::warn!(
|
||||||
|
conv_id = %conv_id,
|
||||||
|
"[ai] {}兜底提交前上下文版本已变(疑似 clear 重建),丢弃",
|
||||||
|
log_head
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// 复位与提交同锁原子(909①-补):消「锁外复位→再取锁提交」之间的并发二次压缩窗口。
|
// 复位与提交同锁原子(909①-补):消「锁外复位→再取锁提交」之间的并发二次压缩窗口。
|
||||||
conv.messages.set_compressing(false);
|
conv.messages.set_compressing(false);
|
||||||
if !KEYWORD_FALLBACK_ENABLED {
|
if !KEYWORD_FALLBACK_ENABLED {
|
||||||
@@ -382,3 +460,123 @@ async fn keyword_fallback_commit(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// 建 conv 并灌 n 条 active 消息,置 is_compressing(true)(模拟触发时已置位)。
|
||||||
|
/// 返回触发时的内容版本哨兵(首条消息 id),供 keyword_fallback_commit 的版本守卫比对。
|
||||||
|
async fn seed_compressing(arc: &Arc<Mutex<AiSession>>, conv_id: &str, n: usize) -> Option<String> {
|
||||||
|
let mut session = arc.lock().await;
|
||||||
|
let conv = session.conv(conv_id);
|
||||||
|
for i in 0..n {
|
||||||
|
conv.messages.push(if i % 2 == 0 {
|
||||||
|
ChatMessage::user(format!("用户消息{i}"))
|
||||||
|
} else {
|
||||||
|
ChatMessage::assistant(format!("助手回复{i}"))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
conv.messages.set_compressing(true);
|
||||||
|
head_id_of(&mut conv.messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 非空关键词:复位 is_compressing 与兜底提交(标 compressed + 尾部插锚点)一并生效。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn keyword_fallback_commit_resets_and_inserts() {
|
||||||
|
let arc = Arc::new(Mutex::new(AiSession::new()));
|
||||||
|
let head = seed_compressing(&arc, "c1", 4).await;
|
||||||
|
|
||||||
|
keyword_fallback_commit(&arc, "c1", 4, "关键词锚点", 0, "压缩失败", &head).await;
|
||||||
|
|
||||||
|
let session = arc.lock().await;
|
||||||
|
let conv = session.conv_read("c1").expect("conv 应存在");
|
||||||
|
assert!(!conv.messages.is_compressing(), "提交后 is_compressing 应复位 false");
|
||||||
|
let msgs = conv.messages.all_messages_clone();
|
||||||
|
assert_eq!(msgs.len(), 5, "压缩后应尾部追加 1 条关键词 system 锚点");
|
||||||
|
for (i, m) in msgs[..4].iter().enumerate() {
|
||||||
|
assert!(!m.is_active(), "第 {i} 条原消息应被标 compressed(释放 token)");
|
||||||
|
}
|
||||||
|
assert_eq!(msgs[4].content, "关键词锚点", "末条应为关键词锚点正文");
|
||||||
|
assert!(
|
||||||
|
matches!(msgs[4].role, df_ai::provider::MessageRole::System),
|
||||||
|
"锚点应为 system 角色"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 空关键词:复位不能丢(否则 is_compressing 永真 → 压缩永久停摆),但不插锚点。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn keyword_fallback_commit_empty_keeps_reset() {
|
||||||
|
let arc = Arc::new(Mutex::new(AiSession::new()));
|
||||||
|
let head = seed_compressing(&arc, "c2", 4).await;
|
||||||
|
|
||||||
|
keyword_fallback_commit(&arc, "c2", 4, "", 0, "压缩 provider 重建失败", &head).await;
|
||||||
|
|
||||||
|
let session = arc.lock().await;
|
||||||
|
let conv = session.conv_read("c2").expect("conv 应存在");
|
||||||
|
assert!(!conv.messages.is_compressing(), "关键词为空仍应复位 is_compressing");
|
||||||
|
assert_eq!(conv.messages.len(), 4, "关键词为空不应插锚点,len 不变");
|
||||||
|
for m in conv.messages.all_messages_clone() {
|
||||||
|
assert!(!m.is_active(), "空关键词路径仍应标 compressed 扣 token");
|
||||||
|
}
|
||||||
|
assert_eq!(conv.messages.history_tokens(), 0, "无锚点插入,token 应清零");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// conv 已删(per_conv 无条目):早返不惰性重建(防污染新 conv)。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn keyword_fallback_commit_missing_conv_noop() {
|
||||||
|
let arc = Arc::new(Mutex::new(AiSession::new()));
|
||||||
|
|
||||||
|
keyword_fallback_commit(&arc, "gone", 4, "锚点", 0, "压缩失败", &None).await;
|
||||||
|
|
||||||
|
let session = arc.lock().await;
|
||||||
|
assert!(
|
||||||
|
session.per_conv.is_empty(),
|
||||||
|
"conv 已删不应惰性重建 per_conv 条目"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 939⑪⑫ 后台压缩守卫契约:纯函数级断言(版本不符→丢弃 / panic→标志复位)。
|
||||||
|
#[cfg(test)]
|
||||||
|
mod guard_tests {
|
||||||
|
use super::{head_id_of, reset_compressing_after_panic, version_matches, AiSession};
|
||||||
|
use df_ai::context::{ContextConfig, ContextManager};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
// 939⑪:内容版本不符(首条 id 变化 / 列表被清空 / 空→非空)即判定丢弃。
|
||||||
|
#[test]
|
||||||
|
fn version_guard_discards_on_head_change() {
|
||||||
|
let a = Some("msg_a".to_string());
|
||||||
|
let b = Some("msg_b".to_string());
|
||||||
|
assert!(version_matches(&a, &a), "同版本必须放行");
|
||||||
|
assert!(!version_matches(&a, &b), "首条 id 变(clear 重建)必须丢弃");
|
||||||
|
assert!(!version_matches(&a, &None), "列表被清空必须丢弃");
|
||||||
|
assert!(!version_matches(&None, &a), "空→非空(新会话)必须丢弃");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 939⑪:空列表的内容版本哨兵为 None。
|
||||||
|
#[test]
|
||||||
|
fn head_id_of_empty_list_is_none() {
|
||||||
|
let mut mgr = ContextManager::new(ContextConfig::default());
|
||||||
|
assert_eq!(head_id_of(&mut mgr), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 939⑫:后台 body panic → is_compressing 被复位,压缩不停摆。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn panic_resets_compressing_flag() {
|
||||||
|
let session = Arc::new(Mutex::new(AiSession::new()));
|
||||||
|
let conv_id = "conv-panic";
|
||||||
|
session.lock().await.conv(conv_id).messages.set_compressing(true);
|
||||||
|
// 模拟后台 body panic,经 catch_unwind 薄包装转受控 Err
|
||||||
|
let r = super::super::unwind_to_result(async { panic!("boom in compress task") }).await;
|
||||||
|
assert!(r.is_err(), "panic 应被捕获为 Err");
|
||||||
|
// panic 分支:复位 is_compressing
|
||||||
|
reset_compressing_after_panic(&session, conv_id).await;
|
||||||
|
assert!(
|
||||||
|
!session.lock().await.conv_read(conv_id).unwrap().messages.is_compressing(),
|
||||||
|
"panic 后 is_compressing 必须复位,否则压缩永久停摆"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ use super::conversation::{save_conversation, TokenAccumulator};
|
|||||||
use super::knowledge_inject::{maybe_spawn_extraction};
|
use super::knowledge_inject::{maybe_spawn_extraction};
|
||||||
use super::title::{ensure_conversation_title, spawn_ensure_title};
|
use super::title::{ensure_conversation_title, spawn_ensure_title};
|
||||||
use super::{AiChatEvent, AiSession, ErrorType};
|
use super::{AiChatEvent, AiSession, ErrorType};
|
||||||
|
// B5a 挂钟闸分任务定级:轮间闸读会话策略(未注册落 legacy 硬停,确定性任务检查点续跑)。
|
||||||
|
use super::unattended::{wall_clock_policy_for, WallClockAction};
|
||||||
// ConvState 经本文件内 `pub mod conv_state;` 同 crate 直接访问(conv_state::ConvState)。
|
// ConvState 经本文件内 `pub mod conv_state;` 同 crate 直接访问(conv_state::ConvState)。
|
||||||
|
|
||||||
/// L1 补丁:run_agentic_loop 入口 provider 解析超时保护的内部错误类型。
|
/// L1 补丁:run_agentic_loop 入口 provider 解析超时保护的内部错误类型。
|
||||||
@@ -102,6 +104,8 @@ pub const ROUND_REENTRY_BACKOFF_SECS: [u64; ROUND_REENTRY_MAX] = [60, 300, 900];
|
|||||||
/// 无人值守长任务(max_iterations=0 不限轮次)缺 token/挂钟兜底——模型死循环级输出
|
/// 无人值守长任务(max_iterations=0 不限轮次)缺 token/挂钟兜底——模型死循环级输出
|
||||||
/// 白烧预算无人踩闸。三闸在每轮头检查(轮间粒度,单轮内不中断):
|
/// 白烧预算无人踩闸。三闸在每轮头检查(轮间粒度,单轮内不中断):
|
||||||
/// ①挂钟:超 UNATTENDED_WALL_SECS(默认 30min,对齐委托任务超时底座)停靠 wall_clock;
|
/// ①挂钟:超 UNATTENDED_WALL_SECS(默认 30min,对齐委托任务超时底座)停靠 wall_clock;
|
||||||
|
/// 未注册分档的会话落此 legacy 阈值;已注册会话由 unattended::wall_clock_policy_for 的
|
||||||
|
/// 策略决定(确定性任务放宽+检查点续跑,探索性/失控疑似硬停),见下方挂钟闸;
|
||||||
/// ②累计 token:超 UNATTENDED_MAX_TOKENS(默认 5M)停靠 token_budget;
|
/// ②累计 token:超 UNATTENDED_MAX_TOKENS(默认 5M)停靠 token_budget;
|
||||||
/// ③轮次:max_iterations>0 既有 for 上界 + 达上限停靠分支,不重复设闸。
|
/// ③轮次:max_iterations>0 既有 for 上界 + 达上限停靠分支,不重复设闸。
|
||||||
/// 超限走既有 emit_task_parked——前端词表已预留,零新契约。
|
/// 超限走既有 emit_task_parked——前端词表已预留,零新契约。
|
||||||
@@ -1163,8 +1167,9 @@ pub(crate) async fn run_agentic_loop(
|
|||||||
// 无注入扩轮时动态判定与原上界等价,行为零变化。扩轮总数封顶防注入洪水无限续跑。
|
// 无注入扩轮时动态判定与原上界等价,行为零变化。扩轮总数封顶防注入洪水无限续跑。
|
||||||
let mut injected_extra_rounds: usize = 0;
|
let mut injected_extra_rounds: usize = 0;
|
||||||
|
|
||||||
// H2 token 三闸基点:loop 启动挂钟起点(轮间粒度检查,单轮不中断)
|
// H2 token 三闸基点:loop 启动挂钟起点(轮间粒度检查,单轮不中断)。
|
||||||
let loop_started = tokio::time::Instant::now();
|
// 可变:确定性任务到点走检查点续跑时顺延起点(见下方挂钟闸)。
|
||||||
|
let mut loop_started = tokio::time::Instant::now();
|
||||||
|
|
||||||
// H3 外部干预感知基点:loop 启动时在被监视工作区取 git HEAD 快照(轮间粒度比对)。
|
// H3 外部干预感知基点:loop 启动时在被监视工作区取 git HEAD 快照(轮间粒度比对)。
|
||||||
// 设计口径:loop_epoch 管「引擎内接管」,H3 管「引擎外干预」——用户/另一 AI 会话在 loop
|
// 设计口径:loop_epoch 管「引擎内接管」,H3 管「引擎外干预」——用户/另一 AI 会话在 loop
|
||||||
@@ -1219,10 +1224,30 @@ pub(crate) async fn run_agentic_loop(
|
|||||||
{
|
{
|
||||||
let elapsed = loop_started.elapsed().as_secs();
|
let elapsed = loop_started.elapsed().as_secs();
|
||||||
let total_tokens = tokens.total() as u64;
|
let total_tokens = tokens.total() as u64;
|
||||||
let over = if elapsed >= UNATTENDED_WALL_SECS {
|
// ── 挂钟闸(B5a):读会话策略决定阈值与到点动作 ──
|
||||||
|
// None(未注册/分档关)→ legacy 30min 硬停,零行为变更;
|
||||||
|
// CheckpointContinue(确定性任务)→ 到点存进度 + 顺延挂钟起点后本轮续跑,不停工;
|
||||||
|
// HardPark(探索性/失控疑似)→ 维持原 emit_task_parked(wall_clock) + teardown。
|
||||||
|
let wall_policy = wall_clock_policy_for(&conv_id);
|
||||||
|
let wall_deadline = wall_policy.map_or(UNATTENDED_WALL_SECS, |p| p.deadline_secs);
|
||||||
|
let mut wall_exceeded = elapsed >= wall_deadline;
|
||||||
|
if wall_exceeded
|
||||||
|
&& matches!(wall_policy.map(|p| p.action), Some(WallClockAction::CheckpointContinue))
|
||||||
|
{
|
||||||
|
// 检查点续跑:落库当前进度(不等 loop 出口),挂钟起点顺延,本轮继续。
|
||||||
|
let save_usage = usage_delta_since(&tokens, &mut saved_token_snapshot);
|
||||||
|
save_conversation(&session_arc, &db, &conv_id, Some(&save_usage), Some(&resolved_model), true).await;
|
||||||
|
loop_started = tokio::time::Instant::now();
|
||||||
|
wall_exceeded = false;
|
||||||
|
tracing::info!(
|
||||||
|
conv_id = %conv_id, elapsed, deadline = wall_deadline,
|
||||||
|
"[ai] 挂钟到点,检查点续跑(确定性任务)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let over = if wall_exceeded {
|
||||||
Some((
|
Some((
|
||||||
"wall_clock",
|
"wall_clock",
|
||||||
format!("任务运行超 {} 分钟,安全停靠:已完成进度已保存,点继续或重发消息可续跑", UNATTENDED_WALL_SECS / 60),
|
format!("任务运行超 {} 分钟,安全停靠:已完成进度已保存,点继续或重发消息可续跑", wall_deadline / 60),
|
||||||
))
|
))
|
||||||
} else if total_tokens >= UNATTENDED_MAX_TOKENS {
|
} else if total_tokens >= UNATTENDED_MAX_TOKENS {
|
||||||
Some((
|
Some((
|
||||||
@@ -1364,9 +1389,13 @@ pub(crate) async fn run_agentic_loop(
|
|||||||
"[ai] 连续 {} 轮失败,重入预算耗尽,任务停靠",
|
"[ai] 连续 {} 轮失败,重入预算耗尽,任务停靠",
|
||||||
consecutive_round_failures,
|
consecutive_round_failures,
|
||||||
);
|
);
|
||||||
|
// 停靠语义:超时类(上游收了请求不响应,非确定性)与其他 Fatal 走
|
||||||
|
// 不同文案——超时突出「可继续」,由停靠卡「继续」一键续跑而非中断;
|
||||||
|
// 其余 Fatal 维持原连续失败文案(确定性错误,续跑无益)。
|
||||||
|
let reason = fatal_park_reason(consecutive_round_failures, &error);
|
||||||
crate::commands::ai::emit_task_parked(
|
crate::commands::ai::emit_task_parked(
|
||||||
&app_handle, &conv_id,
|
&app_handle, &conv_id,
|
||||||
&format!("模型调用连续 {} 轮失败(重入退避后仍未恢复),任务安全停靠:{}", consecutive_round_failures, error),
|
&reason,
|
||||||
"fatal",
|
"fatal",
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -2778,6 +2807,34 @@ fn classify_permanent_provider_error(error: &str) -> bool {
|
|||||||
model_missing || no_access || param_reject
|
model_missing || no_access || param_reject
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── is_timeout_error: 超时类失败判定(首字节超时/流式超时/调用超时) ──
|
||||||
|
// 超时是「上游收了请求却不响应」的非确定性失败:重入退避后仍未恢复时,续跑可能因上游恢复
|
||||||
|
// 而成功,故落停靠语义给一键「继续」,而非让用户只见裸错误文案后中断。与之相对,4xx/鉴权/
|
||||||
|
// 参数类 Fatal 是确定性错误(改配置才有解,续跑无益),不套用本判定——判定只读错误文本的
|
||||||
|
// 超时特征(引擎各超时出口文案含「超时」或 diag 包装的 timeout 分类)。
|
||||||
|
fn is_timeout_error(error: &str) -> bool {
|
||||||
|
error.to_lowercase().contains("timeout") || error.contains("超时")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重入预算耗尽时的停靠文案决策(纯函数,便于单测锁定「超时类 → 停靠」契约)。
|
||||||
|
///
|
||||||
|
/// 超时类走「超时 + 可续跑」文案(点明可一键继续);其余 Fatal 维持原「连续失败」文案。
|
||||||
|
/// stop_kind 均用既有 "fatal"(不新增停靠族:前端 stop_kind 词表/i18n 未同步,新值会退化
|
||||||
|
/// 为中性兜底标签;超时与其他 Fatal 的区分落在文案语义,卡片「继续」按钮两者皆已具备)。
|
||||||
|
fn fatal_park_reason(failures: usize, error: &str) -> String {
|
||||||
|
if is_timeout_error(error) {
|
||||||
|
format!(
|
||||||
|
"模型响应连续 {} 轮超时(上游收了请求未及时响应,重入退避后仍未恢复),任务安全停靠:已完成进度已保存,点「继续」可基于当前上下文续跑",
|
||||||
|
failures
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"模型调用连续 {} 轮失败(重入退避后仍未恢复),任务安全停靠:{}",
|
||||||
|
failures, error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── emit_fatal_error: 致命错误统一 emit AiError(扁平抽自原 Fatal/Exhausted 双分支重复代码) ──
|
// ── emit_fatal_error: 致命错误统一 emit AiError(扁平抽自原 Fatal/Exhausted 双分支重复代码) ──
|
||||||
// 双写经 emit_ai_chat_event。error_type: 可判定的永久性 provider 错误(模型不存在/令牌无权/
|
// 双写经 emit_ai_chat_event。error_type: 可判定的永久性 provider 错误(模型不存在/令牌无权/
|
||||||
// 参数拒绝类 400)归 ProviderConfig,其余维持 Network 现状(2026-10-01 两端统一时的口径)。
|
// 参数拒绝类 400)归 ProviderConfig,其余维持 Network 现状(2026-10-01 两端统一时的口径)。
|
||||||
@@ -3502,6 +3559,56 @@ mod round_reentry_tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 902 契约:超时类失败在轮次重入预算耗尽时落停靠语义(而非只留裸错误文案中断)。
|
||||||
|
// 停靠文案由纯函数 fatal_park_reason 决策,本测试锁「超时类 → 超时+可续跑文案 /
|
||||||
|
// 非超时 Fatal → 原连续失败文案」两分支(完整 loop 依赖 Tauri 运行时,契约由纯函数锁定)。
|
||||||
|
#[cfg(test)]
|
||||||
|
mod fatal_park_tests {
|
||||||
|
use super::{fatal_park_reason, is_timeout_error};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn timeout_class_recognized() {
|
||||||
|
// 首字节超时(stream_recv 出口文案,全角括号)
|
||||||
|
assert!(is_timeout_error(
|
||||||
|
"流式响应超时(10秒未收到首字节,服务无响应或请求体过大)"
|
||||||
|
));
|
||||||
|
// 专用线程 120s 硬超时
|
||||||
|
assert!(is_timeout_error("LLM 调用超时(120s)"));
|
||||||
|
// diag 包装的英文分类(timeout)
|
||||||
|
assert!(is_timeout_error(
|
||||||
|
"[u-switch] 流式接收错误(timeout): request timed out"
|
||||||
|
));
|
||||||
|
// 非超时:4xx/鉴权/模型不存在不误判
|
||||||
|
assert!(!is_timeout_error(
|
||||||
|
"[x] AI 调用失败(HTTP 401): unauthorized"
|
||||||
|
));
|
||||||
|
assert!(!is_timeout_error("model is not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn timeout_exhaustion_parks_with_continue_guidance() {
|
||||||
|
let reason = fatal_park_reason(4, "LLM 调用超时(120s)");
|
||||||
|
assert!(reason.contains("超时"), "超时类停靠文案须点明超时: {reason}");
|
||||||
|
assert!(
|
||||||
|
reason.contains("继续"),
|
||||||
|
"超时类停靠文案须给一键「继续」指引: {reason}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_timeout_fatal_keeps_generic_reason() {
|
||||||
|
let reason = fatal_park_reason(4, "[x] AI 调用失败(HTTP 404): model not found");
|
||||||
|
assert!(
|
||||||
|
reason.contains("连续 4 轮失败"),
|
||||||
|
"非超时 Fatal 维持原连续失败文案: {reason}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
reason.contains("model not found"),
|
||||||
|
"原诊断文本须保留: {reason}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 末轮注入扩轮(todo.md 28)loop 侧契约:到达原轮次终点时,注入侧顺延的余量必须被消费
|
// 末轮注入扩轮(todo.md 28)loop 侧契约:到达原轮次终点时,注入侧顺延的余量必须被消费
|
||||||
// 续跑(「末轮注入→有续轮」),无余量则收尾(与原 for 自然结束等价,零行为变化)。
|
// 续跑(「末轮注入→有续轮」),无余量则收尾(与原 for 自然结束等价,零行为变化)。
|
||||||
// 与注入侧判定 injection_hits_final_round(commands/ai/commands/chat.rs)分半锁同一契约。
|
// 与注入侧判定 injection_hits_final_round(commands/ai/commands/chat.rs)分半锁同一契约。
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ pub(super) async fn insert_pending_approval(
|
|||||||
conversation_id: Some(conv_id.to_string()),
|
conversation_id: Some(conv_id.to_string()),
|
||||||
};
|
};
|
||||||
emit_ai_chat_event(&app_handle, ev);
|
emit_ai_chat_event(&app_handle, ev);
|
||||||
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "skipped_retry", risk_level, Some(skip_msg), Some("auto_retry_guard"), current_message_id).await;
|
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "skipped_retry", risk_level, Some(skip_msg), Some("auto_retry_guard"), current_message_id, None).await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,7 +224,7 @@ pub(super) async fn insert_pending_approval(
|
|||||||
"等待工具审批",
|
"等待工具审批",
|
||||||
&format!("工具 {} 已暂停,批准后继续执行", draft.name),
|
&format!("工具 {} 已暂停,批准后继续执行", draft.name),
|
||||||
);
|
);
|
||||||
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "pending", risk_level, None, None, current_message_id).await;
|
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "pending", risk_level, None, None, current_message_id, None).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// (容错/恢复,开关 `df-ai-approval-retry`):查审计表推算同 (会话, 工具, 参数) 重试计数。
|
/// (容错/恢复,开关 `df-ai-approval-retry`):查审计表推算同 (会话, 工具, 参数) 重试计数。
|
||||||
@@ -366,7 +366,7 @@ pub(super) async fn handle_approval_tool(
|
|||||||
};
|
};
|
||||||
emit_ai_chat_event(&app_handle, ev);
|
emit_ai_chat_event(&app_handle, ev);
|
||||||
// 审计:去重命中记一条(status 透传缓存来源 completed/rejected/failed,;decided_by=auto_dedup),不进 pending
|
// 审计:去重命中记一条(status 透传缓存来源 completed/rejected/failed,;decided_by=auto_dedup),不进 pending
|
||||||
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, &status, risk_level, Some(warned_content), Some("auto_dedup"), current_message_id).await;
|
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, &status, risk_level, Some(warned_content), Some("auto_dedup"), current_message_id, None).await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
//! re-export 经 audit/mod.rs `pub(crate) use finalize::audit_finalize;` 保持
|
//! re-export 经 audit/mod.rs `pub(crate) use finalize::audit_finalize;` 保持
|
||||||
//! `commands::ai::audit::*` 路径透明(调用方零变更)。
|
//! `commands::ai::audit::*` 路径透明(调用方零变更)。
|
||||||
|
|
||||||
|
use crate::commands::ai::tool_result_offload;
|
||||||
use crate::commands::now_millis;
|
use crate::commands::now_millis;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
@@ -12,7 +13,13 @@ use crate::state::AppState;
|
|||||||
/// 走专用 find_by_tool_call_id —— 通用 query 宏硬编码 ORDER BY created_at DESC,
|
/// 走专用 find_by_tool_call_id —— 通用 query 宏硬编码 ORDER BY created_at DESC,
|
||||||
/// 而 ai_tool_executions 无该列,调用会报 "no such column: created_at" 被 unwrap_or_default 吞掉,
|
/// 而 ai_tool_executions 无该列,调用会报 "no such column: created_at" 被 unwrap_or_default 吞掉,
|
||||||
/// 导致审批后审计记录永久卡 pending。
|
/// 导致审批后审计记录永久卡 pending。
|
||||||
pub(crate) async fn audit_finalize(state: &AppState, tool_call_id: &str, status: &str, result: Option<String>) {
|
pub(crate) async fn audit_finalize(
|
||||||
|
state: &AppState,
|
||||||
|
tool_call_id: &str,
|
||||||
|
status: &str,
|
||||||
|
result: Option<String>,
|
||||||
|
duration_ms: Option<i64>,
|
||||||
|
) {
|
||||||
// 区分 Err(DB 故障)与 Ok(None)(真无记录):原 unwrap_or_default 把 Err 压成 None,
|
// 区分 Err(DB 故障)与 Ok(None)(真无记录):原 unwrap_or_default 把 Err 压成 None,
|
||||||
// DB 故障被「未找到」日志掩盖,审批后审计记录卡 pending 无确诊线索。
|
// DB 故障被「未找到」日志掩盖,审批后审计记录卡 pending 无确诊线索。
|
||||||
let mut rec = match state.ai_tool_executions.find_by_tool_call_id(tool_call_id).await {
|
let mut rec = match state.ai_tool_executions.find_by_tool_call_id(tool_call_id).await {
|
||||||
@@ -29,8 +36,13 @@ pub(crate) async fn audit_finalize(state: &AppState, tool_call_id: &str, status:
|
|||||||
rec.status = status.to_string();
|
rec.status = status.to_string();
|
||||||
rec.decided_by = Some("human".to_string());
|
rec.decided_by = Some("human".to_string());
|
||||||
rec.executed_at = Some(now_millis());
|
rec.executed_at = Some(now_millis());
|
||||||
|
// V50 纯执行耗时:审批路径由调用方(ai_approve/ai_authorize_dir)在真执行窗口测量后传入,
|
||||||
|
// 与「requested_at→executed_at」(含等待人工批准)解耦。拒绝/超时未执行传 None。
|
||||||
|
rec.duration_ms = duration_ms;
|
||||||
if let Some(r) = result {
|
if let Some(r) = result {
|
||||||
rec.result = Some(r);
|
// 与 audit_tool_call 的写侧一致:超阈值结果外置落盘,DB 只留句柄,防审批路径大结果
|
||||||
|
// 绕过外置直接内联落库(工具真正执行发生在审批通过之后,这条才是大结果主入口)。
|
||||||
|
rec.result = Some(tool_result_offload::offload_result(tool_call_id, &rec.tool_name, &r));
|
||||||
}
|
}
|
||||||
if let Err(e) = state.ai_tool_executions.update_full(&rec).await {
|
if let Err(e) = state.ai_tool_executions.update_full(&rec).await {
|
||||||
tracing::error!("audit_finalize: 回填 tool_call_id={} 审计状态失败: {}", tool_call_id, e);
|
tracing::error!("audit_finalize: 回填 tool_call_id={} 审计状态失败: {}", tool_call_id, e);
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ async fn deny_sub_agent_tool(
|
|||||||
};
|
};
|
||||||
emit_ai_chat_event(app_handle, ev);
|
emit_ai_chat_event(app_handle, ev);
|
||||||
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
|
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
|
||||||
"rejected", risk_level, Some(msg), Some("sub_agent_denied"), current_message_id).await;
|
"rejected", risk_level, Some(msg), Some("sub_agent_denied"), current_message_id, None).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 任务边界拒绝单点(无人值守,设计 §三):结构化失败 tool_result + emit Completed + 审计留痕。
|
/// 任务边界拒绝单点(无人值守,设计 §三):结构化失败 tool_result + emit Completed + 审计留痕。
|
||||||
@@ -212,7 +212,7 @@ async fn deny_boundary_tool(
|
|||||||
};
|
};
|
||||||
emit_ai_chat_event(app_handle, ev);
|
emit_ai_chat_event(app_handle, ev);
|
||||||
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
|
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
|
||||||
"rejected", risk_level, Some(msg), Some(layer), current_message_id).await;
|
"rejected", risk_level, Some(msg), Some(layer), current_message_id, None).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// P2-1 执行期白名单判定(纯函数可单测):SubAgent 策略下工具是否被白名单裁掉。
|
/// P2-1 执行期白名单判定(纯函数可单测):SubAgent 策略下工具是否被白名单裁掉。
|
||||||
@@ -615,6 +615,7 @@ async fn cancel_expired_approval(
|
|||||||
tc_id,
|
tc_id,
|
||||||
"rejected",
|
"rejected",
|
||||||
Some(format!("审批超时({}分钟)自动取消", timeout_minutes)),
|
Some(format!("审批超时({}分钟)自动取消", timeout_minutes)),
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
// 5. 落库会话状态变更(占位消息替换后需持久化,防重启后丢失)
|
// 5. 落库会话状态变更(占位消息替换后需持久化,防重启后丢失)
|
||||||
@@ -747,7 +748,7 @@ pub(crate) async fn process_tool_calls(
|
|||||||
emit_ai_chat_event(&app_handle, ev);
|
emit_ai_chat_event(&app_handle, ev);
|
||||||
let risk = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
|
let risk = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
|
||||||
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
|
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
|
||||||
"failed", risk, Some(err_payload.clone()), Some("auto_resolve_fail"), current_message_id).await;
|
"failed", risk, Some(err_payload.clone()), Some("auto_resolve_fail"), current_message_id, None).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -893,7 +894,7 @@ pub(crate) async fn process_tool_calls(
|
|||||||
audit_tool_call(
|
audit_tool_call(
|
||||||
&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
|
&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
|
||||||
"rejected", risk_level, Some(err_msg), Some("auto_blacklist"),
|
"rejected", risk_level, Some(err_msg), Some("auto_blacklist"),
|
||||||
current_message_id,
|
current_message_id, None,
|
||||||
).await;
|
).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -952,7 +953,7 @@ pub(crate) async fn process_tool_calls(
|
|||||||
audit_tool_call(
|
audit_tool_call(
|
||||||
&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
|
&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
|
||||||
"skipped_retry", risk_level, Some(skip_msg), Some("auto_retry_guard"),
|
"skipped_retry", risk_level, Some(skip_msg), Some("auto_retry_guard"),
|
||||||
current_message_id,
|
current_message_id, None,
|
||||||
).await;
|
).await;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -991,7 +992,7 @@ pub(crate) async fn process_tool_calls(
|
|||||||
audit_tool_call(
|
audit_tool_call(
|
||||||
&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
|
&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
|
||||||
"pending", risk_level, None, None,
|
"pending", risk_level, None, None,
|
||||||
current_message_id,
|
current_message_id, None,
|
||||||
).await;
|
).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1126,13 +1127,16 @@ pub(crate) async fn process_tool_calls(
|
|||||||
// 阻塞同会话 IPC);改 join_all 并行多工具减少总阻塞时间。P0 重构后锁外并行,根本消除阻塞。
|
// 阻塞同会话 IPC);改 join_all 并行多工具减少总阻塞时间。P0 重构后锁外并行,根本消除阻塞。
|
||||||
// join_all 保序——结果顺序 = trust_hits 输入顺序 = tc_list 原始 index 顺序,不额外 sort
|
// join_all 保序——结果顺序 = trust_hits 输入顺序 = tc_list 原始 index 顺序,不额外 sort
|
||||||
if !trust_hits.is_empty() {
|
if !trust_hits.is_empty() {
|
||||||
let results: Vec<(ToolCallDraft, RiskLevel, Result<String, String>)> =
|
let results: Vec<(ToolCallDraft, RiskLevel, Result<String, String>, i64)> =
|
||||||
futures::future::join_all(trust_hits.into_iter().map(|(draft, args, _dir_label, risk_level)| {
|
futures::future::join_all(trust_hits.into_iter().map(|(draft, args, _dir_label, risk_level)| {
|
||||||
let tools = tools_arc.clone();
|
let tools = tools_arc.clone();
|
||||||
let app_clone = app_handle.clone();
|
let app_clone = app_handle.clone();
|
||||||
let conv_clone = conv_id.to_string();
|
let conv_clone = conv_id.to_string();
|
||||||
async move {
|
async move {
|
||||||
|
// V50 纯执行耗时:本闭包内逐工具测真执行墙钟(并行下各工具独立,非批 wall)。
|
||||||
|
let exec_started = std::time::Instant::now();
|
||||||
let exec_result = execute_with_heartbeat(&tools, &draft.name, args, &app_clone, &conv_clone, &draft.id).await;
|
let exec_result = execute_with_heartbeat(&tools, &draft.name, args, &app_clone, &conv_clone, &draft.id).await;
|
||||||
|
let exec_ms = exec_started.elapsed().as_millis() as i64;
|
||||||
match exec_result {
|
match exec_result {
|
||||||
Ok(val) => {
|
Ok(val) => {
|
||||||
let content = val.to_string();
|
let content = val.to_string();
|
||||||
@@ -1146,7 +1150,7 @@ pub(crate) async fn process_tool_calls(
|
|||||||
// 数据变更类工具执行成功后 emit df-data-changed 联动刷新
|
// 数据变更类工具执行成功后 emit df-data-changed 联动刷新
|
||||||
// (write_file/run_command 不在 data_change_for_tool 映射内,emit_data_changed 内部 None 即 noop)
|
// (write_file/run_command 不在 data_change_for_tool 映射内,emit_data_changed 内部 None 即 noop)
|
||||||
emit_data_changed(&app_clone, &draft.name);
|
emit_data_changed(&app_clone, &draft.name);
|
||||||
(draft, risk_level, Ok(content))
|
(draft, risk_level, Ok(content), exec_ms)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let content = e.to_string();
|
let content = e.to_string();
|
||||||
@@ -1157,7 +1161,7 @@ pub(crate) async fn process_tool_calls(
|
|||||||
conversation_id: Some(conv_clone),
|
conversation_id: Some(conv_clone),
|
||||||
};
|
};
|
||||||
emit_ai_chat_event(&app_clone, ev);
|
emit_ai_chat_event(&app_clone, ev);
|
||||||
(draft, risk_level, Err(content))
|
(draft, risk_level, Err(content), exec_ms)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1165,7 +1169,7 @@ pub(crate) async fn process_tool_calls(
|
|||||||
|
|
||||||
|
|
||||||
// 串行回填 tool_result + 审计(短 lock push + 锁外 audit)
|
// 串行回填 tool_result + 审计(短 lock push + 锁外 audit)
|
||||||
for (draft, risk_level, outcome) in results {
|
for (draft, risk_level, outcome, exec_ms) in results {
|
||||||
let (status, content) = match outcome {
|
let (status, content) = match outcome {
|
||||||
Ok(c) => ("completed", c),
|
Ok(c) => ("completed", c),
|
||||||
Err(c) => ("failed", c),
|
Err(c) => ("failed", c),
|
||||||
@@ -1193,7 +1197,7 @@ pub(crate) async fn process_tool_calls(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 锁外:审计(trust 放行仍记一条,decided_by=auto_trust),留痕可追溯
|
// 锁外:审计(trust 放行仍记一条,decided_by=auto_trust),留痕可追溯
|
||||||
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, status, risk_level, Some(content), Some("auto_trust"), current_message_id).await;
|
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, status, risk_level, Some(content), Some("auto_trust"), current_message_id, Some(exec_ms)).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1256,7 +1260,7 @@ pub(crate) async fn process_tool_calls(
|
|||||||
audit_tool_call(
|
audit_tool_call(
|
||||||
&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
|
&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
|
||||||
"completed", risk_level, Some(warned_content), Some("cache_hit"),
|
"completed", risk_level, Some(warned_content), Some("cache_hit"),
|
||||||
current_message_id,
|
current_message_id, None,
|
||||||
).await;
|
).await;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
conv_id = %conv_id,
|
conv_id = %conv_id,
|
||||||
@@ -1272,13 +1276,16 @@ pub(crate) async fn process_tool_calls(
|
|||||||
}
|
}
|
||||||
let low_risk = low_risk_uncached;
|
let low_risk = low_risk_uncached;
|
||||||
// 携带 args + 原始 JSON result(create_idea source 补全需解析 result.id + args.source)。
|
// 携带 args + 原始 JSON result(create_idea source 补全需解析 result.id + args.source)。
|
||||||
let results: Vec<(ToolCallDraft, serde_json::Value, RiskLevel, Result<String, String>)> =
|
let results: Vec<(ToolCallDraft, serde_json::Value, RiskLevel, Result<String, String>, i64)> =
|
||||||
futures::future::join_all(low_risk.into_iter().map(|(draft, args, risk_level)| {
|
futures::future::join_all(low_risk.into_iter().map(|(draft, args, risk_level)| {
|
||||||
let tools = tools_arc.clone();
|
let tools = tools_arc.clone();
|
||||||
let app_clone = app_handle.clone();
|
let app_clone = app_handle.clone();
|
||||||
let conv_clone = conv_id.to_string();
|
let conv_clone = conv_id.to_string();
|
||||||
async move {
|
async move {
|
||||||
|
// V50 纯执行耗时:闭包内逐工具测真执行墙钟(并行各自独立)。
|
||||||
|
let exec_started = std::time::Instant::now();
|
||||||
let result = execute_with_heartbeat(&tools, &draft.name, args, &app_clone, &conv_clone, &draft.id).await;
|
let result = execute_with_heartbeat(&tools, &draft.name, args, &app_clone, &conv_clone, &draft.id).await;
|
||||||
|
let exec_ms = exec_started.elapsed().as_millis() as i64;
|
||||||
match result {
|
match result {
|
||||||
Ok(val) => {
|
Ok(val) => {
|
||||||
// L3 emit 双写:Low 风险工具执行成功 emit Completed 双路发布。
|
// L3 emit 双写:Low 风险工具执行成功 emit Completed 双路发布。
|
||||||
@@ -1291,7 +1298,7 @@ pub(crate) async fn process_tool_calls(
|
|||||||
// AR-11(方案A):数据变更工具执行成功后 emit df-data-changed,
|
// AR-11(方案A):数据变更工具执行成功后 emit df-data-changed,
|
||||||
// 前端 store listen 刷新列表(仅命中映射的工具 emit,见 emit_data_changed)。
|
// 前端 store listen 刷新列表(仅命中映射的工具 emit,见 emit_data_changed)。
|
||||||
emit_data_changed(&app_clone, &draft.name);
|
emit_data_changed(&app_clone, &draft.name);
|
||||||
(draft, val.clone(), risk_level, Ok(val.to_string()))
|
(draft, val.clone(), risk_level, Ok(val.to_string()), exec_ms)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// AR-6(定向B):Low 工具失败不 emit AiError。
|
// AR-6(定向B):Low 工具失败不 emit AiError。
|
||||||
@@ -1307,7 +1314,7 @@ pub(crate) async fn process_tool_calls(
|
|||||||
conversation_id: Some(conv_clone),
|
conversation_id: Some(conv_clone),
|
||||||
};
|
};
|
||||||
emit_ai_chat_event(&app_clone, ev);
|
emit_ai_chat_event(&app_clone, ev);
|
||||||
(draft, serde_json::Value::Null, risk_level, Err(err_msg))
|
(draft, serde_json::Value::Null, risk_level, Err(err_msg), exec_ms)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1319,7 +1326,7 @@ pub(crate) async fn process_tool_calls(
|
|||||||
// 砍 N 次串行 INSERT 尾巴(治 aichat 效率走查 T1-1)。审计失败不阻断主流程(记日志)。
|
// 砍 N 次串行 INSERT 尾巴(治 aichat 效率走查 T1-1)。审计失败不阻断主流程(记日志)。
|
||||||
let mut audit_records: Vec<df_storage::models::AiToolExecutionRecord> =
|
let mut audit_records: Vec<df_storage::models::AiToolExecutionRecord> =
|
||||||
Vec::with_capacity(results.len());
|
Vec::with_capacity(results.len());
|
||||||
for (draft, raw_result, risk_level, outcome) in results {
|
for (draft, raw_result, risk_level, outcome, exec_ms) in results {
|
||||||
let (status, content) = match outcome {
|
let (status, content) = match outcome {
|
||||||
Ok(c) => ("completed", c),
|
Ok(c) => ("completed", c),
|
||||||
Err(c) => ("failed", c),
|
Err(c) => ("failed", c),
|
||||||
@@ -1370,7 +1377,7 @@ pub(crate) async fn process_tool_calls(
|
|||||||
}
|
}
|
||||||
audit_records.push(build_audit_record(
|
audit_records.push(build_audit_record(
|
||||||
conv_id, &draft.id, &draft.name, &draft.args, status, risk_level,
|
conv_id, &draft.id, &draft.name, &draft.args, status, risk_level,
|
||||||
Some(content), Some(decided_by), current_message_id,
|
Some(content), Some(decided_by), current_message_id, Some(exec_ms),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
// 单事务批量 INSERT(一次 spawn_blocking,砍 N 次串行尾巴;失败仅记日志不阻断)。
|
// 单事务批量 INSERT(一次 spawn_blocking,砍 N 次串行尾巴;失败仅记日志不阻断)。
|
||||||
@@ -1422,7 +1429,7 @@ pub(crate) async fn process_tool_calls(
|
|||||||
dupe_audit_records.push(build_audit_record(
|
dupe_audit_records.push(build_audit_record(
|
||||||
conv_id, &draft.id, &draft.name, &draft.args,
|
conv_id, &draft.id, &draft.name, &draft.args,
|
||||||
"completed", risk_level, Some(warned_content), Some("cache_hit"),
|
"completed", risk_level, Some(warned_content), Some("cache_hit"),
|
||||||
current_message_id,
|
current_message_id, None,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if !dupe_audit_records.is_empty() {
|
if !dupe_audit_records.is_empty() {
|
||||||
@@ -1450,13 +1457,17 @@ pub(crate) async fn process_tool_calls(
|
|||||||
"[ai] 任务边界放行 {} 个工具进入并行执行(decided_by=boundary_auto)",
|
"[ai] 任务边界放行 {} 个工具进入并行执行(decided_by=boundary_auto)",
|
||||||
boundary_auto.len()
|
boundary_auto.len()
|
||||||
);
|
);
|
||||||
let results: Vec<(ToolCallDraft, RiskLevel, Result<String, String>)> =
|
let results: Vec<(ToolCallDraft, RiskLevel, Result<String, String>, i64)> =
|
||||||
futures::future::join_all(boundary_auto.into_iter().map(|(draft, args, risk_level)| {
|
futures::future::join_all(boundary_auto.into_iter().map(|(draft, args, risk_level)| {
|
||||||
let tools = tools_arc.clone();
|
let tools = tools_arc.clone();
|
||||||
let app_clone = app_handle.clone();
|
let app_clone = app_handle.clone();
|
||||||
let conv_clone = conv_id.to_string();
|
let conv_clone = conv_id.to_string();
|
||||||
async move {
|
async move {
|
||||||
match execute_with_heartbeat(&tools, &draft.name, args, &app_clone, &conv_clone, &draft.id).await {
|
// V50 纯执行耗时:闭包内逐工具测真执行墙钟。
|
||||||
|
let exec_started = std::time::Instant::now();
|
||||||
|
let exec_result = execute_with_heartbeat(&tools, &draft.name, args, &app_clone, &conv_clone, &draft.id).await;
|
||||||
|
let exec_ms = exec_started.elapsed().as_millis() as i64;
|
||||||
|
match exec_result {
|
||||||
Ok(val) => {
|
Ok(val) => {
|
||||||
// L3 emit 双写:边界放行工具执行成功 emit Completed 双路发布。
|
// L3 emit 双写:边界放行工具执行成功 emit Completed 双路发布。
|
||||||
let ev = AiChatEvent::AiToolCallCompleted {
|
let ev = AiChatEvent::AiToolCallCompleted {
|
||||||
@@ -1466,7 +1477,7 @@ pub(crate) async fn process_tool_calls(
|
|||||||
};
|
};
|
||||||
emit_ai_chat_event(&app_clone, ev);
|
emit_ai_chat_event(&app_clone, ev);
|
||||||
emit_data_changed(&app_clone, &draft.name);
|
emit_data_changed(&app_clone, &draft.name);
|
||||||
(draft, risk_level, Ok(val.to_string()))
|
(draft, risk_level, Ok(val.to_string()), exec_ms)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// AR-6 同款:失败包进 tool_result 让 LLM 自纠,不 emit AiError 不断轮。
|
// AR-6 同款:失败包进 tool_result 让 LLM 自纠,不 emit AiError 不断轮。
|
||||||
@@ -1477,13 +1488,13 @@ pub(crate) async fn process_tool_calls(
|
|||||||
conversation_id: Some(conv_clone),
|
conversation_id: Some(conv_clone),
|
||||||
};
|
};
|
||||||
emit_ai_chat_event(&app_clone, ev);
|
emit_ai_chat_event(&app_clone, ev);
|
||||||
(draft, risk_level, Err(err_msg))
|
(draft, risk_level, Err(err_msg), exec_ms)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})).await;
|
})).await;
|
||||||
|
|
||||||
for (draft, risk_level, outcome) in results {
|
for (draft, risk_level, outcome, exec_ms) in results {
|
||||||
let (status, content) = match outcome {
|
let (status, content) = match outcome {
|
||||||
Ok(c) => ("completed", c),
|
Ok(c) => ("completed", c),
|
||||||
Err(c) => ("failed", c),
|
Err(c) => ("failed", c),
|
||||||
@@ -1507,7 +1518,7 @@ pub(crate) async fn process_tool_calls(
|
|||||||
// 锁外审计:risk 真实等级透传(F-#97 防失真),decided_by=boundary_auto
|
// 锁外审计:risk 真实等级透传(F-#97 防失真),decided_by=boundary_auto
|
||||||
// 标注授权来自任务边界(无人值守事后可全量追溯)。
|
// 标注授权来自任务边界(无人值守事后可全量追溯)。
|
||||||
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
|
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
|
||||||
status, risk_level, Some(content), Some("boundary_auto"), current_message_id).await;
|
status, risk_level, Some(content), Some("boundary_auto"), current_message_id, Some(exec_ms)).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ pub(crate) fn build_audit_record(
|
|||||||
result: Option<String>,
|
result: Option<String>,
|
||||||
decided_by: Option<&str>,
|
decided_by: Option<&str>,
|
||||||
message_id: Option<&str>,
|
message_id: Option<&str>,
|
||||||
|
duration_ms: Option<i64>,
|
||||||
) -> AiToolExecutionRecord {
|
) -> AiToolExecutionRecord {
|
||||||
let executed_at = if decided_by.is_some() { Some(now_millis()) } else { None };
|
let executed_at = if decided_by.is_some() { Some(now_millis()) } else { None };
|
||||||
// D2a 结果外置:超 8KB 的审计 result 落盘留句柄,治 DB 膨胀(devflow.db 193.7MB 实证)。
|
// D2a 结果外置:超 8KB 的审计 result 落盘留句柄,治 DB 膨胀(devflow.db 193.7MB 实证)。
|
||||||
@@ -66,6 +67,10 @@ pub(crate) fn build_audit_record(
|
|||||||
requested_at: now_millis(),
|
requested_at: now_millis(),
|
||||||
executed_at,
|
executed_at,
|
||||||
decided_by: decided_by.map(|s| s.to_string()),
|
decided_by: decided_by.map(|s| s.to_string()),
|
||||||
|
// V50 纯执行耗时:由调用方在真执行路径测量后传入(执行前→执行后墙钟);
|
||||||
|
// 未真执行(deny/pending/缓存命中)传 None。与 requested_at→executed_at 解耦,
|
||||||
|
// 后者在审批路径混入等待人工批准时长。
|
||||||
|
duration_ms,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,10 +93,11 @@ pub(crate) async fn audit_tool_call(
|
|||||||
result: Option<String>,
|
result: Option<String>,
|
||||||
decided_by: Option<&str>,
|
decided_by: Option<&str>,
|
||||||
message_id: Option<&str>,
|
message_id: Option<&str>,
|
||||||
|
duration_ms: Option<i64>,
|
||||||
) {
|
) {
|
||||||
let record = build_audit_record(
|
let record = build_audit_record(
|
||||||
conv_id, tool_call_id, tool_name, arguments,
|
conv_id, tool_call_id, tool_name, arguments,
|
||||||
status, risk_level, result, decided_by, message_id,
|
status, risk_level, result, decided_by, message_id, duration_ms,
|
||||||
);
|
);
|
||||||
if let Err(e) = repo.insert(record).await {
|
if let Err(e) = repo.insert(record).await {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
@@ -119,7 +125,8 @@ pub(crate) async fn record_audit(
|
|||||||
decided_by: Option<&str>,
|
decided_by: Option<&str>,
|
||||||
message_id: Option<&str>,
|
message_id: Option<&str>,
|
||||||
) {
|
) {
|
||||||
audit_tool_call(repo, conv_id, tool_call_id, tool_name, arguments, status, risk_level, result, decided_by, message_id).await;
|
// 事件总线旁路(无真执行计时),duration_ms 传 None。
|
||||||
|
audit_tool_call(repo, conv_id, tool_call_id, tool_name, arguments, status, risk_level, result, decided_by, message_id, None).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 查询审计历史记录(分页,按 requested_at 倒序)。
|
/// 查询审计历史记录(分页,按 requested_at 倒序)。
|
||||||
@@ -159,6 +166,10 @@ pub struct ToolExecutionDto {
|
|||||||
pub executed_at: Option<String>,
|
pub executed_at: Option<String>,
|
||||||
/// human/auto,None 表示尚未决策
|
/// human/auto,None 表示尚未决策
|
||||||
pub decided_by: Option<String>,
|
pub decided_by: Option<String>,
|
||||||
|
/// 纯执行耗时(毫秒,V50):真执行墙钟,与审批等待解耦;None=未执行/老数据。
|
||||||
|
/// 前端审计面板可用此替代 executed_at-requested_at(后者含审批等待)。
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub duration_ms: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 审批历史查询入参(前端透传,空值=不过滤)。
|
/// 审批历史查询入参(前端透传,空值=不过滤)。
|
||||||
@@ -247,6 +258,7 @@ pub async fn list_tool_executions(
|
|||||||
requested_at: r.requested_at,
|
requested_at: r.requested_at,
|
||||||
executed_at: r.executed_at,
|
executed_at: r.executed_at,
|
||||||
decided_by: r.decided_by,
|
decided_by: r.decided_by,
|
||||||
|
duration_ms: r.duration_ms,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let has_more = (offset as i64 + items.len() as i64) < total;
|
let has_more = (offset as i64 + items.len() as i64) < total;
|
||||||
@@ -522,6 +534,7 @@ mod tests {
|
|||||||
requested_at: t.to_string(),
|
requested_at: t.to_string(),
|
||||||
executed_at: None,
|
executed_at: None,
|
||||||
decided_by: None,
|
decided_by: None,
|
||||||
|
duration_ms: None,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("测试数据插入应成功");
|
.expect("测试数据插入应成功");
|
||||||
@@ -591,6 +604,7 @@ mod tests {
|
|||||||
requested_at: t.to_string(),
|
requested_at: t.to_string(),
|
||||||
executed_at: Some(t.to_string()),
|
executed_at: Some(t.to_string()),
|
||||||
decided_by: Some("auto".to_string()),
|
decided_by: Some("auto".to_string()),
|
||||||
|
duration_ms: None,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("测试数据插入应成功");
|
.expect("测试数据插入应成功");
|
||||||
@@ -618,6 +632,7 @@ mod tests {
|
|||||||
origin: origin.map(|s| s.to_string()),
|
origin: origin.map(|s| s.to_string()),
|
||||||
boundary_json: None,
|
boundary_json: None,
|
||||||
loop_state: None,
|
loop_state: None,
|
||||||
|
status: "active".to_string(),
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("测试会话插入应成功");
|
.expect("测试会话插入应成功");
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider};
|
|||||||
// 摘要任务,选 Standard 智力保摘要质量;不传 tools(纯文本出,避免 LLM 调工具跑偏)。
|
// 摘要任务,选 Standard 智力保摘要质量;不传 tools(纯文本出,避免 LLM 调工具跑偏)。
|
||||||
// 池空/无匹配兜底 default_model(与 title.rs 一致,行为可预期)。
|
// 池空/无匹配兜底 default_model(与 title.rs 一致,行为可预期)。
|
||||||
use df_ai::router::{
|
use df_ai::router::{
|
||||||
is_likely_image_model, select_model_id, Modality, TaskRequirements,
|
is_likely_image_model, select_model_id, Modality, ModelConfig, TaskRequirements,
|
||||||
};
|
};
|
||||||
use df_storage::models::AiProviderRecord;
|
use df_storage::models::AiProviderRecord;
|
||||||
|
|
||||||
@@ -72,15 +72,20 @@ pub(crate) async fn compress_via_llm(
|
|||||||
let active_msgs = df_ai::context::ContextManager::sanitize_messages(active_msgs);
|
let active_msgs = df_ai::context::ContextManager::sanitize_messages(active_msgs);
|
||||||
|
|
||||||
// 路由选模型:无 tool_use(纯文本摘要)。
|
// 路由选模型:无 tool_use(纯文本摘要)。
|
||||||
// None(池空/无匹配)→ 兜底 default_model(行为不变)。
|
// None(池空/无匹配)→ 兜底 default_model,但须校验其 enabled(停用则回落池内启用模型,
|
||||||
|
// 见 resolve_fallback_model);池内无任何启用模型 → Err 让调用方降级,不再静默用停用模型。
|
||||||
let compress_req = TaskRequirements {
|
let compress_req = TaskRequirements {
|
||||||
modalities: vec![Modality::Text],
|
modalities: vec![Modality::Text],
|
||||||
needs_tool_use: false,
|
needs_tool_use: false,
|
||||||
estimated_context: 0,
|
estimated_context: 0,
|
||||||
tier: None,
|
tier: None,
|
||||||
};
|
};
|
||||||
let model = select_model_id(&compress_req, &provider_config.model_configs)
|
let model = match select_model_id(&compress_req, &provider_config.model_configs) {
|
||||||
.unwrap_or_else(|| provider_config.default_model.clone());
|
Some(m) => m,
|
||||||
|
None => {
|
||||||
|
resolve_fallback_model(&provider_config.model_configs, &provider_config.default_model)?
|
||||||
|
}
|
||||||
|
};
|
||||||
// 防御:选中/兜底模型是图像模型时返 Err(走关键词摘要兜底,避免文本任务 404 刷屏)。
|
// 防御:选中/兜底模型是图像模型时返 Err(走关键词摘要兜底,避免文本任务 404 刷屏)。
|
||||||
if is_likely_image_model(&model, &provider_config.model_configs) {
|
if is_likely_image_model(&model, &provider_config.model_configs) {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
@@ -150,6 +155,46 @@ pub(crate) async fn compress_via_llm(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `select_model_id` 返 None(池空/无匹配)时的**兜底模型解析**(治 929 次生缺口)。
|
||||||
|
///
|
||||||
|
/// 原兜底直接 `provider_config.default_model.clone()`,不校验该模型的 `enabled` ——
|
||||||
|
/// 用户在设置里停用 default_model 后,压缩/标题仍照用被停用的模型(与 agentic 侧 override 路径
|
||||||
|
/// `resolve_model_with_override` 同源缺口:停用只挡路由,不挡兜底)。此处在 enabled 口径下解析:
|
||||||
|
///
|
||||||
|
/// - **default_model 在池中且启用** → 采用(正常兜底);
|
||||||
|
/// - **default_model 不在池中**(未拉取模型/池空/配置未同步)→ 无法校验,采用 default_model
|
||||||
|
/// (不因无法校验而阻断,保持旧行为);
|
||||||
|
/// - **default_model 在池中被显式停用** → 回落池中任一 enabled 模型(与 `select_model_id`
|
||||||
|
/// 的 enabled 过滤同口径);
|
||||||
|
/// - **池内无任何 enabled 模型** → Err(明确报错,不再静默沿用停用模型,让用户感知配置问题)。
|
||||||
|
///
|
||||||
|
/// 与 `select_model_id` 分工:后者按「需求匹配 + weight」选优;本函数只解决「兜底候选是否被停用」
|
||||||
|
/// 的校验与回落,不重复路由逻辑。抽为纯函数便于单测(零 IO)。
|
||||||
|
pub(crate) fn resolve_fallback_model(
|
||||||
|
pool: &[ModelConfig],
|
||||||
|
default_model: &str,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
// 池中有该条目 → 以其 enabled 为准;无该条目 → 无法校验,不阻断(采用 default_model)。
|
||||||
|
let default_available = pool
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.model_id == default_model)
|
||||||
|
.map(|m| m.enabled)
|
||||||
|
.unwrap_or(true);
|
||||||
|
if default_available {
|
||||||
|
return Ok(default_model.to_string());
|
||||||
|
}
|
||||||
|
// 回落:池中任一 enabled 模型(与 select_model_id 的 enabled 过滤同口径)。
|
||||||
|
pool.iter()
|
||||||
|
.find(|m| m.enabled)
|
||||||
|
.map(|m| m.model_id.clone())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"无可用模型:default_model「{}」已在模型配置中停用,且池内无任何启用模型(请在设置中启用模型)",
|
||||||
|
default_model
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// 阶段2(占位配对完整性):检测压缩段是否含未闭合占位 tool_result。
|
/// 阶段2(占位配对完整性):检测压缩段是否含未闭合占位 tool_result。
|
||||||
///
|
///
|
||||||
/// 占位 tool_result(content 带 __PENDING__ 标记,审批挂起)若无对应 tool_call 头(头被裁/丢)
|
/// 占位 tool_result(content 带 __PENDING__ 标记,审批挂起)若无对应 tool_call 头(头被裁/丢)
|
||||||
@@ -445,4 +490,47 @@ mod tests {
|
|||||||
];
|
];
|
||||||
assert!(!compress_segment_has_unclosed_placeholder(&msgs));
|
assert!(!compress_segment_has_unclosed_placeholder(&msgs));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 929 次生缺口:resolve_fallback_model(兜底模型须校验 enabled) ──
|
||||||
|
|
||||||
|
/// 构造池内模型(enabled 可控,其余用默认)。
|
||||||
|
fn mc(id: &str, enabled: bool) -> ModelConfig {
|
||||||
|
ModelConfig { enabled, ..ModelConfig::with_defaults(id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fallback_default_enabled_in_pool_used() {
|
||||||
|
// default_model 在池中且启用 → 采用 default_model
|
||||||
|
let pool = vec![mc("deepseek-chat", true), mc("glm-4-flash", true)];
|
||||||
|
assert_eq!(
|
||||||
|
resolve_fallback_model(&pool, "deepseek-chat").unwrap(),
|
||||||
|
"deepseek-chat"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fallback_default_absent_from_pool_used() {
|
||||||
|
// default_model 不在池中(未拉取/池空)→ 无法校验,不阻断,采用 default_model
|
||||||
|
let empty: Vec<ModelConfig> = vec![];
|
||||||
|
assert_eq!(resolve_fallback_model(&empty, "legacy-model").unwrap(), "legacy-model");
|
||||||
|
let pool = vec![mc("glm-4-flash", true)];
|
||||||
|
assert_eq!(resolve_fallback_model(&pool, "legacy-model").unwrap(), "legacy-model");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fallback_default_disabled_falls_back_to_enabled_pool_model() {
|
||||||
|
// default_model 在池中被停用 → 回落池中任一 enabled 模型(不静默沿用停用模型)
|
||||||
|
let pool = vec![mc("stopped-model", false), mc("glm-4-flash", true)];
|
||||||
|
let got = resolve_fallback_model(&pool, "stopped-model").unwrap();
|
||||||
|
assert_eq!(got, "glm-4-flash", "停用的 default_model 应回落启用模型");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fallback_default_disabled_and_no_enabled_errors() {
|
||||||
|
// default_model 停用 + 池内无任何启用模型 → 明确报错(而非静默用停用模型)
|
||||||
|
let pool = vec![mc("stopped-model", false), mc("also-stopped", false)];
|
||||||
|
let err = resolve_fallback_model(&pool, "stopped-model").unwrap_err();
|
||||||
|
assert!(err.contains("stopped-model"), "错误信息应含被停用的 default_model 名便于定位");
|
||||||
|
assert!(err.contains("停用"), "错误信息应说明停用原因");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,10 +84,69 @@ pub const DEFAULT_BUS_CAPACITY: usize = 256;
|
|||||||
/// 事件值语义:emit 与 publish 收到**同一份事件值**(内部 clone),对齐原手抄 `ev.clone()` 两行式。
|
/// 事件值语义:emit 与 publish 收到**同一份事件值**(内部 clone),对齐原手抄 `ev.clone()` 两行式。
|
||||||
/// 消费 ev(调用方无需再 clone)。
|
/// 消费 ev(调用方无需再 clone)。
|
||||||
pub(crate) fn emit_ai_chat_event(app: &AppHandle, ev: AiChatEvent) {
|
pub(crate) fn emit_ai_chat_event(app: &AppHandle, ev: AiChatEvent) {
|
||||||
|
// 子代理运行期事件改挂(见 CONV_ROUTE_OVERRIDE):作用域内命中来源会话的展示类事件
|
||||||
|
// 改挂目标会话再双写;未进作用域或未命中时保持原双写,常规路径零行为变化。
|
||||||
|
if let Ok((from, to)) = CONV_ROUTE_OVERRIDE.try_with(|r| r.clone()) {
|
||||||
|
if let Some(v) = route_conv_event(&ev, &from, &to) {
|
||||||
|
let _ = app.emit("ai-chat-event", v.clone());
|
||||||
|
let _ = app.state::<crate::state::AppState>().ai_event_bus.publish(v);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
let _ = app.emit("ai-chat-event", ev.clone());
|
let _ = app.emit("ai-chat-event", ev.clone());
|
||||||
let _ = app.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
let _ = app.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 子代理运行期事件路由覆写(2026-10-10)。
|
||||||
|
//
|
||||||
|
// spawn_agent 子循环运行期,子会话(from)的运行事件默认挂子会话 conv 推送,父会话视图收不
|
||||||
|
// 到子代理的实时进展(切回父会话只见 spawn 卡停在旧态,出现「已完成」幽灵态)。本 task-local
|
||||||
|
// 在子循环作用域内把 from 会话的**展示类**事件改挂父会话(to)conv 再推送,复用同一 emit 双写
|
||||||
|
// 出口(不开旁路);终态/状态机/审批/求助类事件不改写,避免污染父会话生成态与生命周期。
|
||||||
|
tokio::task_local! {
|
||||||
|
static CONV_ROUTE_OVERRIDE: (String, String);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 在「把 from 会话的展示类事件改挂 to 会话」的作用域内执行 future(子代理运行期包裹子循环
|
||||||
|
/// 的流式与工具调用;作用域外事件路由不变)。
|
||||||
|
pub(crate) async fn route_scope<F, R>(from: String, to: String, fut: F) -> R
|
||||||
|
where
|
||||||
|
F: std::future::Future<Output = R>,
|
||||||
|
{
|
||||||
|
CONV_ROUTE_OVERRIDE.scope((from, to), fut).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 展示类事件白名单:仅这些随子代理运行改挂父会话视图(轮次/文本/心跳/重试/工具生命周期/
|
||||||
|
/// 命令输出)。负例(AiCompleted/AiError/AiAgentFinished/AiConvStateChanged/审批/求助/停靠等)
|
||||||
|
/// 承载父或子各自的会话生命周期,改写会污染父会话生成态,故不改写。
|
||||||
|
const ROUTABLE_EVENT_TYPES: &[&str] = &[
|
||||||
|
"AiAgentRound",
|
||||||
|
"AiTextDelta",
|
||||||
|
"AiHeartbeat",
|
||||||
|
"AiStreamRetry",
|
||||||
|
"AiToolCallStarted",
|
||||||
|
"AiToolCallCompleted",
|
||||||
|
"AiToolAutoApproved",
|
||||||
|
"AiCommandOutput",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 展示类且 conversation_id 命中 from → 返回改挂 to 后的 JSON 值;否则 None(调用方走原双写)。
|
||||||
|
///
|
||||||
|
/// 用序列化探针取 type/conversation_id,避免为 40+ 事件变体逐一手写 match(改一处漏一处的
|
||||||
|
/// 漂移源)。命中率极低(仅子代理运行期),开销可忽略。
|
||||||
|
fn route_conv_event(ev: &AiChatEvent, from: &str, to: &str) -> Option<serde_json::Value> {
|
||||||
|
let mut v = serde_json::to_value(ev).ok()?;
|
||||||
|
let ty = v.get("type").and_then(|t| t.as_str())?;
|
||||||
|
if !ROUTABLE_EVENT_TYPES.contains(&ty) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if v.get("conversation_id").and_then(|c| c.as_str()) != Some(from) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
v["conversation_id"] = serde_json::Value::String(to.to_string());
|
||||||
|
Some(v)
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// EventBus — 基于 tokio::sync::broadcast 的 pub-sub 总线(载荷 serde_json::Value)
|
// EventBus — 基于 tokio::sync::broadcast 的 pub-sub 总线(载荷 serde_json::Value)
|
||||||
//
|
//
|
||||||
@@ -337,4 +396,64 @@ mod tests {
|
|||||||
assert_eq!(received, expected.clone(), "每个变体应原样透传(全透传不丢)");
|
assert_eq!(received, expected.clone(), "每个变体应原样透传(全透传不丢)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// route_conv_event — 子代理运行期事件改挂(白名单 × from 命中)
|
||||||
|
//
|
||||||
|
// 锁定三态:① 白名单内 + conversation_id==from → 改挂 to 返回 Some;
|
||||||
|
// ② 非白名单 → None(不改挂,避免污染父会话生成态);
|
||||||
|
// ③ conversation_id != from / None → None(不误改他会话事件)。
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// 白名单内且 conversation_id 命中 from → 返回改挂 to 后的值(仅 conversation_id 变,type/其余字段原样)。
|
||||||
|
#[test]
|
||||||
|
fn route_conv_event_rewrites_from_to() {
|
||||||
|
let ev = AiChatEvent::AiTextDelta {
|
||||||
|
delta: "hi".into(),
|
||||||
|
conversation_id: Some("sub".into()),
|
||||||
|
};
|
||||||
|
let v = route_conv_event(&ev, "sub", "parent")
|
||||||
|
.expect("白名单内且命中 from 应返回 Some");
|
||||||
|
assert_eq!(v["type"], "AiTextDelta", "type 判别字段应保留");
|
||||||
|
assert_eq!(v["conversation_id"], "parent", "conversation_id 应改挂到 to");
|
||||||
|
assert_eq!(v["delta"], "hi", "其余字段应原样透传");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 非白名单事件(承载父/子会话生命周期)→ None,不改写。
|
||||||
|
#[test]
|
||||||
|
fn route_conv_event_rejects_non_whitelisted() {
|
||||||
|
// AiMaxRoundsReached 不在 ROUTABLE_EVENT_TYPES,改写会污染父会话状态机
|
||||||
|
let ev = AiChatEvent::AiMaxRoundsReached {
|
||||||
|
conversation_id: Some("sub".into()),
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
route_conv_event(&ev, "sub", "parent").is_none(),
|
||||||
|
"非白名单事件不应改挂"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// conversation_id 非 from(他会话来源)→ None,不动。
|
||||||
|
#[test]
|
||||||
|
fn route_conv_event_skips_unmatched_from() {
|
||||||
|
let ev = AiChatEvent::AiAgentRound {
|
||||||
|
round: 2,
|
||||||
|
conversation_id: Some("other".into()),
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
route_conv_event(&ev, "sub", "parent").is_none(),
|
||||||
|
"conversation_id 非 from 不应改挂"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// conversation_id 缺省(None)→ 不命中 → None。
|
||||||
|
#[test]
|
||||||
|
fn route_conv_event_skips_none_conv() {
|
||||||
|
let ev = AiChatEvent::AiHeartbeat {
|
||||||
|
conversation_id: None,
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
route_conv_event(&ev, "sub", "parent").is_none(),
|
||||||
|
"conversation_id 缺省不应改挂"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,30 +9,46 @@ use df_storage::models::AiProviderRecord;
|
|||||||
use crate::commands::err_str;
|
use crate::commands::err_str;
|
||||||
use crate::state::{AppState, SettingsNorm};
|
use crate::state::{AppState, SettingsNorm};
|
||||||
|
|
||||||
/// 获取当前活跃提供商配置
|
/// 获取当前活跃提供商配置。
|
||||||
|
///
|
||||||
|
/// **停用须生效(2026-10-10 用户报「模型配置停用无效」根因)**:原实现显式活跃 provider 直接返回、
|
||||||
|
/// 默认 provider 只按 `is_default` 挑,**全程不看 `enabled`** → 用户在设置里停用某 provider 后
|
||||||
|
/// 会话仍照用(停用形同虚设)。现两条路径都收敛到「启用集合」:显式活跃项被停用 → 改按启用集合解析
|
||||||
|
/// (留 warn 便于定位);无任何启用 provider → 明确报错而非静默使用被停用项。
|
||||||
pub(crate) async fn get_active_provider(state: &AppState) -> Result<AiProviderRecord, String> {
|
pub(crate) async fn get_active_provider(state: &AppState) -> Result<AiProviderRecord, String> {
|
||||||
let session = state.ai_session.lock().await;
|
let session = state.ai_session.lock().await;
|
||||||
if let Some(ref pid) = session.active_provider_id {
|
if let Some(ref pid) = session.active_provider_id {
|
||||||
let provider = state
|
if let Some(provider) = state.ai_providers.get_by_id(pid).await.map_err(err_str)? {
|
||||||
.ai_providers
|
if provider.enabled {
|
||||||
.get_by_id(pid)
|
return Ok(provider);
|
||||||
.await
|
}
|
||||||
.map_err(err_str)?
|
tracing::warn!(
|
||||||
.ok_or_else(|| format!("活跃提供商不存在: {}", pid))?;
|
provider_id = %pid,
|
||||||
Ok(provider)
|
provider_name = %provider.name,
|
||||||
|
"[ai] 活跃 provider 已被停用,改按启用集合解析默认 provider"
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// 查找默认提供商
|
tracing::warn!(provider_id = %pid, "[ai] 活跃 provider 不存在,改按启用集合解析");
|
||||||
|
}
|
||||||
|
}
|
||||||
drop(session);
|
drop(session);
|
||||||
|
resolve_enabled_provider(state).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 在**启用**的 provider 中解析:`is_default` 优先,否则取列表首个(与旧行为同序,仅多一道 enabled 过滤)。
|
||||||
|
async fn resolve_enabled_provider(state: &AppState) -> Result<AiProviderRecord, String> {
|
||||||
let providers = state
|
let providers = state
|
||||||
.ai_providers
|
.ai_providers
|
||||||
.list_all()
|
.list_all()
|
||||||
.await
|
.await
|
||||||
.map_err(err_str)?;
|
.map_err(err_str)?;
|
||||||
let default = providers.iter().find(|p| p.is_default).cloned();
|
let enabled: Vec<AiProviderRecord> = providers.into_iter().filter(|p| p.enabled).collect();
|
||||||
default
|
enabled
|
||||||
.or_else(|| providers.into_iter().next())
|
.iter()
|
||||||
.ok_or_else(|| "未配置 AI 提供商,请先在设置中添加".to_string())
|
.find(|p| p.is_default)
|
||||||
}
|
.cloned()
|
||||||
|
.or_else(|| enabled.into_iter().next())
|
||||||
|
.ok_or_else(|| "未启用任何 AI 提供商,请在设置中启用一个(或新增)".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 当前运行环境画像(注入 system prompt):日期 + OS + shell 执行姿势 + 可用解释器。
|
/// 当前运行环境画像(注入 system prompt):日期 + OS + shell 执行姿势 + 可用解释器。
|
||||||
@@ -45,7 +61,10 @@ pub(crate) async fn get_active_provider(state: &AppState) -> Result<AiProviderRe
|
|||||||
/// - 可用解释器:Windows=python(无 python3);Linux·macOS=python3;node 均有
|
/// - 可用解释器:Windows=python(无 python3);Linux·macOS=python3;node 均有
|
||||||
///
|
///
|
||||||
/// 跨设备:cfg! 分支,平台各自正确姿势。补充探测见 detect_environment 工具(失败自愈)。
|
/// 跨设备:cfg! 分支,平台各自正确姿势。补充探测见 detect_environment 工具(失败自愈)。
|
||||||
fn env_profile_line() -> String {
|
///
|
||||||
|
/// pub(crate):子代理 system prompt 同源注入(见 sub_agent::sub_agent_system_prompt),
|
||||||
|
/// 子代理同样需要日期(不能自行获取)与 shell 执行姿势。
|
||||||
|
pub(crate) fn env_profile_line() -> String {
|
||||||
let today = chrono::Local::now().format("%Y-%m-%d");
|
let today = chrono::Local::now().format("%Y-%m-%d");
|
||||||
format!("当前日期: {today} | {}", env_profile_stable_body())
|
format!("当前日期: {today} | {}", env_profile_stable_body())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ use df_storage::models::AiProviderRecord;
|
|||||||
|
|
||||||
use crate::state::LlmConcurrency;
|
use crate::state::LlmConcurrency;
|
||||||
|
|
||||||
|
use super::compress::resolve_fallback_model;
|
||||||
use super::AiSession;
|
use super::AiSession;
|
||||||
|
|
||||||
/// 对话完成后按需生成智能标题(仅 title 为空时触发一次,不覆盖用户改名)
|
/// 对话完成后按需生成智能标题(仅 title 为空时触发一次,不覆盖用户改名)
|
||||||
@@ -120,15 +121,25 @@ pub(crate) async fn ensure_conversation_title(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// F-01 阶段5: 标题生成路由 — select_model_id None(池空/无匹配)→ 兜底 default_model。
|
// F-01 阶段5: 标题生成路由 — select_model_id None(池空/无匹配)→ 兜底 default_model,
|
||||||
|
// 但须校验其 enabled(停用则回落池内启用模型,见 compress::resolve_fallback_model);
|
||||||
|
// 池内无任何启用模型 → 跳过 LLM 保留 extract_title 兜底,不再静默用停用模型。
|
||||||
let title_req = TaskRequirements {
|
let title_req = TaskRequirements {
|
||||||
modalities: vec![Modality::Text],
|
modalities: vec![Modality::Text],
|
||||||
needs_tool_use: false,
|
needs_tool_use: false,
|
||||||
estimated_context: 0,
|
estimated_context: 0,
|
||||||
tier: None,
|
tier: None,
|
||||||
};
|
};
|
||||||
let title_model = select_model_id(&title_req, &provider_config.model_configs)
|
let title_model = match select_model_id(&title_req, &provider_config.model_configs) {
|
||||||
.unwrap_or_else(|| provider_config.default_model.clone());
|
Some(m) => m,
|
||||||
|
None => match resolve_fallback_model(&provider_config.model_configs, &provider_config.default_model) {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("标题 LLM 跳过(无可用模型): {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
// 防御:选中/兜底模型是图像模型时跳过 LLM(避免文本任务 404 刷屏,治 default_model 配错为图像模型)。
|
// 防御:选中/兜底模型是图像模型时跳过 LLM(避免文本任务 404 刷屏,治 default_model 配错为图像模型)。
|
||||||
// 跳过则保留上方 extract_title 兜底,不锁定差标题。
|
// 跳过则保留上方 extract_title 兜底,不锁定差标题。
|
||||||
if is_likely_image_model(&title_model, &provider_config.model_configs) {
|
if is_likely_image_model(&title_model, &provider_config.model_configs) {
|
||||||
|
|||||||
@@ -463,6 +463,11 @@ pub async fn get_module_git_status(
|
|||||||
.ok_or_else(|| format!("工程 {module_id} 不存在"))?;
|
.ok_or_else(|| format!("工程 {module_id} 不存在"))?;
|
||||||
|
|
||||||
let path = std::path::Path::new(&module.path);
|
let path = std::path::Path::new(&module.path);
|
||||||
|
// F22: 工程目录本身不存在时,不能与非 Git 仓库混为一谈(前端 GitChanges 会误显"非 Git 仓库")。
|
||||||
|
// 先判目录,返回明确错误 → 前端走 loadError 分支显示真实原因。
|
||||||
|
if !path.is_dir() {
|
||||||
|
return Err(format!("工程目录不存在: {}", module.path));
|
||||||
|
}
|
||||||
// 无 .git → 返回空状态
|
// 无 .git → 返回空状态
|
||||||
if !path.join(".git").exists() {
|
if !path.join(".git").exists() {
|
||||||
return Ok(serde_json::to_value(empty_status()).map_err(err_str)?);
|
return Ok(serde_json::to_value(empty_status()).map_err(err_str)?);
|
||||||
@@ -1057,7 +1062,7 @@ pub async fn get_module_file_diff(
|
|||||||
if !root.join(".git").exists() {
|
if !root.join(".git").exists() {
|
||||||
return Ok(serde_json::json!({"path": file_path, "diff": ""}));
|
return Ok(serde_json::json!({"path": file_path, "diff": ""}));
|
||||||
}
|
}
|
||||||
// 跑 git diff <path>(工作树 vs 索引的 unstaged 变更+staged 变更)
|
// 跑 git diff <path>(工作树 vs 索引 = 仅 unstaged 未暂存变更;staged 与 untracked 不在输出内,见 F4)
|
||||||
// spawn_blocking + run_git_cmd(10s 超时,防 git 卡死阻塞 runtime)。
|
// spawn_blocking + run_git_cmd(10s 超时,防 git 卡死阻塞 runtime)。
|
||||||
let cmd_dir = root.clone();
|
let cmd_dir = root.clone();
|
||||||
let path_arg = file_path.replace('\\', "/");
|
let path_arg = file_path.replace('\\', "/");
|
||||||
@@ -1187,6 +1192,21 @@ pub async fn scan_project_modules(
|
|||||||
Ok(new_count)
|
Ok(new_count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// F24:把 git status 输出的「相对仓库根」路径折算为「相对 module.path」。
|
||||||
|
///
|
||||||
|
/// `git status --porcelain` 的路径恒相对**仓库根**(与 `status.relativePaths` 无关,该配置对
|
||||||
|
/// porcelain 不生效)。当 module.path 是仓库子目录(模块路径≠仓库根)时,键与文件树条目
|
||||||
|
/// (相对 module.path)对不上 → git 徽标全失效。prefix = `git rev-parse --show-prefix`
|
||||||
|
/// (当前目录相对仓库根,如 `crates/`;仓库根为空串)。前缀非空时不以前缀开头的路径属仓库
|
||||||
|
/// 其它位置,不归本模块 → None(丢弃);空前缀原样返回。
|
||||||
|
fn scope_git_path(path: &str, prefix: &str) -> Option<String> {
|
||||||
|
if prefix.is_empty() {
|
||||||
|
Some(path.to_string())
|
||||||
|
} else {
|
||||||
|
path.strip_prefix(prefix).map(|s| s.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 采集工程目录 git status --porcelain 的 {posix_rel_path: status} 映射。
|
/// 采集工程目录 git status --porcelain 的 {posix_rel_path: status} 映射。
|
||||||
/// 非 git 仓库/命令失败 → 空映射(不阻断文件树展示,仅 git_status 字段全 None)。
|
/// 非 git 仓库/命令失败 → 空映射(不阻断文件树展示,仅 git_status 字段全 None)。
|
||||||
fn collect_git_status_map(dir: &str) -> HashMap<String, String> {
|
fn collect_git_status_map(dir: &str) -> HashMap<String, String> {
|
||||||
@@ -1206,6 +1226,11 @@ fn collect_git_status_map(dir: &str) -> HashMap<String, String> {
|
|||||||
) else {
|
) else {
|
||||||
return map;
|
return map;
|
||||||
};
|
};
|
||||||
|
// F24: porcelain 路径相对仓库根,若 module.path 是子目录需剥除其相对仓库根的前缀,
|
||||||
|
// 使键统一相对 module.path(与文件树条目对齐)。取失败(退化)→ 空前缀,保持原行为。
|
||||||
|
let prefix = run_git_cmd(path, &["rev-parse", "--show-prefix"], timeout)
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
for line in out.lines() {
|
for line in out.lines() {
|
||||||
if line.len() < 4 {
|
if line.len() < 4 {
|
||||||
continue;
|
continue;
|
||||||
@@ -1226,7 +1251,10 @@ fn collect_git_status_map(dir: &str) -> HashMap<String, String> {
|
|||||||
.split_once(" -> ")
|
.split_once(" -> ")
|
||||||
.map(|(_, new)| new.to_string())
|
.map(|(_, new)| new.to_string())
|
||||||
.unwrap_or(cleaned);
|
.unwrap_or(cleaned);
|
||||||
map.insert(final_path, status);
|
// F24: 折算为相对 module.path;仓库其它位置的改动不归本模块,前缀非空时丢弃。
|
||||||
|
if let Some(scoped) = scope_git_path(&final_path, &prefix) {
|
||||||
|
map.insert(scoped, status);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
map
|
map
|
||||||
}
|
}
|
||||||
@@ -1606,5 +1634,18 @@ mod tests {
|
|||||||
cache.remove("repoA");
|
cache.remove("repoA");
|
||||||
assert!(cache.get("repoA").is_none());
|
assert!(cache.get("repoA").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// F24:git 路径折算 — 仓库根(空前缀)原样保留;子目录模块剥前缀;仓库其它位置丢弃。
|
||||||
|
#[test]
|
||||||
|
fn test_scope_git_path() {
|
||||||
|
// 仓库根:空前缀 → 原样返回(行为与前一致)
|
||||||
|
assert_eq!(scope_git_path("src/main.rs", ""), Some("src/main.rs".to_string()));
|
||||||
|
// 子目录模块 sub/:剥除 "sub/" 前缀 → 相对 module.path
|
||||||
|
assert_eq!(scope_git_path("sub/src/a.rs", "sub/"), Some("src/a.rs".to_string()));
|
||||||
|
// 仓库其它位置(不在 sub/ 下)→ 丢弃
|
||||||
|
assert_eq!(scope_git_path("other/b.rs", "sub/"), None);
|
||||||
|
// 前缀边界:"subx/" 与 "sub/" 同名前缀不误剥(前缀必带尾斜杠)
|
||||||
|
assert_eq!(scope_git_path("subx/c.rs", "sub/"), None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -518,14 +518,25 @@ pub async fn relocate_project_path(
|
|||||||
id: String,
|
id: String,
|
||||||
new_path: String,
|
new_path: String,
|
||||||
) -> Result<ProjectRecord, String> {
|
) -> Result<ProjectRecord, String> {
|
||||||
if !Path::new(&new_path).is_dir() {
|
// BE-CMD-12:relocate 与 create_with_binding / update_project(path) 同款校验——
|
||||||
return Err(format!("目录不存在: {}", new_path));
|
// `..` 段拦截(防穿越)+ normalize_path 规范化后落库(防路径写法差异致重复绑定/白名单授权失效)。
|
||||||
|
let trimmed = new_path.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Err("路径不能为空".to_string());
|
||||||
}
|
}
|
||||||
if let Some(conflict) = find_binding_conflict(&state, &new_path, Some(&id)).await? {
|
if trimmed.split(['\\', '/']).any(|seg| seg == "..") {
|
||||||
|
return Err(format!("路径不得包含 '..' 段: {}", trimmed));
|
||||||
|
}
|
||||||
|
if !Path::new(trimmed).is_dir() {
|
||||||
|
return Err(format!("目录不存在: {}", trimmed));
|
||||||
|
}
|
||||||
|
if let Some(conflict) = find_binding_conflict(&state, trimmed, Some(&id)).await? {
|
||||||
return Err(format!("目录已被项目「{}」绑定", conflict.name));
|
return Err(format!("目录已被项目「{}」绑定", conflict.name));
|
||||||
}
|
}
|
||||||
|
// 规范化路径后探测与落库(对齐 create_with_binding/update_project,存入规范化值)
|
||||||
|
let normalized = normalize_path(trimmed);
|
||||||
// 重探测技术栈(spawn_blocking 防 IO 阻塞 tokio runtime)
|
// 重探测技术栈(spawn_blocking 防 IO 阻塞 tokio runtime)
|
||||||
let root = std::path::PathBuf::from(&new_path);
|
let root = std::path::PathBuf::from(&normalized);
|
||||||
let stack = tokio::task::spawn_blocking(move || detect_stack(&root))
|
let stack = tokio::task::spawn_blocking(move || detect_stack(&root))
|
||||||
.await
|
.await
|
||||||
.map_err(err_str)?
|
.map_err(err_str)?
|
||||||
@@ -533,7 +544,7 @@ pub async fn relocate_project_path(
|
|||||||
let stack_json = serde_json::to_string(&stack).map_err(err_str)?;
|
let stack_json = serde_json::to_string(&stack).map_err(err_str)?;
|
||||||
state
|
state
|
||||||
.projects
|
.projects
|
||||||
.update_field(&id, "path", &new_path)
|
.update_field(&id, "path", &normalized)
|
||||||
.await
|
.await
|
||||||
.map_err(err_str)?;
|
.map_err(err_str)?;
|
||||||
// 重定位目录后立即 reload 白名单(新路径即时授权,防 AI 误弹窗)
|
// 重定位目录后立即 reload 白名单(新路径即时授权,防 AI 误弹窗)
|
||||||
|
|||||||
@@ -387,13 +387,21 @@ pub async fn update_task(
|
|||||||
// review_rounds 累加,唯一 status 写入路径)。即此 is_valid 校验的「通过」分支永不会触达
|
// review_rounds 累加,唯一 status 写入路径)。即此 is_valid 校验的「通过」分支永不会触达
|
||||||
// update_field 的 status 写入(白名单会先拒);它只为非法值兜底 UX,不承担合法值写入职责。
|
// update_field 的 status 写入(白名单会先拒);它只为非法值兜底 UX,不承担合法值写入职责。
|
||||||
// priority 值校验同理补在下方:拦截 "abc" / 999 等脏数据静默落库。
|
// priority 值校验同理补在下方:拦截 "abc" / 999 等脏数据静默落库。
|
||||||
if field == "status" && !TaskStatus::is_valid(&value) {
|
if field == "status" {
|
||||||
|
if !TaskStatus::is_valid(&value) {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"非法 status 值 {:?},合法值: {:?}",
|
"非法 status 值 {:?},合法值: {:?}",
|
||||||
value,
|
value,
|
||||||
TaskStatus::valid_values()
|
TaskStatus::valid_values()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
// BE-CMD-20:合法 status 值亦不可经本 IPC 写入(status 已移出 tasks 白名单、收口到状态机),
|
||||||
|
// 给出可操作指引,避免落到通用 update_field 的白名单冷错误(「表 tasks 不允许的字段名」)。
|
||||||
|
return Err(
|
||||||
|
"status 不可经 update_task 直接修改,请走 advance_task(状态机,含转换校验与 review_rounds 累加)"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
// priority 值域 0..=3(0=critical, 1=high, 2=medium, 3=low),与前端 <select> 及
|
// priority 值域 0..=3(0=critical, 1=high, 2=medium, 3=low),与前端 <select> 及
|
||||||
// constants/project.ts 的 PRIORITY_LABELS 一致。拦截 "abc" / 999 等脏数据。
|
// constants/project.ts 的 PRIORITY_LABELS 一致。拦截 "abc" / 999 等脏数据。
|
||||||
if field == "priority" {
|
if field == "priority" {
|
||||||
@@ -407,6 +415,12 @@ pub async fn update_task(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// BE-CMD-9:content_json 是 JSON 字段,补合法性校验(对齐 create_task 与 update_idea 的
|
||||||
|
// related_ids/scores),防脏 JSON 静默落库;空串视为清除,放行。
|
||||||
|
if field == "content_json" && !value.trim().is_empty() {
|
||||||
|
serde_json::from_str::<serde_json::Value>(&value)
|
||||||
|
.map_err(|e| format!("content_json 不是合法 JSON: {e}"))?;
|
||||||
|
}
|
||||||
// project_id 跨表存在性校验(收尾)。
|
// project_id 跨表存在性校验(收尾)。
|
||||||
// crud.rs tasks 白名单保留 project_id(支持跨项目移动),跨表约束由本命令层兜底。
|
// crud.rs tasks 白名单保留 project_id(支持跨项目移动),跨表约束由本命令层兜底。
|
||||||
// 拦截移动到不存在的项目(手输脏 id / 已物理删除的项目),避免 tasks.project_id 悬空。
|
// 拦截移动到不存在的项目(手输脏 id / 已物理删除的项目),避免 tasks.project_id 悬空。
|
||||||
|
|||||||
+128
-91
@@ -630,11 +630,10 @@ async fn handle_message(
|
|||||||
let (tx, _rx) = tokio::sync::oneshot::channel::<String>();
|
let (tx, _rx) = tokio::sync::oneshot::channel::<String>();
|
||||||
state.register_agent_completion(conv_id.clone(), tx);
|
state.register_agent_completion(conv_id.clone(), tx);
|
||||||
|
|
||||||
// 流式预览:订阅既有 ai_event_bus 拿本 conv 的 AiTextDelta 增量(emit_ai_chat_event
|
// 过程可见性(13a):预览任务订阅既有 ai_event_bus 拿本 conv 的增量(delta/轮次/工具/
|
||||||
// 双写总线已接入,渠道侧纯消费,零改动接线)。spawn 前订阅防早期增量丢失;
|
// 进度)节流刷新占位卡;受理卡与终版由完成订阅任务经帧通道投递。本任务为占位卡唯一
|
||||||
// 终版文本经 deliver_tx 也交给本任务单点投递,占位卡片自 spawn 起只被这一个任务写,
|
// 写者——中间态与终版同任务串行,无迟到中间态覆盖终版的竞态。spawn 前订阅防早期增量丢失
|
||||||
// 中间态与终版写卡天然串行,无迟到中间态覆盖终版的竞态
|
let (frame_tx, frame_rx) = tokio::sync::mpsc::channel::<CardFrame>(8);
|
||||||
let (deliver_tx, deliver_rx) = tokio::sync::oneshot::channel::<String>();
|
|
||||||
let delta_rx = state.ai_event_bus.subscribe();
|
let delta_rx = state.ai_event_bus.subscribe();
|
||||||
let stream_task = tauri::async_runtime::spawn(run_stream_refresher(
|
let stream_task = tauri::async_runtime::spawn(run_stream_refresher(
|
||||||
ctx.clone(),
|
ctx.clone(),
|
||||||
@@ -643,7 +642,7 @@ async fn handle_message(
|
|||||||
message_id.clone(),
|
message_id.clone(),
|
||||||
chat_id.clone(),
|
chat_id.clone(),
|
||||||
delta_rx,
|
delta_rx,
|
||||||
deliver_rx,
|
frame_rx,
|
||||||
));
|
));
|
||||||
|
|
||||||
let task_brief = truncate(&text, 60); // move 前先取展示摘要(spawn 会 move text)
|
let task_brief = truncate(&text, 60); // move 前先取展示摘要(spawn 会 move text)
|
||||||
@@ -669,9 +668,9 @@ async fn handle_message(
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
// 启动失败:通知桥条目清掉防死条目(编排节点同款)
|
// 启动失败:通知桥条目清掉防死条目(编排节点同款)
|
||||||
state.notify_agent_completion(&conv_id, String::new());
|
state.notify_agent_completion(&conv_id, String::new());
|
||||||
// 关终版通道让流式任务退出(此路无任何增量产出,卡片未被流式写过),
|
// 关帧通道让预览任务退出(此路无任何帧产出,卡片未被写过),
|
||||||
// 待其退出后再走失败投递,保卡片写入单点次序
|
// 待其退出后再走失败投递,保卡片写入单点次序
|
||||||
drop(deliver_tx);
|
drop(frame_tx);
|
||||||
let _ = stream_task.await;
|
let _ = stream_task.await;
|
||||||
feishu_log(&format!("spawn 失败 conv={conv_id}: {e}"));
|
feishu_log(&format!("spawn 失败 conv={conv_id}: {e}"));
|
||||||
// 18 渠道错误话术透传:并发护栏拒绝(生成中)≠真失败——固定「启动失败」话术
|
// 18 渠道错误话术透传:并发护栏拒绝(生成中)≠真失败——固定「启动失败」话术
|
||||||
@@ -701,11 +700,10 @@ async fn handle_message(
|
|||||||
// 消息处理 30 分钟,后续消息(含注入旁路)即时响应。终版投递改由 completion_watcher
|
// 消息处理 30 分钟,后续消息(含注入旁路)即时响应。终版投递改由 completion_watcher
|
||||||
// 消费 ai_event_bus 的 AiAgentFinished 广播(引擎层 deliver_completion_report_if_waited
|
// 消费 ai_event_bus 的 AiAgentFinished 广播(引擎层 deliver_completion_report_if_waited
|
||||||
// 统一发出,单一事实源),原地 edit_card 占位卡为结果。
|
// 统一发出,单一事实源),原地 edit_card 占位卡为结果。
|
||||||
// 卡片所有权移交(走查🔴#1 修正):占位卡自此只被 watcher 任务写——受理卡与终版
|
// 卡片所有权(13a 修订):占位卡唯一写者是预览任务(run_stream_refresher),受理卡与
|
||||||
// 都在 watcher 单任务内按序落卡(受理卡先写,快任务兜底命中时跳过受理卡直投终版),
|
// 终版都由本完成订阅任务经帧通道交给它按序落卡;预览任务内部串行,消除「受理卡与终版
|
||||||
// 彻底消除「受理卡与终版两个 HTTP 请求网络乱序,终版被受理卡覆盖丢失」的竞态窗口。
|
// 两个 HTTP 请求网络乱序,终版被受理卡覆盖丢失」的竞态窗口。13a 前独立预览任务在受理
|
||||||
drop(deliver_tx);
|
// 即被关停(drop 帧通道),心跳/阶段进展形同虚设;此处交回帧通道驱动使其真正落到卡片。
|
||||||
let _ = stream_task.await; // 等流式任务退出(快,只走通道关闭分支)
|
|
||||||
let ack = format!("🚀 已受理,后台执行中\n任务:{task_brief}");
|
let ack = format!("🚀 已受理,后台执行中\n任务:{task_brief}");
|
||||||
feishu_log(&format!("受理返回 chat={chat_id} conv={conv_id}"));
|
feishu_log(&format!("受理返回 chat={chat_id} conv={conv_id}"));
|
||||||
|
|
||||||
@@ -723,12 +721,11 @@ async fn handle_message(
|
|||||||
run_completion_watcher(
|
run_completion_watcher(
|
||||||
ctx,
|
ctx,
|
||||||
conv_id,
|
conv_id,
|
||||||
placeholder_id,
|
|
||||||
message_id,
|
|
||||||
chat_id,
|
|
||||||
ack,
|
ack,
|
||||||
inflight_guard,
|
inflight_guard,
|
||||||
cancel_rx,
|
cancel_rx,
|
||||||
|
frame_tx,
|
||||||
|
stream_task,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
// watcher 任意路径退出都摘除取消句柄,防陈旧条目驻留(后续 /cancel 空发无害)
|
// watcher 任意路径退出都摘除取消句柄,防陈旧条目驻留(后续 /cancel 空发无害)
|
||||||
@@ -741,9 +738,16 @@ async fn handle_message(
|
|||||||
/// 判定秒级任务已完成。8s 覆盖秒级任务;长任务仅推迟主循环进入,不影响终版到达时刻
|
/// 判定秒级任务已完成。8s 覆盖秒级任务;长任务仅推迟主循环进入,不影响终版到达时刻
|
||||||
const FAST_TASK_GRACE: std::time::Duration = std::time::Duration::from_secs(8);
|
const FAST_TASK_GRACE: std::time::Duration = std::time::Duration::from_secs(8);
|
||||||
|
|
||||||
/// 完成事件订阅任务(#14 两段式):占位卡唯一写者。先投受理卡,再消费 ai_event_bus 的
|
/// 卡片帧:占位卡唯一写者(预览任务)与裁决者(完成订阅任务)之间的投递契约。
|
||||||
/// AiAgentFinished(本 conv)原地 edit_card 为终版。引擎层 deliver_completion_report_if_waited
|
/// terminal=true 的帧(终版/取消/失败)写入后预览任务收工退出,保证卡片最后写入即终版。
|
||||||
/// 是广播单一发出点,watcher 纯消费无状态。
|
struct CardFrame {
|
||||||
|
text: String,
|
||||||
|
terminal: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 完成事件订阅任务(#14 两段式):只做裁决——消费 ai_event_bus 的 AiAgentFinished(本 conv)
|
||||||
|
/// 决定终版文本,经帧通道交给预览任务原地落卡(占位卡唯一写者是预览任务,受理卡/终版/阶段
|
||||||
|
/// 提示全部经帧通道按序落卡)。引擎层 deliver_completion_report_if_waited 是广播单一发出点。
|
||||||
///
|
///
|
||||||
/// 超时语义:35min(> loop 30min 上限)首超时回查边界内产出,无果投降级提示但不退出
|
/// 超时语义:35min(> loop 30min 上限)首超时回查边界内产出,无果投降级提示但不退出
|
||||||
/// (任务后台不丢,晚到完成事件仍送达);二次超时真弃守(#23:两轮均无事件且无边界内
|
/// (任务后台不丢,晚到完成事件仍送达);二次超时真弃守(#23:两轮均无事件且无边界内
|
||||||
@@ -751,14 +755,15 @@ const FAST_TASK_GRACE: std::time::Duration = std::time::Duration::from_secs(8);
|
|||||||
async fn run_completion_watcher(
|
async fn run_completion_watcher(
|
||||||
ctx: Arc<Ctx>,
|
ctx: Arc<Ctx>,
|
||||||
conv_id: String,
|
conv_id: String,
|
||||||
placeholder_id: Option<String>,
|
|
||||||
message_id: String,
|
|
||||||
chat_id: String,
|
|
||||||
ack: String,
|
ack: String,
|
||||||
// #20:计数生命周期跟任务走,guard 持有至 watcher 退出
|
// #20:计数生命周期跟任务走,guard 持有至 watcher 退出
|
||||||
_inflight: InflightGuard,
|
_inflight: InflightGuard,
|
||||||
// 24 取消收口:/cancel 旁路唤醒(引擎停止路径只 emit AiCompleted,本任务消费不到)
|
// 24 取消收口:/cancel 旁路唤醒(引擎停止路径只 emit AiCompleted,本任务消费不到)
|
||||||
mut cancel_rx: tokio::sync::oneshot::Receiver<()>,
|
mut cancel_rx: tokio::sync::oneshot::Receiver<()>,
|
||||||
|
// 13a:占位卡写权限交预览任务,本任务所有落卡经此投递(terminal 标记终版)
|
||||||
|
frame_tx: tokio::sync::mpsc::Sender<CardFrame>,
|
||||||
|
// 预览任务句柄:投终版后 await 其退出,保证落卡完成后才释放 inflight
|
||||||
|
stream_task: tauri::async_runtime::JoinHandle<()>,
|
||||||
) {
|
) {
|
||||||
let state = ctx.app.state::<crate::state::AppState>();
|
let state = ctx.app.state::<crate::state::AppState>();
|
||||||
let mut rx = state.ai_event_bus.subscribe();
|
let mut rx = state.ai_event_bus.subscribe();
|
||||||
@@ -768,8 +773,8 @@ async fn run_completion_watcher(
|
|||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.map(|d| d.as_millis() as i64)
|
.map(|d| d.as_millis() as i64)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
// 落受理卡(占位缺失回退新发,deliver_result 内部链路)
|
// 落受理卡(经预览任务原地更新占位卡,未收到终版前预览任务持续刷新过程进展)
|
||||||
deliver_result(&ctx.client, &placeholder_id.clone(), &message_id.clone(), &chat_id.clone(), &ack).await;
|
let _ = frame_tx.send(CardFrame { text: ack, terminal: false }).await;
|
||||||
// #27 快任务兜底:受理卡发出后开宽限窗再判定,事件驱动替代恒假的时间比较。
|
// #27 快任务兜底:受理卡发出后开宽限窗再判定,事件驱动替代恒假的时间比较。
|
||||||
// 秒级任务的完成事件可能先于本订阅已广播完(错过),窗结束时 conv 已归非活跃且
|
// 秒级任务的完成事件可能先于本订阅已广播完(错过),窗结束时 conv 已归非活跃且
|
||||||
// 边界内有产出,即判快任务已完成,直投终版
|
// 边界内有产出,即判快任务已完成,直投终版
|
||||||
@@ -778,7 +783,8 @@ async fn run_completion_watcher(
|
|||||||
if let Some(t) = last_assistant_reply(&state.db, &conv_id, Some(accepted_ms)).await {
|
if let Some(t) = last_assistant_reply(&state.db, &conv_id, Some(accepted_ms)).await {
|
||||||
if !t.trim().is_empty() {
|
if !t.trim().is_empty() {
|
||||||
feishu_log(&format!("watcher 快任务兜底 conv={conv_id} len={}", t.len()));
|
feishu_log(&format!("watcher 快任务兜底 conv={conv_id} len={}", t.len()));
|
||||||
deliver_result(&ctx.client, &placeholder_id, &message_id, &chat_id, &t).await;
|
let _ = frame_tx.send(CardFrame { text: t, terminal: true }).await;
|
||||||
|
let _ = stream_task.await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -787,19 +793,11 @@ async fn run_completion_watcher(
|
|||||||
let timeout = std::time::Duration::from_secs(2100);
|
let timeout = std::time::Duration::from_secs(2100);
|
||||||
let mut timeouts = 0usize;
|
let mut timeouts = 0usize;
|
||||||
loop {
|
loop {
|
||||||
let text = tokio::select! {
|
let frame = tokio::select! {
|
||||||
// 24 取消收口:/cancel 旁路唤醒,投「已停止」终版后退出,不再等引擎事件
|
// 24 取消收口:/cancel 旁路唤醒,投「已停止」终版后退出,不再等引擎事件
|
||||||
_ = &mut cancel_rx => {
|
_ = &mut cancel_rx => {
|
||||||
feishu_log(&format!("watcher 收到取消指令 conv={conv_id}"));
|
feishu_log(&format!("watcher 收到取消指令 conv={conv_id}"));
|
||||||
deliver_result(
|
CardFrame { text: "🛑 任务已停止。可发 /status 查看会话状态,或直接发新任务。".to_string(), terminal: true }
|
||||||
&ctx.client,
|
|
||||||
&placeholder_id,
|
|
||||||
&message_id,
|
|
||||||
&chat_id,
|
|
||||||
"🛑 任务已停止。可发 /status 查看会话状态,或直接发新任务。",
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
item = rx.recv() => match item {
|
item = rx.recv() => match item {
|
||||||
Ok(v) => {
|
Ok(v) => {
|
||||||
@@ -813,11 +811,17 @@ async fn run_completion_watcher(
|
|||||||
Some("AiError") => {
|
Some("AiError") => {
|
||||||
let err = v.get("error").and_then(|s| s.as_str()).unwrap_or("未知错误");
|
let err = v.get("error").and_then(|s| s.as_str()).unwrap_or("未知错误");
|
||||||
feishu_log(&format!("watcher 收到 AiError conv={conv_id}"));
|
feishu_log(&format!("watcher 收到 AiError conv={conv_id}"));
|
||||||
format!("❌ 任务执行失败:{},可稍后重试,或发 /status 查看会话状态。", truncate(err, 300))
|
CardFrame {
|
||||||
|
text: format!("❌ 任务执行失败:{},可稍后重试,或发 /status 查看会话状态。", truncate(err, 300)),
|
||||||
|
terminal: true,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Some("AiAgentFinished") => {
|
Some("AiAgentFinished") => {
|
||||||
// 广播语义:agent_id==conv_id 时 summary 即本会话最终报告(引擎层构造)
|
// 广播语义:agent_id==conv_id 时 summary 即本会话最终报告(引擎层构造)
|
||||||
v.get("summary").and_then(|s| s.as_str()).unwrap_or("").to_string()
|
CardFrame {
|
||||||
|
text: v.get("summary").and_then(|s| s.as_str()).unwrap_or("").to_string(),
|
||||||
|
terminal: true,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ => continue,
|
_ => continue,
|
||||||
}
|
}
|
||||||
@@ -829,25 +833,30 @@ async fn run_completion_watcher(
|
|||||||
timeouts += 1;
|
timeouts += 1;
|
||||||
// 二次超时弃守:两轮 35min 均无事件且无边界内产出,认任务失联,停止重刷
|
// 二次超时弃守:两轮 35min 均无事件且无边界内产出,认任务失联,停止重刷
|
||||||
if timeouts >= 2 {
|
if timeouts >= 2 {
|
||||||
let give_up = "⏰ 任务超 70 分钟无任何结果(已停止等待;引擎仍在后台运行则结果稍后可在桌面端查看;可发 /status 查询)。".to_string();
|
|
||||||
feishu_log(&format!("watcher 二次超时弃守 conv={conv_id}"));
|
feishu_log(&format!("watcher 二次超时弃守 conv={conv_id}"));
|
||||||
deliver_result(&ctx.client, &placeholder_id, &message_id, &chat_id, &give_up).await;
|
CardFrame {
|
||||||
return;
|
text: "⏰ 任务超 70 分钟无任何结果(已停止等待;引擎仍在后台运行则结果稍后可在桌面端查看;可发 /status 查询)。".to_string(),
|
||||||
|
terminal: true,
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
// 首超时:DB 回查兜底(任务可能刚完成事件已发完);回查有果照投终版,
|
// 首超时:DB 回查兜底(任务可能刚完成事件已发完);回查有果照投终版,
|
||||||
// 无果投降级提示并继续等待(晚到事件仍送达)
|
// 无果投降级提示并继续等待(晚到事件仍送达)
|
||||||
match last_assistant_reply(&state.db, &conv_id, Some(accepted_ms)).await {
|
match last_assistant_reply(&state.db, &conv_id, Some(accepted_ms)).await {
|
||||||
Some(t) if !t.trim().is_empty() => t,
|
Some(t) if !t.trim().is_empty() => CardFrame { text: t, terminal: true },
|
||||||
_ => {
|
_ => {
|
||||||
let notice = "⏰ 任务执行超 35 分钟仍未返回(后台仍在运行,完成后本卡片自动更新;或本会话 /status 查询)。".to_string();
|
let _ = frame_tx.send(CardFrame {
|
||||||
deliver_result(&ctx.client, &placeholder_id, &message_id, &chat_id, ¬ice).await;
|
text: "⏰ 任务执行超 35 分钟仍未返回(后台仍在运行,完成后本卡片自动更新;或本会话 /status 查询)。".to_string(),
|
||||||
|
terminal: false,
|
||||||
|
}).await;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
feishu_log(&format!("watcher 终版 conv={conv_id} len={}", text.len()));
|
feishu_log(&format!("watcher 终版 conv={conv_id} len={}", frame.text.len()));
|
||||||
deliver_result(&ctx.client, &placeholder_id, &message_id, &chat_id, &text).await;
|
let _ = frame_tx.send(frame).await;
|
||||||
|
let _ = stream_task.await; // 等预览任务落卡终版并退出,再释放 inflight
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1199,18 +1208,33 @@ async fn read_whitelist(ctx: &Ctx) -> Vec<String> {
|
|||||||
const STREAM_FLUSH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(3);
|
const STREAM_FLUSH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(3);
|
||||||
/// 流式预览上限字符数:对齐终版 clip_for_card 口径;超限从头截断保尾部
|
/// 流式预览上限字符数:对齐终版 clip_for_card 口径;超限从头截断保尾部
|
||||||
const STREAM_PREVIEW_MAX_CHARS: usize = 8000;
|
const STREAM_PREVIEW_MAX_CHARS: usize = 8000;
|
||||||
/// 13a-2 静默期心跳阈值:超此秒数无本 conv 事件(delta/轮次/工具),tick 强制刷心跳行。
|
/// 13a-2 静默期心跳阈值:超此秒数无本 conv 内容进展(delta/轮次/工具/进度),tick 强制刷心跳行。
|
||||||
/// 60s 取「长工具常见执行时长」与「卡片冻结感阈值」之间——再短会与常规刷新混叠,
|
/// 60s 取「长工具常见执行时长」与「卡片冻结感阈值」之间——再短会与常规刷新混叠,
|
||||||
/// 再长用户已开始疑心卡死。心跳行此后每静默分钟刷一次(桶去重),事件恢复即自然停
|
/// 再长用户已开始疑心卡死。心跳行此后每静默分钟刷一次(桶去重),内容恢复即自然停。
|
||||||
|
/// 注意:AiHeartbeat 等引擎保活事件不计入活动钟(非内容进展),否则心跳节拍会把静默永远压在阈值内
|
||||||
const HEARTBEAT_SILENT_THRESHOLD: u64 = 60;
|
const HEARTBEAT_SILENT_THRESHOLD: u64 = 60;
|
||||||
|
|
||||||
/// 流式预览任务:消费 ai_event_bus 的 AiTextDelta,节流把累计文本 edit_card 进占位卡片;
|
/// 13a-2 心跳刷判定(纯函数,单测锁定):内容静默达阈值且进入新的静默分钟桶时返回该桶号
|
||||||
/// 终版文本由 deliver_tx 单点传入,复用 deliver_result 投递后退出。
|
/// (应刷),否则 None(不刷)。桶去重保证每个静默分钟只刷一次,内容恢复(静默回落)即停。
|
||||||
|
fn heartbeat_due(silent_secs: u64, painted_bucket: Option<u64>, threshold: u64) -> Option<u64> {
|
||||||
|
if silent_secs < threshold {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let bucket = silent_secs / 60;
|
||||||
|
if Some(bucket) == painted_bucket {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(bucket)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 流式预览任务:订阅 ai_event_bus 的增量(delta/轮次/工具/进度)节流把累计内容 edit_card
|
||||||
|
/// 进占位卡;受理卡/阶段提示/终版经帧通道(frame_rx)传入,本任务是占位卡唯一写者。
|
||||||
///
|
///
|
||||||
/// 竞态设计:占位卡片在本任务存活期只有本任务写,中间态 tick 与终版投递同任务串行,
|
/// 竞态设计:占位卡在本任务存活期只有本任务写,中间态 tick 与帧投递同任务串行,上一次
|
||||||
/// 上一次 edit_card 的 HTTP 响应返回后才可能发下一次,飞书服务端所见次序即代码次序,
|
/// edit_card 的 HTTP 响应返回后才可能发下一次,飞书服务端所见次序即代码次序,终版不会
|
||||||
/// 终版不会被迟到的中间态覆盖,故无需 AbortHandle。中间态刷失败不新发卡片回退
|
/// 被迟到的中间态覆盖,故无需 AbortHandle。中间态刷失败不新发卡片回退(不值得为预览刷屏),
|
||||||
/// (不值得为预览刷屏),新发回退链仍归终版 deliver_result 独有,行为不受本任务影响。
|
/// 新发回退链归帧投递(deliver_result)独有。
|
||||||
async fn run_stream_refresher(
|
async fn run_stream_refresher(
|
||||||
ctx: Arc<Ctx>,
|
ctx: Arc<Ctx>,
|
||||||
conv_id: String,
|
conv_id: String,
|
||||||
@@ -1218,7 +1242,7 @@ async fn run_stream_refresher(
|
|||||||
message_id: String,
|
message_id: String,
|
||||||
chat_id: String,
|
chat_id: String,
|
||||||
mut delta_rx: tokio::sync::broadcast::Receiver<serde_json::Value>,
|
mut delta_rx: tokio::sync::broadcast::Receiver<serde_json::Value>,
|
||||||
mut deliver_rx: tokio::sync::oneshot::Receiver<String>,
|
mut frame_rx: tokio::sync::mpsc::Receiver<CardFrame>,
|
||||||
) {
|
) {
|
||||||
let mut acc = String::new();
|
let mut acc = String::new();
|
||||||
// 累计字符数 O(1) 维护,与上次已刷数比对判"有无新增量",避免无变化空刷烧配额
|
// 累计字符数 O(1) 维护,与上次已刷数比对判"有无新增量",避免无变化空刷烧配额
|
||||||
@@ -1239,22 +1263,28 @@ async fn run_stream_refresher(
|
|||||||
let mut progress_tokens: Option<u64> = None;
|
let mut progress_tokens: Option<u64> = None;
|
||||||
// 13a-1 刷新可观测:流式刷失败连败计数(成功归零;==阈值告警一次,继续累计防重复告警)
|
// 13a-1 刷新可观测:流式刷失败连败计数(成功归零;==阈值告警一次,继续累计防重复告警)
|
||||||
let mut refresh_failures: u32 = 0;
|
let mut refresh_failures: u32 = 0;
|
||||||
// 13a-2 静默期心跳:最后活动时刻(本 conv 任意事件都算活着);静默超阈值强制刷心跳行,
|
// 13a-2 静默期心跳:最后内容进展时刻(delta/轮次/工具/进度;不含 AiHeartbeat);静默超阈值
|
||||||
// 防长工具/LLM 长往返期间卡片冻结在旧帧,用户误判卡死(todo.md 13a-2)
|
// 强制刷心跳行,防长工具/LLM 长往返期间卡片冻结在旧帧,用户误判卡死(todo.md 13a-2)
|
||||||
let mut last_activity = tokio::time::Instant::now();
|
let mut last_activity = tokio::time::Instant::now();
|
||||||
let mut painted_hb_bucket: Option<u64> = None;
|
let mut painted_hb_bucket: Option<u64> = None;
|
||||||
|
// 受理卡(首帧)到达前不写卡:防心跳/预览帧先于受理卡落卡,次序错乱
|
||||||
|
let mut started = false;
|
||||||
let mut ticker = tokio::time::interval(STREAM_FLUSH_INTERVAL);
|
let mut ticker = tokio::time::interval(STREAM_FLUSH_INTERVAL);
|
||||||
// 刷新落后只保最新帧不补帧:预览语义是最新输出才有价值
|
// 刷新落后只保最新帧不补帧:预览语义是最新输出才有价值
|
||||||
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
// 终版单点收口:投递后任务退出,卡片所有权移交终版内容
|
// 帧通道:受理卡/阶段提示/终版。走 deliver_result(净化+裁剪+新发兜底),与预览同任务串行
|
||||||
final_text = &mut deliver_rx => {
|
frame = frame_rx.recv() => {
|
||||||
if let Ok(text) = final_text {
|
let Some(f) = frame else {
|
||||||
deliver_result(&ctx.client, &placeholder_id, &message_id, &chat_id, &text).await;
|
// 发送端丢弃(watcher 退出/受理失败):无更多帧,收工
|
||||||
}
|
|
||||||
// 发送端被丢弃(spawn 失败路径)时不投递,由调用方兜底
|
|
||||||
return;
|
return;
|
||||||
|
};
|
||||||
|
deliver_result(&ctx.client, &placeholder_id, &message_id, &chat_id, &f.text).await;
|
||||||
|
started = true;
|
||||||
|
if f.terminal {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
item = delta_rx.recv() => match item {
|
item = delta_rx.recv() => match item {
|
||||||
Ok(v) => {
|
Ok(v) => {
|
||||||
@@ -1267,11 +1297,10 @@ async fn run_stream_refresher(
|
|||||||
if let Some(delta) = v.get("delta").and_then(|d| d.as_str()) {
|
if let Some(delta) = v.get("delta").and_then(|d| d.as_str()) {
|
||||||
acc.push_str(delta);
|
acc.push_str(delta);
|
||||||
acc_chars += delta.chars().count();
|
acc_chars += delta.chars().count();
|
||||||
// 首帧提速(2026-10-08):首个 delta 到达即立即刷占位卡,
|
// 首帧提速(2026-10-08):首个 delta 到达且受理卡已落地即立即刷占位卡,
|
||||||
// 不等 3s ticker——短响应(简单问答/翻译)原需等满一个节流周期
|
// 不等 3s ticker——短响应(简单问答/翻译)原需等满一个节流周期才见首字,
|
||||||
// 才见首字,感知停顿明显。仅首帧走即时刷(acc_chars 首次>painted),
|
// 感知停顿明显。仅首帧走即时刷,后续仍按 3s 节流守飞书 QPS 配额
|
||||||
// 后续仍按 3s 节流守飞书 QPS 配额
|
if started && painted_chars == 0 && acc_chars > 0 {
|
||||||
if painted_chars == 0 && acc_chars > 0 {
|
|
||||||
if let Some(pid) = placeholder_id.as_deref().filter(|id| !id.is_empty()) {
|
if let Some(pid) = placeholder_id.as_deref().filter(|id| !id.is_empty()) {
|
||||||
let progress_snapshot = progress_round.map(|pr| (pr, progress_max, progress_tokens));
|
let progress_snapshot = progress_round.map(|pr| (pr, progress_max, progress_tokens));
|
||||||
if update_card(&ctx.client, pid, &stream_preview(&acc, round, current_tool.as_deref(), last_tool.as_deref(), tool_steps, None, progress_snapshot)).await {
|
if update_card(&ctx.client, pid, &stream_preview(&acc, round, current_tool.as_deref(), last_tool.as_deref(), tool_steps, None, progress_snapshot)).await {
|
||||||
@@ -1311,48 +1340,46 @@ async fn run_stream_refresher(
|
|||||||
painted_chars = painted_chars.saturating_sub(1); // 标脏
|
painted_chars = painted_chars.saturating_sub(1); // 标脏
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
// AiHeartbeat 等引擎保活事件不重置内容静默钟:否则 20s 心跳节拍会把静默
|
||||||
|
// 永远压在阈值内,13a-2 心跳行永不触发(静默指内容进展停顿,非事件停顿)
|
||||||
|
_ => continue,
|
||||||
}
|
}
|
||||||
// 13a-2 活动时钟:本 conv 任意事件(delta/轮次/工具/进度)都算活着,重置静默计时
|
// 13a-2 活动时钟:本 conv 内容事件(delta/轮次/工具/进度)算活着,重置静默计时
|
||||||
last_activity = tokio::time::Instant::now();
|
last_activity = tokio::time::Instant::now();
|
||||||
}
|
}
|
||||||
// 预览允许丢帧:终版事实源在通知桥 oneshot,消费落后只降级预览不断链路
|
// 预览允许丢帧:终版事实源在帧通道,消费落后只降级预览不断链路
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||||
tracing::warn!("[feishu] 流式预览消费落后,丢弃 {n} 条增量(预览降级,终版不受影响)");
|
tracing::warn!("[feishu] 流式预览消费落后,丢弃 {n} 条增量(预览降级,终版不受影响)");
|
||||||
}
|
}
|
||||||
// 总线随 AppState 常驻理论不关;防御性收口:只等终版,不再消费增量
|
// 总线随 AppState 常驻理论不关;防御性收口:总线关闭即收工(进程收尾)
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
|
||||||
if let Ok(text) = deliver_rx.await {
|
|
||||||
deliver_result(&ctx.client, &placeholder_id, &message_id, &chat_id, &text).await;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
_ = ticker.tick() => {
|
_ = ticker.tick() => {
|
||||||
|
// 受理卡未落地前不刷:防预览/心跳帧抢先覆盖受理卡
|
||||||
|
if !started {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// 占位缺失(首张卡片 reply 失败)时无从原地刷,保持静默等终版走新发回退
|
// 占位缺失(首张卡片 reply 失败)时无从原地刷,保持静默等终版走新发回退
|
||||||
let Some(pid) = placeholder_id.as_deref().filter(|id| !id.is_empty()) else {
|
let Some(pid) = placeholder_id.as_deref().filter(|id| !id.is_empty()) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
// ── 13a-2 静默期心跳:超阈值无事件强制刷心跳行,防卡片冻结疑似卡死 ──
|
// ── 13a-2 静默期心跳:内容静默达阈值且进入新静默分钟桶 → 刷心跳行(纯函数判定) ──
|
||||||
// 长工具/LLM 长往返期间无 delta 无轮次推进,卡片静止;心跳行给出「引擎活着+
|
// 长工具/LLM 长往返期间无 delta 无轮次推进,卡片静止;心跳行给出「引擎活着+
|
||||||
// 进行到哪」证据。桶去重:每静默分钟只刷一次(silent_secs/60 桶号相等即同桶)
|
// 进行到哪」证据。桶去重保证每静默分钟只刷一次,内容恢复即自然停
|
||||||
let silent_secs = last_activity.elapsed().as_secs();
|
let silent_secs = last_activity.elapsed().as_secs();
|
||||||
let hb_needed = silent_secs >= HEARTBEAT_SILENT_THRESHOLD;
|
let hb_bucket = heartbeat_due(silent_secs, painted_hb_bucket, HEARTBEAT_SILENT_THRESHOLD);
|
||||||
let hb_bucket = silent_secs / 60;
|
let hb_needed = hb_bucket.is_some();
|
||||||
if hb_needed && Some(hb_bucket) == painted_hb_bucket {
|
|
||||||
continue; // 本静默分钟已刷过心跳,去重
|
|
||||||
}
|
|
||||||
// 常规信号标脏:过程事件已回拨 painted_chars,字符数不等即刷
|
// 常规信号标脏:过程事件已回拨 painted_chars,字符数不等即刷
|
||||||
let dirty = acc_chars != painted_chars;
|
let dirty = acc_chars != painted_chars;
|
||||||
if !dirty && !hb_needed {
|
if !dirty && !hb_needed {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// 签名去重:正文长度与轨迹行均未变则跳过(防同 tick 内事件风暴空刷)
|
// 签名去重:正文长度与轨迹行均未变则跳过(防同 tick 内事件风暴空刷)
|
||||||
let sig = format!("{}|{}|{}|{}|{}", acc_chars, round.unwrap_or(0), current_tool.as_deref().unwrap_or(""), tool_steps, if hb_needed { hb_bucket } else { u64::MAX });
|
let sig = format!("{}|{}|{}|{}|{}", acc_chars, round.unwrap_or(0), current_tool.as_deref().unwrap_or(""), tool_steps, hb_bucket.unwrap_or(u64::MAX));
|
||||||
if sig == painted_sig {
|
if sig == painted_sig {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// 13a-1 刷新可观测:刷失败不再静默——feishu_log 落盘+连败计数,≥3 连败告警一次(计数继续累计,
|
// 13a-1 刷新可观测:刷失败落盘+连败计数,≥3 连败告警一次(计数继续累计,
|
||||||
// 重置仅在成功后;防每 tick 重复告警刷屏)。排查凭据:刷了没刷/刷败原因全可查
|
// 重置仅在成功后;防每 tick 重复告警刷屏)。排查凭据:刷了没刷/刷败原因全可查
|
||||||
let silent_for_preview = if hb_needed { Some(silent_secs) } else { None };
|
let silent_for_preview = if hb_needed { Some(silent_secs) } else { None };
|
||||||
let progress_snapshot = progress_round.map(|pr| (pr, progress_max, progress_tokens));
|
let progress_snapshot = progress_round.map(|pr| (pr, progress_max, progress_tokens));
|
||||||
@@ -1363,8 +1390,8 @@ _ = ticker.tick() => {
|
|||||||
refresh_failures = 0;
|
refresh_failures = 0;
|
||||||
painted_chars = acc_chars;
|
painted_chars = acc_chars;
|
||||||
painted_sig = sig;
|
painted_sig = sig;
|
||||||
if hb_needed {
|
if let Some(b) = hb_bucket {
|
||||||
painted_hb_bucket = Some(hb_bucket); // 心跳桶已刷,本静默分钟不再刷
|
painted_hb_bucket = Some(b); // 心跳桶已刷,本静默分钟不再刷
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
refresh_failures += 1;
|
refresh_failures += 1;
|
||||||
@@ -1373,7 +1400,7 @@ _ = ticker.tick() => {
|
|||||||
));
|
));
|
||||||
if refresh_failures == REFRESH_FAIL_ALERT_THRESHOLD {
|
if refresh_failures == REFRESH_FAIL_ALERT_THRESHOLD {
|
||||||
feishu_log(&format!(
|
feishu_log(&format!(
|
||||||
"[refresher] ⚠️ 连续 {refresh_failures} 次刷新失败,卡片可能冻结——流式预览降级,终版仍走通知桥(不受影响)"
|
"[refresher] ⚠️ 连续 {refresh_failures} 次刷新失败,卡片可能冻结——流式预览降级,终版仍走帧通道(不受影响)"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1931,7 +1958,7 @@ 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::{
|
||||||
is_terminal_push, md_to_lark_friendly, msg_after_acceptance, parse_command,
|
heartbeat_due, is_terminal_push, md_to_lark_friendly, msg_after_acceptance, parse_command,
|
||||||
strip_mention_placeholders, stream_preview,
|
strip_mention_placeholders, stream_preview,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2069,6 +2096,16 @@ mod tests {
|
|||||||
assert!(out.starts_with("💓 **仍在跑:第 15 轮 · 最近动作 `3 步工具调用` · 静默 72s**\n\n"));
|
assert!(out.starts_with("💓 **仍在跑:第 15 轮 · 最近动作 `3 步工具调用` · 静默 72s**\n\n"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn heartbeat_due_threshold_and_bucket_dedup() {
|
||||||
|
// 13a-2 判定纯函数:未达阈值不刷;达阈值且新桶刷;同静默分钟桶去重;进新桶再刷
|
||||||
|
assert_eq!(heartbeat_due(59, None, 60), None);
|
||||||
|
assert_eq!(heartbeat_due(60, None, 60), Some(1));
|
||||||
|
assert_eq!(heartbeat_due(60, Some(1), 60), None);
|
||||||
|
assert_eq!(heartbeat_due(119, Some(1), 60), None); // 119/60=1 同桶
|
||||||
|
assert_eq!(heartbeat_due(120, Some(1), 60), Some(2));
|
||||||
|
}
|
||||||
|
|
||||||
// ── 38a 出站 md 净化 ──
|
// ── 38a 出站 md 净化 ──
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
class="update-badge-btn"
|
class="update-badge-btn"
|
||||||
:class="{ 'is-active': expanded }"
|
:class="{ 'is-active': expanded }"
|
||||||
:title="$t('settings.updateBadgeTitle')"
|
:title="$t('settings.updateBadgeTitle')"
|
||||||
|
:aria-expanded="expanded"
|
||||||
@click="expanded = !expanded"
|
@click="expanded = !expanded"
|
||||||
>
|
>
|
||||||
<span class="update-badge-dot"></span>
|
<span class="update-badge-dot"></span>
|
||||||
@@ -67,6 +68,15 @@ const installError = ref('')
|
|||||||
let unlistenAvailable: UnlistenFn | null = null
|
let unlistenAvailable: UnlistenFn | null = null
|
||||||
let unlistenProgress: UnlistenFn | null = null
|
let unlistenProgress: UnlistenFn | null = null
|
||||||
|
|
||||||
|
/** window CustomEvent 形态的更新通知:生产者 useUpdateCheck 用浏览器事件广播(非 Tauri emit),
|
||||||
|
* 与 Tauri 监听同名不同通道,故单列此处理器接住它。 */
|
||||||
|
function onUpdateAvailable(e: Event) {
|
||||||
|
const detail = (e as CustomEvent<UpdateAvailablePayload>).detail
|
||||||
|
if (!detail) return
|
||||||
|
info.value = detail
|
||||||
|
visible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
unlistenAvailable = await listen<UpdateAvailablePayload>('app-update-available', (e) => {
|
unlistenAvailable = await listen<UpdateAvailablePayload>('app-update-available', (e) => {
|
||||||
@@ -76,6 +86,10 @@ onMounted(async () => {
|
|||||||
} catch {
|
} catch {
|
||||||
/* 静默 */
|
/* 静默 */
|
||||||
}
|
}
|
||||||
|
// 通道对齐(2026-10-10 实证):生产者 useUpdateCheck 走的是**浏览器 window CustomEvent**
|
||||||
|
// (同名 'app-update-available'),与上面的 Tauri listen 不是同一通道 → 徽章此前永不触发。
|
||||||
|
// 保留 Tauri 监听(后端将来若改走 emit 亦可用),同时挂 window 监听接住现有生产者。
|
||||||
|
window.addEventListener('app-update-available', onUpdateAvailable as EventListener)
|
||||||
try {
|
try {
|
||||||
unlistenProgress = await listen<{ stage: string; downloaded: number; total: number | null }>(
|
unlistenProgress = await listen<{ stage: string; downloaded: number; total: number | null }>(
|
||||||
'app-update-progress',
|
'app-update-progress',
|
||||||
@@ -93,6 +107,7 @@ onUnmounted(() => {
|
|||||||
unlistenProgress?.()
|
unlistenProgress?.()
|
||||||
unlistenAvailable = null
|
unlistenAvailable = null
|
||||||
unlistenProgress = null
|
unlistenProgress = null
|
||||||
|
window.removeEventListener('app-update-available', onUpdateAvailable as EventListener)
|
||||||
})
|
})
|
||||||
|
|
||||||
const progressPercent = computed(() => {
|
const progressPercent = computed(() => {
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="project-card" :class="{ 'project-card--list': view === 'list' }" @click="router.push('/projects/' + project.id)">
|
<div
|
||||||
|
class="project-card"
|
||||||
|
:class="{ 'project-card--list': view === 'list' }"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
@click="openProject"
|
||||||
|
@keydown.enter.prevent="onCardKey"
|
||||||
|
@keydown.space.prevent="onCardKey"
|
||||||
|
>
|
||||||
<div class="card-top">
|
<div class="card-top">
|
||||||
<div class="card-title-row">
|
<div class="card-title-row">
|
||||||
<h2 class="card-name">{{ project.name }}</h2>
|
<h2 class="card-name">{{ project.name }}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-top-actions">
|
<div class="card-top-actions">
|
||||||
<span class="stage-badge" :class="stageClass(project.status)">{{ $t(statusLabel(project.status)) }}</span>
|
<span class="stage-badge" :class="stageClass(project.status)">{{ $t(statusLabel(project.status)) }}</span>
|
||||||
<button class="card-del-btn" :title="$t('projects.archiveTitle')" @click.stop="emit('archive', project)">📦</button>
|
<button class="card-del-btn" :title="$t('projects.archiveTitle')" :aria-label="$t('projects.archiveTitle')" @click.stop="emit('archive', project)">📦</button>
|
||||||
<button class="card-del-btn" :title="$t('projects.deleteTitle')" @click.stop="emit('delete', project)">🗑</button>
|
<button class="card-del-btn" :title="$t('projects.deleteTitle')" :aria-label="$t('projects.deleteTitle')" @click.stop="emit('delete', project)">🗑</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -74,6 +82,20 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
// 整卡点击进详情:可点根元素用 role/tabindex 兜底(卡内嵌套删除/归档按钮,不能整体换 <button>),
|
||||||
|
// 键盘与鼠标复用同一 handler
|
||||||
|
function openProject() {
|
||||||
|
router.push('/projects/' + props.project.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 卡片根键盘触发:仅当事件源就是根本身才开详情——卡内嵌套的归档/删除/展开按钮聚焦后按
|
||||||
|
* Enter/Space 会冒泡到此,原实现(.prevent 直接调 openProject)会把「删除」顶替成「跳转详情」
|
||||||
|
* 并取消按钮自身激活(2026-10-10 a11y 对抗核验实测缺陷)。 */
|
||||||
|
function onCardKey(e: KeyboardEvent) {
|
||||||
|
if (e.target !== e.currentTarget) return
|
||||||
|
openProject()
|
||||||
|
}
|
||||||
|
|
||||||
// 工程数(异步拉取,展示在卡片底部)
|
// 工程数(异步拉取,展示在卡片底部)
|
||||||
const moduleCount = ref(0)
|
const moduleCount = ref(0)
|
||||||
// UX走查 P3-6: 加载态标记,模板据此显骨架占位而非 0(成功/失败都收 loading,失败仍显 0 属既有约定)
|
// UX走查 P3-6: 加载态标记,模板据此显骨架占位而非 0(成功/失败都收 loading,失败仍显 0 属既有约定)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { aiApi } from '@/api'
|
|||||||
import { state } from '@/stores/ai'
|
import { state } from '@/stores/ai'
|
||||||
import { t } from '@/i18n/i18n-helpers'
|
import { t } from '@/i18n/i18n-helpers'
|
||||||
import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream'
|
import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream'
|
||||||
import { findToolCall, removePendingApprovalById } from './aiShared'
|
import { bumpToolEpoch, findToolCall, removePendingApprovalById } from './aiShared'
|
||||||
import { dispatchApprovalIPC } from './useToolApproval'
|
import { dispatchApprovalIPC } from './useToolApproval'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -87,7 +87,7 @@ async function approveToolCall(toolCallId: string, approved: boolean, decision?:
|
|||||||
tc.status = isDeny ? 'rejected' : 'running'
|
tc.status = isDeny ? 'rejected' : 'running'
|
||||||
}
|
}
|
||||||
if (isDeny) {
|
if (isDeny) {
|
||||||
tc.result = t('ai.aiTool.rejectedHint')
|
tc.result = t('aiTool.rejectedHint')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 同步从待审批集合移除(含 per-conv 集合;不等后端 AiApprovalResult 事件来移,防 loading 卡死)
|
// 同步从待审批集合移除(含 per-conv 集合;不等后端 AiApprovalResult 事件来移,防 loading 卡死)
|
||||||
@@ -108,6 +108,7 @@ async function approveToolCall(toolCallId: string, approved: boolean, decision?:
|
|||||||
if (tc) {
|
if (tc) {
|
||||||
tc.status = 'completed'
|
tc.status = 'completed'
|
||||||
tc.result = t('ai.approvalNotDelivered', { error: errMsg })
|
tc.result = t('ai.approvalNotDelivered', { error: errMsg })
|
||||||
|
bumpToolEpoch()
|
||||||
}
|
}
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user