修复: 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:
+1
-1
@@ -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 时
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '定时消息',
|
||||
|
||||
Reference in New Issue
Block a user