修复: aichat上下文管理与信息组织P1批

逐件(证据:深查-aichat质量/信息UX-2026-10-17):
- 恢复链套娃:RECOVERY_ARTIFACT_PREFIX双侧过滤+单点拦截(E1)
- message_repo全量重写保留原created_at(E11,治1750条时间线污染)
- search_files/grep 0命中附阴性存疑提示(E6,共享negative_hit_hint)
- 项目清单三源活跃度排序+system prompt指纹对齐(E4跑题)
- 会话列表活跃/归档offset分页+侧栏加载更多(F4-1,治50硬上限)
- inject消息source标记(轮次分桶不误计发起,V52同源)
- 提示词回复风格条款中英双语(反口水话,含测试断言)
- i18n补键:复制/折叠量/发送title/历史定位提示(F1-1/F2-1/F6-1)

验收:cargo test --workspace 2281 passed 0 failed;vue-tsc EXIT 0
This commit is contained in:
lxy
2026-10-18 23:15:45 +08:00
parent a273a86a7d
commit 05d0f89c16
10 changed files with 570 additions and 57 deletions
+86 -2
View File
@@ -265,6 +265,12 @@ impl AiMessageRepo {
/// 全量重写语义——以入参 records 为该对话的**唯一真相**,先删该 conv 全部旧行再批量插。 /// 全量重写语义——以入参 records 为该对话的**唯一真相**,先删该 conv 全部旧行再批量插。
/// 单事务保证「删 + 插」原子,无中间空窗(reload 不会读到半删半插的中间态)。 /// 单事务保证「删 + 插」原子,无中间空窗(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 优化): /// 设计权衡(非 dirty 增量,留 P2.1 优化):
/// - 内存 ContextManager 是运行时真相源,save 是同步点,每轮 save 全量回写简单可靠; /// - 内存 ContextManager 是运行时真相源,save 是同步点,每轮 save 全量回写简单可靠;
/// - INSERT OR IGNORE 幂等:records 内 id 重复或与残留行(理论不应有,事务已 DELETE)冲突跳过; /// - INSERT OR IGNORE 幂等:records 内 id 重复或与残留行(理论不应有,事务已 DELETE)冲突跳过;
@@ -274,7 +280,7 @@ impl AiMessageRepo {
pub async fn replace_conversation( pub async fn replace_conversation(
&self, &self,
conv_id: &str, conv_id: &str,
records: Vec<AiMessageRecord>, mut records: Vec<AiMessageRecord>,
) -> Result<()> { ) -> Result<()> {
let conn = self.conn.clone(); let conn = self.conn.clone();
let conv_id = conv_id.to_owned(); let conv_id = conv_id.to_owned();
@@ -282,7 +288,29 @@ impl AiMessageRepo {
let mut guard = conn.blocking_lock(); let mut guard = conn.blocking_lock();
let tx = guard.transaction().map_err(storage_err)?; 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<String, String> =
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( tx.execute(
"DELETE FROM ai_messages WHERE conversation_id = ?1", "DELETE FROM ai_messages WHERE conversation_id = ?1",
params![conv_id], params![conv_id],
@@ -693,6 +721,62 @@ mod tests {
assert_eq!(got.len(), 1, "重复 replace 不应叠加"); 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 条。 /// limit=0 契约:返空集(直传 SQLite LIMIT 0),不再旧版 max(1) 钳成 1 条。
/// 锁死语义防回归:请求 0 条却回 1 条会让调用方误判页内有数据。 /// 锁死语义防回归:请求 0 条却回 1 条会让调用方误判页内有数据。
#[tokio::test] #[tokio::test]
+80 -16
View File
@@ -87,9 +87,9 @@ fn build_order_clause(order_by: Option<&str>) -> Result<String> {
/// 带「最近活跃时间」的项目记录(list_active_with_activity 专用返回结构)。 /// 带「最近活跃时间」的项目记录(list_active_with_activity 专用返回结构)。
/// ///
/// `last_active_at` = COALESCE(project_events 最新事件 created_at, projects.updated_at): /// `last_active_at` = MAX(tasks.updated_at 最新值, project_events 最新 created_at,
/// 反映业务活跃(任务/灵感/状态推进等事件),无事件项目回退 updated_at。供前端「最近活跃排序」 /// projects.updated_at):任务活跃为主信号(任务面是真实工作发生地),事件表已停写仅保留维度,
/// 展示,语义比 projects.updated_at(随元信息修改如改 description 也会刷新)更准确。 /// updated_at 兜底。供前端「最近活跃排序」展示与 system prompt 项目清单排序。
/// ///
/// Serialize 供 IPC 层直接序列化回前端(serde 字段名 snake_case,对齐 ProjectRecord)。 /// Serialize 供 IPC 层直接序列化回前端(serde 字段名 snake_case,对齐 ProjectRecord)。
#[derive(Debug, Clone, serde::Serialize)] #[derive(Debug, Clone, serde::Serialize)]
@@ -243,29 +243,35 @@ impl ProjectRepo {
.map_err(storage_err)? .map_err(storage_err)?
} }
/// 列出未删除项目,**按「最近活跃」排序**(LEFT JOIN project_events 取最新事件时间, /// 列出未删除项目,**按「最近活跃」排序**(三源取大:任务活跃为主信号)。
/// COALESCE 回退 updated_at)。
/// ///
/// 业务语义:项目「最近活跃」应反映业务事件(任务/灵感/状态推进),而非 projects.updated_at /// 业务语义:last_active_at = MAX(tasks.updated_at 最新值, project_events 最新 created_at,
/// (后者随元信息修改如改 description/path 也会刷新,不反映真实业务活跃)。本方法通过子查询 /// projects.updated_at)。**任务活跃为主信号**——ai-chat 会话/任务面是真实工作发生地,
/// 取每个项目 project_events 最新 created_at,无事件项目回退 updated_at,排序列统一可比。 /// 任务停更即项目停更;project_events 已停写(现网空表,保留维度向后兼容);
/// projects.updated_at 仅元信息修改(改 description/path 也会刷新,不反映真实业务活跃),
/// 仅作兜底。无任务无事件的项目回退 updated_at,排序列统一可比。
/// ///
/// 子查询而非 JOIN:project_events 每项目可能 0..N 条,JOIN 会展开需 DISTINCT,子查询 /// 子查询而非 JOIN:tasks/project_events 每项目可能 0..N 条,JOIN 会展开需 DISTINCT,
/// `(SELECT MAX(created_at) FROM project_events WHERE project_id = projects.id)` 每行一次 /// 标量子查询每行一次聚合,语义清晰无笛卡尔积风险(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<Vec<ProjectActivityRecord>> { pub async fn list_active_with_activity(&self) -> Result<Vec<ProjectActivityRecord>> {
let conn = self.conn.clone(); let conn = self.conn.clone();
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock(); let guard = conn.blocking_lock();
// COALESCE(子查询最新事件, projects.updated_at):无事件项目回退 updated_at, // 三源取大(SQLite 多参 MAX=标量取大,IFNULL 兜空子查询):任务活跃为主信号,
// 保证所有项目有可比活跃时间。ORDER BY 该列 DESC,同时间 created_at DESC 兜底稳定。 // 事件表已停写仅保留维度,updated_at 兜底。ORDER BY 该列 DESC,同时间 created_at DESC
// 兜底稳定。
let mut stmt = guard let mut stmt = guard
.prepare( .prepare(
"SELECT id, name, description, status, idea_id, path, stack, archived, created_at, updated_at, \ "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), \ MAX(\
updated_at) AS last_active_at \ 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 \ FROM projects WHERE deleted_at IS NULL AND archived = 0 \
ORDER BY last_active_at DESC, created_at DESC", ORDER BY last_active_at DESC, created_at DESC",
) )
@@ -840,4 +846,62 @@ mod tests {
assert_eq!(after.name, "原项目", "回收站项目内容不应被改写"); assert_eq!(after.name, "原项目", "回收站项目内容不应被改写");
assert_eq!(after.updated_at, current.updated_at, "updated_at 不应抖动"); 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 最新值"
);
}
} }
@@ -232,14 +232,24 @@ pub async fn ai_conversation_create(
/// ///
/// 截断按组独立:活跃/归档各自取 limit,归档项不挤占活跃名额(侧栏归档分组数据源依赖 /// 截断按组独立:活跃/归档各自取 limit,归档项不挤占活跃名额(侧栏归档分组数据源依赖
/// include_archived=true,见 useAiConversations.loadConversations)。 /// 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] #[tauri::command]
pub async fn ai_conversation_list( pub async fn ai_conversation_list(
state: State<'_, AppState>, state: State<'_, AppState>,
limit: Option<usize>, limit: Option<usize>,
include_archived: Option<bool>, include_archived: Option<bool>,
active_offset: Option<usize>,
archived_offset: Option<usize>,
) -> Result<Vec<serde_json::Value>, String> { ) -> Result<Vec<serde_json::Value>, String> {
let limit = limit.unwrap_or(50); let limit = limit.unwrap_or(50);
let include_archived = include_archived.unwrap_or(false); 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)?; let records = state.ai_conversations.list_all().await.map_err(err_str)?;
// list_all 已按 created_at DESC(最新在前);默认排除归档 + 截断 limit。 // list_all 已按 created_at DESC(最新在前);默认排除归档 + 截断 limit。
// origin 非空(子代理轨迹,V44)不进侧栏列表——轨迹在库可追溯,展示面只留主会话。 // origin 非空(子代理轨迹,V44)不进侧栏列表——轨迹在库可追溯,展示面只留主会话。
@@ -273,20 +283,25 @@ pub async fn ai_conversation_list(
"loop_state": r.loop_state, "loop_state": r.loop_state,
}) })
}).collect(); }).collect();
// 分组截断:活跃/归档各自独立取 limit,防归档项挤占活跃名额(前端侧栏活跃列表被截空)。 Ok(paginate_by_group(summaries, limit, active_offset, archived_offset))
// 原单 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 原序。 /// F4-1:摘要按组独立分页——活跃/归档各自 skip(offset)+take(limit),活跃在前归档在后,
active.extend(archived); /// 组内保持传入原序(created_at DESC)。offset=0 与旧「分组截断」口径逐字节等价
Ok(active) /// (回归保护见 paginate_first_page_matches_legacy_grouped_truncation)。
/// 抽纯函数供单测:分组正确性/offset 续拉/越界空页/归档不挤占活跃名额。
fn paginate_by_group(
summaries: Vec<serde_json::Value>,
limit: usize,
active_offset: usize,
archived_offset: usize,
) -> Vec<serde_json::Value> {
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<serde_json::Value> = active.into_iter().skip(active_offset).take(limit).collect();
page.extend(archived.into_iter().skip(archived_offset).take(limit));
page
} }
/// B3(无人值守可靠性专项):列出可恢复会话(loop_state='parked')。 /// B3(无人值守可靠性专项):列出可恢复会话(loop_state='parked')。
@@ -391,11 +406,25 @@ pub(crate) async fn recover_parked_inner(
.await .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 恢复引导消息。 /// 目标快照:按最终有效意图生成 parked 恢复引导消息。
/// ///
/// 「按快照续不重放历史目标流」:取 WorkingContext.goals 与 pinned_goals 中仍 Active 的 /// 「按快照续不重放历史目标流」:取 WorkingContext.goals 与 pinned_goals 中仍 Active 的
/// 目标 + unresolved 未解项;中途被调整作废(Cancelled)或已完成(Completed)的目标不复活。 /// 目标 + unresolved 未解项;中途被调整作废(Cancelled)或已完成(Completed)的目标不复活;
/// 快照全空(旧会话无快照)回退 [`PARKED_RECOVERY_MESSAGE`],行为同今日。 /// 恢复产物([系统恢复] 前缀)剔除——回放它即递归套娃,历史污染目标在此单点拦截。
/// 快照全空(旧会话无快照/全被过滤)回退 [`PARKED_RECOVERY_MESSAGE`],行为同今日。
/// 不另立存储:快照即 working_context_json + pinned_goals 两列的语义化读取(单一真值来源)。 /// 不另立存储:快照即 working_context_json + pinned_goals 两列的语义化读取(单一真值来源)。
pub(crate) fn build_recovery_message( pub(crate) fn build_recovery_message(
working_context: &df_ai::context_helpers::WorkingContext, 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 seen = std::collections::HashSet::new();
let mut active: Vec<&str> = Vec::new(); let mut active: Vec<&str> = Vec::new();
for g in &working_context.goals { 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); active.push(&g.text);
} }
} }
for g in pinned_goals { 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); active.push(&g.text);
} }
} }
@@ -442,7 +477,8 @@ pub(crate) fn build_recovery_message(
/// pinned_goals 落库反解(新旧格式兼容):新格式 [{text,status}] 保 status, /// pinned_goals 落库反解(新旧格式兼容):新格式 [{text,status}] 保 status,
/// 旧格式 ["text"] 转 Active;非数组/坏 JSON 一律空(降级不炸)。switch 恢复与 /// 旧格式 ["text"] 转 Active;非数组/坏 JSON 一律空(降级不炸)。switch 恢复与
/// parked 恢复共用(DRY,单一事实源)。 /// parked 恢复共用(DRY,单一事实源)。恢复产物([系统恢复] 前缀)剔除——存量
/// 污染目标不进内存,switch 载入后下轮 save 重写 pinned_goals 即自愈落库。
pub(crate) fn parse_pinned_goals(raw: Option<&str>) -> Vec<crate::commands::ai::GoalEntry> { pub(crate) fn parse_pinned_goals(raw: Option<&str>) -> Vec<crate::commands::ai::GoalEntry> {
let parsed: Option<serde_json::Value> = raw.and_then(|s| serde_json::from_str(s).ok()); let parsed: Option<serde_json::Value> = raw.and_then(|s| serde_json::from_str(s).ok());
match parsed { match parsed {
@@ -468,6 +504,7 @@ pub(crate) fn parse_pinned_goals(raw: Option<&str>) -> Vec<crate::commands::ai::
Some(crate::commands::ai::GoalEntry::new(text)) Some(crate::commands::ai::GoalEntry::new(text))
} }
}) })
.filter(|e| !is_recovery_artifact(&e.text))
.collect(), .collect(),
_ => vec![], _ => vec![],
} }
@@ -1477,6 +1514,73 @@ mod tests {
assert_eq!(back.timestamp, Some(123)); 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 → 还原) // 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(Some("not-json")).is_empty());
assert!(parse_pinned_goals(None).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);
}
} }
+46 -10
View File
@@ -127,6 +127,7 @@ fn system_prompt_parts(lang: &str) -> (String, &'static str, &'static str) {
- {}\n\ - {}\n\
- {}\n\ - {}\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\ ## 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\ - 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\ - 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\
## 聚焦准则\n\ ## 聚焦准则\n\
- 始终围绕用户当前请求的核心目标回答;上一轮的主题只是背景,不是当前任务。\n\ - 始终围绕用户当前请求的核心目标回答;上一轮的主题只是背景,不是当前任务。\n\
- 用户切换话题(明显的新意图)时,以最新请求为准,不要把旧话题带进新回答。\n\ - 用户切换话题(明显的新意图)时,以最新请求为准,不要把旧话题带进新回答。\n\
@@ -445,7 +447,10 @@ async fn entity_fingerprint(db: &Arc<Database>, sql: &'static str) -> Option<Str
/// - 日期:env_profile_line 注入当日日期,跨天必须失效(否则缓存串日期过期)。 /// - 日期:env_profile_line 注入当日日期,跨天必须失效(否则缓存串日期过期)。
/// - lang:system_prompt_parts / 各策略段 / 注明语随 lang 分支。 /// - lang:system_prompt_parts / 各策略段 / 注明语随 lang 分支。
/// - custom_prompt:与 build 内读的同一设置,变更即失效。 /// - custom_prompt:与 build 内读的同一设置,变更即失效。
/// - 活跃 project/task 的 id+updated_at:新增/软删/改 updated_at 均反映到指纹(比 MAX 更稳)。 /// - 活跃 project/task 指纹:task 取 id+updated_at;project 取 id+活跃时间(三源取大:
/// tasks.updated_at 最新/project_events 最新 created_at/projects.updated_at,仅未归档未删除,
/// 与「当前项目」清单的 list_active_with_activity 排序输入对齐)——任务更新/归档变化均
/// 反映到指纹,清单排序变了缓存串必失效。
/// - 去重排除集:被@实体走 augmentation 精准投影,排除集变化会改变清单内容,须纳入指纹。 /// - 去重排除集:被@实体走 augmentation 精准投影,排除集变化会改变清单内容,须纳入指纹。
async fn system_prompt_fingerprint( async fn system_prompt_fingerprint(
state: &AppState, state: &AppState,
@@ -463,7 +468,11 @@ async fn system_prompt_fingerprint(
.unwrap_or_default(); .unwrap_or_default();
let proj_fp = entity_fingerprint( let proj_fp = entity_fingerprint(
&state.db, &state.db,
"SELECT id, updated_at FROM projects WHERE deleted_at IS NULL", "SELECT p.id, MAX(\
IFNULL((SELECT MAX(t.updated_at) FROM tasks t WHERE t.project_id = p.id AND t.deleted_at IS NULL), 0), \
IFNULL((SELECT MAX(e.created_at) FROM project_events e WHERE e.project_id = p.id), 0), \
IFNULL(p.updated_at, 0)\
) FROM projects p WHERE p.deleted_at IS NULL AND p.archived = 0",
) )
.await?; .await?;
let task_fp = entity_fingerprint( let task_fp = entity_fingerprint(
@@ -586,24 +595,25 @@ async fn build_context_lists(
) -> String { ) -> String {
let (_, proj_label, task_label) = system_prompt_parts(lang); let (_, proj_label, task_label) = system_prompt_parts(lang);
let mut out = String::new(); 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() { if !projects.is_empty() {
out.push_str(proj_label); out.push_str(proj_label);
// system prompt 前缀克制:仅最近 20 个项目,防 context 膨胀 // system prompt 前缀克制:仅最近 20 个项目,防 context 膨胀
// 去重:被 @ 的项目跳过(已有 augmentation 精准投影,清单再现致同一实体两次入 prompt) // 去重:被 @ 的项目跳过(已有 augmentation 精准投影,清单再现致同一实体两次入 prompt)
for p in projects.iter().take(20) { 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; continue;
} }
out.push_str(&format!( out.push_str(&format!(
"- {} (id: {}) ({}): {}\n", "- {} (id: {}) ({}): {}\n",
p.name, p.record.name,
p.id, p.record.id,
p.status.as_str(), p.record.status.as_str(),
truncate_desc(&p.description, CONTEXT_DESC_MAX_CHARS) 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)); out.push_str(&format!(" 目录: {}\n", dir));
} }
} }
@@ -921,6 +931,32 @@ mod tests {
assert!(prefix.contains("聚焦准则")); 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] #[test]
fn doc_exploration_strategy_section_present_zh_en() { fn doc_exploration_strategy_section_present_zh_en() {
+38 -3
View File
@@ -1098,7 +1098,7 @@ pub fn register(
} }
// output_mode 分派返回结构 // output_mode 分派返回结构
let result = match output_mode { let mut result = match output_mode {
"files_with_matches" => { "files_with_matches" => {
// 仅返命中文件路径列表(去重,顺序保留首次命中) // 仅返命中文件路径列表(去重,顺序保留首次命中)
let files: Vec<String> = matches_out.iter() let files: Vec<String> = 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) Ok(result)
} }
); );
@@ -1228,10 +1233,13 @@ pub fn register(
let mut out = serde_json::json!({ let mut out = serde_json::json!({
"path": path, "pattern": pattern, "results": page_results, "total": total, "has_more": has_more "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 + 包含匹配),文案勿写"换大小写"。 // 注:search_files 已大小写不敏感(内部 pattern.to_lowercase + 包含匹配),文案勿写"换大小写"。
if total == 0 { 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) Ok(out)
} }
@@ -1675,6 +1683,16 @@ fn file_not_found_error(path: &str) -> anyhow::Error {
anyhow::anyhow!(msg) 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -2003,6 +2021,23 @@ mod tests {
assert!(msg.contains("相近文件候选"), "应含相近文件候选,实际: {msg}"); assert!(msg.contains("相近文件候选"), "应含相近文件候选,实际: {msg}");
assert!(msg.contains("target.rs"), "候选应含 target.rs,实际: {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}");
}
} }
+1 -1
View File
@@ -571,7 +571,7 @@ export interface AiMessage {
* 消息来源(958 溯源/961 轮次归属):undefined=null=桌面本端发起;'inject'=运行中 * 消息来源(958 溯源/961 轮次归属):undefined=null=桌面本端发起;'inject'=运行中
* 补充注入(961② 轮次分桶判据,归属当前轮不开新轮);'feishu'=飞书入站发起。 * 补充注入(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 同通道,后端落库 * 推理过程文本(DeepSeek reasoning_content / Anthropic thinking 同通道,后端落库
* ai_messages.reasoning_content)。undefined=该轮无思考内容。历史消息 reload 时 * ai_messages.reasoning_content)。undefined=该轮无思考内容。历史消息 reload 时
+105 -6
View File
@@ -4,7 +4,8 @@
//! - 多处调 useAiEvents.notifyConversationChanged //! - 多处调 useAiEvents.notifyConversationChanged
//! - newConversation/switchConversation 写 appSettings 持久化活跃会话 id //! - 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 { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
import { aiApi } from '@/api' import { aiApi } from '@/api'
import { useAppSettingsStore } from '@/stores/appSettings' import { useAppSettingsStore } from '@/stores/appSettings'
@@ -14,7 +15,7 @@ import { setStreaming } from './streamingGuard'
import { emitDetachedOpenConversation, registerDetachedOpenConvListener } from './useAiWindow' import { emitDetachedOpenConversation, registerDetachedOpenConvListener } from './useAiWindow'
import { nextMsgId, getConvState, clearConvStreamState, clearLastDelta, startApprovalTimer, clearAllApprovalTimers, switchingConvs, getConvStreamState, setConvCurrentText, notifyConversationChanged, setConvPendingApprovals, discardConvPendingApprovals, backfillConvAgentFinished, bumpToolEpoch } 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 { 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 按窗口域分流 ── // ── V11c(2026-10-05)活跃会话 KV 按窗口域分流 ──
// 主窗与分离窗是独立 JS context,却共用同一 SQLite KV:两窗各自切会话都写 'df-ai-active-conv' // 主窗与分离窗是独立 JS context,却共用同一 SQLite KV:两窗各自切会话都写 'df-ai-active-conv'
@@ -35,17 +36,78 @@ export function activeConvKvKey(): string {
const appSettings = useAppSettingsStore() 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<AiConversationSummary[]> {
return invoke<AiConversationSummary[]>('ai_conversation_list', {
limit: CONV_PAGE_SIZE,
includeArchived: true,
activeOffset,
archivedOffset,
})
}
/** 拉取会话列表;首次加载时若 appSettings 仍有有效 active-conv 且当前无活跃对话则恢复。
* F4-1 分页:从第一页重拉到覆盖既有加载深度(convListPaging)或拉尽——事件后刷新
* (scheduleConversationsRefresh)不把「加载更多」拉出的深度回缩成首屏 50;首启深度 0
* 即只拉一页,首屏性能不变。 */
async function loadConversations() { async function loadConversations() {
if (convListPaging.loading) return
convListPaging.loading = true
try { try {
const depthActive = convListPaging.activeLoaded
const depthArchived = convListPaging.archivedLoaded
const accActive: AiConversationSummary[] = []
const accArchived: AiConversationSummary[] = []
const seen = new Set<string>()
let aOff = 0
let rOff = 0
// includeArchived=true:侧栏归档分组(ConversationSidebar archivedConvs)数据源即本内存列表, // includeArchived=true:侧栏归档分组(ConversationSidebar archivedConvs)数据源即本内存列表,
// 若拉取时排除归档,重启后已归档会话不可见且无恢复入口(2026-10-05 实锤 bug)。 // 若拉取时排除归档,重启后已归档会话不可见且无恢复入口(2026-10-05 实锤 bug)。
// 活跃/归档分组由侧栏 groupedActive/archivedConvs 各自分离渲染;后端已分组截断,归档不挤占活跃名额。 // 活跃/归档分组由侧栏 groupedActive/archivedConvs 各自分离渲染;后端分组分页,归档不挤占活跃名额。
state.conversations = await aiApi.listConversations(50, true) 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 无记录)时,侧栏显示虚拟占位项。 // 虚拟项保底:活跃会话未落库(懒创建——首条消息前 DB 无记录)时,侧栏显示虚拟占位项。
// 后端 ai_conversation_create 仅生成 id 存内存不落库,首条消息发送后 save_conversation 才写库。 // 后端 ai_conversation_create 仅生成 id 存内存不落库,首条消息发送后 save_conversation 才写库。
// 此处 active 会话不在 DB 列表时补一个虚拟项,实现「点新建立即出现在侧栏,不发消息不落库」。 // 此处 active 会话不在 DB 列表时补一个虚拟项,实现「点新建立即出现在侧栏,不发消息不落库」。
// 首条消息落库后 AiCompleted/AiError 触发的 loadConversations 拉到同 id 真实记录,虚拟项被替代无重复。 // 首条消息落库后 AiCompleted/AiError 触发的 loadConversations 拉到同 id 真实记录,虚拟项被替代无重复。
// (虚拟项不计入分页游标——游标只数 DB 真实拉取条数。)
if (state.activeConversationId if (state.activeConversationId
&& !state.conversations.some(c => c.id === state.activeConversationId)) { && !state.conversations.some(c => c.id === state.activeConversationId)) {
const now = String(Date.now()) const now = String(Date.now())
@@ -94,6 +156,41 @@ async function loadConversations() {
} as AiMessage) } as AiMessage)
notifyConversationChanged() notifyConversationChanged()
} }
} finally {
convListPaging.loading = false
}
}
/** F4-1「加载更多」:从当前游标续拉一页并追加(不重拉首页,避免重复传输+闪烁);
* 返回是否实际新增条目(侧栏据此决定是否继续,数据漂移致空页时自然停)。 */
async function loadMoreConversations(): Promise<boolean> {
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<void> {
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 { return {
loadConversations, loadConversations,
scheduleConversationsRefresh, scheduleConversationsRefresh,
loadMoreConversations,
loadAllConversations,
newConversation, newConversation,
switchConversation, switchConversation,
deleteConversation, deleteConversation,
@@ -768,4 +867,4 @@ export function useAiConversations() {
} }
} }
export { loadConversations, scheduleConversationsRefresh, loadMoreHistory, getLoadMoreState } export { loadConversations, scheduleConversationsRefresh, loadMoreConversations, loadAllConversations, loadMoreHistory, getLoadMoreState }
+3
View File
@@ -409,6 +409,9 @@ async function sendMessage(text: string, skill?: string, forceMode = false, part
role: 'user', role: 'user',
content: text.trim(), content: text.trim(),
timestamp: Date.now(), timestamp: Date.now(),
// 与后端落库同标记(chat.rs 落库带 source.channel='inject'):本地气泡
// 不带则轮次分桶把它当「发起消息」多计一轮,重载后才归位
source: { channel: 'inject' },
}) })
} }
void emit('ai-toast', { msg: t('ai.injectMerged'), type: 'success' }) void emit('ai-toast', { msg: t('ai.injectMerged'), type: 'success' })
+10
View File
@@ -133,6 +133,8 @@ export default {
queueSave: 'Save', queueSave: 'Save',
queueCancel: 'Cancel', queueCancel: 'Cancel',
queueSendNow: 'Send now', 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) // 3c UX batch addition (I4 queue backfill: pull a queued item back into the input box)
queueBackfill: 'Backfill', queueBackfill: 'Backfill',
@@ -185,6 +187,10 @@ export default {
copyMsg: 'Copy message', copyMsg: 'Copy message',
copied: 'Copied', copied: 'Copied',
copyFailed: 'Copy failed', 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) // Copy bubble as image (html2canvas → clipboard; falls back to download)
copyAsImage: 'Copy as image', copyAsImage: 'Copy as image',
copiedAsImage: 'Copied as image', copiedAsImage: 'Copied as image',
@@ -287,6 +293,8 @@ export default {
recoverNow: 'Resume', recoverNow: 'Resume',
viewHistory: 'View history messages', viewHistory: 'View history messages',
viewSummary: 'View context summary', 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', viewContext: 'View AI context',
contextSystemPrompt: 'System Prompt', contextSystemPrompt: 'System Prompt',
contextEnvironment: 'Environment', contextEnvironment: 'Environment',
@@ -342,6 +350,8 @@ export default {
editEmptyBlocked: 'Edit content is empty, submission cancelled', editEmptyBlocked: 'Edit content is empty, submission cancelled',
skillNoMatchQuery: 'No skill matches "{q}", send blocked', skillNoMatchQuery: 'No skill matches "{q}", send blocked',
backfillOverwriteConfirm: 'The input box has an unsent draft; backfilling will overwrite it. Continue?', 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) ── // ── Outbox / scheduled messages (OutboxDrawer + ChatInput clock entry, design 2026-10-10 §3.4) ──
outboxEntry: 'Scheduled messages', outboxEntry: 'Scheduled messages',
+10
View File
@@ -133,6 +133,8 @@ export default {
queueSave: '保存', queueSave: '保存',
queueCancel: '取消', queueCancel: '取消',
queueSendNow: '立即发送', queueSendNow: '立即发送',
// 走查-信息UX 面二 F2-1:发送钮 title/aria(原误绑 queueSendNow,语义是插队而非发送)
send: '发送',
// 3c UX 批追加(I4 队列回填:取出排队项填回输入框重新编辑) // 3c UX 批追加(I4 队列回填:取出排队项填回输入框重新编辑)
queueBackfill: '回填', queueBackfill: '回填',
@@ -186,6 +188,10 @@ export default {
copyMsg: '复制消息', copyMsg: '复制消息',
copied: '已复制', copied: '已复制',
copyFailed: '复制失败', copyFailed: '复制失败',
// F1-1:代码块复制按钮(v-html 渲染层注入,decorateCodeHtml 传参)
copyCode: '复制',
// F1-1:长文折叠条带量按钮(展开态复用 collapse 键)
foldedSummary: '已折叠 {chars} 字符 / 约 {tokens} tokens,点击展开',
// 气泡复制为图片(html2canvas 渲染 → 剪贴板;失败降级下载) // 气泡复制为图片(html2canvas 渲染 → 剪贴板;失败降级下载)
copyAsImage: '复制为图片', copyAsImage: '复制为图片',
copiedAsImage: '已复制为图片', copiedAsImage: '已复制为图片',
@@ -288,6 +294,8 @@ export default {
recoverNow: '恢复', recoverNow: '恢复',
viewHistory: '查看历史消息', viewHistory: '查看历史消息',
viewSummary: '查看上下文摘要', viewSummary: '查看上下文摘要',
// 走查-信息UX 面六 F6-1:历史/摘要项点击定位未命中(目标在折叠段内未渲染 DOM)的行内提示
historyLocateInSegment: '该消息在折叠段(已归档/已压缩)内,请先在消息流中展开对应分隔条',
viewContext: '查看 AI 上下文', viewContext: '查看 AI 上下文',
contextSystemPrompt: '系统提示', contextSystemPrompt: '系统提示',
contextEnvironment: '环境', contextEnvironment: '环境',
@@ -343,6 +351,8 @@ export default {
editEmptyBlocked: '编辑内容为空,已取消提交', editEmptyBlocked: '编辑内容为空,已取消提交',
skillNoMatchQuery: '未找到技能「{q}」,已阻止发送', skillNoMatchQuery: '未找到技能「{q}」,已阻止发送',
backfillOverwriteConfirm: '输入框已有未发送草稿,回填将覆盖,确定继续?', backfillOverwriteConfirm: '输入框已有未发送草稿,回填将覆盖,确定继续?',
// 走查-信息UX 面二 F2-4:示例问题卡覆盖草稿二次确认(对齐回填确认语义)
exampleOverwriteConfirm: '输入框已有未发送草稿,点击示例将覆盖,确定继续?',
// ── 待发箱/定时消息(OutboxDrawer + ChatInput ⏰ 入口,设计 2026-10-10 §3.4)── // ── 待发箱/定时消息(OutboxDrawer + ChatInput ⏰ 入口,设计 2026-10-10 §3.4)──
outboxEntry: '定时消息', outboxEntry: '定时消息',