修复: AI生成形状怪异治理(拼图案/分隔条/压字机械校正+装饰克制约束)+大纲驱动生成加固(看门狗/页序对齐/空态去重)
This commit is contained in:
@@ -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<string, RenderMsg>()
|
||||
/** 单一忙碌请求的 rid(用于「停止」) */
|
||||
let agentBusyRid: string | null = null
|
||||
/** 请求级看门狗:agent 180s 无响应则按停止语义释放,防挂死 */
|
||||
let agentWatchdog: ReturnType<typeof setTimeout> | 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, () => {
|
||||
<button class="btn primary go-settings" @click="emit('open-relay-settings')">去设置</button>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div v-if="!viewMsgs.length" class="chat-empty">
|
||||
<div v-if="!viewMsgs.length && !showOutline" class="chat-empty">
|
||||
<template v-if="channel === 'agent'">
|
||||
通过中继把指令发给远端 Agent,例如:<br />
|
||||
「把当前页的标题<b>改得更有冲击力</b>」
|
||||
|
||||
@@ -3,26 +3,31 @@
|
||||
生成大纲 → 逐条编辑 → 逐页/全部生成 → 应用到文稿
|
||||
===================================================================== -->
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { ref, computed, watch, onMounted, nextTick } from 'vue'
|
||||
import type { Outline, OutlineItem, Slide } from '../../core/types'
|
||||
import { store } from '../../core/store'
|
||||
import { outline as genOutline, generatePage, isConfigured } from '../../core/ai'
|
||||
import Icon from '../common/Icon.vue'
|
||||
|
||||
const props = defineProps<{ visible: boolean; initialOutline?: Outline | null; agentChannel?: boolean }>()
|
||||
const props = defineProps<{ visible: boolean; initialOutline?: Outline | null; agentChannel?: boolean; agentBusy?: boolean }>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'busy-change', busy: boolean): void
|
||||
(e: 'toast', msg: string): void
|
||||
(e: 'open-settings'): void
|
||||
/** agent 通道:把生成大纲/单页的指令交由 AiPanel 转发给中继 Agent */
|
||||
(e: 'request-agent', instruction: string): void
|
||||
/** agent 通道:把生成大纲/单页的指令交由 AiPanel 转发给中继 Agent(单页时携带目标条目下标) */
|
||||
(e: 'request-agent', instruction: string, pageIdx?: number): void
|
||||
}>()
|
||||
|
||||
const outline = ref<Outline | null>(null)
|
||||
const topicInput = ref('')
|
||||
const topicEl = ref<HTMLInputElement | null>(null)
|
||||
const titleInput = ref('')
|
||||
const editingTitle = ref(false)
|
||||
const busy = ref(false)
|
||||
/** agent 通道忙态(由 AiPanel 的 agentBusyRid 经 prop 下传):请求进行中禁用交互防并发打断 */
|
||||
const agentBusy = computed(() => !!props.agentBusy)
|
||||
/** 面板级总忙态:本地生成忙 or agent 请求忙 */
|
||||
const locked = computed(() => busy.value || agentBusy.value)
|
||||
const progress = ref({ cur: 0, total: 0 })
|
||||
const generatedSlides = ref<Slide[]>([])
|
||||
const pageCount = ref<'auto' | number>('auto')
|
||||
@@ -66,10 +71,11 @@ function applyInitialOutline(o: Outline) {
|
||||
expanded.value = new Set()
|
||||
}
|
||||
|
||||
/** 接收 agent 通道生成的单页(由 AiPanel 在 op 返回后调用) */
|
||||
function acceptAgentSlide(slide: Slide) {
|
||||
// 对齐第一个未完成条目:generatedSlides 与 items 同索引(空位补 undefined 占位,应用时 filter 掉)
|
||||
const idx = outline.value ? outline.value.items.findIndex(it => !it.done) : -1
|
||||
/** 接收 agent 通道生成的单页(由 AiPanel 在 op 返回后调用;pageIdx 为请求时记录的目标条目下标) */
|
||||
function acceptAgentSlide(slide: Slide, pageIdx?: number) {
|
||||
// 优先按请求时记录的条目下标对齐;无记录时退回扫首个未完成;generatedSlides 与 items 同索引(空位补 undefined 占位)
|
||||
const scanned = outline.value ? outline.value.items.findIndex(it => !it.done) : -1
|
||||
const idx = pageIdx != null ? pageIdx : scanned
|
||||
const at = idx >= 0 ? idx : generatedSlides.value.length
|
||||
while (generatedSlides.value.length < at) generatedSlides.value.push(undefined as unknown as Slide)
|
||||
generatedSlides.value[at] = slide
|
||||
@@ -99,7 +105,7 @@ const allDone = computed(() => total.value > 0 && doneCount.value === total.valu
|
||||
|
||||
/* ---------- 生成大纲 ---------- */
|
||||
async function onGenOutline() {
|
||||
if (busy.value) return
|
||||
if (locked.value) return
|
||||
const topic = topicInput.value.trim()
|
||||
if (!topic) { emit('toast', '请先输入主题'); return }
|
||||
// agent 通道:转自然语言指令走中继,大纲由 agent 返回(AiPanel 填充 initialOutline)
|
||||
@@ -159,11 +165,11 @@ function pageInstruction(item: OutlineItem, idx: number, total: number): string
|
||||
|
||||
/* ---------- 生成单页 ---------- */
|
||||
async function onGenOne(idx: number) {
|
||||
if (busy.value || !outline.value) return
|
||||
if (locked.value || !outline.value) return
|
||||
const item = outline.value.items[idx]
|
||||
if (!item) return
|
||||
// agent 通道:指令交由 AiPanel 转发,结果经 acceptAgentSlide 回收
|
||||
if (props.agentChannel) { emit('request-agent', pageInstruction(item, idx, outline.value.items.length)); return }
|
||||
// agent 通道:指令交由 AiPanel 转发(携带目标条目下标用于结果对齐),结果经 acceptAgentSlide 回收
|
||||
if (props.agentChannel) { emit('request-agent', pageInstruction(item, idx, outline.value.items.length), idx); return }
|
||||
setBusy(true)
|
||||
abortCtrl = new AbortController()
|
||||
try {
|
||||
@@ -190,13 +196,13 @@ async function onGenOne(idx: number) {
|
||||
|
||||
/* ---------- 全部生成(串行) ---------- */
|
||||
async function onGenAll() {
|
||||
if (busy.value || !outline.value) return
|
||||
if (locked.value || !outline.value) return
|
||||
const items = outline.value.items
|
||||
// agent 通道:发首条指令即返回,AiPanel 在每页回收后驱动下一条(见 onOutlineRequestAgent),保证串行与页面顺序对齐
|
||||
if (props.agentChannel) {
|
||||
const next = items.findIndex(it => !it.done)
|
||||
if (next < 0) { emit('toast', '全部条目已生成'); return }
|
||||
emit('request-agent', pageInstruction(items[next], next, items.length))
|
||||
emit('request-agent', pageInstruction(items[next], next, items.length), next)
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
@@ -235,15 +241,23 @@ function onStop() {
|
||||
if (abortCtrl) abortCtrl.abort()
|
||||
}
|
||||
|
||||
defineExpose({ acceptAgentSlide, requestAgent: (s: string) => emit('request-agent', s) })
|
||||
defineExpose({
|
||||
acceptAgentSlide,
|
||||
requestAgent: (s: string, idx?: number) => emit('request-agent', s, idx),
|
||||
/** 供「生成整套」入口聚焦主题输入框;prefill 为可选预填主题(来自聊天输入框) */
|
||||
focusTopic: (prefill?: string) => {
|
||||
if (prefill) topicInput.value = prefill
|
||||
nextTick(() => topicEl.value?.focus())
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="outline-panel">
|
||||
<!-- 主题输入 + 生成大纲 -->
|
||||
<div class="outline-top">
|
||||
<input type="text" v-model="topicInput" placeholder="输入主题,例如「人工智能的产业落地」" :disabled="busy" @keydown.enter="onGenOutline" />
|
||||
<button class="btn primary" :disabled="busy" @click="onGenOutline"><Icon name="sparkles" :size="14" /> 生成大纲</button>
|
||||
<input ref="topicEl" type="text" v-model="topicInput" placeholder="输入主题,例如「人工智能的产业落地」" :disabled="locked" @keydown.enter="onGenOutline" />
|
||||
<button class="btn primary" :disabled="locked" @click="onGenOutline"><Icon name="sparkles" :size="14" /> 生成大纲</button>
|
||||
<button v-if="busy" class="btn danger" @click="onStop">停止</button>
|
||||
</div>
|
||||
|
||||
@@ -255,7 +269,7 @@ defineExpose({ acceptAgentSlide, requestAgent: (s: string) => emit('request-agen
|
||||
v-for="opt in pageCountOpts" :key="String(opt.value)"
|
||||
type="button" role="radio" :aria-checked="pageCount === opt.value"
|
||||
class="count-pill" :class="{ active: pageCount === opt.value }"
|
||||
:title="opt.tip" :disabled="busy"
|
||||
:title="opt.tip" :disabled="locked"
|
||||
@click="pageCount = opt.value"
|
||||
>{{ opt.label }}</button>
|
||||
</div>
|
||||
@@ -282,25 +296,25 @@ defineExpose({ acceptAgentSlide, requestAgent: (s: string) => emit('request-agen
|
||||
<div class="item-head" @click="toggleExpand(it.id)">
|
||||
<span class="fold-arrow" :class="{ open: expanded.has(it.id) }">▸</span>
|
||||
<span class="kind-badge" :style="{ background: KIND_META[it.kind].color }">{{ KIND_META[it.kind].label }}</span>
|
||||
<input class="item-title" type="text" v-model="it.title" :disabled="busy" @click.stop />
|
||||
<input class="item-title" type="text" v-model="it.title" :disabled="locked" @click.stop />
|
||||
<span class="status" :class="{ done: it.done }">{{ it.done ? '已生成' : '待生成' }}</span>
|
||||
<button class="btn small" :disabled="busy" @click.stop="onGenOne(i)" :title="'生成第 ' + (i + 1) + ' 页'">生成此页</button>
|
||||
<button class="btn small danger" :disabled="busy" @click.stop="removeItem(i)" title="删除"><Icon name="trash" :size="13" /></button>
|
||||
<button class="btn small" :disabled="locked" @click.stop="onGenOne(i)" :title="'生成第 ' + (i + 1) + ' 页'">生成此页</button>
|
||||
<button class="btn small danger" :disabled="locked" @click.stop="removeItem(i)" title="删除"><Icon name="trash" :size="13" /></button>
|
||||
</div>
|
||||
<div class="item-summary" v-if="!expanded.has(it.id) && it.points.length">{{ it.points[0] }}</div>
|
||||
|
||||
<!-- 要点列表 -->
|
||||
<div class="item-points" v-show="expanded.has(it.id)">
|
||||
<div v-for="(p, pi) in it.points" :key="pi" class="point-row">
|
||||
<input type="text" v-model="it.points[pi]" :disabled="busy" placeholder="要点内容" />
|
||||
<button class="btn small ghost" :disabled="busy" @click="removePoint(it, pi)" title="删除要点"><Icon name="x" :size="13" /></button>
|
||||
<input type="text" v-model="it.points[pi]" :disabled="locked" placeholder="要点内容" />
|
||||
<button class="btn small ghost" :disabled="locked" @click="removePoint(it, pi)" title="删除要点"><Icon name="x" :size="13" /></button>
|
||||
</div>
|
||||
<button class="btn small ghost add-point" :disabled="busy" @click="addPoint(it)">+ 添加要点</button>
|
||||
<button class="btn small ghost add-point" :disabled="locked" @click="addPoint(it)">+ 添加要点</button>
|
||||
</div>
|
||||
|
||||
<!-- hint -->
|
||||
<div class="item-hint" v-show="expanded.has(it.id)">
|
||||
<input type="text" v-model="it.hint" :disabled="busy" placeholder="补充指令(可选,如「用对比卡片」「数据页」" />
|
||||
<input type="text" v-model="it.hint" :disabled="locked" placeholder="补充指令(可选,如「用对比卡片」「数据页」" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -309,7 +323,7 @@ defineExpose({ acceptAgentSlide, requestAgent: (s: string) => emit('request-agen
|
||||
<div class="outline-footer">
|
||||
<span class="progress-text" v-if="progress.total">{{ progress.cur }}/{{ progress.total }}</span>
|
||||
<span class="done-count" v-else>{{ doneCount }}/{{ total }} 页已生成</span>
|
||||
<button class="btn primary" :disabled="busy || allDone" @click="onGenAll">全部生成</button>
|
||||
<button class="btn primary" :disabled="locked || allDone" @click="onGenAll">全部生成</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+81
-2
@@ -72,6 +72,10 @@ const SYS_BASE =
|
||||
'- 字号:title 44-66、text 22-28、list 24-30、stat 数字 64-80、quote 40-52。\n' +
|
||||
'- 一页一个观点,留白充足,列表不超过 5 条。\n' +
|
||||
'- 现代版式:多用 card 分组;封面/金句/结尾用 g-primary;目录用 3-4 张卡片网格;数据页 stat+chart。\n' +
|
||||
'- 形状纪律:每页装饰形状 ≤3 个;circle/star/triangle/diamond/pentagon/hexagon 框取正方形(w=h),arrow/chevron/bubble 可扁宽;装饰形状完整放在画布内,不得压在 title/text/list 文字上,胶囊条放在标题块正下方。\n' +
|
||||
'- 装饰克制:禁止用多个形状拼组合图案(房子/人物/山丘/图标等);不要用形状当分隔线、进度条、底座;没有明确版式作用就不放形状,宁缺毋滥。\n' +
|
||||
'- 纵向骨架:内容页标题 y=8 h=10,正文/列表/表格/卡片组从 y≈22-26 开始,按内容量给 h——列表 h≈6+条数×8,表格 h≈12+行数×9,卡片组下缘到 y≈85 收底。\n' +
|
||||
'- 内容少时缩小 h 并整体上移,空白留在页面底部;标题与正文间不留大空档。\n' +
|
||||
'- emoji 极克制:默认不给 card.icon,除非确有助于理解,多数卡片留空。\n\n' +
|
||||
'内容准则(重要——避免「AI 味」,写得像该领域的真人):\n' +
|
||||
'- 标题写具体事实而非口号:「华东 Q3 增长 23%」而非「业绩腾飞」;「同屏字+口述记忆降 50%」而非「效率革命」。\n' +
|
||||
@@ -451,15 +455,90 @@ function normElement(e: any): SlideElement | null {
|
||||
|
||||
const VALID_BGS = ['bg', 'panel', 'primary', 'accent', 'g-primary', 'g-deep', 'g-soft']
|
||||
|
||||
/** 形状校正:多边形/圆形等比化(防 clip-path 拉伸变形)、形状收进画布、空装饰不压正文 */
|
||||
function sanitizeShapes(slide: Slide): Slide {
|
||||
// 需要正方形框的形状(w=h 取小者,中心不变):circle 与这些 clip-path 多边形
|
||||
// arrow/chevron/bubble 天然扁宽、ellipse 本就扁圆,不做等比
|
||||
const SQUARE_TYPES = new Set(['circle', 'star', 'triangle', 'diamond', 'pentagon', 'hexagon'])
|
||||
const els = slide.elements
|
||||
for (const el of els) {
|
||||
if (el.type !== 'shape') continue
|
||||
const shapeType = (el.style.shapeType as string) || 'rect'
|
||||
if (SQUARE_TYPES.has(shapeType)) {
|
||||
const m = Math.min(el.w, el.h)
|
||||
if (el.w > m) el.x += (el.w - m) / 2
|
||||
if (el.h > m) el.y += (el.h - m) / 2
|
||||
el.w = m
|
||||
el.h = m
|
||||
}
|
||||
// 完整收进画布,防边缘裁切怪片
|
||||
if (el.x + el.w > 100) el.x = Math.max(0, 100 - el.w)
|
||||
if (el.y + el.h > 100) el.y = Math.max(0, 100 - el.h)
|
||||
if (el.x < 0) el.x = 0
|
||||
if (el.y < 0) el.y = 0
|
||||
}
|
||||
// 空内容装饰形状压在正文文字行上:压 1 个 → 移到该元素正下方(保留「标题下胶囊条」设计);压 ≥2 个 → 丢弃
|
||||
// 判定「真压字」:与元素相交高度 > 形状高 50% 且相交宽度 > 形状宽 30%(避免误伤贴标题下缘的合法胶囊)
|
||||
const PROTECTED = new Set(['title', 'text', 'list', 'quote', 'stat'])
|
||||
const hits = (sh: SlideElement, t: SlideElement) => {
|
||||
const iw = Math.min(sh.x + sh.w, t.x + t.w) - Math.max(sh.x, t.x)
|
||||
const ih = Math.min(sh.y + sh.h, t.y + t.h) - Math.max(sh.y, t.y)
|
||||
return iw > sh.w * 0.3 && ih > sh.h * 0.5
|
||||
}
|
||||
const keep: SlideElement[] = []
|
||||
for (const el of els) {
|
||||
if (el.type !== 'shape' || el.content.trim()) { keep.push(el); continue }
|
||||
const targets = els.filter(o => o !== el && PROTECTED.has(o.type) && hits(el, o))
|
||||
if (targets.length === 0) { keep.push(el); continue }
|
||||
if (targets.length >= 2) continue // 丢弃
|
||||
el.y = Math.min(96, targets[0].y + targets[0].h + 1) // 移到正下方
|
||||
keep.push(el)
|
||||
}
|
||||
// AI 拿形状当分隔线/进度条(横贯页面的灰色细条),渲染效果差,直接丢弃
|
||||
const isDivider = (sh: SlideElement) =>
|
||||
(((sh.style.shapeType as string) || 'rect') === 'rect' && sh.w >= 60 && sh.h <= 6)
|
||||
// 多个空装饰形状叠放拼图案(三角+矩形+圆拼「房子」这类),保留先出现的、丢弃叠在其后的
|
||||
const decoArea = (sh: SlideElement) => sh.w * sh.h
|
||||
const overlaps = (a: SlideElement, b: SlideElement) => {
|
||||
const iw = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x)
|
||||
const ih = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y)
|
||||
if (iw <= 0 || ih <= 0) return false
|
||||
return iw * ih > decoArea(b) * 0.2 // b 是较小者面积按较小者算,这里调用时保证 b 更小
|
||||
}
|
||||
const afterOverlap = keep.filter(el => {
|
||||
if (el.type !== 'shape' || el.content.trim()) return true
|
||||
if (isDivider(el)) return false
|
||||
// 与任意已在保留集里的空装饰形状叠放 → 丢弃当前(较后)这个
|
||||
for (const prev of keep) {
|
||||
if (prev === el || prev.type !== 'shape' || prev.content.trim()) continue
|
||||
const small = decoArea(prev) <= decoArea(el) ? prev : el
|
||||
const big = small === prev ? el : prev
|
||||
if (overlaps(big, small)) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
// 装饰形状数量兜底:prompt 要求 ≤3,机械上限放宽到 5,超出部分丢弃
|
||||
let decoCount = 0
|
||||
const final = afterOverlap.filter(el => {
|
||||
if (el.type === 'shape' && !el.content.trim()) {
|
||||
decoCount++
|
||||
return decoCount <= 5
|
||||
}
|
||||
return true
|
||||
})
|
||||
return { ...slide, elements: final }
|
||||
}
|
||||
|
||||
function normSlide(s: any): Slide | null {
|
||||
if (!s || typeof s !== 'object') return null
|
||||
const bg = VALID_BGS.indexOf(s.background) >= 0 ? s.background
|
||||
: (typeof s.background === 'string' && s.background.charAt(0) === '#' ? s.background : 'bg')
|
||||
const els = (Array.isArray(s.elements) ? s.elements : []).map(normElement).filter(Boolean) as SlideElement[]
|
||||
return { id: uid('s'), background: bg as Slide['background'], elements: els }
|
||||
return sanitizeShapes({ id: uid('s'), background: bg as Slide['background'], elements: els })
|
||||
}
|
||||
|
||||
function normSlides(arr: any[]): Slide[] {
|
||||
/** AI 返回 → 有效幻灯片数组(元素归一化 + 形状校正) */
|
||||
export function normSlides(arr: any[]): Slide[] {
|
||||
return (Array.isArray(arr) ? arr : []).map(normSlide).filter((s): s is Slide => s !== null)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user