修改后端接口 outbox 和 信息同步 worker

This commit is contained in:
mark
2026-09-27 14:59:22 +08:00
parent e74535f044
commit 1b15f8bbbb
40 changed files with 17231 additions and 2157 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,7 @@
import { createHotContext as __vite__createHotContext } from "/@vite/client";import.meta.hot = __vite__createHotContext("/components/Sidebar.vue");import { defineComponent as _defineComponent } from "/@fs/D:/dndClaw/agent-console/node_modules/.vite/deps/vue.js?v=4266a811"; import { createHotContext as __vite__createHotContext } from "/@vite/client";import.meta.hot = __vite__createHotContext("/components/Sidebar.vue");import { defineComponent as _defineComponent } from "/@fs/D:/dndClaw/agent-console/node_modules/.vite/deps/vue.js?v=cc68ba0b";
import { computed } from "/@fs/D:/dndClaw/agent-console/node_modules/.vite/deps/vue.js?v=4266a811"; import { computed } from "/@fs/D:/dndClaw/agent-console/node_modules/.vite/deps/vue.js?v=cc68ba0b";
import { useTheme } from "/composables/useTheme.ts"; import { useTheme } from "/composables/useTheme.ts";
import { nextTick, onMounted, onUnmounted, ref } from "/@fs/D:/dndClaw/agent-console/node_modules/.vite/deps/vue.js?v=4266a811"; import { nextTick, onMounted, onUnmounted, ref } from "/@fs/D:/dndClaw/agent-console/node_modules/.vite/deps/vue.js?v=cc68ba0b";
const _sfc_main = /* @__PURE__ */ _defineComponent({ const _sfc_main = /* @__PURE__ */ _defineComponent({
__name: "Sidebar", __name: "Sidebar",
props: { props: {
@@ -54,7 +54,7 @@ const _sfc_main = /* @__PURE__ */ _defineComponent({
return __returned__; return __returned__;
} }
}); });
import { createCommentVNode as _createCommentVNode, createElementVNode as _createElementVNode, normalizeClass as _normalizeClass, renderList as _renderList, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock, toDisplayString as _toDisplayString, vModelText as _vModelText, withModifiers as _withModifiers, withKeys as _withKeys, withDirectives as _withDirectives } from "/@fs/D:/dndClaw/agent-console/node_modules/.vite/deps/vue.js?v=4266a811"; import { createCommentVNode as _createCommentVNode, createElementVNode as _createElementVNode, normalizeClass as _normalizeClass, renderList as _renderList, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock, toDisplayString as _toDisplayString, vModelText as _vModelText, withModifiers as _withModifiers, withKeys as _withKeys, withDirectives as _withDirectives } from "/@fs/D:/dndClaw/agent-console/node_modules/.vite/deps/vue.js?v=cc68ba0b";
const _hoisted_1 = { class: "sidebar" }; const _hoisted_1 = { class: "sidebar" };
const _hoisted_2 = { class: "brand" }; const _hoisted_2 = { class: "brand" };
const _hoisted_3 = ["title"]; const _hoisted_3 = ["title"];
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,3 @@
2026/09/27-14:57:08.137 a0f8 Reusing MANIFEST D:\dndClaw\agent-console\.electron-userdata\Local Storage\leveldb/MANIFEST-000001
2026/09/27-14:57:08.169 a0f8 Recovering log #3
2026/09/27-14:57:08.171 a0f8 Reusing old log D:\dndClaw\agent-console\.electron-userdata\Local Storage\leveldb/000003.log
@@ -1,3 +0,0 @@
2026/09/27-13:14:35.954 a824 Reusing MANIFEST D:\dndClaw\agent-console\.electron-userdata\Local Storage\leveldb/MANIFEST-000001
2026/09/27-13:14:35.960 a824 Recovering log #3
2026/09/27-13:14:35.960 a824 Reusing old log D:\dndClaw\agent-console\.electron-userdata\Local Storage\leveldb/000003.log
@@ -0,0 +1,3 @@
2026/09/27-14:57:08.545 a0f8 Reusing MANIFEST D:\dndClaw\agent-console\.electron-userdata\Session Storage/MANIFEST-000001
2026/09/27-14:57:08.546 a0f8 Recovering log #3
2026/09/27-14:57:08.546 a0f8 Reusing old log D:\dndClaw\agent-console\.electron-userdata\Session Storage/000003.log
@@ -1,2 +0,0 @@
2026/09/27-13:14:36.283 a824 Creating DB D:\dndClaw\agent-console\.electron-userdata\Session Storage since it was missing.
2026/09/27-13:14:36.289 a824 Reusing MANIFEST D:\dndClaw\agent-console\.electron-userdata\Session Storage/MANIFEST-000001
+23
View File
@@ -33,6 +33,29 @@ export function initDatabase(dbDir: string): Database.Database {
); );
CREATE INDEX IF NOT EXISTS idx_llm_active ON llm_configs(is_active); CREATE INDEX IF NOT EXISTS idx_llm_active ON llm_configs(is_active);
-- 同步队列(outbox):本地变更等待推送到云端
CREATE TABLE IF NOT EXISTS sync_outbox (
id TEXT PRIMARY KEY,
table_name TEXT NOT NULL, -- 变更的表名
record_id TEXT NOT NULL, -- 变更记录的主键 ID
operation TEXT NOT NULL, -- INSERT / UPDATE / DELETE
payload TEXT NOT NULL, -- 变更数据 JSON
status TEXT DEFAULT 'pending', -- pending / syncing / done / failed
retry_count INTEGER DEFAULT 0, -- 重试次数
last_error TEXT, -- 最后一次错误信息
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_outbox_status ON sync_outbox(status);
CREATE INDEX IF NOT EXISTS idx_outbox_table ON sync_outbox(table_name, record_id);
-- 同步元数据(如拉取游标)
CREATE TABLE IF NOT EXISTS sync_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`) `)
console.log(`[db] SQLite 已初始化: ${dbPath}`) console.log(`[db] SQLite 已初始化: ${dbPath}`)
+5
View File
@@ -1,5 +1,6 @@
import { initDatabase, closeDb } from './db/database' import { initDatabase, closeDb } from './db/database'
import { startWebSocketServer } from './transport/websocket' import { startWebSocketServer } from './transport/websocket'
import { syncWorker } from './sync/worker'
import { WorkflowEngine } from './workflow/engine' import { WorkflowEngine } from './workflow/engine'
import { ToolRegistry, readFileTool } from './tools/registry' import { ToolRegistry, readFileTool } from './tools/registry'
import type { AgentContext, LLMConfig, StreamCallbacks } from './types' import type { AgentContext, LLMConfig, StreamCallbacks } from './types'
@@ -32,9 +33,13 @@ function main() {
const wss = startWebSocketServer(WS_PORT) const wss = startWebSocketServer(WS_PORT)
console.log(`[core] WebSocket 端口: ${WS_PORT}`) console.log(`[core] WebSocket 端口: ${WS_PORT}`)
// 3. 启动云端同步 Worker(本地变更 → outbox → 云端推送;云端变更 → 拉取合并)
syncWorker.start()
// 优雅退出 // 优雅退出
process.on('SIGINT', () => { process.on('SIGINT', () => {
console.log('\n[core] 正在关闭...') console.log('\n[core] 正在关闭...')
syncWorker.stop()
wss.close() wss.close()
closeDb() closeDb()
process.exit(0) process.exit(0)
+21 -3
View File
@@ -1,5 +1,6 @@
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
import { getDb } from '../db/database' import { getDb } from '../db/database'
import { outbox } from '../sync/outbox'
import type { LLMConfig } from '../types' import type { LLMConfig } from '../types'
/** /**
@@ -38,7 +39,9 @@ export class LLMConfigManager {
SET config = ?, updated_at = datetime('now') SET config = ?, updated_at = datetime('now')
WHERE id = ?` WHERE id = ?`
).run(JSON.stringify(config), existing.id) ).run(JSON.stringify(config), existing.id)
return this.getById(existing.id)! const record = this.getById(existing.id)!
outbox.enqueue('llm_configs', existing.id, 'UPDATE', record)
return record
} }
// 新增 // 新增
@@ -48,7 +51,9 @@ export class LLMConfigManager {
VALUES (?, ?, ?, ?, 0)` VALUES (?, ?, ?, ?, 0)`
).run(id, name, provider, JSON.stringify(config)) ).run(id, name, provider, JSON.stringify(config))
return this.getById(id)! const record = this.getById(id)!
outbox.enqueue('llm_configs', id, 'INSERT', record)
return record
} }
/** /**
@@ -85,12 +90,22 @@ export class LLMConfigManager {
*/ */
setActive(id: string): LLMConfigRecord | null { setActive(id: string): LLMConfigRecord | null {
const db = getDb() const db = getDb()
// 先找出当前激活的配置(需要写入 UPDATE outbox)
const prevActive = db.prepare("SELECT id FROM llm_configs WHERE is_active = 1 AND id != ?").get(id) as { id: string } | undefined
const tx = db.transaction(() => { const tx = db.transaction(() => {
db.prepare('UPDATE llm_configs SET is_active = 0').run() db.prepare('UPDATE llm_configs SET is_active = 0').run()
db.prepare('UPDATE llm_configs SET is_active = 1 WHERE id = ?').run(id) db.prepare('UPDATE llm_configs SET is_active = 1 WHERE id = ?').run(id)
}) })
tx() tx()
return this.getById(id)
const record = this.getById(id)
if (record) outbox.enqueue('llm_configs', id, 'UPDATE', record)
if (prevActive) {
const prevRecord = this.getById(prevActive.id)
if (prevRecord) outbox.enqueue('llm_configs', prevActive.id, 'UPDATE', prevRecord)
}
return record
} }
/** /**
@@ -99,6 +114,9 @@ export class LLMConfigManager {
delete(id: string): boolean { delete(id: string): boolean {
const db = getDb() const db = getDb()
const result = db.prepare('DELETE FROM llm_configs WHERE id = ?').run(id) const result = db.prepare('DELETE FROM llm_configs WHERE id = ?').run(id)
if (result.changes > 0) {
outbox.enqueue('llm_configs', id, 'DELETE', { id })
}
return result.changes > 0 return result.changes > 0
} }
+116
View File
@@ -0,0 +1,116 @@
import type { OutboxRecord } from './outbox'
/**
* 云端同步客户端
*
* 云端地址通过环境变量配置:
* CLOUD_SYNC_URL - 云端同步服务地址(如 http://localhost:9000)
* CLOUD_SYNC_TOKEN - 鉴权 Token(可选)
*
* 端口留空,等用户提供后通过环境变量传入即可。
*/
const CLOUD_URL = process.env.CLOUD_SYNC_URL || ''
const CLOUD_TOKEN = process.env.CLOUD_SYNC_TOKEN || ''
export interface CloudPushResult {
success: boolean
error?: string
}
export interface CloudPullResult {
/** 从云端拉取到的变更记录 */
changes: Array<{
table_name: string
record_id: string
operation: 'INSERT' | 'UPDATE' | 'DELETE'
payload: any
updated_at: string
}>
/** 拉取游标,下次拉取时传入以实现增量同步 */
cursor?: string
}
/**
* 推送一批 outbox 记录到云端
*/
export async function pushToCloud(records: OutboxRecord[]): Promise<Map<string, CloudPushResult>> {
const results = new Map<string, CloudPushResult>()
if (!CLOUD_URL) {
// 云端地址未配置,全部标记失败
for (const r of records) {
results.set(r.id, { success: false, error: 'CLOUD_SYNC_URL 未配置' })
}
return results
}
for (const r of records) {
try {
const res = await fetch(`${CLOUD_URL}/sync/push`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(CLOUD_TOKEN ? { Authorization: `Bearer ${CLOUD_TOKEN}` } : {})
},
body: JSON.stringify({
table: r.table_name,
record_id: r.record_id,
operation: r.operation,
payload: JSON.parse(r.payload),
local_updated_at: r.updated_at
})
})
if (res.ok) {
results.set(r.id, { success: true })
} else {
const data = await res.json().catch(() => ({}))
results.set(r.id, {
success: false,
error: `HTTP ${res.status}: ${data?.message || res.statusText}`
})
}
} catch (e: any) {
results.set(r.id, { success: false, error: e?.message || String(e) })
}
}
return results
}
/**
* 从云端拉取变更记录(增量)
* @param cursor 上次拉取的游标,为空则全量拉取
*/
export async function pullFromCloud(cursor?: string): Promise<CloudPullResult> {
if (!CLOUD_URL) {
return { changes: [] }
}
try {
const url = new URL(`${CLOUD_URL}/sync/pull`)
if (cursor) url.searchParams.set('cursor', cursor)
const res = await fetch(url.toString(), {
method: 'GET',
headers: {
...(CLOUD_TOKEN ? { Authorization: `Bearer ${CLOUD_TOKEN}` } : {})
}
})
if (!res.ok) {
console.error(`[cloud] 拉取失败: HTTP ${res.status}`)
return { changes: [] }
}
const data = await res.json()
return {
changes: data.changes || [],
cursor: data.cursor
}
} catch (e: any) {
console.error(`[cloud] 拉取异常: ${e?.message}`)
return { changes: [] }
}
}
+130
View File
@@ -0,0 +1,130 @@
import { v4 as uuidv4 } from 'uuid'
import { getDb } from '../db/database'
export type SyncOperation = 'INSERT' | 'UPDATE' | 'DELETE'
export type SyncStatus = 'pending' | 'syncing' | 'done' | 'failed'
export interface OutboxRecord {
id: string
table_name: string
record_id: string
operation: SyncOperation
payload: string
status: SyncStatus
retry_count: number
last_error: string | null
created_at: string
updated_at: string
}
/**
* 同步队列(outbox)
* 本地数据变更后立即写入此表,由 SyncWorker 异步推送到云端。
* 用户读数据永远走本地 SQLite,不等待云端同步。
*/
export class Outbox {
/**
* 写入一条变更记录到同步队列
*/
enqueue(tableName: string, recordId: string, operation: SyncOperation, payload: unknown): OutboxRecord {
const db = getDb()
const id = uuidv4()
db.prepare(
`INSERT INTO sync_outbox (id, table_name, record_id, operation, payload, status)
VALUES (?, ?, ?, ?, ?, 'pending')`
).run(id, tableName, recordId, operation, JSON.stringify(payload))
return this.getById(id)!
}
/**
* 获取待同步的记录(pending 状态,按创建时间升序,最多 limit 条)
*/
getPending(limit = 50): OutboxRecord[] {
const db = getDb()
const rows = db
.prepare(
`SELECT * FROM sync_outbox
WHERE status = 'pending'
ORDER BY created_at ASC
LIMIT ?`
)
.all(limit) as any[]
return rows.map((r) => this.mapRow(r))
}
/**
* 标记为同步中
*/
markSyncing(id: string) {
const db = getDb()
db.prepare(
`UPDATE sync_outbox SET status = 'syncing', updated_at = datetime('now') WHERE id = ?`
).run(id)
}
/**
* 标记为已同步
*/
markDone(id: string) {
const db = getDb()
db.prepare(
`UPDATE sync_outbox SET status = 'done', updated_at = datetime('now') WHERE id = ?`
).run(id)
}
/**
* 标记为失败(重试次数 +1,记录错误)
*/
markFailed(id: string, error: string) {
const db = getDb()
db.prepare(
`UPDATE sync_outbox
SET status = 'failed', retry_count = retry_count + 1, last_error = ?, updated_at = datetime('now')
WHERE id = ?`
).run(error, id)
}
/**
* 将失败的记录重新置为 pending(指数退避由 worker 控制调用时机)
*/
resetToPending(id: string) {
const db = getDb()
db.prepare(
`UPDATE sync_outbox SET status = 'pending', updated_at = datetime('now') WHERE id = ?`
).run(id)
}
/**
* 清理已同步的记录(保留最近 N 天)
*/
cleanupDone(keepDays = 7) {
const db = getDb()
db.prepare(
`DELETE FROM sync_outbox WHERE status = 'done' AND created_at < datetime('now', ?)`
).run(`-${keepDays} days`)
}
getById(id: string): OutboxRecord | null {
const db = getDb()
const row = db.prepare('SELECT * FROM sync_outbox WHERE id = ?').get(id) as any
return row ? this.mapRow(row) : null
}
private mapRow(row: any): OutboxRecord {
return {
id: row.id,
table_name: row.table_name,
record_id: row.record_id,
operation: row.operation,
payload: row.payload,
status: row.status,
retry_count: row.retry_count,
last_error: row.last_error,
created_at: row.created_at,
updated_at: row.updated_at
}
}
}
export const outbox = new Outbox()
+191
View File
@@ -0,0 +1,191 @@
import { outbox } from './outbox'
import { pushToCloud, pullFromCloud } from './cloud'
import { getDb } from '../db/database'
/**
* 同步 Worker
*
* 职责:
* 1. 推送:定期扫描 outbox 中 pending 记录 → 推送到云端
* 2. 拉取:定期从云端拉取其他设备的变更 → 合并到本地 SQLite
* 3. 重试:失败记录指数退避重试
*
* 用户读数据永远走本地 SQLite,同步完全异步。
*/
export class SyncWorker {
private pushTimer: NodeJS.Timeout | null = null
private pullTimer: NodeJS.Timeout | null = null
private running = false
// 配置(可通过环境变量调整)
private readonly pushInterval = Number(process.env.SYNC_PUSH_INTERVAL) || 5000 // 推送间隔 5s
private readonly pullInterval = Number(process.env.SYNC_PULL_INTERVAL) || 30000 // 拉取间隔 30s
private readonly maxRetry = Number(process.env.SYNC_MAX_RETRY) || 10 // 最大重试次数
private readonly baseDelay = Number(process.env.SYNC_BASE_DELAY) || 1000 // 退避基数 1s
private readonly batchSize = Number(process.env.SYNC_BATCH_SIZE) || 20 // 每批推送条数
start() {
if (this.running) return
this.running = true
console.log('[sync] Worker 已启动')
console.log(`[sync] 推送间隔: ${this.pushInterval}ms, 拉取间隔: ${this.pullInterval}ms`)
// 启动时立即执行一次推送和拉取
this.pushLoop()
this.pullLoop()
this.pushTimer = setInterval(() => this.pushLoop(), this.pushInterval)
this.pullTimer = setInterval(() => this.pullLoop(), this.pullInterval)
}
stop() {
this.running = false
if (this.pushTimer) clearInterval(this.pushTimer)
if (this.pullTimer) clearInterval(this.pullTimer)
this.pushTimer = null
this.pullTimer = null
console.log('[sync] Worker 已停止')
}
/**
* 推送循环:扫描 outbox → 推送云端 → 更新状态
*/
private async pushLoop() {
if (!this.running) return
try {
const records = outbox.getPending(this.batchSize)
if (records.length === 0) return
console.log(`[sync] 待推送 ${records.length} 条`)
// 先标记为 syncing
for (const r of records) {
outbox.markSyncing(r.id)
}
// 推送到云端
const results = await pushToCloud(records)
// 处理结果
for (const r of records) {
const result = results.get(r.id)
if (result?.success) {
outbox.markDone(r.id)
console.log(`[sync] ✓ 已推送: ${r.table_name}/${r.record_id} (${r.operation})`)
} else {
outbox.markFailed(r.id, result?.error || '未知错误')
console.warn(`[sync] ✗ 推送失败: ${r.table_name}/${r.record_id} - ${result?.error}`)
// 指数退避后重新入队
const retryCount = r.retry_count + 1
if (retryCount < this.maxRetry) {
const delay = Math.min(this.baseDelay * Math.pow(2, retryCount), 60000)
setTimeout(() => outbox.resetToPending(r.id), delay)
} else {
console.error(`[sync] 超过最大重试次数: ${r.table_name}/${r.record_id}`)
}
}
}
// 清理已同步的旧记录
outbox.cleanupDone(7)
} catch (e: any) {
console.error('[sync] 推送循环异常:', e?.message)
}
}
/**
* 拉取循环:从云端拉取变更 → 合并到本地 SQLite
*/
private async pullLoop() {
if (!this.running) return
try {
const cursor = this.getPullCursor()
const result = await pullFromCloud(cursor)
if (result.changes.length === 0) return
console.log(`[sync] 拉取到 ${result.changes.length} 条云端变更`)
for (const change of result.changes) {
this.mergeChange(change)
}
if (result.cursor) {
this.savePullCursor(result.cursor)
}
} catch (e: any) {
console.error('[sync] 拉取循环异常:', e?.message)
}
}
/**
* 合并云端变更到本地 SQLite
* 以 updated_at 为依据:云端版本更新才覆盖本地
*/
private mergeChange(change: {
table_name: string
record_id: string
operation: 'INSERT' | 'UPDATE' | 'DELETE'
payload: any
updated_at: string
}) {
const db = getDb()
// 检查本地记录的更新时间,只合并更新的版本
const local = db
.prepare(`SELECT updated_at FROM ${change.table_name} WHERE id = ?`)
.get(change.record_id) as { updated_at: string } | undefined
if (local && local.updated_at >= change.updated_at) {
// 本地版本更新或相同,跳过
return
}
try {
if (change.operation === 'DELETE') {
db.prepare(`DELETE FROM ${change.table_name} WHERE id = ?`).run(change.record_id)
} else {
// INSERT 或 UPDATE:用 UPSERT
const fields = Object.keys(change.payload)
const placeholders = fields.map(() => '?').join(', ')
const updateSet = fields.map((f) => `${f} = ?`).join(', ')
const values = fields.map((f) =>
typeof change.payload[f] === 'object' ? JSON.stringify(change.payload[f]) : change.payload[f]
)
db.prepare(
`INSERT INTO ${change.table_name} (id, ${fields.join(', ')})
VALUES (?, ${placeholders})
ON CONFLICT(id) DO UPDATE SET ${updateSet}`
).run(change.record_id, ...values, ...values)
}
console.log(`[sync] 已合并云端变更: ${change.table_name}/${change.record_id} (${change.operation})`)
} catch (e: any) {
console.error(`[sync] 合并失败: ${change.table_name}/${change.record_id} - ${e?.message}`)
}
}
/**
* 拉取游标持久化(存在 SQLite 的 sync_meta 表中)
*/
private getPullCursor(): string | undefined {
const db = getDb()
const row = db
.prepare("SELECT value FROM sync_meta WHERE key = 'pull_cursor'")
.get() as { value: string } | undefined
return row?.value
}
private savePullCursor(cursor: string) {
const db = getDb()
db.prepare(
`INSERT INTO sync_meta (key, value) VALUES ('pull_cursor', ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`
).run(cursor)
}
}
export const syncWorker = new SyncWorker()
-62
View File
@@ -1,62 +0,0 @@
// 测试 agent-core WebSocket 通信
import WebSocket from 'ws'
const ws = new WebSocket('ws://localhost:8787')
ws.on('open', () => {
console.log('[测试] 已连接')
// 测试 1: 保存 LLM 配置
ws.send(JSON.stringify({
type: 'llm:save',
data: {
name: '文心千帆测试',
provider: 'qianfan_v2',
config: {
baseUrl: 'https://qianfan.baidubce.com/v2/chat/completions',
authorization: 'Bearer test-token',
model: 'ernie-4.0',
temperature: 0.7
}
},
id: '1'
}))
})
ws.on('message', (data) => {
const msg = JSON.parse(data.toString())
console.log('[收到]', JSON.stringify(msg, null, 2))
// 收到保存响应后,测试获取列表
if (msg.type === 'llm:saved') {
ws.send(JSON.stringify({ type: 'llm:list', id: '2' }))
}
if (msg.type === 'llm:list') {
// 测试设置激活
if (msg.data && msg.data[0]) {
ws.send(JSON.stringify({ type: 'llm:setActive', data: { id: msg.data[0].id }, id: '3' }))
}
}
if (msg.type === 'llm:activeSet') {
// 测试获取激活
ws.send(JSON.stringify({ type: 'llm:getActive', id: '4' }))
}
if (msg.type === 'llm:active') {
console.log('\n=== 所有测试完成 ===')
ws.close()
process.exit(0)
}
})
ws.on('error', (err) => {
console.error('[错误]', err.message)
process.exit(1)
})
setTimeout(() => {
console.log('超时退出')
process.exit(1)
}, 10000)