diff --git a/src/components/ai/AiPanel.vue b/src/components/ai/AiPanel.vue index 1d7da5a..18d1489 100644 --- a/src/components/ai/AiPanel.vue +++ b/src/components/ai/AiPanel.vue @@ -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 } /* ---------- 一键美化 ---------- */ diff --git a/src/components/ai/OutlinePanel.vue b/src/components/ai/OutlinePanel.vue index f1c6cd0..f396bee 100644 --- a/src/components/ai/OutlinePanel.vue +++ b/src/components/ai/OutlinePanel.vue @@ -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 diff --git a/src/components/editor/ElementView.vue b/src/components/editor/ElementView.vue index a06b7f1..c546b7b 100644 --- a/src/components/editor/ElementView.vue +++ b/src/components/editor/ElementView.vue @@ -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) { diff --git a/src/core/ai.ts b/src/core/ai.ts index 6f7cc8d..49061c8 100644 --- a/src/core/ai.ts +++ b/src/core/ai.ts @@ -34,8 +34,13 @@ const ERROR_HINTS_BY_STATUS: Record = { /* ============================================================ * 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 { +export async function generatePage(opts: { item: OutlineItem; index: number; total: number; plan?: { title: string; items: { kind: string; title: string }[] }; signal?: AbortSignal }): Promise { 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 } diff --git a/src/core/richtext.ts b/src/core/richtext.ts index bec24e7..eea4806 100644 --- a/src/core/richtext.ts +++ b/src/core/richtext.ts @@ -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 { diff --git a/src/styles/editor.css b/src/styles/editor.css index 4dd2944..b48282a 100644 --- a/src/styles/editor.css +++ b/src/styles/editor.css @@ -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; } diff --git a/tests/list-marker.test.ts b/tests/list-marker.test.ts new file mode 100644 index 0000000..02fcb2a --- /dev/null +++ b/tests/list-marker.test.ts @@ -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) + }) +})