新增: Agent 大纲协议(outline/gen_page 动作)与设置弹窗统一(UnifiedSettingsModal 归并 Settings/OSS/Agent 三弹窗)

This commit is contained in:
lxy
2026-08-30 03:32:04 +08:00
parent 989d44f5fa
commit dd9c3db530
10 changed files with 925 additions and 732 deletions
+24 -33
View File
@@ -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<SettingsTab>('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(() => {
<!-- Tabs属性 / AI -->
<aside class="side-panel">
<div class="panel-tabs">
<button class="panel-tab" :class="{ active: activeTab === 'props' }" @click="switchTab('props')">🎨 属性</button>
<button class="panel-tab" :class="{ active: activeTab === 'ai' }" @click="switchTab('ai')">🤖 AI 助手</button>
<button class="panel-tab" :class="{ active: activeTab === 'agent' }" @click="switchTab('agent')">📡 Agent</button>
<button class="panel-tab" :class="{ active: activeTab === 'props' }" @click="switchTab('props')"><Icon name="sliders" :size="13" /> 属性</button>
<button class="panel-tab" :class="{ active: activeTab === 'ai' }" @click="switchTab('ai')"><Icon name="sparkles" :size="13" /> AI 助手</button>
</div>
<PropsPanel v-show="activeTab === 'props'" />
@@ -516,14 +516,9 @@ onUnmounted(() => {
v-show="activeTab === 'ai'"
@busy-change="onBusyChange"
@toast="toast"
@open-settings="settingsVisible = true"
@switch-tab="(t: string) => switchTab(t as 'props' | 'ai' | 'agent')"
/>
<AgentPanel
v-show="activeTab === 'agent'"
@toast="toast"
@open-settings="settingsVisible = true"
@open-settings="openSettings('ai')"
@open-relay-settings="openSettings('relay')"
@switch-tab="(t: string) => switchTab(t as 'props' | 'ai')"
/>
</aside>
</div>
@@ -537,21 +532,17 @@ onUnmounted(() => {
/>
<!-- ===================== 弹窗 ===================== -->
<SettingsModal
<UnifiedSettingsModal
:visible="settingsVisible"
:initial-tab="settingsTab"
@close="settingsVisible = false"
@toast="toast"
/>
<OssSettingsModal
:visible="ossVisible"
@close="ossVisible = false"
@toast="toast"
/>
<LibraryModal
:visible="libraryVisible"
@close="libraryVisible = false"
@toast="toast"
@switch-tab="(t: string) => switchTab(t as 'props' | 'ai' | 'agent')"
@switch-tab="(t: string) => switchTab(t as 'props' | 'ai')"
@open-deck="mode = 'editor'"
/>
<TemplateModal
@@ -564,7 +555,7 @@ onUnmounted(() => {
:visible="importVisible"
@close="onImportClose"
@toast="toast"
@open-settings="settingsVisible = true"
@open-settings="openSettings('ai')"
/>
<PrintModal
v-if="printVisible"
@@ -577,7 +568,7 @@ onUnmounted(() => {
<!-- 全局拖放提示遮罩 -->
<div v-if="globalDragOver" class="global-drop-overlay">
<div class="global-drop-card">
<span class="global-drop-icon">📥</span>
<span class="global-drop-icon"><Icon name="download" :size="44" /></span>
<strong>松开导入文件</strong>
<span class="global-drop-hint">.json 直接导入 · 图片/文档进入资料导入</span>
</div>
-223
View File
@@ -1,223 +0,0 @@
<!-- =====================================================================
AgentPanel.vue Agent 模式面板u-relay 中继接入远端 Agent
连接状态徽标 + 指令输入 + 进度流 + 结果按 SEP 协议应用到 deck
===================================================================== -->
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { store } from '../../core/store'
import { relay, type RelayStatus } from '../../core/relay'
import { buildAgentPrompt, parseChatReply } from '../../core/ai'
import { renderMd } from '../../core/markdown'
const emit = defineEmits<{
(e: 'toast', msg: string): void
(e: 'open-settings'): void
}>()
interface StreamMsg {
key: number
role: 'user' | 'assistant' | 'system'
content: string
streaming?: boolean
tag?: string
error?: boolean
}
const messagesEl = ref<HTMLElement | null>(null)
const inputEl = ref<HTMLTextAreaElement | null>(null)
const inputText = ref('')
const status = ref<RelayStatus>(relay.getStatus())
const statusDetail = ref('')
const busy = ref(false)
let keySeq = 0
const msgs = ref<StreamMsg[]>([])
const configured = computed(() => relay.isConfigured())
const STATUS_LABEL: Record<RelayStatus, string> = {
disabled: '未启用',
connecting: '连接中',
connected: '已连接',
reconnecting: '重连中',
error: '连接失败'
}
function scrollBottom() {
nextTick(() => {
const el = messagesEl.value
if (el) el.scrollTop = el.scrollHeight
})
}
function push(role: StreamMsg['role'], content: string, opts?: Partial<StreamMsg>): StreamMsg {
const m: StreamMsg = { key: ++keySeq, role, content, ...opts }
msgs.value.push(m)
scrollBottom()
return m
}
/** 进行中的请求:request_id → 流式气泡 */
const inflight = new Map<string, StreamMsg>()
function onStatus(s: RelayStatus, detail?: string) {
status.value = s
statusDetail.value = detail || ''
}
function onProgress(rid: string, text: string) {
const m = inflight.get(rid)
if (!m) return
m.content = text
m.streaming = true
scrollBottom()
}
function onResult(rid: string, text: string) {
let m = inflight.get(rid)
inflight.delete(rid)
relay.settle(rid)
if (m) {
m.content = text
m.streaming = false
} else {
m = push('assistant', text)
}
applyResult(m)
busy.value = false
}
function applyResult(m: StreamMsg) {
const { reply, op } = parseChatReply(m.content)
m.content = reply || '(无文字回复)'
if (op && op.action !== 'answer' && op.slides.length) {
const slides = op.slides
const idx = store.getCurrentIndex()
if (op.action === 'create_all') {
store.replaceDeck({ theme: store.theme.value, slides }, { newChat: true })
m.tag = '已替换为 ' + slides.length + ' 页新演示'
} else if (op.action === 'add_page') {
const at = (op.target != null ? op.target : idx) + 1
store.insertSlideAt(Math.min(at, store.getCount()), slides[0])
m.tag = '已新增 1 页'
} else if (op.action === 'update_page') {
const t = Math.max(0, Math.min(op.target != null ? op.target : idx, store.getCount() - 1))
store.replaceSlide(t, slides[0])
if (t !== idx) store.setCurrentIndex(t)
m.tag = '已更新第 ' + (t + 1) + ' 页'
}
}
scrollBottom()
}
async function onSend() {
if (busy.value || !inputText.value.trim()) return
if (!configured.value) { emit('toast', '请先在设置中配置 Agent 中继'); emit('open-settings'); return }
if (status.value !== 'connected') { emit('toast', '中继未连接,请稍候'); return }
const input = inputText.value.trim()
inputText.value = ''
push('user', input)
const prompt = buildAgentPrompt(input)
busy.value = true
try {
const rid = relay.request(prompt)
inflight.set(rid, push('assistant', '', { streaming: true }))
} catch (e: any) {
push('assistant', '⚠ ' + (e?.message || String(e)), { error: true })
busy.value = false
}
}
function onToggleConnect() {
if (status.value === 'connected' || status.value === 'connecting' || status.value === 'reconnecting') {
relay.disconnect()
} else {
relay.connect()
}
}
function onInputKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); onSend() }
}
relay.setHandlers({ onStatus, onProgress, onResult })
onMounted(() => {
if (configured.value) relay.connect()
})
onUnmounted(() => {
// 面板卸载不断开共享连接(切 Tab 不掉线),仅清 handler 由单例保底
})
</script>
<template>
<div class="panel-pane agent-pane">
<div class="agent-status-bar">
<span class="status-badge" :class="status">
<i class="dot"></i>{{ STATUS_LABEL[status] }}
</span>
<button class="agent-action ghost" @click="onToggleConnect">
{{ (status === 'connected' || status === 'connecting' || status === 'reconnecting') ? '断开' : '连接' }}
</button>
<button class="agent-action ghost" @click="emit('open-settings')">设置</button>
</div>
<div v-if="!configured" class="agent-empty">
未配置 Agent 中继请点击右上角设置填写<br />中继 URL / Token / 设备 ID三项齐备即启用
</div>
<div v-show="configured" class="agent-messages" ref="messagesEl">
<div v-if="!msgs.length" class="agent-empty">
通过中继把指令发给远端 Agent例如<br />把当前页的标题改得更有冲击力
</div>
<div v-for="m in msgs" :key="m.key" class="msg" :class="m.role">
<div class="bubble" :class="{ error: m.error }">
<span v-if="m.tag" class="diff-tag"> {{ m.tag }}</span>
<div v-if="m.content" class="md-body" v-html="renderMd(m.content)"></div>
<span v-if="m.streaming" class="cursor"></span>
</div>
</div>
</div>
<div class="chat-input-bar">
<textarea
ref="inputEl"
v-model="inputText"
rows="3"
:placeholder="configured ? '输入 Agent 指令,回车发送(Shift+Enter 换行)' : '请先配置中继'"
:disabled="!configured"
@keydown="onInputKeydown"
></textarea>
<div class="btns">
<button class="btn primary" :disabled="busy || !configured" @click="onSend">发送</button>
</div>
</div>
</div>
</template>
<style scoped>
.agent-pane { display: flex; flex-direction: column; height: 100%; }
.agent-status-bar {
display: flex; align-items: center; gap: 8px;
padding: 8px 10px; border-bottom: 1px solid var(--ui-border);
}
.status-badge {
display: inline-flex; align-items: center; gap: 6px;
font-size: 12px; color: var(--ui-text-secondary, #888); flex: 1;
}
.status-badge .dot { width: 8px; height: 8px; border-radius: 50%; background: #9ca3af; }
.status-badge.connected .dot { background: #22c55e; }
.status-badge.connecting .dot, .status-badge.reconnecting .dot { background: #f59e0b; animation: pulse 1.2s infinite; }
.status-badge.error .dot { background: #ef4444; }
@keyframes pulse { 50% { opacity: 0.3; } }
.agent-action {
border: none; background: none; cursor: pointer; font-size: 12px;
color: var(--ui-text-secondary, #888); padding: 2px 6px;
}
.agent-action:hover { color: var(--ui-text, #333); }
.agent-messages { flex: 1; overflow-y: auto; padding: 10px; }
.agent-empty {
color: var(--ui-text-secondary, #999); font-size: 13px;
text-align: center; padding: 32px 12px; line-height: 1.8;
}
</style>
+353 -32
View File
@@ -1,30 +1,35 @@
<!-- =====================================================================
AiPanel.vue AI 聊天面板
发送/停止/生成整套/润色本页/流式渲染/操作应用
AiPanel.vue AI 聊天面板双通道直连 LLM / u-relay Agent
通道切换/发送分流/流式渲染/操作应用direct chatLog 持久化
agent 走内存 msgs不持久化op 应用统一走 applyOp
===================================================================== -->
<script setup lang="ts">
import { ref, nextTick, computed, watch, onUnmounted } from 'vue'
import type { ChatMessage, AiOp, Slide } from '../../core/types'
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 } from '../../core/ai'
import { generate, 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 OutlinePanel from './OutlinePanel.vue'
import Icon from '../common/Icon.vue'
const emit = defineEmits<{
(e: 'busy-change', busy: boolean): void
(e: 'toast', msg: string): void
(e: 'open-settings'): void
(e: 'open-relay-settings'): void
(e: 'switch-tab', tab: string): void
}>()
const CHAT_KEY = 'u-ppt.chat.v1' // 旧 key,仅用于迁移
const AGENT_CHAT_PREFIX = 'u-ppt.agentchat.v1.' // agent 会话持久化前缀(按 chatId 隔离)
/** 当前会话绑定的 chatId(跟随 deck.chatId */
const currentChatId = computed(() => store.getChatId())
/** 渲染用消息条目(带可选的流式/标签/error 状态) */
/** 渲染用消息条目(带可选的流式/标签/error 状态direct/agent 通道共用结构 */
interface RenderMsg {
key: number
role: 'user' | 'assistant' | 'system'
@@ -34,8 +39,29 @@ interface RenderMsg {
error?: boolean
}
/* ---------- 通道 ---------- */
const channel = ref<'direct' | 'agent'>('direct')
const status = ref<RelayStatus>(relay.getStatus())
const statusDetail = ref('')
const agentConfigured = computed(() => relay.isConfigured())
const STATUS_LABEL: Record<RelayStatus, string> = {
disabled: '未启用',
connecting: '连接中',
connected: '已连接',
reconnecting: '重连中',
error: '连接失败'
}
const renderMsgs = ref<RenderMsg[]>([])
/** agent 通道消息(按 chatId 持久化到 localStoragetag/error/streaming 状态不存) */
const agentMsgs = ref<RenderMsg[]>([])
/** 当前通道渲染的消息源 */
const viewMsgs = computed(() => (channel.value === 'direct' ? renderMsgs.value : agentMsgs.value))
const chatLog = ref<ChatMessage[]>(loadChat())
/** agent 通道当前大纲(agent 返回 outline op 后填充,传给 OutlinePanel */
const agentOutline = ref<Outline | null>(null)
const messagesEl = ref<HTMLElement | null>(null)
const inputEl = ref<HTMLTextAreaElement | null>(null)
@@ -69,6 +95,43 @@ function persistChat() {
}, 400)
}
/* ---------- agent 会话持久化(独立前缀,结构与 ChatMessage 一致,tag/error 不存) ---------- */
let agentSaveTimer: ReturnType<typeof setTimeout> | null = null
/** 加载该 chatId 的 agent 会话(剥掉持久化字段) */
function loadAgentChat(): RenderMsg[] {
try {
const list: ChatMessage[] = JSON.parse(localStorage.getItem(AGENT_CHAT_PREFIX + currentChatId.value) || '[]') || []
return list.map(m => ({ key: ++keySeq, role: m.role as 'user' | 'assistant', content: m.content }))
} catch (e) { return [] }
}
/** 防抖持久化 agent 会话(仅 role/contentquota 溢出静默丢弃旧条目) */
function persistAgentChat() {
if (agentSaveTimer) clearTimeout(agentSaveTimer)
agentSaveTimer = setTimeout(() => {
const list: ChatMessage[] = agentMsgs.value.map(m => ({ role: m.role === 'system' ? 'assistant' : m.role, content: m.content }))
// 超 1.5MB 时成对丢弃最旧条目(参考 store.setChat
while (list.length > 2) {
if (JSON.stringify(list).length <= 1_500_000) break
list.splice(0, 2)
}
try { localStorage.setItem(AGENT_CHAT_PREFIX + currentChatId.value, JSON.stringify(list)) } catch (e) { /* quota 满静默丢弃 */ }
}, 400)
}
/** agent 通道追加一条消息(内存 + 防抖持久化) */
function addAgentMsg(m: RenderMsg) {
agentMsgs.value.push(m)
persistAgentChat()
}
/** 清空 agent 会话(内存 + localStorage */
function clearAgentChat() {
agentMsgs.value = []
try { localStorage.removeItem(AGENT_CHAT_PREFIX + currentChatId.value) } catch (e) {}
}
function setBusy(b: boolean) {
busy.value = b
emit('busy-change', b)
@@ -192,14 +255,14 @@ function streamSetText(s: StreamCtrl, txt: string) {
s.flushPending = false
}
function streamError(s: StreamCtrl, msg: string) {
streamSetText(s, '⚠ ' + msg)
streamSetText(s, msg)
s.msg.error = true
}
function streamTag(s: StreamCtrl, txt: string) {
if (txt) s.msg.tag = txt
}
/** 应用 AI 返回的操作到 store */
/** 应用 AI 返回的操作到 storedirect/agent 通道统一入口;target 越界 clamp,跨页跳转) */
function applyOp(op: AiOp, lockedIdx: number): string {
const slides = op.slides
if (op.action === 'create_all' && slides.length) {
@@ -231,11 +294,125 @@ function persistStream(s: StreamCtrl) {
persistChat()
}
/* ---------- Agent 通道:relay handler 挂接 ---------- */
/** 进行中的 agent 请求:request_id → 流式气泡 */
const inflight = new Map<string, RenderMsg>()
/** 单一忙碌请求的 rid(用于「停止」) */
let agentBusyRid: string | null = null
function onRelayStatus(s: RelayStatus, detail?: string) {
status.value = s
statusDetail.value = detail || ''
}
function onRelayProgress(rid: string, text: string) {
const m = inflight.get(rid)
if (!m) return
m.content = text
m.streaming = true
scrollBottom()
}
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 (m) {
m.content = text
m.streaming = false
} else {
m = { key: ++keySeq, role: 'assistant', content: text }
agentMsgs.value.push(m)
}
// SEP 协议解析与 op 应用(与 direct 通道共用 applyOp,行为一致)
const { reply, op } = parseChatReply(m.content)
m.content = reply || '(无文字回复)'
if (op && op.action === 'outline' && op.outline) {
// agent 返回大纲:填充 topic 后交给 OutlinePanel(作为 initialOutline),自动展开面板
op.outline.topic = op.outline.topic || agentOutlineTopic.value
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])
driveNextOutlinePage()
} else m.tag = applyOp(op, store.getCurrentIndex())
} else if (op && op.action !== 'answer' && op.slides.length) {
m.tag = applyOp(op, store.getCurrentIndex())
}
addAgentMsg(m)
scrollBottom()
}
relay.setHandlers({ onStatus: onRelayStatus, onProgress: onRelayProgress, onResult: onRelayResult })
onMounted(() => {
// 面板挂载即连(配置齐备才连);卸载不断开(切 Tab 不掉线,relay 单例保底)
if (relay.isConfigured()) relay.connect()
})
function onToggleConnect() {
if (status.value === 'connected' || status.value === 'connecting' || status.value === 'reconnecting') {
relay.disconnect()
} else {
relay.connect()
}
}
/** 切换通道 */
function switchChannel(c: 'direct' | 'agent') {
channel.value = c
nextTick(scrollBottom)
}
/** agent 通道发送(发送分流与快捷操作共用;带多轮历史与选中元素上下文) */
function sendAgent(input: string) {
if (!agentConfigured.value) { toast('请先在设置中配置 Agent 中继'); emit('open-relay-settings'); return }
if (status.value !== 'connected') { toast('中继未连接,请稍候'); return }
// 历史取最近 10 条(剥离 tag 后缀),不含本次输入
const history = agentMsgs.value.slice(-10).map(m => ({
role: m.role,
content: m.content.replace(/\n?\[✓[^\]]*\]$/g, '').replace(/\n?(已停止)$/g, '')
}))
addAgentMsg({ key: ++keySeq, role: 'user', content: input })
const prompt = buildAgentPrompt(input, selectedEl.value, history)
try {
const rid = relay.request(prompt)
agentBusyRid = rid
const m: RenderMsg = { key: ++keySeq, role: 'assistant', content: '', streaming: true }
addAgentMsg(m)
inflight.set(rid, m)
setBusy(true)
} catch (e: any) {
addAgentMsg({ key: ++keySeq, role: 'assistant', content: (e?.message || String(e)), error: true })
}
scrollBottom()
}
/** agent 通道停止:放弃匹配槽,气泡标「(已停止)」 */
function stopAgent() {
if (!agentBusyRid) return
const rid = agentBusyRid
agentBusyRid = null
const m = inflight.get(rid)
inflight.delete(rid)
relay.settle(rid)
if (m) {
m.streaming = false
if (!m.content) m.content = '(已停止)'
else m.content += '\n(已停止)'
}
setBusy(false)
}
/* ---------- 发送 ---------- */
function onSend() {
if (busy.value) return
const text = inputText.value.trim()
if (!text) return
if (channel.value === 'agent') { inputText.value = ''; sendAgent(text); return }
if (!isConfigured()) { toast('请先配置 API Key'); emit('open-settings'); return }
inputText.value = ''
runChat(text)
@@ -287,6 +464,7 @@ async function runChat(input: string) {
}
function onStop() {
if (channel.value === 'agent') { stopAgent(); return }
if (abortCtrl) abortCtrl.abort()
}
@@ -295,6 +473,8 @@ async 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 + '」…')
@@ -307,8 +487,8 @@ async function onGenerate() {
store.replaceDeck({ theme: store.theme.value, slides: r.slides }, { newChat: true })
// watch(currentChatId) 会自动清空 renderMsgs 并加载新会话(空的)
// 在新会话里记录这次生成
addPersisted('user', '生成整套:' + topic)
addPersisted('assistant', '已生成 ' + r.slides.length + ' 页演示。可在画布查看与微调,Ctrl+Z 可撤销。')
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))
@@ -321,12 +501,14 @@ async function onGenerate() {
/* ---------- 润色本页 ---------- */
async function onPolish() {
if (busy.value) return
if (!isConfigured()) { toast('请先配置 API Key'); emit('open-settings'); return }
const slide = store.currentSlide.value
if (!slide) return
const idx0 = store.getCurrentIndex()
const idx = idx0 + 1
addPersisted('user', '🪄 润色第 ' + idx + ' 页')
// agent 通道:转自然语言指令走中继
if (channel.value === 'agent') { sendAgent('润色第 ' + idx + ' 页:让内容更有吸引力、表达更精炼,保持布局合理'); return }
if (!isConfigured()) { toast('请先配置 API Key'); emit('open-settings'); return }
addPersisted('user', '润色第 ' + idx + ' 页')
const stream = streamBubble('正在润色第 ' + idx + ' 页…')
setBusy(true); abortCtrl = new AbortController()
@@ -334,7 +516,7 @@ async function onPolish() {
const r = await polish({ slide: slide as Slide, instruction: '让内容更有吸引力、表达更精炼,保持布局合理', signal: abortCtrl.signal })
streamDone(stream)
if (idx0 < store.getCount()) store.replaceSlide(idx0, r.slide)
streamSetText(stream, '🪄 已润色第 ' + idx + ' 页' + (r.note ? '' + r.note : '') + '。Ctrl+Z 可撤销。')
streamSetText(stream, '已润色第 ' + idx + ' 页' + (r.note ? '' + r.note : '') + '。Ctrl+Z 可撤销。')
persistStream(stream)
} catch (e: any) {
if (e?.name === 'AbortError') streamSetText(stream, '(已停止)')
@@ -347,6 +529,12 @@ async function onPolish() {
/* ---------- 清空 ---------- */
async function onClear() {
if (channel.value === 'agent') {
if (!agentMsgs.value.length) { toast('对话已是空的'); return }
if (!(await appConfirm('清空对话记录?', '将清空当前 PPT 的 Agent 对话记录', { danger: true, okText: '清空' }))) return
clearAgentChat()
return
}
if (!chatLog.value.length) { toast('对话已是空的'); return }
if (!(await appConfirm('清空对话记录?', '将清空当前 PPT 的对话记录', { danger: true, okText: '清空' }))) return
chatLog.value = []
@@ -354,19 +542,55 @@ async function onClear() {
renderMsgs.value = []
}
/* ---------- 大纲面板 ---------- */
/* ---------- 大纲面板(双通道通用;agent 通道走 outline/gen_page op ---------- */
const outlinePanelEl = ref<InstanceType<typeof OutlinePanel> | null>(null)
/** agent 通道大纲主题暂存(OutlinePanel 主题输入回传,用于填充 outline.topic */
const agentOutlineTopic = ref('')
function onToggleOutline() {
showOutline.value = !showOutline.value
}
/** OutlinePanelagent 通道)转发指令:把生成大纲/单页的自然语言指令发给中继 Agent */
function onOutlineRequestAgent(instruction: string) {
if (!instruction.startsWith('按大纲生成')) {
// 生成大纲指令:记住主题,agent 返回 outline 后回填 topic
const m = instruction.match(/主题「([^」]+)」/)
if (m) agentOutlineTopic.value = m[1]
agentOutline.value = null
}
sendAgent(instruction)
}
/** agent 单页生成完成后的串行驱动:OutlinePanel 逐页回收后自动发下一条未完成条目 */
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))
}
/** 大纲第 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 : '')
}
/* ---------- 一键美化 ---------- */
async function onBeautify() {
if (busy.value) return
if (!isConfigured()) { toast('请先配置 API Key'); emit('open-settings'); return }
const deck = store.getDeck()
const slides = deck.slides
if (!slides || !slides.length) { toast('当前没有幻灯片可美化'); return }
addPersisted('user', '🎨 一键美化全部 (' + slides.length + ' 页)')
// agent 通道:转自然语言指令走中继
if (channel.value === 'agent') { sendAgent('美化当前演示的全部 ' + slides.length + ' 页,让视觉与排版更精致'); return }
if (!isConfigured()) { toast('请先配置 API Key'); emit('open-settings'); return }
addPersisted('user', '一键美化全部 (' + slides.length + ' 页)')
const stream = streamBubble('正在美化第 1/' + slides.length + ' 页…')
setBusy(true); abortCtrl = new AbortController()
@@ -381,7 +605,7 @@ async function onBeautify() {
done++
}
streamDone(stream)
streamSetText(stream, '已美化 ' + done + ' 页')
streamSetText(stream, '已美化 ' + done + ' 页')
persistStream(stream)
} catch (e: any) {
if (e?.name === 'AbortError') streamSetText(stream, '(已停止,已美化 ' + done + ' 页)')
@@ -401,10 +625,11 @@ function isBusy() { return busy.value }
function focus() { nextTick(() => inputEl.value?.focus()) }
defineExpose({ isBusy, focus })
/* ---------- 初始化:从持久化记录重建 ---------- */
/* ---------- 初始化:从持久化记录重建direct + agent 双通道) ---------- */
rebuildFromChatLog()
agentMsgs.value = loadAgentChat()
/* 组件卸载:停掉所有在跑的打字机 rAF */
/* 组件卸载:停掉所有在跑的打字机 rAF;不断开 relay(切 Tab 不掉线) */
onUnmounted(() => {
for (const s of activeStreams) {
if (s.rafId != null) cancelAnimationFrame(s.rafId)
@@ -412,10 +637,12 @@ onUnmounted(() => {
activeStreams.clear()
})
/* ---------- 会话切换:chatId 变化时重新加载对话 ---------- */
/* ---------- 会话切换:chatId 变化时重新加载对话direct + agent 双通道) ---------- */
watch(currentChatId, () => {
chatLog.value = loadChat()
rebuildFromChatLog()
agentMsgs.value = loadAgentChat()
agentOutline.value = null
scrollBottom()
})
</script>
@@ -423,16 +650,50 @@ watch(currentChatId, () => {
<template>
<div class="panel-pane ai-pane">
<div class="ai-actions">
<button class="ai-action" :disabled="busy" @click="onGenerate"> 生成整套</button>
<button class="ai-action" :disabled="busy" @click="onPolish">🪄 润色本页</button>
<button class="ai-action" :disabled="busy" @click="onToggleOutline">📋 大纲</button>
<button class="ai-action" :disabled="busy" @click="onBeautify">🎨 美化</button>
<button class="ai-action ghost" :disabled="busy" @click="onClear">🗑 清空</button>
<button class="ai-action" :disabled="busy" @click="onGenerate"><Icon name="sparkles" :size="14" /> 生成整套</button>
<button class="ai-action" :disabled="busy" @click="onPolish"><Icon name="wand" :size="14" /> 润色本页</button>
<button class="ai-action" :disabled="busy" @click="onToggleOutline"><Icon name="clipboard" :size="14" /> 大纲</button>
<button class="ai-action" :disabled="busy" @click="onBeautify"><Icon name="palette" :size="14" /> 美化</button>
<button class="ai-action ghost" :disabled="busy" @click="onClear"><Icon name="trash" :size="14" /> 清空</button>
<!-- 通道切换segmentedagent 段带状态色点 -->
<div class="channel-switch">
<button class="seg" :class="{ active: channel === 'direct' }" @click="switchChannel('direct')"><Icon name="zap" :size="14" /> 直连</button>
<button
class="seg"
:class="{ active: channel === 'agent' }"
:title="statusDetail ? STATUS_LABEL[status] + ' · ' + statusDetail : STATUS_LABEL[status]"
@click="switchChannel('agent')"
>
<i class="seg-dot" :class="status"></i>Agent
</button>
</div>
</div>
<OutlinePanel v-if="showOutline" :visible="showOutline" @busy-change="setBusy" @toast="toast" @open-settings="emit('open-settings')" />
<!-- agent 通道连接控制 + 未配置空态 -->
<div v-if="channel === 'agent' && agentConfigured" class="agent-status-bar">
<span class="status-badge" :class="status">
<i class="dot"></i>{{ STATUS_LABEL[status] }}{{ statusDetail ? ' · ' + statusDetail : '' }}
</span>
<button class="agent-action ghost" @click="onToggleConnect">
{{ (status === 'connected' || status === 'connecting' || status === 'reconnecting') ? '断开' : '连接' }}
</button>
<button class="agent-action ghost" @click="emit('open-relay-settings')">设置</button>
</div>
<!-- 选中元素提示条告诉用户 AI 会围绕这个元素对话 -->
<OutlinePanel
v-if="showOutline"
ref="outlinePanelEl"
:visible="showOutline"
:initial-outline="channel === 'agent' ? agentOutline : null"
:agent-channel="channel === 'agent'"
@busy-change="setBusy"
@toast="toast"
@open-settings="emit('open-settings')"
@request-agent="onOutlineRequestAgent"
/>
<!-- 选中元素提示条两通道共用告诉 AI 会围绕这个元素对话 -->
<div v-if="selectedHint" class="selected-hint" title="AI 对话将基于此选中元素">
<span class="selected-hint-icon"></span>
<span class="selected-hint-text">已选中{{ selectedHint }}</span>
@@ -440,18 +701,32 @@ watch(currentChatId, () => {
</div>
<div class="chat-messages" ref="messagesEl">
<div v-if="!renderMsgs.length" class="chat-empty">
<!-- agent 未配置空态 -->
<div v-if="channel === 'agent' && !agentConfigured" class="chat-empty">
未配置 Agent 中继<br />
三项配置中继 URL / Token / 设备ID齐备即启用<br />
<button class="btn primary go-settings" @click="emit('open-relay-settings')">去设置</button>
</div>
<template v-else>
<div v-if="!viewMsgs.length" class="chat-empty">
<template v-if="channel === 'agent'">
通过中继把指令发给远端 Agent例如<br />
把当前页的标题<b>改得更有冲击力</b>
</template>
<template v-else>
告诉我你的主题例如<br />
生成一份关于<b>远程办公趋势</b>的演示
</template>
</div>
<div v-for="m in renderMsgs" :key="m.key" class="msg" :class="m.role">
<div v-for="m in viewMsgs" :key="m.key" class="msg" :class="m.role">
<div class="bubble" :class="{ error: m.error }">
<span v-if="m.tag" class="diff-tag"> {{ m.tag }}</span>
<span v-if="m.tag" class="diff-tag"><Icon name="check" :size="12" /> {{ m.tag }}</span>
<!-- Markdown 整段渲染含代码块/列表/加粗等 DOMPurify 消毒 -->
<div v-if="m.content" class="md-body" v-html="renderMd(m.content)"></div>
<span v-if="m.streaming" class="cursor"></span>
</div>
</div>
</template>
</div>
<div class="chat-input-bar">
@@ -460,13 +735,59 @@ watch(currentChatId, () => {
v-model="inputText"
id="chatInput"
rows="3"
placeholder="输入指令,回车发送(Shift+Enter 换行)"
:placeholder="channel === 'agent' && !agentConfigured ? '请先配置中继' : '输入指令,回车发送(Shift+Enter 换行)'"
:disabled="channel === 'agent' && !agentConfigured"
@keydown="onInputKeydown"
></textarea>
<div class="btns">
<button v-if="!busy" class="btn primary" @click="onSend">发送</button>
<button v-if="!busy" class="btn primary" :disabled="channel === 'agent' && !agentConfigured" @click="onSend">发送</button>
<button v-else class="btn danger" @click="onStop">停止</button>
</div>
</div>
</div>
</template>
<style scoped>
/* ---------- 通道切换(segmented,与 28px ai-action 协调) ---------- */
.channel-switch {
display: inline-flex;
margin-left: auto;
border: 1px solid var(--ui-border, #ddd);
border-radius: 6px;
overflow: hidden;
}
.seg {
display: inline-flex; align-items: center; gap: 5px;
height: 26px; padding: 0 9px;
border: none; background: none; cursor: pointer;
font-size: 12px; color: var(--ui-text-secondary, #888);
}
.seg + .seg { border-left: 1px solid var(--ui-border, #ddd); }
.seg.active { background: var(--ui-primary-soft, #eef); color: var(--ui-primary, #4f6ef7); font-weight: 600; }
.seg-dot { width: 7px; height: 7px; border-radius: 50%; background: #9ca3af; }
.seg-dot.connected { background: #22c55e; }
.seg-dot.connecting, .seg-dot.reconnecting { background: #f59e0b; animation: seg-pulse 1.2s infinite; }
.seg-dot.error { background: #ef4444; }
@keyframes seg-pulse { 50% { opacity: 0.3; } }
/* ---------- agent 状态条(自原 AgentPanel 迁入) ---------- */
.agent-status-bar {
display: flex; align-items: center; gap: 8px;
padding: 6px 10px; border-bottom: 1px solid var(--ui-border);
}
.status-badge {
display: inline-flex; align-items: center; gap: 6px;
font-size: 12px; color: var(--ui-text-secondary, #888); flex: 1;
}
.status-badge .dot { width: 8px; height: 8px; border-radius: 50%; background: #9ca3af; }
.status-badge.connected .dot { background: #22c55e; }
.status-badge.connecting .dot, .status-badge.reconnecting .dot { background: #f59e0b; animation: seg-pulse 1.2s infinite; }
.status-badge.error .dot { background: #ef4444; }
.agent-action {
border: none; background: none; cursor: pointer; font-size: 12px;
color: var(--ui-text-secondary, #888); padding: 2px 6px;
}
.agent-action:hover { color: var(--ui-text, #333); }
.go-settings { margin-top: 10px; }
</style>
+8 -7
View File
@@ -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<OutlineItem['kind'], { label: string; color: string }> = {
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
<!-- 主题输入 + 生成大纲 -->
<div class="outline-top">
<input type="text" v-model="topicInput" placeholder="输入主题,例如「人工智能的产业落地」" :disabled="busy" @keydown.enter="onGenOutline" />
<button class="btn primary" :disabled="busy" @click="onGenOutline"> 生成大纲</button>
<button class="btn primary" :disabled="busy" @click="onGenOutline"><Icon name="sparkles" :size="14" /> 生成大纲</button>
<button v-if="busy" class="btn danger" @click="onStop">停止</button>
</div>
@@ -272,7 +273,7 @@ defineExpose({ acceptAgentSlide, requestAgent: (s: string) => emit('request-agen
<!-- 标题 -->
<div class="outline-title">
<input v-if="editingTitle" type="text" v-model="titleInput" @blur="commitTitle" @keydown.enter="commitTitle" />
<h4 v-else @click="startEditTitle" title="点击编辑">{{ outline.title || '(未命名)' }} <span class="edit-hint"></span></h4>
<h4 v-else @click="startEditTitle" title="点击编辑">{{ outline.title || '(未命名)' }} <span class="edit-hint"><Icon name="edit" :size="12" /></span></h4>
</div>
<!-- 条目列表 -->
@@ -282,9 +283,9 @@ defineExpose({ acceptAgentSlide, requestAgent: (s: string) => emit('request-agen
<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 />
<span class="status" :class="{ done: it.done }">{{ it.done ? '已生成' : '待生成' }}</span>
<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="删除">🗑</button>
<button class="btn small danger" :disabled="busy" @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>
@@ -292,7 +293,7 @@ defineExpose({ acceptAgentSlide, requestAgent: (s: string) => emit('request-agen
<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="删除要点"></button>
<button class="btn small ghost" :disabled="busy" @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>
</div>
-197
View File
@@ -1,197 +0,0 @@
<!-- =====================================================================
OssSettingsModal.vue 云存储OSS设置弹窗
所有媒体资产上云避免撑爆本地存储离线暂存本地联网自动同步
===================================================================== -->
<script setup lang="ts">
import { ref, watch } from 'vue'
import type { OssCfg } from '../../core/types'
import { store } from '../../core/store'
import { syncState, syncPending, canUploadNow } from '../../core/assets'
const props = defineProps<{ visible: boolean }>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'toast', msg: string): void
}>()
const form = ref<OssCfg>({
enabled: false, provider: 'aliyun', dir: 'u-ppt',
aliAccessKeyId: '', aliAccessKeySecret: '', aliEndpoint: '', aliBucket: '',
qiniuAccessKey: '', qiniuSecretKey: '', qiniuBucket: '', qiniuUpHost: '', qiniuDomain: '',
viewerBase: ''
})
function loadFromStore() {
form.value = { ...form.value, ...store.getOssCfg() }
}
watch(() => props.visible, (v) => {
if (v) loadFromStore()
}, { immediate: true })
function save() {
const c = form.value
store.setOssCfg({
enabled: c.enabled,
provider: c.provider,
dir: c.dir.trim(),
aliAccessKeyId: c.aliAccessKeyId.trim(),
aliAccessKeySecret: c.aliAccessKeySecret.trim(),
aliEndpoint: c.aliEndpoint.trim(),
aliBucket: c.aliBucket.trim(),
qiniuAccessKey: c.qiniuAccessKey.trim(),
qiniuSecretKey: c.qiniuSecretKey.trim(),
qiniuBucket: c.qiniuBucket.trim(),
qiniuUpHost: c.qiniuUpHost.trim(),
qiniuDomain: c.qiniuDomain.trim(),
viewerBase: c.viewerBase.trim()
})
emit('toast', c.enabled ? '已保存云存储设置(已启用)' : '已保存云存储设置(未启用)')
emit('close')
}
async function manualSync() {
if (!canUploadNow()) {
emit('toast', '当前不可同步:需桌面版 + 已联网 + 已启用并保存配置')
return
}
const r = await syncPending()
emit('toast', `同步完成:成功 ${r.done},失败 ${r.fail}`)
}
</script>
<template>
<div class="modal-mask" :class="{ hidden: !visible }" @click.self="emit('close')">
<div class="modal">
<h3>云存储OSS设置</h3>
<p class="modal-tip">
开启后图片/视频等媒体自动上传对象存储避免撑爆本地离线时先暂存本地联网后自动同步云端密钥仅保存在本地
</p>
<div class="form-row">
<label>启用云存储</label>
<label class="switch-line">
<input type="checkbox" v-model="form.enabled" />
<span>{{ form.enabled ? '开' : '关(维持本地内嵌)' }}</span>
</label>
</div>
<div class="form-row">
<label>服务商</label>
<select v-model="form.provider">
<option value="aliyun">阿里云 OSS</option>
<option value="qiniu">七牛云 Kodo</option>
</select>
</div>
<div class="form-row">
<label>所属目录</label>
<input type="text" v-model="form.dir" placeholder="如 u-ppt/images,可留空" />
</div>
<div class="form-row">
<label>查看器地址</label>
<input type="text" v-model="form.viewerBase" placeholder="如 https://img.1216.top,生成分享链接必填" />
</div>
<!-- 阿里云 -->
<template v-if="form.provider === 'aliyun'">
<div class="section-title">阿里云 OSS</div>
<div class="form-row">
<label>AccessKeyId</label>
<input type="text" v-model="form.aliAccessKeyId" placeholder="LTAI..." autocomplete="off" />
</div>
<div class="form-row">
<label>AccessKeySecret</label>
<input type="password" v-model="form.aliAccessKeySecret" placeholder="密钥" autocomplete="off" />
</div>
<div class="form-row">
<label>Endpoint</label>
<input type="text" v-model="form.aliEndpoint" placeholder="oss-cn-hangzhou.aliyuncs.com" />
</div>
<div class="form-row">
<label>Bucket</label>
<input type="text" v-model="form.aliBucket" placeholder="bucket 名称" />
</div>
</template>
<!-- 七牛云 -->
<template v-else>
<div class="section-title">七牛云 Kodo</div>
<div class="form-row">
<label>AccessKey</label>
<input type="text" v-model="form.qiniuAccessKey" placeholder="AK" autocomplete="off" />
</div>
<div class="form-row">
<label>SecretKey</label>
<input type="password" v-model="form.qiniuSecretKey" placeholder="SK" autocomplete="off" />
</div>
<div class="form-row">
<label>Bucket</label>
<input type="text" v-model="form.qiniuBucket" placeholder="空间名称" />
</div>
<div class="form-row">
<label>加速域名</label>
<input type="text" v-model="form.qiniuDomain" placeholder="https://cdn.example.com" />
</div>
<div class="form-row">
<label>上传域名可选</label>
<input type="text" v-model="form.qiniuUpHost" placeholder="留空自动探测区域" />
</div>
</template>
<div class="sync-bar">
<div class="sync-info">
<div>
待同步 {{ syncState.pending }} <template v-if="syncState.syncing">同步中</template>
<span v-if="syncState.lastError" class="sync-err">· 有错误</span>
</div>
<div v-if="syncState.lastError" class="sync-err-detail">{{ syncState.lastError }}</div>
</div>
<button class="btn" @click="manualSync" :disabled="syncState.syncing">立即同步</button>
</div>
<div class="modal-actions">
<button class="btn" @click="emit('close')">取消</button>
<button class="btn primary" @click="save">保存</button>
</div>
</div>
</div>
</template>
<style scoped>
.section-title {
margin: 18px 0 10px;
padding-top: 14px;
border-top: 1px solid var(--ui-border);
font-size: 13px;
font-weight: 600;
color: var(--ui-text);
}
.switch-line {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--ui-muted);
}
.switch-line input { width: auto; }
.sync-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid var(--ui-border);
}
.sync-info { font-size: 12px; color: var(--ui-muted); }
.sync-err { color: #e5484d; }
.sync-err-detail {
margin-top: 4px;
max-width: 420px;
word-break: break-all;
font-size: 12px;
line-height: 1.5;
color: #e5484d;
}
</style>
-216
View File
@@ -1,216 +0,0 @@
<!-- =====================================================================
SettingsModal.vue AI 设置弹窗
===================================================================== -->
<script setup lang="ts">
import { ref, watch } from 'vue'
import type { AiCfg } from '../../core/types'
import { store } from '../../core/store'
const props = defineProps<{ visible: boolean }>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'toast', msg: string): void
}>()
const PRESETS: Record<string, { protocol: 'openai' | 'anthropic'; base: string; model: string; label: string }> = {
zhipu: { protocol: 'openai', base: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-4.6', label: '智谱 GLM (OpenAI 协议)' },
zhipu_anth: { protocol: 'anthropic', base: 'https://open.bigmodel.cn/api/anthropic', model: 'glm-4.6', label: '智谱 GLM (Anthropic 协议)' },
deepseek: { protocol: 'openai', base: 'https://api.deepseek.com', model: 'deepseek-chat', label: 'DeepSeek' },
qwen: { protocol: 'openai', base: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-plus', label: '通义千问' },
kimi: { protocol: 'openai', base: 'https://api.moonshot.cn/v1', model: 'moonshot-v1-8k', label: 'Kimi' },
doubao: { protocol: 'openai', base: 'https://ark.cn-beijing.volces.com/api/v3', model: 'doubao-pro-32k', label: '豆包' },
openai: { protocol: 'openai', base: 'https://api.openai.com/v1', model: 'gpt-4o-mini', label: 'OpenAI' },
anthropic: { protocol: 'anthropic', base: 'https://api.anthropic.com', model: 'claude-sonnet-5', label: 'Anthropic' },
gemini: { protocol: 'openai', base: 'https://generativelanguage.googleapis.com/v1beta/openai', model: 'gemini-2.0-flash', label: 'Gemini' },
groq: { protocol: 'openai', base: 'https://api.groq.com/openai/v1', model: 'llama-3.3-70b-versatile', label: 'Groq' },
ollama: { protocol: 'openai', base: 'http://localhost:11434/v1', model: 'llama3.1', label: 'Ollama (本地)' }
}
const PROTO_DEFAULTS: Record<string, { base: string; model: string }> = {
openai: { base: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-4.6' },
anthropic: { base: 'https://open.bigmodel.cn/api/anthropic', model: 'glm-4.6' }
}
const form = ref<AiCfg>({
preset: 'zhipu', protocol: 'openai',
base: '', key: '', model: '', proxy: '',
imgBase: '', imgKey: '', imgModel: '',
relayUrl: '', relayToken: '', relayDeviceId: ''
})
/** 从 store 读取并填充表单 */
function loadFromStore() {
const c = store.getCfg()
form.value = {
preset: PRESETS[c.preset] ? c.preset : 'custom',
protocol: c.protocol || 'openai',
base: c.base, key: c.key, model: c.model, proxy: c.proxy,
imgBase: c.imgBase || '',
imgKey: c.imgKey || '',
imgModel: c.imgModel || '',
relayUrl: c.relayUrl || '',
relayToken: c.relayToken || '',
relayDeviceId: c.relayDeviceId || ''
}
}
watch(() => props.visible, (v) => {
if (v) loadFromStore()
}, { immediate: true })
function applyPreset(key: string) {
const p = PRESETS[key]
if (!p) return
form.value.protocol = p.protocol
form.value.base = p.base
form.value.model = p.model
}
function onProviderChange() {
if (form.value.preset !== 'custom') applyPreset(form.value.preset)
}
function applyProtoDefaults(p: string) {
const d = PROTO_DEFAULTS[p] || PROTO_DEFAULTS.openai
const other = PROTO_DEFAULTS[p === 'openai' ? 'anthropic' : 'openai']
if (!form.value.base.trim() || form.value.base.trim() === other.base) form.value.base = d.base
if (!form.value.model.trim() || form.value.model.trim() === other.model) form.value.model = d.model
}
function onProtocolChange() {
applyProtoDefaults(form.value.protocol)
}
function save() {
store.setCfg({
preset: form.value.preset,
protocol: form.value.protocol,
base: form.value.base.trim(),
key: form.value.key.trim(),
model: form.value.model.trim(),
proxy: form.value.proxy.trim(),
imgBase: (form.value.imgBase || '').trim(),
imgKey: (form.value.imgKey || '').trim(),
imgModel: (form.value.imgModel || '').trim(),
relayUrl: (form.value.relayUrl || '').trim(),
relayToken: (form.value.relayToken || '').trim(),
relayDeviceId: (form.value.relayDeviceId || '').trim()
})
const label = form.value.preset === 'custom'
? (form.value.protocol === 'anthropic' ? 'Anthropic' : 'OpenAI') + ' 自定义'
: (PRESETS[form.value.preset]?.label || form.value.preset)
emit('toast', '已保存 AI 设置(' + label + '')
emit('close')
}
</script>
<template>
<div class="modal-mask" :class="{ hidden: !visible }" @click.self="emit('close')">
<div class="modal">
<h3>AI 设置</h3>
<p class="modal-tip">配置大模型服务商数据仅保存在本地浏览器</p>
<div class="form-row">
<label>服务商</label>
<select v-model="form.preset" @change="onProviderChange">
<optgroup label="国内">
<option value="zhipu">智谱 GLM (OpenAI 协议)</option>
<option value="zhipu_anth">智谱 GLM (Anthropic 协议)</option>
<option value="deepseek">DeepSeek</option>
<option value="qwen">通义千问</option>
<option value="kimi">Kimi</option>
<option value="doubao">豆包</option>
</optgroup>
<optgroup label="海外">
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="gemini">Gemini</option>
<option value="groq">Groq</option>
</optgroup>
<optgroup label="本地">
<option value="ollama">Ollama</option>
</optgroup>
<option value="custom">自定义</option>
</select>
</div>
<div class="form-row">
<label>API 协议</label>
<select v-model="form.protocol" @change="onProtocolChange">
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
</select>
</div>
<div class="form-row">
<label>Base URL</label>
<input type="text" v-model="form.base" placeholder="https://..." />
</div>
<div class="form-row">
<label>API Key</label>
<input type="password" v-model="form.key" placeholder="sk-..." autocomplete="off" />
</div>
<div class="form-row">
<label>模型</label>
<input type="text" v-model="form.model" placeholder="模型名称" />
</div>
<div class="form-row">
<label>代理 URL可选</label>
<input type="text" v-model="form.proxy" placeholder="留空则直连" />
</div>
<div class="section-title">图像模型可选用于 AI 配图</div>
<div class="form-row">
<label>图像 Base URL</label>
<input type="text" v-model="form.imgBase" placeholder="留空则复用上方 Base URL" />
</div>
<div class="form-row">
<label>图像 API Key</label>
<input type="password" v-model="form.imgKey" placeholder="留空则复用上方 Key" autocomplete="off" />
</div>
<div class="form-row">
<label>图像模型</label>
<input type="text" v-model="form.imgModel" placeholder="dall-e-3" />
</div>
<div class="section-title">Agent 中继可选三项齐备即启用 Agent 模式</div>
<div class="form-row">
<label>中继 URL</label>
<input type="text" v-model="form.relayUrl" placeholder="wss://..." />
</div>
<div class="form-row">
<label>中继 Token</label>
<input type="password" v-model="form.relayToken" placeholder="设备配对 Token" autocomplete="off" />
</div>
<div class="form-row">
<label>设备 ID</label>
<input type="text" v-model="form.relayDeviceId" placeholder="device_id" />
</div>
<div class="modal-actions">
<button class="btn" @click="emit('close')">取消</button>
<button class="btn primary" @click="save">保存</button>
</div>
</div>
</div>
</template>
<style scoped>
.section-title {
margin: 18px 0 10px;
padding-top: 14px;
border-top: 1px solid var(--ui-border);
font-size: 13px;
font-weight: 600;
color: var(--ui-text);
}
</style>
@@ -0,0 +1,466 @@
<!-- =====================================================================
UnifiedSettingsModal.vue 统一设置弹窗
合并原 SettingsModalAI/中继 OssSettingsModal云存储tab 分区切换
===================================================================== -->
<script setup lang="ts">
import { ref, watch } from 'vue'
import type { AiCfg, OssCfg } from '../../core/types'
import { store } from '../../core/store'
import { syncState, syncPending, canUploadNow } from '../../core/assets'
export type SettingsTab = 'ai' | 'oss' | 'relay'
const props = defineProps<{ visible: boolean; initialTab?: SettingsTab }>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'toast', msg: string): void
}>()
const tab = ref<SettingsTab>('ai')
watch(() => props.visible, (v) => {
if (v) { tab.value = props.initialTab || 'ai'; loadFromStore() }
}, { immediate: true })
/* ==================== AI 模型 ==================== */
const PRESETS: Record<string, { protocol: 'openai' | 'anthropic'; base: string; model: string; label: string }> = {
zhipu: { protocol: 'openai', base: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-4.6', label: '智谱 GLM (OpenAI 协议)' },
zhipu_anth: { protocol: 'anthropic', base: 'https://open.bigmodel.cn/api/anthropic', model: 'glm-4.6', label: '智谱 GLM (Anthropic 协议)' },
deepseek: { protocol: 'openai', base: 'https://api.deepseek.com', model: 'deepseek-chat', label: 'DeepSeek' },
qwen: { protocol: 'openai', base: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-plus', label: '通义千问' },
kimi: { protocol: 'openai', base: 'https://api.moonshot.cn/v1', model: 'moonshot-v1-8k', label: 'Kimi' },
doubao: { protocol: 'openai', base: 'https://ark.cn-beijing.volces.com/api/v3', model: 'doubao-pro-32k', label: '豆包' },
openai: { protocol: 'openai', base: 'https://api.openai.com/v1', model: 'gpt-4o-mini', label: 'OpenAI' },
anthropic: { protocol: 'anthropic', base: 'https://api.anthropic.com', model: 'claude-sonnet-5', label: 'Anthropic' },
gemini: { protocol: 'openai', base: 'https://generativelanguage.googleapis.com/v1beta/openai', model: 'gemini-2.0-flash', label: 'Gemini' },
groq: { protocol: 'openai', base: 'https://api.groq.com/openai/v1', model: 'llama-3.3-70b-versatile', label: 'Groq' },
ollama: { protocol: 'openai', base: 'http://localhost:11434/v1', model: 'llama3.1', label: 'Ollama (本地)' }
}
const PROTO_DEFAULTS: Record<string, { base: string; model: string }> = {
openai: { base: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-4.6' },
anthropic: { base: 'https://open.bigmodel.cn/api/anthropic', model: 'glm-4.6' }
}
const aiForm = ref<AiCfg>({
preset: 'zhipu', protocol: 'openai',
base: '', key: '', model: '', proxy: '',
imgBase: '', imgKey: '', imgModel: '',
relayUrl: '', relayToken: '', relayDeviceId: ''
})
/* ==================== 云存储(OSS ==================== */
const ossForm = ref<OssCfg>({
enabled: false, provider: 'aliyun', dir: 'u-ppt',
aliAccessKeyId: '', aliAccessKeySecret: '', aliEndpoint: '', aliBucket: '',
qiniuAccessKey: '', qiniuSecretKey: '', qiniuBucket: '', qiniuUpHost: '', qiniuDomain: '',
viewerBase: ''
})
/** 从 store 读取并填充两份表单 */
function loadFromStore() {
const c = store.getCfg()
aiForm.value = {
preset: PRESETS[c.preset] ? c.preset : 'custom',
protocol: c.protocol || 'openai',
base: c.base, key: c.key, model: c.model, proxy: c.proxy,
imgBase: c.imgBase || '',
imgKey: c.imgKey || '',
imgModel: c.imgModel || '',
relayUrl: c.relayUrl || '',
relayToken: c.relayToken || '',
relayDeviceId: c.relayDeviceId || ''
}
ossForm.value = { ...ossForm.value, ...store.getOssCfg() }
}
function applyPreset(key: string) {
const p = PRESETS[key]
if (!p) return
aiForm.value.protocol = p.protocol
aiForm.value.base = p.base
aiForm.value.model = p.model
}
function onProviderChange() {
if (aiForm.value.preset !== 'custom') applyPreset(aiForm.value.preset)
}
function applyProtoDefaults(p: string) {
const d = PROTO_DEFAULTS[p] || PROTO_DEFAULTS.openai
const other = PROTO_DEFAULTS[p === 'openai' ? 'anthropic' : 'openai']
if (!aiForm.value.base.trim() || aiForm.value.base.trim() === other.base) aiForm.value.base = d.base
if (!aiForm.value.model.trim() || aiForm.value.model.trim() === other.model) aiForm.value.model = d.model
}
function onProtocolChange() {
applyProtoDefaults(aiForm.value.protocol)
}
/* ==================== 保存 ==================== */
function save() {
if (tab.value === 'ai') {
const f = aiForm.value
store.setCfg({
preset: f.preset,
protocol: f.protocol,
base: f.base.trim(),
key: f.key.trim(),
model: f.model.trim(),
proxy: f.proxy.trim(),
imgBase: (f.imgBase || '').trim(),
imgKey: (f.imgKey || '').trim(),
imgModel: (f.imgModel || '').trim(),
relayUrl: (f.relayUrl || '').trim(),
relayToken: (f.relayToken || '').trim(),
relayDeviceId: (f.relayDeviceId || '').trim()
})
const label = f.preset === 'custom'
? (f.protocol === 'anthropic' ? 'Anthropic' : 'OpenAI') + ' 自定义'
: (PRESETS[f.preset]?.label || f.preset)
emit('toast', '已保存 AI 设置(' + label + '')
} else if (tab.value === 'oss') {
saveOss()
} else {
// 中继 tab:只更新中继三项,AI 其余字段原样回写
const f = aiForm.value
const c = store.getCfg()
store.setCfg({
...c,
preset: f.preset,
protocol: f.protocol,
base: f.base.trim(),
key: f.key.trim(),
model: f.model.trim(),
proxy: f.proxy.trim(),
imgBase: (f.imgBase || '').trim(),
imgKey: (f.imgKey || '').trim(),
imgModel: (f.imgModel || '').trim(),
relayUrl: (f.relayUrl || '').trim(),
relayToken: (f.relayToken || '').trim(),
relayDeviceId: (f.relayDeviceId || '').trim()
})
emit('toast', '已保存 Agent 中继设置')
}
emit('close')
}
function saveOss() {
const c = ossForm.value
store.setOssCfg({
enabled: c.enabled,
provider: c.provider,
dir: c.dir.trim(),
aliAccessKeyId: c.aliAccessKeyId.trim(),
aliAccessKeySecret: c.aliAccessKeySecret.trim(),
aliEndpoint: c.aliEndpoint.trim(),
aliBucket: c.aliBucket.trim(),
qiniuAccessKey: c.qiniuAccessKey.trim(),
qiniuSecretKey: c.qiniuSecretKey.trim(),
qiniuBucket: c.qiniuBucket.trim(),
qiniuUpHost: c.qiniuUpHost.trim(),
qiniuDomain: c.qiniuDomain.trim(),
viewerBase: c.viewerBase.trim()
})
emit('toast', c.enabled ? '已保存云存储设置(已启用)' : '已保存云存储设置(未启用)')
}
async function manualSync() {
if (!canUploadNow()) {
emit('toast', '当前不可同步:需桌面版 + 已联网 + 已启用并保存配置')
return
}
const r = await syncPending()
emit('toast', `同步完成:成功 ${r.done},失败 ${r.fail}`)
}
</script>
<template>
<div class="modal-mask" :class="{ hidden: !visible }" @click.self="emit('close')">
<div class="modal unified-modal">
<h3>设置</h3>
<!-- tab -->
<div class="tab-bar">
<button class="tab-btn" :class="{ active: tab === 'ai' }" @click="tab = 'ai'">AI 模型</button>
<button class="tab-btn" :class="{ active: tab === 'oss' }" @click="tab = 'oss'">云存储</button>
<button class="tab-btn" :class="{ active: tab === 'relay' }" @click="tab = 'relay'">Agent 中继</button>
</div>
<!-- 内容区统一 min-height避免切 tab 时弹窗高度跳动 -->
<div class="settings-body">
<!-- ==================== AI 模型 ==================== -->
<template v-if="tab === 'ai'">
<p class="modal-tip">配置大模型服务商数据仅保存在本地浏览器</p>
<div class="form-row">
<label>服务商</label>
<select v-model="aiForm.preset" @change="onProviderChange">
<optgroup label="国内">
<option value="zhipu">智谱 GLM (OpenAI 协议)</option>
<option value="zhipu_anth">智谱 GLM (Anthropic 协议)</option>
<option value="deepseek">DeepSeek</option>
<option value="qwen">通义千问</option>
<option value="kimi">Kimi</option>
<option value="doubao">豆包</option>
</optgroup>
<optgroup label="海外">
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="gemini">Gemini</option>
<option value="groq">Groq</option>
</optgroup>
<optgroup label="本地">
<option value="ollama">Ollama</option>
</optgroup>
<option value="custom">自定义</option>
</select>
</div>
<div class="form-row">
<label>API 协议</label>
<select v-model="aiForm.protocol" @change="onProtocolChange">
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
</select>
</div>
<div class="form-row">
<label>Base URL</label>
<input type="text" v-model="aiForm.base" placeholder="https://..." />
</div>
<div class="form-row">
<label>API Key</label>
<input type="password" v-model="aiForm.key" placeholder="sk-..." autocomplete="off" />
</div>
<div class="form-row">
<label>模型</label>
<input type="text" v-model="aiForm.model" placeholder="模型名称" />
</div>
<div class="form-row">
<label>代理 URL可选</label>
<input type="text" v-model="aiForm.proxy" placeholder="留空则直连" />
</div>
<div class="section-title">图像模型可选用于 AI 配图</div>
<div class="form-row">
<label>图像 Base URL</label>
<input type="text" v-model="aiForm.imgBase" placeholder="留空则复用上方 Base URL" />
</div>
<div class="form-row">
<label>图像 API Key</label>
<input type="password" v-model="aiForm.imgKey" placeholder="留空则复用上方 Key" autocomplete="off" />
</div>
<div class="form-row">
<label>图像模型</label>
<input type="text" v-model="aiForm.imgModel" placeholder="dall-e-3" />
</div>
</template>
<!-- ==================== 云存储 ==================== -->
<template v-else-if="tab === 'oss'">
<p class="modal-tip">
开启后图片/视频等媒体自动上传对象存储避免撑爆本地离线时先暂存本地联网后自动同步云端密钥仅保存在本地
</p>
<div class="form-row">
<label>启用云存储</label>
<label class="switch-line">
<input type="checkbox" v-model="ossForm.enabled" />
<span>{{ ossForm.enabled ? '开' : '关(维持本地内嵌)' }}</span>
</label>
</div>
<div class="form-row">
<label>服务商</label>
<select v-model="ossForm.provider">
<option value="aliyun">阿里云 OSS</option>
<option value="qiniu">七牛云 Kodo</option>
</select>
</div>
<div class="form-row">
<label>所属目录</label>
<input type="text" v-model="ossForm.dir" placeholder="如 u-ppt/images,可留空" />
</div>
<div class="form-row">
<label>查看器地址</label>
<input type="text" v-model="ossForm.viewerBase" placeholder="如 https://img.1216.top,生成分享链接必填" />
</div>
<!-- 阿里云 -->
<template v-if="ossForm.provider === 'aliyun'">
<div class="section-title">阿里云 OSS</div>
<div class="form-row">
<label>AccessKeyId</label>
<input type="text" v-model="ossForm.aliAccessKeyId" placeholder="LTAI..." autocomplete="off" />
</div>
<div class="form-row">
<label>AccessKeySecret</label>
<input type="password" v-model="ossForm.aliAccessKeySecret" placeholder="密钥" autocomplete="off" />
</div>
<div class="form-row">
<label>Endpoint</label>
<input type="text" v-model="ossForm.aliEndpoint" placeholder="oss-cn-hangzhou.aliyuncs.com" />
</div>
<div class="form-row">
<label>Bucket</label>
<input type="text" v-model="ossForm.aliBucket" placeholder="bucket 名称" />
</div>
</template>
<!-- 七牛云 -->
<template v-else>
<div class="section-title">七牛云 Kodo</div>
<div class="form-row">
<label>AccessKey</label>
<input type="text" v-model="ossForm.qiniuAccessKey" placeholder="AK" autocomplete="off" />
</div>
<div class="form-row">
<label>SecretKey</label>
<input type="password" v-model="ossForm.qiniuSecretKey" placeholder="SK" autocomplete="off" />
</div>
<div class="form-row">
<label>Bucket</label>
<input type="text" v-model="ossForm.qiniuBucket" placeholder="空间名称" />
</div>
<div class="form-row">
<label>加速域名</label>
<input type="text" v-model="ossForm.qiniuDomain" placeholder="https://cdn.example.com" />
</div>
<div class="form-row">
<label>上传域名可选</label>
<input type="text" v-model="ossForm.qiniuUpHost" placeholder="留空自动探测区域" />
</div>
</template>
<div class="sync-bar">
<div class="sync-info">
<div>
待同步 {{ syncState.pending }} <template v-if="syncState.syncing">同步中</template>
<span v-if="syncState.lastError" class="sync-err">· 有错误</span>
</div>
<div v-if="syncState.lastError" class="sync-err-detail">{{ syncState.lastError }}</div>
</div>
<button class="btn" @click="manualSync" :disabled="syncState.syncing">立即同步</button>
</div>
</template>
<!-- ==================== Agent 中继 ==================== -->
<template v-else>
<p class="modal-tip">通过 u-relay 中继接收远端 Agent 指令可选功能三项配置齐备即自动启用</p>
<div class="form-row">
<label>中继 URL</label>
<input type="text" v-model="aiForm.relayUrl" placeholder="wss://u-work.1216.top(可省略 /ws/miniapp" />
</div>
<div class="form-row">
<label>中继 Token</label>
<input type="password" v-model="aiForm.relayToken" placeholder="设备配对 Token" autocomplete="off" />
</div>
<div class="form-row">
<label>设备 ID</label>
<input type="text" v-model="aiForm.relayDeviceId" placeholder="device_id" />
</div>
</template>
</div>
<div class="modal-actions">
<button class="btn" @click="emit('close')">取消</button>
<button class="btn primary" @click="save">保存</button>
</div>
</div>
</div>
</template>
<style scoped>
/* 弹窗加宽(仅本组件) */
.unified-modal { width: 520px; display: flex; flex-direction: column; }
/* 内容区统一最小高度:以最高 tab(云存储)为基准,切 tab 不再跳动 */
.settings-body { min-height: 440px; }
/* tab 栏:下划线高亮 */
.tab-bar {
display: flex;
gap: 18px;
margin-bottom: 16px;
border-bottom: 1px solid var(--ui-border);
}
.tab-btn {
border: none;
background: transparent;
padding: 8px 2px;
font-size: 13.5px;
color: var(--ui-muted);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.tab-btn.active { color: var(--ui-primary, #5b5bd6); border-bottom-color: var(--ui-primary, #5b5bd6); font-weight: 600; }
/* 紧凑化:label 与输入同行 */
.form-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.form-row > label {
display: block;
width: 110px;
flex-shrink: 0;
text-align: right;
white-space: nowrap;
margin-bottom: 0;
}
.form-row > select,
.form-row > input { flex: 1; min-width: 0; }
.section-title {
margin: 18px 0 10px;
padding-top: 14px;
border-top: 1px solid var(--ui-border);
font-size: 13px;
font-weight: 600;
color: var(--ui-text);
}
.section-title.first { margin-top: 0; padding-top: 0; border-top: none; }
.switch-line {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--ui-muted);
flex: 1;
}
.switch-line input { width: auto; }
.sync-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid var(--ui-border);
}
.sync-info { font-size: 12px; color: var(--ui-muted); }
.sync-err { color: #e5484d; }
.sync-err-detail {
margin-top: 4px;
max-width: 420px;
word-break: break-all;
font-size: 12px;
line-height: 1.5;
color: #e5484d;
}
</style>
+52 -15
View File
@@ -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<Outline> {
const count = opts.count || 7
export async function outline(opts: { topic: string; count?: number | 'auto'; signal?: AbortSignal }): Promise<Outline> {
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 }
}
+12 -1
View File
@@ -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
+3 -1
View File
@@ -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
}
/** 聊天消息 */