新增: 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

@@ -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,