修复: AI生成形状怪异治理(拼图案/分隔条/压字机械校正+装饰克制约束)+大纲驱动生成加固(看门狗/页序对齐/空态去重)

This commit is contained in:
lxy
2026-08-30 04:07:54 +08:00
parent c7c94283c0
commit b08bc0e038
3 changed files with 192 additions and 68 deletions
+71 -40
View File
@@ -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() {
}
/** OutlinePanelagent 通道)转发指令:把生成大纲/单页的自然语言指令发给中继 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>
+40 -26
View File
@@ -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
View File
@@ -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)
}