473 lines
16 KiB
Vue
473 lines
16 KiB
Vue
<!-- =====================================================================
|
||
AiPanel.vue — AI 聊天面板
|
||
发送/停止/生成整套/润色本页/流式渲染/操作应用
|
||
===================================================================== -->
|
||
<script setup lang="ts">
|
||
import { ref, nextTick, computed, watch, onUnmounted } from 'vue'
|
||
import type { ChatMessage, AiOp, Slide } from '../../core/types'
|
||
import { store } from '../../core/store'
|
||
import { generate, polish, chat, beautifyPage, isConfigured } from '../../core/ai'
|
||
import { elementTypes } from '../../core/sample'
|
||
import { renderMd } from '../../core/markdown'
|
||
import { appPrompt, appConfirm } from '../../core/dialog'
|
||
import OutlinePanel from './OutlinePanel.vue'
|
||
|
||
const emit = defineEmits<{
|
||
(e: 'busy-change', busy: boolean): void
|
||
(e: 'toast', msg: string): void
|
||
(e: 'open-settings'): void
|
||
(e: 'switch-tab', tab: string): void
|
||
}>()
|
||
|
||
const CHAT_KEY = 'u-ppt.chat.v1' // 旧 key,仅用于迁移
|
||
|
||
/** 当前会话绑定的 chatId(跟随 deck.chatId) */
|
||
const currentChatId = computed(() => store.getChatId())
|
||
|
||
/** 渲染用消息条目(带可选的流式/标签/error 状态) */
|
||
interface RenderMsg {
|
||
key: number
|
||
role: 'user' | 'assistant' | 'system'
|
||
content: string
|
||
streaming?: boolean
|
||
tag?: string
|
||
error?: boolean
|
||
}
|
||
|
||
const renderMsgs = ref<RenderMsg[]>([])
|
||
const chatLog = ref<ChatMessage[]>(loadChat())
|
||
const messagesEl = ref<HTMLElement | null>(null)
|
||
const inputEl = ref<HTMLTextAreaElement | null>(null)
|
||
|
||
const inputText = ref('')
|
||
const busy = ref(false)
|
||
const showOutline = ref(false)
|
||
|
||
/* ---------- 当前选中元素(用于上下文对话) ---------- */
|
||
const selectedEl = computed(() => store.getSelected())
|
||
const selectedHint = computed(() => {
|
||
const el = selectedEl.value
|
||
if (!el) return ''
|
||
const label = elementTypes[el.type]?.label || el.type
|
||
const preview = (el.content || '').replace(/\n/g, ' ').slice(0, 40)
|
||
return label + (preview ? ' · ' + preview : '')
|
||
})
|
||
let abortCtrl: AbortController | null = null
|
||
let chatSaveTimer: ReturnType<typeof setTimeout> | null = null
|
||
let keySeq = 0
|
||
|
||
function loadChat(): ChatMessage[] {
|
||
// 按 chatId 加载该 PPT 关联的会话
|
||
return store.getChat(currentChatId.value)
|
||
}
|
||
|
||
function persistChat() {
|
||
if (chatLog.value.length > 200) chatLog.value.splice(0, chatLog.value.length - 200)
|
||
if (chatSaveTimer) clearTimeout(chatSaveTimer)
|
||
chatSaveTimer = setTimeout(() => {
|
||
store.setChat(currentChatId.value, chatLog.value)
|
||
}, 400)
|
||
}
|
||
|
||
function setBusy(b: boolean) {
|
||
busy.value = b
|
||
emit('busy-change', b)
|
||
// 停止/完成后焦点回输入框,继续对话无需重新点击
|
||
if (!b) nextTick(() => inputEl.value?.focus())
|
||
}
|
||
|
||
function scrollBottom() {
|
||
nextTick(() => {
|
||
const el = messagesEl.value
|
||
if (el) el.scrollTop = el.scrollHeight
|
||
})
|
||
}
|
||
|
||
function toast(msg: string) { emit('toast', msg) }
|
||
|
||
/** 从持久化 chatLog 重建渲染列表 */
|
||
function rebuildFromChatLog() {
|
||
renderMsgs.value = chatLog.value.map(m => ({
|
||
key: ++keySeq,
|
||
role: m.role,
|
||
content: m.content
|
||
}))
|
||
}
|
||
|
||
/** 追加一条静态消息(同时进 chatLog) */
|
||
function addPersisted(role: ChatMessage['role'], text: string) {
|
||
chatLog.value.push({ role, content: text })
|
||
persistChat()
|
||
renderMsgs.value.push({ key: ++keySeq, role, content: text })
|
||
scrollBottom()
|
||
}
|
||
|
||
/** 在跑的流式气泡集(组件卸载时统一停 rAF) */
|
||
const activeStreams = new Set<StreamCtrl>()
|
||
|
||
/** 创建一条流式气泡(返回控制器对象) */
|
||
interface StreamCtrl {
|
||
msg: RenderMsg
|
||
started: boolean
|
||
/** 打字机缓冲:网络 delta 先入队,rAF 匀速释放渲染(参考 devflow 流式体验) */
|
||
buffer: string
|
||
shown: string
|
||
rafId: number | null
|
||
flushPending: boolean
|
||
/** 冲刷完成回调(持久化等收尾动作) */
|
||
settled?: (() => void) | null
|
||
}
|
||
function streamBubble(placeholder?: string): StreamCtrl {
|
||
const msg: RenderMsg = {
|
||
key: ++keySeq,
|
||
role: 'assistant',
|
||
content: placeholder || '',
|
||
streaming: true
|
||
}
|
||
renderMsgs.value.push(msg)
|
||
scrollBottom()
|
||
const ctrl: StreamCtrl = { msg, started: !placeholder, buffer: '', shown: '', rafId: null, flushPending: false }
|
||
activeStreams.add(ctrl)
|
||
return ctrl
|
||
}
|
||
|
||
/** rAF 匀速释放缓冲:每帧最多放 CHARS_PER_FRAME 字符,网络抖动时也不突兀蹦字 */
|
||
const CHARS_PER_FRAME = 4
|
||
|
||
function pumpTypewriter(s: StreamCtrl) {
|
||
if (s.rafId != null) return
|
||
s.rafId = requestAnimationFrame(() => {
|
||
s.rafId = null
|
||
if (!s.buffer.length) {
|
||
// 缓冲排空:流已结束则收尾,否则待下一批 delta
|
||
if (s.flushPending) {
|
||
s.msg.streaming = false
|
||
s.flushPending = false
|
||
s.settled?.()
|
||
s.settled = null
|
||
}
|
||
return
|
||
}
|
||
// 每帧固定放 4 字符 ≈ 240 字符/秒,网络抖动时也保持匀速观感
|
||
const take = Math.min(s.buffer.length, CHARS_PER_FRAME)
|
||
s.shown += s.buffer.slice(0, take)
|
||
s.buffer = s.buffer.slice(take)
|
||
s.msg.content = s.shown
|
||
scrollBottom()
|
||
if (s.buffer.length || s.flushPending) pumpTypewriter(s)
|
||
})
|
||
}
|
||
|
||
function streamOnVisible(s: StreamCtrl) {
|
||
return (t: string) => {
|
||
if (!s.started) { s.msg.content = ''; s.shown = ''; s.started = true }
|
||
s.buffer += t
|
||
pumpTypewriter(s)
|
||
}
|
||
}
|
||
/** 流结束:标记待冲刷(缓冲剩余字符继续匀速放完再收尾),可选收尾回调 */
|
||
function streamFlush(s: StreamCtrl, onSettled?: () => void) {
|
||
s.settled = onSettled || null
|
||
s.flushPending = true
|
||
if (!s.buffer.length) {
|
||
// 缓冲已空:立即收尾
|
||
s.msg.streaming = false
|
||
s.flushPending = false
|
||
s.settled?.()
|
||
s.settled = null
|
||
return
|
||
}
|
||
pumpTypewriter(s)
|
||
}
|
||
function streamDone(s: StreamCtrl, onSettled?: () => void) {
|
||
streamFlush(s, onSettled)
|
||
}
|
||
function streamSetText(s: StreamCtrl, txt: string) {
|
||
// 外部直接设置最终文本(跳过打字机,用于错误/提示)
|
||
if (s.rafId != null) { cancelAnimationFrame(s.rafId); s.rafId = null }
|
||
s.buffer = ''
|
||
s.shown = txt
|
||
s.msg.content = txt
|
||
s.msg.streaming = false
|
||
s.flushPending = false
|
||
}
|
||
function streamError(s: StreamCtrl, msg: string) {
|
||
streamSetText(s, '⚠ ' + msg)
|
||
s.msg.error = true
|
||
}
|
||
function streamTag(s: StreamCtrl, txt: string) {
|
||
if (txt) s.msg.tag = txt
|
||
}
|
||
|
||
/** 应用 AI 返回的操作到 store */
|
||
function applyOp(op: AiOp, lockedIdx: number): string {
|
||
const slides = op.slides
|
||
if (op.action === 'create_all' && slides.length) {
|
||
store.replaceDeck({ theme: store.theme.value, slides }, { newChat: true })
|
||
return '已替换为 ' + slides.length + ' 页新演示'
|
||
}
|
||
if (op.action === 'add_page' && slides.length) {
|
||
const idx = (op.target != null ? op.target : lockedIdx) + 1
|
||
store.insertSlideAt(Math.min(idx, store.getCount()), slides[0])
|
||
return '已新增 ' + slides.length + ' 页'
|
||
}
|
||
if (op.action === 'update_page' && slides.length) {
|
||
let t = op.target != null ? op.target : lockedIdx
|
||
t = Math.max(0, Math.min(t, store.getCount() - 1))
|
||
store.replaceSlide(t, slides[0])
|
||
// 目标页与发送时页不同:跳转过去让用户直接看到改动
|
||
if (t !== lockedIdx) {
|
||
store.setCurrentIndex(t)
|
||
return '已更新第 ' + (t + 1) + ' 页并跳转'
|
||
}
|
||
return '已更新第 ' + (t + 1) + ' 页'
|
||
}
|
||
return ''
|
||
}
|
||
|
||
/** 把流式气泡固化为持久化记录 */
|
||
function persistStream(s: StreamCtrl) {
|
||
chatLog.value.push({ role: 'assistant', content: s.msg.content + (s.msg.tag ? '\n[✓ ' + s.msg.tag + ']' : '') })
|
||
persistChat()
|
||
}
|
||
|
||
/* ---------- 发送 ---------- */
|
||
function onSend() {
|
||
if (busy.value) return
|
||
const text = inputText.value.trim()
|
||
if (!text) return
|
||
if (!isConfigured()) { toast('请先配置 API Key'); emit('open-settings'); return }
|
||
inputText.value = ''
|
||
runChat(text)
|
||
}
|
||
|
||
async function runChat(input: string) {
|
||
addPersisted('user', input)
|
||
// history 发送前剥离系统追加的 [✓ ...] 标记行,避免污染 AI 上下文
|
||
const history = chatLog.value.slice(-10).map(m => ({
|
||
role: m.role,
|
||
content: m.content.replace(/\n?\[✓[^\]]*\]$/g, '')
|
||
}))
|
||
const idx0 = store.getCurrentIndex()
|
||
const stream = streamBubble()
|
||
|
||
setBusy(true)
|
||
abortCtrl = new AbortController()
|
||
|
||
try {
|
||
const r = await chat({
|
||
history,
|
||
input,
|
||
onVisible: streamOnVisible(stream),
|
||
signal: abortCtrl.signal,
|
||
selectedElement: selectedEl.value
|
||
})
|
||
// op 应用与 tag 立即执行;持久化等打字机冲刷完成(内容已齐)再落
|
||
if (r.op && r.op.action !== 'answer' && r.op.slides.length) {
|
||
const applied = applyOp(r.op, idx0)
|
||
streamTag(stream, applied)
|
||
}
|
||
streamDone(stream, () => {
|
||
if (!stream.msg.content) streamSetText(stream, '(已完成)')
|
||
persistStream(stream)
|
||
})
|
||
} catch (e: any) {
|
||
if (e?.name === 'AbortError') {
|
||
// 停止:放弃缓冲中未显示的部分之外,把已显示内容立即固化
|
||
if (stream.rafId != null) { cancelAnimationFrame(stream.rafId); stream.rafId = null }
|
||
stream.msg.streaming = false
|
||
stream.msg.content = stream.shown ? stream.shown + '\n(已停止)' : '(已停止)'
|
||
} else {
|
||
streamError(stream, e?.message || String(e))
|
||
}
|
||
persistStream(stream)
|
||
} finally {
|
||
setBusy(false); abortCtrl = null
|
||
}
|
||
}
|
||
|
||
function onStop() {
|
||
if (abortCtrl) abortCtrl.abort()
|
||
}
|
||
|
||
/* ---------- 生成整套 ---------- */
|
||
async function onGenerate() {
|
||
if (busy.value) return
|
||
const topic = inputText.value.trim() || await appPrompt('生成整套', { message: '请输入演示主题', placeholder: '例如「远程办公的兴起与未来」' })
|
||
if (!topic) 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
|
||
}
|
||
}
|
||
|
||
/* ---------- 润色本页 ---------- */
|
||
async function onPolish() {
|
||
if (busy.value) return
|
||
if (!isConfigured()) { toast('请先配置 API Key'); emit('open-settings'); return }
|
||
const slide = store.currentSlide.value
|
||
if (!slide) return
|
||
const idx0 = store.getCurrentIndex()
|
||
const idx = idx0 + 1
|
||
addPersisted('user', '🪄 润色第 ' + idx + ' 页')
|
||
const stream = streamBubble('正在润色第 ' + idx + ' 页…')
|
||
setBusy(true); abortCtrl = new AbortController()
|
||
|
||
try {
|
||
const r = await polish({ slide: slide as Slide, instruction: '让内容更有吸引力、表达更精炼,保持布局合理', signal: abortCtrl.signal })
|
||
streamDone(stream)
|
||
if (idx0 < store.getCount()) store.replaceSlide(idx0, r.slide)
|
||
streamSetText(stream, '🪄 已润色第 ' + idx + ' 页' + (r.note ? ':' + r.note : '') + '。Ctrl+Z 可撤销。')
|
||
persistStream(stream)
|
||
} catch (e: any) {
|
||
if (e?.name === 'AbortError') streamSetText(stream, '(已停止)')
|
||
else streamError(stream, e?.message || String(e))
|
||
persistStream(stream)
|
||
} finally {
|
||
setBusy(false); abortCtrl = null
|
||
}
|
||
}
|
||
|
||
/* ---------- 清空 ---------- */
|
||
async function onClear() {
|
||
if (!chatLog.value.length) { toast('对话已是空的'); return }
|
||
if (!(await appConfirm('清空对话记录?', '将清空当前 PPT 的对话记录', { danger: true, okText: '清空' }))) return
|
||
chatLog.value = []
|
||
store.clearChat(currentChatId.value)
|
||
renderMsgs.value = []
|
||
}
|
||
|
||
/* ---------- 大纲面板 ---------- */
|
||
function onToggleOutline() {
|
||
showOutline.value = !showOutline.value
|
||
}
|
||
|
||
/* ---------- 一键美化 ---------- */
|
||
async function onBeautify() {
|
||
if (busy.value) return
|
||
if (!isConfigured()) { toast('请先配置 API Key'); emit('open-settings'); return }
|
||
const deck = store.getDeck()
|
||
const slides = deck.slides
|
||
if (!slides || !slides.length) { toast('当前没有幻灯片可美化'); return }
|
||
addPersisted('user', '🎨 一键美化全部 (' + slides.length + ' 页)')
|
||
const stream = streamBubble('正在美化第 1/' + slides.length + ' 页…')
|
||
setBusy(true); abortCtrl = new AbortController()
|
||
|
||
let done = 0
|
||
try {
|
||
for (let i = 0; i < slides.length; i++) {
|
||
if (abortCtrl.signal.aborted) break
|
||
stream.msg.content = '正在美化 ' + (i + 1) + '/' + slides.length + ' 页…'
|
||
scrollBottom()
|
||
const r = await beautifyPage({ slide: store.getDeck().slides[i], signal: abortCtrl.signal })
|
||
store.replaceSlide(i, r.slide)
|
||
done++
|
||
}
|
||
streamDone(stream)
|
||
streamSetText(stream, '✅ 已美化 ' + done + ' 页')
|
||
persistStream(stream)
|
||
} catch (e: any) {
|
||
if (e?.name === 'AbortError') streamSetText(stream, '(已停止,已美化 ' + done + ' 页)')
|
||
else streamError(stream, e?.message || String(e))
|
||
persistStream(stream)
|
||
} finally {
|
||
setBusy(false); abortCtrl = null
|
||
}
|
||
}
|
||
|
||
function onInputKeydown(e: KeyboardEvent) {
|
||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); onSend() }
|
||
}
|
||
|
||
/** 供父组件查询/聚焦 */
|
||
function isBusy() { return busy.value }
|
||
function focus() { nextTick(() => inputEl.value?.focus()) }
|
||
defineExpose({ isBusy, focus })
|
||
|
||
/* ---------- 初始化:从持久化记录重建 ---------- */
|
||
rebuildFromChatLog()
|
||
|
||
/* 组件卸载:停掉所有在跑的打字机 rAF */
|
||
onUnmounted(() => {
|
||
for (const s of activeStreams) {
|
||
if (s.rafId != null) cancelAnimationFrame(s.rafId)
|
||
}
|
||
activeStreams.clear()
|
||
})
|
||
|
||
/* ---------- 会话切换:chatId 变化时重新加载对话 ---------- */
|
||
watch(currentChatId, () => {
|
||
chatLog.value = loadChat()
|
||
rebuildFromChatLog()
|
||
scrollBottom()
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div class="panel-pane ai-pane">
|
||
<div class="ai-actions">
|
||
<button class="ai-action" :disabled="busy" @click="onGenerate">✨ 生成整套</button>
|
||
<button class="ai-action" :disabled="busy" @click="onPolish">🪄 润色本页</button>
|
||
<button class="ai-action" :disabled="busy" @click="onToggleOutline">📋 大纲</button>
|
||
<button class="ai-action" :disabled="busy" @click="onBeautify">🎨 美化</button>
|
||
<button class="ai-action ghost" :disabled="busy" @click="onClear">🗑 清空</button>
|
||
</div>
|
||
|
||
<OutlinePanel v-if="showOutline" :visible="showOutline" @busy-change="setBusy" @toast="toast" @open-settings="emit('open-settings')" />
|
||
|
||
<!-- 选中元素提示条:告诉用户 AI 会围绕这个元素对话 -->
|
||
<div v-if="selectedHint" class="selected-hint" title="AI 对话将基于此选中元素">
|
||
<span class="selected-hint-icon">④</span>
|
||
<span class="selected-hint-text">已选中:{{ selectedHint }}</span>
|
||
<span class="selected-hint-flag">AI 将围绕它对话</span>
|
||
</div>
|
||
|
||
<div class="chat-messages" ref="messagesEl">
|
||
<div v-if="!renderMsgs.length" class="chat-empty">
|
||
告诉我你的主题,例如:<br />
|
||
「生成一份关于<b>远程办公趋势</b>的演示」
|
||
</div>
|
||
<div v-for="m in renderMsgs" :key="m.key" class="msg" :class="m.role">
|
||
<div class="bubble" :class="{ error: m.error }">
|
||
<span v-if="m.tag" class="diff-tag">✓ {{ m.tag }}</span>
|
||
<!-- Markdown 整段渲染(含代码块/列表/加粗等,经 DOMPurify 消毒) -->
|
||
<div v-if="m.content" class="md-body" v-html="renderMd(m.content)"></div>
|
||
<span v-if="m.streaming" class="cursor"></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="chat-input-bar">
|
||
<textarea
|
||
ref="inputEl"
|
||
v-model="inputText"
|
||
id="chatInput"
|
||
rows="3"
|
||
placeholder="输入指令,回车发送(Shift+Enter 换行)"
|
||
@keydown="onInputKeydown"
|
||
></textarea>
|
||
<div class="btns">
|
||
<button v-if="!busy" class="btn primary" @click="onSend">发送</button>
|
||
<button v-else class="btn danger" @click="onStop">停止</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|