优化: AI Chat 体验全面升级(图标/审批/目标提取/窗口管理)
- AI 入口图标:灯泡→星花 sparkles+发光,与灵感入口区分 - 目标提取根本性改造:用户消息规则→工具调用推理(infer_goal_from_tool_calls) - Vec<String>→Vec<GoalEntry>(text+status),active→completed 状态流转 - 多条/轮提取,system_prompt 仅注入 active 目标 - 代码拆分到 helpers.rs(解决 brace 嵌套致 pub(crate) 不可见) - 审批修复:patch_file 全档位自动放行,批量审批防抖,乐观更新竞态保护 - 审批通知浮层:全局右上角 ApprovalOverlay,窗口遮挡时可见 - 设置导入导出移除:价值低占用空间,跨设备同步建议复制 SQLite - 分离窗口默认置顶+置顶状态持久化(刷新后保持) - header 菜单聚合:清空/压缩上下文收入 ... popout - LLM 文字重叠防御:delta 重复检测+丢弃 - 置顶图标:星→大头针 pushpin - 文档/注释更新:过期 Vec<String>/chat.rs 提取描述修正
This commit is contained in:
@@ -911,7 +911,7 @@ fn migrate_v31(conn: &Connection) -> Result<()> {
|
|||||||
|
|
||||||
/// V32: ai_conversations 加 pinned_goals 列(对话透明化 L1 目标钉扎持久化)
|
/// V32: ai_conversations 加 pinned_goals 列(对话透明化 L1 目标钉扎持久化)
|
||||||
///
|
///
|
||||||
/// 对话目标由 PerConvState.pinned_goals(Vec<String>)管理,原先仅在内存态存在,
|
/// 对话目标由 PerConvState.pinned_goals(Vec<GoalEntry>)管理,原先仅在内存态存在,
|
||||||
/// 此迁移为其提供持久化列,默认空 JSON 数组'[]'。
|
/// 此迁移为其提供持久化列,默认空 JSON 数组'[]'。
|
||||||
fn migrate_v32(conn: &Connection) -> Result<()> {
|
fn migrate_v32(conn: &Connection) -> Result<()> {
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
|
|||||||
@@ -360,7 +360,7 @@ pub struct AiConversationRecord {
|
|||||||
pub pinned: bool, // 是否置顶(排序置前;UX-17)
|
pub pinned: bool, // 是否置顶(排序置前;UX-17)
|
||||||
pub prompt_tokens: Option<i64>, // 输入 token 累计(流式 usage 落库)
|
pub prompt_tokens: Option<i64>, // 输入 token 累计(流式 usage 落库)
|
||||||
pub completion_tokens: Option<i64>, // 输出 token 累计(流式 usage 落库)
|
pub completion_tokens: Option<i64>, // 输出 token 累计(流式 usage 落库)
|
||||||
pub pinned_goals: Option<String>, // 对话目标钉扎持久化(JSON 字符串数组,默认'[]')
|
pub pinned_goals: Option<String>, // 对话目标钉扎持久化(JSON GoalEntry 数组 [{text,status}],兼容旧 ["text"] 格式,默认'[]')
|
||||||
pub pending_approvals: Option<String>, // 挂起审批快照(JSON 对象,以 tool_call_id 为键,默认'{}')
|
pub pending_approvals: Option<String>, // 挂起审批快照(JSON 对象,以 tool_call_id 为键,默认'{}')
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
|
|||||||
235
src-tauri/src/commands/ai/agentic/helpers.rs
Normal file
235
src-tauri/src/commands/ai/agentic/helpers.rs
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
//! 审批后循环恢复 — 从 agentic/mod.rs 拆分。
|
||||||
|
//! 原代码被嵌套在 run_agentic_loop 内部导致 pub(crate) 不可见。
|
||||||
|
|
||||||
|
use tauri::{AppHandle, Emitter, Manager};
|
||||||
|
use crate::state::AppState;
|
||||||
|
use super::approval_timeout;
|
||||||
|
use crate::commands::ai::prompt::{build_system_prompt, get_active_provider};
|
||||||
|
use crate::commands::ai::knowledge_inject::inject_knowledge_into_prompt;
|
||||||
|
use crate::commands::ai::{AiChatEvent, ErrorType, SessionState, GoalEntry};
|
||||||
|
use super::conv_state::ConvState;
|
||||||
|
|
||||||
|
/// BUG-260617-05: try_continue_agent_loop 续跑判定所需 session 字段的一次性快照。
|
||||||
|
struct ContinueSnapshot {
|
||||||
|
is_generating: bool,
|
||||||
|
has_pending: bool,
|
||||||
|
pending_conv_id: Option<String>,
|
||||||
|
agent_language: Option<String>,
|
||||||
|
model_override: Option<String>,
|
||||||
|
pinned_goals_snapshot: Vec<GoalEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查是否所有待审批已处理,如果是则恢复 agentic 循环
|
||||||
|
pub async fn try_continue_agent_loop(
|
||||||
|
app: &AppHandle,
|
||||||
|
state: &AppState,
|
||||||
|
conv_id: &str,
|
||||||
|
start_iteration: usize,
|
||||||
|
) {
|
||||||
|
approval_timeout::cleanup_expired_approvals(app, state, conv_id).await;
|
||||||
|
|
||||||
|
let snap = {
|
||||||
|
let session = state.ai_session.lock().await;
|
||||||
|
let has_pending = session.session_state(conv_id) == 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);
|
||||||
|
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();
|
||||||
|
ContinueSnapshot {
|
||||||
|
is_generating,
|
||||||
|
has_pending,
|
||||||
|
pending_conv_id,
|
||||||
|
agent_language,
|
||||||
|
model_override,
|
||||||
|
pinned_goals_snapshot,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let should_continue = snap.is_generating && !snap.has_pending;
|
||||||
|
|
||||||
|
if !should_continue {
|
||||||
|
if snap.is_generating {
|
||||||
|
tracing::info!(conv_id = %conv_id, "[ai] try_continue 跳过:仍有待审批,转审批等待态");
|
||||||
|
} else {
|
||||||
|
tracing::info!(conv_id = %conv_id, "[ai] try_continue 跳过:generating 已复位");
|
||||||
|
let emit_conv_id = match snap.pending_conv_id.clone() {
|
||||||
|
Some(cid) => cid,
|
||||||
|
None => conv_id.to_string(),
|
||||||
|
};
|
||||||
|
let ev = AiChatEvent::AiCompleted {
|
||||||
|
total_tokens: 0, prompt_tokens: 0, completion_tokens: 0,
|
||||||
|
incomplete: None,
|
||||||
|
conversation_id: Some(emit_conv_id),
|
||||||
|
pinned_goals: snap.pinned_goals_snapshot.clone(),
|
||||||
|
};
|
||||||
|
let _ = app.emit("ai-chat-event", ev.clone());
|
||||||
|
let _ = app.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 非法"),
|
||||||
|
}
|
||||||
|
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()),
|
||||||
|
});
|
||||||
|
let _ = app.state::<crate::state::AppState>().ai_event_bus.publish_event(AiChatEvent::AiError {
|
||||||
|
error: e, error_type: None,
|
||||||
|
conversation_id: Some(conv_id.to_string()),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let lang = snap.agent_language.clone().unwrap_or_else(|| "zh-CN".to_string());
|
||||||
|
let conv_id_owned = conv_id.to_string();
|
||||||
|
let system_prompt = build_system_prompt(state, &lang).await;
|
||||||
|
|
||||||
|
let session_arc = state.ai_session.clone();
|
||||||
|
let tools_arc = state.ai_tools.clone();
|
||||||
|
let db = state.db.clone();
|
||||||
|
let app_handle = app.clone();
|
||||||
|
let knowledge_config = state.knowledge_config.lock().await.clone();
|
||||||
|
let llm_concurrency = state.llm_concurrency.clone();
|
||||||
|
|
||||||
|
let system_prompt = inject_knowledge_into_prompt(state, conv_id, system_prompt, &knowledge_config).await;
|
||||||
|
|
||||||
|
let max_iterations = state.agent_max_iterations.load(std::sync::atomic::Ordering::SeqCst);
|
||||||
|
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)
|
||||||
|
};
|
||||||
|
if !still_generating {
|
||||||
|
let ev = AiChatEvent::AiCompleted {
|
||||||
|
total_tokens: 0, prompt_tokens: 0, completion_tokens: 0,
|
||||||
|
incomplete: None,
|
||||||
|
conversation_id: Some(conv_id_owned.clone()),
|
||||||
|
pinned_goals: snap.pinned_goals_snapshot.clone(),
|
||||||
|
};
|
||||||
|
let _ = app.emit("ai-chat-event", ev.clone());
|
||||||
|
let _ = app.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = app.emit("ai-chat-event", AiChatEvent::AiAgentRound {
|
||||||
|
round: 0, conversation_id: Some(conv_id_owned.clone()),
|
||||||
|
});
|
||||||
|
let _ = app.state::<crate::state::AppState>().ai_event_bus.publish_event(AiChatEvent::AiAgentRound {
|
||||||
|
round: 0, conversation_id: Some(conv_id_owned.clone()),
|
||||||
|
});
|
||||||
|
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
super::run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id_owned, knowledge_config, llm_concurrency, max_iterations, max_retries, start_iteration, model_override).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 LLM 本轮调用的工具中推理目标描述(本轮可能多个,全部提取)。
|
||||||
|
/// 工具调用是结构化数据(工具名+参数),比从文本中提取更可靠。
|
||||||
|
pub(crate) fn infer_goal_from_tool_calls(tool_calls: &std::collections::HashMap<u32, crate::commands::ai::ToolCallDraft>) -> Vec<String> {
|
||||||
|
if tool_calls.is_empty() { return vec![]; }
|
||||||
|
let mut goals: Vec<String> = Vec::new();
|
||||||
|
for tc in tool_calls.values() {
|
||||||
|
let goal = match tc.name.as_str() {
|
||||||
|
"patch_file" | "write_file" | "edit_file" | "append_file" => {
|
||||||
|
serde_json::from_str::<serde_json::Value>(&tc.args).ok()
|
||||||
|
.and_then(|args| args.get("path").and_then(|v| v.as_str()).map(|p| {
|
||||||
|
let f = p.rsplit('/').next().or_else(|| p.rsplit('\\').next()).unwrap_or(p);
|
||||||
|
format!("修改 {}", f)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
"read_file" | "file_info" => {
|
||||||
|
serde_json::from_str::<serde_json::Value>(&tc.args).ok()
|
||||||
|
.and_then(|args| args.get("path").and_then(|v| v.as_str()).map(|p| {
|
||||||
|
let f = p.rsplit('/').next().or_else(|| p.rsplit('\\').next()).unwrap_or(p);
|
||||||
|
format!("查看 {}", f)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
"search_files" | "grep" => {
|
||||||
|
serde_json::from_str::<serde_json::Value>(&tc.args).ok()
|
||||||
|
.and_then(|args| args.get("pattern").or_else(|| args.get("query")).and_then(|v| v.as_str()).map(|q| {
|
||||||
|
let s = if q.len() > 25 { &q[..25] } else { q };
|
||||||
|
format!("搜索 {}", s)
|
||||||
|
}))
|
||||||
|
.or_else(|| Some("搜索文件".to_string()))
|
||||||
|
}
|
||||||
|
"run_command" => {
|
||||||
|
serde_json::from_str::<serde_json::Value>(&tc.args).ok()
|
||||||
|
.and_then(|args| args.get("command").and_then(|v| v.as_str()).map(|c| {
|
||||||
|
let s = if c.len() > 25 { &c[..25] } else { c };
|
||||||
|
format!("执行 {}", s)
|
||||||
|
}))
|
||||||
|
.or_else(|| Some("执行命令".to_string()))
|
||||||
|
}
|
||||||
|
"create_project" => {
|
||||||
|
serde_json::from_str::<serde_json::Value>(&tc.args).ok()
|
||||||
|
.and_then(|args| args.get("name").and_then(|v| v.as_str()).map(|n| format!("创建项目 {}", n)))
|
||||||
|
}
|
||||||
|
"create_idea" => {
|
||||||
|
serde_json::from_str::<serde_json::Value>(&tc.args).ok()
|
||||||
|
.and_then(|args| args.get("title").and_then(|v| v.as_str()).map(|t| format!("捕获灵感 {}", t)))
|
||||||
|
}
|
||||||
|
"run_workflow" => Some("执行工作流".to_string()),
|
||||||
|
"delete_file" | "rename_file" => {
|
||||||
|
serde_json::from_str::<serde_json::Value>(&tc.args).ok()
|
||||||
|
.and_then(|args| args.get("path").and_then(|v| v.as_str()).map(|p| {
|
||||||
|
let f = p.rsplit('/').next().or_else(|| p.rsplit('\\').next()).unwrap_or(p);
|
||||||
|
format!("处理 {}", f)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
"list_directory" => {
|
||||||
|
serde_json::from_str::<serde_json::Value>(&tc.args).ok()
|
||||||
|
.and_then(|args| args.get("path").and_then(|v| v.as_str()).map(|p| format!("浏览 {}", p)))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(g) = goal { if !goals.contains(&g) { goals.push(g); } }
|
||||||
|
}
|
||||||
|
goals
|
||||||
|
}
|
||||||
|
|
||||||
|
/// G2 探索熔断:判定工具结果是否为「空结果」(空成功,非失败)。
|
||||||
|
pub(crate) fn is_empty_tool_result(content: &str) -> bool {
|
||||||
|
let trimmed = content.trim();
|
||||||
|
if trimmed.is_empty() { return true; }
|
||||||
|
const EMPTY_MARKERS: &[&str] = &[
|
||||||
|
"\"total\":0", "\"entries\":[]", "\"matches\":[]", "\"results\":[]", "\"files\":[]",
|
||||||
|
];
|
||||||
|
for marker in EMPTY_MARKERS {
|
||||||
|
if trimmed.contains(marker) { return true; }
|
||||||
|
}
|
||||||
|
const EMPTY_TEXT: &[&str] = &[
|
||||||
|
"未找到", "没有找到", "无匹配", "没有匹配", "未匹配", "未发现", "无记录",
|
||||||
|
"No matches", "no matches", "0 results", "0 matches", "没有数据", "没有符合",
|
||||||
|
];
|
||||||
|
for marker in EMPTY_TEXT {
|
||||||
|
if trimmed.contains(marker) { return true; }
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn test_empty_tool_result() {
|
||||||
|
assert!(is_empty_tool_result(""));
|
||||||
|
assert!(is_empty_tool_result(" "));
|
||||||
|
assert!(is_empty_tool_result(r#"{"total":0}"#));
|
||||||
|
assert!(is_empty_tool_result(r#"{"entries":[]}"#));
|
||||||
|
assert!(is_empty_tool_result("未找到相关文件"));
|
||||||
|
assert!(!is_empty_tool_result(r#"{"total":5}"#));
|
||||||
|
assert!(!is_empty_tool_result(r#"{"entries":["a.txt"]}"#));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,12 +45,17 @@ use crate::state::{AppState, LlmConcurrency};
|
|||||||
use super::audit::process_tool_calls;
|
use super::audit::process_tool_calls;
|
||||||
// compress_via_llm 已随压缩逻辑迁至 context_lifecycle.rs(maybe_auto_compress 内调用)。
|
// compress_via_llm 已随压缩逻辑迁至 context_lifecycle.rs(maybe_auto_compress 内调用)。
|
||||||
use super::conversation::{save_conversation, TokenAccumulator};
|
use super::conversation::{save_conversation, TokenAccumulator};
|
||||||
use super::knowledge_inject::{inject_knowledge_into_prompt, maybe_spawn_extraction};
|
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::prompt::{build_system_prompt, get_active_provider};
|
||||||
use super::stream_recv::{stream_llm, StreamResult};
|
use super::stream_recv::{stream_llm, StreamResult};
|
||||||
use super::title::{ensure_conversation_title, spawn_ensure_title};
|
use super::title::{ensure_conversation_title, spawn_ensure_title};
|
||||||
|
#[allow(unused_imports)]
|
||||||
use super::{AiChatEvent, AiSession, ErrorType, SessionState};
|
use super::{AiChatEvent, AiSession, ErrorType, SessionState};
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
use super::ToolCallDraft;
|
||||||
// ConvState 经本文件内 `pub mod conv_state;` 同 crate 直接访问(conv_state::ConvState)。
|
// ConvState 经本文件内 `pub mod conv_state;` 同 crate 直接访问(conv_state::ConvState)。
|
||||||
|
|
||||||
/// L1 补丁:run_agentic_loop 入口 provider 解析超时保护的内部错误类型。
|
/// L1 补丁:run_agentic_loop 入口 provider 解析超时保护的内部错误类型。
|
||||||
@@ -105,8 +110,9 @@ pub const TOPIC_MARKER_ENABLED: bool = true;
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// G1 目标钉扎(治 R1 目标消息被压缩出局 + R3 重锚定 + R5 prompt 无锚点,2026-06-26)
|
// G1 目标钉扎(治 R1 目标消息被压缩出局 + R3 重锚定 + R5 prompt 无锚点,2026-06-26)
|
||||||
//
|
//
|
||||||
// 机制:首条 active user 消息提取目标存 PerConvState.pinned_goal(内容态字段,绕过
|
// 机制:run_agentic_loop 每轮从 LLM 工具调用推理目标(infer_goal_from_tool_calls),
|
||||||
// sanitize step0 is_active 过滤),run_agentic_loop 入口拼进 system_prompt 尾部。
|
// 存 PerConvState.pinned_goals(Vec<GoalEntry>,内容态字段,绕过 sanitize step0 is_active 过滤),
|
||||||
|
// run_agentic_loop 入口拼 active 目标进 system_prompt 尾部。
|
||||||
// system_prompt 是 loop 不变量 + build_for_request 不裁剪,故目标天然免疫压缩/裁剪,
|
// system_prompt 是 loop 不变量 + build_for_request 不裁剪,故目标天然免疫压缩/裁剪,
|
||||||
// 彻底治 R1(目标消息物理出局)/R5(prompt 说教无锚点),无 insert_at(0) 的连续 System
|
// 彻底治 R1(目标消息物理出局)/R5(prompt 说教无锚点),无 insert_at(0) 的连续 System
|
||||||
// 1214/首位锚点稀释/小预算被裁三重风险。
|
// 1214/首位锚点稀释/小预算被裁三重风险。
|
||||||
@@ -114,10 +120,12 @@ pub const TOPIC_MARKER_ENABLED: bool = true;
|
|||||||
|
|
||||||
/// G1 总开关:目标钉扎是否启用(默认 true)。
|
/// G1 总开关:目标钉扎是否启用(默认 true)。
|
||||||
///
|
///
|
||||||
/// true(默认):chat.rs 两处 push 后提取首条 user 目标存 pinned_goal + loop 入口拼 system_prompt。
|
/// true(默认):run_agentic_loop 每轮从 LLM 工具调用推理目标(infer_goal_from_tool_calls),
|
||||||
/// false(回退):两处提取跳过 + loop 入口拼接跳过,pinned_goal 永远 None,system_prompt 零变化,
|
/// 存入 PerConvState.pinned_goals(Vec<GoalEntry>) + loop 入口拼 active 目标进 system_prompt。
|
||||||
|
/// false(回退):提取跳过 + loop 入口拼接跳过,pinned_goals 永远空 Vec,system_prompt 零变化,
|
||||||
/// 完全退回改动前行为(目标靠压缩摘要 + topic marker + 聚焦准则 prompt 续命)。单点回退,
|
/// 完全退回改动前行为(目标靠压缩摘要 + topic marker + 聚焦准则 prompt 续命)。单点回退,
|
||||||
/// 不影响 G2/G4/G5(对齐 KEYWORD_FALLBACK_ENABLED 模式:每改配开关 + 兜底降级旧行为)。
|
/// 不影响 G2/G4/G5(对齐 KEYWORD_FALLBACK_ENABLED 模式:每改配开关 + 兜底降级旧行为)。
|
||||||
|
/// 2026-07-03 改:提取从 chat.rs 用户消息规则 → run_agentic_loop 工具调用推理(helpers.rs)。
|
||||||
pub const GOAL_PIN_ENABLED: bool = true;
|
pub const GOAL_PIN_ENABLED: bool = true;
|
||||||
|
|
||||||
/// G1 banner 开关:目标拼进 system_prompt 时是否加「## 当前目标」分隔标题(默认 true)。
|
/// G1 banner 开关:目标拼进 system_prompt 时是否加「## 当前目标」分隔标题(默认 true)。
|
||||||
@@ -272,6 +280,10 @@ mod knowledge_lifecycle;
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
pub mod conv_state;
|
pub mod conv_state;
|
||||||
pub mod command_lock;
|
pub mod command_lock;
|
||||||
|
pub(crate) mod helpers;
|
||||||
|
pub(crate) use helpers::try_continue_agent_loop;
|
||||||
|
pub(crate) use helpers::infer_goal_from_tool_calls;
|
||||||
|
pub(crate) use helpers::is_empty_tool_result;
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// F-260614-04 / F-260614-04b: 单 Provider 流式结果 + fallback 辅助
|
// F-260614-04 / F-260614-04b: 单 Provider 流式结果 + fallback 辅助
|
||||||
@@ -798,19 +810,26 @@ pub(crate) async fn run_agentic_loop(
|
|||||||
// pinned_goals 永远空 Vec(单点回退等价改动前)。拼接在 sys_tokens 估算前。
|
// pinned_goals 永远空 Vec(单点回退等价改动前)。拼接在 sys_tokens 估算前。
|
||||||
//
|
//
|
||||||
// 注:仅 run_agentic_loop 入口注入。手动压缩/标题/提炼等路径不注入目标。
|
// 注:仅 run_agentic_loop 入口注入。手动压缩/标题/提炼等路径不注入目标。
|
||||||
|
//
|
||||||
|
// 目标提取方式(2026-07-03 改):从 LLM 本轮工具调用推理目标描述(工具名+路径),
|
||||||
|
// 不再从用户消息规则提取,也不依赖 LLM 输出结构化标记。
|
||||||
|
// 推理结果存入 pinned_goals: Vec<GoalEntry>,含 text+status 状态追踪。
|
||||||
|
// 新目标加入时自动标记之前的 active 为 completed。
|
||||||
let mut system_prompt = system_prompt;
|
let mut system_prompt = system_prompt;
|
||||||
if GOAL_PIN_ENABLED {
|
if GOAL_PIN_ENABLED {
|
||||||
let goals: Vec<String> = {
|
let goals: Vec<super::GoalEntry> = {
|
||||||
let session = session_arc.lock().await;
|
let session = session_arc.lock().await;
|
||||||
session
|
session
|
||||||
.conv_read(&conv_id)
|
.conv_read(&conv_id)
|
||||||
.map(|c| c.pinned_goals.clone())
|
.map(|c| c.pinned_goals.clone())
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
};
|
};
|
||||||
if !goals.is_empty() {
|
// 只把 active 目标注入 system_prompt(completed 不干扰 LLM 注意力)
|
||||||
let goal_lines: Vec<String> = goals.iter().enumerate().map(|(i, g)| {
|
let active_goals: Vec<&super::GoalEntry> = goals.iter().filter(|g| matches!(g.status, super::GoalStatus::Active)).collect();
|
||||||
let g = g.trim();
|
if !active_goals.is_empty() {
|
||||||
let truncated: String = g.chars().take(GOAL_MAX_CHARS).collect();
|
let goal_lines: Vec<String> = active_goals.iter().enumerate().map(|(i, g)| {
|
||||||
|
let trimmed = g.text.trim();
|
||||||
|
let truncated: String = trimmed.chars().take(GOAL_MAX_CHARS).collect();
|
||||||
let truncated = if truncated.chars().count() >= GOAL_MAX_CHARS {
|
let truncated = if truncated.chars().count() >= GOAL_MAX_CHARS {
|
||||||
format!("{}…", truncated)
|
format!("{}…", truncated)
|
||||||
} else {
|
} else {
|
||||||
@@ -830,7 +849,7 @@ pub(crate) async fn run_agentic_loop(
|
|||||||
tracing::info!(
|
tracing::info!(
|
||||||
conv_id = %conv_id,
|
conv_id = %conv_id,
|
||||||
count = goals.len(),
|
count = goals.len(),
|
||||||
first_goal = %goals.first().map(|g| &g[..std::cmp::min(120, g.len())]).unwrap_or(""),
|
first_goal = %goals.first().map(|g| &g.text[..std::cmp::min(120, g.text.len())]).unwrap_or(""),
|
||||||
"[ai] G1 目标钉扎:已把 {} 个 pinned_goals 拼进 system_prompt",
|
"[ai] G1 目标钉扎:已把 {} 个 pinned_goals 拼进 system_prompt",
|
||||||
goals.len()
|
goals.len()
|
||||||
);
|
);
|
||||||
@@ -848,9 +867,9 @@ pub(crate) async fn run_agentic_loop(
|
|||||||
system_prompt = format!("{}\n\n{}\n\n{}", system_prompt, env_prompt, behavior_prompt);
|
system_prompt = format!("{}\n\n{}\n\n{}", system_prompt, env_prompt, behavior_prompt);
|
||||||
|
|
||||||
// ── 多 Agent 并行执行:Coordinator 分解(plan_execution_enabled 时) ──
|
// ── 多 Agent 并行执行:Coordinator 分解(plan_execution_enabled 时) ──
|
||||||
// 关闭时退单 Agent(等价原行为);开启时分解意图→Plan,dispatch+merge→合并产出落回主对话
|
|
||||||
// 对话透明化 L1:拍快照供 AiCompleted 事件携带(coordinator 路径出口也用)
|
// 对话透明化 L1:拍快照供 AiCompleted 事件携带(coordinator 路径出口也用)
|
||||||
let pinned_goals_snapshot: Vec<String> = {
|
// 只取 text(前端不需要状态信息)
|
||||||
|
let pinned_goals_snapshot: Vec<super::GoalEntry> = {
|
||||||
let session = session_arc.lock().await;
|
let session = session_arc.lock().await;
|
||||||
session
|
session
|
||||||
.conv_read(&conv_id)
|
.conv_read(&conv_id)
|
||||||
@@ -1444,7 +1463,55 @@ pub(crate) async fn run_agentic_loop(
|
|||||||
msg.reasoning_content = last_reasoning_content.clone();
|
msg.reasoning_content = last_reasoning_content.clone();
|
||||||
session.conv(&conv_id).messages.push(msg);
|
session.conv(&conv_id).messages.push(msg);
|
||||||
}
|
}
|
||||||
}
|
// G1 目标提取: 从工具调用推理目标描述。
|
||||||
|
// 工具调用是结构化数据(工具名+路径),比从文本中提取更可靠。
|
||||||
|
// 无工具调用时不提取(已有目标保持不变,该轮无新目标)。
|
||||||
|
if GOAL_PIN_ENABLED {
|
||||||
|
// 从工具调用推理目标(本轮可能多个工具,全部提取)
|
||||||
|
let new_goals = infer_goal_from_tool_calls(&tool_calls_acc);
|
||||||
|
if !new_goals.is_empty() {
|
||||||
|
let goal_conv = session.conv(&conv_id);
|
||||||
|
let mut added = false;
|
||||||
|
for g in &new_goals {
|
||||||
|
// 去重:文本不在已有目标中(包含关系检查)
|
||||||
|
let is_dup = goal_conv.pinned_goals.iter().any(|e|
|
||||||
|
e.text.contains(g.as_str()) || g.contains(&e.text)
|
||||||
|
);
|
||||||
|
if !is_dup {
|
||||||
|
goal_conv.pinned_goals.push(super::GoalEntry::new(g.clone()));
|
||||||
|
added = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 有新目标 → 标记之前的 active 为 completed(LLM 进入下一任务)
|
||||||
|
if added && goal_conv.pinned_goals.len() > new_goals.len() {
|
||||||
|
let active_until = goal_conv.pinned_goals.len() - new_goals.len();
|
||||||
|
for entry in goal_conv.pinned_goals.iter_mut().take(active_until) {
|
||||||
|
if matches!(entry.status, super::GoalStatus::Active) {
|
||||||
|
entry.status = super::GoalStatus::Completed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 超上限时保留 active,淘汰最早 completed
|
||||||
|
if goal_conv.pinned_goals.len() > MAX_GOALS {
|
||||||
|
let active: Vec<super::GoalEntry> = goal_conv.pinned_goals.iter()
|
||||||
|
.filter(|g| matches!(g.status, super::GoalStatus::Active))
|
||||||
|
.cloned().collect();
|
||||||
|
if active.len() <= MAX_GOALS {
|
||||||
|
let keep = MAX_GOALS - active.len();
|
||||||
|
let completed: Vec<super::GoalEntry> = goal_conv.pinned_goals.iter()
|
||||||
|
.filter(|g| matches!(g.status, super::GoalStatus::Completed))
|
||||||
|
.rev().take(keep).cloned().collect();
|
||||||
|
let mut merged = active;
|
||||||
|
merged.extend(completed.into_iter().rev());
|
||||||
|
goal_conv.pinned_goals = merged;
|
||||||
|
} else {
|
||||||
|
goal_conv.pinned_goals.truncate(MAX_GOALS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// L3 [UPDATE_GOALS] 标记:LLM 可主动修正目标列表(意图增强/纠偏)。
|
||||||
|
// 后续独立 PR 实现:解析 [UPDATE_GOALS]...[END] 块并替换 pinned_goals。
|
||||||
|
}
|
||||||
|
|
||||||
// 停止信号:已生成文本入库后退出,不再执行后续工具调用
|
// 停止信号:已生成文本入库后退出,不再执行后续工具调用
|
||||||
if stop_flag.load(Ordering::SeqCst) {
|
if stop_flag.load(Ordering::SeqCst) {
|
||||||
@@ -1472,7 +1539,9 @@ pub(crate) async fn run_agentic_loop(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 无工具调用 → 最终文本响应,正常收敛退出
|
// 无工具调用 → 最终文本响应,正常收敛退出
|
||||||
|
#[allow(unused_assignments)]
|
||||||
if !has_tool_calls { converged = true; break; }
|
if !has_tool_calls { converged = true; break; }
|
||||||
|
let _ = converged;
|
||||||
|
|
||||||
// 处理工具调用(Low 自动执行 / Medium+High 待审批)
|
// 处理工具调用(Low 自动执行 / Medium+High 待审批)
|
||||||
let pending_count = {
|
let pending_count = {
|
||||||
@@ -1637,7 +1706,7 @@ pub(crate) async fn run_agentic_loop(
|
|||||||
String::new()
|
String::new()
|
||||||
} else {
|
} else {
|
||||||
let goal_summary = goals.iter()
|
let goal_summary = goals.iter()
|
||||||
.map(|g| g.trim())
|
.map(|g| g.text.trim())
|
||||||
.filter(|g| !g.is_empty())
|
.filter(|g| !g.is_empty())
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join("; ");
|
.join("; ");
|
||||||
@@ -1784,353 +1853,4 @@ pub(crate) async fn run_agentic_loop(
|
|||||||
pinned_goals: pinned_goals_snapshot.clone(),
|
pinned_goals: pinned_goals_snapshot.clone(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// G2 探索熔断:判定工具结果是否为「空结果」(空成功,非失败)。
|
|
||||||
///
|
|
||||||
/// 关键词表**对齐真实 tool_result 格式**(audit.rs tool 执行经 `serde_json::Value::to_string()`
|
|
||||||
/// 序列化为紧凑 JSON,无空格):
|
|
||||||
/// - search_files / grep 空 → `{"total":0,...}` → 命中 `"total":0`(紧凑,冒号后无空格)。
|
|
||||||
/// - list_directory 空 → `{"entries":[],...}` → 命中 `"entries":[]`。
|
|
||||||
/// - read_symbol 未找到 → 含「未找到」回退提示。
|
|
||||||
/// - 通用空 → `"matches":[]` / `"results":[]` / `No matches` / `无匹配` / `未找到` / `没有找到`。
|
|
||||||
///
|
|
||||||
/// 与 L1 is_failure(禁止/失败/Error)**不重叠**:空结果「total:0」不含失败关键词,L1 不命中。
|
|
||||||
/// 漏判风险由 STALL_BREAKER_THRESHOLD=3 + 两段式 WARN_FIRST 容错(多轮漏判才误熔断,最坏退 max_iterations)。
|
|
||||||
pub(crate) fn is_empty_tool_result(content: &str) -> bool {
|
|
||||||
let trimmed = content.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// 紧凑 JSON 空结果标记(对齐 serde_json::Value::to_string() 输出,冒号后无空格)。
|
|
||||||
// search_files/grep:"total":0 ;list_directory:"entries":[];通用空数组 matches/results/files。
|
|
||||||
const EMPTY_MARKERS: &[&str] = &[
|
|
||||||
"\"total\":0",
|
|
||||||
"\"entries\":[]",
|
|
||||||
"\"matches\":[]",
|
|
||||||
"\"results\":[]",
|
|
||||||
"\"files\":[]",
|
|
||||||
];
|
|
||||||
for marker in EMPTY_MARKERS {
|
|
||||||
if trimmed.contains(marker) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 中文 / 英文「未找到」类提示(read_symbol 等回退文本)。
|
|
||||||
const EMPTY_TEXT: &[&str] = &[
|
|
||||||
"未找到",
|
|
||||||
"没有找到",
|
|
||||||
"无匹配",
|
|
||||||
"没有匹配",
|
|
||||||
"未匹配",
|
|
||||||
"未发现",
|
|
||||||
"无记录",
|
|
||||||
"No matches",
|
|
||||||
"no matches",
|
|
||||||
"0 results",
|
|
||||||
"0 matches",
|
|
||||||
"没有数据",
|
|
||||||
"没有符合",
|
|
||||||
];
|
|
||||||
for marker in EMPTY_TEXT {
|
|
||||||
if trimmed.contains(marker) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 检查是否所有待审批已处理,如果是则恢复 agentic 循环
|
|
||||||
///
|
|
||||||
/// B-260615-08:所有静默 return 点显式 emit 收尾事件,避免前端 streaming=true 永久卡。
|
|
||||||
/// 各 return 点的语义判断:
|
|
||||||
/// 1) should_continue=false(generating 已复位 / pending_approvals 非空):
|
|
||||||
/// - generating=false → 用户点了停止(ai_chat_stop 复位)或会话已结束,emit AiCompleted 标当前轮收敛
|
|
||||||
/// (streaming=true 由 AiCompleted 清理)
|
|
||||||
/// - pending_approvals 非空 → 转入审批等待态(其他审批未决),emit AiCompleted 标当前轮结束
|
|
||||||
/// (前端审批态 watchdog 已 clear,不卡)
|
|
||||||
/// 2) get_active_provider Err → 无可用 provider(配置丢失/全删),无法续生成,emit AiError
|
|
||||||
/// (语义:配置错误,用户需设 provider;非 generating 复位可恢复)
|
|
||||||
///
|
|
||||||
/// R-PD-6: conv_id 来源从全局 active_conversation_id 解耦到审批所属会话。
|
|
||||||
/// 触发本函数的 ai_approve 已 remove 触发审批,但 pending_approvals 内剩余审批(若 has_pending)
|
|
||||||
/// 仍各自携带 conversation_id(审批产生时由 process_tool_calls 写入,业务真相源)。
|
|
||||||
/// 故 has_pending=true 分支(审批等待态)直接取剩余审批的 conversation_id 做 conv_id,
|
|
||||||
/// 不读 active_conversation_id 全局单例——该字段在审批等待态(非 generating-only 期)可被
|
|
||||||
/// ai_chat_stop/clear/switch 并发改写,属竞态耦合。has_pending=false(全部审批已处理,续生成)
|
|
||||||
/// 分支:审批已被 remove,改为以剩余 pending_approvals 任一 conversation_id 做一致性校验
|
|
||||||
/// (此处空,校验通过即沿用全局值,该期 generating=true 且 switch 为 readonly 不并发)。
|
|
||||||
pub(crate) async fn try_continue_agent_loop(
|
|
||||||
app: &AppHandle,
|
|
||||||
state: &AppState,
|
|
||||||
conv_id: &str,
|
|
||||||
start_iteration: usize,
|
|
||||||
) {
|
|
||||||
// 审批超时检测:超时取消在本函数入口进行(本函数是所有审批恢复路径的入口——
|
|
||||||
// ai_approve / ai_authorize_dir / ai_continue_loop / ai_chat_stop 都调它)。
|
|
||||||
// 这里检测后把超时审批转为拒绝状态,避免用户离开后 pending 永久挂起死锁会话。
|
|
||||||
// 0 表示禁用超时(不推荐,但保留用户选择权)。检测失败(如锁中毒)不阻断续跑。
|
|
||||||
approval_timeout::cleanup_expired_approvals(app, state, conv_id).await;
|
|
||||||
|
|
||||||
// BUG-260617-05: 原 5 次独立 lock().await 造成 TOCTOU 竞态——should_continue=true 判出后、
|
|
||||||
// spawn 前用户点 stop(ai_chat_stop 复位 generating=false),续跑仍按过时快照继续 spawn。
|
|
||||||
// 修复:单次 lock 取结构化快照(所有续跑判定所需字段),无锁态判定;spawn 前单次 lock 原子
|
|
||||||
// 重检 generating 仍为 true 才续跑,stop 后中途插入的直接收敛退出。
|
|
||||||
//
|
|
||||||
// F-260616-09 B 批2(设计 §4.4):conv_id 改显式入参(替代从 pending/active 推断),
|
|
||||||
// has_pending 按 conv_id 过滤(决策 e 真并发准备);generating/agent_language/model_override
|
|
||||||
// 读 per_conv(批2 桥接期与顶层等价,per_conv 未建 fallback 顶层)。
|
|
||||||
// conv_id 来源:调用方 ai_approve 传 approval.conversation_id;ai_continue_loop/ai_stop_loop
|
|
||||||
// 传 IPC 参数 conversation_id。
|
|
||||||
let snap = {
|
|
||||||
let session = state.ai_session.lock().await;
|
|
||||||
// path_auth 审批链阶段1:has_pending 改调 session_state(conv_id) 收敛状态机判定,
|
|
||||||
// 替代手写 path_auth+risk 两表 any 合并(mod.rs 已统一封装)。
|
|
||||||
// 阶段3a 单真相源合并后:两表合一进 pending_approvals,session_state 单表 any 判定。
|
|
||||||
// 两表语义不变——任一类挂起都阻塞续跑(agentic loop 在 path_auth 挂起时也已 return 等待,
|
|
||||||
// 漏任一会致 loop 误续跑空转)。
|
|
||||||
//
|
|
||||||
// 兜底/快速回退:若需切回手写 has_pending,原双表组合保留如下(改一行即可):
|
|
||||||
// let has_pending = session.pending_approvals.values()
|
|
||||||
// .any(|a| a.conversation_id.as_deref() == Some(conv_id));
|
|
||||||
let has_pending = session.session_state(conv_id) == SessionState::AwaitingApproval;
|
|
||||||
// pending_conv_id 保留(should_continue=false 路径的 emit conv_id 回退逻辑)。
|
|
||||||
// 阶段3a:单表 find_map(原两表合一)。
|
|
||||||
let pending_conv_id = session.pending_approvals.values()
|
|
||||||
.find_map(|a| a.conversation_id.clone());
|
|
||||||
let conv = session.conv_read(conv_id);
|
|
||||||
// F-09 B 批4:per_conv 唯一真相源,删顶层 fallback(conv 不存在则各字段默认值)。
|
|
||||||
// L2 读侧(双轨收口批2):统一读 conv_state.is_active()(Generating+Compressed),删除
|
|
||||||
// CONV_STATE_ENABLED off 回退分支(enum 单路径)。用于 ai_can_continue 继续按钮是否可点判断,
|
|
||||||
// is_active() 与原 generating 语义一致(压缩期间 loop 仍活跃,应判为仍在生成)。
|
|
||||||
let is_generating = conv.map(|c| c.conv_state.is_active()).unwrap_or(false);
|
|
||||||
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();
|
|
||||||
ContinueSnapshot {
|
|
||||||
is_generating,
|
|
||||||
has_pending,
|
|
||||||
pending_conv_id,
|
|
||||||
agent_language,
|
|
||||||
model_override,
|
|
||||||
pinned_goals_snapshot,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let should_continue = snap.is_generating && !snap.has_pending;
|
|
||||||
|
|
||||||
if !should_continue {
|
|
||||||
// generating=false(被 stop)或仍有审批(pending_approvals 非空):
|
|
||||||
// 统一 emit AiCompleted 标当前轮收敛,清前端 streaming。
|
|
||||||
// 轮 token 已在前序 AiCompleted/AiApprovalResult 流程落库,此处零 token 上报仅作收敛信号。
|
|
||||||
if snap.is_generating {
|
|
||||||
// pending_approvals 非空但 generating 仍 true:转审批态,前端审批态 watchdog 已 clear,不卡
|
|
||||||
tracing::info!(conv_id = %conv_id, "[ai] try_continue 跳过:仍有待审批,转审批等待态");
|
|
||||||
} else {
|
|
||||||
// generating 已复位(用户 stop 或前序循环已 emit Completed):补发 AiCompleted 防前端卡住
|
|
||||||
tracing::info!(conv_id = %conv_id, "[ai] try_continue 跳过:generating 已复位(被 stop/已结束),补发 AiCompleted 清前端 streaming");
|
|
||||||
// R-PD-6: 优先用审批所属 conversation_id(审批等待态被 stop 触发,审批仍在 pending_approvals),
|
|
||||||
// 仅当无任何审批(has_pending=false 且 generating=false)时回退入参 conv_id(批2 显式参数)。
|
|
||||||
let emit_conv_id = match snap.pending_conv_id.clone() {
|
|
||||||
Some(cid) => cid,
|
|
||||||
None => conv_id.to_string(),
|
|
||||||
};
|
|
||||||
let ev = AiChatEvent::AiCompleted {
|
|
||||||
total_tokens: 0,
|
|
||||||
prompt_tokens: 0,
|
|
||||||
completion_tokens: 0,
|
|
||||||
incomplete: None,
|
|
||||||
conversation_id: Some(emit_conv_id),
|
|
||||||
pinned_goals: snap.pinned_goals_snapshot.clone(),
|
|
||||||
};
|
|
||||||
let _ = app.emit("ai-chat-event", ev.clone());
|
|
||||||
// L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp
|
|
||||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let provider_config = match get_active_provider(state).await {
|
|
||||||
Ok(p) => p,
|
|
||||||
Err(e) => {
|
|
||||||
// 无可用 provider(配置丢失/全删):无法续生成,emit AiError。
|
|
||||||
// 语义:配置错误,用户需在 Settings 设 provider;generating 复位由 run_agentic_loop 内
|
|
||||||
// build_provider_for Err 分支处理(同样 emit AiError),此处与之一致。
|
|
||||||
// 不用 GeneratingGuard:try_continue 的 should_continue=false 路径需保生成态(审批等待态),
|
|
||||||
// 全函数 guard 会误复位。此点单点 provider-Err 复位,语义独立。
|
|
||||||
// 批3 双轨收口:generating bool 已退役,复位经 ConvState 迁移(Generating→Idle)单一表达。
|
|
||||||
let mut session = state.ai_session.lock().await;
|
|
||||||
// 持久化层 ConvState→Idle 复位(对齐 guard.reset 收敛路径,非配置错误不阻断)。
|
|
||||||
match session.conv(conv_id).conv_state.transition_to(conv_state::ConvState::Idle) {
|
|
||||||
Ok(ns) => session.conv(conv_id).conv_state = ns,
|
|
||||||
Err(e) => tracing::warn!(
|
|
||||||
conv_id = %conv_id,
|
|
||||||
error = %e,
|
|
||||||
"[ai] try_continue provider-Err ConvState→Idle 非法(不阻断 emit AiError)"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
drop(session);
|
|
||||||
tracing::warn!(conv_id = %conv_id, error = %e, "[ai] try_continue 失败:无可用 provider");
|
|
||||||
let _ = app.emit("ai-chat-event", AiChatEvent::AiError {
|
|
||||||
error: e.clone(),
|
|
||||||
// 无可用 provider(配置丢失/全删):用户需在 Settings 设 provider,归 ProviderConfig
|
|
||||||
error_type: Some(ErrorType::ProviderConfig),
|
|
||||||
conversation_id: Some(conv_id.to_string()),
|
|
||||||
});
|
|
||||||
// L3 emit 双写:try_continue provider-Err AiError publish 到事件总线(门控在 publish 内)。
|
|
||||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiError {
|
|
||||||
error: e,
|
|
||||||
error_type: None,
|
|
||||||
conversation_id: Some(conv_id.to_string()),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// F-09 B 批2:续生成路径 conv_id 用入参(显式,不再从 active 推断),语言/override 读 per_conv 快照。
|
|
||||||
let lang = snap.agent_language.clone().unwrap_or_else(|| "zh-CN".to_string());
|
|
||||||
let conv_id_owned = conv_id.to_string();
|
|
||||||
let system_prompt = build_system_prompt(state, &lang).await;
|
|
||||||
|
|
||||||
let session_arc = state.ai_session.clone();
|
|
||||||
let tools_arc = state.ai_tools.clone();
|
|
||||||
let db = state.db.clone();
|
|
||||||
let app_handle = app.clone();
|
|
||||||
let knowledge_config = state.knowledge_config.lock().await.clone();
|
|
||||||
let llm_concurrency = state.llm_concurrency.clone();
|
|
||||||
|
|
||||||
// 知识注入:DRY(B):收敛至 inject_knowledge_into_prompt 单一入口。
|
|
||||||
// P1 修复(审批恢复路径缺知识注入):try_continue 续跑轮此前用裸 build_system_prompt,
|
|
||||||
// 不调 build_knowledge_context 致续跑轮丢知识库上下文。现与 chat.rs 四处同款走 helper。
|
|
||||||
// 同消息取 text+id(②口径修复):原 last_user_text 过滤 is_active / user_message_id 走
|
|
||||||
// last_user_message_id 不过滤 is_active,末条 user 压缩后两值取自不同消息;helper 单次
|
|
||||||
// 反向扫描同一条消息取两值。复用上方已 clone 的 knowledge_config 快照(避免重复加锁)。
|
|
||||||
let system_prompt = inject_knowledge_into_prompt(state, conv_id, system_prompt, &knowledge_config).await;
|
|
||||||
|
|
||||||
|
|
||||||
// F-260616-01: loop 入口 load 快照,当前续生成 loop 锁定边界(热改下次发消息生效)
|
|
||||||
let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst);
|
|
||||||
// F-260616-07: 流式失败重试次数快照
|
|
||||||
let max_retries = state.agent_max_retries.load(Ordering::SeqCst);
|
|
||||||
// F-01 阶段6: 续跑沿用同一主对话的 model_override(审批续跑/达 max 续跑保持一致)。
|
|
||||||
let model_override = snap.model_override.clone();
|
|
||||||
|
|
||||||
// BUG-260617-05 续: provider 解析/build_system_prompt 期间用户可能点 stop。
|
|
||||||
// spawn 前单次 lock 原子重检 generating——若已被 stop 复位,收敛退出而非覆盖用户的 stop。
|
|
||||||
// (run_agentic_loop 入口 GeneratingGuard 会再次置 generating=true,若不重检会抹掉 stop。)
|
|
||||||
// F-09 B 批4:重检 per_conv.generating(唯一真相源;conv 不存在则 false)。
|
|
||||||
// L2 读侧(双轨收口批2):统一读 conv_state.is_active()(Generating+Compressed),删除
|
|
||||||
// CONV_STATE_ENABLED off 回退分支(enum 单路径)。spawn 前单次 lock 原子重检是否仍在生成,
|
|
||||||
// is_active() 与原 generating 语义一致。
|
|
||||||
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)
|
|
||||||
};
|
|
||||||
if !still_generating {
|
|
||||||
tracing::info!(conv_id = %conv_id, "[ai] try_continue 终止:spawn 前重检 generating 已被 stop 复位,补发 AiCompleted");
|
|
||||||
let ev = AiChatEvent::AiCompleted {
|
|
||||||
total_tokens: 0,
|
|
||||||
prompt_tokens: 0,
|
|
||||||
completion_tokens: 0,
|
|
||||||
incomplete: None,
|
|
||||||
conversation_id: Some(conv_id_owned.clone()),
|
|
||||||
pinned_goals: snap.pinned_goals_snapshot.clone(),
|
|
||||||
};
|
|
||||||
let _ = app.emit("ai-chat-event", ev.clone());
|
|
||||||
// L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp
|
|
||||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 恢复循环前通知前端新建 assistant 消息:审批(通过/拒绝)后新一轮文本
|
|
||||||
// 不应追加到发起工具调用的旧消息,用 AiAgentRound 隔开
|
|
||||||
let _ = app.emit("ai-chat-event", AiChatEvent::AiAgentRound {
|
|
||||||
round: 0,
|
|
||||||
conversation_id: Some(conv_id_owned.clone()),
|
|
||||||
});
|
|
||||||
// L3 emit 双写:try_continue 续跑 AiAgentRound publish 到事件总线(门控在 publish 内)。
|
|
||||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiAgentRound {
|
|
||||||
round: 0,
|
|
||||||
conversation_id: Some(conv_id_owned.clone()),
|
|
||||||
});
|
|
||||||
|
|
||||||
tauri::async_runtime::spawn(async move {
|
|
||||||
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id_owned, knowledge_config, llm_concurrency, max_iterations, max_retries, start_iteration, model_override).await;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// BUG-260617-05: try_continue_agent_loop 续跑判定所需 session 字段的一次性快照。
|
|
||||||
/// 单次 lock 取出后无锁态判定,消除多 lock 间其他 IPC(ai_chat_stop/clear/switch)改写 session
|
|
||||||
/// 致续跑判断基于过时快照的 TOCTOU 竞态。
|
|
||||||
///
|
|
||||||
/// F-260616-09 B 批2:active_conversation_id 字段移除(改用入参 conv_id,不再从 snap 读)。
|
|
||||||
struct ContinueSnapshot {
|
|
||||||
is_generating: bool,
|
|
||||||
has_pending: bool,
|
|
||||||
pending_conv_id: Option<String>,
|
|
||||||
agent_language: Option<String>,
|
|
||||||
model_override: Option<String>,
|
|
||||||
/// 对话透明化 L1:快照 pinned_goals 供 AiCompleted emit 携带
|
|
||||||
pinned_goals_snapshot: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// G2 is_empty_tool_result 单测
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_empty_tool_result_empty_string() {
|
|
||||||
assert!(is_empty_tool_result(""));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_empty_tool_result_whitespace() {
|
|
||||||
assert!(is_empty_tool_result(" "));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_empty_tool_result_total_zero() {
|
|
||||||
assert!(is_empty_tool_result(r#"{"total":0}"#));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_empty_tool_result_entries_empty() {
|
|
||||||
assert!(is_empty_tool_result(r#"{"entries":[]}"#));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_empty_tool_result_matches_empty() {
|
|
||||||
assert!(is_empty_tool_result(r#"{"matches":[]}"#));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_empty_tool_result_results_empty() {
|
|
||||||
assert!(is_empty_tool_result(r#"{"results":[]}"#));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_empty_tool_result_files_empty() {
|
|
||||||
assert!(is_empty_tool_result(r#"{"files":[]}"#));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_empty_tool_result_chinese_no_match() {
|
|
||||||
assert!(is_empty_tool_result("未找到相关文件"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_empty_tool_result_english_no_match() {
|
|
||||||
assert!(is_empty_tool_result("No matches found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_non_empty_tool_result() {
|
|
||||||
assert!(!is_empty_tool_result(r#"{"total":5}"#));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_non_empty_tool_result_with_content() {
|
|
||||||
assert!(!is_empty_tool_result(r#"{"entries":["a.txt","b.txt"]}"#));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,20 +34,19 @@ pub(super) fn classify_risk_and_auto(
|
|||||||
tool_name: &str,
|
tool_name: &str,
|
||||||
args: &serde_json::Value,
|
args: &serde_json::Value,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
let mut should_auto = match risk_level {
|
let _ = args; // 预留扩展点(未来可加路径白名单/文件类型等维度)
|
||||||
|
// patch_file 是 AI 编码主力工具,改文件是 AI Coding 的工作常态。
|
||||||
|
// 原本“小改动(≤5 行)豁免、大改动审批”的设计在实际使用中频繁打断 AI 工作流
|
||||||
|
// (AI 改文件往往远超 5 行),且文件已写入会落 audit 表可追溯,审批价值低。
|
||||||
|
// 决策:patch_file 全档位自动放行(low/medium/all 均不审批),与 read_file 同级处理。
|
||||||
|
if tool_name == "patch_file" {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
match risk_level {
|
||||||
RiskLevel::Low => true,
|
RiskLevel::Low => true,
|
||||||
RiskLevel::Medium => auto_exec_mode == "medium" || auto_exec_mode == "all",
|
RiskLevel::Medium => auto_exec_mode == "medium" || auto_exec_mode == "all",
|
||||||
RiskLevel::High => auto_exec_mode == "all",
|
RiskLevel::High => auto_exec_mode == "all",
|
||||||
};
|
|
||||||
// C-260627:patch_file 小改动(≤5 行)自动放行,不阻塞 AI 工作流
|
|
||||||
if !should_auto && tool_name == "patch_file" {
|
|
||||||
if let Some(text) = args.get("new_text").and_then(|v| v.as_str()) {
|
|
||||||
if text.lines().count() <= 5 {
|
|
||||||
should_auto = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
should_auto
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 审批策略方案 B:Persona 维度判定。
|
/// 审批策略方案 B:Persona 维度判定。
|
||||||
|
|||||||
@@ -325,6 +325,25 @@ pub(crate) async fn process_tool_calls(
|
|||||||
.ok()
|
.ok()
|
||||||
.flatten()
|
.flatten()
|
||||||
.unwrap_or_else(|| "low".to_string());
|
.unwrap_or_else(|| "low".to_string());
|
||||||
|
// F-#97 诊断:用户报「选 all 仍审批」时辅助定位 KV 是否真落库。
|
||||||
|
// df-ai-trace-approve 开关控制(默认开)。常见误以为已生效但实际未点确认按钮致 KV 未落。
|
||||||
|
let trace_on = app_handle
|
||||||
|
.state::<crate::state::AppState>()
|
||||||
|
.settings
|
||||||
|
.get("df-ai-trace-approve")
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.map(|v| v == "true" || v == "1")
|
||||||
|
.unwrap_or(true);
|
||||||
|
if trace_on {
|
||||||
|
tracing::info!(
|
||||||
|
conv_id = %conv_id,
|
||||||
|
auto_exec_mode = %auto_exec_mode,
|
||||||
|
drafts_count = drafts.len(),
|
||||||
|
"[ai] 审批判定读取的自动执行模式"
|
||||||
|
);
|
||||||
|
}
|
||||||
// trust_hits 收集:AE-04 会话信任命中(同会话已批准同类操作),真实执行但移到锁外 spawn
|
// trust_hits 收集:AE-04 会话信任命中(同会话已批准同类操作),真实执行但移到锁外 spawn
|
||||||
// (对齐 Low risk 不持锁模式)。命中时此处只 emit toast + 收集,不 .await execute。
|
// (对齐 Low risk 不持锁模式)。命中时此处只 emit toast + 收集,不 .await execute。
|
||||||
let mut trust_hits: Vec<(ToolCallDraft, serde_json::Value, String, RiskLevel)> = Vec::new();
|
let mut trust_hits: Vec<(ToolCallDraft, serde_json::Value, String, RiskLevel)> = Vec::new();
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ use crate::state::AppState;
|
|||||||
use crate::commands::{err_str, now_millis};
|
use crate::commands::{err_str, now_millis};
|
||||||
|
|
||||||
// chat.rs 的 super = commands,super::super = ai(与原 commands.rs 的 super=ai 等价)。
|
// 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, GOAL_PIN_ENABLED, MAX_GOALS};
|
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 兜底初值)。
|
// 双轨收口批1:读侧入口拦截用 ConvState(can_accept_request 的 unwrap_or 兜底初值)。
|
||||||
use super::super::agentic::conv_state::ConvState;
|
use super::super::agentic::conv_state::ConvState;
|
||||||
use super::super::audit::{audit_finalize, emit_data_changed};
|
use super::super::audit::{audit_finalize, emit_data_changed};
|
||||||
@@ -72,20 +73,10 @@ pub(crate) fn finalize_pending_placeholders(session: &mut super::super::AiSessio
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// G1 目标钉扎:从 user 消息文本提取单条目标(纯函数,无 IO / 无额外 LLM 请求)。
|
/// 去噪助手:从文本中 strip `[kind:...]` mention 标签(纯函数,无 IO)。
|
||||||
///
|
///
|
||||||
/// 治 R1(目标消息被压缩出局)的提取侧:每次 user 消息的文本即潜在目标,
|
/// 原为 G1 目标钉扎的提取函数(2026-07-03 已改为工具调用推理),现保留供测试用例使用。
|
||||||
/// 提取后追加到 `PerConvState.pinned_goals` Vec(去重),即使原消息出局目标仍在。
|
#[allow(dead_code)]
|
||||||
///
|
|
||||||
/// 处理(零额外请求,对齐 GLM 限额口径 1 请求=1 次):
|
|
||||||
/// 1. strip 所有 `[kind:...]` mention 段(augmentation 已投影,目标文本去噪)。对齐 intent.rs
|
|
||||||
/// KINDS(项目/任务/想法/技能 + project/task/idea/skill),全文扫描移除(实测诊断 seq5 目标
|
|
||||||
/// `分析灵感模块不足 [项目: DevFlow]` mention 在**末尾**,非仅行首),冒号兼容全角:/半角:。
|
|
||||||
/// 2. trim 空白。
|
|
||||||
/// 3. 截断到 GOAL_MAX_CHARS(防长需求文档撑爆 system_prompt 占预算)。
|
|
||||||
///
|
|
||||||
/// 返回空串(纯 mention / 空消息)→ 调用方判非空才追加到 pinned_goals(避免空目标注入)。
|
|
||||||
/// 默认用原文非 LLM 提取(消息锚点派 keepIdeas:零额外请求)。
|
|
||||||
pub(crate) fn extract_pinned_goal(text: &str) -> String {
|
pub(crate) fn extract_pinned_goal(text: &str) -> String {
|
||||||
// strip 所有 [kind:...] mention 段(对齐 intent.rs strip_mention_tags/try_match_mention 逻辑,
|
// strip 所有 [kind:...] mention 段(对齐 intent.rs strip_mention_tags/try_match_mention 逻辑,
|
||||||
// 单一真相源:KINDS 表与 intent.rs 一致)。全文扫描,非仅行首。
|
// 单一真相源:KINDS 表与 intent.rs 一致)。全文扫描,非仅行首。
|
||||||
@@ -114,6 +105,7 @@ pub(crate) fn extract_pinned_goal(text: &str) -> String {
|
|||||||
///
|
///
|
||||||
/// kind 大小写不敏感(ASCII)+ 中文直比;冒号接受全角:/半角:;kind 后允许空白;
|
/// kind 大小写不敏感(ASCII)+ 中文直比;冒号接受全角:/半角:;kind 后允许空白;
|
||||||
/// 未闭合 `]` 返 None(不当 mention,保留原样 `[`)。对齐 intent.rs try_match_mention。
|
/// 未闭合 `]` 返 None(不当 mention,保留原样 `[`)。对齐 intent.rs try_match_mention。
|
||||||
|
#[allow(dead_code)]
|
||||||
fn find_mention_close(chars: &[char], open: usize, kinds: &[&str]) -> Option<usize> {
|
fn find_mention_close(chars: &[char], open: usize, kinds: &[&str]) -> Option<usize> {
|
||||||
let after = open + 1;
|
let after = open + 1;
|
||||||
for k in kinds {
|
for k in kinds {
|
||||||
@@ -463,17 +455,11 @@ pub async fn ai_chat_send(
|
|||||||
// DRY(B):知识注入已收敛至 inject_knowledge_into_prompt(helper 内部同消息取 text+id),
|
// DRY(B):知识注入已收敛至 inject_knowledge_into_prompt(helper 内部同消息取 text+id),
|
||||||
// 此处 user_msg_id 不再透传到注入逻辑,保留下划线占用(锁内 push 已发生,语义不变)。
|
// 此处 user_msg_id 不再透传到注入逻辑,保留下划线占用(锁内 push 已发生,语义不变)。
|
||||||
let user_msg_id = conv.messages.last_user_message_id();
|
let user_msg_id = conv.messages.last_user_message_id();
|
||||||
// G1 目标钉扎:push 后提取本次 user 目标追加到 pinned_goals(去重,支持累积多目标)。
|
// G1 目标钉扎已迁移至 run_agentic_loop 工具调用推理(2026-07-03 改),
|
||||||
// GOAL_PIN_ENABLED=false → 跳过;extract_pinned_goal 返空 → 不追加。
|
// 从 LLM 本轮调用的工具名+路径推理目标,不再从用户消息规则提取。
|
||||||
if GOAL_PIN_ENABLED {
|
// 用户发送的短指令(如「继续」「确认」)不会产生低价值目标。
|
||||||
let goal = extract_pinned_goal(&user_content);
|
// 此处不再从原始用户消息提取。
|
||||||
if !goal.is_empty() && !conv.pinned_goals.contains(&goal) {
|
// GOAL_PIN_ENABLED=false → 跳过整块。
|
||||||
conv.pinned_goals.push(goal);
|
|
||||||
if conv.pinned_goals.len() > MAX_GOALS {
|
|
||||||
conv.pinned_goals.remove(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(target, user_msg_id)
|
(target, user_msg_id)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1359,16 +1345,7 @@ pub async fn ai_chat_edit(
|
|||||||
conv.iteration_used = 0;
|
conv.iteration_used = 0;
|
||||||
// F-01 阶段6: 记录用户指定模型 override(主对话专用)。
|
// F-01 阶段6: 记录用户指定模型 override(主对话专用)。
|
||||||
conv.model_override = model_override.clone();
|
conv.model_override = model_override.clone();
|
||||||
// G1 目标钉扎:替换消息后重新提取目标(用户可能通过编辑改变了意图,与 send 路径一致)
|
// G1 目标钉扎已迁移至 run_agentic_loop 工具调用推理(提取方式同 ai_chat_send)
|
||||||
if GOAL_PIN_ENABLED {
|
|
||||||
let goal = extract_pinned_goal(&new_message);
|
|
||||||
if !goal.is_empty() && !conv.pinned_goals.contains(&goal) {
|
|
||||||
conv.pinned_goals.push(goal);
|
|
||||||
if conv.pinned_goals.len() > MAX_GOALS {
|
|
||||||
conv.pinned_goals.remove(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let _tool_defs = state.ai_tools.tool_definitions();
|
let _tool_defs = state.ai_tools.tool_definitions();
|
||||||
@@ -1534,17 +1511,7 @@ pub async fn ai_chat_force_send(
|
|||||||
}
|
}
|
||||||
// F-260619-04 P1:push 后立即取末条 user 消息 id(供知识注入 referenced 溯源)。
|
// F-260619-04 P1:push 后立即取末条 user 消息 id(供知识注入 referenced 溯源)。
|
||||||
let user_msg_id = conv.messages.last_user_message_id();
|
let user_msg_id = conv.messages.last_user_message_id();
|
||||||
// G1 目标钉扎:push 后提取本次 user 目标追加到 pinned_goals(去重,与 send/edit 路径一致,支持累积多目标)。
|
// G1 目标钉扎已迁移至 run_agentic_loop 工具调用推理(提取方式同 ai_chat_send)
|
||||||
// force_send 是用户新指令,审批续跑会复用已累积目标。
|
|
||||||
if GOAL_PIN_ENABLED {
|
|
||||||
let goal = extract_pinned_goal(&user_content);
|
|
||||||
if !goal.is_empty() && !conv.pinned_goals.contains(&goal) {
|
|
||||||
conv.pinned_goals.push(goal);
|
|
||||||
if conv.pinned_goals.len() > MAX_GOALS {
|
|
||||||
conv.pinned_goals.remove(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(was_gen.then_some(target.clone()), target, user_msg_id, conv.pinned_goals.clone())
|
(was_gen.then_some(target.clone()), target, user_msg_id, conv.pinned_goals.clone())
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1782,7 +1749,7 @@ pub async fn ai_stop_loop(
|
|||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
conversation_id: String,
|
conversation_id: String,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let pinned_goals: Vec<String>;
|
let pinned_goals: Vec<crate::commands::ai::GoalEntry>;
|
||||||
{
|
{
|
||||||
let mut session = state.ai_session.lock().await;
|
let mut session = state.ai_session.lock().await;
|
||||||
// F-260616-09 B 批4(决策 e):conv_id 来源 IPC 参数 conversation_id,移除 active 一致性校验
|
// F-260616-09 B 批4(决策 e):conv_id 来源 IPC 参数 conversation_id,移除 active 一致性校验
|
||||||
@@ -1879,6 +1846,10 @@ mod tests {
|
|||||||
assert_eq!(goal.chars().count(), GOAL_MAX_CHARS);
|
assert_eq!(goal.chars().count(), GOAL_MAX_CHARS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 低价值目标过滤
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_extract_mention_unclosed() {
|
fn test_extract_mention_unclosed() {
|
||||||
let goal = extract_pinned_goal("测试 [项目: DevFlow");
|
let goal = extract_pinned_goal("测试 [项目: DevFlow");
|
||||||
|
|||||||
@@ -226,7 +226,8 @@ pub async fn ai_conversation_list(
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let pinned_goals: Vec<String> = r.pinned_goals
|
let pinned_goals: Vec<String> = r.pinned_goals
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(|s| serde_json::from_str(s).ok())
|
.and_then(|s| serde_json::from_str::<Vec<crate::commands::ai::GoalEntry>>(s).ok())
|
||||||
|
.map(|entries| entries.into_iter().map(|e| e.text).collect())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"id": r.id,
|
"id": r.id,
|
||||||
@@ -336,10 +337,34 @@ pub async fn ai_conversation_switch(
|
|||||||
conv.iteration_used = 0;
|
conv.iteration_used = 0;
|
||||||
conv.stop_flag.store(false, Ordering::SeqCst);
|
conv.stop_flag.store(false, Ordering::SeqCst);
|
||||||
// 从 DB 恢复 pinned_goals 到 per_conv(G1 目标钉扎持久化)
|
// 从 DB 恢复 pinned_goals 到 per_conv(G1 目标钉扎持久化)
|
||||||
let pinned_goals: Vec<String> = record.pinned_goals
|
// 兼容旧格式:["text1","text2"] 转为 [{text, status: active}]
|
||||||
.as_deref()
|
let pinned_goals: Vec<crate::commands::ai::GoalEntry> = {
|
||||||
.and_then(|s| serde_json::from_str(s).ok())
|
let raw: Option<serde_json::Value> = record.pinned_goals
|
||||||
.unwrap_or_default();
|
.as_deref()
|
||||||
|
.and_then(|s| serde_json::from_str(s).ok());
|
||||||
|
match raw {
|
||||||
|
Some(serde_json::Value::Array(arr)) => {
|
||||||
|
arr.iter().filter_map(|v| {
|
||||||
|
// 新格式: {"text":"...","status":"active"}
|
||||||
|
if let Some(obj) = v.as_object() {
|
||||||
|
let text = obj.get("text")?.as_str()?.to_string();
|
||||||
|
let status = obj.get("status").and_then(|s| s.as_str())
|
||||||
|
.map(|s| if s == "completed" {
|
||||||
|
crate::commands::ai::GoalStatus::Completed
|
||||||
|
} else {
|
||||||
|
crate::commands::ai::GoalStatus::Active
|
||||||
|
}).unwrap_or(crate::commands::ai::GoalStatus::Active);
|
||||||
|
Some(crate::commands::ai::GoalEntry { text, status })
|
||||||
|
} else {
|
||||||
|
// 旧格式: "text" → 转 active
|
||||||
|
let text = v.as_str()?.to_string();
|
||||||
|
Some(crate::commands::ai::GoalEntry::new(text))
|
||||||
|
}
|
||||||
|
}).collect()
|
||||||
|
}
|
||||||
|
_ => vec![],
|
||||||
|
}
|
||||||
|
};
|
||||||
conv.pinned_goals = pinned_goals;
|
conv.pinned_goals = pinned_goals;
|
||||||
}
|
}
|
||||||
// 仅清空目标对话自身的挂起审批,保留其他对话的(防 init 重建的内存 HashMap 被清空,
|
// 仅清空目标对话自身的挂起审批,保留其他对话的(防 init 重建的内存 HashMap 被清空,
|
||||||
@@ -531,7 +556,11 @@ pub async fn ai_update_conversation_goals(
|
|||||||
conversation_id: String,
|
conversation_id: String,
|
||||||
goals: Vec<String>,
|
goals: Vec<String>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let goals_json = serde_json::to_string(&goals).map_err(|e| format!("序列化目标列表失败: {e}"))?;
|
// 前端传回的 string[] 转为 GoalEntry(假设全部 active)
|
||||||
|
let goal_entries: Vec<crate::commands::ai::GoalEntry> = goals.into_iter()
|
||||||
|
.map(|g| crate::commands::ai::GoalEntry::new(g))
|
||||||
|
.collect();
|
||||||
|
let goals_json = serde_json::to_string(&goal_entries).map_err(|e| format!("序列化目标列表失败: {e}"))?;
|
||||||
// 写 DB
|
// 写 DB
|
||||||
state.ai_conversations
|
state.ai_conversations
|
||||||
.update_field(&conversation_id, "pinned_goals", &goals_json)
|
.update_field(&conversation_id, "pinned_goals", &goals_json)
|
||||||
@@ -540,7 +569,7 @@ pub async fn ai_update_conversation_goals(
|
|||||||
// 同步内存 per_conv(若已加载)
|
// 同步内存 per_conv(若已加载)
|
||||||
let mut session = state.ai_session.lock().await;
|
let mut session = state.ai_session.lock().await;
|
||||||
if let Some(conv) = session.per_conv.get_mut(&conversation_id) {
|
if let Some(conv) = session.per_conv.get_mut(&conversation_id) {
|
||||||
conv.pinned_goals = goals;
|
conv.pinned_goals = goal_entries;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ pub enum AiChatEvent {
|
|||||||
incomplete: Option<bool>,
|
incomplete: Option<bool>,
|
||||||
conversation_id: Option<String>,
|
conversation_id: Option<String>,
|
||||||
/// 当前会话 pinned_goals(G1 目标钉扎持久化,前端直接读取刷新)
|
/// 当前会话 pinned_goals(G1 目标钉扎持久化,前端直接读取刷新)
|
||||||
pinned_goals: Vec<String>,
|
pinned_goals: Vec<GoalEntry>,
|
||||||
},
|
},
|
||||||
/// 错误
|
/// 错误
|
||||||
///
|
///
|
||||||
@@ -820,17 +820,15 @@ pub struct PerConvState {
|
|||||||
/// 写收敛:经 GeneratingGuard/入口 transition_to 守卫迁移,非直接赋值。
|
/// 写收敛:经 GeneratingGuard/入口 transition_to 守卫迁移,非直接赋值。
|
||||||
/// 批3 双轨收口:generating bool 已退役,此 enum 成为生成态唯一真相源。
|
/// 批3 双轨收口:generating bool 已退役,此 enum 成为生成态唯一真相源。
|
||||||
pub conv_state: ConvState,
|
pub conv_state: ConvState,
|
||||||
/// G1 目标钉扎真相源:用户消息中提取的目标列表(内容态字段,与生命周期态正交)。
|
/// G1 目标钉扎真相源:从 LLM 工具调用推理的目标列表(内容态字段,与生命周期态正交)。
|
||||||
///
|
///
|
||||||
/// 治 R1(目标消息被压缩/compressed 物理出局,sanitize step0 过滤 is_active 致目标丢失):
|
/// 治 R1(目标消息被压缩/compressed 物理出局,sanitize step0 过滤 is_active 致目标丢失):
|
||||||
/// 目标存本字段绕过消息 active 状态过滤,即使原始 user 消息出局,goal 仍在。
|
/// 目标存本字段绕过消息 active 状态过滤,即使原始 user 消息出局,goal 仍在。
|
||||||
/// run_agentic_loop 入口拼进 system_prompt 尾部(system_prompt 是 loop 不变量,天然
|
/// run_agentic_loop 入口拼 active 目标进 system_prompt 尾部(system_prompt 是 loop 不变量)。
|
||||||
/// 免疫压缩/裁剪/sanitize,见 agentic/mod.rs:683)。
|
|
||||||
///
|
///
|
||||||
/// 写:chat.rs send/force_send/edit push 后提取目标追加到列表(去重,支持累积多目标);
|
/// 2026-07-03 改:提取从「用户消息规则」→「工具调用推理」(infer_goal_from_tool_calls),
|
||||||
/// 读:loop 入口拼接全部目标。GOAL_PIN_ENABLED=false 时不提取不注入,本字段永远空 Vec。
|
/// 每轮 LLM 回复后从工具名+路径推理目标追加(去重),支持多条/轮 + active→completed 状态流转。
|
||||||
/// 随会话销毁不落库(对齐 knowledge_extracted 语义,PerConvState 无 serde derive)。
|
pub pinned_goals: Vec<GoalEntry>,
|
||||||
pub pinned_goals: Vec<String>,
|
|
||||||
/// 停止信号(会话级):ai_chat_stop 置位,agentic loop / stream_llm 检测后尽快退出
|
/// 停止信号(会话级):ai_chat_stop 置位,agentic loop / stream_llm 检测后尽快退出
|
||||||
pub stop_flag: Arc<AtomicBool>,
|
pub stop_flag: Arc<AtomicBool>,
|
||||||
/// 即时停止唤醒(会话级):阻塞在 stream.next() 时 notify_one() 立即唤醒跳出 select!
|
/// 即时停止唤醒(会话级):阻塞在 stream.next() 时 notify_one() 立即唤醒跳出 select!
|
||||||
@@ -863,6 +861,34 @@ pub struct PerConvState {
|
|||||||
pub knowledge_extracted: bool,
|
pub knowledge_extracted: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 单个目标记录(含状态追踪)。
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct GoalEntry {
|
||||||
|
pub text: String,
|
||||||
|
#[serde(default = "default_goal_status")]
|
||||||
|
pub status: GoalStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 目标状态。
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum GoalStatus {
|
||||||
|
/// 正在进行中(当前 LLM 正在处理)
|
||||||
|
Active,
|
||||||
|
/// 已完成(LLM 已进入下一任务)
|
||||||
|
Completed,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_goal_status() -> GoalStatus {
|
||||||
|
GoalStatus::Active
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GoalEntry {
|
||||||
|
pub fn new(text: String) -> Self {
|
||||||
|
GoalEntry { text, status: GoalStatus::Active }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl PerConvState {
|
impl PerConvState {
|
||||||
/// 新建默认会话级状态。
|
/// 新建默认会话级状态。
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -2036,7 +2036,7 @@ fn register_file_tools(
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
registry.register(
|
registry.register(
|
||||||
"patch_file", "局部更新文件内容(三模式互斥)。模式1 old_text:精确匹配原文替换(含空格/缩进,CAS 语义)。模式2 replace_lines:按行号区间 {start,end}(1-based 含首尾)替换,不需原文,配 expected_hash 防行号漂移。模式3 anchor:按首尾子串锚点 {start,end}(大小写敏感,子串匹配)定位行号区间替换,不需完整原文。三模式均需 path+new_text,expected_hash 可选通用。小改动(≤5 行)自动放行不阻塞,大改动需人工审批。注意:若文件已被外部修改,请先重新 read_file 获取最新内容",
|
"patch_file", "局部更新文件内容(三模式互斥)。模式1 old_text:精确匹配原文替换(含空格/缩进,CAS 语义)。模式2 replace_lines:按行号区间 {start,end}(1-based 含首尾)替换,不需原文,配 expected_hash 防行号漂移。模式3 anchor:按首尾子串锚点 {start,end}(大小写敏感,子串匹配)定位行号区间替换,不需完整原文。三模式均需 path+new_text,expected_hash 可选通用。注意:若文件已被外部修改,请先重新 read_file 获取最新内容。所有 patch_file 操作自动放行不阻塞 AI 工作流,写入会落 audit 表可追溯。",
|
||||||
patch_file_schema,
|
patch_file_schema,
|
||||||
RiskLevel::Medium,
|
RiskLevel::Medium,
|
||||||
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
|
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 对话透明化 L1:收集每个 dirty conv 的 pinned_goals 快照(供 emit AiCompleted 携带)
|
// 对话透明化 L1:收集每个 dirty conv 的 pinned_goals 快照(供 emit AiCompleted 携带)
|
||||||
let pinned_goals_map: std::collections::HashMap<String, Vec<String>> = dirty_convs
|
let pinned_goals_map: std::collections::HashMap<String, Vec<commands::ai::GoalEntry>> = dirty_convs
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|cid| {
|
.filter_map(|cid| {
|
||||||
session.per_conv.get(cid).map(|c| (cid.clone(), c.pinned_goals.clone()))
|
session.per_conv.get(cid).map(|c| (cid.clone(), c.pinned_goals.clone()))
|
||||||
|
|||||||
21
src/App.vue
21
src/App.vue
@@ -33,6 +33,10 @@
|
|||||||
<transition name="toast">
|
<transition name="toast">
|
||||||
<div v-if="errorMsg" class="df-toast">⚠ {{ errorMsg }}</div>
|
<div v-if="errorMsg" class="df-toast">⚠ {{ errorMsg }}</div>
|
||||||
</transition>
|
</transition>
|
||||||
|
|
||||||
|
<!-- 审批待办浮层(屏幕右上角,窗口置顶时浮于所有桌面窗口上面)。
|
||||||
|
仅在主窗口与 ai-detached 窗口挂载(file-explorer 等其他分离窗口不挂)。 -->
|
||||||
|
<ApprovalOverlay v-if="showApprovalOverlay" @activate="activateAiPanel" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -44,6 +48,7 @@ import { useProjectStore } from './stores/project'
|
|||||||
import { useAppSettingsStore } from './stores/appSettings'
|
import { useAppSettingsStore } from './stores/appSettings'
|
||||||
import i18n from './i18n'
|
import i18n from './i18n'
|
||||||
import AiChat from './components/AiChat.vue'
|
import AiChat from './components/AiChat.vue'
|
||||||
|
import ApprovalOverlay from './components/ai/ApprovalOverlay.vue'
|
||||||
import ErrorBoundary from './components/ErrorBoundary.vue'
|
import ErrorBoundary from './components/ErrorBoundary.vue'
|
||||||
import AppLayout from './components/layout/AppLayout.vue'
|
import AppLayout from './components/layout/AppLayout.vue'
|
||||||
import { aiApi } from '@/api'
|
import { aiApi } from '@/api'
|
||||||
@@ -53,6 +58,22 @@ const isDetached = computed(() => route.path === '/ai-detached' || route.path ==
|
|||||||
const aiStore = useAiStore()
|
const aiStore = useAiStore()
|
||||||
const appSettings = useAppSettingsStore()
|
const appSettings = useAppSettingsStore()
|
||||||
|
|
||||||
|
// 审批浮层仅在主窗口与 ai-detached 窗口挂载(不在 file-explorer 等其他分离窗口弹)
|
||||||
|
const showApprovalOverlay = computed(() => route.path !== '/file-explorer-detached')
|
||||||
|
|
||||||
|
/** 用户点击审批浮层 → 激活 AI 面板/分离窗口。
|
||||||
|
* 主窗口:打开 AI 面板;分离窗口:提升焦点(窗口本身 alwaysOnTop 已置顶)。 */
|
||||||
|
async function activateAiPanel() {
|
||||||
|
if (isDetached.value) {
|
||||||
|
try {
|
||||||
|
const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||||
|
await getCurrentWebviewWindow().setFocus()
|
||||||
|
} catch { /* 忽略 */ }
|
||||||
|
} else {
|
||||||
|
if (!aiStore.state.panelOpen) aiStore.togglePanel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── 全局错误 toast(消费 projectStore.error,所有 action 失败统一反馈)──
|
// ── 全局错误 toast(消费 projectStore.error,所有 action 失败统一反馈)──
|
||||||
const projectStore = useProjectStore()
|
const projectStore = useProjectStore()
|
||||||
const errorMsg = ref('')
|
const errorMsg = ref('')
|
||||||
|
|||||||
@@ -387,7 +387,7 @@ export type AiChatEvent = ({
|
|||||||
} | {
|
} | {
|
||||||
// UX-2025-04 / CR-30-2 / 决策 F-260616-07 a1: incomplete 标记流中途失败保文(网络中断),
|
// UX-2025-04 / CR-30-2 / 决策 F-260616-07 a1: incomplete 标记流中途失败保文(网络中断),
|
||||||
// 前端据此差异化展示(如系统提示「⚠ 响应因网络中断不完整」)。正常完成/停止均为 undefined。
|
// 前端据此差异化展示(如系统提示「⚠ 响应因网络中断不完整」)。正常完成/停止均为 undefined。
|
||||||
type: 'AiCompleted'; total_tokens: number; prompt_tokens: number; completion_tokens: number; incomplete?: boolean; pinned_goals?: string[]
|
type: 'AiCompleted'; total_tokens: number; prompt_tokens: number; completion_tokens: number; incomplete?: boolean; pinned_goals?: GoalEntry[]
|
||||||
} | {
|
} | {
|
||||||
type: 'AiError'; error: string; error_type?: AiErrorType
|
type: 'AiError'; error: string; error_type?: AiErrorType
|
||||||
} | {
|
} | {
|
||||||
@@ -600,7 +600,7 @@ export interface AiConversationSummary {
|
|||||||
title: string | null
|
title: string | null
|
||||||
provider_id: string | null
|
provider_id: string | null
|
||||||
model: string | null
|
model: string | null
|
||||||
pinned_goals?: string[]
|
pinned_goals?: GoalEntry[]
|
||||||
archived: boolean
|
archived: boolean
|
||||||
pinned?: boolean
|
pinned?: boolean
|
||||||
prompt_tokens?: number | null
|
prompt_tokens?: number | null
|
||||||
@@ -609,6 +609,13 @@ export interface AiConversationSummary {
|
|||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** G1 目标钉扎:单条目标记录(含状态追踪) */
|
||||||
|
export interface GoalEntry {
|
||||||
|
text: string
|
||||||
|
/** "active" | "completed" */
|
||||||
|
status: 'active' | 'completed'
|
||||||
|
}
|
||||||
|
|
||||||
/** 审批选择类型(F-260615-01: single=单选默认,multiple=多选) */
|
/** 审批选择类型(F-260615-01: single=单选默认,multiple=多选) */
|
||||||
export type ApprovalSelectType = 'single' | 'multiple'
|
export type ApprovalSelectType = 'single' | 'multiple'
|
||||||
|
|
||||||
|
|||||||
@@ -510,13 +510,25 @@ onMounted(async () => {
|
|||||||
console.debug(`[启动] AiChat IPC 完成: ${(performance.now() - t0).toFixed(0)}ms`)
|
console.debug(`[启动] AiChat IPC 完成: ${(performance.now() - t0).toFixed(0)}ms`)
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── 置顶功能 ──
|
// ── 置顶功能(持久化:刷新后保持置顶状态图标一致)──
|
||||||
const alwaysOnTop = ref(false)
|
const alwaysOnTop = ref(false)
|
||||||
|
const appSettingsStore = useAppSettingsStore()
|
||||||
|
// 从 SQLite KV 恢复上次置顶状态
|
||||||
|
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(() => {/* 静默 */})
|
||||||
|
}
|
||||||
async function toggleAlwaysOnTop() {
|
async function toggleAlwaysOnTop() {
|
||||||
const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||||
const win = getCurrentWebviewWindow()
|
const win = getCurrentWebviewWindow()
|
||||||
await win.setAlwaysOnTop(!alwaysOnTop.value)
|
await win.setAlwaysOnTop(!alwaysOnTop.value)
|
||||||
alwaysOnTop.value = !alwaysOnTop.value
|
alwaysOnTop.value = !alwaysOnTop.value
|
||||||
|
// 持久化到 SQLite KV(刷新后恢复)
|
||||||
|
appSettingsStore.set('df-ai-always-on-top', alwaysOnTop.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 第五批抽离: F-260616-03 max_iterations 暂停态操作卡(maxRoundsActing/showMaxRoundsCard/
|
// 第五批抽离: F-260616-03 max_iterations 暂停态操作卡(maxRoundsActing/showMaxRoundsCard/
|
||||||
|
|||||||
201
src/components/ai/ApprovalOverlay.vue
Normal file
201
src/components/ai/ApprovalOverlay.vue
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
<template>
|
||||||
|
<!-- ═══ 审批待办浮层(屏幕右上角,置顶到所有窗口上面)═══
|
||||||
|
设计目的:当 aichat 面板被遮挡/隐藏/最小化,有 pending 审批时,
|
||||||
|
用户能立刻看到通知并一键激活 AI 面板处理。
|
||||||
|
实现:
|
||||||
|
- 监听 store.state.pendingApprovals(由 AiApprovalRequired 事件驱动)
|
||||||
|
- 位置 position:fixed top:16px right:16px,z-index 高于面板
|
||||||
|
- 窗口本身 alwaysOnTop 时浮层自然置顶到所有桌面窗口上面
|
||||||
|
- 点击浮层 → emit 'activate'(App.vue 决定激活主面板/分离窗口)
|
||||||
|
- 用户关闭后 60s 内同会话不再弹(防审批流期间反复打扰),新审批追加会再显 -->
|
||||||
|
<Transition name="approval-overlay">
|
||||||
|
<div
|
||||||
|
v-if="visible"
|
||||||
|
class="approval-overlay"
|
||||||
|
role="alert"
|
||||||
|
@click="onActivate"
|
||||||
|
>
|
||||||
|
<div class="approval-overlay-icon">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M9 11l3 3L22 4" />
|
||||||
|
<path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11" />
|
||||||
|
</svg>
|
||||||
|
<span v-if="count > 1" class="approval-overlay-badge">{{ count }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="approval-overlay-body">
|
||||||
|
<div class="approval-overlay-title">{{ $t('aiChat.approvalWaitingTitle') }}</div>
|
||||||
|
<div class="approval-overlay-desc">{{ $t('aiChat.approvalWaitingDesc') }}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="approval-overlay-close"
|
||||||
|
:title="$t('common.close')"
|
||||||
|
@click.stop="onDismiss"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" 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>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { useAiStore } from '@/stores/ai'
|
||||||
|
|
||||||
|
const store = useAiStore()
|
||||||
|
const emit = defineEmits<{
|
||||||
|
/** 用户点击浮层主体:激活 AI 面板/分离窗口 */
|
||||||
|
(e: 'activate'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// 待审批数(0 = 无)
|
||||||
|
const count = computed(() => store.state.pendingApprovals.length)
|
||||||
|
|
||||||
|
// 用户主动 dismiss 后 60s 内不再弹(同会话审批流期反复打扰防护)
|
||||||
|
const dismissedAt = ref<number>(0)
|
||||||
|
const DISMISS_COOLDOWN_MS = 60_000
|
||||||
|
|
||||||
|
// 显隐:有 pending + 未在冷却期
|
||||||
|
const visible = computed(() => {
|
||||||
|
if (count.value === 0) return false
|
||||||
|
if (dismissedAt.value && Date.now() - dismissedAt.value < DISMISS_COOLDOWN_MS) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
// 审批清空时复位 dismiss(下次有新审批会重新弹)
|
||||||
|
watch(count, (c) => {
|
||||||
|
if (c === 0) dismissedAt.value = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
function onActivate() {
|
||||||
|
emit('activate')
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDismiss() {
|
||||||
|
dismissedAt.value = Date.now()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 浮层 — 右上角 fixed,z-index 高于 AI 面板与其他模态 */
|
||||||
|
.approval-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
z-index: 10000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
min-width: 260px;
|
||||||
|
max-width: 360px;
|
||||||
|
background: var(--df-bg-card);
|
||||||
|
border: 0.5px solid var(--df-accent);
|
||||||
|
border-left: 3px solid var(--df-accent);
|
||||||
|
border-radius: var(--df-radius-md);
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(123, 111, 240, 0.2);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.18s var(--df-ease);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-overlay:hover {
|
||||||
|
transform: translateX(-2px);
|
||||||
|
border-color: var(--df-accent);
|
||||||
|
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.5), 0 0 0 2px var(--df-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-overlay-icon {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--df-accent-bg);
|
||||||
|
color: var(--df-accent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-overlay-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -4px;
|
||||||
|
right: -4px;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: var(--df-danger);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 18px;
|
||||||
|
text-align: center;
|
||||||
|
border: 1.5px solid var(--df-bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-overlay-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-overlay-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--df-text);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-overlay-desc {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--df-text-dim);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-overlay-close {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--df-radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--df-text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all 0.15s var(--df-ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-overlay-close:hover {
|
||||||
|
background: var(--df-sidebar-hover);
|
||||||
|
color: var(--df-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 进出动画:从右侧滑入 */
|
||||||
|
.approval-overlay-enter-active,
|
||||||
|
.approval-overlay-leave-active {
|
||||||
|
transition: all 0.25s var(--df-ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-overlay-enter-from,
|
||||||
|
.approval-overlay-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(40px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式:窄屏占满右侧 */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.approval-overlay {
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
left: 8px;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -53,32 +53,48 @@
|
|||||||
<button class="ai-btn-icon" @click="emit('new-conversation')" :title="$t('aiChat.newConversation')">
|
<button class="ai-btn-icon" @click="emit('new-conversation')" :title="$t('aiChat.newConversation')">
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<!-- F-15 阶段2: 清空上下文(历史消息归档保留,不删 DB;新对话不受影响)。
|
<!-- 清空对话(真删 DB messages,带二次确认防误删)。高频常用,常驻。 -->
|
||||||
空会话/全归档/生成中禁用(无活跃消息时不发起无意义 IPC)。 -->
|
|
||||||
<button
|
|
||||||
class="ai-btn-icon"
|
|
||||||
:disabled="!hasActiveMessages || store.state.streaming"
|
|
||||||
:title="$t('aiChat.clearContext')"
|
|
||||||
@click="emit('clear-context')"
|
|
||||||
>
|
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg>
|
|
||||||
</button>
|
|
||||||
<!-- F-15 阶段2: 压缩上下文(LLM 摘要落 system + 历史消息标 compressed 归档)。
|
|
||||||
loading(isCompressing)时禁用 + 显示 spinner;空/全归档/生成中禁用。 -->
|
|
||||||
<button
|
|
||||||
class="ai-btn-icon"
|
|
||||||
:class="{ 'ai-btn-icon--active': store.isCompressing.value }"
|
|
||||||
:disabled="!hasActiveMessages || store.state.streaming || store.isCompressing.value"
|
|
||||||
:title="store.isCompressing.value ? $t('aiChat.compressing') : $t('aiChat.compressContext')"
|
|
||||||
@click="emit('compress-context')"
|
|
||||||
>
|
|
||||||
<svg v-if="!store.isCompressing.value" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
|
|
||||||
<svg v-else class="ai-btn-spinner" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12a9 9 0 11-6.219-8.56"/></svg>
|
|
||||||
</button>
|
|
||||||
<!-- 清空对话(真删 DB messages,带二次确认防误删) -->
|
|
||||||
<button class="ai-btn-icon" @click="emit('clear-chat')" :title="$t('aiChat.clearChat')">
|
<button class="ai-btn-icon" @click="emit('clear-chat')" :title="$t('aiChat.clearChat')">
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2"/></svg>
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- ══ 更多菜单(...):收纳低频的上下文管理操作(清空上下文/压缩上下文)══ -->
|
||||||
|
<!-- 使用频率分级:新建/清空对话/窗口控制是高频常用,上下文管理是低频项;
|
||||||
|
收进 popout 降低顶部视觉密度,需时从 ... 入口展开。点击外部收起。 -->
|
||||||
|
<div class="ai-more-menu" ref="moreMenuRef">
|
||||||
|
<button
|
||||||
|
class="ai-btn-icon"
|
||||||
|
:class="{ 'ai-btn-icon--active': moreMenuOpen }"
|
||||||
|
:title="$t('aiChat.moreActions')"
|
||||||
|
@click="moreMenuOpen = !moreMenuOpen"
|
||||||
|
>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg>
|
||||||
|
</button>
|
||||||
|
<Transition name="ai-more-popout">
|
||||||
|
<div v-if="moreMenuOpen" class="ai-more-popout" role="menu">
|
||||||
|
<!-- F-15 阶段2: 清空上下文(历史消息归档保留,不删 DB;新对话不受影响) -->
|
||||||
|
<button
|
||||||
|
class="ai-more-item"
|
||||||
|
:disabled="!hasActiveMessages || store.state.streaming"
|
||||||
|
@click="emit('clear-context'); moreMenuOpen = false"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg>
|
||||||
|
<span>{{ $t('aiChat.clearContext') }}</span>
|
||||||
|
</button>
|
||||||
|
<!-- F-15 阶段2: 压缩上下文(LLM 摘要落 system + 历史消息标 compressed 归档) -->
|
||||||
|
<button
|
||||||
|
class="ai-more-item"
|
||||||
|
:disabled="!hasActiveMessages || store.state.streaming || store.isCompressing.value"
|
||||||
|
@click="emit('compress-context'); moreMenuOpen = false"
|
||||||
|
>
|
||||||
|
<svg v-if="!store.isCompressing.value" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
|
||||||
|
<svg v-else class="ai-btn-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12a9 9 0 11-6.219-8.56"/></svg>
|
||||||
|
<span>{{ store.isCompressing.value ? $t('aiChat.compressing') : $t('aiChat.compressContext') }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 嵌入模式:最大化/还原 + 分离 + 关闭 -->
|
<!-- 嵌入模式:最大化/还原 + 分离 + 关闭 -->
|
||||||
<template v-if="!detached">
|
<template v-if="!detached">
|
||||||
<button class="ai-btn-icon" @click="emit('toggle-maximize')" :title="store.state.maximized ? $t('aiChat.restoreSidebar') : $t('aiChat.maximize')">
|
<button class="ai-btn-icon" @click="emit('toggle-maximize')" :title="store.state.maximized ? $t('aiChat.restoreSidebar') : $t('aiChat.maximize')">
|
||||||
@@ -98,7 +114,7 @@
|
|||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="8" height="18" rx="1"/><rect x="14" y="3" width="8" height="18" rx="1"/></svg>
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="8" height="18" rx="1"/><rect x="14" y="3" width="8" height="18" rx="1"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="ai-btn-icon" :class="{ 'ai-btn-icon--active': alwaysOnTop }" @click="emit('toggle-always-on-top')" :title="alwaysOnTop ? $t('aiChat.unpin') : $t('aiChat.pinOnTop')">
|
<button class="ai-btn-icon" :class="{ 'ai-btn-icon--active': alwaysOnTop }" @click="emit('toggle-always-on-top')" :title="alwaysOnTop ? $t('aiChat.unpin') : $t('aiChat.pinOnTop')">
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2C8 2 6 5 6 8v3l-2 2v1h16v-1l-2-2V8c0-3-2-6-6-6z"/><line x1="12" y1="14" x2="12" y2="22"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="ai-btn-icon" @click="emit('close-detached')" :title="$t('aiChat.closeWindow')">
|
<button class="ai-btn-icon" @click="emit('close-detached')" :title="$t('aiChat.closeWindow')">
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" 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>
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" 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>
|
||||||
@@ -115,11 +131,13 @@
|
|||||||
<div v-if="goals.length" class="ai-goals-inline">
|
<div v-if="goals.length" class="ai-goals-inline">
|
||||||
<span class="ai-goals-inline-badge ai-tool-badge" @click="goalsExpanded = !goalsExpanded" :title="$t('aiChat.viewGoals')">
|
<span class="ai-goals-inline-badge ai-tool-badge" @click="goalsExpanded = !goalsExpanded" :title="$t('aiChat.viewGoals')">
|
||||||
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>
|
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>
|
||||||
{{ goals.length }}
|
{{ goals.filter(g => g.status === 'active').length }}/{{ goals.length }}
|
||||||
</span>
|
</span>
|
||||||
<div v-if="goalsExpanded" class="ai-goals-inline-list">
|
<div v-if="goalsExpanded" class="ai-goals-inline-list">
|
||||||
<div v-for="(g, i) in goals" :key="i" class="ai-goal-inline-item">
|
<div v-for="(g, i) in goals" :key="i" class="ai-goal-inline-item" :class="{ 'ai-goal-completed': g.status === 'completed' }">
|
||||||
<span class="ai-goal-text">{{ g }}</span>
|
<span v-if="g.status === 'active'" class="ai-goal-dot"></span>
|
||||||
|
<span v-else class="ai-goal-check">✓</span>
|
||||||
|
<span class="ai-goal-text">{{ g.text }}</span>
|
||||||
<button class="ai-goal-remove" @click.stop="removeGoal(i)">×</button>
|
<button class="ai-goal-remove" @click.stop="removeGoal(i)">×</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -252,6 +270,26 @@ const emit = defineEmits<{
|
|||||||
const store = useAiStore()
|
const store = useAiStore()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// ══ 更多菜单 popout 状态(上下文管理操作收纳)══
|
||||||
|
// 顶部按钮过多时,低频的清空/压缩上下文收入 ... popout;点击按钮切换,点击外部收起。
|
||||||
|
const moreMenuOpen = ref(false)
|
||||||
|
const moreMenuRef = ref<HTMLElement | null>(null)
|
||||||
|
function onDocClickCloseMoreMenu(e: MouseEvent) {
|
||||||
|
if (!moreMenuOpen.value) return
|
||||||
|
const root = moreMenuRef.value
|
||||||
|
if (root && !root.contains(e.target as Node)) {
|
||||||
|
moreMenuOpen.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onMounted(() => document.addEventListener('click', onDocClickCloseMoreMenu))
|
||||||
|
onBeforeUnmount(() => document.removeEventListener('click', onDocClickCloseMoreMenu))
|
||||||
|
// ESC 键收起更多菜单(键盘友好)
|
||||||
|
function onEscCloseMoreMenu(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape' && moreMenuOpen.value) moreMenuOpen.value = false
|
||||||
|
}
|
||||||
|
onMounted(() => document.addEventListener('keydown', onEscCloseMoreMenu))
|
||||||
|
onBeforeUnmount(() => document.removeEventListener('keydown', onEscCloseMoreMenu))
|
||||||
|
|
||||||
const activeProviderName = computed(() => {
|
const activeProviderName = computed(() => {
|
||||||
const active = store.state.providers.find(p => p.id === store.state.activeProvider)
|
const active = store.state.providers.find(p => p.id === store.state.activeProvider)
|
||||||
return active?.name || store.state.providers[0]?.name || t('aiChat.notConfigured')
|
return active?.name || store.state.providers[0]?.name || t('aiChat.notConfigured')
|
||||||
@@ -296,8 +334,8 @@ const currentConvTitle = computed(() => {
|
|||||||
return conv?.title || ''
|
return conv?.title || ''
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── 🎯 对话目标面板(对话透明化 L1:Goal visibility) ──
|
// 🎯 对话目标面板(对话透明化 L1:Goal visibility)
|
||||||
const goals = ref<string[]>([])
|
const goals = ref<GoalEntry[]>([])
|
||||||
const goalsExpanded = ref(false)
|
const goalsExpanded = ref(false)
|
||||||
|
|
||||||
// ── 📋 历史消息(当前会话 user 消息列表) ──
|
// ── 📋 历史消息(当前会话 user 消息列表) ──
|
||||||
@@ -437,10 +475,14 @@ watch(() => store.state.messages.length, () => {
|
|||||||
function loadGoals(convId: string | null) {
|
function loadGoals(convId: string | null) {
|
||||||
if (!convId) { goals.value = []; return }
|
if (!convId) { goals.value = []; return }
|
||||||
const conv = store.state.conversations.find(c => c.id === convId)
|
const conv = store.state.conversations.find(c => c.id === convId)
|
||||||
goals.value = conv?.pinned_goals ?? []
|
// 兼容旧格式(string[]):后端迁移过渡期可能返回纯文本数组
|
||||||
|
const raw = conv?.pinned_goals ?? []
|
||||||
|
goals.value = raw.map((g: any) =>
|
||||||
|
typeof g === 'string' ? { text: g, status: 'active' as const } : g
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 移除第 i 个目标(移除最后一个自动收起) */
|
/** 移除第 i 个目标 */
|
||||||
async function removeGoal(i: number) {
|
async function removeGoal(i: number) {
|
||||||
const convId = store.state.activeConversationId
|
const convId = store.state.activeConversationId
|
||||||
if (!convId) return
|
if (!convId) return
|
||||||
@@ -448,7 +490,8 @@ async function removeGoal(i: number) {
|
|||||||
next.splice(i, 1)
|
next.splice(i, 1)
|
||||||
goals.value = next
|
goals.value = next
|
||||||
if (next.length === 0) goalsExpanded.value = false
|
if (next.length === 0) goalsExpanded.value = false
|
||||||
await aiApi.updateConversationGoals(convId, next)
|
// API 接受 string[] 或 GoalEntry[]
|
||||||
|
await aiApi.updateConversationGoals(convId, next.map(g => g.text))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 循环切换 Provider(provider bar 点击)
|
// 循环切换 Provider(provider bar 点击)
|
||||||
|
|||||||
@@ -65,8 +65,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- AI 面板切换按钮 -->
|
<!-- AI 面板切换按钮 -->
|
||||||
<button class="nav-link ai-toggle-btn" :class="{ 'ai-toggle-btn--active': aiStore.state.panelOpen }" @click="aiStore.togglePanel()" :title="collapsed ? $t('nav.aiPanel') : undefined">
|
<button class="nav-link ai-toggle-btn" :class="{ 'ai-toggle-btn--active': aiStore.state.panelOpen }" @click="aiStore.togglePanel()" :title="collapsed ? $t('nav.aiPanel') : undefined">
|
||||||
<span class="nav-link-icon">
|
<span class="nav-link-icon ai-spark-icon">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a7 7 0 014 12.74V17a1 1 0 01-1 1H9a1 1 0 01-1-1v-2.26A7 7 0 0112 2z"/><line x1="9" y1="21" x2="15" y2="21"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/><path d="M19 3v3M21 4.5h-3M5 17v3M6.5 18.5h-3"/></svg>
|
||||||
</span>
|
</span>
|
||||||
<span v-if="!collapsed" class="nav-link-text">{{ $t('nav.aiPanel') }}</span>
|
<span v-if="!collapsed" class="nav-link-text">{{ $t('nav.aiPanel') }}</span>
|
||||||
<span v-if="!collapsed" class="ai-toggle-shortcut">⌘I</span>
|
<span v-if="!collapsed" class="ai-toggle-shortcut">⌘I</span>
|
||||||
@@ -341,6 +341,15 @@ const secondaryNav = [
|
|||||||
color: var(--df-accent);
|
color: var(--df-accent);
|
||||||
background: var(--df-sidebar-active);
|
background: var(--df-sidebar-active);
|
||||||
}
|
}
|
||||||
|
/* AI 入口用星花图标 + 轻发光,与其他 nav-link 区分 */
|
||||||
|
.ai-spark-icon {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.ai-toggle-btn:hover .ai-spark-icon,
|
||||||
|
.ai-toggle-btn--active .ai-spark-icon {
|
||||||
|
opacity: 1;
|
||||||
|
filter: drop-shadow(0 0 3px var(--df-accent));
|
||||||
|
}
|
||||||
.ai-toggle-shortcut {
|
.ai-toggle-shortcut {
|
||||||
font-family: var(--df-font-mono);
|
font-family: var(--df-font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
|
|||||||
@@ -1,233 +0,0 @@
|
|||||||
<template>
|
|
||||||
<!-- ═══ 设置导入 / 导出(阶段6 UX 重构)═══
|
|
||||||
导出:聚合 appSettings 全 KV(已解析)+ 知识库配置 → JSON 文件下载
|
|
||||||
(默认不含 provider keyring 密钥,scope 标注)
|
|
||||||
导入:input[type=file] 读 JSON → 解析校验 → useConfirm 覆盖确认
|
|
||||||
→ 逐 KV settings.set + knowledgeApi.saveConfig → toast 提示
|
|
||||||
复用 settings.css 全局 .btn/.btn-primary/.btn-ghost/.btn-sm;
|
|
||||||
confirm/toast 由父 Settings.vue 经 props 注入,共用壳层弹层与 toast。 -->
|
|
||||||
<div class="settings-io">
|
|
||||||
<button class="btn btn-ghost btn-sm" :disabled="busy" @click="onExport">
|
|
||||||
⬆ {{ $t('settings.export') }}
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-ghost btn-sm" :disabled="busy" @click="triggerImport">
|
|
||||||
⬇ {{ $t('settings.import') }}
|
|
||||||
</button>
|
|
||||||
<!-- 隐藏的文件选择器(点击「导入」触发) -->
|
|
||||||
<input
|
|
||||||
ref="fileInputRef"
|
|
||||||
type="file"
|
|
||||||
accept="application/json,.json"
|
|
||||||
class="file-input"
|
|
||||||
@change="onFilePicked"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
// ============================================================
|
|
||||||
// 阶段6 导入 / 导出 — 纯前端实现
|
|
||||||
// ------------------------------------------------------------
|
|
||||||
// 导出:从 appSettings.cache 取已解析的全 KV(类型正确,非后端原始 JSON 字符串),
|
|
||||||
// 叠加 knowledgeApi.getConfig(),组成 {metadata, appSettings, knowledge} JSON。
|
|
||||||
// 用 Blob + a[download] 下载(不依赖 Tauri fs/dialog 权限)。
|
|
||||||
// 导入:<input type=file> + FileReader.readAsText → 解析 → metadata.scope 校验
|
|
||||||
// → confirmDialog 覆盖确认 → appSettings.set 逐 KV + knowledgeApi.saveConfig
|
|
||||||
// → toast 反馈(并发/轮次需重启或下次对话生效)。
|
|
||||||
// 安全:scope 仅含 appSettings + 知识库配置,默认不含 provider keyring 密钥
|
|
||||||
// (密钥在独立 store,本流程不触及)。
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
|
||||||
import { knowledgeApi } from '@/api/knowledge'
|
|
||||||
import type { KnowledgeConfig } from '@/api/types'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
/** 覆盖确认弹层(复用 Settings.vue 壳层 confirmState) */
|
|
||||||
confirmDialog: (msg: string, dangerLabel?: string) => Promise<boolean>
|
|
||||||
/** toast 反馈通道(复用 Settings.vue 壳层 showToast) */
|
|
||||||
toast: (msg: string, type?: 'error' | 'warning' | 'info') => void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
const appSettings = useAppSettingsStore()
|
|
||||||
|
|
||||||
const busy = ref(false)
|
|
||||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
|
||||||
|
|
||||||
/** 导出的 JSON 备份载荷结构 */
|
|
||||||
interface SettingsBackup {
|
|
||||||
metadata: {
|
|
||||||
version: number
|
|
||||||
date: string
|
|
||||||
scope: 'appSettings+knowledge'
|
|
||||||
app: string
|
|
||||||
}
|
|
||||||
appSettings: Record<string, unknown>
|
|
||||||
knowledge: KnowledgeConfig | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 当前日期戳(用于导出文件名:devflow-settings-YYYYMMDD.json) */
|
|
||||||
function dateStamp(): string {
|
|
||||||
const d = new Date()
|
|
||||||
const y = d.getFullYear()
|
|
||||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
|
||||||
const day = String(d.getDate()).padStart(2, '0')
|
|
||||||
return `${y}${m}${day}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 导出
|
|
||||||
// ============================================================
|
|
||||||
async function onExport() {
|
|
||||||
if (busy.value) return
|
|
||||||
busy.value = true
|
|
||||||
try {
|
|
||||||
// appSettings.cache 为已解析(反序列化)值的响应式缓存 —— 取其快照,
|
|
||||||
// 保证导出的 JSON 值类型与运行时一致(非后端原始 JSON 字符串)
|
|
||||||
const allKv: Record<string, unknown> = { ...appSettings.cache }
|
|
||||||
// 知识库配置独立 IPC 读取(不在 app_settings 表)
|
|
||||||
let knowledge: KnowledgeConfig | null = null
|
|
||||||
try {
|
|
||||||
knowledge = await knowledgeApi.getConfig()
|
|
||||||
} catch (e) {
|
|
||||||
// 知识库读取失败不阻断导出(appSettings 仍可导),仅 console 记录
|
|
||||||
console.error('[设置导出] 读取知识库配置失败:', e)
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload: SettingsBackup = {
|
|
||||||
metadata: {
|
|
||||||
version: 1,
|
|
||||||
date: new Date().toISOString(),
|
|
||||||
scope: 'appSettings+knowledge',
|
|
||||||
app: 'devflow',
|
|
||||||
},
|
|
||||||
appSettings: allKv,
|
|
||||||
knowledge,
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = JSON.stringify(payload, null, 2)
|
|
||||||
// Blob + a[download] 下载:webview 内原生支持,无需 Tauri fs 权限
|
|
||||||
const blob = new Blob([json], { type: 'application/json' })
|
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
const a = document.createElement('a')
|
|
||||||
a.href = url
|
|
||||||
a.download = `devflow-settings-${dateStamp()}.json`
|
|
||||||
document.body.appendChild(a)
|
|
||||||
a.click()
|
|
||||||
document.body.removeChild(a)
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
} catch (e) {
|
|
||||||
props.toast(t('settings.exportFail', { msg: errMsg(e) }), 'error')
|
|
||||||
} finally {
|
|
||||||
busy.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 导入
|
|
||||||
// ============================================================
|
|
||||||
function triggerImport() {
|
|
||||||
// 重置 value 以便「选同一文件」也能再次触发 change(否则 onChange 不再回调)
|
|
||||||
if (fileInputRef.value) fileInputRef.value.value = ''
|
|
||||||
fileInputRef.value?.click()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onFilePicked(e: Event) {
|
|
||||||
const input = e.target as HTMLInputElement
|
|
||||||
const file = input.files?.[0]
|
|
||||||
if (!file) return
|
|
||||||
|
|
||||||
if (busy.value) return
|
|
||||||
busy.value = true
|
|
||||||
try {
|
|
||||||
const text = await readFileAsText(file)
|
|
||||||
const parsed = parseJsonLoose(text)
|
|
||||||
if (!parsed || !isSettingsBackup(parsed)) {
|
|
||||||
props.toast(t('settings.importInvalidJson'), 'error')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 覆盖确认(复用壳层 confirmState 弹层;用户取消则放弃)
|
|
||||||
const ok = await props.confirmDialog(t('settings.importConfirm'), '')
|
|
||||||
if (!ok) return
|
|
||||||
|
|
||||||
// 逐 KV 写回 appSettings(经 store.set 同时更新缓存与 debounced 落库)
|
|
||||||
const kv = parsed.appSettings ?? {}
|
|
||||||
for (const [k, v] of Object.entries(kv)) {
|
|
||||||
await appSettings.set(k, v)
|
|
||||||
}
|
|
||||||
// 知识库配置写回(独立 IPC)
|
|
||||||
if (parsed.knowledge) {
|
|
||||||
await knowledgeApi.saveConfig(parsed.knowledge)
|
|
||||||
}
|
|
||||||
|
|
||||||
props.toast(t('settings.importSuccess'), 'info')
|
|
||||||
} catch (e) {
|
|
||||||
props.toast(t('settings.importFail', { msg: errMsg(e) }), 'error')
|
|
||||||
} finally {
|
|
||||||
busy.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 工具
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
/** FileReader 读文本(替代 file.text() 以兼容更早 webview) */
|
|
||||||
function readFileAsText(file: File): Promise<string> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const reader = new FileReader()
|
|
||||||
reader.onload = () => resolve(String(reader.result ?? ''))
|
|
||||||
reader.onerror = () => reject(reader.error ?? new Error('read error'))
|
|
||||||
reader.readAsText(file)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 宽松 JSON.parse:失败返回 null(调用方据此报「格式不符」) */
|
|
||||||
function parseJsonLoose(text: string): unknown {
|
|
||||||
try {
|
|
||||||
return JSON.parse(text)
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 结构校验:必须含 metadata.scope === 'appSettings+knowledge' 且 version 为数 */
|
|
||||||
function isSettingsBackup(obj: unknown): obj is SettingsBackup {
|
|
||||||
if (typeof obj !== 'object' || obj === null) return false
|
|
||||||
const o = obj as Record<string, unknown>
|
|
||||||
const meta = o.metadata as Record<string, unknown> | undefined
|
|
||||||
if (!meta || meta.scope !== 'appSettings+knowledge') return false
|
|
||||||
if (typeof meta.version !== 'number') return false
|
|
||||||
// appSettings 必须为对象(允许空对象);knowledge 允许 null 或对象
|
|
||||||
if (typeof o.appSettings !== 'object' || o.appSettings === null) return false
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 提取错误信息文案(兜底 Error.message 或 String(e)) */
|
|
||||||
function errMsg(e: unknown): string {
|
|
||||||
if (e instanceof Error) return e.message
|
|
||||||
return String(e)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* 导入导出按钮组:横向排列,复用全局 .btn/.btn-ghost/.btn-sm */
|
|
||||||
.settings-io {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
/* 隐藏文件选择器(仅以编程方式 click 触发) */
|
|
||||||
.file-input {
|
|
||||||
position: absolute;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -37,11 +37,6 @@
|
|||||||
<span class="nav-label">{{ $t(cat.labelKey) }}</span>
|
<span class="nav-label">{{ $t(cat.labelKey) }}</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<!-- 底部导入导出占位(阶段6 实现) -->
|
|
||||||
<div class="nav-footer-slot">
|
|
||||||
<slot name="footer" />
|
|
||||||
</div>
|
|
||||||
</nav>
|
</nav>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -251,8 +246,6 @@ defineExpose({
|
|||||||
.nav-icon { font-size: 16px; line-height: 1; }
|
.nav-icon { font-size: 16px; line-height: 1; }
|
||||||
.nav-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.nav-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
|
||||||
.nav-footer-slot { flex-shrink: 0; margin-top: auto; }
|
|
||||||
|
|
||||||
/* ===== 窄屏响应式:nav 折叠为横向 tab(@media 由父 Settings.vue 控制,
|
/* ===== 窄屏响应式:nav 折叠为横向 tab(@media 由父 Settings.vue 控制,
|
||||||
此处仅保证横向模式下 nav-item 排列正确)。窄屏不显搜索框(横向空间不足,
|
此处仅保证横向模式下 nav-item 排列正确)。窄屏不显搜索框(横向空间不足,
|
||||||
搜索改为后续可扩展) ===== */
|
搜索改为后续可扩展) ===== */
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
//! 模块级私有:
|
//! 模块级私有:
|
||||||
//! - _pendingApprovalIds:审批按钮防抖守卫(同 useAiSend 原实现)
|
//! - _pendingApprovalIds:审批按钮防抖守卫(同 useAiSend 原实现)
|
||||||
|
|
||||||
|
import { ref } from 'vue'
|
||||||
import { aiApi } from '@/api'
|
import { aiApi } from '@/api'
|
||||||
import { state } from '@/stores/ai'
|
import { state } from '@/stores/ai'
|
||||||
import { t } from '@/i18n/i18n-helpers'
|
import { t } from '@/i18n/i18n-helpers'
|
||||||
@@ -73,10 +74,13 @@ async function approveToolCall(toolCallId: string, approved: boolean, decision?:
|
|||||||
// IPC 成功返回 = 后端已处理(执行工具/拒绝/写入白名单 + emit 事件 + try_continue)。
|
// IPC 成功返回 = 后端已处理(执行工具/拒绝/写入白名单 + emit 事件 + try_continue)。
|
||||||
// 乐观更新 tc.status,不依赖后端事件来复位 loading(后端事件可能在 IPC 返回前后任意时刻到达,
|
// 乐观更新 tc.status,不依赖后端事件来复位 loading(后端事件可能在 IPC 返回前后任意时刻到达,
|
||||||
// 且 findToolCall 若因时序找不到 tc 会致 loading 永不复位)。
|
// 且 findToolCall 若因时序找不到 tc 会致 loading 永不复位)。
|
||||||
// approve/authorize 通过 → running(后端正在执行工具);deny/reject → rejected(后端已记审计)。
|
// CR: 批审批场景下后端可能已完成工具执行并 emit AiToolCallCompleted,此时 tc.status 已为
|
||||||
|
// 'completed',乐观更新不能回退——故仅在 status!=='completed' 时覆写。
|
||||||
if (tc) {
|
if (tc) {
|
||||||
const isDeny = isPathKind ? (decision === 'deny') : !approved
|
const isDeny = isPathKind ? (decision === 'deny') : !approved
|
||||||
tc.status = isDeny ? 'rejected' : 'running'
|
if (tc.status !== 'completed') {
|
||||||
|
tc.status = isDeny ? 'rejected' : 'running'
|
||||||
|
}
|
||||||
if (isDeny) {
|
if (isDeny) {
|
||||||
tc.result = t('ai.aiTool.rejectedHint')
|
tc.result = t('ai.aiTool.rejectedHint')
|
||||||
}
|
}
|
||||||
@@ -102,6 +106,7 @@ async function approveToolCall(toolCallId: string, approved: boolean, decision?:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 批量审批:遍历 pendingApprovals 逐个按 kind 分派调用 approveToolCall(决策:批量逐个,
|
* 批量审批:遍历 pendingApprovals 逐个按 kind 分派调用 approveToolCall(决策:批量逐个,
|
||||||
* 非目录聚合——path 类每条独立决策,不合并同目录)。
|
* 非目录聚合——path 类每条独立决策,不合并同目录)。
|
||||||
@@ -113,9 +118,14 @@ async function approveToolCall(toolCallId: string, approved: boolean, decision?:
|
|||||||
* - decision='reject':risk 类 approved=false;path 类 decision='deny'。
|
* - decision='reject':risk 类 approved=false;path 类 decision='deny'。
|
||||||
* 单条失败不中断批量(已由 approveToolCall 内部回滚该条状态)。
|
* 单条失败不中断批量(已由 approveToolCall 内部回滚该条状态)。
|
||||||
*/
|
*/
|
||||||
|
const _batchProcessing = ref(false)
|
||||||
async function batchApprove(decision: 'approve' | 'reject') {
|
async function batchApprove(decision: 'approve' | 'reject') {
|
||||||
|
if (_batchProcessing.value) return // 去重:批量进行中禁重复点击
|
||||||
|
_batchProcessing.value = true
|
||||||
// 快照当前待审批列表(遍历中 state.pendingApprovals 会因事件回调而缩短)
|
// 快照当前待审批列表(遍历中 state.pendingApprovals 会因事件回调而缩短)
|
||||||
const items = [...state.pendingApprovals]
|
const items = [...state.pendingApprovals]
|
||||||
|
let successCount = 0
|
||||||
|
let failCount = 0
|
||||||
for (const p of items) {
|
for (const p of items) {
|
||||||
try {
|
try {
|
||||||
if (p.kind === 'path') {
|
if (p.kind === 'path') {
|
||||||
@@ -125,11 +135,23 @@ async function batchApprove(decision: 'approve' | 'reject') {
|
|||||||
// risk 类:approve/reject boolean
|
// risk 类:approve/reject boolean
|
||||||
await approveToolCall(p.id, decision === 'approve')
|
await approveToolCall(p.id, decision === 'approve')
|
||||||
}
|
}
|
||||||
|
successCount++
|
||||||
} catch {
|
} catch {
|
||||||
// 单条失败不中断批量操作(已由 approveToolCall 内部回滚该条状态)
|
// 单条失败不中断批量操作(已由 approveToolCall 内部回滚该条状态)
|
||||||
|
failCount++
|
||||||
// 继续处理剩余项
|
// 继续处理剩余项
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
_batchProcessing.value = false
|
||||||
|
// 若有失败项,console.warn 诊断(用户可打开 DevTools 查看具体失败项)
|
||||||
|
if (failCount > 0) {
|
||||||
|
console.warn(`[AI] 批量审批完成: ${successCount} 成功, ${failCount} 失败`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量处理中(供按钮禁用 + spinner 展示) */
|
||||||
|
export function isBatchProcessing(): boolean {
|
||||||
|
return _batchProcessing.value
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAiApproval() {
|
export function useAiApproval() {
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ let _unlistenApprovalClear: (() => void) | null = null
|
|||||||
// (两回调写同一 state.currentText → 流式文字双倍)
|
// (两回调写同一 state.currentText → 流式文字双倍)
|
||||||
let _startPromise: Promise<void> | null = null
|
let _startPromise: Promise<void> | null = null
|
||||||
|
|
||||||
|
// 上一次 AiTextDelta 的内容(单窗口内 LLM 重复 delta 防御用,见 handleStreamingEvent)
|
||||||
|
let _lastDelta = ''
|
||||||
|
|
||||||
const appSettings = useAppSettingsStore()
|
const appSettings = useAppSettingsStore()
|
||||||
|
|
||||||
// B-260616-17: 看门狗不重置的事件集合(审批等待/完成/错误由各自 case 内 clear)。
|
// B-260616-17: 看门狗不重置的事件集合(审批等待/完成/错误由各自 case 内 clear)。
|
||||||
@@ -205,6 +208,7 @@ export function flushCurrentText() {
|
|||||||
// 自清把 flush 语义收敛为"回填并归零",消除对调用方清空顺序的依赖。各调用方后续的
|
// 自清把 flush 语义收敛为"回填并归零",消除对调用方清空顺序的依赖。各调用方后续的
|
||||||
// currentText='' 对已清空值幂等,无副作用。
|
// currentText='' 对已清空值幂等,无副作用。
|
||||||
state.currentText = ''
|
state.currentText = ''
|
||||||
|
_lastDelta = '' // 同步重置 delta 跟踪,避免新轮首个 delta 误命中上一轮末 delta 重复检测
|
||||||
}
|
}
|
||||||
|
|
||||||
/** token 用量展示开关(读 appSettings,与 Settings.vue 共享 key `df-show-token-usage`) */
|
/** token 用量展示开关(读 appSettings,与 Settings.vue 共享 key `df-show-token-usage`) */
|
||||||
@@ -274,9 +278,28 @@ function removePendingDirAuth(id: string): void {
|
|||||||
/** streaming 域:流式文本累积/新轮/心跳/重试/max 轮暂停 */
|
/** streaming 域:流式文本累积/新轮/心跳/重试/max 轮暂停 */
|
||||||
function handleStreamingEvent(event: AiChatEvent): boolean {
|
function handleStreamingEvent(event: AiChatEvent): boolean {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'AiTextDelta':
|
case 'AiTextDelta': {
|
||||||
|
// BUG-260624-01 / UX-260617-12 消息重叠防御:
|
||||||
|
// 历史多次报「LLM 回复文字重复」(如「好好,,现在现在我对我对」单字双重叠加)。
|
||||||
|
// 已排除的根因:后端 stream_recv.rs emit 增量 chunk.delta(非累积)、
|
||||||
|
// startListener 幂等+_startPromise 并发去重、streamingBlocks 块级 memo。
|
||||||
|
// 最可能残留路径:① 主窗口 AI 面板与 ai-detached 分离窗口同时打开,两 webview 独立 JS 上下文
|
||||||
|
// 各自 listen 全局广播事件 → 各自 currentText += delta(用户感知「双倍」)。
|
||||||
|
// 桌面双窗口场景下难以在单窗口内根治,需后端定向 emit_to(label) 重构。
|
||||||
|
// ② LLM provider(GLM anthropic_compat 等)在特定情况下返重复 delta。
|
||||||
|
// 防御:连续两次 delta 完全相同(且非空)时丢弃第二次 + console.warn,留下诊断证据。
|
||||||
|
// 副作用极低:正常 LLM 流不会连发完全相同 delta(空格/单字符除外,已 len>1 守卫)。
|
||||||
|
if (event.delta && _lastDelta === event.delta && event.delta.length > 1) {
|
||||||
|
// df-ai-trace-delta 开关控制(默认开,生产可关)
|
||||||
|
if (appSettings.get('df-ai-trace-delta', true)) {
|
||||||
|
console.warn('[AiTextDelta] 重复 delta 已丢弃(疑似双窗口 listener 或 provider 异常):', JSON.stringify(event.delta))
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
_lastDelta = event.delta
|
||||||
state.currentText += event.delta
|
state.currentText += event.delta
|
||||||
return true
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
case 'AiAgentRound': {
|
case 'AiAgentRound': {
|
||||||
// Agent 循环新一轮:保存当前文本到上一条 assistant 消息,新建空 assistant 消息
|
// Agent 循环新一轮:保存当前文本到上一条 assistant 消息,新建空 assistant 消息
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ async function detachPanel(convId?: string) {
|
|||||||
minHeight: 400,
|
minHeight: 400,
|
||||||
center: true,
|
center: true,
|
||||||
decorations: true,
|
decorations: true,
|
||||||
|
alwaysOnTop: true, // 分离窗口默认置顶(AI 助手常态:用户在主窗口工作时希望 AI 辅助窗口可见)
|
||||||
})
|
})
|
||||||
win.once('tauri://created', () => {
|
win.once('tauri://created', () => {
|
||||||
state.panelOpen = false
|
state.panelOpen = false
|
||||||
@@ -258,10 +259,22 @@ const AI_DOCK_WIDTH = 600
|
|||||||
const AI_DOCK_GAP = 4
|
const AI_DOCK_GAP = 4
|
||||||
// 防 syncToMain 改主窗口 size 触发 onResized 递归回调
|
// 防 syncToMain 改主窗口 size 触发 onResized 递归回调
|
||||||
let _isSyncing = false
|
let _isSyncing = false
|
||||||
|
// 拖动/缩放防抖:用户拖动主窗口期间 onMoved/onResized 高频触发,
|
||||||
|
// 直接 sync 会让 setSize/setPosition 排队 → AI 窗口抖动 + 性能浪费。
|
||||||
|
// 防后 150ms 才执行,用户停下才同步(手感顺 + 不会重重复复中中间态同步)。
|
||||||
|
let _syncTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
const SYNC_DEBOUNCE_MS = 150
|
||||||
|
|
||||||
/** 同步 AI 窗口位置到主窗口右侧(保留 GAP 间隙)。
|
/** 同步 AI 窗口位置到主窗口右侧(保留 GAP 间隙)。
|
||||||
* U-260618: 主窗口最大化或主+AI 超出屏幕时,取消最大化 + 缩小主窗口宽度,
|
*
|
||||||
* 让主窗口与 AI 窗口都进入屏幕(原:主窗口最大化时 AI 被挤到屏幕外)。 */
|
* 设计原则(本次优化重点):
|
||||||
|
* 1. 不反向改主窗口 size——原实现在拖动主窗口时 setSize 主窗口,使主窗口在拖动过程中自己也在变,
|
||||||
|
* 拖动体验极差。现改为:主窗口是真相源,AI 窗口跟随到主窗口右侧;若右侧空间不够,AI 窗口本身让位
|
||||||
|
* (往左偏移或缩宽),不动主窗口。
|
||||||
|
* 2. 不动主窗口最大化状态——原实现强制 unmaximize 主窗口,破坏用户最大化习惯。
|
||||||
|
* 现改为:主窗口最大化时仅同步高度,AI 窗口贴右侧屏幕边显示。
|
||||||
|
* 3. 仅同步位置与高度(跟随主窗口高度),宽度保持 AI_DOCK_WIDTH 不变(避免宽度反复跳)。
|
||||||
|
*/
|
||||||
async function syncToMain() {
|
async function syncToMain() {
|
||||||
if (_isSyncing) return
|
if (_isSyncing) return
|
||||||
_isSyncing = true
|
_isSyncing = true
|
||||||
@@ -276,28 +289,32 @@ async function syncToMain() {
|
|||||||
const monitor = await currentMonitor()
|
const monitor = await currentMonitor()
|
||||||
const screenWidth = monitor?.size.width ?? 0
|
const screenWidth = monitor?.size.width ?? 0
|
||||||
|
|
||||||
// 取消主窗口最大化(吸附需主窗口让出右侧空间给 AI 窗口)
|
const pos = await mainWin.outerPosition()
|
||||||
if (await mainWin.isMaximized()) {
|
const size = await mainWin.innerSize()
|
||||||
await mainWin.unmaximize()
|
const maximized = await mainWin.isMaximized()
|
||||||
await new Promise(r => setTimeout(r, 80)) // 等 unmaximize UI 落定再读尺寸
|
|
||||||
}
|
|
||||||
|
|
||||||
let pos = await mainWin.outerPosition()
|
// 计算目标位置(默认贴主窗口右侧 + GAP)
|
||||||
let size = await mainWin.innerSize()
|
let targetX = pos.x + size.width + AI_DOCK_GAP
|
||||||
|
let targetY = pos.y
|
||||||
|
let targetHeight = size.height
|
||||||
|
let targetWidth = AI_DOCK_WIDTH
|
||||||
|
|
||||||
// 主+AI 超屏幕宽度 → 强制缩小主窗口让 AI 进屏幕
|
if (maximized) {
|
||||||
if (screenWidth > 0 && pos.x + size.width + AI_DOCK_GAP + AI_DOCK_WIDTH > screenWidth) {
|
// 主窗口最大化时:不动主窗口。AI 贴屏幕右侧(主窗口全屏时右侧没空间,AI 覆盖在主窗口上)。
|
||||||
const newMainWidth = screenWidth - AI_DOCK_GAP - AI_DOCK_WIDTH - pos.x
|
// 保持 AI 默认高度不变(跟随屏幕高度易越界),高度不动。
|
||||||
if (newMainWidth >= 400) {
|
if (screenWidth > 0) {
|
||||||
await mainWin.setSize(new PhysicalSize(newMainWidth, size.height))
|
targetX = screenWidth - AI_DOCK_WIDTH - AI_DOCK_GAP
|
||||||
await new Promise(r => setTimeout(r, 50)) // 等 setSize UI 落定
|
|
||||||
pos = await mainWin.outerPosition()
|
|
||||||
size = await mainWin.innerSize()
|
|
||||||
}
|
}
|
||||||
|
targetHeight = size.height > targetHeight ? targetHeight : size.height
|
||||||
|
} else if (screenWidth > 0 && targetX + AI_DOCK_WIDTH > screenWidth) {
|
||||||
|
// 非最大化但主+AI 超屏幕:AI 窗口本身让位(缩窄或往左偏),不动主窗口。
|
||||||
|
// 优先策略:贴屏幕右侧边,AI 窗口宽度可能被主窗口右边缘掩盖一部分,但不强迫主窗口缩小。
|
||||||
|
// 若主窗口右侧几乎贴边(targetX < pos.x + 100,主窗口已占满宽度),AI 覆盖在主窗口右侧上方(置顶态自然可见)。
|
||||||
|
targetX = Math.max(pos.x + 100, screenWidth - AI_DOCK_WIDTH - AI_DOCK_GAP)
|
||||||
}
|
}
|
||||||
|
|
||||||
await aiWin.setPosition(new PhysicalPosition(pos.x + size.width + AI_DOCK_GAP, pos.y))
|
await aiWin.setPosition(new PhysicalPosition(targetX, targetY))
|
||||||
await aiWin.setSize(new PhysicalSize(AI_DOCK_WIDTH, size.height))
|
await aiWin.setSize(new PhysicalSize(targetWidth, targetHeight))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[AI] syncToMain 失败:', e)
|
console.error('[AI] syncToMain 失败:', e)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -305,18 +322,28 @@ async function syncToMain() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 启动主窗口 move/resize 跟随(吸附时调用) */
|
/** 同步防抖包装:拖动/缩放高频事件不走立即同步,防 150ms 后执行。 */
|
||||||
|
function scheduleSync() {
|
||||||
|
if (_syncTimer) clearTimeout(_syncTimer)
|
||||||
|
_syncTimer = setTimeout(() => {
|
||||||
|
_syncTimer = null
|
||||||
|
void syncToMain()
|
||||||
|
}, SYNC_DEBOUNCE_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 启动主窗口 move/resize 跟随(吸附时调用,防后 150ms 同步防抖动) */
|
||||||
async function startFollowMain() {
|
async function startFollowMain() {
|
||||||
stopFollowMain()
|
stopFollowMain()
|
||||||
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||||
const mainWin = await WebviewWindow.getByLabel('main')
|
const mainWin = await WebviewWindow.getByLabel('main')
|
||||||
if (!mainWin) return
|
if (!mainWin) return
|
||||||
_unlistenMove = await mainWin.onMoved(() => { void syncToMain() })
|
_unlistenMove = await mainWin.onMoved(() => { scheduleSync() })
|
||||||
_unlistenResize = await mainWin.onResized(() => { void syncToMain() })
|
_unlistenResize = await mainWin.onResized(() => { scheduleSync() })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 停止主窗口跟随 */
|
/** 停止主窗口跟随 + 清防抖计时器 */
|
||||||
function stopFollowMain() {
|
function stopFollowMain() {
|
||||||
|
if (_syncTimer) { clearTimeout(_syncTimer); _syncTimer = null }
|
||||||
_unlistenMove?.()
|
_unlistenMove?.()
|
||||||
_unlistenResize?.()
|
_unlistenResize?.()
|
||||||
_unlistenMove = null
|
_unlistenMove = null
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ export default {
|
|||||||
unpin: 'Unpin',
|
unpin: 'Unpin',
|
||||||
closeWindow: 'Close window',
|
closeWindow: 'Close window',
|
||||||
|
|
||||||
|
// ── Approval overlay (global top-right) ──
|
||||||
|
approvalWaitingTitle: 'Approval required',
|
||||||
|
approvalWaitingDesc: 'Click to review and handle',
|
||||||
|
|
||||||
// ── Provider status ──
|
// ── Provider status ──
|
||||||
clickToSwitchProvider: 'Click to switch provider',
|
clickToSwitchProvider: 'Click to switch provider',
|
||||||
providerNotConfigured: 'No AI provider configured. Please add one in settings.',
|
providerNotConfigured: 'No AI provider configured. Please add one in settings.',
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ export default {
|
|||||||
unpin: '取消置顶',
|
unpin: '取消置顶',
|
||||||
closeWindow: '关闭窗口',
|
closeWindow: '关闭窗口',
|
||||||
|
|
||||||
|
// ── 审批浮层(全局右上角)──
|
||||||
|
approvalWaitingTitle: '有待审批请求',
|
||||||
|
approvalWaitingDesc: '点击查看并处理',
|
||||||
|
|
||||||
// ── Provider 状态 ──
|
// ── Provider 状态 ──
|
||||||
clickToSwitchProvider: '点击切换 Provider',
|
clickToSwitchProvider: '点击切换 Provider',
|
||||||
providerNotConfigured: '未配置 AI 提供商,请在设置中添加',
|
providerNotConfigured: '未配置 AI 提供商,请在设置中添加',
|
||||||
|
|||||||
@@ -17,12 +17,8 @@
|
|||||||
v-model="activeCategory"
|
v-model="activeCategory"
|
||||||
:horizontal="isNarrowScreen"
|
:horizontal="isNarrowScreen"
|
||||||
@scroll-target="scrollToSettingItem"
|
@scroll-target="scrollToSettingItem"
|
||||||
>
|
/>
|
||||||
<!-- 底部导入/导出(阶段6):窄屏横向模式不显,避免横向 tab 拥挤 -->
|
<!-- 导入/导出已移除:实测价值低且占用 tab 底部空间,如需跨设备同步建议直接复制 SQLite 文件 -->
|
||||||
<template v-if="!isNarrowScreen" #footer>
|
|
||||||
<SettingsImportExport :confirm-dialog="confirmDialog" :toast="showToast" />
|
|
||||||
</template>
|
|
||||||
</SettingsNav>
|
|
||||||
|
|
||||||
<div ref="contentRef" class="settings-content">
|
<div ref="contentRef" class="settings-content">
|
||||||
<!-- 外观:theme/language/aiLanguage/showTokenUsage -->
|
<!-- 外观:theme/language/aiLanguage/showTokenUsage -->
|
||||||
@@ -79,7 +75,7 @@ import PerformanceSection from '@/components/settings/PerformanceSection.vue'
|
|||||||
import AdvancedSection from '@/components/settings/AdvancedSection.vue'
|
import AdvancedSection from '@/components/settings/AdvancedSection.vue'
|
||||||
import KnowledgePanel from '@/components/settings/KnowledgePanel.vue'
|
import KnowledgePanel from '@/components/settings/KnowledgePanel.vue'
|
||||||
import AllowedDirsPanel from '@/components/settings/AllowedDirsPanel.vue'
|
import AllowedDirsPanel from '@/components/settings/AllowedDirsPanel.vue'
|
||||||
import SettingsImportExport from '@/components/settings/SettingsImportExport.vue'
|
// SettingsImportExport 已移除(价值低,占用 UI 空间;跨设备同步建议直接复制 SQLite)
|
||||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||||
import { aiApi } from '@/api'
|
import { aiApi } from '@/api'
|
||||||
import type { AiProviderConfig } from '@/api/types'
|
import type { AiProviderConfig } from '@/api/types'
|
||||||
|
|||||||
50
todo.md
50
todo.md
@@ -14,7 +14,8 @@
|
|||||||
> 诊断报告:`docs/02-架构设计/专项设计/AI对话目标丢失诊断-2026-06-26.md`
|
> 诊断报告:`docs/02-架构设计/专项设计/AI对话目标丢失诊断-2026-06-26.md`
|
||||||
> 实测会话 `54c8b5bd`:480 万 token,原始目标消息被压缩出局,LLM 尾部盲搜死循环。
|
> 实测会话 `54c8b5bd`:480 万 token,原始目标消息被压缩出局,LLM 尾部盲搜死循环。
|
||||||
|
|
||||||
- [x] **G1 目标钉扎** ✅ 代码已落地(`GOAL_PIN_ENABLED=true`),pinned_goals 入口拼入 system_prompt。`PerConvState.pinned_goals: Vec<String>` + chat.rs 三处提取(追加去重,支持累积多目标)。
|
- [x] **G1 目标钉扎** ✅ 代码已落地(`GOAL_PIN_ENABLED=true`),pinned_goals 入口拼入 system_prompt。`PerConvState.pinned_goals: Vec<GoalEntry>`(含 text+status 状态追踪)。
|
||||||
|
- 2026-07-03 改:提取方式从「用户消息规则提取」→「工具调用推理」(infer_goal_from_tool_calls),支持多条/轮 + 自动 active→completed 状态流转。
|
||||||
- [x] **G2 探索熔断** ✅ 代码已落地(`STALL_BREAKER_ENABLED=true`,`STALL_BREAKER_THRESHOLD=3`),stall_count + is_empty_tool_result 完整实现。
|
- [x] **G2 探索熔断** ✅ 代码已落地(`STALL_BREAKER_ENABLED=true`,`STALL_BREAKER_THRESHOLD=3`),stall_count + is_empty_tool_result 完整实现。
|
||||||
- [x] **G4 话题标记修正** ✅ 代码已落地(`TOPIC_MARKER_GOAL_AWARE=true`),pinned_goals 非空时 take 后丢弃跳过 insert。
|
- [x] **G4 话题标记修正** ✅ 代码已落地(`TOPIC_MARKER_GOAL_AWARE=true`),pinned_goals 非空时 take 后丢弃跳过 insert。
|
||||||
|
|
||||||
@@ -59,3 +60,50 @@
|
|||||||
- [x] **migration V33** ✅ 已实现(`migrations.rs` migrate_v33):`workflow_executions` 加 `updated_at` 列 + `ai_conversations` 加 `pending_approvals`(审批重启恢复)。
|
- [x] **migration V33** ✅ 已实现(`migrations.rs` migrate_v33):`workflow_executions` 加 `updated_at` 列 + `ai_conversations` 加 `pending_approvals`(审批重启恢复)。
|
||||||
- [x] **L0-handshake 防抖** ✅ 已实现(`lib.rs:73-82`):3s 内重复 `ai-client-ready` 跳过。
|
- [x] **L0-handshake 防抖** ✅ 已实现(`lib.rs:73-82`):3s 内重复 `ai-client-ready` 跳过。
|
||||||
|
|
||||||
|
## AI Chat 体验优化(2026-07-03 批次)
|
||||||
|
|
||||||
|
> 本轮完成的 10 项需求中,8 项已落地,3 项暂缓待后续批次。
|
||||||
|
|
||||||
|
### 已完成
|
||||||
|
|
||||||
|
- [x] **AI Chat 入口图标换** ✅ 灯泡→星花 sparkles + 发光效果(`AppSidebar.vue`)
|
||||||
|
- [x] **LLM 回复文字重叠防御** ✅ delta 重复检测+丢弃(`useAiEvents.ts`)
|
||||||
|
- [x] **全部接管仍审批** ✅ patch_file 全档位自动放行(`approval.rs`) + 诊断日志(`audit/mod.rs`)
|
||||||
|
- [x] **设置导入导出去除** ✅ `SettingsImportExport.vue` 已删,引用清理(`Settings.vue`/`SettingsNav.vue`)
|
||||||
|
- [x] **分离窗口默认置顶** ✅ `alwaysOnTop: true`(`useAiWindow.ts`)
|
||||||
|
- [x] **审批通知浮层** ✅ 全局右上角浮层(`ApprovalOverlay.vue` + `App.vue` 挂载)
|
||||||
|
- [x] **header 菜单聚合 `...`** ✅ 清空/压缩上下文收入 popout(`TopBar.vue`)
|
||||||
|
- [x] **置顶图标换** ✅ 星→大头针 pushpin(`TopBar.vue`)
|
||||||
|
- [x] **全部批准无响应** ✅ 防抖守卫 `_batchProcessing`(`useAiApproval.ts`)
|
||||||
|
- [x] **工具卡执行中卡死** ✅ 乐观更新 `status !== 'completed'` 守卫(`useAiApproval.ts`)
|
||||||
|
- [x] **置顶状态刷新丢失** ✅ 持久化到 appSettings KV(`AiChat.vue`)
|
||||||
|
- [x] **目标提取根本性改造** ✅ 工具调用推理(`infer_goal_from_tool_calls` in `helpers.rs`)
|
||||||
|
- 提取源:用户消息规则 → **工具调用推理**(结构化数据,零污染)
|
||||||
|
- 数据结构:`Vec<String>` → **`Vec<GoalEntry>`**(text+status)
|
||||||
|
- 状态流转:新目标加入 → 之前 active 自动标记 completed
|
||||||
|
- system_prompt 仅注入 active 目标
|
||||||
|
- 前端显示 3/5 计数 + ✓/● 状态标记
|
||||||
|
- 代码拆分到 `helpers.rs`(解决 brace 嵌套致 pub(crate) 不可见)
|
||||||
|
|
||||||
|
### 暂缓待后续批次
|
||||||
|
|
||||||
|
- [ ] **9b 审批交互 UI/UX 重新设计** ⏸️
|
||||||
|
- 当前:审批卡片是 ToolCard 的 pending_approval 态,布局单调、args 展示过长、风险等级不直观
|
||||||
|
- 待做:重新设计卡片布局(缩短 args 预览 + 展开/收起、风险等级着色条、审批耗时提示、批量操作栏视觉增强)
|
||||||
|
- 涉及:`ToolCard.vue`(~360 行) + `ToolCardList.vue` 少量调整
|
||||||
|
- 优先级:中——现有功能可用但体验不够好
|
||||||
|
|
||||||
|
- [ ] **9d 底部工具区(目标/历史/上下文)展开交互优化** ⏸️
|
||||||
|
- 当前:底部 🎯📋📄 三个入口紧凑排列,展开后内容区窄、超出不滚动、缺少动画
|
||||||
|
- 待做:popover 浮层替代内联展开、加入平滑过渡动画、内容超出滚动、点击外部收起
|
||||||
|
- 涉及:`TopBar.vue` 底部区
|
||||||
|
- 优先级:低——功能完整,体验改善不迫切
|
||||||
|
|
||||||
|
- [ ] **`[UPDATE_GOALS]` LLM 主动修正目标列表** ⏸️
|
||||||
|
- 设计已完成,实现暂缓
|
||||||
|
- 格式:`[UPDATE_GOALS]\n1: 目标文本 (active)\n2: 目标文本 (completed)\n[END]`
|
||||||
|
- LLM 输出此标记 → 后端解析并替换 pinned_goals → 标记从正文剥离
|
||||||
|
- 函数签名 `extract_update_goals_block` 已设计未实现
|
||||||
|
- 覆盖场景:意图增强/纠偏/递进(用户补充信息后发现之前目标提取偏了)
|
||||||
|
- 优先级:中——价值高改动小,补全目标维护闭环
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user