修复: list双重标记(自带编号隐藏圆点)+深底accent小字禁用+日期时效基准+整套规划注入单页生成(字号/装饰一致性,pageInstruction双通道共享)

This commit is contained in:
lxy
2026-08-30 04:36:28 +08:00
parent c15d730d1c
commit f7d994842a
7 changed files with 105 additions and 27 deletions
+6 -11
View File
@@ -7,7 +7,7 @@
import { ref, nextTick, computed, watch, onMounted, onUnmounted } from 'vue' import { ref, nextTick, computed, watch, onMounted, onUnmounted } from 'vue'
import type { ChatMessage, AiOp, Outline, Slide } from '../../core/types' import type { ChatMessage, AiOp, Outline, Slide } from '../../core/types'
import { store } from '../../core/store' import { store } from '../../core/store'
import { polish, chat, beautifyPage, isConfigured, buildAgentPrompt, parseChatReply } from '../../core/ai' import { polish, chat, beautifyPage, isConfigured, buildAgentPrompt, parseChatReply, pageInstruction } from '../../core/ai'
import { relay, type RelayStatus } from '../../core/relay' import { relay, type RelayStatus } from '../../core/relay'
import { elementTypes } from '../../core/sample' import { elementTypes } from '../../core/sample'
import { renderMd } from '../../core/markdown' import { renderMd } from '../../core/markdown'
@@ -594,18 +594,13 @@ function driveNextOutlinePage() {
if (!panel || !agentOutline.value) return if (!panel || !agentOutline.value) return
const next = agentOutline.value.items.findIndex(it => !it.done) const next = agentOutline.value.items.findIndex(it => !it.done)
if (next < 0) { toast('大纲全部页已生成'); return } if (next < 0) { toast('大纲全部页已生成'); return }
panel.requestAgent(pageInstructionOf(next), next) panel.requestAgent(pageInstruction(agentOutline.value!.items[next], next, agentOutline.value!.items.length, curAgentPlan()), next)
agentPendingPageIdx = next agentPendingPageIdx = next
} }
/** 大纲第 idx 条的生成指令(与 OutlinePanel.pageInstruction 同格式 */ /** agent 通道当前大纲 → 整套规划摘要(注入单页指令,约束套内风格一致 */
function pageInstructionOf(idx: number): string { function curAgentPlan() {
const items = agentOutline.value!.items const o = agentOutline.value
const it = items[idx] return o ? { title: o.title, items: o.items.map(it => ({ kind: it.kind, title: it.title })) } : undefined
return '按大纲生成第 ' + (idx + 1) + '/' + items.length + ' 页幻灯片:\n' +
'类型:' + it.kind + '\n' +
'标题:' + it.title + '\n' +
'要点:' + it.points.map((p, i) => (i + 1) + '. ' + p).join('\n') +
(it.hint ? '\n补充指令:' + it.hint : '')
} }
/* ---------- 一键美化 ---------- */ /* ---------- 一键美化 ---------- */
+8 -10
View File
@@ -6,7 +6,7 @@
import { ref, computed, watch, onMounted, nextTick } from 'vue' import { ref, computed, watch, onMounted, nextTick } from 'vue'
import type { Outline, OutlineItem, Slide } from '../../core/types' import type { Outline, OutlineItem, Slide } from '../../core/types'
import { store } from '../../core/store' import { store } from '../../core/store'
import { outline as genOutline, generatePage, isConfigured } from '../../core/ai' import { outline as genOutline, generatePage, pageInstruction, isConfigured } from '../../core/ai'
import Icon from '../common/Icon.vue' import Icon from '../common/Icon.vue'
const props = defineProps<{ visible: boolean; initialOutline?: Outline | null; agentChannel?: boolean; agentBusy?: boolean }>() const props = defineProps<{ visible: boolean; initialOutline?: Outline | null; agentChannel?: boolean; agentBusy?: boolean }>()
@@ -154,13 +154,9 @@ function removeItem(idx: number) {
outline.value.items.splice(idx, 1) outline.value.items.splice(idx, 1)
} }
/** 大纲条目 → 自然语言生成指令(agent 通道用,格式对齐 ai.ts generatePage 的 user 内容 */ /** 当前大纲 → 整套规划摘要(注入单页生成,约束套内风格一致 */
function pageInstruction(item: OutlineItem, idx: number, total: number): string { function curPlan() {
return '按大纲生成第 ' + (idx + 1) + '/' + total + ' 页幻灯片:\n' + return outline.value ? { title: outline.value.title, items: outline.value.items.map(it => ({ kind: it.kind, title: it.title })) } : undefined
'类型:' + item.kind + '\n' +
'标题:' + item.title + '\n' +
'要点:' + item.points.map((p, i) => (i + 1) + '. ' + p).join('\n') +
(item.hint ? '\n补充指令:' + item.hint : '')
} }
/* ---------- 生成单页 ---------- */ /* ---------- 生成单页 ---------- */
@@ -169,7 +165,7 @@ async function onGenOne(idx: number) {
const item = outline.value.items[idx] const item = outline.value.items[idx]
if (!item) return if (!item) return
// agent 通道:指令交由 AiPanel 转发(携带目标条目下标用于结果对齐),结果经 acceptAgentSlide 回收 // agent 通道:指令交由 AiPanel 转发(携带目标条目下标用于结果对齐),结果经 acceptAgentSlide 回收
if (props.agentChannel) { emit('request-agent', pageInstruction(item, idx, outline.value.items.length), idx); return } if (props.agentChannel) { emit('request-agent', pageInstruction(item, idx, outline.value.items.length, curPlan()), idx); return }
setBusy(true) setBusy(true)
abortCtrl = new AbortController() abortCtrl = new AbortController()
try { try {
@@ -177,6 +173,7 @@ async function onGenOne(idx: number) {
item, item,
index: idx, index: idx,
total: outline.value.items.length, total: outline.value.items.length,
plan: curPlan(),
signal: abortCtrl.signal signal: abortCtrl.signal
}) })
// 保持 generatedSlides 与 items 顺序对齐 // 保持 generatedSlides 与 items 顺序对齐
@@ -202,7 +199,7 @@ async function onGenAll() {
if (props.agentChannel) { if (props.agentChannel) {
const next = items.findIndex(it => !it.done) const next = items.findIndex(it => !it.done)
if (next < 0) { emit('toast', '全部条目已生成'); return } if (next < 0) { emit('toast', '全部条目已生成'); return }
emit('request-agent', pageInstruction(items[next], next, items.length), next) emit('request-agent', pageInstruction(items[next], next, items.length, curPlan()), next)
return return
} }
setBusy(true) setBusy(true)
@@ -217,6 +214,7 @@ async function onGenAll() {
item: items[i], item: items[i],
index: i, index: i,
total: items.length, total: items.length,
plan: curPlan(),
signal: abortCtrl.signal signal: abortCtrl.signal
}) })
const isFirst = committedCount === 0 const isFirst = committedCount === 0
+4 -4
View File
@@ -7,7 +7,7 @@ import { computed } from 'vue'
import type { SlideElement, BgKey } from '../../core/types' import type { SlideElement, BgKey } from '../../core/types'
import { store, resolveColor, isDarkBg } from '../../core/store' import { store, resolveColor, isDarkBg } from '../../core/store'
import { resolveRef } from '../../core/assets' import { resolveRef } from '../../core/assets'
import { segmentsToHtml, markdownToSegments, hasFormatting } from '../../core/richtext' import { segmentsToHtml, markdownToSegments, hasFormatting, hasLineMarker } from '../../core/richtext'
import ChartView from './ChartView.vue' import ChartView from './ChartView.vue'
import Icon from '../common/Icon.vue' import Icon from '../common/Icon.vue'
@@ -240,15 +240,15 @@ function onBlur(e: Event, field: string) {
<template v-else-if="el.type === 'list'"> <template v-else-if="el.type === 'list'">
<!-- 编辑态 --> <!-- 编辑态 -->
<div v-if="edit" class="el-list" contenteditable="true" data-edit="content" @blur="onBlur($event, 'content')"> <div v-if="edit" class="el-list" contenteditable="true" data-edit="content" @blur="onBlur($event, 'content')">
<div v-for="(line, i) in dataList" :key="i" class="li">{{ line }}</div> <div v-for="(line, i) in dataList" :key="i" class="li" :class="{ 'no-marker': hasLineMarker(line) }">{{ line }}</div>
</div> </div>
<!-- 非编辑态 + segments --> <!-- 非编辑态 + segments -->
<div v-else-if="renderedListItems" class="el-list"> <div v-else-if="renderedListItems" class="el-list">
<div v-for="(html, i) in renderedListItems" :key="i" class="li" v-html="html"></div> <div v-for="(html, i) in renderedListItems" :key="i" class="li" :class="{ 'no-marker': hasLineMarker(dataList[i] || '') }" v-html="html"></div>
</div> </div>
<!-- 非编辑态 + 纯文本 --> <!-- 非编辑态 + 纯文本 -->
<div v-else class="el-list"> <div v-else class="el-list">
<div v-for="(line, i) in dataList" :key="i" class="li">{{ line }}</div> <div v-for="(line, i) in dataList" :key="i" class="li" :class="{ 'no-marker': hasLineMarker(line) }">{{ line }}</div>
</div> </div>
</template> </template>
+29 -2
View File
@@ -34,8 +34,13 @@ const ERROR_HINTS_BY_STATUS: Record<number, string> = {
/* ============================================================ /* ============================================================
* Prompt * Prompt
* ============================================================ */ * ============================================================ */
/** 时效基准:本地日期 y-m-dtoISOString 走 UTC,跨时区会偏一天,故本地拼装) */
const now = new Date()
const TODAY = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0') + '-' + String(now.getDate()).padStart(2, '0')
const SYS_BASE = const SYS_BASE =
'你是「u-ppt」的内容创作助手,擅长把主题变成结构清晰、视觉现代、带入场动效的中文演示稿。\n' + '你是「u-ppt」的内容创作助手,擅长把主题变成结构清晰、视觉现代、带入场动效的中文演示稿。\n' +
'时效基准:当前日期是 ' + TODAY + '。内容中的年份、日期、季度必须与此一致,禁止臆造过时或未来的年份。\n' +
'输出必须严格遵循下面的数据模型,坐标用百分比(0-100),字号为数字。\n\n' + '输出必须严格遵循下面的数据模型,坐标用百分比(0-100),字号为数字。\n\n' +
'幻灯片模型:\n' + '幻灯片模型:\n' +
'{ "slides": [ { "background": "bg|panel|primary|accent|g-primary|g-deep|g-soft", "elements": [ 元素, ... ] } ] }\n' + '{ "slides": [ { "background": "bg|panel|primary|accent|g-primary|g-deep|g-soft", "elements": [ 元素, ... ] } ] }\n' +
@@ -76,6 +81,8 @@ const SYS_BASE =
'- 装饰克制:禁止用多个形状拼组合图案(房子/人物/山丘/图标等);不要用形状当分隔线、进度条、底座;没有明确版式作用就不放形状,宁缺毋滥。\n' + '- 装饰克制:禁止用多个形状拼组合图案(房子/人物/山丘/图标等);不要用形状当分隔线、进度条、底座;没有明确版式作用就不放形状,宁缺毋滥。\n' +
'- 纵向骨架:内容页标题 y=8 h=10,正文/列表/表格/卡片组从 y≈22-26 开始,按内容量给 h——列表 h≈6+条数×8,表格 h≈12+行数×9,卡片组下缘到 y≈85 收底。\n' + '- 纵向骨架:内容页标题 y=8 h=10,正文/列表/表格/卡片组从 y≈22-26 开始,按内容量给 h——列表 h≈6+条数×8,表格 h≈12+行数×9,卡片组下缘到 y≈85 收底。\n' +
'- 内容少时缩小 h 并整体上移,空白留在页面底部;标题与正文间不留大空档。\n' + '- 内容少时缩小 h 并整体上移,空白留在页面底部;标题与正文间不留大空档。\n' +
'- list 渲染层每行自带圆点,content 行首不要再写「•」「-」「①」等编号或符号前缀。\n' +
'- 深底页(g-primary/g-deep/primary/accent 背景)上,正文/脚注/小字不要用 accent(与渐变背景混同),用默认 muted;accent 仅用于大号元素(大数字/大标题)。\n' +
'- emoji 极克制:默认不给 card.icon,除非确有助于理解,多数卡片留空。\n\n' + '- emoji 极克制:默认不给 card.icon,除非确有助于理解,多数卡片留空。\n\n' +
'内容准则(重要——避免「AI 味」,写得像该领域的真人):\n' + '内容准则(重要——避免「AI 味」,写得像该领域的真人):\n' +
'- 标题写具体事实而非口号:「华东 Q3 增长 23%」而非「业绩腾飞」;「同屏字+口述记忆降 50%」而非「效率革命」。\n' + '- 标题写具体事实而非口号:「华东 Q3 增长 23%」而非「业绩腾飞」;「同屏字+口述记忆降 50%」而非「效率革命」。\n' +
@@ -746,9 +753,28 @@ const SYS_OUTLINE =
const SYS_GEN_PAGE = const SYS_GEN_PAGE =
SYS_BASE + SYS_BASE +
'\n任务:根据大纲中的一条,生成「一页」幻灯片。严格遵循要点与 hint,不要偏离主题。\n' + '\n任务:根据大纲中的一条,生成「一页」幻灯片。严格遵循要点与 hint,不要偏离主题。\n' +
'若提供整套规划,标题字号与装饰密度须与同套其他页一致。\n' +
'严格输出:{ "background":"...", "elements":[ ... ] }(单个 slide 对象,不要数组)。\n' + '严格输出:{ "background":"...", "elements":[ ... ] }(单个 slide 对象,不要数组)。\n' +
'kind=cover 用 g-primary 背景;kind=quote 用 g-deepkind=end 用 g-primarykind=content 数据页用 stat+chart。' 'kind=cover 用 g-primary 背景;kind=quote 用 g-deepkind=end 用 g-primarykind=content 数据页用 stat+chart。'
/** 整套规划摘要:注入每次单页生成的提示词,约束套内风格一致 */
export function planDigest(plan: { title: string; items: { kind: string; title: string }[] }): string {
const list = plan.items.map((it, i) => (i + 1) + '.[' + it.kind + '] ' + it.title).join(' / ')
return '整套规划「' + plan.title + '」共 ' + plan.items.length + ' 页:' + list +
'(一致性要求:内容页标题 fontSize 统一 44,正文 20-24;背景惯例 cover/end=g-primary、quote=g-deep、content=bg/panel'
}
/** agent generatePage user
* planDigest */
export function pageInstruction(item: OutlineItem, idx: number, total: number, plan?: { title: string; items: { kind: string; title: string }[] }): string {
return '按大纲生成第 ' + (idx + 1) + '/' + total + ' 页幻灯片:\n' +
'类型:' + item.kind + '\n' +
'标题:' + item.title + '\n' +
'要点:' + item.points.map((p, i) => (i + 1) + '. ' + p).join('\n') +
(item.hint ? '\n补充指令:' + item.hint : '') +
(plan ? '\n' + planDigest(plan) : '')
}
const SYS_THEME = const SYS_THEME =
'你是配色设计师。根据主题关键词,推荐一套现代、专业的 6 色配色(primary 主色、accent 强调色、bg 背景、panel 面板、text 正文、muted 次要文字)。\n\n' + '你是配色设计师。根据主题关键词,推荐一套现代、专业的 6 色配色(primary 主色、accent 强调色、bg 背景、panel 面板、text 正文、muted 次要文字)。\n\n' +
'要求:\n' + '要求:\n' +
@@ -789,13 +815,14 @@ export async function outline(opts: { topic: string; count?: number | 'auto'; si
/* ---------- 2. 按大纲条目生成单页 ---------- */ /* ---------- 2. 按大纲条目生成单页 ---------- */
export async function generatePage(opts: { item: OutlineItem; index: number; total: number; signal?: AbortSignal }): Promise<Slide> { export async function generatePage(opts: { item: OutlineItem; index: number; total: number; plan?: { title: string; items: { kind: string; title: string }[] }; signal?: AbortSignal }): Promise<Slide> {
const user = const user =
'大纲第 ' + (opts.index + 1) + '/' + opts.total + ' 页:\n' + '大纲第 ' + (opts.index + 1) + '/' + opts.total + ' 页:\n' +
'类型:' + opts.item.kind + '\n' + '类型:' + opts.item.kind + '\n' +
'标题:' + opts.item.title + '\n' + '标题:' + opts.item.title + '\n' +
'要点:' + opts.item.points.map((p, i) => (i + 1) + '. ' + p).join('\n') + '要点:' + opts.item.points.map((p, i) => (i + 1) + '. ' + p).join('\n') +
(opts.item.hint ? '\n补充指令:' + opts.item.hint : '') (opts.item.hint ? '\n补充指令:' + opts.item.hint : '') +
(opts.plan ? '\n' + planDigest(opts.plan) : '')
const messages: Message[] = [ const messages: Message[] = [
{ role: 'system', content: SYS_GEN_PAGE }, { role: 'system', content: SYS_GEN_PAGE },
{ role: 'user', content: user } { role: 'user', content: user }
+8
View File
@@ -170,6 +170,14 @@ export function hasFormatting(lines: RichLine[]): boolean {
return false return false
} }
/* ---------- 工具:行首是否自带列表标记(圆点/编号,用于避免渲染层双重标记) ---------- */
/** 检测行首(允许空白)是否自带列表标记:圈号①-⑳ / 阿拉伯数字+点顿 / 圆点符号 / 中文数字+点顿 */
export function hasLineMarker(line: string): boolean {
if (!line) return false
return /^[\s]*(?:[①-⑳]|[(]?\d{1,2}[).、.]|[-•·▪●○*]|[一二三四五六七八九十]+[、..])/.test(line)
}
/* ---------- 工具:安全化(AI 输出或外部数据 → 合法 segments ---------- */ /* ---------- 工具:安全化(AI 输出或外部数据 → 合法 segments ---------- */
export function normSegments(input: any): RichLine[] | undefined { export function normSegments(input: any): RichLine[] | undefined {
+3
View File
@@ -174,6 +174,9 @@
content: ""; position: absolute; left: 0; top: .55em; content: ""; position: absolute; left: 0; top: .55em;
width: .35em; height: .35em; border-radius: 50%; background: currentColor; width: .35em; height: .35em; border-radius: 50%; background: currentColor;
} }
/* 行首自带编号/符号(①、1.、- 等)时不再画圆点,避免双重标记 */
.el-list .li.no-marker::before { display: none; }
.el-list .li.no-marker { padding-left: 0; }
.el-stat { width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; } .el-stat { width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; }
.el-stat .num { font-weight: 800; line-height: 1; } .el-stat .num { font-weight: 800; line-height: 1; }
.el-stat .label { margin-top: .35em; text-align: center; } .el-stat .label { margin-top: .35em; text-align: center; }
+47
View File
@@ -0,0 +1,47 @@
/* list-marker.test.ts — hasLineMarker 行首列表标记检测验证 */
import { describe, it, expect } from 'vitest'
import { hasLineMarker } from '../src/core/richtext'
describe('hasLineMarker 行首列表标记检测', () => {
it('圈号 ①-⑳ 识别为标记', () => {
expect(hasLineMarker('① 第一点')).toBe(true)
expect(hasLineMarker('⑳ 最后一点')).toBe(true)
})
it('阿拉伯数字+点/顿/括号 识别为标记', () => {
expect(hasLineMarker('1. 标题')).toBe(true)
expect(hasLineMarker('12、要点')).toBe(true)
expect(hasLineMarker('3)要点')).toBe(true)
expect(hasLineMarker('(4). 要点')).toBe(true)
expect(hasLineMarker('5.全角点')).toBe(true)
})
it('圆点类符号识别为标记', () => {
expect(hasLineMarker('- 要点')).toBe(true)
expect(hasLineMarker('• 要点')).toBe(true)
expect(hasLineMarker('· 要点')).toBe(true)
expect(hasLineMarker('▪ 要点')).toBe(true)
expect(hasLineMarker('● 要点')).toBe(true)
expect(hasLineMarker('○ 要点')).toBe(true)
expect(hasLineMarker('* 强调')).toBe(true)
})
it('中文数字+点顿 识别为标记', () => {
expect(hasLineMarker('一、概述')).toBe(true)
expect(hasLineMarker('十二. 展开')).toBe(true)
expect(hasLineMarker('十.收尾')).toBe(true)
})
it('行首空白不影响检测', () => {
expect(hasLineMarker(' ① 缩进的圈号')).toBe(true)
expect(hasLineMarker('\t- 制表符开头的圆点')).toBe(true)
})
it('普通文本返回 false', () => {
expect(hasLineMarker('华东 Q3 增长 23%')).toBe(false)
expect(hasLineMarker('第一点(无标点编号)')).toBe(false)
expect(hasLineMarker('')).toBe(false)
expect(hasLineMarker('2026 年趋势')).toBe(false)
expect(hasLineMarker('1 比 2 更好')).toBe(false)
})
})