新增: AI Chat多项增强(审批去重/编辑重发/导出/实体引用/会话置顶搜索)+任务推进链df-nodes落地
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user