diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8a1d059..6bef9a0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,6 +35,11 @@ jobs: - name: Sync version from tag run: node scripts/set-version.mjs ${{ github.ref_name }} + # Bundle the clone-export bot engine to resources/bot-runtime/bot.mjs so + # electron-builder ships it; the "导出好友" feature copies it into products. + - name: Build clone-bot runtime + run: pnpm run build:bot + - name: Build renderer + main run: pnpm --filter @weq/desktop exec electron-vite build diff --git a/.gitignore b/.gitignore index 98c3648..13f589a 100644 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,6 @@ release/ # extend .claude/ -.agents/ \ No newline at end of file +.agents/ +# 导出 bot 的预打包引擎(由 pnpm build:bot 生成) +resources/bot-runtime/ diff --git a/apps/desktop/src/main/bot_webui_window.ts b/apps/desktop/src/main/bot_webui_window.ts new file mode 100644 index 0000000..5049ea1 --- /dev/null +++ b/apps/desktop/src/main/bot_webui_window.ts @@ -0,0 +1,52 @@ +/** + * 打开导出 bot 的 WebUI 控制台窗口(本机 http 页面)。 + * + * 用导出时生成的密钥「免手输」直接登录:先 loadURL 打开控制台,页面首帧加载完成后注入 + * sessionStorage 密钥并 reload —— 控制台前端在 sessionStorage 有密钥时会自动进主界面。 + * + * 窗口隔离:无 preload(加载的是 bot 自己的页面,不该看到应用的 tRPC bridge), + * 链接一律走系统浏览器。镜像 report_window.ts 的隔离范式。 + */ +import { BrowserWindow } from 'electron'; + +/** 探活:GET 判断 bot 的 WebUI 是否在跑(3s 超时)。 */ +export async function probeBotWebUi(url: string): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 3000); + try { + const res = await fetch(url, { method: 'GET', signal: controller.signal }); + return res.ok || res.status === 401; // 200 页面 或 401(服务在跑,只是缺鉴权)都算可达 + } catch { + return false; + } finally { + clearTimeout(timer); + } +} + +/** 打开控制台窗口并用密钥自动登录。url 为基址(如 http://127.0.0.1:8090)。 */ +export async function openBotWebUiWindow(url: string, key: string, botName?: string): Promise { + const win = new BrowserWindow({ + width: 1040, + height: 800, + minWidth: 520, + minHeight: 420, + title: botName ? `${botName} · 控制台` : '克隆体 · 控制台', + autoHideMenuBar: true, + backgroundColor: '#0f1216', + webPreferences: { + sandbox: true, + contextIsolation: true, + // 无 preload:控制台是 bot 自己的页面,应用的特权 bridge 不能进它的视野。 + }, + }); + // 控制台里的外链一律走系统浏览器,不在本窗口内导航/开子窗。 + win.webContents.setWindowOpenHandler(() => ({ action: 'deny' })); + + // 首帧加载完成后注入密钥并 reload(once:只在首次注入,reload 后的加载不再重复)。 + win.webContents.once('did-finish-load', () => { + const js = `try { sessionStorage.setItem('weq-bot-key', ${JSON.stringify(key)}); location.reload(); } catch (e) {}`; + void win.webContents.executeJavaScript(js).catch(() => undefined); + }); + + await win.loadURL(url); +} diff --git a/apps/desktop/src/main/context/app_context.ts b/apps/desktop/src/main/context/app_context.ts index b0ced3f..8d2ee70 100644 --- a/apps/desktop/src/main/context/app_context.ts +++ b/apps/desktop/src/main/context/app_context.ts @@ -694,13 +694,19 @@ export function initAppContext(): AppContext { // Start it now if enabled; live toggling is handled by `applyMcp`. const mcp = userConfig.getSettings().mcp; if (mcp.enabled && mcp.token) { - startMcpServer({ port: mcp.port, token: mcp.token }).catch((error) => { - logger.error('failed to start mcp server on account open', { - event: 'mcp-start-failed', - port: mcp.port, - ...logErrorContext(error), + startMcpServer({ port: mcp.port, token: mcp.token }) + .then((boundPort) => { + // Port fallback may have moved us off a squatted port; persist the + // real one so the UI / client config stay in sync. + if (boundPort !== mcp.port) userConfig.setSettings({ mcp: { port: boundPort } }); + }) + .catch((error) => { + logger.error('failed to start mcp server on account open', { + event: 'mcp-start-failed', + port: mcp.port, + ...logErrorContext(error), + }); }); - }); } // No health check at open — it now runs lazily, only if a real query // later fails in a way that looks like corruption (see the openAccount @@ -918,7 +924,10 @@ export function initAppContext(): AppContext { port: config.port, }); if (config.enabled && config.token) { - await startMcpServer({ port: config.port, token: config.token }); + const boundPort = await startMcpServer({ port: config.port, token: config.token }); + // Port fallback may have moved us off a squatted port; persist the real + // one so getMcpStatus / the client config snippet report what's live. + if (boundPort !== config.port) userConfig.setSettings({ mcp: { port: boundPort } }); } else { await stopMcpServer(); } diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 7fad986..37caa6e 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, ipcMain, nativeImage, protocol, shell } from 'electron'; +import { app, BrowserWindow, ipcMain, Menu, nativeImage, protocol, shell, Tray } from 'electron'; import fs from 'node:fs'; import { electronApp, optimizer, is } from '@electron-toolkit/utils'; import { createRequire } from 'node:module'; @@ -25,7 +25,13 @@ import { stopMcpServer } from './mcp/server'; import { disposeExternalMcp } from './mcp/external'; import { registerChannelIpc } from './channel'; import { registerQzoneIpc } from './qzone'; -import { getLogDir, getLogger, logErrorContext, type MediaElement } from '@weq/service'; +import { + getLogDir, + getLogger, + logErrorContext, + type MediaElement, + type WindowCloseBehavior, +} from '@weq/service'; import { systemAuthService } from './system_auth'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -54,6 +60,81 @@ const WINDOW_LAYOUTS = { const logger = getLogger().child({ scope: 'desktop-main' }); +/** The primary app window. Tracked so the tray / single-instance / close flows + * can reach it without threading it through every call. */ +let mainWindow: BrowserWindow | null = null; +/** System-tray icon; null until built (or if creation failed). */ +let tray: Tray | null = null; +/** Set true right before a *real* quit so the `close` handler stops intercepting + * and lets the window actually close (托盘退出 / 确认框「完全退出」/ before-quit). */ +let isQuitting = false; + +/** Bring the main window back to the foreground (from tray / second instance). */ +function revealWindow(win: BrowserWindow): void { + if (win.isDestroyed()) return; + if (win.isMinimized()) win.restore(); + win.show(); + win.focus(); +} + +/** + * Resolve the current 关闭行为 from persisted settings. Falls back to `'quit'` + * when bootstrap is unavailable (native-load failure → only an error window is + * showing; hiding it to tray would be pointless). + */ +function resolveCloseBehavior(): WindowCloseBehavior { + try { + const boot = getAppContext().bootstrap; + if (!boot) return 'quit'; + return boot.userConfig.getSettings().windowCloseBehavior ?? 'ask'; + } catch { + return 'quit'; + } +} + +/** + * Build the system tray once. The icon is the brand logo, downscaled to a + * tray-appropriate size (16px on Windows; 18px template image on macOS so it + * tracks the menu-bar's light/dark). Menu: 显示主窗口 / 退出 WeQ. Left-click and + * double-click both re-reveal the window. + */ +function buildTray(win: BrowserWindow): void { + if (tray) return; + const iconPath = resolveResource('brand', 'logo.png'); + let image = iconPath ? nativeImage.createFromPath(iconPath) : nativeImage.createEmpty(); + if (!image.isEmpty()) { + image = image.resize( + process.platform === 'darwin' ? { width: 18, height: 18 } : { width: 16, height: 16 }, + ); + if (process.platform === 'darwin') image.setTemplateImage(true); + } + try { + tray = new Tray(image); + tray.setToolTip('WeQ'); + tray.setContextMenu( + Menu.buildFromTemplate([ + { label: '显示主窗口', click: () => revealWindow(win) }, + { type: 'separator' }, + { + label: '退出 WeQ', + click: () => { + isQuitting = true; + app.quit(); + }, + }, + ]), + ); + tray.on('click', () => revealWindow(win)); + tray.on('double-click', () => revealWindow(win)); + logger.info('system tray created', { event: 'tray-create' }); + } catch (e) { + logger.warn('failed to create system tray', { + event: 'tray-create-failed', + error: String(e), + }); + } +} + function registerWindowLayoutIpc(): void { ipcMain.handle('window:set-layout', (event, layout: keyof typeof WINDOW_LAYOUTS) => { const win = BrowserWindow.fromWebContents(event.sender); @@ -82,6 +163,17 @@ function registerWindowLayoutIpc(): void { ipcMain.on('window-close', (event) => { BrowserWindow.fromWebContents(event.sender)?.close(); }); + + // Reply from the renderer's 关闭确认框 (only sent when behavior === 'ask'). + // 'tray' → hide to tray; 'quit' → real quit; 'cancel' → do nothing. + ipcMain.on('window:respond-close', (_event, action: 'tray' | 'quit' | 'cancel') => { + if (action === 'tray') { + mainWindow?.hide(); + } else if (action === 'quit') { + isQuitting = true; + app.quit(); + } + }); } /** @@ -289,6 +381,27 @@ function createWindow(): BrowserWindow { // (e.g. compositor/driver quirks). Guarantee visibility once content loads. win.webContents.on('did-finish-load', reveal); + // Intercept the close button so it doesn't hard-quit. Behavior is + // user-configurable (设置 → 全局设置 → 关闭按钮): + // 'quit' → let it close (window-all-closed then quits); + // 'tray' → hide to the system tray, keep the process alive; + // 'ask' → ask the renderer to show the 关闭确认框 (default, first time). + win.on('close', (e) => { + if (isQuitting || win !== mainWindow) return; + const behavior = resolveCloseBehavior(); + if (behavior === 'quit') { + isQuitting = true; + return; + } + e.preventDefault(); + if (behavior === 'tray' && tray) { + win.hide(); + return; + } + // 'ask' — or 'tray' but the tray failed to build → fall back to asking. + win.webContents.send('window:confirm-close', { canMinimizeToTray: Boolean(tray) }); + }); + win.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url); return { action: 'deny' }; @@ -299,10 +412,24 @@ function createWindow(): BrowserWindow { } else { void win.loadFile(join(__dirname, '../renderer/index.html')); } + mainWindow = win; return win; } +// Single-instance guard: with the app now living in the tray, a second launch +// should just re-reveal the existing window rather than spawn another +// background process. The non-primary instance quits immediately. +const hasSingleInstanceLock = app.requestSingleInstanceLock(); +if (!hasSingleInstanceLock) { + app.quit(); +} else { + app.on('second-instance', () => { + if (mainWindow) revealWindow(mainWindow); + }); +} + void app.whenReady().then(() => { + if (!hasSingleInstanceLock) return; electronApp.setAppUserModelId('app.weq.desktop'); // Order matters: AppContext (loads native + platform) before IPC handler. @@ -326,6 +453,7 @@ void app.whenReady().then(() => { const win = createWindow(); logger.info('main window created', { event: 'create-window' }); createIPCHandler({ router: appRouter, windows: [win] }); + buildTray(win); // Silent background update check (packaged builds only). Result is cached and // pushed to the renderer via the `update.onEvent` subscription → settings red @@ -343,7 +471,24 @@ void app.whenReady().then(() => { }); app.on('window-all-closed', () => { - if (process.platform !== 'darwin') app.quit(); + // With the tray keeping the process alive, closing the window normally hides + // it (never reaching here). We only get here on a real quit path — which + // already sets `isQuitting` — so honour it. macOS keeps the app resident. + if (process.platform !== 'darwin' && isQuitting) app.quit(); +}); + +// A real quit is underway (tray「退出」/ 确认框「完全退出」/ Cmd+Q / OS shutdown): +// flip the flag so the `close` handler stops intercepting, and drop the tray. +app.on('before-quit', () => { + isQuitting = true; + if (tray) { + try { + tray.destroy(); + } catch { + /* ignore */ + } + tray = null; + } }); // Best-effort: stop the account-bound MCP server on quit even if the account diff --git a/apps/desktop/src/main/ipc/routers/account.ts b/apps/desktop/src/main/ipc/routers/account.ts index 84f09a7..b089bac 100644 --- a/apps/desktop/src/main/ipc/routers/account.ts +++ b/apps/desktop/src/main/ipc/routers/account.ts @@ -18,6 +18,8 @@ import { randomUUID } from 'node:crypto'; import { basename, dirname, extname, join } from 'node:path'; import { getAppContext, dbEventBus, type AccountServices } from '../../context/app_context'; import { sampleHitokoto } from '../../hitokoto'; +import { resolveResource } from '../../resource'; +import { dialog } from 'electron'; import { procedure, router } from '../trpc'; import { assistantBus, type AssistantStreamEvent } from '../../mcp/assistant_bus'; import { groupChatBus, type GroupChatStreamEvent } from '../../mcp/agentlab_group_bus'; @@ -27,6 +29,7 @@ import { PRIVATE_PTT_RKEY_TYPE, GROUP_PTT_RKEY_TYPE, getVoiceModel, + buildBotExport, type AlbumMedia, type NewMessages, type DbChange, @@ -630,6 +633,114 @@ export const accountRouter = router({ return requireServices().agentLab.deletePersona(input.personaId); }), + /** 导出克隆体为独立 OneBot bot(napcat/snowluma)产物文件夹。 */ + exportAgentLabPersona: procedure + .input( + z.object({ + personaId: z.string().min(1), + adapterType: z.enum(['napcat', 'snowluma']), + wsUrl: z.string().min(1), + token: z.string().optional(), + selfId: z.string().min(1), + voice: z.boolean().optional(), + groupChat: z.boolean().optional(), + groupReplyMode: z.enum(['llm', 'heuristic']).optional(), + webuiPort: z.number().int().min(1).max(65535).optional(), + // 可选图像模型:写进导出 persona 的 models.vision,供 bot 解析上传的新表情。 + visionModel: agentLabModelRef.optional(), + }), + ) + .mutation(async ({ input }) => { + const ctx = getAppContext(); + const svc = ctx.services?.agentLab; + const cfg = ctx.bootstrap?.agentLabConfig; + if (!svc || !cfg) throw new Error('账号未就绪'); + const record = svc.getPersonaRecord(input.personaId); + if (!record) throw new Error('找不到克隆体'); + const { persona } = record; + + // 抽 persona 用到的 LLM providers(chat/embedding/vision,去重)。 + // 导出弹窗显式指定的图像模型也并进来(即使克隆时没用 vision,也能让 bot 具备解析新表情的能力)。 + const llmIds = new Set(); + for (const m of [persona.models.chat, persona.models.embedding, persona.models.vision, input.visionModel]) { + if (m?.providerId) llmIds.add(m.providerId); + } + const llmProviders: Array<{ id: string; baseUrl: string; apiKey: string }> = []; + for (const id of llmIds) { + const p = cfg.getProvider(id); + if (!p) throw new Error(`缺少 LLM provider 配置:${id}(请先在设置里配置该厂商)`); + llmProviders.push({ id: p.id, baseUrl: p.baseUrl, apiKey: p.apiKey }); + } + // 抽 TTS provider(若绑了语音克隆)。 + const ttsProviders = persona.voice?.providerId + ? [cfg.getTtsProvider(persona.voice.providerId)].filter((t): t is NonNullable => t !== null) + : []; + + // 定位预打包引擎。 + const botMjs = resolveResource('bot-runtime', 'bot.mjs'); + if (!botMjs) throw new Error('找不到 bot 引擎 bot.mjs(开发环境请先运行 pnpm build:bot)'); + + // 选输出位置。 + const picked = await dialog.showOpenDialog({ + title: '选择导出位置', + properties: ['openDirectory', 'createDirectory'], + }); + if (picked.canceled || !picked.filePaths[0]) return { canceled: true as const }; + const safeName = (persona.name || 'clone').replace(/[^\w一-龥-]+/g, '_').slice(0, 40) || 'clone'; + const outDir = join(picked.filePaths[0], `${safeName}-bot`); + + const result = await buildBotExport({ + outDir, + botRuntimeMjs: botMjs, + persona, + pairs: record.pairs, + agentlabRoot: svc.assetRoot, + llmProviders, + ttsProviders, + adapter: { type: input.adapterType, wsUrl: input.wsUrl, token: input.token }, + selfId: input.selfId, + features: { voice: input.voice ?? false, groupChat: input.groupChat ?? false, groupReplyMode: input.groupReplyMode ?? 'llm' }, + webuiPort: input.webuiPort, + visionModel: input.visionModel, + }); + // 记录 id → WebUI 访问信息,导出后在设置页可查密钥 / 一键打开控制台。 + svc.recordExport(input.personaId, { + key: result.webui.key, + id: result.webui.id, + port: result.webui.port, + url: result.webui.url, + outDir: result.outDir, + exportedAt: Date.now(), + }); + return { canceled: false as const, ...result }; + }), + + /** 查某克隆体最近一次导出的 WebUI 访问信息(密钥/端口/url;没导出过则 null)。 */ + getAgentLabExportInfo: procedure + .input(z.object({ personaId: z.string().min(1) })) + .query(({ input }) => { + return requireServices().agentLab.getExportInfo(input.personaId) ?? null; + }), + + /** + * 打开某克隆体导出 bot 的 WebUI 控制台窗口(用存储的密钥自动登录)。 + * 先探活默认地址(或用户传入的 url);不可达则返回 { needUrl: true } 让前端提示输入地址。 + */ + openBotWebUi: procedure + .input(z.object({ personaId: z.string().min(1), url: z.string().optional() })) + .mutation(async ({ input }) => { + const svc = requireServices().agentLab; + const info = svc.getExportInfo(input.personaId); + if (!info) throw new Error('这个克隆体还没有导出过,请先导出机器人。'); + const base = (input.url?.trim() || info.url).replace(/\/+$/, ''); + const { probeBotWebUi, openBotWebUiWindow } = await import('../../bot_webui_window'); + const reachable = await probeBotWebUi(base + '/'); + if (!reachable) return { opened: false as const, needUrl: true as const, defaultUrl: info.url }; + const persona = svc.getPersona(input.personaId); + await openBotWebUiWindow(base, info.key, persona?.name); + return { opened: true as const, needUrl: false as const }; + }), + // ── 克隆体群聊(M2 群骨架)───────────────────────────────────────────── createAgentLabGroup: procedure diff --git a/apps/desktop/src/main/ipc/routers/bootstrap.ts b/apps/desktop/src/main/ipc/routers/bootstrap.ts index ce5f37f..aff6303 100644 --- a/apps/desktop/src/main/ipc/routers/bootstrap.ts +++ b/apps/desktop/src/main/ipc/routers/bootstrap.ts @@ -251,6 +251,17 @@ export const bootstrapRouter = router({ return true; }), + /** + * 关闭按钮行为(最小化到托盘 / 直接退出 / 每次询问)。主进程的 window + * 'close' 拦截会在下一次关闭时读取该值——纯持久化,无需即时应用。 + */ + setWindowCloseBehavior: procedure + .input(z.object({ behavior: z.enum(['ask', 'tray', 'quit']) })) + .mutation(({ input }) => { + requireBootstrap().userConfig.setSettings({ windowCloseBehavior: input.behavior }); + return true; + }), + // ---- MCP server (account-bound) ---- /** diff --git a/apps/desktop/src/main/mcp/server.ts b/apps/desktop/src/main/mcp/server.ts index 8d6af86..6dad933 100644 --- a/apps/desktop/src/main/mcp/server.ts +++ b/apps/desktop/src/main/mcp/server.ts @@ -30,6 +30,9 @@ export interface McpServerOptions { token: string; } +/** How many consecutive ports to probe when the requested one is taken. */ +const PORT_FALLBACK_ATTEMPTS = 20; + let httpServer: http.Server | null = null; let activeConfig: McpServerOptions | null = null; @@ -121,41 +124,77 @@ async function handleRequest( } } +/** + * Try to bind `server` to `port` on loopback. Resolves `true` on success, + * `false` if the port is already in use (so the caller can try the next one). + * Any other bind error rejects. + */ +function tryListen(server: http.Server, port: number): Promise { + return new Promise((resolve, reject) => { + const onError = (err: NodeJS.ErrnoException): void => { + server.off('listening', onListening); + if (err.code === 'EADDRINUSE') { + resolve(false); + return; + } + reject(err); + }; + const onListening = (): void => { + server.off('error', onError); + resolve(true); + }; + server.once('error', onError); + server.once('listening', onListening); + server.listen(port, '127.0.0.1'); + }); +} + /** * Start (or restart) the MCP HTTP server. Idempotent: a call with the same * port+token while already running is a no-op; a different config restarts. - * Rejects if the port can't be bound (e.g. already in use). + * + * If the requested port is already in use (common on Windows where e.g. Baidu + * IME squats on 8765), it probes the next `PORT_FALLBACK_ATTEMPTS` ports and + * binds to the first free one. Returns the port it actually bound to, so the + * caller can persist it and keep the UI / client config in sync. */ -export async function startMcpServer(opts: McpServerOptions): Promise { +export async function startMcpServer(opts: McpServerOptions): Promise { if (httpServer) { if (activeConfig && activeConfig.port === opts.port && activeConfig.token === opts.token) { - return; + return activeConfig.port; } await stopMcpServer(); } const server = http.createServer((req, res) => { void handleRequest(req, res, opts.token); }); - await new Promise((resolve, reject) => { - const onError = (err: Error): void => { - server.off('listening', onListening); - reject(err); - }; - const onListening = (): void => { - server.off('error', onError); - resolve(); - }; - server.once('error', onError); - server.once('listening', onListening); - server.listen(opts.port, '127.0.0.1'); - }); + let boundPort = -1; + for (let i = 0; i < PORT_FALLBACK_ATTEMPTS; i += 1) { + const port = opts.port + i; + if (port > 65535) break; + if (await tryListen(server, port)) { + boundPort = port; + break; + } + if (i > 0) { + logger.warn('mcp port in use, trying next', { event: 'mcp-port-busy', port }); + } + } + if (boundPort === -1) { + server.close(); + throw new Error( + `MCP 端口 ${opts.port}–${Math.min(opts.port + PORT_FALLBACK_ATTEMPTS - 1, 65535)} 都被占用,无法启动。`, + ); + } httpServer = server; - activeConfig = { ...opts }; + activeConfig = { port: boundPort, token: opts.token }; logger.info('mcp server started', { event: 'mcp-start', - port: opts.port, - url: `http://127.0.0.1:${opts.port}`, + port: boundPort, + requestedPort: opts.port, + url: `http://127.0.0.1:${boundPort}`, }); + return boundPort; } /** Stop the MCP HTTP server if running. Idempotent. */ diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index a481fc7..d729f44 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -12,6 +12,7 @@ import { MainView } from './views/MainView'; import { DialogHost } from './components/Dialog'; import { ToastHost } from './components/Toast'; import { WelcomeDialog } from './components/WelcomeDialog'; +import { CloseConfirmDialog } from './components/CloseConfirmDialog'; import { ImageLightbox } from './components/ImageLightbox'; import { ForwardWindowHost } from './components/ForwardWindow'; import { AppLockOverlay } from './components/AppLockOverlay'; @@ -40,6 +41,7 @@ export default function App(): ReactElement { {view === 'main' ? : null} + diff --git a/apps/desktop/src/renderer/src/components/CloseConfirmDialog.tsx b/apps/desktop/src/renderer/src/components/CloseConfirmDialog.tsx new file mode 100644 index 0000000..927884a --- /dev/null +++ b/apps/desktop/src/renderer/src/components/CloseConfirmDialog.tsx @@ -0,0 +1,125 @@ +/** + * 关闭确认框 —— 点击标题栏 ✕ 且「关闭行为」为 `ask` 时弹出. + * + * 主进程在 `win.on('close')` 里拦截关闭,向渲染层发 `window:confirm-close` + * (携带 `canMinimizeToTray`)。用户选择后: + * - 勾了「不再询问」→ 先持久化对应的 windowCloseBehavior; + * - 通过 `window:respond-close` 把动作(tray / quit / cancel)回传主进程。 + * + * 复用 外壳(遮罩 / ESC / 动画)保持与全局弹窗一致;两个选项与「记住 + * 选择」沿用设置页的视觉语言(#0099ff 主色、细描边、小圆角)。 + */ + +import { useEffect, useState, type ReactElement } from 'react'; +import { Minimize2, Power } from 'lucide-react'; +import { Modal } from './Dialog'; +import { trpc } from '../trpc/client'; + +type CloseAction = 'tray' | 'quit' | 'cancel'; + +function ipc(): { on(ch: string, cb: (...a: unknown[]) => void): (() => void) | void; send(ch: string, ...a: unknown[]): void } | undefined { + return (window as unknown as { electron?: { ipcRenderer?: ReturnType } }).electron?.ipcRenderer; +} + +export function CloseConfirmDialog(): ReactElement | null { + const [open, setOpen] = useState(false); + const [canTray, setCanTray] = useState(true); + const [remember, setRemember] = useState(false); + const setBehavior = trpc.bootstrap.setWindowCloseBehavior.useMutation(); + const utils = trpc.useUtils(); + + useEffect(() => { + const off = ipc()?.on('window:confirm-close', (...args: unknown[]) => { + const payload = args[1] as { canMinimizeToTray?: boolean } | undefined; + setCanTray(payload?.canMinimizeToTray !== false); + setRemember(false); + setOpen(true); + }); + return () => { + if (typeof off === 'function') off(); + }; + }, []); + + async function respond(action: CloseAction): Promise { + setOpen(false); + // 「不再询问」:先把选择写进设置,下次直接照此执行(cancel 不记忆)。 + if (remember && action !== 'cancel') { + try { + await setBehavior.mutateAsync({ behavior: action }); + await utils.bootstrap.getSettings.invalidate(); + } catch { + // 记忆失败无伤大雅——本次动作照常执行,最坏下次再问一次。 + } + } + ipc()?.send('window:respond-close', action); + } + + if (!open) return null; + + return ( + void respond('cancel')} labelledBy="weq-close-title" width={392}> +
+
+

+ 关闭 WeQ +

+

+ {canTray + ? '可以最小化到系统托盘继续后台运行,或直接完全退出。' + : '当前系统托盘不可用,本次只能完全退出应用。'} +

+
+ +
+ {canTray ? ( + + ) : null} + + +
+ + + +
+ +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/FaceEmoji.tsx b/apps/desktop/src/renderer/src/components/FaceEmoji.tsx index 48a357e..ad955c0 100644 --- a/apps/desktop/src/renderer/src/components/FaceEmoji.tsx +++ b/apps/desktop/src/renderer/src/components/FaceEmoji.tsx @@ -20,6 +20,7 @@ */ import { useEffect, useRef, useState } from 'react'; +import { Smile } from 'lucide-react'; import type { FaceElement } from '@weq/codec'; import { emojiUrl, resourceUrl } from '@renderer/lib/resourceUrl'; import { cn } from '@renderer/lib/utils'; @@ -170,10 +171,20 @@ function FaceImage({ }) { const [broken, setBroken] = useState(false); + // 图片加载失败 = 这个 faceId 超出了我们预设的所有表情范围(本地没有对应资源)。 + // 不回退成文字 `[表情xxx]`(丑且不像表情),而是回退到一个纯线条的默认笑脸, + // 一眼能看出是「未知表情」的兜底占位。 if (broken) { + const glyphSize = style?.width ?? '1.25em'; return ( - - {label} + + ); } diff --git a/apps/desktop/src/renderer/src/components/settings/GlobalSettingsSection.tsx b/apps/desktop/src/renderer/src/components/settings/GlobalSettingsSection.tsx index 6a03d49..0722db6 100644 --- a/apps/desktop/src/renderer/src/components/settings/GlobalSettingsSection.tsx +++ b/apps/desktop/src/renderer/src/components/settings/GlobalSettingsSection.tsx @@ -16,7 +16,8 @@ */ import { useEffect, useState, type ReactElement } from 'react'; -import { Check, FolderOpen, Info, LockKeyhole, RotateCcw, User } from 'lucide-react'; +import { Check, FolderOpen, Info, LockKeyhole, Minimize2, RotateCcw, User } from 'lucide-react'; +import type { WindowCloseBehavior } from '@weq/service'; import { trpc } from '../../trpc/client'; import { useDialog } from '../Dialog'; import { useToast } from '../Toast'; @@ -38,6 +39,13 @@ const AUTO_LOCK_OPTIONS: ReadonlyArray<{ value: number; label: string }> = [ { value: 30, label: '30 分钟' }, ]; +/** 点击关闭按钮(标题栏 ✕)时的行为。 */ +const CLOSE_BEHAVIOR_OPTIONS: ReadonlyArray<{ value: WindowCloseBehavior; label: string }> = [ + { value: 'ask', label: '每次询问' }, + { value: 'tray', label: '最小化到托盘' }, + { value: 'quit', label: '直接退出' }, +]; + export function GlobalSettingsSection(): ReactElement { const showError = useDialog((s) => s.showError); const pushToast = useToast((s) => s.push); @@ -45,6 +53,7 @@ export function GlobalSettingsSection(): ReactElement { ReturnType > | null>(null); const [autoLockMinutes, setAutoLockMinutes] = useState(0); + const [closeBehavior, setCloseBehavior] = useState('ask'); const version = trpc.bootstrap.getVersionInfo.useQuery(undefined, { refetchOnWindowFocus: false, @@ -76,6 +85,7 @@ export function GlobalSettingsSection(): ReactElement { const pickCache = trpc.bootstrap.pickCacheDir.useMutation(); const clearCache = trpc.bootstrap.clearCacheDir.useMutation(); const setAutoLock = trpc.bootstrap.setAutoLockMinutes.useMutation(); + const setWindowClose = trpc.bootstrap.setWindowCloseBehavior.useMutation(); const cacheBusy = pickCache.isLoading || clearCache.isLoading; useEffect(() => { @@ -83,6 +93,11 @@ export function GlobalSettingsSection(): ReactElement { if (typeof minutes === 'number') setAutoLockMinutes(minutes); }, [settings.data?.autoLockMinutes]); + useEffect(() => { + const behavior = settings.data?.windowCloseBehavior; + if (behavior) setCloseBehavior(behavior); + }, [settings.data?.windowCloseBehavior]); + useEffect(() => { void window.weq.systemAuth .getStatus() @@ -153,6 +168,19 @@ export function GlobalSettingsSection(): ReactElement { } } + async function onSetCloseBehavior(behavior: WindowCloseBehavior): Promise { + const prev = closeBehavior; + setCloseBehavior(behavior); + try { + await setWindowClose.mutateAsync({ behavior }); + await settings.refetch(); + } catch (e) { + setCloseBehavior(prev); + await settings.refetch(); + showError('保存关闭行为设置失败', errMsg(e)); + } + } + const v = version.data; const accountList = accounts.data ?? []; const autoEnterTarget = autoEnter.data; @@ -227,6 +255,42 @@ export function GlobalSettingsSection(): ReactElement { /> + {/* Window close behavior */} + + + + 关闭按钮 + + } + desc={ + closeBehavior === 'tray' + ? '点击关闭按钮后最小化到系统托盘,进程常驻后台,可从托盘图标恢复。' + : closeBehavior === 'quit' + ? '点击关闭按钮后直接完全退出应用。' + : '每次点击关闭按钮时弹窗询问,可选择最小化到托盘或完全退出。' + } + control={ +
+ {CLOSE_BEHAVIOR_OPTIONS.map((opt) => ( + + ))} +
+ } + /> + + {/* Account list */} {accountList.length === 0 ? ( diff --git a/apps/desktop/src/renderer/src/components/settings/McpServerSection.tsx b/apps/desktop/src/renderer/src/components/settings/McpServerSection.tsx index edd02cf..7c6b642 100644 --- a/apps/desktop/src/renderer/src/components/settings/McpServerSection.tsx +++ b/apps/desktop/src/renderer/src/components/settings/McpServerSection.tsx @@ -88,7 +88,15 @@ export function McpServerSection(): ReactElement { async function onToggle(next: boolean): Promise { try { - await setEnabled.mutateAsync({ enabled: next }); + const requested = data?.port; + const result = await setEnabled.mutateAsync({ enabled: next }); + if (next && requested != null && result.port !== requested) { + pushToast({ + tone: 'info', + title: '端口已自动调整', + message: `${requested} 被占用,MCP 服务器现监听 ${result.port}`, + }); + } await status.refetch(); await clientConfig.refetch(); } catch (e) { @@ -105,10 +113,18 @@ export function McpServerSection(): ReactElement { } if (data && port === data.port) return; try { - await setPort.mutateAsync({ port }); + const result = await setPort.mutateAsync({ port }); await status.refetch(); await clientConfig.refetch(); - pushToast({ tone: 'success', title: '端口已更新', message: `MCP 服务器现监听 ${port}` }); + if (result.port !== port) { + pushToast({ + tone: 'info', + title: '端口已自动调整', + message: `${port} 被占用,MCP 服务器现监听 ${result.port}`, + }); + } else { + pushToast({ tone: 'success', title: '端口已更新', message: `MCP 服务器现监听 ${port}` }); + } } catch (e) { showError('修改端口失败', errMsg(e)); await status.refetch(); diff --git a/apps/desktop/src/renderer/src/styles/index.css b/apps/desktop/src/renderer/src/styles/index.css index 9ec9fc7..0cc5e58 100644 --- a/apps/desktop/src/renderer/src/styles/index.css +++ b/apps/desktop/src/renderer/src/styles/index.css @@ -1198,6 +1198,138 @@ html[data-theme="dark"] .chat-empty { font-size: 12px; } + /* ---- close-confirm dialog (关闭 WeQ:托盘 / 退出) ---- */ + /* Token-driven so it tracks light/dark, like the welcome dialog. */ + .weq-close { + display: flex; + flex-direction: column; + padding: 1.15rem 1.2rem 1rem; + color: var(--weq-fg-primary); + background: var(--weq-bg-surface); + } + .weq-close-head { + display: flex; + flex-direction: column; + gap: 0.28rem; + margin-bottom: 0.85rem; + } + .weq-close-title { + margin: 0; + font-size: 15.5px; + font-weight: 650; + color: var(--weq-fg-primary); + } + .weq-close-sub { + margin: 0; + font-size: 12.5px; + line-height: 1.55; + color: var(--weq-fg-secondary); + } + .weq-close-options { + display: flex; + flex-direction: column; + gap: 0.5rem; + } + .weq-close-opt { + display: flex; + align-items: center; + gap: 0.7rem; + padding: 0.7rem 0.8rem; + text-align: left; + border-radius: 0.6rem; + border: 1px solid var(--weq-border-subtle); + background: color-mix(in srgb, var(--weq-accent-effective) 5%, transparent); + cursor: pointer; + transition: border-color 140ms ease, background-color 140ms ease, transform 120ms ease; + } + .weq-close-opt:hover:not(:disabled) { + border-color: var(--weq-border-strong); + background: color-mix(in srgb, var(--weq-accent-effective) 10%, transparent); + } + .weq-close-opt:active:not(:disabled) { + transform: translateY(0.5px); + } + .weq-close-opt:disabled { + opacity: 0.55; + cursor: not-allowed; + } + .weq-close-opt-ico { + display: inline-flex; + flex-shrink: 0; + width: 2.1rem; + height: 2.1rem; + align-items: center; + justify-content: center; + border-radius: 0.5rem; + color: var(--weq-accent-effective); + background: color-mix(in srgb, var(--weq-accent-effective) 12%, transparent); + } + .weq-close-opt-text { + display: flex; + flex-direction: column; + gap: 0.14rem; + min-width: 0; + } + .weq-close-opt-text strong { + font-size: 13px; + font-weight: 620; + color: var(--weq-fg-primary); + } + .weq-close-opt-text span { + font-size: 11.5px; + line-height: 1.5; + color: var(--weq-fg-muted); + } + .weq-close-opt.is-danger .weq-close-opt-ico { + color: #d5495a; + background: rgba(213, 73, 90, 0.12); + } + .weq-close-opt.is-danger:hover:not(:disabled) { + border-color: rgba(213, 73, 90, 0.4); + background: rgba(213, 73, 90, 0.08); + } + .weq-close-remember { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.85rem; + font-size: 12px; + color: var(--weq-fg-secondary); + cursor: pointer; + user-select: none; + } + .weq-close-remember input { + position: absolute; + opacity: 0; + width: 0; + height: 0; + } + .weq-close-remember-box { + display: inline-flex; + flex-shrink: 0; + width: 1rem; + height: 1rem; + border-radius: 0.28rem; + border: 1px solid var(--weq-border-strong); + background: transparent; + transition: background-color 130ms ease, border-color 130ms ease; + } + .weq-close-remember input:checked + .weq-close-remember-box { + border-color: var(--weq-accent-effective); + background: var(--weq-accent-effective) + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E") + center / 0.72rem no-repeat; + } + .weq-close-remember input:focus-visible + .weq-close-remember-box { + outline: 2px solid color-mix(in srgb, var(--weq-accent-effective) 55%, transparent); + outline-offset: 1px; + } + .weq-close-foot { + display: flex; + justify-content: flex-end; + margin-top: 0.9rem; + } + /* ---- first-run welcome dialog (欢迎使用 WeQ) ---- */ /* Fully token-driven so it tracks light/dark. The shared `.weq-modal` shell paints a fixed white gradient with no dark variant, so `.weq-welcome` lays @@ -6352,11 +6484,21 @@ html[data-theme="dark"] .weq-forward-bubble { } .weq-agentlab-field span { + display: inline-flex; + align-items: center; + gap: 6px; font-size: 12px; color: var(--weq-fg-secondary); } +.weq-agentlab-field span svg { + color: var(--weq-fg-muted); + flex: none; +} + .weq-agentlab-field select, +.weq-agentlab-field input, +.weq-agentlab-field textarea, .weq-agentlab-composer textarea { width: 100%; border: 1px solid var(--weq-border-subtle); @@ -6367,11 +6509,16 @@ html[data-theme="dark"] .weq-forward-bubble { font-size: 13px; } -.weq-agentlab-field select { +.weq-agentlab-field select, +.weq-agentlab-field input { min-height: 40px; padding: 0 12px; } +.weq-agentlab-field input::placeholder { + color: var(--weq-fg-muted); +} + /* 选中输入框:边框统一走主题色(聊天 composer / 各配置面板 / 克隆体设置)。 */ .weq-agentlab-field select:focus, .weq-agentlab-field input:focus, @@ -7175,6 +7322,49 @@ html[data-theme="dark"] .weq-forward-bubble { } .weq-clone-check { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--weq-fg-primary); } .weq-clone-check input { flex: none; } +.weq-clone-check > svg { flex: none; color: var(--weq-fg-muted); } +.weq-clone-check.is-disabled { color: var(--weq-fg-muted); cursor: not-allowed; } +.weq-clone-check.is-disabled input { cursor: not-allowed; } + +/* 导出页:可折叠的「使用说明」 */ +.weq-collapse { border: 1px solid var(--weq-border-subtle); border-radius: 10px; overflow: hidden; + background: color-mix(in srgb, var(--weq-bg-surface) 60%, transparent); } +.weq-collapse-head { + width: 100%; display: flex; align-items: center; gap: 8px; padding: 9px 12px; font-size: 12.5px; + color: var(--weq-fg-secondary); background: none; border: none; cursor: pointer; text-align: left; +} +.weq-collapse-head:hover { color: var(--weq-fg-primary); } +.weq-collapse-head > svg:first-child { color: var(--weq-accent-effective); flex: none; } +.weq-collapse-chev { margin-left: auto; flex: none; transition: transform .18s ease; color: var(--weq-fg-muted); } +.weq-collapse-chev.is-open { transform: rotate(180deg); } +.weq-collapse-body { padding: 0 12px 11px; margin: 0; } + +/* 导出页:已导出「运行配置」卡 */ +.weq-bot-runtime { + display: flex; flex-direction: column; gap: 10px; padding: 13px 14px; border-radius: 12px; + border: 1px solid var(--weq-border-strong); + background: color-mix(in srgb, var(--weq-accent-effective) 6%, var(--weq-bg-surface)); +} +.weq-bot-runtime-head { display: flex; align-items: center; gap: 8px; font-size: 12.5px; font-weight: 600; + color: var(--weq-fg-primary); } +.weq-bot-runtime-head > svg { color: var(--weq-accent-effective); flex: none; } +.weq-bot-runtime-port { margin-left: auto; font-size: 11.5px; font-weight: 500; color: var(--weq-fg-muted); + padding: 2px 8px; border-radius: 999px; border: 1px solid var(--weq-border-subtle); } +.weq-bot-keyrow { display: flex; align-items: center; gap: 8px; } +.weq-bot-keyrow > svg { flex: none; color: var(--weq-fg-muted); } +.weq-bot-key { + flex: 1; min-width: 0; font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; font-size: 12px; + color: var(--weq-fg-secondary); background: var(--weq-bg-elevated); border: 1px solid var(--weq-border-subtle); + border-radius: 7px; padding: 6px 9px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.weq-bot-iconbtn { + flex: none; width: 30px; height: 30px; display: grid; place-items: center; border-radius: 7px; + color: var(--weq-fg-secondary); border: 1px solid var(--weq-border-subtle); background: var(--weq-bg-surface); + transition: background .15s, color .15s; +} +.weq-bot-iconbtn:hover { background: var(--weq-bg-elevated); color: var(--weq-fg-primary); } +.weq-bot-urlrow { display: flex; align-items: center; gap: 8px; } +.weq-bot-urlrow .weq-set-input { flex: 1; min-width: 0; } /* 「分析表情包」开关卡:勾选后在同一张卡内露出视觉模型选择,避免选择框孤零零格格不入。 */ .weq-clone-toggle-card { diff --git a/apps/desktop/src/renderer/src/views/MainView.tsx b/apps/desktop/src/renderer/src/views/MainView.tsx index d91fe6e..0fa4459 100644 --- a/apps/desktop/src/renderer/src/views/MainView.tsx +++ b/apps/desktop/src/renderer/src/views/MainView.tsx @@ -2478,18 +2478,24 @@ export function MainView(): ReactElement { const scroll = document.querySelector('.weq-readonly-chat .message-scroll'); if (!scroll) return undefined; - let secondFrame: number | null = null; - const firstFrame = window.requestAnimationFrame(() => { - secondFrame = window.requestAnimationFrame(() => { - scroll.scrollTop = Math.max(0, scroll.scrollHeight - restore.previousHeight + restore.previousTop); - pendingScrollRestoreRef.current = null; - }); + const apply = (): void => { + scroll.scrollTop = Math.max(0, scroll.scrollHeight - restore.previousHeight + restore.previousTop); + }; + + // Restore synchronously (before paint) so the freshly prepended page keeps the + // reading position in a single layout pass. Deferring the whole restore with rAF + // let the browser paint one frame where scrollHeight had already grown but + // scrollTop was still at its old (near-top) value — which made the overlay + // scrollbar thumb snap to the top and back ("上下乱串"). We re-apply once more on + // the next frame in case a late reflow (e.g. a message measuring itself) shifts + // the height; with a stable height this is a no-op, so no visible jitter. + apply(); + const frame = window.requestAnimationFrame(() => { + apply(); + pendingScrollRestoreRef.current = null; }); - return () => { - window.cancelAnimationFrame(firstFrame); - if (secondFrame !== null) window.cancelAnimationFrame(secondFrame); - }; + return () => window.cancelAnimationFrame(frame); }, [selectedConversation?.id, templateMessages.length]); useEffect(() => { @@ -2652,7 +2658,7 @@ export function MainView(): ReactElement { ) @@ -2712,12 +2718,12 @@ export function MainView(): ReactElement { ) diff --git a/apps/desktop/src/renderer/src/views/agentlab/PersonaSettingsModal.tsx b/apps/desktop/src/renderer/src/views/agentlab/PersonaSettingsModal.tsx index 172448a..9e252ed 100644 --- a/apps/desktop/src/renderer/src/views/agentlab/PersonaSettingsModal.tsx +++ b/apps/desktop/src/renderer/src/views/agentlab/PersonaSettingsModal.tsx @@ -7,8 +7,31 @@ * ⑤ 导出好友(占位,依赖 AI tool 导出能力) */ -import { useState, type ReactElement, type ReactNode } from 'react'; -import { BarChart3, Download, FileText, Mic, Settings, Brain, Gauge, X } from 'lucide-react'; +import { useState, useMemo, type ReactElement, type ReactNode } from 'react'; +import { + BarChart3, + Download, + FileText, + Mic, + Settings, + Brain, + Gauge, + X, + Plug, + Link2, + KeyRound, + UserRound, + AudioLines, + Users, + Globe, + ChevronDown, + ExternalLink, + Copy, + Eye, + EyeOff, + Info, + Image as ImageIcon, +} from 'lucide-react'; import { Modal } from '../../components/Dialog'; import { trpc } from '../../trpc/client'; import { useAppDialog } from '../../lib/dialogUtils'; @@ -143,16 +166,6 @@ function WillingTab({ ); } -function Soon({ text }: { text: string }): ReactElement { - return ( -
- -

{text}

- 即将推出 -
- ); -} - type VoiceBinding = { providerId: string; mode: 'clone' | 'preset'; voice?: string }; /** ③ 语音克隆:门控(TA 发过语音 + 配了 TTS)+ 服务商/音色方式选择。 */ @@ -297,6 +310,253 @@ function VoiceTab({ ); } +/** 已导出记录卡片:查看 WebUI 密钥 + 一键打开控制台(连不上时提示输地址)。 */ +function ExportRuntimeCard({ personaId }: { personaId: string }): ReactElement | null { + const dialog = useAppDialog(); + const info = trpc.account.getAgentLabExportInfo.useQuery({ personaId }); + const openMut = trpc.account.openBotWebUi.useMutation(); + const [showKey, setShowKey] = useState(false); + const [urlInput, setUrlInput] = useState(null); + + const data = info.data; + if (!data) return null; + + async function openConsole(url?: string): Promise { + try { + const r = await openMut.mutateAsync({ personaId, url }); + if (r.needUrl) { + setUrlInput(url ?? r.defaultUrl); + dialog.error('连不上控制台', '默认地址没响应。请确认 bot 已 npm start,或在下方填入实际访问地址后重试。'); + } else if (r.opened) { + setUrlInput(null); + } + } catch (e) { + dialog.error('打开失败', e instanceof Error ? e.message : String(e)); + } + } + + async function copyKey(): Promise { + try { + await navigator.clipboard.writeText(data!.key); + dialog.success('已复制', 'WebUI 密钥已复制到剪贴板'); + } catch { + /* 剪贴板不可用则忽略 */ + } + } + + const maskedKey = showKey ? data.key : `${data.key.slice(0, 6)}${'·'.repeat(12)}${data.key.slice(-4)}`; + + return ( +
+
+ + 运行配置 · 已导出 + 端口 {data.port} +
+
+ + {maskedKey} + + +
+ {urlInput !== null ? ( +
+ setUrlInput(e.target.value)} + placeholder="http://127.0.0.1:8090" + /> + +
+ ) : null} +
+ +
+
+ ); +} + +/** 「导出好友」页:填 napcat/snowluma 连接信息,把克隆体导出成独立 bot 产物。 */ +function ExportTab({ + persona, +}: { + persona: { id: string; name: string; voiceCloneEnabled?: boolean }; +}): ReactElement { + const dialog = useAppDialog(); + const utils = trpc.useUtils(); + const exportMut = trpc.account.exportAgentLabPersona.useMutation(); + const providers = trpc.bootstrap.listAgentLabProviders.useQuery(); + const canVoice = !!persona.voiceCloneEnabled; + const [introOpen, setIntroOpen] = useState(false); + const [adapterType, setAdapterType] = useState<'napcat' | 'snowluma'>('napcat'); + const [wsUrl, setWsUrl] = useState('ws://127.0.0.1:8081'); + const [token, setToken] = useState(''); + const [selfId, setSelfId] = useState(''); + const [voice, setVoice] = useState(canVoice); + const [groupChat, setGroupChat] = useState(false); + const [groupReplyMode, setGroupReplyMode] = useState<'llm' | 'heuristic'>('llm'); + const [webuiPort, setWebuiPort] = useState('8090'); + // 图像模型(可选):写进导出 persona 的 models.vision,供 bot 解析「WebUI 上传的新表情」。 + // 留「(不设置)」则沿用克隆时的 vision(若有)。 + const visionOptions = useMemo( + () => + (providers.data ?? []).flatMap((p) => + p.models + .filter((m) => m.capabilities.includes('vision')) + .map((m) => ({ key: `${p.id}::${m.id}`, providerId: p.id, model: m.id, label: `${p.name} · ${m.label ?? m.id}` })), + ), + [providers.data], + ); + const [visionKey, setVisionKey] = useState(''); + + async function doExport(): Promise { + if (!wsUrl.trim()) { + dialog.error('缺少信息', '请填写 WebSocket 地址'); + return; + } + if (!selfId.trim()) { + dialog.error('缺少信息', '请填写 bot 的 QQ 号'); + return; + } + const portNum = Number(webuiPort.trim()); + if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) { + dialog.error('端口无效', 'WebUI 端口需为 1–65535 之间的整数'); + return; + } + try { + const vision = visionOptions.find((o) => o.key === visionKey); + const r = await exportMut.mutateAsync({ + personaId: persona.id, + adapterType, + wsUrl: wsUrl.trim(), + token: token.trim() || undefined, + selfId: selfId.trim(), + voice: voice && canVoice, + groupChat, + groupReplyMode, + webuiPort: portNum, + visionModel: vision ? { providerId: vision.providerId, model: vision.model } : undefined, + }); + if (r.canceled) return; + await utils.account.getAgentLabExportInfo.invalidate({ personaId: persona.id }); + dialog.success( + '导出成功', + `已导出到:\n${r.outDir}\n\n表情 ${r.stickerCount} 张 / 语音参考 ${r.voiceClipCount} 条。\n\n` + + `WebUI 控制台:${r.webui.url}\n访问密钥:${r.webui.key}\n(请妥善保管,也可在下方「运行配置」或产物 README 里查看)\n\n` + + `进入该目录执行 npm install && npm start 即可让 bot 上线。`, + ); + } catch (e) { + dialog.error('导出失败', e instanceof Error ? e.message : String(e)); + } + } + + return ( +
+
+ + {introOpen ? ( +

+ 把「{persona.name}」导出成独立机器人:连接 NapCat / SnowLuma 后即可作为真 QQ 机器人上线。导出时会自动生成 + WebUI 控制台的访问密钥与编号(可查看 token 消耗、收发统计与克隆体总览)。导出的 + config.json 含 API Key 与访问密钥,请妥善保管产物文件夹。 +

+ ) : null} +
+ + + + + + + + + + + + + + + + + + + + {groupChat && ( + + )} + +
+ +
+
+ ); +} + export function PersonaSettingsModal({ persona, paramsContent, @@ -378,7 +638,7 @@ export function PersonaSettingsModal({ ) : tab === 'memory' ? ( ) : ( - + )} diff --git a/native/win32/x64/nt_helper.node b/native/win32/x64/nt_helper.node index 1c500d3..5db3bc2 100644 Binary files a/native/win32/x64/nt_helper.node and b/native/win32/x64/nt_helper.node differ diff --git a/package.json b/package.json index 44470cb..1380fb6 100644 --- a/package.json +++ b/package.json @@ -11,12 +11,14 @@ }, "scripts": { "dev": "pnpm --filter @weq/desktop dev", - "build": "pnpm --filter @weq/desktop build", + "build": "pnpm run build:bot && pnpm --filter @weq/desktop build", + "build:bot": "esbuild packages/bot/src/index.ts --bundle --platform=node --format=esm --external:ws --outfile=resources/bot-runtime/bot.mjs", "proto": "pnpm --filter @weq/protolab dev", "typecheck": "pnpm -r typecheck", "lint": "pnpm -r lint" }, "devDependencies": { + "esbuild": "^0.28.1", "tsx": "^4.22.4", "typescript": "^5.7.3" } diff --git a/packages/agentlab/BACKEND_ROADMAP.md b/packages/agentlab/BACKEND_ROADMAP.md deleted file mode 100644 index 20f65f0..0000000 --- a/packages/agentlab/BACKEND_ROADMAP.md +++ /dev/null @@ -1,49 +0,0 @@ -# AgentLab 后端补全 Roadmap(重后端,另开会话做) - -> 这些是 AgentLab 的**重后端**任务,需要去两个外部大仓找借鉴点: -> - CipherTalk:`C:\Users\17078\Documents\GitHub\CipherTalk`(蒸馏 / 画像 / 表达风格) -> - MaiBot:`C:\Users\17078\Documents\GitHub\MaiBot`(调用引擎 / 反 GPT 味 / 记忆演化) -> 读这两个仓的大文件会**撑爆上下文**,所以单独开干净会话做,先读本文件 + `ROADMAP.md`(§3 调用层)。 -> 来源:用户 2026-06-29 需求陈述。前端侧进度见 `FRONTEND_ROADMAP.md`。 - -## 前置(前端会话已做的轻后端) -- ✅ WeQ助手:调用已注册 AI tool registry(前端会话接线)。 -- ✅ 聊天记录持久化:和克隆体/助手的对话落盘(基础存储;记忆机制在下方 §3 深化)。 -- ✅ token 按模型记账(供主页图表)。 -> 若前端会话只做了占位,这里要补实现。 - -> 进度更新:2026-06-29。B1/B2/B3 + C1~C5 已实现(见下方 ✅);仅剩 §5「其它 AI tool(导出)」未做。 - -## 必做项 - -### 1. 反 GPT 味(参考 MaiBot)✅ -- ✅ `http.ts` `buildSystemPrompt` 强化反 GPT 味约束(平淡简短、禁 markdown/列表/序号/冒号前缀/旁白、不浮夸、别像客服,借 MaiBot 的 reply_style / 输出指令)。 -- ✅ 错别字后处理:新增 `packages/agentlab/src/typo.ts`(`humanizeText`)——手挑同音/形近混淆组 + 偶尔吃句尾句号,确定性 PRNG,默认强度 ~0.18,逐条作用在分段后的消息上。MaiBot 用 pypinyin/jieba,这里降级为离线词表近似。 - -### 2. 聊天记录持久化存储(深化)✅(已完成) -- 和克隆体/助手对话的结构化存储 `agentlab_conversation.ts`。供 §3 记忆蒸馏与未来导出 bot client 持续积累。 - -### 3. 克隆体记忆机制 + 人物画像机制(参考 MaiBot)✅ -- ✅ 存储:`packages/service/src/account/agentlab_memory.ts`(`MemoryStore`,按 personaId 分桶落 `memories.json`)。每条带 `accessCount`/`lastAccessedAt`,容量超限按「强度=access 热度+时间新鲜度」淘汰 → access_count 衰减式遗忘。 -- ✅ 检索:`http.ts` `rankMemories`——关键词重合(BM25 兜底)+ access 强度加成;无命中也带最近/最常想起的几条保持连续感。命中的 id 回报给 service `touch()` +access。 -- ✅ 形成:`extract.ts` `distillMemories`——每 6 个用户回合从最近对话蒸馏「克隆体对对方(用户)」的新事实,fire-and-forget 不阻塞回复。 -- ✅ 注入:system prompt 的「你记得关于对方的事」块。对应前端灯箱「记忆 / 画像」(已接线 `getAgentLabMemories`/`forgetAgentLabMemory`/`clearAgentLabMemories`)。 - -### 4. 画像提示词微调 / 表达风格库(参考 CipherTalk / MaiBot)✅ -- ✅ 表达风格库:`extract.ts` `extractExpressions` 蒸馏期多抽 `(情境,句式)` 对,存 `persona.expressions`;`http.ts` `selectExpressions` runtime 按情境关键词 + count 选几条注入 prompt(低优先「看情况自然用」)。比口头禅更细,缓解「偏口头禅」问题。 -- ⏳ 仍可深化:向量检索选择(现为关键词+count 加权)、AI 复审 expression 质量。 - -### 5. 补全 WeQ AI tool 其它能力 ❌(未做,唯一剩项) -- 例如**导出**(对应前端灯箱「导出好友」仍是占位)。复用 `packages/service/src/account/export/` 链路。 -- 把更多 WeQ 操作注册成 AI tool 供 WeQ助手调用。 - -### 6. token 按模型记账 ✅(已完成) -- `agentlab_usage.ts`,主页图表已接。 - -## 调用层(ROADMAP §3,性价比顺序) -1. ✅ 分段连发 + 打字延迟(`http.ts` 用单行 `---` 约定分条,前端逐条揭示 + typing 动画 + `replyDelayMs`) -2. ✅ 表达风格库(同 §4) -3. ✅ 反 GPT 味(同 §1) -4. ✅ 记忆 BM25 / 衰减(同 §3) -5. ✅ 回复意愿评分(`willing.ts` `scoreReplyWillingness`:内容分 + 存在感惩罚 → 0~1,调节 temperature/分条数/`replyDelayMs`;1:1 不抑制回复,只调「上心程度」) -6. ⏳ 关系三元组 / 知识图谱(远期,未做) diff --git a/packages/agentlab/FRONTEND_ROADMAP.md b/packages/agentlab/FRONTEND_ROADMAP.md deleted file mode 100644 index 9521bfd..0000000 --- a/packages/agentlab/FRONTEND_ROADMAP.md +++ /dev/null @@ -1,81 +0,0 @@ -# AgentLab 前端补全 Roadmap(执行中) - -> 本会话负责「前端补全 + 轻后端」。重后端(反 GPT 味 / 记忆机制 / 画像提示词微调)见 `BACKEND_ROADMAP.md`,另开干净会话做。 -> 来源:用户 2026-06-29 的需求陈述(真相源)。每次接着做先读本文件。 - -## 0. 范围与策略 - -- **本会话做**:前端全部 + 轻后端三件(① WeQ助手调用已注册 AI tools ② 聊天记录持久化 ③ token 按模型记账,供图表用)。 -- **占位 + 写进 BACKEND_ROADMAP**:反 GPT 味、克隆体记忆/画像机制、画像提示词微调、WeQ AI tool 其它能力(如导出)。 -- **复用优先**:聊天渲染复刻 QQ → 复用现有 `apps/desktop/src/renderer/src/im-template/`(chatShell/rail/sidebarHeader);好友选择列表 → 参考现有好友列表实现(带头像/昵称,走 `useProfileResolver`)。 - -## 1. 设置页面优化 - -`apps/desktop/src/renderer/src/components/SettingsDialog.tsx` + `components/settings/*`。 -- 按钮太大、风格不统一 → 统一按钮尺寸/样式(`weq-set-btn` 体系)。 -- 保存成功无提示 → 加 toast/inline 成功反馈(复用 `useAppDialog` 或加轻量 toast)。 -- 通查各分区交互一致性(间距、标题、空态)。 - -## 2. AgentLab 页面完全重写 - -入口 `views/AgentLabView.tsx`(推倒重写)。核心概念:AgentLab 里有两类 **agent**—— -**WeQ助手** 和 **好友Skills(好友克隆)**。 - -### 2.1 杂项 -- 左上角搜索按钮 → 在 CSS 里隐藏(`styles/index.css`)。 - -### 2.2 两类 agent - -**WeQ助手**(预设 agent) -- 预设好,调用**已注册的 AI tools**(复用工具注册表,见记忆 `mcp-server-feature` 的 transport-agnostic 工具注册表)帮用户完成操作。 -- 聊天页**顶部设置按钮** → 弹出灯箱:自定义模型配置、额外提示词、外部 MCP 服务器等。 - -**好友Skills(好友克隆)**= 现在做的克隆功能。 -- **新建**:弹出好友**列表选择**(参考其它好友列表实现,带头像/昵称)。 -- 选完 → 配置:选模型 / 是否分析表情 / **克隆程度**(高=遍历全部对话;低=只取最新 N 条)。 - - UI 明确提示:**高克隆度 = 更大 token 消耗**。 - - 接后端:克隆程度映射到 `buildAgentLabFromC2c` 的 `limit`(高→C2C_SAFETY_CAP 全量;低→小 N)+ `models.vision` 有无(是否分析表情)。 - -### 2.3 克隆进度条 -- 构建过程分阶段(拉语料/转录/表情/画像/向量),用 tRPC subscription 或轮询回报进度 → 进度条。 - -### 2.4 聊天页面复刻 QQ 渲染 -- 和好友/助手聊天页 **复用 `im-template`** 渲染(气泡、头像、昵称、分条),不要另起一套。 - -### 2.5 好友顶部设置灯箱(5 项) -1. 查看训练参数(现有 PersonaParamsPanel 内容搬进灯箱) -2. 自定义额外提示(customPrompt 编辑) -3. 是否开启语音克隆(开关,先占位绑定 `models.voiceClone`) -4. 对自己的记忆 / 画像(占位 → 依赖后端记忆机制) -5. 导出好友(占位 → 依赖 WeQ AI tool 导出能力) - -## 3. Token 统计 + 主页图表 - -- **轻后端**:在 agentlab 的所有 LLM 调用处(`packages/agentlab/src/{http,extract}.ts`)回收 `usage`(OpenAI 兼容返回 `usage.{prompt,completion,total}_tokens`),按 **模型 / 克隆体 / 时间** 记账,持久化(service 层 store)。 -- **主页(未选任何 agent)**:展示 消耗 / 各模型消耗 / 各克隆体消耗 / 时间消耗 等统计图表。 - -## 实施顺序(低风险自洽 → 高耦合) -1. 设置页优化(自洽) -2. AgentLab 重写骨架:分层(助手/好友Skills) + 新建好友选择弹窗 + 模型/克隆度配置 + 进度条 -3. 复用 im-template 复刻聊天渲染 -4. 好友顶部 5 项设置灯箱(部分占位) -5. token 记账 + 主页图表(含轻后端记账) -6. WeQ助手 tool-calling(接工具注册表) - -## 状态(更新 2026-06-29) -- [x] 1 设置页优化(`weq-set-btn` 体系 + Toast 反馈) -- [x] 2 AgentLab 重写骨架(助手/好友克隆分层、好友选择弹窗、模型/克隆度配置、进度条 subscription) -- [x] 3 聊天渲染复刻(复用 im-template 气泡) -- [x] 4 好友设置灯箱:①训练参数 ②额外提示 ③语音克隆开关 均已实现;④记忆/画像 **已接线**(`MemoryTab` + getAgentLabMemories);⑤导出好友 仍占位(依赖后端 AI tool 导出) -- [x] 5 token 记账 + 图表 -- [x] 6 WeQ助手 tool-calling(openai_tools 注册表) -- [x] 补充:分段连发 + 打字延迟(onSend 逐条揭示 + `.weq-agentlab-typing` 动画,吃后端 `segments`/`replyDelayMs`) - -- [x] 系统表情渲染:克隆体回复里的 `/捂脸` 这类 faceText 在气泡里渲染成表情图。 - `emoji.db`→`EmojiService.listSystemFaces`→`account.getSystemFaces`→`ChatBubble` 用 persona.systemFaces 白名单 + faceText→faceId 映射(复用 `FaceEmoji`)。 -- [x] 分段连发逐条落库:`chat()` 每个 segment 存为独立 assistant turn,重启/切换历史保持分句。 -- [x] 历史实时性:发送后 / 切入克隆体时 invalidate `getAgentLabConversation`,修复「切走切回丢历史」。 - -### 仅剩 -- ⑤ 灯箱「导出好友」——等后端 BACKEND_ROADMAP §5 的导出 AI tool。 -- 克隆体「发自定义表情包 / 发语音」——见 BACKEND_ROADMAP(需 sticker 引用协议 / TTS 语音克隆,应用层)。 diff --git a/packages/agentlab/ROADMAP.md b/packages/agentlab/ROADMAP.md deleted file mode 100644 index 503b80f..0000000 --- a/packages/agentlab/ROADMAP.md +++ /dev/null @@ -1,187 +0,0 @@ -# AgentLab(好友克隆)路线图与设计锚点 - -> 这份文档是「好友克隆 / 数字分身」功能的设计真相源(source of truth)。 -> 每次接着做之前先读它,避免重新捋一遍。会持续更新。 -> 最后更新:2026-06-29(调用层 C1~C5 + 记忆/表达风格库已落地) - ---- - -## 0. 一句话愿景 - -把某个 QQ 好友**蒸馏**成一个稳定人设(借鉴 CipherTalk),再用一套**像真人**的调用引擎驱动它(借鉴 MaiBot)。 -一个好友可以被**多次克隆**——换模型、换提示词就是一个新的克隆体。 -克隆体自带**人物画像 + 记忆系统**,未来可以:建自己的群聊、导出成 bot client 接入 QQ 适配器真实聊天。 - -``` -CipherTalk = 蒸馏层(build-time):聊天记录 → 画像/few-shot/问答对/表情/语音偏好 -MaiBot = 调用层(runtime) :何时说/说几条/读空气/表达学习/记忆演化/反GPT味 -``` - -参考点详见对话记录与 `packages/agentlab/`(CipherTalk 仓库在 `Documents/GitHub/CipherTalk`,MaiBot 在 `Documents/GitHub/MaiBot`)。 - ---- - -## 1. 克隆体(Agent)完整数据模型(目标) - -一个训练出来的克隆 AI = 一条 `AgentLabPersona` 记录,存为 `/agentlab//.json`: - -``` -AgentLabPersona { - id // personaId(同一好友可多条) - ownerId // 当前登录账号 uin - name // 用户起的名字(可自定义,默认取好友昵称) - source: { // QQ 相关 profile(克隆对象) - kind: 'c2c'|'group' - targetId // uid - uin, nick, remark - avatarPath? // 可选缓存头像 - } - models: { // 每个任务选哪个 provider+model(model 在 agent 里选,不在 provider 存) - chat: { providerId, model } - embedding?: { providerId, model } - vision?: { providerId, model } // 解读表情包内容 - voiceClone?: { providerId, model } // 未来:应用层做,这里只存绑定 - image?: { providerId, model } // 未来:发图 - } - customPrompt? // 用户自定义提示,拼进 system prompt - profile: AgentLabPersonaProfile // 提取出来的内容(画像卡 + 深层 + 风格 + 语音偏好 + 表情偏好) - fewShots: ... - stats: ... - stickers: AgentLabStickerRef[] // 高频自定义表情包(pic elementType=2 subType=1,本地缓存+解读+场景);不含 mface - systemFaces?: string[] // TA 实际用过的系统表情 faceText 白名单(如 /捂脸 /旺柴);prompt 只许从这里选,防 LLM 造 /吃饭(见 2.3b) - voiceProfile?: { ratio, scenarioSummary } // 语音使用场景(LLM 总结) - memory: AgentLabMemory // 对话记忆——我们和克隆体的对话,不是 QQ 记录(参考 MaiBot) - createdAt, updatedAt // 最近更新时间 -} -``` - -要点: -- **provider 与 model 解耦**:设置里只存 provider(厂商+base_url+api_key+可用模型列表);每个 agent 自己选用哪个 provider 的哪个 model 做哪件事。 -- **同一好友可多次克隆**:personaId 不同即可,换 models/customPrompt 就是新克隆体。 -- **memory 是"我们和克隆体的对话"**,不是 QQ 聊天记录。QQ 记录只在蒸馏期用。memory 供未来导出 bot client 后持续积累(所以现在就预留结构)。 - -### Provider 数据模型(设置里存的,抄 MaiBot) - -``` -ProviderConfig { - id, name // name 用户可改的显示名 - vendor // 模板 id:'siliconflow'|'openai'|'deepseek'|'zhipu'|'moonshot'|'ollama'|'openai-compatible' - baseUrl, apiKey - models: ProviderModel[] // { id, label?, capabilities: ('chat'|'embedding'|'vision')[] } - createdAt, updatedAt -} -``` -- 厂商模板(catalog)给:label、默认 baseUrl、推荐模型(带 capability)。**重点推荐硅基流动 SiliconFlow**(`https://api.siliconflow.cn/v1`)。 -- 设置页只管 provider 的增删改 + 测试连通;model 列表可从模板带入,也可手填。 - ---- - -## 2. 蒸馏层(提取)设计——本轮要补的 CipherTalk 高价值部分 - -### 2.1 语料获取策略(重要改动:不再"取最近 N 条") - -旧方案:`listLatest(limit)` 取最近 N 条 —— 已废弃。 -新方案(一问一答/对话窗口价值优先): -1. **拉完整对话**:从最新往回 `listBefore` 翻页直到耗尽(设一个安全上限,如 20000 条防极端)。 -2. **找高价值语料**:轮次合并 → 抽真实问答对/对话窗口。"价值"= 合并后对方消息数 + 有效问答对数。 -3. **阈值兜底**: - - 私聊高价值语料 < 阈值(如对方有效消息 < 50)→ 去**群聊补采**(该好友所在群里 TA 的发言,只学风格不做问答对,同 CipherTalk)。 - - 私聊 + 群聊仍 < 阈值 → **提示失败**(语料太少,不足以克隆)。 - -### 2.2 语音(保留本地 wav 路径 + 转录) - -- 仅当**设置里配了转录模型且已下载**(`voiceTranscribe.getModelStatus(id).downloaded`)才转录;否则整体跳过、不阻断克隆。 -- 流程:pttElement → `FileSearchService.findFile(ts,name,'ptt')` 取本地 silk(缺失则 `MediaDownloadService.download(fileToken,{ext:'.silk'})`)→ `decodeSilkToWav16kBuffer` 转录 → 文本进语料;同时 `decodeSilkToWav` 保留一份 wav 到缓存,路径记下来方便克隆使用。 -- 语音使用**场景**:统计 voiceRatio + 调 chat 模型总结"TA 什么场景爱发语音"(可精细化)。 - -### 2.3 自定义表情包(真正的"表情"= pic 元素 elementType=2 + subType=1,**不是 mface**) - -- ⚠️ **元素辨认(容易搞错)**: - - ✅ **要做**:自定义表情包 = pic 元素(NTQQ wire `elementType=2`)且 `subType===1`。在 codec 里就是 `kind:'pic' && subType===1`。 - - ❌ **不做**:**mface = QQ 商城表情**(不同的 element,codec 里是 marketface 类)——直接跳过、不解析。别把它当表情包。 - - faceElement(系统表情)走 2.3b,不在这条。 -- 统计高频表情包 → 本地缓存(照抄导出的寻址+CDN补全:`scanConvMedia` / `downloadMissingImages` / `MediaDownloadService.download(fileToken)`,缓存目录 `userConfig.cacheDir('media')` 或 agentlab 专属)。 -- **图像模型解读**表情内容(vision model,从 agent.models.vision 取)+ chat 模型总结**使用场景**("TA 在什么语境发这张")。存成 `AgentLabStickerRef { md5, localPath, cdnToken, count, description, scenario }`。 - -### 2.3b 系统表情 faceElement(`/捂脸` 这类)—— 必须约束成"从固定表里选",不能学成"斜杠+动作" - -- faceElement = QQ **系统表情**,渲染成 `/捂脸`、`/笑哭` 这种(codec: `kind:'face'`,有 `faceId` + `faceText`)。蒸馏期当文本即可(取 `faceText`)。 -- ⚠️ **关键坑(务必给大模型讲清楚)**:QQ 系统表情是一个**有限固定集合**,每个 `/xxx` 对应一个 `faceId`。 - - 如果让 LLM 把风格总结成"爱用斜杠+动作(如 `/捂脸`)",它聊天时就会自由发挥输出 `/吃饭`、`/睡觉` 这种**根本不存在的系统表情**,渲染端无法映射成表情图,变成哑文本 `/吃饭`。 - - 正确做法:**给 LLM 一份常用系统表情清单**(faceText 白名单,如 `/微笑 /笑哭 /捂脸 /流泪 /旺柴 /doge …`),明确说"系统表情只能从下面这个列表里挑用,绝不要自己造 `/动作`"。 - - 提取风格卡时同理:别让 card 写成"爱用 `/动作`",要落到"常用这几个系统表情:`/捂脸 /旺柴 …`"——从真实语料里**统计 TA 实际用过的 faceText 子集**,只把这个子集喂进画像/prompt。 - - 未来渲染:`faceText → faceId` 直接静态映射回系统表情图(系统表情表是固定的,可建一张静态映射表)。 - -### 2.4 画像提取(已做:轮次合并 + LLM card/deep/fewshot) - -已完成,见 `persona.ts` / `extract.ts`。本轮在其基础上接入 2.1 的全量语料 + 2.2/2.3 的语音/表情产物。 - -### 2.5 明确不做(本阶段) - -- ❌ 自动进化(refreshIfStale / reflect 导演笔记)——意义不大,不做。 -- ❌ "最近在聊"记忆注入(蒸馏期的 episodic)——不做。 -- ⏸ 语音克隆——很有趣,但放**应用层**做,不在提取层;这里只在 models 里预留 voiceClone 绑定。 - ---- - -## 3. 调用层设计(MaiBot 参考,后续阶段) - -优先级(性价比): -1. **分段连发 + 打字延迟**(前端 + `\n---\n` 约定,最快去人机感) -2. **表达风格库**(蒸馏期多抽 `(情景,句式)`,复用向量检索;MaiBot `expression_learner`) -3. **prompt 反 GPT 味约束 + 可选错别字后处理**(MaiBot `typo_generator`) -4. **记忆加 BM25 兜底 + access_count 衰减** -5. **回复意愿评分**(存在感惩罚/空窗补偿;等克隆体自动挂会话再上) -6. 关系三元组 / 情景聚合 / 知识图谱(远期,别早做) - -视角转换(务必记住):MaiBot 是「AI 记住你」,WeQ 是「AI 变成 TA」——表达学 TA 自己的、记忆存 TA 的、回复意愿按"TA 会不会回"。 - ---- - -## 4. 未来 - -- 新建群聊:克隆体自己的群聊(多 agent 互动)。 -- 导出 bot client:接入 QQ 适配器真实聊天 → 所以**现在就预留人物画像 + 记忆系统**。 - ---- - -## 5. 实施进度 - -- [x] 轮次合并 + LLM 画像(card/deep/fewshot)+ 问答对向量检索(`persona.ts`/`extract.ts`/`http.ts`) -- [x] 沉浸式 system prompt(反"念设定卡",借 CipherTalk `personaChatEngine`) -- [x] **Thing 2**:provider 机制重构完成。 - - `ProviderConfig` 改为 `{ vendor, baseUrl, apiKey, models: [{id,label,capabilities[]}] }`,**不再存 chatModel/embeddingModel**。 - - model 解耦:每个 agent 存 `models: { chat, embedding?, vision?, voiceClone? }`(ref = providerId+model)+ `customPrompt` + `name`。 - - 厂商模板 `catalog.ts`:硅基流动(推荐)/deepseek/智谱/moonshot/dashscope/openai/ollama/openai-compatible,模型带 capability。 - - http/extract 改吃 `AgentLabEndpoint{baseUrl,apiKey,model}`;`runPersonaChat(chat, embedding|null, req)`。 - - 解析:`AgentLabConfigService.resolveEndpoint(ref)`,注入 `AgentLabService`(app_context 传 resolver)。 - - 设置页 `AgentLabSection` 重写:厂商模板下拉 + 模型列表编辑器(capability 勾选 + 导入模板推荐)。 - - `AgentLabView` 构建流程改为按任务选模型 + 名称 + 自定义提示;chat 不再传 providerId。 - - ⚠️ 破坏性变更:旧 provider/persona(含 chatModel/embeddingModel/无 vendor/无 models)加载后会被过滤/失效,需重新添加 provider、重建 persona。早期阶段可接受。 -- [x] **Thing 1**:全量语料 + 群聊补采 + 阈值兜底;语音转录(本地wav)+场景总结;表情包缓存+vision解读+场景总结;系统表情白名单。(见 `agentlab.ts` 各私有步骤) -- [x] 调用层:分段连发+打字延迟 ✓ → 表达风格库 ✓ → 反GPT味+错别字 ✓ → 记忆BM25/衰减 ✓ → 回复意愿评分 ✓ - - 分段:`http.ts` 单行 `---` 约定分条 + 前端逐条揭示/typing 动画。 - - 表达风格库:`extract.ts:extractExpressions` 蒸馏 (情境,句式) → `http.ts:selectExpressions` runtime 注入。 - - 反GPT味:`http.ts:buildSystemPrompt` 约束 + `typo.ts:humanizeText` 错别字后处理。 - - 记忆:`agentlab_memory.ts` 存(access 衰减遗忘)+ `http.ts:rankMemories`(BM25兜底)+ `extract.ts:distillMemories`(每6回合蒸馏)。 - - 回复意愿:`willing.ts:scoreReplyWillingness`(内容分+存在感惩罚 → 调 temperature/分条/延迟)。 -- [x] 蒸馏/调用细腻度增强(对标 CipherTalk 三项,2026-06-30): - - **深层画像 map-reduce**:`deep` 不再一次性喂最近语料,改全量历史切块(`persona.ts:renderProfileChunks`,块 10000 字 × 最多 12 块,近况优先)→ 并发 3 `extract.ts:extractProfileChunk` → `mergeProfileParts` 合并;新增 `sharedEvents`(共同经历)维度。service `agentlab.ts:extractDeepProfileMapReduce` 编排,内部不抛(deep 失败不拖垮 card/fewShots)。 - - **对话反思**(自动,每 8 用户回合):`extract.ts:reflectConversation` 提炼 corrections(用户对扮演的纠正,必须遵守)+ episode(对话摘要);存 `service/account/agentlab_notes.ts:NotesStore`(notes.json,corrections cap 20 / episodes cap 8 / reflectedCount 水位);`agentlab.ts:maybeReflect` fire-and-forget;`http.ts:buildSystemPrompt` 注入【扮演纠正】+【你们最近聊过】。与 memory 并存(粒度不同)。 - - **表情包使用情境 contexts**:`AgentLabStickerRef.contexts`(TA 发这张前最近对话短句 ≤3);`collectStickersAndFaces` 维护 lastText 收集;作为 `describeSticker` 的专属 hint(替代全局语料切片),并进 `sticker.ts` 运行时选表情评分。 -- [x] **语音克隆 + 表情自知化 + TTS 多厂商**(2026-06-30,借鉴 MaiBot「动作模型 + 模型自知」,**保持单次调用、不上多轮工具循环**): - - **有序自知动作**:`runPersonaChat` 输出解析成 `AgentLabChatAction[]`(text/sticker/voice,`http.ts:parseActions`);service `chat()` 按序落库 + 返回 `renderedTurns` 供前端逐条揭示。 - - **表情自知化**:prompt 列**编号的真实表情清单**(只列有 description 的),模型 `[[发表情:序号]]` 按内容自己选;`resolveStickerToken` 用同一份过滤清单做编号映射;情绪词/md5 兜底。替代旧「吐情绪词→盲匹配」。 - - **真·语音克隆**:用 TA 真实语音做参考音频复刻。蒸馏期 `transcribePtt` 收 wav+文本+`voiceChanged`+`waveform`,`mapC2cMessages` 只收好友语音、**排除变声**、`scoreVoiceClip`(waveform 有声占比 + 时长 3~10s + 文本)挑 Top-5 → `voiceProfile.refClips`。运行时 `[[语音]]` 前缀 → service `synthesizeVoice` → `agentvoice/.` → `[[voice:id]]` turn → `weq-media://agentvoice` → `ChatBubble:VoiceBubble`。门控:TA 发过语音 + 配了 TTS(`PersonaSettingsModal:VoiceTab`,存 `persona.voice` 绑定)。 - - **TTS 多厂商**:`packages/service/src/common/tts.ts`(照 MaiBot tts_voice_plugin 全套 7 家,返回解码五类);复刻型 cosyvoice(Gradio 免费)+gpt-sovits(本地);配置 `AppSettings.voiceTranscribe.ttsProviders` + bootstrap tRPC + 设置页「语音配置」。 - - 待真机:登录 QQ 实测发表情准不准 / 语音克隆音色像不像;CosyVoice 公共空间慢会阻塞回复。 -- [ ] 远期未做:表达风格库向量检索化 + AI 复审;关系三元组/知识图谱;导出 bot client AI tool(前端灯箱「导出好友」等它)。 -- [ ] 未来:群聊(多 agent 互动) / bot client 导出 - -### 关键文件锚点 -- 提取/画像:`packages/agentlab/src/{persona,extract,http,types,catalog,provider}.ts` -- 服务编排:`packages/service/src/account/agentlab.ts`,配置 `packages/service/src/bootstrap/agentlab_config.ts` -- 设置存储:`packages/service/src/bootstrap/user_config.ts`(AppSettings.agentLab) -- 路由:`apps/desktop/src/main/ipc/routers/{bootstrap,account}.ts` -- 前端:`apps/desktop/src/renderer/src/components/settings/AgentLabSection.tsx`、`views/AgentLabView.tsx` -- 复用:媒体寻址 `packages/service/src/account/export/{media_scan,media_export}.ts` + `media_download.ts`;语音 `apps/desktop/src/main/{voice,transcribe/engine}.ts` + `packages/service/src/common/voice_transcribe.ts`;codec `packages/codec/src/element/spec.ts` diff --git a/packages/agentlab/src/http.ts b/packages/agentlab/src/http.ts index 8b65eb2..cbe4e72 100644 --- a/packages/agentlab/src/http.ts +++ b/packages/agentlab/src/http.ts @@ -12,7 +12,7 @@ import type { } from './types'; import { humanizeText } from './typo'; import { scoreReplyWillingness } from './willing'; -import { selectStickerByEmotion } from './sticker'; +import { selectStickerByEmotion, pickRandomSticker } from './sticker'; // 标记(全局,用于从文本里剥离):[[发表情:…]] / 内部 [[sticker:md5]] / 内部 [[voice:id]]。 const EMOTION_MARKER_G = /\[\[发表情[::].+?\]\]/g; @@ -256,22 +256,33 @@ function buildSystemPrompt( if (systemFaces.length > 0) { lines.push( `系统表情:你偶尔用这几个(仅当语气合适时,别每条都带)——${systemFaces.join(' ')}。` + - `这些是 QQ 固定的系统表情,你只能从这个列表里原样挑用,绝对不要自己造别的「/动作」(比如 /吃饭 /睡觉 这种是不存在的)。`, + `这些是 QQ 固定的系统表情,直接原样写在 text 消息的文字里即可(如「哈哈哈 /捂脸」);你只能从这个列表里挑用,绝对不要自己造别的「/动作」(比如 /吃饭 /睡觉 这种是不存在的)。`, ); } // 自定义表情包:给一份「编号 + 真实内容」的清单,让模型看着内容自己挑哪张—— - // 它清楚知道自己发的是什么(不再是吐个情绪词让后端盲匹配)。发图只走 [[发表情:序号]]。 + // 它清楚知道自己发的是什么。发表情 = 一条独立的 emoji 消息,content 填编号(见【输出格式】)。 const stickers = (persona.stickers ?? []).filter((s) => s.description); - if (stickers.length > 0) { - lines.push( - '', - '【你的表情包】(你常用的几张自定义表情,看清楚每张是什么,想发哪张就单独一行写 `[[发表情:序号]]`):', - ...stickers.map((s, i) => `${i + 1}. ${s.description}${s.scenario ? `(${s.scenario})` : ''}`), - '发表情规则:挑你**真正想表达**的那张,单独一行输出 `[[发表情:序号]]`(序号就是上面的数字)。' + - '绝对不要用文字去旁白一个表情(写成「(捂脸)」「[狗头]」「企鹅吐舌」都是错的,发不出图、只会变尬文字)。' + - '表情通常是"单独回一个表情"代替打字(对方说了句好笑的,你就只回个表情、别的不发),而不是每条话后面都补一个——大多数消息里根本没有表情。', - ); + // 没有文字描述的表情(多为刚导入、还没被视觉模型解析的新表情)——进不了编号清单, + // 但允许模型用 content=random「随手发一张」,否则它们永远发不出去。 + const undescribedCount = (persona.stickers ?? []).filter((s) => !s.description).length; + const hasAnySticker = stickers.length > 0 || undescribedCount > 0; + if (hasAnySticker) { + lines.push('', '【你的表情包】(想发哪张就作为一条独立的 emoji 消息发,别用文字旁白表情):'); + if (stickers.length > 0) { + lines.push( + ...stickers.map((s, i) => `${i + 1}. ${s.description}${s.scenario ? `(${s.scenario})` : ''}`), + '发表情规则:挑你**真正想表达**的那张,作为一条独立消息 `{"type":"emoji","content":"编号"}`(编号就是上面的数字)。' + + '绝对不要用文字去旁白一个表情(写成「(捂脸)」「[狗头]」「企鹅吐舌」都是错的,发不出图、只会变尬文字)。' + + '表情通常是"单独回一个表情"代替打字(对方说了句好笑的,你就只回个表情、别的不发),而不是每条话后面都补一个——大多数消息里根本没有表情。', + ); + } + if (undescribedCount > 0) { + lines.push( + `另外你还有 ${undescribedCount} 张没有文字说明的表情;想随手发个表情活跃气氛时,可以发 ` + + '`{"type":"emoji","content":"random"}`(会从这些里随机挑一张)。同样别用文字去旁白它。', + ); + } } // 语音:开了语音克隆且 TA 平时发语音时,允许 bot 自主决定某条用语音发。 @@ -281,8 +292,8 @@ function buildSystemPrompt( '', '【发语音】你可以像平时那样发语音消息。' + (scen ? `你平时发语音的习惯是:${scen}。` : '') + - '想发语音时,把那一条单独成行、开头加 `[[语音]]`,紧跟着写你要说的话(口语、自然,就像真的在说话那样)。' + - '别滥用——只在符合你平时发语音习惯的场景才发,大多数消息还是打字。一条消息要么文字要么语音,别在语音里再夹表情标记。', + '想发语音时,把那一条作为一条 `{"type":"ptt","content":"要说的话"}` 消息(content 是口语、自然,就像真的在说话那样)。' + + '别滥用——只在符合你平时发语音习惯的场景才发,大多数消息还是打字。一条语音就是一条独立消息,别在里面夹表情。', ); } @@ -349,11 +360,11 @@ function buildSystemPrompt( `- 单条消息平淡口语,像随手在 QQ 上打字;单条通常 ${avgChars} 字上下,但别死守这个数——该短就短,该长就长。`, `- 回复长度和条数要忽长忽短、像真人一样没有固定套路:${baseline}有时只回一个字或一个表情,有时一两句,偶尔遇到能聊的才铺开多说几条。**绝对不要每次都用同一个节奏**(比如总是两句话再加一个表情)。`, `- ${pickLengthLean(burst)}`, - '- 分条连发:真的有好几件事要说、或想模拟连着打字的语气时,才用单独一行「---」把消息隔开(一次别超过 4 条);大多数时候一条、甚至一个词就够,别为了凑数硬分。', + '- 分条连发:真有好几件事要说、或想模拟连着打字的语气时,就在 JSON 数组里放多个元素(一次别超过 4 条);大多数时候一条、甚至一个词就够,别为了凑数硬分。', '- 上面的背景、关系、经历、记忆、聊天样本都是你脑子里的东西:只在话题相关时自然带一嘴,别一股脑往外倒,更别逐条展示自己的"人设"。', '- 不知道、记不清的事就像真人一样含糊带过或反问,绝不编造具体细节。', '- 口语、随意,可以不完整、可以略省标点;贴合上面的语气习惯。', - '- 绝对禁止:markdown、列表、序号、加粗、括号注释、表情符号名、冒号开头的前缀(如"好的:")、以及任何分析/解释/旁白。', + '- 每条消息的文字内容(content)里绝对禁止:markdown、列表、序号、加粗、括号注释、表情符号名、冒号开头的前缀(如"好的:")、以及任何分析/解释/旁白。', '- 不浮夸、不堆排比和华丽辞藻、不用 AI 腔;直接以本人身份回话,别像客服或助手。', ); @@ -366,6 +377,33 @@ function buildSystemPrompt( const custom = persona.customPrompt?.trim(); if (custom) lines.push('', '【额外要求】(用户设定,优先遵守)', custom); + // 【输出格式】放最后,作为最强的硬性约束:整段回复必须是 JSON 数组,一个元素 = 一条消息。 + const typeLines = [' · text = 一条文字消息,content 就是这句话(系统表情如 /捂脸 直接写在文字里)。']; + if (hasAnySticker) { + typeLines.push( + stickers.length > 0 + ? ' · emoji = 发一张你的自定义表情,content 填表情清单里的编号(数字字符串),或填 "random" 随机发一张。' + : ' · emoji = 发一张你的自定义表情,content 填 "random" 随机挑一张发。', + ); + } + if (voiceEnabled) typeLines.push(' · ptt = 发一条语音,content 是你要说的话。'); + const allowedTypes = ['text', ...(hasAnySticker ? ['emoji'] : []), ...(voiceEnabled ? ['ptt'] : [])]; + const exampleEmoji = hasAnySticker + ? stickers.length > 0 + ? ',{"type":"emoji","content":"1"}' + : ',{"type":"emoji","content":"random"}' + : ''; + lines.push( + '', + '【输出格式】(最重要,务必严格遵守)', + '- 你的整个回复必须是一个 JSON 数组,数组里每个元素是你要连着发出的一条消息,按顺序发。', + `- 每个元素形如 {"type":"text","content":"..."};type 只能是 ${allowedTypes.map((t) => `"${t}"`).join('、')} 之一:`, + ...typeLines, + '- 想连发几条就放几个元素;一个元素就是完整的一条消息,不要在 content 里用换行或 --- 再分条。', + '- 只输出这个 JSON 数组本身,前后不要有任何解释文字,不要用 ``` 代码块包裹。', + `- 示例(只示范格式、别照抄内容):[{"type":"text","content":"哈哈哈真的假的"},{"type":"text","content":"牛逼啊你"}${exampleEmoji}]`, + ); + return lines.join('\n'); } @@ -476,24 +514,30 @@ function rankPairs( // ── 把模型输出解析成有序动作(text / sticker / voice)──────────────────────── -/** 单独成行的表情标记:[[发表情:序号]] / [[发表情:情绪]](旧)/ [[sticker:md5]](内部,兼容)。 */ -const STICKER_LINE = /^\s*\[\[(?:发表情|sticker)[::]\s*(.+?)\s*\]\]\s*$/i; -/** 一条语音消息的前缀:以 [[语音]] 开头。 */ -const VOICE_PREFIX = /^\s*\[\[\s*语音\s*\]\]\s*/; - -/** 把一段文本按「单独成行的 ---」或空行拆块(无上限,trim 去空)。 */ -function splitBlocks(text: string): string[] { - let parts = text - .split(/\n\s*-{3,}\s*\n/) - .map((s) => s.trim()) - .filter(Boolean); - if (parts.length <= 1) { - parts = text - .split(/\n{2,}/) - .map((s) => s.trim()) - .filter(Boolean); - } - return parts.length ? parts : [text.trim()].filter(Boolean); +/** 模型输出的一条结构化消息(未校验的原始形状)。 */ +interface RawMessageItem { + type?: unknown; + content?: unknown; +} + +/** + * 从模型整段输出里稳健提取 JSON 文本(数组优先,退化到单对象): + * - 优先剥掉 ```json … ``` / ``` … ``` 代码围栏; + * - 截取第一个 `[` 到最后一个 `]`(容忍模型在数组前后夹了解释废话); + * - 没有数组时退化截取 `{` 到 `}`(模型只吐了单个对象没套数组 → 上层会包成数组)。 + * 都拿不到就返回 null,交给上层降级。 + */ +function extractJsonArray(raw: string): string | null { + let s = raw.trim(); + const fence = s.match(/```(?:json)?\s*([\s\S]*?)```/i); + if (fence?.[1]) s = fence[1].trim(); + const aStart = s.indexOf('['); + const aEnd = s.lastIndexOf(']'); + if (aStart >= 0 && aEnd > aStart) return s.slice(aStart, aEnd + 1); + const oStart = s.indexOf('{'); + const oEnd = s.lastIndexOf('}'); + if (oStart >= 0 && oEnd > oStart) return s.slice(oStart, oEnd + 1); + return null; } /** @@ -505,6 +549,10 @@ function splitBlocks(text: string): string[] { function resolveStickerToken(persona: AgentLabPersona, token: string): AgentLabStickerRef | null { const t = token.trim(); const all = persona.stickers ?? []; + // random = 随手发一张(优先没描述的新表情,让刚导入、未解析的表情也有机会发出)。 + if (/^random$/i.test(t)) { + return pickRandomSticker(persona, { preferUndescribed: true }); + } // 编号对应「有描述的清单」——必须和 buildSystemPrompt 里列出的那份过滤后清单一致。 if (/^\d+$/.test(t)) { const listed = all.filter((s) => s.description); @@ -541,7 +589,15 @@ function capTextActions(actions: AgentLabChatAction[], max: number): AgentLabCha return out; } -/** 解析模型整段输出为有序动作。voice 仅产出文本,真正合成在 service 层。 */ +/** + * 解析模型整段输出为有序动作。新协议:模型输出 JSON 数组 + * [{"type":"text|emoji|ptt","content":"..."}],一个元素 = 一条消息(模型自己决定分几条)。 + * - text → 文字(可内联系统表情 /捂脸) + * - emoji → 一个自定义表情,content = 编号(resolveStickerToken) + * - ptt → 语音,content = 要说的话(仅 voiceEnabled;否则降级成文字,不丢内容) + * 强容错:JSON 解析不出来 / 解析全为空 → 整段降级为一条 text,永不崩、永不空。 + * voice 动作只产出文本,真正合成在 service 层。 + */ function parseActions( raw: string, persona: AgentLabPersona, @@ -549,45 +605,52 @@ function parseActions( maxSegments: number, typoIntensity: number | undefined, ): AgentLabChatAction[] { - // 第一遍:逐行把「单独成行的表情标记」切成独立 token,其余文本按 --- / 空行分块,保留顺序。 - const ordered: Array<{ type: 'sticker'; token: string } | { type: 'text'; text: string }> = []; - let buf: string[] = []; - const flush = (): void => { - const block = buf.join('\n'); - buf = []; - for (const b of splitBlocks(block)) ordered.push({ type: 'text', text: b }); - }; - for (const line of raw.split('\n')) { - const m = line.match(STICKER_LINE); - if (m) { - flush(); - ordered.push({ type: 'sticker', token: m[1] ?? '' }); - } else { - buf.push(line); + const humanize = (s: string): string => + typoIntensity === undefined ? humanizeText(s) : humanizeText(s, typoIntensity); + // 剥掉模型可能从历史里学回来的内部标记,避免混进 content 漏给用户。 + const stripMarkers = (s: string): string => + s.replace(EMOTION_MARKER_G, '').replace(STICKER_MD5_MARKER_G, '').replace(VOICE_MARKER_G, '').trim(); + + // 稳健解析出消息数组(失败 → null,走整段降级)。 + let items: RawMessageItem[] | null = null; + const jsonText = extractJsonArray(raw); + if (jsonText) { + try { + const parsed: unknown = JSON.parse(jsonText); + if (Array.isArray(parsed)) items = parsed as RawMessageItem[]; + else if (parsed && typeof parsed === 'object') items = [parsed as RawMessageItem]; + } catch { + items = null; } } - flush(); - // 第二遍:token → action(文本里再分 voice/text + humanize;表情按编号解析)。 - const humanize = (s: string): string => (typoIntensity === undefined ? humanizeText(s) : humanizeText(s, typoIntensity)); const actions: AgentLabChatAction[] = []; - for (const item of ordered) { - if (item.type === 'sticker') { - const sticker = resolveStickerToken(persona, item.token); + for (const item of items ?? []) { + if (!item || typeof item !== 'object') continue; + const type = typeof item.type === 'string' ? item.type.trim().toLowerCase() : ''; + const content = typeof item.content === 'string' ? item.content : ''; + if (type === 'emoji') { + const sticker = resolveStickerToken(persona, content.trim()); if (sticker) actions.push({ kind: 'sticker', sticker }); continue; } - const t = item.text.trim(); - if (!t) continue; - if (voiceEnabled && VOICE_PREFIX.test(t)) { - const body = t.replace(VOICE_PREFIX, '').replace(EMOTION_MARKER_G, '').replace(STICKER_MD5_MARKER_G, '').trim(); - if (body) actions.push({ kind: 'voice', text: body }); - continue; + const clean = stripMarkers(content); + if (!clean) continue; + if (type === 'ptt') { + // 开了语音 → 语音动作;没开 → 降级成文字,别把要说的话丢了。 + actions.push(voiceEnabled ? { kind: 'voice', text: clean } : { kind: 'text', text: humanize(clean) }); + } else { + // text,以及任何未知 type 的兜底:都当文字。 + actions.push({ kind: 'text', text: humanize(clean) }); } - // 防御:剥掉行内残留的标记,避免把内部格式当文本漏出去。 - const clean = t.replace(EMOTION_MARKER_G, '').replace(STICKER_MD5_MARKER_G, '').trim(); - if (clean) actions.push({ kind: 'text', text: humanize(clean) }); } + + // 降级兜底:JSON 完全解析不了 / 解析出来全是空 → 整段清掉标记当一条文字,永不崩、永不空。 + if (actions.length === 0) { + const fallback = stripMarkers(raw); + if (fallback) actions.push({ kind: 'text', text: humanize(fallback) }); + } + return capTextActions(actions, maxSegments); } diff --git a/packages/agentlab/src/index.ts b/packages/agentlab/src/index.ts index dea3d1c..68c4933 100644 --- a/packages/agentlab/src/index.ts +++ b/packages/agentlab/src/index.ts @@ -6,7 +6,7 @@ export { resolveEndpoint, } from './provider'; export { embedTexts, runPersonaChat, reportUsage, keywordsOf, testChatEndpoint, pickMessageText } from './http'; -export { selectStickerByEmotion } from './sticker'; +export { selectStickerByEmotion, pickRandomSticker } from './sticker'; export { humanizeText, DEFAULT_TYPO_INTENSITY } from './typo'; export { scoreReplyWillingness } from './willing'; export { @@ -65,4 +65,32 @@ export { describeSticker, } from './extract'; export { AgentLabStore } from './store'; +export { AgentRuntime } from './runtime'; +export type { + AgentRuntimeDeps, + EndpointResolver, + UsageSink, + ConversationSink, + ConversationTurnLike, + MemorySink, + NotesSink, + TtsPort, + TtsSynthesisOptions, + RuntimeLogger, +} from './runtime'; +export { + TtsService, + TTS_VENDOR_CATALOG, + getTtsCatalogEntry, + getTtsCapabilities, +} from './tts'; +export type { + TtsVendor, + TtsProviderConfig, + TtsRefClip, + TtsSynthesizeOptions, + TtsSynthesizeResult, + TtsCapabilities, + TtsVendorCatalogEntry, +} from './tts'; export type * from './types'; diff --git a/packages/agentlab/src/runtime.ts b/packages/agentlab/src/runtime.ts new file mode 100644 index 0000000..34726d6 --- /dev/null +++ b/packages/agentlab/src/runtime.ts @@ -0,0 +1,639 @@ +/** + * AgentRuntime —— 克隆体「运行时对话引擎」(纯,不依赖 Electron / NTQQ / tRPC)。 + * + * 从 service 层 AgentLabService 下沉而来:桌面端与导出的独立 bot **共用同一套引擎**。 + * 只做「给定 persona + 历史 + 输入 → 产出有序标记文本(含语音合成 / 记忆 / 反思)」, + * 一切外部资源(LLM 端点、TTS、各类持久化 store、自身身份 id)都靠依赖注入(Port 接口)传入。 + * + * 边界:**运行时**逻辑在这里;**蒸馏期**(拉 QQ 语料 / 转录 / 表情下载 / 建 persona)仍留在 + * service 层,因为那些绑死 AccountSession / 媒体管线。群聊多克隆体连锁编排暂留 service(M3 再议), + * 但它复用本类的 generatePersonaTurns / synthesizeVoice。 + */ +import { createHash } from 'node:crypto'; +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { runPersonaChat, embedTexts } from './http'; +import { scoreReplyGate, willingLevelBias } from './reply_gate'; +import { describeRelationTone } from './relation'; +import { distillMemories, reflectConversation, scoreInteractionSentiment, decideGroupReply } from './extract'; +import type { AgentLabStore } from './store'; +import type { + AgentLabPersona, + AgentLabStoredPair, + AgentLabChatTurn, + AgentLabModelRef, + AgentLabEndpoint, + AgentLabMemoryItem, + AgentLabPersonaNotes, + AgentLabRelationStore, +} from './types'; + +// ── 依赖注入 Port 接口 ───────────────────────────────────────────────────── +// 现有 service store(MemoryStore/NotesStore/ConversationStore/TokenUsageStore)与 TtsService +// 通过 TS 结构类型天然满足这些接口;bot 侧可提供自己的实现(JSON 落盘 / 其它后端)。 + +/** LLM 端点解析:把 persona 里的 { providerId, model } 引用解析成可直接 fetch 的 endpoint。 */ +export type EndpointResolver = (ref: AgentLabModelRef) => AgentLabEndpoint; + +/** token 记账落点(TokenUsageStore 满足)。 */ +export interface UsageSink { + record(entry: { + ts: number; + model: string; + kind: 'chat' | 'embedding' | 'vision'; + personaId?: string; + scope: 'build' | 'chat' | 'assistant'; + promptTokens: number; + completionTokens: number; + totalTokens: number; + }): void; +} + +/** 对话历史落点(ConversationStore 满足;只用 role/text/ts 三字段)。 */ +export interface ConversationTurnLike { + role: 'user' | 'assistant'; + text: string; + ts: number; +} +export interface ConversationSink { + get(agentId: string): ConversationTurnLike[]; + append(agentId: string, turns: ConversationTurnLike[]): void; +} + +/** 记忆库落点(MemoryStore 满足)。 */ +export interface MemorySink { + get(personaId: string): AgentLabMemoryItem[]; + /** 只取「关于指定成员」的记忆(群聊防串人);includeUntagged=true 把无 aboutId 的旧记忆也算上。 */ + getAbout(personaId: string, aboutIds: string[], includeUntagged?: boolean): AgentLabMemoryItem[]; + touch(personaId: string, ids: string[], now: number): void; + add( + personaId: string, + texts: string[], + now: number, + about?: { aboutId: string; aboutKind: 'user' | 'persona' }, + embeddings?: Array, + ): void; +} + +/** 反思笔记落点(NotesStore 满足)。 */ +export interface NotesSink { + get(personaId: string): AgentLabPersonaNotes; + getReflectedCount(personaId: string): number; + setReflectedCount(personaId: string, count: number): void; + add(personaId: string, corrections: string[], episode: string): void; +} + +/** TTS 合成端口(把 service 的 { service, getProvider } 抽象成 providerId 维度,解耦厂商类型)。 */ +export interface TtsSynthesisOptions { + refClip?: { path: string; text: string }; + auxRefClips?: Array<{ path: string; text: string }>; + voice?: string; +} +export interface TtsPort { + /** 该 provider 的能力;null = 找不到 provider(视为不可用)。 */ + getCapabilities(providerId: string): { clone: boolean; fixedVoice: boolean } | null; + synthesize( + providerId: string, + text: string, + opts: TtsSynthesisOptions, + ): Promise<{ audio: Uint8Array; format: string }>; +} + +/** 可选日志端口(service 的 getLogger().child(...) 满足;缺省则静默)。 */ +export interface RuntimeLogger { + child(ctx: Record): RuntimeLogger; + info(msg: string, ctx?: Record): void; + warn(msg: string, ctx?: Record): void; + error(msg: string, ctx?: Record): void; +} + +export interface AgentRuntimeDeps { + /** agentlab 根目录(合成语音落 /agentvoice/)。 */ + rootDir: string; + store: AgentLabStore; + endpoints: EndpointResolver; + usage: UsageSink; + conversations: ConversationSink; + memories: MemorySink; + notes: NotesSink; + relations: AgentLabRelationStore; + /** 自身身份 id(桌面 = 登录账号 uin;bot = bot QQ 号)。用于私聊意愿闸取「对我」的关系。 */ + selfId: string; + /** 语音合成(缺省则克隆体不发语音,降级纯文字)。 */ + tts?: TtsPort; + logger?: RuntimeLogger; +} + +const MEMORY_DISTILL_EVERY = 6; +const REFLECT_EVERY = 8; +const MIN_UNREFLECTED = 4; + +export class AgentRuntime { + private readonly rootDir: string; + private readonly store: AgentLabStore; + private readonly endpoints: EndpointResolver; + private readonly usage: UsageSink; + private readonly conversations: ConversationSink; + private readonly memories: MemorySink; + private readonly notes: NotesSink; + private readonly relations: AgentLabRelationStore; + private readonly selfId: string; + private readonly tts?: TtsPort; + private readonly logger?: RuntimeLogger; + + constructor(deps: AgentRuntimeDeps) { + this.rootDir = deps.rootDir; + this.store = deps.store; + this.endpoints = deps.endpoints; + this.usage = deps.usage; + this.conversations = deps.conversations; + this.memories = deps.memories; + this.notes = deps.notes; + this.relations = deps.relations; + this.selfId = deps.selfId; + this.tts = deps.tts; + this.logger = deps.logger; + } + + private selfMemberId(): string { + return this.selfId; + } + + /** 解析 endpoint 并挂上 token 记账回调。 */ + private resolveWithUsage( + ref: AgentLabModelRef, + kind: 'chat' | 'embedding' | 'vision', + ctx: { personaId?: string; scope: 'build' | 'chat' | 'assistant' }, + ): AgentLabEndpoint { + const ep = this.endpoints(ref); + return { + ...ep, + kind, + onUsage: (u) => + this.usage.record({ + ts: Date.now(), + model: u.model, + kind: u.kind, + personaId: ctx.personaId, + scope: ctx.scope, + promptTokens: u.promptTokens, + completionTokens: u.completionTokens, + totalTokens: u.totalTokens, + }), + }; + } + + /** 克隆体的兴趣关键词(话题 + 口头禅 + 高频词),供意愿闸判断「聊到感兴趣的」。 */ + private personaInterestTerms(persona: AgentLabPersona): string[] { + const card = persona.profile?.card; + const terms = [ + ...(card?.topics ?? []), + ...(card?.catchphrases ?? []), + ...(persona.profile?.topTerms ?? []), + ]; + return Array.from(new Set(terms.map((t) => t.trim()).filter((t) => t.length >= 2))); + } + + /** 把对话历史渲染成群聊转录(供 LLM 发言决策):user 文本已带「name」:前缀,assistant 记为「你」。 */ + private renderHistoryTranscript(history: AgentLabChatTurn[], limit: number): string { + return history + .slice(-limit) + .map((t) => (t.role === 'assistant' ? `你:${t.text.slice(0, 80)}` : t.text.slice(0, 80))) + .join('\n'); + } + + /** 意愿档位 → 性格倾向提示(喂给发言决策)。对标 service 的 willingDisposition。 */ + private willingDisposition(level?: number): string { + if (level === undefined) return ''; + if (level >= 70) return '你性格比较爱说话、爱凑热闹,遇到能接的话就想插两句。'; + if (level <= 30) return '你性格偏高冷、不太主动接话,多数时候在潜水,只有真戳到你才开口。'; + return ''; + } + + /** 存在感提示:自己最近说太多就提醒别硬刷屏(用编排层已算好的占比)。 */ + private crowdedHintFromShare(selfShareRecent?: number): string { + if (selfShareRecent !== undefined && selfShareRecent > 0.5) { + return '你最近已经连着说了好几条了,注意别刷屏,没必要就歇一歇。'; + } + return ''; + } + + /** 合成语音的落盘目录(/agentvoice/,懒建)。 */ + private agentVoiceDir(): string { + const dir = join(this.rootDir, 'agentvoice'); + mkdirSync(dir, { recursive: true }); + return dir; + } + + /** 按 id 解析某条合成语音的本地路径。id 必须是安全 basename(仅 hex + .mp3/.wav),防目录逃逸。 */ + getAgentVoicePath(id: string): string | null { + if (!/^[0-9a-f]+\.(mp3|wav)$/i.test(id)) return null; + const path = join(this.agentVoiceDir(), id); + return existsSync(path) ? path : null; + } + + /** + * 这个克隆体当前能不能发语音:开了语音克隆 + 绑了 provider + 该 provider 能力匹配。 + * clone 模式还要求 provider 支持复刻且有参考音频;preset 模式要求 provider 支持固定音色。 + */ + isVoiceReady(persona: AgentLabPersona): boolean { + if (!persona.voiceCloneEnabled || !persona.voice || !this.tts) return false; + const caps = this.tts.getCapabilities(persona.voice.providerId); + if (!caps) return false; + if (persona.voice.mode === 'clone') { + return caps.clone && (persona.voiceProfile?.refClips?.length ?? 0) > 0; + } + return caps.fixedVoice; + } + + /** + * 合成一条语音,写到 agentvoice/.,返回文件名(id)。失败返回 null(调用方降级文字)。 + * clone 模式用 TA 的参考音频复刻;preset 模式用预置音色(provider 默认音色由 TtsPort 实现兜底)。 + */ + async synthesizeVoice(persona: AgentLabPersona, text: string): Promise { + const voice = persona.voice; + const log = this.logger?.child({ personaId: persona.id, textLen: text.length }); + if (!this.tts || !voice) { + log?.warn('语音合成跳过:未接入 TTS 或克隆体没绑定语音', { hasTts: !!this.tts, hasVoiceBinding: !!voice }); + return null; + } + const caps = this.tts.getCapabilities(voice.providerId); + if (!caps) { + log?.warn('语音合成失败:找不到 TTS provider(可能已被删除或未配置)', { providerId: voice.providerId }); + return null; + } + try { + const opts: TtsSynthesisOptions = {}; + if (voice.mode === 'clone') { + const refClips = persona.voiceProfile?.refClips ?? []; + const clips = refClips.filter((c) => existsSync(c.path)); + if (clips.length === 0) { + log?.warn('语音合成失败:clone 模式但没有可用的参考音频(refClips 为空或 wav 文件已丢失)', { + refClipCount: refClips.length, + }); + return null; + } + opts.refClip = { path: clips[0]!.path, text: clips[0]!.text }; + opts.auxRefClips = clips.slice(1, 3).map((c) => ({ path: c.path, text: c.text })); + } else { + opts.voice = voice.voice; + } + const { audio, format } = await this.tts.synthesize(voice.providerId, text, opts); + const ext = format === 'wav' ? 'wav' : 'mp3'; + const hash = createHash('sha1') + .update(`${persona.id}|${voice.providerId}|${voice.mode}|${voice.voice ?? ''}|${text}`) + .digest('hex') + .slice(0, 16); + const id = `${hash}.${ext}`; + const dest = join(this.agentVoiceDir(), id); + if (!existsSync(dest)) writeFileSync(dest, audio); + log?.info('语音合成成功', { id, mode: voice.mode, bytes: audio.length }); + return id; + } catch (error) { + log?.error('语音合成异常,降级为文字', { + providerId: voice.providerId, + mode: voice.mode, + errorMessage: error instanceof Error ? error.message : String(error), + }); + return null; + } + } + + /** + * 单个克隆体「给定历史 + 当前输入 → 产出有序标记文本」的共享生成逻辑(私聊 chat 与群聊复用)。 + * 只做生成 + 记忆命中记账 + action→标记文本(含语音合成),不碰任何对话落库——落哪由调用方决定。 + */ + async generatePersonaTurns( + persona: AgentLabPersona, + pairs: AgentLabStoredPair[], + opts: { + chatEndpoint: AgentLabEndpoint; + embeddingEndpoint: AgentLabEndpoint | null; + history: AgentLabChatTurn[]; + input: string; + now: number; + relationNote?: string; + memories?: AgentLabMemoryItem[]; + }, + ): Promise<{ result: Awaited>; renderedTurns: string[] }> { + const voiceEnabled = this.isVoiceReady(persona); + const result = await runPersonaChat(opts.chatEndpoint, opts.embeddingEndpoint, { + persona, + pairs, + history: opts.history, + input: opts.input, + memories: opts.memories ?? this.memories.get(persona.id), + notes: this.notes.get(persona.id), + voiceEnabled, + relationNote: opts.relationNote, + }); + // 命中的记忆 +access(越常被想起越不易遗忘)。 + this.memories.touch(persona.id, result.usedMemoryIds, opts.now); + + // 按 actions 顺序转成标记文本(text / 表情 [[sticker:md5]] / 语音 [[voice:id]])。 + // 语音合成失败则降级为文字,不丢内容。 + const renderedTurns: string[] = []; + for (const action of result.actions) { + if (action.kind === 'text') { + renderedTurns.push(action.text); + } else if (action.kind === 'sticker') { + renderedTurns.push(`[[sticker:${action.sticker.md5}]]`); + } else { + const voiceId = await this.synthesizeVoice(persona, action.text); + renderedTurns.push(voiceId ? `[[voice:${voiceId}]]` : action.text); + } + } + if (renderedTurns.length === 0) renderedTurns.push(result.text); + return { result, renderedTurns }; + } + + /** + * 私聊入口:给定 personaId + 历史 + 用户输入 → 生成回复(有序标记文本)并落对话库。 + * 意愿闸(可选,persona.willing.gatePrivate)没过则保持沉默、只记用户这条。 + */ + async chat(input: { personaId: string; history: AgentLabChatTurn[]; text: string }) { + const record = this.store.getPersona(input.personaId); + if (!record) throw new Error('找不到 persona'); + if (!record.persona.models?.chat) throw new Error('这是旧版克隆体,模型结构已更新,请删除后重建'); + const ctx = { personaId: input.personaId, scope: 'chat' as const }; + const chatEndpoint = this.resolveWithUsage(record.persona.models.chat, 'chat', ctx); + const embeddingEndpoint = record.persona.models.embedding + ? this.resolveWithUsage(record.persona.models.embedding, 'embedding', ctx) + : null; + + // 发言意愿对私聊生效(可选):意愿闸没过就保持沉默,只记下用户这条,不回。 + const willing = record.persona.willing; + if (willing?.gatePrivate) { + const decision = scoreReplyGate({ + text: input.text, + personaName: record.persona.name, + fromOwner: true, + interestTerms: this.personaInterestTerms(record.persona), + relation: this.relations.get(input.personaId, this.selfMemberId()), + levelBias: willingLevelBias(willing.level), + mustReplyOnMention: willing.mustReplyOnMention !== false, + }); + if (!decision.shouldReply) { + const ts = Date.now(); + this.conversations.append(input.personaId, [{ role: 'user', text: input.text, ts }]); + return { + text: '', + segments: [], + actions: [], + promptPreview: '', + matches: [], + usedMemoryIds: [], + willingness: decision.score, + replyDelayMs: 0, + sticker: null, + renderedTurns: [] as string[], + silent: true, + }; + } + } + + const now = Date.now(); + const { result, renderedTurns } = await this.generatePersonaTurns(record.persona, record.pairs, { + chatEndpoint, + embeddingEndpoint, + history: input.history, + input: input.text, + now, + }); + + const assistantTurns: ConversationTurnLike[] = renderedTurns.map((text) => ({ + role: 'assistant', + text, + ts: now, + })); + this.conversations.append(input.personaId, [ + { role: 'user', text: input.text, ts: now }, + ...assistantTurns, + ]); + + // 每隔若干轮,从最近对话蒸馏记忆 + 反思扮演效果(都不阻塞本次回复)。 + void this.maybeDistillMemories(input.personaId, record.persona.name, chatEndpoint); + void this.maybeReflect(input.personaId, record.persona.name, chatEndpoint); + + return { ...result, renderedTurns, silent: false }; + } + + /** 每 MEMORY_DISTILL_EVERY 个用户回合蒸馏一次记忆;fire-and-forget,失败静默。 */ + private async maybeDistillMemories( + personaId: string, + peerName: string, + chatEndpoint: AgentLabEndpoint, + ): Promise { + try { + const conv = this.conversations.get(personaId); + const userTurns = conv.filter((t) => t.role === 'user').length; + if (userTurns === 0 || userTurns % MEMORY_DISTILL_EVERY !== 0) return; + const known = this.memories + .get(personaId) + .map((m) => m.text) + .slice(-40); + const fresh = await distillMemories( + chatEndpoint, + peerName, + conv.map((t) => ({ role: t.role, text: t.text })), + known, + ); + if (fresh.length === 0) return; + // 私聊记忆标记 aboutId=被克隆好友本人(对方),配了向量模型时嵌入以便语义召回。 + const persona = this.store.getPersona(personaId)?.persona; + const aboutId = persona?.sourceId; + let embeddings: Array | undefined; + if (persona?.models.embedding) { + try { + const embEndpoint = this.resolveWithUsage(persona.models.embedding, 'embedding', { + personaId, + scope: 'chat', + }); + embeddings = await embedTexts(embEndpoint, fresh); + } catch { + /* 嵌入失败就退化成关键词召回 */ + } + } + this.memories.add( + personaId, + fresh, + Date.now(), + aboutId ? { aboutId, aboutKind: 'user' } : undefined, + embeddings, + ); + } catch { + /* 记忆蒸馏失败不影响聊天 */ + } + } + + /** + * 每 REFLECT_EVERY 个用户回合反思一次扮演效果;用 reflectedCount 水位只反思新增片段, + * 提炼出的 corrections(必须遵守)/ summary(episode)写入 NotesSink。fire-and-forget,失败静默。 + */ + private async maybeReflect( + personaId: string, + peerName: string, + chatEndpoint: AgentLabEndpoint, + ): Promise { + try { + const conv = this.conversations.get(personaId); + const userTurns = conv.filter((t) => t.role === 'user').length; + if (userTurns === 0 || userTurns % REFLECT_EVERY !== 0) return; + const reflected = this.notes.getReflectedCount(personaId); + const unreflected = conv.slice(reflected); + if (unreflected.length < MIN_UNREFLECTED) return; + const result = await reflectConversation( + chatEndpoint, + peerName, + unreflected.map((t) => ({ role: t.role, text: t.text })), + ); + if (result.corrections.length > 0 || result.summary) { + this.notes.add(personaId, result.corrections, result.summary); + } + // 无论是否提炼出内容都推进水位,避免下次重复反思同一段。 + this.notes.setReflectedCount(personaId, conv.length); + } catch { + /* 对话反思失败不影响聊天 */ + } + } + + /** + * 群聊入口(单 persona 在真实群):先决定要不要回(mode='llm' 复刻桌面 decideGroupReply 让克隆体 + * 带上下文/关系/记忆自己判断,默认;'heuristic'=纯启发式打分 scoreReplyGate),回则用群历史 + + * 对该群友的关系态生成回复,并异步更新对该群友的关系。单 persona,不做多克隆连锁(那是桌面内玩法)。 + */ + async handleGroupMessage(input: { + personaId: string; + senderId: string; + senderName: string; + text: string; + mentionsSelf: boolean; + history: AgentLabChatTurn[]; + /** 最近若干条里自己的占比(存在感惩罚,0~1)。 */ + selfShareRecent?: number; + /** 距自己上次发言的毫秒数(冷却)。 */ + msSinceOwnLastReply?: number; + /** + * 回复意愿决策方式:'llm'=克隆体带上下文/关系/记忆自己判断要不要开口(默认,与桌面模拟群聊一致, + * 更自然但每条消息多一次 LLM 调用);'heuristic'=纯启发式打分(快·省 token)。 + */ + mode?: 'llm' | 'heuristic'; + }): Promise<{ renderedTurns: string[]; replyDelayMs: number; silent: boolean; reason: string; score: number }> { + const record = this.store.getPersona(input.personaId); + if (!record?.persona.models?.chat) return { renderedTurns: [], replyDelayMs: 0, silent: true, reason: 'no-chat-model', score: 0 }; + const persona = record.persona; + const willing = persona.willing; + const relation = this.relations.get(input.personaId, input.senderId); + const mode = input.mode ?? 'llm'; + + const ctx = { personaId: input.personaId, scope: 'chat' as const }; + const chatEndpoint = this.resolveWithUsage(persona.models.chat, 'chat', ctx); + const relationNote = relation ? describeRelationTone(relation) : undefined; + + // 决策要不要开口。@必回快捷路两模式共用;否则 llm=克隆体自己判断(默认,同桌面群聊 decideGroupReply)、 + // heuristic=纯启发式打分(省 token,走 scoreReplyGate)。 + const mustReply = willing?.mustReplyOnMention !== false; + let replyDelayMs: number; + let reason: string; + let score: number; + if (input.mentionsSelf && mustReply) { + replyDelayMs = Math.round(300 + Math.random() * 500); + reason = '被@必回'; + score = 1; + } else if (mode === 'llm') { + const decision = await decideGroupReply(chatEndpoint, { + personaName: persona.name, + tone: persona.profile?.card?.tone || persona.profile?.styleSummary, + transcript: this.renderHistoryTranscript(input.history, 12), + currentSpeaker: input.senderName, + currentText: input.text, + relationNote, + memories: this.memories + .getAbout(input.personaId, [input.senderId], true) + .slice(0, 5) + .map((m) => m.text), + dispositionHint: this.willingDisposition(willing?.level), + crowdedHint: this.crowdedHintFromShare(input.selfShareRecent), + }); + if (!decision.reply) { + return { renderedTurns: [], replyDelayMs: 0, silent: true, reason: decision.reason, score: 0 }; + } + replyDelayMs = Math.round(300 + Math.random() * 500); + reason = decision.reason; + score = 1; + } else { + const decision = scoreReplyGate({ + text: input.text, + personaName: persona.name, + mentioned: input.mentionsSelf, + fromOwner: false, + interestTerms: this.personaInterestTerms(persona), + relation, + selfShareRecent: input.selfShareRecent, + msSinceOwnLastReply: input.msSinceOwnLastReply, + levelBias: willingLevelBias(willing?.level), + mustReplyOnMention: willing?.mustReplyOnMention !== false, + }); + if (!decision.shouldReply) { + return { renderedTurns: [], replyDelayMs: decision.replyDelayMs, silent: true, reason: decision.reason, score: decision.score }; + } + replyDelayMs = decision.replyDelayMs; + reason = decision.reason; + score = decision.score; + } + + const embeddingEndpoint = persona.models.embedding + ? this.resolveWithUsage(persona.models.embedding, 'embedding', ctx) + : null; + // 只召回「关于这个群友」的记忆,防串人(includeUntagged=true 把旧的无标记记忆也算上)。 + const memories = this.memories.getAbout(input.personaId, [input.senderId], true); + const now = Date.now(); + const { renderedTurns } = await this.generatePersonaTurns(persona, record.pairs, { + chatEndpoint, + embeddingEndpoint, + history: input.history, + input: `「${input.senderName}」:${input.text}`, + now, + relationNote, + memories, + }); + + // 异步更新对该群友的关系(不阻塞回复)。 + this.updateGroupRelation( + input.personaId, + persona.name, + input.senderId, + `对方(${input.senderName}):${input.text}\n你:${renderedTurns.join(' ')}`, + chatEndpoint, + ); + return { renderedTurns, replyDelayMs, silent: false, reason, score }; + } + + /** + * 一段群聊互动后更新「克隆体 → 群友」的关系态。fire-and-forget: + * 每次互动熟悉度 +2;每 5 次互动做一次 LLM 情感打分调 affinity/mood。 + */ + private updateGroupRelation( + personaId: string, + personaName: string, + objectId: string, + exchange: string, + endpoint: AgentLabEndpoint, + ): void { + void (async () => { + try { + const prev = this.relations.get(personaId, objectId); + const nextCount = (prev?.interactionCount ?? 0) + 1; + const delta: { familiarity: number; affinity?: number; mood?: number } = { familiarity: 2 }; + if (nextCount % 5 === 0) { + const s = await scoreInteractionSentiment(endpoint, personaName, exchange); + delta.affinity = s.affinityDelta; + delta.mood = s.moodDelta; + } + this.relations.applyDelta(personaId, objectId, 'user', delta, Date.now()); + } catch { + /* 关系更新失败不影响聊天 */ + } + })(); + } +} diff --git a/packages/agentlab/src/sticker.ts b/packages/agentlab/src/sticker.ts index 5b93f99..499a2d1 100644 --- a/packages/agentlab/src/sticker.ts +++ b/packages/agentlab/src/sticker.ts @@ -88,6 +88,22 @@ function scoreStickerForEmotion(sticker: AgentLabStickerRef, targetEmotion: stri return best + Math.log1p(sticker.count) * 0.05; } +/** + * 随机挑一张表情(供「随机发」通路:模型输出 emoji content=random 时用)。 + * preferUndescribed=true 时优先从「没有文字描述」的表情里挑——这正是刚导入、还没被视觉模型 + * 解析过的新表情,让它们也有机会被发出去(否则永远进不了编号清单)。子集为空则回退全部。 + */ +export function pickRandomSticker( + persona: AgentLabPersona, + opts?: { preferUndescribed?: boolean }, +): AgentLabStickerRef | null { + const all = persona.stickers ?? []; + if (all.length === 0) return null; + const undescribed = all.filter((s) => !s.description && !s.scenario); + const pool = opts?.preferUndescribed && undescribed.length > 0 ? undescribed : all; + return pool[Math.floor(Math.random() * pool.length)] ?? null; +} + /** 从 persona.stickers 里选出最匹配 targetEmotion 的表情;都不沾边时返回高频兜底。 */ export function selectStickerByEmotion( persona: AgentLabPersona, diff --git a/packages/service/src/common/tts.ts b/packages/agentlab/src/tts.ts similarity index 97% rename from packages/service/src/common/tts.ts rename to packages/agentlab/src/tts.ts index d9103e9..49f0aa2 100644 --- a/packages/service/src/common/tts.ts +++ b/packages/agentlab/src/tts.ts @@ -372,30 +372,25 @@ const mimo: Backend = async (cfg, text, opts) => { ? cfg.cloneModel?.trim() || MIMO_MODEL_CLONE : cfg.model?.trim() || MIMO_MODEL_PRESET; + // 复刻协议(照 MaiBot maibot-mimotts-voice):参考音频**作为 audio.voice 的 DataURL**传, + // 不放进 messages 的 input_audio;messages 只给 user(风格/'') + assistant(待合成文本)。 + const audio: Record = { format }; const messages: Array> = []; if (clone && opts.refClip) { const refB64 = (await readFile(opts.refClip.path)).toString('base64'); - messages.push({ - role: 'user', - content: [ - { type: 'input_audio', input_audio: { data: refB64, format: 'wav' } }, - ...(opts.refClip.text ? [{ type: 'text', text: opts.refClip.text }] : []), - ], - }); + audio.voice = `data:audio/wav;base64,${refB64}`; // 复刻模式音色 = 参考音频(DataURL),忽略 opts.voice/cfg.voice + messages.push({ role: 'user', content: opts.refClip.text ?? '' }); + } else { + audio.voice = opts.voice ?? cfg.voice ?? 'mimo_default'; } messages.push({ role: 'assistant', content }); - const audio: Record = { format }; - const voice = opts.voice ?? cfg.voice; - if (voice) audio.voice = voice; // 复刻模式音色由参考音频决定,voice 可空 - else if (!clone) audio.voice = 'mimo_default'; - const res = await fetchWithTimeout( cfg.baseUrl || 'https://api.xiaomimimo.com/v1/chat/completions', { method: 'POST', headers: { 'content-type': 'application/json', 'api-key': cfg.apiKey }, - body: JSON.stringify({ model, messages, audio }), + body: JSON.stringify({ model, modalities: ['text', 'audio'], messages, audio }), }, opts.timeoutMs ?? DEFAULT_TIMEOUT, ); diff --git a/packages/bot/package.json b/packages/bot/package.json new file mode 100644 index 0000000..c72efc9 --- /dev/null +++ b/packages/bot/package.json @@ -0,0 +1,20 @@ +{ + "name": "@weq/bot", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@weq/agentlab": "workspace:*", + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/node": "^22.10.6", + "@types/ws": "^8.5.13" + } +} diff --git a/packages/bot/src/adapter/onebot.ts b/packages/bot/src/adapter/onebot.ts new file mode 100644 index 0000000..e3dfb1c --- /dev/null +++ b/packages/bot/src/adapter/onebot.ts @@ -0,0 +1,179 @@ +/** + * OneBot v11 正向 ws 客户端基类 + napcat/snowluma 两实现 + 工厂。 + * + * 连接:bot 作为 ws 客户端连 napcat/snowluma 的 ws 服务(Authorization: Bearer token), + * 断线自动重连。RPC:发 { action, params, echo },按 echo 匹配回包 resolve/reject(带超时)。 + * 差异(仅此一处):napcat 发消息用 send_group_msg/send_private_msg 分离;snowluma 用 send_msg + message_type。 + */ +import { WebSocket, type RawData } from 'ws'; +import type { AdapterConfig, AdapterType } from '../config'; +import type { IncomingEvent, OneBot11Adapter, OneBotSegment, SendTarget } from './types'; + +interface PendingCall { + resolve: (data: unknown) => void; + reject: (err: Error) => void; + timer: ReturnType; +} + +export abstract class BaseOneBotAdapter implements OneBot11Adapter { + abstract readonly type: AdapterType; + + private ws: WebSocket | null = null; + private readonly pending = new Map(); + private readonly handlers: Array<(event: IncomingEvent) => void> = []; + private echoSeq = 0; + private closed = false; + private reconnectTimer: ReturnType | null = null; + + constructor(protected readonly cfg: AdapterConfig) {} + + connect(): Promise { + this.closed = false; + return new Promise((resolve, reject) => { + const headers: Record = {}; + if (this.cfg.token) headers.Authorization = `Bearer ${this.cfg.token}`; + const ws = new WebSocket(this.cfg.wsUrl, { headers }); + this.ws = ws; + let opened = false; + ws.on('open', () => { + opened = true; + resolve(); + }); + ws.on('message', (data: RawData) => this.onRaw(data.toString())); + ws.on('close', () => { + this.ws = null; + this.rejectAllPending('ws 连接已关闭'); + if (!this.closed) this.scheduleReconnect(); + }); + ws.on('error', (err: Error) => { + if (!opened) reject(err); + // open 之后的错误交给 close 事件触发重连 + }); + }); + } + + close(): void { + this.closed = true; + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + this.ws?.close(); + this.ws = null; + } + + callAction(action: string, params: Record): Promise { + const ws = this.ws; + if (!ws || ws.readyState !== WebSocket.OPEN) { + return Promise.reject(new Error(`ws 未连接,无法调用 action: ${action}`)); + } + const echo = `${action}-${(this.echoSeq += 1)}`; + return new Promise((resolve, reject) => { + const timeoutMs = this.cfg.actionTimeoutMs ?? 15000; + const timer = setTimeout(() => { + this.pending.delete(echo); + reject(new Error(`action ${action} 超时(${timeoutMs}ms)`)); + }, timeoutMs); + this.pending.set(echo, { resolve, reject, timer }); + ws.send(JSON.stringify({ action, params, echo })); + }); + } + + onEvent(handler: (event: IncomingEvent) => void): void { + this.handlers.push(handler); + } + + abstract sendMessage(target: SendTarget, segments: OneBotSegment[]): Promise<{ messageId?: string }>; + + /** 子类发消息后统一解析回包里的 message_id。 */ + protected async send(action: string, params: Record): Promise<{ messageId?: string }> { + const data = (await this.callAction(action, params)) as { message_id?: number | string } | null; + const id = data?.message_id; + return { messageId: id != null ? String(id) : undefined }; + } + + private onRaw(text: string): void { + let msg: Record; + try { + msg = JSON.parse(text) as Record; + } catch { + return; // 非 JSON 帧忽略 + } + // action 回包(带 echo,匹配挂起的调用)。 + if (msg.echo !== undefined) { + const key = String(msg.echo); + const call = this.pending.get(key); + if (call) { + this.pending.delete(key); + clearTimeout(call.timer); + const ok = msg.retcode === 0 || msg.status === 'ok'; + if (ok) call.resolve(msg.data); + else call.reject(new Error(`action 失败: ${text.slice(0, 300)}`)); + return; + } + } + // 上报事件(message / notice / meta_event / request)。 + if (typeof msg.post_type === 'string') { + const event = msg as IncomingEvent; + for (const h of this.handlers) { + try { + h(event); + } catch { + /* 单个 handler 抛错不影响其它 */ + } + } + } + } + + private scheduleReconnect(): void { + if (this.closed || this.reconnectTimer) return; + const delay = this.cfg.reconnectDelayMs ?? 3000; + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + this.connect().catch(() => this.scheduleReconnect()); + }, delay); + } + + private rejectAllPending(reason: string): void { + for (const call of this.pending.values()) { + clearTimeout(call.timer); + call.reject(new Error(reason)); + } + this.pending.clear(); + } +} + +/** napcat:发消息用 send_group_msg / send_private_msg 分离接口。 */ +export class NapcatAdapter extends BaseOneBotAdapter { + readonly type = 'napcat' as const; + + sendMessage(target: SendTarget, segments: OneBotSegment[]): Promise<{ messageId?: string }> { + if (target.chatType === 'group') { + return this.send('send_group_msg', { group_id: Number(target.peerId), message: segments }); + } + return this.send('send_private_msg', { user_id: Number(target.peerId), message: segments }); + } +} + +/** snowluma:发消息用统一的 send_msg + message_type 字段。 */ +export class SnowLumaAdapter extends BaseOneBotAdapter { + readonly type = 'snowluma' as const; + + sendMessage(target: SendTarget, segments: OneBotSegment[]): Promise<{ messageId?: string }> { + const idField = + target.chatType === 'group' ? { group_id: Number(target.peerId) } : { user_id: Number(target.peerId) }; + return this.send('send_msg', { message_type: target.chatType, ...idField, message: segments }); + } +} + +/** 按 config.adapter.type 造对应适配器。 */ +export function createAdapter(cfg: AdapterConfig): OneBot11Adapter { + switch (cfg.type) { + case 'napcat': + return new NapcatAdapter(cfg); + case 'snowluma': + return new SnowLumaAdapter(cfg); + default: + throw new Error(`未知 adapter 类型: ${String((cfg as AdapterConfig).type)}`); + } +} diff --git a/packages/bot/src/adapter/types.ts b/packages/bot/src/adapter/types.ts new file mode 100644 index 0000000..69fbccb --- /dev/null +++ b/packages/bot/src/adapter/types.ts @@ -0,0 +1,51 @@ +/** + * OneBot v11 适配层的传输无关类型。napcat 与 snowluma 都遵循 OneBot v11, + * 差异只在少数 action 命名/字段,故用同一套 `OneBot11Adapter` 接口 + 两个实现。 + */ +import type { AdapterType } from '../config'; + +/** OneBot 消息段:{ type, data }。收发通用(text/at/reply/image/record/face/...)。 */ +export interface OneBotSegment { + type: string; + data: Record; +} + +/** 从 ws 收到的原始事件(OneBot v11 上报)。只声明我们会读的字段,其余透传。 */ +export interface IncomingEvent { + post_type?: string; // 'message' | 'meta_event' | 'notice' | 'request' + message_type?: 'private' | 'group'; + sub_type?: string; + message_id?: number | string; + user_id?: number | string; + group_id?: number | string; + /** 段数组(array 上报格式)或纯字符串(string 上报格式)。 */ + message?: OneBotSegment[] | string; + raw_message?: string; + sender?: { user_id?: number | string; nickname?: string; card?: string }; + self_id?: number | string; + [k: string]: unknown; +} + +/** 发送目标:私聊或群,peerId = user_id 或 group_id。 */ +export interface SendTarget { + chatType: 'private' | 'group'; + peerId: string; +} + +/** + * OneBot v11 适配器统一接口。上层编排(Orchestrator)只依赖它,不关心 napcat/snowluma 差异。 + * 扩展点:新增主动能力时优先用 callAction(OneBot 原生 action),无需改接口。 + */ +export interface OneBot11Adapter { + readonly type: AdapterType; + /** 建立连接(正向 ws 客户端)。resolve 表示已 open。 */ + connect(): Promise; + /** 主动关闭(停止重连)。 */ + close(): void; + /** 发一条消息(内部按 adapter 选 send_msg / send_group_msg / send_private_msg)。 */ + sendMessage(target: SendTarget, segments: OneBotSegment[]): Promise<{ messageId?: string }>; + /** 调任意 OneBot action(撤回 delete_msg / 戳一戳 send_poke / 查群成员 ... 的统一入口)。 */ + callAction(action: string, params: Record): Promise; + /** 注册上报事件回调(message / notice / meta_event 等)。 */ + onEvent(handler: (event: IncomingEvent) => void): void; +} diff --git a/packages/bot/src/capabilities.ts b/packages/bot/src/capabilities.ts new file mode 100644 index 0000000..9ba7caa --- /dev/null +++ b/packages/bot/src/capabilities.ts @@ -0,0 +1,49 @@ +/** + * BotCapabilities —— 主动能力封装(扩展轴③)。 + * + * 把 OneBot 原生 action 包成语义方法,供未来「bot 主动做事」(撤回 / 戳一戳 / 改群名片 / 查群成员…) + * 和 AI-tool hook 调用。新增能力只在这里加一个方法即可,不动编排层/适配层。 + * 底层统一走 adapter.callAction(echo RPC),napcat/snowluma 通用。 + */ +import type { OneBot11Adapter } from './adapter/types'; + +export class BotCapabilities { + constructor(private readonly adapter: OneBot11Adapter) {} + + /** 撤回一条消息。 */ + recall(messageId: string): Promise { + return this.adapter.callAction('delete_msg', { message_id: messageId }); + } + + /** 戳一戳(群内或私聊)。 */ + poke(userId: string, groupId?: string): Promise { + return this.adapter.callAction( + 'send_poke', + groupId ? { group_id: Number(groupId), user_id: Number(userId) } : { user_id: Number(userId) }, + ); + } + + /** 改群名片。 */ + setGroupCard(groupId: string, userId: string, card: string): Promise { + return this.adapter.callAction('set_group_card', { + group_id: Number(groupId), + user_id: Number(userId), + card, + }); + } + + /** 查群成员列表。 */ + getGroupMembers(groupId: string): Promise { + return this.adapter.callAction('get_group_member_list', { group_id: Number(groupId) }); + } + + /** 查陌生人/群友资料。 */ + getStrangerInfo(userId: string): Promise { + return this.adapter.callAction('get_stranger_info', { user_id: Number(userId) }); + } + + /** 逃生口:调任意 OneBot action。 */ + call(action: string, params: Record): Promise { + return this.adapter.callAction(action, params); + } +} diff --git a/packages/bot/src/config.ts b/packages/bot/src/config.ts new file mode 100644 index 0000000..724cf23 --- /dev/null +++ b/packages/bot/src/config.ts @@ -0,0 +1,83 @@ +/** + * 导出的 bot 服务运行时配置(对应产物根目录的 config.json)。 + * + * 设计原则:**声明式 + 扩展友好**。config.json 由桌面「导出好友」生成,用户只需填 napcat/snowluma + * 的 ws 地址与 token 即可启动。未来扩展(更多 provider 能力 / 功能开关)只往这里加字段,不改结构。 + */ +import type { AgentLabModelRef, AgentLabEndpoint, EndpointResolver, TtsProviderConfig } from '@weq/agentlab'; + +export type AdapterType = 'napcat' | 'snowluma'; + +/** OneBot 反向?——本期只做正向:bot 作为 ws 客户端连 napcat/snowluma 的 ws 服务。 */ +export interface AdapterConfig { + type: AdapterType; + /** napcat/snowluma 的正向 ws 地址,如 ws://127.0.0.1:8081 */ + wsUrl: string; + /** 鉴权 token(走 Authorization: Bearer;留空则不带鉴权头)。 */ + token?: string; + /** 断线重连间隔(毫秒,默认 3000)。 */ + reconnectDelayMs?: number; + /** 单次 action RPC 的超时(毫秒,默认 15000)。 */ + actionTimeoutMs?: number; +} + +/** LLM provider(导出时从桌面 AppSettings.agentLab 抽出)。persona.models 的 ref.providerId 指向这些。 */ +export interface BotLlmProvider { + id: string; + baseUrl: string; + apiKey: string; +} + +/** 功能开关(扩展预留:随能力增长往这里加,默认关)。 */ +export interface BotFeatures { + /** 允许克隆体发语音(需 ttsProviders 就绪,M2)。 */ + voice?: boolean; + /** 参与群聊(意愿闸决定是否回,M3)。 */ + groupChat?: boolean; + /** + * 群聊回复意愿决策方式(默认 'llm',与桌面模拟群聊一致): + * 'llm'=克隆体带上下文/关系/记忆自己判断要不要开口(更自然,每条消息多一次 LLM 调用); + * 'heuristic'=纯启发式打分(快·省 token)。 + */ + groupReplyMode?: 'llm' | 'heuristic'; +} + +/** 本机 WebUI 控制台(统计 / 总览;导出时生成密钥+编号)。 */ +export interface WebUiConfig { + /** 是否启用(默认启用;关掉则 bot 不开端口)。 */ + enabled?: boolean; + /** 监听端口(仅 127.0.0.1,默认 8090)。 */ + port?: number; + /** 访问密钥(导出时随机生成的 hex)。 */ + key: string; + /** bot 编号(导出时随机生成的 uuid,用于识别产物)。 */ + id: string; +} + +export interface BotConfig { + adapter: AdapterConfig; + /** bot 自己的 QQ 号(= AgentRuntime.selfId;用于区分「自己发的」消息)。 */ + selfId: string; + /** persona 资产根目录(含 persona.json + stickers/ + voice/ + agentvoice/)。相对 config 或绝对路径。 */ + personaDir: string; + /** LLM providers(persona.models 的 ref 指向)。 */ + llmProviders: BotLlmProvider[]; + /** TTS providers(导出自 AppSettings.voiceTranscribe.ttsProviders;persona.voice.providerId 指向)。 */ + ttsProviders?: TtsProviderConfig[]; + features?: BotFeatures; + /** 本机 WebUI 控制台配置(缺省则不启)。 */ + webui?: WebUiConfig; +} + +/** + * 从 config 的 LLM providers 构造 AgentRuntime 需要的 EndpointResolver: + * persona.models 里的 { providerId, model } 引用 → 可直接 fetch 的 { baseUrl, apiKey, model }。 + */ +export function buildEndpointResolver(providers: BotLlmProvider[]): EndpointResolver { + const byId = new Map(providers.map((p) => [p.id, p])); + return (ref: AgentLabModelRef): AgentLabEndpoint => { + const p = byId.get(ref.providerId); + if (!p) throw new Error(`未配置 LLM provider: ${ref.providerId}(persona 引用了它但 config.llmProviders 里没有)`); + return { baseUrl: p.baseUrl, apiKey: p.apiKey, model: ref.model }; + }; +} diff --git a/packages/bot/src/index.ts b/packages/bot/src/index.ts new file mode 100644 index 0000000..619087d --- /dev/null +++ b/packages/bot/src/index.ts @@ -0,0 +1,214 @@ +/** + * @weq/bot 入口:把一个导出的克隆体跑成 OneBot bot。 + * + * startBot(config, opts) 组装 AgentLabStore(persona) + AgentRuntime(引擎) + Adapter(napcat/snowluma) + + * Orchestrator(编排) + WebUI,连上 ws 即上线。产物的 index.mjs 读 config.json 后调用它。 + * + * 完全重载:WebUI 的 POST /api/reload → 走 opts.reloadConfig() 重读 config.json → 停掉当前实例 + * (断 ws、关 WebUI)→ 用新配置重新 boot()。同进程内等价于一次干净重启,连接/编排/统计全部重建, + * 不需要用户手动 npm start。stats 落盘在 data/stats.json,重载后自动续上。 + */ +import { existsSync } from 'node:fs'; +import { isAbsolute, join } from 'node:path'; +import { + AgentLabStore, + AgentRuntime, + TtsService, + describeSticker, + type RuntimeLogger, + type TtsPort, + type TtsProviderConfig, +} from '@weq/agentlab'; +import { buildEndpointResolver, type BotConfig } from './config'; +import { createAdapter } from './adapter/onebot'; +import type { AssetResolver } from './normalize/outbound'; +import { BotOrchestrator } from './orchestrator'; +import { + JsonConversationStore, + JsonMemoryStore, + JsonNotesStore, + JsonRelationStore, +} from './stores'; +import { StatsStore } from './stats'; +import { startWebUi, type WebUiHandle } from './webui/server'; + +const consoleLogger: RuntimeLogger = { + child: () => consoleLogger, + info: (msg, ctx) => console.log(`[bot] ${msg}`, ctx ?? ''), + warn: (msg, ctx) => console.warn(`[bot] ${msg}`, ctx ?? ''), + error: (msg, ctx) => console.error(`[bot] ${msg}`, ctx ?? ''), +}; + +/** + * 从 config 的 ttsProviders 构造 TtsPort(复用下沉到 @weq/agentlab 的 TtsService,纯 fetch)。 + * 门控:只有开了 features.voice 且配了 provider 才启用;否则返回 undefined(克隆体降级纯文字)。 + */ +function buildTtsPort(config: BotConfig): TtsPort | undefined { + const providers = config.ttsProviders ?? []; + if (!config.features?.voice || providers.length === 0) return undefined; + const service = new TtsService(); + const byId = new Map(providers.map((p) => [p.id, p])); + return { + getCapabilities: (id) => { + const p = byId.get(id); + return p ? service.capabilities(p.vendor) : null; + }, + synthesize: (id, text, opts) => { + const p = byId.get(id); + if (!p) throw new Error(`TTS provider 不存在: ${id}`); + return service.synthesize(p, text, opts); + }, + }; +} + +/** 一次组装好的运行实例(可整体 stop,供重载时替换)。 */ +interface BotInstance { + stop: () => void; +} + +export interface StartBotOptions { + /** + * 重载时重新获取配置(通常是重读 config.json 并把 personaDir 补成绝对路径)。 + * 缺省则重载会复用首次传入的 config(只重建对象,不重读文件)。 + */ + reloadConfig?: () => BotConfig | Promise; +} + +/** 组装一个完整实例:store + runtime + adapter + orchestrator + WebUI,连上即上线。 */ +async function boot( + config: BotConfig, + onReload: () => Promise<{ ok: boolean; message?: string }>, +): Promise { + const store = new AgentLabStore(config.personaDir); + const persona = store.listPersonas()[0]; + if (!persona) throw new Error(`personaDir 里没有克隆体数据: ${config.personaDir}`); + + // 导出产物里语音参考 refClips[].path 是相对 personaDir 的(如 voice/x.wav)——补成绝对并回写一次, + // 这样 AgentRuntime 内部 store.getPersona 拿到的路径可直接读到 wav。 + const refClips = persona.voiceProfile?.refClips ?? []; + let patched = false; + for (const clip of refClips) { + if (clip.path && !isAbsolute(clip.path)) { + clip.path = join(config.personaDir, clip.path); + patched = true; + } + } + if (patched) { + const rec = store.getPersona(persona.id); + if (rec) store.savePersona({ persona, pairs: rec.pairs }); + } + + // 记忆 / 关系 / 对话历史 / 统计落盘到产物 data/ 目录(personaDir 的同级),**重启保持**。 + const dataDir = join(config.personaDir, '..', 'data'); + const stats = new StatsStore(join(dataDir, 'stats.json')); + const resolver = buildEndpointResolver(config.llmProviders); + const runtime = new AgentRuntime({ + rootDir: config.personaDir, + store, + endpoints: resolver, + usage: stats, + conversations: new JsonConversationStore(join(dataDir, 'conversations.json')), + memories: new JsonMemoryStore(join(dataDir, 'memories.json')), + notes: new JsonNotesStore(join(dataDir, 'notes.json')), + relations: new JsonRelationStore(join(dataDir, 'relations.json')), + selfId: config.selfId, + tts: buildTtsPort(config), + logger: consoleLogger, + }); + + const assets: AssetResolver = { + stickerPath: (md5) => { + const p = join(config.personaDir, 'stickers', `${md5}.png`); + return existsSync(p) ? p : null; + }, + voicePath: (id) => runtime.getAgentVoicePath(id), + }; + + const adapter = createAdapter(config.adapter); + const orchestrator = new BotOrchestrator(adapter, runtime, persona.id, config.selfId, assets, { + groupChat: config.features?.groupChat ?? false, + groupReplyMode: config.features?.groupReplyMode ?? 'llm', + stats, + }); + orchestrator.start(); + + // 图像模型(若 persona 绑了 vision):供 WebUI 上传新表情时解析一次内容/场景。没有则上传的新表情走「随机发」。 + const visionRef = persona.models?.vision; + const visionDescribe = visionRef + ? async (imageDataUrl: string): Promise<{ description: string; scenario: string }> => { + const ep = resolver({ providerId: visionRef.providerId, model: visionRef.model }); + return describeSticker({ ...ep, kind: 'vision' }, persona.sourceTitle || persona.name, imageDataUrl, ''); + } + : undefined; + + // 本机 WebUI 控制台(默认开;仅 127.0.0.1,用导出时生成的密钥鉴权)。 + let webui: WebUiHandle | null = null; + if (config.webui && config.webui.enabled !== false && config.webui.key) { + webui = await startWebUi({ + port: config.webui.port ?? 8090, + key: config.webui.key, + id: config.webui.id, + persona, + stats, + features: { voice: config.features?.voice ?? false, groupChat: config.features?.groupChat ?? false }, + ttsProviders: config.ttsProviders, + store, + stickersDir: join(config.personaDir, 'stickers'), + visionDescribe, + onReload, + logger: consoleLogger, + }); + } + + await adapter.connect(); + consoleLogger.info(`已连接 ${config.adapter.type} @ ${config.adapter.wsUrl},克隆体「${persona.name}」上线`); + + return { + stop: () => { + adapter.close(); + webui?.close(); + }, + }; +} + +export async function startBot(config: BotConfig, opts: StartBotOptions = {}): Promise<{ stop: () => void }> { + let current: BotInstance | null = null; + let reloading = false; + + // 完全重载:停当前实例 → 重读配置 → 重新 boot。任一步失败则保底不留半死实例。 + async function reload(): Promise<{ ok: boolean; message?: string }> { + if (reloading) return { ok: false, message: '正在重载中,请稍候' }; + reloading = true; + try { + consoleLogger.info('收到重载请求,正在重启实例…'); + current?.stop(); + current = null; + // 关端口/断连需要一点时间落地,稍等避免 EADDRINUSE / ws 抢占。 + await new Promise((r) => setTimeout(r, 300)); + const nextConfig = opts.reloadConfig ? await opts.reloadConfig() : config; + current = await boot(nextConfig, reload); + consoleLogger.info('重载完成,实例已用新配置上线。'); + return { ok: true, message: '已按新配置完全重载' }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + consoleLogger.error(`重载失败:${message}`); + return { ok: false, message }; + } finally { + reloading = false; + } + } + + current = await boot(config, reload); + return { stop: () => current?.stop() }; +} + +export { buildEndpointResolver } from './config'; +export type { BotConfig, AdapterConfig, AdapterType, BotLlmProvider, BotFeatures, WebUiConfig } from './config'; +export { StatsStore } from './stats'; +export { startWebUi, type WebUiHandle } from './webui/server'; +export { createAdapter, NapcatAdapter, SnowLumaAdapter, BaseOneBotAdapter } from './adapter/onebot'; +export type { OneBot11Adapter, OneBotSegment, IncomingEvent, SendTarget } from './adapter/types'; +export { BotOrchestrator } from './orchestrator'; +export { BotCapabilities } from './capabilities'; +export { normalizeInbound, type NormalizedMessage } from './normalize/inbound'; +export { encodeTurn, type AssetResolver } from './normalize/outbound'; diff --git a/packages/bot/src/normalize/emojis.json b/packages/bot/src/normalize/emojis.json new file mode 100644 index 0000000..6981a50 --- /dev/null +++ b/packages/bot/src/normalize/emojis.json @@ -0,0 +1,2513 @@ +[ + { + "id": "0", + "description": "/惊讶", + "code": "114", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "1", + "description": "/撇嘴", + "code": "101", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "2", + "description": "/色", + "code": "102", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "3", + "description": "/发呆", + "code": "103", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "4", + "description": "/得意", + "code": "104", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "5", + "description": "/流泪", + "code": "105", + "aniStickerId": "16", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "6", + "description": "/害羞", + "code": "106", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "7", + "description": "/闭嘴", + "code": "107", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "8", + "description": "/睡", + "code": "108", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "9", + "description": "/大哭", + "code": "109", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "10", + "description": "/尴尬", + "code": "110", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "11", + "description": "/发怒", + "code": "111", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "12", + "description": "/调皮", + "code": "112", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "13", + "description": "/呲牙", + "code": "113", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "14", + "description": "/微笑", + "code": "100", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "15", + "description": "/难过", + "code": "115", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "16", + "description": "/酷", + "code": "116", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "18", + "description": "/抓狂", + "code": "118", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "19", + "description": "/吐", + "code": "119", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "20", + "description": "/偷笑", + "code": "120", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "21", + "description": "/可爱", + "code": "121", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "22", + "description": "/白眼", + "code": "122", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "23", + "description": "/傲慢", + "code": "123", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "24", + "description": "/饥饿", + "code": "124", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "25", + "description": "/困", + "code": "125", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "26", + "description": "/惊恐", + "code": "126", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "27", + "description": "/流汗", + "code": "127", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "28", + "description": "/憨笑", + "code": "128", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "29", + "description": "/悠闲", + "code": "129", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "30", + "description": "/奋斗", + "code": "130", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "31", + "description": "/咒骂", + "code": "131", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "32", + "description": "/疑问", + "code": "132", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "33", + "description": "/嘘", + "code": "133", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "34", + "description": "/晕", + "code": "134", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "35", + "description": "/折磨", + "code": "135", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "36", + "description": "/衰", + "code": "136", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "37", + "description": "/骷髅", + "code": "137", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "38", + "description": "/敲打", + "code": "138", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "39", + "description": "/再见", + "code": "139", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "41", + "description": "/发抖", + "code": "193", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "42", + "description": "/爱情", + "code": "190", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "43", + "description": "/跳跳", + "code": "192", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "46", + "description": "/猪头", + "code": "162", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "49", + "description": "/拥抱", + "code": "178", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "53", + "description": "/蛋糕", + "code": "168", + "aniStickerId": "17", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "55", + "description": "/炸弹", + "code": "170", + "isSpecial": false + }, + { + "id": "56", + "description": "/刀", + "code": "171", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "59", + "description": "/便便", + "code": "174", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "60", + "description": "/咖啡", + "code": "160", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "63", + "description": "/玫瑰", + "code": "163", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "64", + "description": "/凋谢", + "code": "164", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "66", + "description": "/爱心", + "code": "166", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "67", + "description": "/心碎", + "code": "167", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "74", + "description": "/太阳", + "code": "176", + "aniStickerId": "35", + "isSpecial": false, + "source": "超级表情" + }, + { + "id": "75", + "description": "/月亮", + "code": "175", + "aniStickerId": "36", + "isSpecial": false, + "source": "超级表情" + }, + { + "id": "76", + "description": "/赞", + "code": "179", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "77", + "description": "/踩", + "code": "180", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "78", + "description": "/握手", + "code": "181", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "79", + "description": "/胜利", + "code": "182", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "85", + "description": "/飞吻", + "code": "191", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "86", + "description": "/怄火", + "code": "194", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "89", + "description": "/西瓜", + "code": "156", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "96", + "description": "/冷汗", + "code": "117", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "97", + "description": "/擦汗", + "code": "140", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "98", + "description": "/抠鼻", + "code": "141", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "99", + "description": "/鼓掌", + "code": "142", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "100", + "description": "/糗大了", + "code": "143", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "101", + "description": "/坏笑", + "code": "144", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "102", + "description": "/左哼哼", + "code": "145", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "103", + "description": "/右哼哼", + "code": "146", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "104", + "description": "/哈欠", + "code": "147", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "105", + "description": "/鄙视", + "code": "148", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "106", + "description": "/委屈", + "code": "149", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "107", + "description": "/快哭了", + "code": "150", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "108", + "description": "/阴险", + "code": "151", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "109", + "description": "/左亲亲", + "code": "152", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "110", + "description": "/吓", + "code": "153", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "111", + "description": "/可怜", + "code": "154", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "112", + "description": "/菜刀", + "code": "155", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "114", + "description": "/篮球", + "code": "158", + "aniStickerId": "13", + "isSpecial": false, + "source": "超级表情" + }, + { + "id": "116", + "description": "/示爱", + "code": "165", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "118", + "description": "/抱拳", + "code": "183", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "119", + "description": "/勾引", + "code": "184", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "120", + "description": "/拳头", + "code": "185", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "121", + "description": "/差劲", + "code": "186", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "123", + "description": "/NO", + "code": "188", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "124", + "description": "/OK", + "code": "189", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "125", + "description": "/转圈", + "code": "195", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "129", + "description": "/挥手", + "code": "199", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "137", + "description": "/鞭炮", + "code": "121002", + "aniStickerId": "18", + "isSpecial": false, + "source": "超级表情" + }, + { + "id": "144", + "description": "/喝彩", + "code": "121009", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "146", + "description": "/爆筋", + "code": "121011", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "147", + "description": "/棒棒糖", + "code": "121012", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "148", + "description": "/喝奶", + "code": "121013", + "isSpecial": false + }, + { + "id": "169", + "description": "/手枪", + "code": "121034", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "171", + "description": "/茶", + "code": "241", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "172", + "description": "/眨眼睛", + "code": "242", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "173", + "description": "/泪奔", + "code": "243", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "174", + "description": "/无奈", + "code": "244", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "175", + "description": "/卖萌", + "code": "245", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "176", + "description": "/小纠结", + "code": "246", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "177", + "description": "/喷血", + "code": "247", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "178", + "description": "/斜眼笑", + "code": "248", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "179", + "description": "/doge", + "code": "249", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "181", + "description": "/戳一戳", + "code": "251", + "aniStickerId": "37", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "182", + "description": "/笑哭", + "code": "252", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "183", + "description": "/我最美", + "code": "253", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "185", + "description": "/羊驼", + "code": "255", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "187", + "description": "/幽灵", + "code": "257", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "201", + "description": "/点赞", + "code": "271", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "212", + "description": "/托腮", + "code": "282", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "262", + "description": "/脑阔疼", + "code": "10262", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "263", + "description": "/沧桑", + "code": "10263", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "264", + "description": "/捂脸", + "code": "10264", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "265", + "description": "/辣眼睛", + "code": "10265", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "266", + "description": "/哦哟", + "code": "10266", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "267", + "description": "/头秃", + "code": "10267", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "268", + "description": "/问号脸", + "code": "10268", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "269", + "description": "/暗中观察", + "code": "10269", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "270", + "description": "/emm", + "code": "10270", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "271", + "description": "/吃瓜", + "code": "10271", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "272", + "description": "/呵呵哒", + "code": "10272", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "273", + "description": "/我酸了", + "code": "10273", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "277", + "description": "/汪汪", + "code": "10277", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "281", + "description": "/无眼笑", + "code": "10281", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "282", + "description": "/敬礼", + "code": "10282", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "283", + "description": "/狂笑", + "code": "10283", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "284", + "description": "/面无表情", + "code": "10284", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "285", + "description": "/摸鱼", + "code": "10285", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "286", + "description": "/魔鬼笑", + "code": "10286", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "287", + "description": "/哦", + "code": "10287", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "289", + "description": "/睁眼", + "code": "10289", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "293", + "description": "/摸锦鲤", + "code": "10293", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "294", + "description": "/期待", + "code": "10294", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "295", + "description": "/拿到红包", + "code": "10295", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "297", + "description": "/拜谢", + "code": "10297", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "298", + "description": "/元宝", + "code": "10298", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "299", + "description": "/牛啊", + "code": "10299", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "300", + "description": "/胖三斤", + "code": "10300", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "302", + "description": "/左拜年", + "code": "10302", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "303", + "description": "/右拜年", + "code": "10303", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "305", + "description": "/右亲亲", + "code": "10305", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "306", + "description": "/牛气冲天", + "code": "10306", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "307", + "description": "/喵喵", + "code": "10307", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "311", + "description": "/打call", + "code": "10311", + "aniStickerId": "1", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "312", + "description": "/变形", + "code": "10312", + "aniStickerId": "2", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "314", + "description": "/仔细分析", + "code": "10314", + "aniStickerId": "4", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "317", + "description": "/菜汪", + "code": "10317", + "aniStickerId": "7", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "318", + "description": "/崇拜", + "code": "10318", + "aniStickerId": "8", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "319", + "description": "/比心", + "code": "10319", + "aniStickerId": "9", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "320", + "description": "/庆祝", + "code": "10320", + "aniStickerId": "10", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "323", + "description": "/嫌弃", + "code": "10323", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "324", + "description": "/吃糖", + "code": "10324", + "aniStickerId": "12", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "325", + "description": "/惊吓", + "code": "10325", + "aniStickerId": "14", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "326", + "description": "/生气", + "code": "10326", + "aniStickerId": "15", + "isSpecial": false, + "source": "超级表情" + }, + { + "id": "332", + "description": "/举牌牌", + "code": "10332", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "333", + "description": "/烟花", + "code": "10333", + "aniStickerId": "19", + "isSpecial": false, + "source": "超级表情" + }, + { + "id": "334", + "description": "/虎虎生威", + "code": "10334", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "336", + "description": "/豹富", + "code": "10336", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "337", + "description": "/花朵脸", + "code": "10337", + "aniStickerId": "22", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "338", + "description": "/我想开了", + "code": "10338", + "aniStickerId": "20", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "339", + "description": "/舔屏", + "code": "10339", + "aniStickerId": "21", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "341", + "description": "/打招呼", + "code": "10341", + "aniStickerId": "24", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "342", + "description": "/酸Q", + "code": "10342", + "aniStickerId": "26", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "343", + "description": "/我方了", + "code": "10343", + "aniStickerId": "27", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "344", + "description": "/大怨种", + "code": "10344", + "aniStickerId": "28", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "345", + "description": "/红包多多", + "code": "10345", + "aniStickerId": "29", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "346", + "description": "/你真棒棒", + "code": "10346", + "aniStickerId": "25", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "347", + "description": "/大展宏兔", + "code": "10347", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "349", + "description": "/坚强", + "code": "10349", + "aniStickerId": "32", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "350", + "description": "/贴贴", + "code": "10350", + "aniStickerId": "31", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "351", + "description": "/敲敲", + "code": "10351", + "aniStickerId": "30", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "352", + "description": "/咦", + "code": "10352", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "353", + "description": "/拜托", + "code": "10353", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "354", + "description": "/尊嘟假嘟", + "code": "10354", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "355", + "description": "/耶", + "code": "10355", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "356", + "description": "/666", + "code": "10356", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "357", + "description": "/裂开", + "code": "10357", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "358", + "description": "/骰子", + "code": "10358", + "aniStickerId": "33", + "isSpecial": true + }, + { + "id": "359", + "description": "/包剪锤", + "code": "10359", + "aniStickerId": "34", + "isSpecial": true + }, + { + "id": "360", + "description": "/亲亲", + "code": "10360", + "aniStickerId": "6", + "isSpecial": false, + "source": "汪汪" + }, + { + "id": "361", + "description": "/狗狗笑哭", + "code": "10361", + "aniStickerId": "7", + "isSpecial": false, + "source": "汪汪" + }, + { + "id": "362", + "description": "/好兄弟", + "code": "10362", + "aniStickerId": "3", + "isSpecial": false, + "source": "汪汪" + }, + { + "id": "363", + "description": "/狗狗可怜", + "code": "10363", + "aniStickerId": "8", + "isSpecial": false, + "source": "汪汪" + }, + { + "id": "364", + "description": "/超级赞", + "code": "10364", + "aniStickerId": "1", + "isSpecial": false, + "source": "汪汪" + }, + { + "id": "365", + "description": "/狗狗生气", + "code": "10365", + "aniStickerId": "9", + "isSpecial": false, + "source": "汪汪" + }, + { + "id": "366", + "description": "/芒狗", + "code": "10366", + "aniStickerId": "2", + "isSpecial": false, + "source": "汪汪" + }, + { + "id": "367", + "description": "/狗狗疑问", + "code": "10367", + "aniStickerId": "10", + "isSpecial": false, + "source": "汪汪" + }, + { + "id": "368", + "description": "/奥特笑哭", + "code": "10368", + "aniStickerId": "6", + "isSpecial": false, + "source": "噗噗星人" + }, + { + "id": "369", + "description": "/彩虹", + "code": "10369", + "aniStickerId": "7", + "isSpecial": false, + "source": "噗噗星人" + }, + { + "id": "370", + "description": "/祝贺", + "code": "10370", + "aniStickerId": "4", + "isSpecial": false, + "source": "噗噗星人" + }, + { + "id": "371", + "description": "/冒泡", + "code": "10371", + "aniStickerId": "8", + "isSpecial": false, + "source": "噗噗星人" + }, + { + "id": "372", + "description": "/气呼呼", + "code": "10372", + "aniStickerId": "8", + "isSpecial": false, + "source": "噗噗星人" + }, + { + "id": "373", + "description": "/忙", + "code": "10373", + "aniStickerId": "3", + "isSpecial": false, + "source": "噗噗星人" + }, + { + "id": "374", + "description": "/波波流泪", + "code": "10374", + "aniStickerId": "8", + "isSpecial": false, + "source": "噗噗星人" + }, + { + "id": "375", + "description": "/超级鼓掌", + "code": "10375", + "aniStickerId": "5", + "isSpecial": false, + "source": "噗噗星人" + }, + { + "id": "376", + "description": "/跺脚", + "code": "10376", + "aniStickerId": "8", + "isSpecial": false, + "source": "企鹅" + }, + { + "id": "377", + "description": "/嗨", + "code": "10377", + "aniStickerId": "8", + "isSpecial": false, + "source": "企鹅" + }, + { + "id": "378", + "description": "/企鹅笑哭", + "code": "10378", + "aniStickerId": "8", + "isSpecial": false, + "source": "企鹅" + }, + { + "id": "379", + "description": "/企鹅流泪", + "code": "10379", + "aniStickerId": "7", + "isSpecial": false, + "source": "企鹅" + }, + { + "id": "380", + "description": "/真棒", + "code": "10380", + "aniStickerId": "5", + "isSpecial": false, + "source": "企鹅" + }, + { + "id": "381", + "description": "/路过", + "code": "10381", + "aniStickerId": "6", + "isSpecial": false, + "source": "企鹅" + }, + { + "id": "382", + "description": "/emo", + "code": "10382", + "aniStickerId": "1", + "isSpecial": false, + "source": "企鹅" + }, + { + "id": "383", + "description": "/企鹅爱心", + "code": "10383", + "aniStickerId": "2", + "isSpecial": false, + "source": "企鹅" + }, + { + "id": "384", + "description": "/晚安", + "code": "10384", + "aniStickerId": "9", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "385", + "description": "/太气了", + "code": "10385", + "aniStickerId": "8", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "386", + "description": "/呜呜呜", + "code": "10386", + "aniStickerId": "7", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "387", + "description": "/太好笑", + "code": "10387", + "aniStickerId": "10", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "388", + "description": "/太头疼", + "code": "10388", + "aniStickerId": "5", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "389", + "description": "/太赞了", + "code": "10389", + "aniStickerId": "6", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "390", + "description": "/太头秃", + "code": "10390", + "aniStickerId": "3", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "391", + "description": "/太沧桑", + "code": "10391", + "aniStickerId": "4", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "392", + "description": "/龙年快乐", + "code": "10392", + "aniStickerId": "38", + "isSpecial": true, + "source": "超级表情" + }, + { + "id": "393", + "description": "/新年中龙", + "code": "10393", + "aniStickerId": "39", + "isSpecial": false, + "source": "超级表情" + }, + { + "id": "394", + "description": "/新年大龙", + "code": "10394", + "aniStickerId": "40", + "isSpecial": false, + "source": "超级表情" + }, + { + "id": "395", + "description": "/略略略", + "code": "10395", + "aniStickerId": "41", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "396", + "description": "/狼狗", + "code": "10396", + "aniStickerId": "5", + "isSpecial": false, + "source": "汪汪" + }, + { + "id": "397", + "description": "/抛媚眼", + "code": "10397", + "aniStickerId": "4", + "isSpecial": false, + "source": "汪汪" + }, + { + "id": "398", + "description": "/超级ok", + "code": "10398", + "aniStickerId": "2", + "isSpecial": false, + "source": "噗噗星人" + }, + { + "id": "399", + "description": "/tui", + "code": "10399", + "aniStickerId": "1", + "isSpecial": false, + "source": "噗噗星人" + }, + { + "id": "400", + "description": "/快乐", + "code": "10400", + "aniStickerId": "4", + "isSpecial": false, + "source": "企鹅" + }, + { + "id": "401", + "description": "/超级转圈", + "code": "10401", + "aniStickerId": "3", + "isSpecial": false, + "source": "企鹅" + }, + { + "id": "402", + "description": "/别说话", + "code": "10402", + "aniStickerId": "2", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "403", + "description": "/出去玩", + "code": "10403", + "aniStickerId": "1", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "404", + "description": "/闪亮登场", + "code": "10404", + "aniStickerId": "3", + "isSpecial": false, + "source": "喜花妮" + }, + { + "id": "405", + "description": "/好运来", + "code": "10405", + "aniStickerId": "2", + "isSpecial": false, + "source": "喜花妮" + }, + { + "id": "406", + "description": "/姐是女王", + "code": "10406", + "aniStickerId": "4", + "isSpecial": false, + "source": "喜花妮" + }, + { + "id": "407", + "description": "/我听听", + "code": "10407", + "aniStickerId": "7", + "isSpecial": false, + "source": "喜花妮" + }, + { + "id": "408", + "description": "/臭美", + "code": "10408", + "aniStickerId": "8", + "isSpecial": false, + "source": "喜花妮" + }, + { + "id": "409", + "description": "/送你花花", + "code": "10409", + "aniStickerId": "10", + "isSpecial": false, + "source": "喜花妮" + }, + { + "id": "410", + "description": "/么么哒", + "code": "10410", + "aniStickerId": "5", + "isSpecial": false, + "source": "喜花妮" + }, + { + "id": "411", + "description": "/一起嗨", + "code": "10411", + "aniStickerId": "6", + "isSpecial": false, + "source": "喜花妮" + }, + { + "id": "412", + "description": "/开心", + "code": "10412", + "aniStickerId": "9", + "isSpecial": false, + "source": "喜花妮" + }, + { + "id": "413", + "description": "/摇起来", + "code": "10413", + "aniStickerId": "1", + "isSpecial": false, + "source": "喜花妮" + }, + { + "id": "415", + "description": "/划龙舟", + "code": "10415", + "aniStickerId": "43", + "isSpecial": true, + "source": "超级表情" + }, + { + "id": "416", + "description": "/中龙舟", + "code": "10416", + "aniStickerId": "44", + "isSpecial": false, + "source": "超级表情" + }, + { + "id": "417", + "description": "/大龙舟", + "code": "10417", + "aniStickerId": "45", + "isSpecial": false, + "source": "超级表情" + }, + { + "id": "418", + "description": "/填罐罐", + "code": "10418", + "aniStickerId": "46", + "isSpecial": false + }, + { + "id": "419", + "description": "/火车", + "code": "10419", + "aniStickerId": "47", + "isSpecial": true, + "source": "超级表情" + }, + { + "id": "420", + "description": "/中火车", + "code": "10420", + "aniStickerId": "48", + "isSpecial": false, + "source": "超级表情" + }, + { + "id": "421", + "description": "/大火车", + "code": "10421", + "aniStickerId": "49", + "isSpecial": false, + "source": "超级表情" + }, + { + "id": "422", + "description": "/粽于等到你", + "code": "10422", + "aniStickerId": "50", + "isSpecial": false, + "source": "隐藏表情" + }, + { + "id": "423", + "description": "/复兴号", + "code": "10423", + "aniStickerId": "51", + "isSpecial": false, + "source": "隐藏表情" + }, + { + "id": "424", + "description": "/续标识", + "code": "10424", + "aniStickerId": "52", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "425", + "description": "/求放过", + "code": "10425", + "aniStickerId": "53", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "426", + "description": "/玩火", + "code": "10426", + "aniStickerId": "54", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "427", + "description": "/偷感", + "code": "10427", + "aniStickerId": "55", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "428", + "description": "/收到", + "code": "10428", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "429", + "description": "/蛇年快乐", + "code": "10429", + "aniStickerId": "56", + "isSpecial": false, + "source": "超级表情" + }, + { + "id": "430", + "description": "/蛇身", + "code": "10430", + "aniStickerId": "57", + "isSpecial": false, + "source": "超级表情" + }, + { + "id": "431", + "description": "/蛇尾", + "code": "10431", + "aniStickerId": "56", + "isSpecial": false, + "source": "超级表情" + }, + { + "id": "432", + "description": "/灵蛇献瑞", + "code": "10432", + "aniStickerId": "59", + "isSpecial": false, + "source": "隐藏表情" + }, + { + "id": "443", + "description": "/戳一下", + "code": "10443", + "isSpecial": false + }, + { + "id": "450", + "description": "/撇嘴", + "code": "10450", + "aniStickerId": "61", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "451", + "description": "/色", + "code": "10451", + "aniStickerId": "62", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "452", + "description": "/微笑", + "code": "10452", + "aniStickerId": "60", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "453", + "description": "/发呆", + "code": "10453", + "aniStickerId": "63", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "454", + "description": "/得意", + "code": "10454", + "aniStickerId": "64", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "455", + "description": "/害羞", + "code": "10455", + "aniStickerId": "65", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "456", + "description": "/闭嘴", + "code": "10456", + "aniStickerId": "66", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "457", + "description": "/睡", + "code": "10457", + "aniStickerId": "67", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "458", + "description": "/我吗", + "code": "10458", + "aniStickerId": "68", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "459", + "description": "/优雅", + "code": "10459", + "aniStickerId": "69", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "460", + "description": "/硬撑", + "code": "10460", + "aniStickerId": "70", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "461", + "description": "/宕机", + "code": "10461", + "aniStickerId": "71", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "462", + "description": "/无语", + "code": "10462", + "aniStickerId": "72", + "isSpecial": false, + "source": "QQ黄脸" + }, + { + "id": "463", + "description": "/新年快乐", + "code": "10463", + "aniStickerId": "11", + "isSpecial": true, + "source": "QQ黄脸" + }, + { + "id": "464", + "description": "/马上到", + "code": "10464", + "aniStickerId": "12", + "isSpecial": true, + "source": "QQ黄脸" + }, + { + "id": "465", + "description": "/拆红包", + "code": "10465", + "aniStickerId": "13", + "isSpecial": true, + "source": "QQ黄脸" + }, + { + "id": "466", + "description": "/羞羞哒", + "code": "10466", + "aniStickerId": "14", + "isSpecial": true, + "source": "QQ黄脸" + }, + { + "id": "467", + "description": "/摇花手", + "code": "10467", + "aniStickerId": "15", + "isSpecial": true, + "source": "QQ黄脸" + }, + { + "id": "468", + "description": "/失眠", + "code": "10468", + "aniStickerId": "16", + "isSpecial": true, + "source": "QQ黄脸" + }, + { + "id": "469", + "description": "/坚毅", + "code": "10469", + "aniStickerId": "17", + "isSpecial": true, + "source": "QQ黄脸" + }, + { + "id": "470", + "description": "/马到成功", + "code": "10470", + "isSpecial": false, + "source": "小黄脸表情" + }, + { + "id": "☀", + "description": "/晴天", + "code": "401328", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "☕", + "description": "/咖啡", + "code": "401262", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "☺", + "description": "/可爱", + "code": "401181", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "✨", + "description": "/闪光", + "code": "401137", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "❔", + "description": "/问号", + "code": "401145", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "🌹", + "description": "/玫瑰", + "code": "400116", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "🍉", + "description": "/西瓜", + "code": "400132", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "🍎", + "description": "/苹果", + "code": "400137", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "🍓", + "description": "/草莓", + "code": "400142", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "🍜", + "description": "/拉面", + "code": "400151", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "🍞", + "description": "/面包", + "code": "400153", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "🍧", + "description": "/刨冰", + "code": "400162", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "🍺", + "description": "/啤酒", + "code": "400181", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "🍻", + "description": "/干杯", + "code": "400182", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "🎉", + "description": "/庆祝", + "code": "400198", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "🏪", + "description": "/便利店", + "code": "400281", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "🐎", + "description": "/骑马", + "code": "400302", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "🐔", + "description": "/公鸡", + "code": "400308", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "🐛", + "description": "/虫", + "code": "400315", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "🐮", + "description": "/牛", + "code": "400334", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "🐳", + "description": "/鲸鱼", + "code": "400339", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "🐵", + "description": "/猴", + "code": "400341", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "🐶", + "description": "/狗", + "code": "400342", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "🐷", + "description": "/猪", + "code": "400343", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "🐸", + "description": "/青蛙", + "code": "400344", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "👀", + "description": "/眼睛", + "code": "400351", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "👆", + "description": "/向上", + "code": "400366", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "👊", + "description": "/拳头", + "code": "400390", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "👌", + "description": "/好的", + "code": "400402", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "👍", + "description": "/厉害", + "code": "400408", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "👎", + "description": "/鄙视", + "code": "400414", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "👏", + "description": "/鼓掌", + "code": "400420", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "👢", + "description": "/靴子", + "code": "400449", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "👦", + "description": "/男孩", + "code": "400453", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "👧", + "description": "/女孩", + "code": "400459", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "👻", + "description": "/幽灵", + "code": "400562", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "💉", + "description": "/打针", + "code": "400611", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "💓", + "description": "/爱心", + "code": "400621", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "💝", + "description": "/礼物", + "code": "400631", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "💣", + "description": "/炸弹", + "code": "400637", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "💤", + "description": "/睡觉", + "code": "400638", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "💦", + "description": "/水", + "code": "400640", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "💨", + "description": "/吹气", + "code": "400642", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "💩", + "description": "/便便", + "code": "400643", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "💪", + "description": "/肌肉", + "code": "400644", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "📫", + "description": "/邮箱", + "code": "400714", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "🔥", + "description": "/火", + "code": "400768", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "🔫", + "description": "/手枪", + "code": "400774", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "😁", + "description": "/呲牙", + "code": "400823", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "😂", + "description": "/激动", + "code": "400824", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "😄", + "description": "/高兴", + "code": "400826", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "😊", + "description": "/嘿嘿", + "code": "400832", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "😌", + "description": "/羞涩", + "code": "400834", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "😍", + "description": "/花痴", + "code": "400835", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "😏", + "description": "/哼哼", + "code": "400837", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "😒", + "description": "/不屑", + "code": "400840", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "😓", + "description": "/汗", + "code": "400841", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "😔", + "description": "/失落", + "code": "400842", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "😘", + "description": "/飞吻", + "code": "400846", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "😚", + "description": "/亲亲", + "code": "400848", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "😜", + "description": "/淘气", + "code": "400850", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "😝", + "description": "/吐舌", + "code": "400851", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "😭", + "description": "/大哭", + "code": "400867", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "😰", + "description": "/紧张", + "code": "400870", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "😱", + "description": "/害怕", + "code": "400871", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "😳", + "description": "/瞪眼", + "code": "400873", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "🙏", + "description": "/合十", + "code": "400396", + "isSpecial": false, + "source": "emoji 表情" + }, + { + "id": "🚬", + "description": "/吸烟", + "code": "400987", + "isSpecial": false, + "source": "emoji 表情" + } +] \ No newline at end of file diff --git a/packages/bot/src/normalize/inbound.ts b/packages/bot/src/normalize/inbound.ts new file mode 100644 index 0000000..67251a9 --- /dev/null +++ b/packages/bot/src/normalize/inbound.ts @@ -0,0 +1,89 @@ +/** + * 入站归一化:OneBot 上报事件 → 内部 NormalizedMessage。 + * + * 扩展轴①:目前把 text/at/face 归一成文本,image/record/reply 先给占位。未来「让 bot 看图/听语音/ + * 认引用」只需在 segToText 里把对应分支从占位换成真实处理(vision / 转录 / 拉引用上下文),不动上层。 + */ +import type { IncomingEvent, OneBotSegment } from '../adapter/types'; +import { faceIdToText } from './qq_faces'; + +export interface NormalizedMessage { + chatType: 'private' | 'group'; + /** 会话对端 id:私聊=对方 QQ;群=群号。回消息时作为 SendTarget.peerId。 */ + peerId: string; + /** 发送者 QQ 号。 */ + senderId: string; + /** 发送者展示名(群名片优先,其次昵称)。 */ + senderName: string; + /** 归一化后的文本(喂给克隆体的输入)。 */ + text: string; + /** 是否 @ 了 bot 自己(群聊意愿闸「被点名必回」用)。 */ + mentionsSelf: boolean; + messageId?: string; +} + +/** 把 OneBot 的 message 字段规整成段数组(兼容 array 上报;string 上报退化为单个 text 段)。 */ +function toSegments(message: OneBotSegment[] | string | undefined, rawMessage?: string): OneBotSegment[] { + if (Array.isArray(message)) return message; + const text = typeof message === 'string' ? message : (rawMessage ?? ''); + return text ? [{ type: 'text', data: { text } }] : []; +} + +/** 单个段 → 文本片段(并回报是否 @ 了自己)。扩展点集中在这里。 */ +function segToText(seg: OneBotSegment, selfId: string): { text: string; mentionsSelf: boolean } { + switch (seg.type) { + case 'text': + return { text: String(seg.data.text ?? ''), mentionsSelf: false }; + case 'at': { + const qq = String(seg.data.qq ?? ''); + if (qq === selfId || qq === 'all') return { text: '', mentionsSelf: qq === selfId }; + // TODO(扩展): 查群名片替换成 @昵称,现阶段保留 @QQ。 + return { text: `@${qq} `, mentionsSelf: false }; + } + case 'face': { + // 系统表情 → faceText(如 /惊讶),让克隆体理解对方发了什么表情。 + const ft = faceIdToText(String(seg.data.id ?? '')); + return { text: ft ?? '', mentionsSelf: false }; + } + // TODO(扩展 M5): image → vision 解读;record → 转录;reply → 引用上下文。 + case 'image': + return { text: '[图片]', mentionsSelf: false }; + case 'record': + return { text: '[语音]', mentionsSelf: false }; + default: + return { text: '', mentionsSelf: false }; + } +} + +/** + * 归一化一条上报事件。非 message 类型(meta_event/notice/request)返回 null(M1 不处理,M5 扩展)。 + */ +export function normalizeInbound(event: IncomingEvent, selfId: string): NormalizedMessage | null { + if (event.post_type !== 'message') return null; + const chatType = event.message_type; + if (chatType !== 'private' && chatType !== 'group') return null; + + const senderId = String(event.user_id ?? event.sender?.user_id ?? ''); + if (!senderId) return null; + const peerId = chatType === 'group' ? String(event.group_id ?? '') : senderId; + if (!peerId) return null; + const senderName = event.sender?.card || event.sender?.nickname || senderId; + + let mentionsSelf = false; + const parts: string[] = []; + for (const seg of toSegments(event.message, event.raw_message)) { + const { text, mentionsSelf: m } = segToText(seg, selfId); + if (m) mentionsSelf = true; + if (text) parts.push(text); + } + + return { + chatType, + peerId, + senderId, + senderName, + text: parts.join('').trim(), + mentionsSelf, + messageId: event.message_id != null ? String(event.message_id) : undefined, + }; +} diff --git a/packages/bot/src/normalize/outbound.ts b/packages/bot/src/normalize/outbound.ts new file mode 100644 index 0000000..b30b26d --- /dev/null +++ b/packages/bot/src/normalize/outbound.ts @@ -0,0 +1,49 @@ +/** + * 出站归一化:AgentRuntime 产出的一条 renderedTurn → 一条消息的 OneBot 段数组。 + * + * renderedTurn 三种形态(与桌面 ChatBubble 消费的内部标记一致): + * - 纯文本(可含系统表情 /捂脸)→ text 段(TODO M2: 把 /捂脸 拆成独立 face 段) + * - `[[sticker:md5]]` → image 段(读导出资产 stickers/.png) + * - `[[voice:id]]` → record 段(读导出资产 agentvoice/) + * + * 扩展轴②:未来引擎产出新 action(at/reply/poke...)时,在这里加一条编码分支即可。 + */ +import { readFileSync } from 'node:fs'; +import type { OneBotSegment } from '../adapter/types'; +import { splitSystemFaces } from './qq_faces'; + +const STICKER_RE = /^\[\[sticker:([0-9a-fA-F]+)\]\]$/; +const VOICE_RE = /^\[\[voice:([0-9a-zA-Z._-]+)\]\]$/; + +/** 资产路径解析器:把 sticker md5 / voice id 映射成本地文件绝对路径(找不到返回 null)。 */ +export interface AssetResolver { + stickerPath(md5: string): string | null; + voicePath(id: string): string | null; +} + +/** 本地文件 → OneBot file 字段(base64://,最通用,不要求 napcat 能访问 bot 的文件系统)。 */ +function fileToBase64Uri(path: string): string { + return `base64://${readFileSync(path).toString('base64')}`; +} + +/** + * 编码一条 renderedTurn 为段数组。返回 null 表示这条不发(如表情图/语音文件缺失)。 + */ +export function encodeTurn(turn: string, assets: AssetResolver): OneBotSegment[] | null { + const sticker = turn.match(STICKER_RE); + if (sticker) { + const path = assets.stickerPath(sticker[1]!); + if (!path) return null; + return [{ type: 'image', data: { file: fileToBase64Uri(path) } }]; + } + const voice = turn.match(VOICE_RE); + if (voice) { + const path = assets.voicePath(voice[1]!); + if (!path) return null; + return [{ type: 'record', data: { file: fileToBase64Uri(path) } }]; + } + const text = turn.trim(); + if (!text) return null; + // 纯文本:把系统表情 /捂脸 拆成 face 段,其余留 text 段,QQ 才渲染成表情图。 + return splitSystemFaces(text); +} diff --git a/packages/bot/src/normalize/qq_faces.ts b/packages/bot/src/normalize/qq_faces.ts new file mode 100644 index 0000000..210fc23 --- /dev/null +++ b/packages/bot/src/normalize/qq_faces.ts @@ -0,0 +1,71 @@ +/** + * QQ 系统表情映射(faceText ↔ OneBot faceId),数据源 resources/emoji/emojis.json(342 项, + * 从项目 git 历史 checkout;桌面后来改用 QQ emoji.db 解析,这里给 bot 内置一份静态表,bundle 自包含)。 + * + * 用途:克隆体回复文本里的系统表情(如 /捂脸)→ OneBot face 段 { type:'face', data:{ id } }, + * 这样 QQ 客户端才渲染成表情图,而不是显示哑文字「/捂脸」。 + */ +import emojis from './emojis.json'; +import type { OneBotSegment } from '../adapter/types'; + +interface EmojiEntry { + id: string; + description: string; + code: string; + isSpecial: boolean; + source: string; +} + +/** faceText(如 /惊讶)→ OneBot face id(如 "0")。 */ +const FACE_TEXT_TO_ID = new Map(); +const FACE_ID_TO_TEXT = new Map(); +for (const e of emojis as EmojiEntry[]) { + if (e.description && e.id) { + FACE_TEXT_TO_ID.set(e.description, e.id); + if (!FACE_ID_TO_TEXT.has(e.id)) FACE_ID_TO_TEXT.set(e.id, e.description); + } +} + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +// 按 faceText 长度降序,长的优先匹配(避免 /笑 抢在 /笑哭 前面)。 +const FACE_TEXTS = [...FACE_TEXT_TO_ID.keys()].sort((a, b) => b.length - a.length); +const FACE_PATTERN = FACE_TEXTS.length > 0 ? new RegExp(FACE_TEXTS.map(escapeRegExp).join('|'), 'g') : null; + +/** faceText → faceId(找不到返回 null)。 */ +export function faceTextToId(faceText: string): string | null { + return FACE_TEXT_TO_ID.get(faceText) ?? null; +} + +/** OneBot faceId → faceText(如 "0" → /惊讶);找不到返回 null。 */ +export function faceIdToText(faceId: string): string | null { + return FACE_ID_TO_TEXT.get(faceId) ?? null; +} + +/** + * 把一段文本按系统表情拆成 text/face 混合段:/捂脸 变成独立 face 段,其余留 text 段。 + * 无表情时返回单个 text 段。空文本返回空数组。 + */ +export function splitSystemFaces(text: string): OneBotSegment[] { + if (!text) return []; + if (!FACE_PATTERN) return [{ type: 'text', data: { text } }]; + const segs: OneBotSegment[] = []; + let last = 0; + for (const m of text.matchAll(FACE_PATTERN)) { + const idx = m.index ?? 0; + if (idx > last) { + const chunk = text.slice(last, idx); + if (chunk) segs.push({ type: 'text', data: { text: chunk } }); + } + const id = FACE_TEXT_TO_ID.get(m[0]); + if (id) segs.push({ type: 'face', data: { id } }); + last = idx + m[0].length; + } + if (last < text.length) { + const tail = text.slice(last); + if (tail) segs.push({ type: 'text', data: { text: tail } }); + } + return segs.length > 0 ? segs : [{ type: 'text', data: { text } }]; +} diff --git a/packages/bot/src/orchestrator.ts b/packages/bot/src/orchestrator.ts new file mode 100644 index 0000000..5e7e356 --- /dev/null +++ b/packages/bot/src/orchestrator.ts @@ -0,0 +1,178 @@ +/** + * 编排层:连接 adapter,路由消息,驱动 AgentRuntime,把回复逐条发回。 + * + * 内置详细 console 日志(收到消息 / 意愿闸决策 / 生成内容 / 发送内容),方便真机调试 + * ——尤其「为什么不回」一眼可查(看意愿闸 reason/score 或是否被判为空文本)。 + */ +import { AgentRuntime, type AgentLabChatTurn } from '@weq/agentlab'; +import type { OneBot11Adapter } from './adapter/types'; +import { normalizeInbound, type NormalizedMessage } from './normalize/inbound'; +import { encodeTurn, type AssetResolver } from './normalize/outbound'; +import { BotCapabilities } from './capabilities'; + +const HISTORY_CAP = 40; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** 日志时间戳 HH:MM:SS。 */ +function ts(): string { + return new Date().toISOString().slice(11, 19); +} + +/** 运行统计打点端口(WebUI 统计页用;缺省则不统计)。 */ +export interface StatsSink { + onMessageIn(ts?: number): void; + onMessageOut(ts?: number): void; + onReplyGenerated(): void; +} + +export interface OrchestratorOptions { + /** 是否参与群聊(M3)。默认关:只处理私聊。 */ + groupChat?: boolean; + /** 群聊回复意愿决策方式(默认 'llm',与桌面模拟群聊一致;'heuristic'=纯启发式打分省 token)。 */ + groupReplyMode?: 'llm' | 'heuristic'; + /** 收发消息计数(WebUI 统计)。 */ + stats?: StatsSink; +} + +export class BotOrchestrator { + /** 按对端 id 维护的近期对话历史(喂给 AgentRuntime 作上下文)。 */ + private readonly histories = new Map(); + /** 每个群里 bot 上次开口的时间(意愿闸冷却用)。 */ + private readonly lastGroupReplyAt = new Map(); + /** 主动能力(撤回/戳一戳/查群…)——扩展轴③,供未来 AI-tool hook 调用。 */ + readonly capabilities: BotCapabilities; + + constructor( + private readonly adapter: OneBot11Adapter, + private readonly runtime: AgentRuntime, + private readonly personaId: string, + private readonly selfId: string, + private readonly assets: AssetResolver, + private readonly opts: OrchestratorOptions = {}, + ) { + this.capabilities = new BotCapabilities(adapter); + } + + /** 注册事件回调(在 adapter.connect() 之前调)。 */ + start(): void { + this.adapter.onEvent((event) => { + void this.onEvent(event); + }); + } + + private async onEvent(event: Parameters[0]>[0]): Promise { + const msg = normalizeInbound(event, this.selfId); + if (!msg) return; + if (msg.senderId === this.selfId) return; // 不理会自己发的 + + const where = msg.chatType === 'group' ? `群${msg.peerId}` : '私聊'; + console.log( + `\n[${ts()}] 收到 <${where}> ${msg.senderName}(${msg.senderId})${msg.mentionsSelf ? ' 【@我】' : ''}: ${msg.text || '(无文本)'}`, + ); + + // @我 即使没带文字也要处理(群聊「被@必回」);否则纯空文本消息忽略。 + if (!msg.text && !msg.mentionsSelf) { + console.log(`[${ts()}] └ 跳过:空文本且未@我`); + return; + } + this.opts.stats?.onMessageIn(); + if (msg.chatType === 'private') { + await this.handlePrivate(msg); + } else if (this.opts.groupChat) { + await this.handleGroup(msg); + } else { + console.log(`[${ts()}] └ 跳过:群聊未开启(config.features.groupChat=false)`); + } + } + + private async handlePrivate(msg: NormalizedMessage): Promise { + const history = this.histories.get(msg.peerId) ?? []; + let result: Awaited>; + try { + result = await this.runtime.chat({ personaId: this.personaId, history, text: msg.text }); + } catch (err) { + console.error(`[${ts()}] └ 生成失败:`, err instanceof Error ? err.message : err); + return; + } + if (result.silent) { + console.log(`[${ts()}] └ 静默(私聊意愿闸未过)`); + return; + } + this.opts.stats?.onReplyGenerated(); + console.log(`[${ts()}] └ 生成 ${result.renderedTurns.length} 条: ${JSON.stringify(result.renderedTurns)}`); + await this.deliver({ chatType: 'private', peerId: msg.peerId }, result.renderedTurns, result.replyDelayMs); + + const next: AgentLabChatTurn[] = [ + ...history, + { role: 'user', text: msg.text }, + ...result.renderedTurns.map((text): AgentLabChatTurn => ({ role: 'assistant', text })), + ]; + this.histories.set(msg.peerId, next.slice(-HISTORY_CAP)); + } + + /** 群聊(单 persona + 意愿闸):过闸才回,关系/记忆按群友维护。 */ + private async handleGroup(msg: NormalizedMessage): Promise { + const history = this.histories.get(msg.peerId) ?? []; + // 存在感(最近自己占比)+ 冷却(距上次开口)——喂给意愿闸压制刷屏/抢话。 + const recent = history.slice(-8); + const selfShareRecent = recent.length > 0 ? recent.filter((t) => t.role === 'assistant').length / recent.length : 0; + const lastReplyAt = this.lastGroupReplyAt.get(msg.peerId); + const msSinceOwnLastReply = lastReplyAt !== undefined ? Date.now() - lastReplyAt : undefined; + + let result: Awaited>; + try { + result = await this.runtime.handleGroupMessage({ + personaId: this.personaId, + senderId: msg.senderId, + senderName: msg.senderName, + text: msg.text, + mentionsSelf: msg.mentionsSelf, + history, + selfShareRecent, + msSinceOwnLastReply, + mode: this.opts.groupReplyMode ?? 'llm', + }); + } catch (err) { + console.error(`[${ts()}] └ 群聊生成失败:`, err instanceof Error ? err.message : err); + return; + } + // 意愿闸决策日志:一眼看清「为什么回 / 为什么不回」。 + console.log( + `[${ts()}] └ 意愿闸: ${result.reason} score=${result.score.toFixed(2)} → ${result.silent ? '不回' : '回复'}`, + ); + + history.push({ role: 'user', text: `「${msg.senderName}」:${msg.text}` }); + + if (!result.silent && result.renderedTurns.length > 0) { + this.opts.stats?.onReplyGenerated(); + console.log(`[${ts()}] 生成 ${result.renderedTurns.length} 条: ${JSON.stringify(result.renderedTurns)}`); + await this.deliver({ chatType: 'group', peerId: msg.peerId }, result.renderedTurns, result.replyDelayMs); + for (const t of result.renderedTurns) history.push({ role: 'assistant', text: t }); + this.lastGroupReplyAt.set(msg.peerId, Date.now()); + } + this.histories.set(msg.peerId, history.slice(-HISTORY_CAP)); + } + + /** 逐条把 renderedTurns 编码成 OneBot 段发出(分条之间按 replyDelayMs 拟人停顿)。 */ + private async deliver( + target: { chatType: 'private' | 'group'; peerId: string }, + turns: string[], + replyDelayMs: number, + ): Promise { + for (const turn of turns) { + const segments = encodeTurn(turn, this.assets); + if (!segments) continue; + try { + await this.adapter.sendMessage(target, segments); + this.opts.stats?.onMessageOut(); + console.log(`[${ts()}] 发送 → ${target.chatType}:${target.peerId}: ${turn}`); + } catch (err) { + console.error(`[${ts()}] 发送失败:`, err instanceof Error ? err.message : err); + } + if (replyDelayMs > 0) await sleep(replyDelayMs); + } + } +} diff --git a/packages/bot/src/stats.ts b/packages/bot/src/stats.ts new file mode 100644 index 0000000..ce380a0 --- /dev/null +++ b/packages/bot/src/stats.ts @@ -0,0 +1,280 @@ +/** + * bot 运行统计(JSON 落盘,重启保持)。 + * + * 两条数据来源: + * ① token 用量:实现 UsageSink,AgentRuntime 每次调用 LLM 会喂 promptTokens/completionTokens。 + * ② 收发消息:orchestrator 在收/发/生成处打点(onMessageIn/onMessageOut/onReplyGenerated)。 + * + * 聚合维度:总量 + 按模型 + 按天(YYYY-MM-DD,本地时区),供 WebUI 统计页展示。 + * 落盘到产物 data/stats.json;startedAt 记进程本次启动时间(运行时长),totals 跨重启累加。 + */ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; +import type { UsageSink } from '@weq/agentlab'; + +/** 单日聚合桶。 */ +export interface StatsDayBucket { + /** 本地日期 YYYY-MM-DD */ + date: string; + promptTokens: number; + completionTokens: number; + totalTokens: number; + messagesIn: number; + messagesOut: number; +} + +/** 按模型聚合。 */ +export interface StatsModelBucket { + model: string; + calls: number; + promptTokens: number; + completionTokens: number; + totalTokens: number; +} + +/** 单小时聚合桶(近 24 小时折线用)。 */ +export interface StatsHourBucket { + /** 展示用标签 HH:00(snapshot 时填)。 */ + hour: string; + tokens: number; + calls: number; +} + +interface StatsData { + /** 首次启动(历史累计起点)。 */ + firstStartedAt: number; + totals: { + promptTokens: number; + completionTokens: number; + totalTokens: number; + llmCalls: number; + messagesIn: number; + messagesOut: number; + repliesGenerated: number; + }; + byModel: Record; + byDay: Record; + /** key = YYYY-MM-DD-HH(本地时区);只保留近 ~48h,snapshot 取近 24h。 */ + byHour: Record; +} + +/** WebUI /api/stats 的返回快照。 */ +export interface StatsSnapshot { + firstStartedAt: number; + /** 本次进程启动时间(运行时长 = now - startedAt)。 */ + startedAt: number; + now: number; + totals: StatsData['totals']; + byModel: StatsModelBucket[]; + /** 最近 N 天,最早→最晚。 */ + byDay: StatsDayBucket[]; + /** 最近 24 小时,最早→最晚。 */ + byHour: StatsHourBucket[]; +} + +function emptyData(now: number): StatsData { + return { + firstStartedAt: now, + totals: { + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + llmCalls: 0, + messagesIn: 0, + messagesOut: 0, + repliesGenerated: 0, + }, + byModel: {}, + byDay: {}, + byHour: {}, + }; +} + +/** 本地时区 YYYY-MM-DD。 */ +function dayKey(ts: number): string { + const d = new Date(ts); + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; +} + +/** 本地时区 YYYY-MM-DD-HH(小时聚合键)。 */ +function hourKey(ts: number): string { + const d = new Date(ts); + const h = String(d.getHours()).padStart(2, '0'); + return `${dayKey(ts)}-${h}`; +} + +/** 裁掉早于 48 小时的小时桶(近 24h 展示够用,留一倍余量防时区/边界丢点)。 */ +function pruneHours( + hours: Record, + now: number, +): Record { + const keep = new Set(); + for (let i = 0; i < 48; i++) keep.add(hourKey(now - i * 3600_000)); + const out: Record = {}; + for (const [k, v] of Object.entries(hours)) if (keep.has(k)) out[k] = v; + return out; +} + +export class StatsStore implements UsageSink { + private readonly data: StatsData; + private readonly startedAt: number; + private saveTimer: NodeJS.Timeout | null = null; + + constructor( + private readonly filePath: string, + now: number = Date.now(), + ) { + this.startedAt = now; + this.data = this.load(now); + } + + private load(now: number): StatsData { + try { + if (existsSync(this.filePath)) { + const parsed = JSON.parse(readFileSync(this.filePath, 'utf-8')) as Partial; + const base = emptyData(now); + return { + firstStartedAt: parsed.firstStartedAt ?? now, + totals: { ...base.totals, ...parsed.totals }, + byModel: parsed.byModel ?? {}, + byDay: parsed.byDay ?? {}, + byHour: pruneHours(parsed.byHour ?? {}, now), + }; + } + } catch { + /* 损坏则重来,不阻断 bot */ + } + return emptyData(now); + } + + /** 合并写盘(200ms 防抖,避免高频消息把磁盘打爆)。 */ + private persist(): void { + if (this.saveTimer) return; + this.saveTimer = setTimeout(() => { + this.saveTimer = null; + try { + mkdirSync(dirname(this.filePath), { recursive: true }); + writeFileSync(this.filePath, JSON.stringify(this.data), 'utf-8'); + } catch { + /* 落盘失败不阻断聊天 */ + } + }, 200); + this.saveTimer.unref?.(); + } + + private dayBucket(ts: number): StatsDayBucket { + const key = dayKey(ts); + let bucket = this.data.byDay[key]; + if (!bucket) { + bucket = { date: key, promptTokens: 0, completionTokens: 0, totalTokens: 0, messagesIn: 0, messagesOut: 0 }; + this.data.byDay[key] = bucket; + } + return bucket; + } + + /** UsageSink:AgentRuntime 每次 LLM 调用后回调。 */ + record(entry: { + ts: number; + model: string; + kind: 'chat' | 'embedding' | 'vision'; + promptTokens: number; + completionTokens: number; + totalTokens: number; + }): void { + const t = this.data.totals; + t.promptTokens += entry.promptTokens; + t.completionTokens += entry.completionTokens; + t.totalTokens += entry.totalTokens; + t.llmCalls += 1; + + const model = entry.model || '(unknown)'; + let mb = this.data.byModel[model]; + if (!mb) { + mb = { model, calls: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0 }; + this.data.byModel[model] = mb; + } + mb.calls += 1; + mb.promptTokens += entry.promptTokens; + mb.completionTokens += entry.completionTokens; + mb.totalTokens += entry.totalTokens; + + const day = this.dayBucket(entry.ts); + day.promptTokens += entry.promptTokens; + day.completionTokens += entry.completionTokens; + day.totalTokens += entry.totalTokens; + + const hk = hourKey(entry.ts); + const hb = this.data.byHour[hk] ?? { tokens: 0, calls: 0 }; + hb.tokens += entry.totalTokens; + hb.calls += 1; + this.data.byHour[hk] = hb; + // 长跑进程每小时新增一个键,超过阈值就裁到近 48h,防无界增长。 + if (Object.keys(this.data.byHour).length > 60) { + this.data.byHour = pruneHours(this.data.byHour, entry.ts); + } + + this.persist(); + } + + onMessageIn(ts: number = Date.now()): void { + this.data.totals.messagesIn += 1; + this.dayBucket(ts).messagesIn += 1; + this.persist(); + } + + onMessageOut(ts: number = Date.now()): void { + this.data.totals.messagesOut += 1; + this.dayBucket(ts).messagesOut += 1; + this.persist(); + } + + onReplyGenerated(): void { + this.data.totals.repliesGenerated += 1; + this.persist(); + } + + /** 供 WebUI 读取的快照(byDay 取最近 days 天,补齐空档)。 */ + snapshot(days = 14, now: number = Date.now()): StatsSnapshot { + const byDay: StatsDayBucket[] = []; + const dayMs = 24 * 60 * 60 * 1000; + for (let i = days - 1; i >= 0; i--) { + const key = dayKey(now - i * dayMs); + byDay.push( + this.data.byDay[key] ?? { + date: key, + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + messagesIn: 0, + messagesOut: 0, + }, + ); + } + // 近 24 小时(含当前小时),最早→最晚,补齐空档,label 用本地 HH:00。 + const byHour: StatsHourBucket[] = []; + for (let i = 23; i >= 0; i--) { + const ts = now - i * 3600_000; + const key = hourKey(ts); + const b = this.data.byHour[key]; + byHour.push({ + hour: `${String(new Date(ts).getHours()).padStart(2, '0')}:00`, + tokens: b?.tokens ?? 0, + calls: b?.calls ?? 0, + }); + } + + const byModel = Object.values(this.data.byModel).sort((a, b) => b.totalTokens - a.totalTokens); + return { + firstStartedAt: this.data.firstStartedAt, + startedAt: this.startedAt, + now, + totals: { ...this.data.totals }, + byModel, + byDay, + byHour, + }; + } +} diff --git a/packages/bot/src/stores.ts b/packages/bot/src/stores.ts new file mode 100644 index 0000000..99b3675 --- /dev/null +++ b/packages/bot/src/stores.ts @@ -0,0 +1,194 @@ +/** + * bot 侧的 store 实现(JSON 落盘),满足 AgentRuntime 的各 Sink 接口。 + * 数据落在产物的 data/ 目录,**重启不丢**记忆/关系/对话历史。 + * + * TODO(技术债):与 service 的 JSON store(agentlab_memory/notes/conversation/relation_store)逻辑重复, + * 未来可把它们下沉到 @weq/agentlab 共用(唯一障碍:ConversationStore 的 steps 依赖 service 的 AssistantStep,需泛化)。 + */ +import { createHash } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { + keywordsOf, + makeBaseRelation, + applyRelationDelta, + type AgentLabMemoryItem, + type AgentLabPersonaNotes, + type AgentLabRelation, + type AgentLabRelationStore, + type AgentLabMemberKind, + type ConversationSink, + type ConversationTurnLike, + type MemorySink, + type NotesSink, + type UsageSink, +} from '@weq/agentlab'; + +const MAX_TURNS = 400; +const MAX_MEMORIES = 200; + +function loadJson(filePath: string, fallback: T): T { + try { + if (!existsSync(filePath)) return fallback; + return JSON.parse(readFileSync(filePath, 'utf-8')) as T; + } catch { + return fallback; + } +} + +function saveJson(filePath: string, data: unknown): void { + try { + mkdirSync(dirname(filePath), { recursive: true }); + writeFileSync(filePath, JSON.stringify(data), 'utf-8'); + } catch { + /* 落盘失败不阻断聊天 */ + } +} + +export class JsonConversationStore implements ConversationSink { + private readonly data: Record; + constructor(private readonly filePath: string) { + this.data = loadJson>(filePath, {}); + } + get(agentId: string): ConversationTurnLike[] { + return this.data[agentId] ?? []; + } + append(agentId: string, turns: ConversationTurnLike[]): void { + const next = [...(this.data[agentId] ?? []), ...turns]; + this.data[agentId] = next.length > MAX_TURNS ? next.slice(next.length - MAX_TURNS) : next; + saveJson(this.filePath, this.data); + } +} + +export class JsonMemoryStore implements MemorySink { + private readonly data: Record; + constructor(private readonly filePath: string) { + this.data = loadJson>(filePath, {}); + } + get(personaId: string): AgentLabMemoryItem[] { + return this.data[personaId] ?? []; + } + getAbout(personaId: string, aboutIds: string[], includeUntagged = false): AgentLabMemoryItem[] { + const ids = new Set(aboutIds); + return (this.data[personaId] ?? []).filter((m) => (m.aboutId ? ids.has(m.aboutId) : includeUntagged)); + } + touch(personaId: string, ids: string[], now: number): void { + if (ids.length === 0) return; + const idSet = new Set(ids); + let changed = false; + for (const m of this.data[personaId] ?? []) { + if (idSet.has(m.id)) { + m.accessCount += 1; + m.lastAccessedAt = now; + changed = true; + } + } + if (changed) saveJson(this.filePath, this.data); + } + add( + personaId: string, + texts: string[], + now: number, + about?: { aboutId: string; aboutKind: 'user' | 'persona' }, + embeddings?: Array, + ): void { + const cur = this.data[personaId] ?? []; + const seen = new Set(cur.map((m) => `${m.aboutId ?? ''} ${m.text}`)); + let added = 0; + texts.forEach((raw, i) => { + const t = raw.trim(); + const key = `${about?.aboutId ?? ''} ${t}`; + if (!t || seen.has(key)) return; + seen.add(key); + const emb = embeddings?.[i]; + cur.push({ + id: createHash('sha1').update(`${personaId}|${t}|${now + i}`).digest('hex').slice(0, 16), + text: t, + keywords: keywordsOf(t), + ...(emb && emb.length > 0 ? { embedding: emb } : {}), + ...(about ? { aboutId: about.aboutId, aboutKind: about.aboutKind } : {}), + accessCount: 0, + createdAt: now, + lastAccessedAt: now, + }); + added += 1; + }); + this.data[personaId] = cur.slice(-MAX_MEMORIES); + if (added > 0) saveJson(this.filePath, this.data); + } +} + +export class JsonNotesStore implements NotesSink { + private readonly notes: Record; + private readonly reflected: Record; + constructor(private readonly filePath: string) { + const loaded = loadJson<{ notes?: Record; reflected?: Record }>( + filePath, + {}, + ); + this.notes = loaded.notes ?? {}; + this.reflected = loaded.reflected ?? {}; + } + get(personaId: string): AgentLabPersonaNotes { + return this.notes[personaId] ?? { corrections: [], episodes: [] }; + } + getReflectedCount(personaId: string): number { + return this.reflected[personaId] ?? 0; + } + setReflectedCount(personaId: string, count: number): void { + this.reflected[personaId] = count; + this.persist(); + } + add(personaId: string, corrections: string[], episode: string): void { + const cur = this.get(personaId); + this.notes[personaId] = { + corrections: Array.from(new Set([...cur.corrections, ...corrections])).slice(-20), + episodes: (episode ? [...cur.episodes, episode] : cur.episodes).slice(-8), + }; + this.persist(); + } + private persist(): void { + saveJson(this.filePath, { notes: this.notes, reflected: this.reflected }); + } +} + +/** 克隆体对群友的关系态(JSON 落盘,M3 群聊用,重启保持)。 */ +export class JsonRelationStore implements AgentLabRelationStore { + private readonly data: Record; + constructor(private readonly filePath: string) { + this.data = loadJson>(filePath, {}); + } + private key(subjectPersonaId: string, objectId: string): string { + return `${subjectPersonaId} ${objectId}`; + } + get(subjectPersonaId: string, objectId: string): AgentLabRelation | null { + return this.data[this.key(subjectPersonaId, objectId)] ?? null; + } + listForSubject(subjectPersonaId: string): AgentLabRelation[] { + return Object.values(this.data).filter((r) => r.subjectPersonaId === subjectPersonaId); + } + upsert(relation: AgentLabRelation): void { + this.data[this.key(relation.subjectPersonaId, relation.objectId)] = relation; + saveJson(this.filePath, this.data); + } + applyDelta( + subjectPersonaId: string, + objectId: string, + objectKind: AgentLabMemberKind, + delta: { affinity?: number; familiarity?: number; mood?: number }, + now: number, + ): AgentLabRelation { + const base = + this.get(subjectPersonaId, objectId) ?? makeBaseRelation(subjectPersonaId, objectId, objectKind, now); + const next = applyRelationDelta(base, delta, now); + this.upsert(next); + return next; + } +} + +/** bot 不统计 token(自己看厂商后台即可);保留接口便于将来接账。 */ +export class NoopUsageStore implements UsageSink { + record(): void { + /* no-op */ + } +} diff --git a/packages/bot/src/webui/app.html.ts b/packages/bot/src/webui/app.html.ts new file mode 100644 index 0000000..214630c --- /dev/null +++ b/packages/bot/src/webui/app.html.ts @@ -0,0 +1,831 @@ +/** + * WebUI 单文件前端(内嵌进 bot.mjs,产物零额外文件)。 + * + * 纯原生 HTML/CSS/JS,无三方库: + * - 登录门:输入 hex 密钥 → POST /api/login → 存 sessionStorage → 进主界面。 + * - 页面①「统计」:token 消耗(总/今日/按模型)、收发消息、近 24 小时折线图、运行时长。 + * - 页面②「总览」:训练语料、模型绑定、发言意愿、语音克隆、表情、风格画像。 + * + * 主题令牌完全复刻 WeQ Desktop(浅/深双模式,localStorage 记忆,默认跟随系统)。 + * 导出 renderAppHtml(botName) → 完整 HTML 字符串(server.ts GET / 时返回)。 + */ + +export function renderAppHtml(botName: string): string { + const title = `${escapeHtml(botName)} · 控制台`; + return ` + + + + +${title} + + + + + +
+ + + +`; +} + +function escapeHtml(s: string): string { + return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!); +} + +/* ── 样式:复刻 WeQ 视觉语言(极光 + 几何线 + 玻璃拟态)· 浅/深双模式 ────────── */ +const CSS = String.raw` +:root { + --accent: #0099ff; + /* WeQ 四色极光板(蓝/青绿/暖黄/柔紫),图表与光晕共用 */ + --c-blue: #0099ff; + --c-teal: #53ce90; + --c-gold: #f5a623; + --c-violet: #847ee0; + --bg-app: #eef3fa; + --bg-surface: #ffffff; + --bg-elevated: #fbfcfe; + --fg-primary: #17202e; + --fg-secondary: #48566b; + --fg-muted: #8a94a6; + --border-subtle: color-mix(in srgb, var(--accent) 15%, #e4e9f0); + --border-strong: color-mix(in srgb, var(--accent) 26%, #d3dae4); + --shadow: 0 1px 2px rgba(20, 30, 50, 0.04), 0 10px 30px rgba(20, 30, 50, 0.07); + --shadow-lg: 0 30px 70px -34px rgba(7, 31, 61, 0.30); + /* 玻璃卡:半透明面 + 高光描边,让背景极光/几何线透出来 */ + --glass-bg: color-mix(in srgb, var(--bg-surface) 72%, transparent); + --glass-border: color-mix(in srgb, var(--accent) 18%, rgba(255, 255, 255, 0.6)); + --glass-blur: blur(16px) saturate(1.35); + --pos: #22a06b; + --warn: #e0812b; + --radius: 16px; + color-scheme: light; +} +html[data-theme='dark'] { + --accent: #56a8f7; + --c-blue: #56a8f7; + --c-teal: #4cc38a; + --c-gold: #f0a355; + --c-violet: #9a92f0; + --bg-app: #0c0f14; + --bg-surface: #161b22; + --bg-elevated: #1c2129; + --fg-primary: #eef2f7; + --fg-secondary: #b7c0cd; + --fg-muted: #7c8798; + --border-subtle: color-mix(in srgb, var(--accent) 16%, rgba(255, 255, 255, 0.08)); + --border-strong: color-mix(in srgb, var(--accent) 26%, rgba(255, 255, 255, 0.14)); + --shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 14px 40px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 34px 80px -30px rgba(0, 0, 0, 0.6); + --glass-bg: color-mix(in srgb, var(--bg-surface) 64%, transparent); + --glass-border: color-mix(in srgb, var(--accent) 22%, rgba(255, 255, 255, 0.08)); + --glass-blur: blur(18px) saturate(1.2); + --pos: #4cc38a; + --warn: #f0a355; + color-scheme: dark; +} +* { box-sizing: border-box; margin: 0; padding: 0; } +html, body { height: 100%; } +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif; + font-size: 14px; + line-height: 1.5; + color: var(--fg-primary); + background: + radial-gradient(ellipse at 14% 12%, color-mix(in srgb, var(--c-blue) 16%, transparent) 0, transparent 44%), + radial-gradient(ellipse at 82% 10%, color-mix(in srgb, var(--c-gold) 13%, transparent) 0, transparent 40%), + radial-gradient(ellipse at 86% 84%, color-mix(in srgb, var(--c-teal) 13%, transparent) 0, transparent 44%), + radial-gradient(ellipse at 24% 90%, color-mix(in srgb, var(--c-violet) 10%, transparent) 0, transparent 46%), + var(--bg-app); + min-height: 100%; + -webkit-font-smoothing: antialiased; + overflow-x: hidden; +} +/* 动态几何线条画布 + 斜线纹理层(固定铺满、置底,不吃事件) */ +#bg-geo { + position: fixed; inset: 0; width: 100vw; height: 100vh; + z-index: -2; pointer-events: none; display: block; +} +#bg-aurora { + position: fixed; inset: 0; z-index: -1; pointer-events: none; + opacity: 0.5; + background-image: + linear-gradient(118deg, transparent 0 22%, color-mix(in srgb, var(--c-blue) 6%, transparent) 22.2% 22.5%, transparent 22.9% 100%), + linear-gradient(154deg, transparent 0 61%, color-mix(in srgb, var(--c-teal) 5%, transparent) 61.1% 61.4%, transparent 61.8% 100%), + linear-gradient(72deg, transparent 0 40%, color-mix(in srgb, var(--c-violet) 4%, transparent) 40.2% 40.5%, transparent 41% 100%); + mask-image: radial-gradient(ellipse at 50% 40%, rgba(0,0,0,0.5), transparent 82%); +} +button { font: inherit; cursor: pointer; border: none; background: none; color: inherit; } +input { font: inherit; } +svg { display: block; } +.mono { font-family: ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, Consolas, monospace; } +@media (prefers-reduced-motion: reduce) { #bg-geo { display: none; } } + +/* ── 登录门 ── */ +.login-wrap { min-height: 100vh; display: grid; place-items: center; padding: 24px; } +.login-card { + position: relative; width: min(400px, 100%); border-radius: 22px; padding: 36px 30px; text-align: center; + background: var(--glass-bg); border: 1px solid var(--glass-border); + -webkit-backdrop-filter: var(--glass-blur); backdrop-filter: var(--glass-blur); + box-shadow: var(--shadow-lg); overflow: hidden; + animation: rise-in 520ms cubic-bezier(0.22, 1, 0.36, 1) both; +} +/* 卡内缓慢流转的极光光晕 */ +.login-card::before { + content: ""; position: absolute; inset: -40% -40% auto auto; width: 260px; height: 260px; z-index: 0; + background: radial-gradient(circle, color-mix(in srgb, var(--c-blue) 34%, transparent), transparent 68%); + filter: blur(14px); animation: orbit 14s ease-in-out infinite; +} +.login-card::after { + content: ""; position: absolute; inset: auto auto -40% -40%; width: 240px; height: 240px; z-index: 0; + background: radial-gradient(circle, color-mix(in srgb, var(--c-teal) 30%, transparent), transparent 68%); + filter: blur(14px); animation: orbit 16s ease-in-out infinite reverse; +} +.login-card > * { position: relative; z-index: 1; } +.login-logo { + width: 62px; height: 62px; margin: 0 auto 18px; border-radius: 20px; display: grid; place-items: center; + background: linear-gradient(135deg, var(--c-blue), color-mix(in srgb, var(--c-violet) 70%, var(--c-blue))); + color: #fff; box-shadow: 0 10px 26px color-mix(in srgb, var(--accent) 44%, transparent); + animation: float 3.2s ease-in-out infinite; +} +.login-card h1 { font-size: 20px; font-weight: 680; letter-spacing: -0.01em; } +.login-card .sub { color: var(--fg-muted); font-size: 12.5px; margin-top: 6px; margin-bottom: 22px; } +.field { text-align: left; display: grid; gap: 7px; } +.field label { font-size: 12px; color: var(--fg-secondary); display: inline-flex; align-items: center; gap: 6px; } +.field input { + width: 100%; height: 42px; padding: 0 13px; border-radius: 11px; border: 1px solid var(--border-subtle); + background: var(--bg-elevated); color: var(--fg-primary); outline: none; transition: border-color .15s, box-shadow .15s; +} +.field input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); } +.btn-primary { + margin-top: 18px; width: 100%; height: 42px; border-radius: 11px; font-weight: 600; color: #fff; + background: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 78%, #6f7cff)); + box-shadow: 0 6px 16px color-mix(in srgb, var(--accent) 34%, transparent); transition: transform .1s, filter .15s; +} +.btn-primary:hover { filter: brightness(1.05); } +.btn-primary:active { transform: translateY(1px); } +.btn-primary:disabled { opacity: .6; cursor: default; } +.login-err { color: #e5484d; font-size: 12.5px; margin-top: 12px; min-height: 16px; } + +/* ── 主框架 ── */ +.shell { max-width: 980px; margin: 0 auto; padding: 26px 22px 64px; } +.topbar { + position: sticky; top: 12px; z-index: 20; display: flex; align-items: center; gap: 14px; margin-bottom: 22px; + padding: 10px 14px; border-radius: 16px; + background: var(--glass-bg); border: 1px solid var(--glass-border); + -webkit-backdrop-filter: var(--glass-blur); backdrop-filter: var(--glass-blur); box-shadow: var(--shadow); +} +.brand { display: flex; align-items: center; gap: 11px; } +.brand-badge { + width: 40px; height: 40px; border-radius: 13px; display: grid; place-items: center; color: #fff; flex: none; + background: linear-gradient(135deg, var(--c-blue), color-mix(in srgb, var(--c-violet) 68%, var(--c-blue))); + box-shadow: 0 6px 16px color-mix(in srgb, var(--accent) 34%, transparent); +} +.brand-name { font-size: 16px; font-weight: 680; letter-spacing: -0.01em; } +.brand-sub { font-size: 11.5px; color: var(--fg-muted); } +.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--pos); display: inline-block; margin-right: 5px; + box-shadow: 0 0 0 3px color-mix(in srgb, var(--pos) 22%, transparent); animation: pulse 2.4s ease-in-out infinite; } +.topbar-spacer { flex: 1; } +.icon-btn { + width: 38px; height: 38px; border-radius: 12px; display: grid; place-items: center; color: var(--fg-secondary); + border: 1px solid var(--border-subtle); background: color-mix(in srgb, var(--bg-surface) 60%, transparent); + transition: background .15s, color .15s, transform .12s; } +.icon-btn:hover { background: var(--bg-elevated); color: var(--accent); transform: translateY(-1px); } +.icon-btn:active { transform: translateY(0); } + +.tabs { display: inline-flex; gap: 4px; padding: 4px; margin-bottom: 20px; border-radius: 14px; + background: var(--glass-bg); border: 1px solid var(--glass-border); + -webkit-backdrop-filter: var(--glass-blur); backdrop-filter: var(--glass-blur); box-shadow: var(--shadow); } +.tab { + display: inline-flex; align-items: center; gap: 7px; padding: 8px 16px; border-radius: 11px; font-size: 13px; + font-weight: 560; color: var(--fg-secondary); transition: background .15s, color .15s; +} +.tab:hover { color: var(--fg-primary); } +.tab.active { color: #fff; background: linear-gradient(135deg, var(--c-blue), color-mix(in srgb, var(--c-violet) 62%, var(--c-blue))); + box-shadow: 0 6px 16px color-mix(in srgb, var(--accent) 34%, transparent); } + +/* ── 卡片 ── */ +.grid { display: grid; gap: 14px; } +.grid-4 { grid-template-columns: repeat(4, 1fr); } +.grid-2 { grid-template-columns: repeat(2, 1fr); } +@media (max-width: 720px) { .grid-4 { grid-template-columns: repeat(2, 1fr); } .grid-2 { grid-template-columns: 1fr; } } +.card { + position: relative; background: var(--glass-bg); border: 1px solid var(--glass-border); border-radius: var(--radius); + padding: 18px 19px; box-shadow: var(--shadow); + -webkit-backdrop-filter: var(--glass-blur); backdrop-filter: var(--glass-blur); + transition: transform .18s cubic-bezier(0.22, 1, 0.36, 1), box-shadow .18s, border-color .18s; +} +.card:hover { transform: translateY(-2px); box-shadow: var(--shadow-lg); border-color: var(--border-strong); } +.card-title { font-size: 12.5px; font-weight: 620; color: var(--fg-secondary); display: flex; align-items: center; + gap: 8px; margin-bottom: 14px; } +.card-title svg { color: var(--accent); } +/* KPI 卡:左侧强调色导轨 + 大号数字 */ +.stat { display: flex; flex-direction: column; gap: 5px; overflow: hidden; } +.stat::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; + background: linear-gradient(180deg, var(--c-blue), var(--c-teal)); opacity: .8; } +.stat .k { font-size: 12px; color: var(--fg-muted); display: inline-flex; align-items: center; gap: 6px; } +.stat .k svg { color: var(--accent); } +.stat .v { font-size: 27px; font-weight: 700; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; line-height: 1.15; } +.stat .v small { font-size: 13px; font-weight: 500; color: var(--fg-muted); margin-left: 3px; } +.stat .sub { font-size: 11.5px; color: var(--fg-muted); } + +/* 24h 折线图 */ +.line-wrap { display: flex; gap: 10px; padding-top: 6px; } +.line-yaxis { display: flex; flex-direction: column; justify-content: space-between; height: 150px; + font-size: 10px; color: var(--fg-muted); text-align: right; min-width: 30px; font-variant-numeric: tabular-nums; } +.line-plot { position: relative; flex: 1; min-width: 0; } +.line-svg { width: 100%; height: 150px; display: block; overflow: visible; } +.line-grid { stroke: color-mix(in srgb, var(--fg-muted) 16%, transparent); stroke-width: 1; } +.line-area { fill: url(#lgArea); } +.line-stroke { stroke: var(--c-blue); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; + filter: drop-shadow(0 3px 8px color-mix(in srgb, var(--c-blue) 34%, transparent)); } +.line-dot { fill: var(--bg-surface); stroke: var(--c-blue); stroke-width: 2; cursor: pointer; transition: r .12s; } +.line-dot:hover { r: 5; } +.line-cursor { stroke: color-mix(in srgb, var(--c-blue) 46%, transparent); stroke-width: 1; stroke-dasharray: 3 3; } +.line-axis { display: flex; justify-content: space-between; margin-top: 7px; font-size: 10px; color: var(--fg-muted); + font-variant-numeric: tabular-nums; } +.line-axis span { flex: 1; text-align: center; } + +/* 列表/明细 */ +.rows { display: flex; flex-direction: column; } +.row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 11px 2px; + border-top: 1px solid var(--border-subtle); } +.row:first-child { border-top: none; } +.row .label { font-size: 13px; color: var(--fg-secondary); display: inline-flex; align-items: center; gap: 8px; min-width: 0; } +.row .label svg { color: var(--fg-muted); flex: none; } +.row .val { font-size: 13px; font-weight: 550; color: var(--fg-primary); text-align: right; font-variant-numeric: tabular-nums; + overflow: hidden; text-overflow: ellipsis; } +.pill { display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: 999px; font-size: 11.5px; + font-weight: 550; border: 1px solid var(--border-subtle); } +.pill.on { color: var(--pos); background: color-mix(in srgb, var(--pos) 12%, transparent); + border-color: color-mix(in srgb, var(--pos) 30%, transparent); } +.pill.off { color: var(--fg-muted); background: color-mix(in srgb, var(--fg-muted) 10%, transparent); } +.tags { display: flex; flex-wrap: wrap; gap: 7px; } +.tag { font-size: 12px; padding: 4px 10px; border-radius: 8px; color: var(--fg-secondary); + background: color-mix(in srgb, var(--accent) 9%, transparent); border: 1px solid var(--border-subtle); } +.prose { font-size: 13px; line-height: 1.7; color: var(--fg-secondary); } +.muted-empty { color: var(--fg-muted); font-size: 12.5px; padding: 6px 0; } +.section-h { font-size: 13px; font-weight: 620; margin: 26px 2px 12px; color: var(--fg-primary); + display: flex; align-items: center; gap: 8px; } +.section-h svg { color: var(--accent); } +.section-h .sh-spacer { flex: 1; } +.sh-btn { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; border-radius: 10px; font-size: 12px; + font-weight: 560; color: #fff; background: linear-gradient(135deg, var(--c-blue), color-mix(in srgb, var(--c-violet) 60%, var(--c-blue))); + box-shadow: 0 4px 12px color-mix(in srgb, var(--accent) 30%, transparent); transition: filter .15s, transform .1s; } +.sh-btn:hover { filter: brightness(1.06); } .sh-btn:active { transform: translateY(1px); } .sh-btn:disabled { opacity: .6; cursor: default; } + +/* 表情网格 */ +.stk-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 12px; } +.stk { position: relative; border: 1px solid var(--border-subtle); border-radius: 13px; overflow: hidden; + background: color-mix(in srgb, var(--bg-elevated) 70%, transparent); transition: transform .16s, box-shadow .16s, border-color .16s; } +.stk:hover { transform: translateY(-2px); box-shadow: var(--shadow); border-color: var(--border-strong); } +.stk-img { width: 100%; aspect-ratio: 1; object-fit: contain; background: + conic-gradient(from 45deg, color-mix(in srgb, var(--fg-muted) 6%, transparent) 0 25%, transparent 0 50%) 0 0 / 16px 16px; + display: block; } +.stk-meta { padding: 8px 10px; } +.stk-desc { font-size: 11.5px; color: var(--fg-secondary); line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; + -webkit-box-orient: vertical; overflow: hidden; min-height: 32px; } +.stk-foot { display: flex; align-items: center; gap: 6px; margin-top: 6px; } +.stk-badge { font-size: 10px; padding: 2px 7px; border-radius: 999px; font-weight: 560; border: 1px solid var(--border-subtle); } +.stk-badge.on { color: var(--pos); background: color-mix(in srgb, var(--pos) 12%, transparent); border-color: color-mix(in srgb, var(--pos) 30%, transparent); } +.stk-badge.rand { color: var(--warn); background: color-mix(in srgb, var(--warn) 12%, transparent); border-color: color-mix(in srgb, var(--warn) 30%, transparent); } +.stk-count { font-size: 10.5px; color: var(--fg-muted); margin-left: auto; font-variant-numeric: tabular-nums; } +.stk-del { position: absolute; top: 6px; right: 6px; width: 24px; height: 24px; border-radius: 8px; display: grid; place-items: center; + color: #fff; background: rgba(0,0,0,0.42); opacity: 0; transition: opacity .15s, background .15s; } +.stk:hover .stk-del { opacity: 1; } .stk-del:hover { background: #e5484d; } +.stk-hint { font-size: 11.5px; color: var(--fg-muted); margin: 2px 2px 12px; } +.loading { text-align: center; color: var(--fg-muted); padding: 60px 0; font-size: 13px; } +.spin { animation: spin 1s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } + +/* ── 动效关键帧 ── */ +@keyframes float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-6px); } } +@keyframes pulse { 0%, 100% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--pos) 22%, transparent); } + 50% { box-shadow: 0 0 0 5px color-mix(in srgb, var(--pos) 8%, transparent); } } +@keyframes orbit { 0%, 100% { transform: translate(0, 0) scale(1); } 50% { transform: translate(-24px, 20px) scale(1.14); } } +@keyframes rise-in { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } } +/* 进入主界面 / 切页时卡片依次浮现 */ +.card, .section-h { animation: rise-in 460ms cubic-bezier(0.22, 1, 0.36, 1) both; } +.grid-4 > .card:nth-child(2) { animation-delay: .04s; } +.grid-4 > .card:nth-child(3) { animation-delay: .08s; } +.grid-4 > .card:nth-child(4) { animation-delay: .12s; } +@media (prefers-reduced-motion: reduce) { + .card, .section-h, .login-card, .login-logo, .dot { animation: none !important; } + .card:hover { transform: none; } +} +`; + +/* ── 前端脚本:全部原生,图标用内联 SVG(无 emoji、无三方库) ───────────────── */ +const JS = String.raw` +(function () { + var app = document.getElementById('app'); + var KEY = 'weq-bot-key'; + var THEME = 'weq-bot-theme'; + + // 图标(lucide 同款路径,stroke 走 currentColor)。 + function ic(name, size) { + var s = size || 16; + var P = { + bot: '', + sun: '', + moon: '', + logout: '', + chart: '', + grid: '', + coins: '', + arrowDown: '', + arrowUp: '', + cpu: '', + clock: '', + key: '', + book: '', + brain: '', + mic: '', + gauge: '', + sticker: '', + msg: '', + user: '', + hash: '', + link: '', + alert: '', + refresh: '', + upload: '', + trash: '', + image: '' + }; + return '' + (P[name] || '') + ''; + } + + function initTheme() { + var saved = localStorage.getItem(THEME); + var sys = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + document.documentElement.setAttribute('data-theme', saved || sys); + } + function toggleTheme() { + var cur = document.documentElement.getAttribute('data-theme'); + var next = cur === 'dark' ? 'light' : 'dark'; + document.documentElement.setAttribute('data-theme', next); + localStorage.setItem(THEME, next); + var b = document.getElementById('themeBtn'); + if (b) b.innerHTML = ic(next === 'dark' ? 'sun' : 'moon', 17); + } + function themeIcon() { + return document.documentElement.getAttribute('data-theme') === 'dark' ? 'sun' : 'moon'; + } + + function api(path) { + return fetch(path, { headers: { Authorization: 'Bearer ' + (sessionStorage.getItem(KEY) || '') } }) + .then(function (r) { if (r.status === 401) { logout(); throw new Error('未授权'); } return r.json(); }); + } + function logout() { sessionStorage.removeItem(KEY); renderLogin(); } + + function fmt(n) { + n = n || 0; + if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B'; + if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M'; + if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K'; + return String(n); + } + function fmtInt(n) { return (n || 0).toLocaleString('en-US'); } + function dur(ms) { + var s = Math.floor(ms / 1000), d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60); + if (d > 0) return d + '天 ' + h + '小时'; + if (h > 0) return h + '小时 ' + m + '分'; + return m + '分钟'; + } + function esc(s) { var d = document.createElement('div'); d.textContent = s == null ? '' : String(s); return d.innerHTML; } + + /* ── 登录 ── */ + function renderLogin() { + app.innerHTML = + ''; + var pw = document.getElementById('pw'), go = document.getElementById('go'), err = document.getElementById('err'); + document.getElementById('themeBtn2').onclick = function () { toggleTheme(); this.innerHTML = ic(themeIcon(), 17); }; + function submit() { + var v = pw.value.trim(); + if (!v) { pw.focus(); return; } + go.disabled = true; go.textContent = '验证中…'; err.textContent = ''; + fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: v }) }) + .then(function (r) { return r.json(); }) + .then(function (j) { + if (j.ok) { sessionStorage.setItem(KEY, v); renderMain(); } + else { err.textContent = '密钥不正确,请检查后重试。'; go.disabled = false; go.textContent = '进入控制台'; pw.select(); } + }) + .catch(function () { err.textContent = '无法连接到 bot,请确认进程在运行。'; go.disabled = false; go.textContent = '进入控制台'; }); + } + go.onclick = submit; + pw.onkeydown = function (e) { if (e.key === 'Enter') submit(); }; + pw.focus(); + } + + function doReload() { + var btn = document.getElementById('reloadBtn'); + if (!btn || btn.getAttribute('data-busy')) return; + if (!window.confirm('用当前 config.json 完全重载?bot 会短暂断线后自动用新配置重连。')) return; + btn.setAttribute('data-busy', '1'); + btn.innerHTML = ic('refresh', 17).replace('' + + '
' + + '
' + ic('bot', 22) + '
' + + '
' + esc(BOT_NAME) + '
' + + '
在线 · 克隆体控制台
' + + '
' + + '' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + ''; + document.getElementById('themeBtn').onclick = toggleTheme; + document.getElementById('logoutBtn').onclick = logout; + document.getElementById('reloadBtn').onclick = doReload; + var tabs = app.querySelectorAll('.tab'); + tabs.forEach(function (t) { + t.onclick = function () { activeTab = t.getAttribute('data-tab'); syncTabs(); route(); }; + }); + syncTabs(); route(); + } + function syncTabs() { + app.querySelectorAll('.tab').forEach(function (t) { + t.classList.toggle('active', t.getAttribute('data-tab') === activeTab); + }); + } + function route() { + var view = document.getElementById('view'); + view.innerHTML = '
' + ic('clock', 22) + '
加载中…
'; + if (activeTab === 'stats') loadStats(view); else loadOverview(view); + } + + /* ── 页面①统计 ── */ + function loadStats(view) { + api('/api/stats').then(function (s) { + var today = s.byDay[s.byDay.length - 1] || { totalTokens: 0, messagesIn: 0, messagesOut: 0 }; + var cards = + statCard('coins', 'Token 总消耗', fmt(s.totals.totalTokens), fmtInt(s.totals.totalTokens) + ' tokens') + + statCard('cpu', '今日 Token', fmt(today.totalTokens), s.totals.llmCalls + ' 次调用累计') + + statCard('arrowDown', '收到消息', fmtInt(s.totals.messagesIn), '今日 ' + (today.messagesIn || 0)) + + statCard('arrowUp', '发出消息', fmtInt(s.totals.messagesOut), '今日 ' + (today.messagesOut || 0)); + + var html = '
' + cards + '
'; + + // 运行时长 + 输入/输出 token 拆分 + html += '
'; + html += '
' + ic('coins', 15) + ' Token 构成
' + + row('arrowUp', '输入(prompt)', fmtInt(s.totals.promptTokens)) + + row('arrowDown', '输出(completion)', fmtInt(s.totals.completionTokens)) + + row('hash', 'LLM 调用次数', fmtInt(s.totals.llmCalls)) + + row('brain', '生成回复轮数', fmtInt(s.totals.repliesGenerated)) + '
'; + html += '
' + ic('clock', 15) + ' 运行状态
' + + row('clock', '本次已运行', dur(s.now - s.startedAt)) + + row('clock', '累计起始', new Date(s.firstStartedAt).toLocaleDateString('zh-CN')) + + row('msg', '收发合计', fmtInt(s.totals.messagesIn + s.totals.messagesOut)) + '
'; + html += '
'; + + // 近 24 小时 Token 折线图 + html += '
' + ic('chart', 15) + ' 近 24 小时 Token 消耗
' + + hourLine(s.byHour || []) + '
'; + + // 按模型 + html += '
' + ic('cpu', 15) + ' 按模型消耗
'; + if (s.byModel.length === 0) { + html += '
还没有 LLM 调用记录。
'; + } else { + html += '
'; + s.byModel.forEach(function (m) { + html += '
' + ic('cpu', 14) + '' + esc(m.model) + '' + + '' + fmtInt(m.totalTokens) + ' tok · ' + m.calls + ' 次
'; + }); + html += '
'; + } + view.innerHTML = html; + }).catch(function (e) { view.innerHTML = errBox(e); }); + } + + // 近 24 小时 token 折线图(面积 + 折线 + 网格 + 每点原生 tooltip)。byHour: [{hour,tokens,calls}]。 + function hourLine(byHour) { + var W = 560, H = 140, PX = 6, PY = 10; + var max = 1; + byHour.forEach(function (d) { if (d.tokens > max) max = d.tokens; }); + var n = byHour.length || 1; + var stepX = (W - PX * 2) / Math.max(1, n - 1); + var pts = byHour.map(function (d, i) { + var x = PX + i * stepX; + var y = PY + (1 - d.tokens / max) * (H - PY * 2); + return [x, y]; + }); + var line = pts.map(function (p, i) { return (i === 0 ? 'M' : 'L') + p[0].toFixed(1) + ',' + p[1].toFixed(1); }).join(' '); + var last = pts[pts.length - 1] || [PX, H - PY]; + var first = pts[0] || [PX, H - PY]; + var area = line + ' L' + last[0].toFixed(1) + ',' + (H - PY) + ' L' + first[0].toFixed(1) + ',' + (H - PY) + ' Z'; + var grid = [0, 0.25, 0.5, 0.75, 1].map(function (t) { + var y = PY + (H - PY * 2) * t; + return ''; + }).join(''); + var dots = pts.map(function (p, i) { + var d = byHour[i]; + var tip = d.hour + ' · ' + fmtInt(d.tokens) + ' tokens · ' + d.calls + ' 次调用'; + return '' + + '' + esc(tip) + ''; + }).join(''); + var svg = + '' + + '' + + '' + + '' + + '' + + grid + + '' + + '' + + dots + + ''; + // 左侧 Y 轴(max / 半 / 0) + var yaxis = '
' + fmt(max) + '' + fmt(Math.round(max / 2)) + '0
'; + // 底部 X 轴:每 4 小时显示一个时刻标签,与点对齐。 + var xaxis = '
' + byHour.map(function (d, i) { + return '' + (i % 4 === 0 ? esc(d.hour.slice(0, 2)) : '') + ''; + }).join('') + '
'; + return '
' + yaxis + '
' + svg + xaxis + '
'; + } + + function statCard(icon, k, v, sub) { + return '
' + ic(icon, 14) + esc(k) + '' + + '' + v + '' + esc(sub) + '
'; + } + function row(icon, label, val) { + return '
' + ic(icon, 14) + esc(label) + '' + esc(val) + '
'; + } + function pill(on, textOn, textOff) { + return on ? '' + esc(textOn) + '' : '' + esc(textOff) + ''; + } + + /* ── 页面②总览 ── */ + function loadOverview(view) { + api('/api/overview').then(function (o) { + var html = ''; + // 概况卡 + html += '
' + + statCard('book', '训练语料', fmtInt(o.corpus.corpusMessageCount), o.corpus.pairCount + ' 组问答对') + + statCard('hash', '语料字数', fmt(o.corpus.corpusChars), '平均每句 ' + o.corpus.avgFriendMsgChars + ' 字') + + statCard('sticker', '表情包', fmtInt(o.assets.stickerCount), o.assets.systemFaceCount + ' 个系统表情') + + statCard('user', '来源', esc(o.persona.sourceKind === 'group' ? '群聊' : '好友'), esc(o.persona.sourceTitle || '—')) + + '
'; + + // 模型绑定 + html += '
' + ic('cpu', 15) + ' 模型绑定
'; + html += row('brain', '对话模型', o.models.chat || '—'); + if (o.models.embedding) html += row('hash', '向量模型', o.models.embedding); + if (o.models.vision) html += row('grid', '视觉模型', o.models.vision); + html += '
'; + + // 发言意愿 + 语音 + html += '
'; + html += '
' + ic('gauge', 15) + ' 发言意愿
' + + row('gauge', '意愿档位', o.willing.level + ' / 100') + + rowPill('被@必回', o.willing.mustReplyOnMention) + + rowPill('私聊也按意愿', o.willing.gatePrivate) + + rowPill('参与群聊', o.features.groupChat) + '
'; + + var voiceRows = rowPill('语音克隆', o.voice.cloneEnabled && o.features.voice); + if (o.voice.provider) voiceRows += row('mic', 'TTS 服务商', o.voice.provider); + if (o.voice.cloneEnabled) voiceRows += row('mic', '音色方式', o.voice.mode === 'clone' ? '复刻 TA 的声音' : '预置音色'); + voiceRows += row('mic', '语音占比', Math.round((o.voice.voiceRatio || 0) * 100) + '%'); + html += '
' + ic('mic', 15) + ' 语音
' + voiceRows + '
'; + html += '
'; + + // 风格画像 + html += '
' + ic('brain', 15) + ' 风格画像
'; + if (o.profile.styleSummary) html += '
' + esc(o.profile.styleSummary) + '
'; + else html += '
未提取到风格摘要。
'; + if (o.profile.topTerms && o.profile.topTerms.length) { + html += '
' + + o.profile.topTerms.slice(0, 24).map(function (t) { return '' + esc(t) + ''; }).join('') + '
'; + } + html += '
'; + + if (o.profile.relationshipSummary) { + html += '
' + ic('user', 15) + ' 关系画像
' + + '
' + esc(o.profile.relationshipSummary) + '
'; + } + // 自定义表情区(缩略图 + 上传 + 删除);数据走独立 /api/stickers,异步填充。 + html += '
'; + view.innerHTML = html; + loadStickers(); + }).catch(function (e) { view.innerHTML = errBox(e); }); + } + + /* ── 自定义表情:查看 / 上传 / 删除 ── */ + function loadStickers() { + var box = document.getElementById('stkSection'); + if (!box) return; + api('/api/stickers').then(function (r) { + var list = r.stickers || []; + var head = '
' + ic('sticker', 15) + ' 自定义表情' + + '' + + '
'; + var hint = r.canDescribe + ? '本机已配图像模型:上传后会自动解析表情内容,克隆体按语义精准挑选发送。' + : '未配置图像模型:新上传的表情没有文字说明,克隆体只能在想活跃气氛时“随机发”。可在导出时选一个图像模型来启用精准解析。'; + var grid; + if (list.length === 0) { + grid = '
还没有自定义表情,点右上角「上传表情」添加。
'; + } else { + grid = '
' + list.map(function (s) { + var src = '/api/sticker/' + encodeURIComponent(s.md5) + '?k=' + encodeURIComponent(sessionStorage.getItem(KEY) || ''); + var badge = s.described + ? '已解析' + : '随机发'; + var desc = s.described ? esc(s.description || s.scenario) : '未解析 · 无文字说明'; + return '
' + + '' + + '表情' + + '
' + desc + '
' + + '
' + badge + '发过 ' + (s.count || 0) + ' 次
'; + }).join('') + '
'; + } + box.innerHTML = head + '
' + esc(hint) + '
' + grid; + var up = document.getElementById('stkUp'); + if (up) up.onclick = pickStickerFile; + box.querySelectorAll('.stk-del').forEach(function (btn) { + btn.onclick = function () { + var card = btn.closest('.stk'); + if (card) deleteSticker(card.getAttribute('data-md5')); + }; + }); + }).catch(function () { box.innerHTML = ''; }); + } + + function pickStickerFile() { + var inp = document.createElement('input'); + inp.type = 'file'; + inp.accept = 'image/*'; + inp.onchange = function () { + var f = inp.files && inp.files[0]; + if (!f) return; + if (f.size > 8 * 1024 * 1024) { window.alert('图片太大了(上限 8MB)。'); return; } + var reader = new FileReader(); + reader.onload = function () { uploadSticker(String(reader.result || '')); }; + reader.readAsDataURL(f); + }; + inp.click(); + } + + function uploadSticker(dataUrl) { + if (!dataUrl) return; + var up = document.getElementById('stkUp'); + if (up) { up.disabled = true; up.innerHTML = ic('refresh', 13).replace('' + esc(label) + '' + pill(!!on, '已开启', '未开启') + ''; + } + function errBox(e) { + return '
' + ic('alert', 15) + ' 加载失败
' + + '
' + esc(e && e.message ? e.message : '未知错误') + '
'; + } + + /* ── 动态几何线条背景(星座网络:节点缓慢漂移 + 近邻连线,随主题换色) ── */ + function initBg() { + var cv = document.getElementById('bg-geo'); + if (!cv) return; + if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; + var ctx = cv.getContext('2d'); + if (!ctx) return; + var dpr = Math.min(window.devicePixelRatio || 1, 2); + var W = 0, H = 0, nodes = [], rgb = [0, 153, 255], mouse = { x: -1, y: -1 }; + + // 从当前主题的强调色取 RGB(供线条/节点着色)。 + function readAccent() { + var hex = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#0099ff'; + var m = hex.replace('#', ''); + if (m.length === 3) m = m[0] + m[0] + m[1] + m[1] + m[2] + m[2]; + var n = parseInt(m, 16); + if (!isNaN(n)) rgb = [(n >> 16) & 255, (n >> 8) & 255, n & 255]; + } + function resize() { + W = cv.clientWidth; H = cv.clientHeight; + cv.width = Math.floor(W * dpr); cv.height = Math.floor(H * dpr); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + // 节点数随面积缩放(上限保守,弱机也顺)。 + var target = Math.max(28, Math.min(72, Math.round((W * H) / 26000))); + nodes = []; + for (var i = 0; i < target; i++) { + nodes.push({ + x: Math.random() * W, y: Math.random() * H, + vx: (Math.random() - 0.5) * 0.22, vy: (Math.random() - 0.5) * 0.22, + r: 1 + Math.random() * 1.6, + }); + } + } + var LINK = 140; // 连线阈值(px) + function frame() { + ctx.clearRect(0, 0, W, H); + var i, j, a, b, dx, dy, dist, alpha; + for (i = 0; i < nodes.length; i++) { + a = nodes[i]; + a.x += a.vx; a.y += a.vy; + if (a.x < 0 || a.x > W) a.vx *= -1; + if (a.y < 0 || a.y > H) a.vy *= -1; + } + // 近邻连线 + for (i = 0; i < nodes.length; i++) { + a = nodes[i]; + for (j = i + 1; j < nodes.length; j++) { + b = nodes[j]; + dx = a.x - b.x; dy = a.y - b.y; dist = Math.sqrt(dx * dx + dy * dy); + if (dist < LINK) { + alpha = (1 - dist / LINK) * 0.28; + ctx.strokeStyle = 'rgba(' + rgb[0] + ',' + rgb[1] + ',' + rgb[2] + ',' + alpha.toFixed(3) + ')'; + ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); + } + } + } + // 鼠标牵引连线(靠近指针的节点亮起) + if (mouse.x >= 0) { + for (i = 0; i < nodes.length; i++) { + a = nodes[i]; + dx = a.x - mouse.x; dy = a.y - mouse.y; dist = Math.sqrt(dx * dx + dy * dy); + if (dist < 180) { + alpha = (1 - dist / 180) * 0.4; + ctx.strokeStyle = 'rgba(' + rgb[0] + ',' + rgb[1] + ',' + rgb[2] + ',' + alpha.toFixed(3) + ')'; + ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(mouse.x, mouse.y); ctx.stroke(); + } + } + } + // 节点 + for (i = 0; i < nodes.length; i++) { + a = nodes[i]; + ctx.fillStyle = 'rgba(' + rgb[0] + ',' + rgb[1] + ',' + rgb[2] + ',0.5)'; + ctx.beginPath(); ctx.arc(a.x, a.y, a.r, 0, Math.PI * 2); ctx.fill(); + } + raf = window.requestAnimationFrame(frame); + } + var raf = 0; + readAccent(); resize(); + window.addEventListener('resize', resize); + window.addEventListener('mousemove', function (e) { mouse.x = e.clientX; mouse.y = e.clientY; }); + window.addEventListener('mouseout', function () { mouse.x = -1; mouse.y = -1; }); + // 主题切换(data-theme 变化)→ 重取强调色。 + new MutationObserver(readAccent).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); + frame(); + } + + var BOT_NAME = window.__BOT_NAME__ || 'WeQ Bot'; + initTheme(); + initBg(); + if (sessionStorage.getItem(KEY)) renderMain(); else renderLogin(); +})(); +`; diff --git a/packages/bot/src/webui/server.ts b/packages/bot/src/webui/server.ts new file mode 100644 index 0000000..85efd5f --- /dev/null +++ b/packages/bot/src/webui/server.ts @@ -0,0 +1,379 @@ +/** + * WebUI 后端:一个零依赖的 node http 服务,随 bot 一起起。 + * + * 鉴权:导出时生成的 hex 密钥。POST /api/login 校验;其余 /api/* 走 Authorization: Bearer 。 + * 用 crypto.timingSafeEqual 做常量时间比较,避免时序侧信道。仅 127.0.0.1 监听(本机)。 + * + * 路由: + * GET / → 内嵌单文件前端(app.html.ts) + * POST /api/login → { ok: boolean } + * GET /api/stats → StatsSnapshot(token/消息/按天/按模型) + * GET /api/overview → 训练参数 / 语音 / 表情 / 画像总览(只读) + */ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; +import { timingSafeEqual, createHash } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'; +import { join } from 'node:path'; +import type { AgentLabPersona, AgentLabStickerRef, AgentLabStore, TtsProviderConfig } from '@weq/agentlab'; +import type { RuntimeLogger } from '@weq/agentlab'; +import type { StatsStore } from '../stats'; +import { renderAppHtml } from './app.html'; + +export interface WebUiDeps { + port: number; + /** 访问密钥(hex)。 */ + key: string; + /** bot 编号(uuid,仅用于展示/日志)。 */ + id: string; + persona: AgentLabPersona; + stats: StatsStore; + /** 功能开关(导出 config.features),总览页展示。 */ + features: { voice: boolean; groupChat: boolean }; + /** TTS providers(拿 provider 名字展示,不返回 key)。 */ + ttsProviders?: TtsProviderConfig[]; + /** persona 存储:上传/删除表情后 savePersona 落盘,runtime 下次对话即读到新表情。 */ + store: AgentLabStore; + /** 表情图目录(/stickers),GET/POST/DELETE 表情都在这读写。 */ + stickersDir: string; + /** 有图像模型时用它解析上传的新表情(生成 description/scenario);无则新表情走「随机发」。 */ + visionDescribe?: (imageDataUrl: string) => Promise<{ description: string; scenario: string }>; + /** 完全重载回调(重读 config.json 并重建实例)。缺省则 /api/reload 返回 501。 */ + onReload?: () => Promise<{ ok: boolean; message?: string }>; + logger?: RuntimeLogger; +} + +/** /api/overview 返回结构(全部只读,绝不含任何 apiKey / token)。 */ +interface OverviewPayload { + persona: { name: string; sourceKind: string; sourceTitle: string }; + corpus: { + corpusMessageCount: number; + pairCount: number; + corpusChars: number; + avgFriendMsgChars: number; + }; + models: { chat: string; embedding?: string; vision?: string }; + willing: { level: number; mustReplyOnMention: boolean; gatePrivate: boolean }; + features: { voice: boolean; groupChat: boolean }; + voice: { cloneEnabled: boolean; provider?: string; mode?: string; voiceRatio: number }; + assets: { stickerCount: number; systemFaceCount: number }; + profile: { styleSummary: string; topTerms: string[]; relationshipSummary: string }; +} + +function buildOverview(deps: WebUiDeps): OverviewPayload { + const p = deps.persona; + const providerName = p.voice?.providerId + ? deps.ttsProviders?.find((t) => t.id === p.voice?.providerId)?.name ?? p.voice.providerId + : undefined; + return { + persona: { name: p.name, sourceKind: p.sourceKind, sourceTitle: p.sourceTitle }, + corpus: { + corpusMessageCount: p.corpusMessageCount ?? p.stats?.sourceMessageCount ?? 0, + pairCount: p.pairCount ?? p.stats?.pairCount ?? 0, + corpusChars: p.stats?.corpusChars ?? 0, + avgFriendMsgChars: Math.round(p.stats?.avgFriendMsgChars ?? 0), + }, + models: { + chat: p.models?.chat?.model ?? '', + embedding: p.models?.embedding?.model, + vision: p.models?.vision?.model, + }, + willing: { + level: p.willing?.level ?? 50, + mustReplyOnMention: p.willing?.mustReplyOnMention !== false, + gatePrivate: !!p.willing?.gatePrivate, + }, + features: { voice: deps.features.voice, groupChat: deps.features.groupChat }, + voice: { + cloneEnabled: !!p.voiceCloneEnabled, + provider: providerName, + mode: p.voice?.mode, + voiceRatio: p.voiceProfile?.ratio ?? p.profile?.voiceRatio ?? 0, + }, + assets: { + stickerCount: p.stickers?.length ?? 0, + systemFaceCount: p.systemFaces?.length ?? 0, + }, + profile: { + styleSummary: p.profile?.styleSummary ?? '', + topTerms: p.profile?.topTerms ?? [], + relationshipSummary: p.profile?.relationshipSummary ?? '', + }, + }; +} + +/** 表情列表(只读展示;described=有文字说明,能被 LLM 按语义精准选,否则走随机发)。 */ +function listStickers(persona: AgentLabPersona): Array<{ + md5: string; + description: string; + scenario: string; + count: number; + described: boolean; +}> { + return (persona.stickers ?? []).map((s) => ({ + md5: s.md5, + description: s.description ?? '', + scenario: s.scenario ?? '', + count: s.count ?? 0, + described: !!(s.description || s.scenario), + })); +} + +/** data URL(data:image/png;base64,xxx 或裸 base64)→ Buffer。非法返回 null。 */ +function dataUrlToBuffer(dataUrl: string): Buffer | null { + const m = /^data:image\/[a-zA-Z0-9.+-]+;base64,(.+)$/.exec(dataUrl.trim()); + const b64 = m ? m[1]! : /^[A-Za-z0-9+/=\s]+$/.test(dataUrl.trim()) ? dataUrl.trim() : null; + if (!b64) return null; + try { + const buf = Buffer.from(b64, 'base64'); + return buf.length > 0 ? buf : null; + } catch { + return null; + } +} + +/** 常量时间比较(长度不同直接 false,长度相同才 timingSafeEqual)。 */ +function keyMatches(provided: string, expected: string): boolean { + const a = Buffer.from(provided, 'utf-8'); + const b = Buffer.from(expected, 'utf-8'); + if (a.length !== b.length) return false; + try { + return timingSafeEqual(a, b); + } catch { + return false; + } +} + +function sendJson(res: ServerResponse, code: number, body: unknown): void { + const s = JSON.stringify(body); + res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); + res.end(s); +} + +function bearer(req: IncomingMessage): string { + const h = req.headers['authorization']; + if (!h || Array.isArray(h)) return ''; + return h.startsWith('Bearer ') ? h.slice(7).trim() : ''; +} + +function readBody(req: IncomingMessage, limit = 4096): Promise { + return new Promise((resolve, reject) => { + let data = ''; + let size = 0; + req.on('data', (chunk: Buffer) => { + size += chunk.length; + if (size > limit) { + reject(new Error('body too large')); + req.destroy(); + return; + } + data += chunk.toString('utf-8'); + }); + req.on('end', () => resolve(data)); + req.on('error', reject); + }); +} + +export interface WebUiHandle { + close(): void; + port: number; +} + +/** 启动 WebUI。返回 { close }。监听失败(端口占用)不抛,只记日志并返回可 no-op 的 handle。 */ +export function startWebUi(deps: WebUiDeps): Promise { + const html = renderAppHtml(deps.persona.name || 'WeQ Bot'); + const log = deps.logger; + + const server: Server = createServer((req, res) => { + void handle(req, res).catch((err) => { + sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) }); + }); + }); + + async function handle(req: IncomingMessage, res: ServerResponse): Promise { + const url = (req.url || '/').split('?')[0] ?? '/'; + const method = req.method || 'GET'; + + // 页面 + if (method === 'GET' && (url === '/' || url === '/index.html')) { + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(html); + return; + } + + // 登录 + if (method === 'POST' && url === '/api/login') { + let key = ''; + try { + const parsed = JSON.parse((await readBody(req)) || '{}') as { key?: unknown }; + key = typeof parsed.key === 'string' ? parsed.key : ''; + } catch { + /* 忽略解析错误,当作空 key */ + } + sendJson(res, 200, { ok: keyMatches(key, deps.key) }); + return; + } + + // 表情图(二进制): 标签不能带 Authorization 头,改用 query ?k= 鉴权。仅本机,安全性够用。 + // 路径 /api/sticker/。md5 强校验(仅 hex),杜绝路径穿越。 + if (method === 'GET' && url.startsWith('/api/sticker/')) { + const q = new URL(req.url || '/', 'http://127.0.0.1'); + if (!keyMatches(q.searchParams.get('k') || '', deps.key)) { + sendJson(res, 401, { error: 'unauthorized' }); + return; + } + const md5 = url.slice('/api/sticker/'.length); + if (!/^[0-9a-fA-F]{6,64}$/.test(md5)) { + sendJson(res, 404, { error: 'not found' }); + return; + } + const file = join(deps.stickersDir, `${md5}.png`); + if (!existsSync(file)) { + sendJson(res, 404, { error: 'not found' }); + return; + } + res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'no-store' }); + res.end(readFileSync(file)); + return; + } + + // 受保护 API + if (url.startsWith('/api/')) { + if (!keyMatches(bearer(req), deps.key)) { + sendJson(res, 401, { error: 'unauthorized' }); + return; + } + if (method === 'GET' && url === '/api/stats') { + sendJson(res, 200, deps.stats.snapshot()); + return; + } + if (method === 'GET' && url === '/api/overview') { + sendJson(res, 200, buildOverview(deps)); + return; + } + // 表情列表。 + if (method === 'GET' && url === '/api/stickers') { + sendJson(res, 200, { stickers: listStickers(deps.persona), canDescribe: !!deps.visionDescribe }); + return; + } + // 上传新表情:body { dataUrl } → 存 .png → 追加/更新 persona.stickers → 有图像模型则解析一次 → savePersona。 + if (method === 'POST' && url === '/api/stickers') { + let dataUrl = ''; + try { + const parsed = JSON.parse((await readBody(req, 8 * 1024 * 1024)) || '{}') as { dataUrl?: unknown }; + dataUrl = typeof parsed.dataUrl === 'string' ? parsed.dataUrl : ''; + } catch { + sendJson(res, 400, { error: '请求体过大或格式错误' }); + return; + } + const buf = dataUrlToBuffer(dataUrl); + if (!buf) { + sendJson(res, 400, { error: '不是有效的图片数据' }); + return; + } + const md5 = createHash('md5').update(buf).digest('hex').toUpperCase(); + mkdirSync(deps.stickersDir, { recursive: true }); + writeFileSync(join(deps.stickersDir, `${md5}.png`), buf); + + const stickers = (deps.persona.stickers = deps.persona.stickers ?? []); + let ref = stickers.find((s) => s.md5.toUpperCase() === md5); + if (!ref) { + ref = { + md5, + fileName: `${md5}.png`, + localPath: join('stickers', `${md5}.png`), + cdnToken: '', + count: 0, + description: '', + scenario: '', + contexts: [], + } satisfies AgentLabStickerRef; + stickers.push(ref); + } + // 有图像模型则解析一次内容/场景(失败不阻断,留空走随机发)。 + if (deps.visionDescribe) { + try { + const d = await deps.visionDescribe(dataUrl); + ref.description = d.description || ''; + ref.scenario = d.scenario || ''; + } catch (err) { + deps.logger?.warn(`表情解析失败(将走随机发):${err instanceof Error ? err.message : String(err)}`); + } + } + // 落盘(保留原 pairs)。 + const rec = deps.store.getPersona(deps.persona.id); + deps.store.savePersona({ persona: deps.persona, pairs: rec?.pairs ?? [] }); + sendJson(res, 200, { + ok: true, + sticker: { + md5: ref.md5, + description: ref.description, + scenario: ref.scenario, + count: ref.count, + described: !!(ref.description || ref.scenario), + }, + }); + return; + } + // 删除表情:/api/stickers/。 + if (method === 'DELETE' && url.startsWith('/api/stickers/')) { + const md5 = url.slice('/api/stickers/'.length); + if (!/^[0-9a-fA-F]{6,64}$/.test(md5)) { + sendJson(res, 404, { error: 'not found' }); + return; + } + const stickers = deps.persona.stickers ?? []; + const idx = stickers.findIndex((s) => s.md5.toUpperCase() === md5.toUpperCase()); + if (idx < 0) { + sendJson(res, 404, { error: 'not found' }); + return; + } + const removed = stickers[idx]!; + stickers.splice(idx, 1); + deps.persona.stickers = stickers; + try { + unlinkSync(join(deps.stickersDir, `${removed.md5}.png`)); + } catch { + /* 文件可能已不在,忽略 */ + } + const rec = deps.store.getPersona(deps.persona.id); + deps.store.savePersona({ persona: deps.persona, pairs: rec?.pairs ?? [] }); + sendJson(res, 200, { ok: true }); + return; + } + // 完全重载:重读 config.json 并重建实例。注意——本 http server 会随实例一起重启, + // 故先把响应发出去,再触发重载(否则响应会随 server 关闭而丢失)。 + if (method === 'POST' && url === '/api/reload') { + if (!deps.onReload) { + sendJson(res, 501, { ok: false, message: '当前实例不支持重载' }); + return; + } + sendJson(res, 200, { ok: true, message: '已触发重载,稍后自动用新配置上线' }); + setTimeout(() => { + void deps.onReload!().catch((err) => { + deps.logger?.error(`重载执行失败:${err instanceof Error ? err.message : String(err)}`); + }); + }, 120); + return; + } + sendJson(res, 404, { error: 'not found' }); + return; + } + + res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('Not Found'); + } + + return new Promise((resolve) => { + server.on('error', (err) => { + log?.error(`WebUI 启动失败(端口 ${deps.port}):${err instanceof Error ? err.message : String(err)}`); + resolve({ close: () => undefined, port: deps.port }); + }); + server.listen(deps.port, '127.0.0.1', () => { + log?.info(`WebUI 已启动:http://127.0.0.1:${deps.port} (bot 编号 ${deps.id})`); + resolve({ + close: () => server.close(), + port: deps.port, + }); + }); + }); +} diff --git a/packages/bot/tsconfig.json b/packages/bot/tsconfig.json new file mode 100644 index 0000000..7959233 --- /dev/null +++ b/packages/bot/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/service/src/account/agentlab.ts b/packages/service/src/account/agentlab.ts index fed6458..9d4ff35 100644 --- a/packages/service/src/account/agentlab.ts +++ b/packages/service/src/account/agentlab.ts @@ -5,6 +5,7 @@ import { join } from 'node:path'; import type { AccountSession } from '@weq/account'; import { AgentLabStore, + AgentRuntime, buildPersonaArtifacts, describeSticker, distillMemories, @@ -14,11 +15,7 @@ import { extractPersonaCard, extractProfileChunk, mergeProfileParts, - reflectConversation, renderProfileChunks, - runPersonaChat, - scoreReplyGate, - willingLevelBias, scoreInteractionSentiment, decideGroupReply, describeRelationTone, @@ -58,10 +55,11 @@ import { type AgentLabGroupMember, type AgentLabGroupMessage, type AgentLabRelation, - type AgentLabMemoryItem, type AgentLabWillingConfig, + type TtsPort, + type TtsProviderConfig, + type TtsService, } from '@weq/agentlab'; -import { TtsService, type TtsProviderConfig } from '../common/tts'; import type { UserProfile } from '@weq/db'; import type { Element } from '@weq/codec'; import type { C2cMsg, GroupMsg, C2cPartition } from '@weq/db'; @@ -79,6 +77,7 @@ import { MemoryStore } from './agentlab_memory'; import { NotesStore } from './agentlab_notes'; import { JsonGroupStore } from './agentlab_group_store'; import { JsonRelationStore } from './agentlab_relation_store'; +import { getLogger } from '../common/logger'; const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); @@ -118,6 +117,9 @@ interface VoiceClipCandidate { score: number; } +/** 语音克隆参考音频的目标条数(与 selectRefClips 的 Top-K 对齐):攒够就停止打捞。 */ +const VOICE_REF_NEED = 5; + /** 克隆构建进度事件(前端进度条用;一次构建一串事件,done/error 收尾)。 */ export interface AgentLabBuildProgress { personaId: string; @@ -258,6 +260,22 @@ function imageToDataUrl(path: string): string | null { } } +/** 一次导出产物的 WebUI 访问信息(存 bot_exports.json,供设置页查密钥 / 打开控制台)。 */ +export interface BotExportInfo { + /** WebUI 访问密钥(hex)。 */ + key: string; + /** bot 编号(uuid)。 */ + id: string; + /** WebUI 端口。 */ + port: number; + /** 默认访问地址 http://127.0.0.1:。 */ + url: string; + /** 产物目录。 */ + outDir: string; + /** 导出时间戳。 */ + exportedAt: number; +} + export class AgentLabService extends EventEmitter { private readonly store: AgentLabStore; private readonly usage: TokenUsageStore; @@ -270,6 +288,9 @@ export class AgentLabService extends EventEmitter { private readonly relations: AgentLabRelationStore; /** 群聊记忆蒸馏节流计数(key = `personaId aboutId`,每 6 次互动蒸一次)。 */ private readonly groupMemoryCounter = new Map(); + private readonly logger = getLogger().child({ scope: 'agentlab' }); + /** 运行时对话引擎(下沉自本类,桌面与导出 bot 共用)。 */ + private readonly runtime: AgentRuntime; constructor( private readonly session: AccountSession, @@ -290,6 +311,35 @@ export class AgentLabService extends EventEmitter { this.notes = new NotesStore(join(rootDir, 'notes.json')); this.groups = new JsonGroupStore(join(rootDir, 'groups.json')); this.relations = new JsonRelationStore(join(rootDir, 'relations.json')); + + // 运行时对话引擎下沉到 @weq/agentlab 的 AgentRuntime(桌面与导出 bot 共用同一套)。 + // 把桌面侧依赖注入进去:现有 store + TTS(抽象成 TtsPort)+ 登录账号 uin 作为 selfId。 + const ttsPort: TtsPort | undefined = tts + ? { + getCapabilities: (id) => { + const p = tts.getProvider(id); + return p ? tts.service.capabilities(p.vendor) : null; + }, + synthesize: (id, text, opts) => { + const p = tts.getProvider(id); + if (!p) throw new Error(`TTS provider 不存在: ${id}`); + return tts.service.synthesize(p, text, opts); + }, + } + : undefined; + this.runtime = new AgentRuntime({ + rootDir, + store: this.store, + endpoints: this.resolveEndpoint, + usage: this.usage, + conversations: this.conversations, + memories: this.memories, + notes: this.notes, + relations: this.relations, + selfId: String(this.session.context.uin), + tts: ttsPort, + logger: this.logger, + }); } /** 对话反思笔记(前端「记忆/画像」灯箱可选展示)。 */ @@ -447,17 +497,6 @@ export class AgentLabService extends EventEmitter { })(); } - /** 克隆体的兴趣关键词(话题 + 口头禅 + 高频词),供意愿闸判断「聊到感兴趣的」。 */ - private personaInterestTerms(persona: AgentLabPersona): string[] { - const card = persona.profile?.card; - const terms = [ - ...(card?.topics ?? []), - ...(card?.catchphrases ?? []), - ...(persona.profile?.topTerms ?? []), - ]; - return Array.from(new Set(terms.map((t) => t.trim()).filter((t) => t.length >= 2))); - } - /** * 从「某个克隆体的视角」把群历史渲染成 chat 轮次:自己发的 = assistant, * 别人发的 = user 且带「名字」前缀(让模型分得清多个说话人)。M2 用现有 1:1 生成 @@ -664,7 +703,7 @@ export class AgentLabService extends EventEmitter { const inputText = trigger.senderKind === 'user' ? trigger.text : `「${triggerName}」:${trigger.text}`; await sleep(delayMs); // 越想回越快开口 - const { renderedTurns } = await this.generatePersonaTurns(persona, pairs, { + const { renderedTurns } = await this.runtime.generatePersonaTurns(persona, pairs, { chatEndpoint, embeddingEndpoint, history, @@ -824,76 +863,61 @@ export class AgentLabService extends EventEmitter { return this.store.getPersona(personaId)?.persona ?? null; } - /** - * 按 md5 解析某克隆体的自定义表情包本地路径(供 weq-media://sticker 协议读取)。 - * 找不到 persona / 表情 / 文件不存在时返回 null。 - */ - /** 合成语音的落盘目录(账号 agentlab 根下,懒建)。 */ - private agentVoiceDir(): string { - const dir = join(this.rootDir, 'agentvoice'); - mkdirSync(dir, { recursive: true }); - return dir; + /** 完整 persona 记录(含全部 pairs),供导出 bot 用。 */ + getPersonaRecord(personaId: string): { persona: AgentLabPersona; pairs: AgentLabStoredPair[] } | null { + return this.store.getPersona(personaId); } - /** - * 按 id 解析某条合成语音的本地路径(供 weq-media://agentvoice 协议读取)。 - * id 必须是安全 basename(仅 hex + .mp3/.wav),防目录逃逸。 - */ - getAgentVoicePath(id: string): string | null { - if (!/^[0-9a-f]+\.(mp3|wav)$/i.test(id)) return null; - const path = join(this.agentVoiceDir(), id); - return existsSync(path) ? path : null; + // ── 导出记录(id → WebUI 访问信息):导出后在设置页可查密钥 / 打开控制台 ───────── + private get exportsPath(): string { + return join(this.rootDir, 'bot_exports.json'); } - /** - * 这个克隆体当前能不能发语音:开了语音克隆 + 绑了 provider + 该 provider 能力匹配。 - * clone 模式还要求 provider 支持复刻且有参考音频;preset 模式要求 provider 支持固定音色。 - */ - private isVoiceReady(persona: AgentLabPersona): boolean { - if (!persona.voiceCloneEnabled || !persona.voice || !this.tts) return false; - const provider = this.tts.getProvider(persona.voice.providerId); - if (!provider) return false; - const caps = this.tts.service.capabilities(provider.vendor); - if (persona.voice.mode === 'clone') { - return caps.clone && (persona.voiceProfile?.refClips?.length ?? 0) > 0; + private readExports(): Record { + try { + if (existsSync(this.exportsPath)) { + return JSON.parse(readFileSync(this.exportsPath, 'utf-8')) as Record; + } + } catch { + /* 损坏当空 */ } - return caps.fixedVoice; + return {}; } - /** - * 合成一条语音,写到 agentvoice/.,返回文件名(id)。失败返回 null(调用方降级文字)。 - * clone 模式用 TA 的参考音频复刻;preset 模式用预置音色。 - */ - private async synthesizeVoice(persona: AgentLabPersona, text: string): Promise { - const voice = persona.voice; - if (!this.tts || !voice) return null; - const provider = this.tts.getProvider(voice.providerId); - if (!provider) return null; + /** 记录一次导出的 WebUI 访问信息(覆盖同 persona 的旧记录,保留最近一次)。 */ + recordExport(personaId: string, info: BotExportInfo): void { + const all = this.readExports(); + all[personaId] = info; try { - const opts: import('../common/tts').TtsSynthesizeOptions = {}; - if (voice.mode === 'clone') { - const clips = (persona.voiceProfile?.refClips ?? []).filter((c) => existsSync(c.path)); - if (clips.length === 0) return null; - opts.refClip = { path: clips[0]!.path, text: clips[0]!.text }; - opts.auxRefClips = clips.slice(1, 3).map((c) => ({ path: c.path, text: c.text })); - } else { - opts.voice = voice.voice || provider.voice; - } - const { audio, format } = await this.tts.service.synthesize(provider, text, opts); - const ext = format === 'wav' ? 'wav' : 'mp3'; - const hash = createHash('sha1') - .update(`${persona.id}|${provider.id}|${voice.mode}|${voice.voice ?? ''}|${text}`) - .digest('hex') - .slice(0, 16); - const id = `${hash}.${ext}`; - const dest = join(this.agentVoiceDir(), id); - if (!existsSync(dest)) writeFileSync(dest, audio); - return id; - } catch { - return null; + mkdirSync(this.rootDir, { recursive: true }); + writeFileSync(this.exportsPath, JSON.stringify(all, null, 2), 'utf-8'); + } catch (e) { + this.logger.warn('保存导出记录失败', { error: e instanceof Error ? e.message : String(e) }); } } + /** 读取某 persona 最近一次导出的 WebUI 访问信息(没有则 null)。 */ + getExportInfo(personaId: string): BotExportInfo | null { + return this.readExports()[personaId] ?? null; + } + + /** 该账号 agentlab 资产根目录(stickers/ 和 voice/ 在此),供导出复制资产。 */ + get assetRoot(): string { + return this.rootDir; + } + + /** + * 按 md5 解析某克隆体的自定义表情包本地路径(供 weq-media://sticker 协议读取)。 + * 找不到 persona / 表情 / 文件不存在时返回 null。 + */ + /** + * 按 id 解析某条合成语音的本地路径(供 weq-media://agentvoice 协议读取)。 + * id 必须是安全 basename(仅 hex + .mp3/.wav),防目录逃逸。 + */ + getAgentVoicePath(id: string): string | null { + return this.runtime.getAgentVoicePath(id); + } + getStickerPath(personaId: string, md5: string): string | null { const persona = this.store.getPersona(personaId)?.persona; if (!persona) return null; @@ -984,14 +1008,13 @@ export class AgentLabService extends EventEmitter { ): Promise { if (!this.media?.transcribe || !(this.media.voiceReady?.() ?? false)) return; const PAGE = 500; - const NEED = 5; let transcribed = 0; let scanned = 0; let page = await this.session.c2cMsgs.listLatest(part, PAGE); while ( page.length > 0 && transcribed < VOICE_TRANSCRIBE_CAP && - voiceClips.length < NEED && + voiceClips.length < VOICE_REF_NEED && scanned < C2C_SAFETY_CAP ) { for (const msg of page) { @@ -1008,7 +1031,7 @@ export class AgentLabService extends EventEmitter { durationMs, score: scoreVoiceClip(res.waveform, durationMs, res.spoken), }); - if (voiceClips.length >= NEED || transcribed >= VOICE_TRANSCRIBE_CAP) break; + if (voiceClips.length >= VOICE_REF_NEED || transcribed >= VOICE_TRANSCRIBE_CAP) break; } const oldest = page[page.length - 1]; if (!oldest) break; @@ -1018,6 +1041,57 @@ export class AgentLabService extends EventEmitter { } } + /** + * 群聊语音打捞(group 模式):去好友所在群里找 **TA 本人** 的语音,转录留 wav 攒克隆参考。 + * 语音不分私聊/群聊、一视同仁——私聊没攒够时就来群里补。只收好友本人、排除变声, + * 攒够 Top-K(VOICE_REF_NEED)即停,遍历上限沿用群补采那套(GROUP_MAX 群 × PER_GROUP_SCAN_CAP)。 + */ + private async salvageGroupVoiceClips( + targetUid: string, + voiceClips: VoiceClipCandidate[], + ): Promise { + if (!this.media?.transcribe || !(this.media.voiceReady?.() ?? false)) return; + const groups = await this.session.groupMembers.listUserGroups(targetUid, 50); + const picked = [...groups].sort((a, b) => b.lastSpeakTime - a.lastSpeakTime).slice(0, GROUP_MAX); + let transcribed = 0; + for (const g of picked) { + if (voiceClips.length >= VOICE_REF_NEED || transcribed >= VOICE_TRANSCRIBE_CAP) break; + const groupCode = g.groupCode.toString(); + let scanned = 0; + let beforeSeq: bigint | null = null; + while ( + scanned < PER_GROUP_SCAN_CAP && + voiceClips.length < VOICE_REF_NEED && + transcribed < VOICE_TRANSCRIBE_CAP + ) { + const page: GroupMsg[] = + beforeSeq === null + ? await this.session.groupMsgs.listLatest(groupCode, 500) + : await this.session.groupMsgs.listBefore(groupCode, beforeSeq, 500); + if (page.length === 0) break; + scanned += page.length; + const oldest = page[page.length - 1]; + if (!oldest) break; + beforeSeq = oldest.msgSeq; + for (const m of page) { + if (m.senderUid !== targetUid) continue; // 只要好友本人的语音 + if (!m.elements.some((el) => el.kind === 'ptt')) continue; + const res = await this.transcribePtt(m.elements, Number(m.sendTime) * 1000); + if (!res.spoken || res.voiceChanged || !res.wavPath) continue; + transcribed += 1; + const durationMs = res.durationMs ?? 0; + voiceClips.push({ + path: res.wavPath, + text: res.spoken, + durationMs, + score: scoreVoiceClip(res.waveform, durationMs, res.spoken), + }); + if (voiceClips.length >= VOICE_REF_NEED || transcribed >= VOICE_TRANSCRIBE_CAP) break; + } + } + } + } + /** * 把私聊原始消息映射成语料消息;遇到语音且配了转录模型时,逐条转录(至多 * VOICE_TRANSCRIBE_CAP 条)并把 `[语音]` 占位替换成 `[语音]<文本>`。 @@ -1390,11 +1464,17 @@ export class AgentLabService extends EventEmitter { } const groupStyleMessages = needGroup ? await this.collectGroupStyleMessages(input.targetUid) : []; - // 语音参考兜底:group 模式下,消息撞到上限被截断、且收集到的语料里一条可用语音都没有时, - // 回溯整段历史只找语音(攒语音克隆参考),不动语料。 - if (mode === 'group' && capHit && canTranscribe && voiceClips.length === 0) { - this.emitProgress(input.personaId, '回溯历史语音(语音克隆参考)', 58); - await this.salvageVoiceClips(part, selfUin, voiceClips); + // 语音参考兜底(group 模式):语音不分私聊/群聊、一视同仁——只要还没攒够参考音频就继续补。 + // 先回溯私聊剩余历史(仅在撞 cap、还有更老消息没拉时有意义),仍不够则去好友所在群里补采语音。 + if (mode === 'group' && canTranscribe && voiceClips.length < VOICE_REF_NEED) { + if (capHit && voiceClips.length < VOICE_REF_NEED) { + this.emitProgress(input.personaId, '回溯私聊历史语音(克隆参考)', 56); + await this.salvageVoiceClips(part, selfUin, voiceClips); + } + if (voiceClips.length < VOICE_REF_NEED) { + this.emitProgress(input.personaId, '群聊补充语音(克隆参考)', 58); + await this.salvageGroupVoiceClips(input.targetUid, voiceClips); + } } const sample: AgentLabConversationSample = { @@ -1530,203 +1610,8 @@ export class AgentLabService extends EventEmitter { } async chat(input: { personaId: string; history: AgentLabChatTurn[]; text: string }) { - const record = this.store.getPersona(input.personaId); - if (!record) throw new Error('找不到 persona'); - if (!record.persona.models?.chat) throw new Error('这是旧版克隆体,模型结构已更新,请删除后重建'); - const ctx = { personaId: input.personaId, scope: 'chat' as const }; - const chatEndpoint = this.resolveWithUsage(record.persona.models.chat, 'chat', ctx); - const embeddingEndpoint = record.persona.models.embedding - ? this.resolveWithUsage(record.persona.models.embedding, 'embedding', ctx) - : null; - - // 发言意愿对私聊生效(可选):意愿闸没过就保持沉默,只记下用户这条,不回。 - const willing = record.persona.willing; - if (willing?.gatePrivate) { - const decision = scoreReplyGate({ - text: input.text, - personaName: record.persona.name, - fromOwner: true, - interestTerms: this.personaInterestTerms(record.persona), - relation: this.relations.get(input.personaId, this.selfMemberId()), - levelBias: willingLevelBias(willing.level), - mustReplyOnMention: willing.mustReplyOnMention !== false, - }); - if (!decision.shouldReply) { - const ts = Date.now(); - this.conversations.append(input.personaId, [{ role: 'user', text: input.text, ts }]); - return { - text: '', - segments: [], - actions: [], - promptPreview: '', - matches: [], - usedMemoryIds: [], - willingness: decision.score, - replyDelayMs: 0, - sticker: null, - renderedTurns: [] as string[], - silent: true, - }; - } - } - - const now = Date.now(); - const { result, renderedTurns } = await this.generatePersonaTurns(record.persona, record.pairs, { - chatEndpoint, - embeddingEndpoint, - history: input.history, - input: input.text, - now, - }); - - const assistantTurns: ConversationTurn[] = renderedTurns.map((text) => ({ - role: 'assistant', - text, - ts: now, - })); - this.conversations.append(input.personaId, [ - { role: 'user', text: input.text, ts: now }, - ...assistantTurns, - ]); - - // 每隔若干轮,从最近对话蒸馏出克隆体「对对方」的新记忆(不阻塞本次回复)。 - void this.maybeDistillMemories(input.personaId, record.persona.name, chatEndpoint); - // 每隔若干轮,反思扮演效果:提炼用户纠正 + 对话摘要(不阻塞本次回复)。 - void this.maybeReflect(input.personaId, record.persona.name, chatEndpoint); - - // renderedTurns = 最终落库的有序标记文本,前端据此逐条揭示(含表情图/语音气泡)。 - return { ...result, renderedTurns, silent: false }; - } - - /** - * 单个克隆体「给定历史 + 当前输入 → 产出有序标记文本」的共享生成逻辑 - * (私聊 chat() 与群聊 sendGroupMessage() 复用)。只做生成 + 记忆命中记账 + - * action→标记文本(含语音合成),不碰任何对话落库——落哪由调用方决定。 - */ - private async generatePersonaTurns( - persona: AgentLabPersona, - pairs: AgentLabStoredPair[], - opts: { - chatEndpoint: AgentLabEndpoint; - embeddingEndpoint: AgentLabEndpoint | null; - history: AgentLabChatTurn[]; - input: string; - now: number; - /** 群聊 M4:此刻对当前说话人的关系语气指令。 */ - relationNote?: string; - /** M5:显式指定可召回的记忆(群聊传「关于当前说话人」的,防串人)。缺省=该克隆体全部记忆。 */ - memories?: AgentLabMemoryItem[]; - }, - ): Promise<{ result: Awaited>; renderedTurns: string[] }> { - const voiceEnabled = this.isVoiceReady(persona); - const result = await runPersonaChat(opts.chatEndpoint, opts.embeddingEndpoint, { - persona, - pairs, - history: opts.history, - input: opts.input, - memories: opts.memories ?? this.memories.get(persona.id), - notes: this.notes.get(persona.id), - voiceEnabled, - relationNote: opts.relationNote, - }); - // 命中的记忆 +access(越常被想起越不易遗忘)。 - this.memories.touch(persona.id, result.usedMemoryIds, opts.now); - - // 按 actions 顺序转成标记文本(text / 表情 [[sticker:md5]] / 语音 [[voice:id]])。 - // 语音合成失败则降级为文字,不丢内容。 - const renderedTurns: string[] = []; - for (const action of result.actions) { - if (action.kind === 'text') { - renderedTurns.push(action.text); - } else if (action.kind === 'sticker') { - renderedTurns.push(`[[sticker:${action.sticker.md5}]]`); - } else { - const voiceId = await this.synthesizeVoice(persona, action.text); - renderedTurns.push(voiceId ? `[[voice:${voiceId}]]` : action.text); - } - } - // 极端兜底:actions 为空时至少产出一条完整文本。 - if (renderedTurns.length === 0) renderedTurns.push(result.text); - return { result, renderedTurns }; - } - - /** 每 MEMORY_DISTILL_EVERY 个用户回合蒸馏一次记忆;fire-and-forget,失败静默。 */ - private async maybeDistillMemories( - personaId: string, - peerName: string, - chatEndpoint: AgentLabEndpoint, - ): Promise { - const MEMORY_DISTILL_EVERY = 6; - try { - const conv = this.conversations.get(personaId); - const userTurns = conv.filter((t) => t.role === 'user').length; - if (userTurns === 0 || userTurns % MEMORY_DISTILL_EVERY !== 0) return; - const known = this.memories.get(personaId).map((m) => m.text).slice(-40); - const fresh = await distillMemories( - chatEndpoint, - peerName, - conv.map((t) => ({ role: t.role, text: t.text })), - known, - ); - if (fresh.length === 0) return; - // M5:私聊记忆标记 aboutId=被克隆好友本人(对方),并在配了向量模型时嵌入以便语义召回。 - const persona = this.store.getPersona(personaId)?.persona; - const aboutId = persona?.sourceId; - let embeddings: Array | undefined; - if (persona?.models.embedding) { - try { - const embEndpoint = this.resolveWithUsage(persona.models.embedding, 'embedding', { - personaId, - scope: 'chat', - }); - embeddings = await embedTexts(embEndpoint, fresh); - } catch { - /* 嵌入失败就退化成关键词召回 */ - } - } - this.memories.add( - personaId, - fresh, - Date.now(), - aboutId ? { aboutId, aboutKind: 'user' } : undefined, - embeddings, - ); - } catch { - /* 记忆蒸馏失败不影响聊天 */ - } - } - - /** - * 每 REFLECT_EVERY 个用户回合反思一次扮演效果;用 reflectedCount 水位只反思新增片段, - * 提炼出的 corrections(必须遵守)/ summary(episode)写入 NotesStore。fire-and-forget,失败静默。 - */ - private async maybeReflect( - personaId: string, - peerName: string, - chatEndpoint: AgentLabEndpoint, - ): Promise { - const REFLECT_EVERY = 8; - const MIN_UNREFLECTED = 4; - try { - const conv = this.conversations.get(personaId); - const userTurns = conv.filter((t) => t.role === 'user').length; - if (userTurns === 0 || userTurns % REFLECT_EVERY !== 0) return; - const reflected = this.notes.getReflectedCount(personaId); - const unreflected = conv.slice(reflected); - if (unreflected.length < MIN_UNREFLECTED) return; - const result = await reflectConversation( - chatEndpoint, - peerName, - unreflected.map((t) => ({ role: t.role, text: t.text })), - ); - if (result.corrections.length > 0 || result.summary) { - this.notes.add(personaId, result.corrections, result.summary); - } - // 无论是否提炼出内容都推进水位,避免下次重复反思同一段。 - this.notes.setReflectedCount(personaId, conv.length); - } catch { - /* 对话反思失败不影响聊天 */ - } + // 运行时对话(意愿闸 / 生成 / 落库 / 记忆反思)已下沉到 AgentRuntime,桌面与 bot 共用同一套。 + return this.runtime.chat(input); } private c2cPartition(targetUid: string): { sortNo: bigint } | { uid: string } { diff --git a/packages/service/src/account/agentlab_export.ts b/packages/service/src/account/agentlab_export.ts new file mode 100644 index 0000000..de5cb21 --- /dev/null +++ b/packages/service/src/account/agentlab_export.ts @@ -0,0 +1,223 @@ +/** + * 把一个训练好的克隆体 persona 导出成自包含的 OneBot bot 产物文件夹。 + * + * 产物结构(见 plan / README): + * / + * ├── bot.mjs # esbuild 预打包的 @weq/bot + @weq/agentlab 引擎(resources/bot-runtime/bot.mjs) + * ├── index.mjs # 入口:读 config.json + startBot + * ├── config.json # adapter/selfId/providers/features + * ├── package.json # deps: ws + * ├── README.md + * └── persona/ # AgentLabStore 目录:.json + stickers/ + voice/ + * + * 纯文件操作,不碰 provider 解析/网络——上层(procedure)把 LLM/TTS provider 配置、bot.mjs 路径都备好传进来。 + */ +import { existsSync } from 'node:fs'; +import { copyFile, mkdir, writeFile } from 'node:fs/promises'; +import { randomBytes, randomUUID } from 'node:crypto'; +import { basename, join } from 'node:path'; +import type { AgentLabPersona, AgentLabStoredPair, TtsProviderConfig } from '@weq/agentlab'; + +export interface BotExportLlmProvider { + id: string; + baseUrl: string; + apiKey: string; +} + +export interface BotExportInput { + /** 产物根目录(应为空目录或新建)。 */ + outDir: string; + /** 预打包引擎 bot.mjs 的绝对路径(resources/bot-runtime/bot.mjs)。 */ + botRuntimeMjs: string; + persona: AgentLabPersona; + pairs: AgentLabStoredPair[]; + /** 源资产根目录(该账号的 agentlab 目录,stickers/ 和 voice/ 在这)。 */ + agentlabRoot: string; + /** persona.models 用到的 LLM provider(已抽好 baseUrl/apiKey)。 */ + llmProviders: BotExportLlmProvider[]; + /** persona.voice 用到的 TTS provider(整份配置,含 key)。 */ + ttsProviders: TtsProviderConfig[]; + adapter: { type: 'napcat' | 'snowluma'; wsUrl: string; token?: string }; + /** bot 自己的 QQ 号。 */ + selfId: string; + features: { voice: boolean; groupChat: boolean; groupReplyMode?: 'llm' | 'heuristic' }; + /** 本机 WebUI 控制台端口(默认 8090)。 */ + webuiPort?: number; + /** + * 图像(vision)模型:写进导出 persona 的 models.vision,供 bot 运行时解析「上传的新表情」。 + * 缺省时沿用 persona 原有的 models.vision(克隆时若选过 vision 就有)。其 provider 必须在 llmProviders 里。 + */ + visionModel?: { providerId: string; model: string }; +} + +export interface BotExportResult { + outDir: string; + stickerCount: number; + voiceClipCount: number; + /** 生成的 WebUI 访问信息(密钥请提示用户妥善保管)。 */ + webui: { port: number; key: string; id: string; url: string }; +} + +async function copyIfExists(src: string, dest: string): Promise { + if (!src || !existsSync(src)) return false; + await copyFile(src, dest); + return true; +} + +export async function buildBotExport(input: BotExportInput): Promise { + const personaDir = join(input.outDir, 'persona'); + const stickersDir = join(personaDir, 'stickers'); + const voiceDir = join(personaDir, 'voice'); + await mkdir(stickersDir, { recursive: true }); + await mkdir(voiceDir, { recursive: true }); + + // 1) 引擎 bot.mjs。 + await copyFile(input.botRuntimeMjs, join(input.outDir, 'bot.mjs')); + + // 2) persona(深拷贝后重定位资产路径:绝对 → 产物内相对)。 + const persona: AgentLabPersona = JSON.parse(JSON.stringify(input.persona)); + + // 图像模型:显式指定则写进 persona.models.vision(单一事实源——bot 解析新表情、runtime 都读它)。 + if (input.visionModel) { + persona.models = { ...persona.models, vision: input.visionModel }; + } + + // 表情图:复制到 persona/stickers/.png(bot 出站按 md5 就地找,不依赖 localPath)。 + let stickerCount = 0; + for (const st of persona.stickers ?? []) { + const src = st.localPath || join(input.agentlabRoot, 'stickers', `${st.md5}.png`); + if (await copyIfExists(src, join(stickersDir, `${st.md5}.png`))) { + st.localPath = join('stickers', `${st.md5}.png`); // 相对 personaDir(bot 启动时 resolve) + stickerCount += 1; + } + } + + // 语音参考音频:复制到 persona/voice/,refClips[].path 改成相对(bot 启动时 join personaDir)。 + let voiceClipCount = 0; + for (const clip of persona.voiceProfile?.refClips ?? []) { + const file = basename(clip.path); + if (await copyIfExists(clip.path, join(voiceDir, file))) { + clip.path = join('voice', file); + voiceClipCount += 1; + } + } + + // 3) persona 记录(AgentLabStore 格式:.json = { persona, pairs })。 + await writeFile( + join(personaDir, `${persona.id}.json`), + JSON.stringify({ persona, pairs: input.pairs }, null, 2), + 'utf-8', + ); + + // 4) config.json(含随机生成的 WebUI 访问密钥 + bot 编号)。 + const webuiPort = input.webuiPort && input.webuiPort > 0 ? input.webuiPort : 8090; + const webui = { + enabled: true, + port: webuiPort, + key: randomBytes(16).toString('hex'), // 32 位 hex 密钥 + id: randomUUID(), + }; + const config = { + adapter: { type: input.adapter.type, wsUrl: input.adapter.wsUrl, token: input.adapter.token ?? '' }, + selfId: input.selfId, + personaDir: './persona', + llmProviders: input.llmProviders, + ttsProviders: input.ttsProviders, + features: input.features, + webui, + }; + await writeFile(join(input.outDir, 'config.json'), JSON.stringify(config, null, 2), 'utf-8'); + + // 5) index.mjs 入口。 + await writeFile(join(input.outDir, 'index.mjs'), INDEX_MJS, 'utf-8'); + + // 6) package.json。 + const pkg = { + name: `${sanitizeName(persona.name)}-bot`, + version: '1.0.0', + private: true, + type: 'module', + scripts: { start: 'node index.mjs' }, + dependencies: { ws: '^8.18.0' }, + }; + await writeFile(join(input.outDir, 'package.json'), JSON.stringify(pkg, null, 2), 'utf-8'); + + // 7) README。 + const webuiUrl = `http://127.0.0.1:${webuiPort}`; + await writeFile(join(input.outDir, 'README.md'), renderReadme(persona.name, input, { ...webui, url: webuiUrl }), 'utf-8'); + + return { + outDir: input.outDir, + stickerCount, + voiceClipCount, + webui: { port: webuiPort, key: webui.key, id: webui.id, url: webuiUrl }, + }; +} + +function sanitizeName(name: string): string { + return name.replace(/[^\w一-龥-]+/g, '_').slice(0, 40) || 'clone'; +} + +const INDEX_MJS = `// 克隆体 bot 入口:读 config.json,把相对资产路径补成绝对,启动 bot。 +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { startBot } from './bot.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); +// 每次(含重载)都重读 config.json,改配置后从 WebUI「重载配置」即可生效,无需手动重启。 +function loadConfig() { + const config = JSON.parse(readFileSync(join(here, 'config.json'), 'utf-8')); + config.personaDir = join(here, config.personaDir); + return config; +} + +startBot(loadConfig(), { reloadConfig: loadConfig }).then( + () => console.log('克隆体 bot 已启动。Ctrl+C 退出。'), + (err) => { console.error('启动失败:', err); process.exit(1); }, +); +`; + +function renderReadme( + name: string, + input: BotExportInput, + webui: { port: number; key: string; id: string; url: string }, +): string { + return `# ${name} · 克隆体 Bot + +由 WeQ 导出。这是一个自包含的 OneBot bot,连接 **${input.adapter.type}** 后即可让克隆体作为 QQ bot 上线。 + +## 启动 + +\`\`\`bash +npm install # 安装 ws(唯一运行时依赖) +npm start +\`\`\` + +启动后 bot 会自动开启本机 **WebUI 控制台**,可查看 token 消耗、收发消息统计与克隆体总览。 + +## WebUI 控制台 + +- 地址:${webui.url} +- 访问密钥(首次打开需输入):\`${webui.key}\` +- bot 编号:\`${webui.id}\` + +> 控制台仅监听 \`127.0.0.1\`(本机),用上面的密钥鉴权。改端口 / 关闭:编辑 \`config.json\` 的 \`webui\` 段。 + +## 配置(config.json) + +- \`adapter.wsUrl\`:${input.adapter.type} 的正向 WebSocket 地址(当前:\`${input.adapter.wsUrl}\`)。 +- \`adapter.token\`:连接鉴权 token(Authorization: Bearer)。 +- \`selfId\`:bot 的 QQ 号(当前:\`${input.selfId}\`)。 +- \`features.voice\`:是否允许发语音(当前:${input.features.voice ? '开' : '关'})。 +- \`features.groupChat\`:是否参与群聊(当前:${input.features.groupChat ? '开' : '关'})。 +- \`features.groupReplyMode\`:群聊回复意愿决策(当前:${ + (input.features.groupReplyMode ?? 'llm') === 'llm' ? 'llm·拟人判断' : 'heuristic·启发式打分' + })。\`llm\`=克隆体带上下文自己判断要不要开口(更自然,每条群消息多一次 LLM 调用);\`heuristic\`=纯启发式打分(快·省 token)。 +- \`webui.port\`:控制台端口(当前:\`${webui.port}\`);\`webui.enabled\` 设为 false 可关闭。 + +## ⚠️ 安全提示 + +\`config.json\` 里含有你的 **LLM / TTS API Key**(明文)以及 **WebUI 访问密钥**。请妥善保管这个文件夹,不要公开分享。 +`; +} diff --git a/packages/service/src/bootstrap/agentlab_config.ts b/packages/service/src/bootstrap/agentlab_config.ts index 7f1c158..f32a42d 100644 --- a/packages/service/src/bootstrap/agentlab_config.ts +++ b/packages/service/src/bootstrap/agentlab_config.ts @@ -8,6 +8,7 @@ import { type AgentLabModelRef, type AgentLabProviderCatalogEntry, type AgentLabProviderConfig, + type TtsProviderConfig, } from '@weq/agentlab'; import type { UserConfigService } from './user_config'; @@ -35,6 +36,11 @@ export class AgentLabConfigService { return this.listProviders().find((item) => item.id === providerId) ?? null; } + /** 按 id 取 TTS provider 配置(供导出 bot 打包语音配置)。 */ + getTtsProvider(providerId: string): TtsProviderConfig | null { + return this.userConfig.getSettings().voiceTranscribe.ttsProviders.find((p) => p.id === providerId) ?? null; + } + /** 把 agent 里的「某任务用哪个 provider 的哪个 model」解析成可调用端点。 */ resolveEndpoint(ref: AgentLabModelRef): AgentLabEndpoint { const provider = this.getProvider(ref.providerId); diff --git a/packages/service/src/bootstrap/user_config.ts b/packages/service/src/bootstrap/user_config.ts index 8d4bbc4..db69010 100644 --- a/packages/service/src/bootstrap/user_config.ts +++ b/packages/service/src/bootstrap/user_config.ts @@ -19,8 +19,7 @@ import { mkdirSync, readFileSync, writeFileSync, readdirSync, unlinkSync } from 'node:fs'; import { join, basename } from 'node:path'; import type { Platform } from '@weq/platform'; -import type { AgentLabProviderConfig } from '@weq/agentlab'; -import type { TtsProviderConfig } from '../common/tts'; +import type { AgentLabProviderConfig, TtsProviderConfig } from '@weq/agentlab'; import type { AccountConfig } from '../account/user_config'; import { getLogger, logErrorContext } from '../common/logger'; @@ -64,6 +63,12 @@ function normalizeAgentLabProviders(value: unknown): AgentLabProviderConfig[] { return value.filter(isAgentLabProviderConfig); } +/** Coerce a persisted / patched close-behavior value to the known union, or + * `undefined` so the caller falls back to the default / current value. */ +function normalizeWindowCloseBehavior(value: unknown): WindowCloseBehavior | undefined { + return value === 'ask' || value === 'tray' || value === 'quit' ? value : undefined; +} + function isTtsProviderConfig(value: unknown): value is TtsProviderConfig { if (!value || typeof value !== 'object') return false; const item = value as Partial; @@ -109,6 +114,14 @@ export interface AgentLabSettings { providers: AgentLabProviderConfig[]; } +/** + * 关闭主窗口(标题栏 ✕)时的行为: + * - 'ask' 首次询问,弹窗让用户选择(最小化到托盘 / 完全退出),可记住选择 + * - 'tray' 最小化到系统托盘,进程常驻后台,可从托盘恢复 + * - 'quit' 直接完全退出应用 + */ +export type WindowCloseBehavior = 'ask' | 'tray' | 'quit'; + export interface AppSettings { realtimeEnabled: boolean; mediaCompletion: MediaCompletionConfig; @@ -121,6 +134,8 @@ export interface AppSettings { voiceTranscribe: VoiceTranscribeConfig; mcp: McpServerConfig; agentLab: AgentLabSettings; + /** 点击关闭按钮时的行为。默认 'ask'(首次弹窗询问)。 */ + windowCloseBehavior: WindowCloseBehavior; } export const DEFAULT_APP_SETTINGS: AppSettings = { @@ -129,8 +144,11 @@ export const DEFAULT_APP_SETTINGS: AppSettings = { autoFetchClientKey: true, autoLockMinutes: 0, voiceTranscribe: { modelId: '', ttsProviders: [] }, - mcp: { enabled: false, port: 8765, token: '' }, + // 8765 在 Windows 上常被百度输入法等占用,默认改用不常冲突的高端口; + // 即便仍冲突,启动时也会自动向上探测可用端口(见 mcp/server.ts)。 + mcp: { enabled: false, port: 48765, token: '' }, agentLab: { providers: [] }, + windowCloseBehavior: 'ask', }; export interface UserConfig { @@ -277,6 +295,7 @@ export class UserConfigService { realtimeEnabled: s?.realtimeEnabled ?? d.realtimeEnabled, autoFetchClientKey: s?.autoFetchClientKey ?? d.autoFetchClientKey, autoLockMinutes: s?.autoLockMinutes ?? d.autoLockMinutes, + windowCloseBehavior: normalizeWindowCloseBehavior(s?.windowCloseBehavior) ?? d.windowCloseBehavior, mediaCompletion: { enabled: s?.mediaCompletion?.enabled ?? d.mediaCompletion.enabled, }, @@ -301,6 +320,8 @@ export class UserConfigService { realtimeEnabled: patch.realtimeEnabled ?? current.realtimeEnabled, autoFetchClientKey: patch.autoFetchClientKey ?? current.autoFetchClientKey, autoLockMinutes: patch.autoLockMinutes ?? current.autoLockMinutes, + windowCloseBehavior: + normalizeWindowCloseBehavior(patch.windowCloseBehavior) ?? current.windowCloseBehavior, mediaCompletion: { enabled: patch.mediaCompletion?.enabled ?? current.mediaCompletion.enabled, }, diff --git a/packages/service/src/index.ts b/packages/service/src/index.ts index 6e9198b..62417a1 100644 --- a/packages/service/src/index.ts +++ b/packages/service/src/index.ts @@ -35,6 +35,7 @@ export type { InstallCache, AutoEnterTarget, AppSettings, + WindowCloseBehavior, MediaCompletionConfig, VoiceTranscribeConfig, McpServerConfig, @@ -208,7 +209,7 @@ export type { } from './common/voice_transcribe'; export { getLogDir, getLogger, initLogger, logErrorContext } from './common/logger'; export type { Logger, LoggerContext, LogLevel } from './common/logger'; -export { TtsService, TTS_VENDOR_CATALOG, getTtsCatalogEntry, getTtsCapabilities } from './common/tts'; +export { TtsService, TTS_VENDOR_CATALOG, getTtsCatalogEntry, getTtsCapabilities } from '@weq/agentlab'; export type { TtsVendor, TtsProviderConfig, @@ -217,4 +218,6 @@ export type { TtsSynthesizeResult, TtsCapabilities, TtsVendorCatalogEntry, -} from './common/tts'; +} from '@weq/agentlab'; +export { buildBotExport } from './account/agentlab_export'; +export type { BotExportInput, BotExportResult, BotExportLlmProvider } from './account/agentlab_export'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b78226..b0ee175 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: devDependencies: + esbuild: + specifier: ^0.28.1 + version: 0.28.1 tsx: specifier: ^4.22.4 version: 4.22.4 @@ -282,6 +285,22 @@ importers: specifier: ^22.10.6 version: 22.19.20 + packages/bot: + dependencies: + '@weq/agentlab': + specifier: workspace:* + version: link:../agentlab + ws: + specifier: ^8.18.0 + version: 8.21.0 + devDependencies: + '@types/node': + specifier: ^22.10.6 + version: 22.19.20 + '@types/ws': + specifier: ^8.5.13 + version: 8.18.1 + packages/codec: dependencies: '@protobuf-ts/runtime': @@ -543,8 +562,8 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/aix-ppc64@0.28.0': - resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] @@ -561,8 +580,8 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.28.0': - resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} engines: {node: '>=18'} cpu: [arm64] os: [android] @@ -579,8 +598,8 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-arm@0.28.0': - resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} engines: {node: '>=18'} cpu: [arm] os: [android] @@ -597,8 +616,8 @@ packages: cpu: [x64] os: [android] - '@esbuild/android-x64@0.28.0': - resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} engines: {node: '>=18'} cpu: [x64] os: [android] @@ -615,8 +634,8 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.28.0': - resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] @@ -633,8 +652,8 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.28.0': - resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] @@ -651,8 +670,8 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.28.0': - resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] @@ -669,8 +688,8 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.28.0': - resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] @@ -687,8 +706,8 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.28.0': - resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} engines: {node: '>=18'} cpu: [arm64] os: [linux] @@ -705,8 +724,8 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.28.0': - resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} engines: {node: '>=18'} cpu: [arm] os: [linux] @@ -723,8 +742,8 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.28.0': - resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] @@ -741,8 +760,8 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.28.0': - resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] @@ -759,8 +778,8 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.28.0': - resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] @@ -777,8 +796,8 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.28.0': - resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] @@ -795,8 +814,8 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.28.0': - resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] @@ -813,8 +832,8 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.28.0': - resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} engines: {node: '>=18'} cpu: [s390x] os: [linux] @@ -831,8 +850,8 @@ packages: cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.28.0': - resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} engines: {node: '>=18'} cpu: [x64] os: [linux] @@ -843,8 +862,8 @@ packages: cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-arm64@0.28.0': - resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] @@ -861,8 +880,8 @@ packages: cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.28.0': - resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] @@ -873,8 +892,8 @@ packages: cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-arm64@0.28.0': - resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] @@ -891,8 +910,8 @@ packages: cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.28.0': - resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] @@ -903,8 +922,8 @@ packages: cpu: [arm64] os: [openharmony] - '@esbuild/openharmony-arm64@0.28.0': - resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] @@ -921,8 +940,8 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.28.0': - resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} engines: {node: '>=18'} cpu: [x64] os: [sunos] @@ -939,8 +958,8 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.28.0': - resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] @@ -957,8 +976,8 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.28.0': - resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} engines: {node: '>=18'} cpu: [ia32] os: [win32] @@ -975,8 +994,8 @@ packages: cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.28.0': - resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -1752,6 +1771,9 @@ packages: '@types/verror@1.10.11': resolution: {integrity: sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} @@ -2550,8 +2572,8 @@ packages: engines: {node: '>=18'} hasBin: true - esbuild@0.28.0: - resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} hasBin: true @@ -4187,6 +4209,18 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xmlbuilder@15.1.1: resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} engines: {node: '>=8.0'} @@ -4486,7 +4520,7 @@ snapshots: '@esbuild/aix-ppc64@0.25.12': optional: true - '@esbuild/aix-ppc64@0.28.0': + '@esbuild/aix-ppc64@0.28.1': optional: true '@esbuild/android-arm64@0.21.5': @@ -4495,7 +4529,7 @@ snapshots: '@esbuild/android-arm64@0.25.12': optional: true - '@esbuild/android-arm64@0.28.0': + '@esbuild/android-arm64@0.28.1': optional: true '@esbuild/android-arm@0.21.5': @@ -4504,7 +4538,7 @@ snapshots: '@esbuild/android-arm@0.25.12': optional: true - '@esbuild/android-arm@0.28.0': + '@esbuild/android-arm@0.28.1': optional: true '@esbuild/android-x64@0.21.5': @@ -4513,7 +4547,7 @@ snapshots: '@esbuild/android-x64@0.25.12': optional: true - '@esbuild/android-x64@0.28.0': + '@esbuild/android-x64@0.28.1': optional: true '@esbuild/darwin-arm64@0.21.5': @@ -4522,7 +4556,7 @@ snapshots: '@esbuild/darwin-arm64@0.25.12': optional: true - '@esbuild/darwin-arm64@0.28.0': + '@esbuild/darwin-arm64@0.28.1': optional: true '@esbuild/darwin-x64@0.21.5': @@ -4531,7 +4565,7 @@ snapshots: '@esbuild/darwin-x64@0.25.12': optional: true - '@esbuild/darwin-x64@0.28.0': + '@esbuild/darwin-x64@0.28.1': optional: true '@esbuild/freebsd-arm64@0.21.5': @@ -4540,7 +4574,7 @@ snapshots: '@esbuild/freebsd-arm64@0.25.12': optional: true - '@esbuild/freebsd-arm64@0.28.0': + '@esbuild/freebsd-arm64@0.28.1': optional: true '@esbuild/freebsd-x64@0.21.5': @@ -4549,7 +4583,7 @@ snapshots: '@esbuild/freebsd-x64@0.25.12': optional: true - '@esbuild/freebsd-x64@0.28.0': + '@esbuild/freebsd-x64@0.28.1': optional: true '@esbuild/linux-arm64@0.21.5': @@ -4558,7 +4592,7 @@ snapshots: '@esbuild/linux-arm64@0.25.12': optional: true - '@esbuild/linux-arm64@0.28.0': + '@esbuild/linux-arm64@0.28.1': optional: true '@esbuild/linux-arm@0.21.5': @@ -4567,7 +4601,7 @@ snapshots: '@esbuild/linux-arm@0.25.12': optional: true - '@esbuild/linux-arm@0.28.0': + '@esbuild/linux-arm@0.28.1': optional: true '@esbuild/linux-ia32@0.21.5': @@ -4576,7 +4610,7 @@ snapshots: '@esbuild/linux-ia32@0.25.12': optional: true - '@esbuild/linux-ia32@0.28.0': + '@esbuild/linux-ia32@0.28.1': optional: true '@esbuild/linux-loong64@0.21.5': @@ -4585,7 +4619,7 @@ snapshots: '@esbuild/linux-loong64@0.25.12': optional: true - '@esbuild/linux-loong64@0.28.0': + '@esbuild/linux-loong64@0.28.1': optional: true '@esbuild/linux-mips64el@0.21.5': @@ -4594,7 +4628,7 @@ snapshots: '@esbuild/linux-mips64el@0.25.12': optional: true - '@esbuild/linux-mips64el@0.28.0': + '@esbuild/linux-mips64el@0.28.1': optional: true '@esbuild/linux-ppc64@0.21.5': @@ -4603,7 +4637,7 @@ snapshots: '@esbuild/linux-ppc64@0.25.12': optional: true - '@esbuild/linux-ppc64@0.28.0': + '@esbuild/linux-ppc64@0.28.1': optional: true '@esbuild/linux-riscv64@0.21.5': @@ -4612,7 +4646,7 @@ snapshots: '@esbuild/linux-riscv64@0.25.12': optional: true - '@esbuild/linux-riscv64@0.28.0': + '@esbuild/linux-riscv64@0.28.1': optional: true '@esbuild/linux-s390x@0.21.5': @@ -4621,7 +4655,7 @@ snapshots: '@esbuild/linux-s390x@0.25.12': optional: true - '@esbuild/linux-s390x@0.28.0': + '@esbuild/linux-s390x@0.28.1': optional: true '@esbuild/linux-x64@0.21.5': @@ -4630,13 +4664,13 @@ snapshots: '@esbuild/linux-x64@0.25.12': optional: true - '@esbuild/linux-x64@0.28.0': + '@esbuild/linux-x64@0.28.1': optional: true '@esbuild/netbsd-arm64@0.25.12': optional: true - '@esbuild/netbsd-arm64@0.28.0': + '@esbuild/netbsd-arm64@0.28.1': optional: true '@esbuild/netbsd-x64@0.21.5': @@ -4645,13 +4679,13 @@ snapshots: '@esbuild/netbsd-x64@0.25.12': optional: true - '@esbuild/netbsd-x64@0.28.0': + '@esbuild/netbsd-x64@0.28.1': optional: true '@esbuild/openbsd-arm64@0.25.12': optional: true - '@esbuild/openbsd-arm64@0.28.0': + '@esbuild/openbsd-arm64@0.28.1': optional: true '@esbuild/openbsd-x64@0.21.5': @@ -4660,13 +4694,13 @@ snapshots: '@esbuild/openbsd-x64@0.25.12': optional: true - '@esbuild/openbsd-x64@0.28.0': + '@esbuild/openbsd-x64@0.28.1': optional: true '@esbuild/openharmony-arm64@0.25.12': optional: true - '@esbuild/openharmony-arm64@0.28.0': + '@esbuild/openharmony-arm64@0.28.1': optional: true '@esbuild/sunos-x64@0.21.5': @@ -4675,7 +4709,7 @@ snapshots: '@esbuild/sunos-x64@0.25.12': optional: true - '@esbuild/sunos-x64@0.28.0': + '@esbuild/sunos-x64@0.28.1': optional: true '@esbuild/win32-arm64@0.21.5': @@ -4684,7 +4718,7 @@ snapshots: '@esbuild/win32-arm64@0.25.12': optional: true - '@esbuild/win32-arm64@0.28.0': + '@esbuild/win32-arm64@0.28.1': optional: true '@esbuild/win32-ia32@0.21.5': @@ -4693,7 +4727,7 @@ snapshots: '@esbuild/win32-ia32@0.25.12': optional: true - '@esbuild/win32-ia32@0.28.0': + '@esbuild/win32-ia32@0.28.1': optional: true '@esbuild/win32-x64@0.21.5': @@ -4702,7 +4736,7 @@ snapshots: '@esbuild/win32-x64@0.25.12': optional: true - '@esbuild/win32-x64@0.28.0': + '@esbuild/win32-x64@0.28.1': optional: true '@fast-csv/format@4.3.5': @@ -5414,6 +5448,10 @@ snapshots: '@types/verror@1.10.11': optional: true + '@types/ws@8.18.1': + dependencies: + '@types/node': 22.19.20 + '@types/yauzl@2.10.3': dependencies: '@types/node': 22.19.20 @@ -6421,34 +6459,34 @@ snapshots: '@esbuild/win32-ia32': 0.25.12 '@esbuild/win32-x64': 0.25.12 - esbuild@0.28.0: + esbuild@0.28.1: optionalDependencies: - '@esbuild/aix-ppc64': 0.28.0 - '@esbuild/android-arm': 0.28.0 - '@esbuild/android-arm64': 0.28.0 - '@esbuild/android-x64': 0.28.0 - '@esbuild/darwin-arm64': 0.28.0 - '@esbuild/darwin-x64': 0.28.0 - '@esbuild/freebsd-arm64': 0.28.0 - '@esbuild/freebsd-x64': 0.28.0 - '@esbuild/linux-arm': 0.28.0 - '@esbuild/linux-arm64': 0.28.0 - '@esbuild/linux-ia32': 0.28.0 - '@esbuild/linux-loong64': 0.28.0 - '@esbuild/linux-mips64el': 0.28.0 - '@esbuild/linux-ppc64': 0.28.0 - '@esbuild/linux-riscv64': 0.28.0 - '@esbuild/linux-s390x': 0.28.0 - '@esbuild/linux-x64': 0.28.0 - '@esbuild/netbsd-arm64': 0.28.0 - '@esbuild/netbsd-x64': 0.28.0 - '@esbuild/openbsd-arm64': 0.28.0 - '@esbuild/openbsd-x64': 0.28.0 - '@esbuild/openharmony-arm64': 0.28.0 - '@esbuild/sunos-x64': 0.28.0 - '@esbuild/win32-arm64': 0.28.0 - '@esbuild/win32-ia32': 0.28.0 - '@esbuild/win32-x64': 0.28.0 + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 escalade@3.2.0: {} @@ -7871,7 +7909,7 @@ snapshots: tsx@4.22.4: dependencies: - esbuild: 0.28.0 + esbuild: 0.28.1 optionalDependencies: fsevents: 2.3.3 @@ -8080,6 +8118,8 @@ snapshots: wrappy@1.0.2: {} + ws@8.21.0: {} + xmlbuilder@15.1.1: {} xmlchars@2.2.0: {}