Compare commits

...
4 Commits
Author SHA1 Message Date
lxy 8396849d04 优化: 设置域体验完善
- 校验失败红框定位字段,保存反馈改持久化成功后
- 数值越界钳制,双提示队列化不再重叠
- 搜索防抖跳分类,托盘气泡文案进多语言
2026-10-03 17:57:43 +08:00
lxy f67e65633b 优化: 会话入口与对话交互打磨
- 完成卡点开子代理轨迹浮层,任务抽屉关联会话跳转
- 提及片段编辑差分三向判定,不再错位
- 长回复折叠完成瞬间锚定消息顶,视口不跳
- 空编辑/技能无匹配/回填覆盖草稿均有提示确认
- 队列编辑态切会话重置,刷新快捷键不再误触
- git 变更面板去重缓存,项目卡骨架与溢出重测
2026-10-03 17:57:43 +08:00
lxy 890ceef0ec 新增: 子代理轨迹查看与任务关联会话
- 会话只读窥视命令,不切换当前状态
- 任务关联会话反查,审计表结果精确匹配
- 托盘气泡键名修正,首关说明链路修通
2026-10-03 17:57:33 +08:00
lxy 8e09112d59 修复: 命名空间上限与网络域解析兜底
- 命名空间超容单条拒绝,上限不变式恢复
- usage 部分字段缺失致整帧内容蒸发,补缺省兜底
- tool_call 缺 id 致整批丢弃,对齐流式 fallback 口径
- 视觉模型判定改词素尾,deepseek-v3 不再误判
- 并行分支 panic 任务对账补齐为失败结果
- plan 执行器死代码清理,实况注释校正
2026-10-03 17:57:33 +08:00
38 changed files with 1294 additions and 135 deletions
+69
View File
@@ -508,14 +508,50 @@ impl Coordinator {
});
}
// 记录最近一次 JoinError 详情:JoinError 不携带 subtask 身份,
// 缺失任务靠循环结束后按 id 对账补齐,错误文本由此带出
let mut join_err_msg: Option<String> = None;
while let Some(join_result) = join_set.join_next().await {
match join_result {
Ok(result) => {
results_map.insert(result.subtask_id.clone(), result);
}
Err(e) => {
// panic 与取消分开留痕:panic 是执行体自身崩溃需排查,取消是外部干预
if e.is_panic() {
tracing::error!("[COORDINATOR] 子任务 panic: {}", e);
} else {
tracing::warn!("[COORDINATOR] 子任务被取消: {}", e);
}
join_err_msg = Some(e.to_string());
}
}
}
// panic/取消的子任务不能凭空消失:返回契约是与 plan.tasks 一一对应,
// 缺失会被下游误读为"无此任务";此处按该任务失败语义补失败结果收敛,
// merge 按 success 过滤自然跳过,不扩散中断整层
for task in layer {
if !results_map.contains_key(&task.id) {
tracing::error!(
subtask = %task.id,
"[COORDINATOR] 子任务执行体崩溃,按失败结果收敛(不扩散中断)"
);
results_map.insert(
task.id.clone(),
ExecutionResult {
subtask_id: task.id.clone(),
persona_id: task_persona
.get(&task.id)
.cloned()
.unwrap_or_default(),
output: join_err_msg
.clone()
.unwrap_or_else(|| "子任务 panic/被取消".to_string()),
success: false,
},
);
}
}
} else {
@@ -1415,6 +1451,39 @@ mod tests {
assert_eq!(results[1].subtask_id, "b");
}
#[tokio::test]
async fn par_05_panicked_task_converges_to_failed_result() {
// 同层并行中某子任务 panic:JoinSet 返回 JoinError(panic 变体),
// 应收敛为该任务的失败结果,而不是让它从结果列表凭空消失
let coord = make_coord();
let plan = Plan::from_tasks(vec![
SubTask::new("t1", "task1"),
SubTask::new("t2", "task2"),
SubTask::new("t3", "task3"),
]);
let results = coord
.dispatch_with_budget(&plan, |task, persona_id: String| async move {
if task.id == "t2" {
panic!("模拟子任务执行体崩溃");
}
ExecutionResult {
subtask_id: task.id,
persona_id,
output: format!("done {}", task.intent),
success: true,
}
}, None)
.await;
assert_eq!(results.len(), 3, "panic 子任务也应占一个结果位");
let t2 = results.iter().find(|r| r.subtask_id == "t2").expect("t2 应有失败结果");
assert!(!t2.success, "panic 子任务应收敛为失败而非成功");
assert!(!t2.output.is_empty(), "失败结果应带 JoinError 详情");
assert!(results.iter().filter(|r| r.subtask_id != "t2").all(|r| r.success),
"其余子任务不受 panic 扩散影响");
}
// -- Plan-driven LLM 规划开关 --
#[test]
+19 -4
View File
@@ -134,6 +134,10 @@ pub(crate) fn send_error_class(p: SendErrPreds) -> &'static str {
/// 其余(openai / glm / deepseek 等 OpenAI 兼容)走 OpenAICompatProvider。
/// 调用方(AI Chat 的 run_agentic_loop、df-nodes 的 AiNode)统一引用此工厂,
/// 新增 provider 只改这一处。
///
/// 未知 provider_type 无法走显式报错(工厂签名不动,调用方横跨 src-tauri/df-nodes),
/// 兜底 OpenAI 兼容协议但 warn 留痕,不再静默——协议错配的请求失败可溯源到配置项。
/// anthropic_family 口径与 prompt.rs cache_split / chat.rs needs_url_prefetch 对齐。
pub fn build_provider(
provider_type: &str,
base_url: &str,
@@ -141,11 +145,22 @@ pub fn build_provider(
model: &str,
) -> Box<dyn LlmProvider> {
match provider_type {
"anthropic" => Box::new(anthropic_compat::AnthropicCompatProvider::new(
"anthropic" | "anthropic_compat" => Box::new(
anthropic_compat::AnthropicCompatProvider::new(base_url, api_key, model),
),
"openai_compat" | "openai" | "glm" | "deepseek" => {
Box::new(openai_compat::OpenAICompatProvider::new(
base_url, api_key, model,
)),
_ => Box::new(openai_compat::OpenAICompatProvider::new(
))
}
unknown => {
tracing::warn!(
"未知 provider_type {:?},按 OpenAI 兼容协议兜底;请检查 ai_providers 配置的 provider_type",
unknown
);
Box::new(openai_compat::OpenAICompatProvider::new(
base_url, api_key, model,
)),
))
}
}
}
+30
View File
@@ -333,6 +333,36 @@ mod tests {
assert_eq!(m.cost_tier, CostTier::Medium);
}
// ── 启发式:版本号后缀不误判 Vision ──
#[test]
fn heuristic_version_suffix_no_vision() {
// "-v" 后接数字属版本号(deepseek-v3 为纯文本模型),不得误判 Vision
for name in ["deepseek-v3", "acme-chat-v2", "acme-v12"] {
let m = probe(name);
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
assert!(
!m.modalities.contains(&Modality::Vision),
"{name} 版本号后缀不应误判 Vision: {:?}",
m.modalities
);
}
}
#[test]
fn heuristic_v_morpheme_tail_still_vision() {
// v 词素尾仍判视觉:独立 v 段(qwen-v-plus 形态)与段尾 v(glm-4v 形态,假名避开预设表)
for name in ["acme-chat-v", "acme-4v-plus"] {
let m = probe(name);
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
assert!(
m.modalities.contains(&Modality::Vision),
"{name} 段尾 v 词素应判 Vision: {:?}",
m.modalities
);
}
}
// ── 启发式:默认(Standard/Medium/Text/ToolUse) ──
#[test]
+6 -3
View File
@@ -112,8 +112,11 @@ pub(super) fn heuristic_infer(model_id: &str) -> ModelConfig {
// —— 词素判定助手(命名约定式启发式,集中维护便于增删) ——
/// Vision 词素:`-v`(尾缀如 glm-4v)、以 `v` 结尾、含 `vl` / `vision`。
/// 不直接 `contains('v')`(避免误伤 review/verbal 等)。
/// Vision 词素:`vision` / `vl` 子串、`v` 词素尾(任一分隔段以 v 结尾:glm-4v / qwen-v-plus)。
/// 裸 "v" 必须是词素尾:`contains("-v")` 会把版本号误判成视觉(deepseek-v3 纯文本,
/// "-v" 后接数字属版本号而非视觉词素),按段尾判定后 v3/v2 不命中、4v/v 段仍命中。
pub(super) fn has_vision_token(name: &str) -> bool {
name.contains("vision") || name.contains("vl") || name.ends_with('v') || name.contains("-v")
name.contains("vision")
|| name.contains("vl")
|| name.split(['-', '.', '_']).any(|seg| seg.ends_with('v'))
}
+29 -1
View File
@@ -81,6 +81,10 @@ impl NamespaceStore {
}
/// 存入 namespace。返回引用路径字符串。
///
/// 超限行为分两档:常规超限 LRU 淘汰旧条目腾位(既有语义);单条本身超过
/// max_bytes 时淘汰清空也放不下,拒绝入库只返回引用路径——展开点对
/// read_only()=None 统一落 EVICTED_PLACEHOLDER,与 LRU 淘汰后的不可解析引用同语义。
pub fn store(&mut self, tool_name: &str, content: &str) -> String {
let key = simple_hash(content);
let path = format!("{}{}/{}", NAMESPACE_REF_PREFIX, tool_name, key);
@@ -93,8 +97,15 @@ impl NamespaceStore {
return path;
}
// 检查容量,超限淘汰
let new_bytes = content.len();
// 单条超过总容量:硬入库会把 current_bytes 永久顶破上限(evict 清空后仍无条件
// insert 的旧路径),拒绝入库保住 current_bytes <= max_bytes 不变式
if new_bytes > self.max_bytes {
return path;
}
// 检查容量,超限淘汰
self.evict_if_needed(new_bytes);
let entry = NamespaceEntry {
@@ -268,6 +279,23 @@ mod tests {
}
}
#[test]
fn store_oversized_single_entry_rejected_cap_intact() {
// 回归:单条超过 max_bytes 时旧路径淘汰清空后仍无条件入库,current_bytes
// 永久突破上限。现拒绝入库,仅返回引用路径,展开点落 EVICTED_PLACEHOLDER。
let mut ns = NamespaceStore::new(100);
let path = ns.store("t1", &"x".repeat(200));
assert_eq!(ns.len(), 0, "超容量单条应拒绝入库");
assert_eq!(ns.current_bytes, 0, "字节账目不得突破上限");
assert!(is_namespace_ref(&path), "返回值仍是引用路径");
assert_eq!(ns.read_only(&path), None, "不可解析 → 既有淘汰占位语义");
// 拒绝后不污染状态,常规条目仍可正常入
let p2 = ns.store("t2", "ok");
assert_eq!(ns.read(&p2).map(str::to_owned), Some("ok".to_string()));
assert_eq!(ns.current_bytes, 2);
}
#[test]
fn clear_resets_all() {
let mut ns = NamespaceStore::new(100_000);
+28
View File
@@ -310,7 +310,15 @@ impl OpenAICompatProvider {
.into_iter()
.enumerate()
.map(|(i, c)| {
let missing_id = c.id.is_empty();
let id = tool_call_id_or_fallback(&c.id, i, "gen_tool");
if missing_id {
tracing::warn!(
function = %c.function.name,
fallback_id = %id,
"[OpenAI] 同步响应 tool_call 缺/空 id,已生成 fallback id(防结果路由串号)"
);
}
ToolCall::new(id, c.function.name, c.function.arguments)
})
.collect()
@@ -1293,6 +1301,26 @@ mod tests {
assert_eq!(ids.len(), unique.len(), "id 应全部唯一,实际 {:?}", ids);
}
/// 同步响应 tool_call 缺 id/arguments 字段:serde default 兜底(空串/空串),
/// 缺 id 经 parse_tool_calls 生成 fallback。旧行为:id 必填,单条缺字段令整个
/// 响应 serde 失败 → complete 报"响应解析失败"整批丢弃。
#[test]
fn openai_sync_tool_call_missing_fields_default_instead_of_failing() {
let calls: Vec<OpenAiToolCallResp> = serde_json::from_str(
r#"[{"type":"function","function":{"name":"list_dir"}}]"#,
)
.expect("缺 id/arguments 的 tool_call 应可反序列化,不再整响应解析失败");
let parsed = OpenAICompatProvider::parse_tool_calls(calls);
assert_eq!(parsed.len(), 1);
assert!(
parsed[0].id.starts_with("gen_tool_"),
"缺 id → fallback id, got: {}",
parsed[0].id
);
assert_eq!(parsed[0].function.name, "list_dir");
assert_eq!(parsed[0].function.arguments, "", "缺 arguments 缺省空串");
}
/// CR-空 id 流式:SSE chunk 携带 `"id":""`(SenseNova 兼容缺陷)→ ToolCallDelta.id
/// 转为 `gen_stream_{n}` fallback(n 取自全局计数器,跨轮跨 assistant 递增,非 None),
/// 保证下游 accumulate_tool_calls 写入 draft.id 非空。chunk 完全无 id 字段(None)保持
+65 -1
View File
@@ -102,10 +102,14 @@ pub(crate) struct OpenAiMessageResp {
#[derive(Debug, Deserialize)]
pub(crate) struct OpenAiToolCallResp {
/// 缺省容错:个别兼容端点同步响应省略 id → 缺省空串,由 parse_tool_calls 的
/// tool_call_id_or_fallback 生成唯一 fallback。此前必填,单条 tool_call 缺 id 令
/// 整个响应 serde 失败 → complete 报"响应解析失败"整批丢弃(流式路径早已容错)。
#[serde(default)]
pub id: String,
// 反序列化 #[serde(rename="type")] 字段,保留以对齐 OpenAI 响应结构,标注意图消除 dead_code warning
#[allow(dead_code)]
#[serde(rename = "type")]
#[serde(rename = "type", default)]
pub call_type: String,
pub function: OpenAiFunctionResp,
}
@@ -113,6 +117,9 @@ pub(crate) struct OpenAiToolCallResp {
#[derive(Debug, Deserialize)]
pub(crate) struct OpenAiFunctionResp {
pub name: String,
/// 缺省容错:arguments 缺失 → 空串(对齐 anthropic 同步 tool_use input 缺失 args="" 口径),
/// 防单条缺字段令整个响应解析失败;name 保持必填(无名 tool_call 无法派发,无安全缺省)。
#[serde(default)]
pub arguments: String,
}
@@ -130,8 +137,13 @@ pub(crate) struct PromptTokensDetails {
/// 嵌套 cached_tokens 回填,消费方一律读归一后的扁平字段。
#[derive(Debug, Deserialize)]
pub(crate) struct OpenAiUsageRaw {
/// 三个计数缺省 0:部分兼容网关 usage 只报部分字段(如仅 total_tokens),必填会让
/// 整帧 serde 失败走空 chunk 分支 — 同帧 content 一并蒸发、usage 归零无痕。
#[serde(default)]
pub prompt_tokens: u32,
#[serde(default)]
pub completion_tokens: u32,
#[serde(default)]
pub total_tokens: u32,
/// DeepSeek 扩展:缓存命中 token(低价,deepseek-chat/reasoner)。无此字段 → default 0。
#[serde(default)]
@@ -288,6 +300,14 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
}
// 提取 usage(带 include_usage 时末段 chunk 携带,覆盖累积)
if let Some(u) = chunk.usage {
// 缺省计数的留痕:两个分项均 0 基本可断定 provider 上报不完整(真实完成
// 必有 completion 计数),非流路径下游拿到零 usage 无从察觉。
if u.prompt_tokens == 0 && u.completion_tokens == 0 {
tracing::warn!(
total = u.total_tokens,
"[OpenAI] usage 缺 prompt/completion 分项(按 0 兜底),provider 上报不完整"
);
}
tracing::info!(
prompt = u.prompt_tokens,
completion = u.completion_tokens,
@@ -316,6 +336,21 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
);
let tool_calls = choice.delta.tool_calls.map(|tcs| {
// 缺 index 的增量缺省 0(单工具调用语义等价);但同帧多 call 且 index
// 冲突时缺省会互相踩(下游按 index 累积 → 参数拼错位),warn 留痕定位
// provider 兼容缺陷,不做静默改写。
if tcs.len() > 1 {
let mut idxs: Vec<u32> = tcs.iter().map(|tc| tc.index).collect();
idxs.sort_unstable();
idxs.dedup();
if idxs.len() < tcs.len() {
tracing::warn!(
calls = tcs.len(),
unique = idxs.len(),
"[OpenAI] 同帧多 tool_call 增量 index 冲突(缺省 0 互踩),按 index 累积将错位"
);
}
}
tcs.into_iter()
.map(|tc| {
// CR-空 id:流式 chunk 的 id 可能为 Some("")(SenseNova 兼容缺陷)。
@@ -423,6 +458,35 @@ mod tests {
assert_eq!(usage.prompt_cache_miss_tokens, 36);
}
/// 部分兼容网关 usage 只报部分字段:缺省 0 入累加器,同帧 content 不再因整帧
/// serde 失败被吞(旧行为:必填字段缺失 → 空 chunk → 文本蒸发 + usage 归零无痕)
#[test]
fn sse_partial_usage_frame_parses_with_defaults() {
let mut accum = None;
let data = r#"{"choices":[{"delta":{"content":"hi"}}],"usage":{"total_tokens":10}}"#;
let c = apply_openai_sse(data, &mut accum);
assert_eq!(c.delta, "hi", "usage 缺分项不应令整帧解析失败丢 content");
let u = accum.expect("部分 usage 应写入累加器");
assert_eq!(u.total_tokens, 10);
assert_eq!(u.prompt_tokens, 0, "缺省分项按 0 兜底");
assert_eq!(u.completion_tokens, 0);
}
/// 同帧多 tool_call 增量缺 index:缺省 0 触发 index 冲突路径(仅 warn 留痕,不改写
/// 缺省策略);解析本身不失败,增量仍逐条带出。
#[test]
fn sse_tool_calls_missing_index_in_multi_call_frame_still_parse() {
let mut accum = None;
let data = r#"{"choices":[{"delta":{"tool_calls":[
{"id":"call_a","function":{"name":"ta","arguments":"{"}},
{"id":"call_b","function":{"name":"tb","arguments":"}"}}
]}}]}"#;
let c = apply_openai_sse(data, &mut accum);
let tcs = c.tool_calls.expect("缺 index 的多 call 帧应可解析");
assert_eq!(tcs.len(), 2);
assert!(tcs.iter().all(|tc| tc.index == 0), "缺 index 缺省 0");
}
/// content_filter 属终态:敏感内容拦截的流部分 provider 不发 [DONE] 帧,
/// 必须靠 finish_reason 收敛,否则悬挂;未列入终态的 finish_reason 保持非终态
#[test]
+10 -7
View File
@@ -2,7 +2,8 @@
//!
//! 按 Plan::to_layers 输出的层级顺序执行子任务:
//! - 层间串行:上层全部完成才进下一层(deps 保证)
//! - 层内并行:同层 SubTask 用 tokio::JoinSet 并发执行
//! - 层内并行:同层 SubTask 用 tokio::JoinSet 并发执行(设计目标;
//! 当前为串行循环模拟,见 execute 内 TODO,本执行器未接生产链路)
//!
//! **当前状态**:骨架实现,PLAN_EXECUTION_ENABLED 门控(默认关)。
//! 未接入 agentic loop 主路径,翻 true 启用后仍需与 run_agentic_loop 对接。
@@ -16,7 +17,9 @@ use crate::planner::{Plan, SubTask};
/// Plan 执行总开关。false = 不启用(走单链 ReAct 旧行为)。
///
/// 运行时原子门控,支持热切换(通过 IPC 或前端设置开关)。
/// 默认关,翻 true 后 process_tool_calls 内以 JoinSet 并行执行同层工具。
/// 默认关。现状:PlanExecutor::execute 零生产调用点(仅测试),本执行器未接入主链;
/// agentic loop 读本开关走的是 coordinator::dispatch(Phase 1 串行桩),与本文件无关。
/// JoinSet 真并行留 Phase 3 对接时接通(见 execute 内 TODO)。
/// 与 PLANNING_ENABLED 解耦:plan_hint 独立使能,编排可见性始终可开。
use std::sync::atomic::{AtomicBool, Ordering};
pub static PLAN_EXECUTION_ENABLED: AtomicBool = AtomicBool::new(false);
@@ -87,16 +90,16 @@ impl PlanExecutor {
layer.len()
);
// 层内并行:JoinSet 收集
let _join_set: tokio::task::JoinSet<SubTaskResult> = tokio::task::JoinSet::new();
let summary_clone = parent_summary.clone();
for task in layer {
let task = Arc::new(task.clone());
let summary = summary_clone.clone();
// 注意:run_subtask 是 FnMut,不能直接 move 进 JoinSet(多任务并发调用)。
// Phase 3 对接时,run_subtask 改为 Arc<Fn> 或 channel 模式。
// 当前骨架用串行执行模拟(层内不并行),验证分层逻辑正确性。
// TODO(Phase 3 真并行): 当前为串行循环模拟并行(假并行),仅验证分层逻辑。
// run_subtask 是 FnMut,不能直接 move 进 JoinSet(多任务并发调用);
// 接真并行的改法:run_subtask 换 Arc<Fn> 或 channel 模式,
// 本层全部 spawn 进 JoinSet 统一收集后再汇总 parent_summary,替换此串行 await。
// 现状无用户可见影响:本执行器未接生产链路(execute 零调用点)。
let result = run_subtask(task.clone(), summary).await;
let sr = match result {
Ok(output) => SubTaskResult {
+229 -2
View File
@@ -10,11 +10,12 @@
//! 依赖 audit/utils.rs 的 `truncate_chars` 做参数/结果截断,通过 `super::truncate_chars` 引用。
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use tauri::State;
use df_ai::ai_tools::RiskLevel;
use df_storage::crud::{AiToolExecutionRepo, AuditQuery};
use df_storage::crud::{AiConversationRepo, AiToolExecutionRepo, AuditQuery};
use df_storage::db::Database;
use df_storage::models::AiToolExecutionRecord;
use df_types::types::new_id;
@@ -250,6 +251,128 @@ pub async fn list_tool_executions(
})
}
// ============================================================
// S4 TaskDrawer 关联会话反查(按 task_id 推导发起会话,只读)
// ============================================================
/// 任务工具精确名单(与 tools/task.rs 声明的三个任务工具一一对应)。
///
/// 审计表无"task_id → conversation"映射列,主链路靠本名单行 + result JSON 反推;
/// SQL 与本名单必须同步维护(见下方 SELECT 注释)。
const TASK_CONV_TOOLS: [&str; 3] = ["create_task", "update_task", "advance_task"];
/// 关联会话跳转条目 DTO(传给前端 TaskDrawer「关联会话」info-item)。
#[derive(Debug, Clone, Serialize)]
pub struct TaskConversationRef {
pub conversation_id: String,
/// 会话标题(ai_conversations.title,懒创建未命名时 None,前端回退显 id)
pub title: Option<String>,
/// 最近一次命中命中的工具名(create_task/update_task/advance_task)
pub tool_name: String,
/// 最近一次命中时间(毫秒字符串,同库 requested_at 口径)
pub requested_at: String,
}
/// 单行审计记录命中判定(纯函数):工具在名单内 且 result JSON 顶层 id 与 task_id 精确判等。
///
/// 禁 LIKE:任务 id 做子串匹配会误命中(如 "t1" 命中 "t12"),必须解析 JSON 取 id 字段判等;
/// result 非 JSON / 缺 id 字段 / id 不等 / 工具不在名单 → 一律不命中(failed 行的
/// `{"error":...}` 与 rejected 行的人话文本由此自然被排除)。
fn match_task_audit_row(tool_name: &str, result: Option<&str>, task_id: &str) -> bool {
if !TASK_CONV_TOOLS.contains(&tool_name) {
return false;
}
result
.and_then(|r| serde_json::from_str::<serde_json::Value>(r).ok())
.and_then(|v| v.get("id").and_then(|id| id.as_str()).map(|s| s.to_string()))
.map(|id| id == task_id)
.unwrap_or(false)
}
/// 按 task_id 反查发起过任务工具的会话(供 IPC 与单测共用,不依赖 AppState)。
///
/// 数据源 ai_tool_executions 审计表(V9):每笔任务工具执行都记 conversation_id + result。
/// repo 现有查询只支持 status/risk/tool_keyword(LIKE)维度,表达不了 `tool_name IN 精确名单`,
/// 故走专用 SELECT(与 state.rs 清理孤儿 pending 消息同款 app 层直查先例);SQL 里的工具名单
/// 是 TASK_CONV_TOOLS 的硬拷贝,单测覆盖三个名字防漂移。
///
/// 过滤:① 命中行按会话去重(行按 requested_at 倒序,首见=最近一次);② 子代理会话
/// (origin='sub_agent',V44)剔除,v1 只列主会话;③ 会话已不在库的 id 一并剔除
/// (switchConversation 对不存在 id 会走"新建兜底",跳过去是空会话,不如不列)。
/// LIMIT 1000 扫描上界:抽屉每次打开都查,封顶防超大审计表全表拖慢;取最近 1000 行,
/// 更早的关联对"跳回最近上下文"场景无意义。
pub(crate) async fn find_task_conversations(
db: &Database,
task_id: &str,
) -> Result<Vec<TaskConversationRef>, String> {
let conn = db.conn();
let rows: Vec<(String, String, Option<String>, String)> =
tokio::task::spawn_blocking(move || -> Result<Vec<(String, String, Option<String>, String)>, String> {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare(
// 工具名单硬拷贝自 TASK_CONV_TOOLS(三个名字),改动须两处同步
"SELECT conversation_id, tool_name, result, requested_at FROM ai_tool_executions \
WHERE tool_name IN ('create_task','update_task','advance_task') \
AND conversation_id IS NOT NULL \
ORDER BY CAST(requested_at AS INTEGER) DESC LIMIT 1000",
)
.map_err(|e| e.to_string())?;
let mapped = stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, Option<String>>(2)?,
row.get::<_, String>(3)?,
))
})
.map_err(|e| e.to_string())?;
let mut out = Vec::new();
for r in mapped {
out.push(r.map_err(|e| e.to_string())?);
}
Ok(out)
})
.await
.map_err(|e| format!("find_task_conversations join 失败: {}", e))??;
let conv_repo = AiConversationRepo::new(db);
let mut visited: HashSet<String> = HashSet::new();
let mut out = Vec::new();
for (conv_id, tool_name, result, requested_at) in rows {
if !match_task_audit_row(&tool_name, result.as_deref(), task_id) {
continue;
}
// 去重 + 防同会话重复查 origin(首个可见行即该会话最近一次命中)
if !visited.insert(conv_id.clone()) {
continue;
}
if let Ok(Some(rec)) = conv_repo.get_by_id(&conv_id).await {
// origin=None(主会话)才列;查无记录的会话同样不列(见函数注释③)
if rec.origin.is_none() {
out.push(TaskConversationRef {
conversation_id: conv_id,
title: rec.title,
tool_name,
requested_at,
});
}
}
}
Ok(out)
}
/// TaskDrawer「关联会话」查询 IPC:按 task_id 反查发起过 create/update/advance_task 的主会话,
/// 按 最近命中 倒序去重返回。只读,无副作用。
#[tauri::command]
pub async fn list_task_conversations(
state: State<'_, AppState>,
task_id: String,
) -> Result<Vec<TaskConversationRef>, String> {
find_task_conversations(&state.db, &task_id).await
}
// ============================================================
// AC-5 按工具失败率统计(诊断 IPC,替代 ad-hoc 查库)
// ============================================================
@@ -421,6 +544,110 @@ mod tests {
assert_eq!(out.stats[0].tool_name, "read_file");
}
/// S4 关联会话反查:插入一条指定 conv/tool/result/requested_at 的审计行(测试构造数据用)。
async fn insert_conv_audit(
repo: &AiToolExecutionRepo,
conv: &str,
tool: &str,
result: Option<String>,
t: i64,
) {
repo.insert(AiToolExecutionRecord {
id: new_id(),
conversation_id: Some(conv.to_string()),
message_id: None,
tool_call_id: new_id().into(),
tool_name: tool.to_string(),
arguments: "{}".to_string(),
result,
status: "completed".to_string(),
risk_level: "low".to_string(),
requested_at: t.to_string(),
executed_at: Some(t.to_string()),
decided_by: Some("auto".to_string()),
})
.await
.expect("测试数据插入应成功");
}
/// S4 关联会话反查:插入一条指定 origin 的会话记录(V44 origin 列,测试过滤子会话用)。
async fn insert_conversation(db: &Database, id: &str, title: Option<&str>, origin: Option<&str>) {
use df_storage::models::AiConversationRecord;
AiConversationRepo::new(db)
.insert(AiConversationRecord {
id: id.to_string(),
title: title.map(|s| s.to_string()),
messages: "[]".to_string(),
provider_id: None,
model: None,
models: None,
archived: false,
pinned: false,
prompt_tokens: None,
completion_tokens: None,
pinned_goals: None,
pending_approvals: None,
created_at: "1".to_string(),
updated_at: "1".to_string(),
origin: origin.map(|s| s.to_string()),
})
.await
.expect("测试会话插入应成功");
}
/// S4 关联会话反查三件事:① result JSON 顶层 id 精确判等(禁 LIKE,前缀 id 不误命中);
/// ② 同会话多行命中按最近一次去重 + 行按 requested_at 倒序;③ origin='sub_agent' 子会话
/// 与已不在库的会话 id 剔除,只列主会话。SQL 三个工具名与 TASK_CONV_TOOLS 同步覆盖。
#[tokio::test]
async fn find_task_conversations_exact_id_dedup_origin_filter() {
let db = Database::open_in_memory().await.expect("in-memory db 初始化失败");
let repo = AiToolExecutionRepo::new(&db);
let t = 2_000_000_000_000i64;
insert_conversation(&db, "conv-main", Some("主会话"), None).await;
insert_conversation(&db, "conv-sub", Some("子代理"), Some("sub_agent")).await;
// conv-main:create(t-30) + update(t-10) 两次命中 → 去重后取 update(最近)
insert_conv_audit(&repo, "conv-main", "create_task",
Some(r#"{"id":"t1","title":"A","status":"todo"}"#.into()), t - 30).await;
insert_conv_audit(&repo, "conv-main", "update_task",
Some(r#"{"id":"t1","field":"title","updated":true}"#.into()), t - 10).await;
// conv-sub:advance 命中但 origin='sub_agent' → 过滤
insert_conv_audit(&repo, "conv-sub", "advance_task",
Some(r#"{"id":"t1","status":"in_progress"}"#.into()), t - 20).await;
// 干扰项:非名单工具 / id 前缀(t11 ≠ t1)/ 缺 id / 非法 JSON / 其他任务
insert_conv_audit(&repo, "conv-main", "list_tasks",
Some(r#"{"id":"t1"}"#.into()), t - 5).await;
insert_conv_audit(&repo, "conv-main", "update_task",
Some(r#"{"id":"t12","field":"title","updated":true}"#.into()), t - 6).await;
insert_conv_audit(&repo, "conv-main", "advance_task",
Some(r#"{"error":"boom"}"#.into()), t - 7).await;
insert_conv_audit(&repo, "conv-main", "create_task",
Some("审批超时,自动取消".into()), t - 8).await;
insert_conv_audit(&repo, "conv-main", "create_task",
Some(r#"{"id":"t2","title":"B"}"#.into()), t - 9).await;
insert_conv_audit(&repo, "conv-ghost", "create_task",
Some(r#"{"id":"t1","title":"C"}"#.into()), t - 4).await; // 会话已不在库
let out = find_task_conversations(&db, "t1").await.unwrap();
assert_eq!(out.len(), 1, "只应命中主会话 conv-main(去重后单条)");
assert_eq!(out[0].conversation_id, "conv-main");
assert_eq!(out[0].title.as_deref(), Some("主会话"));
assert_eq!(out[0].tool_name, "update_task", "同会话两行命中取最近一次(update)");
assert_eq!(out[0].requested_at, (t - 10).to_string());
}
/// S4 纯函数判定表:名单外工具 / 非 JSON / 缺 id / id 不等 均不命中,精确判等才命中。
#[test]
fn match_task_audit_row_truth_table() {
assert!(match_task_audit_row("create_task", Some(r#"{"id":"t1"}"#), "t1"));
assert!(!match_task_audit_row("create_task", Some(r#"{"id":"t12"}"#), "t1"), "禁 LIKE:前缀不误命中");
assert!(!match_task_audit_row("list_tasks", Some(r#"{"id":"t1"}"#), "t1"), "名单外工具不命中");
assert!(!match_task_audit_row("create_task", Some("人话文本"), "t1"), "非 JSON 不命中");
assert!(!match_task_audit_row("create_task", Some(r#"{"error":"x"}"#), "t1"), "缺 id 不命中");
assert!(!match_task_audit_row("create_task", None, "t1"), "result 空不命中");
}
/// from 时间过滤:仅统计 requested_at >= from 的记录。
///
/// patch_file 追加一条更早(early)的 completed → from=late 时其不计入,
@@ -604,6 +604,42 @@ pub async fn ai_conversation_load_more(
}))
}
/// 只读窥视子代理轨迹(S3 子代理轨迹查看)
///
/// 复用 ai_conversation_switch 的读路径(ai_messages.list_by_conversation → record_to_message,
/// 含空表回退旧 messages JSON 列的兼容分支),但**只读不切状态**:不写 activeConversationId/
/// per_conv/pending_approvals/标题重生成。子会话(ai_conversations.origin='sub_agent')不进侧栏、
/// 不能走 switch(切走会把父会话 active 顶掉并触发一整套会话态副作用),前端完成卡浮层
/// 查看轨迹的唯一合理通道就是本只读命令。
///
/// 返回全量消息不分页:子代理单任务轨迹体量有限,分页属过度设计(设计宁少勿预留)。
#[tauri::command]
pub async fn ai_conversation_peek(
state: State<'_, AppState>,
conversation_id: String,
) -> Result<serde_json::Value, String> {
let record = state.ai_conversations.get_by_id(&conversation_id).await
.map_err(err_str)?
.ok_or_else(|| format!("对话不存在: {}", conversation_id))?;
// 读路径与 switch 同构:优先 ai_messages 表,空表回退旧 messages JSON 列(双向兼容)
let all_records = state.ai_messages.list_by_conversation(&conversation_id).await
.map_err(err_str)?;
let messages: Vec<ChatMessage> = if !all_records.is_empty() {
all_records.iter().map(record_to_message).collect()
} else if record.messages != "[]" && !record.messages.is_empty() {
serde_json::from_str(&record.messages)
.map_err(|e| format!("解析消息失败: {}", e))?
} else {
Vec::new()
};
let messages_json = serde_json::to_string(&messages)
.map_err(|e| format!("序列化消息失败: {}", e))?;
Ok(serde_json::json!({
"title": record.title,
"messages": messages_json,
}))
}
/// 删除对话
#[tauri::command]
pub async fn ai_conversation_delete(
+8 -2
View File
@@ -458,6 +458,8 @@ pub fn run() {
commands::ai::ai_conversation_create,
commands::ai::ai_conversation_list,
commands::ai::ai_conversation_switch,
// S3 子代理轨迹只读窥视(复用 switch 读路径,不切任何会话状态)
commands::ai::ai_conversation_peek,
commands::ai::ai_conversation_load_more,
commands::ai::ai_conversation_delete,
commands::ai::ai_conversation_rename,
@@ -480,6 +482,8 @@ pub fn run() {
commands::ai::ai_authorize_dir,
// 审批历史面板查 ai_tool_executions 表,敏感字段截断)
commands::ai::audit::record::list_tool_executions,
// S4 TaskDrawer 关联会话:按 task_id 反查发起过任务工具的主会话(审计表推导,只读)
commands::ai::audit::record::list_task_conversations,
// AC-5 按工具失败率统计(诊断 IPC,聚合 ai_tool_executions 覆盖全量/时间范围)
commands::ai::audit::record::tool_failure_stats,
// 知识库
@@ -561,9 +565,11 @@ pub fn run() {
let _ = window.hide(); // 无运行会话:隐藏到托盘(原行为)
return;
}
// 有运行会话:每进程一次给主窗发提示事件(payload 带运行数),前端画气泡
// 有运行会话:每进程一次给主窗发提示事件(payload 带运行数),前端画气泡。
// 键名必须为 running:App.vue 监听端读 e.payload.running,曾发 "active" 致
// 端上恒取 undefined→按 0 处理,气泡整条链路静默失效(UX-P3 批次修正)。
if !CLOSE_NOTICE_SENT.swap(true, std::sync::atomic::Ordering::SeqCst) {
let _ = app.emit_to("main", "tray-hide-notice", serde_json::json!({ "active": active }));
let _ = app.emit_to("main", "tray-hide-notice", serde_json::json!({ "running": active }));
}
// 异步等 2 秒再 hide:气泡可见窗口,禁用 std::thread::sleep(同步阻塞卡渲染)
let win = window.clone();
+17 -5
View File
@@ -29,6 +29,9 @@
</template>
</AppLayout>
<!-- 走查 P3-16:全局 toast 统一收进同一纵向堆叠容器。原错误 toast 与托盘提示气泡
各自 fixed 同位(bottom:24px 居中),两者同现时互相压盖、后弹者盖住先弹者。 -->
<div class="df-toast-stack">
<!-- 全局错误 toast(根级,主窗口与分离窗口共用)-->
<transition name="toast">
<div v-if="errorMsg" class="df-toast">⚠ {{ errorMsg }}</div>
@@ -39,6 +42,7 @@
<transition name="toast">
<div v-if="trayNoticeMsg" class="df-toast df-toast--info">{{ trayNoticeMsg }}</div>
</transition>
</div>
<!-- 审批待办浮层(应用内右上角,窗口聚焦/可见时作快速提示)。
仅在主窗口与 ai-detached 窗口挂载(file-explorer / approval-popup 自身不挂)。
@@ -63,6 +67,7 @@ import { useKnowledgeStore } from './stores/knowledge'
import { useProjectStore } from './stores/project'
import { useAppSettingsStore } from './stores/appSettings'
import i18n from './i18n'
import { t as ti18n } from './i18n/i18n-helpers'
import AiChat from './components/AiChat.vue'
import ApprovalOverlay from './components/ai/ApprovalOverlay.vue'
import ApprovalDialog from './components/project/ApprovalDialog.vue'
@@ -227,7 +232,8 @@ onMounted(async () => {
_unlistenTrayHideNotice = await listen<{ running: number }>('tray-hide-notice', (e) => {
const running = e.payload?.running ?? 0
if (running <= 0) return
trayNoticeMsg.value = `${running} 个会话将继续在后台运行,托盘图标可随时唤起`
// 走查 P3-15:气泡文案硬编码中文 → 入 i18n(运行数经命名参数注入)
trayNoticeMsg.value = ti18n('common.trayNoticeRunning', { n: running })
if (trayNoticeTimer) clearTimeout(trayNoticeTimer)
trayNoticeTimer = setTimeout(() => { trayNoticeMsg.value = '' }, 3500)
})
@@ -313,16 +319,22 @@ onUnmounted(() => {
.page-enter-from { opacity: 0; transform: translateY(6px); }
.page-leave-to { opacity: 0; transform: translateY(-4px); }
/* ═══ 全局错误 toast ═══ */
.df-toast {
/* ═══ 全局 toast 纵向堆叠容器(走查 P3-16)═══
容器独占 fixed 定位,内部 flex 纵排 + 底对齐,多条 toast 依次向上堆叠不再同位互盖。
子项保持 .df-toast 原形态(定宽自适应),定位字段从子项上移到容器。 */
.df-toast-stack {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
display: flex; flex-direction: column; align-items: center; gap: 8px;
z-index: 9999; max-width: 90vw; pointer-events: none;
}
.df-toast {
background: var(--df-danger); color: #fff;
padding: 10px 18px; border-radius: var(--df-radius-sm);
font-size: 13px; z-index: 9999; max-width: 90vw;
font-size: 13px; max-width: 90vw;
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
}
.toast-enter-active, .toast-leave-active { transition: all 0.25s var(--df-ease); }
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translate(-50%, 10px); }
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateY(10px); }
/* 后台运行提示:同 df-toast 形态,信息语义用 accent 底色与错误红区分 */
.df-toast--info { background: var(--df-accent); }
</style>
+18
View File
@@ -368,4 +368,22 @@ export const aiApi = {
updateConversationGoals(conversationId: string, goals: string[]): Promise<void> {
return invoke('ai_update_conversation_goals', { conversationId, goals })
},
/**
* 只读窥视子代理轨迹(S3):后端复用 switch 读路径但**不切任何会话状态**。
* 子会话(origin='sub_agent')不进侧栏、不可 switch,完成卡浮层经此拉轨迹;
* 返回全量消息 JSON 字符串(不分页)。
*/
peekConversation(conversationId: string): Promise<{ title: string | null; messages: string }> {
return invoke('ai_conversation_peek', { conversationId })
},
/**
* S4 TaskDrawer「关联会话」:按任务 id 反查发起过 create/update/advance_task 的会话。
* 后端从 ai_tool_executions 审计表推导(result JSON 顶层 id 精确判等,非 LIKE),
* 已过滤子代理会话(origin='sub_agent')并按最近命中倒序去重,v1 只列主会话。
*/
listTaskConversations(taskId: string): Promise<{ conversation_id: string; title: string | null; tool_name: string; requested_at: string }[]> {
return invoke('list_task_conversations', { taskId })
},
}
+18 -2
View File
@@ -289,6 +289,10 @@ const projectsStore = useProjectStore()
// 滚动边沿/跟随意图重置随 MessageList 迁移,由子组件自管。
watch(() => store.state.activeConversationId, () => {
if (editingMsgId.value) cancelEdit()
// UX-P3: 队列编辑态(editingQueueIdx/editingQueueText)绑定旧会话队列在 activeQueue
// 视图内的下标,切会话后 activeQueue 已换成新会话的集合,残留 idx/text 会错挂到
// 新会话同下标队列项上,随消息编辑态一并重置。
if (editingQueueIdx.value !== null) cancelEditQueued()
})
@@ -331,7 +335,14 @@ function onGlobalKeydown(e: KeyboardEvent) {
// Ctrl+R: 重新生成末条回复(替代浏览器刷新,Tauri 下 preventDefault 可靠)
if (ctrl && (e.key === 'r' || e.key === 'R')) {
e.preventDefault()
if (!store.state.streaming) store.regenerate()
// UX-P3: 末条非 assistant 等场景 regenerate() 会 reject(regenerateUnavailable),原样裸调
// 成 unhandled rejection 用户无感;对齐 MessageList.handleRetry 的 catch + toast。
if (!store.state.streaming) {
store.regenerate().catch((e: unknown) => {
const msg = e instanceof Error ? e.message : String(e)
showToast(t('aiChat.regenerateFailed', { msg }), 'error')
})
}
return
}
// Ctrl+L: 清空当前对话(带 confirm;生成中跳过避免删流式中消息)
@@ -510,9 +521,14 @@ async function handleSendQueuedNow(idx: number) {
* cancelQueued 内部经 activeQueueRealIndex 做过滤视图下标映射,越界 no-op;
* 项携带技能时按名找回 SkillInfo 回挂 chip,找不到(技能已卸载)只回填文本,
* 与队列原地编辑只保 text 同一取舍。 */
function backfillQueued(idx: number) {
async function backfillQueued(idx: number) {
const item = activeQueue.value[idx]
if (!item) return
// UX-P3: 输入框已有未发送草稿(文本/图片/技能 chip 任一非空)时回填会静默覆盖,
// 先 confirm 确认;取消则直接返回,队列项原样保留(尚未 cancelQueued)。
if (chatInputRef.value?.hasUnsentInput()) {
if (!await confirmDialog(t('aiChat.backfillOverwriteConfirm'))) return
}
const skill = item.skill
? store.state.skills.find(s => s.name === item.skill) ?? null
: null
+56 -2
View File
@@ -25,7 +25,8 @@
: (pendingSkill ? (pendingSkill.argument_hint || $t('aiChat.placeholderWithSkill')) : $t('aiChat.placeholderDefault'))"
rows="1"
@keydown="handleKeydown"
@input="autoResize"
@beforeinput="onBeforeInputText"
@input="onInputText"
@paste="onPaste"
@dragover="onDragOver"
@drop="onDrop"
@@ -401,6 +402,38 @@ const mentionStart = ref(-1)
// 后端据 mentionSpans resolve 投影成 Augmentation 注入;MessageList 据此精确切区间渲染 chip。
const pendingMentionSpans = ref<MentionSpan[]>([])
// ── UX-P3: mention span 编辑平移 ──
// span.start 是 selectMention 时刻的绝对偏移,用户在 chip 文本之前增删字符后全部 span 错位
// (MessageList 渲染守卫对不上文本即降级纯文本,chip 全灭)。借 beforeinput(仅用户编辑触发,
// 程序赋值不派发事件)快照旧文本,input 时做公共前后缀差分求出编辑区间,按区间相对各 span
// 的位置处理:编辑点在 span 前→整体平移;在 span 后→不动;落在 span 文本内→丢弃该 span
// (文本已被改写区间失真,宁丢一枚 chip 也不让后端 resolve 到错位区间)。
let mentionTextBeforeEdit = ''
function onBeforeInputText(): void {
mentionTextBeforeEdit = inputEl.value?.value ?? inputText.value
}
function onInputText(): void {
autoResize()
shiftMentionSpans(mentionTextBeforeEdit, inputEl.value?.value ?? inputText.value)
}
function shiftMentionSpans(oldText: string, newText: string): void {
if (pendingMentionSpans.value.length === 0 || oldText === newText) return
// 公共前后缀夹出旧文本中被编辑的区间(粘贴/剪切的多字符增删同为一段区间,该差分通用)
const minLen = Math.min(oldText.length, newText.length)
let p = 0
while (p < minLen && oldText[p] === newText[p]) p++
let s = 0
while (s < minLen - p && oldText[oldText.length - 1 - s] === newText[newText.length - 1 - s]) s++
const editStart = p
const editEnd = oldText.length - s
const delta = newText.length - oldText.length
pendingMentionSpans.value = pendingMentionSpans.value.flatMap((sp) => {
if (editEnd <= sp.start) return [{ ...sp, start: sp.start + delta }]
if (editStart >= sp.start + sp.length) return [sp]
return []
})
}
// ── ⑥.4 Phase 4: @项目展开摘要(输入区 enrichment 预览) ──
// 当用户 @[项目:xxx] 后,从 projectsStore 反查项目+关联任务/灵感,
// 在输入区下方显式 badge + 可展开的上下文参考面板。
@@ -685,6 +718,14 @@ function handleKeydown(e: KeyboardEvent) {
// P1-1 输入法组词守卫:拼音/双拼组词未上屏时 Enter=选词、Esc=取消组词,均非用户发送/取消意图,
// 直接放行给 IME。isComposing 覆盖标准浏览器;keyCode===229 兜部分环境 keydown 阶段不上报 isComposing。
if (e.isComposing || e.keyCode === 229) return
// UX-P3: AI 输入框聚焦时 Ctrl/Cmd+R 会触发 WebView 刷新(浏览器直开/开发态整页刷新丢会话态),
// 此处 preventDefault 掐掉刷新默认行为。只拦输入框聚焦这一场景(本 handler 仅 textarea 触发,
// 全局其他区域不拦);不 stopPropagation,冒泡到 AiChat onGlobalKeydown 的 Ctrl+R 分支,
// 重新生成语义照常生效。
if ((e.ctrlKey || e.metaKey) && (e.key === 'r' || e.key === 'R')) {
e.preventDefault()
return
}
// 各 Esc 消费分支 stopPropagation:P1-2 双层防,分离窗口 AiChat 的 window 级 Esc 会关整个窗口,
// 内层已消费的 Esc 不再外抛(主防线是 AiChat 侧的 e.target 输入控件守卫,此处为辅)
// 编辑态 Escape 取消编辑(最高优先级)
@@ -732,6 +773,14 @@ function handleKeydown(e: KeyboardEvent) {
return
}
}
// UX-P3: 技能联想浮层开着但无匹配时,Enter 原会落到下方发送分支把 "/xxx" 原文当普通
// 消息发出。改为不发送 + toast 提示无此技能;确要发送斜杠原文可先 Esc 关浮层再 Enter。
if (skillOpen.value && e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
e.stopPropagation()
emit('error', { msg: t('aiChat.skillNoMatchQuery', { q: skillQuery.value.trim() }), type: 'warning' })
return
}
// @ 实体引用浮层导航(仅有匹配项时拦截方向/回车;与技能联想互斥)
if (mentionOpen.value && mentionItems.value.length) {
if (e.key === 'ArrowDown') {
@@ -775,7 +824,12 @@ async function handleSend() {
const text = inputText.value.trim()
// UX-09 编辑态:走 editMessage(不 push 新 user,编辑末条 user 后重生成)
if (props.editingMsgId) {
if (!text) return
// UX-P3 空编辑静默:清空文本后 Enter 原来静默 return,用户无反馈卡在编辑态。
// 仍阻止空提交(editMessage 空文本语义不明),但补轻提示告知原因。
if (!text) {
emit('error', { msg: t('aiChat.editEmptyBlocked'), type: 'warning' })
return
}
inputText.value = ''
if (inputEl.value) inputEl.value.style.height = 'auto'
try {
+232 -3
View File
@@ -23,6 +23,7 @@ import { useStreamRenderer } from '../../composables/ai/useStreamRenderer'
import { loadMoreHistory, getLoadMoreState } from '../../composables/ai/useAiConversations'
import { collectTurnStats, turnSummaryText, sumTurnTokens, formatTokensK, type TurnCallStats } from '../../composables/ai/useTurnTrace'
import { getConvAgentFinished, type AgentFinishedCard } from '../../composables/ai/aiShared'
import { aiApi } from '../../api'
import ToolCardList from '../ToolCardList.vue'
import MessageItem from './MessageItem.vue'
import EmptyState from './EmptyState.vue'
@@ -273,9 +274,32 @@ watch(() => store.state.streaming, (s) => {
saveSelection()
clearStreamingState()
nextTick(() => restoreSelection())
anchorCollapseScroll()
}
})
// ── UX-P3: 长回复完成瞬间折叠的滚动锚 ──
// 末条 AI 回复超阈值(LONG_MSG_THRESHOLD)时,完成瞬间 shouldCollapseLong 从 false 翻 true
// (流式中不折叠的约束解除),气泡收缩到 400px;跟随意图下的 scrollToBottom 把视口钉在收缩后
// 的底部,内容整体上跳,刚流完的答案开头/结尾都看不全。改锚消息顶:本回调在流式结束当拍注册
// nextTick(此时折叠 class 尚未渲染),渲染拍后把该消息顶对齐视口顶,视口落点确定(答案开头,
// 回底按钮照常出现),不再随内容收缩被浏览器 clamp 到任意位置。
// 仅跟随意图成立时干预(用户上滑读历史不拉扯);手动展开过的消息不会折叠,无收跳不处理。
// 注册时序:currentText watch(先注册)的 scrollToBottom nextTick 先入队,本 nextTick 后入队
// 后执行,锚定是滚动链的最后一手,不被钉底覆盖。
const COLLAPSE_ANCHOR_PAD_PX = 8
function anchorCollapseScroll(): void {
if (!isFollowingBottom.value) return
const last = store.state.messages[store.state.messages.length - 1]
if (!last || !shouldCollapseLong(last)) return
nextTick(() => {
const c = messagesContainer.value
const el = c?.querySelector(`[data-msg-id="${last.id}"]`)
if (!c || !el) return
c.scrollTop += el.getBoundingClientRect().top - c.getBoundingClientRect().top - COLLAPSE_ANCHOR_PAD_PX
})
}
// ── 工具卡片自动收起:新内容追加(新消息/toolCall 状态变化)时,
// 收起旧的已完成/已拒绝卡片,保留 running/pending/write_file 活跃卡片。
// 折叠态由 ToolCardList 自治,父级仅经 expose 调 collapseInactive ──
@@ -1083,6 +1107,68 @@ function agentFinSummary(summary: string): string {
return s.length > 160 ? s.slice(0, 160) + '…' : s
}
// ── S3 子代理轨迹浮层(完成卡点击展开,只读) ──
// 状态全部组件局部:浮层关即弃、不缓存轨迹(每次点开重新 peek,宁少勿预留),
// 不进 aiShared/stores——子会话轨迹纯只读,与主会话状态机(消息流/生成态)完全无关。
interface AgentPeekMsg {
role: 'user' | 'assistant' | 'tool' | 'system'
text: string
toolNames: string[]
}
const agentPeekVisible = ref(false)
const agentPeekLoading = ref(false)
const agentPeekMsgs = ref<AgentPeekMsg[]>([])
// 单条内容截断上限:tool 结果(文件全文/长输出)可能巨大,简版渲染按 4000 字截断兜底,
// 完整数据仍在库,不做全文渲染(不要求富文本)。
const AGENT_PEEK_TEXT_MAX = 4000
/**
* 完成卡点击:peek 子会话轨迹并开浮层。
* agentId 即子会话 conv id(形态 {parent}::agent-{n},后端 spawn_agent 落库 origin='sub_agent')。
* 加载中/已开时忽略重复点击(防连点重复 IPC);失败走既有 toast 通道给可见反馈,不开空浮层。
*/
async function openAgentTrace(card: AgentFinishedCard): Promise<void> {
if (agentPeekVisible.value || agentPeekLoading.value) return
agentPeekLoading.value = true
try {
const res = await aiApi.peekConversation(card.agentId)
const raw: any[] = typeof res.messages === 'string' ? JSON.parse(res.messages) : (res.messages || [])
agentPeekMsgs.value = raw.map((m) => {
const text = String(m.content || '')
const role = m.role === 'user' || m.role === 'assistant' || m.role === 'tool' || m.role === 'system'
? m.role
: 'assistant'
return {
role,
text: text.length > AGENT_PEEK_TEXT_MAX ? text.slice(0, AGENT_PEEK_TEXT_MAX) + '…' : text,
toolNames: Array.isArray(m.tool_calls)
? m.tool_calls.map((tc: any) => tc?.function?.name || '').filter(Boolean)
: [],
}
})
agentPeekVisible.value = true
} catch (e) {
console.error('[AI] 拉取子代理轨迹失败:', e)
emit('toast', { msg: t('ai.agentTrace.loadFail'), type: 'error' })
} finally {
agentPeekLoading.value = false
}
}
/** 关浮层即弃:清空局部消息态,不缓存轨迹 */
function closeAgentTrace(): void {
agentPeekVisible.value = false
agentPeekMsgs.value = []
}
/** role → 本地化角色名(简版渲染,无头像气泡) */
function agentPeekRoleText(role: AgentPeekMsg['role']): string {
if (role === 'user') return t('ai.agentTrace.roleUser')
if (role === 'tool') return t('ai.agentTrace.roleTool')
if (role === 'system') return t('ai.agentTrace.roleSystem')
return t('ai.agentTrace.roleAssistant')
}
// UX 密度改造:轮次导航条(仅多轮长会话 ≥3 张轨迹卡出现,sticky 置顶,只做跳转不做统计)
const traceNav = computed(() =>
renderItems.value.filter((it): it is Extract<RenderItem, { kind: 'trace' }> => it.kind === 'trace')
@@ -1230,9 +1316,16 @@ defineExpose({
</div>
<!-- S1 子代理完成卡(AiAgentFinished):状态点/状态词三态区分(completed 中性/truncated
警示/failed 错误),摘要单行截断 + 轮数 + tokens;骨架对齐 ai-step-line/ai-retry-line
轻量细条范式,非消息数据不进气泡 -->
轻量细条范式,非消息数据不进气泡。
S3:整卡可点,点击 peek 子会话轨迹开只读浮层(数据关即弃,见 openAgentTrace) -->
<div v-else-if="item.kind === 'agentFin'" class="ai-msg-slot">
<div class="ai-agent-fin" :class="'ai-agent-fin--' + agentFinTone(item.card.status)">
<div
class="ai-agent-fin"
:class="'ai-agent-fin--' + agentFinTone(item.card.status)"
role="button"
:title="$t('ai.agentFinished.viewHint')"
@click="openAgentTrace(item.card)"
>
<span class="ai-agent-fin-dot"></span>
<span class="ai-agent-fin-label">{{ $t('ai.agentFinished.title') }}</span>
<span class="ai-agent-fin-status">{{ agentFinStatusText(item.card.status) }}</span>
@@ -1485,6 +1578,35 @@ defineExpose({
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>
</button>
</Transition>
<!-- S3 子代理轨迹浮层(只读,关即弃):与回底部按钮同为模板根级兄弟,position:fixed
不受 .ai-messages 滚动容器 overflow 裁剪;backdrop 点击关闭,数据不缓存 -->
<div v-if="agentPeekVisible" class="ai-agent-peek-mask" @click.self="closeAgentTrace">
<div class="ai-agent-peek" role="dialog" :aria-label="$t('ai.agentTrace.title')">
<div class="ai-agent-peek-head">
<span class="ai-agent-peek-title">{{ $t('ai.agentTrace.title') }}</span>
<button class="ai-agent-peek-close" :title="$t('common.close')" @click="closeAgentTrace">
<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>
<div class="ai-agent-peek-body">
<div v-if="agentPeekMsgs.length === 0" class="ai-agent-peek-empty">{{ $t('ai.agentTrace.empty') }}</div>
<!-- 简版卡片流:role 名 + 工具调用名 chips + 纯文本内容(不做完整 markdown 富渲染) -->
<div
v-for="(m, i) in agentPeekMsgs"
:key="i"
class="ai-agent-peek-msg"
:class="'ai-agent-peek-msg--' + m.role"
>
<div class="ai-agent-peek-role">
<span>{{ agentPeekRoleText(m.role) }}</span>
<span v-for="name in m.toolNames" :key="name" class="ai-agent-peek-tool">{{ name }}</span>
</div>
<div v-if="m.text" class="ai-agent-peek-text">{{ m.text }}</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
@@ -2268,7 +2390,8 @@ defineExpose({
/* S1 子代理完成卡(AiAgentFinished):三态色分层——completed 中性灰(常态结果不抢视线)
/ truncated 琥珀(结果不完整,与 retry 细条同警示层)/ failed 红(需关注,对齐错误卡层)。
骨架对齐 .ai-retry-line 轻量细条;摘要单行截断,全文经 title hover 查看 */
骨架对齐 .ai-retry-line 轻量细条;摘要单行截断,全文经 title hover 查看。
S3:整卡可点开轨迹浮层,cursor pointer + hover 提示可交互 */
.ai-agent-fin {
display: flex;
align-items: center;
@@ -2280,6 +2403,10 @@ defineExpose({
font-size: 11px;
color: var(--df-text-secondary);
user-select: none;
cursor: pointer;
}
.ai-agent-fin:hover {
background: color-mix(in srgb, var(--df-text-secondary) 12%, transparent);
}
.ai-agent-fin-dot {
width: 5px;
@@ -2323,4 +2450,106 @@ defineExpose({
font-family: var(--df-font-mono);
font-size: 10.5px;
}
/* ═══ S3 子代理轨迹浮层(只读,关即弃) ═══ */
.ai-agent-peek-mask {
position: fixed;
inset: 0;
z-index: 300;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
}
.ai-agent-peek {
width: min(680px, 86vw);
max-height: 76vh;
display: flex;
flex-direction: column;
background: var(--df-bg-raised);
border: 0.5px solid var(--df-border-strong);
border-radius: var(--df-radius-lg);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4);
overflow: hidden;
}
.ai-agent-peek-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-bottom: 0.5px solid var(--df-border);
flex-shrink: 0;
}
.ai-agent-peek-title {
font-size: 12.5px;
font-weight: 600;
color: var(--df-text);
}
.ai-agent-peek-close {
border: none;
background: transparent;
color: var(--df-text-dim);
cursor: pointer;
padding: 4px;
border-radius: var(--df-radius-sm);
display: flex;
}
.ai-agent-peek-close:hover {
color: var(--df-text);
background: color-mix(in srgb, var(--df-text-secondary) 10%, transparent);
}
.ai-agent-peek-body {
overflow-y: auto;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.ai-agent-peek-empty {
color: var(--df-text-dim);
font-size: 12px;
text-align: center;
padding: 24px 0;
}
.ai-agent-peek-msg {
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
padding: 8px 10px;
}
/* role 左侧色条:user 中性/accent、assistant 默认、tool 结果弱化、system 弱化斜体 */
.ai-agent-peek-msg--user { border-left: 2px solid var(--df-accent); }
.ai-agent-peek-msg--assistant { border-left: 2px solid var(--df-success); }
.ai-agent-peek-msg--tool,
.ai-agent-peek-msg--system { border-left: 2px solid var(--df-border-strong); }
.ai-agent-peek-role {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
font-size: 10.5px;
font-weight: 600;
color: var(--df-text-secondary);
margin-bottom: 4px;
}
.ai-agent-peek-tool {
font-family: var(--df-font-mono);
font-size: 10px;
font-weight: 500;
color: var(--df-info);
background: var(--df-info-bg);
border-radius: var(--df-radius-sm);
padding: 1px 6px;
}
.ai-agent-peek-text {
font-size: 12px;
line-height: 1.55;
color: var(--df-text);
white-space: pre-wrap;
word-break: break-word;
}
.ai-agent-peek-msg--tool .ai-agent-peek-text,
.ai-agent-peek-msg--system .ai-agent-peek-text {
color: var(--df-text-secondary);
font-size: 11.5px;
}
</style>
+30 -10
View File
@@ -185,8 +185,6 @@ const loading = ref(false)
const gitStatus = ref<GitStatusResult | null>(null)
const showHistory = ref(false)
const selectedFile = ref('')
const diffContent = ref('')
const diffLoading = ref(false)
// 行内错误提示:loadError 主区展示(loadStatus 失败),actionError 分支条下方展示(loadMore/selectFile/切分支/提交详情 失败)
const loadError = ref('')
const actionError = ref('')
@@ -194,6 +192,12 @@ const actionError = ref('')
// (对齐 ProjectDetail taskReqSeq 模式)
let statusReqSeq = 0
// UX走查 P3-4: 点文件 diff 单槽去重缓存 — 重复点击同一文件不再重复发 git diff IPC,
// 直接复用上次结果回填。切模块/refreshKey 重拉状态均经 loadStatus,缓存在彼处失效,
// 防 AI 改文件后旧 diff 被复用(单槽不设 Map,免过期条目堆积)
let lastDiffKey = ''
let lastDiffContent = ''
/** 单条提交记录形状(对齐 getModuleCommits / GitRecentCommit)。 */
interface CommitItem {
hash: string
@@ -353,6 +357,10 @@ async function loadStatus() {
const mid = props.moduleId
if (!mid) return
const seq = ++statusReqSeq
// UX走查 P3-4: 状态重拉(切模块/外部 refreshKey,如 AI 改文件)即失效 diff 去重缓存,
// 防改文件后旧 diff 被复用
lastDiffKey = ''
lastDiffContent = ''
loading.value = true
loadError.value = ''
actionError.value = ''
@@ -448,23 +456,35 @@ async function switchToHistory() {
async function selectFile(path: string) {
selectedFile.value = path
// 先切预览到该文件(内容视图),diff 拉到后回填,点文件即展示变化
// 先切预览到该文件(内容视图兜底),diff 拉到后回填切 diff 视图,点文件即展示变化
emit('select-file', path)
diffContent.value = ''
actionError.value = ''
diffLoading.value = true
// UX走查 P3-4 同参数去重:重复点击同一文件直接复用缓存 diff,不再发 IPC
const key = `${props.moduleId}::${path}`
if (key === lastDiffKey) {
emit('select-file', path, lastDiffContent)
return
}
// 捕获请求时的状态序号:期间 refreshKey 重拉(缓存已失效)则本次结果不入缓存,防改文件前旧 diff 滞留
const seqAtReq = statusReqSeq
let diff = ''
try {
const res = await moduleApi.getModuleFileDiff(props.moduleId, path)
diffContent.value = res.diff || ''
diff = res.diff || ''
if (seqAtReq === statusReqSeq) {
lastDiffKey = key
lastDiffContent = diff
}
} catch (e) {
diffContent.value = ''
// 失败清缓存使下次点击可重试;不回填 diff,预览停在内容视图(错误经 actionError 条就近提示)
lastDiffKey = ''
lastDiffContent = ''
actionError.value = String(e)
} finally {
diffLoading.value = false
return
}
// 仅当仍是当前选中文件时才回填 diff,防快速切换时旧响应覆盖新文件
if (selectedFile.value === path) {
emit('select-file', path, diffContent.value)
emit('select-file', path, diff)
}
}
+33 -2
View File
@@ -24,7 +24,10 @@
<div class="card-footer">
<div class="footer-stat">
<span class="stat-icon">📁</span>
<span>{{ moduleCount }}</span>
<!-- UX走查 P3-6: 工程数异步入场,加载中显骨架条占位(对齐 GitChanges shimmer 范式),
防未加载数先显 0 再跳变——真 0 与"还没加载"不可混淆 -->
<span v-if="moduleCountLoading" class="stat-skeleton" aria-hidden="true"></span>
<span v-else>{{ moduleCount }}</span>
</div>
<div class="footer-stat">
<span class="stat-icon">📅</span>
@@ -48,7 +51,7 @@
// 依赖均为无副作用纯函数 + 共享常量;删除走 emit 交回父组件处理(confirm 弹层 + store 调用)。
// view prop:'list'(紧凑,默认)/'card'(宽松);描述 max-height 截断 + 展开/收起。
import { useRouter } from 'vue-router'
import { ref, onMounted, watch, nextTick } from 'vue'
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
import { formatDate } from '@/utils/time'
import { parseStack } from '@/utils/project'
import { stripMd } from '@/utils/markdown'
@@ -71,11 +74,14 @@ const router = useRouter()
// 工程数(异步拉取,展示在卡片底部)
const moduleCount = ref(0)
// UX走查 P3-6: 加载态标记,模板据此显骨架占位而非 0(成功/失败都收 loading,失败仍显 0 属既有约定)
const moduleCountLoading = ref(true)
onMounted(async () => {
try {
const list = await moduleApi.listProjectModules(props.project.id)
moduleCount.value = list.length
} catch { /* 老项目无工程记录不报错 */ }
moduleCountLoading.value = false
// DOM 渲染完成后测描述是否溢出(决定是否显示「展开」)
await nextTick()
measureDesc()
@@ -91,6 +97,8 @@ const descExpanded = ref(false)
function measureDesc() {
const el = descRef.value
if (!el) return
// 展开态无行截断,测出的一律是"未溢出",会误收起按钮致无法「收起」;展开期间维持原判定
if (descExpanded.value) return
// scrollHeight > clientHeight 说明被 max-height 截断了
descOverflow.value = el.scrollHeight - el.clientHeight > 1
}
@@ -98,6 +106,13 @@ function measureDesc() {
// 描述/视图模式变化后重新测量(描述异步入库或切 view 行数阈值变,按钮显隐需重算)
watch(() => props.project.description, () => nextTick(measureDesc))
watch(() => props.view, () => nextTick(measureDesc))
// 收起后重测:展开期间判定被冻结,若窗口已放宽到不溢出,收起时自校正防残留多余「展开」按钮
watch(descExpanded, (v) => { if (!v) nextTick(measureDesc) })
// UX走查 P3-6: 窗口尺寸变化会改描述可用宽度,截断与否需重测,否则「展开」按钮显隐失准
function onWinResize() { measureDesc() }
onMounted(() => window.addEventListener('resize', onWinResize))
onBeforeUnmount(() => window.removeEventListener('resize', onWinResize))
</script>
<style scoped>
@@ -196,6 +211,22 @@ watch(() => props.view, () => nextTick(measureDesc))
}
.stat-icon { font-size: 13px; }
/* UX走查 P3-6: 工程数加载占位骨架条(shimmer 配方对齐 GitChanges .gc-skeleton-row),
宽度约两位数字占位,高度对齐 12px 字号,防 0 起显跳变 */
.stat-skeleton {
display: inline-block;
width: 18px;
height: 12px;
border-radius: 3px;
background: linear-gradient(90deg, rgba(255,255,255,0.04) 0%, rgba(255,255,255,0.08) 50%, rgba(255,255,255,0.04) 100%);
background-size: 200% 100%;
animation: pc-shimmer 1.5s infinite linear;
}
@keyframes pc-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.card-top-actions { display: flex; align-items: center; gap: 8px; }
.card-del-btn {
background: transparent; border: none; cursor: pointer;
+24 -13
View File
@@ -6,20 +6,21 @@
</div>
<div class="general-settings">
<SettingRow ref="rowTheme" :label="$t('settings.labelTheme')" :desc="$t('settings.descTheme')">
<select v-model="settings.theme" class="setting-select" @change="markSaved('theme')">
<!-- 走查 P3-11:不再 @change 即显「已保存」,反馈移到 persist 完成后(防假成功) -->
<select v-model="settings.theme" class="setting-select">
<option value="dark">{{ $t('settings.themeDark') }}</option>
<option value="light">{{ $t('settings.themeLight') }}</option>
<option value="system">{{ $t('settings.themeSystem') }}</option>
</select>
</SettingRow>
<SettingRow ref="rowLanguage" :label="$t('settings.labelLanguage')" :desc="$t('settings.descLanguage')">
<select v-model="settings.language" class="setting-select" @change="markSaved('language')">
<select v-model="settings.language" class="setting-select">
<option value="zh-CN">{{ $t('settings.aiLanguageZh') }}</option>
<option value="en">{{ $t('settings.aiLanguageEn') }}</option>
</select>
</SettingRow>
<SettingRow ref="rowAiLanguage" :label="$t('settings.labelAiLanguage')" :desc="$t('settings.descAiLanguage')">
<select v-model="settings.aiLanguage" class="setting-select" @change="markSaved('aiLanguage')">
<select v-model="settings.aiLanguage" class="setting-select">
<option value="auto">{{ $t('settings.aiLanguageAuto') }}</option>
<option value="zh-CN">{{ $t('settings.aiLanguageZh') }}</option>
<option value="en">{{ $t('settings.aiLanguageEn') }}</option>
@@ -27,13 +28,13 @@
</SettingRow>
<SettingRow ref="rowToken" :label="$t('settings.labelShowTokenUsage')" :desc="$t('settings.descShowTokenUsage')">
<label class="toggle">
<input type="checkbox" v-model="settings.showTokenUsage" @change="markSaved('token')" />
<input type="checkbox" v-model="settings.showTokenUsage" />
<span class="toggle-slider"></span>
</label>
</SettingRow>
<SettingRow ref="rowStreamingMd" :label="$t('settings.labelStreamingMd')" :desc="$t('settings.descStreamingMd')">
<label class="toggle">
<input type="checkbox" v-model="settings.streamingMd" @change="markSaved('streamingMd')" />
<input type="checkbox" v-model="settings.streamingMd" />
<span class="toggle-slider"></span>
</label>
</SettingRow>
@@ -91,32 +92,42 @@ const rowLanguage = useTemplateRef<InstanceType<typeof SettingRow>>('rowLanguage
const rowAiLanguage = useTemplateRef<InstanceType<typeof SettingRow>>('rowAiLanguage')
const rowToken = useTemplateRef<InstanceType<typeof SettingRow>>('rowToken')
const rowStreamingMd = useTemplateRef<InstanceType<typeof SettingRow>>('rowStreamingMd')
function markSaved(key: 'theme' | 'language' | 'aiLanguage' | 'token' | 'streamingMd') {
const map = { theme: rowTheme.value, language: rowLanguage.value, aiLanguage: rowAiLanguage.value, token: rowToken.value, streamingMd: rowStreamingMd.value }
map[key]?.markSaved()
// 走查 P3-11 假成功族:原 @change 立即 markSaved,先于持久化完成,失败无人知晓。
// 改为 set 完成后才点亮「已保存」,失败点亮行内红字。注:appSettings.set 当前对落库
// 失败仅 console 内吞(stores/appSettings.ts 降级内存态设计),catch 实际不会触发;
// 待 store 层把失败上抛后本处失败通道即自动生效(登记项,见批次报告)。
async function persist(key: string, value: unknown, row: { value: { markSaved: () => void; markFailed: () => void } | null }) {
try {
await appSettings.set(key, value)
row.value?.markSaved()
} catch (e) {
console.error(`保存设置 ${key} 失败:`, e)
row.value?.markFailed()
}
}
watch(() => settings.theme, (val) => {
applyTheme(val)
void appSettings.set('df-theme', val)
void persist('df-theme', val, rowTheme)
})
watch(() => settings.aiLanguage, (val) => {
void appSettings.set('df-ai-language', val)
void persist('df-ai-language', val, rowAiLanguage)
})
watch(() => settings.showTokenUsage, (val) => {
void appSettings.set('df-show-token-usage', val)
void persist('df-show-token-usage', val, rowToken)
})
watch(() => settings.streamingMd, (val) => {
void appSettings.set('df-ai-streaming-md', val)
void persist('df-ai-streaming-md', val, rowStreamingMd)
})
watch(() => settings.language, (val) => {
void appSettings.set('df-language', val)
// 同步 i18n 实例,让界面立即跟随语言切换
locale.value = val as 'zh-CN' | 'en'
void persist('df-language', val, rowLanguage)
})
onMounted(() => {
+62 -26
View File
@@ -10,7 +10,8 @@
next-round
:label="$t('settings.labelGlobalConcurrency')"
:desc="$t('settings.descGlobalConcurrency')">
<input type="number" class="setting-number" min="1" style="width:80px"
<!-- 走查 P3-14:max=64 前端 sanity 上界(原生 min/max 只约束步进按钮,越界拦截在 JS clamp) -->
<input type="number" class="setting-number" min="1" max="64" style="width:80px"
v-model.number="settings.llmGlobalConcurrency"
@change="onConcurrencyChange" />
</SettingRow>
@@ -19,7 +20,7 @@
next-round
:label="$t('settings.labelPerConvConcurrency')"
:desc="$t('settings.descPerConvConcurrency')">
<input type="number" class="setting-number" min="1" style="width:80px"
<input type="number" class="setting-number" min="1" max="64" style="width:80px"
v-model.number="settings.llmPerConvConcurrency"
@change="onConcurrencyChange" />
</SettingRow>
@@ -29,7 +30,8 @@
:label="$t('settings.labelAgentMaxIterations')"
:desc="$t('settings.descAgentMaxIterations')">
<label class="unlimited-toggle">
<input type="checkbox" v-model="unlimited" @change="markSaved('iter')" />
<!-- 走查 P3-11:勾选反馈不再即时显,由 syncAgentMaxIterations 的 IPC 成功回调点亮 -->
<input type="checkbox" v-model="unlimited" />
<span>{{ $t('settings.labelUnlimited') }}</span>
</label>
<input type="number" class="setting-number" min="1" max="50" style="width:80px"
@@ -52,6 +54,7 @@
<script setup lang="ts">
import { reactive, watch, computed, onMounted, onUnmounted, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { aiApi } from '@/api'
import { useAppSettingsStore } from '@/stores/appSettings'
import SettingRow from './SettingRow.vue'
@@ -59,10 +62,16 @@ import SettingRow from './SettingRow.vue'
// ============================================================
// 性能域 — 本地设置(localStorage):LLM 并发 / 单对话并发 / Agent 轮次 / 流式重试
// 拆自 GeneralPanel,本 Section 仅持有性能相关 key + 各自 debounce IPC 同步
// 阶段5:SettingRow 封装行布局 + nextRound badge(后端快照锁定边界,下次对话生效);
// 4 项变更后调 markSaved 显"已保存"
// 阶段5:SettingRow 封装行布局 + nextRound badge(后端快照锁定边界,下次对话生效)
// 走查 P3-11:「已保存」改为 IPC 成功回调后点亮,失败点亮行内红字(防假成功)
// ============================================================
const appSettings = useAppSettingsStore()
const { t } = useI18n()
// 走查 P3-14:并发越界钳制需要上抛 warning toast,由壳(Settings.vue)的 showToast 消费
const emit = defineEmits<{
(e: 'toast', msg: string, type: 'error' | 'warning' | 'info' | 'success'): void
}>()
const settings = reactive({
llmGlobalConcurrency: appSettings.get<number>('df-llm-global-concurrency', 3),
@@ -73,7 +82,7 @@ const settings = reactive({
agentMaxRetries: appSettings.get<number>('df-ai-agent-max-retries', 3),
})
// 即时生效反馈:SettingRow refs
// 即时生效反馈:SettingRow refs(成功/失败两态,走查 P3-11)
const rowGlobalConcurrency = useTemplateRef<InstanceType<typeof SettingRow>>('rowGlobalConcurrency')
const rowPerConvConcurrency = useTemplateRef<InstanceType<typeof SettingRow>>('rowPerConvConcurrency')
const rowMaxIterations = useTemplateRef<InstanceType<typeof SettingRow>>('rowMaxIterations')
@@ -82,50 +91,73 @@ function markSaved(key: 'global' | 'perConv' | 'iter' | 'retry') {
const map = { global: rowGlobalConcurrency.value, perConv: rowPerConvConcurrency.value, iter: rowMaxIterations.value, retry: rowMaxRetries.value }
map[key]?.markSaved()
}
function markFailed(key: 'global' | 'perConv' | 'iter' | 'retry') {
const map = { global: rowGlobalConcurrency.value, perConv: rowPerConvConcurrency.value, iter: rowMaxIterations.value, retry: rowMaxRetries.value }
map[key]?.markFailed()
}
// SQLite 持久化(watch 触发):落库失败点亮行内红字。注:appSettings.set 当前对落库失败
// 仅 console 内吞(降级内存态设计,stores/appSettings.ts),catch 是 store 上抛错误后的
// 失败通道,今日实际不触发(登记项见批次报告);IPC 同步失败通道在下方 timer 内,是真实信号。
watch(() => settings.llmGlobalConcurrency, (v) => {
void appSettings.set('df-llm-global-concurrency', v)
appSettings.set('df-llm-global-concurrency', v).catch(() => markFailed('global'))
})
watch(() => settings.llmPerConvConcurrency, (v) => {
void appSettings.set('df-llm-per-conv-concurrency', v)
appSettings.set('df-llm-per-conv-concurrency', v).catch(() => markFailed('perConv'))
})
watch(() => settings.agentMaxIterations, (v) => {
void appSettings.set('df-ai-agent-max-iterations', v)
appSettings.set('df-ai-agent-max-iterations', v).catch(() => markFailed('iter'))
})
watch(() => settings.agentMaxRetries, (v) => {
void appSettings.set('df-ai-agent-max-retries', v)
appSettings.set('df-ai-agent-max-retries', v).catch(() => markFailed('retry'))
})
// onMounted 期间调 sync 不应触发"已保存"反馈(那是启动恢复,非用户改动)
let _userInteracting = false
// 走查 P3-14:并发上界 64 为前端 sanity 值(后端 ai_set_concurrency_config 仅 max(1) 无上界,
// 超大并发会让限流器形同虚设、放大上游限流/超时风险);越界时静默修正 + toast 告知修正结果
const MAX_CONCURRENCY = 64
/** 非法输入(NaN/清空)回退默认值,合法值 clamp [1,64] */
function clampConcurrency(v: number | undefined, fallback: number): number {
const n = typeof v === 'number' && Number.isFinite(v) ? Math.trunc(v) : fallback
return Math.min(MAX_CONCURRENCY, Math.max(1, n))
}
// 同步 LLM 并发上限到后端(debounce 300ms,防快速连续调整时频繁 IPC)
// 持久化已由上方 watch 写 appSettings(SQLite) 完成;此处只负责同步内存限流器
let _concurrencyTimer: ReturnType<typeof setTimeout> | null = null
function syncConcurrencyConfig() {
// clamp 到合理区间,防越界输入(缓存被篡改 / 手动输超限)致限流失效;
// 修正后值同步回 v-model(input 显示) + watch 持久化
// 下限 1,无上限;约束单对话并发不超过全局并发
settings.llmGlobalConcurrency = Math.max(1, settings.llmGlobalConcurrency || 3)
settings.llmPerConvConcurrency = Math.max(1, settings.llmPerConvConcurrency || 2)
if (settings.llmPerConvConcurrency > settings.llmGlobalConcurrency) {
settings.llmPerConvConcurrency = settings.llmGlobalConcurrency
// 下限 1,上界 64(前端 sanity);约束单对话并发不超过全局并发
let clamped = false
const g = clampConcurrency(settings.llmGlobalConcurrency, 3)
if (g !== settings.llmGlobalConcurrency) clamped = true
settings.llmGlobalConcurrency = g
let p = clampConcurrency(settings.llmPerConvConcurrency, 2)
if (p > g) { p = g; clamped = true }
settings.llmPerConvConcurrency = p
if (clamped && _userInteracting) {
emit('toast', t('settings.toastConcurrencyClamped', { max: MAX_CONCURRENCY }), 'warning')
}
if (_concurrencyTimer) clearTimeout(_concurrencyTimer)
// 走查 P3-11:interactive 在排程时捕获(debounce 回调执行时 _userInteracting 已复位 false)
const interactive = _userInteracting
_concurrencyTimer = setTimeout(async () => {
try {
await aiApi.setConcurrencyConfig(settings.llmGlobalConcurrency, settings.llmPerConvConcurrency)
await aiApi.setConcurrencyConfig(g, p)
// IPC 成功才显「已保存」,失败显行内红字(原实现先显成功、失败仅 console = 假成功)
if (interactive) { markSaved('global'); markSaved('perConv') }
} catch (e) {
console.error('同步 LLM 并发配置失败:', e)
if (interactive) { markFailed('global'); markFailed('perConv') }
}
}, 300)
if (_userInteracting) {
markSaved('global')
markSaved('perConv')
}
}
// wrapper:并发变更(@change 用户触发)同时反馈两项(global/perConv 任一变动都同步两项)
@@ -158,28 +190,32 @@ function syncAgentMaxIterations() {
settings.agentMaxIterations = Math.min(50, Math.max(1, settings.agentMaxIterations || 10))
}
if (_agentIterTimer) clearTimeout(_agentIterTimer)
const interactive = _userInteracting
_agentIterTimer = setTimeout(async () => {
try {
await aiApi.setAgentMaxIterations(settings.agentMaxIterations)
if (interactive) markSaved('iter')
} catch (e) {
console.error('同步 Agent 最大轮次失败:', e)
if (interactive) markFailed('iter')
}
}, 300)
if (_userInteracting) {
markSaved('iter')
}
}
// 「不限」勾选:agentMaxIterations===0 即不限(后端 0→usize::MAX,loop 靠 stop/收敛/审批退出)
const unlimited = computed({
get: () => settings.agentMaxIterations === 0,
set(v: boolean) {
// 走查 P3-11:勾选属用户交互,标记 interactive 让「已保存」等 IPC 成功后再显
// (原实现在模板 @change 即时 markSaved,先于持久化 = 假成功)
_userInteracting = true
if (v) {
settings.agentMaxIterations = 0
} else if (settings.agentMaxIterations === 0) {
settings.agentMaxIterations = 10
}
syncAgentMaxIterations()
_userInteracting = false
},
})
@@ -191,16 +227,16 @@ function syncAgentMaxRetries() {
// clamp 0-10,防越界
settings.agentMaxRetries = Math.min(10, Math.max(0, settings.agentMaxRetries ?? 3))
if (_agentRetryTimer) clearTimeout(_agentRetryTimer)
const interactive = _userInteracting
_agentRetryTimer = setTimeout(async () => {
try {
await aiApi.setAgentMaxRetries(settings.agentMaxRetries)
if (interactive) markSaved('retry')
} catch (e) {
console.error('同步流式重试次数失败:', e)
if (interactive) markFailed('retry')
}
}, 300)
if (_userInteracting) {
markSaved('retry')
}
}
onMounted(() => {
+58 -11
View File
@@ -67,7 +67,10 @@
<div class="form-grid">
<div class="form-field">
<label class="form-label">{{ $t('settings.labelName') }}</label>
<input v-model="providerForm.name" class="setting-input" :placeholder="$t('settings.phProviderName')" />
<!-- 走查 P3-10:校验失败定位字段——红框 + @input 清错(示范级,仅本表单) -->
<input ref="inputName" v-model="providerForm.name" class="setting-input"
:class="{ 'setting-input--invalid': fieldErrors.name }"
:placeholder="$t('settings.phProviderName')" @input="fieldErrors.name = false" />
</div>
<!-- 项 D:厂商预设下拉 — 选中自动填充 providerType/baseUrl/defaultModel(用户可改覆盖)。
新建态独立"快捷选择";编辑态回填后切预设仍可覆盖(等价快捷填充),不阻塞手改。
@@ -88,12 +91,16 @@
</div>
<div class="form-field">
<label class="form-label">{{ $t('settings.labelBaseUrl') }}</label>
<input v-model="providerForm.baseUrl" class="setting-input" :placeholder="$t('settings.phBaseUrl')" />
<input ref="inputBaseUrl" v-model="providerForm.baseUrl" class="setting-input"
:class="{ 'setting-input--invalid': fieldErrors.baseUrl }"
:placeholder="$t('settings.phBaseUrl')" @input="fieldErrors.baseUrl = false" />
</div>
<div class="form-field">
<label class="form-label">{{ $t('settings.labelApiKey') }}</label>
<!-- 走查 P3-9:type 恒 password 无法核对已粘贴密钥,加显隐切换 -->
<input v-model="providerForm.apiKey" class="setting-input" :type="showApiKey ? 'text' : 'password'" :placeholder="$t('settings.phApiKey')" />
<input ref="inputApiKey" v-model="providerForm.apiKey" class="setting-input" :type="showApiKey ? 'text' : 'password'"
:class="{ 'setting-input--invalid': fieldErrors.apiKey }"
:placeholder="$t('settings.phApiKey')" @input="fieldErrors.apiKey = false" />
<button type="button" class="btn-link" :title="$t(showApiKey ? 'settings.apiKeyHide' : 'settings.apiKeyShow')"
@click="showApiKey = !showApiKey">
{{ $t(showApiKey ? 'settings.apiKeyHide' : 'settings.apiKeyShow') }}
@@ -104,7 +111,9 @@
</div>
<div class="form-field">
<label class="form-label">{{ $t('settings.labelDefaultModel') }}</label>
<input v-model="providerForm.defaultModel" class="setting-input" :placeholder="$t('settings.phDefaultModel')" />
<input ref="inputDefaultModel" v-model="providerForm.defaultModel" class="setting-input"
:class="{ 'setting-input--invalid': fieldErrors.defaultModel }"
:placeholder="$t('settings.phDefaultModel')" @input="fieldErrors.defaultModel = false" />
<!-- 方向 A(轻量):defaultModel 仅作"建议起点"——预设 model id 易过时(DeepSeek chat 停用 /
Gemini 2.5→3.5 等),提示用户点「测试连接」拉真实模型列表覆盖,降低踩过时默认值的困惑。 -->
<div class="form-field-hint">{{ $t('settings.defaultModelHint') }}</div>
@@ -112,6 +121,7 @@
<!-- 供应商级采样温度覆盖:部分受限模型(如 Kimi 系)仅接受 temperature=1,留空走默认 0.7 -->
<div class="form-field">
<label class="form-label">{{ $t('settings.labelSamplingTemp') }}</label>
<!-- 走查 P3-14:原生 min/max 只约束步进按钮,手输越界由 saveProvider clamp + toast 提示 -->
<input type="number" v-model.number="providerForm.temperature" class="setting-input"
:placeholder="$t('settings.phSamplingTemp')" step="0.1" min="0" max="2" />
</div>
@@ -192,7 +202,7 @@
</template>
<script setup lang="ts">
import { reactive, ref, computed, onMounted } from 'vue'
import { reactive, ref, computed, onMounted, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { aiApi } from '@/api'
import type { AiProviderConfig, ModelConfig, Modality, Capability, ProbeSource } from '@/api/types'
@@ -228,6 +238,25 @@ const aiProviders = ref<AiProviderConfig[]>([])
// 密钥显隐切换(走查 P3-9):默认隐藏;重开表单时复位,防上一家 provider 的显钥状态延续
const showApiKey = ref(false)
// 走查 P3-10:校验失败只 toast 不定位字段——补字段级红框 + 滚动聚焦首个缺失字段。
// 仅保存必填四项(name/baseUrl/apiKey/defaultModel)做示范级实现;红框在对应输入框
// @input 时即清(用户开始修正就不该继续红着)。
const fieldErrors = reactive({ name: false, baseUrl: false, apiKey: false, defaultModel: false })
const inputName = useTemplateRef<HTMLInputElement>('inputName')
const inputBaseUrl = useTemplateRef<HTMLInputElement>('inputBaseUrl')
const inputApiKey = useTemplateRef<HTMLInputElement>('inputApiKey')
const inputDefaultModel = useTemplateRef<HTMLInputElement>('inputDefaultModel')
/** 聚焦首个红框字段(focus 默认行为即滚动到该元素,无需额外 scrollIntoView) */
function focusFirstError() {
const el = fieldErrors.name ? inputName.value
: fieldErrors.baseUrl ? inputBaseUrl.value
: fieldErrors.apiKey ? inputApiKey.value
: fieldErrors.defaultModel ? inputDefaultModel.value
: null
el?.focus()
}
function errMsg(e: unknown): string { return e instanceof Error ? e.message : String(e) }
const providerForm = reactive({
@@ -433,6 +462,7 @@ function openProviderForm(p?: AiProviderConfig) {
}
providerForm.fetching = false
providerForm.saveError = null // 重开表单清旧错误横幅
fieldErrors.name = fieldErrors.baseUrl = fieldErrors.apiKey = fieldErrors.defaultModel = false // 重开表单清字段红框
showApiKey.value = false // 密钥默认隐藏,不延续上次表单的显钥状态(防窥)
providerForm.visible = true
// L19/M32: 记初始快照(在 visible=true、字段回填完成后),isFormDirty 据此比对。
@@ -547,10 +577,28 @@ function classifySaveError(raw: string): { title: string; detail: string } {
async function saveProvider() {
// apiKey:新建必填;编辑可留空(不改,FR-S1 后端保留原 DB 值)
const needKey = !providerForm.editId
if (!providerForm.name || !providerForm.baseUrl || (needKey && !providerForm.apiKey) || !providerForm.defaultModel) {
// 走查 P3-10:逐字段判定缺失,红框标出并滚动聚焦首个缺失字段(toast 保留,双通道反馈)
fieldErrors.name = !providerForm.name
fieldErrors.baseUrl = !providerForm.baseUrl
fieldErrors.apiKey = needKey && !providerForm.apiKey
fieldErrors.defaultModel = !providerForm.defaultModel
if (fieldErrors.name || fieldErrors.baseUrl || fieldErrors.apiKey || fieldErrors.defaultModel) {
emit('toast', t('settings.toastSaveIncomplete'), 'warning')
focusFirstError()
return
}
// 走查 P3-14:采样温度钳制 [0,2](原生 min/max 只约束步进按钮,手输 99 照存)。
// 越界有限值 → clamp + toast 告知修正值;NaN/清空残留 → 归一 null(走默认 0.7,同步清显示)
let temperatureOverride: number | undefined
if (typeof providerForm.temperature === 'number' && Number.isFinite(providerForm.temperature)) {
if (providerForm.temperature < 0 || providerForm.temperature > 2) {
providerForm.temperature = Math.min(2, Math.max(0, providerForm.temperature))
emit('toast', t('settings.toastTempClamped', { v: providerForm.temperature }), 'warning')
}
temperatureOverride = providerForm.temperature
} else {
providerForm.temperature = null
}
providerForm.saving = true
try {
await aiApi.saveProvider({
@@ -561,11 +609,7 @@ async function saveProvider() {
apiKey: providerForm.apiKey,
defaultModel: providerForm.defaultModel,
modelConfigs: providerForm.models,
// 采样温度非空且为有效数字才带(清空输入 v-model.number 可能残留 ''/NaN,不过滤会污染后端 config)
temperatureOverride:
typeof providerForm.temperature === 'number' && Number.isFinite(providerForm.temperature)
? providerForm.temperature
: undefined,
temperatureOverride,
})
// 成功明确反馈——清错误横幅 + 关表单 + 绿色「已保存」toast
providerForm.saveError = null
@@ -788,6 +832,9 @@ onMounted(loadProviders)
/* 输入/下拉在 form-field 内 flex 填满(基础样式走全局 settings.css .setting-input/.setting-select) */
.form-field .setting-input,
.form-field .setting-select { flex: 1; min-width: 0; }
/* 走查 P3-10:校验缺失字段红框(@input 即清,用户开始修正就不再红) */
.setting-input--invalid { border-color: var(--df-danger) !important; }
.setting-input--invalid:focus { border-color: var(--df-danger) !important; box-shadow: 0 0 0 1px var(--df-danger); }
.setting-input--sm { flex: 0 0 auto; min-width: 0; width: 70px; padding: 4px 8px; font-size: 12px; }
/* Toggle 小号变体(本面板负载均衡池用;.toggle 基础走全局) */
+26 -10
View File
@@ -11,7 +11,11 @@
<div class="setting-control">
<slot />
<Transition name="saved-fade">
<span v-if="savedVisible" class="saved-hint">{{ $t('settings.savedHint') }}</span>
<!-- 走查 P3-11 假成功族:持久化/IPC 有成功/失败两态,反馈位同理补失败红字,
防止「已保存」承诺落空后用户无从察觉 -->
<span v-if="hintState !== 'none'" class="saved-hint" :class="{ 'saved-hint--fail': hintState === 'failed' }">
{{ hintState === 'saved' ? $t('settings.savedHint') : $t('settings.saveFailedHint') }}
</span>
</Transition>
</div>
</div>
@@ -21,9 +25,10 @@
import { shallowRef } from 'vue'
// ============================================================
// 统一设置行:label/desc 入 props,控件走默认 slot,变更后父级调 markSaved() 显反馈。
// 统一设置行:label/desc 入 props,控件走默认 slot,变更后父级调 markSaved() 显反馈,
// 持久化失败时调 markFailed() 显红字(走查 P3-11:只报成功不报失败 = 半个反馈位)。
// nextRound=true 显"下次对话生效" badge(性能域并发/轮次/重试白名单项)。
// savedVisible 用 shallowRef(布尔翻转无深响应需求);timer ref 持有供连续触发时清旧。
// hintState 用 shallowRef(字符串翻转无深响应需求);timer ref 持有供连续触发时清旧。
// ============================================================
defineProps<{
/** 设置项标题 */
@@ -34,30 +39,41 @@ defineProps<{
nextRound?: boolean
}>()
const savedVisible = shallowRef(false)
const hintState = shallowRef<'none' | 'saved' | 'failed'>('none')
const timer = shallowRef<ReturnType<typeof setTimeout> | null>(null)
/** 显"✓已保存"行内提示,2s 后消失。连续触发清旧 timer 防提前消失。 */
function markSaved() {
savedVisible.value = true
/** 点亮行内反馈提示,2s 后消失。连续触发清旧 timer 防提前消失。 */
function flashHint(state: 'saved' | 'failed') {
hintState.value = state
if (timer.value) clearTimeout(timer.value)
timer.value = setTimeout(() => {
savedVisible.value = false
hintState.value = 'none'
timer.value = null
}, 2000)
}
defineExpose({ markSaved })
/** 显"✓已保存"行内提示(持久化/IPC 成功后才调,防假成功) */
function markSaved() {
flashHint('saved')
}
/** 显"✗ 保存失败"行内提示(持久化/IPC 失败路径调用) */
function markFailed() {
flashHint('failed')
}
defineExpose({ markSaved, markFailed })
</script>
<style scoped>
/* 反馈位"✓已保存" — 行内紧贴控件右侧,2s 淡出 */
/* 反馈位"✓已保存" — 行内紧贴控件右侧,2s 淡出;失败态红字区分 */
.saved-hint {
margin-left: 10px;
font-size: 12px;
color: var(--df-accent);
white-space: nowrap;
}
.saved-hint--fail { color: var(--df-danger); }
/* badge"下次对话生效" — label 右侧小标 */
.next-round-badge {
display: inline-block;
+28 -6
View File
@@ -1,8 +1,9 @@
<template>
<!-- ═══ 设置左导航(master-detail 左栏)═══
阶段3 UX 重构:6 类导航(外观/AI模型/知识库/性能/安全/高级)+ emoji 图标。
阶段4 UX 重构:顶部搜索框 → 匹配 label/desc → 高亮匹配类 + 选中首匹配类
阶段4 UX 重构:顶部搜索框 → 匹配 label/desc → 即时高亮匹配类 + 防抖后跳首匹配类
+ emit scroll-target(父 Settings.vue 滚动到对应 setting-row)。
走查 P3-17:跳转防抖 350ms + 仅命中才跳,避免每键入强制切分类打断输入。
activeCategory 受控(双向),v-model 由 Settings.vue 绑定 + appSettings 持久化。
底部留导入导出占位(阶段6 接入)。 -->
<nav class="settings-nav" :class="{ 'is-horizontal': horizontal }">
@@ -52,7 +53,7 @@
// advanced 高级(autoExecuteMode/logLevel + 连接)
// ============================================================
import { computed, ref, watch } from 'vue'
import { computed, ref, watch, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { SETTINGS_INDEX, type SettingsCategory, type SettingIndexEntry } from './searchIndex'
@@ -126,12 +127,28 @@ function isMatchedCategory(cat: SettingsCategory): boolean {
return matchedCategories.value.includes(cat)
}
/** 关键词变化 → 选中首匹配类 + 上抛首匹配项 key(触发右侧滚动定位)。
* 空关键词时不改 activeCategory(用户清空搜索应留在当前类),也不上抛。 */
/** 关键词变化 → (防抖后)选中首匹配类 + 上抛首匹配项 key(触发右侧滚动定位)。
* 空关键词时不改 activeCategory(用户清空搜索应留在当前类),也不上抛。
* 走查 P3-17:原实现每敲一键立即切分类+滚动,浏览 A 类打字被反复拽到 B 类打断输入。
* 改为 350ms 防抖——连续键入只更新左侧命中高亮(即时,computed),停顿后才跳转定位;
* 且仅当有命中时才跳(无命中保持原地,不打断)。 */
const JUMP_DEBOUNCE_MS = 350
let _jumpTimer: ReturnType<typeof setTimeout> | null = null
function cancelPendingJump() {
if (_jumpTimer) {
clearTimeout(_jumpTimer)
_jumpTimer = null
}
}
watch(
() => searchKeyword.value,
(kw) => {
const trimmed = kw.trim()
() => {
cancelPendingJump()
if (!searchKeyword.value.trim()) return
_jumpTimer = setTimeout(() => {
_jumpTimer = null
// 防抖窗口后关键词可能又变了,重读当前值;无命中不跳(仅命中时跳)
const trimmed = searchKeyword.value.trim()
if (!trimmed) return
const first = matchedEntries.value[0]
if (!first) return
@@ -140,12 +157,17 @@ watch(
}
// 分类切换后需等右侧 Section 挂载,父组件 watch scroll-target 时自带 nextTick
emit('scroll-target', first.key)
}, JUMP_DEBOUNCE_MS)
},
)
// 卸载清防抖 timer,防 unmount 后旧 timer fire 时 emit 到已销毁组件
onUnmounted(cancelPendingJump)
defineExpose({
/** 清空搜索框(父组件切分类时可调用,但当前保留搜索状态便于用户继续搜) */
clearSearch() {
cancelPendingJump()
searchKeyword.value = ''
},
})
+47 -1
View File
@@ -65,6 +65,19 @@
<router-link :to="`/ideas/${task.idea_id}`" class="project-link" @click="$emit('close')">{{ ideaTitle }}</router-link>
</span>
</div>
<!-- S4 关联会话:后端审计表反查的主会话,无关联时入口隐藏;点击关抽屉 + 跳会话 -->
<div v-if="taskConversations.length > 0" class="info-item">
<span class="label">{{ $t('taskDetail.relatedConversations') }}</span>
<span class="value conv-links">
<a
v-for="c in taskConversations"
:key="c.conversation_id"
class="conv-link"
:title="`${c.tool_name} · ${formatDate(c.requested_at)}`"
@click.prevent="handleOpenConversation(c.conversation_id)"
>{{ c.title || c.conversation_id }}</a>
</span>
</div>
<div v-if="task.branch_name" class="info-item">
<span class="label">{{ $t('taskDetail.branch') }}</span>
<span class="value">
@@ -169,10 +182,11 @@ import { ref, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Drawer } from '@arco-design/web-vue'
import { taskApi, projectApi, ideaApi } from '@/api'
import { taskApi, projectApi, ideaApi, aiApi } from '@/api'
import { useProjectStore } from '@/stores/project'
import { formatDate } from '@/utils/time'
import { useRendered } from '@/composables/useMarkdown'
import { useAiConversations } from '@/composables/ai/useAiConversations'
import { taskStatusLabel, taskStatusClass, priorityLabel, priorityClass, ADVANCE_MAP } from '@/constants/project'
import type { AdvanceAction } from '@/constants/project'
import type { TaskRecord, ProjectRecord, IdeaRecord } from '@/api/types'
@@ -192,6 +206,11 @@ const { t } = useI18n()
const router = useRouter()
const store = useProjectStore()
const { showToast } = useToast()
const { openConversation } = useAiConversations()
// 关联会话引用(S4:审计表反查,无关联=空数组 → 入口隐藏)
type TaskConvRef = { conversation_id: string; title: string | null; tool_name: string; requested_at: string }
const taskConversations = ref<TaskConvRef[]>([])
const loading = ref(true)
const errorMsg = ref('')
@@ -248,6 +267,9 @@ async function load() {
if (!tid) return
loading.value = true
errorMsg.value = ''
taskConversations.value = [] // 新一轮加载先清旧引用,防切换任务后残留上一任务的会话
// 关联会话为辅助入口,旁路加载不阻塞主数据;失败静默(入口不显示即降级)
void loadTaskConversations(tid)
try {
const [t, ps, ideasList] = await Promise.all([
taskApi.get(tid),
@@ -268,6 +290,21 @@ async function load() {
}
}
/** S4:拉当前任务的关联会话(独立于主 try,查失败不拖垮抽屉);响应回来校验 taskId 未变 */
async function loadTaskConversations(tid: string) {
try {
const refs = await aiApi.listTaskConversations(tid)
if (tid !== props.taskId) return // 任务已切换,旧任务响应丢弃
taskConversations.value = refs
} catch { taskConversations.value = [] }
}
/** 点击关联会话:先关抽屉(面板在抽屉下层,不关会被遮挡),再走 openConversation 跳转 */
async function handleOpenConversation(convId: string) {
emit('close')
await openConversation(convId)
}
async function loadChildren() {
const cur = task.value
if (!cur || cur.parent_id) { children.value = []; return }
@@ -356,4 +393,13 @@ async function confirmSubtask() {
.dr-badges { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.dr-header-actions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
.loading-hint { text-align: center; padding: 40px 0; }
/* 关联会话:多会话纵向排列,链接样式对齐 .project-link 语义(TaskDrawer 内无其 scoped 定义) */
.conv-links { display: flex; flex-direction: column; gap: 4px; }
.conv-link {
color: var(--df-accent);
font-weight: 500;
cursor: pointer;
word-break: break-all;
}
.conv-link:hover { text-decoration: underline; }
</style>
+12
View File
@@ -656,6 +656,17 @@ function toggleSidebar() {
persistUiState()
}
/** 跨视图跳转会话(S4 TaskDrawer「关联会话」入口):AI 无路由,跳转 = 面板展开 + 切会话。
* 主窗口面板未展开时先展开再切换;detached(分离窗口)下主窗口无面板,只切会话不动面板。 */
async function openConversation(id: string) {
// 已展开不重复写 persistUiState(省一次 debounced SQLite 写)
if (!state.detached && !state.panelOpen) {
state.panelOpen = true
persistUiState()
}
await switchConversation(id)
}
export function useAiConversations() {
return {
loadConversations,
@@ -670,6 +681,7 @@ export function useAiConversations() {
toggleArchivedFold,
toggleGroupFold,
toggleSidebar,
openConversation,
}
}
+13
View File
@@ -89,6 +89,19 @@ export default {
truncated: 'truncated',
failed: 'failed',
meta: '{rounds} rounds · {tokens} tokens',
// S3: finished card is clickable to open the read-only trace (hover hint)
viewHint: 'Click to view execution trace',
},
// S3 sub-agent trace overlay (opened from the finished card, read-only simple render,
// discarded on close)
agentTrace: {
title: 'Sub-agent Trace',
empty: 'No messages recorded',
loadFail: 'Failed to load sub-agent trace',
roleUser: 'User',
roleAssistant: 'Assistant',
roleTool: 'Tool result',
roleSystem: 'System',
},
},
}
+5
View File
@@ -292,5 +292,10 @@ export default {
sendErrRateLimit: 'Rate limit reached, please retry later (error: {msg})',
sendErrQuota: 'AI quota or balance insufficient, check your provider account (error: {msg})',
sendErrNetwork: 'Network error or timeout, check your connection and retry (error: {msg})',
// ── UX-P3 chat polish additions ──
editEmptyBlocked: 'Edit content is empty, submission cancelled',
skillNoMatchQuery: 'No skill matches "{q}", send blocked',
backfillOverwriteConfirm: 'The input box has an unsent draft; backfilling will overwrite it. Continue?',
},
}
+2
View File
@@ -24,5 +24,7 @@ export default {
perPage: 'Per page',
total: '{n} total',
},
// Close-to-tray notice bubble (App.vue consumes the tray-hide-notice event; moved from hardcoded Chinese)
trayNoticeRunning: '{n} conversation(s) keep running in the background — the tray icon brings the window back anytime',
},
}
+8
View File
@@ -17,7 +17,15 @@ export default {
// ===== Instant feedback (phase 5 SettingRow) =====
savedHint: '✓ Saved',
// False-success family: red inline feedback for persist/IPC failure
saveFailedHint: '✗ Save failed',
nextRoundBadge: 'Next message',
// Out-of-range clamp notices (concurrency 1-64 + per-conv ≤ global; temperature reports the corrected value)
toastConcurrencyClamped: 'Value out of range, corrected automatically (concurrency 1-{max}; per-conversation capped at global)',
toastTempClamped: 'Sampling temperature must be within 0-2, corrected to {v}',
// Prompt caching toggle save feedback (previously silent)
toastAnthropicCacheSaved: 'Prompt caching setting saved',
toastAnthropicCacheFail: 'Failed to save the prompt caching setting',
// ===== Connection mgmt (phase 7: experimental badge, not wired to AI yet) =====
experimentalBadge: 'Experimental · not wired to AI tools',
+2
View File
@@ -91,5 +91,7 @@ export default {
descSaveFailed: 'Failed to save description: {msg}',
saveDesc: 'Save',
cancelEdit: 'Cancel',
// S4 related conversations (reverse lookup of main sessions that ran task tools, click to jump)
relatedConversations: 'Conversations',
},
}
+12
View File
@@ -88,6 +88,18 @@ export default {
truncated: '已截断',
failed: '失败',
meta: '{rounds} 轮 · {tokens} tokens',
// S3:完成卡可点击查看只读轨迹(hover 提示)
viewHint: '点击查看执行轨迹',
},
// S3 子代理轨迹浮层(完成卡点击展开,只读简版渲染,关即弃)
agentTrace: {
title: '子代理执行轨迹',
empty: '暂无消息记录',
loadFail: '拉取子代理轨迹失败',
roleUser: '用户',
roleAssistant: '助手',
roleTool: '工具结果',
roleSystem: '系统',
},
},
}
+5
View File
@@ -293,5 +293,10 @@ export default {
sendErrRateLimit: '发送频率超限,请稍后重试(原始错误:{msg})',
sendErrQuota: 'AI 服务额度或余额不足,请检查 Provider 账户(原始错误:{msg})',
sendErrNetwork: '网络异常或请求超时,请检查网络后重试(原始错误:{msg})',
// ── UX-P3 对话域打磨追加 ──
editEmptyBlocked: '编辑内容为空,已取消提交',
skillNoMatchQuery: '未找到技能「{q}」,已阻止发送',
backfillOverwriteConfirm: '输入框已有未发送草稿,回填将覆盖,确定继续?',
},
}
+2
View File
@@ -24,5 +24,7 @@ export default {
perPage: '每页',
total: '共 {n} 条',
},
// 关窗后台运行气泡(App.vue 消费 tray-hide-notice 事件;走查 P3-15 由硬编码中文迁入)
trayNoticeRunning: '{n} 个会话将继续在后台运行,托盘图标可随时唤起',
},
}
+8
View File
@@ -17,7 +17,15 @@ export default {
// ===== 即时生效反馈(阶段5 SettingRow) =====
savedHint: '✓ 已保存',
// 走查 P3-11 假成功族:持久化/IPC 失败的红字反馈位
saveFailedHint: '✗ 保存失败',
nextRoundBadge: '下次对话生效',
// 走查 P3-14 越界钳制提示(并发 1-64 + 单对话不超全局;采样温度单独提示修正值)
toastConcurrencyClamped: '输入超出有效范围,已自动修正(并发 1-{max},单对话不超过全局)',
toastTempClamped: '采样温度须在 0-2 范围内,已修正为 {v}',
// 走查 P3-11:Prompt 缓存开关保存反馈(原切换零反馈)
toastAnthropicCacheSaved: 'Prompt 缓存设置已保存',
toastAnthropicCacheFail: 'Prompt 缓存设置保存失败',
// ===== 连接管理(阶段7:实验功能 badge 明示暂未接入 AI) =====
experimentalBadge: '实验功能·暂未接入 AI 工具',
+2
View File
@@ -91,5 +91,7 @@ export default {
descSaveFailed: '保存描述失败: {msg}',
saveDesc: '保存',
cancelEdit: '取消',
// S4 关联会话(审计表反查发起过任务工具的主会话,点击跳转)
relatedConversations: '关联会话',
},
}
View File
+20 -3
View File
@@ -44,7 +44,7 @@
<input
type="checkbox"
:checked="anthropicCache"
@change="anthropicCache = ($event.target as HTMLInputElement).checked"
@change="onAnthropicCacheChange(($event.target as HTMLInputElement).checked)"
/>
<span class="toggle-slider"></span>
</label>
@@ -57,8 +57,11 @@
:ai-providers="aiProviders"
/>
<!-- 性能:global/per-conv concurrency/max-iterations/max-retries -->
<PerformanceSection v-else-if="activeCategory === 'performance'" />
<!-- 性能:global/per-conv concurrency/max-iterations/max-retries(@toast 接走查 P3-14 越界修正提示) -->
<PerformanceSection
v-else-if="activeCategory === 'performance'"
@toast="showToast"
/>
<!-- 安全:allowed_dirs -->
<AllowedDirsPanel
@@ -153,6 +156,20 @@ onBeforeRouteLeave(async () => {
// 值落库为 JSON 布尔,后端 "true"/"1" 判定兼容)
const anthropicCache = appSettings.useSetting<boolean>('df-ai-anthropic-cache', false)
// 走查 P3-11:原开关切换零反馈,用户无从得知是否生效。改为 set 完成后 toast 反馈成败。
// 注:appSettings.set 当前落库失败仅 console 内吞(降级内存态),catch 是 store 上抛错误后
// 的失败通道,今日实际不触发(登记项见批次报告),成功反馈先补齐。
async function onAnthropicCacheChange(checked: boolean) {
anthropicCache.value = checked
try {
await appSettings.set('df-ai-anthropic-cache', checked)
showToast(ti18n('settings.toastAnthropicCacheSaved'), 'success')
} catch (e) {
console.error('保存 Prompt 缓存开关失败:', e)
showToast(ti18n('settings.toastAnthropicCacheFail'), 'error')
}
}
// 窄屏响应式:监听窗口宽度,nav 折叠为横向 tab(避免依赖仅 CSS 的 :has,
// 并让 SettingsNav 按横向模式渲染 list)
const isNarrowScreen = ref(false)
+5 -1
View File
@@ -541,7 +541,11 @@ async function quickDelete(task: TaskRecord, childCount = 0) {
// deleteTask 返成功布尔,失败 toast(store.error 同时走 error-banner)
const ok = await store.deleteTask(task.id)
if (!ok) showToast(t('tasks.err.deleteFailed'), 'error')
} catch (e) { console.error('删除失败:', e) }
} catch (e) {
// UX走查 P3-3: throw 型异常原仅 console.error 静默,用户无感知;对齐 quickAdvance/quickPriority 的 toast 反馈
console.error('删除失败:', e)
showToast(e?.toString() ?? t('tasks.err.deleteFailed'), 'error')
}
}
// 点击外部关闭快捷菜单
function closeQuickMenu() { quickMenuId.value = null }