新增: Agent 中继模式——u-relay 客户端接入远端 agent 指令链

This commit is contained in:
lxy
2026-08-29 01:43:34 +08:00
parent dde53378c3
commit 99f38726a2
6 changed files with 517 additions and 7 deletions
+12 -4
View File
@@ -19,6 +19,7 @@ import ThumbBar from './components/editor/ThumbBar.vue'
import Canvas from './components/editor/Canvas.vue'
import PropsPanel from './components/editor/PropsPanel.vue'
import AiPanel from './components/ai/AiPanel.vue'
import AgentPanel from './components/ai/AgentPanel.vue'
import SettingsModal from './components/modals/SettingsModal.vue'
import OssSettingsModal from './components/modals/OssSettingsModal.vue'
import LibraryModal from './components/modals/LibraryModal.vue'
@@ -34,8 +35,8 @@ const presentVisible = ref(false)
const presentStartIndex = ref(0)
/* ---------- 活动面板 tab ---------- */
const activeTab = ref<'props' | 'ai'>('props')
function switchTab(name: 'props' | 'ai') {
const activeTab = ref<'props' | 'ai' | 'agent'>('props')
function switchTab(name: 'props' | 'ai' | 'agent') {
activeTab.value = name
}
@@ -506,6 +507,7 @@ onUnmounted(() => {
<div class="panel-tabs">
<button class="panel-tab" :class="{ active: activeTab === 'props' }" @click="switchTab('props')">🎨 属性</button>
<button class="panel-tab" :class="{ active: activeTab === 'ai' }" @click="switchTab('ai')">🤖 AI 助手</button>
<button class="panel-tab" :class="{ active: activeTab === 'agent' }" @click="switchTab('agent')">📡 Agent</button>
</div>
<PropsPanel v-show="activeTab === 'props'" />
@@ -515,7 +517,13 @@ onUnmounted(() => {
@busy-change="onBusyChange"
@toast="toast"
@open-settings="settingsVisible = true"
@switch-tab="(t: string) => switchTab(t as 'props' | 'ai')"
@switch-tab="(t: string) => switchTab(t as 'props' | 'ai' | 'agent')"
/>
<AgentPanel
v-show="activeTab === 'agent'"
@toast="toast"
@open-settings="settingsVisible = true"
/>
</aside>
</div>
@@ -543,7 +551,7 @@ onUnmounted(() => {
:visible="libraryVisible"
@close="libraryVisible = false"
@toast="toast"
@switch-tab="(t: string) => switchTab(t as 'props' | 'ai')"
@switch-tab="(t: string) => switchTab(t as 'props' | 'ai' | 'agent')"
@open-deck="mode = 'editor'"
/>
<TemplateModal
+223
View File
@@ -0,0 +1,223 @@
<!-- =====================================================================
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>
+27 -3
View File
@@ -34,7 +34,8 @@ const PROTO_DEFAULTS: Record<string, { base: string; model: string }> = {
const form = ref<AiCfg>({
preset: 'zhipu', protocol: 'openai',
base: '', key: '', model: '', proxy: '',
imgBase: '', imgKey: '', imgModel: ''
imgBase: '', imgKey: '', imgModel: '',
relayUrl: '', relayToken: '', relayDeviceId: ''
})
/** 从 store 读取并填充表单 */
@@ -46,7 +47,10 @@ function loadFromStore() {
base: c.base, key: c.key, model: c.model, proxy: c.proxy,
imgBase: c.imgBase || '',
imgKey: c.imgKey || '',
imgModel: c.imgModel || ''
imgModel: c.imgModel || '',
relayUrl: c.relayUrl || '',
relayToken: c.relayToken || '',
relayDeviceId: c.relayDeviceId || ''
}
}
@@ -87,7 +91,10 @@ function save() {
proxy: form.value.proxy.trim(),
imgBase: (form.value.imgBase || '').trim(),
imgKey: (form.value.imgKey || '').trim(),
imgModel: (form.value.imgModel || '').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') + ' 自定义'
@@ -172,6 +179,23 @@ function save() {
<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>
+39
View File
@@ -547,6 +547,45 @@ export async function chat(opts: {
return { reply: r.reply, op: normalizeOp(r.op) }
}
/** SEP 分隔标记(对话回复与 JSON 操作的分隔),导出给 relay 等 Transport 复用 */
export const CHAT_SEP = SEP
/**
* 解析 chat 协议回复文本:SEP 前为自然语言回复,SEP 后为 JSON 操作。
* 供 AiPanelSSE 路径)与 AgentPanel(relay 路径)共用,避免复制粘贴。
*/
export function parseChatReply(text: string): { reply: string; op: AiOp | null } {
const parts = text.split(SEP)
return {
reply: (parts[0] || '').trim(),
op: parts.length > 1 ? normalizeOp(tryParse(parts.slice(1).join(SEP))) : null
}
}
/**
* 组装 Agent 请求 prompt:用户指令 + chat 协议要求 + deck 上下文。
* 大 deck(序列化 >700KB)降级为「当前页完整 JSON + 全deck大纲摘要」,防超 1MiB 帧上限。
*/
export function buildAgentPrompt(input: string): string {
const SYS_AGENT =
SYS_BASE +
'\n任务:你是通过中继接入的远程 Agent。根据用户指令编辑当前演示。\n' +
'回复格式:先用中文说明你将做什么,如需修改 PPT,在回复最后另起一行输出分隔标记 ' + SEP + ',紧随其后输出 JSON 操作。\n' +
'JSON 操作格式:{"action":"add_page|update_page|create_all|answer","slides":[...],"target":页码(从1开始,可选)}\n' +
'- add_page:在 target 页后插入新页;- update_page:替换 target 页;- create_all:整体替换;- answer:仅回答不改稿。\n' +
'没有改动时不要输出分隔标记。不要使用 markdown 代码块。'
const deck = store.getDeck()
let body: string
const full = JSON.stringify(deck)
if (full.length > 700 * 1024) {
// 超大 deck 降级:大纲摘要 + 当前页完整 JSON
body = deckContext(store.getCurrentIndex(), null)
} else {
body = '完整 deck JSON\n' + full + '\n当前页码:第 ' + (store.getCurrentIndex() + 1) + ' 页'
}
return SYS_AGENT + '\n\n' + body + '\n\n用户指令:' + input
}
function normalizeOp(json: any): AiOp | null {
if (!json) return null
let action: AiOp['action'] = json.action || 'answer'
+212
View File
@@ -0,0 +1,212 @@
/* =====================================================================
* relay.ts — u-relay 中继客户端(Agent 模式)
* - WebSocket 连 u-relaywss://.../ws/miniapp),miniapp 身份握手
* - 心跳保活(30s ping / pong 看门狗)+ 指数退避重连
* - request_id 匹配请求/响应,不匹配的下行事件忽略
* - 消息协议(payload 内 JSON,与 u-claw 端约定):
* 上行 agent_request{request_id,prompt} / 下行 agent_progress|agent_result{text}
* ===================================================================== */
import { store } from './store'
/** SEP 分隔标记:agent 回复文本中此标记后的 JSON 是 deck 操作(与 ai.ts chat 协议一致) */
export const RELAY_SEP = '%%PPT_JSON%%'
export type RelayStatus = 'disabled' | 'connecting' | 'connected' | 'reconnecting' | 'error'
export interface RelayEvent {
/** 路由骨架字段(容忍未知字段,仅按需取用) */
kind?: string
from?: string
payload?: any
ts?: number
[k: string]: unknown
}
export interface RelayHandlers {
onStatus?: (s: RelayStatus, detail?: string) => void
/** 收到本 device 的下行事件(已通过 request_id 过滤匹配) */
onProgress?: (requestId: string, text: string) => void
onResult?: (requestId: string, text: string) => void
}
const HANDSHAKE_TIMEOUT = 10_000 // relay 要求连接后 10s 内完成握手
const PING_INTERVAL = 30_000 // 应用层心跳
const PONG_TIMEOUT = 15_000 // 超时视为半连接,主动断开触发重连
const MAX_FRAME = 1024 * 1024 // 服务端帧上限 1MiB
const RECONNECT_BASE = 1_000 // 重连退避基数
const RECONNECT_MAX = 30_000 // 重连退避上限
function genId(): string {
return 'req-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10)
}
export class RelayClient {
private ws: WebSocket | null = null
private cfgKey = '' // 连接时配置指纹,配置变更后重连生效
private status: RelayStatus = 'disabled'
private handlers: RelayHandlers = {}
private pingTimer: ReturnType<typeof setInterval> | null = null
private pongTimer: ReturnType<typeof setTimeout> | null = null
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
private handshakeTimer: ReturnType<typeof setTimeout> | null = null
private lastPong = 0
private attempts = 0
private manualClose = false
setHandlers(h: RelayHandlers) { this.handlers = h }
private setStatus(s: RelayStatus, detail?: string) {
this.status = s
this.handlers.onStatus?.(s, detail)
}
getStatus(): RelayStatus { return this.status }
/** 配置是否齐备(三项均有值才算启用) */
isConfigured(): boolean {
const c = store.getCfg()
return !!(c.relayUrl && c.relayToken && c.relayDeviceId)
}
/** 连接(已连接且配置未变则跳过) */
connect() {
if (!this.isConfigured()) { this.setStatus('disabled'); return }
const c = store.getCfg()
const key = c.relayUrl + '|' + c.relayToken + '|' + c.relayDeviceId
if (this.ws && this.cfgKey === key &&
(this.status === 'connected' || this.status === 'connecting' || this.status === 'reconnecting')) return
this.closeSocket()
this.cfgKey = key
this.manualClose = false
this.attempts = 0
this.open()
}
/** 主动断开(停止重连) */
disconnect() {
this.manualClose = true
this.closeSocket()
this.setStatus('disabled')
}
private closeSocket() {
this.stopTimers()
if (this.ws) {
try { this.ws.onclose = null; this.ws.close() } catch (e) { /* ignore */ }
this.ws = null
}
}
private stopTimers() {
if (this.pingTimer) { clearInterval(this.pingTimer); this.pingTimer = null }
if (this.pongTimer) { clearTimeout(this.pongTimer); this.pongTimer = null }
if (this.handshakeTimer) { clearTimeout(this.handshakeTimer); this.handshakeTimer = null }
if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null }
}
private open() {
const c = store.getCfg()
this.setStatus(this.attempts ? 'reconnecting' : 'connecting')
let ws: WebSocket
try { ws = new WebSocket(c.relayUrl!) } catch (e: any) {
this.setStatus('error', 'URL 无效:' + (e?.message || e)); return
}
this.ws = ws
// 握手超时看门狗:10s 内未收到 hello_ack 视为失败
this.handshakeTimer = setTimeout(() => {
if (this.status === 'connecting' || this.status === 'reconnecting') {
try { ws.close() } catch (e) { /* ignore */ }
}
}, HANDSHAKE_TIMEOUT)
ws.onopen = () => {
ws.send(JSON.stringify({ kind: 'miniapp', device_id: c.relayDeviceId, token: c.relayToken }))
}
ws.onmessage = (ev) => {
if (typeof ev.data !== 'string') return
let msg: RelayEvent
try { msg = JSON.parse(ev.data) } catch (e) { return }
const payload = msg.payload
// 控制帧:握手确认 / pong
if (msg.kind === 'control') {
const ck = payload && payload.control_kind
if (ck === 'hello_ack') {
if (this.handshakeTimer) { clearTimeout(this.handshakeTimer); this.handshakeTimer = null }
this.attempts = 0
this.lastPong = Date.now()
this.setStatus('connected')
this.startPing()
} else if (ck === 'pong') {
this.lastPong = Date.now()
if (this.pongTimer) { clearTimeout(this.pongTimer); this.pongTimer = null }
}
return
}
// 业务事件(来自同 device_id 的 device 端):按 payload 内协议分发
if (!payload || typeof payload !== 'object') return
const rid = typeof payload.request_id === 'string' ? payload.request_id : ''
if (!rid || !this.pending.has(rid)) return // 不匹配自己请求的一律忽略
if (payload.type === 'agent_progress') this.handlers.onProgress?.(rid, String(payload.text || ''))
else if (payload.type === 'agent_result') this.handlers.onResult?.(rid, String(payload.text || ''))
}
ws.onclose = () => {
if (this.manualClose) return
this.stopTimers()
// 指数退避重连
const delay = Math.min(RECONNECT_BASE * Math.pow(2, this.attempts), RECONNECT_MAX)
this.attempts++
this.setStatus('reconnecting', delay + 'ms 后重连')
this.reconnectTimer = setTimeout(() => this.open(), delay)
}
ws.onerror = () => { /* onclose 会跟着触发,统一在 onclose 处理 */ }
}
private startPing() {
this.stopPingOnly()
this.pingTimer = setInterval(() => {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return
// pong 看门狗:超时视为 TCP 半连接,主动断开走重连
if (Date.now() - this.lastPong > PING_INTERVAL + PONG_TIMEOUT) {
try { this.ws.close() } catch (e) { /* ignore */ }
return
}
this.ws.send(JSON.stringify({ control_kind: 'ping' }))
if (this.pongTimer) clearTimeout(this.pongTimer)
this.pongTimer = setTimeout(() => {
try { this.ws?.close() } catch (e) { /* ignore */ }
}, PONG_TIMEOUT)
}, PING_INTERVAL)
}
private stopPingOnly() {
if (this.pingTimer) { clearInterval(this.pingTimer); this.pingTimer = null }
if (this.pongTimer) { clearTimeout(this.pongTimer); this.pongTimer = null }
}
/* ---------- 请求 ---------- */
private pending = new Set<string>()
/**
* 发送 agent 请求。返回 request_id。
* @throws 帧体超 1MiB(服务端上限)时抛错
*/
request(prompt: string): string {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) throw new Error('中继未连接')
const frame = JSON.stringify({ type: 'agent_request', request_id: genId(), prompt })
if (frame.length > MAX_FRAME) throw new Error('请求体超过 1MiB 帧上限(deck 上下文过大),请精简演示后重试')
const rid = JSON.parse(frame).request_id as string
this.pending.add(rid)
this.ws.send(frame)
return rid
}
/** 请求结束(收到 result 或调用方放弃)后释放匹配槽 */
settle(rid: string) { this.pending.delete(rid) }
}
/** 全局单例:Agent 面板与设置页共用一条连接 */
export const relay = new RelayClient()
+4
View File
@@ -187,6 +187,10 @@ export interface AiCfg {
imgBase?: string
imgKey?: string
imgModel?: string
/** Agent 中继(u-relay)配置:三项均配置才启用 Agent 模式 */
relayUrl?: string
relayToken?: string
relayDeviceId?: string
}
/** 对象存储(OSS)配置:所有媒体资产上云,离线暂存本地,联网自动同步 */