From dd9c3db530971a5acafc31cd8508ba2ae63c60c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Sun, 30 Aug 2026 03:32:04 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E:=20Agent=20=E5=A4=A7?= =?UTF-8?q?=E7=BA=B2=E5=8D=8F=E8=AE=AE(outline/gen=5Fpage=20=E5=8A=A8?= =?UTF-8?q?=E4=BD=9C)=E4=B8=8E=E8=AE=BE=E7=BD=AE=E5=BC=B9=E7=AA=97?= =?UTF-8?q?=E7=BB=9F=E4=B8=80(UnifiedSettingsModal=20=E5=BD=92=E5=B9=B6=20?= =?UTF-8?q?Settings/OSS/Agent=20=E4=B8=89=E5=BC=B9=E7=AA=97)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.vue | 57 +-- src/components/ai/AgentPanel.vue | 223 --------- src/components/ai/AiPanel.vue | 399 +++++++++++++-- src/components/ai/OutlinePanel.vue | 15 +- src/components/modals/OssSettingsModal.vue | 197 -------- src/components/modals/SettingsModal.vue | 216 -------- .../modals/UnifiedSettingsModal.vue | 466 ++++++++++++++++++ src/core/ai.ts | 67 ++- src/core/relay.ts | 13 +- src/core/types.ts | 4 +- 10 files changed, 925 insertions(+), 732 deletions(-) delete mode 100644 src/components/ai/AgentPanel.vue delete mode 100644 src/components/modals/OssSettingsModal.vue delete mode 100644 src/components/modals/SettingsModal.vue create mode 100644 src/components/modals/UnifiedSettingsModal.vue diff --git a/src/App.vue b/src/App.vue index 47eafed..51f8092 100644 --- a/src/App.vue +++ b/src/App.vue @@ -11,6 +11,7 @@ import { publishDeck } from './core/share' import { appAlert } from './core/dialog' import { isTauri, readLocalFiles } from './core/bridge' import AppDialog from './components/common/AppDialog.vue' +import Icon from './components/common/Icon.vue' import FileDock from './components/common/FileDock.vue' import FilePreviewDrawer from './components/common/FilePreviewDrawer.vue' import { addFiles } from './core/attachments' @@ -19,9 +20,7 @@ import ThumbBar from './components/editor/ThumbBar.vue' import Canvas from './components/editor/Canvas.vue' import PropsPanel from './components/editor/PropsPanel.vue' import AiPanel from './components/ai/AiPanel.vue' -import AgentPanel from './components/ai/AgentPanel.vue' -import SettingsModal from './components/modals/SettingsModal.vue' -import OssSettingsModal from './components/modals/OssSettingsModal.vue' +import UnifiedSettingsModal, { type SettingsTab } from './components/modals/UnifiedSettingsModal.vue' import LibraryModal from './components/modals/LibraryModal.vue' import TemplateModal from './components/modals/TemplateModal.vue' import ImportModal from './components/modals/ImportModal.vue' @@ -35,14 +34,18 @@ const presentVisible = ref(false) const presentStartIndex = ref(0) /* ---------- 活动面板 tab ---------- */ -const activeTab = ref<'props' | 'ai' | 'agent'>('props') -function switchTab(name: 'props' | 'ai' | 'agent') { +const activeTab = ref<'props' | 'ai'>('props') +function switchTab(name: 'props' | 'ai') { activeTab.value = name } /* ---------- 弹窗 ---------- */ const settingsVisible = ref(false) -const ossVisible = ref(false) +const settingsTab = ref('ai') +function openSettings(tab: SettingsTab = 'ai') { + settingsTab.value = tab + settingsVisible.value = true +} const libraryVisible = ref(false) const templateVisible = ref(false) const importVisible = ref(false) @@ -181,7 +184,7 @@ async function onShare() { // 未配置云存储时引导到设置页 if (msg.includes('云存储') || msg.includes('OSS')) { await appAlert('无法分享', msg) - ossVisible.value = true + openSettings('oss') } else { await appAlert('分享失败', msg) } @@ -225,7 +228,7 @@ function onKey(e: KeyboardEvent) { if (inField) return; e.preventDefault(); if (store.redo()) toast('已重做'); return } if ((e.ctrlKey || e.metaKey) && e.key === 's') { - e.preventDefault(); if (!inField) toast('已自动保存到本地'); return + e.preventDefault(); onSave(); return } /* 元素剪贴:输入框内放行原生行为(复制文本等);多选时作用于整组 */ @@ -412,7 +415,7 @@ async function onGlobalDrop(e: DragEvent) { e.preventDefault() globalDragOver.value = false - // 单个 .json 文件 → 直接导入 deck(与工具栏 📥 导入同路径) + // 单个 .json 文件 → 直接导入 deck(与工具栏导入同路径) if (fl.length === 1 && /\.json$/i.test(fl[0].name)) { await importJsonFile(fl[0]) return @@ -473,8 +476,7 @@ onUnmounted(() => { @open-deck="deckLoaded = true; mode = 'editor'" @ai-create="onNewBlank(); switchTab('ai')" @toast="toast" - @open-settings="settingsVisible = true" - @open-oss="ossVisible = true" + @open-settings="openSettings('ai')" /> @@ -483,8 +485,7 @@ onUnmounted(() => { :disabled-actions="disabledActions" @present="onPresent" @open-library="libraryVisible = true" - @open-settings="settingsVisible = true" - @open-oss="ossVisible = true" + @open-settings="openSettings('ai')" @save="onSave" @open-templates="templateVisible = true" @export-json="onExportJson" @@ -505,9 +506,8 @@ onUnmounted(() => { @@ -537,21 +532,17 @@ onUnmounted(() => { /> - - { :visible="importVisible" @close="onImportClose" @toast="toast" - @open-settings="settingsVisible = true" + @open-settings="openSettings('ai')" /> {
- 📥 + 松开导入文件 .json 直接导入 · 图片/文档进入资料导入
diff --git a/src/components/ai/AgentPanel.vue b/src/components/ai/AgentPanel.vue deleted file mode 100644 index 3418bbb..0000000 --- a/src/components/ai/AgentPanel.vue +++ /dev/null @@ -1,223 +0,0 @@ - - - - - - diff --git a/src/components/ai/AiPanel.vue b/src/components/ai/AiPanel.vue index 69b5a3c..694752d 100644 --- a/src/components/ai/AiPanel.vue +++ b/src/components/ai/AiPanel.vue @@ -1,30 +1,35 @@ @@ -423,16 +650,50 @@ watch(currentChatId, () => { + + diff --git a/src/components/ai/OutlinePanel.vue b/src/components/ai/OutlinePanel.vue index 5a4a69c..f4dcde6 100644 --- a/src/components/ai/OutlinePanel.vue +++ b/src/components/ai/OutlinePanel.vue @@ -7,6 +7,7 @@ import { ref, computed, watch, onMounted } 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 emit = defineEmits<{ @@ -80,11 +81,11 @@ function acceptAgentSlide(slide: Slide) { /** kind → 徽标文本与颜色 */ const KIND_META: Record = { - cover: { label: '封面', color: '#4f46e5' }, + cover: { label: '封面', color: '#5b5bd6' }, toc: { label: '目录', color: '#06b6d4' }, content: { label: '内容', color: '#64748b' }, quote: { label: '金句', color: '#f59e0b' }, - end: { label: '结尾', color: '#4f46e5' } + end: { label: '结尾', color: '#5b5bd6' } } function setBusy(b: boolean) { @@ -242,7 +243,7 @@ defineExpose({ acceptAgentSlide, requestAgent: (s: string) => emit('request-agen
- +
@@ -272,7 +273,7 @@ defineExpose({ acceptAgentSlide, requestAgent: (s: string) => emit('request-agen
-

{{ outline.title || '(未命名)' }}

+

{{ outline.title || '(未命名)' }}

@@ -282,9 +283,9 @@ defineExpose({ acceptAgentSlide, requestAgent: (s: string) => emit('request-agen {{ KIND_META[it.kind].label }} - {{ it.done ? '✓ 已生成' : '待生成' }} + {{ it.done ? '已生成' : '待生成' }} - +
{{ it.points[0] }}
@@ -292,7 +293,7 @@ defineExpose({ acceptAgentSlide, requestAgent: (s: string) => emit('request-agen
- +
diff --git a/src/components/modals/OssSettingsModal.vue b/src/components/modals/OssSettingsModal.vue deleted file mode 100644 index 91f85b7..0000000 --- a/src/components/modals/OssSettingsModal.vue +++ /dev/null @@ -1,197 +0,0 @@ - - - - - - diff --git a/src/components/modals/SettingsModal.vue b/src/components/modals/SettingsModal.vue deleted file mode 100644 index 8bc51a1..0000000 --- a/src/components/modals/SettingsModal.vue +++ /dev/null @@ -1,216 +0,0 @@ - - - - - - diff --git a/src/components/modals/UnifiedSettingsModal.vue b/src/components/modals/UnifiedSettingsModal.vue new file mode 100644 index 0000000..fd7c82f --- /dev/null +++ b/src/components/modals/UnifiedSettingsModal.vue @@ -0,0 +1,466 @@ + + + + + + diff --git a/src/core/ai.ts b/src/core/ai.ts index dda7f1d..63f8d91 100644 --- a/src/core/ai.ts +++ b/src/core/ai.ts @@ -566,13 +566,18 @@ export function parseChatReply(text: string): { reply: string; op: AiOp | null } * 组装 Agent 请求 prompt:用户指令 + chat 协议要求 + deck 上下文。 * 大 deck(序列化 >700KB)降级为「当前页完整 JSON + 全deck大纲摘要」,防超 1MiB 帧上限。 */ -export function buildAgentPrompt(input: string): string { +export function buildAgentPrompt(input: string, selectedElement?: SlideElement | null, history?: { role: string; content: string }[]): string { const SYS_AGENT = SYS_BASE + '\n任务:你是通过中继接入的远程 Agent。根据用户指令编辑当前演示。\n' + '回复格式:先用中文说明你将做什么,如需修改 PPT,在回复最后另起一行输出分隔标记 ' + SEP + ',紧随其后输出 JSON 操作。\n' + - 'JSON 操作格式:{"action":"add_page|update_page|create_all|answer","slides":[...],"target":页码(从1开始,可选)}\n' + + 'JSON 操作格式:{"action":"add_page|update_page|create_all|answer|outline|gen_page","slides":[...],"target":页码(从1开始,可选)}\n' + '- add_page:在 target 页后插入新页;- update_page:替换 target 页;- create_all:整体替换;- answer:仅回答不改稿。\n' + + '- outline:用户要求「拟定大纲/先出大纲」时返回,格式 {"action":"outline","title":"整份标题","items":[...]};\n' + + ' items 结构:{ "kind":"cover|toc|content|quote|end", "title":"页标题", "points":["要点 1","要点 2"], "hint":"可选补充指令" };\n' + + ' kind 取值:cover(封面)/toc(目录)/content(内容)/quote(金句)/end(结尾),封面与结尾各 1 页,目录可选;\n' + + ' 页数遵循用户指令(指定具体页数照办;未指定/「自动」则按主题信息量在 5-12 页裁量:概念介绍 5-7、常规 7-9、复杂多维度 9-12);hint 给后续生成幻灯片的补充指令。\n' + + '- gen_page:用户给出大纲条目要求生成该页时返回,格式 {"action":"gen_page","slides":[单个slide对象]}。\n' + '没有改动时不要输出分隔标记。不要使用 markdown 代码块。' const deck = store.getDeck() let body: string @@ -583,7 +588,34 @@ export function buildAgentPrompt(input: string): string { } else { body = '完整 deck JSON:\n' + full + '\n当前页码:第 ' + (store.getCurrentIndex() + 1) + ' 页' } - return SYS_AGENT + '\n\n' + body + '\n\n用户指令:' + input + // 选中元素上下文:与 direct 通道对话行为对齐 + if (selectedElement) { + const typeLabel = selectedElement.type + const preview = (selectedElement.content || '').replace(/\n/g, ' ').slice(0, 100) + body += '\n\n【用户当前选中的元素】(第' + (store.getCurrentIndex() + 1) + '页)' + body += '\n类型: ' + typeLabel + ',内容预览: "' + preview + '"' + body += '\n用户接下来的指令默认针对此元素,除非明确说整页/整套。' + } + // 多轮对话历史(不含本次输入);单条截断防长回复撑爆 1MiB 帧上限 + let hist = '' + if (history && history.length) { + hist = '\n\n对话历史:\n' + history + .map(m => (m.role === 'user' ? '用户: ' : '助手: ') + m.content.slice(0, 1500)) + .join('\n') + } + return SYS_AGENT + '\n\n' + hist + '\n\n' + body + '\n\n用户指令:' + input +} + +/** 大纲条目规范化(outline() 与 normalizeOp 的 outline 分支共用) */ +function normOutlineItems(arr: any[]): OutlineItem[] { + return (Array.isArray(arr) ? arr : []).map((it: any, i: number) => ({ + id: 'ol-' + Date.now() + '-' + i, + kind: ['cover', 'toc', 'content', 'quote', 'end'].includes(it.kind) ? it.kind : 'content', + title: String(it.title || '未命名').slice(0, 80), + points: Array.isArray(it.points) ? it.points.map((p: any) => String(p).slice(0, 200)).filter(Boolean).slice(0, 6) : [], + hint: it.hint ? String(it.hint).slice(0, 120) : undefined, + done: false + })) } function normalizeOp(json: any): AiOp | null { @@ -591,6 +623,14 @@ function normalizeOp(json: any): AiOp | null { let action: AiOp['action'] = json.action || 'answer' const slides = normSlides(json.slides) const target = json.target != null ? (Number(json.target) - 1) : null + // outline:agent 返回整份大纲,条目规范化(复用 outline() 的逻辑) + if (action === 'outline') { + const items = normOutlineItems(json.items) + if (!items.length) action = 'answer' + else return { action, slides, target: null, note: json.note || '', outline: { title: String(json.title || '').slice(0, 80) || '未命名大纲', topic: '', items } } + } + // gen_page:按大纲条目生成的单页,空则降级 answer + if (action === 'gen_page' && !slides.length) action = 'answer' if (action === 'update_page' && slides.length) action = 'update_page' if (action === 'add_page' && slides.length) action = 'add_page' if ((action === 'update_page' || action === 'add_page') && !slides.length) action = 'answer' @@ -610,7 +650,8 @@ import type { Outline, OutlineItem, ThemeSuggestion, ImageGenResult } from './ty const SYS_OUTLINE = '你是「u-ppt」的演示策划助手。用户给你一个主题,你先制定大纲,不要直接写完整幻灯片。\n\n' + '大纲要素:先拟定一个具体、有信息量的整份标题(不要「关于 X 的分享」这类空泛标题);\n' + - '然后拆成 5-8 页,每页给出:页类型 kind、标题 title、3-5 条要点 points、可选 hint。\n' + + '然后拆成页(页数遵循用户指令:指定了具体页数就照办;「自动」则按主题的信息量在 5-12 页间裁量,' + + '概念介绍 5-7 页、常规主题 7-9 页、多维度复杂主题 9-12 页),每页给出:页类型 kind、标题 title、3-5 条要点 points、可选 hint。\n' + 'kind 取值:cover(封面) / toc(目录) / content(内容) / quote(金句) / end(结尾)。\n' + '封面与结尾各 1 页,目录可选。\n' + 'hint 用来给后续生成幻灯片的 AI 补充指令,例如「数据页:用 stat+chart」「对比页:双 card 并置」「引言页:深色背景金句」。\n\n' + @@ -651,22 +692,18 @@ const SYS_BEAUTIFY = /* ---------- 1. 大纲生成 ---------- */ -export async function outline(opts: { topic: string; count?: number; signal?: AbortSignal }): Promise { - const count = opts.count || 7 +export async function outline(opts: { topic: string; count?: number | 'auto'; signal?: AbortSignal }): Promise { + const count = opts.count || 'auto' + const countHint = count === 'auto' + ? '页数自动裁量(结合主题信息量在 5-12 页之间取舍)' + : '请拟定约 ' + count + ' 页' const messages: Message[] = [ { role: 'system', content: SYS_OUTLINE }, - { role: 'user', content: '主题:' + opts.topic + '\n请拟定约 ' + count + ' 页的大纲(含封面与结尾),中文。' } + { role: 'user', content: '主题:' + opts.topic + '\n' + countHint + '的大纲(含封面与结尾),中文。' } ] const r = await streamChat(messages, { jsonMode: true, signal: opts.signal }) if (!r.json) throw new Error('AI 未返回有效大纲,请重试。') - const items: OutlineItem[] = (Array.isArray(r.json.items) ? r.json.items : []).map((it: any, i: number) => ({ - id: 'ol-' + Date.now() + '-' + i, - kind: ['cover', 'toc', 'content', 'quote', 'end'].includes(it.kind) ? it.kind : 'content', - title: String(it.title || '未命名').slice(0, 80), - points: Array.isArray(it.points) ? it.points.map((p: any) => String(p).slice(0, 200)).filter(Boolean).slice(0, 6) : [], - hint: it.hint ? String(it.hint).slice(0, 120) : undefined, - done: false - })) + const items = normOutlineItems(r.json.items) if (!items.length) throw new Error('大纲为空,请重试或换一个主题。') return { title: String(r.json.title || opts.topic).slice(0, 80), topic: opts.topic, items } } diff --git a/src/core/relay.ts b/src/core/relay.ts index 80b7d2a..8b74337 100644 --- a/src/core/relay.ts +++ b/src/core/relay.ts @@ -36,6 +36,17 @@ const MAX_FRAME = 1024 * 1024 // 服务端帧上限 1MiB const RECONNECT_BASE = 1_000 // 重连退避基数 const RECONNECT_MAX = 30_000 // 重连退避上限 +/** 补全中继地址:无路径(或仅"/")时自动追加 /ws/miniapp 端点;已含路径则原样 */ +export function normalizeRelayUrl(url: string): string { + const u = url.trim().replace(/\/+$/, '') + try { + const parsed = new URL(u) + return (parsed.pathname === '/' || parsed.pathname === '') ? u + '/ws/miniapp' : u + } catch { + return u + } +} + function genId(): string { return 'req-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10) } @@ -108,7 +119,7 @@ export class RelayClient { const c = store.getCfg() this.setStatus(this.attempts ? 'reconnecting' : 'connecting') let ws: WebSocket - try { ws = new WebSocket(c.relayUrl!) } catch (e: any) { + try { ws = new WebSocket(normalizeRelayUrl(c.relayUrl!)) } catch (e: any) { this.setStatus('error', 'URL 无效:' + (e?.message || e)); return } this.ws = ws diff --git a/src/core/types.ts b/src/core/types.ts index ee9cca2..7d35d67 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -233,10 +233,12 @@ export interface LibItem { /** AI 返回的操作 */ export interface AiOp { - action: 'create_all' | 'add_page' | 'update_page' | 'answer' + action: 'create_all' | 'add_page' | 'update_page' | 'answer' | 'outline' | 'gen_page' slides: Slide[] target: number | null // 1-based 页码 note: string + /** action=outline 时携带的大纲 */ + outline?: Outline } /** 聊天消息 */