新增: Agent 大纲协议(outline/gen_page 动作)与设置弹窗统一(UnifiedSettingsModal 归并 Settings/OSS/Agent 三弹窗)
This commit is contained in:
+24
-33
@@ -11,6 +11,7 @@ import { publishDeck } from './core/share'
|
|||||||
import { appAlert } from './core/dialog'
|
import { appAlert } from './core/dialog'
|
||||||
import { isTauri, readLocalFiles } from './core/bridge'
|
import { isTauri, readLocalFiles } from './core/bridge'
|
||||||
import AppDialog from './components/common/AppDialog.vue'
|
import AppDialog from './components/common/AppDialog.vue'
|
||||||
|
import Icon from './components/common/Icon.vue'
|
||||||
import FileDock from './components/common/FileDock.vue'
|
import FileDock from './components/common/FileDock.vue'
|
||||||
import FilePreviewDrawer from './components/common/FilePreviewDrawer.vue'
|
import FilePreviewDrawer from './components/common/FilePreviewDrawer.vue'
|
||||||
import { addFiles } from './core/attachments'
|
import { addFiles } from './core/attachments'
|
||||||
@@ -19,9 +20,7 @@ import ThumbBar from './components/editor/ThumbBar.vue'
|
|||||||
import Canvas from './components/editor/Canvas.vue'
|
import Canvas from './components/editor/Canvas.vue'
|
||||||
import PropsPanel from './components/editor/PropsPanel.vue'
|
import PropsPanel from './components/editor/PropsPanel.vue'
|
||||||
import AiPanel from './components/ai/AiPanel.vue'
|
import AiPanel from './components/ai/AiPanel.vue'
|
||||||
import AgentPanel from './components/ai/AgentPanel.vue'
|
import UnifiedSettingsModal, { type SettingsTab } from './components/modals/UnifiedSettingsModal.vue'
|
||||||
import SettingsModal from './components/modals/SettingsModal.vue'
|
|
||||||
import OssSettingsModal from './components/modals/OssSettingsModal.vue'
|
|
||||||
import LibraryModal from './components/modals/LibraryModal.vue'
|
import LibraryModal from './components/modals/LibraryModal.vue'
|
||||||
import TemplateModal from './components/modals/TemplateModal.vue'
|
import TemplateModal from './components/modals/TemplateModal.vue'
|
||||||
import ImportModal from './components/modals/ImportModal.vue'
|
import ImportModal from './components/modals/ImportModal.vue'
|
||||||
@@ -35,14 +34,18 @@ const presentVisible = ref(false)
|
|||||||
const presentStartIndex = ref(0)
|
const presentStartIndex = ref(0)
|
||||||
|
|
||||||
/* ---------- 活动面板 tab ---------- */
|
/* ---------- 活动面板 tab ---------- */
|
||||||
const activeTab = ref<'props' | 'ai' | 'agent'>('props')
|
const activeTab = ref<'props' | 'ai'>('props')
|
||||||
function switchTab(name: 'props' | 'ai' | 'agent') {
|
function switchTab(name: 'props' | 'ai') {
|
||||||
activeTab.value = name
|
activeTab.value = name
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 弹窗 ---------- */
|
/* ---------- 弹窗 ---------- */
|
||||||
const settingsVisible = ref(false)
|
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 libraryVisible = ref(false)
|
||||||
const templateVisible = ref(false)
|
const templateVisible = ref(false)
|
||||||
const importVisible = ref(false)
|
const importVisible = ref(false)
|
||||||
@@ -181,7 +184,7 @@ async function onShare() {
|
|||||||
// 未配置云存储时引导到设置页
|
// 未配置云存储时引导到设置页
|
||||||
if (msg.includes('云存储') || msg.includes('OSS')) {
|
if (msg.includes('云存储') || msg.includes('OSS')) {
|
||||||
await appAlert('无法分享', msg)
|
await appAlert('无法分享', msg)
|
||||||
ossVisible.value = true
|
openSettings('oss')
|
||||||
} else {
|
} else {
|
||||||
await appAlert('分享失败', msg)
|
await appAlert('分享失败', msg)
|
||||||
}
|
}
|
||||||
@@ -225,7 +228,7 @@ function onKey(e: KeyboardEvent) {
|
|||||||
if (inField) return; e.preventDefault(); if (store.redo()) toast('已重做'); return
|
if (inField) return; e.preventDefault(); if (store.redo()) toast('已重做'); return
|
||||||
}
|
}
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
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()
|
e.preventDefault()
|
||||||
globalDragOver.value = false
|
globalDragOver.value = false
|
||||||
|
|
||||||
// 单个 .json 文件 → 直接导入 deck(与工具栏 📥 导入同路径)
|
// 单个 .json 文件 → 直接导入 deck(与工具栏导入同路径)
|
||||||
if (fl.length === 1 && /\.json$/i.test(fl[0].name)) {
|
if (fl.length === 1 && /\.json$/i.test(fl[0].name)) {
|
||||||
await importJsonFile(fl[0])
|
await importJsonFile(fl[0])
|
||||||
return
|
return
|
||||||
@@ -473,8 +476,7 @@ onUnmounted(() => {
|
|||||||
@open-deck="deckLoaded = true; mode = 'editor'"
|
@open-deck="deckLoaded = true; mode = 'editor'"
|
||||||
@ai-create="onNewBlank(); switchTab('ai')"
|
@ai-create="onNewBlank(); switchTab('ai')"
|
||||||
@toast="toast"
|
@toast="toast"
|
||||||
@open-settings="settingsVisible = true"
|
@open-settings="openSettings('ai')"
|
||||||
@open-oss="ossVisible = true"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- ===================== 编辑模式 ===================== -->
|
<!-- ===================== 编辑模式 ===================== -->
|
||||||
@@ -483,8 +485,7 @@ onUnmounted(() => {
|
|||||||
:disabled-actions="disabledActions"
|
:disabled-actions="disabledActions"
|
||||||
@present="onPresent"
|
@present="onPresent"
|
||||||
@open-library="libraryVisible = true"
|
@open-library="libraryVisible = true"
|
||||||
@open-settings="settingsVisible = true"
|
@open-settings="openSettings('ai')"
|
||||||
@open-oss="ossVisible = true"
|
|
||||||
@save="onSave"
|
@save="onSave"
|
||||||
@open-templates="templateVisible = true"
|
@open-templates="templateVisible = true"
|
||||||
@export-json="onExportJson"
|
@export-json="onExportJson"
|
||||||
@@ -505,9 +506,8 @@ onUnmounted(() => {
|
|||||||
<!-- 右:Tabs(属性 / AI) -->
|
<!-- 右:Tabs(属性 / AI) -->
|
||||||
<aside class="side-panel">
|
<aside class="side-panel">
|
||||||
<div class="panel-tabs">
|
<div class="panel-tabs">
|
||||||
<button class="panel-tab" :class="{ active: activeTab === 'props' }" @click="switchTab('props')">🎨 属性</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')">🤖 AI 助手</button>
|
<button class="panel-tab" :class="{ active: activeTab === 'ai' }" @click="switchTab('ai')"><Icon name="sparkles" :size="13" /> AI 助手</button>
|
||||||
<button class="panel-tab" :class="{ active: activeTab === 'agent' }" @click="switchTab('agent')">📡 Agent</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PropsPanel v-show="activeTab === 'props'" />
|
<PropsPanel v-show="activeTab === 'props'" />
|
||||||
@@ -516,14 +516,9 @@ onUnmounted(() => {
|
|||||||
v-show="activeTab === 'ai'"
|
v-show="activeTab === 'ai'"
|
||||||
@busy-change="onBusyChange"
|
@busy-change="onBusyChange"
|
||||||
@toast="toast"
|
@toast="toast"
|
||||||
@open-settings="settingsVisible = true"
|
@open-settings="openSettings('ai')"
|
||||||
@switch-tab="(t: string) => switchTab(t as 'props' | 'ai' | 'agent')"
|
@open-relay-settings="openSettings('relay')"
|
||||||
/>
|
@switch-tab="(t: string) => switchTab(t as 'props' | 'ai')"
|
||||||
|
|
||||||
<AgentPanel
|
|
||||||
v-show="activeTab === 'agent'"
|
|
||||||
@toast="toast"
|
|
||||||
@open-settings="settingsVisible = true"
|
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
@@ -537,21 +532,17 @@ onUnmounted(() => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- ===================== 弹窗 ===================== -->
|
<!-- ===================== 弹窗 ===================== -->
|
||||||
<SettingsModal
|
<UnifiedSettingsModal
|
||||||
:visible="settingsVisible"
|
:visible="settingsVisible"
|
||||||
|
:initial-tab="settingsTab"
|
||||||
@close="settingsVisible = false"
|
@close="settingsVisible = false"
|
||||||
@toast="toast"
|
@toast="toast"
|
||||||
/>
|
/>
|
||||||
<OssSettingsModal
|
|
||||||
:visible="ossVisible"
|
|
||||||
@close="ossVisible = false"
|
|
||||||
@toast="toast"
|
|
||||||
/>
|
|
||||||
<LibraryModal
|
<LibraryModal
|
||||||
:visible="libraryVisible"
|
:visible="libraryVisible"
|
||||||
@close="libraryVisible = false"
|
@close="libraryVisible = false"
|
||||||
@toast="toast"
|
@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'"
|
@open-deck="mode = 'editor'"
|
||||||
/>
|
/>
|
||||||
<TemplateModal
|
<TemplateModal
|
||||||
@@ -564,7 +555,7 @@ onUnmounted(() => {
|
|||||||
:visible="importVisible"
|
:visible="importVisible"
|
||||||
@close="onImportClose"
|
@close="onImportClose"
|
||||||
@toast="toast"
|
@toast="toast"
|
||||||
@open-settings="settingsVisible = true"
|
@open-settings="openSettings('ai')"
|
||||||
/>
|
/>
|
||||||
<PrintModal
|
<PrintModal
|
||||||
v-if="printVisible"
|
v-if="printVisible"
|
||||||
@@ -577,7 +568,7 @@ onUnmounted(() => {
|
|||||||
<!-- 全局拖放提示遮罩 -->
|
<!-- 全局拖放提示遮罩 -->
|
||||||
<div v-if="globalDragOver" class="global-drop-overlay">
|
<div v-if="globalDragOver" class="global-drop-overlay">
|
||||||
<div class="global-drop-card">
|
<div class="global-drop-card">
|
||||||
<span class="global-drop-icon">📥</span>
|
<span class="global-drop-icon"><Icon name="download" :size="44" /></span>
|
||||||
<strong>松开导入文件</strong>
|
<strong>松开导入文件</strong>
|
||||||
<span class="global-drop-hint">.json 直接导入 · 图片/文档进入资料导入</span>
|
<span class="global-drop-hint">.json 直接导入 · 图片/文档进入资料导入</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
+360
-39
@@ -1,30 +1,35 @@
|
|||||||
<!-- =====================================================================
|
<!-- =====================================================================
|
||||||
AiPanel.vue — AI 聊天面板
|
AiPanel.vue — AI 聊天面板(双通道:直连 LLM / u-relay Agent)
|
||||||
发送/停止/生成整套/润色本页/流式渲染/操作应用
|
通道切换/发送分流/流式渲染/操作应用;direct 走 chatLog 持久化,
|
||||||
|
agent 走内存 msgs(不持久化),op 应用统一走 applyOp
|
||||||
===================================================================== -->
|
===================================================================== -->
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, nextTick, computed, watch, onUnmounted } from 'vue'
|
import { ref, nextTick, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||||
import type { ChatMessage, AiOp, Slide } from '../../core/types'
|
import type { ChatMessage, AiOp, Outline, Slide } from '../../core/types'
|
||||||
import { store } from '../../core/store'
|
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 { elementTypes } from '../../core/sample'
|
||||||
import { renderMd } from '../../core/markdown'
|
import { renderMd } from '../../core/markdown'
|
||||||
import { appPrompt, appConfirm } from '../../core/dialog'
|
import { appPrompt, appConfirm } from '../../core/dialog'
|
||||||
import OutlinePanel from './OutlinePanel.vue'
|
import OutlinePanel from './OutlinePanel.vue'
|
||||||
|
import Icon from '../common/Icon.vue'
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'busy-change', busy: boolean): void
|
(e: 'busy-change', busy: boolean): void
|
||||||
(e: 'toast', msg: string): void
|
(e: 'toast', msg: string): void
|
||||||
(e: 'open-settings'): void
|
(e: 'open-settings'): void
|
||||||
|
(e: 'open-relay-settings'): void
|
||||||
(e: 'switch-tab', tab: string): void
|
(e: 'switch-tab', tab: string): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const CHAT_KEY = 'u-ppt.chat.v1' // 旧 key,仅用于迁移
|
const CHAT_KEY = 'u-ppt.chat.v1' // 旧 key,仅用于迁移
|
||||||
|
const AGENT_CHAT_PREFIX = 'u-ppt.agentchat.v1.' // agent 会话持久化前缀(按 chatId 隔离)
|
||||||
|
|
||||||
/** 当前会话绑定的 chatId(跟随 deck.chatId) */
|
/** 当前会话绑定的 chatId(跟随 deck.chatId) */
|
||||||
const currentChatId = computed(() => store.getChatId())
|
const currentChatId = computed(() => store.getChatId())
|
||||||
|
|
||||||
/** 渲染用消息条目(带可选的流式/标签/error 状态) */
|
/** 渲染用消息条目(带可选的流式/标签/error 状态;direct/agent 通道共用结构) */
|
||||||
interface RenderMsg {
|
interface RenderMsg {
|
||||||
key: number
|
key: number
|
||||||
role: 'user' | 'assistant' | 'system'
|
role: 'user' | 'assistant' | 'system'
|
||||||
@@ -34,8 +39,29 @@ interface RenderMsg {
|
|||||||
error?: boolean
|
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[]>([])
|
const renderMsgs = ref<RenderMsg[]>([])
|
||||||
|
/** agent 通道消息(按 chatId 持久化到 localStorage,tag/error/streaming 状态不存) */
|
||||||
|
const agentMsgs = ref<RenderMsg[]>([])
|
||||||
|
/** 当前通道渲染的消息源 */
|
||||||
|
const viewMsgs = computed(() => (channel.value === 'direct' ? renderMsgs.value : agentMsgs.value))
|
||||||
|
|
||||||
const chatLog = ref<ChatMessage[]>(loadChat())
|
const chatLog = ref<ChatMessage[]>(loadChat())
|
||||||
|
/** agent 通道当前大纲(agent 返回 outline op 后填充,传给 OutlinePanel) */
|
||||||
|
const agentOutline = ref<Outline | null>(null)
|
||||||
const messagesEl = ref<HTMLElement | null>(null)
|
const messagesEl = ref<HTMLElement | null>(null)
|
||||||
const inputEl = ref<HTMLTextAreaElement | null>(null)
|
const inputEl = ref<HTMLTextAreaElement | null>(null)
|
||||||
|
|
||||||
@@ -69,6 +95,43 @@ function persistChat() {
|
|||||||
}, 400)
|
}, 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/content,quota 溢出静默丢弃旧条目) */
|
||||||
|
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) {
|
function setBusy(b: boolean) {
|
||||||
busy.value = b
|
busy.value = b
|
||||||
emit('busy-change', b)
|
emit('busy-change', b)
|
||||||
@@ -192,14 +255,14 @@ function streamSetText(s: StreamCtrl, txt: string) {
|
|||||||
s.flushPending = false
|
s.flushPending = false
|
||||||
}
|
}
|
||||||
function streamError(s: StreamCtrl, msg: string) {
|
function streamError(s: StreamCtrl, msg: string) {
|
||||||
streamSetText(s, '⚠ ' + msg)
|
streamSetText(s, msg)
|
||||||
s.msg.error = true
|
s.msg.error = true
|
||||||
}
|
}
|
||||||
function streamTag(s: StreamCtrl, txt: string) {
|
function streamTag(s: StreamCtrl, txt: string) {
|
||||||
if (txt) s.msg.tag = txt
|
if (txt) s.msg.tag = txt
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 应用 AI 返回的操作到 store */
|
/** 应用 AI 返回的操作到 store(direct/agent 通道统一入口;target 越界 clamp,跨页跳转) */
|
||||||
function applyOp(op: AiOp, lockedIdx: number): string {
|
function applyOp(op: AiOp, lockedIdx: number): string {
|
||||||
const slides = op.slides
|
const slides = op.slides
|
||||||
if (op.action === 'create_all' && slides.length) {
|
if (op.action === 'create_all' && slides.length) {
|
||||||
@@ -231,11 +294,125 @@ function persistStream(s: StreamCtrl) {
|
|||||||
persistChat()
|
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() {
|
function onSend() {
|
||||||
if (busy.value) return
|
if (busy.value) return
|
||||||
const text = inputText.value.trim()
|
const text = inputText.value.trim()
|
||||||
if (!text) return
|
if (!text) return
|
||||||
|
if (channel.value === 'agent') { inputText.value = ''; sendAgent(text); return }
|
||||||
if (!isConfigured()) { toast('请先配置 API Key'); emit('open-settings'); return }
|
if (!isConfigured()) { toast('请先配置 API Key'); emit('open-settings'); return }
|
||||||
inputText.value = ''
|
inputText.value = ''
|
||||||
runChat(text)
|
runChat(text)
|
||||||
@@ -287,6 +464,7 @@ async function runChat(input: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onStop() {
|
function onStop() {
|
||||||
|
if (channel.value === 'agent') { stopAgent(); return }
|
||||||
if (abortCtrl) abortCtrl.abort()
|
if (abortCtrl) abortCtrl.abort()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,6 +473,8 @@ async function onGenerate() {
|
|||||||
if (busy.value) return
|
if (busy.value) return
|
||||||
const topic = inputText.value.trim() || await appPrompt('生成整套', { message: '请输入演示主题', placeholder: '例如「远程办公的兴起与未来」' })
|
const topic = inputText.value.trim() || await appPrompt('生成整套', { message: '请输入演示主题', placeholder: '例如「远程办公的兴起与未来」' })
|
||||||
if (!topic) return
|
if (!topic) return
|
||||||
|
// agent 通道:转自然语言指令走中继
|
||||||
|
if (channel.value === 'agent') { inputText.value = ''; sendAgent('生成一套关于「' + topic + '」的完整演示,约 6 页'); return }
|
||||||
if (!isConfigured()) { toast('请先配置 API Key'); emit('open-settings'); return }
|
if (!isConfigured()) { toast('请先配置 API Key'); emit('open-settings'); return }
|
||||||
inputText.value = ''
|
inputText.value = ''
|
||||||
const stream = streamBubble('正在创作「' + topic + '」…')
|
const stream = streamBubble('正在创作「' + topic + '」…')
|
||||||
@@ -307,8 +487,8 @@ async function onGenerate() {
|
|||||||
store.replaceDeck({ theme: store.theme.value, slides: r.slides }, { newChat: true })
|
store.replaceDeck({ theme: store.theme.value, slides: r.slides }, { newChat: true })
|
||||||
// watch(currentChatId) 会自动清空 renderMsgs 并加载新会话(空的)
|
// watch(currentChatId) 会自动清空 renderMsgs 并加载新会话(空的)
|
||||||
// 在新会话里记录这次生成
|
// 在新会话里记录这次生成
|
||||||
addPersisted('user', '✨ 生成整套:' + topic)
|
addPersisted('user', '生成整套:' + topic)
|
||||||
addPersisted('assistant', '✅ 已生成 ' + r.slides.length + ' 页演示。可在画布查看与微调,Ctrl+Z 可撤销。')
|
addPersisted('assistant', '已生成 ' + r.slides.length + ' 页演示。可在画布查看与微调,Ctrl+Z 可撤销。')
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e?.name === 'AbortError') streamSetText(stream, '(已停止)')
|
if (e?.name === 'AbortError') streamSetText(stream, '(已停止)')
|
||||||
else streamError(stream, e?.message || String(e))
|
else streamError(stream, e?.message || String(e))
|
||||||
@@ -321,12 +501,14 @@ async function onGenerate() {
|
|||||||
/* ---------- 润色本页 ---------- */
|
/* ---------- 润色本页 ---------- */
|
||||||
async function onPolish() {
|
async function onPolish() {
|
||||||
if (busy.value) return
|
if (busy.value) return
|
||||||
if (!isConfigured()) { toast('请先配置 API Key'); emit('open-settings'); return }
|
|
||||||
const slide = store.currentSlide.value
|
const slide = store.currentSlide.value
|
||||||
if (!slide) return
|
if (!slide) return
|
||||||
const idx0 = store.getCurrentIndex()
|
const idx0 = store.getCurrentIndex()
|
||||||
const idx = idx0 + 1
|
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 + ' 页…')
|
const stream = streamBubble('正在润色第 ' + idx + ' 页…')
|
||||||
setBusy(true); abortCtrl = new AbortController()
|
setBusy(true); abortCtrl = new AbortController()
|
||||||
|
|
||||||
@@ -334,7 +516,7 @@ async function onPolish() {
|
|||||||
const r = await polish({ slide: slide as Slide, instruction: '让内容更有吸引力、表达更精炼,保持布局合理', signal: abortCtrl.signal })
|
const r = await polish({ slide: slide as Slide, instruction: '让内容更有吸引力、表达更精炼,保持布局合理', signal: abortCtrl.signal })
|
||||||
streamDone(stream)
|
streamDone(stream)
|
||||||
if (idx0 < store.getCount()) store.replaceSlide(idx0, r.slide)
|
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)
|
persistStream(stream)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e?.name === 'AbortError') streamSetText(stream, '(已停止)')
|
if (e?.name === 'AbortError') streamSetText(stream, '(已停止)')
|
||||||
@@ -347,6 +529,12 @@ async function onPolish() {
|
|||||||
|
|
||||||
/* ---------- 清空 ---------- */
|
/* ---------- 清空 ---------- */
|
||||||
async function onClear() {
|
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 (!chatLog.value.length) { toast('对话已是空的'); return }
|
||||||
if (!(await appConfirm('清空对话记录?', '将清空当前 PPT 的对话记录', { danger: true, okText: '清空' }))) return
|
if (!(await appConfirm('清空对话记录?', '将清空当前 PPT 的对话记录', { danger: true, okText: '清空' }))) return
|
||||||
chatLog.value = []
|
chatLog.value = []
|
||||||
@@ -354,19 +542,55 @@ async function onClear() {
|
|||||||
renderMsgs.value = []
|
renderMsgs.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 大纲面板 ---------- */
|
/* ---------- 大纲面板(双通道通用;agent 通道走 outline/gen_page op) ---------- */
|
||||||
|
const outlinePanelEl = ref<InstanceType<typeof OutlinePanel> | null>(null)
|
||||||
|
/** agent 通道大纲主题暂存(OutlinePanel 主题输入回传,用于填充 outline.topic) */
|
||||||
|
const agentOutlineTopic = ref('')
|
||||||
|
|
||||||
function onToggleOutline() {
|
function onToggleOutline() {
|
||||||
showOutline.value = !showOutline.value
|
showOutline.value = !showOutline.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** OutlinePanel(agent 通道)转发指令:把生成大纲/单页的自然语言指令发给中继 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() {
|
async function onBeautify() {
|
||||||
if (busy.value) return
|
if (busy.value) return
|
||||||
if (!isConfigured()) { toast('请先配置 API Key'); emit('open-settings'); return }
|
|
||||||
const deck = store.getDeck()
|
const deck = store.getDeck()
|
||||||
const slides = deck.slides
|
const slides = deck.slides
|
||||||
if (!slides || !slides.length) { toast('当前没有幻灯片可美化'); return }
|
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 + ' 页…')
|
const stream = streamBubble('正在美化第 1/' + slides.length + ' 页…')
|
||||||
setBusy(true); abortCtrl = new AbortController()
|
setBusy(true); abortCtrl = new AbortController()
|
||||||
|
|
||||||
@@ -381,7 +605,7 @@ async function onBeautify() {
|
|||||||
done++
|
done++
|
||||||
}
|
}
|
||||||
streamDone(stream)
|
streamDone(stream)
|
||||||
streamSetText(stream, '✅ 已美化 ' + done + ' 页')
|
streamSetText(stream, '已美化 ' + done + ' 页')
|
||||||
persistStream(stream)
|
persistStream(stream)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e?.name === 'AbortError') streamSetText(stream, '(已停止,已美化 ' + done + ' 页)')
|
if (e?.name === 'AbortError') streamSetText(stream, '(已停止,已美化 ' + done + ' 页)')
|
||||||
@@ -401,10 +625,11 @@ function isBusy() { return busy.value }
|
|||||||
function focus() { nextTick(() => inputEl.value?.focus()) }
|
function focus() { nextTick(() => inputEl.value?.focus()) }
|
||||||
defineExpose({ isBusy, focus })
|
defineExpose({ isBusy, focus })
|
||||||
|
|
||||||
/* ---------- 初始化:从持久化记录重建 ---------- */
|
/* ---------- 初始化:从持久化记录重建(direct + agent 双通道) ---------- */
|
||||||
rebuildFromChatLog()
|
rebuildFromChatLog()
|
||||||
|
agentMsgs.value = loadAgentChat()
|
||||||
|
|
||||||
/* 组件卸载:停掉所有在跑的打字机 rAF */
|
/* 组件卸载:停掉所有在跑的打字机 rAF;不断开 relay(切 Tab 不掉线) */
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
for (const s of activeStreams) {
|
for (const s of activeStreams) {
|
||||||
if (s.rafId != null) cancelAnimationFrame(s.rafId)
|
if (s.rafId != null) cancelAnimationFrame(s.rafId)
|
||||||
@@ -412,10 +637,12 @@ onUnmounted(() => {
|
|||||||
activeStreams.clear()
|
activeStreams.clear()
|
||||||
})
|
})
|
||||||
|
|
||||||
/* ---------- 会话切换:chatId 变化时重新加载对话 ---------- */
|
/* ---------- 会话切换:chatId 变化时重新加载对话(direct + agent 双通道) ---------- */
|
||||||
watch(currentChatId, () => {
|
watch(currentChatId, () => {
|
||||||
chatLog.value = loadChat()
|
chatLog.value = loadChat()
|
||||||
rebuildFromChatLog()
|
rebuildFromChatLog()
|
||||||
|
agentMsgs.value = loadAgentChat()
|
||||||
|
agentOutline.value = null
|
||||||
scrollBottom()
|
scrollBottom()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -423,16 +650,50 @@ watch(currentChatId, () => {
|
|||||||
<template>
|
<template>
|
||||||
<div class="panel-pane ai-pane">
|
<div class="panel-pane ai-pane">
|
||||||
<div class="ai-actions">
|
<div class="ai-actions">
|
||||||
<button class="ai-action" :disabled="busy" @click="onGenerate">✨ 生成整套</button>
|
<button class="ai-action" :disabled="busy" @click="onGenerate"><Icon name="sparkles" :size="14" /> 生成整套</button>
|
||||||
<button class="ai-action" :disabled="busy" @click="onPolish">🪄 润色本页</button>
|
<button class="ai-action" :disabled="busy" @click="onPolish"><Icon name="wand" :size="14" /> 润色本页</button>
|
||||||
<button class="ai-action" :disabled="busy" @click="onToggleOutline">📋 大纲</button>
|
<button class="ai-action" :disabled="busy" @click="onToggleOutline"><Icon name="clipboard" :size="14" /> 大纲</button>
|
||||||
<button class="ai-action" :disabled="busy" @click="onBeautify">🎨 美化</button>
|
<button class="ai-action" :disabled="busy" @click="onBeautify"><Icon name="palette" :size="14" /> 美化</button>
|
||||||
<button class="ai-action ghost" :disabled="busy" @click="onClear">🗑 清空</button>
|
<button class="ai-action ghost" :disabled="busy" @click="onClear"><Icon name="trash" :size="14" /> 清空</button>
|
||||||
|
|
||||||
|
<!-- 通道切换(segmented):agent 段带状态色点 -->
|
||||||
|
<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>
|
</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 对话将基于此选中元素">
|
<div v-if="selectedHint" class="selected-hint" title="AI 对话将基于此选中元素">
|
||||||
<span class="selected-hint-icon">④</span>
|
<span class="selected-hint-icon">④</span>
|
||||||
<span class="selected-hint-text">已选中:{{ selectedHint }}</span>
|
<span class="selected-hint-text">已选中:{{ selectedHint }}</span>
|
||||||
@@ -440,18 +701,32 @@ watch(currentChatId, () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="chat-messages" ref="messagesEl">
|
<div class="chat-messages" ref="messagesEl">
|
||||||
<div v-if="!renderMsgs.length" class="chat-empty">
|
<!-- agent 未配置空态 -->
|
||||||
告诉我你的主题,例如:<br />
|
<div v-if="channel === 'agent' && !agentConfigured" class="chat-empty">
|
||||||
「生成一份关于<b>远程办公趋势</b>的演示」
|
未配置 Agent 中继。<br />
|
||||||
|
三项配置(中继 URL / Token / 设备ID)齐备即启用<br />
|
||||||
|
<button class="btn primary go-settings" @click="emit('open-relay-settings')">去设置</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-for="m in renderMsgs" :key="m.key" class="msg" :class="m.role">
|
<template v-else>
|
||||||
<div class="bubble" :class="{ error: m.error }">
|
<div v-if="!viewMsgs.length" class="chat-empty">
|
||||||
<span v-if="m.tag" class="diff-tag">✓ {{ m.tag }}</span>
|
<template v-if="channel === 'agent'">
|
||||||
<!-- Markdown 整段渲染(含代码块/列表/加粗等,经 DOMPurify 消毒) -->
|
通过中继把指令发给远端 Agent,例如:<br />
|
||||||
<div v-if="m.content" class="md-body" v-html="renderMd(m.content)"></div>
|
「把当前页的标题<b>改得更有冲击力</b>」
|
||||||
<span v-if="m.streaming" class="cursor"></span>
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
告诉我你的主题,例如:<br />
|
||||||
|
「生成一份关于<b>远程办公趋势</b>的演示」
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<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"><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>
|
||||||
|
|
||||||
<div class="chat-input-bar">
|
<div class="chat-input-bar">
|
||||||
@@ -460,13 +735,59 @@ watch(currentChatId, () => {
|
|||||||
v-model="inputText"
|
v-model="inputText"
|
||||||
id="chatInput"
|
id="chatInput"
|
||||||
rows="3"
|
rows="3"
|
||||||
placeholder="输入指令,回车发送(Shift+Enter 换行)"
|
:placeholder="channel === 'agent' && !agentConfigured ? '请先配置中继' : '输入指令,回车发送(Shift+Enter 换行)'"
|
||||||
|
:disabled="channel === 'agent' && !agentConfigured"
|
||||||
@keydown="onInputKeydown"
|
@keydown="onInputKeydown"
|
||||||
></textarea>
|
></textarea>
|
||||||
<div class="btns">
|
<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>
|
<button v-else class="btn danger" @click="onStop">停止</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { ref, computed, watch, onMounted } from 'vue'
|
|||||||
import type { Outline, OutlineItem, Slide } from '../../core/types'
|
import type { Outline, OutlineItem, Slide } from '../../core/types'
|
||||||
import { store } from '../../core/store'
|
import { store } from '../../core/store'
|
||||||
import { outline as genOutline, generatePage, isConfigured } from '../../core/ai'
|
import { outline as genOutline, generatePage, isConfigured } from '../../core/ai'
|
||||||
|
import Icon from '../common/Icon.vue'
|
||||||
|
|
||||||
const props = defineProps<{ visible: boolean; initialOutline?: Outline | null; agentChannel?: boolean }>()
|
const props = defineProps<{ visible: boolean; initialOutline?: Outline | null; agentChannel?: boolean }>()
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -80,11 +81,11 @@ function acceptAgentSlide(slide: Slide) {
|
|||||||
|
|
||||||
/** kind → 徽标文本与颜色 */
|
/** kind → 徽标文本与颜色 */
|
||||||
const KIND_META: Record<OutlineItem['kind'], { label: string; color: string }> = {
|
const KIND_META: Record<OutlineItem['kind'], { label: string; color: string }> = {
|
||||||
cover: { label: '封面', color: '#4f46e5' },
|
cover: { label: '封面', color: '#5b5bd6' },
|
||||||
toc: { label: '目录', color: '#06b6d4' },
|
toc: { label: '目录', color: '#06b6d4' },
|
||||||
content: { label: '内容', color: '#64748b' },
|
content: { label: '内容', color: '#64748b' },
|
||||||
quote: { label: '金句', color: '#f59e0b' },
|
quote: { label: '金句', color: '#f59e0b' },
|
||||||
end: { label: '结尾', color: '#4f46e5' }
|
end: { label: '结尾', color: '#5b5bd6' }
|
||||||
}
|
}
|
||||||
|
|
||||||
function setBusy(b: boolean) {
|
function setBusy(b: boolean) {
|
||||||
@@ -242,7 +243,7 @@ defineExpose({ acceptAgentSlide, requestAgent: (s: string) => emit('request-agen
|
|||||||
<!-- 主题输入 + 生成大纲 -->
|
<!-- 主题输入 + 生成大纲 -->
|
||||||
<div class="outline-top">
|
<div class="outline-top">
|
||||||
<input type="text" v-model="topicInput" placeholder="输入主题,例如「人工智能的产业落地」" :disabled="busy" @keydown.enter="onGenOutline" />
|
<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>
|
<button v-if="busy" class="btn danger" @click="onStop">停止</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -272,7 +273,7 @@ defineExpose({ acceptAgentSlide, requestAgent: (s: string) => emit('request-agen
|
|||||||
<!-- 标题 -->
|
<!-- 标题 -->
|
||||||
<div class="outline-title">
|
<div class="outline-title">
|
||||||
<input v-if="editingTitle" type="text" v-model="titleInput" @blur="commitTitle" @keydown.enter="commitTitle" />
|
<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>
|
</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="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>
|
<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 />
|
<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" :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>
|
||||||
<div class="item-summary" v-if="!expanded.has(it.id) && it.points.length">{{ it.points[0] }}</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 class="item-points" v-show="expanded.has(it.id)">
|
||||||
<div v-for="(p, pi) in it.points" :key="pi" class="point-row">
|
<div v-for="(p, pi) in it.points" :key="pi" class="point-row">
|
||||||
<input type="text" v-model="it.points[pi]" :disabled="busy" placeholder="要点内容" />
|
<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>
|
</div>
|
||||||
<button class="btn small ghost add-point" :disabled="busy" @click="addPoint(it)">+ 添加要点</button>
|
<button class="btn small ghost add-point" :disabled="busy" @click="addPoint(it)">+ 添加要点</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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 — 统一设置弹窗
|
||||||
|
合并原 SettingsModal(AI/中继)与 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
@@ -566,13 +566,18 @@ export function parseChatReply(text: string): { reply: string; op: AiOp | null }
|
|||||||
* 组装 Agent 请求 prompt:用户指令 + chat 协议要求 + deck 上下文。
|
* 组装 Agent 请求 prompt:用户指令 + chat 协议要求 + deck 上下文。
|
||||||
* 大 deck(序列化 >700KB)降级为「当前页完整 JSON + 全deck大纲摘要」,防超 1MiB 帧上限。
|
* 大 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 =
|
const SYS_AGENT =
|
||||||
SYS_BASE +
|
SYS_BASE +
|
||||||
'\n任务:你是通过中继接入的远程 Agent。根据用户指令编辑当前演示。\n' +
|
'\n任务:你是通过中继接入的远程 Agent。根据用户指令编辑当前演示。\n' +
|
||||||
'回复格式:先用中文说明你将做什么,如需修改 PPT,在回复最后另起一行输出分隔标记 ' + SEP + ',紧随其后输出 JSON 操作。\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' +
|
'- 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 代码块。'
|
'没有改动时不要输出分隔标记。不要使用 markdown 代码块。'
|
||||||
const deck = store.getDeck()
|
const deck = store.getDeck()
|
||||||
let body: string
|
let body: string
|
||||||
@@ -583,7 +588,34 @@ export function buildAgentPrompt(input: string): string {
|
|||||||
} else {
|
} else {
|
||||||
body = '完整 deck JSON:\n' + full + '\n当前页码:第 ' + (store.getCurrentIndex() + 1) + ' 页'
|
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 {
|
function normalizeOp(json: any): AiOp | null {
|
||||||
@@ -591,6 +623,14 @@ function normalizeOp(json: any): AiOp | null {
|
|||||||
let action: AiOp['action'] = json.action || 'answer'
|
let action: AiOp['action'] = json.action || 'answer'
|
||||||
const slides = normSlides(json.slides)
|
const slides = normSlides(json.slides)
|
||||||
const target = json.target != null ? (Number(json.target) - 1) : null
|
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 === 'update_page' && slides.length) action = 'update_page'
|
||||||
if (action === 'add_page' && slides.length) action = 'add_page'
|
if (action === 'add_page' && slides.length) action = 'add_page'
|
||||||
if ((action === 'update_page' || action === 'add_page') && !slides.length) action = 'answer'
|
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 =
|
const SYS_OUTLINE =
|
||||||
'你是「u-ppt」的演示策划助手。用户给你一个主题,你先制定大纲,不要直接写完整幻灯片。\n\n' +
|
'你是「u-ppt」的演示策划助手。用户给你一个主题,你先制定大纲,不要直接写完整幻灯片。\n\n' +
|
||||||
'大纲要素:先拟定一个具体、有信息量的整份标题(不要「关于 X 的分享」这类空泛标题);\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' +
|
'kind 取值:cover(封面) / toc(目录) / content(内容) / quote(金句) / end(结尾)。\n' +
|
||||||
'封面与结尾各 1 页,目录可选。\n' +
|
'封面与结尾各 1 页,目录可选。\n' +
|
||||||
'hint 用来给后续生成幻灯片的 AI 补充指令,例如「数据页:用 stat+chart」「对比页:双 card 并置」「引言页:深色背景金句」。\n\n' +
|
'hint 用来给后续生成幻灯片的 AI 补充指令,例如「数据页:用 stat+chart」「对比页:双 card 并置」「引言页:深色背景金句」。\n\n' +
|
||||||
@@ -651,22 +692,18 @@ const SYS_BEAUTIFY =
|
|||||||
|
|
||||||
/* ---------- 1. 大纲生成 ---------- */
|
/* ---------- 1. 大纲生成 ---------- */
|
||||||
|
|
||||||
export async function outline(opts: { topic: string; count?: number; signal?: AbortSignal }): Promise<Outline> {
|
export async function outline(opts: { topic: string; count?: number | 'auto'; signal?: AbortSignal }): Promise<Outline> {
|
||||||
const count = opts.count || 7
|
const count = opts.count || 'auto'
|
||||||
|
const countHint = count === 'auto'
|
||||||
|
? '页数自动裁量(结合主题信息量在 5-12 页之间取舍)'
|
||||||
|
: '请拟定约 ' + count + ' 页'
|
||||||
const messages: Message[] = [
|
const messages: Message[] = [
|
||||||
{ role: 'system', content: SYS_OUTLINE },
|
{ 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 })
|
const r = await streamChat(messages, { jsonMode: true, signal: opts.signal })
|
||||||
if (!r.json) throw new Error('AI 未返回有效大纲,请重试。')
|
if (!r.json) throw new Error('AI 未返回有效大纲,请重试。')
|
||||||
const items: OutlineItem[] = (Array.isArray(r.json.items) ? r.json.items : []).map((it: any, i: number) => ({
|
const items = normOutlineItems(r.json.items)
|
||||||
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
|
|
||||||
}))
|
|
||||||
if (!items.length) throw new Error('大纲为空,请重试或换一个主题。')
|
if (!items.length) throw new Error('大纲为空,请重试或换一个主题。')
|
||||||
return { title: String(r.json.title || opts.topic).slice(0, 80), topic: opts.topic, items }
|
return { title: String(r.json.title || opts.topic).slice(0, 80), topic: opts.topic, items }
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-1
@@ -36,6 +36,17 @@ const MAX_FRAME = 1024 * 1024 // 服务端帧上限 1MiB
|
|||||||
const RECONNECT_BASE = 1_000 // 重连退避基数
|
const RECONNECT_BASE = 1_000 // 重连退避基数
|
||||||
const RECONNECT_MAX = 30_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 {
|
function genId(): string {
|
||||||
return 'req-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10)
|
return 'req-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10)
|
||||||
}
|
}
|
||||||
@@ -108,7 +119,7 @@ export class RelayClient {
|
|||||||
const c = store.getCfg()
|
const c = store.getCfg()
|
||||||
this.setStatus(this.attempts ? 'reconnecting' : 'connecting')
|
this.setStatus(this.attempts ? 'reconnecting' : 'connecting')
|
||||||
let ws: WebSocket
|
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.setStatus('error', 'URL 无效:' + (e?.message || e)); return
|
||||||
}
|
}
|
||||||
this.ws = ws
|
this.ws = ws
|
||||||
|
|||||||
+3
-1
@@ -233,10 +233,12 @@ export interface LibItem {
|
|||||||
|
|
||||||
/** AI 返回的操作 */
|
/** AI 返回的操作 */
|
||||||
export interface AiOp {
|
export interface AiOp {
|
||||||
action: 'create_all' | 'add_page' | 'update_page' | 'answer'
|
action: 'create_all' | 'add_page' | 'update_page' | 'answer' | 'outline' | 'gen_page'
|
||||||
slides: Slide[]
|
slides: Slide[]
|
||||||
target: number | null // 1-based 页码
|
target: number | null // 1-based 页码
|
||||||
note: string
|
note: string
|
||||||
|
/** action=outline 时携带的大纲 */
|
||||||
|
outline?: Outline
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 聊天消息 */
|
/** 聊天消息 */
|
||||||
|
|||||||
Reference in New Issue
Block a user