修复: 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
+1 -1
View File
@@ -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 时
+105 -6
View File
@@ -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<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() {
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<string>()
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<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 {
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 }
+3
View File
@@ -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' })
+10
View File
@@ -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',
+10
View File
@@ -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: '定时消息',