({
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() {
+ Agent 中继(可选,三项齐备即启用 Agent 模式)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/core/ai.ts b/src/core/ai.ts
index f1b2b49..dda7f1d 100644
--- a/src/core/ai.ts
+++ b/src/core/ai.ts
@@ -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 操作。
+ * 供 AiPanel(SSE 路径)与 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'
diff --git a/src/core/relay.ts b/src/core/relay.ts
new file mode 100644
index 0000000..80b7d2a
--- /dev/null
+++ b/src/core/relay.ts
@@ -0,0 +1,212 @@
+/* =====================================================================
+ * relay.ts — u-relay 中继客户端(Agent 模式)
+ * - WebSocket 连 u-relay(wss://.../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 | null = null
+ private pongTimer: ReturnType | null = null
+ private reconnectTimer: ReturnType | null = null
+ private handshakeTimer: ReturnType | 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()
+
+ /**
+ * 发送 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()
diff --git a/src/core/types.ts b/src/core/types.ts
index 196d9bc..ee9cca2 100644
--- a/src/core/types.ts
+++ b/src/core/types.ts
@@ -187,6 +187,10 @@ export interface AiCfg {
imgBase?: string
imgKey?: string
imgModel?: string
+ /** Agent 中继(u-relay)配置:三项均配置才启用 Agent 模式 */
+ relayUrl?: string
+ relayToken?: string
+ relayDeviceId?: string
}
/** 对象存储(OSS)配置:所有媒体资产上云,离线暂存本地,联网自动同步 */