Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8396849d04 | ||
|
|
f67e65633b | ||
|
|
890ceef0ec | ||
|
|
8e09112d59 |
@@ -508,16 +508,52 @@ 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) => {
|
||||
tracing::error!("[COORDINATOR] 子任务 panic: {}", 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 {
|
||||
tracing::info!(
|
||||
layer = layer_idx,
|
||||
@@ -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]
|
||||
|
||||
+21
-6
@@ -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(
|
||||
base_url, api_key, model,
|
||||
)),
|
||||
_ => Box::new(openai_compat::OpenAICompatProvider::new(
|
||||
base_url, api_key, model,
|
||||
)),
|
||||
"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,
|
||||
))
|
||||
}
|
||||
unknown => {
|
||||
tracing::warn!(
|
||||
"未知 provider_type {:?},按 OpenAI 兼容协议兜底;请检查 ai_providers 配置的 provider_type",
|
||||
unknown
|
||||
);
|
||||
Box::new(openai_compat::OpenAICompatProvider::new(
|
||||
base_url, api_key, model,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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'))
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)保持
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
+26
-14
@@ -29,16 +29,20 @@
|
||||
</template>
|
||||
</AppLayout>
|
||||
|
||||
<!-- 全局错误 toast(根级,主窗口与分离窗口共用)-->
|
||||
<transition name="toast">
|
||||
<div v-if="errorMsg" class="df-toast">⚠ {{ errorMsg }}</div>
|
||||
</transition>
|
||||
<!-- 走查 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>
|
||||
</transition>
|
||||
|
||||
<!-- 关窗后台运行气泡(主窗关闭被拦截且仍有会话运行时,后端 emit tray-hide-notice):
|
||||
告知会话转入后台继续生成、托盘图标可随时唤起,复用 df-toast 形态 -->
|
||||
<transition name="toast">
|
||||
<div v-if="trayNoticeMsg" class="df-toast df-toast--info">{{ trayNoticeMsg }}</div>
|
||||
</transition>
|
||||
<!-- 关窗后台运行气泡(主窗关闭被拦截且仍有会话运行时,后端 emit tray-hide-notice):
|
||||
告知会话转入后台继续生成、托盘图标可随时唤起,复用 df-toast 形态 -->
|
||||
<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>
|
||||
|
||||
@@ -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 })
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 基础走全局) */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,26 +127,47 @@ 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()
|
||||
if (!trimmed) return
|
||||
const first = matchedEntries.value[0]
|
||||
if (!first) return
|
||||
if (props.modelValue !== first.category) {
|
||||
emit('update:modelValue', first.category)
|
||||
}
|
||||
// 分类切换后需等右侧 Section 挂载,父组件 watch scroll-target 时自带 nextTick
|
||||
emit('scroll-target', first.key)
|
||||
() => {
|
||||
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
|
||||
if (props.modelValue !== first.category) {
|
||||
emit('update:modelValue', first.category)
|
||||
}
|
||||
// 分类切换后需等右侧 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 = ''
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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?',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -88,6 +88,18 @@ export default {
|
||||
truncated: '已截断',
|
||||
failed: '失败',
|
||||
meta: '{rounds} 轮 · {tokens} tokens',
|
||||
// S3:完成卡可点击查看只读轨迹(hover 提示)
|
||||
viewHint: '点击查看执行轨迹',
|
||||
},
|
||||
// S3 子代理轨迹浮层(完成卡点击展开,只读简版渲染,关即弃)
|
||||
agentTrace: {
|
||||
title: '子代理执行轨迹',
|
||||
empty: '暂无消息记录',
|
||||
loadFail: '拉取子代理轨迹失败',
|
||||
roleUser: '用户',
|
||||
roleAssistant: '助手',
|
||||
roleTool: '工具结果',
|
||||
roleSystem: '系统',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -293,5 +293,10 @@ export default {
|
||||
sendErrRateLimit: '发送频率超限,请稍后重试(原始错误:{msg})',
|
||||
sendErrQuota: 'AI 服务额度或余额不足,请检查 Provider 账户(原始错误:{msg})',
|
||||
sendErrNetwork: '网络异常或请求超时,请检查网络后重试(原始错误:{msg})',
|
||||
|
||||
// ── UX-P3 对话域打磨追加 ──
|
||||
editEmptyBlocked: '编辑内容为空,已取消提交',
|
||||
skillNoMatchQuery: '未找到技能「{q}」,已阻止发送',
|
||||
backfillOverwriteConfirm: '输入框已有未发送草稿,回填将覆盖,确定继续?',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -24,5 +24,7 @@ export default {
|
||||
perPage: '每页',
|
||||
total: '共 {n} 条',
|
||||
},
|
||||
// 关窗后台运行气泡(App.vue 消费 tray-hide-notice 事件;走查 P3-15 由硬编码中文迁入)
|
||||
trayNoticeRunning: '{n} 个会话将继续在后台运行,托盘图标可随时唤起',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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 工具',
|
||||
|
||||
@@ -91,5 +91,7 @@ export default {
|
||||
descSaveFailed: '保存描述失败: {msg}',
|
||||
saveDesc: '保存',
|
||||
cancelEdit: '取消',
|
||||
// S4 关联会话(审计表反查发起过任务工具的主会话,点击跳转)
|
||||
relatedConversations: '关联会话',
|
||||
},
|
||||
}
|
||||
|
||||
+20
-3
@@ -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
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user