From b914f0947ac8de3b8dd654423452a30f834fc1ec Mon Sep 17 00:00:00 2001 From: eric Date: Tue, 24 Mar 2026 13:16:18 -0500 Subject: [PATCH] '' --- .gitignore | 2 + config/wechat_chatbot.example.json | 10 + electron/package.json | 7 +- electron/pnpm-lock.yaml | 8 + electron/src/main/clientDb.ts | 226 ++++++++++++++ electron/src/main/index.ts | 68 ++++ electron/src/preload/index.ts | 8 + electron/src/renderer/src/App.css | 149 +++++++++ electron/src/renderer/src/App.tsx | 62 ++-- .../src/renderer/src/NetworkDashboard.tsx | 183 ++++++----- electron/src/renderer/src/ProxySettings.tsx | 10 +- electron/src/renderer/src/RemoteBinding.tsx | 52 +--- electron/src/renderer/src/SystemMetrics.tsx | 11 +- electron/src/renderer/src/WeChatChatbot.tsx | 284 +++++++++++++++++ .../src/renderer/src/WorkspaceDashboard.tsx | 226 ++++++++++++++ electron/src/renderer/src/vite-env.d.ts | 12 + electron/src/renderer/src/workspace.ts | 40 +++ integrations/openclaw-wechat/README.md | 20 ++ skills/wechat-chatbot/SKILL.md | 39 +++ web2.py | 2 + web2_wechat_chatbot.py | 292 ++++++++++++++++++ wechat_chatbot_cli.py | 141 +++++++++ 22 files changed, 1697 insertions(+), 155 deletions(-) create mode 100644 config/wechat_chatbot.example.json create mode 100644 electron/src/main/clientDb.ts create mode 100644 electron/src/renderer/src/WeChatChatbot.tsx create mode 100644 electron/src/renderer/src/WorkspaceDashboard.tsx create mode 100644 electron/src/renderer/src/workspace.ts create mode 100644 integrations/openclaw-wechat/README.md create mode 100644 skills/wechat-chatbot/SKILL.md create mode 100644 web2_wechat_chatbot.py create mode 100644 wechat_chatbot_cli.py diff --git a/.gitignore b/.gitignore index fc68b19..0b7997c 100644 --- a/.gitignore +++ b/.gitignore @@ -172,6 +172,8 @@ config/youtube_oauth_token.json config/web2_api_token.txt config/web2_bind_host.txt config/easytier_network.json +config/wechat_chatbot.json +config/wechat_chatbot.state.json electron/resources/easytier/easytier-core.exe electron/resources/easytier/_extract_tmp/ diff --git a/config/wechat_chatbot.example.json b/config/wechat_chatbot.example.json new file mode 100644 index 0000000..120fb25 --- /dev/null +++ b/config/wechat_chatbot.example.json @@ -0,0 +1,10 @@ +{ + "apiKey": "wc_live_xxxxxxxx", + "proxyUrl": "http://your-proxy-server:3000", + "deviceType": "ipad", + "proxy": "10", + "accountId": "default", + "webhookHost": "", + "webhookPort": 18790, + "webhookPath": "/webhook/wechat" +} diff --git a/electron/package.json b/electron/package.json index 84ef031..fdc5ca9 100644 --- a/electron/package.json +++ b/electron/package.json @@ -15,7 +15,8 @@ "dependencies": { "qrcode": "^1.5.4", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "sql.js": "^1.14.1" }, "devDependencies": { "@types/node": "^22.10.0", @@ -52,7 +53,9 @@ { "from": "resources/easytier", "to": "easytier", - "filter": ["**/*"] + "filter": [ + "**/*" + ] }, { "from": "..", diff --git a/electron/pnpm-lock.yaml b/electron/pnpm-lock.yaml index 7b23575..b64bd71 100644 --- a/electron/pnpm-lock.yaml +++ b/electron/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) + sql.js: + specifier: ^1.14.1 + version: 1.14.1 devDependencies: '@types/node': specifier: ^22.10.0 @@ -1793,6 +1796,9 @@ packages: sprintf-js@1.1.3: resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + sql.js@1.14.1: + resolution: {integrity: sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==} + ssri@9.0.1: resolution: {integrity: sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} @@ -3920,6 +3926,8 @@ snapshots: sprintf-js@1.1.3: optional: true + sql.js@1.14.1: {} + ssri@9.0.1: dependencies: minipass: 3.3.6 diff --git a/electron/src/main/clientDb.ts b/electron/src/main/clientDb.ts new file mode 100644 index 0000000..8837d7b --- /dev/null +++ b/electron/src/main/clientDb.ts @@ -0,0 +1,226 @@ +/** + * 客户端本地 SQLite(sql.js):直播事件、用户操作、运行日志、数据快照。 + * 数据库文件:userData/client-data.sqlite + */ +import { createRequire } from 'module' +import fs from 'fs' +import path from 'path' +import { app } from 'electron' +import initSqlJs, { type Database } from 'sql.js' + +let db: Database | null = null +let persistTimer: ReturnType | null = null + +export function getDbPath(): string { + return path.join(app.getPath('userData'), 'client-data.sqlite') +} + +function schedulePersist(): void { + if (persistTimer) clearTimeout(persistTimer) + persistTimer = setTimeout(() => { + persistTimer = null + flushClientDb() + }, 400) +} + +export function flushClientDb(): void { + if (!db) return + try { + const buf = Buffer.from(db.export()) + fs.mkdirSync(path.dirname(getDbPath()), { recursive: true }) + fs.writeFileSync(getDbPath(), buf) + } catch { + /* ignore */ + } +} + +function runMigrations(): void { + if (!db) return + db.run(` + CREATE TABLE IF NOT EXISTS live_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + process TEXT, + event_type TEXT NOT NULL, + status TEXT, + message TEXT, + detail_json TEXT + ); + CREATE TABLE IF NOT EXISTS user_actions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + action TEXT NOT NULL, + nav TEXT, + detail_json TEXT + ); + CREATE TABLE IF NOT EXISTS client_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + level TEXT NOT NULL, + source TEXT, + message TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS data_snapshots ( + key TEXT PRIMARY KEY, + json TEXT NOT NULL, + ts INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_live_ts ON live_events(ts DESC); + CREATE INDEX IF NOT EXISTS idx_user_ts ON user_actions(ts DESC); + CREATE INDEX IF NOT EXISTS idx_client_ts ON client_logs(ts DESC); + `) +} + +export async function initClientDb(): Promise { + const require = createRequire(import.meta.url) + // sql.js exports "." and "./dist/*" only — not package.json + const wasmPath = require.resolve('sql.js/dist/sql-wasm.wasm') + const SQL = await initSqlJs({ locateFile: () => wasmPath }) + const dbPath = getDbPath() + if (fs.existsSync(dbPath)) { + const fileBuf = fs.readFileSync(dbPath) + db = new SQL.Database(fileBuf) + } else { + db = new SQL.Database() + } + runMigrations() + schedulePersist() +} + +export function appendLiveEvent(payload: { + eventType: string + process?: string + status?: string + message?: string + detail?: unknown +}): void { + if (!db) return + const ts = Date.now() + const detailJson = payload.detail != null ? JSON.stringify(payload.detail) : null + db.run( + `INSERT INTO live_events (ts, process, event_type, status, message, detail_json) VALUES (?, ?, ?, ?, ?, ?)`, + [ + ts, + payload.process ?? null, + payload.eventType, + payload.status ?? null, + payload.message ?? null, + detailJson, + ], + ) + schedulePersist() +} + +export function appendUserAction(payload: { action: string; nav?: string; detail?: unknown }): void { + if (!db) return + const ts = Date.now() + const detailJson = payload.detail != null ? JSON.stringify(payload.detail) : null + db.run(`INSERT INTO user_actions (ts, action, nav, detail_json) VALUES (?, ?, ?, ?)`, [ + ts, + payload.action, + payload.nav ?? null, + detailJson, + ]) + schedulePersist() +} + +export function appendClientLog(level: string, source: string, message: string): void { + if (!db) return + const ts = Date.now() + db.run(`INSERT INTO client_logs (ts, level, source, message) VALUES (?, ?, ?, ?)`, [ + ts, + level, + source, + message, + ]) + schedulePersist() +} + +export function saveDataSnapshot(key: string, json: unknown): void { + if (!db) return + const ts = Date.now() + const text = JSON.stringify(json) + db.run(`INSERT OR REPLACE INTO data_snapshots (key, json, ts) VALUES (?, ?, ?)`, [key, text, ts]) + schedulePersist() +} + +export type DashboardRow = { + id: number + ts: number + process?: string | null + event_type?: string + status?: string | null + message?: string | null + detail_json?: string | null + action?: string + nav?: string | null + level?: string + source?: string | null +} + +function execToRows(sql: string): DashboardRow[] { + if (!db) return [] + try { + const res = db.exec(sql) + if (!res.length) return [] + const { columns, values } = res[0] + return values.map((row) => { + const o: Record = {} + columns.forEach((c, i) => { + o[c] = row[i] + }) + return o as DashboardRow + }) + } catch { + return [] + } +} + +export function getDashboardSnapshot(): { + liveEvents: DashboardRow[] + userActions: DashboardRow[] + clientLogs: DashboardRow[] + snapshots: Record +} { + const empty = { + liveEvents: [] as DashboardRow[], + userActions: [] as DashboardRow[], + clientLogs: [] as DashboardRow[], + snapshots: {} as Record, + } + if (!db) return empty + + try { + empty.liveEvents = execToRows( + `SELECT id, ts, process, event_type, status, message, detail_json FROM live_events ORDER BY id DESC LIMIT 80`, + ) + empty.userActions = execToRows( + `SELECT id, ts, action, nav, detail_json FROM user_actions ORDER BY id DESC LIMIT 80`, + ) + empty.clientLogs = execToRows( + `SELECT id, ts, level, source, message FROM client_logs ORDER BY id DESC LIMIT 120`, + ) + + const snapRes = db.exec(`SELECT key, json, ts FROM data_snapshots`) + if (snapRes.length) { + const { columns, values } = snapRes[0] + for (const row of values) { + const o: Record = {} + columns.forEach((c, i) => { + o[c] = row[i] + }) + const key = String(o.key ?? '') + const json = String(o.json ?? '') + const ts = Number(o.ts ?? 0) + try { + empty.snapshots[key] = { ts, data: JSON.parse(json) as unknown } + } catch { + empty.snapshots[key] = { ts, data: json } + } + } + } + } catch { + /* ignore */ + } + return empty +} diff --git a/electron/src/main/index.ts b/electron/src/main/index.ts index faba286..74f4c1d 100644 --- a/electron/src/main/index.ts +++ b/electron/src/main/index.ts @@ -15,6 +15,15 @@ import { startBundledEasyTier, stopBundledEasyTier, } from './easytier' +import { + appendClientLog, + appendLiveEvent, + appendUserAction, + flushClientDb, + getDashboardSnapshot, + initClientDb, + saveDataSnapshot, +} from './clientDb' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) @@ -371,6 +380,9 @@ async function createWindow(): Promise { }, }) mainWindow.once('ready-to-show', () => mainWindow?.show()) + mainWindow.webContents.once('did-finish-load', () => { + appendClientLog('info', 'main', '渲染界面已加载') + }) const devUrl = process.env.ELECTRON_RENDERER_URL if (devUrl) { @@ -432,6 +444,58 @@ if (!gotLock) { }) app.whenReady().then(async () => { + try { + await initClientDb() + appendClientLog('info', 'main', '客户端主进程就绪') + } catch (e) { + console.error('[clientDb]', e) + } + + ipcMain.removeHandler('workspace:log-live') + ipcMain.handle('workspace:log-live', (_evt, payload: unknown) => { + if (!payload || typeof payload !== 'object') return + const p = payload as Record + const eventType = typeof p.eventType === 'string' ? p.eventType : 'unknown' + appendLiveEvent({ + eventType, + process: typeof p.process === 'string' ? p.process : undefined, + status: typeof p.status === 'string' ? p.status : undefined, + message: typeof p.message === 'string' ? p.message : undefined, + detail: p.detail, + }) + }) + + ipcMain.removeHandler('workspace:log-user') + ipcMain.handle('workspace:log-user', (_evt, payload: unknown) => { + if (!payload || typeof payload !== 'object') return + const p = payload as Record + const action = typeof p.action === 'string' ? p.action : 'unknown' + appendUserAction({ + action, + nav: typeof p.nav === 'string' ? p.nav : undefined, + detail: p.detail, + }) + }) + + ipcMain.removeHandler('workspace:log-client') + ipcMain.handle('workspace:log-client', (_evt, payload: unknown) => { + if (!payload || typeof payload !== 'object') return + const p = payload as Record + const level = typeof p.level === 'string' ? p.level : 'info' + const source = typeof p.source === 'string' ? p.source : 'renderer' + const message = typeof p.message === 'string' ? p.message : '' + if (message) appendClientLog(level, source, message) + }) + + ipcMain.removeHandler('workspace:save-snapshot') + ipcMain.handle('workspace:save-snapshot', (_evt, key: unknown, data: unknown) => { + if (typeof key !== 'string' || !key.trim()) return + saveDataSnapshot(key.trim(), data) + }) + + ipcMain.removeHandler('workspace:get-dashboard') + ipcMain.handle('workspace:get-dashboard', () => getDashboardSnapshot()) + ipcMain.removeHandler('open-external') ipcMain.handle('open-external', async (_evt, url: unknown) => { if (typeof url !== 'string' || !/^https?:\/\//i.test(url)) { @@ -547,6 +611,8 @@ if (!gotLock) { startBundledEasyTier(backendRoot) + appendClientLog('info', 'main', '本地服务已连接,准备打开界面') + await createWindow() app.on('activate', () => { @@ -561,6 +627,8 @@ if (!gotLock) { }) app.on('before-quit', () => { + appendClientLog('info', 'main', '应用退出') + flushClientDb() stopBundledEasyTier() killPythonTree() }) diff --git a/electron/src/preload/index.ts b/electron/src/preload/index.ts index a5b66bd..1ac571a 100644 --- a/electron/src/preload/index.ts +++ b/electron/src/preload/index.ts @@ -16,3 +16,11 @@ contextBridge.exposeInMainWorld('remoteBinding', { generateToken: () => ipcRenderer.invoke('remote:generate-token'), getBindPayload: (selectedIp: string) => ipcRenderer.invoke('remote:get-bind-payload', selectedIp), }) + +contextBridge.exposeInMainWorld('workspace', { + logLiveEvent: (payload: unknown) => ipcRenderer.invoke('workspace:log-live', payload), + logUserAction: (payload: unknown) => ipcRenderer.invoke('workspace:log-user', payload), + logClient: (payload: unknown) => ipcRenderer.invoke('workspace:log-client', payload), + saveSnapshot: (key: string, data: unknown) => ipcRenderer.invoke('workspace:save-snapshot', key, data), + getDashboard: () => ipcRenderer.invoke('workspace:get-dashboard'), +}) diff --git a/electron/src/renderer/src/App.css b/electron/src/renderer/src/App.css index 51024b1..bd2dddd 100644 --- a/electron/src/renderer/src/App.css +++ b/electron/src/renderer/src/App.css @@ -1185,6 +1185,150 @@ body.ready { gap: 1rem; } +.app-panel--workspace { + display: flex; + flex-direction: column; + gap: 0; +} + +.card--workspace { + border-color: rgba(110, 168, 254, 0.25); + background: linear-gradient(165deg, rgba(110, 168, 254, 0.06) 0%, var(--bg-card) 100%); +} + +.ws-head { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.75rem; +} + +.ws-cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 0.75rem; + margin-bottom: 1rem; +} + +.ws-card { + padding: 0.75rem 1rem; + border-radius: 10px; + background: rgba(0, 0, 0, 0.22); + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.ws-card__title { + font-size: 13px; + font-weight: 600; + margin: 0 0 0.35rem; + color: #e8eaed; +} + +.ws-card__meta { + margin: 0 0 0.5rem; + font-size: 11px; + color: var(--text-muted, #8b929e); +} + +.ws-card__empty { + margin: 0; + font-size: 12px; + color: var(--text-muted, #8b929e); +} + +.ws-snap-grid { + display: grid; + gap: 0.5rem; + font-size: 12px; +} + +.ws-snap-k { + display: block; + color: var(--text-muted, #8b929e); + font-size: 11px; +} + +.ws-snap-v { + font-weight: 600; + margin-right: 0.35rem; +} + +.ws-snap-meta { + color: #b8c0cc; + font-size: 11px; +} + +.ws-section { + margin-top: 1rem; +} + +.ws-section__title { + margin: 0 0 0.5rem; + font-size: 14px; + font-weight: 600; + color: #e8eaed; +} + +.ws-table-wrap { + overflow: auto; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.06); + background: rgba(0, 0, 0, 0.2); +} + +.ws-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.ws-table th, +.ws-table td { + padding: 0.45rem 0.6rem; + text-align: left; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.ws-table th { + color: var(--text-muted, #8b929e); + font-weight: 500; + white-space: nowrap; +} + +.ws-table tr:last-child td { + border-bottom: none; +} + +.ws-mono { + font-family: ui-monospace, monospace; + font-size: 11px; +} + +.ws-cell-clip { + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ws-empty { + margin: 0; + padding: 0.75rem; + font-size: 12px; + color: var(--text-muted, #8b929e); +} + +.ws-pre { + margin: 0; + font-size: 11px; + line-height: 1.4; + max-height: 160px; + overflow: auto; + color: #b8c0cc; +} + .card--remote { border-color: rgba(100, 149, 237, 0.28); background: linear-gradient(165deg, rgba(100, 149, 237, 0.07) 0%, var(--bg-card) 100%); @@ -1254,6 +1398,11 @@ body.ready { margin-bottom: 0.75rem; } +.wc-proxy-param { + width: 7rem; + max-width: 100%; +} + .remote-toggle-label { display: flex; align-items: center; diff --git a/electron/src/renderer/src/App.tsx b/electron/src/renderer/src/App.tsx index ad441a0..1208ca5 100644 --- a/electron/src/renderer/src/App.tsx +++ b/electron/src/renderer/src/App.tsx @@ -4,18 +4,23 @@ import NetworkDashboard from './NetworkDashboard' import RemoteBinding from './RemoteBinding' import ProxySettings from './ProxySettings' import SystemMetrics from './SystemMetrics' +import WeChatChatbot from './WeChatChatbot' +import WorkspaceDashboard from './WorkspaceDashboard' +import { logLiveEvent, logUserAction } from './workspace' import { UI_DROPDOWN_YOUTUBE_ONLY, UI_SHOW_YOUTUBE_GOOGLE_OAUTH } from './constants' import { isObsScript, isTiktokScript, isYoutubeScript, pm2NameFromScript } from './scriptUtils' type Entry = { script: string; label: string; pm2: string } -type NavId = 'record' | 'network' | 'proxy' | 'system' +type NavId = 'desk' | 'record' | 'network' | 'proxy' | 'system' | 'wechat' const NAV_ITEMS: { id: NavId; label: string; hint: string }[] = [ + { id: 'desk', label: '工作台', hint: '事件与综合仪表盘' }, { id: 'record', label: '录制', hint: '任务与推流控制' }, { id: 'network', label: '网络', hint: '连通与测速' }, { id: 'proxy', label: '代理', hint: '仅录制进程' }, { id: 'system', label: '系统', hint: '负载与网速' }, + { id: 'wechat', label: '微信', hint: 'Chatbot 与扫码' }, ] type StatusVariant = @@ -45,13 +50,12 @@ export default function App() { const [entries, setEntries] = useState([]) const [currentProcess, setCurrentProcess] = useState('') const [statusMeta, setStatusMeta] = useState<{ line: string; variant: StatusVariant }>({ - line: '请稍候…', + line: '—', variant: 'loading', }) - const [logText, setLogText] = useState('正在读取运行信息…') + const [logText, setLogText] = useState('') const [bootError, setBootError] = useState(null) const [emptyHint, setEmptyHint] = useState(null) - const [initialLoad, setInitialLoad] = useState(true) const [youtubeText, setYoutubeText] = useState('') const [urlText, setUrlText] = useState('') @@ -66,6 +70,7 @@ export default function App() { const [fetchKeyLoading, setFetchKeyLoading] = useState(false) const [nav, setNav] = useState('record') + const lastLiveStatusRef = useRef<{ process: string; status: string } | null>(null) const logRef = useRef(null) @@ -107,7 +112,6 @@ export default function App() { const loadConfig = useCallback( async (endpoint: string, setContent: (s: string) => void, setSt: (s: string) => void) => { - setSt('正在读取…') try { const res = await apiFetch(`${apiUrl(endpoint)}?process=${encodeURIComponent(currentProcess)}`) if (!res.ok) throw new Error(`连接失败 ${res.status}`) @@ -139,7 +143,7 @@ export default function App() { }, []) const startYoutubeOAuth = useCallback(async () => { - setYoutubeStatus('正在获取授权地址…') + setYoutubeStatus('') try { const res = await apiFetch(apiUrl('/youtube/oauth/start')) const data = (await res.json()) as { url?: string; error?: string } @@ -183,7 +187,6 @@ export default function App() { setSt: (s: string) => void, endLoading: () => void, ) => { - setSt('正在保存…') try { const res = await apiFetch(`${apiUrl(endpoint)}?process=${encodeURIComponent(currentProcess)}`, { method: 'POST', @@ -213,6 +216,11 @@ export default function App() { if (action === 'status') { const ps = data.process_status as string + const prev = lastLiveStatusRef.current + if (!prev || prev.process !== currentProcess || prev.status !== ps) { + lastLiveStatusRef.current = { process: currentProcess, status: ps } + logLiveEvent({ eventType: 'process_status', process: currentProcess, status: ps }) + } const { line, variant } = statusPresentation(ps) setStatusMeta({ line, variant }) @@ -249,7 +257,13 @@ export default function App() { .join('\n') || '已完成' const actionLabel = action === 'start' ? '开始' : action === 'stop' ? '停止' : action === 'restart' ? '重新开始' : action - setLogText(`已对「${who}」执行:${actionLabel}\n\n${out}\n\n正在刷新状态…`) + logLiveEvent({ + eventType: 'control', + process: currentProcess, + message: `${actionLabel} 已执行`, + detail: { action }, + }) + setLogText(`已对「${who}」执行:${actionLabel}\n\n${out}`) setTimeout(() => void runPm2Action('status'), 1500) } } catch (err) { @@ -266,13 +280,16 @@ export default function App() { document.body.classList.add('ready') }, []) + useEffect(() => { + logUserAction('navigate', nav) + }, [nav]) + useEffect(() => { let cancelled = false ;(async () => { try { const filtered = await loadProcessMonitor() if (cancelled) return - setInitialLoad(false) if (!filtered.length) { setEmptyHint( UI_DROPDOWN_YOUTUBE_ONLY @@ -285,7 +302,6 @@ export default function App() { setEmptyHint(null) setCurrentProcess(filtered[0].pm2) } catch (e) { - setInitialLoad(false) setBootError( `软件内部的录制服务没有启动成功。\n\n若您使用的是完整安装包,请尝试重启电脑后再打开;仍不行请联系技术支持。\n\n(技术信息:${e instanceof Error ? e.message : String(e)})`, ) @@ -405,6 +421,11 @@ export default function App() {
+ {nav === 'desk' && ( +
+ +
+ )} {nav === 'network' && (
@@ -421,6 +442,11 @@ export default function App() {
)} + {nav === 'wechat' && ( +
+ +
+ )} {nav === 'record' && (
@@ -433,7 +459,7 @@ export default function App() { id="processSelect" className="select-process" aria-label="选择要管理的录制任务" - disabled={initialLoad || !!emptyHint} + disabled={!!emptyHint} value={currentProcess} onChange={(e) => onProcessChange(e.target.value)} > @@ -446,15 +472,7 @@ export default function App() {
- {initialLoad && ( -
-
-
-
-
- )} - - {!initialLoad && ent && ( + {ent && (
当前任务 {ent.label} @@ -472,7 +490,7 @@ export default function App() {
)} - {!emptyHint && !initialLoad && ( + {!emptyHint && (
{isYoutube && (
@@ -504,7 +522,7 @@ export default function App() { title={!ytOauth?.connected ? '请先完成 Google 授权' : ''} onClick={() => void fetchYoutubeStreamKey()} > - {fetchKeyLoading ? '拉取中…' : '拉取串流密钥并写入配置'} + 拉取串流密钥并写入配置
)} diff --git a/electron/src/renderer/src/NetworkDashboard.tsx b/electron/src/renderer/src/NetworkDashboard.tsx index 4f578bd..80debbf 100644 --- a/electron/src/renderer/src/NetworkDashboard.tsx +++ b/electron/src/renderer/src/NetworkDashboard.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState } from 'react' import { apiFetch, apiUrl } from './api' +import { saveWorkspaceSnapshot } from './workspace' type SiteInfo = { host: string @@ -31,6 +32,37 @@ type NetworkPayload = { error?: string } +const EMPTY_SITE: SiteInfo = { + host: '', + reachable: false, + latency_ms: -1, + error: null, + resolved_ip: null, + country: null, + country_code: null, + geo_error: null, +} + +const EMPTY_SPEED: SpeedBlock = { + label: '', + download_mbps: null, + download_error: null, + download_bytes_sampled: 0, + upload_mbps: null, + upload_error: null, + upload_target: null, +} + +const DEFAULT_NETWORK: NetworkPayload = { + google: EMPTY_SITE, + douyin: EMPTY_SITE, + routes: { different: null, note: '' }, + speed: { + overseas: EMPTY_SPEED, + domestic: EMPTY_SPEED, + }, +} + function fmtMbps(v: number | null | undefined): string { if (v == null || Number.isNaN(v)) return '—' return `${v.toFixed(2)} Mbps` @@ -48,7 +80,9 @@ function SiteRow({ title, site }: { title: string; site: SiteInfo }) {
延迟 - {site.latency_ms != null ? `${site.latency_ms.toFixed(0)} ms` : '—'} + + {site.latency_ms != null && site.latency_ms >= 0 ? `${site.latency_ms.toFixed(0)} ms` : '—'} + 解析 IP {site.resolved_ip ?? '—'} 地区(参考) @@ -68,11 +102,9 @@ function SiteRow({ title, site }: { title: string; site: SiteInfo }) { export default function NetworkDashboard() { const [data, setData] = useState(null) - const [loading, setLoading] = useState(true) const [err, setErr] = useState(null) const load = useCallback(async () => { - setLoading(true) setErr(null) try { const res = await apiFetch(apiUrl('/network_status')) @@ -83,11 +115,10 @@ export default function NetworkDashboard() { return } setData(json) + saveWorkspaceSnapshot('network_status', json) } catch (e) { setErr(e instanceof Error ? e.message : String(e)) setData(null) - } finally { - setLoading(false) } }, []) @@ -100,6 +131,8 @@ export default function NetworkDashboard() { return () => clearInterval(id) }, [load]) + const view = data ?? DEFAULT_NETWORK + return (
@@ -112,8 +145,8 @@ export default function NetworkDashboard() { 下行 / 256KB 上行)。

-
@@ -123,82 +156,78 @@ export default function NetworkDashboard() {

)} - {loading && !data && !err &&
正在检测网络…
} + <> +
+ + +
- {data && ( - <> -
- - -
+
+ 线路是否像「两条线」 + + {view.routes.different === true + ? '是(解析国家不同)' + : view.routes.different === false + ? '否或不确定' + : '未知'} + +

{view.routes.note}

+
-
- 线路是否像「两条线」 - - {data.routes.different === true - ? '是(解析国家不同)' - : data.routes.different === false - ? '否或不确定' - : '未知'} - -

{data.routes.note}

-
- -
-

网速(估算)

-
-
-
海外
-
- 下行 - {fmtMbps(data.speed.overseas.download_mbps)} -
-
- 上行 - {fmtMbps(data.speed.overseas.upload_mbps)} -
- {(data.speed.overseas.download_error || data.speed.overseas.upload_error) && ( -

- {[data.speed.overseas.download_error, data.speed.overseas.upload_error] - .filter(Boolean) - .join(' · ')} -

- )} +
+

网速(估算)

+
+
+
海外
+
+ 下行 + {fmtMbps(view.speed.overseas.download_mbps)}
-
-
国内
-
- 下行 - {fmtMbps(data.speed.domestic.download_mbps)} -
-
- 上行 - {fmtMbps(data.speed.domestic.upload_mbps)} -
- {(data.speed.domestic.download_error || data.speed.domestic.upload_error) && ( -

- {[data.speed.domestic.download_error, data.speed.domestic.upload_error] - .filter(Boolean) - .join(' · ')} -

- )} +
+ 上行 + {fmtMbps(view.speed.overseas.upload_mbps)}
+ {(view.speed.overseas.download_error || view.speed.overseas.upload_error) && ( +

+ {[view.speed.overseas.download_error, view.speed.overseas.upload_error] + .filter(Boolean) + .join(' · ')} +

+ )} +
+
+
国内
+
+ 下行 + {fmtMbps(view.speed.domestic.download_mbps)} +
+
+ 上行 + {fmtMbps(view.speed.domestic.upload_mbps)} +
+ {(view.speed.domestic.download_error || view.speed.domestic.upload_error) && ( +

+ {[view.speed.domestic.download_error, view.speed.domestic.upload_error] + .filter(Boolean) + .join(' · ')} +

+ )}
-

- 上行若失败多为网站不接受测试流量,不代表您无法上传直播;海外项走 Google / httpbin,国内项走抖音首页流与门户 - POST。 -

- - )} +

+ 上行若失败多为网站不接受测试流量,不代表您无法上传直播;海外项走 Google / httpbin,国内项走抖音首页流与门户 + POST。 +

+
+ ) } diff --git a/electron/src/renderer/src/ProxySettings.tsx b/electron/src/renderer/src/ProxySettings.tsx index c53152b..efbf10d 100644 --- a/electron/src/renderer/src/ProxySettings.tsx +++ b/electron/src/renderer/src/ProxySettings.tsx @@ -5,12 +5,10 @@ type ProxyCfg = { enabled: boolean; http: string; socks: string } export default function ProxySettings() { const [cfg, setCfg] = useState({ enabled: false, http: '', socks: '' }) - const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [msg, setMsg] = useState('') const load = useCallback(async () => { - setLoading(true) setMsg('') try { const res = await apiFetch(apiUrl('/proxy_config')) @@ -23,8 +21,6 @@ export default function ProxySettings() { }) } catch (e) { setMsg(e instanceof Error ? e.message : String(e)) - } finally { - setLoading(false) } }, []) @@ -60,10 +56,7 @@ export default function ProxySettings() { 系统代理。若你使用 Clash Verge 等,请在本机开启「混合端口」后,把地址填成与之一致(常见为 7890)。 YouTube RTMP 推流若仍失败,可能需要代理软件开启 TUN 或自行调整规则。

- {loading ? ( -

正在读取配置…

- ) : ( -
+
- )} ) } diff --git a/electron/src/renderer/src/RemoteBinding.tsx b/electron/src/renderer/src/RemoteBinding.tsx index 9343d6f..ca2f185 100644 --- a/electron/src/renderer/src/RemoteBinding.tsx +++ b/electron/src/renderer/src/RemoteBinding.tsx @@ -29,7 +29,6 @@ export default function RemoteBinding() { : undefined) as RemoteBindingApi | undefined const [status, setStatus] = useState(null) - const [loading, setLoading] = useState(true) const [busy, setBusy] = useState(false) const [msg, setMsg] = useState('') const [selectedIp, setSelectedIp] = useState('') @@ -45,11 +44,9 @@ export default function RemoteBinding() { const load = useCallback(async () => { if (!rb?.getStatus) { - setLoading(false) setMsg('仅 Electron 桌面端支持此面板;网页调试请直接配置 API。') return } - setLoading(true) try { const s = await rb.getStatus() setStatus(s) @@ -57,8 +54,6 @@ export default function RemoteBinding() { } catch (e) { setStatus(null) setMsg(e instanceof Error ? e.message : String(e)) - } finally { - setLoading(false) } }, [rb]) @@ -164,15 +159,6 @@ export default function RemoteBinding() { } } - if (loading) { - return ( -
-

远程控制与扫码绑定

-

加载中…

-
- ) - } - if (!rb?.getStatus) { return (
@@ -182,19 +168,7 @@ export default function RemoteBinding() { ) } - if (!status) { - return ( -
-

远程控制与扫码绑定

-

- {msg || '无法加载状态'} -

- -
- ) - } + const s = status return (
@@ -211,21 +185,21 @@ export default function RemoteBinding() {

- - {status.easytierBundled ? '已内置 easytier-core' : '未找到内置程序(开发环境请执行 pnpm run prepare-easytier)'} + + {s?.easytierBundled ? '已内置 easytier-core' : '未找到内置程序(开发环境请执行 pnpm run prepare-easytier)'} - {status.easytierBundled && ( - - {status.easytierRunning ? 'EasyTier 进程运行中' : '进程未运行或已退出'} + {s?.easytierBundled && ( + + {s.easytierRunning ? 'EasyTier 进程运行中' : '进程未运行或已退出'} )} - {!!status.easytierNetworkName && ( - 组网名:{status.easytierNetworkName} + {!!s?.easytierNetworkName && ( + 组网名:{s.easytierNetworkName} )}
- {status.easytierBundled && status.easytierLastError && ( + {s?.easytierBundled && s.easytierLastError && (

- EasyTier:{status.easytierLastError} + EasyTier:{s.easytierLastError}

)} @@ -305,11 +279,11 @@ export default function RemoteBinding() { 供 React Native 扫码;需已开启「允许远程」并填写可访问的 IP。
- {!status.remoteEnabled && ( + {!s?.remoteEnabled && (

勾选「允许远程」并生效后,虚拟网内手机可访问本机 API 并扫码绑定。

)} - {status.remoteEnabled && bindPayload && qrImg && ( + {s?.remoteEnabled && bindPayload && qrImg && (

React Native 扫码解析 JSON:含 baseUrltoken、 @@ -329,7 +303,7 @@ export default function RemoteBinding() {

)} - {status.remoteEnabled && !bindPayload && ( + {s?.remoteEnabled && !bindPayload && (

尚未生成绑定数据:在下拉中选局域网 IP,或在「手动 IP」填写后点「生成绑定二维码」;亦可点「刷新 IP 列表」更新网卡地址。

diff --git a/electron/src/renderer/src/SystemMetrics.tsx b/electron/src/renderer/src/SystemMetrics.tsx index 79c428d..8b5bdbc 100644 --- a/electron/src/renderer/src/SystemMetrics.tsx +++ b/electron/src/renderer/src/SystemMetrics.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState } from 'react' import { apiFetch, apiUrl } from './api' +import { saveWorkspaceSnapshot } from './workspace' type Metrics = { ts?: number @@ -40,6 +41,7 @@ export default function SystemMetrics() { } setErr(null) setData(json) + saveWorkspaceSnapshot('system_metrics', json) } catch (e) { setErr(e instanceof Error ? e.message : String(e)) } @@ -68,15 +70,13 @@ export default function SystemMetrics() { {err}

)} - {!data && !err &&

正在读取…

} - {data && ( -
+
CPU
- {data.cpu_percent != null ? `${data.cpu_percent.toFixed(1)}%` : '—'} + {data?.cpu_percent != null ? `${data.cpu_percent.toFixed(1)}%` : '—'}
-
逻辑处理器 {data.cpu_count_logical ?? '—'} 个
+
逻辑处理器 {data?.cpu_count_logical ?? '—'} 个
内存
@@ -94,7 +94,6 @@ export default function SystemMetrics() {
含所有网卡收发合计
- )} {data?.swap && data.swap.total != null && data.swap.total > 0 && (
交换分区 {data.swap.percent != null ? `${data.swap.percent.toFixed(0)}%` : '—'} ·{' '} diff --git a/electron/src/renderer/src/WeChatChatbot.tsx b/electron/src/renderer/src/WeChatChatbot.tsx new file mode 100644 index 0000000..1165624 --- /dev/null +++ b/electron/src/renderer/src/WeChatChatbot.tsx @@ -0,0 +1,284 @@ +import { useCallback, useEffect, useState } from 'react' +import { apiFetch, apiUrl } from './api' + +type WechatConfigView = { + configured: boolean + proxyUrl: string + apiKeyMasked: string + deviceType: string + proxy: string + webhookHost: string + webhookPort: number + webhookPath: string + accountId: string + session: { wcId?: string; nickName?: string } +} + +export default function WeChatChatbot() { + const [cfg, setCfg] = useState(null) + const [msg, setMsg] = useState('') + const [busy, setBusy] = useState(false) + + const [apiKeyInput, setApiKeyInput] = useState('') + const [proxyUrlInput, setProxyUrlInput] = useState('') + const [deviceType, setDeviceType] = useState('ipad') + const [proxyLine, setProxyLine] = useState('10') + + const [wId, setWId] = useState('') + const [qrUrl, setQrUrl] = useState('') + const [poll, setPoll] = useState(false) + const [proxyStatus, setProxyStatus] = useState | null>(null) + + const load = useCallback(async () => { + setMsg('') + try { + const r = await apiFetch(apiUrl('/chatbot/wechat/config')) + if (!r.ok) throw new Error(String(r.status)) + const j = (await r.json()) as WechatConfigView + setCfg(j) + setProxyUrlInput(j.proxyUrl || '') + setDeviceType(j.deviceType || 'ipad') + setProxyLine(j.proxy || '10') + setApiKeyInput('') + } catch (e) { + setCfg(null) + setMsg(e instanceof Error ? e.message : String(e)) + } + }, []) + + useEffect(() => { + void load() + }, [load]) + + const saveConfig = async () => { + setBusy(true) + setMsg('') + try { + const body: Record = { + proxyUrl: proxyUrlInput.trim(), + deviceType, + proxy: proxyLine.trim(), + } + if (apiKeyInput.trim()) body.apiKey = apiKeyInput.trim() + const r = await apiFetch(apiUrl('/chatbot/wechat/config'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + if (!r.ok) { + const t = await r.text() + throw new Error(t || String(r.status)) + } + await load() + } catch (e) { + setMsg(e instanceof Error ? e.message : String(e)) + } finally { + setBusy(false) + } + } + + const loadProxyStatus = async () => { + setBusy(true) + setMsg('') + try { + const r = await apiFetch(apiUrl('/chatbot/wechat/proxy/status')) + const j = await r.json() + if (!r.ok) { + setMsg(typeof j.error === 'string' ? j.error : JSON.stringify(j)) + setProxyStatus(null) + return + } + setProxyStatus(j as Record) + } catch (e) { + setMsg(e instanceof Error ? e.message : String(e)) + } finally { + setBusy(false) + } + } + + const startQr = async () => { + setBusy(true) + setMsg('') + setQrUrl('') + setWId('') + try { + const r = await apiFetch(apiUrl('/chatbot/wechat/qr/start'), { method: 'POST' }) + const j = (await r.json()) as { wId?: string; qrCodeUrl?: string; error?: string } + if (!r.ok) { + setMsg(j.error || String(r.status)) + return + } + if (!j.wId || !j.qrCodeUrl) { + setMsg('未返回二维码') + return + } + setWId(j.wId) + setQrUrl(j.qrCodeUrl) + setPoll(true) + } catch (e) { + setMsg(e instanceof Error ? e.message : String(e)) + } finally { + setBusy(false) + } + } + + useEffect(() => { + if (!poll || !wId) return + const tick = async () => { + try { + const r = await apiFetch(apiUrl('/chatbot/wechat/login/check'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ wId }), + }) + const j = (await r.json()) as { + status?: string + wcId?: string + nickName?: string + verifyUrl?: string + error?: string + } + if (!r.ok) { + setMsg(j.error || String(r.status)) + setPoll(false) + return + } + if (j.status === 'logged_in') { + setPoll(false) + setMsg(`已登录:${j.nickName || ''}(${j.wcId || ''})`) + void load() + return + } + if (j.status === 'need_verify' && j.verifyUrl) { + setMsg(`需要辅助验证:${j.verifyUrl}`) + } + } catch (e) { + setMsg(e instanceof Error ? e.message : String(e)) + setPoll(false) + } + } + const id = window.setInterval(() => void tick(), 5000) + void tick() + return () => window.clearInterval(id) + }, [poll, wId, load]) + + return ( +
+

+ 微信 Chatbot +

+

+ 与 openclaw-wechat 使用同一类代理 API(需自备 apiKey 与 proxyUrl)。保存配置后可在下方获取二维码并用微信扫码登录;亦可用{' '} + wechat_chatbot_cli.py 供脚本与 Agent 调用。 +

+ +
+ + setProxyUrlInput(e.target.value)} + disabled={busy} + autoComplete="off" + placeholder="http://your-proxy:3000" + /> +
+
+ + setApiKeyInput(e.target.value)} + disabled={busy} + autoComplete="off" + placeholder={cfg?.apiKeyMasked ? `已保存 ${cfg.apiKeyMasked}` : 'wc_live_…'} + /> +
+
+ + + + setProxyLine(e.target.value)} + disabled={busy} + /> +
+ +
+ + + +
+ + {proxyStatus && ( +
+          {JSON.stringify(proxyStatus, null, 2)}
+        
+ )} + + {cfg?.session?.wcId && ( +

+ 本地记录:{cfg.session.nickName || '—'}({cfg.session.wcId}) +

+ )} + +
+ + {!cfg?.configured && ( + 请先填写并保存 apiKey 与 proxyUrl(参考 config/wechat_chatbot.example.json)。 + )} +
+ + {qrUrl && ( +
+

+ 请使用微信扫描下方二维码。若图片无法显示,请在新标签页打开二维码链接。 +

+ 微信登录二维码 +

+ + 打开二维码链接 + +

+
+ )} + + {msg && ( +

+ {msg} +

+ )} +
+ ) +} diff --git a/electron/src/renderer/src/WorkspaceDashboard.tsx b/electron/src/renderer/src/WorkspaceDashboard.tsx new file mode 100644 index 0000000..e21599b --- /dev/null +++ b/electron/src/renderer/src/WorkspaceDashboard.tsx @@ -0,0 +1,226 @@ +import { useCallback, useEffect, useState } from 'react' +import type { WorkspaceDbSnapshot } from './workspace' +import { getWorkspace } from './workspace' + +function fmtTs(ts: unknown): string { + if (typeof ts !== 'number') return '—' + try { + return new Date(ts).toLocaleString() + } catch { + return '—' + } +} + +function SnapshotSummary({ data }: { data: unknown }) { + if (!data || typeof data !== 'object') return
+ const d = data as Record + if ('google' in d && 'douyin' in d) { + const g = d.google as { reachable?: boolean; latency_ms?: number; country?: string } + const y = d.douyin as { reachable?: boolean; latency_ms?: number; country?: string } + return ( +
+
+ Google + {g?.reachable ? '可达' : '不可达'} + + {g?.latency_ms != null && g.latency_ms >= 0 ? `${g.latency_ms.toFixed(0)} ms` : '—'} ·{' '} + {g?.country ?? '—'} + +
+
+ 抖音 + {y?.reachable ? '可达' : '不可达'} + + {y?.latency_ms != null && y.latency_ms >= 0 ? `${y.latency_ms.toFixed(0)} ms` : '—'} ·{' '} + {y?.country ?? '—'} + +
+
+ ) + } + if ('cpu_percent' in d || 'memory' in d) { + const m = d.memory as { percent?: number } | undefined + return ( +
+
+ CPU + + {typeof d.cpu_percent === 'number' ? `${d.cpu_percent.toFixed(1)}%` : '—'} + +
+
+ 内存 + {m?.percent != null ? `${m.percent.toFixed(1)}%` : '—'} +
+
+ ) + } + return ( +
{JSON.stringify(data, null, 2).slice(0, 1200)}
+ ) +} + +export default function WorkspaceDashboard() { + const ws = getWorkspace() + const [data, setData] = useState(null) + const [err, setErr] = useState('') + + const load = useCallback(async () => { + if (!ws?.getDashboard) { + setErr('工作台存储仅在桌面客户端可用。') + return + } + setErr('') + try { + const d = (await ws.getDashboard()) as WorkspaceDbSnapshot + setData(d) + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)) + } + }, [ws]) + + useEffect(() => { + void load() + const id = window.setInterval(() => void load(), 12000) + return () => window.clearInterval(id) + }, [load]) + + const net = data?.snapshots?.network_status + const sys = data?.snapshots?.system_metrics + + return ( +
+
+
+

+ 工作台 +

+

+ 本地 SQLite(用户数据目录 client-data.sqlite)汇总:直播事件、操作记录、客户端日志,以及最近一次网络 / 系统快照。 +

+
+ +
+ + {err && ( +

+ {err} +

+ )} + +
+
+
网络快照
+ {net ? ( + <> +

更新 {fmtTs(net.ts)}

+ + + ) : ( +

暂无(打开「网络」页后会自动写入)

+ )} +
+
+
本机负载快照
+ {sys ? ( + <> +

更新 {fmtTs(sys.ts)}

+ + + ) : ( +

暂无(打开「系统」页后会自动写入)

+ )} +
+
+ +
+

直播与任务事件

+
+ + + + + + + + + + + + {(data?.liveEvents?.length ? data.liveEvents : []).map((row) => ( + + + + + + + + ))} + +
时间进程类型状态说明
{fmtTs(row.ts)}{String(row.process ?? '—')}{String(row.event_type ?? '—')}{String(row.status ?? '—')} + {String(row.message ?? '—')} +
+ {!data?.liveEvents?.length &&

暂无记录

} +
+
+ +
+

用户操作

+
+ + + + + + + + + + + {(data?.userActions?.length ? data.userActions : []).map((row) => ( + + + + + + + ))} + +
时间操作页面详情
{fmtTs(row.ts)}{String(row.action ?? '—')}{String(row.nav ?? '—')} + {row.detail_json ? String(row.detail_json).slice(0, 120) : '—'} +
+ {!data?.userActions?.length &&

暂无记录

} +
+
+ +
+

客户端运行日志

+
+ + + + + + + + + + + {(data?.clientLogs?.length ? data.clientLogs : []).map((row) => ( + + + + + + + ))} + +
时间级别来源内容
{fmtTs(row.ts)}{String(row.level ?? '—')}{String(row.source ?? '—')}{String(row.message ?? '—')}
+ {!data?.clientLogs?.length &&

暂无记录

} +
+
+
+ ) +} diff --git a/electron/src/renderer/src/vite-env.d.ts b/electron/src/renderer/src/vite-env.d.ts index 3309233..ea569aa 100644 --- a/electron/src/renderer/src/vite-env.d.ts +++ b/electron/src/renderer/src/vite-env.d.ts @@ -34,4 +34,16 @@ interface Window { generateToken: () => Promise<{ ok: true } | { ok: false; error: string }> getBindPayload: (selectedIp: string) => Promise | null> } + workspace?: { + logLiveEvent: (payload: unknown) => Promise + logUserAction: (payload: unknown) => Promise + logClient: (payload: unknown) => Promise + saveSnapshot: (key: string, data: unknown) => Promise + getDashboard: () => Promise<{ + liveEvents: Array> + userActions: Array> + clientLogs: Array> + snapshots: Record + }> + } } diff --git a/electron/src/renderer/src/workspace.ts b/electron/src/renderer/src/workspace.ts new file mode 100644 index 0000000..556c4da --- /dev/null +++ b/electron/src/renderer/src/workspace.ts @@ -0,0 +1,40 @@ +/** 主进程 SQLite 工作台 API(仅 Electron preload 注入时可用) */ + +export type WorkspaceDbSnapshot = { + liveEvents: Array> + userActions: Array> + clientLogs: Array> + snapshots: Record +} + +export function getWorkspace() { + return typeof window !== 'undefined' + ? (window as Window & { workspace?: WorkspaceApi }).workspace + : undefined +} + +type WorkspaceApi = { + logLiveEvent: (payload: unknown) => Promise + logUserAction: (payload: unknown) => Promise + logClient: (payload: unknown) => Promise + saveSnapshot: (key: string, data: unknown) => Promise + getDashboard: () => Promise +} + +export function saveWorkspaceSnapshot(key: string, data: unknown): void { + void getWorkspace()?.saveSnapshot(key, data) +} + +export function logUserAction(action: string, nav?: string, detail?: unknown): void { + void getWorkspace()?.logUserAction({ action, nav, detail }) +} + +export function logLiveEvent(payload: { + eventType: string + process?: string + status?: string + message?: string + detail?: unknown +}): void { + void getWorkspace()?.logLiveEvent(payload) +} diff --git a/integrations/openclaw-wechat/README.md b/integrations/openclaw-wechat/README.md new file mode 100644 index 0000000..f395c38 --- /dev/null +++ b/integrations/openclaw-wechat/README.md @@ -0,0 +1,20 @@ +# openclaw-wechat(上游 OpenClaw 插件) + +本目录用于**引用**开源仓库 [freestylefly/openclaw-wechat](https://github.com/freestylefly/openclaw-wechat):在已安装 [OpenClaw](https://github.com/openclaw/openclaw) 的环境下,作为 **OpenClaw 通道插件**使用微信代理 API,与网关 `openclaw gateway start` 配合(终端扫码登录等)。 + +与本仓库 **web2 内置能力**的关系: + +- **Python 桥接**(`web2_wechat_chatbot.py`)直接调用与插件相同的**代理服务 HTTP API**(`/v1/account/status`、`/v1/iPadLogin` 等),无需运行 OpenClaw 即可在「直播录制助手」里配置密钥、拉取二维码、轮询登录。 +- 若你已在 OpenClaw 中使用该插件,只需将 **同一套 `apiKey` + `proxyUrl`** 写入 `config/wechat_chatbot.json`,两端行为一致。 + +## 添加子模块(可选) + +```bash +git submodule add https://github.com/freestylefly/openclaw-wechat.git integrations/openclaw-wechat/repo +``` + +随后可在 OpenClaw 侧按上游 README 安装:`openclaw plugins install @canghe/openclaw-wechat`(以 [上游说明](https://github.com/freestylefly/openclaw-wechat) 为准)。 + +## 许可 + +上游为 MIT;使用微信代理服务须遵守其条款,且勿用于违法用途(见上游仓库声明)。 diff --git a/skills/wechat-chatbot/SKILL.md b/skills/wechat-chatbot/SKILL.md new file mode 100644 index 0000000..f172b2f --- /dev/null +++ b/skills/wechat-chatbot/SKILL.md @@ -0,0 +1,39 @@ +--- +name: wechat-chatbot +description: 通过本仓库 web2 的 /chatbot/wechat API 配置微信代理 Chatbot、扫码登录与发消息(与 openclaw-wechat 代理协议一致)。 +--- + +# 微信 Chatbot(HTTP API) + +后端基址默认 `http://127.0.0.1:8001`(或 `WEB2_API_BASE`)。若启用 `WEB2_API_TOKEN`,请求需带 `Authorization: Bearer `。 + +## 配置 + +- 复制 `config/wechat_chatbot.example.json` 为 `config/wechat_chatbot.json`,填写 `apiKey`、`proxyUrl`(由代理服务商提供,与 [openclaw-wechat](https://github.com/freestylefly/openclaw-wechat) 一致)。 +- 或通过 API 写入:`POST /chatbot/wechat/config`,JSON 字段见示例文件。 + +## 常用端点 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/chatbot/wechat/config` | 查看配置(apiKey 脱敏)与本地会话摘要 | +| POST | `/chatbot/wechat/config` | 保存配置 | +| GET | `/chatbot/wechat/proxy/status` | 代理账号状态 | +| POST | `/chatbot/wechat/qr/start` | 获取登录二维码 URL 与 `wId` | +| POST | `/chatbot/wechat/login/check` | Body:`{"wId":"..."}`,轮询直至 `logged_in` | +| POST | `/chatbot/wechat/send` | Body:`{"targetId":"wxid或群id","text":"..."}` | +| POST | `/chatbot/wechat/webhook/register` | Body:`{"webhookUrl":"https://..."}` 供云端收消息 | + +## CLI + +```bash +python wechat_chatbot_cli.py config-get --json +python wechat_chatbot_cli.py qr-start --json +python wechat_chatbot_cli.py login-check --w-id --json +python wechat_chatbot_cli.py send --target --text "hi" --json +``` + +## 与 CLI-Anything / Agent 的协作方式 + +- 优先使用 **`--json`** 或解析 HTTP JSON,便于与 [CLI-Anything](https://github.com/HKUDS/CLI-Anything) 类工具链衔接。 +- 扫码流程:先 `qr/start` 展示 `qrCodeUrl`,每约 5 秒调用 `login/check` 直至成功或超时。 diff --git a/web2.py b/web2.py index 82464c9..c48fa78 100644 --- a/web2.py +++ b/web2.py @@ -25,6 +25,7 @@ from web2_youtube_oauth import ( oauth_exchange_code, oauth_status as youtube_oauth_status_snapshot, ) +from web2_wechat_chatbot import register_wechat_chatbot_routes app = FastAPI(title="多进程录制控制台") @@ -184,6 +185,7 @@ app.add_middleware( allow_headers=["*"], ) +register_wechat_chatbot_routes(app, BASE_DIR) # ---------------- 工具函数 ---------------- async def read_log_path(path: str, lines: int) -> str: diff --git a/web2_wechat_chatbot.py b/web2_wechat_chatbot.py new file mode 100644 index 0000000..a602dc4 --- /dev/null +++ b/web2_wechat_chatbot.py @@ -0,0 +1,292 @@ +""" +微信 Chatbot 桥接:与 openclaw-wechat 使用的代理 HTTP API 对齐(见 integrations/openclaw-wechat)。 +需自备 apiKey 与 proxyUrl(第三方代理服务),本模块提供扫码登录、状态查询与发消息等 REST,供 Web / CLI / Agent 调用。 +""" +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Optional + +import httpx +from fastapi import Request +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field + +CONFIG_NAME = "wechat_chatbot.json" +STATE_NAME = "wechat_chatbot.state.json" + + +def _config_path(base: Path) -> Path: + return base / "config" / CONFIG_NAME + + +def _state_path(base: Path) -> Path: + return base / "config" / STATE_NAME + + +def load_wechat_chatbot_config(base_dir: Path) -> dict[str, Any]: + p = _config_path(base_dir) + if not p.exists(): + return {} + try: + return json.loads(p.read_text(encoding="utf-8")) + except Exception: + return {} + + +def load_wechat_state(base_dir: Path) -> dict[str, Any]: + p = _state_path(base_dir) + if not p.exists(): + return {} + try: + return json.loads(p.read_text(encoding="utf-8")) + except Exception: + return {} + + +def save_wechat_state(base_dir: Path, state: dict[str, Any]) -> None: + p = _state_path(base_dir) + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8") + + +def mask_api_key(key: str) -> str: + k = (key or "").strip() + if len(k) <= 8: + return "****" if k else "" + return f"{k[:4]}…{k[-4:]}" + + +class WechatProxyClient: + """与 freestylefly/openclaw-wechat 中 ProxyClient 请求格式一致。""" + + def __init__( + self, + api_key: str, + base_url: str, + account_id: str = "default", + timeout: float = 60.0, + ): + if not api_key or not api_key.strip(): + raise ValueError("缺少 apiKey") + if not base_url or not base_url.strip(): + raise ValueError("缺少 proxyUrl") + self.api_key = api_key.strip() + self.base_url = base_url.strip().rstrip("/") + self.account_id = account_id.strip() or "default" + self.timeout = timeout + + def _request(self, endpoint: str, data: Optional[dict[str, Any]] = None) -> Any: + url = f"{self.base_url}{endpoint}" + headers = { + "Content-Type": "application/json", + "X-API-Key": self.api_key, + "X-Account-ID": self.account_id, + } + with httpx.Client(timeout=self.timeout) as client: + r = client.post(url, headers=headers, json=data) + try: + result = r.json() + except Exception: + raise RuntimeError(f"HTTP {r.status_code}: {r.text[:500]}") from None + + if not r.is_success: + raise RuntimeError(result.get("error") or result.get("message") or f"HTTP {r.status_code}") + + code = result.get("code") + if code in ("1000", "1001", "1002"): + d = result.get("data") + return d if isinstance(d, (dict, list)) else result + if code and str(code) != "1000": + raise RuntimeError(result.get("message") or str(result)) + d = result.get("data") + return d if d is not None else result + + def get_status(self) -> dict[str, Any]: + out = self._request("/v1/account/status") + if not isinstance(out, dict): + return {"raw": out} + return { + "valid": out.get("valid", True), + "wcId": out.get("wcId"), + "isLoggedIn": out.get("isLoggedIn", False), + "nickName": out.get("nickName"), + "tier": out.get("tier"), + "quota": out.get("quota"), + "error": out.get("error"), + } + + def get_qr_code(self, device_type: str = "ipad", proxy: str = "10") -> dict[str, str]: + out = self._request( + "/v1/iPadLogin", + {"deviceType": device_type or "ipad", "proxy": proxy or "10"}, + ) + if not isinstance(out, dict): + raise RuntimeError("代理返回格式异常") + w_id = out.get("wId") or out.get("w_id") + qr_url = out.get("qrCodeUrl") or out.get("qr_code_url") + if not w_id or not qr_url: + raise RuntimeError("代理未返回二维码信息") + return {"wId": str(w_id), "qrCodeUrl": str(qr_url)} + + def check_login(self, w_id: str) -> dict[str, Any]: + out = self._request("/v1/getIPadLoginInfo", {"wId": w_id}) + if not isinstance(out, dict): + return {"status": "waiting"} + st = out.get("status") + if st == "logged_in": + return { + "status": "logged_in", + "wcId": out.get("wcId"), + "nickName": out.get("nickName"), + "headUrl": out.get("headUrl"), + } + if st == "need_verify": + return {"status": "need_verify", "verifyUrl": out.get("verifyUrl")} + return {"status": "waiting"} + + def send_text(self, target_id: str, content: str) -> dict[str, Any]: + out = self._request("/v1/sendText", {"wcId": target_id, "content": content}) + return out if isinstance(out, dict) else {"result": out} + + def register_webhook(self, webhook_url: str) -> None: + self._request("/v1/webhook/register", {"webhookUrl": webhook_url}) + + +def build_client(cfg: dict[str, Any]) -> WechatProxyClient: + return WechatProxyClient( + api_key=str(cfg.get("apiKey") or ""), + base_url=str(cfg.get("proxyUrl") or ""), + account_id=str(cfg.get("accountId") or "default"), + ) + + +class WechatConfigBody(BaseModel): + apiKey: Optional[str] = None + proxyUrl: Optional[str] = None + deviceType: Optional[str] = None + proxy: Optional[str] = None + webhookHost: Optional[str] = None + webhookPort: Optional[int] = None + webhookPath: Optional[str] = None + accountId: Optional[str] = None + + +class WechatSendBody(BaseModel): + targetId: str = Field(..., description="接收方 wxid 或群 id") + text: str = Field(..., description="文本内容") + + +class WechatWebhookBody(BaseModel): + webhookUrl: str + + +def register_wechat_chatbot_routes(app, base_dir: Path) -> None: + """在 FastAPI app 上注册 /chatbot/wechat/* 路由。""" + + @app.get("/chatbot/wechat/config") + async def wechat_config_get(): + cfg = load_wechat_chatbot_config(base_dir) + st = load_wechat_state(base_dir) + key = str(cfg.get("apiKey") or "") + return { + "configured": bool(cfg.get("proxyUrl") and key), + "proxyUrl": cfg.get("proxyUrl") or "", + "apiKeyMasked": mask_api_key(key), + "deviceType": cfg.get("deviceType") or "ipad", + "proxy": cfg.get("proxy") or "10", + "webhookHost": cfg.get("webhookHost") or "", + "webhookPort": int(cfg.get("webhookPort") or 18790), + "webhookPath": cfg.get("webhookPath") or "/webhook/wechat", + "accountId": cfg.get("accountId") or "default", + "session": { + "wcId": st.get("wcId"), + "nickName": st.get("nickName"), + }, + } + + @app.post("/chatbot/wechat/config") + async def wechat_config_post(body: WechatConfigBody): + cfg = load_wechat_chatbot_config(base_dir) + d = body.model_dump(exclude_none=True) + for k, v in d.items(): + cfg[k] = v + out_path = _config_path(base_dir) + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8") + return {"ok": True} + + @app.get("/chatbot/wechat/proxy/status") + async def wechat_proxy_status(): + cfg = load_wechat_chatbot_config(base_dir) + if not cfg.get("apiKey") or not cfg.get("proxyUrl"): + return JSONResponse({"error": "未配置 apiKey 或 proxyUrl"}, status_code=400) + try: + c = build_client(cfg) + return c.get_status() + except Exception as e: + return JSONResponse({"error": str(e)}, status_code=502) + + @app.post("/chatbot/wechat/qr/start") + async def wechat_qr_start(): + cfg = load_wechat_chatbot_config(base_dir) + if not cfg.get("apiKey") or not cfg.get("proxyUrl"): + return JSONResponse({"error": "未配置 apiKey 或 proxyUrl"}, status_code=400) + try: + c = build_client(cfg) + dt = str(cfg.get("deviceType") or "ipad") + px = str(cfg.get("proxy") or "10") + return c.get_qr_code(device_type=dt, proxy=px) + except Exception as e: + return JSONResponse({"error": str(e)}, status_code=502) + + @app.post("/chatbot/wechat/login/check") + async def wechat_login_check(request: Request): + try: + body = await request.json() + except Exception: + return JSONResponse({"error": "需要 JSON body: { \"wId\": \"...\" }"}, status_code=400) + w_id = (body or {}).get("wId") or (body or {}).get("w_id") + if not w_id or not isinstance(w_id, str): + return JSONResponse({"error": "缺少 wId"}, status_code=400) + cfg = load_wechat_chatbot_config(base_dir) + if not cfg.get("apiKey") or not cfg.get("proxyUrl"): + return JSONResponse({"error": "未配置 apiKey 或 proxyUrl"}, status_code=400) + try: + c = build_client(cfg) + r = c.check_login(w_id.strip()) + if r.get("status") == "logged_in" and r.get("wcId"): + save_wechat_state( + base_dir, + { + "wcId": r.get("wcId"), + "nickName": r.get("nickName"), + }, + ) + return r + except Exception as e: + return JSONResponse({"error": str(e)}, status_code=502) + + @app.post("/chatbot/wechat/send") + async def wechat_send(body: WechatSendBody): + cfg = load_wechat_chatbot_config(base_dir) + if not cfg.get("apiKey") or not cfg.get("proxyUrl"): + return JSONResponse({"error": "未配置 apiKey 或 proxyUrl"}, status_code=400) + try: + c = build_client(cfg) + return c.send_text(body.targetId.strip(), body.text) + except Exception as e: + return JSONResponse({"error": str(e)}, status_code=502) + + @app.post("/chatbot/wechat/webhook/register") + async def wechat_webhook_reg(body: WechatWebhookBody): + cfg = load_wechat_chatbot_config(base_dir) + if not cfg.get("apiKey") or not cfg.get("proxyUrl"): + return JSONResponse({"error": "未配置 apiKey 或 proxyUrl"}, status_code=400) + try: + c = build_client(cfg) + c.register_webhook(body.webhookUrl.strip()) + return {"ok": True} + except Exception as e: + return JSONResponse({"error": str(e)}, status_code=502) diff --git a/wechat_chatbot_cli.py b/wechat_chatbot_cli.py new file mode 100644 index 0000000..3181aea --- /dev/null +++ b/wechat_chatbot_cli.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +""" +微信 Chatbot HTTP API 的命令行客户端(供脚本与 Agent 调用)。 +环境变量:WEB2_API_BASE(默认 http://127.0.0.1:8001)、WEB2_API_TOKEN(若后端启用鉴权)。 + +示例: + python wechat_chatbot_cli.py config-get --json + python wechat_chatbot_cli.py proxy-status --json + python wechat_chatbot_cli.py qr-start --json + python wechat_chatbot_cli.py login-check --w-id --json + python wechat_chatbot_cli.py send --target wxid_xxx --text "hello" --json +""" +from __future__ import annotations + +import argparse +import json +import os +import sys + +import httpx + + +def base_url() -> str: + return os.environ.get("WEB2_API_BASE", "http://127.0.0.1:8001").rstrip("/") + + +def headers() -> dict[str, str]: + h = {"Content-Type": "application/json"} + tok = os.environ.get("WEB2_API_TOKEN", "").strip() + if tok: + h["Authorization"] = f"Bearer {tok}" + return h + + +def out(obj: object, as_json: bool) -> None: + if as_json: + print(json.dumps(obj, ensure_ascii=False, indent=2)) + else: + print(obj) + + +def main() -> None: + p = argparse.ArgumentParser(description="微信 Chatbot API CLI") + p.add_argument("--json", action="store_true", help="输出 JSON") + sub = p.add_subparsers(dest="cmd", required=True) + + sub.add_parser("config-get", help="GET /chatbot/wechat/config") + + pc = sub.add_parser("config-set", help="POST /chatbot/wechat/config(从 stdin 读 JSON 或文件)") + pc.add_argument("--file", "-f", help="JSON 文件路径") + + sub.add_parser("proxy-status", help="GET /chatbot/wechat/proxy/status") + + sub.add_parser("qr-start", help="POST /chatbot/wechat/qr/start") + + pl = sub.add_parser("login-check", help="POST /chatbot/wechat/login/check") + pl.add_argument("--w-id", required=True, dest="w_id") + + ps = sub.add_parser("send", help="POST /chatbot/wechat/send") + ps.add_argument("--target", required=True, help="对方 wxid 或群 id") + ps.add_argument("--text", required=True) + + pw = sub.add_parser("webhook-register", help="POST /chatbot/wechat/webhook/register") + pw.add_argument("--url", required=True, help="公网可访问的 webhook 完整 URL") + + args = p.parse_args() + bu = base_url() + h = headers() + + try: + with httpx.Client(timeout=120.0) as client: + if args.cmd == "config-get": + r = client.get(f"{bu}/chatbot/wechat/config", headers=h) + r.raise_for_status() + out(r.json(), args.json) + elif args.cmd == "config-set": + raw = sys.stdin.read() + if getattr(args, "file", None): + raw = open(args.file, encoding="utf-8").read() + body = json.loads(raw) + r = client.post(f"{bu}/chatbot/wechat/config", headers=h, json=body) + r.raise_for_status() + out(r.json(), args.json) + elif args.cmd == "proxy-status": + r = client.get(f"{bu}/chatbot/wechat/proxy/status", headers=h) + try: + out(r.json(), args.json) + except Exception: + print(r.text, file=sys.stderr) + sys.exit(1) + elif args.cmd == "qr-start": + r = client.post(f"{bu}/chatbot/wechat/qr/start", headers=h) + try: + out(r.json(), args.json) + except Exception: + print(r.text, file=sys.stderr) + sys.exit(1) + elif args.cmd == "login-check": + r = client.post( + f"{bu}/chatbot/wechat/login/check", + headers=h, + json={"wId": args.w_id}, + ) + try: + out(r.json(), args.json) + except Exception: + print(r.text, file=sys.stderr) + sys.exit(1) + elif args.cmd == "send": + r = client.post( + f"{bu}/chatbot/wechat/send", + headers=h, + json={"targetId": args.target, "text": args.text}, + ) + try: + out(r.json(), args.json) + except Exception: + print(r.text, file=sys.stderr) + sys.exit(1) + elif args.cmd == "webhook-register": + r = client.post( + f"{bu}/chatbot/wechat/webhook/register", + headers=h, + json={"webhookUrl": args.url}, + ) + try: + out(r.json(), args.json) + except Exception: + print(r.text, file=sys.stderr) + sys.exit(1) + except httpx.HTTPStatusError as e: + print(str(e), file=sys.stderr) + try: + print(e.response.text, file=sys.stderr) + except Exception: + pass + sys.exit(1) + + +if __name__ == "__main__": + main()