From b08bc0e0387bcd71ec04cd1c4d773743cb85bb12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Sun, 30 Aug 2026 04:07:54 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20AI=E7=94=9F=E6=88=90?= =?UTF-8?q?=E5=BD=A2=E7=8A=B6=E6=80=AA=E5=BC=82=E6=B2=BB=E7=90=86(?= =?UTF-8?q?=E6=8B=BC=E5=9B=BE=E6=A1=88/=E5=88=86=E9=9A=94=E6=9D=A1/?= =?UTF-8?q?=E5=8E=8B=E5=AD=97=E6=9C=BA=E6=A2=B0=E6=A0=A1=E6=AD=A3+?= =?UTF-8?q?=E8=A3=85=E9=A5=B0=E5=85=8B=E5=88=B6=E7=BA=A6=E6=9D=9F)+?= =?UTF-8?q?=E5=A4=A7=E7=BA=B2=E9=A9=B1=E5=8A=A8=E7=94=9F=E6=88=90=E5=8A=A0?= =?UTF-8?q?=E5=9B=BA(=E7=9C=8B=E9=97=A8=E7=8B=97/=E9=A1=B5=E5=BA=8F?= =?UTF-8?q?=E5=AF=B9=E9=BD=90/=E7=A9=BA=E6=80=81=E5=8E=BB=E9=87=8D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ai/AiPanel.vue | 111 ++++++++++++++++++----------- src/components/ai/OutlinePanel.vue | 66 ++++++++++------- src/core/ai.ts | 83 ++++++++++++++++++++- 3 files changed, 192 insertions(+), 68 deletions(-) diff --git a/src/components/ai/AiPanel.vue b/src/components/ai/AiPanel.vue index 694752d..1d7da5a 100644 --- a/src/components/ai/AiPanel.vue +++ b/src/components/ai/AiPanel.vue @@ -7,11 +7,11 @@ import { ref, nextTick, computed, watch, onMounted, onUnmounted } from 'vue' import type { ChatMessage, AiOp, Outline, Slide } from '../../core/types' import { store } from '../../core/store' -import { generate, polish, chat, beautifyPage, isConfigured, buildAgentPrompt, parseChatReply } from '../../core/ai' +import { polish, chat, beautifyPage, isConfigured, buildAgentPrompt, parseChatReply } from '../../core/ai' import { relay, type RelayStatus } from '../../core/relay' import { elementTypes } from '../../core/sample' import { renderMd } from '../../core/markdown' -import { appPrompt, appConfirm } from '../../core/dialog' +import { appConfirm } from '../../core/dialog' import OutlinePanel from './OutlinePanel.vue' import Icon from '../common/Icon.vue' @@ -299,6 +299,11 @@ function persistStream(s: StreamCtrl) { const inflight = new Map() /** 单一忙碌请求的 rid(用于「停止」) */ let agentBusyRid: string | null = null +/** 请求级看门狗:agent 180s 无响应则按停止语义释放,防挂死 */ +let agentWatchdog: ReturnType | null = null +const AGENT_TIMEOUT_MS = 180_000 +/** 当前 inflight 单页生成对应的大纲条目下标(null=非单页请求),结果到达时按此对齐写入 */ +let agentPendingPageIdx: number | null = null function onRelayStatus(s: RelayStatus, detail?: string) { status.value = s @@ -317,7 +322,11 @@ function onRelayResult(rid: string, text: string) { let m = inflight.get(rid) inflight.delete(rid) relay.settle(rid) - if (rid === agentBusyRid) { agentBusyRid = null; setBusy(false) } + if (rid === agentBusyRid) { + agentBusyRid = null + if (agentWatchdog) { clearTimeout(agentWatchdog); agentWatchdog = null } + setBusy(false) + } if (m) { m.content = text m.streaming = false @@ -334,11 +343,15 @@ function onRelayResult(rid: string, text: string) { agentOutline.value = op.outline showOutline.value = true } else if (op && (op.action === 'gen_page' || op.action === 'add_page') && op.slides.length) { - // 大纲单页生成结果:转交 OutlinePanel(若面板打开且有大纲),回收后驱动下一条(串行全部生成) - if (showOutline.value && agentOutline.value) { - outlinePanelEl.value?.acceptAgentSlide(op.slides[0]) + // 大纲单页生成结果:有大纲则按请求时记录的条目下标对齐回收(面板关闭也照写画布),回收后驱动下一条(串行全部生成) + if (agentOutline.value) { + outlinePanelEl.value?.acceptAgentSlide(op.slides[0], agentPendingPageIdx != null ? agentPendingPageIdx : undefined) + agentPendingPageIdx = null driveNextOutlinePage() - } else m.tag = applyOp(op, store.getCurrentIndex()) + } else { + toast('收到页面结果但无活动大纲,已忽略') + agentPendingPageIdx = null + } } else if (op && op.action !== 'answer' && op.slides.length) { m.tag = applyOp(op, store.getCurrentIndex()) } @@ -370,7 +383,13 @@ function switchChannel(c: 'direct' | 'agent') { /** agent 通道发送(发送分流与快捷操作共用;带多轮历史与选中元素上下文) */ function sendAgent(input: string) { if (!agentConfigured.value) { toast('请先在设置中配置 Agent 中继'); emit('open-relay-settings'); return } - if (status.value !== 'connected') { toast('中继未连接,请稍候'); return } + if (status.value !== 'connected') { + toast('中继未连接,请稍候') + agentFailCurrent('') + return + } + // busy 守卫:同一时刻只允许一个 agent 请求,防止并发覆盖 agentBusyRid 打断串行链 + if (agentBusyRid) { toast('当前有生成任务进行中'); return } // 历史取最近 10 条(剥离 tag 后缀),不含本次输入 const history = agentMsgs.value.slice(-10).map(m => ({ role: m.role, @@ -385,17 +404,40 @@ function sendAgent(input: string) { addAgentMsg(m) inflight.set(rid, m) setBusy(true) + // 请求级看门狗:180s 无响应按停止语义释放,防请求挂死 + if (agentWatchdog) clearTimeout(agentWatchdog) + agentWatchdog = setTimeout(() => { + agentWatchdog = null + if (rid !== agentBusyRid) return + const pageIdx = agentPendingPageIdx + stopAgent() + toast('agent 响应超时') + if (pageIdx != null) toast('第 ' + (pageIdx + 1) + ' 页生成失败,可手动重试') + }, AGENT_TIMEOUT_MS) } catch (e: any) { addAgentMsg({ key: ++keySeq, role: 'assistant', content: (e?.message || String(e)), error: true }) + agentFailCurrent(e?.message || String(e)) } scrollBottom() } +/** agent 请求失败收尾:清看门狗/占位,复位 busy 允许手动续发;单页请求时提示带页码的失败信息 */ +function agentFailCurrent(reason: string) { + if (agentWatchdog) { clearTimeout(agentWatchdog); agentWatchdog = null } + agentBusyRid = null + const pageIdx = agentPendingPageIdx + agentPendingPageIdx = null + if (busy.value) setBusy(false) + if (pageIdx != null) toast('第 ' + (pageIdx + 1) + ' 页生成失败' + (reason ? ':' + reason : '') + ',可手动重试') + else if (reason) toast(reason) +} + /** agent 通道停止:放弃匹配槽,气泡标「(已停止)」 */ function stopAgent() { if (!agentBusyRid) return const rid = agentBusyRid agentBusyRid = null + if (agentWatchdog) { clearTimeout(agentWatchdog); agentWatchdog = null } const m = inflight.get(rid) inflight.delete(rid) relay.settle(rid) @@ -468,34 +510,14 @@ function onStop() { if (abortCtrl) abortCtrl.abort() } -/* ---------- 生成整套 ---------- */ -async function onGenerate() { +/* ---------- 生成整套:统一入口,打开大纲面板并聚焦主题输入(大纲驱动创作) ---------- */ +function onGenerate() { if (busy.value) return - const topic = inputText.value.trim() || await appPrompt('生成整套', { message: '请输入演示主题', placeholder: '例如「远程办公的兴起与未来」' }) - if (!topic) return - // agent 通道:转自然语言指令走中继 - if (channel.value === 'agent') { inputText.value = ''; sendAgent('生成一套关于「' + topic + '」的完整演示,约 6 页'); return } - if (!isConfigured()) { toast('请先配置 API Key'); emit('open-settings'); return } - inputText.value = '' - const stream = streamBubble('正在创作「' + topic + '」…') - setBusy(true); abortCtrl = new AbortController() - - try { - const r = await generate({ topic, signal: abortCtrl.signal }) - streamDone(stream) - // 生成整套 → 开新会话,旧会话保留在 localStorage - store.replaceDeck({ theme: store.theme.value, slides: r.slides }, { newChat: true }) - // watch(currentChatId) 会自动清空 renderMsgs 并加载新会话(空的) - // 在新会话里记录这次生成 - addPersisted('user', '生成整套:' + topic) - addPersisted('assistant', '已生成 ' + r.slides.length + ' 页演示。可在画布查看与微调,Ctrl+Z 可撤销。') - } catch (e: any) { - if (e?.name === 'AbortError') streamSetText(stream, '(已停止)') - else streamError(stream, e?.message || String(e)) - persistStream(stream) - } finally { - setBusy(false); abortCtrl = null - } + // 输入框已有主题则预填进大纲面板(沿用旧版「输入框即主题」习惯),并清空聊天输入避免两处重复 + const prefill = inputText.value.trim() + if (prefill) inputText.value = '' + showOutline.value = true + nextTick(() => outlinePanelEl.value?.focusTopic(prefill)) } /* ---------- 润色本页 ---------- */ @@ -552,12 +574,16 @@ function onToggleOutline() { } /** OutlinePanel(agent 通道)转发指令:把生成大纲/单页的自然语言指令发给中继 Agent */ -function onOutlineRequestAgent(instruction: string) { +function onOutlineRequestAgent(instruction: string, pageIdx?: number) { if (!instruction.startsWith('按大纲生成')) { - // 生成大纲指令:记住主题,agent 返回 outline 后回填 topic + // 生成大纲指令:记住主题,agent 返回 outline 后回填 topic;期间面板进入生成中状态(agentBusyRid 驱动面板忙态) const m = instruction.match(/主题「([^」]+)」/) if (m) agentOutlineTopic.value = m[1] agentOutline.value = null + agentPendingPageIdx = null + } else { + // 单页生成:记录目标条目下标,结果到达时按此对齐写入(避免扫首个未完成错位) + agentPendingPageIdx = pageIdx != null ? pageIdx : null } sendAgent(instruction) } @@ -567,8 +593,9 @@ function driveNextOutlinePage() { const panel = outlinePanelEl.value if (!panel || !agentOutline.value) return const next = agentOutline.value.items.findIndex(it => !it.done) - if (next < 0) return - panel.requestAgent(pageInstructionOf(next)) + if (next < 0) { toast('大纲全部页已生成'); return } + panel.requestAgent(pageInstructionOf(next), next) + agentPendingPageIdx = next } /** 大纲第 idx 条的生成指令(与 OutlinePanel.pageInstruction 同格式) */ function pageInstructionOf(idx: number): string { @@ -639,6 +666,9 @@ onUnmounted(() => { /* ---------- 会话切换:chatId 变化时重新加载对话(direct + agent 双通道) ---------- */ watch(currentChatId, () => { + // 有 inflight agent 请求先按停止语义清理(含看门狗/busy 复位),避免旧会话结果串进新会话 + if (agentBusyRid) stopAgent() + agentPendingPageIdx = null chatLog.value = loadChat() rebuildFromChatLog() agentMsgs.value = loadAgentChat() @@ -687,6 +717,7 @@ watch(currentChatId, () => { :visible="showOutline" :initial-outline="channel === 'agent' ? agentOutline : null" :agent-channel="channel === 'agent'" + :agent-busy="channel === 'agent' && busy" @busy-change="setBusy" @toast="toast" @open-settings="emit('open-settings')" @@ -708,7 +739,7 @@ watch(currentChatId, () => {