修复: aichat 切换/新建对话缺陷(队列串会话/回切丢消息/审批误终态化/删除复活/切换链路)
This commit is contained in:
@@ -297,19 +297,19 @@ pub async fn ai_conversation_switch(
|
|||||||
|
|
||||||
// 后端 per_conv 存全量消息(LLM 上下文用),前端只渲染最近 N 条(分页懒加载,治长对话卡顿)。
|
// 后端 per_conv 存全量消息(LLM 上下文用),前端只渲染最近 N 条(分页懒加载,治长对话卡顿)。
|
||||||
// PAGE_SIZE:首屏渲染条数,超出的历史消息由前端滚顶加载更多(ai_conversation_load_more IPC)。
|
// PAGE_SIZE:首屏渲染条数,超出的历史消息由前端滚顶加载更多(ai_conversation_load_more IPC)。
|
||||||
|
// 默认从 DB 快照构造首屏渲染;目标正在生成时下方会改用内存最新消息(见 already_live 分支)。
|
||||||
|
// 三变量置 mut 供覆盖。
|
||||||
const PAGE_SIZE: usize = 50;
|
const PAGE_SIZE: usize = 50;
|
||||||
let total_count = messages.len();
|
let total_count = messages.len();
|
||||||
let render_messages: Vec<ChatMessage> = if total_count > PAGE_SIZE {
|
let page_start = total_count.saturating_sub(PAGE_SIZE);
|
||||||
messages[total_count - PAGE_SIZE..].to_vec()
|
let mut render_messages: Vec<ChatMessage> = if total_count > PAGE_SIZE {
|
||||||
|
messages[page_start..].to_vec()
|
||||||
} else {
|
} else {
|
||||||
messages.clone()
|
messages.clone()
|
||||||
};
|
};
|
||||||
let messages_json = serde_json::to_string(&render_messages)
|
let mut has_more = total_count > PAGE_SIZE;
|
||||||
.map_err(|e| format!("序列化消息失败: {}", e))?;
|
let mut earliest_seq = if !all_records.is_empty() && total_count > 0 {
|
||||||
let has_more = total_count > PAGE_SIZE;
|
|
||||||
let earliest_seq = if !all_records.is_empty() {
|
|
||||||
// 返回首屏最早消息的 seq,前端滚顶加载时传此值作游标
|
// 返回首屏最早消息的 seq,前端滚顶加载时传此值作游标
|
||||||
let page_start = total_count.saturating_sub(PAGE_SIZE);
|
|
||||||
Some(all_records[page_start].seq)
|
Some(all_records[page_start].seq)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -321,111 +321,146 @@ pub async fn ai_conversation_switch(
|
|||||||
let need_title_regen = record.title.is_none() || record.title.as_deref() == Some("新对话");
|
let need_title_regen = record.title.is_none() || record.title.as_deref() == Some("新对话");
|
||||||
|
|
||||||
let mut session = state.ai_session.lock().await;
|
let mut session = state.ai_session.lock().await;
|
||||||
// F-260616-09 B 批4(决策 e 真并发上线):删除 readonly 分支。
|
// 真并发下切走直接改 active_conversation_id + 新 conv per_conv 惰性建/从 DB reload;
|
||||||
// 旧实现:active conv 生成中 → 只读切换(不改 session 状态),因单例 messages 会被新 conv 覆盖。
|
// 目标正在跑 loop 时保留其 per_conv 现状(防 DB 旧快照覆盖 loop 正在写的内存新消息)。
|
||||||
// 决策 e:per_conv 已隔离,切走直接改 active_conversation_id + 新 conv per_conv 惰性建/从 DB reload。
|
|
||||||
// **后台 conv(目标正在跑 loop)的 per_conv 已存在则跳过 reload**——防覆盖其内存 messages
|
|
||||||
// (后台 loop 正在写自己的 per_conv.messages,reload 会用 DB 旧快照覆盖内存新消息)。
|
|
||||||
session.active_conversation_id = Some(conversation_id.clone());
|
session.active_conversation_id = Some(conversation_id.clone());
|
||||||
// F-260619-03 Phase B: 切换 active 会话 → 清空上一会话的临时授权目录(session 字段语义为
|
// 切换 active 会话 → 清空上一会话的临时授权目录(session 字段语义为"当前活跃会话的临时授权",
|
||||||
// "当前活跃会话的临时授权",不跨会话继承)。
|
// 不跨会话继承)。
|
||||||
drop(session);
|
drop(session);
|
||||||
state.clear_session_allowed_dirs().await;
|
state.clear_session_allowed_dirs().await;
|
||||||
let mut session = state.ai_session.lock().await;
|
let mut session = state.ai_session.lock().await;
|
||||||
// 读点(B-Phase2):改读无锁 conv_states.is_active()(Generating/Compressed),判断目标会话是否
|
// 读点:改读无锁 conv_states.is_active()(Generating/Compressed),判断目标会话是否已在生成中
|
||||||
// 已在生成中(是否需要从 DB reload)。语义等价(同一 ConvState 值)——压缩期间后台 loop
|
// (是否需要从 DB reload)。压缩期间后台 loop 仍持有 per_conv.messages,reload 会用 DB 旧快照
|
||||||
// 仍持有 per_conv.messages,reload 会用 DB 旧快照覆盖内存新消息,应判为已在生成而跳过 reload。
|
// 覆盖内存新消息,应判为已在生成而跳过 reload。
|
||||||
let already_live = state.conv_states.is_active(&conversation_id);
|
let already_live = state.conv_states.is_active(&conversation_id);
|
||||||
if !already_live {
|
if already_live {
|
||||||
|
// 目标正在生成 → DB 快照落后于内存,首屏渲染改用内存 per_conv 最新消息,
|
||||||
|
// 否则前端切过去看到旧内容 + 本轮首响应气泡丢失。per_conv 无该 conv(异常)保持 DB 回退。
|
||||||
|
if let Some(mem_msgs) = session.conv_read(&conversation_id).map(|c| c.messages.all_messages_clone()) {
|
||||||
|
if !mem_msgs.is_empty() {
|
||||||
|
let mtotal = mem_msgs.len();
|
||||||
|
let mstart = mtotal.saturating_sub(PAGE_SIZE);
|
||||||
|
render_messages = if mtotal > PAGE_SIZE {
|
||||||
|
mem_msgs[mstart..].to_vec()
|
||||||
|
} else {
|
||||||
|
mem_msgs
|
||||||
|
};
|
||||||
|
has_more = mtotal > PAGE_SIZE;
|
||||||
|
// 内存消息 DB seq ≈ 索引(头部历史与 DB 逐条对齐),页首条索引作游标
|
||||||
|
earliest_seq = Some(mstart as i64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
// 目标 conv 未在生成:从 DB reload messages 到其 per_conv(首次切入或上次切走后无后台 loop)。
|
// 目标 conv 未在生成:从 DB reload messages 到其 per_conv(首次切入或上次切走后无后台 loop)。
|
||||||
// 已在生成:保留其 per_conv 现状(后台 loop 持有),messages 由 loop 自行维护。
|
|
||||||
let conv = session.conv(&conversation_id);
|
let conv = session.conv(&conversation_id);
|
||||||
conv.messages.restore_from_messages(messages);
|
// reload 仅在内存无「未落库新消息」时才覆盖 per_conv.messages:
|
||||||
|
// 判据 = 内存消息数 > 已持久化计数 或 有全量重写标记(len > persisted_msg_count 或
|
||||||
|
// needs_full_rewrite,说明内存比 DB 新/脏)。核心原则:宁可保留内存态(内存通常最新),
|
||||||
|
// 绝不用 DB 旧快照覆盖内存新消息——save 是后台异步落库,用户在落库前切走再切回,
|
||||||
|
// 或 save 超时/写失败、断路器熔断退出不 save 时,DB 都落后于内存,restore 会丢末轮消息。
|
||||||
|
let mem_has_unsaved = {
|
||||||
|
let m = &conv.messages;
|
||||||
|
m.len() > m.persisted_msg_count() || m.needs_full_rewrite()
|
||||||
|
};
|
||||||
|
if !mem_has_unsaved {
|
||||||
|
conv.messages.restore_from_messages(messages);
|
||||||
|
// 从 DB 恢复 pinned_goals 到 per_conv(G1 目标钉扎持久化)
|
||||||
|
// 兼容旧格式:["text1","text2"] 转为 [{text, status: active}]
|
||||||
|
let pinned_goals: Vec<crate::commands::ai::GoalEntry> = {
|
||||||
|
let raw: Option<serde_json::Value> = record.pinned_goals
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|s| serde_json::from_str(s).ok());
|
||||||
|
match raw {
|
||||||
|
Some(serde_json::Value::Array(arr)) => {
|
||||||
|
arr.iter().filter_map(|v| {
|
||||||
|
// 新格式: {"text":"...","status":"active"}
|
||||||
|
if let Some(obj) = v.as_object() {
|
||||||
|
let text = obj.get("text")?.as_str()?.to_string();
|
||||||
|
let status = obj.get("status").and_then(|s| s.as_str())
|
||||||
|
.map(|s| if s == "completed" {
|
||||||
|
crate::commands::ai::GoalStatus::Completed
|
||||||
|
} else {
|
||||||
|
crate::commands::ai::GoalStatus::Active
|
||||||
|
}).unwrap_or(crate::commands::ai::GoalStatus::Active);
|
||||||
|
Some(crate::commands::ai::GoalEntry { text, status })
|
||||||
|
} else {
|
||||||
|
// 旧格式: "text" → 转 active
|
||||||
|
let text = v.as_str()?.to_string();
|
||||||
|
Some(crate::commands::ai::GoalEntry::new(text))
|
||||||
|
}
|
||||||
|
}).collect()
|
||||||
|
}
|
||||||
|
_ => vec![],
|
||||||
|
}
|
||||||
|
};
|
||||||
|
conv.pinned_goals = pinned_goals;
|
||||||
|
}
|
||||||
|
// 会话级临时态复位(无论是否 restore messages,重入即复位)
|
||||||
conv.model_override = None;
|
conv.model_override = None;
|
||||||
conv.session_trust.clear();
|
conv.session_trust.clear();
|
||||||
conv.agent_language = None;
|
conv.agent_language = None;
|
||||||
conv.iteration_used = 0;
|
conv.iteration_used = 0;
|
||||||
conv.stop_flag.store(false, Ordering::SeqCst);
|
conv.stop_flag.store(false, Ordering::SeqCst);
|
||||||
// 从 DB 恢复 pinned_goals 到 per_conv(G1 目标钉扎持久化)
|
|
||||||
// 兼容旧格式:["text1","text2"] 转为 [{text, status: active}]
|
|
||||||
let pinned_goals: Vec<crate::commands::ai::GoalEntry> = {
|
|
||||||
let raw: Option<serde_json::Value> = record.pinned_goals
|
|
||||||
.as_deref()
|
|
||||||
.and_then(|s| serde_json::from_str(s).ok());
|
|
||||||
match raw {
|
|
||||||
Some(serde_json::Value::Array(arr)) => {
|
|
||||||
arr.iter().filter_map(|v| {
|
|
||||||
// 新格式: {"text":"...","status":"active"}
|
|
||||||
if let Some(obj) = v.as_object() {
|
|
||||||
let text = obj.get("text")?.as_str()?.to_string();
|
|
||||||
let status = obj.get("status").and_then(|s| s.as_str())
|
|
||||||
.map(|s| if s == "completed" {
|
|
||||||
crate::commands::ai::GoalStatus::Completed
|
|
||||||
} else {
|
|
||||||
crate::commands::ai::GoalStatus::Active
|
|
||||||
}).unwrap_or(crate::commands::ai::GoalStatus::Active);
|
|
||||||
Some(crate::commands::ai::GoalEntry { text, status })
|
|
||||||
} else {
|
|
||||||
// 旧格式: "text" → 转 active
|
|
||||||
let text = v.as_str()?.to_string();
|
|
||||||
Some(crate::commands::ai::GoalEntry::new(text))
|
|
||||||
}
|
|
||||||
}).collect()
|
|
||||||
}
|
|
||||||
_ => vec![],
|
|
||||||
}
|
|
||||||
};
|
|
||||||
conv.pinned_goals = pinned_goals;
|
|
||||||
}
|
}
|
||||||
// 仅清空目标对话自身的挂起审批,保留其他对话的(防 init 重建的内存 HashMap 被清空,
|
// 首屏渲染序列化延后到 already_live 覆盖之后(内存 override 已生效)。
|
||||||
// 重启恢复链路:restore_pending_approvals(init 重建) → switchConversation(此处不清目标对话的)
|
let messages_json = serde_json::to_string(&render_messages)
|
||||||
// → ai_pending_tool_calls 查询 → ai_approve 落库)。
|
.map_err(|e| format!("序列化消息失败: {}", e))?;
|
||||||
// SW-260618-02(7-ipc-approval 配套 stop/clear/force_send 终态化):切走目标 conv 前,
|
// pending 终态化/恢复链路仅对「目标未在生成」执行:生成中时内存 pending_approvals 是
|
||||||
// 先把其 pending 审批占位 tool_result 终态化为"会话已切换",防占位残留下次发送喂给 LLM。
|
// loop 持有的真实挂起,终态化 + 清空 + 从 DB 恢复会误伤 live pending 导致新审批丢失。
|
||||||
// 须在 retain 前(遍历即将被丢弃的条目)——chat.rs:44 硬契约,顺序不能反。
|
// 生成中时以内存为准,不动 pending。
|
||||||
super::chat::finalize_pending_placeholders(&mut *session, &conversation_id, "会话已切换");
|
|
||||||
// 阶段3a 单真相源合并:单表 retain(kind 不区分,清本 conv 保留其他)。
|
|
||||||
session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(&conversation_id));
|
|
||||||
|
|
||||||
// 从 DB 恢复本对话的挂起审批快照(重启/切回时,之前 save_conversation 持久化的 pending_approvals)
|
|
||||||
// 反序列化后插入 session.pending_approvals,使 ai_pending_tool_calls 可查回、审批卡片恢复。
|
|
||||||
//
|
|
||||||
// 陈旧过滤根因修复(2026-08,治"审批待办关不了"):
|
|
||||||
// 对话 pending_approvals JSON 列与 ai_tool_executions 审计表是**两份独立持久化**——
|
|
||||||
// 审批处理时(ai_approve/ai_authorize_dir)若 remove 返 None(内存与 JSON 列不同步 / 恢复竞态),
|
|
||||||
// 走 None 分支不 save → JSON 列不被修剪 → 残留陈旧条目(completed/rejected/failed 的)。
|
|
||||||
// 切回此对话时本段会反复 restore 这些已落定条目,前端 ApprovalPopup/ToolCard 永久显挂起卡,
|
|
||||||
// 用户拒绝又走 None 分支死循环。修复:恢复前用审计表核对,仅恢复审计 status='pending' 的条目,
|
|
||||||
// 跳过已落定的陈旧条目(并即时修剪 JSON 列,防下次再恢复陈旧)。
|
|
||||||
let restored_pending: std::collections::HashMap<String, crate::commands::ai::PendingApproval> =
|
let restored_pending: std::collections::HashMap<String, crate::commands::ai::PendingApproval> =
|
||||||
if let Some(pending_json) = &record.pending_approvals {
|
if already_live {
|
||||||
if pending_json != "{}" && !pending_json.is_empty() {
|
std::collections::HashMap::new()
|
||||||
match serde_json::from_str::<
|
} else {
|
||||||
std::collections::HashMap<String, crate::commands::ai::PendingApproval>
|
// 仅清空目标对话自身的挂起审批,保留其他对话的(防 init 重建的内存 HashMap 被清空,
|
||||||
>(pending_json) {
|
// 重启恢复链路:restore_pending_approvals(init 重建) → switchConversation(此处不清目标对话的)
|
||||||
Ok(m) => m,
|
// → ai_pending_tool_calls 查询 → ai_approve 落库)。
|
||||||
Err(_) => {
|
// 切走目标 conv 前,先把其 pending 审批占位 tool_result 终态化为"会话已切换",
|
||||||
tracing::warn!(
|
// 防占位残留下次发送喂给 LLM。须在 retain 前(遍历即将被丢弃的条目)。
|
||||||
"切换对话 {} 反序列化 pending_approvals 失败,原始内容前 200 字: {:?}",
|
super::chat::finalize_pending_placeholders(&mut *session, &conversation_id, "会话已切换");
|
||||||
conversation_id, &pending_json.chars().take(200).collect::<String>()
|
// 阶段3a 单真相源合并:单表 retain(kind 不区分,清本 conv 保留其他)。
|
||||||
);
|
session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(&conversation_id));
|
||||||
std::collections::HashMap::new()
|
|
||||||
|
// 从 DB 恢复本对话的挂起审批快照(重启/切回时,之前 save_conversation 持久化的 pending_approvals)
|
||||||
|
// 反序列化后插入 session.pending_approvals,使 ai_pending_tool_calls 可查回、审批卡片恢复。
|
||||||
|
//
|
||||||
|
// 陈旧过滤根因修复(2026-08,治"审批待办关不了"):
|
||||||
|
// 对话 pending_approvals JSON 列与 ai_tool_executions 审计表是**两份独立持久化**——
|
||||||
|
// 审批处理时(ai_approve/ai_authorize_dir)若 remove 返 None(内存与 JSON 列不同步 / 恢复竞态),
|
||||||
|
// 走 None 分支不 save → JSON 列不被修剪 → 残留陈旧条目(completed/rejected/failed 的)。
|
||||||
|
// 切回此对话时本段会反复 restore 这些已落定条目,前端 ApprovalPopup/ToolCard 永久显挂起卡,
|
||||||
|
// 用户拒绝又走 None 分支死循环。修复:恢复前用审计表核对,仅恢复审计 status='pending' 的条目,
|
||||||
|
// 跳过已落定的陈旧条目(并即时修剪 JSON 列,防下次再恢复陈旧)。
|
||||||
|
let restored = if let Some(pending_json) = &record.pending_approvals {
|
||||||
|
if pending_json != "{}" && !pending_json.is_empty() {
|
||||||
|
match serde_json::from_str::<
|
||||||
|
std::collections::HashMap<String, crate::commands::ai::PendingApproval>
|
||||||
|
>(pending_json) {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(_) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"切换对话 {} 反序列化 pending_approvals 失败,原始内容前 200 字: {:?}",
|
||||||
|
conversation_id, &pending_json.chars().take(200).collect::<String>()
|
||||||
|
);
|
||||||
|
std::collections::HashMap::new()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
std::collections::HashMap::new()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
std::collections::HashMap::new()
|
std::collections::HashMap::new()
|
||||||
|
};
|
||||||
|
// 仅恢复本 conv 的条目(已按 conv_id 过滤写入,DB 快照天然是本 conv 的)
|
||||||
|
session.pending_approvals.extend(restored.clone());
|
||||||
|
if !restored.is_empty() {
|
||||||
|
tracing::info!(
|
||||||
|
"切换对话 {} 恢复 {} 条挂起审批(来自 DB pending_approvals 列,待审计核对)",
|
||||||
|
conversation_id, restored.len()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
restored
|
||||||
std::collections::HashMap::new()
|
|
||||||
};
|
};
|
||||||
// 仅恢复本 conv 的条目(已按 conv_id 过滤写入,DB 快照天然是本 conv 的)
|
|
||||||
session.pending_approvals.extend(restored_pending.clone());
|
|
||||||
if !restored_pending.is_empty() {
|
|
||||||
tracing::info!(
|
|
||||||
"切换对话 {} 恢复 {} 条挂起审批(来自 DB pending_approvals 列,待审计核对)",
|
|
||||||
conversation_id, restored_pending.len()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// 释放 session lock 再做 async provider 查询 + spawn(避免持锁 await DB)
|
// 释放 session lock 再做 async provider 查询 + spawn(避免持锁 await DB)
|
||||||
drop(session);
|
drop(session);
|
||||||
|
|
||||||
@@ -433,8 +468,8 @@ pub async fn ai_conversation_switch(
|
|||||||
// 审计已落定(completed/rejected/failed/interrupted/skipped_retry)→ 视为陈旧,从内存移除,
|
// 审计已落定(completed/rejected/failed/interrupted/skipped_retry)→ 视为陈旧,从内存移除,
|
||||||
// 并累积"存活"集合用于回写修剪后的 JSON 列(根治:下次切回不再恢复陈旧)。
|
// 并累积"存活"集合用于回写修剪后的 JSON 列(根治:下次切回不再恢复陈旧)。
|
||||||
// 审计查询失败(DB 故障)保守信任内存已 extend 的条目(不因 DB 故障误删用户真挂起审批)。
|
// 审计查询失败(DB 故障)保守信任内存已 extend 的条目(不因 DB 故障误删用户真挂起审批)。
|
||||||
// 仅当确实有恢复条目时才跑核对(空 HashMap 跳过,无开销)。
|
// 仅当确实有恢复条目时才跑核对(空 HashMap 跳过,无开销);already_live 时 restored 恒空(跳过)。
|
||||||
if !restored_pending.is_empty() {
|
if !already_live && !restored_pending.is_empty() {
|
||||||
let mut stale_ids: Vec<String> = Vec::new();
|
let mut stale_ids: Vec<String> = Vec::new();
|
||||||
for tc_id in restored_pending.keys() {
|
for tc_id in restored_pending.keys() {
|
||||||
match state.ai_tool_executions.find_by_tool_call_id(tc_id).await {
|
match state.ai_tool_executions.find_by_tool_call_id(tc_id).await {
|
||||||
@@ -568,35 +603,37 @@ pub async fn ai_conversation_delete(
|
|||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
conversation_id: String,
|
conversation_id: String,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
// G1.3:单事务删 ai_messages 子行 + ai_conversations 主行(孤儿根治)。
|
// 记录已删除 conv_id,必须先于 DB 删除写入:save_conversation 入口检测到即跳过,
|
||||||
// 顺序:先删数据再摘内存(下方 per_conv.remove)——防后台在途 save 在删主行后复活孤儿消息。
|
// 封死「loop 在途 save 复活」窗口(不惰性重建 per_conv、不 Ok(None) INSERT 空壳)。
|
||||||
|
// 删除语义:删了就删了,后续 save 不应复活。conv_id 是 ULID 不重用,标记长期保留。
|
||||||
|
{
|
||||||
|
let mut session = state.ai_session.lock().await;
|
||||||
|
session.deleted_convs.insert(conversation_id.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单事务删 ai_messages 子行 + ai_conversations 主行(防孤儿消息)。
|
||||||
|
// 顺序:先删数据再摘内存(下方 per_conv.remove)。
|
||||||
state.ai_conversations.delete_with_messages(&conversation_id).await.map_err(err_str)?;
|
state.ai_conversations.delete_with_messages(&conversation_id).await.map_err(err_str)?;
|
||||||
|
|
||||||
let mut session = state.ai_session.lock().await;
|
let mut session = state.ai_session.lock().await;
|
||||||
// 删除任意对话(含非活跃)都应清理其积压审批:挂起审批是会话级 HashMap,
|
// 删除任意对话(含非活跃)都应清理其积压审批:挂起审批是会话级 HashMap,
|
||||||
// 非活跃对话的恢复审批(recovered,conversation_id 指向被删对话)若不 retain 清理,
|
// 非活跃对话的恢复审批(recovered,conversation_id 指向被删对话)若不 retain 清理,
|
||||||
// 会永久残留死审批条目。对齐 ai_conversation_switch 的 retain 口径(仅清目标对话,保留其他)。
|
// 会永久残留死审批条目。对齐 ai_conversation_switch 的 retain 口径(仅清目标对话,保留其他)。
|
||||||
// SW-260618-02(7-ipc-approval 配套 stop/clear/force_send 终态化):删除目标 conv 前,
|
// 删除目标 conv 前,先把其 pending 审批占位 tool_result 终态化为"会话已删除",
|
||||||
// 先把其 pending 审批占位 tool_result 终态化为"会话已删除",防占位残留下次发送喂给 LLM。
|
// 防占位残留下次发送喂给 LLM。须在 retain 前(遍历即将被丢弃的条目)。
|
||||||
// 须在 retain 前(遍历即将被丢弃的条目)——chat.rs:44 硬契约,顺序不能反。
|
|
||||||
super::chat::finalize_pending_placeholders(&mut *session, &conversation_id, "会话已删除");
|
super::chat::finalize_pending_placeholders(&mut *session, &conversation_id, "会话已删除");
|
||||||
// 阶段3a 单真相源合并:单表 retain(kind 不区分)。
|
// 阶段3a 单真相源合并:单表 retain(kind 不区分)。
|
||||||
session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(&conversation_id));
|
session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(&conversation_id));
|
||||||
// F-260616-09 B 批4:删除 conv 时移除其 per_conv 条目(设计 §4.1 conv 存在性判据依赖此,
|
// 删除 conv 时移除其 per_conv 条目(loop 检测 conv 不存在即退出)。
|
||||||
// 旧 loop 检测 conv 不存在即退出)。per_conv 唯一真相源,删顶层 messages.clear 双写。
|
|
||||||
session.per_conv.remove(&conversation_id);
|
session.per_conv.remove(&conversation_id);
|
||||||
// B-Phase4:conv 删除同步清理 ConvStateStore 条目,防已删 conv 残留
|
// conv 删除同步清理生成状态条目,防已删 conv 残留活跃态致 id 复用时脏状态。
|
||||||
// Generating 态致 id 复用(同一 conv_id 重新创建)脏状态。
|
|
||||||
state.conv_states.remove(&conversation_id);
|
state.conv_states.remove(&conversation_id);
|
||||||
let was_active = session.active_conversation_id.as_deref() == Some(&conversation_id);
|
let was_active = session.active_conversation_id.as_deref() == Some(&conversation_id);
|
||||||
if was_active {
|
if was_active {
|
||||||
session.active_conversation_id = None;
|
session.active_conversation_id = None;
|
||||||
}
|
}
|
||||||
// F-260616-09 B 批5:同时清理 LlmConcurrency 的 per_conv Semaphore 条目(防 HashMap 无限增长)。
|
// 同时清理该 conv 的限流 Semaphore 条目(防 HashMap 无限增长)。
|
||||||
// conv 已删=LlmConcurrency 该条目不再被 acquire(无 conv 则无 loop/标题/提炼/压缩针对它)。
|
// 已持 permit 不受影响(绑旧 Arc,随 Drop 释放),仅阻止新条目累积。
|
||||||
// 已持 permit 不受影响(permit 绑旧 Arc,随 Drop 释放),仅阻止新条目累积。
|
|
||||||
// 时机:conv 删除即清理(比"loop 结束 + 无 pending"更确定——conv 删了必无 pending,
|
|
||||||
// 上述 retain 已清)。loop 正常收敛/达 MAX/stop 但 conv 未删时不清理(下次发消息复用,限流计数连续)。
|
|
||||||
drop(session); // 释放 AiSession 锁再取 LlmConcurrency 锁(避免潜在锁序问题)
|
drop(session); // 释放 AiSession 锁再取 LlmConcurrency 锁(避免潜在锁序问题)
|
||||||
state.llm_concurrency.release_conv(&conversation_id).await;
|
state.llm_concurrency.release_conv(&conversation_id).await;
|
||||||
// F-260619-03 Phase B: 删除的是当前活跃会话 → 清空其临时授权目录(session 字段语义为
|
// F-260619-03 Phase B: 删除的是当前活跃会话 → 清空其临时授权目录(session 字段语义为
|
||||||
|
|||||||
@@ -223,10 +223,8 @@ async fn save_conversation_inner(
|
|||||||
// 累积致 token 暴增。仅影响持久化视图,不污染内存真相源(ContextManager)——build_for_request
|
// 累积致 token 暴增。仅影响持久化视图,不污染内存真相源(ContextManager)——build_for_request
|
||||||
// 仍读全量 messages。
|
// 仍读全量 messages。
|
||||||
//
|
//
|
||||||
// F-260616-09 B 批4:per_conv.messages 唯一真相源(conv_id 来源:本函数入参,与 save 落库的 conv 一致)。
|
// per_conv.messages 是唯一真相源(conv_id 来源:本函数入参,与 save 落库的 conv 一致)。
|
||||||
// loop 内 save 由 run_agentic_loop 入参 conv_id 透传;IPC 路径(commands.rs)save 也传 conv_id。
|
// conv() 惰性建:save 路径 conv 必然已建;极端未建(如启动恢复无 live conv)建空态,save 空消息(幂等)。
|
||||||
// conv() 惰性建:save 路径 conv 必然已建(send/regenerate/edit/switch 均先 conv());若极端
|
|
||||||
// 未建(如启动恢复无 live conv),conv() 建空 PerConvState,save 空 messages(幂等不污染)。
|
|
||||||
// BUG-2026-07-19: lock 段只 clone 必要读(messages + 元数据),truncate 移出 lock。
|
// BUG-2026-07-19: lock 段只 clone 必要读(messages + 元数据),truncate 移出 lock。
|
||||||
// 原 clone+truncate 全在 lock 内,对话历史长(多轮+大工具结果)时 clone+truncate 持锁可达秒级,
|
// 原 clone+truncate 全在 lock 内,对话历史长(多轮+大工具结果)时 clone+truncate 持锁可达秒级,
|
||||||
// 致 guard.reset/process_tool_calls 等 session lock 竞争超时(aichat 卡死连环:工具卡片不呈现+
|
// 致 guard.reset/process_tool_calls 等 session lock 竞争超时(aichat 卡死连环:工具卡片不呈现+
|
||||||
@@ -238,6 +236,16 @@ async fn save_conversation_inner(
|
|||||||
// 避免同时持有 session 的 mut 和 immut 借用(E0502)。
|
// 避免同时持有 session 的 mut 和 immut 借用(E0502)。
|
||||||
let (mut msgs, provider_id, created_at, pinned_goals, persisted_count, needs_full_rewrite) = {
|
let (mut msgs, provider_id, created_at, pinned_goals, persisted_count, needs_full_rewrite) = {
|
||||||
let mut session = session_arc.lock().await;
|
let mut session = session_arc.lock().await;
|
||||||
|
// 已删除对话直接跳过写入(loop 在「push 后、save 前」被删除的幽灵复活根治):
|
||||||
|
// 此处不惰性重建 per_conv、不触发 INSERT 空壳 + insert_batch 复活。
|
||||||
|
// 删除语义:删了就删了,后续 save 不应复活。return 前 guard 自动 drop(MutexGuard),无持锁泄漏。
|
||||||
|
if session.deleted_convs.contains(conv_id) {
|
||||||
|
tracing::debug!(
|
||||||
|
"save_conversation 跳过已删除对话(loop 在途 save 不复活): conv_id={}",
|
||||||
|
conv_id
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let __wait = __lock_start.elapsed();
|
let __wait = __lock_start.elapsed();
|
||||||
let provider_id = session.active_provider_id.clone();
|
let provider_id = session.active_provider_id.clone();
|
||||||
let created_at = session.active_conv_created_at.clone();
|
let created_at = session.active_conv_created_at.clone();
|
||||||
@@ -386,6 +394,19 @@ async fn save_conversation_inner(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
|
// 兜底二次校验(封在途 save 走到 Ok(None) 的复活窗口):save 起始时可能尚未
|
||||||
|
// 记录删除标记,但 get_by_id 恰在 DB 删除之后返回 Ok(None)——INSERT 前再确认一次。
|
||||||
|
let deleted_now = {
|
||||||
|
let session = session_arc.lock().await;
|
||||||
|
session.deleted_convs.contains(conv_id)
|
||||||
|
};
|
||||||
|
if deleted_now {
|
||||||
|
tracing::debug!(
|
||||||
|
"save_conversation 落库前检测到对话已删除,跳过 INSERT(不复活): conv_id={}",
|
||||||
|
conv_id
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// 懒创建首次落库(此为空对话不落库的落库点:走到这里 messages 必非空)
|
// 懒创建首次落库(此为空对话不落库的落库点:走到这里 messages 必非空)
|
||||||
// messages JSON 列首次落库也写(兼容未跑迁移的老库 fallback 读路径),
|
// messages JSON 列首次落库也写(兼容未跑迁移的老库 fallback 读路径),
|
||||||
// 同时写 ai_messages(新读路径真相源)。
|
// 同时写 ai_messages(新读路径真相源)。
|
||||||
|
|||||||
@@ -586,10 +586,8 @@ fn normalize_dir_key(dir: &str) -> String {
|
|||||||
|
|
||||||
/// AI 会话内状态(Mutex 保护)
|
/// AI 会话内状态(Mutex 保护)
|
||||||
///
|
///
|
||||||
/// F-260616-09 B 批4(决策 e 真并发上线):会话级状态全部迁入 [`per_conv`](Self::per_conv)
|
/// 会话级状态全部迁入 [`per_conv`](Self::per_conv) HashMap(按 conversation_id 切分),
|
||||||
/// HashMap(按 conversation_id 切分),顶层单例字段(messages/generating/stop_flag/notify/
|
/// 顶层单例会话级字段已全部退役删除,per_conv 是唯一真相源。
|
||||||
/// iteration_used/agent_language/model_override/session_trust)已退役删除。批2 双写桥接代码
|
|
||||||
/// (IPC 写路径的顶层双写行 + 各文件 fallback 读)在批4 全部清理完毕,per_conv 是唯一真相源。
|
|
||||||
pub struct AiSession {
|
pub struct AiSession {
|
||||||
/// 当前提供商 ID
|
/// 当前提供商 ID
|
||||||
pub active_provider_id: Option<String>,
|
pub active_provider_id: Option<String>,
|
||||||
@@ -608,13 +606,17 @@ pub struct AiSession {
|
|||||||
/// 兜底/回退:本合并是结构性的,回退走 git revert(plan 风险表「分 3a/3b 各 revert」)。
|
/// 兜底/回退:本合并是结构性的,回退走 git revert(plan 风险表「分 3a/3b 各 revert」)。
|
||||||
/// 兼容靠 kind 字段默认 Risk(老构造点不传 kind 即默认 Risk,语义不变)。
|
/// 兼容靠 kind 字段默认 Risk(老构造点不传 kind 即默认 Risk,语义不变)。
|
||||||
pub pending_approvals: HashMap<String, PendingApproval>,
|
pub pending_approvals: HashMap<String, PendingApproval>,
|
||||||
/// F-260616-09 B 阶段 多会话并发架构 — 会话级状态按 conversation_id 切分。
|
/// 多会话并发架构 — 会话级状态按 conversation_id 切分,全部调用方经
|
||||||
///
|
/// `session.conv(conv_id).*` / `session.conv_read(conv_id)` 读写,per_conv 是唯一真相源。
|
||||||
/// **批4 状态(per_conv 唯一真相源)**:批2-批4 已迁移所有调用方(agentic/mod.rs loop +
|
|
||||||
/// GeneratingGuard + try_continue + commands.rs IPC 写路径 + audit.rs process_tool_calls +
|
|
||||||
/// conversation.rs save + title.rs + knowledge_inject.rs + lib.rs L0)读写
|
|
||||||
/// `session.conv(conv_id).*` / `session.conv_read(conv_id)`。顶层单例会话级字段已全部删除。
|
|
||||||
pub per_conv: HashMap<String, PerConvState>,
|
pub per_conv: HashMap<String, PerConvState>,
|
||||||
|
/// 已删除对话 ID 集合(delete 在 loop 运行中幽灵复活根治)。
|
||||||
|
///
|
||||||
|
/// `ai_conversation_delete` 删除时记录被删 conv_id;`save_conversation` 入口检测到已删除则
|
||||||
|
/// 跳过写入,防 loop 在「push 后、save 前」删除发生 → save 用 [`conv()`](Self::conv) 惰性重建
|
||||||
|
/// 空 per_conv + `Ok(None)` 分支重新 INSERT 空壳复活 + insert_batch 写回消息。
|
||||||
|
/// 删除语义:删了就删了,loop 后续 save 不应复活。conv_id 是 ULID 不重用,条目长期保留
|
||||||
|
/// (单条 ~40B,删除量级下可忽略)。
|
||||||
|
pub deleted_convs: HashSet<String>,
|
||||||
/// T2 工具命名空间存储(运行时缓存,大工具结果不进主队列)
|
/// T2 工具命名空间存储(运行时缓存,大工具结果不进主队列)
|
||||||
pub namespace_store: df_ai::namespace_store::NamespaceStore,
|
pub namespace_store: df_ai::namespace_store::NamespaceStore,
|
||||||
}
|
}
|
||||||
@@ -627,6 +629,7 @@ impl AiSession {
|
|||||||
active_conv_created_at: None,
|
active_conv_created_at: None,
|
||||||
pending_approvals: HashMap::new(),
|
pending_approvals: HashMap::new(),
|
||||||
per_conv: HashMap::new(),
|
per_conv: HashMap::new(),
|
||||||
|
deleted_convs: HashSet::new(),
|
||||||
namespace_store: df_ai::namespace_store::NamespaceStore::default(),
|
namespace_store: df_ai::namespace_store::NamespaceStore::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-20
@@ -66,18 +66,18 @@
|
|||||||
path 挂起归一进 ToolCard 内联审批(单真相源:状态层合);关(false,兜底回退)时挂载
|
path 挂起归一进 ToolCard 内联审批(单真相源:状态层合);关(false,兜底回退)时挂载
|
||||||
DirAuthDialog 独立弹窗老链路。两路共存,appSettings 随时回退无需改代码。 -->
|
DirAuthDialog 独立弹窗老链路。两路共存,appSettings 随时回退无需改代码。 -->
|
||||||
<DirAuthDialog v-if="!unifiedApproval" @toast="(p) => showToast(p.msg, p.type)" />
|
<DirAuthDialog v-if="!unifiedApproval" @toast="(p) => showToast(p.msg, p.type)" />
|
||||||
<!-- 待发送队列(生成中排队的消息,完成后自动续发) -->
|
<!-- 待发送队列(生成中排队的消息,完成后自动续发;仅显示当前活跃会话的条目) -->
|
||||||
<div v-if="store.state.queue.length > 0" class="ai-queue" :class="{ 'ai-queue--timeout': queueTimedOut }">
|
<div v-if="activeQueue.length > 0" class="ai-queue" :class="{ 'ai-queue--timeout': queueTimedOut }">
|
||||||
<div class="ai-queue-head">
|
<div class="ai-queue-head">
|
||||||
<span class="ai-queue-title">{{ $t('aiChat.queueTitle', { n: store.state.queue.length }) }}<span v-if="queueWaitSeconds >= 0" class="ai-queue-timer">({{ queueWaitSeconds }}s)</span></span>
|
<span class="ai-queue-title">{{ $t('aiChat.queueTitle', { n: activeQueue.length }) }}<span v-if="queueWaitSeconds >= 0" class="ai-queue-timer">({{ queueWaitSeconds }}s)</span></span>
|
||||||
<div class="ai-queue-actions">
|
<div class="ai-queue-actions">
|
||||||
<button v-if="queueTimedOut" class="ai-queue-force" @click="handleForceSend">{{ $t('ai.forceSendBtn') }}</button>
|
<button v-if="queueTimedOut" class="ai-queue-force" @click="handleForceSend">{{ $t('ai.forceSendBtn') }}</button>
|
||||||
<button class="ai-queue-clear" @click="store.clearQueue">{{ $t('aiChat.clearQueue') }}</button>
|
<button class="ai-queue-clear" @click="store.clearQueue(store.state.activeConversationId)">{{ $t('aiChat.clearQueue') }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ai-queue-list">
|
<div class="ai-queue-list">
|
||||||
<div v-for="(item, idx) in store.state.queue" :key="idx" class="ai-queue-item">
|
<div v-for="(item, idx) in activeQueue" :key="idx" class="ai-queue-item">
|
||||||
<!-- UX-260616-06 决策只编 text:编辑态切 inline input,回车/失焦写回,ESC 取消 -->
|
<!-- 编辑态只编 text:inline input,回车/失焦写回,ESC 取消 -->
|
||||||
<template v-if="editingQueueIdx === idx">
|
<template v-if="editingQueueIdx === idx">
|
||||||
<span v-if="item.skill" class="ai-queue-skill">/{{ item.skill }}</span>
|
<span v-if="item.skill" class="ai-queue-skill">/{{ item.skill }}</span>
|
||||||
<input
|
<input
|
||||||
@@ -94,9 +94,9 @@
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
<span v-if="item.skill" class="ai-queue-skill">/{{ item.skill }}</span>
|
<span v-if="item.skill" class="ai-queue-skill">/{{ item.skill }}</span>
|
||||||
<span class="ai-queue-text">{{ item.text }}</span>
|
<span class="ai-queue-text">{{ item.text }}</span>
|
||||||
<!-- UX-260616-06:编辑按钮 -->
|
<!-- 编辑按钮 -->
|
||||||
<button class="ai-queue-btn" @click="startEditQueued(idx)" :title="$t('aiChat.queueEdit')">{{ $t('aiChat.queueEdit') }}</button>
|
<button class="ai-queue-btn" @click="startEditQueued(idx)" :title="$t('aiChat.queueEdit')">{{ $t('aiChat.queueEdit') }}</button>
|
||||||
<!-- UX-260616-07 决策 a:立即发送(打断当前+发本条) -->
|
<!-- 立即发送(打断当前+发本条) -->
|
||||||
<button class="ai-queue-btn" @click="handleSendQueuedNow(idx)" :title="$t('aiChat.queueSendNow')">{{ $t('aiChat.queueSendNow') }}</button>
|
<button class="ai-queue-btn" @click="handleSendQueuedNow(idx)" :title="$t('aiChat.queueSendNow')">{{ $t('aiChat.queueSendNow') }}</button>
|
||||||
<button class="ai-queue-x" @click="store.cancelQueued(idx)" :title="$t('common.cancel')">×</button>
|
<button class="ai-queue-x" @click="store.cancelQueued(idx)" :title="$t('common.cancel')">×</button>
|
||||||
</template>
|
</template>
|
||||||
@@ -385,33 +385,40 @@ function onBeforeUnloadGuard(e: BeforeUnloadEvent) {
|
|||||||
// 第二批抽离: handleKeydown/autoResize/handleSend 已迁移至 ChatInput.vue
|
// 第二批抽离: handleKeydown/autoResize/handleSend 已迁移至 ChatInput.vue
|
||||||
// (ChatInput 经 emit('sent')/emit('cancel-edit')/emit('error') 与父交互)
|
// (ChatInput 经 emit('sent')/emit('cancel-edit')/emit('error') 与父交互)
|
||||||
|
|
||||||
// ── B-260616-02 L2 发送韧性:排队超时检测 + 强制发送 ──
|
// ── L2 发送韧性:排队超时检测 + 强制发送 ──
|
||||||
|
|
||||||
/** 队首排队是否已超 30s */
|
/** 当前活跃会话的待发送队列(按 activeConversationId 过滤)。
|
||||||
const queueTimedOut = computed(() => store.isQueueTimedOut())
|
* 队列是全局数组,多会话并发可各自排队;旧实现渲染全部会话的 queue,切/新建后会显示
|
||||||
|
* 旧会话排队消息。此处仅显当前活跃会话条目,相关操作(编辑/删除/立即发送/清空/超时)均按此收敛。 */
|
||||||
|
const activeQueue = computed(() =>
|
||||||
|
store.state.queue.filter(q => q.conversationId === store.state.activeConversationId),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 队首排队是否已超 30s(仅看当前活跃会话队列) */
|
||||||
|
const queueTimedOut = computed(() => store.isQueueTimedOut(store.state.activeConversationId))
|
||||||
|
|
||||||
/** 队首已排队秒数(用于 UI 展示倒计时;无队列时 -1) */
|
/** 队首已排队秒数(用于 UI 展示倒计时;无队列时 -1) */
|
||||||
const queueWaitSeconds = computed(() => {
|
const queueWaitSeconds = computed(() => {
|
||||||
const first = store.state.queue[0]
|
const first = activeQueue.value[0]
|
||||||
if (!first) return -1
|
if (!first) return -1
|
||||||
return Math.floor((Date.now() - first.enqueuedAt) / 1000)
|
return Math.floor((Date.now() - first.enqueuedAt) / 1000)
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 强制发送:委托 tryForceSend 唯一负责弹 confirm + shift 队列 + force_send */
|
/** 强制发送:委托 tryForceSend 唯一负责弹 confirm + 移除队列 + force_send(作用于当前活跃会话) */
|
||||||
async function handleForceSend() {
|
async function handleForceSend() {
|
||||||
await store.tryForceSend(confirmDialog)
|
await store.tryForceSend(confirmDialog, store.state.activeConversationId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── UX-260616-06 队列消息编辑(决策只编 text)+ UX-260616-07 立即发送(决策 a 插队)──
|
// ── 队列消息编辑(只编 text)+ 立即发送(插队)──
|
||||||
|
|
||||||
/** 当前正在编辑的队列项 index;null=非编辑态 */
|
/** 当前正在编辑的队列项 index;null=非编辑态 */
|
||||||
const editingQueueIdx = ref<number | null>(null)
|
const editingQueueIdx = ref<number | null>(null)
|
||||||
/** 编辑态输入框文本(回车写回 / ESC 取消还原) */
|
/** 编辑态输入框文本(回车写回 / ESC 取消还原) */
|
||||||
const editingQueueText = ref('')
|
const editingQueueText = ref('')
|
||||||
|
|
||||||
/** 进入编辑态:记录 index + 快照当前 text */
|
/** 进入编辑态:记录 index + 快照当前 text(activeQueue 视图内下标) */
|
||||||
function startEditQueued(idx: number) {
|
function startEditQueued(idx: number) {
|
||||||
const item = store.state.queue[idx]
|
const item = activeQueue.value[idx]
|
||||||
if (!item) return
|
if (!item) return
|
||||||
editingQueueIdx.value = idx
|
editingQueueIdx.value = idx
|
||||||
editingQueueText.value = item.text
|
editingQueueText.value = item.text
|
||||||
@@ -431,7 +438,7 @@ function saveEditQueued() {
|
|||||||
editingQueueText.value = ''
|
editingQueueText.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
/** UX-260616-07 立即发送:委托 store.sendQueuedNow(splice+stop+sendMessage) */
|
/** 立即发送:委托 store.sendQueuedNow(移除+stop+sendMessage) */
|
||||||
async function handleSendQueuedNow(idx: number) {
|
async function handleSendQueuedNow(idx: number) {
|
||||||
await store.sendQueuedNow(idx)
|
await store.sendQueuedNow(idx)
|
||||||
}
|
}
|
||||||
@@ -620,7 +627,8 @@ async function initContextEventListeners(): Promise<void> {
|
|||||||
// 后端 emit ai_context_cleared 后会重置 session.messages,前端刷对话拉最新状态。
|
// 后端 emit ai_context_cleared 后会重置 session.messages,前端刷对话拉最新状态。
|
||||||
// 守卫:事件到达前用户可能切走,activeConversationId 变 null,跳过刷新避免空指针。
|
// 守卫:事件到达前用户可能切走,activeConversationId 变 null,跳过刷新避免空指针。
|
||||||
if (store.state.activeConversationId) {
|
if (store.state.activeConversationId) {
|
||||||
void store.switchConversation(store.state.activeConversationId)
|
// force=true:同会话刷新(同会话点击短路对程序化刷新放行,清空后需重拉最新上下文)
|
||||||
|
void store.switchConversation(store.state.activeConversationId, true)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// 治 Task#1:仅手动压缩触发(useAiContext 的 AiManualCompressed case 调此回调);
|
// 治 Task#1:仅手动压缩触发(useAiContext 的 AiManualCompressed case 调此回调);
|
||||||
@@ -629,7 +637,8 @@ async function initContextEventListeners(): Promise<void> {
|
|||||||
showToast(t('aiChat.compressSuccess'), 'info')
|
showToast(t('aiChat.compressSuccess'), 'info')
|
||||||
// 刷新对话(历史消息落库标 compressed + system 摘要消息回填到视图)
|
// 刷新对话(历史消息落库标 compressed + system 摘要消息回填到视图)
|
||||||
if (store.state.activeConversationId) {
|
if (store.state.activeConversationId) {
|
||||||
void store.switchConversation(store.state.activeConversationId)
|
// force=true:同会话刷新(压缩后需重拉最新消息视图)
|
||||||
|
void store.switchConversation(store.state.activeConversationId, true)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (_convId, message) => {
|
onError: (_convId, message) => {
|
||||||
|
|||||||
@@ -316,3 +316,16 @@ export function clearConvStreamState(convId: string | null | undefined): void {
|
|||||||
if (!convId) return
|
if (!convId) return
|
||||||
convStreamStates.delete(convId)
|
convStreamStates.delete(convId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 切换中缓冲 ──
|
||||||
|
//
|
||||||
|
// 背景:后台会话(A)生成中,用户点击切换到 A(switchConversation await 往返期间),A 的
|
||||||
|
// AiTextDelta 事件因 `convId !== activeConversationId` 被 handleEvent 的 isCurrent 守卫丢弃 →
|
||||||
|
// 切换完成后回复缺前缀(切换窗口 token 丢失)。
|
||||||
|
//
|
||||||
|
// 本集合标记「正在被 switchConversation 拉取的目标 conv」:useAiEvents.handleEvent 对非当前
|
||||||
|
// 会话的 AiTextDelta 若命中此集合,则累积到该 conv 的 per-conv 流式态(而非 drop);
|
||||||
|
// 切换成功后由 switchConversation 恢复该流式文本续显,失败/过期路径丢弃(防陈旧重复)。
|
||||||
|
//
|
||||||
|
// 普通 Set(仅事件处理器读,无渲染追踪需求);生命周期由 switchConversation 的 finally 管理。
|
||||||
|
export const switchingConvs = new Set<string>()
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { state } from '@/stores/ai'
|
|||||||
import { notifyConversationChanged } from './useAiEvents'
|
import { notifyConversationChanged } from './useAiEvents'
|
||||||
import { persistUiState } from './useAiPanel'
|
import { persistUiState } from './useAiPanel'
|
||||||
import { setStreaming } from './streamingGuard'
|
import { setStreaming } from './streamingGuard'
|
||||||
import { nextMsgId, getConvState, clearConvStreamState, startApprovalTimer, clearAllApprovalTimers } from './aiShared'
|
import { nextMsgId, getConvState, clearConvStreamState, startApprovalTimer, clearAllApprovalTimers, switchingConvs, getConvStreamState, setConvCurrentText } from './aiShared'
|
||||||
import { t } from '@/i18n/i18n-helpers'
|
import { t } from '@/i18n/i18n-helpers'
|
||||||
import type { AiConversationDetail, AiMessage, AiToolCallInfo, ConvId } from '@/api/types'
|
import type { AiConversationDetail, AiMessage, AiToolCallInfo, ConvId } from '@/api/types'
|
||||||
|
|
||||||
@@ -63,42 +63,60 @@ async function loadConversations() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 新建防抖锁:快速连点「+」/ Ctrl+N 只触发一次(300ms 内重复调用直接 return,
|
||||||
|
// 防多个空会话 + 虚拟项堆积)。模块级(跨组件共享,主/分离窗口各自实例由 store 单例统一)。
|
||||||
|
let _newConvLock = false
|
||||||
|
|
||||||
/** 新建空对话并切过去 */
|
/** 新建空对话并切过去 */
|
||||||
async function newConversation() {
|
async function newConversation() {
|
||||||
// G3.4:收敛进会话操作族 withConvOp——原裸 await 失败抛 unhandled rejection 用户无感,
|
// 新建会话清空所有审批计时器(对齐切换/删除会话——旧 conv 的审批超时到点会误拒后台
|
||||||
// 现失败推错误气泡 + 保持当前视图。新建无乐观本地变化(后端生成 id,失败无副作用)。
|
// 挂起审批 + 错误气泡进新会话视图)。
|
||||||
const result = await withConvOp(
|
clearAllApprovalTimers()
|
||||||
() => {},
|
// 防抖(300ms 内重复调用直接 return)
|
||||||
() => aiApi.createConversation(),
|
if (_newConvLock) return
|
||||||
() => {},
|
_newConvLock = true
|
||||||
'newConvFail',
|
try {
|
||||||
)
|
// G3.4:收敛进会话操作族 withConvOp——原裸 await 失败抛 unhandled rejection 用户无感,
|
||||||
if (!result) return // 失败已推气泡,保持当前视图
|
// 现失败推错误气泡 + 保持当前视图。新建无乐观本地变化(后端生成 id,失败无副作用)。
|
||||||
state.activeConversationId = result.id
|
const result = await withConvOp(
|
||||||
void appSettings.set('df-ai-active-conv', result.id)
|
() => {},
|
||||||
// G3.5:新对话无历史,load_more 游标复位
|
() => aiApi.createConversation(),
|
||||||
loadMoreCursor.hasMore = false
|
() => {},
|
||||||
loadMoreCursor.earliestSeq = null
|
'newConvFail',
|
||||||
loadMoreCursor.convId = result.id
|
)
|
||||||
loadMoreCursor.loading = false
|
if (!result) return // 失败已推气泡,保持当前视图
|
||||||
state.messages = []
|
state.activeConversationId = result.id
|
||||||
state.currentText = ''
|
void appSettings.set('df-ai-active-conv', result.id)
|
||||||
state.pendingApprovals = []
|
// G3.5:新对话无历史,load_more 游标复位
|
||||||
setStreaming(false, { reason: 'newConversation' })
|
loadMoreCursor.hasMore = false
|
||||||
// F-09 决策e(真并发):newConversation 对齐 switchConversation 并行语义——旧 conv 后台 loop 继续,
|
loadMoreCursor.earliestSeq = null
|
||||||
// 不清 generatingConvs(保留旧 conv 生成态跟踪,侧栏显双会话生成,AiCompleted/AiError 按
|
loadMoreCursor.convId = result.id
|
||||||
// conversation_id 正确收尾旧 conv)。原 state.generatingConvs.clear() 是 A 路线 F-260616-09
|
loadMoreCursor.loading = false
|
||||||
// 单 loop 软隔离遗留,真并发下致后台生成前端丢失跟踪,废弃。
|
state.messages = []
|
||||||
// agentRound 复位 0;searchQuery 清空防新会话侧栏被旧搜索过滤。
|
state.currentText = ''
|
||||||
// queue 保留旧会话排队消息(它们有 conversationId,会在旧会话 AiCompleted 时按 ID 精准 drain)
|
state.pendingApprovals = []
|
||||||
state.agentRound = 0
|
setStreaming(false, { reason: 'newConversation' })
|
||||||
state.searchQuery = ''
|
// F-09 决策e(真并发):newConversation 对齐 switchConversation 并行语义——旧 conv 后台 loop 继续,
|
||||||
await loadConversations()
|
// 不清 generatingConvs(保留旧 conv 生成态跟踪,侧栏显双会话生成,AiCompleted/AiError 按
|
||||||
notifyConversationChanged()
|
// conversation_id 正确收尾旧 conv)。原 state.generatingConvs.clear() 是 A 路线 F-260616-09
|
||||||
|
// 单 loop 软隔离遗留,真并发下致后台生成前端丢失跟踪,废弃。
|
||||||
|
// agentRound 复位 0;searchQuery 清空防新会话侧栏被旧搜索过滤。
|
||||||
|
// queue 保留旧会话排队消息(它们有 conversationId,会在旧会话 AiCompleted 时按 ID 精准 drain)
|
||||||
|
state.agentRound = 0
|
||||||
|
state.searchQuery = ''
|
||||||
|
await loadConversations()
|
||||||
|
notifyConversationChanged()
|
||||||
|
} finally {
|
||||||
|
// 防抖释放:300ms 后允许再次新建
|
||||||
|
setTimeout(() => { _newConvLock = false }, 300)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 切换 token:快速连点 A→B 时,后返回的 A 响应按 token 丢弃,防 messages 错配(FR-R1)
|
// 切换 token:快速连点 A→B 时,后返回的 A 响应按 token 丢弃,防 messages 错配(FR-R1)
|
||||||
let _latestSwitchId = 0
|
let _latestSwitchId = 0
|
||||||
|
// 最近一次切换的切换窗口缓冲(成功提交路径恢复续显用;失败/过期路径丢弃,
|
||||||
|
// 防陈旧文本在后续切换被误恢复造成内容重复)。由 switchConversation 的 finally 统一填充。
|
||||||
|
let _switchBufferedText = ''
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// G3.5 load_more 分页(滚顶加载更早历史)
|
// G3.5 load_more 分页(滚顶加载更早历史)
|
||||||
@@ -232,18 +250,27 @@ async function loadMoreHistory(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 切换到指定会话:加载历史消息(含 tool_calls 回填 + tool_result 映射 + pending 审批恢复) */
|
/** 切换到指定会话:加载历史消息(含 tool_calls 回填 + tool_result 映射 + pending 审批恢复)
|
||||||
export async function switchConversation(id: string) {
|
* @param force true=跳过同会话短路(程序化刷新当前会话用:清空/压缩后回刷,如 AiChat.vue
|
||||||
// AIC-FIX-17-P0-2:切换会话时清空所有审批计时器(防切走后过期 timer 误拒后台 pending + 错误气泡推错视图)。
|
* onCleared/onCompressed 回调);false(默认)=同会话点击直接 return。 */
|
||||||
// 新会话的审批计时器由下文 restore pending 路径的 startApprovalTimer 重新建立。
|
export async function switchConversation(id: string, force = false) {
|
||||||
|
// 同会话点击短路:已活跃会话不重拉。原实现无条件走 currentText='' + 全量 parse +
|
||||||
|
// pending 重恢复,流式中点侧栏高亮项会清空流式文本、delta 从空续流 → 回复缺前缀。短路保留
|
||||||
|
// in-flight 流式文本。程序化刷新(force=true)不受影响。
|
||||||
|
if (!force && id === state.activeConversationId) return
|
||||||
|
// 切换会话时清空所有审批计时器(防切走后过期 timer 误拒后台挂起审批 + 错误气泡推错视图)。
|
||||||
|
// 新会话的审批计时器由下文恢复挂起路径的 startApprovalTimer 重新建立。
|
||||||
clearAllApprovalTimers()
|
clearAllApprovalTimers()
|
||||||
const mySwitchId = ++_latestSwitchId
|
const mySwitchId = ++_latestSwitchId
|
||||||
// 允许生成中切换:后台继续生成,事件按 conversation_id 路由不污染当前视图
|
// 允许生成中切换:后台继续生成,事件按 conversation_id 路由不污染当前视图
|
||||||
let detail: AiConversationDetail
|
let detail: AiConversationDetail
|
||||||
try {
|
try {
|
||||||
|
// 标记目标会话切换中(await 往返期间),useAiEvents 对非当前会话的 AiTextDelta
|
||||||
|
// 若命中此集合则累积到其 per-conv 流式态而非丢弃(治切换窗口丢 token)。
|
||||||
|
switchingConvs.add(id)
|
||||||
detail = await aiApi.switchConversation(id)
|
detail = await aiApi.switchConversation(id)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// G3.3:区分 Err 形态——仅"对话不存在"(已删除/未落库的虚 ID)才 create-new 兜底;
|
// 区分错误形态:仅"对话不存在"(已删除/未落库的虚 ID)才新建会话兜底;
|
||||||
// 瞬态 IPC 失败(网络抖动/后端异常)保留原视图 + 推错误气泡,不再吞当前视图。
|
// 瞬态 IPC 失败(网络抖动/后端异常)保留原视图 + 推错误气泡,不再吞当前视图。
|
||||||
const errMsg = e instanceof Error ? e.message : String(e)
|
const errMsg = e instanceof Error ? e.message : String(e)
|
||||||
if (errMsg.includes('对话不存在')) {
|
if (errMsg.includes('对话不存在')) {
|
||||||
@@ -253,7 +280,7 @@ export async function switchConversation(id: string) {
|
|||||||
void appSettings.set('df-ai-active-conv', created.id)
|
void appSettings.set('df-ai-active-conv', created.id)
|
||||||
// 用新对话 id 重走后续逻辑
|
// 用新对话 id 重走后续逻辑
|
||||||
detail = { id: created.id as ConvId, title: null, messages: '[]' }
|
detail = { id: created.id as ConvId, title: null, messages: '[]' }
|
||||||
// G3.5:新建对话无历史,load_more 游标复位
|
// 新建对话无历史,load_more 游标复位
|
||||||
loadMoreCursor.hasMore = false
|
loadMoreCursor.hasMore = false
|
||||||
loadMoreCursor.earliestSeq = null
|
loadMoreCursor.earliestSeq = null
|
||||||
loadMoreCursor.convId = created.id
|
loadMoreCursor.convId = created.id
|
||||||
@@ -263,46 +290,54 @@ export async function switchConversation(id: string) {
|
|||||||
void loadConversations()
|
void loadConversations()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 瞬态失败:保留当前视图 + 错误气泡(复用 M30 pushConvOpFail 模式);过期响应丢弃。
|
// 瞬态失败:保留当前视图 + 错误气泡(复用会话操作失败气泡模式);过期响应丢弃。
|
||||||
// 保留 loadConversations() 刷新:陈旧 id(已被后端删除)在下一次列表刷新中消失。
|
// 保留 loadConversations() 刷新:陈旧 id(已被后端删除)在下一次列表刷新中消失。
|
||||||
console.error('[AI] switchConversation 失败(瞬态),保留当前视图:', e)
|
console.error('[AI] switchConversation 失败(瞬态),保留当前视图:', e)
|
||||||
if (mySwitchId !== _latestSwitchId) return
|
if (mySwitchId !== _latestSwitchId) return
|
||||||
pushConvOpFail('switchConvFail')
|
pushConvOpFail('switchConvFail')
|
||||||
void loadConversations()
|
void loadConversations()
|
||||||
return
|
return
|
||||||
|
} finally {
|
||||||
|
// 切换结束(含失败/过期/新建会话路径)清切换中标记 + 统一收集并清空缓冲。
|
||||||
|
// 成功提交路径在下方用 _switchBufferedText 恢复续显;失败/过期路径缓冲被丢弃,
|
||||||
|
// 防陈旧文本在后续切换被误恢复造成内容重复。
|
||||||
|
switchingConvs.delete(id)
|
||||||
|
_switchBufferedText = getConvStreamState(id)?.currentText ?? ''
|
||||||
|
setConvCurrentText(id, '')
|
||||||
}
|
}
|
||||||
// 过期响应丢弃(用户已切到别的对话,防 A 后返回覆盖 B)
|
// 过期响应丢弃(用户已切到别的对话,防 A 后返回覆盖 B)
|
||||||
if (mySwitchId !== _latestSwitchId) return
|
if (mySwitchId !== _latestSwitchId) return
|
||||||
state.activeConversationId = id
|
// 透传 has_more/earliest_seq 游标(前端滚顶加载更多历史)。
|
||||||
// G3.5:透传 has_more/earliest_seq 游标(前端滚顶加载更多历史)。
|
|
||||||
// 后端在 generating/空对话等场景返 false/null,此处默认兜底。
|
// 后端在 generating/空对话等场景返 false/null,此处默认兜底。
|
||||||
const switchDetailAny = detail as any
|
const switchDetailAny = detail as any
|
||||||
loadMoreCursor.hasMore = switchDetailAny.has_more ?? false
|
loadMoreCursor.hasMore = switchDetailAny.has_more ?? false
|
||||||
loadMoreCursor.earliestSeq = switchDetailAny.earliest_seq ?? null
|
loadMoreCursor.earliestSeq = switchDetailAny.earliest_seq ?? null
|
||||||
loadMoreCursor.convId = id
|
loadMoreCursor.convId = id
|
||||||
loadMoreCursor.loading = false
|
loadMoreCursor.loading = false
|
||||||
void appSettings.set('df-ai-active-conv', id)
|
// streaming 是全局单值,切到非生成会话需按目标会话生成态重算,否则残留停止按钮
|
||||||
// P1#6 技术债审查(2026-06-21):streaming 是全局单值,切到非生成会话需按目标 conv 生成态重算,
|
// (输入区 v-if=streaming)→ 点击停止会传 activeConversationId 发错会话。对齐新建会话的
|
||||||
// 否则残留 stop 按钮(ChatInput.vue:88 v-if=streaming)→ 点击 store.stopChat 传 activeConversationId
|
// 复位语义;目标在后台生成时保留 true(停止/流式显示正确)。读会话状态(枚举真相源)派生,
|
||||||
// 发错会话。对齐 newConversation 复位语义;目标在后台生成时保留 true(stop/流式显示正确)。
|
// 桥接语义:非终止三态(generating/stopping/compressed)视为生成中。
|
||||||
// 根治归 F-09 B 路线:streaming 改 per-conv 态。此处为 A 路线过渡补丁。
|
|
||||||
// 批4 双轨收口:读 getConvState(enum 真相源)派生,替代旧 generatingConvs.has。
|
|
||||||
// 桥接语义:has(id)=true 等价于 conv_state∈{generating,stopping,compressed}(非终止三态)。
|
|
||||||
const targetCs = getConvState(id)
|
const targetCs = getConvState(id)
|
||||||
const targetGen = targetCs === 'generating' || targetCs === 'stopping' || targetCs === 'compressed'
|
const targetGen = targetCs === 'generating' || targetCs === 'stopping' || targetCs === 'compressed'
|
||||||
setStreaming(targetGen, { convId: id, reason: 'switchConversation-recompute' })
|
|
||||||
|
|
||||||
|
// 先 parse 成功替换 messages,成功后再置 activeConversationId(同一同步块内赋值,
|
||||||
|
// 无 await 间隙 → 无中间态渲染)。原顺序先置 active 后 parse,parse 失败时 active 已是新 id 而
|
||||||
|
// messages 仍是旧视图 + 错误气泡 → active 与 messages 错配(视图显示旧会话却以新会话高亮)。
|
||||||
try {
|
try {
|
||||||
const rawMsgs = typeof detail.messages === 'string'
|
const rawMsgs = typeof detail.messages === 'string'
|
||||||
? JSON.parse(detail.messages)
|
? JSON.parse(detail.messages)
|
||||||
: detail.messages
|
: detail.messages
|
||||||
// G3.5:消息映射收敛进 parseConvMessages(switch + load_more 共用,过滤/映射语义一致)。
|
// 消息映射收敛进 parseConvMessages(切换 + 加载更多共用,过滤/映射语义一致)。
|
||||||
// 相比原内联逻辑唯一行为变化:id 由 `loaded-${i}` 改为优先后端真实消息 id
|
// 相比原内联逻辑唯一行为变化:id 由 `loaded-${i}` 改为优先后端真实消息 id
|
||||||
// (DB 主键,prepend 时 v-for key 稳定不重建 DOM),缺失才兜底 `loaded-${i}`。
|
// (DB 主键,prepend 时 v-for key 稳定不重建 DOM),缺失才兜底 `loaded-${i}`。
|
||||||
state.messages = parseConvMessages(rawMsgs, 'loaded')
|
state.messages = parseConvMessages(rawMsgs, 'loaded')
|
||||||
|
// parse 成功才置 active(保证 active 与 messages 一致)
|
||||||
|
state.activeConversationId = id
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// UX-260617-08:历史消息解析/映射失败原仅 state.messages=[] → 切换后空白用户不知原因。
|
// 历史消息解析/映射失败原仅 state.messages=[] → 切换后空白用户不知原因。
|
||||||
// 改为推错误气泡提示 + 控制台日志(保留 state.messages 不再清空,避免空白无反馈)。
|
// 改为推错误气泡提示 + 控制台日志(保留 state.messages 不再清空,避免空白无反馈)。
|
||||||
|
// parse 失败不切换(active/messages 保持原状),仅推错误气泡,视图不显错配。
|
||||||
console.error('[AI] 切换对话历史消息解析失败:', e)
|
console.error('[AI] 切换对话历史消息解析失败:', e)
|
||||||
state.messages.push({
|
state.messages.push({
|
||||||
id: `switch-conv-fail-${nextMsgId()}`,
|
id: `switch-conv-fail-${nextMsgId()}`,
|
||||||
@@ -311,7 +346,11 @@ export async function switchConversation(id: string) {
|
|||||||
isError: true,
|
isError: true,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
} as AiMessage)
|
} as AiMessage)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
// 切换提交完成才持久化活跃会话 + 重算 streaming(active 已指向目标 conv)
|
||||||
|
void appSettings.set('df-ai-active-conv', id)
|
||||||
|
setStreaming(targetGen, { convId: id, reason: 'switchConversation-recompute' })
|
||||||
|
|
||||||
// 加载历史对话 token 总量(来自 DB summary);切换对话清空实时值
|
// 加载历史对话 token 总量(来自 DB summary);切换对话清空实时值
|
||||||
const conv = state.conversations.find(c => c.id === id)
|
const conv = state.conversations.find(c => c.id === id)
|
||||||
@@ -327,16 +366,18 @@ export async function switchConversation(id: string) {
|
|||||||
state.lastTokenUsage = null
|
state.lastTokenUsage = null
|
||||||
|
|
||||||
state.currentText = ''
|
state.currentText = ''
|
||||||
// 恢复该对话积压的待审批:重启后后端从审计表重建了 pending_approvals,
|
// 恢复切换窗口缓冲的流式文本(切换中累积到该会话 per-conv 态的后端快照之后增量),
|
||||||
// 此处查回并把对应 toolCard.status 置 pending_approval,使审批卡片重新可见
|
// 由消息列表续显,避免切换完成时丢可见内容(回复缺前缀)。空缓冲则无操作。
|
||||||
// 阶段4:按 IPC 返的 kind 渲染——'path' 类显 once/always/deny(tc.kind='path' + 推 path/dir 文案),
|
if (_switchBufferedText) state.currentText = _switchBufferedText
|
||||||
// 'risk' 类显 approve/reject(tc.kind='risk'/缺省)。对齐阶段3b 统一审批模型(两 kind 都可恢复)。
|
// 恢复该对话积压的待审批:重启后后端从审计表重建了挂起审批,
|
||||||
|
// 此处查回并把对应 toolCard.status 置为待审批,使审批卡片重新可见。
|
||||||
|
// 按 IPC 返的 kind 渲染:'path' 类显 once/always/deny,'risk' 类显 approve/reject。
|
||||||
try {
|
try {
|
||||||
const pending = await aiApi.pendingToolCalls(id)
|
const pending = await aiApi.pendingToolCalls(id)
|
||||||
// 第二 await 后二次比对:切换 A→B 期间避免用 A 的 pending 覆写 B 的 pendingApprovals
|
// 第二 await 后二次比对:切换 A→B 期间避免用 A 的挂起覆写 B 的 pendingApprovals
|
||||||
if (mySwitchId !== _latestSwitchId) return
|
if (mySwitchId !== _latestSwitchId) return
|
||||||
if (pending.length) {
|
if (pending.length) {
|
||||||
// kind 索引:tool_call_id → kind('risk'/'path'),供恢复 tc/pendingApprovals 按类型渲染
|
// kind 索引:tool_call_id → kind('risk'/'path'),供恢复工具卡/挂起列表按类型渲染
|
||||||
const pendingKindMap = new Map(pending.map(p => [p.tool_call_id, p.kind]))
|
const pendingKindMap = new Map(pending.map(p => [p.tool_call_id, p.kind]))
|
||||||
const pendingIds = new Set(pending.map(p => p.tool_call_id))
|
const pendingIds = new Set(pending.map(p => p.tool_call_id))
|
||||||
const restored: AiToolCallInfo[] = []
|
const restored: AiToolCallInfo[] = []
|
||||||
@@ -363,8 +404,7 @@ export async function switchConversation(id: string) {
|
|||||||
path: tc.path,
|
path: tc.path,
|
||||||
dir: tc.dir,
|
dir: tc.dir,
|
||||||
reason: tc.reason,
|
reason: tc.reason,
|
||||||
// A2-B10 conv-scoped 审批收尾:恢复的历史挂起带目标会话 id,
|
// 恢复的历史挂起带目标会话 id:会话终止收尾按此仅清本会话的待审批项,不连累并发会话。
|
||||||
// cleanupTerminatedConversation 按此仅清本会话的待审批项(不连累并发会话)。
|
|
||||||
conversationId: id,
|
conversationId: id,
|
||||||
})
|
})
|
||||||
// 重新启动审批计时器(APPROVAL_TIMEOUT_MS 默认 15min,0=不限时跳过;
|
// 重新启动审批计时器(APPROVAL_TIMEOUT_MS 默认 15min,0=不限时跳过;
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { aiApi } from '@/api'
|
|||||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||||
import { state } from '@/stores/ai'
|
import { state } from '@/stores/ai'
|
||||||
import { t } from '@/i18n/i18n-helpers'
|
import { t } from '@/i18n/i18n-helpers'
|
||||||
import { nextMsgId, findToolCall, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers, convStates, setConvState } from './aiShared'
|
import { nextMsgId, findToolCall, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers, convStates, setConvState, switchingConvs, getConvStreamState, setConvCurrentText, setConvStreaming } from './aiShared'
|
||||||
// 批4 双轨收口:getConvState 下沉到 aiShared,本模块 re-export 保持消费方
|
// 批4 双轨收口:getConvState 下沉到 aiShared,本模块 re-export 保持消费方
|
||||||
// (ChatInput.vue/MaxRoundsCard.vue 等)import 路径不变,组件零改动透明继承。
|
// (ChatInput.vue/MaxRoundsCard.vue 等)import 路径不变,组件零改动透明继承。
|
||||||
export { getConvState } from './aiShared'
|
export { getConvState } from './aiShared'
|
||||||
@@ -877,15 +877,30 @@ export function handleEvent(event: AiChatEvent) {
|
|||||||
// 不影响其他态(handleUserMessageEvent 去重幂等)。
|
// 不影响其他态(handleUserMessageEvent 去重幂等)。
|
||||||
if (handleUserMessageEvent(event)) return
|
if (handleUserMessageEvent(event)) return
|
||||||
// 事件不属于当前展示对话(生成中切走了)→ 不污染当前视图,仅完成/错误/求助时刷新侧边栏
|
// 事件不属于当前展示对话(生成中切走了)→ 不污染当前视图,仅完成/错误/求助时刷新侧边栏
|
||||||
// L1 求助协议(§2.3):AiHelpRequired 同 AiError 后端已 guard.reset 终止 loop,需收敛生成态。
|
|
||||||
const isCurrent = !convId || convId === state.activeConversationId
|
const isCurrent = !convId || convId === state.activeConversationId
|
||||||
if (!isCurrent) {
|
if (!isCurrent) {
|
||||||
if (event.type === 'AiCompleted' || event.type === 'AiError' || event.type === 'AiHelpRequired') {
|
if (event.type === 'AiCompleted' || event.type === 'AiError' || event.type === 'AiHelpRequired') {
|
||||||
// 批4 双轨收口:非当前会话终止事件,收敛 convStates(删 Map 项回 idle/不在生成),
|
// 非当前会话终止事件:收敛会话状态(删 Map 项回不在生成)。非当前会话的 error 态无
|
||||||
// 对齐 :592/:660/:711 当前会话分支语义。非当前会话的 error 态无消费方(MaxRoundsCard/
|
// 消费方(操作卡/输入区均读 activeConversationId),回退 null 与停止按钮回退行为等价。
|
||||||
// ChatInput 均读 activeConversationId),回退 null 与停止按钮 streaming 回退行为等价。
|
|
||||||
convStates.delete(convId || '')
|
convStates.delete(convId || '')
|
||||||
void loadConversations()
|
void loadConversations()
|
||||||
|
// 后台会话终止也触发队列续发/清队(原 isCurrent 守卫拦截导致非当前会话排队消息永不
|
||||||
|
// drain,切回后队列卡死)。完成触发续发;错误终止对齐当前会话分支清该会话队列。
|
||||||
|
if (event.type === 'AiCompleted') {
|
||||||
|
emit('ai-drain-queue', { conversationId: event.conversation_id })
|
||||||
|
} else if (event.type === 'AiError') {
|
||||||
|
if (event.conversation_id) {
|
||||||
|
state.queue = state.queue.filter(q => q.conversationId !== event.conversation_id)
|
||||||
|
} else {
|
||||||
|
state.queue = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (event.type === 'AiTextDelta' && convId && switchingConvs.has(convId)) {
|
||||||
|
// 切换中缓冲:目标会话正被 switchConversation 拉取,该会话的 delta 累积到其 per-conv
|
||||||
|
// 流式态(而非丢弃),切换完成后由渲染层续显,避免切换窗口丢可见内容。不做气泡/工具
|
||||||
|
// 副作用(消息视图尚未切到目标会话,避免污染当前视图)。
|
||||||
|
setConvStreaming(convId, true)
|
||||||
|
setConvCurrentText(convId, (getConvStreamState(convId)?.currentText ?? '') + (event.delta ?? ''))
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+168
-120
@@ -24,7 +24,7 @@ import { t } from '@/i18n/i18n-helpers'
|
|||||||
import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream'
|
import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream'
|
||||||
import { setStreaming } from './streamingGuard'
|
import { setStreaming } from './streamingGuard'
|
||||||
import { startListener, flushCurrentText } from './useAiEvents'
|
import { startListener, flushCurrentText } from './useAiEvents'
|
||||||
import { nextMsgId, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers, resolveAiLang, convStates } from './aiShared'
|
import { nextMsgId, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers, resolveAiLang, convStates, setConvCurrentText, setConvStreaming } from './aiShared'
|
||||||
|
|
||||||
/// 待发送队列上限(超过抛错提示用户)
|
/// 待发送队列上限(超过抛错提示用户)
|
||||||
const QUEUE_LIMIT = 10
|
const QUEUE_LIMIT = 10
|
||||||
@@ -60,67 +60,75 @@ const modelOverride = ref<string | null>(null)
|
|||||||
// ── 内核发送:推送气泡+调 IPC,被 normal/force 两条路径共用 ──
|
// ── 内核发送:推送气泡+调 IPC,被 normal/force 两条路径共用 ──
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* F-260614-05 Phase 2b: parts 仅影响本地 push 的 user 消息(渲染多模态图)。
|
* 内核发送:推送气泡 + 调 IPC,被 normal/force 两条路径共用。
|
||||||
*
|
*
|
||||||
* 后端 ai_chat_send IPC 当前不接 parts(Phase 2c 接入);本轮 doSend 仅把 parts 写入本地 push 的
|
* @param convId 目标会话 id(显式传入优先,缺省当前活跃会话)。后台会话续发(drainQueue)场景
|
||||||
* user 消息让用户气泡可见图,后端请求仍走 content 文本路径(无图)。
|
* 目标会话 ≠ 当前活跃视图,此时不 push 本地气泡、不碰当前视图流式态,避免污染当前视图。
|
||||||
* Phase 2c 后端接 parts 后,本函数透传给 aiApi.sendMessage/forceSend 即可全链路生效。
|
|
||||||
*
|
|
||||||
* Input Augmentation: spans 透传给后端 ai_chat_send 的 mention_spans 参数(后端 resolve
|
|
||||||
* 投影成 Augmentation 注入)+ 写入本地 push 的 user 消息(MessageList 据此渲染 chip)。
|
|
||||||
* undefined/空数组 → 本地 user 消息 mentionSpans 置 undefined(纯文本气泡零回归)+ 后端不传。
|
|
||||||
*/
|
*/
|
||||||
async function doSend(text: string, skill?: string, force = false, parts?: ContentPart[], spans?: MentionSpan[]) {
|
async function doSend(text: string, skill?: string, force = false, parts?: ContentPart[], spans?: MentionSpan[], convId?: string | null) {
|
||||||
|
// 目标会话:显式传入优先,缺省当前活跃会话。
|
||||||
|
const targetConvId = convId ?? state.activeConversationId
|
||||||
|
// state.messages 是当前活跃会话的全局视图;后台会话续发不 push 本地气泡,
|
||||||
|
// 切回目标会话时由切换加载从 DB/事件恢复完整历史,避免把 A 的消息污染 B 的视图。
|
||||||
|
const isViewTarget = !targetConvId || targetConvId === state.activeConversationId
|
||||||
// 防御:先 flush 残留 currentText(正常情况已是空,flushCurrentText 直接 return;
|
// 防御:先 flush 残留 currentText(正常情况已是空,flushCurrentText 直接 return;
|
||||||
// 异常时序下可防止上一轮文本被 state.currentText='' 误清)
|
// 异常时序下可防止上一轮文本被清空误伤)。仅当前视图会话发送才 flush,后台续发不碰当前视图流式文本。
|
||||||
flushCurrentText()
|
if (isViewTarget) flushCurrentText()
|
||||||
|
|
||||||
const userMsgId = `user-${nextMsgId()}`
|
const userMsgId = `user-${nextMsgId()}`
|
||||||
state.messages.push({
|
|
||||||
id: userMsgId as MessageId,
|
|
||||||
role: 'user',
|
|
||||||
content: text.trim(),
|
|
||||||
// 仅在非空时挂 parts(纯文本消息保持 undefined,渲染走原 content 路径零回归)
|
|
||||||
parts: parts && parts.length > 0 ? parts : undefined,
|
|
||||||
// Input Augmentation: 仅在非空时挂 mentionSpans(供 MessageList chip 渲染;空则 undefined 零回归)
|
|
||||||
mentionSpans: spans && spans.length > 0 ? spans : undefined,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
})
|
|
||||||
|
|
||||||
const aiMsgId = `ai-${nextMsgId()}`
|
const aiMsgId = `ai-${nextMsgId()}`
|
||||||
state.messages.push({
|
if (isViewTarget) {
|
||||||
id: aiMsgId as MessageId,
|
state.messages.push({
|
||||||
role: 'assistant',
|
id: userMsgId as MessageId,
|
||||||
content: '',
|
role: 'user',
|
||||||
timestamp: Date.now(),
|
content: text.trim(),
|
||||||
})
|
// 仅在非空时挂 parts(纯文本消息保持 undefined,渲染走原 content 路径零回归)
|
||||||
setStreaming(true, { convId: state.activeConversationId, reason: 'doSend' })
|
parts: parts && parts.length > 0 ? parts : undefined,
|
||||||
state.currentText = ''
|
// Input Augmentation: 仅在非空时挂 mentionSpans(供消息列表 chip 渲染;空则 undefined 零回归)
|
||||||
resetStreamWatchdog() // 启动流式看门狗,无数据超时兜底
|
mentionSpans: spans && spans.length > 0 ? spans : undefined,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
state.messages.push({
|
||||||
|
id: aiMsgId as MessageId,
|
||||||
|
role: 'assistant',
|
||||||
|
content: '',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
})
|
||||||
|
setStreaming(true, { convId: targetConvId, reason: 'doSend' })
|
||||||
|
state.currentText = ''
|
||||||
|
// 启动流式看门狗(per-conv,无数据超时兜底)
|
||||||
|
resetStreamWatchdog(targetConvId || undefined)
|
||||||
|
} else {
|
||||||
|
// 后台会话续发:直接写其 per-conv 流式态(setStreaming 写当前活跃视图,后台场景会写错会话);
|
||||||
|
// 不 arm 看门狗(目标会话流式态暂由切换加载时按会话状态重算,后台不干扰当前视图)。
|
||||||
|
setConvStreaming(targetConvId, true)
|
||||||
|
setConvCurrentText(targetConvId, '')
|
||||||
|
}
|
||||||
|
|
||||||
await startListener()
|
await startListener()
|
||||||
const lang = resolveAiLang()
|
const lang = resolveAiLang()
|
||||||
try {
|
try {
|
||||||
// force=true 走 force_send IPC(复位残留 generating),否则走正常 send
|
// force=true 走 force_send IPC(复位残留 generating),否则走正常 send
|
||||||
// F-01 阶段6: 透传 modelOverride(主对话专用,后端兜底校验池内才用)
|
|
||||||
const override = modelOverride.value
|
const override = modelOverride.value
|
||||||
if (force) {
|
if (force) {
|
||||||
// F-05 Phase 2c: 透传 parts(图片)给后端 ai_chat_force_send,多模态全链生效
|
// 透传 parts(图片) + mentionSpans + 目标会话 id
|
||||||
// F-260616-09 B 批4(决策 e):传 activeConversationId,操作指定 conv 的 per_conv。
|
await aiApi.forceSend(text.trim(), lang, skill, override, parts, targetConvId, spans)
|
||||||
// Input Augmentation: 透传 mentionSpans(spans 非空时后端 resolve_and_inject 注入)。
|
|
||||||
await aiApi.forceSend(text.trim(), lang, skill, override, parts, state.activeConversationId, spans)
|
|
||||||
} else {
|
} else {
|
||||||
await aiApi.sendMessage(text.trim(), lang, skill, override, parts, state.activeConversationId, spans)
|
await aiApi.sendMessage(text.trim(), lang, skill, override, parts, targetConvId, spans)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// IPC 失败(spawn 前/provider 配置错等):回滚 streaming 防光标卡死 + 移除 user 消息与空气泡占位;
|
// IPC 失败(spawn 前/provider 配置错等):回滚目标会话 streaming 防光标卡死 + 移除本地气泡占位;
|
||||||
// 重新抛出由 handleSend 回填输入框,用户可重试
|
// 重新抛出由调用方回填输入框,用户可重试。彻底清理会话状态,避免按钮永久红色。
|
||||||
// BUG-2026-07-17:彻底清理 convStates,避免按钮永久红色
|
const failConvId = targetConvId
|
||||||
const convId = state.activeConversationId
|
if (isViewTarget) {
|
||||||
setStreaming(false, { convId, reason: 'doSend-ipc-fail' })
|
setStreaming(false, { convId: failConvId, reason: 'doSend-ipc-fail' })
|
||||||
clearStreamWatchdog(convId || undefined)
|
state.messages = state.messages.filter(m => m.id !== aiMsgId && m.id !== userMsgId)
|
||||||
if (convId) convStates.delete(convId)
|
} else {
|
||||||
state.messages = state.messages.filter(m => m.id !== aiMsgId && m.id !== userMsgId)
|
setConvStreaming(failConvId, false)
|
||||||
|
}
|
||||||
|
clearStreamWatchdog(failConvId || undefined)
|
||||||
|
if (failConvId) convStates.delete(failConvId)
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -255,44 +263,51 @@ async function editMessage(newMessage: string) {
|
|||||||
/**
|
/**
|
||||||
* 取出队首并发送(AiCompleted 触发,此时 streaming 已 false)。
|
* 取出队首并发送(AiCompleted 触发,此时 streaming 已 false)。
|
||||||
*
|
*
|
||||||
* B-260617-02: 原 void sendMessage(...) fire-and-forget 吞没了 doSend IPC 失败 throw,
|
* 原 fire-and-forget 吞没了 doSend IPC 失败 throw,既无 AiCompleted 触发下次 drain(队列永久
|
||||||
* 既无 AiCompleted 触发下次 drain(队列永久卡死),也无任何用户反馈。
|
* 卡死),也无任何用户反馈。现显式 catch:复位 streaming + 推错误气泡(后台会话不推,避免污染
|
||||||
* 现显式 catch:复位 streaming + 推错误气泡(复用 AiError 的 isError 气泡模式) +
|
* 当前视图)+ 回填失败消息到该会话队首并停 drain(不静默丢用户输入,保留剩余队列供手动重试/
|
||||||
* 回填失败消息到队首并停 drain(不静默丢用户输入,保留剩余队列供手动重试/编辑/取消)。
|
* 编辑/取消)。成功路径不受影响——doSend 成功后端会再 emit AiCompleted 续 drain 链路。
|
||||||
* 成功路径不受影响——doSend 成功后端会再 emit AiCompleted 续 drain 链路。
|
|
||||||
*/
|
*/
|
||||||
export function drainQueue(convId?: string | null) {
|
export function drainQueue(convId?: string | null) {
|
||||||
if (state.queue.length === 0) return
|
if (state.queue.length === 0) return
|
||||||
// 找队首属于目标 conv 的消息;若穿越(非当前 conv 的项堵在队首)则跳过整条队列
|
// 找队首属于目标会话的消息;若穿越(非目标会话的项堵在队首)则跳过整条队列
|
||||||
const targetId = convId ?? state.activeConversationId
|
const targetId = convId ?? state.activeConversationId
|
||||||
|
if (!targetId) return
|
||||||
const idx = state.queue.findIndex(q => q.conversationId === targetId)
|
const idx = state.queue.findIndex(q => q.conversationId === targetId)
|
||||||
if (idx === -1) return
|
if (idx === -1) return
|
||||||
const next = state.queue.splice(idx, 1)[0]!
|
const next = state.queue.splice(idx, 1)[0]!
|
||||||
const activeConvId = state.activeConversationId
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
await sendMessage(next.text, next.skill, false, next.parts)
|
// 用目标会话 id 发送(而非当前活跃会话)——若已切到别的会话,原实现会把 A 的队首消息
|
||||||
|
// 误发到当前活跃会话 B。队列项无 mentionSpans 字段,传 undefined。
|
||||||
|
await sendMessage(next.text, next.skill, false, next.parts, undefined, targetId)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// BUG-2026-07-17: 失败时彻底收尾,带 convId 精准清理 per-conv watchdog + convStates
|
// 失败时彻底收尾:带目标会话 id 精准清理 per-conv 看门狗 + 会话状态
|
||||||
setStreaming(false, { convId: activeConvId, reason: 'drainQueue-fail' })
|
setConvStreaming(targetId, false)
|
||||||
clearStreamWatchdog(activeConvId || undefined)
|
clearStreamWatchdog(targetId || undefined)
|
||||||
if (activeConvId) convStates.delete(activeConvId)
|
if (targetId) convStates.delete(targetId)
|
||||||
const errMsg = e instanceof Error ? e.message : String(e)
|
const errMsg = e instanceof Error ? e.message : String(e)
|
||||||
state.queue.unshift(next) // 回填失败消息到队首,保留剩余队列(不静默丢用户输入)
|
// 回填失败消息到该会话队首,保留剩余队列(不静默丢用户输入)
|
||||||
state.messages.push({
|
const reIdx = state.queue.findIndex(q => q.conversationId === targetId)
|
||||||
id: `err-${nextMsgId()}`,
|
state.queue.splice(reIdx === -1 ? state.queue.length : reIdx, 0, next)
|
||||||
role: 'assistant',
|
// 仅目标会话为当前视图才推错误气泡(后台会话续发失败不污染当前视图;错误消息留在队列
|
||||||
content: t('ai.queuedSendFailed', { error: errMsg }),
|
// 供切回后编辑/重试/取消)
|
||||||
isError: true,
|
if (targetId === state.activeConversationId) {
|
||||||
timestamp: Date.now(),
|
state.messages.push({
|
||||||
} as AiMessage)
|
id: `err-${nextMsgId()}`,
|
||||||
|
role: 'assistant',
|
||||||
|
content: t('ai.queuedSendFailed', { error: errMsg }),
|
||||||
|
isError: true,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
} as AiMessage)
|
||||||
|
}
|
||||||
console.error('[AI] drainQueue 续发失败:', e)
|
console.error('[AI] drainQueue 续发失败:', e)
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送消息 — 三级降级(B-260616-02 L2 发送韧性):
|
* 发送消息 — 三级降级(L0 直接发 / L1 入队 / L2 强制):
|
||||||
*
|
*
|
||||||
* L0 normal: 后端 idle → 直接 doSend()
|
* L0 normal: 后端 idle → 直接 doSend()
|
||||||
* L1 queued: 后端 busy → 入队等 AiCompleted 续发
|
* L1 queued: 后端 busy → 入队等 AiCompleted 续发
|
||||||
@@ -301,25 +316,27 @@ export function drainQueue(convId?: string | null) {
|
|||||||
*
|
*
|
||||||
* forceMode=true 时跳过入队,直接走 ai_chat_force_send。
|
* forceMode=true 时跳过入队,直接走 ai_chat_force_send。
|
||||||
*
|
*
|
||||||
|
* @param convId 目标会话 id(显式传入优先,缺省当前活跃会话)。后台会话续发(drainQueue)必须
|
||||||
|
* 显式传目标会话,否则切走后会用当前活跃会话发错消息。
|
||||||
|
*
|
||||||
* 注:入队条件仅用 backendGenerating(IPC 查后端真实态),不再检查 state.streaming。
|
* 注:入队条件仅用 backendGenerating(IPC 查后端真实态),不再检查 state.streaming。
|
||||||
* streaming 是渲染态(文本是否在屏幕上输出),不能作为「后端能否接收消息」的代理。
|
* streaming 是渲染态(文本是否在屏幕上输出),不能作为「后端能否接收消息」的代理。
|
||||||
* 文本显示完但后端在落库时,streaming 仍 true——此时按钮已白(基于 delta 时间戳),
|
|
||||||
* 用户点击发送,若后端还在忙则入队短暂等待(~1s),AiCompleted 后自动续发。
|
|
||||||
*/
|
*/
|
||||||
async function sendMessage(text: string, skill?: string, forceMode = false, parts?: ContentPart[], spans?: MentionSpan[]) {
|
async function sendMessage(text: string, skill?: string, forceMode = false, parts?: ContentPart[], spans?: MentionSpan[], convId?: string | null) {
|
||||||
if (!text.trim() && !(parts && parts.length)) return
|
if (!text.trim() && !(parts && parts.length)) return
|
||||||
|
// 目标会话:显式传入优先(后台会话续发场景),缺省当前活跃会话
|
||||||
|
const targetConvId = convId ?? state.activeConversationId
|
||||||
|
|
||||||
// ── L2 强制模式:跳过所有预检,直接 force_send ──
|
// ── L2 强制模式:跳过所有预检,直接 force_send ──
|
||||||
if (forceMode) {
|
if (forceMode) {
|
||||||
await doSend(text, skill, true, parts, spans)
|
await doSend(text, skill, true, parts, spans, targetConvId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// B-260615-22(方案 A):发送前 IPC 查后端真实 generating。
|
// 发送前 IPC 查后端真实 generating(精确查目标会话,不读全局单值)。
|
||||||
// F-260616-09 B 批4(决策 e):传 activeConversationId 精确查当前 conv(不读全局单值)。
|
|
||||||
let backendGenerating = false
|
let backendGenerating = false
|
||||||
try {
|
try {
|
||||||
backendGenerating = await invoke<boolean>('ai_is_generating', { conversationId: state.activeConversationId || null })
|
backendGenerating = await invoke<boolean>('ai_is_generating', { conversationId: targetConvId || null })
|
||||||
} catch {
|
} catch {
|
||||||
backendGenerating = false
|
backendGenerating = false
|
||||||
}
|
}
|
||||||
@@ -335,51 +352,55 @@ async function sendMessage(text: string, skill?: string, forceMode = false, part
|
|||||||
skill: skill || undefined,
|
skill: skill || undefined,
|
||||||
enqueuedAt: Date.now(),
|
enqueuedAt: Date.now(),
|
||||||
parts: parts && parts.length > 0 ? parts : undefined,
|
parts: parts && parts.length > 0 ? parts : undefined,
|
||||||
conversationId: state.activeConversationId,
|
conversationId: targetConvId,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── L0:normal 直接发送 ──
|
// ── L0:normal 直接发送 ──
|
||||||
await doSend(text, skill, false, parts, spans)
|
await doSend(text, skill, false, parts, spans, targetConvId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 排队是否已超时(任一条超即算,因队首阻塞导致后续全堵) */
|
/** 排队是否已超时(任一条超即算,因队首阻塞导致后续全堵;未传 convId 时仅看当前活跃会话队列) */
|
||||||
export function isQueueTimedOut(): boolean {
|
export function isQueueTimedOut(convId?: string | null): boolean {
|
||||||
const first = state.queue[0]
|
const targetConv = convId ?? state.activeConversationId
|
||||||
|
const q = targetConv ? state.queue.filter(x => x.conversationId === targetConv) : state.queue
|
||||||
|
const first = q[0]
|
||||||
return first !== undefined && (Date.now() - first.enqueuedAt > QUEUE_TIMEOUT_MS)
|
return first !== undefined && (Date.now() - first.enqueuedAt > QUEUE_TIMEOUT_MS)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 超时时弹 confirm,用户确认后以 force_mode 重发队首消息;取消则保持排队。
|
* 超时时弹 confirm,用户确认后以 force_mode 重发目标会话队首消息;取消则保持排队。
|
||||||
*
|
*
|
||||||
* B-260617-04: force_send 失败时,原实现队首已 shift 致消息静默丢失。
|
* force_send 失败时,队首已移除,此处回填到该会话队首(原全局 unshift 在跨会话队列下会错位),
|
||||||
* 现失败把队首 unshift 回队列(保留 UI 队列可见,用户可改普通发送/编辑/取消),
|
* 并复位目标会话 streaming / 清看门狗,让 UI 脱卡死态。不自动重试 force(避免循环),交用户决定。
|
||||||
* 并复位 streaming/clearStreamWatchdog(对齐 doSend 失败回填模式)让 UI 脱卡死态。
|
|
||||||
* 不自动重试 force(避免循环),交用户决定下一步。
|
|
||||||
*/
|
*/
|
||||||
export async function tryForceSend(confirmFn: (msg: string) => Promise<boolean>): Promise<boolean> {
|
export async function tryForceSend(confirmFn: (msg: string) => Promise<boolean>, convId?: string | null): Promise<boolean> {
|
||||||
const first = state.queue[0]
|
const targetConv = convId ?? state.activeConversationId
|
||||||
|
const idx = targetConv ? state.queue.findIndex(q => q.conversationId === targetConv) : 0
|
||||||
|
const first = idx === -1 ? undefined : state.queue[idx]
|
||||||
if (!first) return false
|
if (!first) return false
|
||||||
const confirmed = await confirmFn(t('ai.forceSendConfirm'))
|
const confirmed = await confirmFn(t('ai.forceSendConfirm'))
|
||||||
if (!confirmed) return false
|
if (!confirmed) return false
|
||||||
// 从队列移除队首,以 force_mode 发送
|
// 从队列移除该会话队首,以 force_mode 发送
|
||||||
state.queue.shift()
|
state.queue.splice(idx, 1)
|
||||||
// 清看门狗:doSend 内 force_send 路径会重设 streaming=true 并重启 watchdog,
|
// 清看门狗:doSend 内 force_send 路径会重设 streaming=true 并重启看门狗,
|
||||||
// 此处无需前置 false(前置 false 会触发 AiChat watch 清流式块再重建,产生瞬态抖动)
|
// 此处无需前置 false(前置 false 会触发消息列表 watch 清流式块再重建,产生瞬态抖动)
|
||||||
clearStreamWatchdog()
|
clearStreamWatchdog()
|
||||||
try {
|
try {
|
||||||
await sendMessage(first.text, first.skill, true, first.parts)
|
await sendMessage(first.text, first.skill, true, first.parts, undefined, first.conversationId)
|
||||||
return true
|
return true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// force_send 也失败:回填队首保消息不丢,复位 streaming 让 UI 可继续操作
|
// force_send 也失败:回填该会话队首保消息不丢,复位 streaming 让 UI 可继续操作
|
||||||
state.queue.unshift({
|
const reIdx = targetConv ? state.queue.findIndex(q => q.conversationId === targetConv) : 0
|
||||||
|
state.queue.splice(reIdx === -1 ? state.queue.length : reIdx, 0, {
|
||||||
text: first.text,
|
text: first.text,
|
||||||
skill: first.skill,
|
skill: first.skill,
|
||||||
enqueuedAt: first.enqueuedAt,
|
enqueuedAt: first.enqueuedAt,
|
||||||
parts: first.parts,
|
parts: first.parts,
|
||||||
|
conversationId: first.conversationId,
|
||||||
})
|
})
|
||||||
setStreaming(false, { convId: state.activeConversationId, reason: 'tryForceSend-fail' })
|
setStreaming(false, { convId: targetConv ?? state.activeConversationId, reason: 'tryForceSend-fail' })
|
||||||
clearStreamWatchdog()
|
clearStreamWatchdog()
|
||||||
console.error('[AI] tryForceSend force_send 失败,消息已回填队首:', e)
|
console.error('[AI] tryForceSend force_send 失败,消息已回填队首:', e)
|
||||||
return false
|
return false
|
||||||
@@ -388,52 +409,79 @@ export async function tryForceSend(confirmFn: (msg: string) => Promise<boolean>)
|
|||||||
|
|
||||||
/** 批量审批已拆分到 useAiApproval.ts(fe-arch P0-1) */
|
/** 批量审批已拆分到 useAiApproval.ts(fe-arch P0-1) */
|
||||||
|
|
||||||
/** 取消队列中指定位置的消息 */
|
/**
|
||||||
function cancelQueued(index: number) {
|
* 把「当前活跃会话视图内的队列下标」映射到全局队列真实下标。
|
||||||
state.queue.splice(index, 1)
|
*
|
||||||
|
* 队列区在 AiChat.vue 按 activeConversationId 过滤渲染(activeQueue),UI 上的编辑/删除/立即发送
|
||||||
|
* 传的是过滤视图内的 idx。此处按当前活跃会话在全局队列中定位真实下标;越界(项已被并发 drain
|
||||||
|
* 移除等)返 -1,调用方据此 no-op。
|
||||||
|
*/
|
||||||
|
function activeQueueRealIndex(idx: number): number {
|
||||||
|
const targetConv = state.activeConversationId
|
||||||
|
if (!targetConv) return -1
|
||||||
|
let seen = -1
|
||||||
|
for (let i = 0; i < state.queue.length; i++) {
|
||||||
|
if (state.queue[i].conversationId === targetConv) {
|
||||||
|
seen++
|
||||||
|
if (seen === idx) return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 清空整个待发送队列 */
|
/** 取消队列中指定位置的消息(仅当前活跃会话视图内的队列项) */
|
||||||
function clearQueue() {
|
function cancelQueued(index: number) {
|
||||||
state.queue = []
|
const realIdx = activeQueueRealIndex(index)
|
||||||
|
if (realIdx === -1) return
|
||||||
|
state.queue.splice(realIdx, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清空待发送队列(未传 convId 时仅清当前活跃会话;空 active 则全清兜底) */
|
||||||
|
function clearQueue(convId?: string | null) {
|
||||||
|
const targetConv = convId ?? state.activeConversationId
|
||||||
|
if (targetConv) {
|
||||||
|
state.queue = state.queue.filter(q => q.conversationId !== targetConv)
|
||||||
|
} else {
|
||||||
|
state.queue = []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UX-260616-06: 编辑队列项的 text(决策只编 text)。
|
* 编辑队列项的 text(决策只编 text)。
|
||||||
*
|
*
|
||||||
* 设计:
|
* 设计:
|
||||||
* - 只改 text,skill 编辑态不暴露(skill 来自发送时技能联想,编辑态改 skill 复杂化交互无收益)。
|
* - 只改 text,skill 编辑态不暴露(skill 来自发送时技能联想,编辑态改 skill 复杂化交互无收益)。
|
||||||
* - 边界:index 越界 / newText 空白 → 直接忽略(no-op),不抛错(UI 不会触发,防御)。
|
* - 边界:index 越界 / newText 空白 → 直接忽略(no-op),不抛错(UI 不会触发,防御)。
|
||||||
* - queue item 类型 { text, skill?, enqueuedAt } 不改。
|
|
||||||
*/
|
*/
|
||||||
function editQueued(index: number, newText: string) {
|
function editQueued(index: number, newText: string) {
|
||||||
if (index < 0 || index >= state.queue.length) return
|
const realIdx = activeQueueRealIndex(index)
|
||||||
|
if (realIdx === -1) return
|
||||||
const trimmed = newText.trim()
|
const trimmed = newText.trim()
|
||||||
if (!trimmed) return
|
if (!trimmed) return
|
||||||
state.queue[index].text = trimmed
|
state.queue[realIdx].text = trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UX-260616-07: 队列项立即发送(决策 a:插队=打断当前+发本条,复用 UX-05 stop 链路)。
|
* 队列项立即发送(插队=打断当前+发本条,复用停止链路)。
|
||||||
*
|
*
|
||||||
* 流程(顺序关键):
|
* 顺序关键:
|
||||||
* 1. 先 splice 出该项(记下 {text, skill})——必须在 stopChat 之前,
|
* 1. 先 splice 出该项——必须在 stopChat 之前,否则 stopChat→AiCompleted→drainQueue 续发时
|
||||||
* 否则 stopChat→AiCompleted→drainQueue 续发时该项还在队列致重复发。
|
* 该项还在队列致重复发。
|
||||||
* 2. 调 stopChat() 打断当前生成(stopChat UX-05 后已不清队列,故 splice 步骤前置必要)。
|
* 2. 调 stopChat() 打断当前生成(停止后已不清队列,故 splice 步骤前置必要)。
|
||||||
* 3. 直接 sendMessage(splicedItem.text, skill) 立即发这条(不等 drainQueue 轮到)。
|
* 3. 直接 sendMessage(..., forceMode=true) 立即发这条(不等 drainQueue 轮到)。
|
||||||
* 当前未完成回复已由 stop 截断保留(agentic/mod.rs save_conversation),无需额外处理。
|
* 当前未完成回复已由停止截断保留,无需额外处理。
|
||||||
*/
|
*/
|
||||||
async function sendQueuedNow(index: number) {
|
async function sendQueuedNow(index: number) {
|
||||||
if (index < 0 || index >= state.queue.length) return
|
const realIdx = activeQueueRealIndex(index)
|
||||||
const spliced = state.queue.splice(index, 1)[0]
|
if (realIdx === -1) return
|
||||||
|
const spliced = state.queue.splice(realIdx, 1)[0]
|
||||||
await stopChat()
|
await stopChat()
|
||||||
// B-260617-01: forceMode=true 跳过 L1 预检(ai_is_generating)直走 force_send。
|
// forceMode=true 跳过 L1 预检(后端 generating 判定)直走 force_send:stopChat 仅发出停止信号
|
||||||
// stopChat() 仅 await stop IPC 发出(置 stop_flag=true),不等后端 loop 跑到
|
// (置 stop_flag),不等后端 loop 跑到检测点复位 generating,紧接的普通发送会命中 L1 的
|
||||||
// agentic/mod.rs guard 检测点+guard.reset()(generating 才复位)。紧接 sendMessage
|
// backendGenerating(后端真值仍 true)→ spliced 被入队而非立即发,与"立即发送"语义不符。
|
||||||
// 命中 L1 的 backendGenerating(后端真值仍 true)→ spliced 被入队而非立即发,
|
// force_send 原子复位 generating=false 再发,无竞态窗口;stop_flag 让旧 loop 在下个检测点退出。
|
||||||
// 与"立即发送"语义不符。force_send(commands/chat.rs ai_chat_force_send)原子复位 generating=false
|
// 显式传 spliced 所属会话 id(当前活跃视图内操作,即当前会话)。
|
||||||
// 再发,无竞态窗口;stopChat 的 stop_flag 让旧 loop 在下个检测点退出,不冲突。
|
await sendMessage(spliced.text, spliced.skill, true, spliced.parts, undefined, spliced.conversationId)
|
||||||
await sendMessage(spliced.text, spliced.skill, true, spliced.parts)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 停止当前生成:本地先复位 streaming,再发停止信号。
|
/** 停止当前生成:本地先复位 streaming,再发停止信号。
|
||||||
|
|||||||
Reference in New Issue
Block a user