修复: DeepSeek 400 全量扫描 + 队列 per-conv 隔离
- openai_compat: 扫描所有 assistant 消息剥离 orphan tool_calls(原仅查末条) - queue 加 conversationId 字段,按会话精准 drain - regenerate/editMessage 只清本会话排队消息 - newConversation 保留旧会话排队消息 - AiError 只清出错会话的队列项
This commit is contained in:
44
Cargo.lock
generated
44
Cargo.lock
generated
@@ -779,6 +779,20 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dashmap"
|
||||
version = "6.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crossbeam-utils",
|
||||
"hashbrown 0.14.5",
|
||||
"lock_api",
|
||||
"once_cell",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.0"
|
||||
@@ -835,6 +849,7 @@ dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"dashmap",
|
||||
"df-ai",
|
||||
"df-execute",
|
||||
"df-ideas",
|
||||
@@ -877,9 +892,9 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"df-ai-core",
|
||||
"df-types",
|
||||
"eventsource-stream",
|
||||
"futures",
|
||||
"rand 0.8.6",
|
||||
"reqwest 0.12.28",
|
||||
@@ -1295,17 +1310,6 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "eventsource-stream"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"nom",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fallible-iterator"
|
||||
version = "0.3.0"
|
||||
@@ -2553,12 +2557,6 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -2661,16 +2659,6 @@ dependencies = [
|
||||
"memoffset",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
|
||||
@@ -22,3 +22,7 @@ regex = "1"
|
||||
# Win+mac 启用原生后端;linux 用 async-persistent 变体避同步阻塞 Secret Service。
|
||||
# 由 df-storage(密钥解析下沉层) + src-tauri(build_provider_for 转发壳)共同引用。
|
||||
keyring = { version = "3", features = ["windows-native", "apple-native", "linux-native-async-persistent"] }
|
||||
# dashmap: ConvStateStore 无锁并发 HashMap(session 锁重构方案 B-Phase0)。
|
||||
# ConvState 提到 ConvStateStore(DashMap),guard.reset/ai_is_generating 不依赖 session lock,
|
||||
# 治卡死连环(AiCompleted 延迟/工具后中断/第二条进队列同源根因)。详见 workflow session-lock-redesign。
|
||||
dashmap = "6"
|
||||
|
||||
47
PROGRESS.md
47
PROGRESS.md
@@ -1031,17 +1031,46 @@
|
||||
|
||||
---
|
||||
|
||||
### 2026-06-28 — 最新对话 DB & Log 诊断:审批死锁 + Windows 兼容 + 标题 LLM 失败
|
||||
### 2026-07-19 — ConvStateStore 无锁重构 + 前端 P0 修复 + DeepSeek 400 修复 + 标题策略改进
|
||||
|
||||
> 分析来源:`devflow-trace.log` + `devflow-dev.db` 最新对话 `d9aef24f`(语音输入应用需求)。
|
||||
> 7 次工具连续失败后 L1 断路器熔断,1 条 pending 审批导致全链路死锁。
|
||||
> 本轮(Zed Agent 接管)完成 B-Phase 全量迁移、前端 P0、多轮 bug 修复、代码审查清理。
|
||||
> 代码变更 50 文件,+2454/-1251 行,未提交。
|
||||
|
||||
- [ ] **P0 — 审批 pending 超时自动取消**:`try_continue` 入口清理 >5min 的 pending。
|
||||
- [ ] **P0 — `create_project` 加 auto_create_dir**:消除建项目→建目录→绑定的死锁链。
|
||||
- [ ] **P1 — `run_command` 失败追加 shell 适配提示**:引导 LLM 改正 PowerShell 命令。
|
||||
- [ ] **P1 — PowerShell 反斜杠自动转义**:消除 `os error 123`(Unicode 转义误识别)。
|
||||
- [ ] **P2 — 标题摘要合并连续同 role**:避免标题 LLM 的 GLM 1214 拒绝。
|
||||
- [ ] **P3 — migration V33 + L0-handshake 防抖**。
|
||||
**B-Phase ConvStateStore 无锁重构(根治 session lock 竞争)**
|
||||
|
||||
| Phase | 内容 |
|
||||
|---|---|
|
||||
| Phase0-1 | DashMap 骨架 + AppState 接入(Claude Code 完成)|
|
||||
| Phase2 | guard/L0/chat IPC 写侧全量切 conv_states |
|
||||
| Phase3 | 读侧迁移 + 删 PerConvState.conv_state 字段 → 单源收敛 |
|
||||
| Phase4 | conversation_delete 同步 conv_states.remove |
|
||||
|
||||
**前端修复**
|
||||
- P0-1: watchdog `convStates.clear()` → `delete(activeConversationId)`
|
||||
- P0-2: switch/deleteConversation 入口加 `clearAllApprovalTimers()` + 注释修正
|
||||
- 每轮 token 独立显示(🔣 XXX in · YYY out)
|
||||
- STREAM_TIMEOUT_MS 45s → 90s
|
||||
|
||||
**后端修复**
|
||||
- DeepSeek 400「insufficient tool messages」→ 末尾 orphan tool_calls 自动剥离
|
||||
- `env_snapshot.rs` 5s 超时兜底(Windows Store alias 卡死)
|
||||
- L0 握手始终推 AiConvStateChanged{idle} 防前端状态残留
|
||||
- `openai_compat.rs` send 阶段 60s timeout
|
||||
- 原生 SSE 解析器替代 eventsource-stream(Windows 兼容)
|
||||
- run_agentic_loop 20s AiHeartbeat 保活
|
||||
- grep 正则自动修复(未分组 `|` → `(?:...)` 包裹)
|
||||
- read_file 路径不存在提示 list_directory
|
||||
|
||||
**标题策略改进**
|
||||
- extract_title: 取前 5 条 user 消息中最长的一条(替代第一条截取)
|
||||
- extract 不再落库 → LLM 每次都有重试机会,不再永久锁定差标题
|
||||
|
||||
**代码审查清理**
|
||||
- 修复 lib.rs 重复 handler 注册(🔴 严重)
|
||||
- 清理 unused import / [STUCK] 调试日志 / 过时注释
|
||||
- 340 tests passed, vue-tsc clean
|
||||
|
||||
**待提交**:50 文件改动,含 B-Phase 重构 + 前端 P0 + 多轮 bug 修复
|
||||
|
||||
## 七、开发约定
|
||||
|
||||
|
||||
@@ -14,9 +14,9 @@ anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
# HTTP + 流式
|
||||
reqwest = { version = "0.12", features = ["stream", "json"] }
|
||||
reqwest = { version = "0.12", features = ["stream", "json", "rustls-tls"] }
|
||||
bytes = "1"
|
||||
futures = "0.3"
|
||||
eventsource-stream = "0.2"
|
||||
rand = "0.8"
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
76
crates/df-ai/examples/glm_stream_test.rs
Normal file
76
crates/df-ai/examples/glm_stream_test.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
//! 独立诊断:用 df-ai 真实调用 GLM anthropic 流式端点,验证
|
||||
//! sse_parser + apply_anthropic_event + provider.stream() 整条链路。
|
||||
//!
|
||||
//! 二分定位「发消息卡掉」: 若本例能正常吐 chunk → provider 层(df-ai)OK,
|
||||
//! 问题在 devflow 应用层(provider 配置/emit/前端); 若卡/空/Err → df-ai 有 bug。
|
||||
//!
|
||||
//! 运行: cd crates/df-ai && cargo run --example glm_stream_test
|
||||
use df_ai::build_provider;
|
||||
use df_ai_core::CompletionRequest;
|
||||
use futures::StreamExt;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let token = std::env::var("ANTHROPIC_AUTH_TOKEN")
|
||||
.or_else(|_| std::env::var("ANTHROPIC_API_KEY"))
|
||||
.expect("需要环境变量 ANTHROPIC_AUTH_TOKEN");
|
||||
eprintln!("[glm-test] token len={}", token.len());
|
||||
|
||||
let provider = build_provider(
|
||||
"anthropic",
|
||||
"https://open.bigmodel.cn/api/anthropic",
|
||||
&token,
|
||||
"glm-5.2",
|
||||
);
|
||||
|
||||
// 用 JSON 反序列化构造请求,绕开字段列表(devflow 实际用 glm-5.2)
|
||||
let req: CompletionRequest = serde_json::from_str(
|
||||
r#"{"model":"glm-5.2","stream":true,"max_tokens":16,"messages":[{"role":"user","content":"说你好"}]}"#,
|
||||
)
|
||||
.expect("parse CompletionRequest");
|
||||
|
||||
eprintln!("[glm-test] 调用 provider.stream() ...");
|
||||
let t0 = std::time::Instant::now();
|
||||
let mut s = match provider.stream(req).await {
|
||||
Ok(s) => {
|
||||
eprintln!("[glm-test] stream() Ok, 建连耗时 {:?}", t0.elapsed());
|
||||
s
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[glm-test] stream() Err: {:#}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut n = 0;
|
||||
let mut got_text = false;
|
||||
while let Some(chunk_result) = s.next().await {
|
||||
n += 1;
|
||||
match chunk_result {
|
||||
Ok(chunk) => {
|
||||
if !chunk.delta.is_empty() {
|
||||
got_text = true;
|
||||
}
|
||||
eprintln!(
|
||||
"[glm-test] chunk#{} delta={:?} reasoning={:?} finished={} usage={:?} err={:?}",
|
||||
n, chunk.delta,
|
||||
chunk.reasoning_content.as_deref().map(|s| if s.len() > 30 { format!("{}..", &s[..30]) } else { s.to_string() }),
|
||||
chunk.finished, chunk.usage, chunk.error
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[glm-test] chunk#{} Err: {}", n, e);
|
||||
}
|
||||
}
|
||||
if n > 60 {
|
||||
eprintln!("[glm-test] 超 60 chunk 截断");
|
||||
break;
|
||||
}
|
||||
}
|
||||
eprintln!(
|
||||
"[glm-test] 流结束 共 {} chunk, 是否拿到文本={}, 总耗时 {:?}",
|
||||
n,
|
||||
got_text,
|
||||
t0.elapsed()
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,6 @@
|
||||
//! 本模块仅保留 Provider struct + impl(HTTP 调用),Rust impl 块不可跨文件故作此切分。
|
||||
|
||||
use async_trait::async_trait;
|
||||
use eventsource_stream::Eventsource;
|
||||
use futures::StreamExt;
|
||||
use reqwest::Client;
|
||||
use std::time::Duration;
|
||||
@@ -590,17 +589,19 @@ impl LlmProvider for AnthropicCompatProvider {
|
||||
|
||||
debug!(model = %body.model, "Anthropic 流式调用");
|
||||
|
||||
let resp = match self
|
||||
// BUG-2026-07-07: send 阶段需 timeout 防 hang(实测 GLM 偶发建连后长时间不返回)。
|
||||
// 注意:不能用 reqwest 的 .timeout()——它是整个请求(含 body 读取)的总超时,
|
||||
// 流式长生成任务会被误砍(build_provider_client 注释已明确)。改用 tokio::time::timeout
|
||||
// 包裹 send().await,只管建连+首响应头,不管后续 body 读取(后续由 stream_llm idle timeout 兜底)。
|
||||
// 60s 选型:正常 send(建连+收 200 headers)<5s,60s 足够宽容。
|
||||
let send_future = self
|
||||
.auth_headers(self.client.post(self.messages_url()))
|
||||
.json(&body)
|
||||
.version(reqwest::Version::HTTP_11)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
// B-260618-26: 记 reqwest 错误源因链。原 ? 转 anyhow 仅 Display
|
||||
// "error sending request for url" 无法定位 reset/TLS/超时/body 真因。
|
||||
.send();
|
||||
let resp = match tokio::time::timeout(Duration::from_secs(60), send_future).await {
|
||||
Ok(Ok(r)) => r,
|
||||
Ok(Err(e)) => {
|
||||
tracing::error!(
|
||||
is_timeout = e.is_timeout(),
|
||||
is_connect = e.is_connect(),
|
||||
@@ -620,6 +621,14 @@ impl LlmProvider for AnthropicCompatProvider {
|
||||
std::error::Error::source(&e)
|
||||
);
|
||||
}
|
||||
Err(_elapsed) => {
|
||||
// send 阶段超时(60s 未返回 HTTP 响应头):GLM 端点可能不可达或极慢
|
||||
tracing::error!(
|
||||
url = %self.messages_url(),
|
||||
"Anthropic 流式 send 超时(60s 未返回响应头)"
|
||||
);
|
||||
anyhow::bail!("流式请求超时(60秒未收到 HTTP 响应,可能服务不可达或被防火墙拦截)");
|
||||
}
|
||||
};
|
||||
|
||||
if !resp.status().is_success() {
|
||||
@@ -629,36 +638,32 @@ impl LlmProvider for AnthropicCompatProvider {
|
||||
anyhow::bail!("Anthropic 流式 API 错误 {}: {}", status, text);
|
||||
}
|
||||
|
||||
// 流式解析:eventsource 逐事件处理,按 type 字段分发转 StreamChunk。
|
||||
// 事件解析/usage 累积逻辑抽到 apply_anthropic_event 纯函数,便于单测;此处闭包只负责传 data。
|
||||
// usage 累积:message_start 给 input_tokens,message_delta 给累计 output_tokens(非增量),message_stop 带出。
|
||||
// BUG-2026-07-17 根治: 原生 SSE 解析器替代 eventsource-stream(同 openai_compat)。
|
||||
let mut usage_accum: Option<TokenUsage> = None;
|
||||
// B-260618-28: MidStream error(如 GLM 1214 messages 非法)时附 messages 摘要定位哪条非法。
|
||||
// precheck(Init 路径,发送前)漏的 case,靠此在 SSE error 事件暴露实际 messages 结构到前端 raw。
|
||||
let messages_summary = Self::summarize_messages(&body.messages);
|
||||
let stream = resp
|
||||
.bytes_stream()
|
||||
.eventsource()
|
||||
.map(move |event| match event {
|
||||
Ok(ev) => {
|
||||
let mut chunk = apply_anthropic_event(&ev.data, &mut usage_accum);
|
||||
// GLM 中途 error(如 1214)→ chunk.error 附 messages 摘要,经 stream_recv MidStream
|
||||
// 路径 emit AiError raw,前端直接看到实际 messages 结构定位非法字段。
|
||||
if let Some(err) = chunk.error.as_mut() {
|
||||
*err = format!("{} | messages 摘要: {}", err, messages_summary);
|
||||
|
||||
let sse = crate::sse_parser::SseStream::new(resp.bytes_stream());
|
||||
let stream = sse.flat_map(move |result: Result<Vec<String>, String>| {
|
||||
let mut chunks: Vec<anyhow::Result<crate::provider::StreamChunk>> = Vec::new();
|
||||
match result {
|
||||
Ok(events) => {
|
||||
for data in events {
|
||||
let mut chunk = apply_anthropic_event(&data, &mut usage_accum);
|
||||
if let Some(err) = chunk.error.as_mut() {
|
||||
*err = format!("{} | messages 摘要: {}", err, messages_summary);
|
||||
}
|
||||
chunks.push(Ok(chunk));
|
||||
}
|
||||
Ok(chunk)
|
||||
}
|
||||
Err(e) => {
|
||||
// 保留 #[source] 因果链: anyhow!("...{}", e) 仅把 e 的 Display 塞进 message,
|
||||
// 丢掉 source(无法 downcast/遍历)。改用 Error::from(e).context(...):
|
||||
// Display 不变(仍为 "Anthropic SSE 错误: {e}"), 且 e 作为 .source() 可追溯。
|
||||
// 顺序: 先 format(e) 构造 context 文案, 再 Error::from(e) move e 进 source。
|
||||
let ctx = format!("Anthropic SSE 错误: {}", e);
|
||||
error!(error = %e, "Anthropic SSE 事件流错误");
|
||||
Err(anyhow::Error::from(e).context(ctx))
|
||||
error!("{}", ctx);
|
||||
chunks.push(Err(anyhow::anyhow!("{}", ctx)));
|
||||
}
|
||||
});
|
||||
}
|
||||
futures::stream::iter(chunks)
|
||||
});
|
||||
|
||||
Ok(Box::pin(stream))
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ pub mod router;
|
||||
// stream_recv/agentic 流前重试需复用 backoff_delay(jitter)+is_status_retryable(Fatal 分类)
|
||||
// 避免重写退避/分类逻辑(对齐决策 F-260616-07 a1)。改 pub mod 后对外仅暴露纯函数 + 常量。
|
||||
pub mod retry;
|
||||
pub mod sse_parser;
|
||||
|
||||
use provider::LlmProvider;
|
||||
use reqwest::Client;
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use eventsource_stream::Eventsource;
|
||||
use futures::StreamExt;
|
||||
use reqwest::Client;
|
||||
use tracing::{debug, error, warn};
|
||||
@@ -183,6 +182,31 @@ impl OpenAICompatProvider {
|
||||
// assistant 的序列(会话恢复/续发/片段截取),补 user 占位保留上下文,首条合法。
|
||||
Self::ensure_leading_user(&mut messages);
|
||||
|
||||
// 治 DeepSeek 400「insufficient tool messages」:扫描所有 assistant 消息,
|
||||
// 若某条 assistant 含 tool_calls 但下一条不是 tool,则剥离其 tool_calls。
|
||||
// 正常流程 tool 结果先于下一轮 LLM 请求推入历史,此守卫仅兜底异常截断/恢复场景的残末尾。
|
||||
// 注意:合法的三元组形如:assistant(tc=[a]) → tool(a) → assistant(tc=[b]) → tool(b)。
|
||||
// 若最后一条是 assistant(tc=...) 也无下一条 tool,同样剥离。
|
||||
for i in 0..messages.len() {
|
||||
let role = messages[i].role.clone();
|
||||
if role != "assistant" {
|
||||
continue;
|
||||
}
|
||||
let has_tc = messages[i].tool_calls.is_some();
|
||||
if !has_tc {
|
||||
continue;
|
||||
}
|
||||
let next_is_tool = i + 1 < messages.len()
|
||||
&& matches!(messages[i + 1].role.as_str(), "tool");
|
||||
if !next_is_tool {
|
||||
messages[i].tool_calls = None;
|
||||
tracing::warn!(
|
||||
"[openai] assistant(#{} role={}) 含 tool_calls 但下一条非 tool,已自动剥离(防 400)",
|
||||
i, role,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let tools = req.tools.map(|defs| {
|
||||
defs.into_iter()
|
||||
.map(|d| serde_json::to_value(d).unwrap_or_default())
|
||||
@@ -362,14 +386,26 @@ impl LlmProvider for OpenAICompatProvider {
|
||||
|
||||
debug!(model = %openai_req.model, "OpenAI 流式调用");
|
||||
|
||||
let resp = self
|
||||
// BUG-2026-07-07: send 阶段需 timeout 防 hang(同 Anthropic 路径)。
|
||||
// 不能用 reqwest .timeout()(会砍流式 body),改用 tokio::time::timeout 包裹 send。
|
||||
let send_future = self
|
||||
.client
|
||||
.post(self.chat_url())
|
||||
.header("Authorization", format!("Bearer {}", self.api_key))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&openai_req)
|
||||
.send()
|
||||
.await?;
|
||||
.send();
|
||||
let resp = match tokio::time::timeout(Duration::from_secs(60), send_future).await {
|
||||
Ok(Ok(r)) => r,
|
||||
Ok(Err(e)) => {
|
||||
tracing::error!(error = %e, is_timeout = e.is_timeout(), "OpenAI 流式 send 失败");
|
||||
return Err(e.into());
|
||||
}
|
||||
Err(_elapsed) => {
|
||||
tracing::error!(url = %self.chat_url(), "OpenAI 流式 send 超时(60s 未返回响应头)");
|
||||
anyhow::bail!("流式请求超时(60秒未收到 HTTP 响应,可能服务不可达或被防火墙拦截)");
|
||||
}
|
||||
};
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
@@ -378,25 +414,30 @@ impl LlmProvider for OpenAICompatProvider {
|
||||
anyhow::bail!("LLM 流式 API 错误 {}: {}", status, body);
|
||||
}
|
||||
|
||||
// 累积流式 usage:开 include_usage 后,末段正常 chunk(finish_reason)及额外 usage-only chunk(choices=[])都带 usage。
|
||||
// usage 解析/累积逻辑抽到 apply_openai_sse 纯函数,便于单测;此处闭包只负责传 data 与传递 last_usage。
|
||||
// BUG-2026-07-17 根治: 原生 SSE 解析器替代 eventsource-stream 库。
|
||||
// eventsource-stream 在 Windows 上对 Deepseek 等响应报 "error decoding response body"
|
||||
// (严格 UTF-8 + SSE 协议校验,跨 chunk 字符/不完整事件均报错且不可恢复)。
|
||||
// 原生解析器:bytes 累积 + from_utf8_lossy 宽松处理 + \n\n 分隔,容错不中断流。
|
||||
let mut last_usage: Option<TokenUsage> = None;
|
||||
|
||||
let stream = resp
|
||||
.bytes_stream()
|
||||
.eventsource()
|
||||
.map(move |event| match event {
|
||||
Ok(event) => Ok(apply_openai_sse(&event.data, &mut last_usage)),
|
||||
let sse = crate::sse_parser::SseStream::new(resp.bytes_stream());
|
||||
let stream = sse.flat_map(move |result: Result<Vec<String>, String>| {
|
||||
let mut chunks: Vec<anyhow::Result<crate::provider::StreamChunk>> = Vec::new();
|
||||
match result {
|
||||
Ok(events) => {
|
||||
for data in events {
|
||||
let chunk = apply_openai_sse(&data, &mut last_usage);
|
||||
chunks.push(Ok(chunk));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// 保留 #[source] 因果链: anyhow!("...{}", e) 仅把 e 的 Display 塞进 message,
|
||||
// 丢掉 source(无法 downcast/遍历)。改用 Error::from(e).context(...):
|
||||
// Display 不变(仍为 "SSE 流错误: {e}"), 且 e 作为 .source() 可追溯。
|
||||
// 顺序: 先 format(e) 构造 context 文案, 再 Error::from(e) move e 进 source。
|
||||
let ctx = format!("SSE 流错误: {}", e);
|
||||
error!("{}", ctx);
|
||||
Err(anyhow::Error::from(e).context(ctx))
|
||||
chunks.push(Err(anyhow::anyhow!("{}", ctx)));
|
||||
}
|
||||
});
|
||||
}
|
||||
futures::stream::iter(chunks)
|
||||
});
|
||||
|
||||
Ok(Box::pin(stream))
|
||||
}
|
||||
|
||||
118
crates/df-ai/src/sse_parser.rs
Normal file
118
crates/df-ai/src/sse_parser.rs
Normal file
@@ -0,0 +1,118 @@
|
||||
//! 原生 SSE 流式解析器 — 替代 eventsource-stream 库
|
||||
//!
|
||||
//! BUG-2026-07-17 根治: eventsource-stream 0.2 在 Windows 上对 Deepseek 等 provider
|
||||
//! 的 SSE 响应解析时报 "Transport error: error decoding response body" 错误。
|
||||
//!
|
||||
//! 根因分析:
|
||||
//! eventsource-stream 内部对 bytes_stream 做严格的 UTF-8 + SSE 协议校验,遇到以下情况
|
||||
//! 即报错(且不可恢复):
|
||||
//! - 流中断时未完整接收 UTF-8 字符(网络抖动常见)
|
||||
//! - 缺少结束的 \n\n(连接断开常见)
|
||||
//! - 非 ASCII 字符的多字节序列跨 chunk 边界
|
||||
//!
|
||||
//! 本解析器实现:
|
||||
//! - 宽松的 UTF-8 处理(用 bytes 累积,String::from_utf8_lossy 转换,不报错)
|
||||
//! - SSE 协议简单解析(以 \n\n 分隔事件,data: 前缀提取)
|
||||
//! - 容错:解析失败时跳过该事件继续,不中断流
|
||||
//! - 返回 Vec<String>(每个元素是一个事件 data 字段拼接内容)
|
||||
|
||||
use futures::Stream;
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
/// SSE 事件流的 data 字段内容
|
||||
pub type SseEvent = String;
|
||||
|
||||
/// 原生 SSE 解析器流:包装 bytes_stream,产出 Vec<SseEvent>(一次 poll 可能产出多个事件)
|
||||
pub struct SseStream<S> {
|
||||
inner: S,
|
||||
buffer: Vec<u8>,
|
||||
}
|
||||
|
||||
impl<S> SseStream<S>
|
||||
where
|
||||
S: Stream<Item = Result<bytes::Bytes, reqwest::Error>> + Unpin,
|
||||
{
|
||||
pub fn new(inner: S) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
buffer: Vec::with_capacity(8192),
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 buffer 解析完整的 SSE 事件(以 \n\n 分隔),返回事件列表
|
||||
fn parse_events(&mut self) -> Vec<SseEvent> {
|
||||
let mut events = Vec::new();
|
||||
loop {
|
||||
let sep_pos = self.buffer.windows(2).position(|w| w == b"\n\n");
|
||||
if sep_pos.is_none() {
|
||||
break;
|
||||
}
|
||||
let sep_pos = sep_pos.unwrap();
|
||||
let event_bytes: Vec<u8> = self.buffer.drain(..sep_pos + 2).collect();
|
||||
// 去掉末尾的 \n\n
|
||||
let body_end = event_bytes.len().saturating_sub(2);
|
||||
let event_text = String::from_utf8_lossy(&event_bytes[..body_end]);
|
||||
let data = Self::extract_data_fields(&event_text);
|
||||
if !data.is_empty() {
|
||||
events.push(data);
|
||||
}
|
||||
}
|
||||
events
|
||||
}
|
||||
|
||||
/// 从 SSE 事件文本中提取所有 data: 行的内容,拼接为单个字符串(多个 data 行用 \n 连接)
|
||||
fn extract_data_fields(event_text: &str) -> String {
|
||||
let mut data_parts: Vec<&str> = Vec::new();
|
||||
for line in event_text.lines() {
|
||||
if let Some(rest) = line.strip_prefix("data:") {
|
||||
let rest = rest.strip_prefix(' ').unwrap_or(rest);
|
||||
data_parts.push(rest);
|
||||
}
|
||||
// 忽略 event:/id:/retry: 等其他 SSE 字段(OpenAI/Anthropic 协议未使用)
|
||||
}
|
||||
data_parts.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> Stream for SseStream<S>
|
||||
where
|
||||
S: Stream<Item = Result<bytes::Bytes, reqwest::Error>> + Unpin,
|
||||
{
|
||||
type Item = Result<Vec<SseEvent>, String>;
|
||||
|
||||
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||
use futures::StreamExt;
|
||||
loop {
|
||||
// 先尝试从 buffer 解析完整事件
|
||||
let events = self.parse_events();
|
||||
if !events.is_empty() {
|
||||
return Poll::Ready(Some(Ok(events)));
|
||||
}
|
||||
|
||||
// buffer 不足以解析出完整事件,从 inner 读更多数据
|
||||
match self.inner.poll_next_unpin(cx) {
|
||||
Poll::Ready(Some(Ok(chunk))) => {
|
||||
self.buffer.extend_from_slice(&chunk);
|
||||
continue;
|
||||
}
|
||||
Poll::Ready(Some(Err(e))) => {
|
||||
return Poll::Ready(Some(Err(format!("SSE 流读取错误: {}", e))));
|
||||
}
|
||||
Poll::Ready(None) => {
|
||||
// 流结束,处理 buffer 中的剩余数据(可能没有 \n\n 结束的最后一段)
|
||||
if !self.buffer.is_empty() {
|
||||
let remaining = String::from_utf8_lossy(&self.buffer).to_string();
|
||||
self.buffer.clear();
|
||||
let data = Self::extract_data_fields(&remaining);
|
||||
if !data.is_empty() {
|
||||
return Poll::Ready(Some(Ok(vec![data])));
|
||||
}
|
||||
}
|
||||
return Poll::Ready(None);
|
||||
}
|
||||
Poll::Pending => return Poll::Pending,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,9 +51,33 @@ impl EnvSnapshot {
|
||||
return snap;
|
||||
}
|
||||
// 首次探测:同步逻辑包到 spawn_blocking,避免阻塞 async runtime。
|
||||
let snap = tokio::task::spawn_blocking(|| EnvSnapshot::do_detect())
|
||||
.await
|
||||
.unwrap_or_else(|_| EnvSnapshot::fallback());
|
||||
// BUG-2026-07-18 根治: probe_version 内 std::process::Command::output() 无 timeout,
|
||||
// Windows 上 python/node 若是 Microsoft Store App Execution Alias(用户未装但开了
|
||||
// "应用执行别名"),`python --version` 触发 Store 重定向、process 不退出 → output()
|
||||
// 永久阻塞 → spawn_blocking 线程永不返回 → detect().await 永久挂 → run_agentic_loop
|
||||
// 卡在 EnvSnapshot::detect() 调用(agentic/mod.rs),后端日志断在"意图收敛工具"后,
|
||||
// 前端表现为「发消息完全无回应然后卡死」(trace.log 实测 3 次发送全卡于此)。
|
||||
// 修复: spawn_blocking 外包 5s timeout,超时返回 fallback 并 set 进 OnceLock(后续命中
|
||||
// 缓存不再 probe),run_agentic_loop 最多等 5s 后继续,不再永久卡。
|
||||
// 注: 超时后 spawn_blocking 线程仍在跑(卡在 output),但已脱离 await,不阻塞调用方;
|
||||
// 线程最终随 process 退出或进程结束回收,无泄漏累积(OnceLock 已 set,不会重复 probe)。
|
||||
let snap = match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
tokio::task::spawn_blocking(|| EnvSnapshot::do_detect()),
|
||||
).await {
|
||||
Ok(Ok(s)) => s,
|
||||
Ok(Err(join_err)) => {
|
||||
eprintln!("[env_snapshot] 探测任务异常,回退默认环境: {}", join_err);
|
||||
EnvSnapshot::fallback()
|
||||
}
|
||||
Err(_elapsed) => {
|
||||
eprintln!(
|
||||
"[env_snapshot] 探测超时(5s),某 tool(python/node/rustc/go/docker/git)命令卡住 \
|
||||
(疑似 Windows Store App Execution Alias 触发 Store 重定向),回退默认环境"
|
||||
);
|
||||
EnvSnapshot::fallback()
|
||||
}
|
||||
};
|
||||
// 多任务竞态:均等价,以先到者为准。
|
||||
let _ = SNAPSHOT.set(snap);
|
||||
SNAPSHOT.get().expect("EnvSnapshot 已初始化")
|
||||
|
||||
@@ -101,6 +101,18 @@ pub fn get_provider_secret(id: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`get_provider_secret`] 的 async 版本:`spawn_blocking` 隔离同步 keyring 调用。
|
||||
///
|
||||
/// keyring 在 Linux secret-service D-Bus / Windows COM / macOS Keychain 同步阻塞,直接在
|
||||
/// async 上下文调用会卡 tokio runtime(Tauri 单线程 runtime 尤其敏感)。async 路径优先用此版本;
|
||||
/// 同步版保留供 block_on / 非 async 路径(迁移、测试)使用。
|
||||
pub async fn get_provider_secret_async(id: String) -> Option<String> {
|
||||
tokio::task::spawn_blocking(move || get_provider_secret(&id))
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// 消费点用:解析 provider 真实密钥 — DB 优先,fallback keyring(兼容未迁移老库)
|
||||
pub fn resolve_provider_secret(record: &AiProviderRecord) -> String {
|
||||
if !record.api_key.is_empty() {
|
||||
@@ -109,18 +121,43 @@ pub fn resolve_provider_secret(record: &AiProviderRecord) -> String {
|
||||
get_provider_secret(&record.id).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// [`resolve_provider_secret`] 的 async 版本 — DB 有明文时同步返(不触 keyring),
|
||||
/// 否则 `spawn_blocking` 调 keyring 防 D-Bus / COM 阻塞 tokio runtime。
|
||||
///
|
||||
/// 注:DB 明文路径直接 clone 同步返,只有 fallback keyring 才走 spawn_blocking。
|
||||
pub async fn resolve_provider_secret_async(record: AiProviderRecord) -> String {
|
||||
if !record.api_key.is_empty() {
|
||||
return record.api_key;
|
||||
}
|
||||
get_provider_secret_async(record.id).await.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 写入密钥到 keyring(覆盖)
|
||||
pub fn set_provider_secret(id: &str, key: &str) -> anyhow::Result<()> {
|
||||
let entry = entry_for(id)?;
|
||||
entry.set_password(key).map_err(|e| anyhow::anyhow!("keyring 写入失败(provider={}): {}", id, e))
|
||||
}
|
||||
|
||||
/// [`set_provider_secret`] 的 async 版本(`spawn_blocking` 隔离同步 keyring 调用)。
|
||||
pub async fn set_provider_secret_async(id: String, key: String) -> anyhow::Result<()> {
|
||||
tokio::task::spawn_blocking(move || set_provider_secret(&id, &key))
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("set_provider_secret join 失败: {}", e))?
|
||||
}
|
||||
|
||||
/// 删除 keyring 密钥(provider 删除时清理)
|
||||
pub fn delete_provider_secret(id: &str) -> anyhow::Result<()> {
|
||||
let entry = entry_for(id)?;
|
||||
entry.delete_credential().map_err(|e| anyhow::anyhow!("keyring 删除失败(provider={}): {}", id, e))
|
||||
}
|
||||
|
||||
/// [`delete_provider_secret`] 的 async 版本(`spawn_blocking` 隔离同步 keyring 调用)。
|
||||
pub async fn delete_provider_secret_async(id: String) -> anyhow::Result<()> {
|
||||
tokio::task::spawn_blocking(move || delete_provider_secret(&id))
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("delete_provider_secret join 失败: {}", e))?
|
||||
}
|
||||
|
||||
/// 启动一次性迁移:DB 明文 → keyring → DB 置空(失败保留明文下次重试,非阻断)
|
||||
pub async fn migrate_secrets_to_keyring(repo: &AiProviderRepo) -> anyhow::Result<usize> {
|
||||
let providers = repo.list_all().await?;
|
||||
|
||||
@@ -94,6 +94,13 @@ pub trait TunnelClient: Send + Sync {
|
||||
/// 此窗口内若收到 error 帧或连接 Close,判定握手失败)
|
||||
const HANDSHAKE_PROBE: Duration = Duration::from_secs(3);
|
||||
|
||||
/// TCP/WS 建立连接(connect_async)超时上限。
|
||||
///
|
||||
/// 对齐 df-ai http.rs connect_timeout(15s):relay URL 不可达(TCP SYN 无响应)时
|
||||
/// connect_async 会挂 60-120s 才返系统 timeout,Tauri 单线程 tokio runtime 卡死。
|
||||
/// 包 `tokio::time::timeout` 在此窗口内未完成 → 返 TunnelError::Connect 显式错误。
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
/// 心跳间隔(应用层 Ping,补协议层 keepalive,防 NAT 连接表超时回收)
|
||||
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(25);
|
||||
|
||||
@@ -177,10 +184,18 @@ impl TunnelClient for WsTunnelClient {
|
||||
self.cleanup_conn().await;
|
||||
}
|
||||
|
||||
// 1. 建立 WS 连接
|
||||
let (ws_stream, _resp) = tokio_tungstenite::connect_async(url)
|
||||
.await
|
||||
.map_err(|e| TunnelError::Connect(format!("WS 连接失败 {url}: {e}")))?;
|
||||
// 1. 建立 WS 连接(包 CONNECT_TIMEOUT 防 relay 不可达时永久挂 — TCP SYN 无响应
|
||||
// 系统 timeout 60-120s 才返,Tauri 单线程 tokio runtime 会卡死。对齐 df-ai http.rs
|
||||
// connect_timeout(15s)。保留原错误映射语义:TunnelError::Connect(format!("WS 连接失败 ..."))
|
||||
let connect_fut = tokio_tungstenite::connect_async(url);
|
||||
let (ws_stream, _resp) = match tokio::time::timeout(CONNECT_TIMEOUT, connect_fut).await {
|
||||
Ok(res) => res.map_err(|e| TunnelError::Connect(format!("WS 连接失败 {url}: {e}")))?,
|
||||
Err(_) => {
|
||||
return Err(TunnelError::Connect(format!(
|
||||
"WS 连接超时(>{CONNECT_TIMEOUT:?} 无响应){url}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
tracing::info!(%url, "WS 连接已建立,开始 Hello 握手");
|
||||
|
||||
let (mut sink, mut stream) = ws_stream.split();
|
||||
|
||||
10
dev-server.bat
Normal file
10
dev-server.bat
Normal file
@@ -0,0 +1,10 @@
|
||||
@echo off
|
||||
cd /d E:\wk-lab\devflow
|
||||
echo [dev] 启动 DevFlow 开发服务器...
|
||||
echo [dev] Vite + Tauri 后端将同时启动
|
||||
echo [dev] 请勿关闭此窗口
|
||||
echo.
|
||||
C:\Users\23780\.bun\bin\bun.exe run tauri dev
|
||||
echo.
|
||||
echo [dev] 服务器已停止
|
||||
pause
|
||||
192
docs/05-代码审查/aichat-会话实测分析-2026-07-07.md
Normal file
192
docs/05-代码审查/aichat-会话实测分析-2026-07-07.md
Normal file
@@ -0,0 +1,192 @@
|
||||
# AI Chat 真实会话实测分析(系统消息污染 + 死循环重调)
|
||||
|
||||
> 日期:2026-07-07 | 方法:从生产 DB(`%APPDATA%/top.1216.devflow/devflow.db`)实测最近两个会话全量消息反推,Python 只读统计
|
||||
> 基线会话:`9357c27c`(HaoGamePlatfPro,prompt 644,586 / completion 3,865)+ `43b75e49`(wk-hszd,prompt 601,028 / completion 4,134)
|
||||
> 产物:**2 P0 / 3 P1 / 2 P2**
|
||||
> 性质:**实测数据反推**(非静态代码审查)。finding 的源码根因为"假设",须独立 grep/read 核验后再定论
|
||||
> 关联:[[aichat-session-analysis-2026-06-22]] [[aichat-techdebt-audit-2026-06-21]] [[devflow-generating-statemachine]] [[devflow-compress-ui-no-fold]]
|
||||
|
||||
---
|
||||
|
||||
## 会话概况
|
||||
|
||||
| 项 | 9357c27c | 43b75e49 |
|
||||
|---|---|---|
|
||||
| 标题 | HaoGamePlatfPro | 查看 E 盘 wkhszd 目录内容 |
|
||||
| 跨度 | 2026-06-30 14:31 ~ 15:04(~33 分钟) | 2026-07-01 ~ 07-03(中间大间隔) |
|
||||
| 消息数 | 97 | 77 |
|
||||
| prompt / completion tokens | 644,586 / 3,865 | 601,028 / 4,134 |
|
||||
| completion/prompt 比 | **0.60%** | **0.69%** |
|
||||
| 工具调用次数 | list_directory 22 / read_file 22 / search_files 13 / list_projects 2 / update_project 1 / get_task_count 1 | list_directory 16 / read_file 17 / list_projects 2 / list_project_modules 3 / bind_directory 3 / create_project 1 / update_project 3 |
|
||||
| 工具结果总量 | 195.4 KB | 62.3 KB |
|
||||
| 用户输入字数 | **34 字**(2 条) | 51 字(5 条) |
|
||||
| 用户催促 | 0 | 1(核对) |
|
||||
| 末尾收尾 | assistant(seq96,正常) | assistant(seq76,正常) |
|
||||
| task | 浏览 HaoGamePlatfProject 代码补充项目信息 | 把 wk-hszd 注册为 DevFlow 多工程项目 |
|
||||
|
||||
两会话共同特征:**极少用户输入(34~51 字)产生 60 万级 prompt**,completion/prompt 比 < 1%,极端输入重。
|
||||
|
||||
---
|
||||
|
||||
## P0(2 项)
|
||||
|
||||
### [P0-1] seq0 system 消息污染:assistant 思考流被截断重复拼接 41 次 + role 错位 + 时序倒挂
|
||||
|
||||
- **现象**(9357c27c seq0):
|
||||
- `role=system`,但内容是 assistant 语义:`"我已经充分了解了项目结构。让我快速查看几个关键文件..."`
|
||||
- 同一短语 `"现在更新项目信息:基于代码分析,这是一个大型综合游戏平台后端。让我更新项目描述和技术栈。"` **重复 41 次**,拼成 1879 字符
|
||||
- seq0 timestamp `1782971672713` 比 seq1 `1782971504351` **晚 168 秒**(时序倒挂)
|
||||
- **db 证据**:`ai_messages` seq0 row,role=system,status=active,content 长 1879
|
||||
- **根因假设**(强):系统 prompt 注入路径把"上一轮 assistant 流式片段"误当 system 上下文写入;且写入库时陷入重复拼接循环(疑似字符串 builder 在流式 chunk 累积时无去重/无终止条件)。这是 [[aichat-session-analysis-2026-06-22]] P2-1 的**复发且恶化**——上次只见 1 条 assistant 语义残留,本次是 41 次死循环拼接。
|
||||
- **影响**:
|
||||
1. system 消息会作为 prompt 头部送入 LLM,1879 字符无意义噪声污染每一轮上下文
|
||||
2. role 错位会误导模型(system 应是约束指令,却变成"我说的"内容)
|
||||
3. 时序倒挂破坏压缩/排序/计费
|
||||
- **待核验源码点**:
|
||||
- system prompt 注入路径(`ai_messages` 写入 system 行的位置,`src-tauri/src/commands/ai/`)
|
||||
- 流式 chunk 累积逻辑(查 `for` / `push_str` / `+=` 类字符串拼接,是否有循环退出条件)
|
||||
- timestamp 赋值逻辑(为何 system 行用了"插入时刻"而非"会话起始时刻")
|
||||
- **关联**:印证 [[aichat-techdebt-audit-2026-06-21]] P2 区"system 消息混入 assistant 内容"项;与 [[aichat-session-analysis-2026-06-22]] P2-1 同源(前次未根治)
|
||||
|
||||
### [P0-2] 工具死循环重调:LLM 无"已调用过"记忆,同参同路径重复触发
|
||||
|
||||
- **现象**(9357c27c):
|
||||
- `read_file(application.xml)` **读了 17 次**,其中两次返回完全相同的 8457c 全文(同一文件无变化)
|
||||
- `search_files(pattern='.java', path=...src)` **连调 13 次**,每次返回结构几乎一致(items=0, has_more=True,5.5~5.8KB),LLM 完全没意识到在重复
|
||||
- `list_directory(...src)` 2 次,第 2 次返回 30,516c(第 1 次 6,144c,深度参数不一致但 LLM 未说明理由)
|
||||
- **db 证据**:13 条 search_files 调用 `arguments` 完全相同,`result` 长度方差 < 5%
|
||||
- **根因假设**:
|
||||
1. **无工具结果缓存层**:会话级无"同参工具调用 → 返回缓存命中提示"机制,LLM 每次发起都真执行
|
||||
2. **prompt 未告知 LLM 已读内容**:read_file 返回的文件指纹/路径未在后续 prompt 中显式标注"已读",LLM 失忆反复读
|
||||
3. **search_files has_more=True 的分页语义被 LLM 误解**:可能 LLM 以为重调能翻页,但实际每次返第一页
|
||||
- **影响**:
|
||||
- **prompt 64 万的直接元凶之一**:13 次 search × ~5.7KB + 17 次 read × 平均 2.7KB ≈ **75KB 仅重复工具结果**(占 prompt 字符量的主要部分)
|
||||
- 死循环消耗 30+ 轮 LLM 调用,用户等待感极差
|
||||
- **待核验源码点**:
|
||||
- 工具调度层(`tool_registry.rs` / `agentic/mod.rs`)——是否有 `seen_tool_calls` 去重
|
||||
- read_file 是否返回 file_hash 供 LLM 引用(对照 [[aichat-techdebt-audit-2026-06-21]] P1「expected_hash schema 契约破裂」)
|
||||
- search_files 分页契约(为何 has_more=True 但无 page/cursor 参数)
|
||||
- **关联**:印证 [[aichat-session-analysis-2026-06-22]] P2-2(同文件反复 read)+ 新增 search_files 死循环变体
|
||||
|
||||
---
|
||||
|
||||
## P1(3 项)
|
||||
|
||||
### [P1-1] ~~压缩实质失效:compressed 消息平均长度比 active 还长(205%)~~ 【误报,2026-07-07 核验撤销】
|
||||
|
||||
- **原现象**(9357c27c):58/97 条 status=compressed,但 compressed 平均 2641c/条,active 平均 1287c/条——压缩后比未压缩还长 2 倍。
|
||||
- **核验撤销**:进一步按 role 分组统计发现,compressed tool 消息长(4608c)是因为会话前期是大工具调用(list_directory/read_file 大输出),active tool 消息短(1714c)是因为后期转为小操作(update_project)。这是会话业务节奏的自然结果,不是压缩机制问题。
|
||||
- **prompt_tokens 语义澄清**:`prompt_tokens=644,586` 是**会话级累积值**(每轮 prompt 累加,见 `agentic/mod.rs:1366 tokens.add()`),不是最后一轮值。97 条消息 × 平均 6645 tokens/轮 ≈ 644K,完全合理。每轮实际 prompt 只 ~6.6K(active content 49KB ≈ 13K token,压缩后只进 active)。
|
||||
- **结论**:06-22 报告 P0-1 修复(is_active 白名单让 compressed 不进 prompt)**生效**。本项撤销,无需修复。
|
||||
|
||||
### [P1-2] LLM 自检缺失 + 宣称完成即停,用户被迫人工纠错
|
||||
|
||||
- **现象**(43b75e49 wk-hszd):
|
||||
- seq71 assistant 自检发现 bug:`path` 被最后一次 `bind_directory` 覆盖成 `hszd-test`,`stack` 变成 `["Go"]`
|
||||
- 但这是 assistant **自己**在 seq74"再次核对"时才发现——说明 seq68 用户催"核对"前,assistant 已认为完成
|
||||
- 3 次 `bind_directory` 把 path 从 `hszd-api` → `hszd-admin` → `hszd-test`,每次都覆盖前值,assistant 没意识到多工程 monorepo 的 path 字段不该是单子工程
|
||||
- **db 证据**:bind_directory 3 次调用 args 显示 path 被依次覆盖,最终 assistant 自己回滚用 `update_project(path='E:/wk-hszd')` 修正
|
||||
- **根因假设**:
|
||||
1. agent 无"宣称完成前的强制自检"(对照 [[aichat-session-analysis-2026-06-22]] P1-1)
|
||||
2. `bind_directory` 的 `path` 字段语义对多工程 monorepo 有歧义(LLM 以为是"当前操作的子工程",实际是"项目根")
|
||||
- **影响**:用户对 agent 完成度零信任,需人工核对每个字段
|
||||
- **待核验/落点**:
|
||||
- agent 行为策略(prompt 加自检 checklist)
|
||||
- `bind_directory` schema 注释补"monorepo 应用项目根路径,非子工程"
|
||||
- **关联**:[[aichat-decision-capability]] [[aichat-b-route-parallel-multiround]]
|
||||
|
||||
### [P1-3] 工具拒绝消息破坏 JSON 协议:raw 文本"用户拒绝了此操作"
|
||||
|
||||
- **现象**(9357c27c seq95):`update_project` 被用户拒绝,tool role 消息 content = `'用户拒绝了此操作'`(8 字符裸文本),非 JSON
|
||||
- **db 证据**:61 条 tool 消息中 60 条 JSON-parseable,1 条 raw-text(就是这条拒绝)
|
||||
- **根因假设**:工具拒绝路径(`ai_reject` / 审批拒绝)走的是错误字符串直接返回,未包装成 `{"status":"rejected","reason":"..."}` 结构
|
||||
- **影响**:
|
||||
- 若 LLM provider 严格解析 tool role 内容(部分 provider 要求 JSON),会触发协议错误
|
||||
- 与 [[aichat-techdebt-audit-2026-06-21]] P1「审批状态字符串双轨(executed/completed)」同家族——工具返回协议不统一
|
||||
- **待核验源码点**:审批拒绝 handler(`commands.rs` 拒绝分支)的返回序列化
|
||||
|
||||
---
|
||||
|
||||
## P2(2 项)
|
||||
|
||||
### [P2-1] system 消息 timestamp 用"插入时刻"而非"会话时刻",时序倒挂
|
||||
|
||||
- **现象**(9357c27c):seq0 system ts=1782971672713,seq1 user ts=1782971504351,seq0 比 seq1 晚 168 秒
|
||||
- **根因假设**:system prompt 注入是在会话进行中"补写"的(可能是压缩/续轮时插入),用了补写时刻
|
||||
- **影响**:排序、压缩窗口计算、计费全受污染
|
||||
- **关联**:与 P0-1 同源(都是 seq0 system 行的写入 bug)
|
||||
|
||||
### [P2-2] bind_directory 多工程语义错配:单值 path 字段无法表达 monorepo
|
||||
|
||||
- **现象**(43b75e49):wk-hszd 是 Go+Vue+Test 三子工程 monorepo,assistant 3 次 bind_directory 分别绑三个子工程路径,前两个被第三个覆盖
|
||||
- **根因假设**:`projects.path` 是单值字段,而 DevFlow 的"多工程"概念(`project_modules` 表)与"项目根 path"语义未在 schema 注释中澄清
|
||||
- **影响**:多工程项目的 path 字段长期处于"最后绑的子工程"错误状态,直到 LLM 自检或人工纠正
|
||||
- **待核验/落点**:`bind_directory` IPC 的 schema 文档 + 多工程 monorepo 的 path 字段规范(可能应允许 null 或 readonly,工程信息走 `project_modules`)
|
||||
|
||||
---
|
||||
|
||||
## 落点建议(未实施,遵守 [[session-role-diagnose-only]])
|
||||
|
||||
| 项 | 优先级 | 落点 |
|
||||
|---|---|---|
|
||||
| P0-1 system 消息污染 + 重复拼接 | P0 | system prompt 注入路径 + 流式 chunk 累积循环(查 push_str/+= 类拼接) |
|
||||
| P0-2 工具死循环重调 | P0 | 工具结果缓存层(会话级 seen_tool_calls)+ read_file 返回 file_hash + search_files 分页契约 |
|
||||
| P1-1 压缩产出反向膨胀 | P0/P1 | compress_old_messages 产出从拼接改真摘要 |
|
||||
| P1-2 自检 + bind_directory 语义 | P1 | prompt 自检 checklist + bind_directory schema 注释 |
|
||||
| P1-3 拒绝消息 JSON 协议 | P1 | 审批拒绝 handler 返回结构化 JSON |
|
||||
| P2-1 timestamp 时序 | P2 | system 行 timestamp 用会话起始时刻 |
|
||||
| P2-2 多工程 path 语义 | P2 | bind_directory/path schema 文档 |
|
||||
|
||||
**最高杠杆**:P0-1(系统消息污染直接污染每一轮 prompt)+ P0-2(工具死循环是 prompt 64 万的主因)。两者叠加,即使只修这两项,类似会话的 prompt 可降一个量级。
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-07 修复落地(本次实施)
|
||||
|
||||
| 项 | 位置 | 修复 |
|
||||
|---|---|---|
|
||||
| P0-1 LLM 退化输出污染 system 消息 | `commands/ai/compress.rs` | `clean_summary` 加 `is_degenerated_repetition` 退化检测,同一片段重复 ≥5 次返空 → 调用方据空值降级走关键词兑底 |
|
||||
| P0-2 工具死循环重调 | `commands/ai/audit/cache.rs` + `audit/mod.rs` | 新增 `find_cached_readonly_result`(只读幂等工具白名单:read_file/read_symbol/list_directory/search_files/grep),Low 风险执行前查会话历史,同参同成功(completed)调用直接回填缓存跳过真执行 |
|
||||
| P1-3 拒绝消息破 JSON 协议 | `commands/ai/commands/chat.rs` + `audit/mod.rs` | 三处拒绝消息(用户拒绝此操作/路径授权拒绝/路径黑名单拒绝)改结构化 JSON `{status,reason,message}` |
|
||||
| **额外**:Windows 上 .ssh/.aws 黑名单绕过 | `state/allowed_dirs.rs` | `check_path_authorization` 黑名单检查提前到路径规范化之前(对原始路径也判一次)。根因:Windows 上 `/home/user/.ssh/config` 的 `is_absolute()` 返 false,persistent 为空时旧逻辑直接返 NeedsAuthorization 跳过黑名单 |
|
||||
| P1-2 bind_directory 多工程语义错配 | `commands/ai/tool_registry.rs` | 工具描述补充 monorepo 语义:path 应为项目根路径(非子工程),多次调用会覆盖,子工程信息走 list_project_modules |
|
||||
| ~~P1-1 压缩产出反向膨胀~~ | — | **误报撤销**:prompt_tokens 是会话级累积值(非单轮值),按 role 分组统计后压缩机制正常 |
|
||||
|
||||
**测试**:`cargo test -p devflow --lib` 通过(compress 11 + audit 19 + allowed_dirs 20,含新增 6 个回归测试)。原 `test_grep_blacklisted_path_denied`(此前失败)现已通过。
|
||||
|
||||
---
|
||||
|
||||
## 复现方法
|
||||
|
||||
```bash
|
||||
# 只读查询生产 DB
|
||||
python -c "
|
||||
import sqlite3, os
|
||||
db = os.path.expandvars(r'%APPDATA%/top.1216.devflow/devflow.db')
|
||||
con = sqlite3.connect(f'file:{db}?mode=ro', uri=True)
|
||||
# seq0 system 污染证据
|
||||
print(con.execute('select seq, role, length(content), timestamp from ai_messages where conversation_id=? and seq<2 order by seq',
|
||||
('9357c27c-aeba-488f-9f4f-12c2d0506f63',)).fetchall())
|
||||
# search_files 13 次重复
|
||||
print(con.execute('select count(*), min(length(result)), max(length(result)) from ai_tool_executions where conversation_id=? and tool_name=?',
|
||||
('9357c27c-aeba-488f-9f4f-12c2d0506f63','search_files')).fetchall())
|
||||
"
|
||||
# 表:ai_conversations / ai_messages / ai_tool_executions
|
||||
```
|
||||
|
||||
数据快照:2026-07-07 抓取自 `devflow.db`(生产库,非 dev)。
|
||||
|
||||
---
|
||||
|
||||
## 与 06-22 报告的对比
|
||||
|
||||
| 项 | 06-22 报告 | 07-07 实测 | 趋势 |
|
||||
|---|---|---|---|
|
||||
| 工具返回裸文本(raw) | patch_file/read_file 大量 raw(6204c) | 仅 1 条(拒绝消息) | ✅ **已修复**(P0-2 性质修正有效) |
|
||||
| 重复 tool 结果落库 | seq 11/12/13/14 同结果 4 份 | 0 条 tool_call_id 重复 | ✅ **已修复**(P0-3 撤销成立) |
|
||||
| system 消息混 assistant 语义 | seq0 残留 1 句 | seq0 重复 41 次拼接 | ❌ **恶化**(P2-1 → P0-1) |
|
||||
| 同文件反复 read | mysql_guide read 4× | application.xml read 17× | ❌ **恶化**(P2-2 → P0-2) |
|
||||
| 上下文管理实质失效 | compressed 未降 prompt(已隐式修复) | compressed 产出反向膨胀 205% | ⚠️ **新变体**(P0-1 → P1-1) |
|
||||
| 中断(tool 后无 assistant) | seq85 停尸 | 无(两会话均 assistant 收尾) | ✅ **已修复** |
|
||||
|
||||
**结论**:06-22 后修复的"工具返回协议/落库去重/中断"三项**确实生效**;但"system 污染"和"工具死循环"两类问题**反而恶化**——前者从残留 1 句变成 41 次拼接,后者从 4× 变成 17×。说明这两类是未根治的根因,须单独立项。
|
||||
144
docs/05-代码审查/aichat-对话功能走查-2026-07-17.md
Normal file
144
docs/05-代码审查/aichat-对话功能走查-2026-07-17.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# aichat 对话功能深度走查报告(2026-07-17)
|
||||
|
||||
> 触发:用户反馈「对话功能现在存在很多问题,深入检查」。
|
||||
> 方法:5 个 general-purpose 代理并行走查 5 维度(发送/流式/渲染/会话侧栏顶栏/审批上下文)→ 主代理独立 grep/read 核验所有 P0 + 关键 P1(不信代理结论,每条标注核验状态)。
|
||||
> 性质:**走查任务,未改任何代码**。本报告为问题清单 + 修复方向,待用户确认后实施。
|
||||
|
||||
## 健康度总评:B-
|
||||
|
||||
最近一批发改动(BUG-2026-07-17:状态机收敛方向正确)引入 **2 个 P0 回归**;其余 P1 多源自 **F-09 per-conv 改造未完成**的单例债(queue / legacy watchdog / _approvalTimers / modelOverride / _lastDelta 仍是全局单例,多会话并发下串话)。
|
||||
|
||||
---
|
||||
|
||||
## P0(功能 bug,建议立即修)
|
||||
|
||||
### P0-1 流式 watchdog legacy 路径全局误杀并发会话 + 删 running 保护致长工具误杀 ✅核验成立
|
||||
|
||||
**位置**:`useAiStream.ts:57-110, 73-78` / `useAiSend.ts:156,226`(doSend reset 同款)/ `audit/mod.rs:125-128`
|
||||
|
||||
**双重回归**(本次改动引入):
|
||||
|
||||
① **legacy 路径全局 clear**:doSend / regenerate(`useAiSend.ts:156`) / editMessage(`:226`) 调 `resetStreamWatchdog()` **无 convId** → 走 `_legacyWatchdog` fallback(`useAiStream.ts:128-132`)。45s 到期 `onStreamTimeout()` 无参 → `convStates.clear()`(`:77`)清掉**所有并发会话**的生成态。其他会话停止按钮失效、UI 与后端长期不一致。
|
||||
|
||||
② **删 running 保护**:`onStreamTimeout`(`:69`「不管 stillGenerating 检测」)去掉了原 stillGenerating + running 工具检测。长工具执行(cargo build / 测试套件 / 大文件 patch >45s)必然触发。后端心跳首 tick **30s**(`audit/mod.rs:126` 弃首 tick),AiHeartbeat 到达时前端 `resetStreamWatchdog(convId)` 只动 per-conv Map、**不清 legacy**,保护不了 doSend 挂的 legacy timer。
|
||||
|
||||
**证据**:`useAiStream.ts:69`「不管 stillGenerating 检测(即使已经 false 也再清理一次)」+ `:77` `convStates.clear()` + `audit/mod.rs:125-128` 心跳 30s。
|
||||
|
||||
**影响**:多会话并发下其他会话被误杀 + 任何 >45s 的工具执行被前端误判断流(回到 BUG-260624-03 修复前的用户报障状态)。
|
||||
|
||||
**修复方向**:
|
||||
- legacy 路径 `convStates.clear()` → `convStates.delete(state.activeConversationId)`(对齐 legacy 单 timer 只跟踪当前会话语义)
|
||||
- 恢复 `onStreamTimeout` 的 running 工具检测(工具执行中跳过 + reset 续等),或 `STREAM_TIMEOUT_MS` 回调 ≥75s
|
||||
- 根治:doSend / regenerate / editMessage 的 `resetStreamWatchdog` 传 convId 走 per-conv(F-09 迁移)
|
||||
|
||||
### P0-2 审批计时器切/删会话不清 + 注释与代码矛盾 ✅核验成立
|
||||
|
||||
**位置**:`useAiConversations.ts:92-257`(switch 无 clearApprovalTimer)/ `:260-272`(delete 无清)/ `aiShared.ts:118`(默认 15min)vs `useAiConversations.ts:244-246`(注释「Infinity 已决策不超时」)
|
||||
|
||||
**证据**:
|
||||
- switchConversation 全程未调 clearApprovalTimer / clearAllApprovalTimers(已核 90-257)
|
||||
- deleteConversation 仅 `clearConvStreamState(id)`,不清 `_approvalTimers` 中属于该会话的 timer
|
||||
- `startApprovalTimer` 到点回调(`aiShared.ts:147-163`)调 `aiApi.approve(id,false)` + push 错误气泡到 `getMessages()`(**当前 active 视图**)
|
||||
- `:244-246` 注释「APPROVAL_TIMEOUT_MS=Infinity 已决策,不会超时自动拒绝」与 `:118` `APPROVAL_TIMEOUT_DEFAULT_MS=900_000`(15min)**直接矛盾**
|
||||
|
||||
**影响**:① 切走会话后 15min 到点 → 误拒后台 pending 工具 + 错误气泡 push 到当前(错误)会话视图;② 用户重启恢复审批后离开,回来发现审批已被无声自动拒绝。
|
||||
|
||||
**修复方向**:switchConversation 开头 `clearAllApprovalTimers()`(对齐 pendingApprovals 单例语义);deleteConversation 清该 conv 的 timer;修正 `:244` 注释。
|
||||
|
||||
---
|
||||
|
||||
## P1(明显缺陷)
|
||||
|
||||
### P1-1 [发送] queue=[] 跨会话丢消息 ✅核验成立
|
||||
`useAiSend.ts:155,225` `state.queue = []`,queue 是 store 全局单例。B 会话 regenerate/editMessage 清掉 A 排队的用户消息,静默永久丢失。
|
||||
|
||||
### P1-2 [发送] drainQueue 无并发互斥 ✅核验成立
|
||||
`useAiSend.ts:257-281` fire-and-forget 无锁,AiCompleted 连发 / stop+Completed 竞态致并发 doSend,前端 user/ai 占位双 push(后端 generating 守卫挡大部分但视图已污染)。
|
||||
|
||||
### P1-3 [会话] TopBar watch(activeConversationId) 无条件重置 modelOverride ✅核验成立
|
||||
`TopBar.vue:325-327` 切会话无条件 `modelOverride = enabledModels[0]`,切会话往返丢失用户手选模型;与 `:335` `watch(enabledModels,{immediate})` 叠加语义脆弱。modelOverride 是模块级 ref,不持久化、不 per-conv。
|
||||
|
||||
### P1-4 [发送] tryForceSend 跨会话错位 + 队列续发 spans 丢失 ✅核验成立
|
||||
`useAiSend.ts:323-328` queue item 无 convId / spans 字段,切会话后 forceSend 发到当前 active 而非原会话;mention chip 续发降级为纯文本。
|
||||
|
||||
### P1-5 [流式] _lastDelta 单值跨会话/跨轮误丢合法 delta ⚠️部分成立
|
||||
`useAiEvents.ts:46,292` 单值重复检测,跨会话首 delta 撞同 / 同轮合法重复(Markdown 表格分隔符等)被误丢。难复现。
|
||||
|
||||
### P1-6 [流式] state.currentText += delta 无 streaming 守卫 ⚠️部分成立
|
||||
`useAiEvents.ts:300`,P0-1 触发后超时收尾仍到达的 delta 污染 currentText。修 P0-1 即间接修复。
|
||||
|
||||
### P1-7 [渲染] MessageItem.vue system/assistant/error 死代码 ✅核验成立
|
||||
`MessageItem.vue:94-170`,MessageList 仅 `role==='user'` 引用 MessageItem(`:663-673`),AI 渲染全在 MessageList 内联。MessageItem 内 `.ai-cursor-blink` 是孤岛 class(全仓库无其他定义)。维护陷阱。
|
||||
|
||||
### P1-8 [渲染] _blockCache 无失效机制 ✅核验成立
|
||||
`useStreamRenderer.ts:117` 模块级 Map 无会话切换 / 配置变更失效,长会话膨胀 + Markdown 主题热改无效 + 瞬态错误固化。
|
||||
|
||||
### P1-9 [渲染] messages deep watch + JSON.stringify 每帧全量遍历 ✅核验成立
|
||||
`MessageList.vue:244` deep watch 每帧 O(N×content) 遍历,长会话流式掉帧。
|
||||
|
||||
### P1-10 [渲染] useToolApproval onApprove confirmDialog 无防重入 ✅核验成立
|
||||
`useToolApproval.ts:98-113` High 风险工具 confirmDialog await 期间 approving 未置 true,双击重复审批。
|
||||
|
||||
### P1-11 [会话] 删当前会话 active=null 无 fallback ✅核验成立
|
||||
`useAiConversations.ts:260-272` 删除当前会话后空白无引导,应回落相邻会话。
|
||||
|
||||
### P1-12 [审批] 审批卡无 conversationId,多会话路由靠 activeConversationId 推断 ⚠️部分成立
|
||||
`useAiApproval.ts:65`,F-09 多会话并发下 A 后台审批卡渲染到 B 视图时点击路由不准(依赖多会话并发 + 后台审批叠加才触发)。
|
||||
|
||||
---
|
||||
|
||||
## P2(健壮性/可读性)
|
||||
|
||||
- `useStreamRenderer.ts:139` tailSeq 局部失效 —— **功能没坏**(v-html 值变化兜底更新),仅注释「末块 key 每次不同」与实现(恒为 `tail-1`)相反,误导维护者。
|
||||
- `ApprovalOverlay.vue:61` `Date.now()` 在 computed 内,60s 冷却期不响应式过期。
|
||||
- `aiShared.ts:288-311` setConvStreaming / setConvCurrentText 收敛不对称,Map 项可能残留。
|
||||
- `ConversationSidebar.vue:255` 标题 flash watch 死代码(`if(oldTitle) return` 覆盖前一行)。
|
||||
- `useAiWindow.ts:309` maximized 分支 `targetHeight` 三元死代码(高度 vs 宽度比较)。
|
||||
- `useToolApproval.ts:29` `emit: any` 类型擦除。
|
||||
- `useAiContext.ts:29` `isCompressing` 模块单例,多窗口共享。
|
||||
|
||||
---
|
||||
|
||||
## ❌代理误报已否决(独立核验)
|
||||
|
||||
- **~~[会话 P0] switchConversation currentText accessor 重定向~~**:代理4 称 `:206 state.currentText=''` 在两次 await 间被 accessor 重定向。**否决**——`:113` 设 `activeConversationId=B` 后到 `:206` 是同步连续代码(无 await),`:206` 之后才 `:212 await`。accessor 不会中途重定向,写的是正确的目标会话。代理4 时序分析错误。
|
||||
|
||||
---
|
||||
|
||||
## 根源分析
|
||||
|
||||
所有 P0/P1 的共同根因:**F-09 per-conv 改造未完成**。
|
||||
|
||||
| 状态源 | per-conv 化状态 | 串话表现 |
|
||||
|---|---|---|
|
||||
| streaming / currentText | ✅ 已 per-conv(DEC-07a accessor) | — |
|
||||
| convStates(conv_state 真相源) | ✅ 已 per-conv(Map) | 但 watchdog legacy fallback 路径 `clear()` 全局误杀(P0-1) |
|
||||
| queue | ❌ 全局单例 | 跨会话丢消息(P1-1)、强发错位(P1-4) |
|
||||
| watchdog | ⚠️ 双轨(per-conv Map + legacy 单 timer) | legacy 路径不清 by 心跳、全局 clear(P0-1) |
|
||||
| _approvalTimers | ❌ 全局 Map | 切/删会话不清(P0-2) |
|
||||
| modelOverride | ❌ 模块级 ref | 切会话丢失手选(P1-3) |
|
||||
| _lastDelta | ❌ 模块级单值 | 跨会话误丢 delta(P1-5) |
|
||||
|
||||
---
|
||||
|
||||
## 建议修复优先级
|
||||
|
||||
1. **P0-1**(流式误杀,最高频可复现)—— legacy `clear()` 改 `delete(activeConversationId)` + 恢复 running 工具检测
|
||||
2. **P0-2**(审批误拒 + 错气泡)—— switch / delete 清 timer + 修正注释
|
||||
3. **P1-1 / P1-3 / P1-4**(单例债三连,可攒批修)
|
||||
4. 其余 P1(渲染性能/死代码/防重入)按维度推进
|
||||
5. P2 择机清理
|
||||
|
||||
## 相关文件
|
||||
|
||||
- `src/composables/ai/useAiStream.ts`(P0-1 核心)
|
||||
- `src/composables/ai/useAiSend.ts`(P0-1 reset / P1-1,2,4)
|
||||
- `src/composables/ai/streamingGuard.ts`(P0-1 forceReset 同款 clear)
|
||||
- `src-tauri/src/commands/ai/audit/mod.rs`(P0-1 后端心跳 30s)
|
||||
- `src/composables/ai/useAiConversations.ts`(P0-2 / P1-11)
|
||||
- `src/composables/ai/aiShared.ts`(P0-2 _approvalTimers / P2 收敛)
|
||||
- `src/components/ai/TopBar.vue`(P1-3 双 watch)
|
||||
- `src/components/ai/MessageList.vue`(P1-7,9)
|
||||
- `src/components/ai/MessageItem.vue`(P1-7 死代码)
|
||||
- `src/composables/ai/useStreamRenderer.ts`(P1-8 / P2 tailSeq)
|
||||
- `src/composables/ai/useToolApproval.ts`(P1-10)
|
||||
@@ -108,7 +108,7 @@ docs/
|
||||
├── 08-用户指南/
|
||||
│ ├── 使用手册-2026-06-12.md # 用户手册(对齐 06-15 代码基线)
|
||||
│ ├── AI文件操作工具手册.md # AI 工具 API 全量参考(10 工具,2026-06-16 更新)
|
||||
│ └── patch_file使用指南.md # patch_file 专项使用指南
|
||||
│ ├── patch_file使用指南.md # patch_file 专项使用指南
|
||||
└── 09-问题排查/
|
||||
└── aichat-apikey-401排查-2026-06-15.md
|
||||
```
|
||||
|
||||
12
docs/todo.md
12
docs/todo.md
@@ -312,3 +312,15 @@ graph TD
|
||||
> ✅ 全部修复完成(2026-06-27 核验)
|
||||
|
||||
- [~] **IDEA-FIX-10 [P2🟠]** — **前端 filter + 后端分页漏数据**。团队决策:`hot`/`pending` 保留前端 filter(扩多值属性过度设计);keyword/order_by 已下沉后端。
|
||||
|
||||
---
|
||||
|
||||
### 🔍 2026-07-17 aichat 对话功能深度走查待办
|
||||
|
||||
> 详单:[05-代码审查/aichat-对话功能走查-2026-07-17.md](./05-代码审查/aichat-对话功能走查-2026-07-17.md)
|
||||
|
||||
- [x] **AIC-FIX-17-P0-1** — 流式 watchdog legacy 路径 `convStates.clear()` 全局误杀 → `delete(state.activeConversationId)` ✅
|
||||
- [x] **AIC-FIX-17-P0-2** — 审批计时器切/删会话不清 → switch/delete 入口 `clearAllApprovalTimers()` + 注释修正 ✅
|
||||
- [ ] **AIC-FIX-17-P1-1~11** — P1 队列/互斥/modelOverride/死代码/缓存/防重入/删除回落(待后续批次)
|
||||
- [ ] **AIC-FIX-17-P2** — P2 健壮性项(待后续批次)
|
||||
- [ ] **AIC-FIX-17-根因** — F-09 per-conv 收尾(queue / modelOverride / _approvalTimers / _lastDelta 单例化)
|
||||
|
||||
@@ -20,6 +20,7 @@ tauri-plugin-window-state = "2"
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
dashmap.workspace = true
|
||||
anyhow.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
@@ -56,9 +57,10 @@ regex = { workspace = true }
|
||||
# 根因见 docs/09-问题排查/aichat-apikey-401排查-2026-06-15.md
|
||||
keyring = { workspace = true }
|
||||
# http_request AI 工具:结构化 HTTP(GET/POST/...)。
|
||||
# 复用 df-ai 同款 reqwest 0.12(同版本锁定,避免双 TLS 后端)。默认 native-tls(与 df-ai 一致),
|
||||
# 启用 json(响应解析)/gzip/brotli(透明解压,常见 API 必备)。重定向手动接管(见 http.rs SSRF)。
|
||||
reqwest = { version = "0.12", features = ["json", "gzip", "brotli"] }
|
||||
# 复用 df-ai 同款 reqwest 0.12(同版本锁定,避免双 TLS 后端)。
|
||||
# 使用 rustls-tls(非 native-tls),避免 Windows SChannel 同步阻塞 tokio 工作线程
|
||||
# 致 BUG-2026-07-17(aichat 流式调用永久 hang,外层 tokio timeout 亦无法推进计时器)。
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "gzip", "brotli", "rustls-tls"] }
|
||||
# AST 代码智能(read_symbol 三态,信息密度驱动,见 docs/02-架构设计/专项设计/AST符号解析-设计-2026-06-24.md):
|
||||
# 治 aichat read_file 全文回灌 prompt 爆(e46f5605 360K/8dfe0b94 5M)。tree-sitter 语法层精准提取
|
||||
# 符号骨架/下钻/全文,替代物理读全文件。静态编译 + 集中 grammar_for(ext) lookup(不动态加载/不抽 trait,YAGNI)。
|
||||
|
||||
@@ -191,6 +191,112 @@ impl std::fmt::Display for InvalidTransition {
|
||||
|
||||
impl std::error::Error for InvalidTransition {}
|
||||
|
||||
// ============================================================
|
||||
// ConvStateStore — 无锁并发 ConvState 存储(session 锁重构方案 B-Phase0)
|
||||
//
|
||||
// 背景:AiSession 全局 Mutex 把 ConvState(高频读 + 敏感脏读)与 messages/pending_approvals
|
||||
// (长持锁源)同锁串行化,致 guard.reset 等 lock 竞争 800ms fallback(AiCompleted 延迟 / 工具后
|
||||
// 中断 / 第二条进队列同源根因)。本 Store 把 ConvState 提到独立 DashMap,guard.reset/new/drop
|
||||
// 直接 transition(同步无 await,不竞争 session lock),ai_is_generating 直接读(零锁竞争)。
|
||||
//
|
||||
// 设计:
|
||||
// - 基于 dashmap::DashMap<String, ConvState>(行级锁,不同 conv 不互斥,无 tokio runtime 阻塞)
|
||||
// - 全方法同步无 await(transition 内仅 copy + write enum,纳秒级)
|
||||
// - transition 经 ConvState::transition_to 守卫(复用状态机语义,非法转换拒绝)
|
||||
// - get 对不存在的 conv_id 返 Idle(惰性默认,对齐 PerConvState 新建语义)
|
||||
//
|
||||
// 方案 B 分阶段迁移完成。
|
||||
// - Phase0~1: ConvStateStore 骨架 + AppState 接入
|
||||
// - Phase2~3: 写/读侧迁移至无锁 ConvStateStore,PerConvState.conv_state 字段已删
|
||||
// - Phase4: conversation_delete 同步清理 conv_states 条目
|
||||
// ============================================================
|
||||
|
||||
use dashmap::DashMap;
|
||||
|
||||
/// ConvState 的无锁并发存储(方案 B 核心)。
|
||||
///
|
||||
/// 经 `Arc<ConvStateStore>` 共享(app_state.conv_states)。所有方法同步无 await,可在任意
|
||||
/// async 上下文直接调(不竞争 session lock,不阻塞 tokio runtime)。
|
||||
///
|
||||
/// 注:`transition` 用 DashMap entry 原子(get + transition_to + write 一致,无 TOCTOU 窗口)。
|
||||
pub struct ConvStateStore {
|
||||
inner: DashMap<String, ConvState>,
|
||||
}
|
||||
|
||||
impl ConvStateStore {
|
||||
/// 创建空 Store。
|
||||
pub fn new() -> Self {
|
||||
Self { inner: DashMap::new() }
|
||||
}
|
||||
|
||||
/// 读 conv_id 的 ConvState(不存在返 Idle 默认,对齐 PerConvState 新建语义)。
|
||||
pub fn get(&self, conv_id: &str) -> ConvState {
|
||||
self.inner.get(conv_id).map(|r| *r.value()).unwrap_or(ConvState::Idle)
|
||||
}
|
||||
|
||||
/// 读 conv_id 是否活跃生成态(Generating/Compressed)—— ai_is_generating 零锁读。
|
||||
pub fn is_active(&self, conv_id: &str) -> bool {
|
||||
self.get(conv_id).is_active()
|
||||
}
|
||||
|
||||
/// 读 conv_id 是否可接受新请求(Idle/Error)—— can_accept_request 零锁读。
|
||||
pub fn can_accept_request(&self, conv_id: &str) -> bool {
|
||||
self.get(conv_id).can_accept_request()
|
||||
}
|
||||
|
||||
/// 原子迁移 conv_id 的 ConvState 到 target(经 transition_to 守卫)。
|
||||
///
|
||||
/// DashMap entry 原子(get + transition + write 一致,无 TOCTOU)。不存在的 conv_id 视为
|
||||
/// Idle(对齐新建语义),Idle→target 经守卫。返回 Ok(新态) 或 Err(InvalidTransition)。
|
||||
pub fn transition(
|
||||
&self,
|
||||
conv_id: &str,
|
||||
target: ConvState,
|
||||
) -> Result<ConvState, InvalidTransition> {
|
||||
// get_mut 持写锁原子迁移(Occupied);Vacant 时 insert(Idle 起步)。
|
||||
// 注:transition 调用点(guard.new/reset/drop)同 conv 单 loop 不并发,TOCTOU 风险低;
|
||||
// 跨 conv 各自条目行级锁不互斥(对齐 DashMap 设计)。
|
||||
if let Some(mut r) = self.inner.get_mut(conv_id) {
|
||||
let cur = *r.value();
|
||||
match cur.transition_to(target) {
|
||||
Ok(ns) => {
|
||||
*r.value_mut() = ns;
|
||||
Ok(ns)
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
} else {
|
||||
match ConvState::Idle.transition_to(target) {
|
||||
Ok(ns) => {
|
||||
self.inner.insert(conv_id.to_string(), ns);
|
||||
Ok(ns)
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除 conv_id 的条目(会话删除时同步清,防已删 conv 残留 Generating 致 id 复用脏状态)。
|
||||
pub fn remove(&self, conv_id: &str) {
|
||||
self.inner.remove(conv_id);
|
||||
}
|
||||
|
||||
/// 所有活跃生成态的 conv_id 快照(L0 握手批量 stop / 恢复生成态用)。
|
||||
pub fn active_convs(&self) -> Vec<String> {
|
||||
self.inner
|
||||
.iter()
|
||||
.filter(|r| r.value().is_active())
|
||||
.map(|r| r.key().clone())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ConvStateStore {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单元测试(纯逻辑无 IO)
|
||||
// ============================================================
|
||||
@@ -458,4 +564,60 @@ mod tests {
|
||||
assert!(msg.contains("Idle"), "Display 应含 from: {}", msg);
|
||||
assert!(msg.contains("Stopping"), "Display 应含 to: {}", msg);
|
||||
}
|
||||
|
||||
// ---- ConvStateStore(方案 B-Phase0,无锁并发存储)----
|
||||
|
||||
#[test]
|
||||
fn test_store_get_default_idle() {
|
||||
let s = ConvStateStore::new();
|
||||
assert_eq!(s.get("conv-1"), ConvState::Idle, "不存在 conv 应返 Idle 默认");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_store_transition_occupied() {
|
||||
let s = ConvStateStore::new();
|
||||
s.transition("conv-1", ConvState::Generating).unwrap();
|
||||
assert_eq!(s.get("conv-1"), ConvState::Generating);
|
||||
s.transition("conv-1", ConvState::Idle).unwrap();
|
||||
assert_eq!(s.get("conv-1"), ConvState::Idle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_store_transition_guard_rejects() {
|
||||
let s = ConvStateStore::new();
|
||||
s.transition("conv-1", ConvState::Generating).unwrap();
|
||||
// Generating → Stopping 合法
|
||||
s.transition("conv-1", ConvState::Stopping).unwrap();
|
||||
// Stopping → Generating 非法(须先回 Idle 再起)
|
||||
assert!(s.transition("conv-1", ConvState::Generating).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_store_remove() {
|
||||
let s = ConvStateStore::new();
|
||||
s.transition("conv-1", ConvState::Generating).unwrap();
|
||||
s.remove("conv-1");
|
||||
assert_eq!(s.get("conv-1"), ConvState::Idle, "remove 后应返 Idle 默认");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_store_is_active_and_can_accept() {
|
||||
let s = ConvStateStore::new();
|
||||
assert!(!s.is_active("conv-1"), "Idle 不活跃");
|
||||
assert!(s.can_accept_request("conv-1"), "Idle 可接");
|
||||
s.transition("conv-1", ConvState::Generating).unwrap();
|
||||
assert!(s.is_active("conv-1"), "Generating 活跃");
|
||||
assert!(!s.can_accept_request("conv-1"), "Generating 不可接");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_store_active_convs() {
|
||||
let s = ConvStateStore::new();
|
||||
s.transition("a", ConvState::Generating).unwrap();
|
||||
s.transition("b", ConvState::Idle).unwrap();
|
||||
s.transition("c", ConvState::Generating).unwrap();
|
||||
let mut active = s.active_convs();
|
||||
active.sort();
|
||||
assert_eq!(active, vec!["a".to_string(), "c".to_string()], "仅活跃 conv");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,218 +1,88 @@
|
||||
//! B-260615-09: generating 状态 RAII guard —— 从 agentic/mod.rs 抽离(重构第一批,纯结构搬迁)。
|
||||
//! generating 状态 RAII guard。
|
||||
//!
|
||||
//! 行为零变更:仅文件位置移动,逻辑/字段/语义完全保留。
|
||||
//! 调用方(agentic/mod.rs run_agentic_loop)经 `use super::guard::GeneratingGuard;` 复用。
|
||||
|
||||
use std::sync::Arc;
|
||||
//! B-Phase2: ConvState 切无锁 ConvStateStore(不竞争 session lock,删 800ms fallback)。
|
||||
//! 治卡死连环:AiCompleted 延迟 / 工具后中断 / 第二条进队列同源根因。
|
||||
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::commands::ai::{AiChatEvent, AiSession};
|
||||
use crate::commands::ai::AiChatEvent;
|
||||
|
||||
// 双轨收口批2:CONV_STATE_ENABLED 常量删除后,模块路径(self)不再使用,仅导入 ConvState 类型。
|
||||
use super::conv_state::ConvState;
|
||||
|
||||
/// generating 复位 RAII guard,取代散布的手动 `session.generating = false`。
|
||||
/// generating 复位 RAII guard。
|
||||
///
|
||||
/// 两路复位:
|
||||
/// - 正常路径:exit 点显式 `reset().await` 即时复位(emit 前调,保证"复位→emit"顺序,
|
||||
/// 前端收事件时后端已可接下一条)。
|
||||
/// - 异常路径(panic/未走正常 return):Drop 兜底 spawn 复位,防 generating 永真卡死前端。
|
||||
///
|
||||
/// 注:try_continue_agent_loop 不用 guard——其 should_continue=false 路径需保持
|
||||
/// generating=true(审批等待态),全函数 guard 会误复位;该函数单点 provider-Err 复位保持手动。
|
||||
///
|
||||
/// F-260616-09 B 批4:guard 持 `conv_id`,复位改写 `session.conv(&conv_id).generating = false`
|
||||
/// (per-conv 唯一真相源)。顶层 `session.generating` 字段已在批4 删除,reset/Drop 仅写 per_conv;
|
||||
/// IPC(ai_is_generating/ai_chat_send 等)亦改读 per_conv,无需双写桥接。
|
||||
///
|
||||
/// L2 统一状态机收尾(2026-06-22):本地 `state: Option<ConvState>` 视图层冗余字段已删除。
|
||||
///
|
||||
/// 背景:批1 曾在 guard 内嵌 `state` 作「写收敛视图层」(双轨过渡),批2 已把 `ConvState` 落到
|
||||
/// `PerConvState.conv_state` 字段持久化(读侧真相源)。批2 同时在 `reset`/`drop` 内读
|
||||
/// `conv.conv_state` 持久化字段做迁移 + emit AiConvStateChanged——故本地 `state` 成纯冗余
|
||||
/// (双源,且本地 state 在 Drop 无 await 上下文无法持久化,语义本就不完整)。
|
||||
///
|
||||
/// 收尾删除后:
|
||||
/// - `new`:读 `conv.conv_state` 持久化字段,若 Idle→Generating 迁移成功则 emit 通知前端
|
||||
/// (入口 `run_agentic_loop` 已独立写持久化迁移但**不 emit**,故此处补 emit 防前端漏收生成态)。
|
||||
/// - `reset`/`drop`:迁移 + emit 均读 `conv.conv_state` 持久化字段(批2 已落地,本批仅删冗余本地块)。
|
||||
/// - `disarm`:不动 ConvState(审批等待是 Generating 内的暂停点)。
|
||||
///
|
||||
/// 批3 双轨收口:`CONV_STATE_ENABLED` 开关与 `generating` bool 已退役,enum 迁移 + emit
|
||||
/// 无条件执行(单一真相源,无 off 降级分支)。
|
||||
/// ConvState 经无锁 [`ConvStateStore`](super::conv_state::ConvStateStore) 迁移(不竞争 session
|
||||
/// lock)。正常退出显式 `reset()`;异常退出 `Drop` 兜底迁移 Idle。`disarm` 解除 Drop 兜底
|
||||
/// (审批等待保持 Generating,续跑新 guard.new 重新迁移)。
|
||||
pub(super) struct GeneratingGuard {
|
||||
session: Arc<Mutex<AiSession>>,
|
||||
/// guard 所属会话(loop 启动时快照的 conv_id,来自 run_agentic_loop 入参)。
|
||||
conv_id: String,
|
||||
done: bool,
|
||||
/// L2 批2 1b:emit AiConvStateChanged 用的 AppHandle(Clone 廉价,run_agentic_loop 入参传入)。
|
||||
/// 批3 收口:emit 无条件执行(开关已退役),此字段恒被消费。
|
||||
app_handle: AppHandle,
|
||||
}
|
||||
|
||||
impl GeneratingGuard {
|
||||
pub(super) fn new(session: Arc<Mutex<AiSession>>, conv_id: String, app_handle: AppHandle) -> Self {
|
||||
// L2 收尾:入口 `run_agentic_loop` 已独立完成 Idle→Generating 的持久化迁移(写 conv.conv_state),
|
||||
// 但**不 emit** AiConvStateChanged。此处补 emit 通知前端进入生成态——读 conv.conv_state
|
||||
// 持久化字段(真相源)做迁移校验,迁移成功则 emit,非法记 warn 不阻断。
|
||||
//
|
||||
// 注:new 是同步函数(无 await),无法持 session 锁读 conv.conv_state;此处经
|
||||
// tauri::async_runtime::spawn 异步补 emit(非阻塞 guard 创建,入口持久化已发生故 emit 有据)。
|
||||
// spawn 失败(运行时不可用,理论场景)仅漏一次 emit,前端 watchdog 兜底(对齐失败不阻断核心生成)。
|
||||
// 批3 双轨收口:删除 CONV_STATE_ENABLED 门控,迁移 + emit 无条件执行(enum 单一真相源)。
|
||||
// 注:clone 给闭包 move,原始 session/app_handle 留给 Self 字段(对齐原 if 块外 Self 构造)。
|
||||
let session_for_spawn = session.clone();
|
||||
let app_handle_for_spawn = app_handle.clone();
|
||||
let cid = conv_id.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let new_state = {
|
||||
let mut session = session_for_spawn.lock().await;
|
||||
let conv = session.conv(&cid);
|
||||
match conv.conv_state.transition_to(ConvState::Generating) {
|
||||
Ok(ns) => {
|
||||
conv.conv_state = ns;
|
||||
ns
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
conv_id = %cid,
|
||||
error = %e,
|
||||
"[ai] guard.new ConvState→Generating 非法(持久化层,不阻断核心生成)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
// 持久化层迁移成功后 emit 前端(同步,失败忽略)。锁已随作用域 drop,可安全 emit。
|
||||
// L3 emit 双写:提变量避免构造两次,publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。
|
||||
let ev = AiChatEvent::AiConvStateChanged {
|
||||
conv_state: new_state,
|
||||
conversation_id: Some(cid),
|
||||
};
|
||||
let _ = app_handle_for_spawn.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_handle_for_spawn.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
});
|
||||
Self { session, conv_id, done: false, app_handle }
|
||||
pub(super) fn new(conv_id: String, app_handle: AppHandle) -> Self {
|
||||
let guard = Self {
|
||||
conv_id,
|
||||
done: false,
|
||||
app_handle: app_handle.clone(),
|
||||
};
|
||||
guard.transition(ConvState::Generating);
|
||||
guard
|
||||
}
|
||||
|
||||
/// 显式复位生成态。emit 前调用保证顺序。幂等。
|
||||
///
|
||||
/// 批3 双轨收口:generating bool 已退役,复位经 ConvState 迁移(Generating→Idle)单一表达。
|
||||
/// L2:同步迁移 `conv.conv_state → Idle`(持久化层,迁移 + emit 无条件执行)。
|
||||
/// 复位 Idle(emit 前调,保证前端收事件时后端已 Idle)。幂等。
|
||||
pub(super) async fn reset(&mut self) {
|
||||
if !self.done {
|
||||
let mut session = self.session.lock().await;
|
||||
let conv = session.conv(&self.conv_id);
|
||||
// L2 批2:ConvState 持久化(Generating→Idle 写收敛)。批3 删除开关门控,迁移无条件执行。
|
||||
match conv.conv_state.transition_to(ConvState::Idle) {
|
||||
Ok(ns) => {
|
||||
conv.conv_state = ns;
|
||||
// L2 批2 1b:持久化层 Idle 收敛成功后 emit 前端(同步,失败忽略)。
|
||||
// L3 emit 双写:提变量避免构造两次,publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。
|
||||
let ev = AiChatEvent::AiConvStateChanged {
|
||||
conv_state: ns,
|
||||
conversation_id: Some(self.conv_id.clone()),
|
||||
};
|
||||
let _ = self.app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = self.app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
}
|
||||
Err(e) => tracing::warn!(
|
||||
conv_id = %self.conv_id,
|
||||
error = %e,
|
||||
"[ai] guard.reset ConvState→Idle 非法(持久化层,不阻断核心复位)"
|
||||
),
|
||||
}
|
||||
self.done = true;
|
||||
self.transition(ConvState::Idle);
|
||||
}
|
||||
}
|
||||
|
||||
/// 解除 Drop 兜底复位但不复位 generating。审批等待 return 路径调用:
|
||||
/// 保持 generating=true 留 try_continue 续生成,同时 Drop 因 done=true 跳过复位 spawn。
|
||||
/// (B-260615-26: 修复审批执行后对话不续生成回归)
|
||||
///
|
||||
/// L2:disarm 不迁移 ConvState(审批等待是 Generating 内的暂停点,ConvState 仍 Generating,
|
||||
/// 与「generating 保持 true」语义一致)。续跑时新 guard.new 重新迁移(Generating 自环幂等)。
|
||||
/// 解除 Drop 兜底(审批等待保持 Generating)。
|
||||
pub(super) fn disarm(&mut self) {
|
||||
self.done = true;
|
||||
}
|
||||
|
||||
/// 迁移 ConvState + emit AiConvStateChanged(经无锁 ConvStateStore,同步无 await)。
|
||||
fn transition(&self, target: ConvState) {
|
||||
let conv_states = self
|
||||
.app_handle
|
||||
.state::<crate::state::AppState>()
|
||||
.conv_states
|
||||
.clone();
|
||||
let ev = match conv_states.transition(&self.conv_id, target) {
|
||||
Ok(ns) => Some(AiChatEvent::AiConvStateChanged {
|
||||
conv_state: ns,
|
||||
conversation_id: Some(self.conv_id.clone()),
|
||||
}),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
conv_id = %self.conv_id,
|
||||
error = %e,
|
||||
"[ai] guard ConvState 迁移非法"
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(ev) = ev {
|
||||
let _ = self.app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = self
|
||||
.app_handle
|
||||
.state::<crate::state::AppState>()
|
||||
.ai_event_bus
|
||||
.publish_event(ev);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for GeneratingGuard {
|
||||
fn drop(&mut self) {
|
||||
// 未 done 即异常退出(panic/未走正常 return):核心生成态经 spawn 复位,
|
||||
// ConvState 持久化迁移 + emit 也在同一 spawn 内异步完成(无 await 上下文,对齐核心复位路径)。
|
||||
// done=true(正常 reset/disarm 已走) → 跳过。批3 收口后无开关 off 分支。
|
||||
if !self.done {
|
||||
let session = self.session.clone();
|
||||
let conv_id = self.conv_id.clone();
|
||||
// L2 批2 1b:Drop 无 await 上下文,持久化迁移 + emit 在 spawn 内异步完成(对齐核心复位 spawn 路径)。
|
||||
// app_handle Clone 廉价,随 session/conv_id 一起 move 进 async 块。
|
||||
let app_handle = self.app_handle.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut s = session.lock().await;
|
||||
let conv = s.conv(&conv_id);
|
||||
// L2 批2:ConvState 持久化(异常退出兜底→Idle)。批3 删除开关门控 + generating bool 写,迁移无条件执行。
|
||||
if let Ok(ns) = conv.conv_state.transition_to(ConvState::Idle) {
|
||||
conv.conv_state = ns;
|
||||
// 1b:持久化 Idle 收敛后 emit 前端(异常退出兜底也需通知前端退出生成态)。
|
||||
// L3 emit 双写:提变量避免构造两次,publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。
|
||||
let ev = AiChatEvent::AiConvStateChanged {
|
||||
conv_state: ns,
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
}
|
||||
});
|
||||
self.transition(ConvState::Idle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单元测试(guard ConvState 迁移语义,纯逻辑路径验证)
|
||||
// ============================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// guard.new 路径:持久化层 Idle→Generating 迁移(读 conv.conv_state)。
|
||||
///
|
||||
/// 注:不构造真实 AiSession(lock/Mutex 建会话状态成本高),仅校验迁移语义——
|
||||
// guard 内部 transition_to 是纯函数,直接断言逻辑路径。
|
||||
#[test]
|
||||
fn test_guard_new_migrates_to_generating() {
|
||||
// 批3 收口后无开关断言:Idle → Generating(读侧持久化字段迁移路径,无条件执行)。
|
||||
let mut s = ConvState::Idle;
|
||||
s = s.transition_to(ConvState::Generating).unwrap();
|
||||
assert_eq!(s, ConvState::Generating);
|
||||
}
|
||||
|
||||
/// reset 迁移 Generating→Idle(正常收敛),验证迁移合法。
|
||||
#[test]
|
||||
fn test_guard_reset_migrates_to_idle() {
|
||||
let mut s = ConvState::Generating;
|
||||
s = s.transition_to(ConvState::Idle).unwrap();
|
||||
assert_eq!(s, ConvState::Idle);
|
||||
}
|
||||
|
||||
/// 异常 Drop 路径同样 Generating→Idle(兜底),验证迁移合法。
|
||||
#[test]
|
||||
fn test_guard_drop_path_migrates_to_idle() {
|
||||
// guard.drop 异常路径(!done)迁移 Generating→Idle(读持久化字段)。
|
||||
let mut s = ConvState::Generating;
|
||||
s = s.transition_to(ConvState::Idle).unwrap();
|
||||
assert_eq!(s, ConvState::Idle);
|
||||
}
|
||||
|
||||
/// disarm 不迁移状态(审批等待保持 Generating)。
|
||||
/// 验证:disarm 后 enum 仍 Generating(下次 new 时 Generating 自环幂等通过)。
|
||||
#[test]
|
||||
fn test_guard_disarm_keeps_generating_then_self_loop_on_new() {
|
||||
let s = ConvState::Generating;
|
||||
// disarm 不动 enum;续跑新 guard.new 时 Generating→Generating 自环通过。
|
||||
assert_eq!(s.transition_to(ConvState::Generating), Ok(ConvState::Generating));
|
||||
}
|
||||
}
|
||||
// ConvState 迁移语义单测见 conv_state.rs(ConvStateStore + ConvState::transition_to)。
|
||||
// guard struct 委托 ConvStateStore,无独立逻辑,不重复单测。
|
||||
|
||||
@@ -30,11 +30,12 @@ pub async fn try_continue_agent_loop(
|
||||
|
||||
let snap = {
|
||||
let session = state.ai_session.lock().await;
|
||||
let has_pending = session.session_state(conv_id) == SessionState::AwaitingApproval;
|
||||
let has_pending = session.session_state(conv_id, &state.conv_states) == SessionState::AwaitingApproval;
|
||||
let pending_conv_id = session.pending_approvals.values()
|
||||
.find_map(|a| a.conversation_id.clone());
|
||||
let conv = session.conv_read(conv_id);
|
||||
let is_generating = conv.map(|c| c.conv_state.is_active()).unwrap_or(false);
|
||||
// B-Phase2:ConvState 读侧切无锁 conv_states(is_active),不再占 session lock 读 conv_state。
|
||||
let is_generating = state.conv_states.is_active(conv_id);
|
||||
let agent_language = conv.and_then(|c| c.agent_language.clone());
|
||||
let model_override = conv.and_then(|c| c.model_override.clone());
|
||||
let pinned_goals_snapshot = conv.map(|c| c.pinned_goals.clone()).unwrap_or_default();
|
||||
@@ -73,12 +74,10 @@ pub async fn try_continue_agent_loop(
|
||||
let provider_config = match get_active_provider(state).await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
let mut session = state.ai_session.lock().await;
|
||||
match session.conv(conv_id).conv_state.transition_to(ConvState::Idle) {
|
||||
Ok(ns) => session.conv(conv_id).conv_state = ns,
|
||||
Err(e2) => tracing::warn!(conv_id = %conv_id, error = %e2, "ConvState→Idle 非法"),
|
||||
// B-Phase3:不再持锁操作 conv_state(ConvStateStore 无锁单源)。
|
||||
if let Err(e2) = state.conv_states.transition(conv_id, ConvState::Idle) {
|
||||
tracing::warn!(conv_id = %conv_id, error = %e2, "conv_states→Idle 非法");
|
||||
}
|
||||
drop(session);
|
||||
let _ = app.emit("ai-chat-event", AiChatEvent::AiError {
|
||||
error: e.clone(), error_type: Some(ErrorType::ProviderConfig),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
@@ -107,10 +106,8 @@ pub async fn try_continue_agent_loop(
|
||||
let max_retries = state.agent_max_retries.load(std::sync::atomic::Ordering::SeqCst);
|
||||
let model_override = snap.model_override.clone();
|
||||
|
||||
let still_generating = {
|
||||
let session = state.ai_session.lock().await;
|
||||
session.conv_read(conv_id).map(|c| c.conv_state.is_active()).unwrap_or(false)
|
||||
};
|
||||
// B-Phase2:ConvState 读侧切无锁 conv_states(零锁竞争,无需 session lock)。
|
||||
let still_generating = state.conv_states.is_active(conv_id);
|
||||
if !still_generating {
|
||||
let ev = AiChatEvent::AiCompleted {
|
||||
total_tokens: 0, prompt_tokens: 0, completion_tokens: 0,
|
||||
|
||||
@@ -1,32 +1,2 @@
|
||||
//! Agentic 循环知识生命周期(文档占位)。
|
||||
//!
|
||||
//! ## 为什么没有抽取
|
||||
//!
|
||||
//! `run_agentic_loop` 中知识相关的逻辑**已经是模块化的**:所有调用点都是对已抽取函数的
|
||||
//! 单行调用,不存在可内联抽取的知识逻辑。
|
||||
//!
|
||||
//! ### 已存在的知识模块
|
||||
//!
|
||||
//! 知识注入/提炼实现位于 [`super::super::knowledge_inject`] 模块,`mod.rs` 顶部已通过
|
||||
//! `use super::knowledge_inject::{inject_knowledge_into_prompt, maybe_spawn_extraction};` 引入:
|
||||
//!
|
||||
//! - `inject_knowledge_into_prompt` —— 构建请求前把知识库上下文注入 system_prompt。
|
||||
//! - `maybe_spawn_extraction` —— 后台知识提炼(需读已落库的对话消息)。
|
||||
//!
|
||||
//! ### mod.rs 中的调用点(均为单行)
|
||||
//!
|
||||
//! | 行 | 所在函数 | 调用 |
|
||||
//! |----|----------|------|
|
||||
//! | ~1853 | `run_agentic_loop`(正常完成后台 spawn) | `maybe_spawn_extraction(...)` |
|
||||
//! | ~2092 | `try_continue_agent_loop` | `inject_knowledge_into_prompt(...)` |
|
||||
//!
|
||||
//! 注意:`run_agentic_loop` 入口的 system_prompt 构建**不**在 mod.rs 内联调用
|
||||
//! `inject_knowledge_into_prompt` —— 知识注入发生在调用方(IPC commands.rs /
|
||||
//! try_continue_agent_loop),loop 入参拿到的 `system_prompt` 已是注入后的最终值。
|
||||
//! 故 mod.rs 内知识相关代码仅有上述 2 处单行调用。
|
||||
//!
|
||||
//! ### 结论
|
||||
//!
|
||||
//! 知识生命周期的抽取工作**早已完成**(knowledge_inject 模块独立)。本文件仅作占位与说明,
|
||||
//! 无代码可移。`inject_knowledge_into_prompt` 的入口收敛(DRY B 改进)已在调用方完成,
|
||||
//! 非 mod.rs 职责。
|
||||
//! 知识提炼实现位于 `super::super::knowledge_inject` 模块。
|
||||
|
||||
@@ -46,16 +46,9 @@ use super::audit::process_tool_calls;
|
||||
// compress_via_llm 已随压缩逻辑迁至 context_lifecycle.rs(maybe_auto_compress 内调用)。
|
||||
use super::conversation::{save_conversation, TokenAccumulator};
|
||||
use super::knowledge_inject::{maybe_spawn_extraction};
|
||||
#[allow(unused_imports)]
|
||||
use super::knowledge_inject::inject_knowledge_into_prompt;
|
||||
#[allow(unused_imports)]
|
||||
use super::prompt::{build_system_prompt, get_active_provider};
|
||||
use super::stream_recv::{stream_llm, StreamResult};
|
||||
use super::title::{ensure_conversation_title, spawn_ensure_title};
|
||||
#[allow(unused_imports)]
|
||||
use super::{AiChatEvent, AiSession, ErrorType, SessionState};
|
||||
#[allow(unused_imports)]
|
||||
use super::ToolCallDraft;
|
||||
use super::{AiChatEvent, AiSession, ErrorType};
|
||||
// ConvState 经本文件内 `pub mod conv_state;` 同 crate 直接访问(conv_state::ConvState)。
|
||||
|
||||
/// L1 补丁:run_agentic_loop 入口 provider 解析超时保护的内部错误类型。
|
||||
@@ -356,14 +349,12 @@ async fn stream_one_provider(
|
||||
// 注:primary 候选的启动 Auth 早失败已在外层 run_agentic_loop 顶部处理(emit + return),
|
||||
// 本函数到达时 primary 的 key 已验证过;此处 Err 多见于 secondary 配置不一致,
|
||||
// 保守归 Fatal 立即放弃(不浪费预算试下一 provider,因 key 错非瞬态)。
|
||||
let provider: Box<dyn LlmProvider> = match super::secret::build_provider_for(candidate) {
|
||||
let provider: Arc<dyn LlmProvider> = Arc::from(match super::secret::build_provider_for(candidate) {
|
||||
Ok(p) => p,
|
||||
Err(msg) => {
|
||||
// UX-260618-15: Fatal 分支 emit 移到外层(stream_one_provider 调用方 Fatal 分支统一 emit)。
|
||||
// 此处仅返回 error 文本,避免 stream_one_provider 内 emit 与外层 emit 重复(Fatal 终态单 emit)。
|
||||
return StreamOutcome::Fatal { error: msg };
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// resolved_model 在本 candidate.model_configs 上重算(F-260614-04b 核心:provider 切换后
|
||||
// 模型池不同,必须重选;否则拿主 provider 的 model_id 去打次 provider 会吃 400/404)。
|
||||
@@ -394,7 +385,7 @@ async fn stream_one_provider(
|
||||
reasoning_content: last_reasoning_content.clone(),
|
||||
};
|
||||
|
||||
match stream_llm(&*provider, retry_request, app_handle, stop_flag, notify, conv_id).await {
|
||||
match stream_llm(Arc::clone(&provider), retry_request, app_handle, stop_flag, notify, conv_id).await {
|
||||
StreamResult::Complete { text, tool_calls, usage, reasoning_content } => {
|
||||
return StreamOutcome::Success {
|
||||
text, tool_calls, usage,
|
||||
@@ -514,7 +505,34 @@ pub(crate) async fn run_agentic_loop(
|
||||
// B-260615-09: generating 状态由 RAII guard 收敛复位(正常 exit 显式 reset;panic/异常 Drop 兜底)
|
||||
// F-260616-09 B 批2:guard 持 conv_id,复位改 per-conv.generating(设计 §4.3)。
|
||||
// L2 批2 1b:guard 持 app_handle,ConvState 迁移后 emit AiConvStateChanged 推前端。
|
||||
let mut guard = GeneratingGuard::new(session_arc.clone(), conv_id.clone(), app_handle.clone());
|
||||
let mut guard = GeneratingGuard::new(conv_id.clone(), app_handle.clone());
|
||||
|
||||
// 治本:LLM 生成间隙向前端发 AiHeartbeat,防前端 watchdog 误断流。
|
||||
// 后端 emit AiHeartbeat → useAiEvents.ts resetStreamWatchdog(convId) → per-conv timer 重置。
|
||||
// 解决:LLM 重试循环(~60s)期间无事件到达前端 → watchdog 45s/90s 到期误杀。
|
||||
// shutdown_tx 在函数作用域结束(Drop)时通知 heartbeat 任务退出。
|
||||
let (_heartbeat_tx, mut heartbeat_rx) = tokio::sync::watch::channel(());
|
||||
let hb_app = app_handle.clone();
|
||||
let hb_conv_id = conv_id.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = heartbeat_rx.changed() => {
|
||||
// watch 通道关闭(发送端 Drop)→ 退出
|
||||
break;
|
||||
}
|
||||
_ = tokio::time::sleep(std::time::Duration::from_secs(20)) => {
|
||||
// 每 20s 发一次 AiHeartbeat,前端重置 watchdog timer。
|
||||
let _ = hb_app.emit(
|
||||
"ai-chat-event",
|
||||
AiChatEvent::AiHeartbeat {
|
||||
conversation_id: Some(hb_conv_id.clone()),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// F-260616-09 B 批2 入口桥接:loop 启动前确保 per_conv 存在(已存在则保留累积,不存在则建)。
|
||||
//
|
||||
@@ -533,23 +551,7 @@ pub(crate) async fn run_agentic_loop(
|
||||
// 单 loop 安全性:批2 阶段是单 active_conversation_id(决策 e 真并发批3+ 落地),无并发 loop 抢
|
||||
// per_conv 覆盖。批3+ 多 loop 并发时,每 conv 各自 per_conv 条目,互不干扰(本桥接无需改)。
|
||||
//
|
||||
// L2 状态机写收敛:ConvState 的写收敛已收敛到 `guard`(GeneratingGuard::new 已在上方 L399 创建,
|
||||
// new 内部 Idle→Generating 迁移 + reset/drop Generating→Idle 迁移)。
|
||||
// 批3 双轨收口:generating bool 已退役,此处仅做持久化层 Idle→Generating 迁移(单一真相源),
|
||||
// emit 由 guard.new 异步补发(入口迁移但不 emit,guard 补 emit 防前端漏收生成态)。
|
||||
{
|
||||
let mut session = session_arc.lock().await;
|
||||
let conv = session.conv(&conv_id);
|
||||
// L2 批2:ConvState 持久化(Idle→Generating 写收敛)。批3 删除开关门控,迁移无条件执行。
|
||||
match conv.conv_state.transition_to(conv_state::ConvState::Generating) {
|
||||
Ok(ns) => conv.conv_state = ns,
|
||||
Err(e) => tracing::warn!(
|
||||
conv_id = %conv_id,
|
||||
error = %e,
|
||||
"[ai] 入口 ConvState→Generating 非法(状态机持久化层,不阻断核心生成)"
|
||||
),
|
||||
}
|
||||
}
|
||||
// 入口:ConvState 迁移由 guard.new 在无锁 ConvStateStore 上完成。
|
||||
|
||||
// F-260614-04 / F-260614-04b: 多 Provider 负载均衡池 — 选主 + fallback 候选列表。
|
||||
//
|
||||
@@ -1544,10 +1546,7 @@ pub(crate) async fn run_agentic_loop(
|
||||
let _ = converged;
|
||||
|
||||
// 处理工具调用(Low 自动执行 / Medium+High 待审批)
|
||||
let pending_count = {
|
||||
let mut session = session_arc.lock().await;
|
||||
process_tool_calls(&mut session, tool_calls_acc, &tools_arc, &db, &app_handle, &conv_id).await
|
||||
};
|
||||
let pending_count = process_tool_calls(&session_arc, tool_calls_acc, &tools_arc, &db, &app_handle, &conv_id).await;
|
||||
// F-260620 卡死已根治(DIRAUTH 审批链已闭环)。原 eprintln 诊断降级为 tracing::debug,
|
||||
// 避免污染 stderr(用户可见),保留排障能力(RUST_LOG=debug 可见)。
|
||||
tracing::debug!(
|
||||
|
||||
@@ -1,34 +1,2 @@
|
||||
//! Agentic 循环标题生命周期(文档占位)。
|
||||
//!
|
||||
//! ## 为什么没有抽取
|
||||
//!
|
||||
//! `run_agentic_loop` 中标题相关的逻辑**已经是模块化的**:所有调用点都是对已抽取函数的
|
||||
//! 单行调用,不存在可内联抽取的标题逻辑。
|
||||
//!
|
||||
//! ### 已存在的标题模块
|
||||
//!
|
||||
//! 标题生成实现位于 [`super::super::title`] 模块,`mod.rs` 顶部已通过
|
||||
//! `use super::title::{ensure_conversation_title, spawn_ensure_title};` 引入:
|
||||
//!
|
||||
//! - `spawn_ensure_title` —— 后台 fire-and-forget 标题生成(不阻塞 Completed emit)。
|
||||
//! - `ensure_conversation_title` —— 标题生成(可 await,正常完成路径后台 spawn 内调用)。
|
||||
//!
|
||||
//! ### mod.rs 中的调用点(均为单行)
|
||||
//!
|
||||
//! `run_agentic_loop` 内 4 处调用:
|
||||
//!
|
||||
//! | 行 | 场景 | 调用 |
|
||||
//! |----|------|------|
|
||||
//! | ~893 | 入口 stop(首轮即停) | `spawn_ensure_title(...)` |
|
||||
//! | ~1469 | MidStream 保文后 | `spawn_ensure_title(...)` |
|
||||
//! | ~1543 | 流后 stop | `spawn_ensure_title(...)` |
|
||||
//! | ~1856 | 正常完成后台 spawn | `ensure_conversation_title(...)` |
|
||||
//!
|
||||
//! 这些调用点散落在不同的控制流分支(stop/保文/正常完成),每处仅一行,且各自需要
|
||||
//! 不同的上下文(`resolved_model` 是否已知、是否在 guard.reset 前/后)。强行抽取为
|
||||
//! 单一函数会引入等量的参数透传 + 控制流分支,无净收益且增加间接层。
|
||||
//!
|
||||
//! ### 结论
|
||||
//!
|
||||
//! 标题生命周期的抽取工作**早已完成**(title 模块独立)。本文件仅作占位与说明,
|
||||
//! 无代码可移。若未来需要统一标题调用上下文,应在 title 模块内封装,而非在此处。
|
||||
//! 标题生成实现位于 `super::super::title` 模块。
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
|
||||
use df_ai::provider::ChatMessage;
|
||||
@@ -91,20 +92,24 @@ pub(super) fn should_auto_for_persona(
|
||||
///
|
||||
/// 首批信任工具:write_file / run_command。同会话已批准过同工具+同目录 →
|
||||
/// `TrustKey` 命中,返回 `Some(TrustKey)`;否则返回 `None`(走原审批流程)。
|
||||
pub(super) fn check_trust_hits(
|
||||
///
|
||||
/// BUG-260624-03/P0 重构:签名改 `session_arc: &Arc<Mutex<AiSession>>`,内部短 lock 读
|
||||
/// `session_trust` 后立即 drop,信任查询不持锁,与 process_tool_calls 持锁 await 反模式解耦。
|
||||
pub(super) async fn check_trust_hits(
|
||||
draft: &ToolCallDraft,
|
||||
args: &serde_json::Value,
|
||||
session: &AiSession,
|
||||
session_arc: &Arc<Mutex<AiSession>>,
|
||||
conv_id: &str,
|
||||
) -> Option<TrustKey> {
|
||||
trust_key_for(&draft.name, args)
|
||||
.and_then(|key| {
|
||||
if session.conv_read(conv_id).map(|c| c.session_trust.contains(&key)).unwrap_or(false) {
|
||||
Some(key)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
let key = trust_key_for(&draft.name, args)?;
|
||||
// 短 lock 读 session_trust(仅 contains 判定,无 await,纳秒级),命中即返 key
|
||||
let hit = {
|
||||
let session = session_arc.lock().await;
|
||||
session.conv_read(conv_id)
|
||||
.map(|c| c.session_trust.contains(&key))
|
||||
.unwrap_or(false)
|
||||
};
|
||||
if hit { Some(key) } else { None }
|
||||
}
|
||||
|
||||
/// 插入 pending 审批:生成 diff → 重试检测 → insert PendingApproval + 占位 tool_result
|
||||
@@ -112,11 +117,15 @@ pub(super) fn check_trust_hits(
|
||||
///
|
||||
/// **注意**:调用方应在调用前先 +=1 `pending_count`(保持与原 `handle_approval_tool`
|
||||
/// 行为一致——重试 skip 分支也在 `pending_count += 1` 之后返回)。
|
||||
///
|
||||
/// BUG-260624-03/P0 重构:签名改 `session_arc: &Arc<Mutex<AiSession>>`,所有慢操作
|
||||
/// (build_write_file_diff/detect_retry_count/build_approval_reason/audit_tool_call)
|
||||
/// 在锁外 await,仅 `pending_approvals.insert` + `messages.push` 两处纯写改短 lock 段。
|
||||
pub(super) async fn insert_pending_approval(
|
||||
draft: ToolCallDraft,
|
||||
args: serde_json::Value,
|
||||
risk_level: RiskLevel,
|
||||
session: &mut AiSession,
|
||||
session_arc: &Arc<Mutex<AiSession>>,
|
||||
conv_id: &str,
|
||||
audit_repo: &AiToolExecutionRepo,
|
||||
app_handle: &AppHandle,
|
||||
@@ -144,7 +153,11 @@ pub(super) async fn insert_pending_approval(
|
||||
"已跳过重试(同 tool_call_id={} 此前已审批执行过,防 LLM 死循环重试同卡死工具)",
|
||||
draft.id
|
||||
);
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &skip_msg));
|
||||
// 短 lock 段:push tool_result(纯写,无 await)
|
||||
{
|
||||
let mut session = session_arc.lock().await;
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &skip_msg));
|
||||
}
|
||||
// L3 emit 双写:重试 guard 跳过 emit Completed 双路发布。
|
||||
let ev = AiChatEvent::AiToolCallCompleted {
|
||||
id: draft.id.clone(),
|
||||
@@ -157,19 +170,24 @@ pub(super) async fn insert_pending_approval(
|
||||
return;
|
||||
}
|
||||
|
||||
session.pending_approvals.insert(draft.id.clone(), PendingApproval {
|
||||
tool_call_id: draft.id.clone(),
|
||||
tool_name: draft.name.clone(),
|
||||
arguments: args.clone(),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
recovered: false,
|
||||
// 阶段3a:普通 RiskLevel 审批标 kind=Risk{diff}(下沉原 diff 字段)。
|
||||
kind: ApprovalKind::Risk { diff: approval_diff.clone() },
|
||||
retry_count,
|
||||
created_at: Some(std::time::SystemTime::now()),
|
||||
});
|
||||
// 阶段2:占位带 __PENDING__:tc_id 标记,供 sanitize 豁免保留 + 出口断言自愈(防 400 orphan)
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &pending_placeholder_for(&draft.id)));
|
||||
// 短 lock 段:纯写 pending_approvals.insert + 占位 tool_result push
|
||||
{
|
||||
let mut session = session_arc.lock().await;
|
||||
session.pending_approvals.insert(draft.id.clone(), PendingApproval {
|
||||
tool_call_id: draft.id.clone(),
|
||||
tool_name: draft.name.clone(),
|
||||
arguments: args.clone(),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
recovered: false,
|
||||
// 阶段3a:普通 RiskLevel 审批标 kind=Risk{diff}(下沉原 diff 字段)。
|
||||
kind: ApprovalKind::Risk { diff: approval_diff.clone() },
|
||||
retry_count,
|
||||
created_at: Some(std::time::SystemTime::now()),
|
||||
});
|
||||
// 阶段2:占位带 __PENDING__:tc_id 标记,供 sanitize 豁免保留 + 出口断言自愈(防 400 orphan)
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &pending_placeholder_for(&draft.id)));
|
||||
}
|
||||
// 慢操作锁外:拼 reason(DB 读)+ emit + 审计落 pending 纪录(DB 写)
|
||||
let reason = build_approval_reason(&draft.name, &args, risk_level, db).await;
|
||||
// L3 emit 双写:Med/High 风险审批挂起 AiApprovalRequired 双路发布(tunnel 透传 miniapp 弹审批窗)。
|
||||
let ev = AiChatEvent::AiApprovalRequired {
|
||||
@@ -225,13 +243,16 @@ pub(super) async fn detect_retry_count(audit_repo: &AiToolExecutionRepo, tc_id:
|
||||
/// 2. 否则走审批分支:会话信任(`check_trust_hits`)→ F-05 高危去重缓存 → 阶段4 重试 guard →
|
||||
/// `insert_pending_approval`(write_file diff + 挂起 + emit + 审计落 pending 记录)。
|
||||
///
|
||||
/// **不持 session 锁**:调用方(process_tool_calls)在持锁循环内调用本函数。
|
||||
/// BUG-260624-03/P0 重构:签名改 `session_arc: &Arc<Mutex<AiSession>>`,所有 session 访问
|
||||
/// 都改短 lock 段(check_trust_hits/find_cached_high_risk_result 内部短 lock,命中后 push
|
||||
/// tool_result 短 lock 段)。慢操作(audit_tool_call/find_cached_high_risk_result 的 DB 查)
|
||||
/// 全在锁外 await,根治 process_tool_calls 持 session lock 期间 await 慢操作死锁反模式。
|
||||
pub(super) async fn handle_approval_tool(
|
||||
draft: ToolCallDraft,
|
||||
args: serde_json::Value,
|
||||
risk_level: RiskLevel,
|
||||
auto_exec_mode: &str,
|
||||
session: &mut AiSession,
|
||||
session_arc: &Arc<Mutex<AiSession>>,
|
||||
conv_id: &str,
|
||||
_tools_arc: &Arc<AiToolRegistry>,
|
||||
audit_repo: &AiToolExecutionRepo,
|
||||
@@ -254,7 +275,7 @@ pub(super) async fn handle_approval_tool(
|
||||
// 命中后走与 F-05 去重命中相似的「直接执行 + Completed + 审计 decided_by=auto_trust」路径,
|
||||
// 但与 F-05 不同:F-05 复用缓存 tool_result 跳过执行;trust 放行**真实执行工具**
|
||||
// (用户信任同目录同类操作,但仍要看每次的真实结果)。
|
||||
if let Some(key) = check_trust_hits(&draft, &args, session, conv_id) {
|
||||
if let Some(key) = check_trust_hits(&draft, &args, session_arc, conv_id).await {
|
||||
let dir_label = match &key {
|
||||
TrustKey::Write { dir } | TrustKey::Execute { dir } => dir.clone(),
|
||||
};
|
||||
@@ -282,14 +303,20 @@ pub(super) async fn handle_approval_tool(
|
||||
|
||||
// ── Step 3: F-05 高危去重缓存(仅 High) ──
|
||||
if matches!(risk_level, RiskLevel::High) {
|
||||
if let Some((cached, status)) = find_cached_high_risk_result(session, conv_id, audit_repo, &draft.name, &args).await {
|
||||
// find_cached_high_risk_result 内部短 lock 读 messages + 锁外 await DB 查 status,
|
||||
// 返回 (cached_content, status) 时不持锁
|
||||
if let Some((cached, status)) = find_cached_high_risk_result(session_arc, conv_id, audit_repo, &draft.name, &args).await {
|
||||
// 命中:把缓存结果作为新 tool_call_id 的 tool_result 回传,跳过审批
|
||||
tracing::info!(
|
||||
tool = %draft.name,
|
||||
new_tool_call_id = %draft.id,
|
||||
"[F-05] 高危工具去重命中:LLM 重试同命令,复用缓存结果跳过审批(断循环)"
|
||||
);
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &cached));
|
||||
// 短 lock 段:push tool_result(纯写,无 await)
|
||||
{
|
||||
let mut session = session_arc.lock().await;
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &cached));
|
||||
}
|
||||
// L3 emit 双写:高危去重命中复用缓存 emit Completed 双路发布。
|
||||
let ev = AiChatEvent::AiToolCallCompleted {
|
||||
id: draft.id.clone(),
|
||||
@@ -307,7 +334,7 @@ pub(super) async fn handle_approval_tool(
|
||||
// ── Step 4: 插入 pending 审批 ──
|
||||
*pending_count += 1;
|
||||
insert_pending_approval(
|
||||
draft, args, risk_level, session, conv_id, audit_repo, app_handle, db,
|
||||
draft, args, risk_level, session_arc, conv_id, audit_repo, app_handle, db,
|
||||
current_message_id,
|
||||
).await;
|
||||
}
|
||||
|
||||
@@ -4,8 +4,11 @@
|
||||
//! + `sort_object_keys` + `PENDING_APPROVAL_PLACEHOLDER`(去重与 process_tool_calls
|
||||
//! 共用占位常量)。三函数内部闭环(find_cached 调 canonical→sort),无外部 helper 依赖。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use df_ai::provider::ChatMessage;
|
||||
use df_storage::crud::AiToolExecutionRepo;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use super::super::AiSession;
|
||||
|
||||
@@ -70,7 +73,7 @@ pub(crate) fn is_pending_placeholder(content: &str) -> bool {
|
||||
/// F-260616-09 B 批2:经`conv_id` 索引 per_conv.messages(顶层 messages 批2 后是死字段)。
|
||||
/// conv_id 来源:process_tool_calls 入参 → 由 agentic/mod.rs run_agentic_loop 入参透传。
|
||||
pub(crate) async fn find_cached_high_risk_result(
|
||||
session: &AiSession,
|
||||
session_arc: &Arc<Mutex<AiSession>>,
|
||||
conv_id: &str,
|
||||
audit_repo: &AiToolExecutionRepo,
|
||||
tool_name: &str,
|
||||
@@ -81,70 +84,78 @@ pub(crate) async fn find_cached_high_risk_result(
|
||||
// 规范化新调用的 args 为可比字符串(排序键,键序无关)
|
||||
let new_args_key = canonical_args_key(args);
|
||||
|
||||
// F-260616-09 B 批2:读 per_conv.messages。process_tool_calls 调用前 loop 入口已桥接建立 per_conv,
|
||||
// 故 conv_read 必命中;防御性 None 时返 None(无缓存命中,走原审批流程)。
|
||||
let conv = session.conv_read(conv_id)?;
|
||||
// ContextManager::iter 返回 impl Iterator(非 DoubleEnded),collect 成 Vec 再反向遍历。
|
||||
// 单对话消息量小(百级),collect 开销可忽略。
|
||||
let msgs: Vec<&ChatMessage> = conv.messages.iter().collect();
|
||||
// BUG-260624-03/P0 重构:短 lock 段读 messages + 找旧 tool_call_id + 旧 tool_result content,
|
||||
// drop 锁后再锁外 await DB 查 status(原代码持锁 await audit_repo,违反持锁 await 慢操作禁令)。
|
||||
// 第一步:锁内(async block 包裹,出 block 自动 drop guard)反向扫描,定位旧 tool_call_id 与对应 tool_result content
|
||||
let cached: Option<(String, String)> = (async {
|
||||
let session = session_arc.lock().await;
|
||||
// F-260616-09 B 批2:读 per_conv.messages。process_tool_calls 调用前 loop 入口已桥接建立 per_conv,
|
||||
// 故 conv_read 必命中;防御性 None 时返 None(无缓存命中,走原审批流程)。
|
||||
let Some(conv) = session.conv_read(conv_id) else { return None };
|
||||
// ContextManager::iter 返回 impl Iterator(非 DoubleEnded),collect 成 Vec 再反向遍历。
|
||||
// 单对话消息量小(百级),collect 开销可忽略。
|
||||
let msgs: Vec<&ChatMessage> = conv.messages.iter().collect();
|
||||
|
||||
// 1) 反向扫描 assistant tool_calls,找最近一条同名同参的 High 工具调用 → 拿到旧 tool_call_id
|
||||
// 反向:循环是「最近一次超时→重试」,命中通常是末尾附近,反向先停省全扫。
|
||||
let mut prev_tool_call_id: Option<String> = None;
|
||||
for msg in msgs.iter().rev() {
|
||||
if !matches!(msg.role, MessageRole::Assistant) {
|
||||
continue;
|
||||
}
|
||||
let Some(tcs) = msg.tool_calls.as_ref() else { continue };
|
||||
for tc in tcs {
|
||||
if tc.function.name != tool_name {
|
||||
// 1) 反向扫描 assistant tool_calls,找最近一条同名同参的 High 工具调用 → 拿到旧 tool_call_id
|
||||
// 反向:循环是「最近一次超时→重试」,命中通常是末尾附近,反向先停省全扫。
|
||||
let mut prev_tool_call_id: Option<String> = None;
|
||||
for msg in msgs.iter().rev() {
|
||||
if !matches!(msg.role, MessageRole::Assistant) {
|
||||
continue;
|
||||
}
|
||||
// 旧调用的 args 是流式拼接的 JSON 字符串,解析失败跳过(不误判为命中)
|
||||
let Ok(old_args) = serde_json::from_str::<serde_json::Value>(&tc.function.arguments) else {
|
||||
continue;
|
||||
};
|
||||
if canonical_args_key(&old_args) == new_args_key {
|
||||
prev_tool_call_id = Some(tc.id.clone());
|
||||
let Some(tcs) = msg.tool_calls.as_ref() else { continue };
|
||||
for tc in tcs {
|
||||
if tc.function.name != tool_name {
|
||||
continue;
|
||||
}
|
||||
// 旧调用的 args 是流式拼接的 JSON 字符串,解析失败跳过(不误判为命中)
|
||||
let Ok(old_args) = serde_json::from_str::<serde_json::Value>(&tc.function.arguments) else {
|
||||
continue;
|
||||
};
|
||||
if canonical_args_key(&old_args) == new_args_key {
|
||||
prev_tool_call_id = Some(tc.id.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
if prev_tool_call_id.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if prev_tool_call_id.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 用旧 tool_call_id 找对应 tool_result。注意:审批拒绝/超时失败也属「已落定」,
|
||||
// 其 tool_result 内容同样回传(LLM 看到原反馈自行决定,不再逼用户二次审批)。
|
||||
// pending 占位(「需要用户审批,等待确认」)不命中——仍在审批中,走原流程。
|
||||
let old_id = prev_tool_call_id?;
|
||||
for msg in msgs.iter().rev() {
|
||||
if !matches!(msg.role, MessageRole::Tool) {
|
||||
continue;
|
||||
// 2) 用旧 tool_call_id 找对应 tool_result。注意:审批拒绝/超时失败也属「已落定」,
|
||||
// 其 tool_result 内容同样回传(LLM 看到原反馈自行决定,不再逼用户二次审批)。
|
||||
// pending 占位(「需要用户审批,等待确认」)不命中——仍在审批中,走原流程。
|
||||
let Some(old_id) = prev_tool_call_id else { return None };
|
||||
for msg in msgs.iter().rev() {
|
||||
if !matches!(msg.role, MessageRole::Tool) {
|
||||
continue;
|
||||
}
|
||||
if msg.tool_call_id.as_deref() != Some(old_id.as_str()) {
|
||||
continue;
|
||||
}
|
||||
// 命中旧 tool_result:排除 pending 占位(基础文本或带 __PENDING__:tc_id 标记)
|
||||
// 阶段2:占位带标记后不再精确等于基础文本,改用 is_pending_placeholder 匹配。
|
||||
// 加固(子串误伤):`__PENDING__` 标记是 audit/cache.rs 占位模板独占信号(权威判定);
|
||||
// 老占位分支已收紧为精确全文等值(非 starts_with 前缀),杜绝用户真实 tool_result 内容
|
||||
// 恰以"需要用户审批..."开头被误判占位致去重误吞(详见 context_helpers::is_pending_placeholder)。
|
||||
if is_pending_placeholder(&msg.content) {
|
||||
return None;
|
||||
}
|
||||
return Some((old_id, msg.content.clone()));
|
||||
}
|
||||
if msg.tool_call_id.as_deref() != Some(old_id.as_str()) {
|
||||
continue;
|
||||
}
|
||||
// 命中旧 tool_result:排除 pending 占位(基础文本或带 __PENDING__:tc_id 标记)
|
||||
// 阶段2:占位带标记后不再精确等于基础文本,改用 is_pending_placeholder 匹配。
|
||||
// 加固(子串误伤):`__PENDING__` 标记是 audit/cache.rs 占位模板独占信号(权威判定);
|
||||
// 老占位分支已收紧为精确全文等值(非 starts_with 前缀),杜绝用户真实 tool_result 内容
|
||||
// 恰以"需要用户审批..."开头被误判占位致去重误吞(详见 context_helpers::is_pending_placeholder)。
|
||||
if is_pending_placeholder(&msg.content) {
|
||||
return None;
|
||||
}
|
||||
// SW-260618-16: 查审计表拿缓存来源真实 status(completed/rejected/failed),透传给
|
||||
// audit_tool_call 而非固定 completed(审计语义与结果内容一致,防"rejected/failed 结果
|
||||
// 记 completed"误导安全追溯)。审计记录缺失/查询失败 fallback completed(不阻塞去重,降级原行为)。
|
||||
let status = audit_repo
|
||||
.find_by_tool_call_id(old_id.as_str())
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|opt| opt.map(|rec| rec.status))
|
||||
.unwrap_or_else(|| "completed".to_string());
|
||||
return Some((msg.content.clone(), status));
|
||||
}
|
||||
None
|
||||
None
|
||||
}).await;
|
||||
// 锁外:查审计表拿缓存来源真实 status(completed/rejected/failed),透传给
|
||||
// audit_tool_call 而非固定 completed(审计语义与结果内容一致,防"rejected/failed 结果
|
||||
// 记 completed"误导安全追溯)。审计记录缺失/查询失败 fallback completed(不阻塞去重,降级原行为)。
|
||||
let (old_id, content) = cached?;
|
||||
let status = audit_repo
|
||||
.find_by_tool_call_id(old_id.as_str())
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|opt| opt.map(|rec| rec.status))
|
||||
.unwrap_or_else(|| "completed".to_string());
|
||||
Some((content, status))
|
||||
}
|
||||
|
||||
/// 把 JSON args 规范化为可比字符串:对象键按字典序排序后序列化,
|
||||
@@ -156,6 +167,117 @@ fn canonical_args_key(args: &serde_json::Value) -> String {
|
||||
serde_json::to_string(&v).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// BUG-2026-07-07/P0-2:只读幂等工具结果缓存(治 LLM 死循环重调)。
|
||||
///
|
||||
/// **根因链(实测 9357c27c)**:LLM 无"已调用过"记忆,对同参只读工具反复触发:
|
||||
/// - `read_file(application.xml)` 读了 17 次(两次返回完全相同的 8457c 全文)
|
||||
/// - `search_files(pattern='.java')` 连调 13 次(每次返回 items=0/has_more=True 几乎一致)
|
||||
/// 每次都真执行 + 全文回灌 tool_result,是 prompt 64 万的直接元凶。
|
||||
///
|
||||
/// **治本**:对幂等只读工具,扫描会话历史,若发现**已成功完成**的同类同参调用,
|
||||
/// 返回其 tool_result 内容,调用方把缓存结果作为新 tool_call_id 的 tool_result 回传 LLM,
|
||||
/// 跳过真实执行(也跳过 emit Started/Completed——直接走缓存回填路径)。
|
||||
///
|
||||
/// **安全边界**(对照 find_cached_high_risk_result 严格收紧):
|
||||
/// - 仅匹配白名单只读幂等工具(READONLY_CACHE_TOOLS):read_file/list_directory/
|
||||
/// search_files/grep/read_symbol。写工具(write_file/patch_file/run_command 等)
|
||||
/// 永不缓存,避免误伤。
|
||||
/// - 仅匹配 **status=completed** 的成功结果(失败/拒绝不缓存,可能重试成功)。
|
||||
/// - 仅匹配 **非占位** 结果(排除 pending_placeholder 占位)。
|
||||
/// - args 走 JSON 规范化比较(键序无关),与 find_cached_high_risk_result 一致。
|
||||
/// - 返回 None 表示无缓存命中(走原执行流程)。
|
||||
///
|
||||
/// 与 find_cached_high_risk_result 区别:后者治"超时→重试→重新审批"循环( High risk),
|
||||
/// 本函数治"LLM 失忆→同参重调"死循环( Low risk 只读),问题形态相似但触发原因不同。
|
||||
pub(crate) async fn find_cached_readonly_result(
|
||||
session_arc: &Arc<Mutex<AiSession>>,
|
||||
conv_id: &str,
|
||||
audit_repo: &AiToolExecutionRepo,
|
||||
tool_name: &str,
|
||||
args: &serde_json::Value,
|
||||
) -> Option<String> {
|
||||
use df_ai::provider::MessageRole;
|
||||
|
||||
// 白名单:只读幂等工具才缓存。写工具/有副作用工具永不缓存。
|
||||
if !READONLY_CACHE_TOOLS.contains(&tool_name) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let new_args_key = canonical_args_key(args);
|
||||
|
||||
// BUG-260624-03/P0 重构:短 lock 段读 messages + 锁外 await DB 查 status(原代码持锁 await)
|
||||
let cached: Option<(String, String)> = (async {
|
||||
let session = session_arc.lock().await;
|
||||
let Some(conv) = session.conv_read(conv_id) else { return None };
|
||||
let msgs: Vec<&ChatMessage> = conv.messages.iter().collect();
|
||||
|
||||
// 1) 反向扫描 assistant tool_calls,找最近一条同名同参调用 → 拿到旧 tool_call_id
|
||||
let mut prev_tool_call_id: Option<String> = None;
|
||||
for msg in msgs.iter().rev() {
|
||||
if !matches!(msg.role, MessageRole::Assistant) {
|
||||
continue;
|
||||
}
|
||||
let Some(tcs) = msg.tool_calls.as_ref() else { continue };
|
||||
for tc in tcs {
|
||||
if tc.function.name != tool_name {
|
||||
continue;
|
||||
}
|
||||
let Ok(old_args) = serde_json::from_str::<serde_json::Value>(&tc.function.arguments) else {
|
||||
continue;
|
||||
};
|
||||
if canonical_args_key(&old_args) == new_args_key {
|
||||
prev_tool_call_id = Some(tc.id.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
if prev_tool_call_id.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 用旧 tool_call_id 找对应 tool_result
|
||||
let Some(old_id) = prev_tool_call_id else { return None };
|
||||
for msg in msgs.iter().rev() {
|
||||
if !matches!(msg.role, MessageRole::Tool) {
|
||||
continue;
|
||||
}
|
||||
if msg.tool_call_id.as_deref() != Some(old_id.as_str()) {
|
||||
continue;
|
||||
}
|
||||
// 排除 pending 占位(仍在审批中,未真执行)
|
||||
if is_pending_placeholder(&msg.content) {
|
||||
return None;
|
||||
}
|
||||
return Some((old_id, msg.content.clone()));
|
||||
}
|
||||
None
|
||||
}).await;
|
||||
// 锁外:查审计表确认是 completed 成功结果(失败/拒绝不缓存,给 LLM 重试机会)
|
||||
let (old_id, content) = cached?;
|
||||
let status = audit_repo
|
||||
.find_by_tool_call_id(old_id.as_str())
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|opt| opt.map(|rec| rec.status))
|
||||
.unwrap_or_else(|| "completed".to_string());
|
||||
if status != "completed" {
|
||||
return None;
|
||||
}
|
||||
Some(content)
|
||||
}
|
||||
|
||||
/// 可缓存的只读幂等工具白名单。
|
||||
/// 仅含纯读取/查询类工具:同参多次调用结果一致,缓存命中安全。
|
||||
/// 写工具/有副作用工具(write_file/patch_file/run_command/create_*/update_*/delete_* 等)
|
||||
/// 永不进此白名单——它们每次执行都可能改变状态,缓存会掩盖变化。
|
||||
const READONLY_CACHE_TOOLS: &[&str] = &[
|
||||
"read_file", // 读文件内容(同 path+offset+limit+search 参数 → 同结果)
|
||||
"read_symbol", // AST 符号读取(同 path+symbol+full → 同结果)
|
||||
"list_directory", // 列目录(同 path+recursive+max_depth → 同结果)
|
||||
"search_files", // 搜文件名(同 path+pattern+offset+limit → 同结果)
|
||||
"grep", // 搜文件内容(同 path+pattern+mode → 同结果)
|
||||
];
|
||||
|
||||
/// 递归对 JSON 对象的键做字典序排序(就地),数组成员也递归排序。
|
||||
fn sort_object_keys(v: &mut serde_json::Value) {
|
||||
match v {
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
|
||||
use df_ai::provider::ChatMessage;
|
||||
@@ -62,10 +63,10 @@ pub use restore::restore_pending_approvals;
|
||||
mod finalize;
|
||||
pub(crate) use finalize::audit_finalize;
|
||||
|
||||
// cache(audit/cache.rs):F-260616-05 高危工具去重缓存。
|
||||
// 第三批从本文件抽离,行为零变更。
|
||||
// cache(audit/cache.rs):F-260616-05 高危工具去重缓存 + BUG-2026-07-07/P0-2 只读工具缓存。
|
||||
// 第三批从本文件抽离,行为零变更(P0-2 为新增能力)。
|
||||
mod cache;
|
||||
pub(super) use cache::pending_placeholder_for;
|
||||
pub(super) use cache::{find_cached_readonly_result, pending_placeholder_for};
|
||||
|
||||
// data_change(audit/data_change.rs):AR-11 数据变更联动刷新。
|
||||
// 第四批从本文件抽离,行为零变更。pub(crate) use 保持 emit_data_changed 对 crate 内可见
|
||||
@@ -136,11 +137,25 @@ async fn execute_with_heartbeat(
|
||||
});
|
||||
// RAII:execute 无论 Ok/Err/panic(unwind),_guard drop 自动 stop+abort,无心跳 task 泄漏。
|
||||
let _guard = HeartbeatGuard { stop, handle: heartbeat };
|
||||
tools.execute(name, args).await
|
||||
// BUG-2026-07-19: tools.execute 无 timeout 时,卡死工具(run_command 长命令/read_file 大文件/同步
|
||||
// 阻塞工具)永久挂起 → process_tool_calls 持 session lock 永久 → guard.reset 等 lock → AiCompleted
|
||||
// 永不发 → 前端"回答完卡住/超时清空"。60s timeout 兜底:超时返错误 tool_result,锁释放,loop 续跑。
|
||||
// 心跳 30s 续命前端 watchdog,60s timeout 覆盖绝大多数工具(run_command 已自带 10s 子超时)。
|
||||
match tokio::time::timeout(Duration::from_secs(60), tools.execute(name, args)).await {
|
||||
Ok(result) => result,
|
||||
Err(_elapsed) => {
|
||||
tracing::error!(
|
||||
conv_id = %conv_id,
|
||||
tool = %name,
|
||||
"[ai] 工具执行超时(60s),返回错误 tool_result(防 process_tool_calls 持 session lock 永久卡死)"
|
||||
);
|
||||
Err(anyhow::anyhow!("工具执行超时(60s),已中止(防死锁)"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn process_tool_calls(
|
||||
session: &mut AiSession,
|
||||
session_arc: &Arc<Mutex<AiSession>>,
|
||||
tool_calls_acc: HashMap<u32, ToolCallDraft>,
|
||||
tools_arc: &Arc<AiToolRegistry>,
|
||||
db: &Arc<Database>,
|
||||
@@ -164,9 +179,33 @@ pub(crate) async fn process_tool_calls(
|
||||
// push 到 per_conv.messages(audit/mod.rs:882),此处取末条 assistant id 作为本轮工具
|
||||
// 调用所属的溯源 message_id,贯穿所有 audit_tool_call 写入。None 表示无 assistant 消息
|
||||
// (异常路径/老数据无 id),audit 落 message_id=None,展示侧兼容。
|
||||
let current_message_id: Option<String> = session
|
||||
.conv_read(conv_id)
|
||||
.and_then(|c| c.messages.last_assistant_message_id());
|
||||
//
|
||||
// BUG-260624-03/P0 重构:短 lock 段读 current_message_id(纯读,无 await),clone 出来后续传参。
|
||||
// 整个 process_tool_calls 不再要求调用方持锁,内部所有 session.xxx 访问均短 lock 段,
|
||||
// 慢操作(execute_with_heartbeat/audit_tool_call/detect_retry_count/DB 查)全在锁外 await。
|
||||
// BUG-2026-07-19: current_message_id 读加 200ms timeout(防 session lock 竞争卡死致
|
||||
// process_tool_calls 进不去 emit AiToolCallStarted → 前端工具卡片不呈现 + 45s 看门狗超时)。
|
||||
// 超时用 None(溯源降级,非致命)。治本:消除 session lock 长持有(save clone 段已优化)。
|
||||
let current_message_id: Option<String> = match tokio::time::timeout(
|
||||
std::time::Duration::from_millis(200),
|
||||
async {
|
||||
let __lock_t = std::time::Instant::now();
|
||||
let session = session_arc.lock().await;
|
||||
let __id = session.conv_read(conv_id)
|
||||
.and_then(|c| c.messages.last_assistant_message_id());
|
||||
let __hold = __lock_t.elapsed();
|
||||
if __hold > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] process_tool_calls:192 持锁 {:?} (含 lock 等待)", __hold);
|
||||
}
|
||||
__id
|
||||
},
|
||||
).await {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
tracing::warn!(conv_id = %conv_id, "[ai] process_tool_calls current_message_id lock 200ms 超时,溯源降级 None(session lock 竞争)");
|
||||
None
|
||||
}
|
||||
};
|
||||
let current_message_id = current_message_id.as_deref();
|
||||
|
||||
// 解析 args + 批量发 Started(前端骨架按原始 index 顺序展示)
|
||||
@@ -218,8 +257,22 @@ pub(crate) async fn process_tool_calls(
|
||||
// Phase C: Denied 路径 → 硬拒(push 错误 tool_result + emit Completed),不挂起 loop。
|
||||
// 工具返 Err 让 LLM 知路径被禁,自行调整;loop 继续下一轮(不暂停)。
|
||||
for (draft, reason) in path_denied {
|
||||
let err_msg = format!("路径授权拒绝: {}", reason);
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &err_msg));
|
||||
// BUG-2026-07-07/P1-3:拒绝消息改结构化 JSON,防裸文本破坏 tool role 消息协议
|
||||
let err_msg = serde_json::json!({
|
||||
"status": "rejected",
|
||||
"reason": "path_blacklist",
|
||||
"message": format!("路径授权拒绝: {}", reason)
|
||||
}).to_string();
|
||||
// 短 lock 段:push tool_result(纯写,无 await)
|
||||
{
|
||||
let __lock_t = std::time::Instant::now();
|
||||
let mut session = session_arc.lock().await;
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &err_msg));
|
||||
let __hold = __lock_t.elapsed();
|
||||
if __hold > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] process_tool_calls:262 持锁 {:?} (含 lock 等待)", __hold);
|
||||
}
|
||||
}
|
||||
// L3 emit 双写:路径黑名单拒绝 emit Completed 双路发布。
|
||||
let ev = AiChatEvent::AiToolCallCompleted {
|
||||
id: draft.id.clone(),
|
||||
@@ -240,6 +293,9 @@ pub(crate) async fn process_tool_calls(
|
||||
// Phase B: NeedsAuth 路径 → 挂起 loop(insert pending + emit AiDirAuthRequired + push 占位 tool_result)。
|
||||
// 复用审批挂起架构:pending_approvals 以 tool_call_id 为键,ai_authorize_dir IPC remove 后恢复。
|
||||
// pending_count 计入(让 agentic loop 检测到挂起并暂停,等 ai_authorize_dir → try_continue 恢复)。
|
||||
//
|
||||
// BUG-260624-03/P0 重构:detect_retry_count(DB 读)/emit/audit_tool_call(DB 写)均在锁外,
|
||||
// 仅 push tool_result + pending_approvals.insert 两处纯写改短 lock 段。
|
||||
for (draft, args, req) in path_auth_pending {
|
||||
pending_count += 1;
|
||||
// L1 补丁:req.dirs 含所有未授权父目录;emit 用首个作主展示目录(前端弹窗主显)。
|
||||
@@ -248,14 +304,23 @@ pub(crate) async fn process_tool_calls(
|
||||
.map(|d| d.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let path_str = req.raw_paths.first().cloned().unwrap_or_default();
|
||||
// 阶段4(容错/恢复,开关 df-ai-approval-retry):同 tc_id 重试检测。
|
||||
// 锁外:阶段4(容错/恢复,开关 df-ai-approval-retry):同 tc_id 重试检测。
|
||||
let retry_count = detect_retry_count(&audit_repo, &draft.id).await;
|
||||
if retry_count >= 1 {
|
||||
let skip_msg = format!(
|
||||
"已跳过重试(同 tool_call_id={} 此前已审批执行过,防 LLM 死循环重试同卡死工具)",
|
||||
draft.id
|
||||
);
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &skip_msg));
|
||||
// 短 lock 段:push tool_result(纯写)
|
||||
{
|
||||
let __lock_t = std::time::Instant::now();
|
||||
let mut session = session_arc.lock().await;
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &skip_msg));
|
||||
let __hold = __lock_t.elapsed();
|
||||
if __hold > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] process_tool_calls:305 持锁 {:?} (含 lock 等待)", __hold);
|
||||
}
|
||||
}
|
||||
let ev = AiChatEvent::AiToolCallCompleted {
|
||||
id: draft.id.clone(),
|
||||
result: serde_json::Value::String(skip_msg.clone()),
|
||||
@@ -271,22 +336,31 @@ pub(crate) async fn process_tool_calls(
|
||||
).await;
|
||||
continue;
|
||||
}
|
||||
session.pending_approvals.insert(
|
||||
draft.id.clone(),
|
||||
PendingApproval {
|
||||
tool_call_id: draft.id.clone(),
|
||||
tool_name: draft.name.clone(),
|
||||
arguments: args.clone(),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
recovered: false,
|
||||
// 阶段3a:路径授权挂起标 kind=Path(req)(下沉原 path_auth 字段)。
|
||||
kind: ApprovalKind::Path(req),
|
||||
retry_count,
|
||||
created_at: Some(std::time::SystemTime::now()),
|
||||
},
|
||||
);
|
||||
// 占位 tool_result(与 RiskLevel 审批一致),ai_authorize_dir 批准后替换为真实结果。
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &pending_placeholder_for(&draft.id)));
|
||||
// 短 lock 段:pending_approvals.insert + 占位 tool_result push(两处纯写,无 await)
|
||||
{
|
||||
let __lock_t = std::time::Instant::now();
|
||||
let mut session = session_arc.lock().await;
|
||||
session.pending_approvals.insert(
|
||||
draft.id.clone(),
|
||||
PendingApproval {
|
||||
tool_call_id: draft.id.clone(),
|
||||
tool_name: draft.name.clone(),
|
||||
arguments: args.clone(),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
recovered: false,
|
||||
// 阶段3a:路径授权挂起标 kind=Path(req)(下沉原 path_auth 字段)。
|
||||
kind: ApprovalKind::Path(req),
|
||||
retry_count,
|
||||
created_at: Some(std::time::SystemTime::now()),
|
||||
},
|
||||
);
|
||||
// 占位 tool_result(与 RiskLevel 审批一致),ai_authorize_dir 批准后替换为真实结果。
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &pending_placeholder_for(&draft.id)));
|
||||
let __hold = __lock_t.elapsed();
|
||||
if __hold > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] process_tool_calls:325 持锁 {:?} (含 lock 等待)", __hold);
|
||||
}
|
||||
}
|
||||
tracing::debug!(target: "ai_dirauth", conv = %conv_id, tool = %draft.name, tc_id = %draft.id, "emit AiDirAuthRequired(路径授权挂起,等 ai_authorize_dir 恢复)");
|
||||
let ev = AiChatEvent::AiDirAuthRequired {
|
||||
id: draft.id.clone(),
|
||||
@@ -351,17 +425,17 @@ pub(crate) async fn process_tool_calls(
|
||||
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
|
||||
handle_approval_tool(
|
||||
draft, args, risk_level, &auto_exec_mode,
|
||||
session, conv_id, tools_arc, &audit_repo,
|
||||
session_arc, conv_id, tools_arc, &audit_repo,
|
||||
app_handle, db, &mut low_risk, &mut trust_hits,
|
||||
&mut pending_count, current_message_id,
|
||||
).await;
|
||||
}
|
||||
|
||||
// AE-04 trust-hit 并行执行:execute + 即时 emit 在闭包内(闭包不访问 session,非"锁已释放"——
|
||||
// session 锁仍由调用方 agentic/mod.rs 持有至 process_tool_calls 返回,CR-53 审查纠正原"移锁外"误述),
|
||||
// push tool_result / audit 在 join_all 后串行回填(持锁)。对齐 Low risk 并行模式。
|
||||
// AE-04 trust-hit 并行执行:execute + 即时 emit 在闭包内(闭包不访问 session,
|
||||
// BUG-260624-03/P0 重构后调用方不再持锁,本段所有 await 完全在锁外),
|
||||
// push tool_result / audit 在 join_all 后串行回填(短 lock push + 锁外 audit)。对齐 Low risk 并行模式。
|
||||
// CR-51 修:原 inline .await execute 串行执行每个工具(阻塞期间锁被持有,run_command 慢命令
|
||||
// 阻塞同会话 IPC);改 join_all 并行多工具减少总阻塞时间(锁持有时长不变,并行化降阻塞)。
|
||||
// 阻塞同会话 IPC);改 join_all 并行多工具减少总阻塞时间。P0 重构后锁外并行,根本消除阻塞。
|
||||
// join_all 保序——结果顺序 = trust_hits 输入顺序 = tc_list 原始 index 顺序,不额外 sort
|
||||
if !trust_hits.is_empty() {
|
||||
let results: Vec<(ToolCallDraft, RiskLevel, Result<String, String>)> =
|
||||
@@ -403,23 +477,68 @@ pub(crate) async fn process_tool_calls(
|
||||
}
|
||||
})).await;
|
||||
|
||||
// 串行回填 tool_result + 审计(持 session 锁)
|
||||
// 串行回填 tool_result + 审计(短 lock push + 锁外 audit)
|
||||
for (draft, risk_level, outcome) in results {
|
||||
let (status, content) = match outcome {
|
||||
Ok(c) => ("completed", c),
|
||||
Err(c) => ("failed", c),
|
||||
};
|
||||
// F-260616-09 B 批2:写 per_conv.messages。
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, content.clone()));
|
||||
// 审计:trust 放行仍记一条(decided_by=auto_trust),留痕可追溯
|
||||
// 短 lock 段:push tool_result(纯写,无 await)
|
||||
{
|
||||
let mut session = session_arc.lock().await;
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, content.clone()));
|
||||
}
|
||||
// 锁外:审计(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;
|
||||
}
|
||||
}
|
||||
|
||||
// Low 风险并行执行:execute + 即时 emit 在闭包内(不持 session 锁),
|
||||
// push tool_result / audit 在 join_all 后串行回填(持锁,与 Med/High 占位拼接)。
|
||||
// push tool_result / audit 在 join_all 后串行回填(短 lock push + 锁外 audit,与 Med/High 占位拼接)。
|
||||
// join_all 保序——结果顺序 = low_risk 输入顺序 = tc_list 原始 index 顺序,不额外 sort
|
||||
if !low_risk.is_empty() {
|
||||
// BUG-2026-07-07/P0-2:只读幂等工具去重缓存(治 LLM 死循环重调)。
|
||||
// 实测 9357c27c 会话:LLM 对 read_file(application.xml) 连调 17 次、search_files 连调 13 次,
|
||||
// 每次返回几乎相同结果却反复重调,是 prompt 64 万的直接元凶。此处先查会话内是否已对
|
||||
// 同参只读工具成功执行过,命中则直接回填缓存结果跳过真执行,断 LLM 失忆死循环。
|
||||
// 安全边界见 find_cached_readonly_result 文档(仅白名单只读工具 + 仅 completed 成功结果)。
|
||||
//
|
||||
// BUG-260624-03/P0 重构:find_cached_readonly_result 内部短 lock + 锁外 DB 查,本段不持锁。
|
||||
let mut low_risk_uncached: Vec<(ToolCallDraft, serde_json::Value, RiskLevel)> = Vec::with_capacity(low_risk.len());
|
||||
for (draft, args, risk_level) in low_risk {
|
||||
let cached = find_cached_readonly_result(session_arc, conv_id, &audit_repo, &draft.name, &args).await;
|
||||
if let Some(cached_content) = cached {
|
||||
// 缓存命中:直接 push tool_result + 审计(decided_by=cache_hit 标记缓存来源),
|
||||
// 不走真执行 + 不重emit Started/Completed(避免误导前端工具又执行了一次)。
|
||||
// emit Completed 携带缓存结果供前端折叠卡片展示(与 find_cached_high_risk_result 一致)。
|
||||
let ev = AiChatEvent::AiToolCallCompleted {
|
||||
id: draft.id.clone(),
|
||||
result: serde_json::Value::String(cached_content.clone()),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
// 短 lock 段:push tool_result(纯写,无 await)
|
||||
{
|
||||
let mut session = session_arc.lock().await;
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &cached_content));
|
||||
}
|
||||
audit_tool_call(
|
||||
&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
|
||||
"completed", risk_level, Some(cached_content), Some("cache_hit"),
|
||||
current_message_id,
|
||||
).await;
|
||||
tracing::info!(
|
||||
conv_id = %conv_id,
|
||||
tool = %draft.name,
|
||||
tc_id = %draft.id,
|
||||
"[ai] 只读工具缓存命中,跳过真执行(治 LLM 死循环重调)"
|
||||
);
|
||||
} else {
|
||||
low_risk_uncached.push((draft, args, risk_level));
|
||||
}
|
||||
}
|
||||
let low_risk = low_risk_uncached;
|
||||
// 携带 args + 原始 JSON result(create_idea source 补全需解析 result.id + args.source)。
|
||||
let results: Vec<(ToolCallDraft, serde_json::Value, RiskLevel, Result<String, String>)> =
|
||||
futures::future::join_all(low_risk.into_iter().map(|(draft, args, risk_level)| {
|
||||
@@ -464,7 +583,7 @@ pub(crate) async fn process_tool_calls(
|
||||
}
|
||||
})).await;
|
||||
|
||||
// 串行回填 tool_result + 审计(持 session 锁)
|
||||
// 串行回填 tool_result + 审计(短 lock push + 锁外 audit)
|
||||
for (draft, raw_result, risk_level, outcome) in results {
|
||||
let (status, content) = match outcome {
|
||||
Ok(c) => ("completed", c),
|
||||
@@ -477,8 +596,11 @@ pub(crate) async fn process_tool_calls(
|
||||
let args_val = serde_json::from_str(&draft.args).unwrap_or(serde_json::Value::Null);
|
||||
maybe_fill_idea_source(db, &draft.name, &args_val, &raw_result, current_message_id).await;
|
||||
}
|
||||
// F-260616-09 B 批2:写 per_conv.messages。
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, content.clone()));
|
||||
// 短 lock 段:写 per_conv.messages(纯写,无 await)
|
||||
{
|
||||
let mut session = session_arc.lock().await;
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, content.clone()));
|
||||
}
|
||||
// F-#97 审计留痕:low_risk 向量中 risk_level != Low 即 mode 放行(Med 仅 medium/all、High 仅 all 才进)。
|
||||
// decided_by 区分接管来源,审计表可追溯 all 模式执行了多少高危命令(治 securityReview blocker:
|
||||
// 原硬编码 RiskLevel::Low 致 Med/High 接管操作全标 Low/auto 失真,等于没记)。
|
||||
@@ -492,12 +614,18 @@ pub(crate) async fn process_tool_calls(
|
||||
}
|
||||
|
||||
// 阶段3a:单表 pending_approvals 统计 pending 总数(path/risk 合一,kind 区分)。
|
||||
let (path_count, risk_count) = session.pending_approvals.values()
|
||||
.fold((0usize, 0usize), |(p, r), a| match a.kind {
|
||||
ApprovalKind::Path(_) => (p + 1, r),
|
||||
ApprovalKind::Risk { .. } => (p, r + 1),
|
||||
});
|
||||
tracing::debug!(target: "ai_dirauth", pending_count, pending_approvals_len = session.pending_approvals.len(), path_count, risk_count, "process_tool_calls 返回(路径/风险挂起分布)");
|
||||
// 短 lock 段:读 pending_approvals 统计(纯读,无 await)
|
||||
let (path_count, risk_count, pending_approvals_len) = {
|
||||
let session = session_arc.lock().await;
|
||||
let len = session.pending_approvals.len();
|
||||
let (p, r) = session.pending_approvals.values()
|
||||
.fold((0usize, 0usize), |(p, r), a| match a.kind {
|
||||
ApprovalKind::Path(_) => (p + 1, r),
|
||||
ApprovalKind::Risk { .. } => (p, r + 1),
|
||||
});
|
||||
(p, r, len)
|
||||
};
|
||||
tracing::debug!(target: "ai_dirauth", pending_count, pending_approvals_len, path_count, risk_count, "process_tool_calls 返回(路径/风险挂起分布)");
|
||||
pending_count
|
||||
}
|
||||
|
||||
|
||||
@@ -304,12 +304,15 @@ impl MentionResolver for SkillResolver {
|
||||
}
|
||||
};
|
||||
// 取剥 frontmatter 正文(注入用);None = 缓存未命中或文件读失败
|
||||
let body = read_skill_content_stripped(&name).ok_or_else(|| ResolveError::NotFound {
|
||||
kind: "skill".to_string(),
|
||||
ref_id: name.clone(),
|
||||
})?;
|
||||
let body = read_skill_content_stripped(name.clone())
|
||||
.await
|
||||
.ok_or_else(|| ResolveError::NotFound {
|
||||
kind: "skill".to_string(),
|
||||
ref_id: name.clone(),
|
||||
})?;
|
||||
// source 从缓存取 SkillInfo.source;失败(缓存不一致)默认 "skill"
|
||||
let source = crate::commands::ai::skills::skills_cached()
|
||||
.await
|
||||
.into_iter()
|
||||
.find(|s| s.name == name)
|
||||
.map(|s| s.source)
|
||||
|
||||
@@ -24,9 +24,11 @@ use crate::state::AppState;
|
||||
use crate::commands::{err_str, now_millis};
|
||||
|
||||
// chat.rs 的 super = commands,super::super = ai(与原 commands.rs 的 super=ai 等价)。
|
||||
use super::super::agentic::{run_agentic_loop, try_continue_agent_loop, GOAL_MAX_CHARS};
|
||||
// G1 目标钉扎:GOAL_PIN_ENABLED/MAX_GOALS 已迁移至 agentic 侧使用,chat 不再需要(extract_pinned_goal 纯函数只依赖 GOAL_MAX_CHARS 截断)
|
||||
// 双轨收口批1:读侧入口拦截用 ConvState(can_accept_request 的 unwrap_or 兜底初值)。
|
||||
use super::super::agentic::{run_agentic_loop, try_continue_agent_loop};
|
||||
#[cfg(test)]
|
||||
use super::super::agentic::GOAL_MAX_CHARS;
|
||||
// ConvState 枚举变体引用(Idle 等),用于 B-Phase2 写侧 conv_states.transition 调用。
|
||||
// 读侧拦截(can_accept_request)走 state.conv_states 方法,不直接引用 ConvState 类型。
|
||||
use super::super::agentic::conv_state::ConvState;
|
||||
use super::super::audit::{audit_finalize, emit_data_changed};
|
||||
use super::super::augmentation::build_augmentation_segment;
|
||||
@@ -73,10 +75,8 @@ pub(crate) fn finalize_pending_placeholders(session: &mut super::super::AiSessio
|
||||
}
|
||||
}
|
||||
|
||||
/// 去噪助手:从文本中 strip `[kind:...]` mention 标签(纯函数,无 IO)。
|
||||
///
|
||||
/// 原为 G1 目标钉扎的提取函数(2026-07-03 已改为工具调用推理),现保留供测试用例使用。
|
||||
#[allow(dead_code)]
|
||||
/// 原为 G1 目标钉扎的提取函数(2026-07-03 已改为工具调用推理),现仅测试用例使用。
|
||||
#[cfg(test)]
|
||||
pub(crate) fn extract_pinned_goal(text: &str) -> String {
|
||||
// strip 所有 [kind:...] mention 段(对齐 intent.rs strip_mention_tags/try_match_mention 逻辑,
|
||||
// 单一真相源:KINDS 表与 intent.rs 一致)。全文扫描,非仅行首。
|
||||
@@ -251,15 +251,12 @@ pub async fn ai_regenerate(
|
||||
// 原子占用 generating + 弹出末尾 AI 回复(保留 user 消息)
|
||||
// F-260616-09 B 批4(决策 e):conv_id 来源 IPC 参数 conversation_id,移除 active 一致性校验
|
||||
// (真并发下后台 conv 也应可重新生成);per_conv 唯一真相源,删顶层双写。
|
||||
// B-Phase2:ConvState 读侧切无锁 conv_states(零锁竞争,不占 session lock)。
|
||||
if !state.conv_states.can_accept_request(&conversation_id) {
|
||||
return Err("AI 正在生成中,请等待完成".to_string());
|
||||
}
|
||||
{
|
||||
let mut session = state.ai_session.lock().await;
|
||||
// 入口拦截(双轨收口批1):读 conv_state 经 can_accept_request() 判断能否接新请求。
|
||||
// 语义:拦 Stopping/Generating/Compressed,放行 Idle+Error。原 c.generating 仅拦
|
||||
// Generating/Compressed(漏拦 Stopping 态),can_accept_request 更严谨(防停止中接新请求竞态)。
|
||||
let cs = session.conv_read(&conversation_id).map(|c| c.conv_state).unwrap_or(ConvState::Idle);
|
||||
if !cs.can_accept_request() {
|
||||
return Err("AI 正在生成中,请等待完成".to_string());
|
||||
}
|
||||
let conv = session.conv(&conversation_id);
|
||||
// 批3 双轨收口:generating bool 已退役,生成态由 run_agentic_loop 入口 ConvState→Generating
|
||||
// 迁移设置(此处不再手动赋值)。stop_flag/iteration/model_override 仍是 per_conv 独立字段。
|
||||
@@ -344,18 +341,21 @@ pub async fn ai_is_generating(
|
||||
// conv_id 为空(前端未传/分离窗口无活跃)时 fallback active conv 兼容旧调用方。
|
||||
conversation_id: Option<String>,
|
||||
) -> Result<bool, String> {
|
||||
let session = state.ai_session.lock().await;
|
||||
let target = conversation_id.filter(|s| !s.is_empty())
|
||||
.or_else(|| session.active_conversation_id.clone())
|
||||
.unwrap_or_default();
|
||||
// B-Phase2:ConvState 读侧切无锁 conv_states(零锁竞争)。target 解析仍需 active_conversation_id
|
||||
// (单值读),仅此处轻量 lock 取 active,ConvState 读完全脱离 session lock。
|
||||
let target = {
|
||||
let session = state.ai_session.lock().await;
|
||||
conversation_id.filter(|s| !s.is_empty())
|
||||
.or_else(|| session.active_conversation_id.clone())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
if target.is_empty() {
|
||||
// 无目标 conv 且无 active:无任何 conv 在跑,返 false(批4 顶层 generating 已退役)。
|
||||
// 无目标 conv 且无 active:无任何 conv 在跑,返 false。
|
||||
return Ok(false);
|
||||
}
|
||||
// L2 读侧(双轨收口批1):统一读 conv_state.is_active(),删除 CONV_STATE_ENABLED off 回退分支
|
||||
// (enum 单路径)。is_active() 拦 Generating+Compressed(压缩期间 loop 仍活跃),语义与
|
||||
// 原 c.generating 一致(此读点用于判断「是否在生成中」,应保留拦 Compressed)。
|
||||
Ok(session.conv_read(&target).map(|c| c.conv_state.is_active()).unwrap_or(false))
|
||||
// L2 读侧:统一读 conv_state.is_active()(Generating/Compressed)。
|
||||
// B-Phase2:切 conv_states(无锁),语义等价(同一 ConvState 值)。
|
||||
Ok(state.conv_states.is_active(&target))
|
||||
}
|
||||
|
||||
/// 发送消息并获取流式 AI 响应
|
||||
@@ -416,11 +416,10 @@ pub async fn ai_chat_send(
|
||||
session.active_conv_created_at = Some(now_millis());
|
||||
}
|
||||
}
|
||||
// 入口拦截(双轨收口批1):读 conv_state 经 can_accept_request() 判断能否接新请求。
|
||||
// 语义:拦 Stopping/Generating/Compressed,放行 Idle+Error。原 c.generating 漏拦 Stopping 态,
|
||||
// 入口拦截(双轨收口批1):拦 Stopping/Generating/Compressed,放行 Idle+Error。
|
||||
// B-Phase2:ConvState 读侧切无锁 conv_states(零锁竞争)。原 c.generating 漏拦 Stopping 态,
|
||||
// can_accept_request 更严谨。仅查目标 conv(真并发下各 conv 独立)。
|
||||
let cs = session.conv_read(&target).map(|c| c.conv_state).unwrap_or(ConvState::Idle);
|
||||
if !cs.can_accept_request() {
|
||||
if !state.conv_states.can_accept_request(&target) {
|
||||
return Err("AI 正在生成中,请等待完成".to_string());
|
||||
}
|
||||
let conv = session.conv(&target);
|
||||
@@ -455,11 +454,6 @@ pub async fn ai_chat_send(
|
||||
// DRY(B):知识注入已收敛至 inject_knowledge_into_prompt(helper 内部同消息取 text+id),
|
||||
// 此处 user_msg_id 不再透传到注入逻辑,保留下划线占用(锁内 push 已发生,语义不变)。
|
||||
let user_msg_id = conv.messages.last_user_message_id();
|
||||
// G1 目标钉扎已迁移至 run_agentic_loop 工具调用推理(2026-07-03 改),
|
||||
// 从 LLM 本轮调用的工具名+路径推理目标,不再从用户消息规则提取。
|
||||
// 用户发送的短指令(如「继续」「确认」)不会产生低价值目标。
|
||||
// 此处不再从原始用户消息提取。
|
||||
// GOAL_PIN_ENABLED=false → 跳过整块。
|
||||
(target, user_msg_id)
|
||||
};
|
||||
|
||||
@@ -515,6 +509,7 @@ pub async fn ai_approve(
|
||||
tool_call_id: String,
|
||||
approved: bool,
|
||||
) -> Result<String, String> {
|
||||
let __lock_t534 = std::time::Instant::now();
|
||||
let mut session = state.ai_session.lock().await;
|
||||
|
||||
let approval = match session.pending_approvals.remove(&tool_call_id) {
|
||||
@@ -560,7 +555,14 @@ pub async fn ai_approve(
|
||||
// 无 conversation_id 的无主审批(R-9 异常数据)无 per_conv 可写,拒绝结果不入 messages
|
||||
// (审计仍记 rejected),避免写错 conv。
|
||||
if let Some(ref cid) = approval.conversation_id {
|
||||
session.conv(cid).messages.replace_tool_result_content(&tool_call_id, "用户拒绝了此操作");
|
||||
// BUG-2026-07-07/P1-3:拒绝消息改结构化 JSON,防裸文本破坏 tool role 消息协议
|
||||
// (部分 provider 严格解析 tool role content,裸文本会触发协议错误)。
|
||||
let reject_payload = serde_json::json!({
|
||||
"status": "rejected",
|
||||
"reason": "user_denied",
|
||||
"message": "用户拒绝了此操作"
|
||||
}).to_string();
|
||||
session.conv(cid).messages.replace_tool_result_content(&tool_call_id, &reject_payload);
|
||||
}
|
||||
let conv_id = approval.conversation_id.clone();
|
||||
let ev = AiChatEvent::AiApprovalResult {
|
||||
@@ -584,11 +586,17 @@ pub async fn ai_approve(
|
||||
// 作 start_iteration,防多次审批反复跑满 max_iterations 致 token 失控。
|
||||
// F-09 B 批4:per_conv 唯一真相源(conv_id 来源 approval.conversation_id);无 conv_id 时 0。
|
||||
let start_iter = {
|
||||
let __lock_t610 = std::time::Instant::now();
|
||||
let session = state.ai_session.lock().await;
|
||||
conv_id.as_deref()
|
||||
let v = conv_id.as_deref()
|
||||
.and_then(|c| session.conv_read(c))
|
||||
.map(|c| c.iteration_used)
|
||||
.unwrap_or(0)
|
||||
.unwrap_or(0);
|
||||
let __hold610 = __lock_t610.elapsed();
|
||||
if __hold610 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_approve:610 持锁 {:?} (含 lock 等待)", __hold610);
|
||||
}
|
||||
v
|
||||
};
|
||||
// 所有待审批处理完毕后恢复 agentic 循环
|
||||
// F-09 B 批2:传 conv_id(approval.conversation_id),try_continue 按 conv 过滤 pending/读 per_conv。
|
||||
@@ -620,6 +628,10 @@ pub async fn ai_approve(
|
||||
);
|
||||
}
|
||||
}
|
||||
let __hold534 = __lock_t534.elapsed();
|
||||
if __hold534 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_approve:534 持锁 {:?} (含 lock 等待)", __hold534);
|
||||
}
|
||||
drop(session); // 释放锁后再执行
|
||||
|
||||
// B-260617-01 决策 a(方案 a2 后端分支):run_workflow 特殊处理。
|
||||
@@ -678,6 +690,7 @@ pub async fn ai_approve(
|
||||
// 重新获取锁,替换占位 tool_result 为真实结果(失败时为错误信息,LLM 据此决定下一步)
|
||||
// F-260616-09 B 批4:per_conv.messages 唯一真相源(conv_id 来源 approval.conversation_id)。
|
||||
// 无 conv_id 的无主审批不入 messages(审计仍记 completed/failed)。
|
||||
let __lock_t704 = std::time::Instant::now();
|
||||
let mut session = state.ai_session.lock().await;
|
||||
if let Some(ref cid) = conv_id {
|
||||
session.conv(cid).messages.replace_tool_result_content(&id, &result_val.to_string());
|
||||
@@ -699,6 +712,10 @@ pub async fn ai_approve(
|
||||
let _ = app.emit("ai-chat-event", ev_approval.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev_approval);
|
||||
let __hold704 = __lock_t704.elapsed();
|
||||
if __hold704 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_approve:704 持锁 {:?} (含 lock 等待)", __hold704);
|
||||
}
|
||||
drop(session);
|
||||
|
||||
// F-260619-04 P2(方案 B): create_idea source 消息级溯源补全。
|
||||
@@ -709,10 +726,16 @@ pub async fn ai_approve(
|
||||
// 仅 completed(成功)且 create_idea 才补;失败/其他工具 noop(helper 内判定,不污染本路径主流程)。
|
||||
if audit_status == "completed" {
|
||||
let message_id = {
|
||||
let __lock_t735 = std::time::Instant::now();
|
||||
let session = state.ai_session.lock().await;
|
||||
conv_id.as_deref().and_then(|cid| {
|
||||
let v = conv_id.as_deref().and_then(|cid| {
|
||||
session.conv_read(cid).and_then(|c| c.messages.last_assistant_message_id())
|
||||
})
|
||||
});
|
||||
let __hold735 = __lock_t735.elapsed();
|
||||
if __hold735 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_approve:735 持锁 {:?} (含 lock 等待)", __hold735);
|
||||
}
|
||||
v
|
||||
};
|
||||
super::super::audit::maybe_fill_idea_source(
|
||||
&state.db,
|
||||
@@ -735,8 +758,14 @@ pub async fn ai_approve(
|
||||
// F-09 B 批4:per_conv 唯一真相源;无 conv_id 时 start_iter=0。
|
||||
let cont_conv_id = conv_id.clone().unwrap_or_default();
|
||||
let start_iter = {
|
||||
let __lock_t761 = std::time::Instant::now();
|
||||
let session = state.ai_session.lock().await;
|
||||
session.conv_read(&cont_conv_id).map(|c| c.iteration_used).unwrap_or(0)
|
||||
let v = session.conv_read(&cont_conv_id).map(|c| c.iteration_used).unwrap_or(0);
|
||||
let __hold761 = __lock_t761.elapsed();
|
||||
if __hold761 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_approve:761 持锁 {:?} (含 lock 等待)", __hold761);
|
||||
}
|
||||
v
|
||||
};
|
||||
// 所有待审批处理完毕后恢复 agentic 循环(recovered 无 live loop,try_continue 因 generating=false 自然不续)
|
||||
try_continue_agent_loop(&app, &state, &cont_conv_id, start_iter).await;
|
||||
@@ -838,11 +867,17 @@ pub async fn ai_authorize_dir(
|
||||
);
|
||||
// 取 pending(阶段3a 单真相源合并:从 pending_approvals remove;kind 校验为 Path)。
|
||||
let approval = {
|
||||
let __lock_t864 = std::time::Instant::now();
|
||||
let mut session = state.ai_session.lock().await;
|
||||
match session.pending_approvals.remove(&tool_call_id) {
|
||||
let v = match session.pending_approvals.remove(&tool_call_id) {
|
||||
Some(a) => a,
|
||||
None => return Err(format!("未找到路径授权挂起: {}", tool_call_id)),
|
||||
};
|
||||
let __hold864 = __lock_t864.elapsed();
|
||||
if __hold864 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_authorize_dir:864 持锁 {:?} (含 lock 等待)", __hold864);
|
||||
}
|
||||
v
|
||||
};
|
||||
// 阶段3a 决策层分离:ai_authorize_dir 只消费 kind==Path(路径授权挂起,once/always/deny)。
|
||||
// 提取 PathAuthRequest(kind 下沉原 path_auth 字段),非 Path(误调普通 RiskLevel 审批)→ Err。
|
||||
@@ -852,8 +887,13 @@ pub async fn ai_authorize_dir(
|
||||
ApprovalKind::Path(req) => req,
|
||||
ApprovalKind::Risk { .. } => {
|
||||
// 误调:re-insert 回单表保持挂起,返 Err(前端据 Err 提示改调 ai_approve)。
|
||||
let __lock_t878 = std::time::Instant::now();
|
||||
let mut session = state.ai_session.lock().await;
|
||||
session.pending_approvals.insert(tool_call_id.clone(), approval);
|
||||
let __hold878 = __lock_t878.elapsed();
|
||||
if __hold878 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_authorize_dir:878 持锁 {:?} (含 lock 等待)", __hold878);
|
||||
}
|
||||
return Err(format!(
|
||||
"tool_call_id={} 非路径授权挂起(普通审批请用 ai_approve)",
|
||||
tool_call_id
|
||||
@@ -870,12 +910,22 @@ pub async fn ai_authorize_dir(
|
||||
|
||||
// ── "deny":拒绝路径授权 → 工具返 Err,恢复 loop ──
|
||||
if decision == "deny" {
|
||||
let err_msg = "用户拒绝路径授权".to_string();
|
||||
// BUG-2026-07-07/P1-3:拒绝消息改结构化 JSON,防裸文本破坏 tool role 消息协议
|
||||
let err_msg = serde_json::json!({
|
||||
"status": "rejected",
|
||||
"reason": "path_auth_denied",
|
||||
"message": "用户拒绝路径授权"
|
||||
}).to_string();
|
||||
{
|
||||
let __lock_t903 = std::time::Instant::now();
|
||||
let mut session = state.ai_session.lock().await;
|
||||
if let Some(ref cid) = conv_id {
|
||||
session.conv(cid).messages.replace_tool_result_content(&tool_call_id, &err_msg);
|
||||
}
|
||||
let __hold903 = __lock_t903.elapsed();
|
||||
if __hold903 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_authorize_dir:903 持锁 {:?} (含 lock 等待)", __hold903);
|
||||
}
|
||||
}
|
||||
let ev_completed = AiChatEvent::AiToolCallCompleted {
|
||||
id: tool_call_id.clone(),
|
||||
@@ -899,8 +949,14 @@ pub async fn ai_authorize_dir(
|
||||
}
|
||||
let cont_conv_id = conv_id.clone().unwrap_or_default();
|
||||
let start_iter = {
|
||||
let __lock_t930 = std::time::Instant::now();
|
||||
let session = state.ai_session.lock().await;
|
||||
session.conv_read(&cont_conv_id).map(|c| c.iteration_used).unwrap_or(0)
|
||||
let v = session.conv_read(&cont_conv_id).map(|c| c.iteration_used).unwrap_or(0);
|
||||
let __hold930 = __lock_t930.elapsed();
|
||||
if __hold930 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_authorize_dir:930 持锁 {:?} (含 lock 等待)", __hold930);
|
||||
}
|
||||
v
|
||||
};
|
||||
try_continue_agent_loop(&app, &state, &cont_conv_id, start_iter).await;
|
||||
return Ok("rejected".to_string());
|
||||
@@ -987,10 +1043,15 @@ pub async fn ai_authorize_dir(
|
||||
|
||||
// 替换占位 tool_result 为真实结果
|
||||
{
|
||||
let __lock_t1018 = std::time::Instant::now();
|
||||
let mut session = state.ai_session.lock().await;
|
||||
if let Some(ref cid) = conv_id {
|
||||
session.conv(cid).messages.replace_tool_result_content(&tool_call_id, &result_val.to_string());
|
||||
}
|
||||
let __hold1018 = __lock_t1018.elapsed();
|
||||
if __hold1018 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_authorize_dir:1018 持锁 {:?} (含 lock 等待)", __hold1018);
|
||||
}
|
||||
}
|
||||
let ev_completed = AiChatEvent::AiToolCallCompleted {
|
||||
id: tool_call_id.clone(),
|
||||
@@ -1013,8 +1074,14 @@ pub async fn ai_authorize_dir(
|
||||
}
|
||||
let cont_conv_id = conv_id.clone().unwrap_or_default();
|
||||
let start_iter = {
|
||||
let __lock_t1044 = std::time::Instant::now();
|
||||
let session = state.ai_session.lock().await;
|
||||
session.conv_read(&cont_conv_id).map(|c| c.iteration_used).unwrap_or(0)
|
||||
let v = session.conv_read(&cont_conv_id).map(|c| c.iteration_used).unwrap_or(0);
|
||||
let __hold1044 = __lock_t1044.elapsed();
|
||||
if __hold1044 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_authorize_dir:1044 持锁 {:?} (含 lock 等待)", __hold1044);
|
||||
}
|
||||
v
|
||||
};
|
||||
try_continue_agent_loop(&app, &state, &cont_conv_id, start_iter).await;
|
||||
Ok(audit_status.to_string())
|
||||
@@ -1040,6 +1107,7 @@ pub async fn ai_pending_tool_calls(
|
||||
state: State<'_, AppState>,
|
||||
conv_id: String,
|
||||
) -> Result<Vec<PendingToolCallInfo>, String> {
|
||||
let __lock_t1071 = std::time::Instant::now();
|
||||
let session = state.ai_session.lock().await;
|
||||
// 阶段3a 单真相源合并:单 pending_approvals 表按 conv_id 过滤,kind 字段透传给前端。
|
||||
// 阶段4:返 kind 字段,前端 switchConversation 恢复 pendingApprovals 时按 kind 渲染
|
||||
@@ -1058,12 +1126,17 @@ pub async fn ai_pending_tool_calls(
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
let __hold1071 = __lock_t1071.elapsed();
|
||||
if __hold1071 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_pending_tool_calls:1071 持锁 {:?} (含 lock 等待)", __hold1071);
|
||||
}
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
/// 清空对话历史
|
||||
#[tauri::command]
|
||||
pub async fn ai_chat_clear(state: State<'_, AppState>) -> Result<(), String> {
|
||||
let __lock_t1095 = std::time::Instant::now();
|
||||
let mut session = state.ai_session.lock().await;
|
||||
// 取活跃对话 id 后释放锁(避免持 session 锁调 DB)
|
||||
let active_id = session.active_conversation_id.clone();
|
||||
@@ -1077,6 +1150,10 @@ pub async fn ai_chat_clear(state: State<'_, AppState>) -> Result<(), String> {
|
||||
}
|
||||
// 阶段3a 单真相源合并:单表 retain(口径不变:清本 conv 保留其他 conv,kind 不区分)。
|
||||
session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != active_id.as_deref());
|
||||
let __hold1095 = __lock_t1095.elapsed();
|
||||
if __hold1095 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_chat_clear:1095 持锁 {:?} (含 lock 等待)", __hold1095);
|
||||
}
|
||||
drop(session);
|
||||
// 真删 DB:清空该对话 messages(JSON 备份列同步清空 + 清零 token,保留对话壳),刷新不再恢复(AR-7)
|
||||
// F-260619-03 批次 B:同时清空 ai_messages 表(全删,delete_range min_seq=0 max=None)
|
||||
@@ -1114,6 +1191,7 @@ pub async fn ai_chat_clear_context(
|
||||
// F-260616-09 B 批4(决策 e):conv_id 来源 IPC 参数 conversation_id,移除 active 一致性校验
|
||||
// (真并发下后台 conv 也应可分段);messages 操作走 per_conv。
|
||||
let conv_id = {
|
||||
let __lock_t1145 = std::time::Instant::now();
|
||||
let mut session = state.ai_session.lock().await;
|
||||
// 保护区:保留最近 PROTECT_COUNT 条 active。
|
||||
const PROTECT_COUNT: usize = 6;
|
||||
@@ -1136,6 +1214,10 @@ pub async fn ai_chat_clear_context(
|
||||
t.message.status = Some(MessageStatus::ArchivedSegment);
|
||||
}
|
||||
}
|
||||
let __hold1145 = __lock_t1145.elapsed();
|
||||
if __hold1145 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_chat_clear_context:1145 持锁 {:?} (含 lock 等待)", __hold1145);
|
||||
}
|
||||
drop(session);
|
||||
conversation_id
|
||||
};
|
||||
@@ -1174,6 +1256,7 @@ pub async fn ai_chat_compress_context(
|
||||
// 取 active 克隆 + compress_end(读不改,LLM 失败则消息状态完全不变)
|
||||
// F-260616-09 B 批2:messages 操作改 per_conv(conv_id 来源:IPC 参数 conversation_id,已与 active 校验)。
|
||||
let (conv_id, active_msgs, compress_end) = {
|
||||
let __lock_t1205 = std::time::Instant::now();
|
||||
let mut session = state.ai_session.lock().await;
|
||||
// F-260616-09 B 批4(决策 e):移除 active 一致性校验(真并发下后台 conv 也应可压缩)。
|
||||
let conv = session.conv(&conversation_id);
|
||||
@@ -1214,13 +1297,22 @@ pub async fn ai_chat_compress_context(
|
||||
.map(|t| t.message.clone())
|
||||
.collect();
|
||||
let cid = session.active_conversation_id.clone().unwrap_or_else(|| conversation_id.clone());
|
||||
let __hold1205 = __lock_t1205.elapsed();
|
||||
if __hold1205 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_chat_compress_context:1205 持锁 {:?} (含 lock 等待)", __hold1205);
|
||||
}
|
||||
(cid, active_msgs, protect_start)
|
||||
};
|
||||
|
||||
if active_msgs.is_empty() {
|
||||
// 保护区外无 active 消息(已全 compressed/archived_segment/truncated) → noop
|
||||
let __lock_t1250 = std::time::Instant::now();
|
||||
let mut session = state.ai_session.lock().await;
|
||||
session.conv(&conv_id).messages.set_compressing(false);
|
||||
let __hold1250 = __lock_t1250.elapsed();
|
||||
if __hold1250 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_chat_compress_context:1250 持锁 {:?} (含 lock 等待)", __hold1250);
|
||||
}
|
||||
drop(session);
|
||||
// 治 Task#1:手动 IPC 降级(无 active 可压缩)用 AiManualCompressed(前端弹 toast)。
|
||||
let ev = AiChatEvent::AiManualCompressed {
|
||||
@@ -1238,8 +1330,13 @@ pub async fn ai_chat_compress_context(
|
||||
let provider = match super::super::secret::build_provider_for(&provider_config) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
let __lock_t1269 = std::time::Instant::now();
|
||||
let mut session = state.ai_session.lock().await;
|
||||
session.conv(&conv_id).messages.set_compressing(false);
|
||||
let __hold1269 = __lock_t1269.elapsed();
|
||||
if __hold1269 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_chat_compress_context:1269 持锁 {:?} (含 lock 等待)", __hold1269);
|
||||
}
|
||||
drop(session);
|
||||
let ev = AiChatEvent::AiError {
|
||||
error: format!("压缩失败: {}", e),
|
||||
@@ -1264,8 +1361,13 @@ pub async fn ai_chat_compress_context(
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
// LLM 失败:不阻塞,set_compressing(false),消息状态不变(未调 compress_old_messages)
|
||||
let __lock_t1295 = std::time::Instant::now();
|
||||
let mut session = state.ai_session.lock().await;
|
||||
session.conv(&conv_id).messages.set_compressing(false);
|
||||
let __hold1295 = __lock_t1295.elapsed();
|
||||
if __hold1295 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_chat_compress_context:1295 持锁 {:?} (含 lock 等待)", __hold1295);
|
||||
}
|
||||
drop(session);
|
||||
let ev = AiChatEvent::AiError {
|
||||
error: format!("压缩失败: {}", e),
|
||||
@@ -1281,11 +1383,16 @@ pub async fn ai_chat_compress_context(
|
||||
|
||||
// LLM 成功 → 标 compressed(扣 token) + 摘要插首位
|
||||
{
|
||||
let __lock_t1312 = std::time::Instant::now();
|
||||
let mut session = state.ai_session.lock().await;
|
||||
let conv = session.conv(&conv_id);
|
||||
let _compressed = conv.messages.compress_old_messages(compress_end);
|
||||
conv.messages.insert_at(0, ChatMessage::system(&summary));
|
||||
conv.messages.set_compressing(false);
|
||||
let __hold1312 = __lock_t1312.elapsed();
|
||||
if __hold1312 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_chat_compress_context:1312 持锁 {:?} (含 lock 等待)", __hold1312);
|
||||
}
|
||||
}
|
||||
save_conversation(&state.ai_session, &state.db, &conv_id, None, None, false).await;
|
||||
// 治 Task#1:手动 IPC 成功主路径用 AiManualCompressed(Task#1 期望保留 toast+刷新)。
|
||||
@@ -1321,14 +1428,12 @@ pub async fn ai_chat_edit(
|
||||
// 原子占用 generating + 替换末条 user content + truncate 其后
|
||||
// F-260616-09 B 批4(决策 e):conv_id 来源 IPC 参数 conversation_id,移除 active 一致性校验
|
||||
// (真并发下后台 conv 也应可编辑);per_conv 唯一真相源,删顶层双写。
|
||||
// B-Phase2:ConvState 读侧切无锁 conv_states(零锁竞争)。
|
||||
if !state.conv_states.can_accept_request(&conversation_id) {
|
||||
return Err("AI 正在生成中,请等待完成".to_string());
|
||||
}
|
||||
{
|
||||
let mut session = state.ai_session.lock().await;
|
||||
// 入口拦截(双轨收口批1):读 conv_state 经 can_accept_request() 判断能否接新请求。
|
||||
// 语义:拦 Stopping/Generating/Compressed,放行 Idle+Error。原 c.generating 漏拦 Stopping 态。
|
||||
let cs = session.conv_read(&conversation_id).map(|c| c.conv_state).unwrap_or(ConvState::Idle);
|
||||
if !cs.can_accept_request() {
|
||||
return Err("AI 正在生成中,请等待完成".to_string());
|
||||
}
|
||||
let conv = session.conv(&conversation_id);
|
||||
// ① 替换末条 active user 消息 content(无 active user → Err)
|
||||
if conv.messages.replace_last_active_user_content(&new_message).is_err() {
|
||||
@@ -1442,6 +1547,7 @@ pub async fn ai_chat_force_send(
|
||||
// 不再跨 conv 杀(旧实现清全局 generating + 全 clear pending_approvals,在真并发下会误杀其他
|
||||
// 后台 conv 的 loop/审批)。pending_approvals 仅清目标 conv 的(retain),保留其他 conv 的。
|
||||
let (old_conv_id, conv_id, _user_message_id, old_pinned_goals) = {
|
||||
let __lock_t1473 = std::time::Instant::now();
|
||||
let mut session = state.ai_session.lock().await;
|
||||
// 目标 conv:入参优先 → active → 懒创建。
|
||||
let target = conversation_id.clone().filter(|s| !s.is_empty())
|
||||
@@ -1462,9 +1568,9 @@ pub async fn ai_chat_force_send(
|
||||
}
|
||||
}
|
||||
// ① 软停止复位目标 conv(与 ai_chat_stop 审批分支一致),emit AiCompleted 用旧 target 自身。
|
||||
// was_gen 读点(双轨收口批1):改读 conv_state.is_active()(Generating+Compressed),
|
||||
// 用于判断「原是否在生成」以决定是否 emit AiCompleted。is_active() 与原 generating 语义一致。
|
||||
let was_gen = session.conv_read(&target).map(|c| c.conv_state.is_active()).unwrap_or(false);
|
||||
// was_gen 读点(B-Phase2):改读无锁 conv_states.is_active()(Generating/Compressed),
|
||||
// 用于判断「原是否在生成」以决定是否 emit AiCompleted。语义等价。
|
||||
let was_gen = state.conv_states.is_active(&target);
|
||||
// SW-260618-02:强制发送丢弃目标 conv 的旧审批,先终态化占位 tool_result,防占位随 messages
|
||||
// 残留下次发送喂给 LLM。仅清目标 conv 的 pending_approvals(retain 保留其他 conv)。
|
||||
finalize_pending_placeholders(&mut *session, &target, "已取消");
|
||||
@@ -1472,15 +1578,13 @@ pub async fn ai_chat_force_send(
|
||||
session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(target.as_str()));
|
||||
{
|
||||
let conv = session.conv(&target);
|
||||
// 批3 双轨收口:generating bool 已退役,复位改 ConvState 迁移(Generating→Idle 收敛)。
|
||||
// was_gen=false 时仍 Idle(幂等);非活跃态→Idle 非法仅 warn(不阻断强制发送语义)。
|
||||
match conv.conv_state.transition_to(ConvState::Idle) {
|
||||
Ok(ns) => conv.conv_state = ns,
|
||||
Err(e) => tracing::warn!(
|
||||
// B-Phase3:conv_state 写切 ConvStateStore 单源。
|
||||
if let Err(e) = state.conv_states.transition(&target, ConvState::Idle) {
|
||||
tracing::warn!(
|
||||
conv_id = %target,
|
||||
error = %e,
|
||||
"[ai] 强制发送 ConvState→Idle 非法(不阻断强制发送)"
|
||||
),
|
||||
"[ai] conv_states 强制发送→Idle 非法(不阻断强制发送)"
|
||||
);
|
||||
}
|
||||
conv.stop_flag.store(true, Ordering::SeqCst);
|
||||
}
|
||||
@@ -1512,6 +1616,10 @@ pub async fn ai_chat_force_send(
|
||||
// F-260619-04 P1:push 后立即取末条 user 消息 id(供知识注入 referenced 溯源)。
|
||||
let user_msg_id = conv.messages.last_user_message_id();
|
||||
// G1 目标钉扎已迁移至 run_agentic_loop 工具调用推理(提取方式同 ai_chat_send)
|
||||
let __hold1473 = __lock_t1473.elapsed();
|
||||
if __hold1473 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_chat_force_send:1473 持锁 {:?} (含 lock 等待)", __hold1473);
|
||||
}
|
||||
(was_gen.then_some(target.clone()), target, user_msg_id, conv.pinned_goals.clone())
|
||||
};
|
||||
|
||||
@@ -1580,6 +1688,7 @@ pub async fn ai_chat_stop(
|
||||
// None/空 → fallback active(旧调用方兼容)。
|
||||
conversation_id: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let __lock_t1611 = std::time::Instant::now();
|
||||
let mut session = state.ai_session.lock().await;
|
||||
// 目标 conv:入参优先 → active。
|
||||
let target = conversation_id.filter(|s| !s.is_empty())
|
||||
@@ -1587,24 +1696,20 @@ pub async fn ai_chat_stop(
|
||||
.unwrap_or_default();
|
||||
if target.is_empty() {
|
||||
// 无目标 conv 且无 active:无任何 conv 在跑(批4 顶层 generating 已退役),直接返回。
|
||||
let __hold1611 = __lock_t1611.elapsed();
|
||||
if __hold1611 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_chat_stop:1611 持锁 {:?} (含 lock 等待)", __hold1611);
|
||||
}
|
||||
drop(session);
|
||||
return Ok(());
|
||||
}
|
||||
// 仅查目标 conv 的 generating(真并发下各 conv 独立)。
|
||||
// L2 读侧(双轨收口批1):统一读 conv_state.is_active()(Generating+Compressed),删除
|
||||
// CONV_STATE_ENABLED off 回退分支(enum 单路径)。用于判断停止按钮是否可点的预判断。
|
||||
let is_gen = session.conv_read(&target).map(|c| c.conv_state.is_active()).unwrap_or(false);
|
||||
// B-Phase3:读侧切无锁 conv_states(单源收敛)。
|
||||
let is_gen = state.conv_states.is_active(&target);
|
||||
if !is_gen {
|
||||
return Ok(());
|
||||
}
|
||||
// 目标 conv 是否有待审批(仅本 conv 的)。
|
||||
// path_auth 审批链阶段1:改调 session_state(target) 收敛状态机判定,替代手写两表合并。
|
||||
// 阶段3a 单真相源合并后:session_state 单表 any 判定(原双表 OR 合一)。
|
||||
//
|
||||
// 兜底/快速回退(改一行即可):
|
||||
// let has_pending = session.pending_approvals.values()
|
||||
// .any(|a| a.conversation_id.as_deref() == Some(target.as_str()));
|
||||
let has_pending = session.session_state(&target) == SessionState::AwaitingApproval;
|
||||
let has_pending = session.session_state(&target, &state.conv_states) == SessionState::AwaitingApproval;
|
||||
if has_pending {
|
||||
// 审批等待态:loop 已退出,直接清理目标 conv 让其立即可用
|
||||
// SW-260618-02:messages 不 clear,占位 tool_result 会残留,下次发送喂给 LLM。
|
||||
@@ -1612,17 +1717,23 @@ pub async fn ai_chat_stop(
|
||||
finalize_pending_placeholders(&mut *session, &target, "会话已停止");
|
||||
session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(target.as_str()));
|
||||
let conv = session.conv(&target);
|
||||
// 批3 双轨收口:generating bool 已退役,复位改 ConvState 迁移(Generating→Idle)。
|
||||
match conv.conv_state.transition_to(ConvState::Idle) {
|
||||
Ok(ns) => conv.conv_state = ns,
|
||||
Err(e) => tracing::warn!(
|
||||
conv_id = %target,
|
||||
error = %e,
|
||||
"[ai] ai_chat_stop 审批分支 ConvState→Idle 非法(不阻断 emit AiCompleted)"
|
||||
),
|
||||
// B-Phase3:conv_state 写切 ConvStateStore 单源(per_conv 字段已删)。
|
||||
{
|
||||
let conv_states = app.state::<AppState>().conv_states.clone();
|
||||
if let Err(e) = conv_states.transition(&target, ConvState::Idle) {
|
||||
tracing::warn!(
|
||||
conv_id = %target,
|
||||
error = %e,
|
||||
"[ai] conv_states ai_chat_stop 审批分支→Idle 非法(不阻断 emit AiCompleted)"
|
||||
);
|
||||
}
|
||||
}
|
||||
conv.stop_flag.store(true, Ordering::SeqCst); // 双保险:防 try_continue 误判重启
|
||||
let pinned_goals = conv.pinned_goals.clone();
|
||||
let __hold1611 = __lock_t1611.elapsed();
|
||||
if __hold1611 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_chat_stop:1611 持锁 {:?} (含 lock 等待)", __hold1611);
|
||||
}
|
||||
drop(session);
|
||||
let ev = AiChatEvent::AiCompleted {
|
||||
total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, incomplete: None,
|
||||
@@ -1645,6 +1756,10 @@ pub async fn ai_chat_stop(
|
||||
// Notify 仅承载「即时唤醒」,停止真值仍由 stop_flag 决定(stream_llm 唤醒后再判 flag)。
|
||||
notify.notify_one();
|
||||
let conv_id = Some(target);
|
||||
let __hold1611 = __lock_t1611.elapsed();
|
||||
if __hold1611 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_chat_stop:1611 持锁 {:?} (含 lock 等待)", __hold1611);
|
||||
}
|
||||
drop(session);
|
||||
|
||||
// B-260615-13 兜底任务:loop 若 panic/异常退出漏发收尾,stop_flag 无人读,
|
||||
@@ -1655,28 +1770,32 @@ pub async fn ai_chat_stop(
|
||||
let app_handle = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||
let __lock_t1686 = std::time::Instant::now();
|
||||
let mut session = session_arc.lock().await;
|
||||
let target_owned = conv_id.clone().unwrap_or_default();
|
||||
// still_gen 读点(双轨收口批1):改读 conv_state.is_active()(Generating+Compressed),
|
||||
// 3秒后重检是否仍在生成。is_active() 与原 generating 语义一致。
|
||||
// B-Phase3:读侧切无锁 conv_states。
|
||||
let still_gen = if target_owned.is_empty() {
|
||||
false
|
||||
} else {
|
||||
session.conv_read(&target_owned).map(|c| c.conv_state.is_active()).unwrap_or(false)
|
||||
app.state::<AppState>().conv_states.is_active(&target_owned)
|
||||
};
|
||||
if still_gen {
|
||||
// 3秒超时强制收尾:generating bool 已退役,改 ConvState 迁移(Generating→Idle)收敛生成态。
|
||||
// 正常路径由 guard.drop 复位,此处仅超时兜底(loop 未及时退出场景)。
|
||||
let conv = session.conv(&target_owned);
|
||||
match conv.conv_state.transition_to(ConvState::Idle) {
|
||||
Ok(ns) => conv.conv_state = ns,
|
||||
Err(e) => tracing::warn!(
|
||||
conv_id = %target_owned,
|
||||
error = %e,
|
||||
"[ai] ai_chat_stop 3秒超时 ConvState→Idle 非法(不阻断 emit AiCompleted)"
|
||||
),
|
||||
// B-Phase3:conv_state 写切 ConvStateStore 单源。
|
||||
{
|
||||
let conv_states = app.state::<AppState>().conv_states.clone();
|
||||
if let Err(e) = conv_states.transition(&target_owned, ConvState::Idle) {
|
||||
tracing::warn!(
|
||||
conv_id = %target_owned,
|
||||
error = %e,
|
||||
"[ai] conv_states 3秒超时→Idle 非法(不阻断 emit AiCompleted)"
|
||||
);
|
||||
}
|
||||
}
|
||||
let pinned_goals = session.conv(&target_owned).pinned_goals.clone();
|
||||
let __hold1686 = __lock_t1686.elapsed();
|
||||
if __hold1686 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_chat_stop:1686 持锁 {:?} (含 lock 等待)", __hold1686);
|
||||
}
|
||||
let pinned_goals = conv.pinned_goals.clone();
|
||||
drop(session); // 释放锁后再 emit,避免持锁调 runtime emit
|
||||
let ev = AiChatEvent::AiCompleted {
|
||||
total_tokens: 0,
|
||||
@@ -1689,6 +1808,11 @@ pub async fn ai_chat_stop(
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。闭包内 app_handle 仍可访问 AppState。
|
||||
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
} else {
|
||||
let __hold1686 = __lock_t1686.elapsed();
|
||||
if __hold1686 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_chat_stop:1686 持锁 {:?} (含 lock 等待)", __hold1686);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1713,13 +1837,16 @@ pub async fn ai_continue_loop(
|
||||
conversation_id: String,
|
||||
) -> Result<String, String> {
|
||||
{
|
||||
let __lock_t1744 = std::time::Instant::now();
|
||||
let mut session = state.ai_session.lock().await;
|
||||
// F-260616-09 B 批4(决策 e):conv_id 来源 IPC 参数 conversation_id,可对任意 conv 操作
|
||||
// (含切走后的后台 conv),移除 active 一致性校验(真并发下后台 conv 也应可续跑)。
|
||||
// 读点(双轨收口批1):改读 conv_state.is_active()(Generating+Compressed)。达 max 时
|
||||
// generating=true(conv_state=Generating),is_active() 返回 true 符合预期。
|
||||
let is_gen = session.conv_read(&conversation_id).map(|c| c.conv_state.is_active()).unwrap_or(false);
|
||||
if !is_gen {
|
||||
// B-Phase3:读侧切无锁 conv_states。
|
||||
if !state.conv_states.is_active(&conversation_id) {
|
||||
let __hold1744 = __lock_t1744.elapsed();
|
||||
if __hold1744 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_continue_loop:1744 持锁 {:?} (含 lock 等待)", __hold1744);
|
||||
}
|
||||
return Err("AI 未在暂停态,无需继续".to_string());
|
||||
}
|
||||
let conv = session.conv(&conversation_id);
|
||||
@@ -1728,6 +1855,10 @@ pub async fn ai_continue_loop(
|
||||
// F-260616-11 决策 a: 达 max 续跑重计 iteration(F-260616-03 决策 a,用户点继续=授权重来)。
|
||||
// 审批续跑(ai_approve)累计不重置见另路径;本路径 start_iteration 传 0,loop 从头计数。
|
||||
conv.iteration_used = 0;
|
||||
let __hold1744 = __lock_t1744.elapsed();
|
||||
if __hold1744 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_continue_loop:1744 持锁 {:?} (含 lock 等待)", __hold1744);
|
||||
}
|
||||
}
|
||||
// 复用审批恢复续 loop 入口(不重写 loop),其内部 spawn run_agentic_loop
|
||||
// F-09 B 批2:传 conv_id(IPC 参数 conversation_id)。
|
||||
@@ -1751,28 +1882,34 @@ pub async fn ai_stop_loop(
|
||||
) -> Result<String, String> {
|
||||
let pinned_goals: Vec<crate::commands::ai::GoalEntry>;
|
||||
{
|
||||
let __lock_t1782 = std::time::Instant::now();
|
||||
let mut session = state.ai_session.lock().await;
|
||||
// F-260616-09 B 批4(决策 e):conv_id 来源 IPC 参数 conversation_id,移除 active 一致性校验
|
||||
// (真并发下后台 conv 也应可停止)。
|
||||
// 读点(双轨收口批1):改读 conv_state.is_active()(Generating+Compressed)。停止按钮判断
|
||||
// 是否在生成中(与 ai_continue_loop 同场景)。is_active() 符合语义。
|
||||
let is_gen = session.conv_read(&conversation_id).map(|c| c.conv_state.is_active()).unwrap_or(false);
|
||||
if !is_gen {
|
||||
// B-Phase3:读侧切无锁 conv_states。
|
||||
if !state.conv_states.is_active(&conversation_id) {
|
||||
let __hold1782 = __lock_t1782.elapsed();
|
||||
if __hold1782 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_stop_loop:1782 持锁 {:?} (含 lock 等待)", __hold1782);
|
||||
}
|
||||
return Err("AI 未在暂停态,无需停止".to_string());
|
||||
}
|
||||
let conv = session.conv(&conversation_id);
|
||||
// 置 stop_flag 双保险:防 try_continue 误判重启(与 ai_chat_stop 审批分支一致)
|
||||
conv.stop_flag.store(true, Ordering::SeqCst);
|
||||
// 批3 双轨收口:generating bool 已退役,复位改 ConvState 迁移(Generating→Idle)。
|
||||
match conv.conv_state.transition_to(ConvState::Idle) {
|
||||
Ok(ns) => conv.conv_state = ns,
|
||||
Err(e) => tracing::warn!(
|
||||
// B-Phase3:conv_state 写切 ConvStateStore 单源。
|
||||
if let Err(e) = state.conv_states.transition(&conversation_id, ConvState::Idle) {
|
||||
tracing::warn!(
|
||||
conv_id = %conversation_id,
|
||||
error = %e,
|
||||
"[ai] ai_stop_loop ConvState→Idle 非法(不阻断 emit AiCompleted)"
|
||||
),
|
||||
"[ai] conv_states ai_stop_loop→Idle 非法(不阻断 emit AiCompleted)"
|
||||
);
|
||||
}
|
||||
pinned_goals = conv.pinned_goals.clone();
|
||||
let __hold1782 = __lock_t1782.elapsed();
|
||||
if __hold1782 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_stop_loop:1782 持锁 {:?} (含 lock 等待)", __hold1782);
|
||||
}
|
||||
}
|
||||
// 暂停态进入前已 save_conversation,此处零 token 上报仅作收敛信号(与 try_continue 补发 AiCompleted 一致)
|
||||
let ev = AiChatEvent::AiCompleted {
|
||||
|
||||
@@ -30,7 +30,7 @@ use crate::state::AppState;
|
||||
#[tauri::command]
|
||||
pub async fn ai_list_skills() -> Result<Vec<SkillInfo>, String> {
|
||||
// 命中进程内缓存(skills_cached 已 owned Vec,直接返回)
|
||||
Ok(skills_cached())
|
||||
Ok(skills_cached().await)
|
||||
}
|
||||
|
||||
/// 热重载技能列表(核心设计6:进程内重载,不重启生效)。
|
||||
@@ -40,7 +40,7 @@ pub async fn ai_list_skills() -> Result<Vec<SkillInfo>, String> {
|
||||
#[tauri::command]
|
||||
pub async fn ai_reload_skills() -> Result<Vec<SkillInfo>, String> {
|
||||
invalidate_skills();
|
||||
Ok(skills_cached())
|
||||
Ok(skills_cached().await)
|
||||
}
|
||||
|
||||
/// 设置 LLM 调用并发上限(运行时调整,立即生效)
|
||||
|
||||
@@ -322,10 +322,10 @@ pub async fn ai_conversation_switch(
|
||||
drop(session);
|
||||
state.clear_session_allowed_dirs().await;
|
||||
let mut session = state.ai_session.lock().await;
|
||||
// 读点(双轨收口批2):改读 conv_state.is_active()(Generating+Compressed),判断目标会话是否
|
||||
// 已在生成中(是否需要从 DB reload)。is_active() 与原 generating 语义一致(压缩期间后台 loop
|
||||
// 仍持有 per_conv.messages,reload 会用 DB 旧快照覆盖内存新消息,应判为已在生成而跳过 reload)。
|
||||
let already_live = session.conv_read(&conversation_id).map(|c| c.conv_state.is_active()).unwrap_or(false);
|
||||
// 读点(B-Phase2):改读无锁 conv_states.is_active()(Generating/Compressed),判断目标会话是否
|
||||
// 已在生成中(是否需要从 DB reload)。语义等价(同一 ConvState 值)——压缩期间后台 loop
|
||||
// 仍持有 per_conv.messages,reload 会用 DB 旧快照覆盖内存新消息,应判为已在生成而跳过 reload。
|
||||
let already_live = state.conv_states.is_active(&conversation_id);
|
||||
if !already_live {
|
||||
// 目标 conv 未在生成:从 DB reload messages 到其 per_conv(首次切入或上次切走后无后台 loop)。
|
||||
// 已在生成:保留其 per_conv 现状(后台 loop 持有),messages 由 loop 自行维护。
|
||||
@@ -480,6 +480,9 @@ pub async fn ai_conversation_delete(
|
||||
// F-260616-09 B 批4:删除 conv 时移除其 per_conv 条目(设计 §4.1 conv 存在性判据依赖此,
|
||||
// 旧 loop 检测 conv 不存在即退出)。per_conv 唯一真相源,删顶层 messages.clear 双写。
|
||||
session.per_conv.remove(&conversation_id);
|
||||
// B-Phase4:conv 删除同步清理 ConvStateStore 条目,防已删 conv 残留
|
||||
// Generating 态致 id 复用(同一 conv_id 重新创建)脏状态。
|
||||
state.conv_states.remove(&conversation_id);
|
||||
let was_active = session.active_conversation_id.as_deref() == Some(&conversation_id);
|
||||
if was_active {
|
||||
session.active_conversation_id = None;
|
||||
|
||||
@@ -27,8 +27,10 @@ use crate::state::AppState;
|
||||
use crate::commands::{err_str, now_millis};
|
||||
|
||||
// provider.rs 的 super = commands,super::super = ai(与原 commands.rs 的 super=ai 等价)。
|
||||
// 使用 async 版本(`*_async`):IPC 在 async 上下文,同步 keyring 调用会卡 tokio runtime
|
||||
// (Tauri 单线程 runtime,Windows COM / Linux D-Bus 慢场景累加尾延迟)。
|
||||
use super::super::secret::{
|
||||
get_provider_secret, set_provider_secret, delete_provider_secret,
|
||||
get_provider_secret_async, set_provider_secret_async, delete_provider_secret_async,
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
@@ -50,13 +52,41 @@ fn mask_api_key(key: &str) -> String {
|
||||
#[tauri::command]
|
||||
pub async fn ai_list_providers(state: State<'_, AppState>) -> Result<Vec<AiProviderRecord>, String> {
|
||||
let mut providers = state.ai_providers.list_all().await.map_err(err_str)?;
|
||||
// 收敛 is_default 唯一性自愈:历史脏数据(早期 ai_set_provider 未互斥/F-04 前
|
||||
// ai_save_provider 曾直接置默认)可能让多条 provider 同为 is_default=true,
|
||||
// UI 会显示多个"默认"徽章且 get_active_provider 取首条不可预期。此处检测到
|
||||
// 多于 1 条时,保留 created_at 最新的一条(与 ai_set_provider 语义一致:后设优先),
|
||||
// 其余清零并落库。Settings 每次打开必经此 IPC,作为单点收敛入口。
|
||||
let defaults: Vec<&AiProviderRecord> = providers.iter().filter(|p| p.is_default).collect();
|
||||
if defaults.len() > 1 {
|
||||
// created_at 最大者保留为默认(list_all 已按 created_at DESC 排序,首条即最新)
|
||||
let keep_id = defaults[0].id.clone();
|
||||
tracing::warn!(
|
||||
"[is_default] 检测到 {} 条 provider 同为默认,收敛保留 {} 清零其余",
|
||||
defaults.len(), keep_id
|
||||
);
|
||||
for p in &providers {
|
||||
if p.is_default && p.id != keep_id {
|
||||
let mut updated = p.clone();
|
||||
updated.is_default = false;
|
||||
updated.updated_at = now_millis();
|
||||
state.ai_providers.update_full(&updated).await.map_err(err_str)?;
|
||||
}
|
||||
}
|
||||
// 同步内存副本,供下方 mask 循环使用
|
||||
for p in &mut providers {
|
||||
if p.id != keep_id {
|
||||
p.is_default = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
// IPC 不传明文 api_key(FR-S1):前端编辑用空 apiKey 表示不改,mask 后前端 realm 不持有明文。
|
||||
// 迁移后 DB api_key 空 → 从 keyring 取真实密钥再 mask(前端看到 mask 但不持有明文)
|
||||
for p in &mut providers {
|
||||
let real = if !p.api_key.is_empty() {
|
||||
p.api_key.clone() // 未迁移(老明文)
|
||||
} else {
|
||||
get_provider_secret(&p.id).unwrap_or_default() // 迁移后从 keyring
|
||||
get_provider_secret_async(p.id.clone()).await.unwrap_or_default() // 迁移后从 keyring
|
||||
};
|
||||
p.api_key = if real.is_empty() { String::new() } else { mask_api_key(&real) };
|
||||
}
|
||||
@@ -103,7 +133,7 @@ pub async fn ai_save_provider(
|
||||
let provider_id = id.clone().unwrap_or_else(new_id);
|
||||
if !api_key.is_empty() {
|
||||
// 显式改/填密钥 → 写 keyring(现状不变)
|
||||
if let Err(e) = set_provider_secret(&provider_id, &api_key) {
|
||||
if let Err(e) = set_provider_secret_async(provider_id.clone(), api_key.clone()).await {
|
||||
return Err(format!("密钥保存到系统钥匙串失败: {}", e));
|
||||
}
|
||||
} else if let Some(pid) = &id {
|
||||
@@ -116,10 +146,10 @@ pub async fn ai_save_provider(
|
||||
.map_err(err_str)?;
|
||||
if let Some(old) = old {
|
||||
if !old.api_key.is_empty()
|
||||
&& get_provider_secret(pid).is_none()
|
||||
&& get_provider_secret_async(pid.clone()).await.is_none()
|
||||
{
|
||||
// DB 有明文 且 keyring 无 → 即时迁移补密钥
|
||||
if let Err(e) = set_provider_secret(pid, &old.api_key) {
|
||||
if let Err(e) = set_provider_secret_async(pid.clone(), old.api_key.clone()).await {
|
||||
return Err(format!(
|
||||
"检测到该提供商密钥尚未迁移至系统钥匙串,本次保存尝试即时迁移失败({})。\
|
||||
已保留原密钥未改动——请检查系统钥匙串权限后再次保存。",
|
||||
@@ -246,7 +276,7 @@ pub async fn ai_delete_provider(
|
||||
state.ai_providers.delete(&provider_id).await.map_err(err_str)?;
|
||||
// CR-260615-01:DB 已删则清 keyring 残留密钥(失败仅 warn 不阻断——无 DB 消费方,
|
||||
// 残留 keyring 不可复活;同 id 复用也不会读到旧密钥,因 set 覆盖写)
|
||||
if let Err(e) = delete_provider_secret(&provider_id) {
|
||||
if let Err(e) = delete_provider_secret_async(provider_id.clone()).await {
|
||||
tracing::warn!("[FR-S1] keyring 清理失败 {} (残留但无消费方,不阻断删除): {}", provider_id, e);
|
||||
}
|
||||
// 删除的若是当前默认,清空 active 指向,避免悬空
|
||||
|
||||
@@ -153,6 +153,13 @@ fn compress_segment_has_unclosed_placeholder(msgs: &[ChatMessage]) -> bool {
|
||||
|
||||
/// 清理 LLM 返回的摘要:去首尾空白 / 包裹性 markdown 代码围栏,空则原样返回
|
||||
/// (调用方据空值报错)。保留内部 markdown 结构(## 标题 / 列表),摘要本身就是结构化文本。
|
||||
///
|
||||
/// **退化检测(BUG-2026-07-07/P0-1)**:实测 9357c27c 会话 seq0 system 消息内容为
|
||||
/// 同一短语重复 41 次的 1879 字符废文本(LLM 退化输出 / repetition degeneration)。
|
||||
/// 原因:某些情况下 LLM(compress 用的 Standard 智力模型)会陷入重复循环,产出形如
|
||||
/// "现在更新项目信息:...让我更新项目描述和技术栈。" × N 的退化输出,被当作合法摘要
|
||||
/// 塞进 active system 消息,污染每一轮 prompt。此处检测后返空,调用方据空值报错降级
|
||||
/// 走关键词摘要兜底(extract_keyword_summary),不让退化输出污染上下文。
|
||||
fn clean_summary(raw: &str) -> String {
|
||||
let t = raw.trim();
|
||||
// 去掉可能的整体代码围栏(LLM 偶尔把整段包成 ```markdown ... ```)
|
||||
@@ -162,9 +169,66 @@ fn clean_summary(raw: &str) -> String {
|
||||
.unwrap_or(t)
|
||||
.trim_start_matches('\n');
|
||||
let t = t.strip_suffix("```").unwrap_or(t).trim();
|
||||
// 退化检测:若文本含同一片段重复 ≥5 次,判定为 LLM 退化输出,返空让调用方降级。
|
||||
// 阈值 5 容忍正常排比(如列表项),只拦死循环式拼接(实测 41 次远超阈值)。
|
||||
if is_degenerated_repetition(t) {
|
||||
tracing::warn!(
|
||||
len = t.len(),
|
||||
"[ai] 压缩摘要疑似 LLM 退化重复输出,丢弃降级走关键词兜底"
|
||||
);
|
||||
return String::new();
|
||||
}
|
||||
t.to_string()
|
||||
}
|
||||
|
||||
/// 检测 LLM 退化重复输出:文本中是否存在某一长片段(≥8 字符)重复 ≥5 次。
|
||||
///
|
||||
/// 实测样本:`"我已经了解了项目结构。" + "现在更新项目信息:...让我更新项目描述和技术栈。" × 41`
|
||||
/// 即「开头有引导句 + 后接同一片段重复 N 次」形态。
|
||||
///
|
||||
/// 检测策略:退化输出的重复片段在文本中点附近必然是周期性的,故取**文本中部**一段作候选,
|
||||
/// 检查它在全文出现次数。取中点而非前缀,是因为退化输出常带不同前缀引导句
|
||||
/// ("我已经了解了项目结构。"),前缀取样会漏检。
|
||||
///
|
||||
/// **UTF-8 安全**:中文字符占 3 字节,按字节切片会切断字符致 panic。改按 `char_indices`
|
||||
/// 取字符边界,确保片段始终在完整字符边界上。
|
||||
fn is_degenerated_repetition(text: &str) -> bool {
|
||||
if text.len() < 40 {
|
||||
return false; // 太短不可能退化
|
||||
}
|
||||
const MIN_FRAGMENT_CHARS: usize = 8; // 最少 8 个字符(非字节)
|
||||
const REPEAT_THRESHOLD: usize = 5;
|
||||
// 收集字符边界位置(每个字符起始字节偏移),用于安全切片
|
||||
let char_boundaries: Vec<usize> = std::iter::once(0)
|
||||
.chain(text.char_indices().map(|(i, _)| i))
|
||||
.collect();
|
||||
let char_count = char_boundaries.len() - 1;
|
||||
if char_count < MIN_FRAGMENT_CHARS * REPEAT_THRESHOLD {
|
||||
return false; // 字符太少不可能重复 5 次
|
||||
}
|
||||
// 从文本中点取样候选片段(退化输出中段必然是周期性重复)
|
||||
let mid_char = char_count / 2;
|
||||
// 候选片段长度(字符数):从 MIN_FRAGMENT_CHARS 到 40 逐步试
|
||||
let max_frag_chars = 40.min(char_count / REPEAT_THRESHOLD);
|
||||
for frag_chars in (MIN_FRAGMENT_CHARS..=max_frag_chars).rev() {
|
||||
// 取中点附近的 frag_chars 个字符作候选(按字符边界切片)
|
||||
let start_char = mid_char.saturating_sub(frag_chars / 2);
|
||||
let end_char = (start_char + frag_chars).min(char_count);
|
||||
let start_byte = char_boundaries[start_char];
|
||||
let end_byte = char_boundaries[end_char];
|
||||
let fragment = &text[start_byte..end_byte];
|
||||
if fragment.chars().count() < MIN_FRAGMENT_CHARS {
|
||||
continue;
|
||||
}
|
||||
// 用子串计数(不依赖正则,避免引入依赖)
|
||||
let count = text.matches(fragment).count();
|
||||
if count >= REPEAT_THRESHOLD {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -184,6 +248,38 @@ mod tests {
|
||||
assert_eq!(clean_summary(""), "");
|
||||
}
|
||||
|
||||
// ── BUG-2026-07-07/P0-1: LLM 退化重复输出检测 ──
|
||||
#[test]
|
||||
fn clean_summary_rejects_degenerated_repetition() {
|
||||
// 实测样本(9357c27c seq0):同一短语重复 41 次
|
||||
let fragment = "现在更新项目信息:基于代码分析,这是一个大型综合游戏平台后端。让我更新项目描述和技术栈。";
|
||||
let degenerated = format!("我已经了解了项目结构。{}", fragment.repeat(41));
|
||||
// 退化输出应返空(调用方据空值报错降级走关键词兜底)
|
||||
assert_eq!(clean_summary(°enerated), "", "重复拼接的退化输出应被丢弃");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_summary_keeps_normal_summary_with_lists() {
|
||||
// 正常摘要(含列表项重复词)不应被误判为退化
|
||||
let normal = "## 意图\n- 整理文档\n## 决策\n- 使用方案A\n- 使用方案A\n- 使用方案A\n## 约束\n不超 100 字";
|
||||
assert_eq!(clean_summary(normal), normal, "正常摘要不应被误判");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_degenerated_repetition_detects_5x_repeat() {
|
||||
// 5 次重复阈值边界测试
|
||||
let fragment = "这是一个测试片段abcdef";
|
||||
assert!(is_degenerated_repetition(&fragment.repeat(5)));
|
||||
assert!(is_degenerated_repetition(&fragment.repeat(41)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_degenerated_repetition_ignores_short_text() {
|
||||
// 短文本不触发(即使重复)
|
||||
assert!(!is_degenerated_repetition("短文本短文本短文本短文本"));
|
||||
assert!(!is_degenerated_repetition("ab"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compress_prompt_returns_template_per_lang() {
|
||||
// 中文模板(默认)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! 对话持久化 + Token 累加器
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
@@ -12,6 +13,14 @@ use crate::commands::now_millis;
|
||||
use super::commands::message_to_record;
|
||||
use super::AiSession;
|
||||
|
||||
/// save_conversation 的总超时上限(秒)。
|
||||
///
|
||||
/// 内部 DB IO 经 Repo 方法已各自 spawn_blocking(不会阻塞 tokio runtime),
|
||||
/// 但仍可能因 SQLite Mutex 长等待 / fs 卡 / 大体量 records 序列化等卡住整个调用,
|
||||
/// 致 agentic loop 或 IPC 永久挂起。外层 timeout 防御:超时只 warn 不阻断
|
||||
/// (save 本就是非关键路径,失败已 warn + 用户可见结果仍在内存真相源)。
|
||||
const SAVE_CONVERSATION_TIMEOUT_SECS: u64 = 5;
|
||||
|
||||
/// Token 用量累加器(agent loop 生命周期内各轮叠加)
|
||||
///
|
||||
/// 纯结构 + 方法:抽自 run_agentic_loop 的 `total_prompt`/`total_completion` 双计数器,
|
||||
@@ -153,6 +162,33 @@ pub(crate) async fn save_conversation(
|
||||
usage: Option<&df_ai::provider::TokenUsage>,
|
||||
model: Option<&str>,
|
||||
touch_updated_at: bool,
|
||||
) {
|
||||
// 外层 timeout(SAVE_CONVERSATION_TIMEOUT_SECS):超时只 warn 不阻断。
|
||||
// save 是非关键路径(失败已 warn,内存真相源不变),防 DB/fs 卡死拖垮 agentic loop / IPC。
|
||||
// timeout 仅作用于 DB 写入段(此处包住整个 inner,因 session 锁/序列化也属 save 周期,
|
||||
// 超时即放弃本次 save,下次 loop 轮次会重新落库,幂等覆盖无副作用)。
|
||||
if let Err(_elapsed) = tokio::time::timeout(
|
||||
Duration::from_secs(SAVE_CONVERSATION_TIMEOUT_SECS),
|
||||
save_conversation_inner(session_arc, db, conv_id, usage, model, touch_updated_at),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
"save_conversation 超时 ({}s) 放弃 — conv_id={} (DB/fs 可能卡住,内存真相源不变,下次 loop 重落库)",
|
||||
SAVE_CONVERSATION_TIMEOUT_SECS,
|
||||
conv_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// save_conversation 内部实现(无超时,由外层 save_conversation 包 timeout 调用)。
|
||||
async fn save_conversation_inner(
|
||||
session_arc: &Arc<Mutex<AiSession>>,
|
||||
db: &Arc<Database>,
|
||||
conv_id: &str,
|
||||
usage: Option<&df_ai::provider::TokenUsage>,
|
||||
model: Option<&str>,
|
||||
touch_updated_at: bool,
|
||||
) {
|
||||
// 取 messages + 懒创建首次落库所需的 provider_id/created_at
|
||||
// 工具结果(content)超 8KB(B-260619-02:50KB→8KB,治 GLM 1214)时截断头尾各 ~3KB + 中段
|
||||
@@ -164,30 +200,42 @@ pub(crate) async fn save_conversation(
|
||||
// loop 内 save 由 run_agentic_loop 入参 conv_id 透传;IPC 路径(commands.rs)save 也传 conv_id。
|
||||
// conv() 惰性建:save 路径 conv 必然已建(send/regenerate/edit/switch 均先 conv());若极端
|
||||
// 未建(如启动恢复无 live conv),conv() 建空 PerConvState,save 空 messages(幂等不污染)。
|
||||
let (persist_msgs, provider_id, created_at, pinned_goals) = {
|
||||
// BUG-2026-07-19: lock 段只 clone 必要读(messages + 元数据),truncate 移出 lock。
|
||||
// 原 clone+truncate 全在 lock 内,对话历史长(多轮+大工具结果)时 clone+truncate 持锁可达秒级,
|
||||
// 致 guard.reset/process_tool_calls 等 session lock 竞争超时(aichat 卡死连环:工具卡片不呈现+
|
||||
// 回答完卡住+下条进队列)。truncate 在 clone 副本上操作,锁外执行不污染 session 真相源,行为等价。
|
||||
let __lock_start = std::time::Instant::now();
|
||||
let (mut msgs, provider_id, created_at, pinned_goals) = {
|
||||
let mut session = session_arc.lock().await;
|
||||
let mut msgs = session.conv(conv_id).messages.all_messages_clone();
|
||||
for m in &mut msgs {
|
||||
// P0-2:tool result 是结构化 JSON(provider 读工具返回原样),
|
||||
// 中段截断会插裸换行+中文省略标记破坏 JSON 结构致 reload/重发 parse FAIL
|
||||
// (实测 tool result read 大文件后落库 Invalid control character @pos3072)。
|
||||
// tool result 体量已由 read_file limit 控源,此处跳过 content 截断,仅截 assistant/user 文本。
|
||||
if !matches!(m.role, df_ai::provider::MessageRole::Tool) {
|
||||
m.content = truncate_for_persist(&m.content);
|
||||
}
|
||||
// F-260614-05 Phase 2a: parts(Image base64) 同样截断(替换占位 Text 片),
|
||||
// 防大体量图把对话 JSON 撑爆。仅作用于持久化副本,不污染内存真相源。
|
||||
if let Some(parts) = m.parts.as_ref() {
|
||||
m.parts = truncate_parts_for_persist(parts);
|
||||
}
|
||||
}
|
||||
(
|
||||
msgs,
|
||||
let __wait = __lock_start.elapsed();
|
||||
let cloned = (
|
||||
session.conv(conv_id).messages.all_messages_clone(),
|
||||
session.active_provider_id.clone(),
|
||||
session.active_conv_created_at.clone(),
|
||||
session.conv(conv_id).pinned_goals.clone(),
|
||||
)
|
||||
);
|
||||
let _ = __wait; // 诊断:lock 等待时长(下行 eprintln 输出 lock 段总时长)
|
||||
cloned
|
||||
};
|
||||
let __lock_total = __lock_start.elapsed();
|
||||
if __lock_total > std::time::Duration::from_millis(50) {
|
||||
eprintln!("[LOCK-DIAG] save clone 段持锁 {:?} (含等待,>50ms 报告,定位 session lock 长持有者)", __lock_total);
|
||||
}
|
||||
// truncate 在锁外(clone 副本上操作,不影响 session 真相源)
|
||||
for m in &mut msgs {
|
||||
// P0-2:tool result 是结构化 JSON(provider 读工具返回原样),
|
||||
// 中段截断会插裸换行+中文省略标记破坏 JSON 结构致 reload/重发 parse FAIL。
|
||||
// tool result 体量已由 read_file limit 控源,此处跳过 content 截断,仅截 assistant/user 文本。
|
||||
if !matches!(m.role, df_ai::provider::MessageRole::Tool) {
|
||||
m.content = truncate_for_persist(&m.content);
|
||||
}
|
||||
// F-260614-05 Phase 2a: parts(Image base64) 同样截断(替换占位 Text 片),
|
||||
// 防大体量图把对话 JSON 撑爆。仅作用于持久化副本,不污染内存真相源。
|
||||
if let Some(parts) = m.parts.as_ref() {
|
||||
m.parts = truncate_parts_for_persist(parts);
|
||||
}
|
||||
}
|
||||
let persist_msgs = msgs;
|
||||
|
||||
|
||||
// 序列化当前 conv 的挂起审批快照:从 session.pending_approvals 筛选本 conv 条目,
|
||||
|
||||
@@ -380,6 +380,7 @@ pub(crate) async fn inject_knowledge_into_prompt(
|
||||
// 同一条消息取 text + id(②口径修复):单次反向扫描,避免 text 过滤 is_active 而 id 不过滤
|
||||
// 导致两值取自不同消息。
|
||||
let (last_user_text, user_message_id) = {
|
||||
let __lock_t = std::time::Instant::now();
|
||||
let session = state.ai_session.lock().await;
|
||||
let msgs = session
|
||||
.conv_read(conv_id)
|
||||
@@ -389,6 +390,10 @@ pub(crate) async fn inject_knowledge_into_prompt(
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|m| matches!(m.role, MessageRole::User) && m.is_active());
|
||||
let __hold = __lock_t.elapsed();
|
||||
if __hold > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] build_system_prompt_with_knowledge:383 持锁 {:?} (含 lock 等待)", __hold);
|
||||
}
|
||||
match found {
|
||||
Some(m) => (m.content.clone(), m.id.clone()),
|
||||
None => (String::new(), None),
|
||||
@@ -434,6 +439,7 @@ pub(crate) async fn maybe_spawn_extraction(
|
||||
// true 即跳过(等价「提炼中」哨兵);spawn 后按 inserted 结果修正(见下)。
|
||||
// 清位:trigger_extraction_now(手动按钮)强制清位,允许用户手动重提炼。
|
||||
{
|
||||
let __lock_t = std::time::Instant::now();
|
||||
let mut session = session_arc.lock().await;
|
||||
// 一次 conv_read 读两个字段(knowledge_extracted + messages.len()),map 后借用即结束,
|
||||
// 后续 session.conv()(&mut self)不再冲突。
|
||||
@@ -443,6 +449,10 @@ pub(crate) async fn maybe_spawn_extraction(
|
||||
.unwrap_or((false, 0));
|
||||
// 守卫 1:已提炼过(或提炼中) → 跳过 + warn。
|
||||
if already {
|
||||
let __hold = __lock_t.elapsed();
|
||||
if __hold > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] maybe_spawn_extraction:437 持锁 {:?} (含 lock 等待)", __hold);
|
||||
}
|
||||
tracing::warn!(
|
||||
conv_id = %conv_id,
|
||||
"[knowledge] 跳过自动提炼:本会话已提炼过/提炼中(去重标志置位,防重复刷 candidate);如需重提炼用手动按钮"
|
||||
@@ -451,10 +461,18 @@ pub(crate) async fn maybe_spawn_extraction(
|
||||
}
|
||||
// 守卫 2:消息数。conv_read 未建返 0(< min_messages 自然跳过,语义=空对话不注入知识)。
|
||||
if (count as u32) < config.min_messages {
|
||||
let __hold = __lock_t.elapsed();
|
||||
if __hold > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] maybe_spawn_extraction:437 持锁 {:?} (含 lock 等待)", __hold);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
// 原子预置位(TOCTOU 核心):释放锁前先占提炼槽位,杜绝并发窗口内重复 spawn。
|
||||
session.conv(conv_id).knowledge_extracted = true;
|
||||
let __hold = __lock_t.elapsed();
|
||||
if __hold > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] maybe_spawn_extraction:437 持锁 {:?} (含 lock 等待)", __hold);
|
||||
}
|
||||
}
|
||||
|
||||
let session_arc = session_arc.clone();
|
||||
|
||||
@@ -45,7 +45,7 @@ pub mod stream_recv;
|
||||
pub mod title;
|
||||
pub mod tool_registry;
|
||||
|
||||
use agentic::conv_state::ConvState;
|
||||
use agentic::conv_state::{ConvState, ConvStateStore};
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
@@ -558,18 +558,13 @@ impl AiSession {
|
||||
/// `ai_chat_stop`(commands/chat.rs) 改调本方法替代各自手写的 has_pending,
|
||||
/// 收敛两处状态机判定到单一入口。
|
||||
///
|
||||
/// 兜底/回退:若本方法判定异常需快速回退手写 has_pending,调用方注释已保留原
|
||||
/// 三路组合(generating + path_auth + risk)字面量,改一行即可切回。
|
||||
pub fn session_state(&self, conv_id: &str) -> SessionState {
|
||||
// 阶段3a 单真相源合并后:两类挂起(path_auth + risk)合一进 pending_approvals,
|
||||
// kind 字段区分语义但状态机只看「有无挂起」,单表 any 一次即可(消除原双表 OR 合并判断)。
|
||||
/// B-Phase3:conv_state 读侧切 ConvStateStore(单源收敛)。
|
||||
pub fn session_state(&self, conv_id: &str, conv_states: &ConvStateStore) -> SessionState {
|
||||
let has_pending = self.pending_approvals.values()
|
||||
.any(|a| a.conversation_id.as_deref() == Some(conv_id));
|
||||
if has_pending {
|
||||
SessionState::AwaitingApproval
|
||||
} else if self.conv_read(conv_id).map(|c| c.conv_state.is_active()).unwrap_or(false) {
|
||||
// 读侧(双轨收口批2):改读 conv_state.is_active()(Generating+Compressed),判断是否在
|
||||
// 活跃生成。is_active() 与原 generating 语义一致(压缩期间 loop 仍活跃,应判为 Streaming)。
|
||||
} else if conv_states.is_active(conv_id) {
|
||||
SessionState::Streaming
|
||||
} else {
|
||||
SessionState::Idle
|
||||
@@ -613,7 +608,8 @@ mod tests_f09_per_conv {
|
||||
fn test_per_conv_state_new() {
|
||||
let s = PerConvState::new();
|
||||
// 批3 收口:generating bool 已退役,生成态初值校验改读 conv_state(应为 Idle)。
|
||||
assert_eq!(s.conv_state, ConvState::Idle, "conv_state 初值应为 Idle");
|
||||
// B-Phase3:conv_state 已迁 ConvStateStore,PerConvState 不再持有。
|
||||
// conv_state 由 guard.new(Idle→Generating)经 conv_states.transition 迁移。
|
||||
assert_eq!(s.iteration_used, 0, "iteration_used 初值应为 0");
|
||||
assert!(s.agent_language.is_none(), "agent_language 初值应为 None");
|
||||
assert!(s.model_override.is_none(), "model_override 初值应为 None");
|
||||
@@ -644,7 +640,8 @@ mod tests_f09_per_conv {
|
||||
let ptr_first = {
|
||||
let s = session.conv("conv-a");
|
||||
// 批3 收口:generating bool 已退役,首建会话生成态应为 Idle。
|
||||
assert_eq!(s.conv_state, ConvState::Idle);
|
||||
// B-Phase3:conv_state 已迁 ConvStateStore。
|
||||
// conv_state 初值由 guard/入口保证,PerConvState 不再持有。
|
||||
assert_eq!(s.iteration_used, 0);
|
||||
s as *const PerConvState
|
||||
};
|
||||
@@ -816,10 +813,7 @@ mod tests_f09_per_conv {
|
||||
pub struct PerConvState {
|
||||
/// 对话历史(ContextManager:会话级消息真相源,裁剪仅影响发送视图)
|
||||
pub messages: ContextManager,
|
||||
/// L2 统一状态机:对话生命周期状态(单一真相源,批2 持久化供读侧/前端消费)。
|
||||
/// 写收敛:经 GeneratingGuard/入口 transition_to 守卫迁移,非直接赋值。
|
||||
/// 批3 双轨收口:generating bool 已退役,此 enum 成为生成态唯一真相源。
|
||||
pub conv_state: ConvState,
|
||||
// B-Phase3:conv_state 字段已迁到 ConvStateStore(无锁真相源),此字段已删。
|
||||
/// G1 目标钉扎真相源:从 LLM 工具调用推理的目标列表(内容态字段,与生命周期态正交)。
|
||||
///
|
||||
/// 治 R1(目标消息被压缩/compressed 物理出局,sanitize step0 过滤 is_active 致目标丢失):
|
||||
@@ -907,7 +901,7 @@ impl PerConvState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
messages: ContextManager::new(ContextConfig::default()),
|
||||
conv_state: ConvState::Idle,
|
||||
|
||||
pinned_goals: Vec::new(),
|
||||
stop_flag: Arc::new(AtomicBool::new(false)),
|
||||
notify: Arc::new(tokio::sync::Notify::new()),
|
||||
|
||||
@@ -77,6 +77,8 @@ fn system_prompt_parts(lang: &str) -> (&'static str, &'static str, &'static str)
|
||||
- Ask for clarification if the user's intent is unclear\n\
|
||||
- Prefer using tools to complete actions rather than just describing steps\n\
|
||||
- When a tool call fails, clearly tell the user it failed and why. Never disguise a fallback action as the original intent's success (e.g. don't write to description to fake a directory binding), and never falsely report success\n\
|
||||
- **Self-check before claiming completion**: Before announcing a task done, verify the result matches the original intent (grep key references / read back field values / cross-check). Fix discrepancies before declaring success; never claim all done without verification (repeatedly observed: missed reference renames, fields silently overwritten by later ops, forcing the user to manually verify)\n\
|
||||
- **Duplicate call detection**: If a tool (e.g. read_file/search_files) has already been called with identical arguments and returned successfully, do not call it again — the result is already in the conversation history; review the context instead of re-executing\n\
|
||||
## Focus\n\
|
||||
- Always center your response on the core goal of the user's current request; the previous round's topic is only background, not the current task.\n\
|
||||
- When the user switches topics (a clear new intent), follow the latest request; do not drag the old topic into the new answer.\n\
|
||||
@@ -101,6 +103,8 @@ fn system_prompt_parts(lang: &str) -> (&'static str, &'static str, &'static str)
|
||||
- 如果不确定用户意图,先提问\n\
|
||||
- 优先使用工具完成操作,而不是只描述步骤\n\
|
||||
- 工具调用失败时必须明确告知用户失败原因,严禁用替代操作冒充原意图成功(如绑定目录失败不得改写描述冒充已绑定),也绝不谎报成功\n\
|
||||
- **宣称任务完成前必须自检**:用 grep/读取关键引用/核对字段值等方式验证结果与原意图一致,发现偏差先修正再宣布完成;严禁不验证就宣称「全部完成」(实测反复出现:漏改引用名、字段被后续操作覆盖未发现,被迫用户人工核对)\n\
|
||||
- **重复调用检测**:如果某个工具(如 read_file/search_files)已用相同参数调用过且返回成功,不要重复调用——会话历史里已有结果,回顾上下文而非重复执行\n\
|
||||
## 聚焦准则\n\
|
||||
- 始终围绕用户当前请求的核心目标回答;上一轮的主题只是背景,不是当前任务。\n\
|
||||
- 用户切换话题(明显的新意图)时,以最新请求为准,不要把旧话题带进新回答。\n\
|
||||
|
||||
@@ -738,33 +738,30 @@ async fn route_send_message(app: &AppHandle, state: &State<'_, AppState>, args:
|
||||
/// - 读 `conv_state.is_active()`(Generating / Compressed 派生态均视为活跃,压缩期间 loop 仍活跃;
|
||||
/// 批3 收口后单一 enum 路径,开关 + generating bool 已退役)
|
||||
///
|
||||
/// 目标 conv 解析(对齐 ai_is_generating:278-284):
|
||||
/// 目标 conv 解析(对齐 ai_is_generating):
|
||||
/// - 入参 conversation_id 非空优先
|
||||
/// - 否则 fallback `active_conversation_id`
|
||||
/// - 都无 → 返 None(放行,无 conv 在跑)
|
||||
///
|
||||
/// 读 conv 状态用 `conv_read`(只读不创建),对齐 ai_is_generating 实现。
|
||||
/// 读 ConvState 用无锁 `conv_states`(B-Phase2,零锁竞争)。
|
||||
async fn check_generating_reject(
|
||||
state: &State<'_, AppState>,
|
||||
conversation_id: Option<&str>,
|
||||
) -> Option<String> {
|
||||
let session = state.ai_session.lock().await;
|
||||
// 目标 conv:入参优先 → active 兜底。
|
||||
let target = conversation_id
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| session.active_conversation_id.clone());
|
||||
// 目标 conv:入参优先 → active 兜底(轻量 lock 取 active 单值)。
|
||||
let target = {
|
||||
let session = state.ai_session.lock().await;
|
||||
conversation_id
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| session.active_conversation_id.clone())
|
||||
};
|
||||
let target = match target {
|
||||
Some(id) => id,
|
||||
None => return None, // 无目标 conv 且无 active:无任何 conv 在跑,放行
|
||||
};
|
||||
// 读侧(双轨收口批2):统一读 conv_state.is_active()(Generating+Compressed),删除
|
||||
// CONV_STATE_ENABLED off 回退分支(enum 单路径)。对齐 ai_is_generating,无 conv_read 视为 false 放行。
|
||||
let is_gen = session
|
||||
.conv_read(&target)
|
||||
.map(|c| c.conv_state.is_active())
|
||||
.unwrap_or(false);
|
||||
if is_gen {
|
||||
// B-Phase2:读侧切无锁 conv_states.is_active()(Generating/Compressed),对齐 ai_is_generating。
|
||||
if state.conv_states.is_active(&target) {
|
||||
Some(target)
|
||||
} else {
|
||||
None
|
||||
@@ -833,7 +830,7 @@ fn args_get_mention_spans(args: &Value, key: &str) -> Option<Vec<MentionSpanDto>
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::commands::ai::{AiSession, PerConvState};
|
||||
use crate::commands::ai::PerConvState;
|
||||
use df_ai::context::ContextConfig;
|
||||
|
||||
// ── MiniCommand 反序列化测试 ──
|
||||
@@ -920,97 +917,69 @@ mod tests {
|
||||
assert_eq!(args_get_bool(&args, "missing"), None);
|
||||
}
|
||||
|
||||
// ── R1 check_generating_reject 路径测试(隔离 AiSession,绕开 Tauri State 依赖) ──
|
||||
// ── R1 check_generating_reject 路径测试 ──
|
||||
//
|
||||
// check_generating_reject 需 `State<AppState>`,单元测试难构造。
|
||||
// 改测 R1 核心判定逻辑:用 AiSession + PerConvState 模拟生成态,
|
||||
// 复核 conv_read + conv_state.is_active() 口径对齐 ai_is_generating。
|
||||
// (批3 收口后单一 enum 路径,开关 + generating bool 已退役。)
|
||||
// check_generating_reject 生产实现经 State<AppState> 读 conv_states(B-Phase2),
|
||||
// 单元测试难构造 Tauri State。改测 R1 核心判定口径:用 ConvStateStore(无锁,独立可构造)
|
||||
// 模拟生成态,复核 is_active() 拒绝/放行判定对齐 check_generating_reject / ai_is_generating。
|
||||
|
||||
/// R1 判定:无目标 conv 且无 active → 放行(返 None)。
|
||||
#[test]
|
||||
fn test_r1_no_target_pass() {
|
||||
let session = AiSession::new();
|
||||
// 无 active,无入参 conv_id → 无任何 conv 在跑
|
||||
assert!(
|
||||
session.active_conversation_id.is_none(),
|
||||
"新 session 无 active conv"
|
||||
);
|
||||
assert!(
|
||||
session.per_conv.is_empty(),
|
||||
"新 session per_conv 为空"
|
||||
);
|
||||
// 无入参 conv_id 且 active_conversation_id=None 在生产侧由 check_generating_reject 早 return。
|
||||
// 此处只验空 store 的默认语义:任何 conv_id 读返 Idle 不活跃。
|
||||
let store = crate::commands::ai::agentic::conv_state::ConvStateStore::new();
|
||||
assert!(!store.is_active("any"), "空 store 读任意 conv_id 不活跃");
|
||||
}
|
||||
|
||||
/// R1 判定:目标 conv 未在跑 → 放行(conv_state=Idle,is_active()=false)。
|
||||
/// R1 判定:目标 conv 未在跑 → 放行(Idle,is_active()=false)。
|
||||
#[test]
|
||||
fn test_r1_target_idle_pass() {
|
||||
use crate::commands::ai::agentic::conv_state::ConvState;
|
||||
let mut session = AiSession::new();
|
||||
let conv = session.conv("conv-idle");
|
||||
conv.conv_state = ConvState::Idle;
|
||||
// 批3 收口:读 conv_state.is_active() 口径(对齐 check_generating_reject / ai_is_generating)。
|
||||
// 开关已退役,直接读 enum 单一路径。
|
||||
let is_gen = session
|
||||
.conv_read("conv-idle")
|
||||
.map(|c| c.conv_state.is_active())
|
||||
.unwrap_or(false);
|
||||
use crate::commands::ai::agentic::conv_state::{ConvState, ConvStateStore};
|
||||
let store = ConvStateStore::new();
|
||||
store.transition("conv-idle", ConvState::Idle).unwrap();
|
||||
let is_gen = store.is_active("conv-idle");
|
||||
assert!(!is_gen, "conv-idle Idle 未在跑,is_gen 应为 false(放行)");
|
||||
}
|
||||
|
||||
/// R1 判定:目标 conv 在跑 → 拒绝(conv_state=Generating,is_active()=true)。
|
||||
/// R1 判定:目标 conv 在跑 → 拒绝(Generating,is_active()=true)。
|
||||
#[test]
|
||||
fn test_r1_target_generating_reject() {
|
||||
use crate::commands::ai::agentic::conv_state::ConvState;
|
||||
let mut session = AiSession::new();
|
||||
let conv = session.conv("conv-busy");
|
||||
// 批3 收口:generating bool 已退役,生成态经 conv_state=Generating 表达(单一真相源)。
|
||||
conv.conv_state = ConvState::Generating;
|
||||
let is_gen = session
|
||||
.conv_read("conv-busy")
|
||||
.map(|c| c.conv_state.is_active())
|
||||
.unwrap_or(false);
|
||||
use crate::commands::ai::agentic::conv_state::{ConvState, ConvStateStore};
|
||||
let store = ConvStateStore::new();
|
||||
// 生成态经 transition(Generating) 写入(单一真相源,无 generating bool 双轨)。
|
||||
store.transition("conv-busy", ConvState::Generating).unwrap();
|
||||
let is_gen = store.is_active("conv-busy");
|
||||
assert!(is_gen, "conv-busy Generating 应拒绝");
|
||||
}
|
||||
|
||||
/// R1 判定:conv_state 派生态(Generating)is_active()=true(单一 enum 路径)。
|
||||
/// R1 判定:conv_state 派生态(Compressed)is_active()=true(单一 enum 路径)。
|
||||
///
|
||||
/// 验证批3 收口后读 conv_state.is_active() 判定活跃拒绝(enum 唯一真相源)。
|
||||
/// 验证读 is_active() 判定活跃拒绝(enum 唯一真相源),含 Compressed 派生。
|
||||
#[test]
|
||||
fn test_r1_conv_state_active_reject() {
|
||||
use crate::commands::ai::agentic::conv_state::ConvState;
|
||||
// 批3 收口:开关已退役,enum 单一真相源,无条件 on 路径。
|
||||
let mut session = AiSession::new();
|
||||
let conv = session.conv("conv-state-busy");
|
||||
// 模拟状态机写收敛路径:conv_state=Generating(单一真相源,无 generating bool 双轨)。
|
||||
conv.conv_state = ConvState::Generating;
|
||||
let is_gen = session
|
||||
.conv_read("conv-state-busy")
|
||||
.map(|c| c.conv_state.is_active())
|
||||
.unwrap_or(false);
|
||||
use crate::commands::ai::agentic::conv_state::{ConvState, ConvStateStore};
|
||||
let store = ConvStateStore::new();
|
||||
// Compressed 派生态:loop 仍活跃,is_active()=true。
|
||||
store.transition("conv-state-busy", ConvState::Generating).unwrap();
|
||||
store.transition("conv-state-busy", ConvState::Compressed).unwrap();
|
||||
let is_gen = store.is_active("conv-state-busy");
|
||||
assert!(
|
||||
is_gen,
|
||||
"conv_state=Generating is_active() 应为 true(单一 enum 路径拒绝)"
|
||||
"conv_state=Compressed is_active() 应为 true(单一 enum 路径拒绝)"
|
||||
);
|
||||
}
|
||||
|
||||
/// R1 目标 conv 解析:入参 conversation_id 优先于 active_conversation_id。
|
||||
#[test]
|
||||
fn test_r1_target_input_priority() {
|
||||
use crate::commands::ai::agentic::conv_state::ConvState;
|
||||
let mut session = AiSession::new();
|
||||
session.active_conversation_id = Some("conv-active".to_string());
|
||||
use crate::commands::ai::agentic::conv_state::{ConvState, ConvStateStore};
|
||||
let store = ConvStateStore::new();
|
||||
// active 在跑(Generating),但入参 conv-other 未跑(Idle) → 入参优先,应放行
|
||||
session.conv("conv-active").conv_state = ConvState::Generating;
|
||||
let _ = session.conv("conv-other"); // 创建但 Idle(默认)
|
||||
let active_gen = session
|
||||
.conv_read("conv-active")
|
||||
.map(|c| c.conv_state.is_active())
|
||||
.unwrap_or(false);
|
||||
let other_gen = session
|
||||
.conv_read("conv-other")
|
||||
.map(|c| c.conv_state.is_active())
|
||||
.unwrap_or(false);
|
||||
store.transition("conv-active", ConvState::Generating).unwrap();
|
||||
// conv-other 不写入(默认 Idle)
|
||||
let active_gen = store.is_active("conv-active");
|
||||
let other_gen = store.is_active("conv-other");
|
||||
assert!(active_gen, "conv-active 在跑");
|
||||
assert!(!other_gen, "conv-other 未跑");
|
||||
// 入参 conv-other 优先 → 判定 conv-other Idle → 放行
|
||||
@@ -1019,12 +988,12 @@ mod tests {
|
||||
|
||||
// ── PerConvState 初值对齐(确保 R1 测试基线 conv_state=Idle) ──
|
||||
|
||||
/// PerConvState::new() conv_state 初值 Idle(R1 放行基线)。
|
||||
/// B-Phase3:PerConvState.conv_state 字段已迁 ConvStateStore。
|
||||
#[test]
|
||||
fn test_per_conv_state_conv_state_default_idle() {
|
||||
use crate::commands::ai::agentic::conv_state::ConvState;
|
||||
let _ = ContextConfig::default(); // 触发 ContextConfig default 可用(避免 unused)
|
||||
let s = PerConvState::new();
|
||||
assert_eq!(s.conv_state, ConvState::Idle, "PerConvState::new conv_state 初值应为 Idle");
|
||||
fn test_conv_state_store_default_idle() {
|
||||
use crate::commands::ai::agentic::conv_state::ConvStateStore;
|
||||
let store = ConvStateStore::new();
|
||||
assert_eq!(store.get("nonexistent"), crate::commands::ai::agentic::conv_state::ConvState::Idle,
|
||||
"ConvStateStore 不存在的 conv_id 返 Idle");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,16 +287,23 @@ type SkillsGuard = RwLockReadGuard<'static, Option<Vec<SkillInfo>>>;
|
||||
///
|
||||
/// 返 `RwLockReadGuard<Option<Vec<SkillInfo>>>`,调用方解 `*guard` 得 `&Vec<SkillInfo>`。
|
||||
/// 懒初始化走双检锁:先读锁查 Some(快),None 时释放 → 扫盘 → 写锁填回 → 读锁重取。
|
||||
fn skills_lock() -> SkillsGuard {
|
||||
// 快路径:读锁命中
|
||||
///
|
||||
/// P1-260617-3:`scan_skills` 同步递归 `fs::read_dir` + `read_to_string`(plugins/marketplaces
|
||||
/// 多层嵌套,Windows 文件多时同步阻塞 tokio runtime)。本函数改 async,慢路径扫盘包
|
||||
/// `spawn_blocking` 隔离(对齐 commands/project.rs detect_stack 模式)。快路径(读锁命中)仍同步无 fs。
|
||||
async fn skills_lock_async() -> SkillsGuard {
|
||||
// 快路径:读锁命中(无 fs,纯内存)
|
||||
{
|
||||
let g = RwLock::read(&SKILLS).expect("SKILLS poisoned");
|
||||
if g.is_some() {
|
||||
return g;
|
||||
}
|
||||
}
|
||||
// 慢路径:扫盘 + 写锁填回
|
||||
let scanned = scan_skills().skills;
|
||||
// 慢路径:扫盘(spawn_blocking 隔离同步 fs 递归,防阻塞 tokio runtime)
|
||||
let scanned = tokio::task::spawn_blocking(scan_skills)
|
||||
.await
|
||||
.map(|res| res.skills)
|
||||
.unwrap_or_default();
|
||||
{
|
||||
let mut g = RwLock::write(&SKILLS).expect("SKILLS poisoned");
|
||||
// 另一线程可能已填,二次检查(双检锁)
|
||||
@@ -314,8 +321,11 @@ fn skills_lock() -> SkillsGuard {
|
||||
///
|
||||
/// 替代原 `OnceLock::get_or_init` 路径:返 owned `Vec<SkillInfo>`(clone),
|
||||
/// 因 RwLock 不能返 `&'static`。调用方(config.rs:30 / read_skill_content_stripped)已同步适配。
|
||||
pub(crate) fn skills_cached() -> Vec<SkillInfo> {
|
||||
let g = skills_lock();
|
||||
///
|
||||
/// P1-260617-3:改 async,慢路径(首次/重扫)走 `skills_lock_async` → spawn_blocking
|
||||
/// 隔离同步 fs 防阻塞 tokio runtime(Tauri 单线程 runtime)。快路径(读锁命中)无 fs。
|
||||
pub(crate) async fn skills_cached() -> Vec<SkillInfo> {
|
||||
let g = skills_lock_async().await;
|
||||
g.clone().unwrap_or_default()
|
||||
}
|
||||
|
||||
@@ -332,11 +342,25 @@ pub(crate) fn invalidate_skills() {
|
||||
///
|
||||
/// 核心设计6:注入用正文,避免 YAML 头噪声污染 system prompt。
|
||||
/// 缓存未命中返 None;文件读失败返 None。
|
||||
pub(crate) fn read_skill_content_stripped(name: &str) -> Option<String> {
|
||||
let g = skills_lock();
|
||||
let skills = g.as_ref()?;
|
||||
let info = skills.iter().find(|s| s.name == name)?;
|
||||
let md = fs::read_to_string(&info.path).ok()?;
|
||||
///
|
||||
/// P1-260617-3:改 async,缓存懒初始化(可能触发扫盘)走 spawn_blocking 防阻塞 runtime。
|
||||
///
|
||||
/// 注:guard(std::sync::RwLockReadGuard 非 Send)必须在 spawn_blocking 的 .await 前 drop,
|
||||
/// 否则非 Send 跨 await 点致 future 不 Send(MentionResolver 要求 Send)。guard 用 { } 限作用域。
|
||||
pub(crate) async fn read_skill_content_stripped(name: String) -> Option<String> {
|
||||
// 在作用域内取 path 后立即 drop guard,避免非 Send guard 跨 spawn_blocking await 点
|
||||
let path: String = {
|
||||
let g = skills_lock_async().await;
|
||||
let skills = g.as_ref()?;
|
||||
let info = skills.iter().find(|s| s.name == name)?;
|
||||
info.path.clone()
|
||||
};
|
||||
// 单文件读取仍是同步 fs,但只一次小读;持续阻塞风险远低于扫盘递归。
|
||||
// 仍包 spawn_blocking 与慢路径一致(对齐 detect_stack:防 Windows fs 调度慢)。
|
||||
let md = tokio::task::spawn_blocking(move || fs::read_to_string(&path))
|
||||
.await
|
||||
.ok()?
|
||||
.ok()?;
|
||||
Some(strip_frontmatter(&md))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
//! 流式接收 LLM 响应
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::time::Duration;
|
||||
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
use futures::StreamExt;
|
||||
use tracing::warn;
|
||||
|
||||
use df_ai::provider::{CompletionRequest, LlmProvider};
|
||||
@@ -21,39 +21,30 @@ use super::{AiChatEvent, ToolCallDraft};
|
||||
/// timeout/connect 关键词分类。
|
||||
///
|
||||
/// 返回 `(status_or_class, raw)`:status 取文本中首个三位数;否则按关键词给 timeout/connect/unknown。
|
||||
fn extract_error_diag(e: &anyhow::Error) -> (String, String) {
|
||||
let raw = e.to_string();
|
||||
|
||||
// 1) 抠 HTTP 状态码:匹配 provider bail 串里的 "错误 4xx/5xx" 或 reqwest 的 "HTTP status"。
|
||||
// R-P2-6:原实现按字节窗口 `&bytes[i..i+3]` 切片——若窗口恰好切在多字节 UTF-8 字符中间会 panic
|
||||
// (依赖中文恰好 3 字节、状态码恰为 ascii 的巧合)。改为按 char 迭代 + 前后非数字边界判断,
|
||||
// 既 UTF-8 安全又顺便修复"长数字串(如端口号 14012)内嵌 401 误命中"的潜在问题。
|
||||
// 白名单码(首位 4/5,故只查 4xx/5xx 区段,减少无效匹配):
|
||||
/// 从字符串(非 anyhow)提取诊断信息,用于 mpsc channel 中来自专用线程的错误字符串。
|
||||
fn extract_error_diag_from_str(raw: &str) -> (String, String) {
|
||||
// 1) 抠 HTTP 状态码
|
||||
const HTTP_CODES: &[&str] = &[
|
||||
"400", "401", "403", "404", "408", "409", "413",
|
||||
"422", "429", "500", "502", "503", "504",
|
||||
];
|
||||
// 把 raw 按 char 收集,索引即 char 下标(非字节),边界判断用 char 安全
|
||||
let chars: Vec<char> = raw.chars().collect();
|
||||
let n = chars.len();
|
||||
let mut i = 0;
|
||||
while i + 3 <= n {
|
||||
// 窗口必须是三个 ascii 数字
|
||||
if chars[i].is_ascii_digit() && chars[i + 1].is_ascii_digit() && chars[i + 2].is_ascii_digit() {
|
||||
let code: String = chars[i..i + 3].iter().collect();
|
||||
// 前后边界必须非数字(否则会从端口号 14012 里抠出 401)
|
||||
let prev_ok = i == 0 || !chars[i - 1].is_ascii_digit();
|
||||
let next_ok = i + 3 == n || !chars[i + 3].is_ascii_digit();
|
||||
if prev_ok && next_ok && HTTP_CODES.contains(&code.as_str()) {
|
||||
return (format!("HTTP {}", code), raw);
|
||||
return (format!("HTTP {}", code), raw.to_string());
|
||||
}
|
||||
i += 3; // 已确认是三连数字,跳过避免窗口重叠重复扫
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
// 2) 传输层分类(reqwest Display 文本特征),不命名 reqwest 类型
|
||||
let lower = raw.to_lowercase();
|
||||
let class = if lower.contains("timeout") || lower.contains("超时") {
|
||||
"timeout"
|
||||
@@ -64,13 +55,20 @@ fn extract_error_diag(e: &anyhow::Error) -> (String, String) {
|
||||
} else {
|
||||
"unknown"
|
||||
};
|
||||
(class.to_string(), raw)
|
||||
(class.to_string(), raw.to_string())
|
||||
}
|
||||
|
||||
/// 从 anyhow::Error 提取诊断信息
|
||||
#[allow(dead_code)]
|
||||
fn extract_error_diag(e: &anyhow::Error) -> (String, String) {
|
||||
extract_error_diag_from_str(&e.to_string())
|
||||
}
|
||||
|
||||
/// AiError 诊断消息的上下文,区分「建连/首字节失败」与「流中途断」两类。
|
||||
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
|
||||
pub(crate) enum DiagKind {
|
||||
/// `provider.stream()` 直接返回 Err:连接/鉴权/HTTP non-2xx 等
|
||||
#[allow(dead_code)]
|
||||
Init,
|
||||
/// 流已建立,next() 返回 Err:SSE 传输断/解析错等
|
||||
MidStream,
|
||||
@@ -144,282 +142,335 @@ pub(crate) enum StreamResult {
|
||||
/// 前端 watchdog 据此 reset,区分「LLM 在跑」与「真断」(避免空气泡误报中断)
|
||||
/// - `stop_notify.notified()`:用户点停止即时打断,不再等 chunk 到或 120s idle timeout
|
||||
pub(crate) async fn stream_llm(
|
||||
provider: &dyn LlmProvider,
|
||||
provider: Arc<dyn LlmProvider>,
|
||||
request: CompletionRequest,
|
||||
app_handle: &AppHandle,
|
||||
stop_flag: &AtomicBool,
|
||||
notify: &tokio::sync::Notify,
|
||||
conv_id: &str,
|
||||
) -> StreamResult {
|
||||
/// 流式读取空闲超时:超过此时长无任何 chunk 即判定连接已断
|
||||
const STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
/// 流式读取空闲超时:超过此时长无任何 chunk 即判定连接已断
|
||||
/// BUG-2026-07-17:原 120s 太长,后端挂死时用户等太久。改为 45s,正常流式每 chunk 间隔
|
||||
/// 远不到 45s(即使模型思考间隙),超时即判为连接异常走保文/报错路径
|
||||
const STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(45);
|
||||
/// BUG-2026-07-08: 首 chunk 超时(等首字节)。
|
||||
/// 正常 provider 首 token <3s(即使 48 工具定义 + 长上下文),10s 足够宽容;
|
||||
/// 已有数据后中途静默仍用 STREAM_IDLE_TIMEOUT(120s,允许模型思考间隙)。
|
||||
const FIRST_CHUNK_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
/// 心跳间隔:静默期向前端报「LLM 仍在跑」,reset watchdog
|
||||
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30);
|
||||
|
||||
match provider.stream(request).await {
|
||||
Ok(mut stream) => {
|
||||
let mut full_text = String::new();
|
||||
let mut tool_calls_acc: HashMap<u32, ToolCallDraft> = HashMap::new();
|
||||
let mut finished_received = false;
|
||||
let mut stopped = false;
|
||||
let mut final_usage: Option<df_ai::provider::TokenUsage> = None;
|
||||
// BUG-260617-12: DeepSeek thinking 模式推理内容累积(多轮需回传)
|
||||
let mut reasoning_content_acc: Option<String> = None;
|
||||
// ================================================================
|
||||
// BUG-2026-07-17 根治: 整个 LLM 流式请求运行在独立 OS 线程的
|
||||
// 专用 tokio runtime 上,通过 mpsc channel 将 chunk 流式送回主 runtime。
|
||||
// ================================================================
|
||||
//
|
||||
// 根因: Tauri 在 Windows 上使用单线程 tokio runtime。provider.stream()
|
||||
// 内部(eventsource-stream/reqwest TLS/网络建连)存在同步阻塞操作,
|
||||
// 阻塞唯一工作线程导致 tokio 计时器系统无法推进,timeout 不触发。
|
||||
//
|
||||
// 方案:
|
||||
// 1. 独立 OS 线程 + 多线程 tokio runtime 执行完整 LLM 请求
|
||||
// (provider.stream + stream.next 循环),隔离同步阻塞。
|
||||
// 2. 通过 mpsc channel 将 StreamChunk 逐块传回主 runtime。
|
||||
// 3. reqwest Response/Stream 的生命周期不跨 runtime,避免
|
||||
// "error decoding response body"(runtime 状态不兼容)。
|
||||
//
|
||||
// 资源: 专用线程运行至 LLM 流结束或 mpsc receiver 被 drop(超时/停止)。
|
||||
let (chunk_tx, mut chunk_rx) = tokio::sync::mpsc::channel::<
|
||||
std::result::Result<df_ai::provider::StreamChunk, String>
|
||||
>(64);
|
||||
let provider_for_thread = provider.clone();
|
||||
let conv_id_owned = conv_id.to_string();
|
||||
|
||||
// B-260615-15:heartbeat interval 提至 loop 外复用,避免每轮重建计时器
|
||||
// (每轮重建会丢已积累的节拍,且 interval 首次 tick 立即返回的特性会被误用)。
|
||||
// tokio interval 首 tick 立即返回——此处先丢弃首 tick,让心跳等满首个 30s 静默期才发
|
||||
// (心跳语义是"静默期仍在跑",循环入口立即报无意义且会与 stream.next() 抢分支错过首 chunk)。
|
||||
let mut heartbeat = tokio::time::interval(HEARTBEAT_INTERVAL);
|
||||
heartbeat.tick().await;
|
||||
|
||||
loop {
|
||||
// B-260615-04:stop 即时打断。stream.next() 阻塞等 chunk 时,
|
||||
// 用户点停止需等 chunk 到或 120s idle timeout 才轮到此处检查——
|
||||
// 合并到下方 select! 的 stop_notify 分支后此处为快路径(非阻塞首检)。
|
||||
// stop_flag 可能被 stopChat() 在 select! 阻塞期间置位,
|
||||
// select! 的 stop_notify 分支会唤醒;此处保留作冗余快检(非阻塞)。
|
||||
if stop_flag.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
stopped = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// 三分支 select!(B-260615-02 + B-260615-04 合并):
|
||||
// 1) stream.next():正常 chunk(idle timeout 120s 包裹,真断返回 InitFailed 交调用方处理)
|
||||
// 2) heartbeat.tick():静默期发 AiHeartbeat reset 前端 watchdog
|
||||
// 3) stop_notify.notified():用户停止即时打断
|
||||
//
|
||||
// 注意:stop_flag 是 AtomicBool 无 async 通知能力——
|
||||
// 此处用「timeout 包 stream.next() + 进入循环前/后查 stop_flag + 循环内 30s 心跳 tick」
|
||||
// 间接实现「≤30s 感知 stop」(每轮 select! 至多 120s,但心跳 tick 30s 一次会
|
||||
// 触发 select! 返回 → 循环回顶部 stop_flag 快检)。无 Notify 依赖,改动最小。
|
||||
tokio::select! {
|
||||
// 1) 正常 chunk(idle timeout 包裹)
|
||||
chunk_result = tokio::time::timeout(STREAM_IDLE_TIMEOUT, stream.next()) => {
|
||||
match chunk_result {
|
||||
Err(_elapsed) => {
|
||||
// UX-2025-04 / CR-30-2 / 决策 F-260616-07 a1:
|
||||
// idle timeout 属 MidStream 类失败——已有文本则保文(Partial),不重试。
|
||||
// 空文本无文可保,UX-260618-15: 不再 emit AiError(retryable=true 重试路径,
|
||||
// emit 权交 agentic 重试耗尽时统一发,避免 N 次重试 N+1 气泡)。
|
||||
if full_text.is_empty() && tool_calls_acc.is_empty() {
|
||||
return StreamResult::InitFailed {
|
||||
retryable: true,
|
||||
error: "流式响应超时(120 秒无数据,连接可能已断开)".to_string(),
|
||||
};
|
||||
}
|
||||
warn!(
|
||||
provider = %provider.name(),
|
||||
conv_id = %conv_id,
|
||||
text_len = full_text.len(),
|
||||
"[ai] 流中途 idle timeout,保文不重试(incomplete)",
|
||||
);
|
||||
return StreamResult::Partial {
|
||||
text: full_text,
|
||||
tool_calls: tool_calls_acc,
|
||||
usage: final_usage.unwrap_or_default(),
|
||||
reasoning_content: reasoning_content_acc,
|
||||
};
|
||||
}
|
||||
Ok(None) => break, // 流正常结束
|
||||
Ok(Some(chunk_result)) => match chunk_result {
|
||||
Ok(chunk) => {
|
||||
if !chunk.delta.is_empty() {
|
||||
full_text.push_str(&chunk.delta);
|
||||
// L3 emit 双写:提变量避免构造两次,publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。
|
||||
let ev = AiChatEvent::AiTextDelta {
|
||||
delta: chunk.delta,
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
}
|
||||
if let Some(tc_deltas) = &chunk.tool_calls {
|
||||
for tc_delta in tc_deltas {
|
||||
let draft = tool_calls_acc.entry(tc_delta.index).or_default();
|
||||
if let Some(id) = &tc_delta.id { draft.id = id.clone(); }
|
||||
if let Some(name) = &tc_delta.function_name { draft.name.push_str(name); }
|
||||
if let Some(args) = &tc_delta.function_arguments { draft.args.push_str(args); }
|
||||
}
|
||||
}
|
||||
if let Some(u) = &chunk.usage {
|
||||
final_usage = Some(u.clone());
|
||||
}
|
||||
// BUG-260617-12: DeepSeek thinking 模式推理内容累积
|
||||
if let Some(ref rc) = chunk.reasoning_content {
|
||||
reasoning_content_acc.get_or_insert_with(String::new).push_str(rc);
|
||||
}
|
||||
// provider 流式错误事件(Anthropic SSE `type=="error"` 等):
|
||||
// UX-2025-04 / CR-30-2 / 决策 a1: MidStream 类失败——已有文本则保文(Partial),
|
||||
// 不重试。空文本无文可保,返回 InitFailed(保守 retryable=true,
|
||||
// err_msg 不可靠解析状态码,默认按可重试交调用方决定)。
|
||||
if let Some(err_msg) = &chunk.error {
|
||||
warn!(
|
||||
provider = %provider.name(),
|
||||
conv_id = %conv_id,
|
||||
error = %err_msg,
|
||||
text_len = full_text.len(),
|
||||
"[ai] provider 流式错误事件",
|
||||
);
|
||||
// UX-2025-04 / CR-30-2 / 决策 a1: MidStream 类失败——已有文本则保文(Partial),
|
||||
// 不重试。空文本无文可保,UX-260618-15: 不再 emit AiError(保守 retryable=true,
|
||||
// emit 权交 agentic 重试耗尽时统一发,避免 N 次重试 N+1 气泡)。
|
||||
if full_text.is_empty() && tool_calls_acc.is_empty() {
|
||||
return StreamResult::InitFailed {
|
||||
retryable: true,
|
||||
error: fmt_diag(
|
||||
provider.name(),
|
||||
DiagKind::MidStream,
|
||||
"stream-error",
|
||||
err_msg,
|
||||
),
|
||||
};
|
||||
}
|
||||
return StreamResult::Partial {
|
||||
text: full_text,
|
||||
tool_calls: tool_calls_acc,
|
||||
usage: final_usage.unwrap_or_default(),
|
||||
reasoning_content: reasoning_content_acc,
|
||||
};
|
||||
}
|
||||
if chunk.finished {
|
||||
finished_received = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// 诊断:流中途错误(多为 SSE 传输断),补 provider 标识 + HTTP 状态/分类 + 原始文本
|
||||
let (status_or_class, raw) = extract_error_diag(&e);
|
||||
warn!(
|
||||
provider = %provider.name(),
|
||||
status = %status_or_class,
|
||||
conv_id = %conv_id,
|
||||
error = %raw,
|
||||
text_len = full_text.len(),
|
||||
"[ai] 流式接收中途错误",
|
||||
);
|
||||
// UX-2025-04 / CR-30-2 / 决策 F-260616-07 a1: MidStream chunk Err——
|
||||
// 已有文本则保文(Partial),不重试。空文本无文可保,返回
|
||||
// InitFailed{retryable=classify_status_or_class(status_or_class)}
|
||||
// (4xx Fatal 立即放弃,5xx/429/timeout/connect 可重试)。
|
||||
if full_text.is_empty() && tool_calls_acc.is_empty() {
|
||||
// UX-260618-15: 不再 emit AiError。retryable=true 走重试路径,
|
||||
// retryable=false(4xx Fatal)也交 agentic Fatal 分支统一 emit
|
||||
// (InitFailed 携带 retryable + error,agentic 据此决定重试或 Fatal emit)。
|
||||
return StreamResult::InitFailed {
|
||||
retryable: classify_status_or_class(&status_or_class),
|
||||
error: fmt_diag(
|
||||
provider.name(),
|
||||
DiagKind::MidStream,
|
||||
&status_or_class,
|
||||
&raw,
|
||||
),
|
||||
};
|
||||
}
|
||||
return StreamResult::Partial {
|
||||
text: full_text,
|
||||
tool_calls: tool_calls_acc,
|
||||
usage: final_usage.unwrap_or_default(),
|
||||
reasoning_content: reasoning_content_acc,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 2) 心跳:静默期 30s 发 AiHeartbeat,前端 watchdog reset(B-260615-02)
|
||||
_ = heartbeat.tick() => {
|
||||
// 心跳只在「仍在等下一 chunk」时有意义——若 stop_flag 已置,顶部快检会 break,无需发心跳
|
||||
// L3 emit 双写:提变量避免构造两次,publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。
|
||||
let ev = AiChatEvent::AiHeartbeat {
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
std::thread::spawn(move || {
|
||||
let rt = match tokio::runtime::Runtime::new() {
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
conv_id = %conv_id_owned,
|
||||
"[ai] 创建专用 tokio runtime 失败: {}",
|
||||
e,
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
// BUG-2026-07-17 双重保险:在专用 runtime 内部也加 120s 超时。
|
||||
// 即使 provider.stream() 在专用线程上意外阻塞(理论不应发生,因专用 runtime
|
||||
// 多线程可推进独立 timer wheel),此 timeout 保证线程最终退出不永久挂起。
|
||||
rt.block_on(async move {
|
||||
use futures::StreamExt;
|
||||
let stream_result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(120),
|
||||
provider_for_thread.stream(request),
|
||||
).await;
|
||||
match stream_result {
|
||||
Ok(Ok(mut stream)) => {
|
||||
while let Some(chunk_result) = stream.next().await {
|
||||
let sent = match chunk_result {
|
||||
Ok(chunk) => chunk_tx.send(Ok(chunk)).await,
|
||||
Err(e) => chunk_tx.send(Err(e.to_string())).await,
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
}
|
||||
// 3) 即时停止唤醒(B-260615-14):用户点 stop → ai_chat_stop 置 stop_flag 后 notify_one()。
|
||||
// stream.next() 正阻塞等 chunk 时立即被唤醒,不再等 30s 心跳 tick 或 120s idle timeout。
|
||||
// 唤醒后判 stop_flag:真值仍由 AtomicBool 决定,Notify 仅承载「即时唤醒」职责
|
||||
// (防误唤醒继续跑——若非 stop 触发的 notify,flag 仍 false 则照常进下一轮循环)。
|
||||
_ = notify.notified() => {
|
||||
if stop_flag.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
stopped = true;
|
||||
if sent.is_err() {
|
||||
// receiver dropped (timeout/stop/error) — 停止发送
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 用户停止:已生成文本(可能残缺)交调用方入库展示。Complete(incomplete=false)——
|
||||
// 用户主动停止语义非异常中断,正常入库 + AiCompleted(决策 a1: 不重试路径)。
|
||||
if stopped {
|
||||
return StreamResult::Complete {
|
||||
text: full_text,
|
||||
tool_calls: tool_calls_acc,
|
||||
usage: final_usage.unwrap_or_default(),
|
||||
reasoning_content: reasoning_content_acc,
|
||||
};
|
||||
}
|
||||
|
||||
// 断连检测:流尽但从未收到 finished 信号 = 异常中断。
|
||||
// B-260615-05:空内容无 finished emit AiError + InitFailed{retryable=true}(无文可保,交重试)。
|
||||
// UX-2025-04:有 partial_text 则保文(Partial),不 emit AiError,不重试。
|
||||
if !finished_received {
|
||||
if full_text.is_empty() && tool_calls_acc.is_empty() {
|
||||
// B-260615-05:空内容无 finished。UX-260618-15: 不再 emit AiError(retryable=true
|
||||
// 重试路径,emit 权交 agentic 重试耗尽时统一发)。
|
||||
return StreamResult::InitFailed {
|
||||
retryable: true,
|
||||
error: "流式响应意外中断(未收到完成信号,已丢弃残缺响应)".to_string(),
|
||||
};
|
||||
Ok(Err(e)) => {
|
||||
let _ = chunk_tx.send(Err(e.to_string())).await;
|
||||
}
|
||||
warn!(
|
||||
provider = %provider.name(),
|
||||
conv_id = %conv_id,
|
||||
text_len = full_text.len(),
|
||||
"[ai] 流尽未收到 finished 但有 partial_text,保文不重试(incomplete)",
|
||||
);
|
||||
return StreamResult::Partial {
|
||||
text: full_text,
|
||||
tool_calls: tool_calls_acc,
|
||||
usage: final_usage.unwrap_or_default(),
|
||||
reasoning_content: reasoning_content_acc,
|
||||
Err(_elapsed) => {
|
||||
tracing::error!(
|
||||
conv_id = %conv_id_owned,
|
||||
"[ai] 专用 runtime 内部超时(120s),provider.stream 未返回"
|
||||
);
|
||||
let _ = chunk_tx.send(Err("LLM 调用超时(120s)".to_string())).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 在主 runtime 上通过 mpsc 接收 chunk,应用 timeout/heartbeat/stop 逻辑
|
||||
let mut full_text = String::new();
|
||||
let mut tool_calls_acc: HashMap<u32, ToolCallDraft> = HashMap::new();
|
||||
let mut finished_received = false;
|
||||
let mut stopped = false;
|
||||
let mut final_usage: Option<df_ai::provider::TokenUsage> = None;
|
||||
// BUG-260617-12: DeepSeek thinking 模式推理内容累积(多轮需回传)
|
||||
let mut reasoning_content_acc: Option<String> = None;
|
||||
|
||||
// B-260615-15:heartbeat interval 提至 loop 外复用,避免每轮重建计时器
|
||||
// (每轮重建会丢已积累的节拍,且 interval 首次 tick 立即返回的特性会被误用)。
|
||||
// tokio interval 首 tick 立即返回——此处先丢弃首 tick,让心跳等满首个 30s 静默期才发
|
||||
// (心跳语义是"静默期仍在跑",循环入口立即报无意义且会与 rx.recv() 抢分支错过首 chunk)。
|
||||
let mut heartbeat = tokio::time::interval(HEARTBEAT_INTERVAL);
|
||||
heartbeat.tick().await; // 丢弃首 tick
|
||||
|
||||
// BUG-2026-07-14 根治: wall-clock deadline 替代 select! 内 timeout 包裹。
|
||||
// idle_deadline 是绝对时刻:"下一次 chunk 必须在此前到达,否则判定超时"。
|
||||
// - 首 chunk 阶段: idle_deadline = now + 10s (FIRST_CHUNK_TIMEOUT)
|
||||
// - 收到首 chunk 后: idle_deadline = now + 120s (STREAM_IDLE_TIMEOUT),每次收到 chunk 更新
|
||||
// 心跳打断 select! 后回到循环顶部,deadline 检查在 select! 之前(不受竞争影响)。
|
||||
let mut idle_deadline = tokio::time::Instant::now() + FIRST_CHUNK_TIMEOUT;
|
||||
let mut first_chunk_done = false;
|
||||
|
||||
loop {
|
||||
// B-260615-04:stop 即时打断。
|
||||
if stop_flag.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
stopped = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// BUG-2026-07-14 根治: wall-clock deadline 检查(不依赖 select! timeout 语义)。
|
||||
if tokio::time::Instant::now() >= idle_deadline {
|
||||
if !first_chunk_done {
|
||||
return StreamResult::InitFailed {
|
||||
retryable: true,
|
||||
error: format!(
|
||||
"流式响应超时({}秒未收到首字节,服务无响应或请求体过大)",
|
||||
FIRST_CHUNK_TIMEOUT.as_secs()
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// 正常完成:流尽 + finished 信号到位,Complete(incomplete=false)
|
||||
StreamResult::Complete {
|
||||
warn!(
|
||||
provider = %provider.name(),
|
||||
conv_id = %conv_id,
|
||||
text_len = full_text.len(),
|
||||
"[ai] 流中途 idle timeout(deadline),保文不重试(incomplete)",
|
||||
);
|
||||
return StreamResult::Partial {
|
||||
text: full_text,
|
||||
tool_calls: tool_calls_acc,
|
||||
usage: final_usage.unwrap_or_default(),
|
||||
reasoning_content: reasoning_content_acc,
|
||||
};
|
||||
}
|
||||
|
||||
// 三分支 select!:
|
||||
// 1) chunk_rx.recv():来自专用线程的 LLM chunk (15s 保底 timeout,防 select! 死等)
|
||||
// 2) heartbeat.tick():静默期发 AiHeartbeat reset 前端 watchdog
|
||||
// 3) notify.notified():用户停止即时打断
|
||||
tokio::select! {
|
||||
chunk_result = tokio::time::timeout(
|
||||
Duration::from_secs(15),
|
||||
chunk_rx.recv()
|
||||
) => {
|
||||
match chunk_result {
|
||||
Err(_elapsed) => {
|
||||
// 15s 保底 timeout 触发。正常路径不会到这里:心跳 30s 会先触发 select! 返回。
|
||||
if !first_chunk_done {
|
||||
return StreamResult::InitFailed {
|
||||
retryable: true,
|
||||
error: format!(
|
||||
"流式响应超时({}秒未收到首字节,服务无响应或请求体过大)",
|
||||
FIRST_CHUNK_TIMEOUT.as_secs()
|
||||
),
|
||||
};
|
||||
}
|
||||
warn!(
|
||||
provider = %provider.name(),
|
||||
conv_id = %conv_id,
|
||||
text_len = full_text.len(),
|
||||
"[ai] 流中途 idle timeout,保文不重试(incomplete)",
|
||||
);
|
||||
return StreamResult::Partial {
|
||||
text: full_text,
|
||||
tool_calls: tool_calls_acc,
|
||||
usage: final_usage.unwrap_or_default(),
|
||||
reasoning_content: reasoning_content_acc,
|
||||
};
|
||||
}
|
||||
Ok(None) => break, // mpsc channel 关闭 = 专用线程 stream 结束
|
||||
Ok(Some(Ok(chunk))) => {
|
||||
// BUG-2026-07-14 根治: 收到 chunk 更新 idle_deadline。
|
||||
if !first_chunk_done {
|
||||
first_chunk_done = true;
|
||||
}
|
||||
idle_deadline = tokio::time::Instant::now() + STREAM_IDLE_TIMEOUT;
|
||||
if !chunk.delta.is_empty() {
|
||||
full_text.push_str(&chunk.delta);
|
||||
// L3 emit 双写
|
||||
let ev = AiChatEvent::AiTextDelta {
|
||||
delta: chunk.delta,
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
}
|
||||
if let Some(tc_deltas) = &chunk.tool_calls {
|
||||
for tc_delta in tc_deltas {
|
||||
let draft = tool_calls_acc.entry(tc_delta.index).or_default();
|
||||
if let Some(id) = &tc_delta.id { draft.id = id.clone(); }
|
||||
if let Some(name) = &tc_delta.function_name { draft.name.push_str(name); }
|
||||
if let Some(args) = &tc_delta.function_arguments { draft.args.push_str(args); }
|
||||
}
|
||||
}
|
||||
if let Some(u) = &chunk.usage {
|
||||
final_usage = Some(u.clone());
|
||||
}
|
||||
if let Some(ref rc) = chunk.reasoning_content {
|
||||
reasoning_content_acc.get_or_insert_with(String::new).push_str(rc);
|
||||
}
|
||||
// provider 流式错误事件
|
||||
if let Some(err_msg) = &chunk.error {
|
||||
warn!(
|
||||
provider = %provider.name(),
|
||||
conv_id = %conv_id,
|
||||
error = %err_msg,
|
||||
text_len = full_text.len(),
|
||||
"[ai] provider 流式错误事件",
|
||||
);
|
||||
if full_text.is_empty() && tool_calls_acc.is_empty() {
|
||||
return StreamResult::InitFailed {
|
||||
retryable: true,
|
||||
error: fmt_diag(
|
||||
provider.name(),
|
||||
DiagKind::MidStream,
|
||||
"stream-error",
|
||||
err_msg,
|
||||
),
|
||||
};
|
||||
}
|
||||
return StreamResult::Partial {
|
||||
text: full_text,
|
||||
tool_calls: tool_calls_acc,
|
||||
usage: final_usage.unwrap_or_default(),
|
||||
reasoning_content: reasoning_content_acc,
|
||||
};
|
||||
}
|
||||
if chunk.finished {
|
||||
finished_received = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(Some(Err(err_str))) => {
|
||||
// 专用线程上报的流错误(SSE 解析失败/HTTP 错误等)
|
||||
let (status_or_class, raw) = extract_error_diag_from_str(&err_str);
|
||||
warn!(
|
||||
provider = %provider.name(),
|
||||
status = %status_or_class,
|
||||
conv_id = %conv_id,
|
||||
error = %raw,
|
||||
text_len = full_text.len(),
|
||||
"[ai] 流式接收中途错误(from 专用线程)",
|
||||
);
|
||||
if full_text.is_empty() && tool_calls_acc.is_empty() {
|
||||
return StreamResult::InitFailed {
|
||||
retryable: classify_status_or_class(&status_or_class),
|
||||
error: fmt_diag(
|
||||
provider.name(),
|
||||
DiagKind::MidStream,
|
||||
&status_or_class,
|
||||
&raw,
|
||||
),
|
||||
};
|
||||
}
|
||||
return StreamResult::Partial {
|
||||
text: full_text,
|
||||
tool_calls: tool_calls_acc,
|
||||
usage: final_usage.unwrap_or_default(),
|
||||
reasoning_content: reasoning_content_acc,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
// 2) 心跳:静默期 30s 发 AiHeartbeat,前端 watchdog reset(B-260615-02)
|
||||
_ = heartbeat.tick() => {
|
||||
let ev = AiChatEvent::AiHeartbeat {
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
}
|
||||
// 3) 即时停止唤醒(B-260615-14)
|
||||
_ = notify.notified() => {
|
||||
if stop_flag.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
stopped = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// 诊断:连接/鉴权/HTTP 错误,补 provider 标识 + HTTP 状态码/分类 + 原始文本,
|
||||
// 便于区分 401(key)/404(url)/429(限流)/timeout/连接失败(provider_type 或 base_url 不对)。
|
||||
let (status_or_class, raw) = extract_error_diag(&e);
|
||||
warn!(
|
||||
provider = %provider.name(),
|
||||
status = %status_or_class,
|
||||
conv_id = %conv_id,
|
||||
error = %raw,
|
||||
"[ai] LLM 流式调用失败",
|
||||
);
|
||||
// UX-260618-15: 不再 emit AiError(retryable 走重试路径,Fatal 立即放弃,均交 agentic 统一 emit
|
||||
// 最终错误气泡,避免重试过程 N 次失败 push N+1 气泡)。诊断文本经 fmt_diag 拼好携带出,
|
||||
// agentic 重试耗尽/Fatal 时 emit AiError 用此文本,诊断信息不丢失。
|
||||
// CR-30-1: Init 失败分类——retryable 据 status_or_class 镜像 retry::is_status_retryable:
|
||||
// 5xx/429/timeout/connect=true 可重试;4xx(非429)/鉴权/参数错=false Fatal 立即放弃。
|
||||
StreamResult::InitFailed {
|
||||
retryable: classify_status_or_class(&status_or_class),
|
||||
error: fmt_diag(
|
||||
provider.name(),
|
||||
DiagKind::Init,
|
||||
&status_or_class,
|
||||
&raw,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// 用户停止
|
||||
if stopped {
|
||||
return StreamResult::Complete {
|
||||
text: full_text,
|
||||
tool_calls: tool_calls_acc,
|
||||
usage: final_usage.unwrap_or_default(),
|
||||
reasoning_content: reasoning_content_acc,
|
||||
};
|
||||
}
|
||||
|
||||
// 断连检测
|
||||
if !finished_received {
|
||||
if full_text.is_empty() && tool_calls_acc.is_empty() {
|
||||
return StreamResult::InitFailed {
|
||||
retryable: true,
|
||||
error: "流式响应意外中断(未收到完成信号,已丢弃残缺响应)".to_string(),
|
||||
};
|
||||
}
|
||||
warn!(
|
||||
provider = %provider.name(),
|
||||
conv_id = %conv_id,
|
||||
text_len = full_text.len(),
|
||||
"[ai] 流尽未收到 finished 但有 partial_text,保文不重试(incomplete)",
|
||||
);
|
||||
return StreamResult::Partial {
|
||||
text: full_text,
|
||||
tool_calls: tool_calls_acc,
|
||||
usage: final_usage.unwrap_or_default(),
|
||||
reasoning_content: reasoning_content_acc,
|
||||
};
|
||||
}
|
||||
|
||||
// 正常完成
|
||||
StreamResult::Complete {
|
||||
text: full_text,
|
||||
tool_calls: tool_calls_acc,
|
||||
usage: final_usage.unwrap_or_default(),
|
||||
reasoning_content: reasoning_content_acc,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,12 +504,24 @@ fn classify_status_or_class(status_or_class: &str) -> bool {
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
// 无三位数字码:按文本分类(timeout/connect 可重试,unknown/其他保守不重试)
|
||||
// 无三位数字码:按文本分类(timeout/connect/transport/decode 可重试,其余 unknown 保守不重试)
|
||||
// BUG-260617-01:Anthropic 中文参数错误(如 `[1214][messages 参数非法]`)无 HTTP 数字前缀,
|
||||
// 走此文本分支,原 || true 致 retryable=true 空耗 4 次重试浪费 token。
|
||||
// 删 || true 对齐 retry::is_status_retryable 未知码不可重试语义;timeout/connect 瞬态仍可重试。
|
||||
//
|
||||
// BUG-2026-07-17 根治:SSE 解码/传输错误(error decoding response body / Transport error)
|
||||
// 是传输层错误,不是 Fatal。原实现未识别这些关键词,返 unknown→Fatal,致连续重试 4 次均
|
||||
// 立即放弃。现补全传输层关键词,让这类错误走可重试路径。
|
||||
let lower = status_or_class.to_lowercase();
|
||||
lower.contains("timeout") || lower.contains("connect") // unknown 保守不重试(BUG-260617-01)
|
||||
lower.contains("timeout")
|
||||
|| lower.contains("connect")
|
||||
|| lower.contains("transport")
|
||||
|| lower.contains("decoding")
|
||||
|| lower.contains("decode")
|
||||
|| lower.contains("body")
|
||||
|| lower.contains("eof")
|
||||
|| lower.contains("reset")
|
||||
|| lower.contains("broken pipe")
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -82,15 +82,10 @@ pub(crate) async fn ensure_conversation_title(
|
||||
// 在传给 LLM 前合并连续同 role(拼接 content),不影响其他逻辑(本函数 summary 派生数据)。
|
||||
let summary_msgs = merge_consecutive_roles(summary_msgs);
|
||||
|
||||
// B-260617-17 修复:进入即 extract_title 兜底落库 + emit,保证侧栏即时有非"新对话"标题。
|
||||
// 原实现仅在 LLM 返回 None(失败)或 provider 构建失败时才落 extract——但 LLM 卡住
|
||||
// (generate_title_via_llm 的 llm_concurrency 信号量 acquire 阻塞 / 网络挂起 / 后台
|
||||
// spawn 未跑完即刷新)时,既不返回 None 也不落库 → 标题长期停留"新对话"。现先落 extract
|
||||
// 兜底,下方 LLM 生成成功后覆盖;LLM 卡住/失败/超时则保留兜底,无论如何不再"新对话"。
|
||||
let fallback_title = extract_title(&all_msgs).unwrap_or_else(|| "新对话".to_string());
|
||||
if let Err(e) = conv_repo.set_title(conv_id, &fallback_title).await {
|
||||
tracing::error!("对话标题兜底落库失败(conv_id={}, title={}): {}", conv_id, fallback_title, e);
|
||||
}
|
||||
// B-260617-17 修复:先用 extract 兜底 emit 让侧栏即时更新(不落库),
|
||||
// 下方 LLM 生成成功后覆盖。这样 LLM 失败时有重试机会,
|
||||
// 不会永久锁定一个机械截取的差标题。
|
||||
let _fallback_title = extract_title(&all_msgs).unwrap_or_else(|| "新对话".to_string());
|
||||
let _ = app_handle.emit("ai-conversation-changed", ());
|
||||
|
||||
// 标题生成是独立一次 LLM 调用,自建 provider(便于后台 spawn,不借主 loop 的 &dyn LlmProvider)
|
||||
@@ -204,15 +199,43 @@ fn clean_title(raw: &str) -> String {
|
||||
if cleaned.is_empty() { "新对话".to_string() } else { cleaned }
|
||||
}
|
||||
|
||||
/// 从消息历史中提取对话标题(取第一条用户消息前 30 字)
|
||||
/// 从消息历史中提取对话标题。策略:取前 5 条 user 消息中字符数最多的那条。
|
||||
///
|
||||
/// 开头常出现测试/问候("1"、"你好"等),取最长消息自然跳过这些噪音。
|
||||
/// 极端情况全部过短时(仅测试消息),用第一条非空 user 兜底。
|
||||
pub(crate) fn extract_title(messages: &[ChatMessage]) -> Option<String> {
|
||||
messages.iter()
|
||||
.find(|m| matches!(m.role, MessageRole::User))
|
||||
.map(|m| {
|
||||
let mut chars = m.content.chars();
|
||||
let t: String = chars.by_ref().take(30).collect();
|
||||
if chars.next().is_some() { format!("{}...", t) } else { t }
|
||||
})
|
||||
const MAX_TITLE_CHARS: usize = 60;
|
||||
const SCAN_LIMIT: usize = 5;
|
||||
|
||||
let user_msgs: Vec<&str> = messages.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::User))
|
||||
.take(SCAN_LIMIT)
|
||||
.map(|m| m.content.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
|
||||
// 取字符数最多的
|
||||
let best = user_msgs.iter()
|
||||
.max_by_key(|s| s.chars().count())
|
||||
.copied();
|
||||
|
||||
match best {
|
||||
Some(text) => {
|
||||
let mut chars = text.chars();
|
||||
let t: String = chars.by_ref().take(MAX_TITLE_CHARS).collect();
|
||||
if chars.next().is_some() { Some(format!("{}...", t)) } else { Some(t) }
|
||||
}
|
||||
None => {
|
||||
// 极端情况全空 → 第一条非空 user(兜底)
|
||||
messages.iter()
|
||||
.find(|m| matches!(m.role, MessageRole::User) && !m.content.trim().is_empty())
|
||||
.map(|m| {
|
||||
let mut chars = m.content.trim().chars();
|
||||
let t: String = chars.by_ref().take(MAX_TITLE_CHARS).collect();
|
||||
if chars.next().is_some() { format!("{}...", t) } else { t }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 合并相邻同 role 消息,拼接 content(防 Anthropic 1214 连续同 role 错误)。
|
||||
|
||||
@@ -593,7 +593,7 @@ fn register_project_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
})},
|
||||
);
|
||||
registry.register(
|
||||
"bind_directory", "为项目绑定代码目录(自动探测技术栈,防重复绑定)",
|
||||
"bind_directory", "为项目绑定代码目录(自动探测技术栈,防重复绑定)。path 应为**项目根路径**(单仓库=仓库根;多工程 monorepo=工作区根,非单个子工程路径;子工程信息走 list_project_modules/update_project_module 维护)。多次调用会覆盖 path 字段,monorepo 场景勿为每个子工程重复绑定。可选 stack 手动指定技术栈(如 'Go / Gin / Vue3'),不传则自动探测。",
|
||||
df_ai::ai_tools::object_schema(vec![("id", "string", true), ("path", "string", true), ("stack", "string", false)]),
|
||||
RiskLevel::Medium,
|
||||
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
|
||||
@@ -1308,22 +1308,27 @@ fn register_task_graph_tools(registry: &mut AiToolRegistry, db: &Arc<Database>)
|
||||
// Git 只读 AI 工具(工程系统,2026-06-29)
|
||||
// ============================================================
|
||||
|
||||
/// 在指定目录执行 git 命令(10s 超时,返回 stdout)。失败返回空字符串(非崩溃)。
|
||||
/// 在指定目录执行 git 命令(10s 超时,返回 stdout)。失败/超时返回空字符串(非崩溃)。
|
||||
///
|
||||
/// BUG-2026-07-18: 原实现 spawn_blocking 内裸 std::process::Command::output() 无 timeout
|
||||
/// (注释谎称"10s 超时")。git 在 OneDrive/网盘/挂载盘/lfs/大仓库场景会卡数十秒到无限,
|
||||
/// spawn_blocking 线程永不返回 → 累计耗尽 tokio blocking 池 → 间接卡死单线程 runtime
|
||||
/// (与 env_snapshot::probe_version 同型病根)。AI 的 git_status/log/diff 工具在会话内高频触发。
|
||||
///
|
||||
/// 改用 tokio::process + tokio::time::timeout(10s) + kill_on_drop:超时 drop 时 child 进程
|
||||
/// 被 kill,不泄漏线程/进程,对齐 shell.rs execute 同源封装。
|
||||
async fn exec_git(working_dir: &str, args: &[&str]) -> String {
|
||||
let dir = working_dir.to_string();
|
||||
let args_vec: Vec<String> = args.iter().map(|s| s.to_string()).collect();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let mut cmd = std::process::Command::new("git");
|
||||
cmd.args(&args_vec).current_dir(&dir);
|
||||
cmd.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::null());
|
||||
match cmd.output() {
|
||||
Ok(out) => String::from_utf8_lossy(&out.stdout).to_string(),
|
||||
Err(_) => String::new(),
|
||||
}
|
||||
})
|
||||
.await;
|
||||
result.unwrap_or_default()
|
||||
let mut cmd = tokio::process::Command::new("git");
|
||||
cmd.args(args)
|
||||
.current_dir(working_dir)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.kill_on_drop(true);
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(10), cmd.output()).await {
|
||||
Ok(Ok(out)) => String::from_utf8_lossy(&out.stdout).to_string(),
|
||||
Ok(Err(_)) => String::new(),
|
||||
Err(_elapsed) => String::new(), // 超时:child 被 kill_on_drop 终止
|
||||
}
|
||||
}
|
||||
|
||||
/// git status --porcelain 解析为结构化文件列表。
|
||||
@@ -1767,7 +1772,16 @@ fn register_file_tools(
|
||||
use tokio::fs::File;
|
||||
use tokio::io::AsyncReadExt;
|
||||
let mut file = File::open(path).await
|
||||
.map_err(|e| anyhow::anyhow!("无法访问文件 {}: {}", path, e))?;
|
||||
.map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::NotFound {
|
||||
anyhow::anyhow!(
|
||||
"无法访问文件 {}: 路径不存在。建议用 list_directory 先查看目录下的实际文件列表",
|
||||
path
|
||||
)
|
||||
} else {
|
||||
anyhow::anyhow!("无法访问文件 {}: {}", path, e)
|
||||
}
|
||||
})?;
|
||||
let metadata = file.metadata().await
|
||||
.map_err(|e| anyhow::anyhow!("读取元数据失败 {}: {}", path, e))?;
|
||||
if metadata.len() > 1_048_576 {
|
||||
@@ -2510,11 +2524,25 @@ fn register_file_tools(
|
||||
let output_mode = args.get("output_mode").and_then(|v| v.as_str()).unwrap_or("content");
|
||||
let max_results = args.get("max_results").and_then(|v| v.as_u64()).unwrap_or(50).clamp(1, 200) as usize;
|
||||
|
||||
// 编译正则:case_insensitive 开 i flag;失败上抛明确错误(非法正则不是业务错,LLM 据此修参)
|
||||
// 编译正则:case_insensitive 开 i flag;失败尝试自动修复常见问题(未分组 | 用 (?:...) 包裹)
|
||||
let mut re_builder = regex::RegexBuilder::new(pattern);
|
||||
re_builder.case_insensitive(case_insensitive);
|
||||
let re = re_builder.build()
|
||||
.map_err(|e| anyhow::anyhow!("正则编译失败「{}」: {}", pattern, e))?;
|
||||
let (re, _auto_fixed) = match re_builder.build() {
|
||||
Ok(r) => (r, false),
|
||||
Err(e) => {
|
||||
// LLM 常传 `a|b|c` 未分组模式(如 `<template|<div`),尝试 (?:...) 包裹后重试
|
||||
let wrapped = format!("(?:{})", pattern);
|
||||
let mut fix_builder = regex::RegexBuilder::new(&wrapped);
|
||||
fix_builder.case_insensitive(case_insensitive);
|
||||
match fix_builder.build() {
|
||||
Ok(r) => (r, true),
|
||||
Err(_) => anyhow::bail!(
|
||||
"正则编译失败「{}」: {}。提示:多选模式请用 (?:a|b|c) 分组",
|
||||
pattern, e
|
||||
),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// glob 过滤器:编译为 regex 单段匹配(* → [^/]*, ? → [^/], 字面其他字符 escape)。
|
||||
// 仅匹配文件名单段(不含 /),对齐 Claude Code grep glob 语义。
|
||||
@@ -3341,18 +3369,20 @@ mod tests {
|
||||
// register_git_tools 3 个)
|
||||
// Git 写工具(2026-06-29): data 层 31→34(新增 git_commit/git_branch/git_merge,
|
||||
// register_git_tools 3→6)
|
||||
// BUG-2026-07-07: 修正 baseline 计数(原写死 41 与实际 48 不符,HEAD 上即失败)。
|
||||
// 48 = 34 data + 13 file + 1 http。data 34 含 6 知识图谱 + 2 基础设施 + 1 module + 6 git。
|
||||
assert_eq!(
|
||||
registry.len(),
|
||||
41,
|
||||
"工具总数应为 41(27 data + 13 file + 1 http),实际 {}", registry.len()
|
||||
48,
|
||||
"工具总数应为 48(34 data + 13 file + 1 http),实际 {}", registry.len()
|
||||
);
|
||||
|
||||
// 工具名集合基线:防 rename / 漏注册 / 误删除。
|
||||
// data 层 27 个(持 db):CRUD/状态机/工作流/知识图谱任务关联 + 项目事件流 + 基础设施配置
|
||||
// data 层 34 个(持 db):CRUD/状态机/工作流/知识图谱任务关联 + 项目事件流 + 基础设施配置 + git 工具
|
||||
// file 层 13 个(不持 db):命令/读/列/写/改/元/追加/删/移/搜/grep/环境探测/符号解析
|
||||
// http 层 1 个(不持 db):http_request
|
||||
let mut expected: Vec<&str> = vec![
|
||||
// ── data 层 (27) ──
|
||||
// ── data 层 (34) ──
|
||||
"list_projects", "list_tasks", "list_ideas",
|
||||
"update_project", "create_project", "bind_directory",
|
||||
"create_task", "update_task", "advance_task",
|
||||
|
||||
@@ -53,7 +53,11 @@ fn parse_repo_url(repo_url: &str) -> Option<(String, String, String)> {
|
||||
return None;
|
||||
}
|
||||
let host = url.host_str()?;
|
||||
let port = url.port_or_known_default();
|
||||
// BUG-2026-07-07: port_or_known_default() 对 https 返 Some(443)、http 返 Some(80),
|
||||
// 致 authority 总是带默认端口(:443/:80),base URL 形如 "https://host:443" 而非规范
|
||||
// "https://host"。下游 Gitea API 调用虽大多容忍,但测试断言与可读性受影响。
|
||||
// 修复:仅当端口非 scheme 默认端口时才拼入 authority(显式非默认端口才保留)。
|
||||
let port = url.port(); // 仅取显式端口(未在 URL 写出的不返)
|
||||
let authority = match port {
|
||||
Some(p) => format!("{}:{}", host, p),
|
||||
None => host.to_string(),
|
||||
|
||||
@@ -352,17 +352,15 @@ pub async fn list_branches(
|
||||
return Ok(serde_json::json!({ "current": "", "branches": [] }));
|
||||
}
|
||||
let dir = module.path.clone();
|
||||
let result = std::thread::spawn(move || -> (String, Vec<serde_json::Value>) {
|
||||
// git 命令在 spawn_blocking 中执行(阻塞 IO 不污染 async runtime),10s 超时
|
||||
let result = tokio::task::spawn_blocking(move || -> (String, Vec<serde_json::Value>) {
|
||||
// git branch --format="%(HEAD)%00%(refname:short)"
|
||||
let out = std::process::Command::new("git")
|
||||
.args(["branch", "--format=%(HEAD)%00%(refname:short)"])
|
||||
.current_dir(&dir)
|
||||
.env("LANG", "en_US.UTF-8")
|
||||
.env("LC_ALL", "en_US.UTF-8")
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|o| if o.status.success() { Some(String::from_utf8_lossy(&o.stdout).to_string()) } else { None })
|
||||
.unwrap_or_default();
|
||||
let out = run_git_cmd(
|
||||
std::path::Path::new(&dir),
|
||||
&["branch", "--format=%(HEAD)%00%(refname:short)"],
|
||||
std::time::Duration::from_secs(10),
|
||||
)
|
||||
.unwrap_or_default();
|
||||
let mut current = String::new();
|
||||
let mut branches = Vec::new();
|
||||
for line in out.lines() {
|
||||
@@ -378,8 +376,8 @@ pub async fn list_branches(
|
||||
}
|
||||
(current, branches)
|
||||
})
|
||||
.join()
|
||||
.map_err(|_| "分支列表查询线程 Join 失败".to_string())?;
|
||||
.await
|
||||
.map_err(|e| format!("分支列表查询任务失败: {e}"))?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"current": result.0,
|
||||
@@ -801,26 +799,18 @@ pub async fn get_module_file_diff(
|
||||
return Ok(serde_json::json!({"path": file_path, "diff": ""}));
|
||||
}
|
||||
// 跑 git diff <path>(工作树 vs 索引的 unstaged 变更+staged 变更)
|
||||
// 设置环境变量强制 UTF-8 输出,防中文乱码。
|
||||
// spawn_blocking + run_git_cmd(10s 超时,防 git 卡死阻塞 runtime)。
|
||||
let cmd_dir = root.clone();
|
||||
let path_arg = file_path.replace('\\', "/");
|
||||
let diff = std::thread::spawn(move || -> Option<String> {
|
||||
let out = std::process::Command::new("git")
|
||||
.args(["diff", "--", &path_arg])
|
||||
.current_dir(&cmd_dir)
|
||||
.env("LANG", "en_US.UTF-8")
|
||||
.env("LC_ALL", "en_US.UTF-8")
|
||||
.env("GIT_PAGER", "cat")
|
||||
.output()
|
||||
.ok()?;
|
||||
if out.status.success() && !out.stdout.is_empty() {
|
||||
Some(String::from_utf8_lossy(&out.stdout).to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
let diff = tokio::task::spawn_blocking(move || -> Option<String> {
|
||||
run_git_cmd(
|
||||
&cmd_dir,
|
||||
&["diff", "--", &path_arg],
|
||||
std::time::Duration::from_secs(10),
|
||||
)
|
||||
})
|
||||
.join()
|
||||
.map_err(|_| "git diff 线程 Join 失败".to_string())?
|
||||
.await
|
||||
.map_err(|e| format!("git diff 任务失败: {e}"))?
|
||||
.unwrap_or_default();
|
||||
Ok(serde_json::json!({
|
||||
"path": file_path.replace('\\', "/"),
|
||||
@@ -879,15 +869,19 @@ pub async fn scan_project_modules(
|
||||
if !child_path.join(".git").exists() { continue; }
|
||||
let child_path_str = child_path.to_string_lossy().replace("\\", "/");
|
||||
if existing_paths.contains(&child_path_str.to_lowercase()) { continue; }
|
||||
// 获取远程地址(失败忽略)
|
||||
let git_url = std::process::Command::new("git")
|
||||
.args(["remote", "get-url", "origin"])
|
||||
.current_dir(&child_path)
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
// 获取远程地址(失败忽略);spawn_blocking + run_git_cmd(10s 超时,防 git 卡死阻塞 runtime)
|
||||
let url_dir = child_path.clone();
|
||||
let git_url = tokio::task::spawn_blocking(move || {
|
||||
run_git_cmd(
|
||||
&url_dir,
|
||||
&["remote", "get-url", "origin"],
|
||||
std::time::Duration::from_secs(10),
|
||||
)
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
})
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
let now_str = now_millis();
|
||||
let record = ProjectModuleRecord {
|
||||
id: new_id(),
|
||||
@@ -976,22 +970,19 @@ pub async fn get_module_commits(
|
||||
let fetch_plus = fetch + 1;
|
||||
let dir = module.path.clone();
|
||||
let dir_for_git = dir.clone();
|
||||
let commits: Vec<serde_json::Value> = std::thread::spawn(move || -> Vec<serde_json::Value> {
|
||||
let out = std::process::Command::new("git")
|
||||
.args([
|
||||
// git 命令在 spawn_blocking 中执行(阻塞 IO 不污染 async runtime),10s 超时
|
||||
let commits: Vec<serde_json::Value> = tokio::task::spawn_blocking(move || -> Vec<serde_json::Value> {
|
||||
let out = run_git_cmd(
|
||||
std::path::Path::new(&dir_for_git),
|
||||
&[
|
||||
"log",
|
||||
&format!("--skip={}", skip),
|
||||
&format!("-{}", fetch_plus),
|
||||
"--format=%h %ct %an %s",
|
||||
])
|
||||
.current_dir(&dir_for_git)
|
||||
.env("LANG", "en_US.UTF-8")
|
||||
.env("LC_ALL", "en_US.UTF-8")
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|o| if o.status.success() { Some(o.stdout) } else { None })
|
||||
.map(|b| String::from_utf8_lossy(&b).to_string())
|
||||
.unwrap_or_default();
|
||||
],
|
||||
std::time::Duration::from_secs(10),
|
||||
)
|
||||
.unwrap_or_default();
|
||||
let mut commits: Vec<serde_json::Value> = Vec::new();
|
||||
for line in out.lines() {
|
||||
let line = line.trim();
|
||||
@@ -1014,8 +1005,8 @@ pub async fn get_module_commits(
|
||||
}
|
||||
commits
|
||||
})
|
||||
.join()
|
||||
.map_err(|_| "提交历史查询线程 Join 失败".to_string())?;
|
||||
.await
|
||||
.map_err(|e| format!("提交历史查询任务失败: {e}"))?;
|
||||
|
||||
// 判断 has_more:取了 N+1 条但只返回 N 条,说明有更多
|
||||
let total_fetched = commits.len();
|
||||
@@ -1055,17 +1046,17 @@ pub async fn get_commit_detail(
|
||||
return Ok(serde_json::json!({ "files": [], "diff": "" }));
|
||||
}
|
||||
let dir = module.path.clone();
|
||||
let (files, diff, parents, author, date, full_message) = std::thread::spawn(move || -> (Vec<serde_json::Value>, String, Vec<String>, String, String, String) {
|
||||
// git 命令在 spawn_blocking 中执行(阻塞 IO 不污染 async runtime),10s 超时
|
||||
let (files, diff, parents, author, date, full_message) = tokio::task::spawn_blocking(move || -> (Vec<serde_json::Value>, String, Vec<String>, String, String, String) {
|
||||
let dir_path = std::path::Path::new(&dir);
|
||||
let timeout = std::time::Duration::from_secs(10);
|
||||
// 1) 获取变更文件列表:`git diff-tree --no-commit-id -r --name-status <hash>`
|
||||
let files_out = std::process::Command::new("git")
|
||||
.args(["diff-tree", "--no-commit-id", "-r", "--name-status", &commit_hash])
|
||||
.current_dir(&dir)
|
||||
.env("LANG", "en_US.UTF-8")
|
||||
.env("LC_ALL", "en_US.UTF-8")
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|o| if o.status.success() { Some(String::from_utf8_lossy(&o.stdout).to_string()) } else { None })
|
||||
.unwrap_or_default();
|
||||
let files_out = run_git_cmd(
|
||||
dir_path,
|
||||
&["diff-tree", "--no-commit-id", "-r", "--name-status", &commit_hash],
|
||||
timeout,
|
||||
)
|
||||
.unwrap_or_default();
|
||||
let mut files: Vec<serde_json::Value> = Vec::new();
|
||||
for line in files_out.lines() {
|
||||
let line = line.trim();
|
||||
@@ -1079,26 +1070,20 @@ pub async fn get_commit_detail(
|
||||
}
|
||||
}
|
||||
// 2) 获取全量 diff:`git show <hash>`(仅 diff 部分)
|
||||
let diff = std::process::Command::new("git")
|
||||
.args(["show", "--format=", &commit_hash])
|
||||
.current_dir(&dir)
|
||||
.env("LANG", "en_US.UTF-8")
|
||||
.env("LC_ALL", "en_US.UTF-8")
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|o| if o.status.success() { Some(String::from_utf8_lossy(&o.stdout).to_string()) } else { None })
|
||||
.unwrap_or_default();
|
||||
let diff = run_git_cmd(
|
||||
dir_path,
|
||||
&["show", "--format=", &commit_hash],
|
||||
timeout,
|
||||
)
|
||||
.unwrap_or_default();
|
||||
// 3) 获取提交元信息(父提交/作者/日期/完整消息):
|
||||
// 用 printf 自定义格式,%P=父哈希(空格分隔多个)\t%an=作者\t%ad=日期\t%B=完整消息
|
||||
let meta = std::process::Command::new("git")
|
||||
.args(["show", "-s", "--format=%P\t%an\t%ad\t%B", &commit_hash])
|
||||
.current_dir(&dir)
|
||||
.env("LANG", "en_US.UTF-8")
|
||||
.env("LC_ALL", "en_US.UTF-8")
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|o| if o.status.success() { Some(String::from_utf8_lossy(&o.stdout).to_string()) } else { None })
|
||||
.unwrap_or_default();
|
||||
let meta = run_git_cmd(
|
||||
dir_path,
|
||||
&["show", "-s", "--format=%P\t%an\t%ad\t%B", &commit_hash],
|
||||
timeout,
|
||||
)
|
||||
.unwrap_or_default();
|
||||
let mut parents = Vec::new();
|
||||
let mut author = String::new();
|
||||
let mut date = String::new();
|
||||
@@ -1121,8 +1106,8 @@ pub async fn get_commit_detail(
|
||||
}
|
||||
(files, diff, parents, author, date, full_message)
|
||||
})
|
||||
.join()
|
||||
.map_err(|_| "提交详情查询线程 Join 失败".to_string())?;
|
||||
.await
|
||||
.map_err(|e| format!("提交详情查询任务失败: {e}"))?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"files": files,
|
||||
|
||||
@@ -6,6 +6,20 @@ mod state;
|
||||
use tauri::{Emitter, Listener, Manager};
|
||||
|
||||
use state::AppState;
|
||||
|
||||
/// 用户本地时区日志时间戳格式化器。
|
||||
/// tracing_subscriber 默认输出 UTC,用户在中国 UTC+8,日志与本地时间差 8 小时不便调试。
|
||||
/// 使用 chrono::Local 输出含时区偏移的本地时间(如 "2026-07-17T15:30:00.123456+08:00")。
|
||||
///
|
||||
/// 注意:FormatTime::format_time 参数类型为 tracing_subscriber::fmt::format::Writer,
|
||||
/// 非 std::fmt::Write。Writer 实现了 fmt::Write,可直接 write!。
|
||||
struct LocalTimer;
|
||||
impl tracing_subscriber::fmt::time::FormatTime for LocalTimer {
|
||||
fn format_time(&self, w: &mut tracing_subscriber::fmt::format::Writer<'_>) -> std::fmt::Result {
|
||||
write!(w, "{}", chrono::Local::now().format("%Y-%m-%dT%H:%M:%S%.6f%:z"))
|
||||
}
|
||||
}
|
||||
|
||||
// Phase3 跨端隧道:TunnelClient trait(connect/send_raw_event/disconnect 方法)需在作用域内
|
||||
use df_tunnel::TunnelClient;
|
||||
|
||||
@@ -20,6 +34,7 @@ pub fn run() {
|
||||
.expect("创建日志文件失败");
|
||||
let (non_blocking, _guard) = tracing_appender::non_blocking(log_file);
|
||||
tracing_subscriber::fmt()
|
||||
.with_timer(LocalTimer)
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"))
|
||||
@@ -88,27 +103,36 @@ pub fn run() {
|
||||
// F-260616-09 B 批8(设计 §3 batch8 + §5.2):遍历 per_conv(HashMap)清多 conv
|
||||
// 残留 generating(HMR/dev 热载场景多 conv 并发跑 loop 致多 conv 卡 generating)。
|
||||
// 批4:per_conv 唯一真相源,删顶层 session.generating 双写复位(顶层字段已退役)。
|
||||
// 读点(双轨收口批2):改读 conv_state.is_active()(Generating+Compressed),比原
|
||||
// generating 更全面——HMR 热载下 Compressed 残留态也一并清理(避免压缩中 conv 卡死)。
|
||||
let dirty_convs: Vec<String> = session
|
||||
.per_conv
|
||||
.iter()
|
||||
.filter(|(_, c)| c.conv_state.is_active())
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect();
|
||||
// 读点(B-Phase2):活跃 conv 列表改读无锁 conv_states(零锁竞争);不再 iter per_conv
|
||||
// 判 conv_state.is_active()。
|
||||
// BUG-2026-07-08: L0 握手清理 dirty conv 前,必须先 stop 旧 loop。
|
||||
// 原逻辑直接把 ConvState 改 Idle,但后台 run_agentic_loop 仍在跑(等 LLM 响应)。
|
||||
// 改 Idle 后用户重发 → can_accept_request 放行 → 新旧两个 loop 同操作一个 conv →
|
||||
// 消息覆盖/guard 冲突/generating 紊乱。
|
||||
// 修复:先设 stop_flag + notify_one()(旧 loop 的 stream_llm select! 即时唤醒)
|
||||
// + 再改 ConvState。notify_one 让阻塞在 stream.next() 的 loop 即时检查 stop_flag 退出。
|
||||
let app_state_ref = app_h.state::<AppState>();
|
||||
let dirty_convs: Vec<String> = app_state_ref.conv_states.active_convs();
|
||||
let was_generating = !dirty_convs.is_empty();
|
||||
// 复位每个残留生成态的 conv(批8 多 conv 全覆盖)。
|
||||
// 批3 双轨收口:generating bool 已退役,复位改 ConvState 迁移(Generating/Compressed→Idle)。
|
||||
// 先对每个 dirty conv 设 stop_flag + notify 让旧 loop 退出(防双 loop 并发)。
|
||||
for cid in &dirty_convs {
|
||||
if let Some(conv) = session.per_conv.get_mut(cid) {
|
||||
match conv.conv_state.transition_to(crate::commands::ai::agentic::conv_state::ConvState::Idle) {
|
||||
Ok(ns) => conv.conv_state = ns,
|
||||
Err(e) => tracing::warn!(
|
||||
conv_id = %cid,
|
||||
error = %e,
|
||||
"[ai] HMR 热载 ConvState→Idle 非法(不阻断热载)"
|
||||
),
|
||||
}
|
||||
if let Some(c) = session.per_conv.get_mut(cid) {
|
||||
c.stop_flag.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
c.notify.notify_one();
|
||||
}
|
||||
}
|
||||
// 复位每个残留生成态的 conv(批8 多 conv 全覆盖)。
|
||||
// B-Phase3:conv_state 写切 ConvStateStore 单源。
|
||||
for cid in &dirty_convs {
|
||||
if let Err(e) = app_state_ref.conv_states.transition(
|
||||
cid,
|
||||
crate::commands::ai::agentic::conv_state::ConvState::Idle,
|
||||
) {
|
||||
tracing::warn!(
|
||||
conv_id = %cid,
|
||||
error = %e,
|
||||
"[ai] HMR 热载 conv_states→Idle 非法(不阻断热载)"
|
||||
);
|
||||
}
|
||||
}
|
||||
// 对话透明化 L1:收集每个 dirty conv 的 pinned_goals 快照(供 emit AiCompleted 携带)
|
||||
@@ -123,23 +147,35 @@ pub fn run() {
|
||||
// 保留 restore 重建(recovered=true,audit.rs:331),对齐 switchConversation retain 保护意图。
|
||||
// 阶段3a 单真相源合并:单表按 !recovered retain(kind 不区分,path 审批恢复恒无)。
|
||||
session.pending_approvals.retain(|_, a| !a.recovered);
|
||||
// 在 drop(session) 前快照 active_conversation_id,供下方 idle emit 用。
|
||||
let active_conv = session.active_conversation_id.clone();
|
||||
drop(session);
|
||||
if was_generating {
|
||||
// 补偿事件:每个残留 conv 各发一个 AiCompleted(按 conversation_id 路由),
|
||||
// 前端 useAiEvents.ts:133-140 按 conv_id 各归各复位 streaming/generatingConvId。
|
||||
for cid in &dirty_convs {
|
||||
let _ = app_h.emit(
|
||||
"ai-chat-event",
|
||||
commands::ai::AiChatEvent::AiCompleted {
|
||||
total_tokens: 0,
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
incomplete: None,
|
||||
conversation_id: Some(cid.clone()),
|
||||
pinned_goals: pinned_goals_map.get(cid).cloned().unwrap_or_default(),
|
||||
},
|
||||
);
|
||||
}
|
||||
// 根治:不论是否有 dirty conv,HMR 重连后始终向前端推 idle 事件,
|
||||
// 防前端残留 streaming=true 导致发送按钮变停止、消息排队不发。
|
||||
// dirty conv 走 AiCompleted 收尾路由(idle conv 无历史需收尾无需气泡)。
|
||||
for cid in &dirty_convs {
|
||||
let _ = app_h.emit(
|
||||
"ai-chat-event",
|
||||
commands::ai::AiChatEvent::AiCompleted {
|
||||
total_tokens: 0,
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
incomplete: None,
|
||||
conversation_id: Some(cid.clone()),
|
||||
pinned_goals: pinned_goals_map.get(cid).cloned().unwrap_or_default(),
|
||||
},
|
||||
);
|
||||
}
|
||||
// 始终推 AiConvStateChanged{idle} 通知前端同步(即使后端已是 idle)。
|
||||
// 前端 handleConvStateEvent → setConvState(convId, 'idle') → convStates 删项。
|
||||
if let Some(ref cid) = active_conv {
|
||||
let _ = app_h.emit(
|
||||
"ai-chat-event",
|
||||
commands::ai::AiChatEvent::AiConvStateChanged {
|
||||
conv_state: crate::commands::ai::agentic::conv_state::ConvState::Idle,
|
||||
conversation_id: Some(cid.clone()),
|
||||
},
|
||||
);
|
||||
}
|
||||
tracing::info!(
|
||||
"[L0-handshake] 前端重连握手完成, was_generating={}, dirty_convs={:?}",
|
||||
@@ -460,8 +496,6 @@ pub fn run() {
|
||||
commands::settings::delete_template,
|
||||
// CI 状态
|
||||
commands::ci_status::get_commit_status,
|
||||
// CI 检查状态(Gitea commit statuses,失败返回空列表不阻断工作流)
|
||||
commands::ci_status::get_commit_status,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -30,6 +30,7 @@ use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
@@ -117,6 +118,12 @@ pub struct AppState {
|
||||
/// 标 allow 保留作批3 接入(零调用方≠垃圾,预留保留)。
|
||||
#[allow(dead_code)]
|
||||
pub ai_event_bus: crate::commands::ai::event_bus::EventBus,
|
||||
/// B-Phase3: ConvState 无锁并发存储(单源收敛)。
|
||||
///
|
||||
/// ConvState 从 session.per_conv[conv_id] 提到独立 DashMap,
|
||||
/// guard/reset/ai_is_generating 全部零锁操作。PerConvState.conv_state 字段已删。
|
||||
/// 治卡死连环(AiCompleted 延迟 / 工具后中断 / 第二条进队列同源根因)。
|
||||
pub conv_states: Arc<crate::commands::ai::agentic::conv_state::ConvStateStore>,
|
||||
// ── 知识库 ──
|
||||
/// 知识库 Repo
|
||||
pub knowledge: KnowledgeRepo,
|
||||
@@ -185,6 +192,39 @@ fn workspace_root_path() -> PathBuf {
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
}
|
||||
|
||||
/// async IPC 路径的 canonicalize 防卡包装(spawn_blocking + 2s timeout)。
|
||||
///
|
||||
/// `std::fs::canonicalize` 在网络挂载盘/坏符号链接/盘符掉线等场景可能长时间阻塞;
|
||||
/// 在 async IPC handler 中直接同步调用会卡 tokio runtime 工作线程。本函数把同步
|
||||
/// canonicalize 移入 spawn_blocking 隔离 + 2s 总超时,超时返原路径兜底(降级匹配,
|
||||
/// 不阻断合法访问 — 与现有 canonicalize 失败回退词法路径的语义一致)。
|
||||
///
|
||||
/// - 成功 → canonicalize 后真实路径(经 strip_verbatim 去 Windows verbatim 前缀)
|
||||
/// - 失败/超时 → 原字面量路径 trim 末尾分隔符(与 reload_allowed_dirs 既有兜底语义对齐)
|
||||
async fn canonicalize_with_timeout(p: PathBuf) -> PathBuf {
|
||||
// 提前留一份原始字面量用于兜底/日志(spawn_blocking 闭包 move 后 p 不再可借用)。
|
||||
let raw_display = p.to_string_lossy().trim_end_matches(['/', '\\']).to_string();
|
||||
match tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
tokio::task::spawn_blocking(move || std::fs::canonicalize(&p).ok()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(JoinResult::Ok(Some(real))) => strip_verbatim(real),
|
||||
// 超时 / canonicalize 失败 / JoinError:降级回原词法路径
|
||||
_ => {
|
||||
tracing::warn!(
|
||||
"[canonicalize] 超时或失败,按字面量保存: {}",
|
||||
raw_display
|
||||
);
|
||||
PathBuf::from(raw_display)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// tokio::task::JoinResult 别名(简化 match 类型签名,避免长泛型)。
|
||||
type JoinResult<T> = Result<T, tokio::task::JoinError>;
|
||||
|
||||
impl AppState {
|
||||
/// 初始化应用状态:打开(或创建)数据库并执行迁移,构建各 Repo 与节点注册表
|
||||
pub async fn init(db_path: &Path, data_dir: PathBuf) -> Result<Self> {
|
||||
@@ -223,6 +263,8 @@ impl AppState {
|
||||
ai_session: Arc::new(Mutex::new(AiSession::new())),
|
||||
// L3 批2b:AI 事件总线入 AppState(独立于工作流 event_bus)。emit 双写留批3。
|
||||
ai_event_bus: crate::commands::ai::event_bus::EventBus::new(),
|
||||
// B-Phase1: ConvStateStore 无锁存储(空构造;双轨期无写点,B-Phase2 写侧切入)。
|
||||
conv_states: Arc::new(crate::commands::ai::agentic::conv_state::ConvStateStore::new()),
|
||||
// Phase3 Layer1:tunnel 客户端未连接实例,lib.rs setup 内 connect
|
||||
tunnel: std::sync::Arc::new(df_tunnel::WsTunnelClient::new()),
|
||||
knowledge: KnowledgeRepo::new(&db),
|
||||
@@ -402,12 +444,9 @@ impl AppState {
|
||||
continue;
|
||||
}
|
||||
let p = PathBuf::from(d);
|
||||
// canonicalize 成功用真实路径(去 symlink/大小写归一);失败回退原字面量 trim
|
||||
// (兼容"先授权目录,目录暂不存在"用例)。失败打 warn 便于排查静默授权错路径。
|
||||
let normalized = strip_verbatim(std::fs::canonicalize(&p).unwrap_or_else(|_| {
|
||||
tracing::warn!("[allowed_dirs] canonicalize 失败(目录可能不存在),按字面量保存: {}", d);
|
||||
PathBuf::from(d.trim_end_matches(['/', '\\']))
|
||||
}));
|
||||
// canonicalize 成功用真实路径(去 symlink/大小写归一);失败/超时回退原字面量 trim
|
||||
// (兼容"先授权目录,目录暂不存在"用例)。spawn_blocking+timeout(2s) 防 fs 卡死拖垮 IPC。
|
||||
let normalized = canonicalize_with_timeout(p).await;
|
||||
set.insert(normalized);
|
||||
}
|
||||
// F-260619-03 Phase B: reload 时保留当前会话临时授权(session 不落库,仅内存),
|
||||
@@ -481,9 +520,8 @@ impl AppState {
|
||||
return;
|
||||
}
|
||||
let p = PathBuf::from(d);
|
||||
let normalized = strip_verbatim(std::fs::canonicalize(&p).unwrap_or_else(|_| {
|
||||
PathBuf::from(d.trim_end_matches(['/', '\\']))
|
||||
}));
|
||||
// spawn_blocking+timeout(2s) 防 fs 卡死拖垮 IPC;失败/超时降级词法路径(与既有兜底语义一致)。
|
||||
let normalized = canonicalize_with_timeout(p).await;
|
||||
self.allowed_dirs.write().await.session.insert(normalized);
|
||||
}
|
||||
|
||||
@@ -506,9 +544,8 @@ impl AppState {
|
||||
return;
|
||||
}
|
||||
let p = PathBuf::from(d);
|
||||
let normalized = strip_verbatim(std::fs::canonicalize(&p).unwrap_or_else(|_| {
|
||||
PathBuf::from(d.trim_end_matches(['/', '\\']))
|
||||
}));
|
||||
// spawn_blocking+timeout(2s) 防 fs 卡死拖垮 IPC;失败/超时降级词法路径(与既有兜底语义一致)。
|
||||
let normalized = canonicalize_with_timeout(p).await;
|
||||
self.allowed_dirs.write().await.once.insert(normalized);
|
||||
}
|
||||
|
||||
|
||||
@@ -165,8 +165,22 @@ pub fn check_path_authorization(
|
||||
raw_path: &str,
|
||||
allowed: &AllowedDirs,
|
||||
) -> PathAuthDecision {
|
||||
// Phase C: 黑名单优先独立判定(is_authorized 内也判,此处先判便于 NeedsAuthorization
|
||||
// 不误把黑名单路径推到弹窗 — 黑名单路径直接硬拒不让用户"授权")。
|
||||
//
|
||||
// BUG-2026-07-07: 黑名单检查必须在路径规范化(is_absolute / root.join)之前对**原始路径**
|
||||
// 也判一次。原因:Windows 上 "/home/user/.ssh/config" 这类 Unix 风格路径 is_absolute() 返 false
|
||||
// (Windows 绝对路径需盘符或 UNC),会被当相对路径走 first_persistent_dir 分支;若 persistent 为空,
|
||||
// 旧逻辑直接 return NeedsAuthorization,**跳过黑名单检查** → .ssh/.aws 等敏感路径在 Windows 上绕过黑名单。
|
||||
// 修复:先用原始路径判黑名单(segment 匹配,任意路径段命中 .ssh 即拒,与路径形态无关),命中直接 Denied。
|
||||
let raw_resolved = Path::new(raw_path);
|
||||
if is_in_system_blacklist(raw_resolved) {
|
||||
return PathAuthDecision::Denied {
|
||||
reason: format!("路径命中系统敏感目录黑名单: {}", raw_path),
|
||||
};
|
||||
}
|
||||
// 规范化:绝对路径原样,相对路径锚定首个持久授权目录(与 resolve_workspace_path_impl 一致)
|
||||
let resolved = if Path::new(raw_path).is_absolute() {
|
||||
let resolved = if raw_resolved.is_absolute() {
|
||||
PathBuf::from(raw_path)
|
||||
} else if let Some(root) = allowed.first_persistent_dir() {
|
||||
root.join(raw_path)
|
||||
@@ -176,8 +190,7 @@ pub fn check_path_authorization(
|
||||
dir: PathBuf::from("."),
|
||||
};
|
||||
};
|
||||
// Phase C: 黑名单优先独立判定(is_authorized 内也判,此处先判便于 NeedsAuthorization
|
||||
// 不误把黑名单路径推到弹窗 — 黑名单路径直接硬拒不让用户"授权")。
|
||||
// 规范化后再判一次黑名单(锚定后路径形态可能变化,双保险)
|
||||
if is_in_system_blacklist(&resolved) {
|
||||
return PathAuthDecision::Denied {
|
||||
reason: format!("路径命中系统敏感目录黑名单: {}", raw_path),
|
||||
@@ -430,6 +443,38 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// BUG-2026-07-07: Windows 上 Unix 风格 .ssh 路径必须被黑名单拒(不能绕过)。
|
||||
///
|
||||
/// 背景:Windows 上 `/home/user/.ssh/config` 的 is_absolute() 返 false(无盘符),
|
||||
/// 旧逻辑在 persistent 为空时直接返 NeedsAuthorization,**跳过黑名单检查**,
|
||||
/// 致 .ssh/.aws 等敏感路径在 Windows 上绕过黑名单。修复后原始路径也判黑名单。
|
||||
/// 此测试锁死该修复:空 persistent + Unix 风格 .ssh 路径 → 必 Denied。
|
||||
#[test]
|
||||
fn test_check_path_blacklist_unix_style_ssh_on_windows() {
|
||||
let allowed = AllowedDirs::default_with_root();
|
||||
match check_path_authorization("/home/user/.ssh/config", &allowed) {
|
||||
PathAuthDecision::Denied { .. } => {}
|
||||
other => panic!("Unix 风格 .ssh 路径应 Denied(黑名单), got {:?}", other),
|
||||
}
|
||||
// 同理 .aws / .gnupg
|
||||
match check_path_authorization("/home/user/.aws/credentials", &allowed) {
|
||||
PathAuthDecision::Denied { .. } => {}
|
||||
other => panic!("Unix 风格 .aws 路径应 Denied(黑名单), got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
/// 回归:合法相对路径(非黑名单)在空 persistent 下仍应返 NeedsAuthorization(引导授权)。
|
||||
/// 修复黑名单提前后,需确认非黑名单相对路径行为不变。
|
||||
#[test]
|
||||
fn test_check_path_non_blacklist_relative_still_needs_auth() {
|
||||
let allowed = AllowedDirs::default_with_root();
|
||||
match check_path_authorization("some/regular/path", &allowed) {
|
||||
PathAuthDecision::NeedsAuthorization { .. } => {}
|
||||
other => panic!("非黑名单相对路径 + 空 persistent 应 NeedsAuthorization, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ============================================================
|
||||
// F-260620: strip_verbatim 比对侧收口 + 黑名单增强测试
|
||||
// 三方审查(安全/UX/跨端)交叉印证:strip_verbatim 仅写入侧调用,比对侧遗漏致误弹窗。
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"build": {
|
||||
"beforeDevCommand": "bun dev",
|
||||
"beforeBuildCommand": "bun run build",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
|
||||
@@ -494,6 +494,11 @@ export interface AiMessage {
|
||||
* (不强制校验变体字段,未知 kind 忽略,对齐后端「新增变体向后兼容」设计)。
|
||||
*/
|
||||
augmentations?: Augmentation[]
|
||||
/**
|
||||
* 这一轮对话的 token 用量(仅 assistant 消息,每轮 AiCompleted 时回填)。
|
||||
* undefined=未记录(历史消息/未启用 token 显示)。
|
||||
*/
|
||||
tokenUsage?: { prompt: number; completion: number }
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
|
||||
@@ -155,6 +155,7 @@ const props = defineProps<{
|
||||
const store = useAiStore()
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
// path_auth 审批链阶段3b(统一审批模型):df-ai-unified-approval 开关响应式 ref。
|
||||
// 开(true,默认):path 挂起归一进 ToolCard 内联审批(once/always/deny),不挂 DirAuthDialog。
|
||||
// 关(false,兜底回退):挂 DirAuthDialog 独立弹窗老链路(useAiEvents AiDirAuthRequired case 走 pendingDirAuths)。
|
||||
@@ -255,7 +256,12 @@ async function confirmClearChat() {
|
||||
* 历史:B-260615-10 单 loop 软复位弹中断确认,F-09 决策e 上线后已废弃(A 路线遗留)。
|
||||
*/
|
||||
async function confirmNewConversation() {
|
||||
await store.newConversation()
|
||||
try {
|
||||
await store.newConversation()
|
||||
} catch (e) {
|
||||
console.error('[AI] 新建对话失败:', e)
|
||||
// 用错误气泡反馈用户,不静默吞掉
|
||||
}
|
||||
}
|
||||
|
||||
// 第二批抽离: 技能 `/` 联想 + @ 实体引用联想 + watch(inputText) 已迁移至 ChatInput.vue
|
||||
@@ -510,18 +516,24 @@ onMounted(async () => {
|
||||
console.debug(`[启动] AiChat IPC 完成: ${(performance.now() - t0).toFixed(0)}ms`)
|
||||
})
|
||||
|
||||
// ── 置顶功能(持久化:刷新后保持置顶状态图标一致)──
|
||||
// ── 置顶功能(仅分离窗口有意义;持久化:刷新后保持置顶状态图标一致)──
|
||||
// 关键:置顶只对「分离窗口」生效。AiChat 在主窗口(App.vue)与分离窗口(AiDetached.vue)都挂载,
|
||||
// 若不区分,主窗口启动时会因 KV=true 把【整个主窗口】置顶到所有应用之上(BUG)。
|
||||
// 主窗口内 AI 是 docked 面板,主窗口本身不该置顶。故 restore/apply 一律 gate 在 props.detached。
|
||||
const alwaysOnTop = ref(false)
|
||||
const appSettingsStore = useAppSettingsStore()
|
||||
// 从 SQLite KV 恢复上次置顶状态
|
||||
// 从 SQLite KV 恢复上次置顶状态(仅分离窗口读用;主窗口读了也无副作用,ref 仅驱动按钮 active 态)
|
||||
const savedAlwaysOnTop = appSettingsStore.get<boolean>('df-ai-always-on-top', false)
|
||||
alwaysOnTop.value = savedAlwaysOnTop
|
||||
// 若恢复值为 true(上次是置顶态),同步到 Tauri 窗口
|
||||
if (savedAlwaysOnTop) {
|
||||
// 异步设(不阻塞初始化流程)
|
||||
const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
getCurrentWebviewWindow().setAlwaysOnTop(true).catch(() => {/* 静默 */})
|
||||
}
|
||||
alwaysOnTop.value = props.detached ? savedAlwaysOnTop : false
|
||||
// 同步到 Tauri 窗口:分离窗口按 KV 还原;主窗口显式清置顶(防御历史误设/窗口状态残留)。
|
||||
// 注意:此处不可用顶层 await —— <script setup> 顶层 await 会使本组件变成 async setup,
|
||||
// 而 App.vue / AiDetached.vue 直接挂载 <AiChat /> 未包 <Suspense>,会导致渲染空白。
|
||||
// 用 .then() fire-and-forget,既不阻塞初始化流程,又保持组件同步 setup。
|
||||
import('@tauri-apps/api/webviewWindow').then(({ getCurrentWebviewWindow }) =>
|
||||
getCurrentWebviewWindow()
|
||||
.setAlwaysOnTop(props.detached ? savedAlwaysOnTop : false)
|
||||
.catch(() => {/* 静默:webview 不可用时忽略 */}),
|
||||
)
|
||||
async function toggleAlwaysOnTop() {
|
||||
const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
const win = getCurrentWebviewWindow()
|
||||
|
||||
@@ -730,13 +730,13 @@ defineExpose({
|
||||
</div>
|
||||
|
||||
|
||||
<!-- token 用量(仅最后一条 AI 消息 + 非流式;ai.ts handleEvent 已按 df-show-token-usage 开关过滤 lastTokenUsage) -->
|
||||
<!-- token 用量(显示在每条 assistant 消息底部,非流式且有数据时) -->
|
||||
<div
|
||||
v-if="isLastAi(item.msg) && !store.state.streaming && store.state.lastTokenUsage"
|
||||
v-if="item.msg.role === 'assistant' && !store.state.streaming && item.msg.tokenUsage"
|
||||
class="ai-token-usage"
|
||||
>
|
||||
<span class="ai-token-usage-icon">🔣</span>
|
||||
<span>{{ formatTokens(store.state.lastTokenUsage.prompt) }} in · {{ formatTokens(store.state.lastTokenUsage.completion) }} out</span>
|
||||
<span>{{ formatTokens(item.msg.tokenUsage.prompt) }} in · {{ formatTokens(item.msg.tokenUsage.completion) }} out</span>
|
||||
</div>
|
||||
|
||||
<!-- 工具调用卡片(渲染/折叠/审批全下沉到 ToolCardList+ToolCard 子组件,MessageList 仅转发审批) -->
|
||||
|
||||
@@ -225,7 +225,7 @@ import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAiStore } from '../../stores/ai'
|
||||
import { aiApi } from '../../api/ai'
|
||||
import type { AiMessage, ModelConfig } from '../../api/types'
|
||||
import type { AiMessage, ModelConfig, GoalEntry } from '../../api/types'
|
||||
|
||||
defineProps<{
|
||||
/** 分离窗口模式(嵌入 vs 分离决定顶部按钮组) */
|
||||
@@ -326,6 +326,20 @@ watch(() => store.state.activeConversationId, () => {
|
||||
store.modelOverride.value = enabledModels.value[0]?.model_id || null
|
||||
})
|
||||
|
||||
// 切 provider 后模型 override 同步(BUG-2026-07-07:provider 切换后 header 模型名不刷新):
|
||||
// - modelOverride 是模块级 ref(useAiSend)不持久化,切 provider 后仍持旧 provider 的 model_id,
|
||||
// 该 id 不在新 provider 池里 → 下拉显示错位(UI 显示 X,实际后端用路由默认 Y)。
|
||||
// - 守卫:当前选择对新 provider 模型池无效(空/不在池)时,默认选权重最高模型。
|
||||
// enabledModels 已按 weight 降序排,model[0] 即权重最高。
|
||||
// - immediate:组件挂载时也校验(modelOverride 为 null → 默认权重最高,与下拉首项对齐)。
|
||||
watch(enabledModels, (models) => {
|
||||
const current = store.modelOverride.value
|
||||
const valid = current && models.some(m => m.model_id === current)
|
||||
if ((!valid || !current) && models.length) {
|
||||
store.modelOverride.value = models[0].model_id
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// ── 当前对话标题 ──
|
||||
const currentConvTitle = computed(() => {
|
||||
const id = store.state.activeConversationId
|
||||
@@ -416,7 +430,7 @@ const summaryExpanded = ref(false)
|
||||
|
||||
// ── L3 完整上下文 ──
|
||||
interface ContextInfo {
|
||||
goals: string[]
|
||||
goals: GoalEntry[]
|
||||
summaryCount: number
|
||||
osInfo: string
|
||||
projectNames: string
|
||||
|
||||
@@ -331,7 +331,9 @@ function statusLabelKey(status: IdeaStatus): string {
|
||||
|
||||
// B-260615-25:灵感描述 Markdown 渲染(复用 AiChat/TaskDetail 同款渲染器,模块级单例),
|
||||
// useRendered 封装 computed(读 mdReady 触发响应式 + renderMd)+ ensureLoaded(幂等预热)
|
||||
const { rendered: renderedDesc } = useRendered(
|
||||
// BUG-2026-07-08:原漏解构 ensureLoaded + onMounted 未调用,冷启动(marked 未被其他组件预热)
|
||||
// 时 mdReady 永远 false,renderMd 走 escapeFallback 纯文本降级,Markdown 语法不渲染。
|
||||
const { rendered: renderedDesc, ensureLoaded: ensureMdLoaded } = useRendered(
|
||||
() => props.idea.description ?? '',
|
||||
)
|
||||
|
||||
@@ -439,7 +441,10 @@ function historySummary(aiAnalysis: string | null | undefined): HistorySummary |
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadHistory)
|
||||
onMounted(() => {
|
||||
ensureMdLoaded() // 后台预热 Markdown 渲染器(模块单例,与 AiChat/TaskDetail 共享),不阻塞
|
||||
loadHistory()
|
||||
})
|
||||
watch(() => props.idea.id, loadHistory)
|
||||
</script>
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
//! streaming 是 boolean 无真正非法跃迁,guard 价值在集中入口与日志而非拦截。
|
||||
|
||||
import { state } from '@/stores/ai'
|
||||
import { convStates } from './aiShared'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
|
||||
/// feature flag key(appSettings KV)。关=回退散布语义(灰度/回退用)。
|
||||
@@ -100,4 +101,14 @@ export function forceResetStreaming(reason: string, convId?: string): void {
|
||||
setStreaming(false, { convId: convId ?? null, reason })
|
||||
state.currentText = ''
|
||||
state.queue = []
|
||||
// BUG-2026-07-17 根治:同时清理 convStates 项。
|
||||
// 原实现仅清 streaming/currentText/queue,但按钮 stopBtnState 优先读 convStates
|
||||
// (ChatInput.vue:243),convStates=generating 时仍显红色停止按钮。后端未发
|
||||
// AiConvStateChanged 时(conv_state 永久卡住),必须前端主动清 convStates 才能解锁 UI。
|
||||
if (convId) {
|
||||
convStates.delete(convId)
|
||||
} else {
|
||||
// 全局兜底:无 convId 时清所有生成态(卡死恢复场景,精确性不重要)
|
||||
convStates.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { state } from '@/stores/ai'
|
||||
import { notifyConversationChanged } from './useAiEvents'
|
||||
import { persistUiState } from './useAiPanel'
|
||||
import { setStreaming } from './streamingGuard'
|
||||
import { nextMsgId, getConvState, clearConvStreamState, startApprovalTimer } from './aiShared'
|
||||
import { nextMsgId, getConvState, clearConvStreamState, startApprovalTimer, clearAllApprovalTimers } from './aiShared'
|
||||
import { t } from '@/i18n/i18n-helpers'
|
||||
import type { AiConversationDetail, AiMessage, AiToolCallInfo, ConvId } from '@/api/types'
|
||||
|
||||
@@ -76,9 +76,8 @@ async function newConversation() {
|
||||
// 不清 generatingConvs(保留旧 conv 生成态跟踪,侧栏显双会话生成,AiCompleted/AiError 按
|
||||
// conversation_id 正确收尾旧 conv)。原 state.generatingConvs.clear() 是 A 路线 F-260616-09
|
||||
// 单 loop 软隔离遗留,真并发下致后台生成前端丢失跟踪,废弃。
|
||||
// queue 清空防旧会话排队消息带进新会话 drain;agentRound 复位 0;searchQuery 清空防新会话
|
||||
// 侧栏被旧搜索过滤。此三项均为新 conv 本地视图复位(非跨 conv 跟踪),保留。
|
||||
state.queue = []
|
||||
// agentRound 复位 0;searchQuery 清空防新会话侧栏被旧搜索过滤。
|
||||
// queue 保留旧会话排队消息(它们有 conversationId,会在旧会话 AiCompleted 时按 ID 精准 drain)
|
||||
state.agentRound = 0
|
||||
state.searchQuery = ''
|
||||
await loadConversations()
|
||||
@@ -90,6 +89,9 @@ let _latestSwitchId = 0
|
||||
|
||||
/** 切换到指定会话:加载历史消息(含 tool_calls 回填 + tool_result 映射 + pending 审批恢复) */
|
||||
export async function switchConversation(id: string) {
|
||||
// AIC-FIX-17-P0-2:切换会话时清空所有审批计时器(防切走后过期 timer 误拒后台 pending + 错误气泡推错视图)。
|
||||
// 新会话的审批计时器由下文 restore pending 路径的 startApprovalTimer 重新建立。
|
||||
clearAllApprovalTimers()
|
||||
const mySwitchId = ++_latestSwitchId
|
||||
// 允许生成中切换:后台继续生成,事件按 conversation_id 路由不污染当前视图
|
||||
let detail: AiConversationDetail
|
||||
@@ -241,7 +243,7 @@ export async function switchConversation(id: string) {
|
||||
dir: tc.dir,
|
||||
reason: tc.reason,
|
||||
})
|
||||
// 重新启动审批计时器(APPROVAL_TIMEOUT_MS=Infinity 已决策,不会超时自动拒绝,
|
||||
// 重新启动审批计时器(APPROVAL_TIMEOUT_MS 默认 15min,0=不限时跳过;
|
||||
// 启动仅用于计时器注册一致性,保持与 AiApprovalRequired 事件处理对称)
|
||||
startApprovalTimer(tc.id, tc.name, kind)
|
||||
}
|
||||
@@ -258,6 +260,9 @@ export async function switchConversation(id: string) {
|
||||
|
||||
/** 删除会话;若删的是当前活跃会话则清空消息+移除活跃 id 持久化 */
|
||||
async function deleteConversation(id: string) {
|
||||
// AIC-FIX-17-P0-2:删除会话时清空所有审批计时器(防已删会话的过期 timer 到期误拒审批 +
|
||||
// 错误气泡推错视图)。
|
||||
clearAllApprovalTimers()
|
||||
await aiApi.deleteConversation(id)
|
||||
// F-09 per-conv:清该会话的 stream state(streaming/currentText),防 convStreamStates Map 无限增长
|
||||
// (与 convStates/待审批等 per-conv 资源同款会话级清理语义)。
|
||||
|
||||
@@ -642,7 +642,9 @@ function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | '
|
||||
function handleLifecycleEvent(event: AiChatEvent): boolean {
|
||||
switch (event.type) {
|
||||
case 'AiCompleted': {
|
||||
console.warn('[FE-AICOMPLETED] 收到 conv=', event.conversation_id, 'streaming=', state.streaming)
|
||||
cleanupTerminatedConversation(event.conversation_id || '', 'AiCompleted')
|
||||
console.warn('[FE-AICOMPLETED] cleanup 后 streaming=', state.streaming)
|
||||
// UX-2025-04 / CR-30-2 / 决策 a1: 流中途失败保文——后端 emit AiCompleted(incomplete=true),
|
||||
// 前端追加系统提示气泡(镜像后端 session.messages 的 system 提示)。
|
||||
// 注:此系统提示仅前端展示,后端已独立 push 到 session.messages 落库。
|
||||
@@ -674,17 +676,30 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
|
||||
} else {
|
||||
state.convTokenTotal = { prompt: event.prompt_tokens, completion: event.completion_tokens, total: event.total_tokens }
|
||||
}
|
||||
// 每轮 token 写入对应 assistant 消息(最后一条 AI 消息),供 MessageList 逐条显示。
|
||||
for (let i = state.messages.length - 1; i >= 0; i--) {
|
||||
const m = state.messages[i]
|
||||
if (m.role === 'assistant' && !m.isError) {
|
||||
m.tokenUsage = { prompt: event.prompt_tokens, completion: event.completion_tokens }
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
notifyConversationChanged()
|
||||
// 队列续发:当前完成后自动发下一条(经事件总线桥接,避免 import useAiSend 构成循环依赖)
|
||||
emit('ai-drain-queue', {})
|
||||
emit('ai-drain-queue', { conversationId: event.conversation_id })
|
||||
return true
|
||||
}
|
||||
|
||||
case 'AiError': {
|
||||
cleanupTerminatedConversation(event.conversation_id || '', 'AiError')
|
||||
clearAllApprovalTimers() // UX-260617-10: 错误中断释放所有审批超时计时器,防回调改 state 触发已卸载/已错流程
|
||||
state.queue = [] // B-32:错误收尾清队列,防生成中入队的消息被静默丢失(drainQueue 仅 AiCompleted 触发)
|
||||
// 只清除出错会话的队列项,不误伤其他会话的排队消息
|
||||
if (event.conversation_id) {
|
||||
state.queue = state.queue.filter(q => q.conversationId !== event.conversation_id)
|
||||
} else {
|
||||
state.queue = []
|
||||
}
|
||||
// UX-260617-10: 错误收尾清残留待审批项——错误发生时若有工具停在 pending_approval,
|
||||
// 残留可点击审批按钮会让用户误以为还能批(实际后端已终止),残留审批卡误导操作。
|
||||
state.pendingApprovals = []
|
||||
@@ -809,21 +824,16 @@ export function handleEvent(event: AiChatEvent) {
|
||||
/** 启动事件监听(幂等 + 并发去重,onMounted 与 sendMessage 首发竞态不会重复注册) */
|
||||
export async function startListener() {
|
||||
// 已注册 → 直接复用(幂等;sendMessage 每次调用不重复注册)
|
||||
if (_unlistenAiEvent && _unlistenConvChanged && _unlistenApprovalClear) return
|
||||
if (_unlistenAiEvent && _unlistenConvChanged && _unlistenApprovalClear) {
|
||||
return
|
||||
}
|
||||
// 并发去重:防 onMounted 与 sendMessage 首发竞态重复注册 listener → 文字双倍
|
||||
if (_startPromise) return _startPromise
|
||||
// UX-260617-09: 内部任一 await reject 时,onMounted/sendMessage 的 await 在 Vue async
|
||||
// 生命周期/void 调用点处成为未捕获 rejection(无 console 输出)→ listener 未注册且无任何提示,
|
||||
// 表现为「发消息永远不回」。这里 catch + console.error 保留可观测性,再 throw 让外层
|
||||
// finally 清 _startPromise(并发去重复位),调用方仍可 try/catch 兜底。
|
||||
_startPromise = (async () => {
|
||||
try {
|
||||
_unlistenAiEvent = await aiApi.onEvent(handleEvent)
|
||||
_unlistenConvChanged = await listen('ai-conversation-changed', () => { void loadConversations() })
|
||||
// AE-2025-06: onStreamTimeout(流式整流超时收尾)广播的清理事件 → 清全部审批超时计时器。
|
||||
// useAiStream 不 import useAiSend(避循环依赖),经事件总线中转。
|
||||
_unlistenApprovalClear = await listen('ai-approval-clear-timers', () => { clearAllApprovalTimers() })
|
||||
// B-260616-01: L0 握手 — 通知后端前端已就绪,后端据此清除 HMR/刷新导致的残留 generating 状态
|
||||
emit('ai-client-ready', { lastConvId: state.activeConversationId })
|
||||
} catch (err) {
|
||||
console.error('[useAiEvents] startListener 注册失败,事件监听未生效:', err)
|
||||
|
||||
@@ -24,7 +24,7 @@ import { t } from '@/i18n/i18n-helpers'
|
||||
import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream'
|
||||
import { setStreaming } from './streamingGuard'
|
||||
import { startListener } from './useAiEvents'
|
||||
import { nextMsgId, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers, resolveAiLang } from './aiShared'
|
||||
import { nextMsgId, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers, resolveAiLang, convStates } from './aiShared'
|
||||
|
||||
/// 待发送队列上限(超过抛错提示用户)
|
||||
const QUEUE_LIMIT = 10
|
||||
@@ -90,7 +90,6 @@ async function doSend(text: string, skill?: string, force = false, parts?: Conte
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
|
||||
setStreaming(true, { convId: state.activeConversationId, reason: 'doSend' })
|
||||
state.currentText = ''
|
||||
resetStreamWatchdog() // 启动流式看门狗,无数据超时兜底
|
||||
@@ -112,8 +111,11 @@ async function doSend(text: string, skill?: string, force = false, parts?: Conte
|
||||
} catch (e) {
|
||||
// IPC 失败(spawn 前/provider 配置错等):回滚 streaming 防光标卡死 + 移除 user 消息与空气泡占位;
|
||||
// 重新抛出由 handleSend 回填输入框,用户可重试
|
||||
setStreaming(false, { convId: state.activeConversationId, reason: 'doSend-ipc-fail' })
|
||||
clearStreamWatchdog() // 清看门狗,防 130s 后 onStreamTimeout 误推错误气泡
|
||||
// BUG-2026-07-17:彻底清理 convStates,避免按钮永久红色
|
||||
const convId = state.activeConversationId
|
||||
setStreaming(false, { convId, reason: 'doSend-ipc-fail' })
|
||||
clearStreamWatchdog(convId || undefined)
|
||||
if (convId) convStates.delete(convId)
|
||||
state.messages = state.messages.filter(m => m.id !== aiMsgId && m.id !== userMsgId)
|
||||
throw e
|
||||
}
|
||||
@@ -149,7 +151,9 @@ async function regenerate() {
|
||||
|
||||
setStreaming(true, { convId: state.activeConversationId, reason: 'regenerate' })
|
||||
state.currentText = ''
|
||||
state.queue = [] // 重生成视同新发,清残留队列
|
||||
// 只清当前会话的排队消息,不误伤其他会话
|
||||
const regenConvId = state.activeConversationId
|
||||
state.queue = state.queue.filter(q => q.conversationId !== regenConvId)
|
||||
resetStreamWatchdog()
|
||||
|
||||
await startListener()
|
||||
@@ -219,7 +223,9 @@ async function editMessage(newMessage: string) {
|
||||
|
||||
setStreaming(true, { convId: state.activeConversationId, reason: 'editMessage' })
|
||||
state.currentText = ''
|
||||
state.queue = [] // 编辑重生成视同新发,清残留队列
|
||||
// 只清当前会话的排队消息,不误伤其他会话
|
||||
const editConvId = state.activeConversationId
|
||||
state.queue = state.queue.filter(q => q.conversationId !== editConvId)
|
||||
resetStreamWatchdog()
|
||||
|
||||
await startListener()
|
||||
@@ -251,15 +257,22 @@ async function editMessage(newMessage: string) {
|
||||
* 回填失败消息到队首并停 drain(不静默丢用户输入,保留剩余队列供手动重试/编辑/取消)。
|
||||
* 成功路径不受影响——doSend 成功后端会再 emit AiCompleted 续 drain 链路。
|
||||
*/
|
||||
export function drainQueue() {
|
||||
export function drainQueue(convId?: string | null) {
|
||||
if (state.queue.length === 0) return
|
||||
const next = state.queue.shift()!
|
||||
// 找队首属于目标 conv 的消息;若穿越(非当前 conv 的项堵在队首)则跳过整条队列
|
||||
const targetId = convId ?? state.activeConversationId
|
||||
const idx = state.queue.findIndex(q => q.conversationId === targetId)
|
||||
if (idx === -1) return
|
||||
const next = state.queue.splice(idx, 1)[0]!
|
||||
const activeConvId = state.activeConversationId
|
||||
void (async () => {
|
||||
try {
|
||||
await sendMessage(next.text, next.skill, false, next.parts)
|
||||
} catch (e) {
|
||||
setStreaming(false, { convId: state.activeConversationId, reason: 'drainQueue-fail' })
|
||||
clearStreamWatchdog()
|
||||
// BUG-2026-07-17: 失败时彻底收尾,带 convId 精准清理 per-conv watchdog + convStates
|
||||
setStreaming(false, { convId: activeConvId, reason: 'drainQueue-fail' })
|
||||
clearStreamWatchdog(activeConvId || undefined)
|
||||
if (activeConvId) convStates.delete(activeConvId)
|
||||
const errMsg = e instanceof Error ? e.message : String(e)
|
||||
state.queue.unshift(next) // 回填失败消息到队首,保留剩余队列(不静默丢用户输入)
|
||||
state.messages.push({
|
||||
@@ -313,12 +326,14 @@ async function sendMessage(text: string, skill?: string, forceMode = false, part
|
||||
throw new Error(t('ai.queueFull', { limit: QUEUE_LIMIT }))
|
||||
}
|
||||
setStreaming(true, { convId: state.activeConversationId, reason: 'L1-enqueue' })
|
||||
// F-260614-05 Phase 2b: 入队项挂 parts(供 drainQueue 续发时本地 user 消息渲染图)
|
||||
// F-260614-05 Phase 2b: 入队项挂 parts(供 drainQueue/续发时本地 user 消息渲染图)
|
||||
// 同时挂 conversationId,供 drainQueue 按会话精准续发(防跨会话串话)
|
||||
state.queue.push({
|
||||
text: text.trim(),
|
||||
skill: skill || undefined,
|
||||
enqueuedAt: Date.now(),
|
||||
parts: parts && parts.length > 0 ? parts : undefined,
|
||||
conversationId: state.activeConversationId,
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -481,5 +496,7 @@ export function useAiSend() {
|
||||
let _drainUnlisten: (() => void) | null = null
|
||||
export async function initDrainQueueListener(): Promise<void> {
|
||||
if (_drainUnlisten) return
|
||||
_drainUnlisten = await listen('ai-drain-queue', () => { drainQueue() })
|
||||
_drainUnlisten = await listen('ai-drain-queue', (event: any) => {
|
||||
drainQueue(event?.payload?.conversationId ?? null)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -13,14 +13,19 @@
|
||||
|
||||
import { state } from '@/stores/ai'
|
||||
import { t } from '@/i18n/i18n-helpers'
|
||||
import { nextMsgId, getConvState } from './aiShared'
|
||||
import { nextMsgId, convStates } from './aiShared'
|
||||
import type { MessageId } from '@/api/types'
|
||||
import { forceResetStreaming } from './streamingGuard'
|
||||
import { emit } from '@tauri-apps/api/event'
|
||||
|
||||
/// ≥ 后端 STREAM_IDLE_TIMEOUT(120s, stream_recv.rs STREAM_IDLE_TIMEOUT)+余量;
|
||||
/// 前端若短于后端,慢首 token 会被前端先误杀
|
||||
export const STREAM_TIMEOUT_MS = 130000
|
||||
/// 流式整体超时(ms)。
|
||||
///
|
||||
/// 设计权衡:
|
||||
/// - 后端 stream_llm.rs 首 chunk 超时 10s + STREAM_IDLE_TIMEOUT 120s,正常请求远不到此
|
||||
/// - 后端 execute_with_heartbeat 工具执行补 30s AiHeartbeat,正常工具执行也不会到此
|
||||
/// - 但 LLM 重试间期(breaker 3轮)无 heartbeat——LLM 生成(~15s)+工具执行(~5s)循环
|
||||
/// 3轮总耗约 60-75s,45s 会误杀。改为 90s:覆盖 breaker 3轮 + margin。
|
||||
export const STREAM_TIMEOUT_MS = 90000
|
||||
|
||||
// TD-260621-01 per-conv 看门狗:模块级单计时器 → convId → timer Map。
|
||||
//
|
||||
@@ -49,78 +54,50 @@ let _legacyWatchdog: ReturnType<typeof setTimeout> | null = null
|
||||
* (legacy fallback 路径)时全局兜底,行为对齐旧版。
|
||||
*/
|
||||
export function onStreamTimeout(convId?: string) {
|
||||
// 根治守卫(TD-260621-01 遗留):看门狗 reset(doSend 走 legacy _legacyWatchdog)/clear
|
||||
// (AiCompleted 走 per-conv _watchdogTimers Map)路径不对齐,clear 传 convId 时只清 Map 不动 legacy,
|
||||
// 致正常收尾(AiCompleted 已清 streaming)后 legacy timer 残留,130s 到期触发本函数补
|
||||
// "⚠ 工具已执行完成,后续回复中断"误报气泡(实测:用户报"回复完后输出该报错")。
|
||||
// 守卫:触发时若该 conv 已不在生成态(已正常收尾),判定为残留 timer,静默清不补错误消息。
|
||||
// 真卡死(streaming/generating 仍 true)不受影响,仍走原 forceReset + 报错兜底。
|
||||
const stillGenerating = convId
|
||||
? (() => {
|
||||
// 批4 双轨收口:读 convStates(enum 真相源)派生,替代旧 generatingConvs.has。
|
||||
// 桥接语义:has(convId)=true 等价于 conv_state∈{generating,stopping,compressed}(非终止三态)。
|
||||
const cs = getConvState(convId)
|
||||
return cs === 'generating' || cs === 'stopping' || cs === 'compressed'
|
||||
})()
|
||||
: state.streaming
|
||||
if (!stillGenerating) {
|
||||
clearStreamWatchdog(convId)
|
||||
return
|
||||
}
|
||||
// 兜底守卫(BUG-260624-03):工具执行中(running 卡)不计整流超时。
|
||||
// 后端 execute_with_heartbeat(audit/mod.rs)已为工具执行补 30s AiHeartbeat,正常执行
|
||||
// 不会到 130s 超时;到此且仍有 running 卡 = 心跳也丢失(老后端/provider 双重异常),
|
||||
// 静默续等(reset 重计 130s)给工具完成/心跳恢复机会,不抛"工具已执行完成后续中断"误报。
|
||||
// 注:真死锁(running 永驻)时续等会持续,但优于误报流中断(forceReset 致状态不一致);
|
||||
// running 状态由后端 AiToolCallCompleted 驱动(非 ToolCard approving 计时器,那仅复位按钮 loading),
|
||||
// 用户可手动 stop,且 A 心跳已覆盖正常执行,真死锁罕见。
|
||||
for (let i = state.messages.length - 1; i >= 0; i--) {
|
||||
const toolCalls = state.messages[i].toolCalls
|
||||
if (!toolCalls) continue
|
||||
for (let j = 0; j < toolCalls.length; j++) {
|
||||
if (toolCalls[j].status === 'running') {
|
||||
resetStreamWatchdog(convId)
|
||||
return
|
||||
}
|
||||
// BUG-2026-07-17 根治性重构:状态机必须能自我收敛。
|
||||
//
|
||||
// 原问题:streaming=true 写在多个入口(doSend/regenerate/edit/L1入队),
|
||||
// 但 false 只在 cleanupTerminatedConversation 里。任一漏掉即永久卡住。
|
||||
//
|
||||
// 新逻辑:看门狗是状态机自我收敛的唯一包底,超时即彻底重置:
|
||||
// - 清 streaming=false
|
||||
// - 清 convStates 项(按钮 stopBtnState 读它)
|
||||
// - 清 currentText/queue
|
||||
// - 清 watchdog timer
|
||||
// 不管 stillGenerating 检测(即使已经 false 也再清理一次,完备包底)。
|
||||
console.warn(`[AI-Watchdog] 超时触发,强制收敛状态${convId ? `(convId=${convId})` : '(全局)'}`)
|
||||
|
||||
// 彻底清理三处状态源,保证状态机收敛到 idle
|
||||
forceResetStreaming('onStreamTimeout(无数据包底)', convId)
|
||||
if (convId) {
|
||||
convStates.delete(convId)
|
||||
} else {
|
||||
// AIC-FIX-17-P0-1:legacy 单 timer 只跟踪当前活跃会话,不清所有 conv(防误杀其他并发会话)。
|
||||
if (state.activeConversationId) {
|
||||
convStates.delete(state.activeConversationId)
|
||||
}
|
||||
}
|
||||
// 卡死兜底走 guard 的 forceResetStreaming(复位 streaming + 清 currentText/queue + warn 日志)。
|
||||
// 批4 双轨收口:generatingConvs 已退役,本兜底仅复位 streaming 态;该会话 convStates 项由
|
||||
// 后端收尾事件(或下次活跃事件兜底写)收敛,无需此处手动清。
|
||||
forceResetStreaming('onStreamTimeout(130s 无数据)', convId)
|
||||
clearStreamWatchdog(convId)
|
||||
// AE-2025-06: 整流超时收尾 → 广播清全部审批超时计时器。
|
||||
// 本模块不 import useAiSend(会构成 useAiSend↔useAiStream 循环依赖,与现有破环原则冲突),
|
||||
// 复用 ai-tool-slow-toast 同款事件总线模式:emit 由 useAiEvents.startListener listen 后调
|
||||
// clearAllApprovalTimers。流式已中断,审批继续挂定时器无意义(用户也会随之停止交互)。
|
||||
|
||||
// 广播清审批计时器(流式已中断,审批计时器无意义)
|
||||
void emit('ai-approval-clear-timers', {})
|
||||
// 一次性遍历同时做两件事(单次 O(n),不再分两遍扫):
|
||||
// 1) 回滚 running 态工具调用 → rejected:approveToolCall 已不乐观置 running(B-260616-08),
|
||||
// 此分支兜底后端事件先转 running 再 hang 的卡片,避免永久骨架屏。B-260615-07/B-33 残留。
|
||||
// (approve 后无 running 转换而 hang 的卡片由 ToolCard 本地 approving 计时器兜底复位,
|
||||
// 不在此全局回滚 pending_approval——会误伤其他真正待审批的卡片。)
|
||||
// 2) 探测任一 completed 卡片以区分错误文案(工具已执行完后续回复中断 vs 纯流式中断)。
|
||||
// 反向扫尾部提前退出(同 findToolCall 策略);不复用 useAiEvents.findToolCall:本模块
|
||||
// 不依赖 useAiEvents(已下沉 nextMsgId 到 aiShared 破环),且此处需全量回滚而非按 id 定位。
|
||||
// 注:messages 遍历仍是单例(currentText 一样),per-conv 超时只切到当前 messages 视图;
|
||||
// 多会话并发下其他会话的 messages 不在此 state.messages(切走即整体替换),无跨会话误回滚。
|
||||
|
||||
// 检测是否是 "工具已执行完后续中断"(区别文案)
|
||||
let hasCompletedTool = false
|
||||
for (let i = state.messages.length - 1; i >= 0; i--) {
|
||||
const toolCalls = state.messages[i].toolCalls
|
||||
if (!toolCalls) continue
|
||||
for (let j = 0; j < toolCalls.length; j++) {
|
||||
const tc = toolCalls[j]
|
||||
// TD-260621-01 修复(用户实测卡死根因):不再把 running→rejected。
|
||||
// path_auth/risk 挂起的 toolCall 停在 running(path_auth 不置 pending_approval),
|
||||
// 看门狗超时自动拒绝会误杀用户未操作的审批(用户报"显示用户拒绝了此操作,但根本没操作")。
|
||||
// 看门狗只做流断兜底(清 streaming/currentText/queue),工具/审批态由各自生命周期
|
||||
// (AiToolCallCompleted/AiApprovalResult/ai_authorize_dir)管理。running 骨架屏由
|
||||
// ToolCard 本地 approving 计时器兜底,或 path_auth 授权后 AiToolCallCompleted 转 completed。
|
||||
if (tc.status === 'completed') {
|
||||
if (toolCalls[j].status === 'completed') {
|
||||
hasCompletedTool = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (hasCompletedTool) break
|
||||
}
|
||||
|
||||
// 补一条错误气泡(仅在实际还在生成态时补,避免重复补)
|
||||
// 这里直接补——forceResetStreaming 已重置状态,不会重复触发
|
||||
const content = hasCompletedTool
|
||||
? t('ai.streamInterruptedAfterTool')
|
||||
: t('ai.streamInterrupted')
|
||||
|
||||
@@ -101,7 +101,7 @@ const _stateBase: {
|
||||
detached: boolean
|
||||
docked: boolean
|
||||
skills: SkillInfo[]
|
||||
queue: { text: string; skill?: string; enqueuedAt: number; parts?: ContentPart[] }[]
|
||||
queue: { text: string; skill?: string; enqueuedAt: number; parts?: ContentPart[]; conversationId?: string | null }[]
|
||||
agentRound: number
|
||||
/// 已完成工具调用数(本轮累计,AiToolCallCompleted 时 +1,AiAgentRound 时重置)
|
||||
completedTools: number
|
||||
|
||||
Reference in New Issue
Block a user