优化: AI模块(provider重试兼容+aichat交互+工具卡片可读化+diff高亮)
This commit is contained in:
@@ -7,7 +7,7 @@ edition = "2021"
|
||||
df-core = { path = "../df-core" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true, features = ["sync"] }
|
||||
tokio = { workspace = true, features = ["sync", "time"] }
|
||||
async-trait = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
@@ -11,19 +11,23 @@ use eventsource_stream::Eventsource;
|
||||
use futures::StreamExt;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
use tracing::{debug, error, warn};
|
||||
|
||||
use crate::provider::{
|
||||
CompletionRequest, CompletionResponse, LlmProvider, MessageRole,
|
||||
StreamChunk, StreamResult, TokenUsage, ToolCall, ToolCallDelta,
|
||||
};
|
||||
use crate::retry::{
|
||||
retry_with_backoff, AttemptOutcome, is_reqwest_error_retryable, is_status_retryable,
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Anthropic API 请求/响应结构体
|
||||
// ============================================================
|
||||
|
||||
/// Anthropic 请求体
|
||||
#[derive(Debug, Serialize)]
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct AnthropicRequest {
|
||||
model: String,
|
||||
messages: Vec<serde_json::Value>,
|
||||
@@ -40,7 +44,7 @@ struct AnthropicRequest {
|
||||
}
|
||||
|
||||
/// Anthropic 工具定义(input_schema 对应 OpenAI 的 parameters)
|
||||
#[derive(Debug, Serialize)]
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct AnthropicToolDef {
|
||||
name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -406,21 +410,48 @@ impl LlmProvider for AnthropicCompatProvider {
|
||||
|
||||
debug!(model = %body.model, "Anthropic 同步调用");
|
||||
|
||||
let resp = self
|
||||
.auth_headers(self.client.post(self.messages_url()))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
error!(%status, %text, "Anthropic API 调用失败");
|
||||
anyhow::bail!("Anthropic API 错误 {}: {}", status, text);
|
||||
// 指数退避重试(B-260616-07): 包裹 send + 状态码判定。
|
||||
// 同时补 FR-R4 遗漏: Anthropic 同步路径此前无单请求 timeout(建连后挂起会无限 hang),
|
||||
// 此处加 60s timeout,与 OpenAI 路径对齐。
|
||||
let label = format!("Anthropic[{}]", body.model);
|
||||
retry_with_backoff(&label, move |_| {
|
||||
let client = self.client.clone();
|
||||
let url = self.messages_url();
|
||||
let api_key = self.api_key.clone();
|
||||
let body = body.clone();
|
||||
async move {
|
||||
// send: 补 60s 单请求 timeout(此前缺失,FR-R4 遗漏)
|
||||
let rb = client
|
||||
.post(url)
|
||||
.header("x-api-key", &api_key)
|
||||
.header("anthropic-version", ANTHROPIC_VERSION)
|
||||
.header("Content-Type", "application/json")
|
||||
.timeout(Duration::from_secs(60))
|
||||
.json(&body);
|
||||
let resp = match rb.send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
if is_reqwest_error_retryable(&e) {
|
||||
return AttemptOutcome::Retryable(format!("请求失败(可重试): {}", e));
|
||||
}
|
||||
|
||||
let resp: AnthropicResponse = resp.json().await?;
|
||||
|
||||
return AttemptOutcome::Fatal(format!("请求失败(不可重试): {}", e));
|
||||
}
|
||||
};
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status().as_u16();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
let msg = format!("Anthropic API 错误 {}: {}", status, text);
|
||||
if is_status_retryable(status) {
|
||||
warn!(%status, "Anthropic 同步调用可重试状态码");
|
||||
return AttemptOutcome::Retryable(msg);
|
||||
}
|
||||
error!(%status, %text, "Anthropic API 调用失败(不可重试)");
|
||||
return AttemptOutcome::Fatal(msg);
|
||||
}
|
||||
let resp: AnthropicResponse = match resp.json().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => return AttemptOutcome::Fatal(format!("响应解析失败: {}", e)),
|
||||
};
|
||||
// content 块中拼接 text,收集 tool_use
|
||||
let mut text = String::new();
|
||||
let mut tool_calls: Vec<ToolCall> = Vec::new();
|
||||
@@ -432,9 +463,6 @@ impl LlmProvider for AnthropicCompatProvider {
|
||||
}
|
||||
}
|
||||
"tool_use" => {
|
||||
// tool_use 必须带 id:后续 tool_result 用它回引,缺 id 的 tool_call
|
||||
// 会拖出空 tool_use_id 的 tool_result,触发 GLM 500 卡死会话。
|
||||
// id 缺失/为空时跳过该 tool_use 解析并告警。
|
||||
let id = match block.id {
|
||||
Some(id) if !id.is_empty() => id,
|
||||
_ => {
|
||||
@@ -455,20 +483,21 @@ impl LlmProvider for AnthropicCompatProvider {
|
||||
other => warn!(block_type = other, "Anthropic 响应含未知 content 块类型,已忽略"),
|
||||
}
|
||||
}
|
||||
|
||||
let usage = TokenUsage {
|
||||
prompt_tokens: resp.usage.input_tokens,
|
||||
completion_tokens: resp.usage.output_tokens,
|
||||
total_tokens: resp.usage.input_tokens + resp.usage.output_tokens,
|
||||
};
|
||||
|
||||
Ok(CompletionResponse {
|
||||
AttemptOutcome::Ok(CompletionResponse {
|
||||
text,
|
||||
model: resp.model,
|
||||
usage,
|
||||
tool_calls: if tool_calls.is_empty() { None } else { Some(tool_calls) },
|
||||
})
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn stream(&self, request: CompletionRequest) -> anyhow::Result<StreamResult> {
|
||||
let mut req = request;
|
||||
|
||||
@@ -6,6 +6,7 @@ pub mod context;
|
||||
pub mod coordinator;
|
||||
pub mod openai_compat;
|
||||
pub mod provider;
|
||||
mod retry;
|
||||
|
||||
use provider::LlmProvider;
|
||||
|
||||
|
||||
@@ -16,13 +16,16 @@ use crate::provider::{
|
||||
CompletionRequest, CompletionResponse, LlmProvider, StreamChunk, StreamResult,
|
||||
TokenUsage, ToolCall, ToolCallDelta,
|
||||
};
|
||||
use crate::retry::{
|
||||
retry_with_backoff, AttemptOutcome, is_reqwest_error_retryable, is_status_retryable,
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// OpenAI API 请求/响应结构体
|
||||
// ============================================================
|
||||
|
||||
/// OpenAI 兼容请求体
|
||||
#[derive(Debug, Serialize)]
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct OpenAiRequest {
|
||||
model: String,
|
||||
messages: Vec<OpenAiMessage>,
|
||||
@@ -41,7 +44,7 @@ struct OpenAiRequest {
|
||||
}
|
||||
|
||||
/// OpenAI 消息格式
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct OpenAiMessage {
|
||||
role: String,
|
||||
content: String,
|
||||
@@ -398,44 +401,57 @@ impl LlmProvider for OpenAICompatProvider {
|
||||
|
||||
debug!(model = %openai_req.model, "OpenAI 同步调用");
|
||||
|
||||
// 同步路径整体超时(FR-R4):client 仅配 connect_timeout,无总 timeout(防误砍流式长生成)。
|
||||
// complete 为一次性请求,挂死会让整轮对话静默卡住,此处用 RequestBuilder 的单请求 timeout 兜底,
|
||||
// 超时返回 is_timeout 错误(不静默挂),且不影响 stream() 流式路径。
|
||||
let resp = self
|
||||
.client
|
||||
.post(self.chat_url())
|
||||
.header("Authorization", format!("Bearer {}", self.api_key))
|
||||
// 指数退避重试(B-260616-07): 包裹 send + 状态码判定。
|
||||
// 单请求 60s timeout 保持不变(FR-R4),重试是额外层: 3 次 × 60s 最坏 180s,
|
||||
// 由 retry_with_backoff 内部 30s 总预算主动止损。
|
||||
let label = format!("OpenAI[{}]", openai_req.model);
|
||||
retry_with_backoff(&label, move |_| {
|
||||
let client = self.client.clone();
|
||||
let url = self.chat_url();
|
||||
let api_key = self.api_key.clone();
|
||||
let openai_req = openai_req.clone();
|
||||
async move {
|
||||
// send
|
||||
let resp = client
|
||||
.post(url)
|
||||
.header("Authorization", format!("Bearer {}", api_key))
|
||||
.header("Content-Type", "application/json")
|
||||
.timeout(Duration::from_secs(60))
|
||||
.json(&openai_req)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if e.is_timeout() {
|
||||
error!(model = %openai_req.model, "OpenAI 同步调用超时(60s)");
|
||||
anyhow::anyhow!("LLM 同步调用超时(60s),请检查网络或模型可用性: {}", e)
|
||||
} else {
|
||||
anyhow::anyhow!(e)
|
||||
.await;
|
||||
let resp = match resp {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
if is_reqwest_error_retryable(&e) {
|
||||
return AttemptOutcome::Retryable(format!("请求失败(可重试): {}", e));
|
||||
}
|
||||
})?;
|
||||
|
||||
return AttemptOutcome::Fatal(format!("请求失败(不可重试): {}", e));
|
||||
}
|
||||
};
|
||||
// 状态码判定
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let status = resp.status().as_u16();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
error!(%status, %body, "LLM API 调用失败");
|
||||
anyhow::bail!("LLM API 错误 {}: {}", status, body);
|
||||
let msg = format!("LLM API 错误 {}: {}", status, body);
|
||||
if is_status_retryable(status) {
|
||||
warn!(%status, "OpenAI 同步调用可重试状态码");
|
||||
return AttemptOutcome::Retryable(msg);
|
||||
}
|
||||
|
||||
let body: OpenAiResponse = resp.json().await?;
|
||||
let choice = body
|
||||
.choices
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("LLM 响应无 choices"))?;
|
||||
|
||||
error!(%status, %body, "LLM API 调用失败(不可重试)");
|
||||
return AttemptOutcome::Fatal(msg);
|
||||
}
|
||||
// body 解析: 解析错属 Fatal(响应已成功送达,重试也会因同样格式失败)
|
||||
let body: OpenAiResponse = match resp.json().await {
|
||||
Ok(b) => b,
|
||||
Err(e) => return AttemptOutcome::Fatal(format!("响应解析失败: {}", e)),
|
||||
};
|
||||
let choice = match body.choices.into_iter().next() {
|
||||
Some(c) => c,
|
||||
None => return AttemptOutcome::Fatal("LLM 响应无 choices".to_string()),
|
||||
};
|
||||
let text = choice.message.content.unwrap_or_default();
|
||||
let tool_calls = choice.message.tool_calls.map(Self::parse_tool_calls);
|
||||
|
||||
let usage = body.usage.map(|u| TokenUsage {
|
||||
prompt_tokens: u.prompt_tokens,
|
||||
completion_tokens: u.completion_tokens,
|
||||
@@ -445,14 +461,16 @@ impl LlmProvider for OpenAICompatProvider {
|
||||
completion_tokens: 0,
|
||||
total_tokens: 0,
|
||||
});
|
||||
|
||||
Ok(CompletionResponse {
|
||||
AttemptOutcome::Ok(CompletionResponse {
|
||||
text,
|
||||
model: body.model,
|
||||
usage,
|
||||
tool_calls,
|
||||
})
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn stream(&self, request: CompletionRequest) -> anyhow::Result<StreamResult> {
|
||||
let mut req = request;
|
||||
|
||||
257
crates/df-ai/src/retry.rs
Normal file
257
crates/df-ai/src/retry.rs
Normal file
@@ -0,0 +1,257 @@
|
||||
//! complete() 指数退避重试 — 仅非流式同步路径,不动 stream()
|
||||
//!
|
||||
//! 背景: complete() 是 LLM 一次性请求,网络波动/瞬态 5xx/429 会让整轮对话静默失败。
|
||||
//! 各调用方(title.rs/knowledge_inject.rs/project.rs/ai_node.rs)虽已有人工兜底降级,
|
||||
//! 但重试能在不侵入上层的前提下吸收瞬态错误,对低频后台调用零风险。
|
||||
//!
|
||||
//! 策略:
|
||||
//! - 最多 3 次尝试(初次 + 2 次重试),指数退避 1s → 2s → 4s + ±20% jitter
|
||||
//! - 总挂钟预算 30s(含 sleep + 各次请求耗时),超预算直接放弃重试返回最后错误
|
||||
//! - 仅对可重试错误重试: timeout / connect / 5xx / 429; 不重试 4xx(非429) / 参数错 / 鉴权错
|
||||
//! - 不引入 reqwest-retry / tower: complete 路径简单,手写 loop 零依赖、可调试、不与 SSE/双层 Semaphore 耦合
|
||||
//!
|
||||
//! 流式 stream() 路径不进入此模块(流式长生成不能整体 timeout,重试会丢增量)。
|
||||
//!
|
||||
//! 重试期间不释放 Semaphore permit(已在调用方持有),对并发池有挤占 —— 但 complete 调用低频可接受。
|
||||
|
||||
use std::future::Future;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use tracing::warn;
|
||||
|
||||
/// 最多尝试次数(含初次)。B-260616-07: 3 次 = 初次 + 2 次重试。
|
||||
///
|
||||
/// 配置化 TODO: 未来接入 per-provider config(`AiProviderRecord.config` JSON)或全局开关
|
||||
/// (`useSetting('df-ai-max-retries')`)时改为读取配置。当前低频后台调用,常量足够。
|
||||
pub const MAX_COMPLETE_ATTEMPTS: u32 = 3;
|
||||
|
||||
/// 退避基准(秒): 第 n 次重试前 sleep `BASE_BACKOFF_SECS * 2^(n-1)`。
|
||||
/// 即 1s → 2s → 4s。
|
||||
const BASE_BACKOFF_SECS: u64 = 1;
|
||||
|
||||
/// 整体重试挂钟预算(含 sleep 与各次请求耗时)。超过即放弃,返回最后错误。
|
||||
/// 与单请求 60s timeout 配合: 最坏 3 次 × 60s = 180s,30s 预算会在此之前主动止损。
|
||||
const MAX_TOTAL_BUDGET: Duration = Duration::from_secs(30);
|
||||
|
||||
/// jitter 上限(相对 base 的 ±比例)。避免重试风暴对齐。
|
||||
const JITTER_RATIO: f64 = 0.2;
|
||||
|
||||
/// 一次尝试的分类结果 —— 在 anyhow 不透明化前决定是否值得重试。
|
||||
#[derive(Debug)]
|
||||
pub(super) enum AttemptOutcome<T> {
|
||||
/// 成功,携带结果。
|
||||
Ok(T),
|
||||
/// 可重试错误(timeout / connect / 5xx / 429)。携带人类可读错误描述。
|
||||
Retryable(String),
|
||||
/// 不可重试错误(4xx 非 429 / 鉴权 / 参数错 / 解析错)。携带描述,直接放弃。
|
||||
Fatal(String),
|
||||
}
|
||||
|
||||
/// 判定 reqwest::Error 是否可重试(仅 timeout / connect 类)。
|
||||
///
|
||||
/// body 解析错(reqwest::Error::is_decode)与请求构造错(is_builder)属 Fatal,不重试。
|
||||
pub(super) fn is_reqwest_error_retryable(e: &reqwest::Error) -> bool {
|
||||
e.is_timeout() || e.is_connect() || (e.is_request() && !e.is_builder())
|
||||
}
|
||||
|
||||
/// 判定 HTTP 状态码是否可重试: 5xx 与 429 重试,其余(含 4xx)不重试。
|
||||
pub(super) fn is_status_retryable(status: u16) -> bool {
|
||||
status == 429 || (500..600).contains(&status)
|
||||
}
|
||||
|
||||
/// 指数退避 + jitter: 返回第 `attempt`(1-based)次重试前应 sleep 的时长。
|
||||
/// attempt=1 → ~1s, attempt=2 → ~2s, attempt=3 → ~4s,各 ±20% jitter。
|
||||
///
|
||||
/// jitter 用 SystemTime 纳秒取模生成(无依赖),避免多客户端同步重试风暴。
|
||||
/// 以毫秒粒度计算后向下取整(避免秒级截断把 0.9s 砍成 0)。
|
||||
fn backoff_delay(attempt: u32) -> Duration {
|
||||
let base_ms = BASE_BACKOFF_SECS.saturating_mul(1u64 << (attempt - 1)) * 1000;
|
||||
// 纳秒 → [0, 2000) 区间,再映射到 [-1.0, +1.0) 比例
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.map(|d| d.subsec_nanos() as u64)
|
||||
.unwrap_or(0);
|
||||
let jitter_ratio = (nanos % 2000) as f64 / 1000.0 - 1.0; // [-1.0, 1.0)
|
||||
let factor = 1.0 + jitter_ratio * JITTER_RATIO; // [0.8, 1.2]
|
||||
let ms = (base_ms as f64 * factor).max(0.0) as u64;
|
||||
Duration::from_millis(ms)
|
||||
}
|
||||
|
||||
/// 对一个返回 `AttemptOutcome` 的异步操作做指数退避重试。
|
||||
///
|
||||
/// `attempt_fn` 每次被调用执行一次尝试并返回分类结果。
|
||||
/// 达到 `MAX_COMPLETE_ATTEMPTS` 或超出 `MAX_TOTAL_BUDGET` 或遇 Fatal 即终止。
|
||||
/// 终止时若仍失败,返回最后一次的 Retryable/Fatal 描述作为 `anyhow::Error`。
|
||||
///
|
||||
/// 调用方约定: `attempt_fn` 内部应保留完整请求重发能力(每次重建 RequestBuilder),
|
||||
/// 因为 reqwest::RequestBuilder 一次 `.send()` 后不可复用。
|
||||
pub(super) async fn retry_with_backoff<T, F, Fut>(
|
||||
label: &str,
|
||||
attempt_fn: F,
|
||||
) -> anyhow::Result<T>
|
||||
where
|
||||
F: Fn(u32) -> Fut,
|
||||
Fut: Future<Output = AttemptOutcome<T>>,
|
||||
{
|
||||
let deadline = tokio::time::Instant::now() + MAX_TOTAL_BUDGET;
|
||||
let mut last_err = String::from("未尝试");
|
||||
|
||||
for attempt in 1..=MAX_COMPLETE_ATTEMPTS {
|
||||
match attempt_fn(attempt).await {
|
||||
AttemptOutcome::Ok(v) => return Ok(v),
|
||||
AttemptOutcome::Fatal(msg) => {
|
||||
// 不可重试: 立即放弃,不浪费预算
|
||||
return Err(anyhow::anyhow!("{}", msg));
|
||||
}
|
||||
AttemptOutcome::Retryable(msg) => {
|
||||
last_err = msg;
|
||||
let is_last = attempt == MAX_COMPLETE_ATTEMPTS;
|
||||
if is_last {
|
||||
warn!(
|
||||
label = label,
|
||||
attempt = attempt,
|
||||
"重试已达上限({}次),放弃", MAX_COMPLETE_ATTEMPTS
|
||||
);
|
||||
break;
|
||||
}
|
||||
let now = tokio::time::Instant::now();
|
||||
if now >= deadline {
|
||||
warn!(
|
||||
label = label,
|
||||
attempt = attempt,
|
||||
"重试预算({:?})已耗尽,放弃", MAX_TOTAL_BUDGET
|
||||
);
|
||||
break;
|
||||
}
|
||||
let delay = backoff_delay(attempt).min(deadline.saturating_duration_since(now));
|
||||
warn!(
|
||||
label = label,
|
||||
attempt = attempt,
|
||||
delay_ms = delay.as_millis() as u64,
|
||||
err = %last_err,
|
||||
"可重试错误,退避后重试"
|
||||
);
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow::anyhow!("{} 重试耗尽: {}", label, last_err))
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单测(纯逻辑,不发 HTTP)
|
||||
// ============================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn backoff_is_monotonic_within_jitter() {
|
||||
// base 序列: 1, 2, 4 秒; jitter ±20% 后仍在 [0.8,4.8] 范围
|
||||
for attempt in 1..=3 {
|
||||
let d = backoff_delay(attempt);
|
||||
let base_ms = (BASE_BACKOFF_SECS * (1u64 << (attempt - 1))) * 1000;
|
||||
let lo = (base_ms as f64 * (1.0 - JITTER_RATIO)) as u64;
|
||||
let hi = (base_ms as f64 * (1.0 + JITTER_RATIO)) as u64;
|
||||
assert!(
|
||||
d.as_millis() as u64 >= lo && d.as_millis() as u64 <= hi,
|
||||
"attempt={} delay={:?} 应在 [{},{}]ms",
|
||||
attempt,
|
||||
d,
|
||||
lo,
|
||||
hi
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_classification() {
|
||||
assert!(is_status_retryable(429));
|
||||
assert!(is_status_retryable(500));
|
||||
assert!(is_status_retryable(503));
|
||||
assert!(!is_status_retryable(400));
|
||||
assert!(!is_status_retryable(401));
|
||||
assert!(!is_status_retryable(403));
|
||||
assert!(!is_status_retryable(404));
|
||||
}
|
||||
|
||||
/// 初次成功 → 不重试
|
||||
#[tokio::test]
|
||||
async fn first_attempt_ok() {
|
||||
let calls = Arc::new(AtomicU32::new(0));
|
||||
let calls_clone = calls.clone();
|
||||
let r = retry_with_backoff("test", move |_| {
|
||||
let c = calls_clone.clone();
|
||||
async move {
|
||||
c.fetch_add(1, Ordering::SeqCst);
|
||||
AttemptOutcome::Ok(42u32)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(r, 42);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
/// 第二次成功 → 恰好 2 次调用
|
||||
#[tokio::test]
|
||||
async fn retry_once_then_ok() {
|
||||
let calls = Arc::new(AtomicU32::new(0));
|
||||
let calls_clone = calls.clone();
|
||||
let r = retry_with_backoff("test", move |_| {
|
||||
let c = calls_clone.clone();
|
||||
async move {
|
||||
let n = c.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
if n == 1 {
|
||||
AttemptOutcome::Retryable("timeout".into())
|
||||
} else {
|
||||
AttemptOutcome::Ok(7u32)
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(r, 7);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
/// 全部 Retryable → 用尽 3 次,返回错误
|
||||
#[tokio::test]
|
||||
async fn all_retryable_exhausts() {
|
||||
let calls = Arc::new(AtomicU32::new(0));
|
||||
let calls_clone = calls.clone();
|
||||
let r = retry_with_backoff("test", move |_| {
|
||||
let c = calls_clone.clone();
|
||||
async move {
|
||||
c.fetch_add(1, Ordering::SeqCst);
|
||||
AttemptOutcome::<u32>::Retryable("503".into())
|
||||
}
|
||||
})
|
||||
.await;
|
||||
assert!(r.is_err());
|
||||
assert!(r.unwrap_err().to_string().contains("重试耗尽"));
|
||||
assert_eq!(calls.load(Ordering::SeqCst), MAX_COMPLETE_ATTEMPTS);
|
||||
}
|
||||
|
||||
/// Fatal 立即放弃,不重试
|
||||
#[tokio::test]
|
||||
async fn fatal_aborts_immediately() {
|
||||
let calls = Arc::new(AtomicU32::new(0));
|
||||
let calls_clone = calls.clone();
|
||||
let r = retry_with_backoff("test", move |_| {
|
||||
let c = calls_clone.clone();
|
||||
async move {
|
||||
c.fetch_add(1, Ordering::SeqCst);
|
||||
AttemptOutcome::<u32>::Fatal("401".into())
|
||||
}
|
||||
})
|
||||
.await;
|
||||
assert!(r.is_err());
|
||||
assert!(r.unwrap_err().to_string().contains("401"));
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1, "Fatal 不应触发重试");
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,24 @@ pub struct ShellResult {
|
||||
pub duration_ms: u64,
|
||||
}
|
||||
|
||||
/// Shell 类型选择
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ShellType {
|
||||
/// Windows cmd.exe (默认 Windows)
|
||||
Cmd,
|
||||
/// PowerShell
|
||||
PowerShell,
|
||||
/// Unix sh (默认非 Windows)
|
||||
Sh,
|
||||
}
|
||||
|
||||
impl Default for ShellType {
|
||||
fn default() -> Self {
|
||||
if cfg!(windows) { ShellType::Cmd } else { ShellType::Sh }
|
||||
}
|
||||
}
|
||||
|
||||
/// Shell 命令执行请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ShellRequest {
|
||||
@@ -27,6 +45,9 @@ pub struct ShellRequest {
|
||||
pub env: std::collections::HashMap<String, String>,
|
||||
/// 超时时间(秒),None 表示不超时
|
||||
pub timeout_secs: Option<u64>,
|
||||
/// Shell 类型(默认:Windows→Cmd, 非Windows→Sh)
|
||||
#[serde(default)]
|
||||
pub shell_type: Option<ShellType>,
|
||||
}
|
||||
|
||||
/// 执行 Shell 命令
|
||||
@@ -35,16 +56,26 @@ pub struct ShellRequest {
|
||||
pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let mut cmd = if cfg!(windows) {
|
||||
let shell_type = request.shell_type.unwrap_or_default();
|
||||
let mut cmd = match shell_type {
|
||||
ShellType::PowerShell => {
|
||||
let mut c = tokio::process::Command::new("powershell");
|
||||
c.arg("-NoProfile").arg("-Command").arg(&request.command);
|
||||
c.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
c
|
||||
}
|
||||
ShellType::Cmd => {
|
||||
let mut c = tokio::process::Command::new("cmd");
|
||||
c.arg("/C").arg(&request.command);
|
||||
c.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
c
|
||||
} else {
|
||||
}
|
||||
ShellType::Sh => {
|
||||
let mut c = tokio::process::Command::new("sh");
|
||||
c.arg("-c").arg(&request.command);
|
||||
c.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
c
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(dir) = &request.working_dir {
|
||||
|
||||
@@ -69,7 +69,35 @@ async fn build_approval_reason(
|
||||
db: &Arc<Database>,
|
||||
) -> String {
|
||||
let s = |key: &str| args.get(key).and_then(|v| v.as_str()).unwrap_or("");
|
||||
let detail = match tool_name {
|
||||
// 优先级:tool_display_hint(轻量标签) → display_hint_for_tool(模板填充) → 硬编码 fallback
|
||||
let detail = if let Some(hint) = super::tool_registry::tool_display_hint(tool_name) {
|
||||
// 轻量命中:直接用中文动作前缀 + 风险后缀(不含参数细节)
|
||||
hint.to_string()
|
||||
} else if let Some((template, keys)) = super::tool_registry::display_hint_for_tool(tool_name) {
|
||||
// 按 keys 列表取参数值;含 "id"/"project_id" 的值走 resolve_project_label 解析
|
||||
let mut values = Vec::with_capacity(keys.len());
|
||||
for &key in keys {
|
||||
let val = s(key);
|
||||
if val.is_empty() { values.clear(); break; }
|
||||
match key {
|
||||
"id" | "project_id" => values.push(resolve_project_label(db, &val).await),
|
||||
_ => values.push(val.to_string()),
|
||||
}
|
||||
}
|
||||
// 任一关键参数为空则整条 fallback 为空串(与原行为一致)
|
||||
if values.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
// 模板 {} 占位符按位置填充
|
||||
let mut result = template.to_string();
|
||||
for v in &values {
|
||||
result = result.replacen("{}", v, 1);
|
||||
}
|
||||
result
|
||||
}
|
||||
} else {
|
||||
// fallback:原有完整硬编码逻辑(保持行为不变)
|
||||
match tool_name {
|
||||
"delete_project" => {
|
||||
let id = s("id");
|
||||
if !id.is_empty() { format!("删除项目{}", resolve_project_label(db, id).await) } else { String::new() }
|
||||
@@ -117,11 +145,8 @@ async fn build_approval_reason(
|
||||
let title = s("title");
|
||||
if !title.is_empty() { format!("捕获灵感:{}", title) } else { String::new() }
|
||||
}
|
||||
"run_workflow" => {
|
||||
let name = s("name");
|
||||
if !name.is_empty() { format!("运行工作流:{}", name) } else { String::new() }
|
||||
}
|
||||
_ => String::new(),
|
||||
}
|
||||
};
|
||||
if detail.is_empty() {
|
||||
// fallback:无可读字段,保留风险等级提示模板
|
||||
|
||||
@@ -253,6 +253,42 @@ pub async fn ai_chat_clear(state: State<'_, AppState>) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 强制发送消息(B-260616-02: L2 发送韧性)
|
||||
///
|
||||
/// 当后端 generating=true 残留(HMR/异常退出等)导致 sendMessage 被拦截时,
|
||||
/// 前端可调此命令强制复位 generating + 清审批,然后走 ai_chat_send 同款流程发消息。
|
||||
/// 等价于"先软停止 → 再发送"的原子操作,避免竞态窗口。
|
||||
#[tauri::command]
|
||||
pub async fn ai_chat_force_send(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
message: String,
|
||||
language: Option<String>,
|
||||
skill: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
// 原子复位:清 generating + 清积压审批 + 置 stop_flag,与 ai_chat_stop 审批分支一致
|
||||
let old_conv_id = {
|
||||
let mut session = state.ai_session.lock().await;
|
||||
let old = session.active_conversation_id.clone();
|
||||
session.generating = false;
|
||||
session.pending_approvals.clear();
|
||||
session.stop_flag.store(true, Ordering::SeqCst);
|
||||
old
|
||||
};
|
||||
// 通知前端旧生成已结束(若有残留 conv)
|
||||
if let Some(ref cid) = old_conv_id {
|
||||
let _ = app.emit("ai-chat-event", AiChatEvent::AiCompleted {
|
||||
total_tokens: 0,
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
conversation_id: Some(cid.clone()),
|
||||
});
|
||||
}
|
||||
// 复位完成后走 ai_chat_send 同款流程(内部会重新设 generating=true 并 spawn loop)
|
||||
// 直接内联而非递归调 ai_chat_send,避免 IPC 嵌套
|
||||
ai_chat_send(app, state, message, language, skill).await
|
||||
}
|
||||
|
||||
/// 停止当前 AI 生成
|
||||
///
|
||||
/// 两种场景:
|
||||
|
||||
@@ -61,7 +61,9 @@ fn system_prompt_parts(lang: &str) -> (&'static str, &'static str) {
|
||||
You can perform the following actions via tool calls:\n\
|
||||
- Create/query projects, tasks, and ideas\n\
|
||||
- Run workflows\n\
|
||||
- Read file contents, list directories, create/write files\n\n\
|
||||
- Read file contents, list directories, create/write files\n\
|
||||
- Get file metadata (size, line count, binary detection)\n\
|
||||
- Append content to files, search files by name pattern\n\n\
|
||||
## Guidelines\n\
|
||||
- Briefly explain your intent before executing actions\n\
|
||||
- Ask for clarification if the user's intent is unclear\n\
|
||||
@@ -76,7 +78,9 @@ fn system_prompt_parts(lang: &str) -> (&'static str, &'static str) {
|
||||
你可以通过工具调用执行以下操作:\n\
|
||||
- 创建/查询项目、任务、灵感\n\
|
||||
- 运行工作流\n\
|
||||
- 读取文件内容、列出目录、创建/写入文件\n\n\
|
||||
- 读取文件内容、列出目录、创建/写入文件\n\
|
||||
- 获取文件元信息(大小、行数、二进制检测)\n\
|
||||
- 追加写入文件、按名称模式搜索文件\n\n\
|
||||
## 行为准则\n\
|
||||
- 执行操作前简要说明你的意图\n\
|
||||
- 如果不确定用户意图,先提问\n\
|
||||
|
||||
@@ -10,6 +10,11 @@ export const aiApi = {
|
||||
return invoke('ai_chat_send', { message, language: language || 'zh-CN', skill: skill || null })
|
||||
},
|
||||
|
||||
/** 强制发送(B-260616-02: 复位 generating 残留后走 send 同款流程) */
|
||||
forceSend(message: string, language?: string, skill?: string): Promise<string> {
|
||||
return invoke('ai_chat_force_send', { message, language: language || 'zh-CN', skill: skill || null })
|
||||
},
|
||||
|
||||
/** 批准/拒绝工具调用 */
|
||||
approve(toolCallId: string, approved: boolean): Promise<string> {
|
||||
return invoke('ai_approve', { toolCallId, approved })
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -37,12 +37,18 @@
|
||||
</div>
|
||||
<div v-if="tc.reason" class="ai-tool-approval-reason">⚠ {{ tc.reason }}</div>
|
||||
<div class="ai-tool-actions">
|
||||
<button class="ai-tool-btn ai-tool-btn--approve" @click="emit('approve', { id: tc.id, approved: true })">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
<button class="ai-tool-btn ai-tool-btn--approve"
|
||||
:disabled="approving"
|
||||
@click="onApprove(true)">
|
||||
<span v-if="approving" class="ai-tool-btn-spinner" />
|
||||
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
{{ $t('aiTool.approve') }}
|
||||
</button>
|
||||
<button class="ai-tool-btn ai-tool-btn--reject" @click="emit('approve', { id: tc.id, approved: false })">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
<button class="ai-tool-btn ai-tool-btn--reject"
|
||||
:disabled="approving"
|
||||
@click="onApprove(false)">
|
||||
<span v-if="approving" class="ai-tool-btn-spinner" />
|
||||
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
{{ $t('aiTool.reject') }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -101,11 +107,23 @@
|
||||
<code>{{ formatToolResult(tc) }}</code>
|
||||
</div>
|
||||
</div><!-- /.ai-tool-body -->
|
||||
|
||||
<!-- AE-2025-05:High 风险审批二次确认(本卡自治 useConfirm,不与 AiChat 父级 confirmState 串台) -->
|
||||
<ConfirmDialog
|
||||
:visible="confirmState.visible"
|
||||
:msg="confirmState.msg"
|
||||
:danger-label="t('common.confirm')"
|
||||
@result="answerConfirm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import type { AiToolCallInfo } from '@/api/types'
|
||||
import i18n from '@/i18n'
|
||||
|
||||
// composable 外全局 i18n 实例(非 setup 上下文,供纯函数 argLabel/formatToolResult 使用)
|
||||
const t = ((i18n as any).global.t as (k: string, p?: Record<string, unknown>) => string).bind((i18n as any).global)
|
||||
|
||||
/** 工具结果 JSON 已知字段(全可选;list_* 运行时返回数组,靠 Array.isArray 区分) */
|
||||
interface ToolResult {
|
||||
@@ -148,19 +166,22 @@ function formatJson(data: unknown): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** 审批参数 key → 友好标签映射(静态,不查 store);未命中回退原 key */
|
||||
const ARG_LABEL_MAP: Record<string, string> = {
|
||||
id: 'ID',
|
||||
project_id: '项目 ID',
|
||||
name: '名称',
|
||||
title: '标题',
|
||||
description: '描述',
|
||||
field: '字段',
|
||||
value: '新值',
|
||||
path: '路径',
|
||||
priority: '优先级',
|
||||
tags: '标签',
|
||||
source: '来源',
|
||||
/** 审批参数 key → 友好标签映射(动态 i18n);未命中回退原 key */
|
||||
function argLabel(key: string): string {
|
||||
const map: Record<string, string> = {
|
||||
id: t('aiTool.argLabel.id'),
|
||||
project_id: t('aiTool.argLabel.projectId'),
|
||||
name: t('aiTool.argLabel.name'),
|
||||
title: t('aiTool.argLabel.title'),
|
||||
description: t('aiTool.argLabel.description'),
|
||||
field: t('aiTool.argLabel.field'),
|
||||
value: t('aiTool.argLabel.value'),
|
||||
path: t('aiTool.argLabel.path'),
|
||||
priority: t('aiTool.argLabel.priority'),
|
||||
tags: t('aiTool.argLabel.tags'),
|
||||
source: t('aiTool.argLabel.source'),
|
||||
}
|
||||
return map[key] || key
|
||||
}
|
||||
|
||||
/** 审批参数转键值对数组(非对象/空时返回空数组,防 args 非 object 时 v-for 报错) */
|
||||
@@ -168,7 +189,7 @@ function toolArgsEntries(args: unknown): Array<{ key: string; label: string; val
|
||||
if (args && typeof args === 'object' && !Array.isArray(args)) {
|
||||
return Object.entries(args as Record<string, unknown>).map(([key, val]) => ({
|
||||
key,
|
||||
label: ARG_LABEL_MAP[key] || key,
|
||||
label: argLabel(key),
|
||||
val,
|
||||
}))
|
||||
}
|
||||
@@ -254,7 +275,7 @@ function formatToolResult(tc: AiToolCallInfo): string {
|
||||
: typeof a.name === 'string' ? a.name
|
||||
: typeof a.path === 'string' ? a.path
|
||||
: ''
|
||||
if (detail) return `${raw}(${detail})`
|
||||
if (detail) return t('aiTool.resultDetail', { raw, detail })
|
||||
}
|
||||
return raw
|
||||
}
|
||||
@@ -266,13 +287,19 @@ function formatToolResult(tc: AiToolCallInfo): string {
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, watch, onBeforeUnmount } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useProjectStore } from '@/stores/project'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import ConfirmDialog from './ConfirmDialog.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const projectStore = useProjectStore()
|
||||
|
||||
// AE-2025-05:本卡自治二次确认状态机。每张 ToolCard 各持一份 confirmState,与 AiChat
|
||||
// 父级 useConfirm 实例互不影响(并发弹层天然隔离,无需共享)。
|
||||
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
|
||||
|
||||
const props = defineProps<{
|
||||
/** 单条工具调用数据 */
|
||||
tc: AiToolCallInfo
|
||||
@@ -291,6 +318,67 @@ const emit = defineEmits<{
|
||||
approve: [{ id: string; approved: boolean }]
|
||||
}>()
|
||||
|
||||
/**
|
||||
* 审批按钮 loading 态(B-260616-08)。
|
||||
* 点击后置 true,禁用两按钮防重复点击并显示 spinner。后端回事件 useAiEvents 把 tc.status
|
||||
* 离开 pending_approval(转 completed/rejected)→ watch 自动复位 false,卡片同步切态。
|
||||
*
|
||||
* 兜底:approve 后状态保持 pending_approval,若后端既不回事件也不转 running(仅在
|
||||
* AiApprovalResult/approved 后 hang 在工具执行前),全局看门狗只回滚 running 不会触及本卡。
|
||||
* 故此处加本地兜底计时(对齐全局看门狗 STREAM_TIMEOUT_MS),到点复位 loading 让用户能重试/改判。
|
||||
*/
|
||||
const APPROVE_LOADING_TIMEOUT_MS = 130000 // 对齐 useAiStream.STREAM_TIMEOUT_MS
|
||||
const approving = ref(false)
|
||||
let approvingTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
/**
|
||||
* AE-2025-05:High 风险工具白名单(后端 tool_registry.rs RiskLevel::High 对齐)。
|
||||
* AiToolCallInfo 无 risk 字段(风险仅存后端),故前端按工具名判定。新增 High 工具时同步加这里。
|
||||
* - delete 类(delete_project/purge_project/delete_task/delete_file/restore_project)→ 删除/不可恢复文案
|
||||
* - run_command → 执行命令文案
|
||||
* - 其余 High(未来扩展) → 通用高危文案
|
||||
*/
|
||||
const HIGH_RISK_TOOLS = new Set<string>([
|
||||
'delete_task', 'delete_project', 'restore_project', 'purge_project', 'delete_file', 'run_command',
|
||||
])
|
||||
|
||||
/** High 风险工具的二次确认文案(按操作类型) */
|
||||
function highRiskConfirmMsg(name: string): string {
|
||||
if (name === 'run_command') return t('aiTool.confirmHighExec')
|
||||
if (name === 'delete_task' || name === 'delete_project' || name === 'purge_project' || name === 'delete_file' || name === 'restore_project') {
|
||||
return t('aiTool.confirmHighDelete')
|
||||
}
|
||||
return t('aiTool.confirmHighGeneric')
|
||||
}
|
||||
|
||||
async function onApprove(approved: boolean) {
|
||||
if (approving.value) return // 防重入
|
||||
// AE-2025-05:High 风险 + 批准 → 置 approving 前先二次确认,误点可取消。
|
||||
// 拒绝(approved=false)不再二次确认(拒绝本就无害,加确认反增摩擦)。
|
||||
if (approved && HIGH_RISK_TOOLS.has(props.tc.name)) {
|
||||
const ok = await confirmDialog(highRiskConfirmMsg(props.tc.name))
|
||||
if (!ok) return
|
||||
}
|
||||
approving.value = true
|
||||
// 本地兜底:到点强制复位 loading(后端无回执、卡片仍 pending_approval 时给用户重试入口)
|
||||
approvingTimer = setTimeout(() => {
|
||||
approving.value = false
|
||||
approvingTimer = null
|
||||
}, APPROVE_LOADING_TIMEOUT_MS)
|
||||
emit('approve', { id: props.tc.id, approved })
|
||||
}
|
||||
// status 离开 pending_approval → 后端已回事件 → 复位 loading 并清计时器
|
||||
watch(() => props.tc.status, (s) => {
|
||||
if (s !== 'pending_approval') {
|
||||
approving.value = false
|
||||
if (approvingTimer) { clearTimeout(approvingTimer); approvingTimer = null }
|
||||
}
|
||||
})
|
||||
// 组件卸载清计时器防泄漏
|
||||
onBeforeUnmount(() => {
|
||||
if (approvingTimer) clearTimeout(approvingTimer)
|
||||
})
|
||||
|
||||
/** 一次解析结果供模板多次读(模板引用 6 次,computed 避免每次 patch 重 parse) */
|
||||
const parsed = computed(() => parseResult(props.tc.result))
|
||||
|
||||
@@ -602,6 +690,35 @@ function toolResultSummary(tc: AiToolCallInfo): string {
|
||||
color: #fff;
|
||||
border-color: var(--df-danger);
|
||||
}
|
||||
/* 审批 loading(B-260616-08):IPC 送达待后端回执期间禁用两按钮防重复点击,spinner 转在原位 */
|
||||
.ai-tool-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.ai-tool-btn--approve:disabled:hover,
|
||||
.ai-tool-btn--reject:disabled:hover {
|
||||
/* 覆盖 hover 高亮,disabled 态不闪色 */
|
||||
background: var(--df-success-bg);
|
||||
color: var(--df-success);
|
||||
border-color: var(--df-border);
|
||||
}
|
||||
.ai-tool-btn--reject:disabled:hover {
|
||||
background: var(--df-danger-bg);
|
||||
color: var(--df-danger);
|
||||
}
|
||||
.ai-tool-btn-spinner {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 1.5px solid currentColor;
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
animation: aiToolBtnSpin 0.7s linear infinite;
|
||||
}
|
||||
@keyframes aiToolBtnSpin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* -- File Content (read_file) -- */
|
||||
.ai-tool-file-content {
|
||||
|
||||
@@ -1,24 +1,54 @@
|
||||
<template>
|
||||
<div class="ai-tool-calls">
|
||||
<div class="ai-tool-calls" ref="rootEl">
|
||||
<!-- 批量操作栏(存在待审批项时显示) -->
|
||||
<div v-if="pendingCount > 0" class="ai-batch-approve">
|
||||
<button class="ai-btn ai-btn--sm" @click="$emit('batch-approve', 'approve')">
|
||||
{{ $t('aiTool.approveAll', { n: pendingCount }) }}
|
||||
</button>
|
||||
<button class="ai-btn ai-btn--sm ai-btn--danger" @click="$emit('batch-approve', 'reject')">
|
||||
{{ $t('aiTool.rejectAll') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 全部展开 / 全部收起(仅当存在可折叠分组时显示) -->
|
||||
<div v-if="collapsibleGroupCount > 0" class="ai-tool-global-toggle" @click="toggleAllGroups">
|
||||
<span class="ai-tool-global-toggle-icon">{{ allGroupsExpanded ? '▾' : '▸' }}</span>
|
||||
<span>{{ allGroupsExpanded ? $t('aiTool.collapseAll') : $t('aiTool.expandAll') }}</span>
|
||||
</div>
|
||||
|
||||
<template v-for="(group, gi) in groupedToolCalls" :key="group.name">
|
||||
<!-- 分组标题:同名工具 >=2 个时显示 -->
|
||||
<div v-if="group.calls.length >= 2" class="ai-tool-group-header" @click="toggleGroupCollapse(gi)">
|
||||
<span class="ai-tool-group-icon" v-html="groupIcon(group.name)"></span>
|
||||
<span class="ai-tool-group-name">{{ groupDisplayName(group) }}</span>
|
||||
<span class="ai-tool-group-count">{{ group.calls.length }}</span>
|
||||
<span class="ai-tool-group-chevron" :class="{ 'is-open': !isGroupCollapsed(gi) }">▸</span>
|
||||
</div>
|
||||
<ToolCard
|
||||
v-for="tc in toolCalls"
|
||||
v-for="(tc, ci) in group.calls"
|
||||
:key="tc.id"
|
||||
:tc="tc"
|
||||
:isExpanded="expandedCards.has(tc.id)"
|
||||
:isExpanded="isCardExpanded(tc, gi, ci)"
|
||||
:isContentExpanded="expandedTools.has(tc.id)"
|
||||
@toggle="toggleCardExpand"
|
||||
@expand-content="toggleExpand"
|
||||
@approve="(e) => emit('approve', e)"
|
||||
:class="{ 'ai-tool-card--group-hidden': group.calls.length >= 2 && isGroupCollapsed(gi) }"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import ToolCard from './ToolCard.vue'
|
||||
import type { AiToolCallInfo } from '@/api/types'
|
||||
|
||||
defineProps<{
|
||||
// useI18n needed for $t() in template (collapseAll / expandAll)
|
||||
useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
/** 当前消息的工具调用列表 */
|
||||
toolCalls: AiToolCallInfo[]
|
||||
}>()
|
||||
@@ -26,12 +56,126 @@ defineProps<{
|
||||
const emit = defineEmits<{
|
||||
/** 审批按钮 → 父组件转发 store.approveToolCall */
|
||||
approve: [{ id: string; approved: boolean }]
|
||||
/** 批量审批 → 父组件调用 approveAll/rejectAll */
|
||||
'batch-approve': [decision: 'approve' | 'reject']
|
||||
}>()
|
||||
|
||||
// 根元素 ref(scrollToFirstPending 查 DOM 用)
|
||||
const rootEl = ref<HTMLElement>()
|
||||
// 卡片级折叠(整卡 body 显隐,区别于 read_file 内容级)
|
||||
const expandedCards = ref(new Set<string>())
|
||||
// 内容级展开(read_file "展开全部")
|
||||
const expandedTools = ref(new Set<string>())
|
||||
// 分组折叠状态(按分组索引);多卡片分组默认收起
|
||||
const collapsedGroups = ref<Set<number>>(new Set())
|
||||
|
||||
interface ToolCallGroup {
|
||||
name: string
|
||||
calls: AiToolCallInfo[]
|
||||
}
|
||||
|
||||
/** 按 tool.name 全局分组:同名工具归为一组(不要求相邻),保持原始顺序 */
|
||||
const groupedToolCalls = computed<ToolCallGroup[]>(() => {
|
||||
const map = new Map<string, AiToolCallInfo[]>()
|
||||
// 第一遍:按 name 收集
|
||||
for (const tc of props.toolCalls) {
|
||||
const arr = map.get(tc.name)
|
||||
if (arr) arr.push(tc)
|
||||
else map.set(tc.name, [tc])
|
||||
}
|
||||
// 第二遍:按 toolCalls 首次出现顺序输出(保证显示顺序与调用顺序一致)
|
||||
const seen = new Set<string>()
|
||||
const groups: ToolCallGroup[] = []
|
||||
for (const tc of props.toolCalls) {
|
||||
if (!seen.has(tc.name)) {
|
||||
seen.add(tc.name)
|
||||
groups.push({ name: tc.name, calls: map.get(tc.name)! })
|
||||
}
|
||||
}
|
||||
return groups
|
||||
})
|
||||
|
||||
/** 可折叠分组数(calls >= 2 的组),控制全局按钮显隐 */
|
||||
const collapsibleGroupCount = computed(() =>
|
||||
groupedToolCalls.value.filter(g => g.calls.length >= 2).length
|
||||
)
|
||||
|
||||
/** 待审批工具数(status === 'pending_approval'),控制批量操作栏显隐 */
|
||||
const pendingCount = computed(() =>
|
||||
props.toolCalls.filter(tc => tc.status === 'pending_approval').length
|
||||
)
|
||||
|
||||
/** 是否所有可折叠分组都已展开 */
|
||||
const allGroupsExpanded = computed(() => {
|
||||
if (collapsibleGroupCount.value === 0) return true
|
||||
return groupedToolCalls.value.every((g, gi) =>
|
||||
g.calls.length < 2 || !collapsedGroups.value.has(gi)
|
||||
)
|
||||
})
|
||||
|
||||
/** 初始化默认折叠:多卡片分组(>=2)默认收起,首条不参与 group-hidden 但跟随折叠态 */
|
||||
function initDefaultCollapse(): void {
|
||||
const set = new Set<number>()
|
||||
for (let i = 0; i < groupedToolCalls.value.length; i++) {
|
||||
if (groupedToolCalls.value[i].calls.length >= 2) {
|
||||
set.add(i)
|
||||
}
|
||||
}
|
||||
collapsedGroups.value = set
|
||||
}
|
||||
|
||||
/** 首次有数据时执行默认折叠初始化(watch 防重复,修复异步加载竞态 BUG-B) */
|
||||
let initialized = false
|
||||
watch(
|
||||
() => props.toolCalls.length,
|
||||
(len) => {
|
||||
if (len > 0 && !initialized) {
|
||||
initialized = true
|
||||
initDefaultCollapse()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function isGroupCollapsed(gi: number): boolean {
|
||||
return collapsedGroups.value.has(gi)
|
||||
}
|
||||
|
||||
function toggleGroupCollapse(gi: number): void {
|
||||
const next = new Set(collapsedGroups.value)
|
||||
if (next.has(gi)) next.delete(gi)
|
||||
else next.add(gi)
|
||||
collapsedGroups.value = next
|
||||
}
|
||||
|
||||
/** 全部展开 / 全部收起切换 */
|
||||
function toggleAllGroups(): void {
|
||||
const next = new Set<number>()
|
||||
if (allGroupsExpanded.value) {
|
||||
// 当前全展开 → 全部收起
|
||||
for (let i = 0; i < groupedToolCalls.value.length; i++) {
|
||||
if (groupedToolCalls.value[i].calls.length >= 2) next.add(i)
|
||||
}
|
||||
}
|
||||
// 当前非全展开 → 全部展开(next 保持空 Set)
|
||||
collapsedGroups.value = next
|
||||
}
|
||||
|
||||
/**
|
||||
* 单卡是否展开:
|
||||
* - 分组未折叠 → 用户手动 expandedCards 或首卡默认展开
|
||||
* - 分组已折叠 → false(整组隐藏)
|
||||
* - 多卡片分组 + 未手动操作过 → 仅首卡(ci===0)展开
|
||||
*/
|
||||
function isCardExpanded(tc: AiToolCallInfo, gi: number, ci: number): boolean {
|
||||
if (isGroupCollapsed(gi)) return false
|
||||
// 用户手动切换过该卡
|
||||
if (expandedCards.value.has(tc.id)) return true
|
||||
// 多卡片分组:仅首卡默认展开
|
||||
if (groupedToolCalls.value[gi]?.calls.length >= 2) return ci === 0
|
||||
// 单卡组:默认展开
|
||||
return true
|
||||
}
|
||||
|
||||
function toggleCardExpand(id: string) {
|
||||
if (expandedCards.value.has(id)) expandedCards.value.delete(id)
|
||||
@@ -54,7 +198,61 @@ function collapseInactive(activeIds: Set<string>): void {
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ collapseInactive })
|
||||
/**
|
||||
* 滚动到第一个 pending_approval 卡片。
|
||||
* 若该卡处于折叠分组(display:none → offsetParent 为 null),先展开所有折叠组,
|
||||
* 下一帧重查并滚动。供 AiChat 头部审批徽标点击调用。
|
||||
*/
|
||||
function scrollToFirstPending(): void {
|
||||
const root = rootEl.value
|
||||
if (!root) return
|
||||
const card = root.querySelector<HTMLElement>('.ai-tool-card--pending_approval')
|
||||
if (!card) return
|
||||
if (card.offsetParent === null) {
|
||||
// 处于折叠分组内 → 展开后下一帧重查
|
||||
collapsedGroups.value = new Set()
|
||||
nextTick(() => {
|
||||
root.querySelector<HTMLElement>('.ai-tool-card--pending_approval')
|
||||
?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
})
|
||||
return
|
||||
}
|
||||
card.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
|
||||
defineExpose({ collapseInactive, scrollToFirstPending })
|
||||
|
||||
/* -- 分组图标(复用 ToolCard 的 toolCategory 逻辑) -- */
|
||||
function groupIcon(name: string): string {
|
||||
if (name === 'read_file') return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>'
|
||||
if (name === 'write_file') return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>'
|
||||
if (name === 'list_directory') return '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>'
|
||||
if (name.includes('create')) return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>'
|
||||
if (name.includes('delete')) return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>'
|
||||
return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>'
|
||||
}
|
||||
|
||||
/** 分组显示名:工具名 + 数量提示 */
|
||||
function groupDisplayName(group: ToolCallGroup): string {
|
||||
const nameMap: Record<string, string> = {
|
||||
read_file: '读取文件',
|
||||
write_file: '写入文件',
|
||||
list_directory: '列出目录',
|
||||
create_task: '创建任务',
|
||||
create_project: '创建项目',
|
||||
create_idea: '创建灵感',
|
||||
list_tasks: '列出任务',
|
||||
list_projects: '列出项目',
|
||||
list_ideas: '列出灵感',
|
||||
delete_project: '删除项目',
|
||||
restore_project: '恢复项目',
|
||||
purge_project: '彻底删除项目',
|
||||
update_project: '更新项目',
|
||||
run_workflow: '运行工作流',
|
||||
}
|
||||
const label = nameMap[group.name] || group.name.replace(/_/g, ' ')
|
||||
return label
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -63,4 +261,95 @@ defineExpose({ collapseInactive })
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* -- 批量操作栏 -- */
|
||||
.ai-batch-approve {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: rgba(255,255,255,0.02);
|
||||
}
|
||||
.ai-batch-approve .ai-btn--sm {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* -- 全部展开/收起按钮 -- */
|
||||
.ai-tool-global-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-size: 11px;
|
||||
color: var(--df-text-dim);
|
||||
border-radius: var(--df-radius-sm);
|
||||
transition: background 0.15s var(--df-ease);
|
||||
width: fit-content;
|
||||
}
|
||||
.ai-tool-global-toggle:hover {
|
||||
background: rgba(255,255,255,0.03);
|
||||
color: var(--df-text-secondary);
|
||||
}
|
||||
.ai-tool-global-toggle-icon {
|
||||
font-size: 10px;
|
||||
transition: transform 0.15s var(--df-ease);
|
||||
}
|
||||
|
||||
/* -- 分组标题 -- */
|
||||
.ai-tool-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-radius: var(--df-radius-sm);
|
||||
transition: background 0.15s var(--df-ease);
|
||||
font-size: 11px;
|
||||
color: var(--df-text-secondary);
|
||||
}
|
||||
.ai-tool-group-header:hover {
|
||||
background: rgba(255,255,255,0.03);
|
||||
}
|
||||
.ai-tool-group-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--df-text-dim);
|
||||
}
|
||||
.ai-tool-group-name {
|
||||
font-weight: 500;
|
||||
color: var(--df-text-secondary);
|
||||
}
|
||||
.ai-tool-group-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
background: var(--df-accent-bg);
|
||||
color: var(--df-accent);
|
||||
}
|
||||
.ai-tool-group-chevron {
|
||||
margin-left: auto;
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
transition: transform 0.15s var(--df-ease);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ai-tool-group-chevron.is-open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* -- 分组隐藏态的卡片 -- */
|
||||
.ai-tool-card--group-hidden {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -16,7 +16,7 @@ import { state } from '@/stores/ai'
|
||||
import i18n from '@/i18n'
|
||||
import { nextMsgId } from './aiShared'
|
||||
import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream'
|
||||
import { drainQueue } from './useAiSend'
|
||||
import { drainQueue, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers } from './useAiSend'
|
||||
import { loadConversations } from './useAiConversations'
|
||||
import type { AiChatEvent, AiToolCallInfo } from '@/api/types'
|
||||
|
||||
@@ -26,12 +26,61 @@ const t = ((i18n as any).global.t as (key: string) => string).bind((i18n as any)
|
||||
|
||||
let _unlistenAiEvent: (() => void) | null = null
|
||||
let _unlistenConvChanged: (() => void) | null = null
|
||||
// AE-2025-06: onStreamTimeout 广播的审批计时器清理事件 unlistener
|
||||
let _unlistenApprovalClear: (() => void) | null = null
|
||||
// startListener 并发去重:防 onMounted 与 sendMessage 首发竞态下重复注册 listener
|
||||
// (两回调写同一 state.currentText → 流式文字双倍)
|
||||
let _startPromise: Promise<void> | null = null
|
||||
|
||||
const appSettings = useAppSettingsStore()
|
||||
|
||||
// B-260616-17: 看门狗不重置的事件集合(审批等待/完成/错误由各自 case 内 clear)。
|
||||
// 模块级 Set 复用,避免 handleEvent 每事件(delta/token 高频)新建数组字面量做 includes。
|
||||
const NO_RESET_WATCHDOG = new Set<AiChatEvent['type']>(['AiApprovalRequired', 'AiCompleted', 'AiError'])
|
||||
|
||||
// B-260616-12: 工具执行超时提示(纯前端降级,后端无工具级取消 IPC)。
|
||||
// 每个 running 工具一个独立 setTimeout;到时若仍未收到 Completed/Approval,
|
||||
// 经 Tauri 事件 ai-tool-slow-toast 通知 AiChat.vue 弹 warning toast(仅提示一次,
|
||||
// 不动 running 态——慢工具如 read_file 大文件/run_workflow 长任务不可误杀)。
|
||||
// 真正取消需后端开 ai_cancel_tool IPC + 工具执行 select 改造,单独立项。
|
||||
const TOOL_SLOW_MS = 30000
|
||||
// callId → timer;记录所有已挂载的工具级计时器
|
||||
const _toolTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
// 已提示过的 callId(同一工具只弹一次 toast,即便仍 running 到本轮结束)
|
||||
const _toolSlowNotified = new Set<string>()
|
||||
|
||||
/** 启动工具级慢执行计时器(超时仅弹 toast,不修改 status) */
|
||||
function startToolSlowTimer(callId: string, toolName: string): void {
|
||||
if (_toolTimers.has(callId)) return // 幂等:同 id 重复 Started 不重建
|
||||
const timer = setTimeout(() => {
|
||||
_toolTimers.delete(callId)
|
||||
if (_toolSlowNotified.has(callId)) return
|
||||
_toolSlowNotified.add(callId)
|
||||
// 经 Tauri 事件总线广播(composable 无组件上下文,无法直接调 toast)。
|
||||
// AiChat.vue(主窗口与分离窗口各挂一份)listen 后弹本地 toast。
|
||||
// toolName 来自后端事件,经 i18n key 查不到翻译时回退原值——这里原样透传,
|
||||
// 由消费方 i18n 插值展示。
|
||||
void emit('ai-tool-slow-toast', { name: toolName })
|
||||
}, TOOL_SLOW_MS)
|
||||
_toolTimers.set(callId, timer)
|
||||
}
|
||||
|
||||
/** 清除单个工具的慢执行计时器(收到 Completed/Approval 时调用) */
|
||||
function clearToolSlowTimer(callId: string): void {
|
||||
const timer = _toolTimers.get(callId)
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
_toolTimers.delete(callId)
|
||||
}
|
||||
}
|
||||
|
||||
/** 清除全部工具慢执行计时器(stopListener/整流超时收尾时调用) */
|
||||
function clearAllToolSlowTimers(): void {
|
||||
for (const timer of _toolTimers.values()) clearTimeout(timer)
|
||||
_toolTimers.clear()
|
||||
_toolSlowNotified.clear()
|
||||
}
|
||||
|
||||
/** 通知会话列表发生变化(供 newConversation/deleteConversation/rename/archive 等触发刷新侧栏) */
|
||||
export function notifyConversationChanged() {
|
||||
emit('ai-conversation-changed', {})
|
||||
@@ -106,7 +155,7 @@ export function handleEvent(event: AiChatEvent) {
|
||||
state.generatingConvId = convId
|
||||
}
|
||||
// 流式看门狗:活跃事件(delta/工具/新轮/审批结果)重置;审批等待/完成/错误在 case 内 clear
|
||||
if (!['AiApprovalRequired', 'AiCompleted', 'AiError'].includes(event.type)) {
|
||||
if (!NO_RESET_WATCHDOG.has(event.type)) {
|
||||
resetStreamWatchdog()
|
||||
}
|
||||
switch (event.type) {
|
||||
@@ -124,6 +173,12 @@ export function handleEvent(event: AiChatEvent) {
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
// AE-2025-07: 记录当前轮次供进度条展示。
|
||||
// event.round>0 = run_agentic_loop 内 iteration+1(第几轮工具→LLM 循环);
|
||||
// event.round==0 = try_continue_agent_loop 审批通过后"隔开新一轮"的占位事件,
|
||||
// 此时实际轮次尚未推进(run_agentic_loop 入口 iteration=0),不覆盖,避免审批通过瞬间
|
||||
// 进度条 2→0 闪烁;下一轮真正的 round>0 事件到达时再更新。
|
||||
if (event.round > 0) state.agentRound = event.round
|
||||
break
|
||||
}
|
||||
|
||||
@@ -143,6 +198,8 @@ export function handleEvent(event: AiChatEvent) {
|
||||
lastMsg.toolCalls = lastMsg.toolCalls || []
|
||||
lastMsg.toolCalls.push(info)
|
||||
}
|
||||
// B-260616-12: 工具开始执行即挂慢执行计时器(超时仅 toast,不动 running 态)
|
||||
startToolSlowTimer(event.id, event.name)
|
||||
break
|
||||
}
|
||||
|
||||
@@ -153,6 +210,9 @@ export function handleEvent(event: AiChatEvent) {
|
||||
tc.result = event.result
|
||||
}
|
||||
state.pendingApprovals = state.pendingApprovals.filter(p => p.id !== event.id)
|
||||
clearToolSlowTimer(event.id)
|
||||
// AE-2025-06: 工具结束(无论审批通过后执行还是被拒),清审批超时计时器
|
||||
clearApprovalTimer(event.id)
|
||||
break
|
||||
}
|
||||
|
||||
@@ -170,6 +230,10 @@ export function handleEvent(event: AiChatEvent) {
|
||||
tc.status = 'pending_approval'
|
||||
tc.reason = event.reason
|
||||
}
|
||||
// B-260616-12: 进入审批等待→取消该工具慢执行计时器(审批耗时由用户主导,非执行慢)
|
||||
clearToolSlowTimer(event.id)
|
||||
// AE-2025-06: 审批等待开始→启动审批超时计时器(5min 不处理自动拒绝)
|
||||
startApprovalTimer(event.id, event.name)
|
||||
break
|
||||
}
|
||||
|
||||
@@ -180,19 +244,27 @@ export function handleEvent(event: AiChatEvent) {
|
||||
tc.status = 'rejected'
|
||||
// CR-260615-08(P1-1):走 i18n —— 原硬编码中文使 en locale 拒绝提示恒中文,
|
||||
// 且废掉已存在的 aiTool.rejectedHint(en: 'User rejected this action')翻译
|
||||
tc.result = t('aiTool.rejectedHint')
|
||||
tc.result = t('ai.aiTool.rejectedHint')
|
||||
}
|
||||
state.pendingApprovals = state.pendingApprovals.filter(p => p.id !== event.id)
|
||||
clearToolSlowTimer(event.id)
|
||||
// AE-2025-06: 用户已拒绝(状态离开 pending_approval)→清审批超时计时器
|
||||
clearApprovalTimer(event.id)
|
||||
} else {
|
||||
// B-260616-12: 审批通过→工具重新进入执行态,重启慢执行计时器
|
||||
startToolSlowTimer(event.id, findToolCall(event.id)?.name || '')
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'AiCompleted': {
|
||||
clearStreamWatchdog()
|
||||
clearAllToolSlowTimers() // B-260616-12: 整轮结束清全部工具慢执行计时器与已提示集合
|
||||
flushCurrentText()
|
||||
state.currentText = ''
|
||||
state.streaming = false
|
||||
state.generatingConvId = null
|
||||
state.agentRound = 0 // AE-2025-07: agentic 结束,复位轮次(隐藏进度条)
|
||||
// token 用量记录(开关开时):lastTokenUsage 供当前回复展示,convTokenTotal 累加对话总量
|
||||
if (isShowTokenUsage()) {
|
||||
state.lastTokenUsage = {
|
||||
@@ -220,9 +292,11 @@ export function handleEvent(event: AiChatEvent) {
|
||||
|
||||
case 'AiError': {
|
||||
clearStreamWatchdog()
|
||||
clearAllToolSlowTimers() // B-260616-12: 错误收尾清全部工具慢执行计时器与已提示集合
|
||||
state.streaming = false
|
||||
state.generatingConvId = null
|
||||
state.currentText = ''
|
||||
state.agentRound = 0 // AE-2025-07: agentic 异常中断,复位轮次
|
||||
state.queue = [] // B-32:错误收尾清队列,防生成中入队的消息被静默丢失(drainQueue 仅 AiCompleted 触发)
|
||||
localStorage.removeItem('df-ai-gen')
|
||||
localStorage.removeItem('df-ai-text')
|
||||
@@ -241,12 +315,17 @@ export function handleEvent(event: AiChatEvent) {
|
||||
/** 启动事件监听(幂等 + 并发去重,onMounted 与 sendMessage 首发竞态不会重复注册) */
|
||||
export async function startListener() {
|
||||
// 已注册 → 直接复用(幂等;sendMessage 每次调用不重复注册)
|
||||
if (_unlistenAiEvent && _unlistenConvChanged) return
|
||||
if (_unlistenAiEvent && _unlistenConvChanged && _unlistenApprovalClear) return
|
||||
// 并发去重:防 onMounted 与 sendMessage 首发竞态重复注册 listener → 文字双倍
|
||||
if (_startPromise) return _startPromise
|
||||
_startPromise = (async () => {
|
||||
_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 })
|
||||
})()
|
||||
try {
|
||||
await _startPromise
|
||||
@@ -261,9 +340,13 @@ export async function startListener() {
|
||||
function stopListener() {
|
||||
_unlistenAiEvent?.()
|
||||
_unlistenConvChanged?.()
|
||||
_unlistenApprovalClear?.()
|
||||
_unlistenAiEvent = null
|
||||
_unlistenConvChanged = null
|
||||
_unlistenApprovalClear = null
|
||||
clearStreamWatchdog()
|
||||
clearAllToolSlowTimers() // B-260616-12: 卸载时释放所有工具慢执行计时器,防回调改 state 触发已卸载组件
|
||||
clearAllApprovalTimers() // AE-2025-06: 卸载时释放所有审批超时计时器,防回调 push 消息触发已卸载组件
|
||||
}
|
||||
|
||||
export function useAiEvents() {
|
||||
|
||||
@@ -15,30 +15,40 @@ import { state } from '@/stores/ai'
|
||||
|
||||
const appSettings = useAppSettingsStore()
|
||||
|
||||
// 启动恢复:从 appSettings(SQLite)读取上次的 UI 布局(模块级单例 state)
|
||||
// detached/docked 不恢复 — 重启后分离窗口必然不存在,必须回 false
|
||||
export function restoreUiState() {
|
||||
const s = appSettings.get<any>('df-ai-ui', null)
|
||||
/** 侧栏宽度可拖拽范围(UX-2025-16)。120=窄屏友好,280=宽屏舒适 */
|
||||
export const SIDEBAR_WIDTH_MIN = 120
|
||||
export const SIDEBAR_WIDTH_MAX = 280
|
||||
export const SIDEBAR_WIDTH_DEFAULT = 200
|
||||
|
||||
/** clamp 侧栏宽度到合法区间;非法/缺省值回默认(防 NaN/0/负数污染 state) */
|
||||
function clampSidebarWidth(v: any): number {
|
||||
const n = typeof v === 'number' && Number.isFinite(v) ? v : SIDEBAR_WIDTH_DEFAULT
|
||||
return Math.min(SIDEBAR_WIDTH_MAX, Math.max(SIDEBAR_WIDTH_MIN, n))
|
||||
}
|
||||
|
||||
/**
|
||||
* 将缓存的 UI 布局对象安全应用到模块级 state(仅 boolean 字段覆盖,其余忽略)。
|
||||
* restoreUiState 与 watch 回调共用,消除重复赋值。
|
||||
* detached/docked 不恢复 — 重启后分离窗口必然不存在,必须回 false
|
||||
*/
|
||||
function applyUiState(s: any) {
|
||||
if (!s) return
|
||||
if (typeof s.panelOpen === 'boolean') state.panelOpen = s.panelOpen
|
||||
if (typeof s.maximized === 'boolean') state.maximized = s.maximized
|
||||
if (typeof s.sidebarOpen === 'boolean') state.sidebarOpen = s.sidebarOpen
|
||||
if (typeof s.archivedCollapsed === 'boolean') state.archivedCollapsed = s.archivedCollapsed
|
||||
// 侧栏宽度(UX-2025-16):数值字段,clamp 后写入(与上方 boolean 同级兜底)
|
||||
state.sidebarWidth = clampSidebarWidth(s.sidebarWidth)
|
||||
}
|
||||
|
||||
// 启动恢复:从 appSettings(SQLite)读取上次的 UI 布局(模块级单例 state)
|
||||
export function restoreUiState() {
|
||||
applyUiState(appSettings.get<any>('df-ai-ui', null))
|
||||
}
|
||||
restoreUiState()
|
||||
|
||||
// loadAll() 异步填充缓存后,再次应用 UI 布局(模块级 state,watch 仅触发一次)
|
||||
watch(
|
||||
() => appSettings.cache['df-ai-ui'],
|
||||
(v) => {
|
||||
if (!v) return
|
||||
const s = v as any
|
||||
if (typeof s.panelOpen === 'boolean') state.panelOpen = s.panelOpen
|
||||
if (typeof s.maximized === 'boolean') state.maximized = s.maximized
|
||||
if (typeof s.sidebarOpen === 'boolean') state.sidebarOpen = s.sidebarOpen
|
||||
if (typeof s.archivedCollapsed === 'boolean') state.archivedCollapsed = s.archivedCollapsed
|
||||
},
|
||||
)
|
||||
watch(() => appSettings.cache['df-ai-ui'], (v) => applyUiState(v as any))
|
||||
|
||||
/** 持久化 UI 布局到 appSettings(debounced 写 SQLite) */
|
||||
export function persistUiState() {
|
||||
@@ -47,9 +57,20 @@ export function persistUiState() {
|
||||
maximized: state.maximized,
|
||||
sidebarOpen: state.sidebarOpen,
|
||||
archivedCollapsed: state.archivedCollapsed,
|
||||
// 侧栏宽度(UX-2025-16):拖拽 mouseup 时由 AiChat 调 persistUiState 落盘
|
||||
sidebarWidth: state.sidebarWidth,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置侧栏宽度(clamp 后写 state,不持久化)。
|
||||
* 拖拽 mousemove 高频调用,持久化由调用方在 mouseup 时一次 persistUiState() 完成,
|
||||
* 避免每像素触发 debounced SQLite 写。
|
||||
*/
|
||||
export function setSidebarWidth(px: number) {
|
||||
state.sidebarWidth = clampSidebarWidth(px)
|
||||
}
|
||||
|
||||
/** 切换面板展开/收起(收起时同步退出最大化,避免 v-show 卡死 main 交互) */
|
||||
function togglePanel() {
|
||||
state.panelOpen = !state.panelOpen
|
||||
@@ -103,5 +124,8 @@ export function useAiPanel() {
|
||||
loadSkills,
|
||||
setProvider,
|
||||
clearChat,
|
||||
// UX-2025-16:侧栏宽度拖拽
|
||||
setSidebarWidth,
|
||||
persistUiState,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
//! 发送与队列管理 — sendMessage/approveToolCall + 队列(drain/cancel/clear) + stopChat
|
||||
//!
|
||||
//! B-260616-02 L2 发送韧性:三级降级
|
||||
//! L0 normal:后端 idle → 直接发送
|
||||
//! L1 queued: 后端 busy → 入队等待(≤30s 正常排队)
|
||||
//! L2 force: 排队>30s → 弹 confirm → 用户确认后调 ai_chat_force_send 复位残留并强制发出
|
||||
//!
|
||||
//! 模块级私有:
|
||||
//! - 无(队列存在 state.queue 中,看门狗/审批状态机依赖 events 与 stream)
|
||||
//!
|
||||
@@ -25,52 +30,74 @@ const appSettings = useAppSettingsStore()
|
||||
/// 待发送队列上限(超过抛错提示用户)
|
||||
const QUEUE_LIMIT = 10
|
||||
|
||||
/** 取出队首并发送(AiCompleted 触发,此时 streaming 已 false) */
|
||||
export function drainQueue() {
|
||||
if (state.queue.length === 0) return
|
||||
const next = state.queue.shift()!
|
||||
void sendMessage(next.text, next.skill)
|
||||
/// 排队超时阈值(ms) — 超过后弹 confirm 让用户选择"强制发送"或继续等(B-260616-02)
|
||||
const QUEUE_TIMEOUT_MS = 30_000
|
||||
|
||||
/// 审批等待超时阈值(ms) — 用户 5 分钟内不处理则自动拒绝,防 pending_approval 永久卡死对话。
|
||||
/// 纯前端定时器:审批依赖页面交互,页面关闭/不处理即超时 auto-reject 合理(后端无超时)。
|
||||
/// TODO: 未来接 Settings 可配(df-ai-approval-timeout-ms),当前写死减范围(避免改 Settings.vue god file)
|
||||
const APPROVAL_TIMEOUT_MS = 5 * 60 * 1000
|
||||
|
||||
/**
|
||||
* 审批超时计时器:toolCallId → timer。
|
||||
* 在 AiApprovalRequired(审批开始等待)启动,AiApprovalResult/AiToolCallCompleted(状态离开 pending_approval)、
|
||||
* onStreamTimeout(整流超时收尾)、stopListener(组件卸载)时清除。
|
||||
* 与 useAiEvents._toolTimers(慢执行 toast)同模式,但本计时器到点会改状态(auto-reject),
|
||||
* 故与 running 态 toast 解耦——审批态不应被 toast 计时器误触发。
|
||||
*/
|
||||
const _approvalTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
/**
|
||||
* 启动审批超时计时器(幂等:同 id 重复启动不重建,沿用现有计时器)。
|
||||
* 到点回调:调 ai_approve(id, false) 自动拒绝 + push 一条系统错误消息到 state.messages
|
||||
* (参照 onStreamTimeout push 模式;composable 无 UI 上下文,toast 跨层复杂,系统消息更简单闭环)。
|
||||
* TODO: toast 跨层接线(目前仅 push 系统消息,无即时强提示)
|
||||
*/
|
||||
export function startApprovalTimer(toolCallId: string, toolName: string): void {
|
||||
if (_approvalTimers.has(toolCallId)) return
|
||||
const timer = setTimeout(() => {
|
||||
_approvalTimers.delete(toolCallId)
|
||||
// 自动拒绝:调 ai_approve(false) 通知后端不执行该工具
|
||||
aiApi.approve(toolCallId, false).catch(e => {
|
||||
// IPC 失败(网络断/后端已关):后端状态已乱,前端仍 push 提示让用户知晓
|
||||
console.error('[AI] 审批超时自动拒绝 IPC 未送达:', e)
|
||||
})
|
||||
// push 系统错误消息(参照 onStreamTimeout push 模式)
|
||||
state.messages.push({
|
||||
id: `approval-timeout-${nextMsgId()}`,
|
||||
role: 'assistant',
|
||||
content: t('ai.approvalTimeout', { toolName }),
|
||||
isError: true,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}, APPROVAL_TIMEOUT_MS)
|
||||
_approvalTimers.set(toolCallId, timer)
|
||||
}
|
||||
|
||||
/** 发送消息:生成中入队,否则推送 user+air 气泡并触发后端流式 */
|
||||
async function sendMessage(text: string, skill?: string) {
|
||||
if (!text.trim()) return
|
||||
|
||||
// B-260615-22(方案 A):发送前 IPC 查后端真实 generating。
|
||||
// 前端 state.streaming 与后端 AiSession.generating 各自维护:后端 loop 异常退出/
|
||||
// AiError 已复位 generating=false 时,本地 streaming 可能仍为 false(状态不同步),
|
||||
// 仅查本地预检会放行,随后撞 ai_chat_send:45 的 generating 拦截。此处对齐后端真值。
|
||||
let backendGenerating = false
|
||||
try {
|
||||
backendGenerating = await invoke<boolean>('ai_is_generating')
|
||||
} catch {
|
||||
// IPC 失败不阻断发送:退化到仅靠本地 streaming 预检(原行为),让 ai_chat_send 兜底。
|
||||
backendGenerating = false
|
||||
/** 清除单个审批计时器(审批完成/拒绝时调用) */
|
||||
export function clearApprovalTimer(toolCallId: string): void {
|
||||
const timer = _approvalTimers.get(toolCallId)
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
_approvalTimers.delete(toolCallId)
|
||||
}
|
||||
if (backendGenerating) {
|
||||
if (state.queue.length >= QUEUE_LIMIT) {
|
||||
throw new Error(t('ai.queueFull', { limit: QUEUE_LIMIT }))
|
||||
}
|
||||
// 后端真在生成但本地 streaming 已复位(false):状态不同步——对齐本地 streaming=true
|
||||
// 让用户感知(禁用输入框等),并入队等待 AiCompleted 续发;不入队则消息静默丢失。
|
||||
if (!state.streaming) {
|
||||
state.streaming = true
|
||||
}
|
||||
state.queue.push({ text: text.trim(), skill: skill || undefined })
|
||||
return
|
||||
}
|
||||
|
||||
// 本地 streaming=true 但后端 generating=false(看门狗/AiError 已复位本地,或反向不同步):
|
||||
// 后端可接收,本地直接进入发送(无需复位 streaming)。
|
||||
// 生成中:进入待发送队列,当前对话完成后(AiCompleted)由 drainQueue 自动续发
|
||||
if (state.streaming) {
|
||||
if (state.queue.length >= QUEUE_LIMIT) {
|
||||
throw new Error(t('ai.queueFull', { limit: QUEUE_LIMIT }))
|
||||
}
|
||||
state.queue.push({ text: text.trim(), skill: skill || undefined })
|
||||
return
|
||||
/** 清除全部审批计时器(onStreamTimeout/stopListener 整流收尾时调用) */
|
||||
export function clearAllApprovalTimers(): void {
|
||||
for (const timer of _approvalTimers.values()) clearTimeout(timer)
|
||||
_approvalTimers.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* 入队时记录时间戳,供 UI 展示排队时长和触发超时提示。
|
||||
* queue item 扩展: 原 { text, skill? } → { text, skill?, enqueuedAt }
|
||||
* 兼容 drainQueue/cancelQueued/clearQueue 等已有消费方(enqueuedAt 仅读不写)。
|
||||
*/
|
||||
|
||||
// ── 内核发送:推送气泡+调 IPC,被 normal/force 两条路径共用 ──
|
||||
|
||||
async function doSend(text: string, skill?: string, force = false) {
|
||||
const userMsgId = `user-${nextMsgId()}`
|
||||
state.messages.push({
|
||||
id: userMsgId,
|
||||
@@ -97,25 +124,105 @@ async function sendMessage(text: string, skill?: string) {
|
||||
? appSettings.get<string>('df-language', 'zh-CN')
|
||||
: raw
|
||||
try {
|
||||
// force=true 走 force_send IPC(复位残留 generating),否则走正常 send
|
||||
if (force) {
|
||||
await aiApi.forceSend(text.trim(), lang, skill)
|
||||
} else {
|
||||
await aiApi.sendMessage(text.trim(), lang, skill)
|
||||
}
|
||||
} catch (e) {
|
||||
// IPC 失败(spawn 前/provider 配置错等):回滚 streaming 防光标卡死 + 移除 user 消息与空气泡占位;
|
||||
// 重新抛出由 handleSend 回填输入框,用户可重试
|
||||
state.streaming = false
|
||||
clearStreamWatchdog() // 清 :61 启动的看门狗,防 130s 后 onStreamTimeout 误推错误气泡
|
||||
clearStreamWatchdog() // 清看门狗,防 130s 后 onStreamTimeout 误推错误气泡
|
||||
state.messages = state.messages.filter(m => m.id !== aiMsgId && m.id !== userMsgId)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/** 工具审批:乐观置 running,IPC 失败时改 completed+错误文案(不回滚避免卡按钮) */
|
||||
/** 取出队首并发送(AiCompleted 触发,此时 streaming 已 false) */
|
||||
export function drainQueue() {
|
||||
if (state.queue.length === 0) return
|
||||
const next = state.queue.shift()!
|
||||
void sendMessage(next.text, next.skill)
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息 — 三级降级(B-260616-02 L2 发送韧性):
|
||||
*
|
||||
* L0 normal: 后端 idle → 直接 doSend()
|
||||
* L1 queued: 后端 busy → 入队等 AiCompleted 续发(≤30s 正常等待)
|
||||
* L2 force: 排队>30s → 返回特殊标记,由调用方(handleSend)弹 confirm;
|
||||
* 用户确认后重新进 sendMessage(forceMode=true)走 forceSend IPC
|
||||
*
|
||||
* forceMode=true 时跳过入队,直接走 ai_chat_force_send。
|
||||
*/
|
||||
async function sendMessage(text: string, skill?: string, forceMode = false) {
|
||||
if (!text.trim()) return
|
||||
|
||||
// ── L2 强制模式:跳过所有预检,直接 force_send ──
|
||||
if (forceMode) {
|
||||
await doSend(text, skill, true)
|
||||
return
|
||||
}
|
||||
|
||||
// B-260615-22(方案 A):发送前 IPC 查后端真实 generating。
|
||||
let backendGenerating = false
|
||||
try {
|
||||
backendGenerating = await invoke<boolean>('ai_is_generating')
|
||||
} catch {
|
||||
backendGenerating = false
|
||||
}
|
||||
|
||||
// ── L1:后端 busy → 入队 ──
|
||||
if (backendGenerating || state.streaming) {
|
||||
if (state.queue.length >= QUEUE_LIMIT) {
|
||||
throw new Error(t('ai.queueFull', { limit: QUEUE_LIMIT }))
|
||||
}
|
||||
state.streaming = true
|
||||
state.queue.push({ text: text.trim(), skill: skill || undefined, enqueuedAt: Date.now() })
|
||||
return
|
||||
}
|
||||
|
||||
// ── L0:normal 直接发送 ──
|
||||
await doSend(text, skill, false)
|
||||
}
|
||||
|
||||
/** 排队是否已超时(任一条超即算,因队首阻塞导致后续全堵) */
|
||||
export function isQueueTimedOut(): boolean {
|
||||
const first = state.queue[0]
|
||||
return first !== undefined && (Date.now() - first.enqueuedAt > QUEUE_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
/** 超时时弹 confirm,用户确认后以 force_mode 重发队首消息;取消则保持排队 */
|
||||
export async function tryForceSend(confirmFn: (msg: string) => Promise<boolean>): Promise<boolean> {
|
||||
const first = state.queue[0]
|
||||
if (!first) return false
|
||||
const confirmed = await confirmFn(t('ai.forceSendConfirm'))
|
||||
if (!confirmed) return false
|
||||
// 从队列移除队首,以 force_mode 发送
|
||||
state.queue.shift()
|
||||
// 清看门狗:doSend 内 force_send 路径会重设 streaming=true 并重启 watchdog,
|
||||
// 此处无需前置 false(前置 false 会触发 AiChat watch 清流式块再重建,产生瞬态抖动)
|
||||
clearStreamWatchdog()
|
||||
try {
|
||||
await sendMessage(first.text, first.skill, true)
|
||||
return true
|
||||
} catch {
|
||||
// force_send 也失败:消息不回队列(避免循环),让用户看到错误
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** 工具审批:保持 pending_approval 让审批卡片可见(按钮 loading 由 ToolCard 本地 ref 持有),
|
||||
* IPC 失败时改 completed+错误文案。后端回事件后由 useAiEvents 转 completed/rejected。
|
||||
* B-260616-08:不再乐观置 running——原写法让 .ai-tool-approval 整块消失(切骨架屏),
|
||||
* 按钮无 loading 中间态、用户无重审入口;loading 现下沉到 ToolCard 局部 ref,语义正确。 */
|
||||
async function approveToolCall(toolCallId: string, approved: boolean) {
|
||||
// 乐观置运行中,禁用审批按钮防重复点击(后端事件回来后转 completed/rejected)
|
||||
// 复用 findToolCall(反向扫描)避免重复实现查找逻辑
|
||||
// 复用 findToolCall(反向扫描)避免重复实现查找逻辑;仅缓存引用用于 IPC 失败回滚
|
||||
const tc = findToolCall(toolCallId)
|
||||
if (tc) tc.status = 'running'
|
||||
// 重启看门狗覆盖审批执行→续生成窗口(useAiEvents.ts:162 AiApprovalRequired 已 clear,
|
||||
// 审批态无心跳兜底;approve 后后端要跑工具+续生成,期间任何后端异常不回则按钮永久 running。
|
||||
// 审批态无心跳兜底;approve 后后端要跑工具+续生成,期间任何后端异常不回则按钮永久 loading。
|
||||
// 复用通用 watchdog 而非加审批专用超时:复用同一收尾路径(onStreamTimeout 兜底复位 streaming),
|
||||
// 避免新增独立计时器与状态分支)
|
||||
resetStreamWatchdog()
|
||||
@@ -136,6 +243,21 @@ async function approveToolCall(toolCallId: string, approved: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 批量审批:遍历 pendingApprovals 逐个调用 approveToolCall */
|
||||
async function batchApprove(decision: 'approve' | 'reject') {
|
||||
const approved = decision === 'approve'
|
||||
// 快照当前待审批列表(遍历中 state.pendingApprovals 会因事件回调而缩短)
|
||||
const ids = [...state.pendingApprovals].map(p => p.id)
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await approveToolCall(id, approved)
|
||||
} catch {
|
||||
// 单条失败不中断批量操作(已由 approveToolCall 内部回滚该条状态)
|
||||
// 继续处理剩余项
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 取消队列中指定位置的消息 */
|
||||
function cancelQueued(index: number) {
|
||||
state.queue.splice(index, 1)
|
||||
@@ -160,9 +282,15 @@ export function useAiSend() {
|
||||
return {
|
||||
sendMessage,
|
||||
approveToolCall,
|
||||
batchApprove,
|
||||
drainQueue,
|
||||
cancelQueued,
|
||||
clearQueue,
|
||||
stopChat,
|
||||
isQueueTimedOut,
|
||||
tryForceSend,
|
||||
startApprovalTimer,
|
||||
clearApprovalTimer,
|
||||
clearAllApprovalTimers,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import { state } from '@/stores/ai'
|
||||
import i18n from '@/i18n'
|
||||
import { nextMsgId } from './aiShared'
|
||||
import { emit } from '@tauri-apps/api/event'
|
||||
|
||||
// composable 内非组件上下文(无 setup),用 vue-i18n 全局实例的 t 而非 useI18n()。
|
||||
// 通过 any 中转规避 vue-i18n 深度 message schema 泛型导致的 TS2589(类型实例化过深)。
|
||||
@@ -32,13 +33,19 @@ export function onStreamTimeout() {
|
||||
state.currentText = ''
|
||||
state.queue = [] // B-32:超时收尾同步清队列,防生成中入队的消息静默丢失
|
||||
clearStreamWatchdog()
|
||||
// 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,后端 hang 时
|
||||
// 看门狗触发本回调,若不回滚则卡片永久骨架屏(ToolCard:24 渲染 running 无审批按钮,无重审入口)。
|
||||
// B-260615-07 残留。
|
||||
// 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 破环),且此处需全量回滚 running 而非按 id 定位。
|
||||
// 不依赖 useAiEvents(已下沉 nextMsgId 到 aiShared 破环),且此处需全量回滚而非按 id 定位。
|
||||
let hasCompletedTool = false
|
||||
for (let i = state.messages.length - 1; i >= 0; i--) {
|
||||
const toolCalls = state.messages[i].toolCalls
|
||||
|
||||
@@ -18,6 +18,56 @@
|
||||
//! 渲染结果不变。
|
||||
|
||||
import { computed, ref, type ComputedRef } from 'vue'
|
||||
// UX-2025-05: 代码块语法高亮 —— 用 highlight.js/lib/core + 按需 registerLanguage,
|
||||
// 只打常用 ~16 门语言进包(避免全量 import ~1MB);core+已注册语言 gzip 后远小于全量。
|
||||
import hljs from 'highlight.js/lib/core'
|
||||
import javascript from 'highlight.js/lib/languages/javascript'
|
||||
import typescript from 'highlight.js/lib/languages/typescript'
|
||||
import rust from 'highlight.js/lib/languages/rust'
|
||||
import python from 'highlight.js/lib/languages/python'
|
||||
import go from 'highlight.js/lib/languages/go'
|
||||
import sql from 'highlight.js/lib/languages/sql'
|
||||
import bash from 'highlight.js/lib/languages/bash'
|
||||
import json from 'highlight.js/lib/languages/json'
|
||||
import yaml from 'highlight.js/lib/languages/yaml'
|
||||
import markdown from 'highlight.js/lib/languages/markdown'
|
||||
import xml from 'highlight.js/lib/languages/xml'
|
||||
import css from 'highlight.js/lib/languages/css'
|
||||
import shell from 'highlight.js/lib/languages/shell'
|
||||
import java from 'highlight.js/lib/languages/java'
|
||||
import csharp from 'highlight.js/lib/languages/csharp'
|
||||
import cpp from 'highlight.js/lib/languages/cpp'
|
||||
// B-260616-11(②子项): diff 语言注册,```diff 代码块 +/- 行获 hljs-addition/deletion 红绿(github-dark.css 自带)
|
||||
import diff from 'highlight.js/lib/languages/diff'
|
||||
// 主题:github-dark 与现有深色 UI(#0c0e1a)贴合;亮色主题下 code 块仍可读(深底浅字)。
|
||||
// 注意:该 CSS 在 ai-md.css 之前/之后导入均可,hljs 用 .hljs/.hljs-* token class 着色,
|
||||
// 与 .ai-md pre code 的基础色互不冲突(token 是独立 <span>)。
|
||||
import 'highlight.js/styles/github-dark.css'
|
||||
|
||||
// 按需注册常用语言(大小写不敏感的别名映射见 _LANG_ALIASES)
|
||||
hljs.registerLanguage('javascript', javascript)
|
||||
hljs.registerLanguage('typescript', typescript)
|
||||
hljs.registerLanguage('rust', rust)
|
||||
hljs.registerLanguage('python', python)
|
||||
hljs.registerLanguage('go', go)
|
||||
hljs.registerLanguage('sql', sql)
|
||||
hljs.registerLanguage('bash', bash)
|
||||
hljs.registerLanguage('json', json)
|
||||
hljs.registerLanguage('yaml', yaml)
|
||||
hljs.registerLanguage('markdown', markdown)
|
||||
hljs.registerLanguage('xml', xml)
|
||||
hljs.registerLanguage('css', css)
|
||||
hljs.registerLanguage('shell', shell)
|
||||
hljs.registerLanguage('java', java)
|
||||
hljs.registerLanguage('csharp', csharp)
|
||||
hljs.registerLanguage('cpp', cpp)
|
||||
hljs.registerLanguage('diff', diff)
|
||||
// 围栏 info 字符串常见别名 → 已注册语言(hljs.getLanguage 内置部分别名,这里补齐业务常用)
|
||||
const _LANG_ALIASES: Record<string, string> = {
|
||||
js: 'javascript', ts: 'typescript', py: 'python', rb: 'ruby',
|
||||
sh: 'bash', shell: 'shell', yml: 'yaml', md: 'markdown',
|
||||
html: 'xml', vue: 'xml', svg: 'xml', cs: 'csharp', 'c++': 'cpp', 'c#': 'csharp',
|
||||
}
|
||||
|
||||
// ═══ 模块级单例:AiChat 流式核心与历史 renderMd 共享同一份渲染器/缓存 ═══
|
||||
let _marked: typeof import('marked')['marked'] | null = null
|
||||
@@ -26,10 +76,47 @@ const mdReady = ref(false)
|
||||
// 历史消息整段缓存(文本不变,重渲染命中省去 marked+sanitize 全量解析)
|
||||
const _mdCache = new Map<string, string>()
|
||||
|
||||
/**
|
||||
* UX-2025-05: marked code renderer 注入 —— 优先按 infostring 指定语言高亮(需已注册),
|
||||
* 否则 highlightAuto 在已注册语言里猜测。输出 <pre><code class="hljs language-{lang}">,
|
||||
* 保留 marked 原 code 块外层结构(pre/code),兼容现有 .ai-md pre code 样式与 DOMPurify。
|
||||
*
|
||||
* 返回 false 让 marked 用默认 code 渲染再包 pre 不可行(marked v8 code 返回值语义),
|
||||
* 故这里直接返回完整 <pre><code>...</code></pre> HTML(marked renderer.code 的契约)。
|
||||
*/
|
||||
function highlightCode(code: string, infostring: string): string {
|
||||
// infostring 形如 "ts" / "javascript" / "rust,norun" —— 取首个 token 去空白小写
|
||||
const langRaw = (infostring || '').split(/[\s,]/)[0]?.trim().toLowerCase() ?? ''
|
||||
const lang = _LANG_ALIASES[langRaw] ?? langRaw
|
||||
let highlighted: string
|
||||
try {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
highlighted = hljs.highlight(code, { language: lang, ignoreIllegals: true }).value
|
||||
} else {
|
||||
// 无 infostring 或未注册语言:在已注册语言里自动猜测(仍有限,但覆盖常用)
|
||||
highlighted = hljs.highlightAuto(code).value
|
||||
}
|
||||
} catch {
|
||||
// 高亮异常时降级为纯 HTML 转义(marked 原行为),不破坏渲染
|
||||
return code.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
const cls = lang ? `hljs language-${lang}` : 'hljs'
|
||||
return `<pre><code class="${cls}">${highlighted}</code></pre>`
|
||||
}
|
||||
|
||||
/** 异步加载 marked + DOMPurify;加载完成后 mdReady 翻转触发重渲染(模块级,所有调用方共享) */
|
||||
async function loadMarkdown() {
|
||||
const [{ marked }, dp] = await Promise.all([import('marked'), import('dompurify')])
|
||||
marked.setOptions({ breaks: true, gfm: true })
|
||||
// UX-2025-05: 注入 code renderer 做语法高亮(marked.use renderer 合并,仅覆盖 code,
|
||||
// 其余 renderer 沿用 marked 默认)。
|
||||
marked.use({
|
||||
renderer: {
|
||||
code({ text, lang }: { text: string; lang?: string }) {
|
||||
return highlightCode(text, lang ?? '')
|
||||
},
|
||||
},
|
||||
})
|
||||
_marked = marked
|
||||
_purify = dp.default
|
||||
_mdCache.clear() // 渲染器就绪,清兜底缓存触发完整重渲染
|
||||
|
||||
@@ -8,11 +8,15 @@ export default {
|
||||
fileInfo: 'File Info',
|
||||
appendFile: 'Append File',
|
||||
searchFiles: 'Search Files',
|
||||
patchFile: 'Patch File',
|
||||
deleteFile: 'Delete File',
|
||||
renameFile: 'Rename/Move File',
|
||||
},
|
||||
aiTool: {
|
||||
fileInfoDesc: 'Get file or directory metadata (exists, size, line count, modified time, is binary, is directory) without reading content',
|
||||
appendFileDesc: 'Append content to end of file. Creates file if not exists. Returns bytes written and new file size',
|
||||
searchFilesDesc: 'Search for files matching a pattern (case-insensitive substring match) in a directory. Supports recursive search, max 50 results with total count and has_more flag',
|
||||
renameFileDesc: 'Rename or move a file (one tool covers both rename and move). from=source path, to=target path, overwrite=default false (reject if target exists). Atomic rename on same volume, auto copy+remove across volumes',
|
||||
},
|
||||
errorNotFound: 'Request failed: the endpoint or model does not exist. Please check the Provider configuration.',
|
||||
errorAuth: 'Request failed: the API key is invalid or lacks permission.',
|
||||
@@ -21,6 +25,15 @@ export default {
|
||||
streamInterruptedAfterTool: '⚠ The tool finished, but the following reply was interrupted (no data stream for a long time). You can click Continue to retry.',
|
||||
streamInterrupted: '⚠ The response was interrupted (no data stream for a long time). This may be due to a network disconnection or a backend error. Please try again.',
|
||||
queueFull: 'Send queue is full (max {limit} items)',
|
||||
forceSendConfirm: 'AI generation appears stuck. Force-stop and send anyway? (This may interrupt the current response)',
|
||||
forceSendBtn: 'Force Send',
|
||||
queueTimedOut: 'Queue timed out',
|
||||
approvalNotDelivered: 'Approval operation failed to reach the backend: {error}',
|
||||
approvalTimeout: '⚰ Approval timed out, automatically rejected: {toolName}',
|
||||
// AE-2025-07: Agentic loop progress bar copy
|
||||
// Note: max round / completed tool count are not surfaced by backend this round;
|
||||
// template conditionally omits them. Add *WithMax keys once backend exposes them.
|
||||
agenticProgress: '🔄 Round {round} · ⏳{pending} pending',
|
||||
agenticProgressNoPending: '🔄 Round {round}',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -18,7 +18,10 @@ export default {
|
||||
// ── Header buttons ──
|
||||
conversationList: 'Chat list',
|
||||
maximize: 'Maximize',
|
||||
pendingApprovalCount: '{n} pending',
|
||||
pendingApprovalHint: 'Click to jump to the first pending approval',
|
||||
restoreSidebar: 'Restore sidebar',
|
||||
sidebarResizeHint: 'Drag to resize sidebar',
|
||||
detachWindow: 'Detach window',
|
||||
closePanel: 'Close panel',
|
||||
dockRight: 'Dock to main window right',
|
||||
@@ -32,11 +35,22 @@ export default {
|
||||
providerNotConfigured: 'No AI provider configured. Please add one in settings.',
|
||||
notConfigured: 'Not configured',
|
||||
|
||||
// ── Empty states ──
|
||||
// ── Empty states (UX-2025-20: example prompt cards + no-provider guidance) ──
|
||||
emptyNoProvider: 'No AI provider configured',
|
||||
emptyNoProviderHint: 'Please add a provider in settings to start chatting',
|
||||
emptyTitle: 'Chat with AI assistant',
|
||||
emptyHint: 'Try "create a project" or "list all tasks"',
|
||||
// Example prompt cards: click to fill and send (4 prompts, DevFlow scenarios)
|
||||
examplePrompt1: 'Analyze this project’s dependencies',
|
||||
examplePrompt2: 'Help me write a workflow node',
|
||||
examplePrompt3: 'List all current tasks',
|
||||
examplePrompt4: 'Generate a project idea',
|
||||
goToSettings: 'Open settings',
|
||||
goToSettingsHint: 'The detached window cannot open settings directly. Configure AI providers on the main window’s Settings page.',
|
||||
|
||||
// ── Provider switch feedback (UX-2025-14) ──
|
||||
providerSwitched: 'Switched to {model}',
|
||||
providerSwitchHint: 'Current model: {model}',
|
||||
|
||||
// ── Back to bottom ──
|
||||
backToBottom: 'Back to bottom',
|
||||
@@ -63,5 +77,10 @@ export default {
|
||||
clearChat: 'Clear',
|
||||
confirmClearChat: 'Are you sure you want to clear all messages in this chat? This cannot be undone.',
|
||||
toastSendFail: 'Send failed: {msg}',
|
||||
|
||||
// ── Message copy (F-260615-13) ──
|
||||
copyMsg: 'Copy message',
|
||||
copied: 'Copied',
|
||||
copyFailed: 'Copy failed',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3,9 +3,14 @@ export default {
|
||||
running: 'Running…',
|
||||
approve: 'Approve',
|
||||
reject: 'Reject',
|
||||
approveAll: 'Approve All ({n})',
|
||||
rejectAll: 'Reject All',
|
||||
rejectedHint: 'User rejected this action',
|
||||
// B-260616-12: tool execution slow hint (frontend fallback, no backend cancel IPC)
|
||||
executionSlow: 'Tool "{name}" is taking longer than expected, please wait…',
|
||||
collapse: 'Collapse',
|
||||
expandAll: 'Expand all',
|
||||
collapseAll: 'Collapse all',
|
||||
written: 'written',
|
||||
items: 'items',
|
||||
lines: 'lines',
|
||||
@@ -45,5 +50,28 @@ export default {
|
||||
|
||||
projectLabel: '"{name}"',
|
||||
projectIdNotFound: '(project not found, id={id})',
|
||||
|
||||
// AE-2025-05: High-risk approval second confirm (ask once more before approve; misclick cancellable)
|
||||
confirmHighDelete: 'Permanently delete? This action cannot be undone',
|
||||
confirmHighExec: 'Run this command?',
|
||||
confirmHighGeneric: 'Proceed with this high-risk action?',
|
||||
|
||||
// Tool result detail format (plain-text result + approval placeholder appends arg summary)
|
||||
resultDetail: '{raw} ({detail})',
|
||||
|
||||
// Approval argument key labels
|
||||
argLabel: {
|
||||
id: 'ID',
|
||||
projectId: 'Project ID',
|
||||
name: 'Name',
|
||||
title: 'Title',
|
||||
description: 'Description',
|
||||
field: 'Field',
|
||||
value: 'Value',
|
||||
path: 'Path',
|
||||
priority: 'Priority',
|
||||
tags: 'Tags',
|
||||
source: 'Source',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -8,11 +8,15 @@ export default {
|
||||
fileInfo: '文件信息',
|
||||
appendFile: '追加写入',
|
||||
searchFiles: '搜索文件',
|
||||
patchFile: '修改文件',
|
||||
deleteFile: '删除文件',
|
||||
renameFile: '重命名/移动文件',
|
||||
},
|
||||
aiTool: {
|
||||
fileInfoDesc: '获取文件或目录的元信息(是否存在、大小、行数、修改时间、是否二进制、是否目录),不读取文件内容',
|
||||
appendFileDesc: '向文件末尾追加内容,文件不存在则自动创建。返回写入字数和新文件大小',
|
||||
searchFilesDesc: '在指定目录下搜索匹配模式(字符串包含匹配)的文件名,返回路径和大小列表。支持递归搜索,结果限 50 条',
|
||||
renameFileDesc: '重命名或移动文件(同一工具覆盖 rename 和 move)。from=源路径、to=目标路径、overwrite=默认 false 目标存在拒绝。同卷原子 rename,跨卷自动 copy+remove',
|
||||
},
|
||||
errorNotFound: '调用失败:接口地址或模型不存在,请检查 Provider 配置',
|
||||
errorAuth: '调用失败:API Key 无效或无权限',
|
||||
@@ -21,6 +25,14 @@ export default {
|
||||
streamInterruptedAfterTool: '⚠ 工具已执行完成,后续回复中断(长时间无数据流)。可点继续重试。',
|
||||
streamInterrupted: '⚠ 响应中断(长时间无数据流)。可能是网络断连或后端异常,请重试。',
|
||||
queueFull: '待发送队列已满(最多 {limit} 条)',
|
||||
forceSendConfirm: 'AI 生成似乎卡住了。是否强制结束当前生成并发送消息?(可能中断正在生成的回复)',
|
||||
forceSendBtn: '强制发送',
|
||||
queueTimedOut: '排队超时',
|
||||
approvalNotDelivered: '审批操作未送达后端:{error}',
|
||||
approvalTimeout: '⚰ 审批超时,已自动拒绝:{toolName}',
|
||||
// AE-2025-07: Agentic 循环进度条文案
|
||||
// 注:max 轮次/已完成工具数本轮后端暂不透传,模板条件渲染省略;后端补全后新增 *WithMax key
|
||||
agenticProgress: '🔄 循环 {round} · ⏳{pending}待审批',
|
||||
agenticProgressNoPending: '🔄 循环 {round}',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -18,7 +18,10 @@ export default {
|
||||
// ── Header 按钮 ──
|
||||
conversationList: '对话列表',
|
||||
maximize: '最大化',
|
||||
pendingApprovalCount: '{n} 项待审批',
|
||||
pendingApprovalHint: '点击跳转到第一个待审批项',
|
||||
restoreSidebar: '还原侧栏',
|
||||
sidebarResizeHint: '拖拽调整侧栏宽度',
|
||||
detachWindow: '分离窗口',
|
||||
closePanel: '关闭面板',
|
||||
dockRight: '吸附到主窗口右侧',
|
||||
@@ -32,11 +35,22 @@ export default {
|
||||
providerNotConfigured: '未配置 AI 提供商,请在设置中添加',
|
||||
notConfigured: '未配置',
|
||||
|
||||
// ── 空状态 ──
|
||||
// ── 空状态(UX-2025-20:示例问题卡片 + 无 provider 引导) ──
|
||||
emptyNoProvider: '未配置 AI 提供商',
|
||||
emptyNoProviderHint: '请在设置中添加 Provider 后开始对话',
|
||||
emptyTitle: '与 AI 助手对话',
|
||||
emptyHint: '可以说"帮我创建一个项目"或"列出所有任务"',
|
||||
// 示例问题卡片:点击自动填入并发送(4 条,DevFlow 场景相关)
|
||||
examplePrompt1: '分析这个项目的依赖关系',
|
||||
examplePrompt2: '帮我写一个工作流节点',
|
||||
examplePrompt3: '列出当前所有任务',
|
||||
examplePrompt4: '生成一个项目灵感',
|
||||
goToSettings: '去设置',
|
||||
goToSettingsHint: '分离窗口无法直接打开设置,请在主窗口的设置页配置 AI 提供商',
|
||||
|
||||
// ── Provider 切换反馈(UX-2025-14) ──
|
||||
providerSwitched: '已切换到 {model}',
|
||||
providerSwitchHint: '当前模型:{model}',
|
||||
|
||||
// ── 回到底部 ──
|
||||
backToBottom: '回到底部',
|
||||
@@ -63,5 +77,10 @@ export default {
|
||||
clearChat: '清空',
|
||||
confirmClearChat: '确定清空当前对话的所有消息?此操作不可恢复。',
|
||||
toastSendFail: '发送失败:{msg}',
|
||||
|
||||
// ── 消息拷贝(F-260615-13) ──
|
||||
copyMsg: '复制消息',
|
||||
copied: '已复制',
|
||||
copyFailed: '复制失败',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3,9 +3,14 @@ export default {
|
||||
running: '执行中…',
|
||||
approve: '批准执行',
|
||||
reject: '拒绝',
|
||||
approveAll: '全部批准 {n} 项',
|
||||
rejectAll: '全部拒绝',
|
||||
rejectedHint: '用户拒绝了此操作',
|
||||
// B-260616-12:工具执行超时提示(前端降级,无后端取消 IPC)
|
||||
executionSlow: '工具「{name}」执行较久,请稍候…',
|
||||
collapse: '收起',
|
||||
expandAll: '展开全部',
|
||||
collapseAll: '全部收起',
|
||||
written: '已写入',
|
||||
items: '项',
|
||||
lines: '行',
|
||||
@@ -48,5 +53,28 @@ export default {
|
||||
// 审批参数特化(id/project_id 回显项目名,查不到标注原 id)
|
||||
projectLabel: '「{name}」',
|
||||
projectIdNotFound: '(项目已不存在, id={id})',
|
||||
|
||||
// AE-2025-05:High 风险审批二次确认文案(批准前再问一次,误点可取消)
|
||||
confirmHighDelete: '确定永久删除?此操作不可恢复',
|
||||
confirmHighExec: '确定执行此命令?',
|
||||
confirmHighGeneric: '确定执行此高危操作?',
|
||||
|
||||
// 工具结果详情格式(非 JSON 纯文本 + 审批占位时补参数摘要)
|
||||
resultDetail: '{raw}({detail})',
|
||||
|
||||
// 审批参数键标签
|
||||
argLabel: {
|
||||
id: 'ID',
|
||||
projectId: '项目 ID',
|
||||
name: '名称',
|
||||
title: '标题',
|
||||
description: '描述',
|
||||
field: '字段',
|
||||
value: '新值',
|
||||
path: '路径',
|
||||
priority: '优先级',
|
||||
tags: '标签',
|
||||
source: '来源',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -34,8 +34,6 @@ import type { AiChatEvent, AiConversationSummary, AiMessage, AiProviderConfig, A
|
||||
* - 现有 push 逻辑(composables 内)零改动,上限由本模块 watch 统一兜底。
|
||||
*/
|
||||
const MESSAGE_CAP = 200
|
||||
/** push 单次增量上限(用于区分 push 增长 vs 整体替换)。useAiSend 连续 push user+ai=2,其余事件 push 1。 */
|
||||
const MESSAGE_PUSH_BURST = 2
|
||||
import { useAiEvents } from '@/composables/ai/useAiEvents'
|
||||
import { useAiStream } from '@/composables/ai/useAiStream'
|
||||
import { useAiSend } from '@/composables/ai/useAiSend'
|
||||
@@ -60,14 +58,19 @@ export const state = reactive({
|
||||
conversations: [] as AiConversationSummary[],
|
||||
activeConversationId: null as string | null,
|
||||
sidebarOpen: false,
|
||||
// 侧栏宽度(px,可拖拽;clamp 120~280,默认 200;持久化于 df-ai-ui。UX-2025-16)
|
||||
sidebarWidth: 200,
|
||||
// 窗口分离
|
||||
detached: false,
|
||||
// 吸附跟随中
|
||||
docked: false,
|
||||
// 本机技能(`/` 联想)
|
||||
skills: [] as SkillInfo[],
|
||||
// 待发送队列(生成中发的消息排队,AiCompleted 后自动续发)
|
||||
queue: [] as { text: string; skill?: string }[],
|
||||
// 待发送队列(生成中发的消息排队,AiCompleted 后自动续发;enqueuedAt 记录入队时间供超时检测)
|
||||
queue: [] as { text: string; skill?: string; enqueuedAt: number }[],
|
||||
// Agentic 循环当前轮次(0=非agentic/未开始;>0=收到后端 AiAgentRound 事件的 round)
|
||||
// AE-2025-07:进度条用; AiCompleted/AiError 复位为 0
|
||||
agentRound: 0,
|
||||
// 归档分组折叠态(默认折叠)
|
||||
archivedCollapsed: true,
|
||||
// token 用量展示(受 df-show-token-usage 开关控制;lastTokenUsage=最近一条回复,convTokenTotal=当前对话累计)
|
||||
@@ -82,7 +85,7 @@ export type { AiChatEvent, AiConversationSummary, AiMessage, AiProviderConfig, A
|
||||
/**
|
||||
* messages 软上限守卫:push 式增长超 MESSAGE_CAP 时,splice 掉最早条滚动淘汰。
|
||||
*
|
||||
* 仅拦截 push(oldVal>0 且 newVal-oldVal ≤ MESSAGE_PUSH_BURST);整体替换(切换对话)
|
||||
* 仅拦截 push(oldVal>0 且增量≤2);整体替换(切换对话)
|
||||
* 放行,避免截断后端历史。watch 在模块加载即注册(state 单例生命周期内常驻)。
|
||||
*/
|
||||
watch(
|
||||
@@ -90,7 +93,7 @@ watch(
|
||||
(newLen, oldLen) => {
|
||||
if (
|
||||
oldLen > 0 &&
|
||||
newLen - oldLen <= MESSAGE_PUSH_BURST &&
|
||||
newLen - oldLen <= 2 && // 最多连续 push 2 条触发滚动(useAiSend user+ai=2)
|
||||
newLen > MESSAGE_CAP
|
||||
) {
|
||||
state.messages.splice(0, newLen - MESSAGE_CAP)
|
||||
|
||||
@@ -373,12 +373,14 @@ import { reactive, ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { aiApi, knowledgeApi } from '@/api'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
import { useAiStore } from '@/stores/ai'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import type { AiProviderConfig } from '@/api/types'
|
||||
import i18n from '@/i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appSettings = useAppSettingsStore()
|
||||
const aiStore = useAiStore()
|
||||
|
||||
// ============================================================
|
||||
// AI 提供商 — 真实后端 CRUD
|
||||
@@ -418,6 +420,8 @@ const providerForm = reactive({
|
||||
async function loadProviders() {
|
||||
try {
|
||||
aiProviders.value = await aiApi.listProviders()
|
||||
// 同步刷新全局 store(AiChat 等 AI 面板消费方共享同一 state,避免改完仍提示「未配置」)
|
||||
await aiStore.loadProviders()
|
||||
} catch (e) {
|
||||
console.error(t('settings.toastLoadProviderFail'), e)
|
||||
showToast(t('settings.toastLoadProviderFail'), 'error')
|
||||
|
||||
Reference in New Issue
Block a user