修复: list双重标记(自带编号隐藏圆点)+深底accent小字禁用+日期时效基准+整套规划注入单页生成(字号/装饰一致性,pageInstruction双通道共享)
This commit is contained in:
@@ -7,7 +7,7 @@
|
||||
import { ref, nextTick, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import type { ChatMessage, AiOp, Outline, Slide } from '../../core/types'
|
||||
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 { elementTypes } from '../../core/sample'
|
||||
import { renderMd } from '../../core/markdown'
|
||||
@@ -594,18 +594,13 @@ function driveNextOutlinePage() {
|
||||
if (!panel || !agentOutline.value) return
|
||||
const next = agentOutline.value.items.findIndex(it => !it.done)
|
||||
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
|
||||
}
|
||||
/** 大纲第 idx 条的生成指令(与 OutlinePanel.pageInstruction 同格式) */
|
||||
function pageInstructionOf(idx: number): string {
|
||||
const items = agentOutline.value!.items
|
||||
const it = items[idx]
|
||||
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 : '')
|
||||
/** agent 通道当前大纲 → 整套规划摘要(注入单页指令,约束套内风格一致) */
|
||||
function curAgentPlan() {
|
||||
const o = agentOutline.value
|
||||
return o ? { title: o.title, items: o.items.map(it => ({ kind: it.kind, title: it.title })) } : undefined
|
||||
}
|
||||
|
||||
/* ---------- 一键美化 ---------- */
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
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 { outline as genOutline, generatePage, pageInstruction, isConfigured } from '../../core/ai'
|
||||
import Icon from '../common/Icon.vue'
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/** 大纲条目 → 自然语言生成指令(agent 通道用,格式对齐 ai.ts generatePage 的 user 内容) */
|
||||
function pageInstruction(item: OutlineItem, idx: number, total: number): 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 : '')
|
||||
/** 当前大纲 → 整套规划摘要(注入单页生成,约束套内风格一致) */
|
||||
function curPlan() {
|
||||
return outline.value ? { title: outline.value.title, items: outline.value.items.map(it => ({ kind: it.kind, title: it.title })) } : undefined
|
||||
}
|
||||
|
||||
/* ---------- 生成单页 ---------- */
|
||||
@@ -169,7 +165,7 @@ async function onGenOne(idx: number) {
|
||||
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), idx); return }
|
||||
if (props.agentChannel) { emit('request-agent', pageInstruction(item, idx, outline.value.items.length, curPlan()), idx); return }
|
||||
setBusy(true)
|
||||
abortCtrl = new AbortController()
|
||||
try {
|
||||
@@ -177,6 +173,7 @@ async function onGenOne(idx: number) {
|
||||
item,
|
||||
index: idx,
|
||||
total: outline.value.items.length,
|
||||
plan: curPlan(),
|
||||
signal: abortCtrl.signal
|
||||
})
|
||||
// 保持 generatedSlides 与 items 顺序对齐
|
||||
@@ -202,7 +199,7 @@ async function onGenAll() {
|
||||
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), next)
|
||||
emit('request-agent', pageInstruction(items[next], next, items.length, curPlan()), next)
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
@@ -217,6 +214,7 @@ async function onGenAll() {
|
||||
item: items[i],
|
||||
index: i,
|
||||
total: items.length,
|
||||
plan: curPlan(),
|
||||
signal: abortCtrl.signal
|
||||
})
|
||||
const isFirst = committedCount === 0
|
||||
|
||||
@@ -7,7 +7,7 @@ import { computed } from 'vue'
|
||||
import type { SlideElement, BgKey } from '../../core/types'
|
||||
import { store, resolveColor, isDarkBg } from '../../core/store'
|
||||
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 Icon from '../common/Icon.vue'
|
||||
|
||||
@@ -240,15 +240,15 @@ function onBlur(e: Event, field: string) {
|
||||
<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-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>
|
||||
<!-- 非编辑态 + 有 segments -->
|
||||
<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 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>
|
||||
</template>
|
||||
|
||||
|
||||
+29
-2
@@ -34,8 +34,13 @@ const ERROR_HINTS_BY_STATUS: Record<number, string> = {
|
||||
/* ============================================================
|
||||
* Prompt 模板
|
||||
* ============================================================ */
|
||||
/** 时效基准:本地日期 y-m-d(toISOString 走 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 =
|
||||
'你是「u-ppt」的内容创作助手,擅长把主题变成结构清晰、视觉现代、带入场动效的中文演示稿。\n' +
|
||||
'时效基准:当前日期是 ' + TODAY + '。内容中的年份、日期、季度必须与此一致,禁止臆造过时或未来的年份。\n' +
|
||||
'输出必须严格遵循下面的数据模型,坐标用百分比(0-100),字号为数字。\n\n' +
|
||||
'幻灯片模型:\n' +
|
||||
'{ "slides": [ { "background": "bg|panel|primary|accent|g-primary|g-deep|g-soft", "elements": [ 元素, ... ] } ] }\n' +
|
||||
@@ -76,6 +81,8 @@ const SYS_BASE =
|
||||
'- 装饰克制:禁止用多个形状拼组合图案(房子/人物/山丘/图标等);不要用形状当分隔线、进度条、底座;没有明确版式作用就不放形状,宁缺毋滥。\n' +
|
||||
'- 纵向骨架:内容页标题 y=8 h=10,正文/列表/表格/卡片组从 y≈22-26 开始,按内容量给 h——列表 h≈6+条数×8,表格 h≈12+行数×9,卡片组下缘到 y≈85 收底。\n' +
|
||||
'- 内容少时缩小 h 并整体上移,空白留在页面底部;标题与正文间不留大空档。\n' +
|
||||
'- list 渲染层每行自带圆点,content 行首不要再写「•」「-」「①」等编号或符号前缀。\n' +
|
||||
'- 深底页(g-primary/g-deep/primary/accent 背景)上,正文/脚注/小字不要用 accent(与渐变背景混同),用默认 muted;accent 仅用于大号元素(大数字/大标题)。\n' +
|
||||
'- emoji 极克制:默认不给 card.icon,除非确有助于理解,多数卡片留空。\n\n' +
|
||||
'内容准则(重要——避免「AI 味」,写得像该领域的真人):\n' +
|
||||
'- 标题写具体事实而非口号:「华东 Q3 增长 23%」而非「业绩腾飞」;「同屏字+口述记忆降 50%」而非「效率革命」。\n' +
|
||||
@@ -746,9 +753,28 @@ const SYS_OUTLINE =
|
||||
const SYS_GEN_PAGE =
|
||||
SYS_BASE +
|
||||
'\n任务:根据大纲中的一条,生成「一页」幻灯片。严格遵循要点与 hint,不要偏离主题。\n' +
|
||||
'若提供整套规划,标题字号与装饰密度须与同套其他页一致。\n' +
|
||||
'严格输出:{ "background":"...", "elements":[ ... ] }(单个 slide 对象,不要数组)。\n' +
|
||||
'kind=cover 用 g-primary 背景;kind=quote 用 g-deep;kind=end 用 g-primary;kind=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 =
|
||||
'你是配色设计师。根据主题关键词,推荐一套现代、专业的 6 色配色(primary 主色、accent 强调色、bg 背景、panel 面板、text 正文、muted 次要文字)。\n\n' +
|
||||
'要求:\n' +
|
||||
@@ -789,13 +815,14 @@ export async function outline(opts: { topic: string; count?: number | 'auto'; si
|
||||
|
||||
/* ---------- 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 =
|
||||
'大纲第 ' + (opts.index + 1) + '/' + opts.total + ' 页:\n' +
|
||||
'类型:' + opts.item.kind + '\n' +
|
||||
'标题:' + opts.item.title + '\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[] = [
|
||||
{ role: 'system', content: SYS_GEN_PAGE },
|
||||
{ role: 'user', content: user }
|
||||
|
||||
@@ -170,6 +170,14 @@ export function hasFormatting(lines: RichLine[]): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
/* ---------- 工具:行首是否自带列表标记(圆点/编号,用于避免渲染层双重标记) ---------- */
|
||||
|
||||
/** 检测行首(允许空白)是否自带列表标记:圈号①-⑳ / 阿拉伯数字+点顿 / 圆点符号 / 中文数字+点顿 */
|
||||
export function hasLineMarker(line: string): boolean {
|
||||
if (!line) return false
|
||||
return /^[\s]*(?:[①-⑳]|[((]?\d{1,2}[)).、.]|[-•·▪●○*]|[一二三四五六七八九十]+[、..])/.test(line)
|
||||
}
|
||||
|
||||
/* ---------- 工具:安全化(AI 输出或外部数据 → 合法 segments) ---------- */
|
||||
|
||||
export function normSegments(input: any): RichLine[] | undefined {
|
||||
|
||||
@@ -174,6 +174,9 @@
|
||||
content: ""; position: absolute; left: 0; top: .55em;
|
||||
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 .num { font-weight: 800; line-height: 1; }
|
||||
.el-stat .label { margin-top: .35em; text-align: center; }
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user