优化: AI Chat 渲染与无障碍 + 文件查看 + 同步 IO 收口

- 渲染: 结果/过程分离(气泡只放最终结果,过程轮叙述+工具卡收进轨迹卡默认收起可展开——治『流式后正文消失』且不再 N 气泡打扰)
- 渲染 P0: v-memo 定向 dep(只有真正渲染流式块的 item 失效,其余取占位) + renderItems 桶 memo(签名覆盖全消费输入,item 引用稳定) + 指纹 watch 降为 O(1) toolEpoch
- TopBar 三处 O(n) messages computed 合并单次遍历;md 缓存改有界 LRU(除满即全清抖动);useTurnTrace 补 never 穷尽 + default;MessageItem 模板去重
- 无障碍(四批累计): 可点容器键盘化(含 role/tabindex/keydown/aria-expanded) + icon-only aria-label 约 60 处 + 装饰图标 aria-hidden + 52 个滚动容器 overscroll-behavior:contain + 设置开关聚焦可见环 + outline 零补偿站点补 :focus-visible
- UX 走查项: 灵感 IDEA-10/11、知识 KNOW-6/7/9、项目详情 O8、审计 SA-4、设置 SA-6/26/27/28(脏表单/冗余 save/clamp/删除二次确认)
- 文件查看专项: 切工程状态残留 F5/F6、FileTree 去重与展开反馈 F7/F15/F17、切文件头部残留 F18、R 状态着色 F19;FilePreview diff 文件头(---/+++)误当增删渲染修复 + 大纲行高亮死样式修复 + 图片失败文案 i18n 化
- 同步 IO 收口(异步上下文内阻塞): unattended 看板读写改 tokio::fs、fetch_url 探活改 spawn_blocking、http/generate_image 清理改 tokio::fs
- 子代理 P3 批量: 任务书钉扎上限/截断文案区分父子语境/环境画像日期/temperature 可覆盖/model 回退上报/lang 透传
- crate 清理: df-execute·df-types 注释与格式、df-ideas 过时注释、df-nodes subflow 夸大描述、df-project 死变量
This commit is contained in:
lxy
2026-10-10 08:09:45 +08:00
parent 8df4e33656
commit 39e0eac39d
67 changed files with 1344 additions and 364 deletions
+8 -2
View File
@@ -102,7 +102,9 @@ impl EnvSnapshot {
}
}
/// 兜底:spawn_blocking panic/join 失败时返回最小可用快照(全 None,字段不空)。
/// 兜底:spawn_blocking panic/join 失败或外层 5s 超时时返回最小可用快照(全 None,字段不空)。
/// 注:异常/超时均**冻结**为 fallback 写入 OnceLock(EX-3)——本进程后续 detect() 不再重探,
/// 瞬时挂起(如 Store Alias)会被固化为整进程降级快照;解冻需重启进程。
fn fallback() -> EnvSnapshot {
EnvSnapshot {
os: std::env::consts::OS.to_string(),
@@ -310,7 +312,11 @@ fn extract_codepage(text: &str) -> Option<String> {
}
}
/// 执行 `tool --version`,解析首行返回版本串。失败/超时返回 None,不阻塞调用方。
/// 执行 `tool --version`,解析首行返回版本串。失败(非零退出/无首行)返回 None。
///
/// 注:单条命令本身**无独立超时**(EX-4)——若 `tool --version` 挂起(如 Windows Store Alias),
/// 本函数会一直阻塞,由外层 [`EnvSnapshot::detect`] 的 5s spawn_blocking 超时兜底整批(届时
/// 全部工具版本一起回退 fallback),而非逐命令超时。「不阻塞调用方」靠的是外层超时,非本函数。
///
/// 例:python --version 输出 "Python 3.11.5" → 返回 "3.11.5";git --version 输出
/// "git version 2.41.0" → 返回 "2.41.0"。统一抽掉工具名前缀,只保留版本号本身。
+7 -3
View File
@@ -320,7 +320,7 @@ pub struct ShellRequest {
pub env: std::collections::HashMap<String, String>,
/// 超时时间(秒),None 表示不超时
pub timeout_secs: Option<u64>,
/// Shell 类型(默认:Windows→Cmd, 非Windows→Sh)
/// Shell 类型(None 时取 [`ShellType::default`]:Windows→pwsh 可用则 pwsh 否则 PowerShell,非 Windows→Sh)
#[serde(default)]
pub shell_type: Option<ShellType>,
}
@@ -419,7 +419,7 @@ pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
// 提交冻结机械闸(39 治理):冻结在场且命令为 git commit → spawn 前拒绝,改动留工作区
crate::commit_freeze::check(&request).map_err(anyhow::Error::msg)?;
// 探测 pwsh(惰性 + OnceLock 全局缓存,只探一次),使后续 ShellType::default() 可读取缓存
// 探测 pwsh(惰性 + 模块级 PWSH_CACHE,成功才写缓存),使后续 ShellType::default() 可读取缓存
#[cfg(windows)]
let _ = probe_pwsh().await;
@@ -453,7 +453,11 @@ pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
/// 治 run_command 执行中黑盒:execute() 等 exit 才返回整块 stdout/stderr,长命令(cargo/npm 构建)
/// 期间前端只看 Started→Completed,中间进度不可见。本函数 spawn 子进程后并发逐行读两条流,
/// 每读到一行回调 `on_output(kind, line)`(调用方可 emit 事件给前端实时展示),仍等进程 exit
/// 后返回完整 ShellResult(与 execute() 同形,调用方无需感知差异)。
/// 后返回完整 ShellResult。
///
/// 与 [`execute`] 的输出差异(EX-2,调用方须知):本函数 stdout/stderr 按行重组——经
/// `BufReader::lines()` 逐行拼接,行尾统一为 `\n`(剥 \r\n)且补末尾换行;execute 则保留原始字节
/// (lossy 解码,含 \r\n,无补换行)。二者 ShellResult 字段同形,仅换行形态不同;需字节级一致时用 execute。
///
/// 4性:
/// - 合理机制:spawn + BufReader::lines() 逐行,不丢未换行结尾的末段(read_to_end 兜底)
-2
View File
@@ -125,8 +125,6 @@ impl ScoringEngine {
}
}
/// 统计 text 中命中任一关键词的数量(小写匹配,兼顾中英文)
/// 局限:纯子串匹配,不识别"不复用""无用户增长"等否定前缀,接 LLM 后由语义层修正
/// 单字否定前缀:关键词紧邻这些字时该命中不计(「不复用」不计入「复用」正向命中)。
///
/// 注意「别」单字歧义大(别具/别的/特别 均非否定),故不进单字清单——
+3 -2
View File
@@ -1,7 +1,8 @@
//! 子工作流节点 — 加载一个子 DAG 并在当前执行上下文中递归执行
//! 子工作流节点 — 加载并校验一个子 DAG,返回其 JSON 快照
//!
//! 用于复用通用流程(如"代码审查"可作为任意主流程的子步骤)。
//! 执行结果不直接展开节点,而是返回子 DAG 的 JSON 快照,由 DagExecutor 消费展开。
//! 注意:本节点不递归执行子 DAG,只做配置/深度校验后返回子 DAG 快照;
//! 快照的展开执行需调用方自行处理(DagExecutor 当前不会自动执行返回的 subflow)。
use async_trait::async_trait;
use df_workflow::dag_def::DagDef;
-3
View File
@@ -114,14 +114,11 @@ pub(crate) fn strip_readme_noise(content: &str) -> String {
if trimmed == "---" || trimmed == "+++" {
// 开头处的 frontmatter:跳到下一个匹配分隔符
let delim = trimmed;
let mut consumed_any = false;
for next in lines.by_ref() {
consumed_any = true;
if next.trim() == delim {
break;
}
}
let _ = consumed_any;
continue;
}
// ── HTML 注释块(<!-- ... -->,可能跨行)──
+4
View File
@@ -315,6 +315,10 @@ impl ProjectStatus {
///
/// Input Augmentation 层 ProjectResolver/TaskResolver 把记录的 String status 投影成
/// 类型化枚举用。未知字符串返 None。
///
/// ⚠️ TYPES-2:本函数返 None 是显式信号,但若调用方用 `unwrap_or_default()` 兜底,
/// 未知值会**静默归 Planning**(ProjectStatus::default),掩盖数据异常。建议调用方对
/// None 分支记 warn 审计(而非静默兜底),便于发现脏 status。
pub fn from_db_str(s: &str) -> Option<Self> {
Some(match s {
"planning" => ProjectStatus::Planning,
+353 -23
View File
@@ -33,9 +33,12 @@
//! (父侧 ai_chat_stop / ai_chat_force_send / ai_conversation_delete 经 SubAgentRegistry 级联)。
//!
//! ## 事件与展示边界
//! 仅在结束时挂父会话 emit 一条 AiAgentFinished(前端展示二期消费);不 emit AiCompleted /
//! AiMaxRounds 终态事件、不 spawn 标题/知识提炼。stream_round 复用带来的残留(均为网络异常
//! 罕见路径,消费方按「未知 conversation_id 丢弃」自我过滤,完整清单记录于此):
//! 运行期展示类事件(轮次/流式文本/工具生命周期/命令输出)经 `route_scope` 把子会话
//! conv 改挂父会话再推送,父会话视图能看到子代理实时进展(治「切回父会话 spawn 卡停在旧态」
//! 的幽灵态);复用既有 emit 双写出口,不另开旁路。仅结束时挂父会话 emit 一条 AiAgentFinished
//! (前端展示二期消费);不 emit AiCompleted / AiMaxRounds 终态事件、不 spawn 标题/知识提炼。
//! 非展示类事件(Fatal 的 AiError、MidStream 的 AiConvStateChanged 等)不改写,维持子会话
//! 路由——消费方按「未知 conversation_id 丢弃」自我过滤,完整残留清单记录于此:
//! - Fatal 分支会向子 conv emit AiError;
//! - MidStream 分支会触发一次子 conv 的后台标题任务(写库被零落库拦截,仅一次无效 LLM 调用);
//! - MidStream 分支 teardown_round 还会向子 conv emit 一条完整 AiCompleted(incomplete=true,
@@ -45,11 +48,11 @@ use std::collections::{BTreeMap, HashMap, VecDeque};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use tauri::AppHandle;
use tauri::{AppHandle, State};
use tokio::sync::Mutex;
use df_ai::ai_tools::AiToolRegistry;
use df_ai::context::{ContextManager, TokenEstimator};
use df_ai::context::{ContextConfig, ContextManager, TokenEstimator};
use df_ai::context_helpers::PLACEHOLDER_INTEGRITY_ENABLED;
use df_ai::provider::{ChatMessage, MessageRole, ToolDefinition};
use df_ai::router::{Modality, TaskRequirements};
@@ -63,13 +66,14 @@ use crate::commands::ai::agentic::{
};
use crate::commands::ai::audit::{process_tool_calls, ToolExecPolicy};
use crate::commands::ai::conversation::{
save_conversation_meta, truncate_for_persist, SaveMetaOverride, TokenAccumulator,
save_conversation_meta, SaveMetaOverride, TokenAccumulator, TRUNCATE_THRESHOLD,
};
use crate::commands::ai::event_bus::emit_ai_chat_event;
use crate::commands::ai::event_bus::{emit_ai_chat_event, route_scope};
use crate::commands::ai::{AiChatEvent, ToolCallDraft};
use crate::state::LlmConcurrency;
use crate::state::{AppState, LlmConcurrency};
use super::guard::GeneratingGuard;
use super::context_lifecycle::maybe_auto_compress;
use crate::commands::ai::provider_pool::ProviderPool;
use super::router_setup::resolve_model_with_override;
use super::{
@@ -109,10 +113,34 @@ pub(crate) const SUB_AGENT_WALL_TIMEOUT_SECS: u64 = 900;
/// 自达统一出口。
pub(crate) const SUB_AGENT_TOOL_BATCH_HEADROOM_SECS: u64 = 65;
/// 子代理上下文窗口预算上限(token)。自动压缩触发阈值与 build_for_request 裁剪预算
/// 的数据源,口径同 [`ContextConfig::max_tokens`]。默认 128_000 对齐 ContextConfig::default
/// ——接入压缩层不改动既有预算(零行为变更);值单点集中于此,后续按需分档只改本常量。
const SUB_AGENT_CONTEXT_MAX_TOKENS: u32 = 128_000;
/// 子代理会话的上下文配置(压缩/裁剪预算来源)。仅覆写 max_tokens 为子代理常量,
/// 输出预留与安全系数沿用 ContextConfig::default,与主会话同一压缩链路口径一致。
fn sub_agent_context_config() -> ContextConfig {
ContextConfig {
max_tokens: SUB_AGENT_CONTEXT_MAX_TOKENS,
..ContextConfig::default()
}
}
/// 出口报告内联诊断文本的封顶(字符数)。Fatal 的 provider 原始错误串可能携带超长
/// 堆栈/响应体,父会话报告只需头部即可定位(401/404/参数错),超长部分截断省略。
const DIAG_TEXT_MAX_CHARS: usize = 400;
/// 任务书钉扎副本的长度上限(字符)。钉扎进 system prompt 的副本是循环不变量(不受
/// build_for_request 预算裁剪),超长任务书会在每轮 system prompt 里永续常驻占 token;
/// 首条 user 消息已带完整任务书,钉扎副本只需够覆盖目标/完成标准/约束,超限截尾留标注。
const SUB_AGENT_TASK_PIN_MAX_CHARS: usize = 4_000;
/// 子代理报告交付父会话前的超长截断头/尾保留字符数(对齐对话侧落库截断 HEAD/TAIL 量级,
/// 保证头尾语义一致:结论在头、末尾未尽事项在尾)。阈值本身复用 TRUNCATE_THRESHOLD。
const SUB_AGENT_REPORT_HEAD: usize = 3_072;
const SUB_AGENT_REPORT_TAIL: usize = 3_072;
// ============================================================
// 结果类型
// ============================================================
@@ -185,6 +213,10 @@ pub(crate) struct SubAgentOutcome {
pub(crate) rejected_tools: Vec<(String, u32)>,
/// 停止类别(终态事件 stop_kind 数据源);启动失败=Error,正常收敛=Converged。
pub(crate) stop_kind: Option<SubAgentStopKind>,
/// 实际使用的模型 id(candidate 切换/池空回退后的最终值,非请求值)。
/// 请求 override 落空回退到别的模型时,父会话据此知道子代理「实际用什么跑的」;
/// 启动期失败(未解析出模型)为空串。
pub(crate) model: String,
}
impl SubAgentOutcome {
@@ -197,6 +229,7 @@ impl SubAgentOutcome {
tokens: 0,
rejected_tools: Vec::new(),
stop_kind: Some(SubAgentStopKind::Error),
model: String::new(),
}
}
}
@@ -424,6 +457,50 @@ pub(crate) fn cap_diag_text(s: &str, max_chars: usize) -> String {
out
}
/// 任务书钉扎副本的长度收敛(治「spawn task 全文永续无瘦身」):超上限时保留头部
/// (目标/完成标准通常在任务书前部)并附省略标注,完整任务书仍在首条 user 消息里
/// (钉扎只为把目标锚定在不受裁剪的 system 段,无需常驻全文)。纯函数可单测。
fn cap_pinned_task(task: &str) -> String {
if task.chars().count() <= SUB_AGENT_TASK_PIN_MAX_CHARS {
return task.to_string();
}
let head: String = task.chars().take(SUB_AGENT_TASK_PIN_MAX_CHARS).collect();
format!("{}\n[…任务书过长,钉扎副本已截断;完整任务书见首条消息]…", head)
}
/// 子代理报告交付父会话前的超长收敛(阈值/头尾语义与对话落库截断一致,省略文案按子代理
/// 语境改写)。通用 truncate_for_persist 的「完整内容仅在内存态可读」是面向本地 DB 持久化的
/// 措辞,而子代理报告是交付给父 LLM 的返回值——须明示完整报告随子代理轨迹落库,
/// 父侧不误以为拿不到全文。纯函数可单测。
fn cap_report_for_parent(content: &str) -> String {
let chars: Vec<char> = content.chars().collect();
if chars.len() <= TRUNCATE_THRESHOLD {
return content.to_string();
}
let head: String = chars.iter().take(SUB_AGENT_REPORT_HEAD).collect();
let tail: String = chars[chars.len() - SUB_AGENT_REPORT_TAIL..].iter().collect();
let omitted = chars.len() - SUB_AGENT_REPORT_HEAD - SUB_AGENT_REPORT_TAIL;
format!(
"{}\n\n[...报告过长,已省略中间 {} 字符(头尾保留);完整报告随子代理轨迹落库...]\n\n{}",
head, omitted, tail
)
}
/// 把子代理采样温度注入 provider 的 config JSON(供应商级温度覆盖的数据源:stream_relay
/// 读 candidate.config 的 "temperature" 字段决定请求 temperature)。保留 config 内既有字段,
/// 仅覆写 temperature;config 缺失/非法时新建仅含 temperature 的对象。钳到 [0,2],防越界值打挂 provider。
fn inject_temperature(provider: &mut df_storage::models::AiProviderRecord, temperature: f32) {
let t = temperature.clamp(0.0, 2.0) as f64;
let mut obj = provider
.config
.as_deref()
.and_then(|cfg| serde_json::from_str::<serde_json::Value>(cfg).ok())
.and_then(|v| v.as_object().cloned())
.unwrap_or_default();
obj.insert("temperature".to_string(), serde_json::json!(t));
provider.config = Some(serde_json::Value::Object(obj).to_string());
}
/// 判定 tool_result 内容是否为子代理拒绝 envelope(S2 报告协议解析核心,纯函数可单测)。
/// 仅识别结构化 JSON 的 status=rejected + reason=sub_agent_denied;非 JSON/正常结果/
/// 其他拒绝(如主会话 path_blacklist)一律不算,防被拒清单误计。
@@ -462,9 +539,33 @@ pub(crate) fn append_rejected_section(summary: String, rejected: &[(String, u32)
/// 替换「默认最多 6 轮」类硬编码文案——父可传 1..20,子代理必须按真实预算规划探索节奏,
/// 预算将尽时主动收尾交卷,而非被截断后拿末轮过程性独白当报告。
/// 工具纪律段经 prompt::sub_agent_tool_discipline_section 同源拼接(P1-1)。
/// `lang` 为父会话语言(agent_language,缺省中文):决定本段与工具纪律段语言;
/// 环境画像(日期 + OS/shell 执行姿势)与父会话同源注入(子代理同样无法自行获取日期)。
/// 派工纪律段经 super::DISPATCH_DISCIPLINE 母本同源拼接(N1),与主会话共享同一文本。
pub(crate) fn sub_agent_system_prompt(max_rounds: u32) -> String {
let mut p = format!(
pub(crate) fn sub_agent_system_prompt(max_rounds: u32, lang: &str) -> String {
// lang 只决定本段与工具纪律段语言;派工纪律段(DISPATCH_DISCIPLINE)是跨端共享母本、
// 文本单份(见 agentic/mod.rs),不随 lang 分叉。
let body = if lang == "en" {
format!(
"You are a sub-agent spawned by the DevFlow main session, running in an isolated context to carry out the parent's task brief.\n\
## Role\n\
- Execute for the parent task only; the user cannot see your process and cannot interact with or approve you\n\
- Use tools for exploration/retrieval/verification work; put conclusions in your final reply\n\
- You have no approval channel: path-authorization requests and medium/high-risk tools are denied outright with a failure result;\n\
for a denied operation, switch to an authorized way or state the gap in your report\n\
- This task's budget (actual values): up to {max_rounds} rounds / cumulative token cap {max_tokens} / wall-clock cap {wall_secs}s (15 min). Stop when converged, do no exploration beyond the brief;\n\
when the budget is nearly exhausted (last round) output the conclusion report directly, leave no exploration unfinished\n\
## Output requirements\n\
- Your final reply IS the conclusion report delivered to the parent; no pleasantries, do not ask the user questions\n\
- Report structure: conclusion first, then key findings/evidence/data, finally open items and recommendations\n\
- Preserve file paths, identifiers and error messages verbatim in the report (the parent locates by them; do not rewrite or summarize)\n\
- If any tool call was denied: state in open items which subtasks were left incomplete due to tool-permission limits, so the parent decides how to take over",
max_rounds = max_rounds,
max_tokens = SUB_AGENT_MAX_TOTAL_TOKENS,
wall_secs = SUB_AGENT_WALL_TIMEOUT_SECS,
)
} else {
format!(
"你是 DevFlow 主会话派生的子代理,在隔离上下文中独立执行父任务交办的任务书。\n\
## 定位\n\
- 面向父任务执行:只做任务书范围内的事,用户看不到你的过程,无法与你交互或审批\n\
@@ -477,15 +578,21 @@ pub(crate) fn sub_agent_system_prompt(max_rounds: u32) -> String {
## 输出要求\n\
- 最终回复就是交付给父任务的结论报告,不要寒暄、不要向用户提问\n\
- 报告结构:结论先行,然后列关键发现/证据/数据,最后列未尽事项与建议\n\
- 报告中的文件路径、标识符、错误信息照原样保留(父会话据此定位,勿改写或概括)\n\
- 若有工具调用被拒绝:在未尽事项中说明哪些子任务因工具权限受限未完成,父会话据此决定接手方式",
max_rounds = max_rounds,
max_tokens = SUB_AGENT_MAX_TOTAL_TOKENS,
wall_secs = SUB_AGENT_WALL_TIMEOUT_SECS,
);
)
};
// 环境画像(日期 + OS/shell 执行姿势)与父会话同源注入:日期 LLM 无法自行获取,
// 不注入则子代理写文件/报告里的日期全靠猜。
let mut p = crate::commands::ai::prompt::env_profile_line();
p.push_str(&body);
// 工具纪律段与父会话 system prompt 同源(read_symbol 优先/重复调用检测/URL 三工具
// 纪律/禁臆造 URL/查配置用 get_app_config);obscura 安装引导等交互条款已在共享函数
// 内裁剪,不适用于子代理。
p.push_str(&crate::commands::ai::prompt::sub_agent_tool_discipline_section("zh"));
// 内裁剪,不适用于子代理。lang 透传父会话语言,英文会话得英文纪律段。
p.push_str(&crate::commands::ai::prompt::sub_agent_tool_discipline_section(lang));
// 派工纪律同源注入(N1):引用父模块母本常量,与主会话 build_runtime_prompt 共享同一文本
// (报告限长/结论先行/证据随附直接约束子代理输出形态,若再派工亦同规约束);
// 内置于二进制而非知识库,防新实例知识库为空时纪律失传。
@@ -517,6 +624,9 @@ pub(crate) async fn run_sub_agent_loop(
allowed: Vec<String>,
max_rounds: u32,
model_override: Option<String>,
// 采样温度独立覆盖(P3):None=沿用 provider 配置/默认 0.7(默认不变,与主链路一致),
// Some(t)=本次子代理克隆候选注入 t,不影响主会话。
temperature_override: Option<f32>,
llm_concurrency: LlmConcurrency,
// 父会话 save 元数据快照(P3-5):出口落库显式传入,防继承当前活跃会话的
// provider/created_at。
@@ -526,7 +636,7 @@ pub(crate) async fn run_sub_agent_loop(
session_arc.clone(), tools_arc, db.clone(), app_handle.clone(),
parent_conv_id.clone(), child_conv_id.clone(),
system_prompt, task_prompt, tool_defs, allowed,
max_rounds, model_override, llm_concurrency.clone(),
max_rounds, model_override, temperature_override, llm_concurrency.clone(),
).await;
// 统一出口:挂父会话 emit 终态(前端展示二期消费;报告本体经父 tool_result 返回值交付)
@@ -600,6 +710,7 @@ async fn run_sub_agent_loop_inner(
allowed: Vec<String>,
max_rounds: u32,
model_override: Option<String>,
temperature_override: Option<f32>,
llm_concurrency: LlmConcurrency,
) -> SubAgentOutcome {
// 挂钟 deadline(三重熔断之三)
@@ -611,9 +722,10 @@ async fn run_sub_agent_loop_inner(
// (build_for_request 保尾弃头)时第一个被逐出,完成标准/约束随之蒸发。system 是
// 循环不变量(sys_tokens 一次估算缓存),不受消息淘汰影响;消息流首条 user 仍保留,
// 被淘汰也不丢目标。system_prompt 参数在此定形,后续全循环复用。
// 钉扎副本经 cap_pinned_task 收敛长度:超长任务书只钉目标锚,防全文字符串每轮常驻占 token。
let system_prompt = format!(
"{}\n## 任务书钉扎(全程有效,上下文裁剪不可逐出)\n<task>\n{}\n</task>\n",
system_prompt, task_prompt
system_prompt, cap_pinned_task(&task_prompt)
);
// 并发份额:global + per_conv(child) 双非阻塞 try_acquire,任一耗尽即 failed
@@ -644,6 +756,9 @@ async fn run_sub_agent_loop_inner(
session.deleted_convs.insert(child_conv_id.clone());
session.conv_origins.insert(child_conv_id.clone(), SUB_AGENT_ORIGIN.to_string());
let conv = session.conv(&child_conv_id);
// 上下文预算单点接入:子会话 ContextManager 用子代理配置(默认值与父会话一致),
// 供压缩触发阈值与 build_for_request 裁剪读取;新条目消息为空,直接替换无损。
conv.messages = ContextManager::new(sub_agent_context_config());
conv.messages.push(ChatMessage::user(&task_prompt));
conv.stop_flag.store(false, Ordering::SeqCst);
conv.iteration_used = 0;
@@ -655,7 +770,7 @@ async fn run_sub_agent_loop_inner(
GeneratingGuard::new(child_conv_id.clone(), child_epoch.clone(), my_epoch, app_handle.clone());
// ── provider 解析(30s 超时,对齐主 loop 入口;失败走 failed 返回值,不 emit AiError)──
let (primary_provider, candidates) = {
let (primary_provider, mut candidates) = {
let resolve = tokio::time::timeout(std::time::Duration::from_secs(30), async {
let provider_repo = AiProviderRepo::new(&db);
let pool = match provider_repo.list_all().await {
@@ -695,6 +810,16 @@ async fn run_sub_agent_loop_inner(
let mut resolved_model = resolve_model_with_override(&req_probe, &primary_provider, &model_override);
let mut provider_config = primary_provider;
// 子代理采样温度独立覆盖(P3):默认 None 不注入(沿用 provider 配置或 0.7,行为不变)。
// Some(t) 时把 temperature 写进本次子代理的候选 config JSON——stream_relay 的供应商级
// 温度覆盖正从 candidate.config 读取,故只改克隆候选即可生效,不触碰主会话/DB 记录。
if let Some(temp) = temperature_override {
inject_temperature(&mut provider_config, temp);
for c in candidates.iter_mut() {
inject_temperature(c, temp);
}
}
// system_prompt 是整个子循环的不变量,token 估算一次缓存复用(对齐主 loop)
let sys_tokens = TokenEstimator::default().estimate_text(&system_prompt);
@@ -733,6 +858,35 @@ async fn run_sub_agent_loop_inner(
break;
}
// 子代理运行期进度可见(父会话视图):每轮起算前挂父会话发 AiAgentRound 建独立气泡,
// 本轮后续流式文本/工具事件经 conv 路由改挂父会话续写该气泡(见 route_scope)。
emit_ai_chat_event(&app_handle, AiChatEvent::AiAgentRound {
round: round + 1,
conversation_id: Some(parent_conv_id.clone()),
});
// ── 自动压缩(智能裁剪):复用父会话同一压缩链路 ——
// 子代理长跑探索/多文件阅读同样累积上下文,缺此层只剩 build_for_request 硬裁剪、
// 无 LLM 摘要重锚定,主题随裁剪蒸发。provider 沿用本会话当前 candidate(与父 loop
// 同款);压缩后台化(fire-and-forget)不阻塞本轮,结果下一轮生效。返回 true =
// 子会话条目已消失,退出循环(对齐 prepare 段的 conv 缺失处理)。压缩过程产生的
// 展示类事件(AiCompressing/AiAutoCompressed)挂子会话,消费方按未知 conv 丢弃,
// 与模块头「非展示类事件不改写」残留清单一并说明。
if maybe_auto_compress(
&session_arc,
&child_conv_id,
&app_handle,
&provider_config,
&llm_concurrency,
round as usize,
sys_tokens,
).await {
status = SubAgentStatus::Failed;
stop_reason = "子会话条目已消失".to_string();
stop_kind = Some(SubAgentStopKind::Error);
break;
}
// ── prepare:build_for_request(预算裁剪)+ system prompt + namespace 展开 +
// tool_result 视图压缩 + 占位配对断言 + 输入 token 估值(对齐主 loop prepare 段)──
let (messages, estimated_prompt) = {
@@ -797,7 +951,11 @@ async fn run_sub_agent_loop_inner(
// 子循环 future(报告蒸发+注册表泄漏+子会话滞留)。stream_round 内部短锁段无跨
// await 持锁、mid-flight 无消息写,drop 安全(guard 由本函数持有,出口统一复位)。
let stream_budget = deadline.saturating_duration_since(tokio::time::Instant::now());
match tokio::time::timeout(stream_budget, stream_round(
// 子代理展示类事件改挂父会话:本轮流式(AiTextDelta/AiHeartbeat/AiStreamRetry 等)
// 经 route_scope 从子会话改挂父会话推送,父会话视图实时可见(终态/状态机事件不改写)。
match tokio::time::timeout(stream_budget, route_scope(
child_conv_id.clone(), parent_conv_id.clone(),
stream_round(
&session_arc, &db, &child_conv_id,
&messages, &tool_defs, &app_handle,
&child_stop, &child_notify,
@@ -809,6 +967,7 @@ async fn run_sub_agent_loop_inner(
// 子会话无目标钉扎,空快照(该参数仅供主 loop AiCompleted 快照透传)
&[],
&child_epoch, my_epoch,
),
)).await {
Ok(RoundOutcome::Continue { full_text, tool_calls, round_usage, round_is_estimated, truncated_by_max_tokens }) => {
rounds_done += 1;
@@ -868,11 +1027,16 @@ async fn run_sub_agent_loop_inner(
.values()
.map(|d| (d.id.clone(), d.name.clone()))
.collect();
let pending = process_tool_calls(
// 工具事件(AiToolCallStarted/Completed/AiCommandOutput 等)同样改挂父会话,
// 让父会话视图看到子代理的工具执行实时状态;子会话自身消息写入不受影响。
let pending = route_scope(
child_conv_id.clone(), parent_conv_id.clone(),
process_tool_calls(
&session_arc, tool_calls, &tools_arc, &db, &app_handle, &child_conv_id,
ToolExecPolicy::SubAgent,
// P2-1 执行期白名单:Low 工具不在名单同样就地拒绝(layer=whitelist-clamp)
Some(&allowed),
),
).await;
// S2 报告协议:收集本轮被拒清单(deny 单点已写入结构化 envelope,此处只读解析)
collect_rejected_tools(&session_arc, &child_conv_id, &round_calls, &mut rejected_tools).await;
@@ -951,8 +1115,8 @@ async fn run_sub_agent_loop_inner(
// ── 报告提取:收敛轮 full_text 优先;截断/失败取末条 assistant 文本附终止说明;再无则用原因 ──
let total_tokens = tokens_snapshot(&tokens).total_tokens as u64;
let summary = if !report.trim().is_empty() {
// 父侧注入前超长截断复用现有 truncate_for_persist(头尾保留语义一致)
truncate_for_persist(report.trim())
// 父侧注入前超长截断:用子代理语境文案(非通用落库文案),阈值/头尾语义一致
cap_report_for_parent(report.trim())
} else {
match last_assistant_text(&session_arc, &child_conv_id).await {
Some(text) => {
@@ -961,7 +1125,7 @@ async fn run_sub_agent_loop_inner(
} else {
format!("{}\n\n(子代理提前终止:{})", text, stop_reason)
};
truncate_for_persist(combined.trim())
cap_report_for_parent(combined.trim())
}
None if !stop_reason.is_empty() => format!("子代理未产出报告:{}", stop_reason),
None => "子代理未产出报告".to_string(),
@@ -979,6 +1143,7 @@ async fn run_sub_agent_loop_inner(
status = status.as_str(),
rounds = rounds_done,
tokens = total_tokens,
model = %resolved_model,
summary_len = summary.len(),
"[sub-agent] 子代理结束"
);
@@ -991,6 +1156,8 @@ async fn run_sub_agent_loop_inner(
tokens: total_tokens,
rejected_tools: rejected,
stop_kind,
// 实际生效模型(candidate 切换/回退后的最终值),父会话据此知晓子代理真用了哪个模型
model: resolved_model.clone(),
}
}
@@ -1088,6 +1255,99 @@ async fn collect_rejected_tools(
}
}
// ============================================================
// 完成卡「已消化」标记(KV 持久,app_settings)
// ============================================================
/// 完成卡「已消化」标记的 KV key 前缀:key = `agentCardDigested:{parentConvId}`,
/// 值 = 已标记 agentId 的 JSON 字符串数组。完成卡本身是派生态(由子代理轨迹重建),
/// 「是否已消化」是用户偏好,故复用 app_settings KV 而不落 SQLite 新表/新列;轨迹删除后
/// 孤儿 key 自然失效(重建路径只对轨迹存在的卡应用标记),无需清理任务。
pub(crate) const AGENT_CARD_DIGESTED_KEY_PREFIX: &str = "agentCardDigested:";
/// 组装某父会话的已消化标记 KV key。
pub(crate) fn digested_kv_key(parent_conv_id: &str) -> String {
format!("{}{}", AGENT_CARD_DIGESTED_KEY_PREFIX, parent_conv_id)
}
/// 解析 KV 原始串为已消化 agentId 列表。容错:缺省/非 JSON/非数组一律降级为空,
/// 数组内空串与非字符串项跳过;结果去重升序,保证确定性与幂等收口。纯函数可单测。
pub(crate) fn parse_digested_ids(raw: Option<&str>) -> Vec<String> {
let Some(s) = raw else {
return Vec::new();
};
// 结构不符(坏值/旧格式残留)一律空列表兜底,不向调用方抛错——标记是纯偏好,读不动即视为未标记
let Ok(serde_json::Value::Array(items)) = serde_json::from_str::<serde_json::Value>(s) else {
return Vec::new();
};
let mut set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for it in items {
if let Some(id) = it.as_str().filter(|id| !id.is_empty()) {
set.insert(id.to_string());
}
}
set.into_iter().collect()
}
/// 批量置位/清位已消化标记,返回新列表(去重升序)。空 id 忽略;幂等(重复置位/清位收敛)。
pub(crate) fn apply_digested(list: Vec<String>, agent_ids: &[String], digested: bool) -> Vec<String> {
let mut set: std::collections::BTreeSet<String> = list.into_iter().collect();
for id in agent_ids.iter().filter(|id| !id.is_empty()) {
if digested {
set.insert(id.clone());
} else {
set.remove(id);
}
}
set.into_iter().collect()
}
/// 序列化已消化列表为稳定 KV 值(JSON 字符串数组;空列表序列化为 `[]`,便于清位后覆盖旧值)。
pub(crate) fn serialize_digested_ids(list: &[String]) -> String {
serde_json::to_string(list).unwrap_or_else(|_| "[]".to_string())
}
/// 读某父会话的已消化标记列表(完成卡重建路径读 KV 合并的数据源,只读无副作用)。
#[tauri::command]
pub async fn ai_sub_agent_digested_list(
state: State<'_, AppState>,
conversation_id: String,
) -> Result<Vec<String>, String> {
let raw = state
.settings
.get(&digested_kv_key(&conversation_id))
.await
.map_err(crate::commands::err_str)?;
Ok(parse_digested_ids(raw.as_deref()))
}
/// 批量置位/清位完成卡「已消化」标记(digested=true 标记,false 取消),返回更新后的完整列表。
///
/// 为什么由后端收口读改写而非前端各自 settings KV 自读自写:同会话多卡批量标记并发时,
/// 前端 read-modify-write 会互相覆盖丢标记;键格式(agentCardDigested:{conv} → string[])
/// 亦单点约定,前端不散落拼 key。
#[tauri::command]
pub async fn ai_sub_agent_digest(
state: State<'_, AppState>,
conversation_id: String,
agent_ids: Vec<String>,
digested: bool,
) -> Result<Vec<String>, String> {
let key = digested_kv_key(&conversation_id);
let raw = state
.settings
.get(&key)
.await
.map_err(crate::commands::err_str)?;
let list = apply_digested(parse_digested_ids(raw.as_deref()), &agent_ids, digested);
state
.settings
.set(&key, &serialize_digested_ids(&list))
.await
.map_err(crate::commands::err_str)?;
Ok(list)
}
// ============================================================
// 单元测试:白名单钳制 / max_rounds 熔断 / registry 级联注册移除
// ============================================================
@@ -1329,7 +1589,7 @@ mod tests {
/// 同源工具纪律段(P1-1)。
#[test]
fn sub_agent_prompt_mentions_report_contract() {
let p = sub_agent_system_prompt(6);
let p = sub_agent_system_prompt(6, "zh-CN");
assert!(p.contains("子代理"));
assert!(p.contains("最终回复"));
assert!(p.contains("审批通道"), "应说明无审批通道(防挂死设计的前提告知)");
@@ -1339,8 +1599,12 @@ mod tests {
assert!(p.contains(&SUB_AGENT_MAX_TOTAL_TOKENS.to_string()), "token 上限注入");
assert!(p.contains(&SUB_AGENT_WALL_TIMEOUT_SECS.to_string()), "挂钟上限注入");
assert!(!p.contains("默认最多 6 轮"), "硬编码缺省文案应已移除: {}", p);
// 环境画像(日期)与父会话同源注入:子代理无法自行获取日期
assert!(p.contains("当前日期"), "环境画像日期须注入: {}", p);
// 报告约定:文件路径原样保留
assert!(p.contains("文件路径"), "报告约定须含文件路径原样保留: {}", p);
// 父设小预算时按真实值注入(防按 6 轮规划被截断)
assert!(sub_agent_system_prompt(3).contains("最多 3 轮"));
assert!(sub_agent_system_prompt(3, "zh-CN").contains("最多 3 轮"));
// P1-1 工具纪律段同源拼接
assert!(p.contains("read_symbol"), "read_symbol 优先指引须注入: {}", p);
assert!(p.contains("重复调用检测"), "重复调用检测须注入");
@@ -1348,6 +1612,37 @@ mod tests {
assert!(p.contains("get_app_config"), "查配置正解须注入");
assert!(p.contains("失败 2 次必换方案"), "render=true 铁律须注入");
assert!(!p.contains("要我帮你装吗"), "obscura 交互条款不进子代理");
// lang=en:主体与工具纪律段均转英文(派工纪律段母本单份仍中文,见函数文档)
let en = sub_agent_system_prompt(6, "en");
assert!(en.contains("sub-agent"), "英文主体: {}", en);
assert!(en.contains("no approval channel"));
assert!(en.contains(DUPLICATE_EN_MARKER), "英文纪律段须随 lang 透传");
assert!(!en.contains("你没有审批通道"));
}
/// 英文工具纪律段特征串(间接断言 lang="en" 已透传到 discipline 段)。
const DUPLICATE_EN_MARKER: &str = "Duplicate call detection";
/// 任务书钉扎长度收敛:上限内原样,超限截断并留标注(治全文字符串每轮常驻)。
#[test]
fn cap_pinned_task_bounds_length() {
let short = "统计 crates 下 TODO 数量";
assert_eq!(cap_pinned_task(short), short);
let long = "x".repeat(SUB_AGENT_TASK_PIN_MAX_CHARS + 500);
let capped = cap_pinned_task(&long);
assert!(capped.chars().count() < long.chars().count(), "超限须收敛");
assert!(capped.contains("完整任务书见首条消息"), "须留截断标注");
}
/// 子代理报告截断:阈值内原样,超阈截断并用子代理语境文案(不含落库语境的「内存态」)。
#[test]
fn cap_report_for_parent_uses_subagent_wording() {
let short = "结论:已完成";
assert_eq!(cap_report_for_parent(short), short);
let long = "y".repeat(TRUNCATE_THRESHOLD + 100);
let capped = cap_report_for_parent(&long);
assert!(capped.contains("随子代理轨迹落库"), "须用子代理语境文案");
assert!(!capped.contains("内存态"), "不得沿用父侧落库文案");
}
/// P2-4 停止类别枚举序列化为 snake_case 字符串(事件面消费契约)。
@@ -1436,4 +1731,39 @@ mod tests {
vec!["write_file×3".to_string(), "run_command×1".to_string()]
);
}
/// 完成卡「已消化」标记:KV key 形态 + 容错解析(坏值/空串/非串项) + 批量置位清位幂等 + 序列化往返。
#[test]
fn digested_marker_kv_roundtrip_and_tolerance() {
assert_eq!(digested_kv_key("conv-1"), "agentCardDigested:conv-1");
// 容错解析:缺省/非 JSON/非数组 一律空;去重升序;跳过空串与非串项
assert!(parse_digested_ids(None).is_empty());
assert!(parse_digested_ids(Some("not json")).is_empty());
assert!(parse_digested_ids(Some("{}")).is_empty());
assert_eq!(
parse_digested_ids(Some(r#"["b","a","a","",1,null]"#)),
vec!["a".to_string(), "b".to_string()]
);
// 批量置位(空 id 忽略) + 幂等
let list = apply_digested(
vec![],
&["p::agent-2".to_string(), "p::agent-1".to_string(), String::new()],
true,
);
assert_eq!(list, vec!["p::agent-1".to_string(), "p::agent-2".to_string()]);
let list = apply_digested(list, &["p::agent-1".to_string()], true);
assert_eq!(list, vec!["p::agent-1".to_string(), "p::agent-2".to_string()], "重复置位幂等");
// 批量清位(含不存在 id,不 panic) + 幂等
let list = apply_digested(list, &["p::agent-1".to_string(), "ghost".to_string()], false);
assert_eq!(list, vec!["p::agent-2".to_string()]);
assert!(apply_digested(list, &["p::agent-2".to_string()], false).is_empty());
// 序列化 → 解析 往返;空列表序列化为 `[]`
let list = vec!["x".to_string(), "y".to_string()];
assert_eq!(parse_digested_ids(Some(&serialize_digested_ids(&list))), list);
assert_eq!(serialize_digested_ids(&[]), "[]");
}
}
+22 -5
View File
@@ -27,7 +27,7 @@ use crate::state::{AppState, log_slow_lock};
use crate::commands::{err_str, now_millis};
// chat.rs 的 super = commands,super::super = ai(与原 commands.rs 的 super=ai 等价)。
use super::super::agentic::{run_agentic_loop, try_continue_agent_loop};
use super::super::agentic::{resolve_model_with_override, run_agentic_loop, try_continue_agent_loop};
use super::super::event_bus::emit_ai_chat_event;
#[cfg(test)]
use super::super::agentic::GOAL_MAX_CHARS;
@@ -866,6 +866,17 @@ pub(crate) async fn spawn_agentic_session(
return Err("AI 正在生成中,请等待完成".to_string());
}
let conv = session.conv(&target);
// 939 ★③:按目标模型实际上下文窗口重设消息预算(仅在新建空会话生效,收口见 model_context)。
// 路由口径同主 loop pre-loop(needs_tool_use=true;estimated_context/tier 置零——预算只关心窗口,
// 与 loop 内按真实估计的重路由无关);override 穿透/兜底由 resolve_model_with_override 统一处理。
let budget_req = df_ai::router::TaskRequirements {
modalities: vec![df_ai::router::Modality::Text],
needs_tool_use: true,
estimated_context: 0,
tier: None,
};
let budget_model = resolve_model_with_override(&budget_req, &provider_config, &model_override);
conv.apply_model_window(&budget_model, &provider_config.model_configs);
// 批3 双轨收口:generating bool 已退役,生成态由 run_agentic_loop 入口迁移设置(不再手动赋值)。
conv.stop_flag.store(false, Ordering::SeqCst);
conv.agent_language = language.clone();
@@ -1256,7 +1267,7 @@ pub async fn ai_approve(
save_conversation(&state.ai_session, &state.db, cid, None, None, true).await;
}
// 审计:拒绝(决策者=human)
audit_finalize(&state, &tool_call_id, "rejected", None).await;
audit_finalize(&state, &tool_call_id, "rejected", None, None).await;
// F-260616-11 决策 a: 审批续跑 iteration 累计(不重置)——读 per_conv.iteration_used 透传
// 作 start_iteration,防多次审批反复跑满 max_iterations 致 token 失控。
// F-09 B 批4:per_conv 唯一真相源(conv_id 来源 approval.conversation_id);无 conv_id 时 0。
@@ -1315,6 +1326,8 @@ pub async fn ai_approve(
// 仅分支 run_workflow,其余工具审批路径完全不变(含 AE-04 trust_key_for 写 trust / audit 终态
// 回填 / emit_data_changed / save / try_continue)。workflow 联动任务推进由 run_workflow 内部
// 按 task_id+target_status 自动处理(workflow.rs:270-308),此处不重复推进。
// V50 纯执行耗时:只测真执行窗口(run_workflow / execute_with_heartbeat),不含等待人工批准。
let exec_started = std::time::Instant::now();
let exec_result: anyhow::Result<serde_json::Value> = if approval.tool_name == "run_workflow" {
super::super::execute_run_workflow_for_tool(&app, &state, &args, conv_id.as_deref()).await
} else {
@@ -1329,6 +1342,7 @@ pub async fn ai_approve(
&app, conv_id.as_deref().unwrap_or_default(), &id,
).await
};
let exec_ms = exec_started.elapsed().as_millis() as i64;
// 工具失败不 return Err:把错误包成 tool_result,落库 + emit completed + 续循环全走通。
// 否则前端 approveToolCall 的 catch 会回滚 pending_approval,审批按钮卡死无法消除。
let (audit_status, result_val) = match &exec_result {
@@ -1336,7 +1350,7 @@ pub async fn ai_approve(
Err(e) => ("failed", serde_json::Value::String(e.to_string())),
};
// 审计:人工审批后无论成败回填(决策者=human)
audit_finalize(&state, &tool_call_id, audit_status, Some(result_val.to_string())).await;
audit_finalize(&state, &tool_call_id, audit_status, Some(result_val.to_string()), Some(exec_ms)).await;
// AR-11(方案A):人工审批通过且工具执行成功(非 failed)后 emit df-data-changed,
// 前端 store listen 刷新列表(仅命中映射的工具 emit,见 emit_data_changed)。
@@ -1607,7 +1621,7 @@ pub async fn ai_authorize_dir(
conversation_id: conv_id.clone(),
};
emit_ai_chat_event(&app, ev_approval);
audit_finalize(&state, &tool_call_id, "rejected", Some(err_msg)).await;
audit_finalize(&state, &tool_call_id, "rejected", Some(err_msg), None).await;
if let Some(ref cid) = conv_id {
save_conversation(&state.ai_session, &state.db, cid, None, None, true).await;
}
@@ -1657,6 +1671,8 @@ pub async fn ai_authorize_dir(
tracing::debug!(tool = %approval.tool_name, "[AI-AUTHZ] execute 前");
// 防工具卡死:超时兜底已收敛进 execute_with_heartbeat 的 outer_secs 分档,
// execute 不返回时超时返 Err → 走 failed 分支 finalize+emit+try_continue,IPC 必返回不卡。
// V50 纯执行耗时:只测真执行窗口,不含等待人工批准。
let exec_started = std::time::Instant::now();
let exec_result: anyhow::Result<serde_json::Value> = if approval.tool_name == "run_workflow" {
super::super::execute_run_workflow_for_tool(&app, &state, &args, conv_id.as_deref()).await
} else {
@@ -1668,6 +1684,7 @@ pub async fn ai_authorize_dir(
&app, conv_id.as_deref().unwrap_or_default(), &tool_call_id,
).await
};
let exec_ms = exec_started.elapsed().as_millis() as i64;
tracing::debug!(
ok = exec_result.is_ok(),
status = if exec_result.is_ok() { "completed" } else { "failed" },
@@ -1685,7 +1702,7 @@ pub async fn ai_authorize_dir(
if let Some(note) = cross_drive_hint(&approval.tool_name, &req.raw_paths) {
result_val = append_cross_drive_note(result_val, &note);
}
audit_finalize(&state, &tool_call_id, audit_status, Some(result_val.to_string())).await;
audit_finalize(&state, &tool_call_id, audit_status, Some(result_val.to_string()), Some(exec_ms)).await;
if exec_result.is_ok() {
emit_data_changed(&app, &approval.tool_name);
// MED-1(审查): path 绑定工具(Medium)审批执行后 reload 白名单。
@@ -348,6 +348,11 @@ pub(crate) async fn recover_parked_inner(
return Err("该会话正在生成中,无需恢复".to_string());
}
let message = PARKED_RECOVERY_MESSAGE.to_string();
// V50 终局态复位:恢复即重新进行,清 incomplete(停靠时已标)。loop 侧收尾单点在 agentic 主循环,
// 此处先复位保证续作期间语义正确;续作后正常收敛/再次停靠会再各自覆写。
if let Err(e) = state.ai_conversations.set_status(conversation_id, "active").await {
tracing::warn!(conv_id = %conversation_id, error = %e, "[ai] 恢复会话复位 status=active 失败");
}
spawn_agentic_session(
app,
state,
@@ -1573,6 +1578,7 @@ mod tests {
origin: origin.map(|s| s.to_string()),
boundary_json: None,
loop_state: None,
status: "active".to_string(),
})
.await
.expect("测试会话插入应成功");
+17 -5
View File
@@ -546,6 +546,8 @@ async fn save_conversation_inner(
.filter(|s| !s.is_empty()),
// #10:懒创建继承 loop running(时序根治——spawn 先置标后落库的 UPDATE 0 行丢失)
loop_state: if conv_is_active { Some("running".to_string()) } else { None },
// V50 终局态:新落库默认 active(进行中);终局由收尾路径 set_status 覆写。
status: "active".to_string(),
};
if let Err(e) = conv_repo.insert(rec).await {
tracing::warn!("落库对话失败 {conv_id}: {e}");
@@ -575,17 +577,27 @@ async fn save_conversation_inner(
.advance_persisted_count(total_len, needs_full_rewrite);
}
// 自动 checkpoint(每 20 轮或总 token > 150k 时创建)
// 自动 checkpoint(每 20 条消息为一个快照点)
{
let total_tokens: i64 = persist_msgs.len() as i64;
if total_tokens > 0 && total_tokens % 20 == 0 {
let msg_count: i64 = persist_msgs.len() as i64;
if msg_count > 0 && msg_count % 20 == 0 {
let snapshot = serde_json::to_string(&persist_msgs).unwrap_or_default();
if !snapshot.is_empty() && snapshot.len() < 1_000_000 {
let ck_id = format!("ck_{}_{}", conv_id.replace('-', ""), total_tokens);
// token_total 语义=真实 token 用量(逐条 prompt+completion 累加),非消息条数。
// 原误用 persist_msgs.len() 当 token,致统计差 3 个量级(p50=120 vs 数万级)。
let token_total: i64 = persist_msgs
.iter()
.map(|m| {
m.prompt_tokens.unwrap_or(0) as i64
+ m.completion_tokens.unwrap_or(0) as i64
})
.sum();
// ck_id 仍以消息条数锚定(同轮/同长度重存幂等跳过),与 token_total 语义解耦。
let ck_id = format!("ck_{}_{}", conv_id.replace('-', ""), msg_count);
let ck_now = now_millis().to_string();
// 参数化绑定(走 AiConversationRepo::insert_checkpoint),替代原 format! 拼 SQL,
// 防 snapshot 含引号/特殊字符致注入或损坏。
if let Err(e) = conv_repo.insert_checkpoint(&ck_id, conv_id, &snapshot, total_tokens, &ck_now).await {
if let Err(e) = conv_repo.insert_checkpoint(&ck_id, conv_id, &snapshot, token_total, &ck_now).await {
tracing::warn!("checkpoint 写入失败 {conv_id}: {e}");
}
}
+4 -1
View File
@@ -260,7 +260,10 @@ async fn fetch_with_obscura(url_raw: &str, max_length: usize) -> anyhow::Result<
let started = Instant::now();
// ── 检测 obscura ──
let obscura_path = match find_obscura() {
// find_obscura 内部 std::thread + recv_timeout 阻塞探活(单候选最多 15s),须在阻塞池跑,
// 否则卡住单线程 runtime;join 失败(理论不发生)按"未装"降级。
let detected = tokio::task::spawn_blocking(find_obscura).await.ok().flatten();
let obscura_path = match detected {
Some(p) => p,
None => {
// 未装:回退静态 + hint。
+8 -10
View File
@@ -442,16 +442,14 @@ async fn write_atomic(output_path: &str, data: &[u8]) -> anyhow::Result<()> {
.map_err(|e| anyhow::anyhow!("创建目录失败: {}", e))?;
}
let tmp = format!("{}.tmp-img", output_path);
tokio::fs::write(&tmp, data).await
.map_err(|e| {
let _ = std::fs::remove_file(&tmp);
anyhow::anyhow!("写入图片失败: {}", e)
})?;
tokio::fs::rename(&tmp, output_path).await
.map_err(|e| {
let _ = std::fs::remove_file(&tmp);
anyhow::anyhow!("原子替换失败: {}", e)
})?;
if let Err(e) = tokio::fs::write(&tmp, data).await {
let _ = tokio::fs::remove_file(&tmp).await;
return Err(anyhow::anyhow!("写入图片失败: {}", e));
}
if let Err(e) = tokio::fs::rename(&tmp, output_path).await {
let _ = tokio::fs::remove_file(&tmp).await;
return Err(anyhow::anyhow!("原子替换失败: {}", e));
}
Ok(())
}
+8 -10
View File
@@ -451,16 +451,14 @@ pub(crate) async fn execute_http_request(args: Value, allowed: &AllowedDirs) ->
}
// 全量写(原子:tmp→rename,避免写一半崩溃留半成品,与 write_file 同思路)
let tmp = format!("{}.tmp-http", path);
tokio::fs::write(&tmp, &full_body).await
.map_err(|e| {
let _ = std::fs::remove_file(&tmp);
anyhow::anyhow!("写入 output_file 失败: {}", e)
})?;
tokio::fs::rename(&tmp, &path).await
.map_err(|e| {
let _ = std::fs::remove_file(&tmp);
anyhow::anyhow!("output_file 原子重命名失败: {}", e)
})?;
if let Err(e) = tokio::fs::write(&tmp, &full_body).await {
let _ = tokio::fs::remove_file(&tmp).await;
return Err(anyhow::anyhow!("写入 output_file 失败: {}", e));
}
if let Err(e) = tokio::fs::rename(&tmp, &path).await {
let _ = tokio::fs::remove_file(&tmp).await;
return Err(anyhow::anyhow!("output_file 原子重命名失败: {}", e));
}
return Ok(json!({
"method": method_str,
"url": final_url,
+5 -3
View File
@@ -96,8 +96,8 @@
@keydown="onQueueEditKeydown"
@blur="saveEditQueued"
/>
<button class="ai-queue-btn" @mousedown.prevent="saveEditQueued" :title="$t('aiChat.queueSave')">✓</button>
<button class="ai-queue-btn" @mousedown.prevent="cancelEditQueued" :title="$t('aiChat.queueCancel')">✕</button>
<button class="ai-queue-btn" @mousedown.prevent="saveEditQueued" :title="$t('aiChat.queueSave')" :aria-label="$t('aiChat.queueSave')">✓</button>
<button class="ai-queue-btn" @mousedown.prevent="cancelEditQueued" :title="$t('aiChat.queueCancel')" :aria-label="$t('aiChat.queueCancel')">✕</button>
</template>
<template v-else>
<span v-if="item.skill" class="ai-queue-skill">/{{ item.skill }}</span>
@@ -110,7 +110,7 @@
<button class="ai-queue-btn" @click="startEditQueued(idx)" :title="$t('aiChat.queueEdit')">{{ $t('aiChat.queueEdit') }}</button>
<!-- 立即发送(打断当前+发本条) -->
<button class="ai-queue-btn" @click="handleSendQueuedNow(idx)" :title="$t('aiChat.queueSendNow')">{{ $t('aiChat.queueSendNow') }}</button>
<button class="ai-queue-x" @click="store.cancelQueued(idx)" :title="$t('common.cancel')">×</button>
<button class="ai-queue-x" @click="store.cancelQueued(idx)" :title="$t('common.cancel')" :aria-label="$t('common.cancel')">×</button>
</template>
</div>
</div>
@@ -1024,6 +1024,8 @@ async function initContextEventListeners(): Promise<void> {
border-radius: calc(var(--df-radius) - 2px);
outline: none;
}
/* 聚焦可见环:边框恒为 accent 聚焦无变化,且 scoped 选择器特异性高于全局 :focus-visible 兜底,须就地补环,仅键盘聚焦显示 */
.ai-queue-edit-input:focus-visible { outline: 2px solid var(--df-accent); outline-offset: 1px; }
/* (.ai-btn-icon:disabled / .ai-btn-spinner / @keyframes ai-btn-spin 已抽 components.css 全局共享) */
+1 -1
View File
@@ -5,7 +5,7 @@
渲染非元素根节点(slot 透传)导致过渡动画警告/卡死(App.vue <transition name="page">)。 -->
<div class="error-boundary-root" :class="{ 'is-error': !!error }">
<template v-if="error">
<div class="error-boundary-icon">⚠</div>
<div class="error-boundary-icon" aria-hidden="true">⚠</div>
<div class="error-boundary-title">{{ $t('error.title') }}</div>
<div class="error-boundary-desc">{{ errorMessage }}</div>
<details class="error-boundary-details">
+48 -5
View File
@@ -24,7 +24,7 @@
<span class="ai-tool-name">{{ toolDisplayName(tc) }}</span>
<span v-if="tc.status === 'running'" class="ai-tool-sub">{{ $t('aiTool.running') }}</span>
<span v-else-if="isFailed" class="ai-tool-sub ai-tool-sub--failed" :title="summaryTitle">{{ $t('aiTool.executionFailed') }}</span>
<span v-else-if="toolResultSummary(tc)" class="ai-tool-sub" :title="summaryTitle">{{ toolResultSummary(tc) }}</span>
<span v-else-if="resultSummary" class="ai-tool-sub" :title="summaryTitle">{{ resultSummary }}</span>
</div>
<span class="ai-tool-status-dot" :class="'ai-tool-status-dot--' + (isFailed ? 'failed' : tc.status)" :title="statusTitle"></span>
<!-- 统一卡形态第4要素:耗时(前端 startedAt/completedAt 差;历史消息无时间戳隐藏不显 0)。
@@ -70,14 +70,14 @@
{{ argsExpanded ? $t('aiTool.argsCollapse') : $t('aiTool.argsMore', { n: argsCount - 1 }) }}
</button>
</div>
<div v-if="tc.reason" class="ai-tool-approval-reason">⚠ {{ tc.reason }}</div>
<div v-if="tc.reason" class="ai-tool-approval-reason"><span aria-hidden="true">⚠</span> {{ tc.reason }}</div>
<!-- BUG-260624-02: 审批到期倒计时(与前端自动拒绝时刻精确对齐,≤60s 红色脉冲);
无活跃计时器(不限时/已到期)回退显挂起时长(>2分钟橙色,>5分钟红色) -->
<div v-if="expireSecs !== null" class="ai-tool-wait-time" :class="'is-' + expireLevel">
{{ $t('aiTool.expireIn', { time: formatWaitTime(expireSecs) }) }}
</div>
<div v-else-if="waitSecs > 0" class="ai-tool-wait-time" :class="'is-' + waitLevel">
⏱ {{ $t('aiTool.waitTime', { time: formatWaitTime(waitSecs) }) }}
<span aria-hidden="true">⏱</span> {{ $t('aiTool.waitTime', { time: formatWaitTime(waitSecs) }) }}
</div>
<!-- AE-2025-03: write_file 审批 diff 预览(红删绿增,行级)。旧文件不存在→tc.diff 为空,回退显上方 args content -->
<div v-if="tc.diff" class="ai-tool-approval-diff">
@@ -137,14 +137,32 @@
<!-- 已拒绝提示 -->
<div v-if="tc.status === 'rejected'" class="ai-tool-rejected">
<span class="ai-tool-rejected-icon">✕</span>
<span class="ai-tool-rejected-icon" aria-hidden="true">✕</span>
{{ tc.result || $t('aiTool.rejectedHint') }}
</div>
<!-- D2a 外置结果(句柄 JSON):显「结果已外置」+ 展开;读回原文后复用 ToolResultBody 渲染 -->
<div v-if="tc.status === 'completed' && offloadHandle" class="ai-tool-offloaded">
<OffloadedResultBar
:handle="offloadHandle"
:expanded="offloadExpanded"
:loading="offloadLoading"
:error="offloadError"
@toggle="toggleOffload"
/>
<ToolResultBody
v-if="offloadExpanded && offloadResolved != null"
:tc="resolvedOffloadTc"
:isContentExpanded="isContentExpanded"
@expand-content="(id) => emit('expand-content', id)"
@install-obscura="() => emit('install-obscura')"
/>
</div>
<!-- 各工具结果展示(read_file/list_directory/search_files/write_file/patch_file/run_command/http_request/fetch_url/通用)
委托 ToolResultBody 子组件,透传 tc + 内容展开态 + expand-content/install-obscura 冒泡 -->
<ToolResultBody
v-if="tc.status === 'completed'"
v-else-if="tc.status === 'completed'"
:tc="tc"
:isContentExpanded="isContentExpanded"
@expand-content="(id) => emit('expand-content', id)"
@@ -179,6 +197,7 @@ import {
argString,
} from '@/composables/ai/useToolCard'
import { parseDiffLines } from '@/composables/ai/useToolCardRender'
import { useOffloadedResult } from '@/composables/ai/useOffloadedResult'
import { useToolCardHeader } from '@/composables/ai/useToolCardHeader'
import { useToolApproval, toolRiskLevel } from '@/composables/ai/useToolApproval'
import { useTimerOwnership } from '@/composables/useTimerOwnership'
@@ -187,6 +206,7 @@ import { formatDate, formatDuration } from '@/utils/time'
import type { AiToolCallInfo } from '@/api/types'
import ConfirmDialog from './ConfirmDialog.vue'
import ToolResultBody from './ToolResultBody.vue'
import OffloadedResultBar from './OffloadedResultBar.vue'
// 审批按钮图标 SVG 常量(消除模板 6 处内联重复;v-html 渲染完整 svg 元素)
const ICON_CHECK = '<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>'
@@ -246,6 +266,27 @@ const {
/** 一次解析结果供失败判定复用(头部 isFailed 用于卡片 border/status-dot 着色) */
const parsed = computed(() => parseResult(props.tc.result))
/**
* D2a 外置句柄:completed 结果若是句柄 JSON(超 8KB 落盘、DB 只留句柄),
* 头部摘要与结果区改走「结果已外置」+ 展开读回,不把句柄当普通结果解析渲染。
*/
const {
handle: offloadHandle,
expanded: offloadExpanded,
loading: offloadLoading,
error: offloadError,
resolved: offloadResolved,
toggle: toggleOffload,
} = useOffloadedResult(() => (props.tc.status === 'completed' ? props.tc.result : null))
/** 读回后把原文塞回 tc.result,交 ToolResultBody 按工具名走既有渲染分支(零重复渲染逻辑) */
const resolvedOffloadTc = computed(() => ({ ...props.tc, result: offloadResolved.value ?? props.tc.result }))
/** 头部副标题:外置句柄显「结果已外置」,其余走既有摘要函数 */
const resultSummary = computed(() =>
offloadHandle.value ? t('aiTool.resultOffloaded') : toolResultSummary(props.tc),
)
/**
* 是否工具失败(completed 语义)。头部多处复用(.ai-tool-card--failed / 状态点 / sub 文案)。
* 逻辑与模块级 isToolFailure 同源;结果展示区的 isFailed 已迁入 ToolResultBody(各自 computed)。
@@ -253,6 +294,8 @@ const parsed = computed(() => parseResult(props.tc.result))
const isFailed = computed(() => {
const tc = props.tc
if (tc.status !== 'completed') return false
// 外置句柄是「结果留了指针」非执行失败,不参与失败判定
if (offloadHandle.value) return false
const r = parsed.value
if (tc.name === 'run_command') {
if (r && typeof r.exit_code === 'number') return r.exit_code !== 0
+4 -4
View File
@@ -134,7 +134,7 @@
<span class="ai-tool-file-meta">{{ formatSizeDiff(parsed?.size_diff) }} {{ $t('aiTool.bytesUnit') }}</span>
<span v-if="parsed?.matches_found && parsed.matches_found > 1" class="ai-tool-file-meta">{{ $t('aiTool.patchedMatches', { n: parsed.matches_found }) }}</span>
</div>
<div v-if="parsed?.warning" class="ai-tool-approval-reason">⚠ {{ parsed.warning }}</div>
<div v-if="parsed?.warning" class="ai-tool-approval-reason"><span aria-hidden="true">⚠</span> {{ parsed.warning }}</div>
<div v-if="resultDiffLines.length" class="ai-tool-approval-diff">
<pre class="ai-tool-diff-pre"><code><span v-for="(ln, idx) in resultDiffLines" :key="idx" class="ai-tool-diff-line" :class="'ai-tool-diff-line--' + ln.kind">{{ ln.text }}{{ '\n' }}</span></code></pre>
<!-- R12a:长 diff 展开入口(默认预览 120 行,点开全量) -->
@@ -177,7 +177,7 @@
>{{ httpBodyExpanded ? $t('aiTool.collapse') : $t('aiTool.expand') }}</button>
</div>
<div v-if="parsed?.truncated && !httpBodyExpanded" class="ai-tool-http-trunc-warn">
⚠ {{ $t('aiTool.httpTruncated', { bytes: formatBytes(parsed?.body_bytes) }) }}
<span aria-hidden="true">⚠</span> {{ $t('aiTool.httpTruncated', { bytes: formatBytes(parsed?.body_bytes) }) }}
</div>
<pre v-if="httpBodyExpanded && parsed?.body" class="ai-tool-cmd-output"><code>{{ parsed.body }}</code></pre>
</div>
@@ -207,7 +207,7 @@
<div v-if="parsed?.title" class="ai-tool-fetch-title">{{ parsed?.title }}</div>
<!-- obscura 未装引导(render=true 但回退静态):提示 + "AI 帮装"按钮 -->
<div v-if="obscuraMissing" class="ai-tool-obscura-hint">
<div class="ai-tool-obscura-hint-text">⚠ {{ parsed?.hint || $t('aiTool.fetchUrlFallbackHint') }}</div>
<div class="ai-tool-obscura-hint-text"><span aria-hidden="true">⚠</span> {{ parsed?.hint || $t('aiTool.fetchUrlFallbackHint') }}</div>
<button class="ai-tool-btn ai-tool-btn--install" @click="onInstallObscura">
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
{{ $t('aiTool.installObscura') }}
@@ -222,7 +222,7 @@
class="ai-tool-result"
:class="{ 'ai-tool-result--failed': isFailed }"
>
<div v-if="isFailed" class="ai-tool-failed-banner">⚠ {{ $t('aiTool.executionFailedHint') }}</div>
<div v-if="isFailed" class="ai-tool-failed-banner"><span aria-hidden="true">⚠</span> {{ $t('aiTool.executionFailedHint') }}</div>
<code>{{ formatToolResult(tc) }}</code>
</div>
</template>
+1
View File
@@ -29,6 +29,7 @@
<button
class="approval-overlay-close"
:title="$t('common.close')"
:aria-label="$t('common.close')"
@click.stop="onDismiss"
>
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+60
View File
@@ -70,33 +70,41 @@
idle(或 conv_state 未收到回退旧 streaming=false)显发送按钮。双轨过渡不强制全替旧 bool。 -->
<button
v-if="stopBtnState === 'stopping'"
type="button"
class="ai-send-btn ai-send-btn--stop ai-send-btn--stopping"
:title="$t('aiChat.stopping')"
:aria-label="$t('aiChat.stopping')"
disabled
>
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
</button>
<button
v-else-if="stopBtnState === 'retry'"
type="button"
class="ai-send-btn ai-send-btn--stop ai-send-btn--retry"
:title="$t('aiChat.stopFailedRetry')"
:aria-label="$t('aiChat.stopFailedRetry')"
@click="store.stopChat"
>
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
</button>
<button
v-else-if="stopBtnState === 'stop'"
type="button"
class="ai-send-btn ai-send-btn--stop"
:title="$t('aiChat.stopGenerating')"
:aria-label="$t('aiChat.stopGenerating')"
@click="store.stopChat"
>
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
</button>
<button
v-else
type="button"
class="ai-send-btn"
:class="{ 'ai-send-btn--active': canSend }"
:disabled="!canSend"
:aria-label="$t('aiChat.queueSendNow')"
@click="handleSend"
>
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
@@ -128,7 +136,9 @@ import { ref, computed, nextTick, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
import { useProjectStore } from '../../stores/project'
import { useAppSettingsStore } from '../../stores/appSettings'
import { getConvState } from '../../composables/ai/useAiEvents'
import { continueList, exitListOnBackspace } from '../../composables/ai/useListContinuation'
import { textIdle } from '../../composables/ai/useAiPendingState'
import { getConvDraft, setConvDraft, classifySendError, type SendErrorKind } from '../../composables/ai/aiShared'
import { extractImageUrlParts } from '../../composables/ai/utils'
@@ -158,6 +168,7 @@ const emit = defineEmits<{
const store = useAiStore()
const projectsStore = useProjectStore()
const appSettings = useAppSettingsStore()
const { t } = useI18n()
const inputText = ref('')
@@ -803,6 +814,33 @@ function selectMention(item: MentionItem) {
})
}
// ── 列表续写开关(设计:docs/02-架构设计/专项设计/输入框列表预览与自动编号-设计-2026-10-10.md,P1=V0 零视觉) ──
// appSettings 键 `df-ai-list-input`,默认关:关=完全回退现状 textarea 行为,零残留。
// 逻辑层为纯函数(useListContinuation),此处只做接线(键分支 + 应用编辑 + @span 平移)。
const listInputEnabled = computed(() => appSettings.get<boolean>('df-ai-list-input', false))
/** 当前光标位(textarea 未挂载时退化为文末) */
function caretOffset(): number {
return inputEl.value?.selectionStart ?? inputText.value.length
}
/** 应用列表续写/退出的编辑结果:改文本 → 显式平移 @span(程序赋值不派发 input)→ 回光标 + 重算高度 */
function applyListEdit(r: { text: string; caret: number }): void {
const old = inputText.value
inputText.value = r.text
// 程序赋值不触发 input 事件(见 onInputText 说明),mention span 须手动平移,同 shiftMentionSpans 调用范式
shiftMentionSpans(old, r.text)
nextTick(() => {
const el = inputEl.value
if (el) {
el.focus()
el.setSelectionRange(r.caret, r.caret)
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, INPUT_MAX_HEIGHT) + 'px'
}
})
}
function handleKeydown(e: KeyboardEvent) {
// P1-1 输入法组词守卫:拼音/双拼组词未上屏时 Enter=选词、Esc=取消组词,均非用户发送/取消意图,
// 直接放行给 IME。isComposing 覆盖标准浏览器;keyCode===229 兜部分环境 keydown 阶段不上报 isComposing。
@@ -895,6 +933,28 @@ function handleKeydown(e: KeyboardEvent) {
return
}
}
// 列表续写(开关 df-ai-list-input,默认关):空项退格退出 / Shift+Enter 续写补号。
// 插在技能·@ 浮层导航之后(浮层开时不拦,用户意图是选实体)、Enter 发送之前;
// 只绑 Shift+Enter(换行链),Enter 发送语义不变。关闭时整段短路 → 零行为变更。
if (listInputEnabled.value && !skillOpen.value && !mentionOpen.value) {
const withMod = e.ctrlKey || e.metaKey || e.altKey
if (e.key === 'Backspace' && !e.shiftKey && !withMod) {
const r = exitListOnBackspace(inputText.value, caretOffset())
if (r) {
e.preventDefault()
applyListEdit(r)
return
}
}
if (e.key === 'Enter' && e.shiftKey && !withMod) {
const r = continueList(inputText.value, caretOffset())
if (r) {
e.preventDefault()
applyListEdit(r)
return
}
}
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
+15 -4
View File
@@ -47,7 +47,7 @@
}"
tabindex="0"
@click="onSelectSearchResult(conv.id)"
@keydown="onItemKeydown($event, conv.id)"
@keydown="onItemKeydown($event, conv.id, true)"
>
<div class="ai-conv-item-main">
<!-- UX走查 P3-1: 搜索态此前无重命名入口(无 startRename 分支),与分组视图降级不一致;
@@ -140,7 +140,7 @@
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId, 'ai-conv-item--switching': conv.id === pendingSwitchId, 'ai-conv-item--pinned': conv.pinned }"
tabindex="0"
@click="beginSwitch(conv.id)"
@keydown="onItemKeydown($event, conv.id)"
@keydown="onItemKeydown($event, conv.id, true)"
>
<div class="ai-conv-item-main">
<input
@@ -220,6 +220,7 @@
:title="t('aiChat.recoverableHint')"
@click="onRecover(conv.id)"
@keydown.enter.prevent="onRecover(conv.id)"
@keydown.space.prevent="onRecover(conv.id)"
>
<div class="ai-conv-item-main">
<span class="ai-conv-item-title">
@@ -429,13 +430,19 @@ function onSearchKeydown(e: KeyboardEvent) {
}
// UX走查 P2-6: 会话项键盘可达(tabindex=0)。Enter/Space 等价点击选中;仅焦点落在本项 div
// 自身时处理——焦点在内部按钮/重命名输入框时 target≠currentTarget,避免与控件自身语义双触发
function onItemKeydown(e: KeyboardEvent, convId: string) {
// 自身时处理——焦点在内部按钮/重命名输入框时 target≠currentTarget,避免与控件自身语义双触发。
// renamable=true 的会话项(搜索结果/时间分组,即双击标题可重命名的项)额外支持 F2 键盘重命名,
// 作为 .ai-conv-item-title 双击入口的键盘等价(文件管理器惯例;归档项不支持重命名故不传该标志)。
function onItemKeydown(e: KeyboardEvent, convId: string, renamable = false) {
if (e.isComposing || e.keyCode === 229) return
if (e.target !== e.currentTarget) return
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
beginSwitch(convId)
} else if (renamable && e.key === 'F2') {
e.preventDefault()
const conv = store.state.conversations.find(c => c.id === convId)
if (conv) startRename(conv)
}
}
@@ -791,6 +798,8 @@ body.ai-sidebar-resizing * {
font-size: 12px;
color: var(--df-text);
}
/* 聚焦可见环:无边框输入聚焦无变化,且 scoped 选择器特异性高于全局 :focus-visible 兜底,须就地补环,仅键盘聚焦显示 */
.ai-conv-search-input:focus-visible { outline: 2px solid var(--df-accent); outline-offset: 1px; }
.ai-conv-search-input::placeholder {
color: var(--df-text-dim);
}
@@ -916,6 +925,8 @@ body.ai-sidebar-resizing * {
outline: none;
box-sizing: border-box;
}
/* 聚焦可见环:边框恒为 accent 聚焦无变化,且 scoped 选择器特异性高于全局 :focus-visible 兜底,须就地补环,仅键盘聚焦显示 */
.ai-conv-item-edit:focus-visible { outline: 2px solid var(--df-accent); outline-offset: 1px; }
.ai-conv-item-time {
font-size: 9px;
color: var(--df-text-dim);
+1
View File
@@ -14,6 +14,7 @@
<button
v-for="opt in helpActive.options"
:key="opt"
type="button"
class="ai-help-required-btn"
@click="handleSelectOption(opt)"
>{{ opt }}</button>
+14 -7
View File
@@ -8,6 +8,7 @@
//! (含流式分块、工具卡片、token 用量、错误重试、压缩摘要置顶等聚合逻辑),
//! 本组件专注 user,不做 AI 消息渲染,避免两套渲染入口并存导致的死代码与职责混乱。
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { AiMessage, ContentPart, MentionSpan } from '../../api/types'
import { useAiStore } from '../../stores/ai'
@@ -30,7 +31,7 @@ function leadingSkill(msg: AiMessage): { name: string; rest: string } | null {
return { name: m[1], rest: m[2] ?? '' }
}
defineProps<{
const props = defineProps<{
msg: AiMessage
/** 是否显示编辑按钮(父合成:末条 user + 非生成中 + 非编辑中) */
canEdit?: boolean
@@ -92,6 +93,12 @@ function imageSrc(img: ImageContentPart): string {
function imgAlt(img: ImageContentPart): string {
return img.alt || t('aiChat.imagePreviewAlt')
}
// 模板中同一渲染内多次引用的纯派生值 → computed 缓存一次求值
// (纯函数仅依赖 props.msg / store.state.skills 等响应式源,缓存语义与原先逐次调用等价)
const skill = computed(() => leadingSkill(props.msg))
const segments = computed(() => segmentUserContent(props.msg))
const imageParts = computed(() => msgImageParts(props.msg))
</script>
<template>
@@ -107,19 +114,19 @@ function imgAlt(img: ImageContentPart): string {
</div>
<div class="ai-msg-bubble ai-msg-bubble--user">
<!-- 问题3 技能调用留痕:前导 /技能名 命中已安装技能 → 紫色 chip 标识,替代原文前缀显示 -->
<template v-if="leadingSkill(msg)">
<span class="ai-msg-chip ai-msg-chip--skill" :title="t('aiChat.skillBadgeTip', { name: leadingSkill(msg)!.name })">⚡ /{{ leadingSkill(msg)!.name }}</span>
<template v-if="leadingSkill(msg)!.rest">{{ ' ' + leadingSkill(msg)!.rest }}</template>
<template v-if="skill">
<span class="ai-msg-chip ai-msg-chip--skill" :title="t('aiChat.skillBadgeTip', { name: skill!.name })">⚡ /{{ skill!.name }}</span>
<template v-if="skill!.rest">{{ ' ' + skill!.rest }}</template>
</template>
<template v-else>
<template v-for="(seg, idx) in segmentUserContent(msg)" :key="msg.id + '-seg-' + idx">
<template v-for="(seg, idx) in segments" :key="msg.id + '-seg-' + idx">
<span v-if="seg.type === 'text'">{{ seg.text }}</span>
<span v-else class="ai-msg-chip" :class="'ai-msg-chip--' + seg.span.kind">{{ seg.span.label }}</span>
</template>
</template>
<template v-if="msgImageParts(msg).length">
<template v-if="imageParts.length">
<div class="ai-msg-images">
<img v-for="(img, idx) in msgImageParts(msg)" :key="msg.id + '-img-' + idx" :src="imageSrc(img)" :alt="imgAlt(img)" class="ai-msg-image" loading="lazy" />
<img v-for="(img, idx) in imageParts" :key="msg.id + '-img-' + idx" :src="imageSrc(img)" :alt="imgAlt(img)" class="ai-msg-image" loading="lazy" />
</div>
</template>
<!-- UX-09:末条 user 消息编辑按钮(hover 显示),点击经 start-edit 链回填输入框编辑重发 -->
+162 -75
View File
@@ -22,7 +22,7 @@ import { useMessageScroll } from '../../composables/ai/useMessageScroll'
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 { getConvAgentFinished, toolEpoch, type AgentFinishedCard } from '../../composables/ai/aiShared'
import { aiApi } from '../../api'
import ToolCardList from '../ToolCardList.vue'
import MessageItem from './MessageItem.vue'
@@ -420,22 +420,16 @@ function onPendingOutOfView(): void {
}
/**
* 移除 deep:true,改在 watch source 中计算指纹(消息数 + toolCalls 的 id/status),
* 避免 deep 每帧全量深遍历长会话数组引起掉帧。流式 delta 只改 content 不影响指纹,
* 不触发无意义的收起重算。length 变化(新消息/截断)和 toolCall 状态翻转(新卡/完成/拒绝)
* 均能正确触发——与 body 的快照收缩逻辑等价,无需 deep 兜底。
* 工具卡自动收起:工具状态翻转(新卡/完成/待审批/拒绝)时收起旧的已完成/已拒绝卡片,
* 保留 running/pending/write_file 活跃卡片。
*
* P0 渲染层:源从 `map(toolCalls→{id,s}) + JSON.stringify` 的 O(n) 指纹改为 toolEpoch 版本计数(O(1))。
* 版本号在工具态突变点(aiShared.bumpToolEpoch)自增,免每次工具事件全量遍历长会话数组。
* messages.length 变化(新消息/截断/切会话)同样触发(messages 长度与 toolEpoch 任一变即回调)。
*/
let lastMsgSnapshot = ''
watch(
() => JSON.stringify({
n: store.state.messages.length,
tc: store.state.messages.map(m => (m.toolCalls || []).map(t => ({ id: t.id, s: t.status }))),
}),
(snap) => {
if (snap === lastMsgSnapshot) return
const isFirst = lastMsgSnapshot === ''
lastMsgSnapshot = snap
if (isFirst) return
[() => store.state.messages.length, toolEpoch],
() => {
// R10 同门:非跟随态(上滚阅读中)不强制收起正浏览的工具卡
if (isFollowingBottom.value) collapseAllToolLists(buildActiveToolIds(store.state.messages))
},
@@ -683,7 +677,12 @@ const DECO_CACHE_LIMIT = 800
function decorateCodeHtml(raw: string): string {
if (!raw || !raw.includes('<pre><code')) return raw
const hit = _decoCache.get(raw)
if (hit !== undefined) return hit
if (hit !== undefined) {
// LRU:命中即置为最近使用(delete+set 移到 Map 末尾),淘汰时优先保留热项
_decoCache.delete(raw)
_decoCache.set(raw, hit)
return hit
}
// marked 的 code renderer 统一输出 <pre><code class="hljs ...">…</code></pre>
//(含缩进码块),代码内容已 HTML 转义,正则非贪婪到首个闭合标签不会越界。
// R4 语言角标:code class 形如 hljs language-ts,捕获 language-xxx 输出到 .md-code-block
@@ -696,7 +695,11 @@ function decorateCodeHtml(raw: string): string {
return `<div class="md-code-block"${langAttr}><button type="button" class="md-code-copy">复制</button><pre><code${attrs}>${body}</code></pre></div>`
},
)
if (_decoCache.size > DECO_CACHE_LIMIT) _decoCache.clear()
// 有界 LRU 淘汰:超限删最久未用项(Map 首项)后插入,替代原整体 clear
if (_decoCache.size >= DECO_CACHE_LIMIT) {
const oldest = _decoCache.keys().next().value
if (oldest !== undefined) _decoCache.delete(oldest)
}
_decoCache.set(raw, out)
return out
}
@@ -1029,66 +1032,84 @@ type RenderItem =
* 无过程消息的轮次(单轮问答)零改动原样渲染。
*
* UX 密度改造:相邻消息间隔 ≥30 分钟或跨天时插居中时间分隔线(timesep 项),
* per-block 相对时间已移除。轨距追踪 lastMsgTs 只在消息桶边界推进,
* per-block 相对时间已移除。轨距追踪 lastTs(renderItems 局部)只在消息桶边界推进,
* 轨迹卡与其内部轮次之间不插线(同轮间隔无叙事价值)。轨迹桶自身也不再发
* timesep 行:卡头「第 N 轮」+ 绝对起始时刻已承载时间行职责(两行合一)。
*/
const TIME_SEP_GAP_MS = 30 * 60 * 1000
let lastMsgTs = 0
/** 与上一条已渲染消息是否需要时间分隔线(间隔 ≥30 分钟或跨天;首条不插) */
function needsTimeSep(ts: number): boolean {
if (!lastMsgTs || !ts) return false
if (Math.abs(ts - lastMsgTs) >= TIME_SEP_GAP_MS) return true
const a = new Date(lastMsgTs)
/** 与上一时刻是否需要时间分隔线(间隔 ≥30 分钟或跨天;首条不插)。
* prevTs 由调用方传入(桶级缓存不持跨桶可变态,改参数化保纯)。 */
function needsTimeSep(ts: number, prevTs: number): boolean {
if (!prevTs || !ts) return false
if (Math.abs(ts - prevTs) >= TIME_SEP_GAP_MS) return true
const a = new Date(prevTs)
const b = new Date(ts)
return a.getFullYear() !== b.getFullYear()
|| a.getMonth() !== b.getMonth()
|| a.getDate() !== b.getDate()
}
/** 需要时插 timesep 项并推进 lastMsgTs(同一出口防漏插/重插) */
function pushTimeSepIfNeeded(items: RenderItem[], ts: number): void {
if (needsTimeSep(ts)) {
items.push({ kind: 'timesep', key: 'tsep-' + ts + '-' + items.length, ts })
// ── 桶级 memo(P0 渲染层):同签名桶复用同一批 item 对象 ──
// 两重收益:①工具事件时 O(n)→O(变更桶)(其余桶命中缓存不重算);②item 对象引用稳定
// → 模板 v-memo 的 [item] dep 生效前提(桶未变 → item 同引用 → v-memo 跳过该 item 的 patch)。
// 签名覆盖 flushBucket 产出真正消费的全部输入(消息身份/文本/token + 工具态 + 轮号 + 生成态 +
// 前桶时刻 + 轨迹展开态);漏字段 → 命中陈旧 item → 局部不刷新,故从宽纳入。LRU 有界(沿用 _decoCache 范式)。
const _bucketCache = new Map<string, { items: RenderItem[]; endTs: number }>()
const BUCKET_CACHE_LIMIT = 128
function bucketSig(msgs: AiMessage[], round: number, executing: boolean, prevTs: number, traceExpanded: boolean): string {
const parts: string[] = [round + '#' + (executing ? 1 : 0) + '#' + prevTs + '#' + (traceExpanded ? 1 : 0)]
for (const m of msgs) {
let s = m.id + ':' + m.role + ':' + (m.status ?? '') + ':' + (m.isError ? 1 : 0) + ':' + (m.isRetry ? 1 : 0)
+ ':' + (m.timestamp ?? 0) + ':' + (m.model ?? '')
+ ':' + (m.content ?? '') + ':' + (m.reasoning_content ?? '')
+ ':' + (m.tokenUsage?.prompt ?? m.prompt_tokens ?? '')
+ ':' + (m.tokenUsage?.completion ?? m.completion_tokens ?? '')
+ ':' + (m.tokenUsage?.cache_hit ?? m.prompt_cache_hit_tokens ?? '')
+ ':' + (m.tokenUsage?.cache_miss ?? m.prompt_cache_miss_tokens ?? '')
+ ':' + (m.tokenUsage?.reasoning ?? m.reasoning_tokens ?? '')
+ ':' + (m.tokenUsage?.is_estimated ? 1 : 0)
+ ':' + (m.parts?.length ?? 0) + ':' + (m.mentionSpans?.length ?? 0)
for (const t of m.toolCalls ?? []) {
s += ',' + t.id + ':' + t.status + ':' + (t.completedAt ?? 0)
}
lastMsgTs = ts
parts.push(s)
}
return parts.join('|')
}
const renderItems = computed<RenderItem[]>(() => {
/** 无缓存构建一个桶的 item(重活:collectTurnStats/sumTurnTokens/isToolResultJson 均在此)。
* 返回 endTs(末条时刻)= 该桶结束后应作为下一桶 prevTs 的值。 */
function genBucketItems(
msgs: AiMessage[],
round: number,
executing: boolean,
prevTs: number,
traceKey: string,
traceExpanded: boolean,
): { items: RenderItem[]; endTs: number } {
const items: RenderItem[] = []
let bucket: AiMessage[] = []
lastMsgTs = 0
// 轨迹序号计数:轨迹卡头「第 N 轮」与执行中步骤行同一来源(computed 重算从 0 起,
// 计数即轨迹卡出现顺序,与轮次导航条序号一致)
let traceCount = 0
const flushBucket = () => {
const msgs = bucket.filter(shouldRenderMsg)
bucket = []
if (msgs.length === 0) return
const cur = msgs[msgs.length - 1]
const pushSep = (ts: number) => {
if (needsTimeSep(ts, prevTs)) items.push({ kind: 'timesep', key: 'tsep-' + ts + '-' + items.length, ts })
}
// BUG-261001:执行中分支仅作用于「正在生成」的轮次桶(含 store 末条消息的桶)。
// 原实现只判 isViewingGenerating——只要会话在生成,全部轮次桶(含早已完成的历史轮次)
// 都被降级为不可交互的 step 摘要行,历史轨迹卡展开/折叠点击全部失效
// (用户表象:新一轮执行时,上一轮轨迹也"不可点击")。历史桶保持完成态轨迹卡可点。
const lastStoreId = store.state.messages[store.state.messages.length - 1]?.id
const cur = msgs[msgs.length - 1]
if (isViewingGenerating.value && cur.id === lastStoreId) {
if (executing) {
const prev = msgs.slice(0, -1)
if (prev.length > 0) {
pushTimeSepIfNeeded(items, msgs[0].timestamp)
traceCount++
pushSep(msgs[0].timestamp)
// 闪烁修复(2026-10-09):step 项 key 与下方完成态 trace 项对齐(同为 trace-{首条id}),
// 使「执行中 step → 完成态 trace」切换时 Vue 复用同一 DOM 节点(key 稳定),不销毁重建。
// 原 step-{id} 与 trace-{id} key 不同 → 完成瞬间整块轨迹 DOM 重建 → 闪烁。
items.push({ kind: 'step', key: 'trace-' + prev[0].id, round: traceCount, stats: collectTurnStats(msgs) })
items.push({ kind: 'step', key: 'trace-' + prev[0].id, round, stats: collectTurnStats(msgs) })
} else {
pushTimeSepIfNeeded(items, cur.timestamp)
pushSep(cur.timestamp)
}
items.push({ kind: 'msg', key: 'm-' + cur.id, msg: cur, final: true })
lastMsgTs = cur.timestamp
return
return { items, endTs: cur.timestamp }
}
// 最终答复:末条有正文且非纯 JSON 工具结果的消息;全无正文时保底末条(工具轮可见)
let fi = -1
@@ -1103,8 +1124,6 @@ const renderItems = computed<RenderItem[]>(() => {
const traceMsgs = msgs.filter(m => m !== finalMsg)
// 完成:轨迹折叠为一张卡,轮次内容在卡内 rail 面板展开(不再摊平成独立消息项)
if (traceMsgs.length > 0) {
const key = 'trace-' + traceMsgs[0].id
const expanded = expandedTraceIds.value.has(key)
// 终点计入末轮时长:过程消息里最晚的工具完成时刻与末轮开始时刻取较大者
let lastCompleted = 0
for (const m of traceMsgs) {
@@ -1114,32 +1133,72 @@ const renderItems = computed<RenderItem[]>(() => {
}
// 轨迹桶不再发 timesep 行:卡头「第 N 轮」+ 绝对起始时刻已承载时间行职责;
// 仅推进轨距,保持出口语义(后续消息的间隔判定从本桶起点起算)
lastMsgTs = traceMsgs[0].timestamp
traceCount++
items.push({
kind: 'trace', key,
round: traceCount,
kind: 'trace', key: traceKey,
round,
stats: collectTurnStats(traceMsgs),
startTs: traceMsgs[0].timestamp,
endTs: Math.max(lastCompleted, finalMsg.timestamp),
tokens: sumTurnTokens(traceMsgs),
traceMsgs, expanded,
traceMsgs, expanded: traceExpanded,
})
} else {
pushTimeSepIfNeeded(items, finalMsg.timestamp)
}
// 正文不折叠(2026-10-10 根因修复):此前整轮(含模型正文)折进轨迹卡默认收起 → 流式期间
// 能看到正文、轮末正文随卡收起,用户表象为「气泡被清空/进入等待」,历次在 flush/清空/骨架层
// 修补均无效。现把过程消息里**有正文**的各成一个常显气泡(剥掉 toolCalls——工具卡仍归轨迹卡,
// 不重复渲染),轨迹卡只折叠工具步骤与思考。
for (const m of traceMsgs) {
const c = m.content
if (m.role === 'assistant' && c && c.trim() && !isToolResultJson(c)) {
items.push({ kind: 'msg', key: 'm-' + m.id, msg: { ...m, toolCalls: undefined } })
}
pushSep(finalMsg.timestamp)
}
// 结果/过程分离(2026-10-10 用户口径:过程太多压住结果、密度过大):
// 气泡**只放最终结果**(finalMsg 正文);本桶其余轮的叙述与工具卡全收进「执行轨迹卡」(默认一行
// 收起,卡内展开可看逐轮全文与工具卡)——既不再"流式后正文消失",又不再"一条消息回 N 个气泡"。
items.push({ kind: 'msg', key: 'm-' + finalMsg.id, msg: finalMsg, final: true })
lastMsgTs = finalMsg.timestamp
return { items, endTs: finalMsg.timestamp }
}
/** 桶 memo 取用:签名命中回同一对象数组(引用稳定);未命中构建 + 有界 LRU 写入(命中置末)。 */
function buildBucket(msgs: AiMessage[], round: number, executing: boolean, prevTs: number): { items: RenderItem[]; endTs: number } {
const traceKey = 'trace-' + msgs[0].id
const traceExpanded = expandedTraceIds.value.has(traceKey)
const sig = bucketSig(msgs, round, executing, prevTs, traceExpanded)
const hit = _bucketCache.get(sig)
if (hit) {
_bucketCache.delete(sig)
_bucketCache.set(sig, hit)
return hit
}
const built = genBucketItems(msgs, round, executing, prevTs, traceKey, traceExpanded)
if (_bucketCache.size >= BUCKET_CACHE_LIMIT) {
const oldest = _bucketCache.keys().next().value
if (oldest !== undefined) _bucketCache.delete(oldest)
}
_bucketCache.set(sig, built)
return built
}
const renderItems = computed<RenderItem[]>(() => {
const items: RenderItem[] = []
let bucket: AiMessage[] = []
let lastTs = 0
// 轨迹序号计数:轨迹卡头「第 N 轮」与执行中步骤行同一来源(computed 重算从 0 起,
// 计数即轨迹卡出现顺序,与轮次导航条序号一致)
let traceCount = 0
const pushSep = (ts: number) => {
if (needsTimeSep(ts, lastTs)) {
items.push({ kind: 'timesep', key: 'tsep-' + ts + '-' + items.length, ts })
}
lastTs = ts
}
const flushBucket = () => {
const msgs = bucket.filter(shouldRenderMsg)
bucket = []
if (msgs.length === 0) return
const lastStoreId = store.state.messages[store.state.messages.length - 1]?.id
const executing = isViewingGenerating.value && msgs[msgs.length - 1].id === lastStoreId
// 轨迹/步骤项产生时轮号自增(仅 msgs.length>1 会产出 step/trace;轮号计入桶签名,
// 前序桶变化致轮号位移时该桶正确重算)
const round = msgs.length > 1 ? ++traceCount : traceCount
const built = buildBucket(msgs, round, executing, lastTs)
for (const it of built.items) items.push(it)
lastTs = built.endTs
}
for (const seg of messageSegments.value) {
@@ -1149,7 +1208,7 @@ const renderItems = computed<RenderItem[]>(() => {
items.push({ kind: 'sep', key: seg.key, seg })
if (expandedSegmentIds.value.has(seg.key)) {
for (const m of seg.msgs) {
pushTimeSepIfNeeded(items, m.timestamp)
pushSep(m.timestamp)
items.push({ kind: 'msg', key: 'e-' + seg.key + '-' + m.id, msg: m })
}
}
@@ -1161,7 +1220,7 @@ const renderItems = computed<RenderItem[]>(() => {
bucket.push(m)
} else {
flushBucket()
pushTimeSepIfNeeded(items, m.timestamp)
pushSep(m.timestamp)
items.push({ kind: 'msg', key: 'm-' + m.id, msg: m })
}
}
@@ -1186,6 +1245,23 @@ const renderItems = computed<RenderItem[]>(() => {
return items
})
// ── v-memo 依赖数组(P0 渲染层,内联在模板 v-for 的 v-memo 处) ──
// renderItems(computed)已读源经 [item] 自动失效(桶 memo 保证桶未变时 item 引用稳定 → 跳过 patch);
// v-memo 额外补「renderItems 未读、但渲染该 item 时读了」的兄弟响应式源 —— 漏一个 → 该项局部不刷新:
// - expandedThinkIds / expandedJsonIds / expandedLongIds:思考 pill / JSON 块 / 长文折叠展开态
// (整体 Set 引用纳入;useToggleSet 每次 toggle 换新 Set → 引用变 → 该项重渲);
// - tokenPopoverMsgId / editingMsgId / agentPeekLoadingId:token 面板 / user 编辑按钮 / 子代理 peek 转圈;
// - store.state.streaming / isViewingGenerating:光标、user 编辑按钮 can-edit、流式块闸直接读的源
// (非 renderItems 已读,须显式纳入,否则流式启停该 item 不刷新);
// - _mdRenderKey:marked 就绪后 forceUpdate 递增、经 renderMdDecorated 读;taskTitleCache:任务 chip 标题懒加载。
// - 流式文本源(定向 dep):流式 delta 只改 currentText→streamingBlocks→decoratedStreamingBlocks,
// 不改任何消息 content、不改桶签名 → item 引用稳定。若此处不纳入流式源,流式 item 的 v-memo 恒命中,
// 子树跳过 patch,气泡冻结在首个空帧(要等工具事件/收尾才整段出现)。
// 只对「正在渲染流式块的 item」(isLastAi + streaming,即模板内 v-if 渲染 decoratedStreamingBlocks 的那一项)
// 纳入 decoratedStreamingBlocks(每 rAF 换新数组引用 → dep 失效 → 该项重渲),其余 item 取常量 '' 占位,
// 使 v-memo 仅在流式项失效,空闲/历史项不重渲的 P0 收益不受影响。
// 注:vue-tsc 不解析 v-memo 表达式,抽独立函数会触发 noUnusedLocals 误报,故直接内联数组。
// ── S1 子代理完成卡(AiAgentFinished) ──
// 状态→色调三态:completed 中性(常态结果不抢视线)/ truncated 警示(结果不完整)/
// failed 错误(需关注);未知的 status 字面量兜底中性,不误报成功也不误报失败。
@@ -1502,7 +1578,7 @@ defineExpose({
每项外层包 .ai-msg-slot(虚拟滚动已移除,现恒渲染;
slot 仅作 flex 子占位 + 消息分组的语义容器,gap 由 .ai-messages 承担)。
UX 密度改造:timesep 项=相邻消息间隔 ≥30 分钟或跨天的居中时间分隔线。 -->
<template v-for="item in renderItems" :key="item.key">
<template v-for="item in renderItems" :key="item.key" v-memo="[item, store.state.streaming, isViewingGenerating, expandedThinkIds, expandedJsonIds, expandedLongIds, tokenPopoverMsgId, editingMsgId, agentPeekLoadingId, _mdRenderKey, taskTitleCache, item.kind === 'msg' && isLastAi(item.msg) && store.state.streaming ? decoratedStreamingBlocks : '']">
<!-- 执行轨迹卡(完成轮次:过程内容折叠为一张,默认一行聚合,点开在卡内 rail 面板回看
每轮思考 pill + 工具卡;头部聚合行含调用/耗时/全程 token 汇总) -->
<div v-if="item.kind === 'trace'" class="ai-msg-slot ai-msg-slot--proc">
@@ -1526,8 +1602,12 @@ defineExpose({
v-for="ref in traceTaskRefs(item.traceMsgs)"
:key="ref.taskId"
class="ai-trace-task-chip"
role="button"
tabindex="0"
:title="$t('ai.trace.taskRef', { name: taskTitleOf(ref.taskId) })"
@click.stop="openTask(ref.taskId)"
@keydown.enter.stop.prevent="openTask(ref.taskId)"
@keydown.space.stop.prevent="openTask(ref.taskId)"
>🔗 {{ taskTitleOf(ref.taskId) }}</span>
<!-- 绝对起始时刻(mono 小字):轨迹桶已不发独立时间分隔线,时刻职责由卡头承载 -->
<span class="ai-trace-tokens">{{ formatAbsolute(item.startTs) }}</span>
@@ -1550,11 +1630,18 @@ defineExpose({
<span v-if="roundTokenText(m)" class="ai-think-tokens">{{ roundTokenText(m) }}</span>
</button>
<div
v-if="expandedThinkIds.has(m.id) && m.content?.trim()"
v-if="expandedThinkIds.has(m.id) && m.reasoning_content?.trim()"
class="ai-think-full ai-md"
v-html="renderMdDecorated(m.reasoning_content)"
></div>
</template>
<!-- 轮内正文(该轮叙述):随卡默认收起(结果优先、控密度),展开轨迹卡即见全文。
复用 .ai-think-full 的弱化样式(12px/次要色/左缩进),零新增 CSS。 -->
<div
v-if="m.role === 'assistant' && m.content && m.content.trim()"
class="ai-think-full ai-md"
v-html="renderMdDecorated(m.content)"
></div>
</template>
<ToolCardList
v-if="m.toolCalls && m.toolCalls.length > 0"
ref="toolCardListRef"
+2 -2
View File
@@ -35,7 +35,7 @@
<div v-if="budgetOpen" class="ai-boundary-budget">
<div class="ai-boundary-field" :class="{ 'is-error': budgetErrField === 'rounds' }">
<span>{{ t('ai.taskBoundary.budgetRounds') }}</span>
<input v-model="rounds" class="ai-boundary-input ai-boundary-input--num" type="text" inputmode="numeric" :disabled="locked || roundsUnlimited" @input="budgetErrField = ''" />
<input v-model="rounds" class="ai-boundary-input ai-boundary-input--num" type="text" inputmode="numeric" :aria-label="t('ai.taskBoundary.budgetRounds')" :disabled="locked || roundsUnlimited" @input="budgetErrField = ''" />
<label class="ai-boundary-unlimited">
<input v-model="roundsUnlimited" type="checkbox" :disabled="locked" />
<span>{{ t('ai.taskBoundary.budgetUnlimited') }}</span>
@@ -43,7 +43,7 @@
</div>
<div class="ai-boundary-field" :class="{ 'is-error': budgetErrField === 'wallSecs' }">
<span>{{ t('ai.taskBoundary.budgetWallSecs') }}</span>
<input v-model="wallSecs" class="ai-boundary-input ai-boundary-input--num" type="text" inputmode="numeric" :disabled="locked || wallUnlimited" @input="budgetErrField = ''" />
<input v-model="wallSecs" class="ai-boundary-input ai-boundary-input--num" type="text" inputmode="numeric" :aria-label="t('ai.taskBoundary.budgetWallSecs')" :disabled="locked || wallUnlimited" @input="budgetErrField = ''" />
<label class="ai-boundary-unlimited">
<input v-model="wallUnlimited" type="checkbox" :disabled="locked" />
<span>{{ t('ai.taskBoundary.budgetUnlimited') }}</span>
+45 -33
View File
@@ -209,7 +209,7 @@
<span v-if="g.status === 'active'" class="ai-goal-dot"></span>
<span v-else class="ai-goal-check">✓</span>
<span class="ai-goal-text">{{ g.text }}</span>
<button class="ai-goal-remove" @click.stop="removeGoal(i)">×</button>
<button class="ai-goal-remove" @click.stop="removeGoal(i)" :aria-label="$t('common.delete')">×</button>
</div>
</div>
</Transition>
@@ -222,7 +222,7 @@
</button>
<Transition name="ai-pop">
<div v-if="historyExpanded" class="ai-history-inline-list">
<div v-for="h in historyMsgs" :key="h.id" class="ai-history-inline-item" @click="scrollToMessage(h.id)">
<div v-for="h in historyMsgs" :key="h.id" class="ai-history-inline-item" role="button" tabindex="0" @click="scrollToMessage(h.id)" @keydown.enter.prevent="scrollToMessage(h.id)" @keydown.space.prevent="scrollToMessage(h.id)">
<span class="ai-history-index">{{ h.num }}</span>
<span class="ai-history-text">{{ h.text }}</span>
<span class="ai-history-time">{{ h.time }}</span>
@@ -238,7 +238,7 @@
</button>
<Transition name="ai-pop">
<div v-if="summaryExpanded" class="ai-summary-inline-list">
<div v-for="(s, i) in summaryMsgs" :key="i" class="ai-summary-inline-item" @click="scrollToMessage(s.id)">
<div v-for="(s, i) in summaryMsgs" :key="i" class="ai-summary-inline-item" role="button" tabindex="0" @click="scrollToMessage(s.id)" @keydown.enter.prevent="scrollToMessage(s.id)" @keydown.space.prevent="scrollToMessage(s.id)">
<span class="ai-summary-label">{{ $t('aiChat.compressedSummaryLabel') }} #{{ summaryMsgs.length - i }}</span>
<p class="ai-summary-preview">{{ s.text }}</p>
</div>
@@ -309,7 +309,7 @@ import { aiApi } from '../../api/ai'
import { pendingTaskProgress } from '../../composables/ai/useAiPendingState'
import { convRounds } from '../../composables/ai/aiShared'
import { switchConversation } from '../../composables/ai/useAiConversations'
import type { AiMessage, ModelConfig, GoalEntry } from '../../api/types'
import type { ModelConfig, GoalEntry } from '../../api/types'
defineProps<{
/** 分离窗口模式(嵌入 vs 分离决定顶部按钮组) */
@@ -595,6 +595,7 @@ const goalsExpanded = ref(false)
// ── 📋 历史消息(当前会话 user 消息列表) ──
interface HistoryItem { id: string; text: string; time: string; num: number }
interface SummaryItem { id: string; text: string }
/** 时间显示:今日 HH:mm / 昨天 HH:mm / MM-DD HH:mm */
function fmtTime(ts: number): string {
const d = new Date(ts)
@@ -616,17 +617,45 @@ function fmtTime(ts: number): string {
}
const MAX_HISTORY = 20
const MAX_CHARS = 80
const historyMsgs = computed<HistoryItem[]>(() => {
const allUserMsgs = store.state.messages
.filter((m): m is AiMessage & { timestamp: number } => m.role === 'user' && !!m.content && !!m.timestamp)
.map((m, idx) => ({
// 单次遍历 messages,一次产出「历史(user 消息)/摘要(system 消息)/项目名」三路派生,
// 替代原先三处 computed 各自全量扫描(historyMsgs/summaryMsgs/contextInfo)。
// 三路的筛选条件、收集顺序、截断与反转语义与拆分前逐项一致。
const msgDerived = computed(() => {
const history: HistoryItem[] = []
const summaries: SummaryItem[] = []
const projectLabels = new Set<string>()
for (const m of store.state.messages) {
if (m.role === 'user') {
// 项目名收集只认 user 的 mentionSpans(不设 content/timestamp 门槛),
// 与原 contextInfo 的独立遍历同条件,故与下面的历史筛选互不干扰。
if (m.mentionSpans?.length) {
for (const sp of m.mentionSpans) {
if (sp.kind === 'project' && sp.label) projectLabels.add(sp.label)
}
}
if (m.content && m.timestamp) {
history.push({
id: m.id,
text: m.content.length > MAX_CHARS ? m.content.slice(0, MAX_CHARS) + '…' : m.content,
time: fmtTime(m.timestamp),
num: idx + 1,
}))
return allUserMsgs.slice(-MAX_HISTORY).reverse()
num: history.length + 1,
})
}
} else if (m.role === 'system' && m.content) {
summaries.push({
id: m.id,
text: m.content.length > 120 ? m.content.slice(0, 120) + '…' : m.content,
})
}
}
return {
history: history.slice(-MAX_HISTORY).reverse(),
summaries,
projectNames: Array.from(projectLabels).join(', '),
}
})
const historyMsgs = computed<HistoryItem[]>(() => msgDerived.value.history)
const summaryMsgs = computed<SummaryItem[]>(() => msgDerived.value.summaries)
const historyExpanded = ref(false)
/** 点击历史消息项 → 滚动定位到对应的消息气泡 */
@@ -672,15 +701,7 @@ function onBottomToolsClickOutside(e: MouseEvent) {
}
}
// ── 📄 上下文摘要(压缩/归档的系统消息) ──
interface SummaryItem { id: string; text: string }
const summaryMsgs = computed<SummaryItem[]>(() => {
return store.state.messages
.filter(m => m.role === 'system' && !!m.content)
.map(m => ({
id: m.id,
text: m.content.length > 120 ? m.content.slice(0, 120) + '…' : m.content,
}))
})
// SummaryItem 类型与 summaryMsgs 派生已上移至历史区(与 historyMsgs 共享单次遍历)。
const summaryExpanded = ref(false)
// ── L3 完整上下文 ──
@@ -707,22 +728,11 @@ function togglePanel(panel: 'goals' | 'history' | 'summary' | 'context' | 'runni
const contextInfo = computed<ContextInfo>(() => {
const convId = store.state.activeConversationId
const conv = convId ? store.state.conversations.find(c => c.id === convId) : undefined
// 从所有 user 消息中收集 mentionSpans(kind === 'project'),去重
const projectLabels = new Set<string>()
for (const m of store.state.messages) {
if (m.role === 'user' && m.mentionSpans?.length) {
for (const sp of m.mentionSpans) {
if (sp.kind === 'project' && sp.label) {
projectLabels.add(sp.label)
}
}
}
}
return {
goals: conv?.pinned_goals ?? [],
summaryCount: summaryMsgs.value.length,
summaryCount: msgDerived.value.summaries.length,
osInfo: 'Windows 11 / PowerShell',
projectNames: Array.from(projectLabels).join(', ') || t('aiChat.none'),
projectNames: msgDerived.value.projectNames || t('aiChat.none'),
}
})
@@ -916,6 +926,8 @@ onBeforeUnmount(() => {
outline: none;
cursor: pointer;
}
/* 聚焦可见环:聚焦态无边框变化,且 scoped 选择器特异性高于全局 :focus-visible 兜底,须就地补环,仅键盘聚焦显示 */
.ai-model-select:focus-visible { outline: 2px solid var(--df-accent); outline-offset: 1px; }
.ai-model-select--compact {
max-width: 130px;
}
+1 -1
View File
@@ -12,7 +12,7 @@
<div class="stat-card-inner">
<div class="stat-top">
<div class="stat-icon-wrap" :style="{ background: stat.iconBg }">
<span class="stat-icon">{{ stat.icon }}</span>
<span class="stat-icon" aria-hidden="true">{{ stat.icon }}</span>
</div>
</div>
<div class="stat-value">{{ stat.value }}</div>
+2 -2
View File
@@ -17,7 +17,7 @@
class="btn btn-primary btn-sm"
:to="`/projects/${idea.promoted_to}`"
>
🚀 {{ $t('ideas.promotedProject') }} →
<span aria-hidden="true">🚀</span> {{ $t('ideas.promotedProject') }} →
</router-link>
<span class="status-tag" :class="'status-' + idea.status">{{ $t(ideaStatusLabelKey(idea.status)) }}</span>
</div>
@@ -119,7 +119,7 @@
</button>
<!-- P1-⑥ 重试样式:错误提示分两行,重试用 ghost btn-sm(替代突兀的内联 accent 按钮) -->
<div v-if="evalError" class="eval-error">
<p class="eval-error-msg">⚠️ {{ evalError }}</p>
<p class="eval-error-msg"><span aria-hidden="true">⚠️</span> {{ evalError }}</p>
<button class="btn btn-ghost btn-sm btn-retry" :disabled="evaluating" @click="$emit('evaluate')">
{{ $t('ideas.retryEval') }}
</button>
+1 -1
View File
@@ -155,7 +155,7 @@
<div v-show="sectionOpen.lifecycle" class="section-body">
<div class="timeline">
<div v-for="(node, idx) in timelineNodes" :key="node.id" class="tl-node">
<div class="tl-dot">{{ node.icon }}</div>
<div class="tl-dot" aria-hidden="true">{{ node.icon }}</div>
<div v-if="idx < timelineNodes.length - 1" class="tl-line"></div>
<div class="tl-body">
<div class="tl-label">{{ node.label }}</div>
+5 -3
View File
@@ -24,6 +24,7 @@
class="nav-link"
active-class="nav-link--active"
:title="collapsed ? $t(item.label) : undefined"
:aria-label="collapsed ? $t(item.label) : undefined"
>
<span class="nav-link-icon" v-html="item.svg"></span>
<span v-if="!collapsed" class="nav-link-text">{{ $t(item.label) }}</span>
@@ -41,6 +42,7 @@
class="nav-link"
active-class="nav-link--active"
:title="collapsed ? $t(item.label) : undefined"
:aria-label="collapsed ? $t(item.label) : undefined"
>
<span class="nav-link-icon" v-html="item.svg"></span>
<span v-if="!collapsed" class="nav-link-text">{{ $t(item.label) }}</span>
@@ -52,7 +54,7 @@
<!-- Footer -->
<div class="sidebar-footer">
<!-- AI 面板切换按钮(footer 常驻跨路由渲染,承担后台运行中会话的全局可见标识) -->
<button class="nav-link ai-toggle-btn" :class="{ 'ai-toggle-btn--active': aiStore.state.panelOpen }" @click="aiStore.togglePanel()" :title="runningConvCount > 0 ? `${runningConvCount} 个会话运行中` : (collapsed ? $t('nav.aiPanel') : undefined)">
<button class="nav-link ai-toggle-btn" :class="{ 'ai-toggle-btn--active': aiStore.state.panelOpen }" @click="aiStore.togglePanel()" :title="runningConvCount > 0 ? `${runningConvCount} 个会话运行中` : (collapsed ? $t('nav.aiPanel') : undefined)" :aria-label="runningConvCount > 0 ? `${runningConvCount} 个会话运行中` : (collapsed ? $t('nav.aiPanel') : undefined)">
<span class="nav-link-icon ai-spark-icon">
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/><path d="M19 3v3M21 4.5h-3M5 17v3M6.5 18.5h-3"/></svg>
</span>
@@ -61,14 +63,14 @@
<span v-if="collapsed && runningConvCount > 0" class="ai-running-dot"></span>
<span v-if="!collapsed" class="ai-toggle-shortcut">Ctrl+I</span>
</button>
<router-link to="/settings" class="nav-link" active-class="nav-link--active" :title="collapsed ? $t('nav.settings') : undefined">
<router-link to="/settings" class="nav-link" active-class="nav-link--active" :title="collapsed ? $t('nav.settings') : undefined" :aria-label="collapsed ? $t('nav.settings') : undefined">
<span class="nav-link-icon">
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
</span>
<span v-if="!collapsed" class="nav-link-text">{{ $t('nav.settings') }}</span>
</router-link>
<!-- 7b:收缩/展开切换按钮(最底部,快捷键 Ctrl+B 同效) -->
<button class="nav-link collapse-toggle-btn" @click="toggleCollapsed" :title="collapsed ? $t('nav.expand') : $t('nav.collapse')">
<button class="nav-link collapse-toggle-btn" @click="toggleCollapsed" :title="collapsed ? $t('nav.expand') : $t('nav.collapse')" :aria-label="collapsed ? $t('nav.expand') : $t('nav.collapse')">
<span class="nav-link-icon">
<svg aria-hidden="true" v-if="!collapsed" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
<svg aria-hidden="true" v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
@@ -29,6 +29,7 @@
@click="onDeleteDepBtnClick"
type="button"
:title="$t('fileExplorer.deleteCurrentModule')"
:aria-label="$t('fileExplorer.deleteCurrentModule')"
>
<svg aria-hidden="true" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
+12 -6
View File
@@ -71,20 +71,20 @@
</div>
<div class="toolbar-right">
<button class="btn btn-ghost btn-sm" type="button" :title="$t('fileExplorer.editCurrentModule')" @click="openEditModule">
<button class="btn btn-ghost btn-sm" type="button" :title="$t('fileExplorer.editCurrentModule')" :aria-label="$t('fileExplorer.editCurrentModule')" @click="openEditModule">
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
<button class="btn btn-ghost btn-sm" type="button" :title="$t('fileExplorer.deleteCurrentModule')" @click="confirmRemoveModule">
<button class="btn btn-ghost btn-sm" type="button" :title="$t('fileExplorer.deleteCurrentModule')" :aria-label="$t('fileExplorer.deleteCurrentModule')" @click="confirmRemoveModule">
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
<button class="btn btn-ghost btn-sm" type="button" :title="$t('fileExplorer.addModuleTitle')" @click="openAddModule">
<button class="btn btn-ghost btn-sm" type="button" :title="$t('fileExplorer.addModuleTitle')" :aria-label="$t('fileExplorer.addModuleTitle')" @click="openAddModule">
<svg aria-hidden="true" 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"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<button class="btn btn-ghost btn-sm" type="button" :disabled="refreshing" @click="refresh">
<span v-if="refreshing" class="spinner"></span>
{{ refreshing ? $t('fileExplorer.refreshing') : $t('fileExplorer.refresh') }}
</button>
<button class="btn btn-ghost btn-sm" type="button" :title="$t('fileExplorer.detachToWindow')" @click="onDetach">
<button class="btn btn-ghost btn-sm" type="button" :title="$t('fileExplorer.detachToWindow')" :aria-label="$t('fileExplorer.detachToWindow')" @click="onDetach">
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 3h6v6"/>
<path d="M10 14L21 3"/>
@@ -99,10 +99,10 @@
<!-- 左:视图切换按钮(文件树 / 变更) -->
<div class="explorer-sidebar">
<div class="explorer-view-tabs">
<button class="view-tab" :class="{ active: viewMode === 'tree' }" @click="viewMode = 'tree'">
<button class="view-tab" :class="{ active: viewMode === 'tree' }" type="button" :title="$t('fileExplorer.viewTree')" :aria-label="$t('fileExplorer.viewTree')" @click="viewMode = 'tree'">
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
</button>
<button class="view-tab" :class="{ active: viewMode === 'changes' }" @click="switchToChanges">
<button class="view-tab" :class="{ active: viewMode === 'changes' }" type="button" :title="$t('fileExplorer.viewChanges')" :aria-label="$t('fileExplorer.viewChanges')" @click="switchToChanges">
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
<span v-if="changedCount > 0" class="changed-badge">{{ changedCount }}</span>
</button>
@@ -426,6 +426,12 @@ onDeactivated(() => { viewVisible.value = false })
const changedCount = computed(() => gitStatusData.value?.changed_files.length ?? 0)
// F5:切工程清 git 状态缓存 —— 否则 changedCount 徽标沿用上一工程的计数(张冠李戴),
// 且 switchToChanges 因 gitStatusData 非空短路,不再为新工程重拉。进入变更视图时自然重拉。
watch(currentModuleId, () => {
gitStatusData.value = null
})
async function switchToChanges() {
viewMode.value = 'changes'
if (!currentModuleId.value || gitStatusData.value) return
+39 -4
View File
@@ -25,6 +25,7 @@
:class="{ active: showDiff }"
@click="toggleDiff"
:title="showDiff ? $t('fileExplorer.showContent') : $t('fileExplorer.showDiff')"
:aria-label="showDiff ? $t('fileExplorer.showContent') : $t('fileExplorer.showDiff')"
>
{{ showDiff ? '📄' : '📝' }}
</button>
@@ -81,7 +82,7 @@
/>
<div v-else class="preview-image-error">
<div class="preview-placeholder-icon">🖼️</div>
<div class="preview-placeholder-text">图片加载失败(路径: {{ filePath }})</div>
<div class="preview-placeholder-text">{{ $t('fileExplorer.imageLoadFailed', { path: filePath }) }}</div>
</div>
</div>
@@ -105,7 +106,7 @@
<!-- 文本/代码(highlight.js 语法高亮 + 行号;非 diff 模式时显示) -->
<div v-else ref="codeScrollRef" class="preview-code-scroll">
<div class="preview-line-numbers" aria-hidden="true">
<div v-for="n in lineCount" :key="n" class="preview-line-num">{{ n }}</div>
<div v-for="n in lineCount" :key="n" class="preview-line-num" :class="{ 'is-active': activeOutlineLine === n }">{{ n }}</div>
</div>
<pre class="preview-code"><code :class="hljsClass" v-html="htmlContent"></code></pre>
</div>
@@ -121,7 +122,15 @@
☰ <span class="outline-overlay-count">{{ outlineSymbols.length }}</span>
</button>
<div v-else class="preview-outline">
<div class="preview-outline-head" @click="outlineCollapsed = true">
<div
class="preview-outline-head"
role="button"
tabindex="0"
:aria-expanded="!outlineCollapsed"
@click="outlineCollapsed = true"
@keydown.enter.prevent="outlineCollapsed = true"
@keydown.space.prevent="outlineCollapsed = true"
>
<span class="preview-outline-title">☰ {{ $t('fileExplorer.symbols') }}</span>
<span class="preview-outline-count">{{ outlineSymbols.length }}</span>
<span class="preview-outline-spacer"></span>
@@ -140,7 +149,11 @@
class="outline-item"
:class="['grp-' + grp.key, { 'is-active': activeOutlineLine === sym.line }]"
:title="sym.signature"
role="button"
tabindex="0"
@click="gotoOutlineLine(sym.line)"
@keydown.enter.prevent="gotoOutlineLine(sym.line)"
@keydown.space.prevent="gotoOutlineLine(sym.line)"
>
<span class="outline-kind">{{ outlineKindLabel(sym.kind) }}</span>
<span class="outline-name">{{ sym.name || sym.signature || '?' }}</span>
@@ -256,12 +269,22 @@ const diffLines = computed<DiffLine[]>(() => {
const lines: DiffLine[] = []
let oldNum = 0
let newNum = 0
// `diff --git` 起、首个 `@@` 之前的行属文件头块(index/mode/---/+++ 等)。
// `--- a/x` / `+++ b/x` 是文件路径行而非增删行,否则会被误当删除/新增渲染且占行号。
let inFileHeader = false
for (const raw of diffViewContent.value.split('\n')) {
if (raw.startsWith('@@')) {
if (raw.startsWith('diff --git ')) {
inFileHeader = true
lines.push({ type: 'ctx', prefix: ' ', text: raw, oldNum: '', newNum: '' })
} else if (raw.startsWith('@@')) {
inFileHeader = false
const h = parseHunkHeader(raw)
oldNum = h.oldStart
newNum = h.newStart
lines.push({ type: 'hdr', prefix: '', text: raw, oldNum: '', newNum: '' })
} else if (inFileHeader) {
// 文件头元信息行:不属内容,不占行号
lines.push({ type: 'ctx', prefix: ' ', text: raw, oldNum: '', newNum: '' })
} else if (raw.startsWith('+')) {
lines.push({ type: 'add', prefix: '+', text: raw.slice(1), oldNum: '', newNum: String(newNum++) })
} else if (raw.startsWith('-')) {
@@ -485,10 +508,21 @@ async function loadFile() {
outlineSupported.value = false
outlineSymbols.value = []
activeOutlineLine.value = -1
// 未选文件:一并复位头部/内容态,避免残留上一文件的大小、截断、二进制标记(头部常驻渲染)
loading.value = false
error.value = null
fileSize.value = null
truncated.value = false
isBinary.value = false
imageLoadError.value = false
return
}
loading.value = true
error.value = null
// F18:切文件即清上一文件的头部元信息(大小/截断/二进制),避免加载期间残留旧值
fileSize.value = null
truncated.value = false
isBinary.value = false
// Markdown 文件触发预热(确保 marked 就绪,useRendered computed 据此渲染)
if (props.filePath.toLowerCase().endsWith('.md')) {
ensureMdLoaded()
@@ -590,6 +624,7 @@ function gitStatusClass(status: string): string {
if (x.startsWith('A')) return 'git-added'
if (x.startsWith('M')) return 'git-modified'
if (x.startsWith('D')) return 'git-deleted'
if (x.startsWith('R')) return 'git-modified' // F19:R(重命名)与文件树/变更视图一致按 modified 着色
return 'git-other'
}
+54 -12
View File
@@ -37,9 +37,17 @@
v-if="entry.is_dir"
class="tree-row tree-row-dir"
:title="entry.path"
role="button"
tabindex="0"
:aria-expanded="expandedPaths.has(entry.path)"
@click="toggleDir(entry)"
@keydown.enter.prevent="toggleDir(entry)"
@keydown.space.prevent="toggleDir(entry)"
>
<span class="expand-icon">{{ expandedPaths.has(entry.path) ? '▾' : '▸' }}</span>
<span class="expand-icon">
<span v-if="loadingDirs.has(entry.path)" class="dir-spinner"></span>
<template v-else>{{ expandedPaths.has(entry.path) ? '▾' : '▸' }}</template>
</span>
<span class="file-icon">📁</span>
<span class="file-name">{{ entry.name }}</span>
<!-- 空目录标:has_children 由后端列目录时即时统计,未展开即可见「空」,
@@ -62,7 +70,11 @@
v-else
class="tree-row tree-row-file"
:title="entry.path"
role="button"
tabindex="0"
@click="emit('file-select', entry.path)"
@keydown.enter.prevent="emit('file-select', entry.path)"
@keydown.space.prevent="emit('file-select', entry.path)"
>
<span class="expand-icon placeholder"></span>
<!-- 有类别图标(TS/JS/MD 等)只显彩色类别标,无则显通用 📄,避免叠加 -->
@@ -149,6 +161,14 @@ const error = ref<string | null>(null)
// 会让整个子树渲染分支(v-else-if="error")替换成错误文案,一次子目录失败就炸掉已展开的其他节点。
// 改为按 path 记录,条目行内显 ⚠ + title 原因,点击重试;成功加载即清除。
const toggleErrors = reactive(new Map<string, string>())
// F15/F17:按目录的「展开中」状态 —— 展开时该目录行内显 spinner(加载反馈),
// 并作为 in-flight 守卫防止快速双击同目录并发重复请求。
const loadingDirs = reactive(new Set<string>())
// F7:请求序号 + 在途键集合。切工程时 moduleId watch 与 loadedChildren 失效 watch 会并发
// 调用 loadEntries,用 (moduleId,subPath) 键去重避免同一目录双重请求;序号守卫丢弃旧响应。
let loadSeq = 0
const inflightEntries = new Set<string>()
/** 拉取当前 subPath 的条目列表。 */
async function loadEntries() {
@@ -162,17 +182,25 @@ async function loadEntries() {
entries.value = cached
return
}
const inflightKey = `${props.moduleId}::${cacheKey}`
if (inflightEntries.has(inflightKey)) return
const seq = ++loadSeq
inflightEntries.add(inflightKey)
loading.value = true
error.value = null
entries.value = [] // 清旧条目,避免切工程时短暂显示上一个工程的文件
try {
const res = await moduleApi.getModuleFileTree(props.moduleId, props.subPath || undefined)
if (seq !== loadSeq) return // 旧响应晚到,丢弃不覆盖新工程数据
entries.value = res.entries
// 缓存到顶层 loadedChildren(供 toggle 判断是否已有数据,避免重复请求)。
props.loadedChildren.set(cacheKey, res.entries)
} catch (e) {
if (seq !== loadSeq) return
error.value = e instanceof Error ? e.message : String(e)
} finally {
loading.value = false
inflightEntries.delete(inflightKey)
if (seq === loadSeq) loading.value = false
}
}
@@ -184,25 +212,27 @@ async function toggleDir(entry: FileTreeEntry) {
if (props.expandedPaths.has(entry.path)) {
// 收起:仅移除展开标记(loadedChildren 缓存保留,下次展开秒开)。
emit('toggle-dir', entry.path, [])
} else {
// 展开:若未加载过则先拉取并缓存(去重——只在这里拉一次;
// 递归子树实例 onMounted 时会复用此缓存,不重复请求同目录)。
if (!props.loadedChildren.has(entry.path)) {
return
}
if (props.loadedChildren.has(entry.path)) {
// 已加载过:直接展开,不重复请求
emit('toggle-dir', entry.path, props.loadedChildren.get(entry.path) ?? [])
return
}
// F17:展开中忽略重复点击(快速双击同目录不并发请求)
if (loadingDirs.has(entry.path)) return
emit('load-children', entry.path)
loadingDirs.add(entry.path)
try {
loading.value = true
const res = await moduleApi.getModuleFileTree(props.moduleId, entry.path)
props.loadedChildren.set(entry.path, res.entries)
toggleErrors.delete(entry.path)
emit('toggle-dir', entry.path, res.entries)
} catch (e) {
// B3[PD-P1-6]:行内错误态(该目录 ⚠ + title 原因),不设整树 error;不展开,点击可重试
toggleErrors.set(entry.path, e instanceof Error ? e.message : String(e))
return
} finally {
loading.value = false
}
}
emit('toggle-dir', entry.path, props.loadedChildren.get(entry.path) ?? [])
loadingDirs.delete(entry.path)
}
}
@@ -380,6 +410,18 @@ watch(
font-size: 10px;
}
/* F15:目录展开中行内小 spinner(替代 ▸/▾),给展开操作即时反馈 */
.expand-icon .dir-spinner {
display: inline-block;
width: 9px;
height: 9px;
border: 1.5px solid var(--df-border, #444);
border-top-color: var(--df-accent, #3a8);
border-radius: 50%;
animation: df-spin 0.8s linear infinite;
vertical-align: middle;
}
.expand-icon.placeholder {
visibility: hidden;
width: 0; /* 文件无展开箭头,不占横向空间,避免图标前留白 */
+39 -5
View File
@@ -6,7 +6,7 @@
<span class="branch-name">{{ branchName }}</span>
<span v-if="!showHistory" class="branch-count">{{ gitStatus?.changed_files.length ?? 0 }} {{ $t('gitChanges.changedFiles') }}</span>
<span v-else class="branch-count">{{ totalCommits }} {{ $t('gitChanges.commitsUnit') }}</span>
<button v-if="!showHistory" class="branch-switch-btn" @click="onSwitchBranch" :title="$t('gitChanges.switchBranch')">
<button v-if="!showHistory" class="branch-switch-btn" @click="onSwitchBranch" :title="$t('gitChanges.switchBranch')" :aria-label="$t('gitChanges.switchBranch')">
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>
</button>
</div>
@@ -15,7 +15,7 @@
<div v-if="showBranchPicker" class="gc-branch-picker">
<div class="gc-branch-picker-header">
<span>{{ $t('gitChanges.branches') }}</span>
<button class="gc-branch-picker-close" @click="closeBranchPicker">✕</button>
<button class="gc-branch-picker-close" @click="closeBranchPicker" :aria-label="$t('common.close')">✕</button>
</div>
<div v-if="branchLoading" class="gc-loading"><span class="spinner"></span></div>
<div v-else class="gc-branch-list">
@@ -73,7 +73,11 @@
class="gc-file-row"
:class="{ selected: selectedFile === f.path }"
:style="{ paddingLeft: filePathDepth(f.path) * 16 + 14 + 'px' }"
role="button"
tabindex="0"
@click="selectFile(f.path)"
@keydown.enter.prevent="selectFile(f.path)"
@keydown.space.prevent="selectFile(f.path)"
>
<span class="gc-file-status" :class="'gc-st-' + group.key">{{ statusLabel(f.status) }}</span>
<span class="gc-file-name">{{ filePathLeaf(f.path) }}</span>
@@ -105,7 +109,12 @@
:key="c.hash"
class="gc-commit-row"
:class="{ selected: selectedCommit?.hash === c.hash }"
role="button"
tabindex="0"
:aria-expanded="selectedCommit?.hash === c.hash"
@click="selectCommit(c)"
@keydown.enter.prevent="selectCommit(c)"
@keydown.space.prevent="selectCommit(c)"
>
<div class="gc-commit-line1">
<span class="gc-commit-hash">{{ c.hash.slice(0, 8) }}</span>
@@ -117,7 +126,15 @@
</div>
<!-- 加载更多 -->
<div v-if="hasMoreCommits && !loadingMore" class="gc-load-more" @click="loadMoreCommits">
<div
v-if="hasMoreCommits && !loadingMore"
class="gc-load-more"
role="button"
tabindex="0"
@click="loadMoreCommits"
@keydown.enter.prevent="loadMoreCommits"
@keydown.space.prevent="loadMoreCommits"
>
{{ $t('gitChanges.loadMore') }}
</div>
<div v-if="loadingMore" class="gc-loading"><span class="spinner"></span></div>
@@ -132,7 +149,7 @@
<span class="gc-commit-detail-author">{{ selectedCommit.author }}</span>
<span class="gc-commit-detail-time">{{ formatTime(selectedCommit.timestamp) }}</span>
<span class="gc-commit-detail-msg">{{ selectedCommit.subject }}</span>
<button class="gc-detail-close" @click="closeCommitDetail">✕</button>
<button class="gc-detail-close" @click="closeCommitDetail" :aria-label="$t('common.close')">✕</button>
</div>
<div v-if="commitDetailLoading" class="gc-loading"><span class="spinner"></span></div>
<template v-else-if="commitDetail">
@@ -143,7 +160,11 @@
:key="f.path"
class="gc-commit-file-row"
:class="{ active: commitSelectedFile === f.path }"
role="button"
tabindex="0"
@click="showCommitFileDiff(f.path)"
@keydown.enter.prevent="showCommitFileDiff(f.path)"
@keydown.space.prevent="showCommitFileDiff(f.path)"
>
<span class="gc-file-status" :class="'gc-st-' + statusKey(f.status)">{{ statusLabel(f.status) }}</span>
<span class="gc-file-path">{{ f.path }}</span>
@@ -530,7 +551,20 @@ function showCommitFileDiff(path: string) {
emit('select-file', path, extractFileDiff(commitDetail.value.diff, path))
}
watch(() => props.moduleId, loadStatus, { immediate: true })
watch(() => props.moduleId, () => {
// 切换工程:清理上一工程的遗留状态(提交详情/选中文件/搜索词/分支弹层),防残留旧仓库数据。
// 刻意不复位 showHistory(保留用户所在 Tab);refreshKey 刷新走 loadStatus 亦不清理(见下),
// 故复位只挂在本 watch,不放 loadStatus。
selectedFile.value = ''
selectedCommit.value = null
commitDetail.value = null
commitSelectedFile.value = ''
commitSearch.value = ''
showBranchPicker.value = false
branchList.value = []
emit('select-file', '', '')
loadStatus()
}, { immediate: true })
// B2[PD-P1-5]:外部刷新信号(AI 写文件后 FileExplorer 自增)变化 → 重拉 git 状态。
// 需保留当前历史 Tab/详情展示(只刷 gitStatus/commits,不动 showHistory/selectedCommit)。
@@ -205,7 +205,12 @@ async function addDir() {
}
// 删除:即时持久化,dirs 由后端返回值回填(与添加对称,删除也立即生效)。
// SA-28: 删除即持久化且无二次确认(误点即丢一条授权),补确认弹层——复用本组件已有的
// useConfirm/内联弹层(与「信任所有目录」开关同套)。文案受「不新增 i18n 键」约束,
// 无专用键,以 common.delete 拼路径;理想应有 allowedDirsRemoveConfirm 专用键(记 note)。
async function removeDir(idx: number) {
const dir = dirs.value[idx]
if (!await confirmDialog(`${t('common.delete')}「${dir}」?`, t('common.delete'))) return
const next = dirs.value.filter((_, i) => i !== idx)
saving.value = true
try {
+1 -1
View File
@@ -11,7 +11,7 @@
<div class="connection-list" v-if="connections.length > 0">
<div class="connection-card" v-for="conn in connections" :key="conn.id">
<div class="conn-left">
<span class="conn-icon">{{ typeIcon(conn.type) }}</span>
<span class="conn-icon" aria-hidden="true">{{ typeIcon(conn.type) }}</span>
<div class="conn-info">
<div class="conn-name-row">
<span class="conn-name">{{ conn.name }}</span>
+19 -1
View File
@@ -17,7 +17,7 @@
</select>
</SettingRow>
<SettingRow ref="rowMinMessages" :label="$t('settings.labelMinMessages')" :desc="$t('settings.descMinMessages')">
<input type="number" min="2" max="50" v-model.number="knowledgeConfig.min_messages" class="setting-number" style="width:80px" @change="markSaved('minMessages')" />
<input type="number" min="2" max="50" v-model.number="knowledgeConfig.min_messages" class="setting-number" style="width:80px" @change="onMinMessagesChange" />
</SettingRow>
<SettingRow ref="rowAutoInject" :label="$t('settings.labelAutoInject')" :desc="$t('settings.descAutoInject')">
<label class="toggle">
@@ -76,9 +76,15 @@ const embeddingProviders = computed(() =>
props.aiProviders.filter(p => p.provider_type !== 'anthropic')
)
// SA-26: 加载回填(loaded 翻 true + 字段赋值)会触发下方 deep watch 一次 ->
// 把刚读到的值原样回写 saveConfig IPC,纯冗余。用一次性标志让该次 flush 跳过:
// 赋值前打标,watch 回调首行消费并 return。首次加载 loaded 恒 false->true = 必有变更,
// 该次 flush 必触发因而标志必被消费,不会误吞后续用户改动(本函数仅 onMounted 调用一次)。
let _skipNextFlush = false
async function loadKnowledgeConfig() {
try {
const cfg = await knowledgeApi.getConfig()
_skipNextFlush = true
Object.assign(knowledgeConfig, cfg)
knowledgeConfig.loaded = true
} catch (e) {
@@ -114,6 +120,8 @@ function saveKnowledgeConfigDebounced() {
watch(
() => ({ ...knowledgeConfig }),
() => {
// SA-26: 加载回填导致的 flush 跳过(非用户改动),避免进面板即多一次无谓 saveConfig IPC
if (_skipNextFlush) { _skipNextFlush = false; return }
if (knowledgeConfig.loaded) saveKnowledgeConfigDebounced()
},
{ deep: true },
@@ -138,6 +146,16 @@ function markSaved(key: SavedKey) {
_pendingSavedKey = key
}
// SA-27: input 原生 min/max 只约束步进按钮,手输越界不受限 -> 对齐 PerformanceSection clamp 范式。
// 非法值(NaN/清空)回退默认 4,合法值 clamp [2,50](与模板 min/max 一致),修正后回写 v-model
// 触发保存与「已保存」反馈。注:未加越界 toast(本组件未接 @toast 通道,且无通用的越界修正文案 key)。
function onMinMessagesChange() {
const raw = knowledgeConfig.min_messages
const n = typeof raw === 'number' && Number.isFinite(raw) ? Math.trunc(raw) : 4
knowledgeConfig.min_messages = Math.min(50, Math.max(2, n))
markSaved('minMessages')
}
/** 真正点亮行内「✓已保存」反馈(仅 IPC 成功路径调用)。 */
function flashRow(key: SavedKey) {
const map: Record<SavedKey, InstanceType<typeof SettingRow> | null> = {
+7 -4
View File
@@ -168,16 +168,16 @@
<div class="model-tags">
<span v-for="md in m.modalities" v-show="!redundantTags.modalities.has(md)" :key="'md-' + md"
class="tag tag-modality" :title="$t('settings.tagTooltipModality')">
{{ $t('settings.tagModality.' + md) }}<span class="tag-remove" @click.stop="removeModality(m, md)">×</span>
{{ $t('settings.tagModality.' + md) }}<span class="tag-remove" role="button" tabindex="0" :aria-label="$t('common.delete')" @click.stop="removeModality(m, md)" @keydown.enter.prevent="removeModality(m, md)" @keydown.space.prevent="removeModality(m, md)">×</span>
</span>
<span v-for="cap in m.capabilities" v-show="!redundantTags.capabilities.has(cap)" :key="'cap-' + cap"
class="tag tag-capability" :title="$t('settings.tagTooltipCapability')">
{{ $t('settings.tagCapability.' + cap) }}<span class="tag-remove" @click.stop="removeCapability(m, cap)">×</span>
{{ $t('settings.tagCapability.' + cap) }}<span class="tag-remove" role="button" tabindex="0" :aria-label="$t('common.delete')" @click.stop="removeCapability(m, cap)" @keydown.enter.prevent="removeCapability(m, cap)" @keydown.space.prevent="removeCapability(m, cap)">×</span>
</span>
<span v-if="m.probe_source && m.probe_source !== 'user_set'" v-show="!redundantTags.sources.has(m.probe_source)"
class="tag tag-source" :title="$t('settings.tagTooltipSource')">{{ $t('settings.tagSource.' + m.probe_source) }}</span>
<span v-if="m.probe_source === 'user_set'" class="tag tag-source" :title="$t('settings.tagTooltipSource')">{{ $t('settings.tagSource.user_set') }}</span>
<span class="tag-add" :title="$t('settings.addTagTitle')" @click.stop="startAddTag(m)">+</span>
<span class="tag-add" role="button" tabindex="0" :title="$t('settings.addTagTitle')" :aria-label="$t('settings.addTagTitle')" @click.stop="startAddTag(m)" @keydown.enter.prevent="startAddTag(m)" @keydown.space.prevent="startAddTag(m)">+</span>
</div>
<!-- 添加能力内联菜单(点+展开;选未有的能力/模态即添加) -->
<div v-if="addingTagModel === m.model_id" class="add-tag-menu">
@@ -293,7 +293,10 @@ const providerForm = reactive({
*/
const redundantTags = computed(() => {
const models = providerForm.models
if (models.length === 0) return { modalities: new Set<string>(), capabilities: new Set<string>(), sources: new Set<string>() }
// SA-6: 去冗余的前提是「模型间对比」——仅 1 个模型时它的所有标签都满足「全模型共有」,
// 无脑隐藏会把唯一模型的能力标签全抹掉(行内空白,用户看不到该模型能力)。
// 故 ≥2 个模型起才去冗余;0/1 个一律全显(sources 分支本就 <2 直接放行,此处对齐)。
if (models.length < 2) return { modalities: new Set<string>(), capabilities: new Set<string>(), sources: new Set<string>() }
const common = (pick: (m: ModelConfig) => string[]) => {
const sets = models.map(m => new Set(pick(m)))
const first = sets[0]
+4
View File
@@ -28,11 +28,15 @@
v-for="cat in categories"
:key="cat.value"
class="nav-item"
role="button"
tabindex="0"
:class="{
'nav-item--active': cat.value === modelValue,
'nav-item--matched': isMatchedCategory(cat.value),
}"
@click="$emit('update:modelValue', cat.value)"
@keydown.enter.prevent="$emit('update:modelValue', cat.value)"
@keydown.space.prevent="$emit('update:modelValue', cat.value)"
>
<span class="nav-icon">{{ cat.icon }}</span>
<span class="nav-label">{{ $t(cat.labelKey) }}</span>
+8
View File
@@ -107,6 +107,14 @@
/* Toggle(3/5 Panel 重复) */
.toggle { position: relative; display: inline-block; width: 40px; height: 22px; cursor: pointer; }
.toggle input { opacity: 0; width: 0; height: 0; }
/* 键盘聚焦可见环:原生 checkbox 被 opacity:0+零尺寸隐藏,global.css 的 :focus-visible 兜底
画在零尺寸 input 上不可见;把聚焦态转移到可见的 slider。用 :focus-visible(非 :focus-within)
保证鼠标点击不显环、仅 Tab 键盘导航出现;.toggle--sm 变体同匹配(.toggle 前缀)。
:focus{border-color} 仅覆盖 setting-select/input 等可见输入,覆盖不到这种视觉隐藏 checkbox。 */
.toggle input:focus-visible + .toggle-slider {
outline: 2px solid var(--df-accent);
outline-offset: 2px;
}
.toggle-slider { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: var(--df-border); border-radius: var(--df-radius-lg); transition: background 0.2s; }
.toggle-slider::before { content: ''; position: absolute; width: 16px; height: 16px; left: 3px; bottom: 3px; background: var(--df-text-dim); border-radius: 50%; transition: transform 0.2s, background 0.2s; }
.toggle input:checked + .toggle-slider { background: var(--df-accent); }
+3 -3
View File
@@ -8,7 +8,7 @@
<div class="tbb-actions">
<!-- 推进到 ▾(仅渲染全部可达的交集目标) -->
<div class="tbb-menu" ref="advanceMenuRef">
<button class="btn btn-ghost btn-sm" :disabled="busy || advanceTargets.length === 0" @click.stop="toggleMenu('advance')">
<button class="btn btn-ghost btn-sm" :disabled="busy || advanceTargets.length === 0" :aria-expanded="activeMenu === 'advance'" @click.stop="toggleMenu('advance')">
{{ $t('tasks.batch.advanceTo') }} ▾
</button>
<div v-if="activeMenu === 'advance'" class="tbb-dropdown" @click.stop>
@@ -25,7 +25,7 @@
<!-- 改优先级 ▾ -->
<div class="tbb-menu" ref="priorityMenuRef">
<button class="btn btn-ghost btn-sm" :disabled="busy" @click.stop="toggleMenu('priority')">
<button class="btn btn-ghost btn-sm" :disabled="busy" :aria-expanded="activeMenu === 'priority'" @click.stop="toggleMenu('priority')">
{{ $t('tasks.batch.priority') }} ▾
</button>
<div v-if="activeMenu === 'priority'" class="tbb-dropdown" @click.stop>
@@ -37,7 +37,7 @@
<!-- 指派 ▾(内嵌输入 + 建议) -->
<div class="tbb-menu" ref="assigneeMenuRef">
<button class="btn btn-ghost btn-sm" :disabled="busy" @click.stop="toggleMenu('assignee')">
<button class="btn btn-ghost btn-sm" :disabled="busy" :aria-expanded="activeMenu === 'assignee'" @click.stop="toggleMenu('assignee')">
{{ $t('tasks.batch.assignee') }} ▾
</button>
<div v-if="activeMenu === 'assignee'" class="tbb-dropdown tbb-dropdown--assignee" @click.stop>
+10 -1
View File
@@ -123,7 +123,16 @@
<span class="subtask-progress-text">{{ $t('taskDetail.progressTitle') }}: {{ subtaskProgress.done }}/{{ subtaskProgress.total }}</span>
</div>
<div v-if="children.length > 0" class="subtask-list">
<div v-for="child in children" :key="child.id" class="subtask-item" @click="router.push(`/tasks/${child.id}`); $emit('close')">
<div
v-for="child in children"
:key="child.id"
class="subtask-item"
role="button"
tabindex="0"
@click="router.push(`/tasks/${child.id}`); $emit('close')"
@keydown.enter.prevent="router.push(`/tasks/${child.id}`); $emit('close')"
@keydown.space.prevent="router.push(`/tasks/${child.id}`); $emit('close')"
>
<span class="subtask-title">{{ child.title }}</span>
<span class="status-tag" :class="taskStatusClass(child.status)">{{ $t(taskStatusLabel(child.status)) }}</span>
<span class="priority-badge" :class="priorityClass(child.priority)">{{ priorityLabel(child.priority) }}</span>
@@ -13,7 +13,7 @@
class="wf-dag-node"
:class="nodeClass(node.id, props.nodeStatuses)"
>
<span class="wf-dag-node-status-dot"></span>
<span class="wf-dag-node-status-dot" aria-hidden="true"></span>
<span class="wf-dag-node-name">{{ node.id }}</span>
<span class="wf-dag-node-type">{{ nodeTypeLabel[node.node_type] || node.node_type }}</span>
<span v-if="nodeStatuses[node.id]" class="wf-dag-node-status">{{ statusLabel(nodeStatuses[node.id]) }}</span>
@@ -27,7 +27,7 @@
</div>
</div>
<!-- 层间箭头 -->
<div v-if="li < layers.length - 1" class="wf-dag-layer-arrow">↓</div>
<div v-if="li < layers.length - 1" class="wf-dag-layer-arrow" aria-hidden="true">↓</div>
</div>
</div>
@@ -52,7 +52,11 @@
v-else
class="wf-dag-cond-label"
:class="{ 'wf-dag-cond-label--uncond': !edge.condition, 'wf-dag-cond-label--editable': true }"
role="button"
tabindex="0"
@click="startEdit(i, edge)"
@keydown.enter.prevent="startEdit(i, edge)"
@keydown.space.prevent="startEdit(i, edge)"
>{{ edge.condition || $t('taskDetail.workflowUnconditional') }}</span>
</div>
</div>
+15 -1
View File
@@ -10,7 +10,7 @@
//! - 审批计时器(startApprovalTimer/clearApprovalTimer/clearAllApprovalTimers):
//! events 模块触发(start/clear/allClear),send 模块也需导出给组件,故下沉到共享层
import { reactive } from 'vue'
import { reactive, ref } from 'vue'
import { emit } from '@tauri-apps/api/event'
import { aiApi } from '@/api'
import { useAppSettingsStore } from '@/stores/appSettings'
@@ -305,6 +305,18 @@ export interface ConvStreamState {
*/
export const convStreamStates = reactive(new Map<string, ConvStreamState>())
/**
* 工具状态版本号(O(1) 收敛):工具卡状态任一突变(push 新卡/完成/待审批/拒绝)经本值自增,
* MessageList 的「收起旧工具卡」联动 watch 改 watch 本值 —— 替代原 map+JSON.stringify 的
* O(n) 指纹,工具事件从每次全量遍历降为常数。单调递增,消费方只需「变没变」不需值语义。
*/
export const toolEpoch = ref(0)
/** 工具状态变更唯一出口:自增版本号(所有工具态突变点调用,避免散点漏调)。 */
export function bumpToolEpoch(): void {
toolEpoch.value++
}
/**
* 取某 conv 的 stream state;未追踪过返回 null。
*
@@ -412,6 +424,7 @@ export function addPendingApproval(info: AiToolCallInfo): boolean {
const view = getState()?.pendingApprovals
if (view && !view.some(p => p.id === info.id)) view.push(info)
}
if (added) bumpToolEpoch() // 新增待审批 = 工具态变更
return added
}
@@ -427,6 +440,7 @@ export function removePendingApprovalById(id: string): void {
const idx = view.findIndex(p => p.id === id)
if (idx >= 0) view.splice(idx, 1)
}
bumpToolEpoch() // 待审批移除(审批结果/完成)= 工具态变更
}
/**
+2 -1
View File
@@ -12,7 +12,7 @@ import { state } from '@/stores/ai'
import { persistUiState } from './useAiPanel'
import { setStreaming } from './streamingGuard'
import { emitDetachedOpenConversation, registerDetachedOpenConvListener } from './useAiWindow'
import { nextMsgId, getConvState, clearConvStreamState, clearLastDelta, startApprovalTimer, clearAllApprovalTimers, switchingConvs, getConvStreamState, setConvCurrentText, notifyConversationChanged, setConvPendingApprovals, discardConvPendingApprovals, backfillConvAgentFinished } from './aiShared'
import { nextMsgId, getConvState, clearConvStreamState, clearLastDelta, startApprovalTimer, clearAllApprovalTimers, switchingConvs, getConvStreamState, setConvCurrentText, notifyConversationChanged, setConvPendingApprovals, discardConvPendingApprovals, backfillConvAgentFinished, bumpToolEpoch } from './aiShared'
import { t } from '@/i18n/i18n-helpers'
import type { AiConversationDetail, AiMessage, AiToolCallInfo, AiToolCallStatus, ConvId, MessageId } from '@/api/types'
@@ -499,6 +499,7 @@ export async function switchConversation(id: string, force = false, opts?: { noC
for (const tc of (m.toolCalls || [])) {
if (pendingIds.has(tc.id)) {
tc.status = 'pending_approval'
bumpToolEpoch()
const kind = pendingKindMap.get(tc.id) ?? 'risk'
tc.kind = kind
// path 类审批:从 tc.args.path 推 path/dir 文案(后端 IPC 仅返 kind,无 dir/path;
+19 -2
View File
@@ -19,7 +19,7 @@ import { invoke } from '@tauri-apps/api/core'
import { ref } from 'vue'
import { aiApi } from '@/api'
import { state } from '@/stores/ai'
import type { ContentPart, AiMessage, MentionSpan, MessageId } from '@/api/types'
import type { ContentPart, AiMessage, MentionSpan, MessageId, AiErrorType } from '@/api/types'
import { t } from '@/i18n/i18n-helpers'
import { resetStreamWatchdog, clearStreamWatchdog, clearResumeAttempts } from './useAiStream'
import { setStreaming } from './streamingGuard'
@@ -35,6 +35,20 @@ const QUEUED_SEND_ERR_KEY: Record<SendErrorKind, string> = {
other: 'aiChat.toastSendFail',
}
/// B3:发送错误串 → 不可重试错误类型。仅永久性错误返回 fatal 类型(provider_config),
/// 瞬时错误(限流/网络)返回 'unknown' 保持可重试。复用 classifySendError 的额度特征
/// (402/额度/余额→quota)+ 404/未找到/无权限/鉴权等永久性特征,产出 MessageList.canRetry
/// 认可的 fatal 类型,使其隐藏「重试」按钮——避免用户对永久错误(模型不存在/无权访问/
/// 额度耗尽)反复重试必再败。原始错误串仍经 { msg } 保留在气泡文案里。
function permanentErrorType(raw: string): AiErrorType {
const s = String(raw ?? '')
if (classifySendError(s) === 'quota') return 'provider_config'
if (/\b404\b|not[\s_-]?found|不存在|无权|forbidden|\b403\b|unauthor|invalid[\s_-]?api[\s_-]?key|鉴权/i.test(s)) {
return 'provider_config'
}
return 'unknown'
}
/// 待发送队列上限(超过抛错提示用户)
const QUEUE_LIMIT = 10
@@ -321,10 +335,13 @@ export function drainQueue(convId?: string | null) {
// UX-P2-A5:失败气泡接入 classifySendError 分类文案(对齐 ChatInput 发送失败 toast 四类),
// 替代原 raw 错误直出的 ai.queuedSendFailed;原始错误串经 { msg } 细节保留
state.messages.push({
id: `err-${nextMsgId()}`,
id: `err-${nextMsgId()}` as MessageId,
role: 'assistant',
content: t(QUEUED_SEND_ERR_KEY[classifySendError(errMsg)], { msg: errMsg }),
isError: true,
// B3:永久性错误(额度/404/无权)附 fatal errorType,MessageList.canRetry 据此隐藏「重试」;
// 瞬时错误(限流/网络)为 'unknown' 维持可重试。
errorType: permanentErrorType(errMsg),
timestamp: Date.now(),
} as AiMessage)
}
+5 -2
View File
@@ -6,7 +6,7 @@
import { emit } from '@tauri-apps/api/event'
import { state } from '@/stores/ai'
import { t } from '@/i18n/i18n-helpers'
import { findToolCall, clearApprovalTimer, startApprovalTimer, startToolSlowTimer, clearToolSlowTimer, addPendingApproval, removePendingApprovalById } from './aiShared'
import { findToolCall, clearApprovalTimer, startApprovalTimer, startToolSlowTimer, clearToolSlowTimer, addPendingApproval, removePendingApprovalById, bumpToolEpoch } from './aiShared'
import { removePendingDirAuth } from './useAiPendingState'
import { clearStreamWatchdog } from './useAiStream'
import type { AiChatEvent, AiToolCallInfo } from '@/api/types'
@@ -38,6 +38,9 @@ export function handleToolEvent(event: AiChatEvent): boolean {
if (target) {
target.toolCalls = target.toolCalls || []
target.toolCalls.push(info)
// 工具态变更 → 版本号自增(MessageList 收起联动 O(1) 检测;完成/待审批/拒绝经
// aiShared 的 pending 出入口自增,此处仅覆盖「新卡 push」这一不经 pending 的路径)
bumpToolEpoch()
}
}
// 工具开始执行即挂慢执行计时器(超时仅 toast,不动 running 态)
@@ -117,7 +120,7 @@ export function handleToolEvent(event: AiChatEvent): boolean {
const tc = findToolCall(event.id)
if (tc) {
tc.status = 'rejected'
tc.result = t('ai.aiTool.rejectedHint')
tc.result = t('aiTool.rejectedHint')
}
// 移除待审批(含 per-conv 集合)
removePendingApprovalById(event.id)
+9
View File
@@ -72,6 +72,15 @@ export function aggregateCalls(calls: AiToolCallInfo[]): TurnCallStats {
if (isToolFailure(tc)) s.fail++
else s.ok++
break
default: {
// 穷尽性保护:四个状态全部显式处理后,此处 tc.status 收窄为 never;
// 新增状态漏处理时该赋值在编译期报错,防静默漏计(运行时仍可能收到后端新状态)。
const _exhaustive: never = tc.status
void _exhaustive
// 运行时兜底:未知状态仅记 debug 日志,不计数 —— 与旧实现的跳过语义等价(旧无匹配即只计 total)。
console.debug(`[TurnTrace] 未知工具调用状态,跳过计数:${tc.status}`)
break
}
}
}
return s
+13 -2
View File
@@ -75,6 +75,8 @@ let _purify: typeof import('dompurify')['default'] | null = null
const mdReady = ref(false)
// 历史消息整段缓存(文本不变,重渲染命中省去 marked+sanitize 全量解析)
const _mdCache = new Map<string, string>()
// 缓存容量上限:超限按 LRU 淘汰最久未用项(Map 首项),非整体 clear
const _MD_CACHE_LIMIT = 200
/**
* marked code renderer 注入 —— 优先按 infostring 指定语言高亮(需已注册),
@@ -190,9 +192,18 @@ function renderMd(text: string): string {
// 防御性:即使现网 AI 多带围栏,裸行也正确处理(围栏内整段不动)。
const wrapped = wrapNakedDiff(text)
const cached = _mdCache.get(wrapped)
if (cached !== undefined) return cached
if (cached !== undefined) {
// LRU:命中即置为最近使用(delete+set 移到 Map 末尾),淘汰时优先保留热项
_mdCache.delete(wrapped)
_mdCache.set(wrapped, cached)
return cached
}
const html = _purify.sanitize(_marked.parse(wrapped) as string)
if (_mdCache.size > 200) _mdCache.clear()
// 有界 LRU 淘汰:超限删最久未用项(Map 首项)后插入,替代原整体 clear 造成的重复解析抖动
if (_mdCache.size >= _MD_CACHE_LIMIT) {
const oldest = _mdCache.keys().next().value
if (oldest !== undefined) _mdCache.delete(oldest)
}
_mdCache.set(wrapped, html)
return html
}
+2
View File
@@ -31,6 +31,8 @@ export default {
executionFailedHint: 'Tool execution failed, see details below',
// R6: click-to-copy full result path (hover title suffix)
clickToCopy: 'Click to copy full path',
// D2a offloaded result: oversized tool result stored to disk, DB keeps only a handle JSON (expand button reuses expand/collapse)
resultOffloaded: 'Result offloaded',
// Singular expand/collapse (ToolCard single-card content, command output toggle)
expand: 'Expand',
collapse: 'Collapse',
+6
View File
@@ -56,6 +56,12 @@ export default {
auto_blacklist: 'Auto (blacklist)',
auto_takeover_medium: 'Auto (med-takeover)',
auto_takeover_all: 'Auto (full-takeover)',
auto_retry_guard: 'Auto (retry guard)',
auto_resolve_fail: 'Auto (fail resolved)',
boundary_auto: 'Auto (boundary)',
// boundary-dirs is a structured rejection path (hyphenated decided_by), not an auto-allow
'boundary-dirs': 'Boundary dirs',
sub_agent_denied: 'Sub-agent denied',
},
// Tab navigation
tabAudit: 'Approval History',
+4
View File
@@ -2,6 +2,9 @@ export default {
fileExplorer: {
// Tab label
tabTitle: '📁 Files',
// View switcher (tree / changes)
viewTree: 'File tree',
viewChanges: 'Git changes',
// Toolbar
refresh: 'Refresh',
refreshing: 'Refreshing…',
@@ -30,6 +33,7 @@ export default {
outlineGroupOther: 'Others',
// Errors
loadFailed: 'Failed to load',
imageLoadFailed: 'Failed to load image (path: {path})',
// Diff
showDiff: 'Show Diff',
showContent: 'Show Content',
+2
View File
@@ -31,6 +31,8 @@ export default {
executionFailedHint: '工具执行失败,详情见下方',
// R6:工具结果路径可点击复制(hover title 后半段)
clickToCopy: '点击复制完整路径',
// D2a 外置结果:工具结果超阈值落盘,DB 只留句柄 JSON(展开按钮复用 expand/collapse)
resultOffloaded: '结果已外置',
collapse: '收起',
expand: '展开',
diffExpand: '展开全部 {n} 行',
+6
View File
@@ -56,6 +56,12 @@ export default {
auto_blacklist: '自动(黑名单)',
auto_takeover_medium: '自动(中风险接管)',
auto_takeover_all: '自动(全接管)',
auto_retry_guard: '自动(重试守卫)',
auto_resolve_fail: '自动(失败收敛)',
boundary_auto: '自动(边界)',
// boundary-dirs 是结构化拒绝路径(decided_by 含连字符),不属自动放行,标签独立
'boundary-dirs': '边界目录',
sub_agent_denied: '子代理拒绝',
},
// Tab 导航
tabAudit: '审批历史',
+4
View File
@@ -2,6 +2,9 @@ export default {
fileExplorer: {
// Tab 标签
tabTitle: '📁 文件',
// 视图切换(文件树 / 变更)
viewTree: '文件树',
viewChanges: 'Git 变更',
// 工具栏
refresh: '刷新',
refreshing: '刷新中…',
@@ -30,6 +33,7 @@ export default {
outlineGroupOther: '其他',
// 错误
loadFailed: '加载失败',
imageLoadFailed: '图片加载失败(路径: {path})',
// Diff
showDiff: '查看变更',
showContent: '查看内容',
+2
View File
@@ -50,6 +50,8 @@
.status-approved { background: rgba(100,255,218,0.15); color: var(--df-success); }
.status-promoted { background: rgba(108,99,255,0.2); color: var(--df-accent); }
.status-rejected { background: rgba(255,107,107,0.2); color: var(--df-danger); }
/* 归档态(归档箱内才有):中性灰,与 draft 同系;此前列表/详情渲染 .status-archived 无对应色 → 徽章悬空 */
.status-archived { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
/* — 任务状态徽章色(7 态):Tasks 列表 + TaskDetail/ProjectDetail 详情共用 —
WK-6:原仅 TaskDetail/ProjectDetail scoped 定义,Tasks.vue 列表拿不到 → 裸文本。
+33 -5
View File
@@ -142,19 +142,47 @@ html, body, #app {
/* ── UX 走查 P2-1(2026-10-06):Web 规范合规全局两刀 ──
* ① :focus-visible 兜底 outline:全仓 outline:none 25 处散点只删可见环不给补偿,
* 键盘用户失焦可见性;全局一条兜底(仅键盘导航触发,鼠标点击不显),散点无需逐处改。
* ② 弹层 overscroll-behavior:contain:弹层内滚到边界时滚动穿透到底层页面
* (背景跟着晃),全仓此前零命中;约定所有浮层容器类统一滚动锁,新增弹层自动生效。
* 选择器=实证类名清单(2026-10-06 全仓 grep 核对):modal-overlay 10 处 /
* TopBar 四面板 inline-list / ai-more-menu / ConfirmDialog 根。
* ② 滚动容器 overscroll-behavior:contain(2026-10-06 首刀覆盖浮层,G3 2026-10-10 扩到
* 全量滚动容器):内部滚到边界时滚动穿透到底层页面(背景跟着晃),防滚动链穿透。
* 集中一处声明,新增同类滚动容器直接追加选择器即可。遮罩/点外关闭容器与
* @click.stop 菜单容器(module-dropdown-menu)按约束不在此列。
*/
:focus-visible {
outline: 2px solid var(--df-accent);
outline-offset: 1px;
}
/* 浮层容器(2026-10-06 实证类名:modal-overlay / TopBar 四面板 inline-list / ai-more-menu / ConfirmDialog 根) */
.modal-overlay,
.ai-goals-inline-list, .ai-history-inline-list,
.ai-summary-inline-list, .ai-context-inline-list,
.ai-more-menu, .confirm-box {
.ai-more-menu, .confirm-box,
/* 主布局:内容区 + 侧栏导航 */
.df-main, .sidebar-nav,
/* 抽屉 / 弹层 body(Arco Drawer 挂 body) */
.arco-drawer-body, .popup-body,
/* AI 会话滚动区:消息列表 / 会话侧栏 / 运行任务 / JSON·思考·子代理窥视 */
.ai-messages, .ai-conv-list, .ai-running-tasks-list,
.ai-msg-json, .ai-think-full, .ai-agent-peek-body,
/* AI 输入 / 补全浮层 / 上下文面板 */
.ai-input, .ai-skill-popover, .ai-enrichment-panel, .ai-help-required-hint,
/* AI 工具输出块 */
.ai-tool-stream, .ai-tool-result, .ai-tool-cmd-output,
.ai-tool-grep-matches, .ai-tool-grep-context, .ai-tool-dir-entries,
.ai-tool-file-pre--collapsed, .ai-tool-diff-pre, .diff-content,
/* 视图滚动面板 */
.idea-list-panel, .idea-detail-panel,
.kn-detail-panel, .list-cards,
.kanban-col-body,
.detail-overview, .log-list,
.audit-table-wrap, .detail-pre,
.conv-list-panel, .conv-detail-panel, .conv-msg-toolcall-args,
.trash-list, .import-table-wrap,
.explorer-tree, .explorer-changes,
.preview-image, .preview-md, .preview-code-scroll, .preview-diff, .preview-outline-body,
.gc-body, .gc-history-list, .gc-commit-files, .gc-branch-list,
/* 其它滚动容器 */
.error-boundary-stack, .x6-demo__log,
.update-pop-notes, .update-card-notes {
overscroll-behavior: contain;
}
+4 -4
View File
@@ -19,7 +19,7 @@
<span v-if="approvals.length > 1" class="popup-header-badge">{{ approvals.length }}</span>
</div>
<div class="popup-header-title">{{ $t('aiChat.popupTitle') }}</div>
<button class="popup-header-close" :title="$t('common.close')" @click.stop="onClose">
<button class="popup-header-close" :title="$t('common.close')" :aria-label="$t('common.close')" @click.stop="onClose">
<svg aria-hidden="true" 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" />
@@ -51,7 +51,7 @@
</span>
</div>
<!-- 风险说明(后端 build_approval_reason 已含工具/参数/risk 描述) -->
<div v-if="tc.reason" class="popup-item-reason">⚠ {{ tc.reason }}</div>
<div v-if="tc.reason" class="popup-item-reason"><span aria-hidden="true">⚠</span> {{ tc.reason }}</div>
<!-- H1 修复:审批挂起时长提示(对齐 ToolCard: >2min 橙色 / >5min 红色脉冲)。
后端 APPROVAL_TIMEOUT_MS 5min 静默拒绝,浮窗原本无计时,用户误以为还有时间。
基于审批到达时间(popup 首次见到该 tc.id 的时刻)计时,与 ToolCard 同款语义同款样式。 -->
@@ -60,7 +60,7 @@
</div>
<!-- path 类:目录提示 -->
<div v-if="tc.kind === 'path' && tc.dir" class="popup-item-dir">
📁 {{ tc.dir }}
<span aria-hidden="true">📁</span> {{ tc.dir }}
</div>
<!-- 操作按钮组:path 类显 once/session/always/deny 四选项(与 ToolCard/DirAuthDialog 同款);
risk 类显 approve/reject 两选项。IPC 分派统一走 dispatchApprovalIPC。 -->
@@ -529,7 +529,6 @@ function onDragEnd() { isDragging.value = false }
border-radius: var(--df-radius-md);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), 0 0 0 1px var(--df-accent-soft);
overflow: hidden;
user-select: none;
}
.popup-shell[data-dragging="true"] {
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.6), 0 0 0 2px var(--df-accent);
@@ -544,6 +543,7 @@ function onDragEnd() { isDragging.value = false }
background: linear-gradient(135deg, var(--df-accent-bg), transparent);
border-bottom: 0.5px solid var(--df-border, rgba(123, 111, 240, 0.2));
cursor: move;
user-select: none;
}
.popup-header-icon {
position: relative;
+53 -3
View File
@@ -82,7 +82,10 @@
<span v-else class="decided-none">—</span>
</td>
<td class="col-args"><code class="brief">{{ r.arguments_brief || '—' }}</code></td>
<td class="col-result"><code class="brief">{{ r.result_brief || '—' }}</code></td>
<td class="col-result">
<span v-if="hasOffloadMark(r.result_brief)" class="offload-chip">{{ t('aiTool.resultOffloaded') }}</span>
<code v-else class="brief">{{ r.result_brief || '—' }}</code>
</td>
</tr>
<tr v-if="expandedId === r.id" class="audit-detail-row">
<td colspan="7">
@@ -93,7 +96,18 @@
</div>
<div class="detail-section">
<span class="detail-label">{{ t('auditLog.col.result') }}</span>
<pre class="detail-pre">{{ r.result_brief || '—' }}</pre>
<!-- 外置句柄(D2a):显「结果已外置」+ 展开经 IPC 读回原文全文 -->
<template v-if="offloadHandle">
<OffloadedResultBar
:handle="offloadHandle"
:expanded="offloadExpanded"
:loading="offloadLoading"
:error="offloadError"
@toggle="toggleOffload"
/>
<pre v-if="offloadResolved != null" class="detail-pre">{{ offloadResolved }}</pre>
</template>
<pre v-else class="detail-pre">{{ r.result_brief || '—' }}</pre>
</div>
</div>
</td>
@@ -175,6 +189,8 @@ import { invoke } from '@tauri-apps/api/core'
import { formatRelative, formatDate } from '@/utils/time'
import { usePersistedRef } from '@/composables/usePersistedRef'
import { aiApi } from '@/api/ai'
import { hasOffloadMark, useOffloadedResult } from '@/composables/ai/useOffloadedResult'
import OffloadedResultBar from '@/components/OffloadedResultBar.vue'
import type { AiConversationSummary } from '@/api/types'
const { t } = useI18n()
@@ -255,6 +271,20 @@ function toggleExpand(id: string) {
expandedId.value = expandedId.value === id ? null : id
}
// 展开行的外置句柄详情(D2a):同一视图只展开一行,读回状态复用单实例;
// 切行时 reset 防串上一行的读回结果/错误。句柄来源=后端截断的 result_brief。
const expandedRecord = computed(() => records.value.find(r => r.id === expandedId.value) ?? null)
const {
handle: offloadHandle,
expanded: offloadExpanded,
loading: offloadLoading,
error: offloadError,
resolved: offloadResolved,
toggle: toggleOffload,
reset: resetOffload,
} = useOffloadedResult(() => expandedRecord.value?.result_brief ?? null)
watch(expandedId, () => resetOffload())
// 审批→执行 耗时(秒级,毫秒差太精细)
function durationLabel(requested: string, executed: string): string {
const ms = new Date(executed).getTime() - new Date(requested).getTime()
@@ -356,24 +386,32 @@ async function loadConversations() {
convLoading.value = false
}
}
// 对话详情请求序号守卫(对齐上方 _loadSeq 模式):快速切换对话时,
// 旧对话 export 响应晚到不覆盖新对话消息;折叠时自增使在途请求作废
let _convSeq = 0
async function toggleConv(id: string) {
if (expandedConv.value === id) {
_convSeq++ // 折叠:作废在途请求,避免其回填残留
expandedConv.value = null
convMessages.value = []
convLoading.value = false
return
}
const seq = ++_convSeq
expandedConv.value = id
convLoading.value = true
convError.value = ''
try {
const json = await aiApi.exportConversation(id, 'json')
if (seq !== _convSeq) return // 旧响应丢弃,只取最新
const parsed = JSON.parse(json) as ConvMessage[] ?? []
convMessages.value = parsed || []
} catch (e) {
if (seq !== _convSeq) return // 旧请求的失败不污染当前视图
convError.value = String(e)
convMessages.value = []
} finally {
convLoading.value = false
if (seq === _convSeq) convLoading.value = false
}
}
@@ -578,6 +616,18 @@ onMounted(() => {
white-space: pre-wrap;
}
/* D2a 外置句柄:结果列显标记替代截断 JSON(点行展开看详情并可读回全文) */
.offload-chip {
display: inline-block;
font-size: 10px;
font-weight: 600;
padding: 1px 7px;
border-radius: var(--df-radius-xs);
background: var(--df-warning-bg);
color: var(--df-warning);
white-space: nowrap;
}
/* 风险 badge */
.risk-badge {
display: inline-block;
+5 -5
View File
@@ -7,7 +7,7 @@
<span class="dash-subtitle">{{ $t('dashboard.subtitle') }}</span>
</div>
<div class="dash-header-right">
<button class="btn btn-ghost btn-sm dash-refresh-btn" @click="refresh" :disabled="loading" :title="$t('dashboard.refresh')">
<button class="btn btn-ghost btn-sm dash-refresh-btn" @click="refresh" :disabled="loading" :title="$t('dashboard.refresh')" :aria-label="$t('dashboard.refresh')">
<svg aria-hidden="true" v-if="loading" class="dash-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12a9 9 0 11-6.219-8.56"/></svg>
<svg aria-hidden="true" v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>
</button>
@@ -18,7 +18,7 @@
+ 本地加载异常 errorMsg(样式全局 .error-banner) ═══ -->
<div v-if="store.error || errorMsg" class="error-banner" style="margin-bottom: 14px">
<span class="error-text">{{ store.error || errorMsg }}</span>
<button class="error-dismiss" @click="store.clearError(); errorMsg = ''">✕</button>
<button class="error-dismiss" @click="store.clearError(); errorMsg = ''" :aria-label="$t('common.close')">✕</button>
</div>
<!-- ═══ Main Grid ═══ -->
@@ -35,15 +35,15 @@
<!-- ═══ 统计概要(底部一行小字,原独立统计卡已下沉) ═══ -->
<div class="dash-stats-inline">
<span class="dash-stats-item clickable" @click="go('/ideas')" :title="$t('dashboard.viewAll')">
<span class="dash-stats-item clickable" role="button" tabindex="0" @click="go('/ideas')" @keydown.enter.prevent="go('/ideas')" @keydown.space.prevent="go('/ideas')" :title="$t('dashboard.viewAll')">
<span class="dash-stats-num">{{ store.stats.ideas }}</span>{{ $t('dashboard.stats.ideas') }}
</span>
<span class="dash-stats-sep">·</span>
<span class="dash-stats-item clickable" @click="go('/projects')" :title="$t('dashboard.viewAll')">
<span class="dash-stats-item clickable" role="button" tabindex="0" @click="go('/projects')" @keydown.enter.prevent="go('/projects')" @keydown.space.prevent="go('/projects')" :title="$t('dashboard.viewAll')">
<span class="dash-stats-num">{{ store.stats.projects }}</span>{{ $t('dashboard.stats.projects') }}
</span>
<span class="dash-stats-sep">·</span>
<span class="dash-stats-item clickable" @click="go('/tasks')" :title="$t('dashboard.viewAll')">
<span class="dash-stats-item clickable" role="button" tabindex="0" @click="go('/tasks')" @keydown.enter.prevent="go('/tasks')" @keydown.space.prevent="go('/tasks')" :title="$t('dashboard.viewAll')">
<span class="dash-stats-num">{{ store.stats.activeTasks }}</span>{{ $t('dashboard.stats.activeTasks') }}
</span>
<span class="dash-stats-sep">·</span>
+1 -1
View File
@@ -3,7 +3,7 @@
<!-- projectId 来自 URL query(同步可得),缺失时直接给错误态而非无限 spinner -->
<FileExplorer v-if="projectId" :project-id="projectId" />
<div v-else class="fe-detached-error">
<span class="fe-detached-error-icon">⚠️</span>
<span class="fe-detached-error-icon" aria-hidden="true">⚠️</span>
<p>{{ $t('fileExplorer.missingProjectId') }}</p>
</div>
</div>
+3 -1
View File
@@ -11,7 +11,7 @@
<!-- 错误条:消费 store.error(对齐 Knowledge/Projects error-banner,不整表替换列表) -->
<div v-if="store.error" class="error-banner" style="margin-bottom: var(--df-gap-page)">
<span class="error-text">{{ store.error }}</span>
<button class="error-dismiss" @click="store.clearError()">✕</button>
<button class="error-dismiss" @click="store.clearError()" :aria-label="$t('common.close')">✕</button>
</div>
<!-- 搜索和筛选栏 -->
@@ -455,6 +455,8 @@ async function evaluateCurrentIdea() {
evalError.value = ''
try {
await store.evaluateIdea(currentIdea.value.id)
// 评分排序模式下评估会改写分数,store 原地替换不重排 → 按当前视图重载以重排序(时间排序无需重排)
if (sortMode.value === 'score') await loadCurrentView()
} catch (e: any) {
evalError.value = e?.toString() ?? t('ideas.evalFailed')
console.error(t('ideas.evalFailed'), e)
+15 -5
View File
@@ -16,7 +16,7 @@
<!-- 错误条:消费 store.error(样式全局 .error-banner,仅间距用页面变量) -->
<div v-if="store.error" class="error-banner" style="margin-bottom: var(--df-gap-page)">
<span class="error-text">{{ store.error }}</span>
<button class="error-dismiss" @click="clearError">✕</button>
<button class="error-dismiss" @click="clearError" :aria-label="$t('common.close')">✕</button>
</div>
<!-- 顶层 Tab: 知识库 / 审核收件箱 -->
@@ -92,7 +92,7 @@
</div>
</div>
<div v-if="listItems.length === 0" class="list-empty">
<div v-if="listItems.length === 0 && !store.loading" class="list-empty">
<div>{{ topTab === 'inbox' ? $t('knowledge.emptyInbox') : topTab === 'rejected' ? $t('knowledge.emptyRejected') : topTab === 'archived' ? $t('knowledge.emptyArchived') : $t('knowledge.emptyLibrary') }}</div>
</div>
</div>
@@ -113,6 +113,9 @@
@resubmit="resubmitCurrent"
/>
<!-- 详情加载中(切换知识时清空旧详情,避免残留上一条内容;失败走 toast,不再静默) -->
<div v-else-if="detailLoading" class="detail-empty">{{ $t('common.loading') }}</div>
<div v-else class="detail-empty">
<div class="detail-empty-icon">👈</div>
<div>{{ $t('knowledge.detailEmptyHint') }}</div>
@@ -260,16 +263,23 @@ function getCategoryCount(key: string): number {
// ===== 详情选中 + 加载 =====
const selectedId = ref<string | null>(null)
const detail = ref<KnowledgeDetailPayload | null>(null)
// 详情加载态:切换知识期间显示 loading,避免旧详情残留;加载失败 toast 反馈
const detailLoading = ref(false)
// Markdown 渲染器预热保留在父级(模块单例,与 AiChat/TaskDetail/KnowledgeDetail 共享),
// 详情内容渲染由 KnowledgeDetail 子组件通过 useRendered 消费,此处仅 ensureLoaded 预热不阻塞首屏
const { ensureLoaded } = useRendered(() => '')
async function selectKnowledge(id: string) {
selectedId.value = id
detailLoading.value = true
detail.value = null // 清旧详情驱动 loading 态(否则残留上一条内容)
try {
detail.value = await store.getDetail(id)
} catch (e: any) {
console.error('加载详情失败', e)
// 加载失败:详情保持空态 + toast(此前静默 console.error,用户看不到失败)
showToast(e?.toString() ?? t('knowledge.err.loadFailed'), 'error')
} finally {
detailLoading.value = false
}
}
@@ -469,8 +479,8 @@ onMounted(() => {
} else {
store.loadList()
}
// candidates badge 始终预加载(顶部 Tab 计数用)
store.loadCandidates()
// candidates badge 始终预加载(顶部 Tab 计数用);inbox 视图上面分支已加载,跳过避免重复请求
if (topTab.value !== 'inbox') store.loadCandidates()
})
</script>
+7 -1
View File
@@ -317,6 +317,7 @@
</button>
</div>
<div class="task-list">
<div v-if="tasksLoading" class="empty-hint">{{ $t('common.loading') }}</div>
<div
class="task-card"
v-for="task in filteredProjectTasks"
@@ -346,7 +347,7 @@
</div>
</div>
</div>
<div v-if="projectTasks.length === 0" class="empty-hint">{{ $t('projectDetail.emptyTasks') }}</div>
<div v-if="!tasksLoading && projectTasks.length === 0" class="empty-hint">{{ $t('projectDetail.emptyTasks') }}</div>
<!-- 批量操作栏(选中任务后浮现) -->
<TaskBatchBar
v-if="selection.count > 0"
@@ -611,12 +612,15 @@ const assessmentLabel = (recommendation: string): string =>
// 改为按 project_id 走后端 list_tasks {project_id} 独立拉取,存本地 ref。
// TaskQuery.project_id 命中 idx_tasks_project_id(见 api/types.ts:191)。
const projectTasks = ref<TaskRecord[]>([])
// 概览任务列表加载态(O8):加载中显占位,避免与「空」提示混淆(此前长度恒 0 → 首屏先闪空态)
const tasksLoading = ref(false)
// A4[PD-P2-2]:请求序号守卫 —— 快速切项目时旧请求晚到不覆盖新项目数据(闭包捕获 projectId,
// resolve 后比对当前 projectId 相同才赋值)。
let taskReqSeq = 0
async function loadProjectTasks() {
const seq = ++taskReqSeq
const pid = projectId.value
tasksLoading.value = true
try {
const list = await taskApi.list({ project_id: pid })
if (seq !== taskReqSeq || pid !== projectId.value) return
@@ -625,6 +629,8 @@ async function loadProjectTasks() {
if (seq !== taskReqSeq || pid !== projectId.value) return
console.error('加载项目任务失败:', e)
projectTasks.value = []
} finally {
if (seq === taskReqSeq) tasksLoading.value = false
}
}
// taskStatusLabel / taskStatusClass 由 ../constants/project 提供
+6 -4
View File
@@ -9,12 +9,14 @@
class="view-toggle-btn"
:class="{ active: viewMode === 'list' }"
:title="$t('projects.viewList')"
:aria-label="$t('projects.viewList')"
@click="viewMode = 'list'"
>☰</button>
<button
class="view-toggle-btn"
:class="{ active: viewMode === 'card' }"
:title="$t('projects.viewCard')"
:aria-label="$t('projects.viewCard')"
@click="viewMode = 'card'"
>▦</button>
</div>
@@ -28,7 +30,7 @@
<!-- 错误条:消费 store.error(加载/创建/删除失败对用户可见,对齐 Knowledge error-banner) -->
<div v-if="store.error" class="error-banner">
<span class="error-text">{{ store.error }}</span>
<button class="error-dismiss" @click="store.clearError()">✕</button>
<button class="error-dismiss" @click="store.clearError()" :aria-label="$t('common.close')">✕</button>
</div>
<!-- 新建项目模态框 -->
@@ -50,14 +52,14 @@
<button class="btn btn-ghost btn-sm" type="button" @click="pickDir">{{ $t('projects.selectDir') }}</button>
<button v-if="newPath" class="btn btn-ghost btn-sm" type="button" @click="clearDir">{{ $t('projects.clearDir') }}</button>
</div>
<div v-if="pathWarning" class="path-warning">⚠ {{ pathWarning }}</div>
<div v-if="pathWarning" class="path-warning"><span aria-hidden="true">⚠</span> {{ pathWarning }}</div>
<div v-if="newPath" class="scan-row">
<button class="btn btn-ghost btn-sm" type="button" @click="aiScan" :disabled="scanning">
{{ scanning ? $t('projects.aiScanning') : $t('projects.aiScanFill') }}
</button>
<span v-if="scanProjectType" class="tech-tag">{{ scanProjectType }}</span>
</div>
<div v-if="scanError" class="path-warning">⚠ {{ scanError }}</div>
<div v-if="scanError" class="path-warning"><span aria-hidden="true">⚠</span> {{ scanError }}</div>
<div v-if="detectedStack.length" class="card-tags" style="margin-top: 8px">
<span class="tech-tag" v-for="t in detectedStack" :key="t">{{ t }}</span>
</div>
@@ -129,7 +131,7 @@
<!-- 扫描结果表格(只读预览 + 勾选) -->
<div v-if="importScanning" class="import-status">{{ $t('projects.importScanning') }}</div>
<div v-else-if="importError" class="path-warning">⚠ {{ importError }}</div>
<div v-else-if="importError" class="path-warning"><span aria-hidden="true">⚠</span> {{ importError }}</div>
<div v-else-if="scannedItems.length === 0 && importRootPath" class="import-status">{{ $t('projects.importEmpty') }}</div>
<div v-if="scannedItems.length" class="import-table-wrap">
+16 -1
View File
@@ -167,7 +167,10 @@
v-for="child in children"
:key="child.id"
class="subtask-item"
role="button"
tabindex="0"
@click="router.push(`/tasks/${child.id}`)"
@keydown="onSubtaskKeydown($event, child)"
>
<input
type="checkbox"
@@ -179,7 +182,7 @@
<span class="subtask-title">{{ child.title }}</span>
<span class="status-tag" :class="taskStatusClass(child.status)">{{ $t(taskStatusLabel(child.status)) }}</span>
<span class="priority-badge" :class="priorityClass(child.priority)">{{ priorityLabel(child.priority) }}</span>
<button class="subtask-quick-btn" :title="$t('tasks.quickActions')" @click.stop="toggleChildMenu(child.id)">⚙️</button>
<button class="subtask-quick-btn" :title="$t('tasks.quickActions')" :aria-label="$t('tasks.quickActions')" :aria-expanded="childMenuId === child.id" @click.stop="toggleChildMenu(child.id)">⚙️</button>
<!-- 子任务行快捷菜单(复用列表页 quickStatuses/quickPriorities 模式,advance 后刷新子列表) -->
<div v-if="childMenuId === child.id" class="quick-menu" @click.stop>
<div class="quick-menu-section">
@@ -338,6 +341,18 @@ function toggleChildMenu(id: string) {
}
function closeChildMenu() { childMenuId.value = null }
/** 子任务行键盘可达(role=button tabindex=0):Enter/Space 等价点击进入任务详情。
* 仅焦点落在行 div 自身时处理——焦点在内部勾选框/快捷按钮时 target≠currentTarget,
* 避免与其原生语义(空格勾选、按钮激活)双触发。 */
function onSubtaskKeydown(e: KeyboardEvent, child: TaskRecord) {
if (e.isComposing || e.keyCode === 229) return
if (e.target !== e.currentTarget) return
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
router.push(`/tasks/${child.id}`)
}
}
// 复用列表页 quickStatuses/quickPriorities 模式(子任务行快捷推进)。
// 基础列表 = TASK_STATUS_ORDER 全 8 态(含 deferred/cancelled,快捷菜单经 ADVANCE_MAP 过滤后只显合法项)。
const STATUS_FILTER_META: Record<string, { icon: string; labelKey: string }> = {
+23 -6
View File
@@ -17,7 +17,7 @@
<!-- 错误条:消费 store.error(对齐 Knowledge/Projects error-banner,不整表替换列表) -->
<div v-if="store.error" class="error-banner" style="margin-bottom: var(--df-gap-page)">
<span class="error-text">{{ store.error }}</span>
<button class="error-dismiss" @click="store.clearError()">✕</button>
<button class="error-dismiss" @click="store.clearError()" :aria-label="$t('common.close')">✕</button>
</div>
<!-- 筛选栏(紧凑下拉) -->
@@ -91,7 +91,15 @@
class="task-group"
:class="{ collapsed: collapsedGroups.has(group.projectName) }"
>
<div class="group-header" @click="toggleGroup(group.projectName)">
<div
class="group-header"
role="button"
tabindex="0"
:aria-expanded="!collapsedGroups.has(group.projectName)"
@click="toggleGroup(group.projectName)"
@keydown.enter.prevent="toggleGroup(group.projectName)"
@keydown.space.prevent="toggleGroup(group.projectName)"
>
<span class="group-chevron">{{ collapsedGroups.has(group.projectName) ? '▸' : '▾' }}</span>
<span class="group-icon">{{ group.icon }}</span>
<h2 class="group-name">{{ group.projectName }}</h2>
@@ -140,7 +148,7 @@
</div>
<div class="task-actions">
<span class="status-tag" :class="taskStatusClass(row.task.status)">{{ $t(statusLabel(row.task.status)) }}</span>
<button class="task-quick-btn" :title="$t('tasks.quickActions')" @click.stop="toggleQuickMenu(row.task.id)">⚙️</button>
<button class="task-quick-btn" :title="$t('tasks.quickActions')" :aria-label="$t('tasks.quickActions')" @click.stop="toggleQuickMenu(row.task.id)">⚙️</button>
<div v-if="quickMenuId === row.task.id" class="quick-menu" @click.stop>
<div class="quick-menu-section">
<div class="quick-menu-label">{{ $t('tasks.quickStatus') }}</div>
@@ -197,7 +205,7 @@
</div>
<div class="task-actions">
<span class="status-tag" :class="taskStatusClass(child.status)">{{ $t(statusLabel(child.status)) }}</span>
<button class="task-quick-btn" :title="$t('tasks.quickActions')" @click.stop="toggleQuickMenu(child.id)">⚙️</button>
<button class="task-quick-btn" :title="$t('tasks.quickActions')" :aria-label="$t('tasks.quickActions')" @click.stop="toggleQuickMenu(child.id)">⚙️</button>
<div v-if="quickMenuId === child.id" class="quick-menu" @click.stop>
<div class="quick-menu-section">
<div class="quick-menu-label">{{ $t('tasks.quickStatus') }}</div>
@@ -245,7 +253,7 @@
</div>
<div class="task-actions">
<span class="status-tag" :class="taskStatusClass(row.task.status)">{{ $t(statusLabel(row.task.status)) }}</span>
<button class="task-quick-btn" :title="$t('tasks.quickActions')" @click.stop="toggleQuickMenu(row.task.id)">⚙️</button>
<button class="task-quick-btn" :title="$t('tasks.quickActions')" :aria-label="$t('tasks.quickActions')" @click.stop="toggleQuickMenu(row.task.id)">⚙️</button>
<div v-if="quickMenuId === row.task.id" class="quick-menu" @click.stop>
<div class="quick-menu-section">
<div class="quick-menu-label">{{ $t('tasks.quickStatus') }}</div>
@@ -298,7 +306,14 @@
:title="parentIdSet.has(task.id) ? $t('tasks.kanban.parentNoDrag') : undefined"
>
<div class="task-card-top">
<span class="task-card-title" @click.stop="openDrawer(task.id)">{{ task.title }}</span>
<span
class="task-card-title"
role="button"
tabindex="0"
@click.stop="openDrawer(task.id)"
@keydown.enter.prevent="openDrawer(task.id)"
@keydown.space.prevent="openDrawer(task.id)"
>{{ task.title }}</span>
</div>
<div class="task-card-meta">
<span v-if="task.parent_id" class="parent-tag" :title="parentTitle(task.parent_id)">⤷ {{ parentTitle(task.parent_id) }}</span>
@@ -1241,6 +1256,8 @@ onUnmounted(() => {
transition: border-color 0.15s;
}
.filter-select:hover { border-color: var(--df-accent); }
/* 聚焦可见环:仅 hover 有变化、聚焦态无边框变化,且 scoped 选择器特异性高于全局 :focus-visible 兜底,须就地补环 */
.filter-select:focus-visible { outline: 2px solid var(--df-accent); outline-offset: 1px; }
.filter-chip-group {
display: flex;
flex-wrap: wrap;