diff --git a/crates/df-storage/src/crud/message_repo.rs b/crates/df-storage/src/crud/message_repo.rs index 8eb7dde..39ae826 100644 --- a/crates/df-storage/src/crud/message_repo.rs +++ b/crates/df-storage/src/crud/message_repo.rs @@ -265,6 +265,12 @@ impl AiMessageRepo { /// 全量重写语义——以入参 records 为该对话的**唯一真相**,先删该 conv 全部旧行再批量插。 /// 单事务保证「删 + 插」原子,无中间空窗(reload 不会读到半删半插的中间态)。 /// + /// created_at 保出生时间(2026-10-17 修复批量污染):created_at 语义 = 消息首次入库时间, + /// 全量重写只该变内容不变出生时间。调用方(save_conversation)对整批 records 统一盖 + /// 对话级 created_at(老对话 None 时 now 兜底),直插会把全对话消息 created_at 抹成同一秒。 + /// 故 DELETE 前先取存量 `id → created_at` 映射,重插时同 id 行回填原值;新 id(真新消息) + /// 保留入参值。ai_messages 表无 updated_at 列,内容变更无单独时间戳(不在此列语义内)。 + /// /// 设计权衡(非 dirty 增量,留 P2.1 优化): /// - 内存 ContextManager 是运行时真相源,save 是同步点,每轮 save 全量回写简单可靠; /// - INSERT OR IGNORE 幂等:records 内 id 重复或与残留行(理论不应有,事务已 DELETE)冲突跳过; @@ -274,7 +280,7 @@ impl AiMessageRepo { pub async fn replace_conversation( &self, conv_id: &str, - records: Vec, + mut records: Vec, ) -> Result<()> { let conn = self.conn.clone(); let conv_id = conv_id.to_owned(); @@ -282,7 +288,29 @@ impl AiMessageRepo { let mut guard = conn.blocking_lock(); let tx = guard.transaction().map_err(storage_err)?; { - // 先删该 conv 全部旧行(全量重写语义) + // 先取存量 id → created_at 映射(DELETE 前留证,重插回填保出生时间) + { + let mut stmt = tx + .prepare("SELECT id, created_at FROM ai_messages WHERE conversation_id = ?1") + .map_err(storage_err)?; + let rows = stmt + .query_map(params![conv_id], |r| { + Ok((r.get::<_, String>("id")?, r.get::<_, String>("created_at")?)) + }) + .map_err(storage_err)?; + let mut original: std::collections::HashMap = + std::collections::HashMap::new(); + for r in rows { + let (id, created_at) = r.map_err(storage_err)?; + original.insert(id, created_at); + } + for rec in &mut records { + if let Some(orig) = original.get(&rec.id) { + rec.created_at = orig.clone(); + } + } + } + // 再删该 conv 全部旧行(全量重写语义) tx.execute( "DELETE FROM ai_messages WHERE conversation_id = ?1", params![conv_id], @@ -693,6 +721,62 @@ mod tests { assert_eq!(got.len(), 1, "重复 replace 不应叠加"); } + /// replace_conversation 保 created_at 出生时间(2026-10-17 批量污染回归): + /// 调用方对整批统一盖一个 created_at(如对话级时间或 now),重写后既有 id 行 + /// 的 created_at 必须逐行保留原入库值,不得被统一时间抹平;新 id 行保留入参值。 + #[tokio::test] + async fn replace_conversation_preserves_created_at() { + let db = Database::open_in_memory().await.expect("open_in_memory"); + let repo = AiMessageRepo::new(&db); + + // 预置两行,created_at 各异(模拟真实逐条入库的出生时间) + let mut m0 = mk_msg("m0", "c", 0, "user", "v1"); + m0.created_at = "2026-10-01T10:00:00.000".into(); + let mut m1 = mk_msg("m1", "c", 1, "assistant", "v1"); + m1.created_at = "2026-10-02T11:30:00.000".into(); + repo.insert_batch(vec![m0, m1]).await.expect("insert"); + + // 重写:既有 id 内容变了、created_at 被调用方统一盖成同一时间; + // 另带一条新 id 消息(真新消息,该保留入参时间)。 + let uniform_ts = "2026-10-17T21:25:01.000"; + let rewrite = |id: &str, seq: i64, role: &str, content: &str| { + let mut m = mk_msg(id, "c", seq, role, content); + m.created_at = uniform_ts.into(); + m + }; + repo.replace_conversation( + "c", + vec![ + rewrite("m0", 0, "user", "v2-改写内容"), + rewrite("m1", 1, "assistant", "v2-改写内容"), + rewrite("m2_new", 2, "user", "新消息"), + ], + ) + .await + .expect("replace"); + + let got = repo.list_by_conversation("c").await.expect("list"); + assert_eq!(got.len(), 3); + // 逐行断言:既有 id 的 created_at 保持原值,内容确实变了 + assert_eq!(got[0].id, "m0"); + assert_eq!(got[0].created_at, "2026-10-01T10:00:00.000", "既有行 created_at 不得被重写抹平"); + assert_eq!(got[0].content, "v2-改写内容"); + assert_eq!(got[1].id, "m1"); + assert_eq!(got[1].created_at, "2026-10-02T11:30:00.000", "既有行 created_at 不得被重写抹平"); + assert_eq!(got[1].content, "v2-改写内容"); + // 新 id(首次入库)保留入参 created_at + assert_eq!(got[2].id, "m2_new"); + assert_eq!(got[2].created_at, uniform_ts, "新消息保留入参 created_at"); + + // 二次重写再验:出生时间已固化,连续重写不漂移 + repo.replace_conversation("c", vec![rewrite("m0", 0, "user", "v3")]) + .await + .expect("replace again"); + let got = repo.list_by_conversation("c").await.expect("list"); + assert_eq!(got.len(), 1); + assert_eq!(got[0].created_at, "2026-10-01T10:00:00.000", "连续重写 created_at 不漂移"); + } + /// limit=0 契约:返空集(直传 SQLite LIMIT 0),不再旧版 max(1) 钳成 1 条。 /// 锁死语义防回归:请求 0 条却回 1 条会让调用方误判页内有数据。 #[tokio::test] diff --git a/crates/df-storage/src/crud/project_repo.rs b/crates/df-storage/src/crud/project_repo.rs index 0368de3..54112da 100644 --- a/crates/df-storage/src/crud/project_repo.rs +++ b/crates/df-storage/src/crud/project_repo.rs @@ -87,9 +87,9 @@ fn build_order_clause(order_by: Option<&str>) -> Result { /// 带「最近活跃时间」的项目记录(list_active_with_activity 专用返回结构)。 /// -/// `last_active_at` = COALESCE(project_events 最新事件 created_at, projects.updated_at): -/// 反映业务活跃(任务/灵感/状态推进等事件),无事件项目回退 updated_at。供前端「最近活跃排序」 -/// 展示,语义比 projects.updated_at(随元信息修改如改 description 也会刷新)更准确。 +/// `last_active_at` = MAX(tasks.updated_at 最新值, project_events 最新 created_at, +/// projects.updated_at):任务活跃为主信号(任务面是真实工作发生地),事件表已停写仅保留维度, +/// updated_at 兜底。供前端「最近活跃排序」展示与 system prompt 项目清单排序。 /// /// Serialize 供 IPC 层直接序列化回前端(serde 字段名 snake_case,对齐 ProjectRecord)。 #[derive(Debug, Clone, serde::Serialize)] @@ -243,29 +243,35 @@ impl ProjectRepo { .map_err(storage_err)? } - /// 列出未删除项目,**按「最近活跃」排序**(LEFT JOIN project_events 取最新事件时间, - /// COALESCE 回退 updated_at)。 + /// 列出未删除项目,**按「最近活跃」排序**(三源取大:任务活跃为主信号)。 /// - /// 业务语义:项目「最近活跃」应反映业务事件(任务/灵感/状态推进),而非 projects.updated_at - /// (后者随元信息修改如改 description/path 也会刷新,不反映真实业务活跃)。本方法通过子查询 - /// 取每个项目 project_events 最新 created_at,无事件项目回退 updated_at,排序列统一可比。 + /// 业务语义:last_active_at = MAX(tasks.updated_at 最新值, project_events 最新 created_at, + /// projects.updated_at)。**任务活跃为主信号**——ai-chat 会话/任务面是真实工作发生地, + /// 任务停更即项目停更;project_events 已停写(现网空表,保留维度向后兼容); + /// projects.updated_at 仅元信息修改(改 description/path 也会刷新,不反映真实业务活跃), + /// 仅作兜底。无任务无事件的项目回退 updated_at,排序列统一可比。 /// - /// 子查询而非 JOIN:project_events 每项目可能 0..N 条,JOIN 会展开需 DISTINCT,子查询 - /// `(SELECT MAX(created_at) FROM project_events WHERE project_id = projects.id)` 每行一次 - /// 聚合,语义清晰无笛卡尔积风险。单用户桌面应用项目数小,无性能压力。 + /// 子查询而非 JOIN:tasks/project_events 每项目可能 0..N 条,JOIN 会展开需 DISTINCT, + /// 标量子查询每行一次聚合,语义清晰无笛卡尔积风险(SQLite 多参 MAX 为标量取大, + /// IFNULL 兜空子查询 NULL)。单用户桌面应用项目数小,无性能压力。 /// - /// 返回 `ProjectActivityRecord`(ProjectRecord + last_active_at 字段),供前端排序展示。 + /// 返回 `ProjectActivityRecord`(ProjectRecord + last_active_at 字段),供前端排序展示 + /// 与 system prompt「当前项目」清单(build_context_lists)按活跃度排序。 pub async fn list_active_with_activity(&self) -> Result> { let conn = self.conn.clone(); tokio::task::spawn_blocking(move || { let guard = conn.blocking_lock(); - // COALESCE(子查询最新事件, projects.updated_at):无事件项目回退 updated_at, - // 保证所有项目有可比活跃时间。ORDER BY 该列 DESC,同时间 created_at DESC 兜底稳定。 + // 三源取大(SQLite 多参 MAX=标量取大,IFNULL 兜空子查询):任务活跃为主信号, + // 事件表已停写仅保留维度,updated_at 兜底。ORDER BY 该列 DESC,同时间 created_at DESC + // 兜底稳定。 let mut stmt = guard .prepare( "SELECT id, name, description, status, idea_id, path, stack, archived, created_at, updated_at, \ - COALESCE((SELECT MAX(created_at) FROM project_events WHERE project_id = projects.id), \ - updated_at) AS last_active_at \ + MAX(\ + IFNULL((SELECT MAX(updated_at) FROM tasks WHERE project_id = projects.id AND deleted_at IS NULL), 0), \ + IFNULL((SELECT MAX(created_at) FROM project_events WHERE project_id = projects.id), 0), \ + updated_at\ + ) AS last_active_at \ FROM projects WHERE deleted_at IS NULL AND archived = 0 \ ORDER BY last_active_at DESC, created_at DESC", ) @@ -840,4 +846,62 @@ mod tests { assert_eq!(after.name, "原项目", "回收站项目内容不应被改写"); assert_eq!(after.updated_at, current.updated_at, "updated_at 不应抖动"); } + + #[tokio::test] + async fn list_active_with_activity_orders_by_task_activity_over_created_at() { + // 回归(事故形状):项目 A created_at 较晚(创建序排前)但无任务/停更;项目 B + // created_at 较早但有一条 tasks.updated_at 最新 → 活跃序 B 排 A 前 + // (任务活跃为主信号,治创建序误导 AI 把停摆项目当最高优先锚点)。 + use crate::crud::task_repo::TaskRepo; + use crate::models::TaskRecord; + use df_types::types::TaskStatus; + + let db = Database::open_in_memory().await.expect("open_in_memory"); + let projects = ProjectRepo::new(&db); + let tasks = TaskRepo::new(&db); + + // B 先建(早),A 后建(晚)——创建序会排 A 前 + let mut early = prec("pb", "早建但活跃"); + early.created_at = "1700000000000".to_string(); + early.updated_at = "1700000000000".to_string(); + let mut late = prec("pa", "晚建但停更"); + late.created_at = "1700000010000".to_string(); + late.updated_at = "1700000010000".to_string(); + projects.insert(early).await.unwrap(); + projects.insert(late).await.unwrap(); + + // B 挂一条最新任务;A 无任务 → B 活跃时间取任务 updated_at,A 回退自身 updated_at + let task = TaskRecord { + id: "tb".to_string(), + project_id: "pb".to_string(), + title: "B 的任务".to_string(), + description: String::new(), + status: TaskStatus::InProgress, + priority: 0, + branch_name: None, + assignee: None, + workflow_def_id: None, + base_branch: None, + review_rounds: 0, + output_json: None, + idea_id: None, + queue: "todo".to_string(), + parent_id: None, + content_json: None, + module_id: None, + archived: false, + created_at: "1700000000000".to_string(), + updated_at: "1790000000000".to_string(), + }; + tasks.insert(task).await.unwrap(); + + let listed = projects.list_active_with_activity().await.unwrap(); + assert_eq!(listed.len(), 2); + assert_eq!(listed[0].record.id, "pb", "任务活跃最新的早建项目应排前"); + assert_eq!(listed[1].record.id, "pa", "无任务的晚建项目应排后"); + assert_eq!( + listed[0].last_active_at, "1790000000000", + "活跃时间应取该项目任务 updated_at 最新值" + ); + } } diff --git a/src-tauri/src/commands/ai/commands/conversation.rs b/src-tauri/src/commands/ai/commands/conversation.rs index 0aa4b8b..b01f25f 100644 --- a/src-tauri/src/commands/ai/commands/conversation.rs +++ b/src-tauri/src/commands/ai/commands/conversation.rs @@ -232,14 +232,24 @@ pub async fn ai_conversation_create( /// /// 截断按组独立:活跃/归档各自取 limit,归档项不挤占活跃名额(侧栏归档分组数据源依赖 /// include_archived=true,见 useAiConversations.loadConversations)。 +/// +/// F4-1(2026-10-17)分页:active_offset/archived_offset 按组跳过前 N 条(默认 0=第一页, +/// 旧调用方零改动)。治「会话列表+搜索硬上限 50」——实测 115 条活跃主会话 57% 在桌面 +/// 端零入口;侧栏首屏仍一页(50/组)保启动性能,「加载更多」按 offset 续拉,搜索前前端 +/// 逐页拉满(搜索跑在已加载列表上,截断列表=搜索失灵)。offset 超组长度返回该组空页 +/// (自然收敛,无需显式 has_more 字段:页内某组满额 limit 即可能还有下一页)。 #[tauri::command] pub async fn ai_conversation_list( state: State<'_, AppState>, limit: Option, include_archived: Option, + active_offset: Option, + archived_offset: Option, ) -> Result, String> { let limit = limit.unwrap_or(50); let include_archived = include_archived.unwrap_or(false); + let active_offset = active_offset.unwrap_or(0); + let archived_offset = archived_offset.unwrap_or(0); let records = state.ai_conversations.list_all().await.map_err(err_str)?; // list_all 已按 created_at DESC(最新在前);默认排除归档 + 截断 limit。 // origin 非空(子代理轨迹,V44)不进侧栏列表——轨迹在库可追溯,展示面只留主会话。 @@ -273,20 +283,25 @@ pub async fn ai_conversation_list( "loop_state": r.loop_state, }) }).collect(); - // 分组截断:活跃/归档各自独立取 limit,防归档项挤占活跃名额(前端侧栏活跃列表被截空)。 - // 原单 take(limit) 在 include_archived=true 时归档项混排会占满 50 名额。 - let mut active = Vec::with_capacity(limit); - let mut archived = Vec::with_capacity(limit); - for s in summaries { - if s.get("archived").and_then(|v| v.as_bool()) == Some(true) { - if archived.len() < limit { archived.push(s); } - } else if active.len() < limit { - active.push(s); - } - } - // 活跃在前(侧栏主区),归档在后(归档分组);各自保持 created_at DESC 原序。 - active.extend(archived); - Ok(active) + Ok(paginate_by_group(summaries, limit, active_offset, archived_offset)) +} + +/// F4-1:摘要按组独立分页——活跃/归档各自 skip(offset)+take(limit),活跃在前归档在后, +/// 组内保持传入原序(created_at DESC)。offset=0 与旧「分组截断」口径逐字节等价 +/// (回归保护见 paginate_first_page_matches_legacy_grouped_truncation)。 +/// 抽纯函数供单测:分组正确性/offset 续拉/越界空页/归档不挤占活跃名额。 +fn paginate_by_group( + summaries: Vec, + limit: usize, + active_offset: usize, + archived_offset: usize, +) -> Vec { + let (active, archived): (Vec<_>, Vec<_>) = summaries + .into_iter() + .partition(|s| s.get("archived").and_then(|v| v.as_bool()) != Some(true)); + let mut page: Vec = active.into_iter().skip(active_offset).take(limit).collect(); + page.extend(archived.into_iter().skip(archived_offset).take(limit)); + page } /// B3(无人值守可靠性专项):列出可恢复会话(loop_state='parked')。 @@ -391,11 +406,25 @@ pub(crate) async fn recover_parked_inner( .await } +/// 恢复产物前缀:恢复链自身生成的引导消息以它开头(恢复产物非用户意图,任何情况下 +/// 都不该作为有效目标回放——2026-10-15 目标语义重构约定)。递归套娃根因:恢复消息 +/// 经 begin_turn(chat.rs:950/2461)被记为 WorkingContext Active 目标落库,下次恢复 +/// 回放时被当有效目标嵌套进新恢复消息,逐层加深(实证 conv 76730ce8 seq 250: +/// 目标文本=上一条恢复消息原文截 200 字符)。防线收敛在恢复链单点: +/// parse_pinned_goals 反解剔除 + build_recovery_message 回放剔除,同判据。 +pub(crate) const RECOVERY_ARTIFACT_PREFIX: &str = "[系统恢复]"; + +/// 恢复产物判定:目标文本以恢复前缀开头即恢复链自产物,不作为有效目标。 +fn is_recovery_artifact(text: &str) -> bool { + text.starts_with(RECOVERY_ARTIFACT_PREFIX) +} + /// 目标快照:按最终有效意图生成 parked 恢复引导消息。 /// /// 「按快照续不重放历史目标流」:取 WorkingContext.goals 与 pinned_goals 中仍 Active 的 -/// 目标 + unresolved 未解项;中途被调整作废(Cancelled)或已完成(Completed)的目标不复活。 -/// 快照全空(旧会话无快照)回退 [`PARKED_RECOVERY_MESSAGE`],行为同今日。 +/// 目标 + unresolved 未解项;中途被调整作废(Cancelled)或已完成(Completed)的目标不复活; +/// 恢复产物([系统恢复] 前缀)剔除——回放它即递归套娃,历史污染目标在此单点拦截。 +/// 快照全空(旧会话无快照/全被过滤)回退 [`PARKED_RECOVERY_MESSAGE`],行为同今日。 /// 不另立存储:快照即 working_context_json + pinned_goals 两列的语义化读取(单一真值来源)。 pub(crate) fn build_recovery_message( working_context: &df_ai::context_helpers::WorkingContext, @@ -405,12 +434,18 @@ pub(crate) fn build_recovery_message( let mut seen = std::collections::HashSet::new(); let mut active: Vec<&str> = Vec::new(); for g in &working_context.goals { - if matches!(g.status, GoalStatus::Active) && seen.insert(g.text.clone()) { + if matches!(g.status, GoalStatus::Active) + && !is_recovery_artifact(&g.text) + && seen.insert(g.text.clone()) + { active.push(&g.text); } } for g in pinned_goals { - if matches!(g.status, crate::commands::ai::GoalStatus::Active) && seen.insert(g.text.clone()) { + if matches!(g.status, crate::commands::ai::GoalStatus::Active) + && !is_recovery_artifact(&g.text) + && seen.insert(g.text.clone()) + { active.push(&g.text); } } @@ -442,7 +477,8 @@ pub(crate) fn build_recovery_message( /// pinned_goals 落库反解(新旧格式兼容):新格式 [{text,status}] 保 status, /// 旧格式 ["text"] 转 Active;非数组/坏 JSON 一律空(降级不炸)。switch 恢复与 -/// parked 恢复共用(DRY,单一事实源)。 +/// parked 恢复共用(DRY,单一事实源)。恢复产物([系统恢复] 前缀)剔除——存量 +/// 污染目标不进内存,switch 载入后下轮 save 重写 pinned_goals 即自愈落库。 pub(crate) fn parse_pinned_goals(raw: Option<&str>) -> Vec { let parsed: Option = raw.and_then(|s| serde_json::from_str(s).ok()); match parsed { @@ -468,6 +504,7 @@ pub(crate) fn parse_pinned_goals(raw: Option<&str>) -> Vec vec![], } @@ -1477,6 +1514,73 @@ mod tests { assert_eq!(back.timestamp, Some(123)); } + // ============================================================ + // F4-1 会话列表分页(paginate_by_group:活跃/归档各自 offset+limit) + // ============================================================ + + fn conv_val(id: &str, archived: bool) -> serde_json::Value { + serde_json::json!({ "id": id, "archived": archived }) + } + + /// 首页(offset=0)与旧「分组截断」口径等价:各组前 limit 条,活跃在前归档在后。 + #[test] + fn paginate_first_page_matches_legacy_grouped_truncation() { + // 60 活跃 + 60 归档,limit 50:旧口径各取 50(归档不挤占活跃名额) + let summaries: Vec<_> = (0..60).map(|i| conv_val(&format!("a{i}"), false)) + .chain((0..60).map(|i| conv_val(&format!("r{i}"), true))) + .collect(); + let page = paginate_by_group(summaries, 50, 0, 0); + assert_eq!(page.len(), 100, "各组独立取满 50"); + assert!(page.iter().take(50).all(|s| s["archived"] == false), "前 50 全活跃"); + assert!(page.iter().skip(50).all(|s| s["archived"] == true), "后 50 全归档"); + assert_eq!(page[0]["id"], "a0"); + assert_eq!(page[49]["id"], "a49"); + assert_eq!(page[50]["id"], "r0"); + assert_eq!(page[99]["id"], "r49"); + } + + /// offset 续拉:第二页从各组 offset 处继续,两组独立推进互不影响 + /// (115 活跃场景:第一页 a0..a49,第二页 active_offset=50 拉 a50..a99)。 + #[test] + fn paginate_offset_advances_each_group_independently() { + let summaries: Vec<_> = (0..115).map(|i| conv_val(&format!("a{i}"), false)) + .chain((0..3).map(|i| conv_val(&format!("r{i}"), true))) + .collect(); + // 第二页:活跃 offset 50(满页 50 条),归档 offset 0(仅 3 条全回) + let page2 = paginate_by_group(summaries.clone(), 50, 50, 0); + assert_eq!(page2.len(), 53); + assert_eq!(page2[0]["id"], "a50"); + assert_eq!(page2[49]["id"], "a99"); + assert_eq!(page2[50]["id"], "r0", "归档组从 0 起独立分页,不被活跃 offset 影响"); + // 第三页:活跃 offset 100(尾页 15 条),归档 offset 3(越界空) + let page3 = paginate_by_group(summaries, 50, 100, 3); + assert_eq!(page3.len(), 15); + assert_eq!(page3[0]["id"], "a100"); + assert_eq!(page3[14]["id"], "a114"); + assert!(page3.iter().all(|s| s["archived"] == false), "归档越界返回该组空页"); + } + + /// offset 超组长度的组返回空页(自然收敛);两组同时越界返回空 Vec。 + #[test] + fn paginate_offset_beyond_length_yields_empty() { + let summaries = vec![conv_val("a0", false), conv_val("r0", true)]; + assert!(paginate_by_group(summaries.clone(), 50, 5, 0).iter().all(|s| s["archived"] == true)); + assert!(paginate_by_group(summaries.clone(), 50, 0, 5).iter().all(|s| s["archived"] == false)); + assert!(paginate_by_group(summaries, 50, 5, 5).is_empty()); + } + + /// 输入混排(归档穿插在活跃间,list_all 全量 DESC 实际形态)时 partition 仍按组归位, + /// 组内保持传入原序。 + #[test] + fn paginate_partition_from_interleaved_input() { + let summaries = vec![ + conv_val("a0", false), conv_val("r0", true), conv_val("a1", false), conv_val("r1", true), + ]; + let page = paginate_by_group(summaries, 50, 0, 0); + let ids: Vec<&str> = page.iter().map(|s| s["id"].as_str().unwrap()).collect(); + assert_eq!(ids, vec!["a0", "a1", "r0", "r1"], "活跃全量在前(原序),归档全量在后(原序)"); + } + // ============================================================ // F-260619-03 批次 B 端到端 round-trip(写路径全链:映射 → replace → list → 还原) // ============================================================ @@ -1789,4 +1893,72 @@ mod tests { assert!(parse_pinned_goals(Some("not-json")).is_empty()); assert!(parse_pinned_goals(None).is_empty()); } + + // ── 恢复产物过滤(递归套娃防线,conv 76730ce8 seq 250 实证) ── + + /// 构造一条上一代恢复消息全文(截 200 字符,复刻 begin_turn 误记目标的现场形态)。 + fn prev_recovery_text() -> String { + let full = format!( + "{} 应用重启导致上次任务中断,已完成进度已保留。请按以下目标快照继续完成未竟任务:\n\n有效目标:\n- 检查 各个 u-claw 实例状态\n\n若快照不足以判断进度,先向用户确认已完成步骤再继续。", + RECOVERY_ARTIFACT_PREFIX + ); + full.chars().take(200).collect() + } + + #[test] + fn recovery_message_zero_nesting_with_polluted_goals() { + use df_ai::context_helpers::GoalStatus; + // 嵌套恢复现场:WorkingContext.goals 与 pinned_goals 都躺着上一条恢复消息全文, + // 两侧 Active——未过滤时它会被当有效目标嵌进新恢复消息(套娃逐层加深)。 + let wc = wc_with_goals(&[ + (&prev_recovery_text(), GoalStatus::Active), + ("交付 v2 登录接口", GoalStatus::Active), + ]); + let pinned = vec![ + crate::commands::ai::GoalEntry::new(prev_recovery_text()), + crate::commands::ai::GoalEntry::new("补齐单测覆盖".to_string()), + ]; + let msg = build_recovery_message(&wc, &pinned); + // 输出只允许保留消息头一处恢复前缀(零套娃),正常用户目标不受影响 + assert_eq!( + msg.matches(RECOVERY_ARTIFACT_PREFIX).count(), + 1, + "恢复产物目标必须被剔除,输出仅消息头一处前缀: {msg}" + ); + assert!(msg.contains("交付 v2 登录接口"), "正常用户目标须保留: {msg}"); + assert!(msg.contains("补齐单测覆盖"), "正常钉扎目标须保留: {msg}"); + assert!( + !msg.contains("有效目标:\n- [系统恢复]"), + "上一代恢复消息不得作为目标行回放: {msg}" + ); + } + + #[test] + fn recovery_message_all_polluted_falls_back_to_constant() { + use df_ai::context_helpers::GoalStatus; + // 快照只剩恢复产物(历史最深套娃态):全部过滤后回退常量,套娃链在此截断 + let wc = wc_with_goals(&[(&prev_recovery_text(), GoalStatus::Active)]); + let pinned = vec![crate::commands::ai::GoalEntry::new(prev_recovery_text())]; + assert_eq!( + build_recovery_message(&wc, &pinned), + PARKED_RECOVERY_MESSAGE, + "全污染快照过滤后按空快照回退常量" + ); + } + + #[test] + fn parse_pinned_goals_drops_recovery_artifacts_idempotent() { + // 存量污染反解剔除:恢复前缀条目不进内存(幂等——清洗后再解析结果不变) + let polluted = serde_json::to_string(&vec![ + crate::commands::ai::GoalEntry::new(prev_recovery_text()), + crate::commands::ai::GoalEntry::new("正常目标保留".to_string()), + ]) + .unwrap(); + let parsed = parse_pinned_goals(Some(&polluted)); + assert_eq!(parsed.len(), 1, "恢复产物条目须剔除: {parsed:?}"); + assert_eq!(parsed[0].text, "正常目标保留"); + // 幂等:清洗结果重解析不变 + let cleaned = serde_json::to_string(&parsed).unwrap(); + assert_eq!(parse_pinned_goals(Some(&cleaned)).len(), 1); + } } diff --git a/src-tauri/src/commands/ai/prompt.rs b/src-tauri/src/commands/ai/prompt.rs index f0a3dec..f2115e3 100644 --- a/src-tauri/src/commands/ai/prompt.rs +++ b/src-tauri/src/commands/ai/prompt.rs @@ -127,6 +127,7 @@ fn system_prompt_parts(lang: &str) -> (String, &'static str, &'static str) { - {}\n\ - {}\n\ - {}\n\ + - Response style: lead with the conclusion and the action/result; omit restating the user's question, pleasantries, and summary closers; prefer bullet points over paragraphs; do not expand on what was not asked. Explain whenever explanation is needed — cut filler, not substance.\n\ ## Focus\n\ - Always center your response on the core goal of the user's current request; the previous round's topic is only background, not the current task.\n\ - When the user switches topics (a clear new intent), follow the latest request; do not drag the old topic into the new answer.\n\ @@ -160,6 +161,7 @@ fn system_prompt_parts(lang: &str) -> (String, &'static str, &'static str) { - {}\n\ - {}\n\ - {}\n\ + - 回复风格:结论先行,直接给动作/结果;不复述用户问题,不做客套开场和总结性收尾;要点优先于段落;用户没问的不展开。需要解释时照常解释——省的是空话,不是内容。\n\ ## 聚焦准则\n\ - 始终围绕用户当前请求的核心目标回答;上一轮的主题只是背景,不是当前任务。\n\ - 用户切换话题(明显的新意图)时,以最新请求为准,不要把旧话题带进新回答。\n\ @@ -445,7 +447,10 @@ async fn entity_fingerprint(db: &Arc, sql: &'static str) -> Option String { let (_, proj_label, task_label) = system_prompt_parts(lang); let mut out = String::new(); - // 附加当前数据上下文 - if let Ok(projects) = state.projects.list_active().await { + // 附加当前数据上下文。项目段按最近活跃排序(任务活跃为主信号),治创建序误导项目绑定 + // (创建序会把早停摆项目排在清单首位,AI 误当最高优先项目锚点)。 + if let Ok(projects) = state.projects.list_active_with_activity().await { if !projects.is_empty() { out.push_str(proj_label); // system prompt 前缀克制:仅最近 20 个项目,防 context 膨胀 // 去重:被 @ 的项目跳过(已有 augmentation 精准投影,清单再现致同一实体两次入 prompt) for p in projects.iter().take(20) { - if excluded_project_ids.iter().any(|id| id == &p.id) { + if excluded_project_ids.iter().any(|id| id == &p.record.id) { continue; } out.push_str(&format!( "- {} (id: {}) ({}): {}\n", - p.name, - p.id, - p.status.as_str(), - truncate_desc(&p.description, CONTEXT_DESC_MAX_CHARS) + p.record.name, + p.record.id, + p.record.status.as_str(), + truncate_desc(&p.record.description, CONTEXT_DESC_MAX_CHARS) )); - if let Some(ref dir) = p.path { + if let Some(ref dir) = p.record.path { out.push_str(&format!(" 目录: {}\n", dir)); } } @@ -921,6 +931,32 @@ mod tests { assert!(prefix.contains("聚焦准则")); } + // M4 回复风格纪律:中文版行为准则含「回复风格」条款(反口水话,不反内容) + #[test] + fn system_prompt_zh_has_response_style_clause() { + let (prefix, _, _) = system_prompt_parts("zh-CN"); + assert!( + prefix.contains("回复风格"), + "中文 system prompt 应含回复风格条款,实际: {}", + prefix + ); + // 核心条款抽样:结论先行 + 「省的是空话,不是内容」的度(需要解释时照常解释) + assert!(prefix.contains("结论先行")); + assert!(prefix.contains("省的是空话,不是内容")); + } + + // M4 回复风格纪律:英文版 Guidelines 含 Response style 条款 + #[test] + fn system_prompt_en_has_response_style_clause() { + let (prefix, _, _) = system_prompt_parts("en"); + assert!( + prefix.contains("Response style"), + "英文 system prompt 应含 Response style 条款,实际: {}", + prefix + ); + assert!(prefix.contains("cut filler, not substance")); + } + // 文档探索策略段(中/英)存在且含关键引导 #[test] fn doc_exploration_strategy_section_present_zh_en() { diff --git a/src-tauri/src/commands/ai/tools/file.rs b/src-tauri/src/commands/ai/tools/file.rs index 706a1ce..464496a 100644 --- a/src-tauri/src/commands/ai/tools/file.rs +++ b/src-tauri/src/commands/ai/tools/file.rs @@ -1098,7 +1098,7 @@ pub fn register( } // output_mode 分派返回结构 - let result = match output_mode { + let mut result = match output_mode { "files_with_matches" => { // 仅返命中文件路径列表(去重,顺序保留首次命中) let files: Vec = matches_out.iter() @@ -1183,6 +1183,11 @@ pub fn register( }) } }; + // 0 命中「阴性存疑」提示:路径/glob 写法/大小写任一不对都静默 0 命中, + // LLM 拿空结果易直接当「不存在」下结论(实测假阴性被当事实断言 3 轮),强制附一行复核提示。 + if total == 0 { + result["hint"] = serde_json::json!(negative_hit_hint("路径/glob 写法/大小写 -i")); + } Ok(result) } ); @@ -1228,10 +1233,13 @@ pub fn register( let mut out = serde_json::json!({ "path": path, "pattern": pattern, "results": page_results, "total": total, "has_more": has_more }); - // total=0 时空结果引导(治 LLM 搜不到就放弃/盲目重试)。 + // total=0 时空结果引导(治 LLM 搜不到就放弃/盲目重试)+ 阴性存疑提示(0 命中≠不存在)。 // 注:search_files 已大小写不敏感(内部 pattern.to_lowercase + 包含匹配),文案勿写"换大小写"。 if total == 0 { - out["hint"] = serde_json::json!("未找到匹配文件。可尝试: ① 放宽关键词(更短/字符更少); ② 用 grep 工具做跨文件内容正则搜索(pattern 支持正则, -i 大小写不敏感)。注: search_files 为文件名大小写不敏感包含匹配"); + out["hint"] = serde_json::json!(format!( + "{}\n未找到匹配文件。可尝试: ① 补 recursive=true 搜子目录(不传只搜当前层); ② 放宽关键词(更短/字符更少); ③ 用 grep 工具做跨文件内容正则搜索(pattern 支持正则, -i 大小写不敏感)。注: search_files 为文件名大小写不敏感包含匹配", + negative_hit_hint("路径/recursive") + )); } Ok(out) } @@ -1675,6 +1683,16 @@ fn file_not_found_error(path: &str) -> anyhow::Error { anyhow::anyhow!(msg) } +/// 搜索工具(search_files/grep)0 命中「阴性存疑」提示。 +/// +/// 治勘察假阴性被当事实断言:search_files 未传 recursive 只搜根目录, +/// 0 命中被 LLM 直接当「功能未实施」连续断言 3 轮。路径/glob/recursive 等范围 +/// 参数写错时同样静默 0 命中,故空结果强制附本提示让 LLM 复核后再下结论。 +/// 措辞克制一行;`scope` 按工具传入各自的复核点。 +fn negative_hit_hint(scope: &str) -> String { + format!("⚠ 0 命中不一定代表不存在——请复核搜索范围({scope})后再下结论") +} + #[cfg(test)] mod tests { use super::*; @@ -2003,6 +2021,23 @@ mod tests { assert!(msg.contains("相近文件候选"), "应含相近文件候选,实际: {msg}"); assert!(msg.contains("target.rs"), "候选应含 target.rs,实际: {msg}"); } + + // ============================================================ + // negative_hit_hint — 搜索工具 0 命中阴性存疑提示(治假阴性被当事实断言) + // ============================================================ + + /// 两搜索工具 0 命中提示共用句式:声明「0 命中≠不存在」+ 按工具各自的复核点。 + #[test] + fn negative_hit_hint_states_doubt_and_scope() { + // grep 侧复核点:路径/glob 写法/大小写 + let grep_hint = negative_hit_hint("路径/glob 写法/大小写 -i"); + assert!(grep_hint.contains("0 命中不一定代表不存在"), "应声明阴性存疑,实际: {grep_hint}"); + assert!(grep_hint.contains("复核搜索范围"), "应引导复核,实际: {grep_hint}"); + assert!(grep_hint.contains("glob"), "grep 侧应含 glob 复核点,实际: {grep_hint}"); + // search_files 侧复核点:recursive(实测根因是未传 recursive 只搜根目录) + let search_hint = negative_hit_hint("路径/recursive"); + assert!(search_hint.contains("recursive"), "search_files 侧应含 recursive 复核点,实际: {search_hint}"); + } } diff --git a/src/api/types.ts b/src/api/types.ts index 4ccd0c3..7331941 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -571,7 +571,7 @@ export interface AiMessage { * 消息来源(958 溯源/961 轮次归属):undefined=null=桌面本端发起;'inject'=运行中 * 补充注入(961② 轮次分桶判据,归属当前轮不开新轮);'feishu'=飞书入站发起。 */ - source?: { channel: string; message_id: string; chat_id?: string; sender?: string } | null + source?: { channel: string; message_id?: string; chat_id?: string; sender?: string } | null /** * 推理过程文本(DeepSeek reasoning_content / Anthropic thinking 同通道,后端落库 * ai_messages.reasoning_content)。undefined=该轮无思考内容。历史消息 reload 时 diff --git a/src/composables/ai/useAiConversations.ts b/src/composables/ai/useAiConversations.ts index f8f41da..b98c478 100644 --- a/src/composables/ai/useAiConversations.ts +++ b/src/composables/ai/useAiConversations.ts @@ -4,7 +4,8 @@ //! - 多处调 useAiEvents.notifyConversationChanged //! - newConversation/switchConversation 写 appSettings 持久化活跃会话 id -import { ref } from 'vue' +import { ref, reactive } from 'vue' +import { invoke } from '@tauri-apps/api/core' import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow' import { aiApi } from '@/api' import { useAppSettingsStore } from '@/stores/appSettings' @@ -14,7 +15,7 @@ import { setStreaming } from './streamingGuard' import { emitDetachedOpenConversation, registerDetachedOpenConvListener } from './useAiWindow' 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' +import type { AiConversationDetail, AiConversationSummary, AiMessage, AiToolCallInfo, AiToolCallStatus, ConvId, MessageId } from '@/api/types' // ── V11c(2026-10-05)活跃会话 KV 按窗口域分流 ── // 主窗与分离窗是独立 JS context,却共用同一 SQLite KV:两窗各自切会话都写 'df-ai-active-conv' @@ -35,17 +36,78 @@ export function activeConvKvKey(): string { const appSettings = useAppSettingsStore() -/** 拉取会话列表;首次加载时若 appSettings 仍有有效 active-conv 且当前无活跃对话则恢复 */ +// ============================================================ +// F4-1(2026-10-17)会话列表分页:后端 ai_conversation_list 活跃/归档各自 +// offset+limit 分页(每页 CONV_PAGE_SIZE/组),首屏一页保启动性能;侧栏「加载更多」 +// 按游标续拉,搜索输入时拉满(搜索过滤跑在已加载列表上,截断列表=搜索失灵)。 +// 治实测 115 条活跃主会话 57% 在桌面端零入口(硬 50 截断)。 +// ============================================================ + +/** 每页条数(每组独立;对齐后端 limit.unwrap_or(50) 首屏口径) */ +const CONV_PAGE_SIZE = 50 +/** 单次「拉满」安全页数上限(防异常数据下无限循环;50*40=2000/组远超真实规模) */ +const CONV_MAX_PAGES = 40 + +/** 分页游标(模块级单例,主/分离窗各自实例):已拉取条数按组计数 + 是否还有下一页。 + * reactive 供侧栏「加载更多」按钮直接绑显隐/loading 态。 */ +export const convListPaging = reactive({ + activeLoaded: 0, + archivedLoaded: 0, + hasMore: false, + loading: false, +}) + +/** 拉一页会话摘要。直接 invoke:api 层 listConversations 封装不带 offset 参数, + * 分页需透传 activeOffset/archivedOffset(composable 直连 invoke 有 useAiSend 等先例)。 */ +function fetchConvPage(activeOffset: number, archivedOffset: number): Promise { + return invoke('ai_conversation_list', { + limit: CONV_PAGE_SIZE, + includeArchived: true, + activeOffset, + archivedOffset, + }) +} + +/** 拉取会话列表;首次加载时若 appSettings 仍有有效 active-conv 且当前无活跃对话则恢复。 + * F4-1 分页:从第一页重拉到覆盖既有加载深度(convListPaging)或拉尽——事件后刷新 + * (scheduleConversationsRefresh)不把「加载更多」拉出的深度回缩成首屏 50;首启深度 0 + * 即只拉一页,首屏性能不变。 */ async function loadConversations() { + if (convListPaging.loading) return + convListPaging.loading = true try { + const depthActive = convListPaging.activeLoaded + const depthArchived = convListPaging.archivedLoaded + const accActive: AiConversationSummary[] = [] + const accArchived: AiConversationSummary[] = [] + const seen = new Set() + let aOff = 0 + let rOff = 0 // includeArchived=true:侧栏归档分组(ConversationSidebar archivedConvs)数据源即本内存列表, // 若拉取时排除归档,重启后已归档会话不可见且无恢复入口(2026-10-05 实锤 bug)。 - // 活跃/归档分组由侧栏 groupedActive/archivedConvs 各自分离渲染;后端已分组截断,归档不挤占活跃名额。 - state.conversations = await aiApi.listConversations(50, true) + // 活跃/归档分组由侧栏 groupedActive/archivedConvs 各自分离渲染;后端分组分页,归档不挤占活跃名额。 + for (let i = 0; i < CONV_MAX_PAGES; i++) { + const page = await fetchConvPage(aOff, rOff) + const act = page.filter(c => !c.archived) + const arc = page.filter(c => c.archived) + for (const c of act) if (!seen.has(c.id)) { seen.add(c.id); accActive.push(c) } + for (const c of arc) if (!seen.has(c.id)) { seen.add(c.id); accArchived.push(c) } + aOff += act.length + rOff += arc.length + // 某组满额一页即可能还有下一页(后端无显式 has_more,满额启发式同 load_more 消息分页) + convListPaging.hasMore = act.length === CONV_PAGE_SIZE || arc.length === CONV_PAGE_SIZE + if (!convListPaging.hasMore) break + // 已覆盖此前加载深度即止(刷新场景),首启深度 0 第一页后即止 + if (aOff >= depthActive && rOff >= depthArchived) break + } + state.conversations = [...accActive, ...accArchived] + convListPaging.activeLoaded = aOff + convListPaging.archivedLoaded = rOff // 虚拟项保底:活跃会话未落库(懒创建——首条消息前 DB 无记录)时,侧栏显示虚拟占位项。 // 后端 ai_conversation_create 仅生成 id 存内存不落库,首条消息发送后 save_conversation 才写库。 // 此处 active 会话不在 DB 列表时补一个虚拟项,实现「点新建立即出现在侧栏,不发消息不落库」。 // 首条消息落库后 AiCompleted/AiError 触发的 loadConversations 拉到同 id 真实记录,虚拟项被替代无重复。 + // (虚拟项不计入分页游标——游标只数 DB 真实拉取条数。) if (state.activeConversationId && !state.conversations.some(c => c.id === state.activeConversationId)) { const now = String(Date.now()) @@ -94,6 +156,41 @@ async function loadConversations() { } as AiMessage) notifyConversationChanged() } + } finally { + convListPaging.loading = false + } +} + +/** F4-1「加载更多」:从当前游标续拉一页并追加(不重拉首页,避免重复传输+闪烁); + * 返回是否实际新增条目(侧栏据此决定是否继续,数据漂移致空页时自然停)。 */ +async function loadMoreConversations(): Promise { + if (convListPaging.loading || !convListPaging.hasMore) return false + convListPaging.loading = true + try { + const page = await fetchConvPage(convListPaging.activeLoaded, convListPaging.archivedLoaded) + const act = page.filter(c => !c.archived) + const arc = page.filter(c => c.archived) + const existing = new Set(state.conversations.map(c => c.id)) + const fresh = page.filter(c => !existing.has(c.id)) + if (fresh.length) state.conversations = [...state.conversations, ...fresh] + convListPaging.activeLoaded += act.length + convListPaging.archivedLoaded += arc.length + convListPaging.hasMore = act.length === CONV_PAGE_SIZE || arc.length === CONV_PAGE_SIZE + return fresh.length > 0 + } catch (e) { + console.error('[AI] 加载更多会话失败:', e) + return false + } finally { + convListPaging.loading = false + } +} + +/** F4-1 搜索全量兜底:把列表拉满。搜索过滤(stores.filteredConversations)跑在已加载 + * 列表上,未拉满时老会话搜不到——搜索本就该扫全量,输入触发时一次拉尽。 */ +async function loadAllConversations(): Promise { + for (let i = 0; i < CONV_MAX_PAGES; i++) { + if (!convListPaging.hasMore) break + if (!(await loadMoreConversations())) break } } @@ -754,6 +851,8 @@ export function useAiConversations() { return { loadConversations, scheduleConversationsRefresh, + loadMoreConversations, + loadAllConversations, newConversation, switchConversation, deleteConversation, @@ -768,4 +867,4 @@ export function useAiConversations() { } } -export { loadConversations, scheduleConversationsRefresh, loadMoreHistory, getLoadMoreState } +export { loadConversations, scheduleConversationsRefresh, loadMoreConversations, loadAllConversations, loadMoreHistory, getLoadMoreState } diff --git a/src/composables/ai/useAiSend.ts b/src/composables/ai/useAiSend.ts index c7d02bb..696f06b 100644 --- a/src/composables/ai/useAiSend.ts +++ b/src/composables/ai/useAiSend.ts @@ -409,6 +409,9 @@ async function sendMessage(text: string, skill?: string, forceMode = false, part role: 'user', content: text.trim(), timestamp: Date.now(), + // 与后端落库同标记(chat.rs 落库带 source.channel='inject'):本地气泡 + // 不带则轮次分桶把它当「发起消息」多计一轮,重载后才归位 + source: { channel: 'inject' }, }) } void emit('ai-toast', { msg: t('ai.injectMerged'), type: 'success' }) diff --git a/src/i18n/en/aiChat.ts b/src/i18n/en/aiChat.ts index c7ec4a5..ce350a6 100644 --- a/src/i18n/en/aiChat.ts +++ b/src/i18n/en/aiChat.ts @@ -133,6 +133,8 @@ export default { queueSave: 'Save', queueCancel: 'Cancel', queueSendNow: 'Send now', + // UX walk panel-2 F2-1: send button title/aria (was mis-bound to queueSendNow) + send: 'Send', // 3c UX batch addition (I4 queue backfill: pull a queued item back into the input box) queueBackfill: 'Backfill', @@ -185,6 +187,10 @@ export default { copyMsg: 'Copy message', copied: 'Copied', copyFailed: 'Copy failed', + // F1-1: code block copy button (injected at v-html render layer via decorateCodeHtml) + copyCode: 'Copy', + // F1-1: collapsed long-message summary toggle (expanded state reuses collapse key) + foldedSummary: '{chars} chars collapsed / ~{tokens} tokens — click to expand', // Copy bubble as image (html2canvas → clipboard; falls back to download) copyAsImage: 'Copy as image', copiedAsImage: 'Copied as image', @@ -287,6 +293,8 @@ export default { recoverNow: 'Resume', viewHistory: 'View history messages', viewSummary: 'View context summary', + // UX walk panel-6 F6-1: inline hint when a history/summary jump misses (target inside a collapsed segment) + historyLocateInSegment: 'This message is inside a collapsed segment (archived/compressed). Expand the divider in the message flow first', viewContext: 'View AI context', contextSystemPrompt: 'System Prompt', contextEnvironment: 'Environment', @@ -342,6 +350,8 @@ export default { editEmptyBlocked: 'Edit content is empty, submission cancelled', skillNoMatchQuery: 'No skill matches "{q}", send blocked', backfillOverwriteConfirm: 'The input box has an unsent draft; backfilling will overwrite it. Continue?', + // UX walk panel-2 F2-4: example-card overwrite confirm (aligned with backfill confirm) + exampleOverwriteConfirm: 'The input box has an unsent draft; the example will overwrite it. Continue?', // ── Outbox / scheduled messages (OutboxDrawer + ChatInput clock entry, design 2026-10-10 §3.4) ── outboxEntry: 'Scheduled messages', diff --git a/src/i18n/zh-CN/aiChat.ts b/src/i18n/zh-CN/aiChat.ts index e020ff9..9d6e9d1 100644 --- a/src/i18n/zh-CN/aiChat.ts +++ b/src/i18n/zh-CN/aiChat.ts @@ -133,6 +133,8 @@ export default { queueSave: '保存', queueCancel: '取消', queueSendNow: '立即发送', + // 走查-信息UX 面二 F2-1:发送钮 title/aria(原误绑 queueSendNow,语义是插队而非发送) + send: '发送', // 3c UX 批追加(I4 队列回填:取出排队项填回输入框重新编辑) queueBackfill: '回填', @@ -186,6 +188,10 @@ export default { copyMsg: '复制消息', copied: '已复制', copyFailed: '复制失败', + // F1-1:代码块复制按钮(v-html 渲染层注入,decorateCodeHtml 传参) + copyCode: '复制', + // F1-1:长文折叠条带量按钮(展开态复用 collapse 键) + foldedSummary: '已折叠 {chars} 字符 / 约 {tokens} tokens,点击展开', // 气泡复制为图片(html2canvas 渲染 → 剪贴板;失败降级下载) copyAsImage: '复制为图片', copiedAsImage: '已复制为图片', @@ -288,6 +294,8 @@ export default { recoverNow: '恢复', viewHistory: '查看历史消息', viewSummary: '查看上下文摘要', + // 走查-信息UX 面六 F6-1:历史/摘要项点击定位未命中(目标在折叠段内未渲染 DOM)的行内提示 + historyLocateInSegment: '该消息在折叠段(已归档/已压缩)内,请先在消息流中展开对应分隔条', viewContext: '查看 AI 上下文', contextSystemPrompt: '系统提示', contextEnvironment: '环境', @@ -343,6 +351,8 @@ export default { editEmptyBlocked: '编辑内容为空,已取消提交', skillNoMatchQuery: '未找到技能「{q}」,已阻止发送', backfillOverwriteConfirm: '输入框已有未发送草稿,回填将覆盖,确定继续?', + // 走查-信息UX 面二 F2-4:示例问题卡覆盖草稿二次确认(对齐回填确认语义) + exampleOverwriteConfirm: '输入框已有未发送草稿,点击示例将覆盖,确定继续?', // ── 待发箱/定时消息(OutboxDrawer + ChatInput ⏰ 入口,设计 2026-10-10 §3.4)── outboxEntry: '定时消息',