Files
u-ppt/src/components/ai/AiPanel.vue
T

473 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- =====================================================================
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>