新增: AI Chat多项增强(审批去重/编辑重发/导出/实体引用/会话置顶搜索)+任务推进链df-nodes落地

This commit is contained in:
2026-06-16 12:41:13 +08:00
parent 212a927eee
commit 7d5cd4c89a
62 changed files with 4576 additions and 248 deletions

View File

@@ -67,7 +67,9 @@ export async function switchConversation(id: string) {
}
state.messages = rawMsgs
.filter((m: any) => m.role !== 'tool')
// 过滤 tool 消息 + truncated 软删消息(UX-09:编辑某条 user 后其后消息标 truncated,
// 保留 DB 可追溯但从视图移除;前端无 status 字段故按原始 JSON 字段过滤)
.filter((m: any) => m.role !== 'tool' && m.status !== 'truncated')
.map((m: any, i: number) => ({
id: `loaded-${i}`,
role: m.role,
@@ -170,6 +172,15 @@ async function archiveConversation(id: string, archived: boolean) {
notifyConversationChanged()
}
/** 置顶/取消置顶(后端 + 本地侧栏排序同步;UX-17) */
async function setPinnedConversation(id: string, pinned: boolean) {
await aiApi.setPinnedConversation(id, pinned)
// 本地同步置顶态(排序 computed 读 pinned,立即重排)
const conv = state.conversations.find(c => c.id === id)
if (conv) conv.pinned = pinned
notifyConversationChanged()
}
/** 折叠/展开归档分组 */
function toggleArchivedFold() {
state.archivedCollapsed = !state.archivedCollapsed
@@ -190,6 +201,7 @@ export function useAiConversations() {
deleteConversation,
renameConversation,
archiveConversation,
setPinnedConversation,
toggleArchivedFold,
toggleSidebar,
}

View File

@@ -10,6 +10,7 @@
//! - nextMsgId 已下沉到 aiShared(原为本模块导出,useAiStream 亦依赖之构成循环依赖,故抽出)
import { listen, emit } from '@tauri-apps/api/event'
import { ref } from 'vue'
import { aiApi } from '@/api'
import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai'
@@ -18,7 +19,7 @@ import { nextMsgId } from './aiShared'
import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream'
import { drainQueue, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers } from './useAiSend'
import { loadConversations } from './useAiConversations'
import type { AiChatEvent, AiToolCallInfo } from '@/api/types'
import type { AiChatEvent, AiMessage, AiToolCallInfo } from '@/api/types'
// composable 内非组件上下文(无 setup),用 vue-i18n 全局实例的 t 而非 useI18n()。
// 通过 any 中转规避 vue-i18n 深度 message schema 泛型导致的 TS2589(类型实例化过深)。
@@ -36,7 +37,8 @@ const appSettings = useAppSettingsStore()
// B-260616-17: 看门狗不重置的事件集合(审批等待/完成/错误由各自 case 内 clear)。
// 模块级 Set 复用,避免 handleEvent 每事件(delta/token 高频)新建数组字面量做 includes。
const NO_RESET_WATCHDOG = new Set<AiChatEvent['type']>(['AiApprovalRequired', 'AiCompleted', 'AiError'])
// F-260616-03: AiMaxRoundsReached 加入——达 max 暂停态等用户决定继续/停止,不计整流超时。
const NO_RESET_WATCHDOG = new Set<AiChatEvent['type']>(['AiApprovalRequired', 'AiCompleted', 'AiError', 'AiMaxRoundsReached'])
// B-260616-12: 工具执行超时提示(纯前端降级,后端无工具级取消 IPC)。
// 每个 running 工具一个独立 setTimeout;到时若仍未收到 Completed/Approval,
@@ -81,6 +83,15 @@ function clearAllToolSlowTimers(): void {
_toolSlowNotified.clear()
}
// F-260616-03: 达 max_iterations 暂停态(后端仍 generating=true)。
//
// 不进 store.state(批次41 领地,且与 pendingApprovals 同性质是"待用户操作"信号,
// 独立事件源——AiMaxRoundsReached 与 AiApprovalRequired 不混)。用模块级 ref 导出,
// AiChat.vue 直接 import 读;case 内 push,true 表"有挂起询问"。
// 一次只追踪一个挂起询问(后端 AiSession 单例,达 max 后续轮次前用户必先决定),
// 故用 boolean ref 而非数组,语义更精确。
export const pendingMaxRounds = ref(false)
/** 通知会话列表发生变化(供 newConversation/deleteConversation/rename/archive 等触发刷新侧栏) */
export function notifyConversationChanged() {
emit('ai-conversation-changed', {})
@@ -186,6 +197,13 @@ export function handleEvent(event: AiChatEvent) {
// 心跳事件:仅维持看门狗(已在上方 reset),无需额外处理;显式 case 防 switch 穿透
break
case 'AiMaxRoundsReached':
// F-260616-03: 达 max_iterations 暂停态(后端仍 generating=true),前端展示操作卡询问。
// 后端已 save 落库,这里仅翻 pendingMaxRounds=true 驱动 UI 卡片;看门狗已由
// NO_RESET_WATCHDOG 跳过 reset(达 max 不计整流超时)。
pendingMaxRounds.value = true
break
case 'AiToolCallStarted': {
const info: AiToolCallInfo = {
id: event.id,
@@ -265,6 +283,7 @@ export function handleEvent(event: AiChatEvent) {
state.streaming = false
state.generatingConvId = null
state.agentRound = 0 // AE-2025-07: agentic 结束,复位轮次(隐藏进度条)
pendingMaxRounds.value = false // F-260616-03: 收尾(停止/续跑后新一轮达 max 才会再 set),清操作卡
// token 用量记录(开关开时):lastTokenUsage 供当前回复展示,convTokenTotal 累加对话总量
if (isShowTokenUsage()) {
state.lastTokenUsage = {
@@ -298,15 +317,20 @@ export function handleEvent(event: AiChatEvent) {
state.currentText = ''
state.agentRound = 0 // AE-2025-07: agentic 异常中断,复位轮次
state.queue = [] // B-32:错误收尾清队列,防生成中入队的消息被静默丢失(drainQueue 仅 AiCompleted 触发)
pendingMaxRounds.value = false // F-260616-03: 异常中断,清操作卡
localStorage.removeItem('df-ai-gen')
localStorage.removeItem('df-ai-text')
// UX-03: 错误消息携带 error_type(供错误气泡差异化显隐「去设置」按钮)。
// AiMessage 类型未含 errorType 字段(不在本批白名单),用对象字面量 + cast 扩展;
// 消费方(AiChat.vue canOpenSettings)经同 cast 读取,类型闭环在两端,不污染 types.ts。
state.messages.push({
id: `err-${nextMsgId()}`,
role: 'assistant',
content: friendlyError(event.error),
isError: true,
errorType: event.error_type,
timestamp: Date.now(),
})
} as AiMessage)
break
}
}

View File

@@ -140,6 +140,135 @@ async function doSend(text: string, skill?: string, force = false) {
}
}
/**
* 重新生成最后一条 AI 回复(UX-02)。
*
* 与 doSend 的区别:
* - 不 push 新 user 消息(触发它的 user 消息已在历史末尾)
* - 仅 push 一个空 AI 气泡占位 + 置 streaming,后端弹出旧 AI 回复后重跑 loop
* - 失败回滚只移除占位气泡(不移除 user 消息)
*
* 后端 ai_regenerate 占用 generating 防并发双发,此处不再额外入队/预检。
*/
async function regenerate() {
// 末尾须有 AI 回复可重生成(后端也会校验,前端先挡避免空跑)
const msgs = state.messages
const last = msgs[msgs.length - 1]
if (!last || last.role !== 'assistant') {
throw new Error(t('aiChat.regenerateUnavailable'))
}
// 移除末尾旧 AI 气泡(后端也弹 history;前端先弹避免占位前残留旧文),push 空气泡占位
state.messages.pop()
const aiMsgId = `ai-${nextMsgId()}`
state.messages.push({
id: aiMsgId,
role: 'assistant',
content: '',
timestamp: Date.now(),
})
state.streaming = true
state.currentText = ''
state.queue = [] // 重生成视同新发,清残留队列
resetStreamWatchdog()
await startListener()
const raw = appSettings.get<string>('df-ai-language', 'auto')
const lang = raw === 'auto'
? appSettings.get<string>('df-language', 'zh-CN')
: raw
const convId = state.activeConversationId
if (!convId) {
// 无活跃对话:回滚占位,报错
state.streaming = false
clearStreamWatchdog()
state.messages = state.messages.filter(m => m.id !== aiMsgId)
throw new Error(t('aiChat.regenerateUnavailable'))
}
try {
await aiApi.regenerate(convId, lang)
} catch (e) {
// IPC 失败:回滚 streaming + 移除占位气泡(旧回复后端已弹但前端先弹了,保持弹后态)
state.streaming = false
clearStreamWatchdog()
state.messages = state.messages.filter(m => m.id !== aiMsgId)
throw e
}
}
/**
* 编辑最后一条 user 消息并重新生成(UX-09)。
*
* 与 regenerate 的区别:
* - regenerate:末条须是 assistant,pop 旧 AI 回复后重跑
* - editMessage:末条须是 user(中间编辑被后端拒绝),就地改其 content,截断其后所有消息,
* push 空气泡占位后重跑。被截断消息后端标 truncated(软删保留 DB),前端视图层同步移除。
*
* 后端 ai_chat_edit 占用 generating 防并发双发,此处不再额外入队/预检。
*
* @param newMessage 编辑后的新内容(替换末条 user 消息 content)
*/
async function editMessage(newMessage: string) {
if (!newMessage.trim()) return
// 末条须是 user(中间编辑语义复杂,拒绝);后端也会校验,前端先挡避免空跑
const msgs = state.messages
const last = msgs[msgs.length - 1]
if (!last || last.role !== 'user') {
throw new Error(t('aiChat.editNotLastUser'))
}
// 截断末条 user 之后的消息(此时 last 即末条 user,其后应已有 assistant 回复或为空)
// 找到末条 user 的下标,移除其后所有消息
let lastUserIdx = -1
for (let i = msgs.length - 1; i >= 0; i--) {
if (msgs[i].role === 'user') { lastUserIdx = i; break }
}
if (lastUserIdx < 0) {
throw new Error(t('aiChat.editNotLastUser'))
}
// 截断其后(保留末条 user 本身,改其 content)
state.messages.splice(lastUserIdx + 1)
msgs[lastUserIdx].content = newMessage.trim()
// push 空气泡占位(新 AI 回复将流入)
const aiMsgId = `ai-${nextMsgId()}`
state.messages.push({
id: aiMsgId,
role: 'assistant',
content: '',
timestamp: Date.now(),
})
state.streaming = true
state.currentText = ''
state.queue = [] // 编辑重生成视同新发,清残留队列
resetStreamWatchdog()
await startListener()
const raw = appSettings.get<string>('df-ai-language', 'auto')
const lang = raw === 'auto'
? appSettings.get<string>('df-language', 'zh-CN')
: raw
const convId = state.activeConversationId
if (!convId) {
state.streaming = false
clearStreamWatchdog()
state.messages = state.messages.filter(m => m.id !== aiMsgId)
throw new Error(t('aiChat.editNotLastUser'))
}
try {
await aiApi.editMessage(convId, newMessage.trim(), lang)
} catch (e) {
// IPC 失败:回滚 streaming + 移除占位气泡(被截断的旧回复后端已标 truncated,前端已移除)
state.streaming = false
clearStreamWatchdog()
state.messages = state.messages.filter(m => m.id !== aiMsgId)
throw e
}
}
/** 取出队首并发送AiCompleted 触发,此时 streaming 已 false */
export function drainQueue() {
if (state.queue.length === 0) return
@@ -281,6 +410,8 @@ async function stopChat() {
export function useAiSend() {
return {
sendMessage,
regenerate,
editMessage,
approveToolCall,
batchApprove,
drainQueue,