app · d8d0592

Dev index Recent changes / versions Changelog About TODO

feat(diagnostics): Socket 外部事件訂閱

d8d0592 · 2026-11-10 23:37 · app
.env.exampleDiagnosticsRoutes.cjsDocs/ChangeHistory.mdDocs/DIAGNOSTICS.mdDocs/RUNTIME.mdDocs/TEST_FILES_REFERENCE.mdScriptLib/diagnostics/socket-client.cjsServer.jsTest/diagnostics.test.cjsTest/diagnostics_ui.cjsassets/app.css.build.jsonassets/diagnostics.jsdiagnostics.htmltodo.md
commit d8d059210ce472741f755004bce33ae46198fdfaAuthor: Nuclear0709 <agp05432@gmail.com>Date:   Tue Nov 10 23:37:20 2026 +0800     feat(diagnostics): Socket 外部事件訂閱        - 訂閱 ReplyKit SocketServer 的 external log batch/diagnostic    - 連線目標由 DIAGNOSTICS_SOCKET_HOST/PORT 設定,/diagnostics 只保留開關    - 切換只送 subscribeExternalLogs;已建立連線不重連,取消保留連線    - 修正狀態輪詢重設訂閱 checkbox 的 dirty 狀態    - 補 socket client/API/UI 測試與文件 diff --git a/.env.example b/.env.exampleindex 9fd96be..a54e08e 100644--- a/.env.example+++ b/.env.example@@ -120,6 +120,10 @@ SOCKET_API=http://192.168.0.101:9322 # ReplyKit/外部工具的 HTTP log bridge 可 POST 到 http://<主服務>:3332/post。 # 選填 token;設定後必須用 ?token=... 或 x-diagnostics-token/Bearer。 # DIAGNOSTICS_TOKEN=+# 選填:啟動時自動訂閱 ReplyKit SocketServer 的外部事件(也可在 /diagnostics 頁開關)+# DIAGNOSTICS_SOCKET_ENABLED=0+# DIAGNOSTICS_SOCKET_HOST=192.168.0.242+# DIAGNOSTICS_SOCKET_PORT=9322  # ─── 圖片快取 ──────────────────────────────────────── # TikTok/Twitch 頭像、禮物圖與贊助 overlay 圖片的本地快取 URL 前綴。diff --git a/DiagnosticsRoutes.cjs b/DiagnosticsRoutes.cjsindex e31b088..06c487c 100644--- a/DiagnosticsRoutes.cjs+++ b/DiagnosticsRoutes.cjs@@ -12,9 +12,9 @@ function json(res, status, value) {  * 診斷入口:HTTP LogServer 相容 /post、帶外 diagnostic、以及唯讀診斷頁。  * 資料只存在記憶體,不寫 log 檔或聊天歷史。  * @param {import('./ScriptLib/diagnostics/store.cjs').DiagnosticsStore} store- * @param {{token?: string}} [options]+ * @param {{token?: string, socketClient?: Object, socketDefaults?: {host?: string, port?: number}}} [options]  */-function createDiagnosticsRoutes(store, { token = '' } = {}) {+function createDiagnosticsRoutes(store, { token = '', socketClient = null, socketDefaults = {} } = {}) {     function authorizedIntake(req) {         if (!token) return true;         const header = req.headers['x-diagnostics-token'];@@ -103,7 +103,61 @@ function createDiagnosticsRoutes(store, { token = '' } = {}) {         return true;     } -    return { servePage, serveApi, serveIntake };+    function serveSocket(req, res, authorized = false) {+        const url = new URL(req.url, 'http://localhost');+        if (url.pathname !== '/api/diagnostics/socket') return false;+        if (!authorized) {+            json(res, 401, { ok: false, code: 'AUTH_REQUIRED', error: '請先登入' });+            return true;+        }+        if (!socketClient) {+            json(res, 501, { ok: false, error: 'Socket 訂閱未啟用' });+            return true;+        }+        if (req.method === 'GET') {+            json(res, 200, {+                ok: true,+                ...socketClient.snapshot(),+                defaults: { host: socketDefaults.host || '', port: socketDefaults.port || 0 }+            });+            return true;+        }+        if (req.method !== 'POST') {+            json(res, 405, { ok: false, error: '只支援 GET 或 POST' });+            return true;+        }+        let body = '';+        let tooLarge = false;+        req.on('data', chunk => {+            body += chunk;+            if (Buffer.byteLength(body) > 64 * 1024) tooLarge = true;+        });+        req.on('end', () => {+            if (tooLarge) {+                json(res, 413, { ok: false, error: 'body too large' });+                return;+            }+            try {+                const data = JSON.parse(body);+                if (data.enabled === true) {+                    const host = String(socketDefaults.host || '').trim();+                    const port = Number(socketDefaults.port || 0);+                    if (!socketClient.start(host, port)) {+                        json(res, 400, { ok: false, error: '需要有效的 host 與 port' });+                        return;+                    }+                } else {+                    socketClient.unsubscribe();+                }+                json(res, 200, { ok: true, ...socketClient.snapshot() });+            } catch (error) {+                json(res, 400, { ok: false, error: 'JSON 解析失敗:' + error.message });+            }+        });+        return true;+    }++    return { servePage, serveApi, serveIntake, serveSocket }; }  module.exports = { createDiagnosticsRoutes };diff --git a/Docs/ChangeHistory.md b/Docs/ChangeHistory.mdindex d03ea09..9d335a2 100644--- a/Docs/ChangeHistory.md+++ b/Docs/ChangeHistory.md@@ -1,5 +1,50 @@ # ChangeHistory +## 2026.11.10 22:58 YouTube 未啟用時不再輸出斷線收尾++**類型**: 修正 · **檔案**: `ScriptLib/youtube/chat.mjs`、`TikTok.js`、`Test/youtube_stop.test.mjs`++### 問題++`handleExit` 無條件呼叫 `stopYoutubeChat()`,而後者每次都會輸出「❌ Youtube 聊天室已斷線」,即使這次執行根本沒有啟用 YouTube。`handleExit` 也可能被多個退出來源重複呼叫,造成同一段收尾與 YouTube 訊息出現兩次。++### 修改++- YouTube 模組新增 `youtubeActive`,只在原生聊天室、觀眾輪詢或瀏覽器 fallback 真正啟動後設為 true。+- `stopYoutubeChat` 先記錄是否曾啟用;未啟用時不輸出斷線訊息。+- 有啟用時改成中性「⏹️ Youtube 聊天室已停止」。+- `handleExit` 加 idempotent guard,重複退出訊號只收尾一次。++### 驗證++- `node --test Test/youtube_stop.test.mjs`+- `npm run lint`++---++## 2026.11.10 22:48 診斷 Socket 外部事件訂閱++**類型**: 功能 · **檔案**: `ScriptLib/diagnostics/socket-client.cjs`、`DiagnosticsRoutes.cjs`、`Server.js`、`diagnostics.html`、`assets/diagnostics.js`、`Test/diagnostics.test.cjs`、`Test/diagnostics_ui.cjs`、`.env.example`、`Docs/DIAGNOSTICS.md`++### 修改++- 新增 ReplyKit SocketServer 客戶端:送出 `subscribeExternalLogs`,接收 `externalLogSubscription`/`externalLogBatch`/`diagnostic`。+- 斷線自動重連並重新訂閱;取消訂閱會送 `enabled:false` 並關閉連線。+- TTWChat 只送訂閱訊息,不送 extension 專用 type,維持外部工具角色。+- 連線目標只由 `DIAGNOSTICS_SOCKET_HOST`/`PORT` 設定;`/diagnostics` 只保留訂閱開關與狀態。+- 訂閱切換只送 `subscribeExternalLogs`,同一條已建立連線不重連;取消只送 `enabled:false` 並保留連線。+- Socket 訂閱欄位有 dirty 狀態:使用者勾選後,2 秒狀態輪詢不會把 checkbox 重設回伺服器舊值。+- Log I/O 卡片只顯示 `subsystem:"logio"`;socket 外部日誌進下方應用日誌列表。+- 補 socket client、API 與 UI 測試。++### 驗證++- `node --test Test/diagnostics.test.cjs`+- `node Test/diagnostics_ui.cjs`+- `npm run lint`、`npm run check:css`++---+ ## 2026.11.10 20:42 應用診斷入口與 HTTP log bridge  **類型**: 功能 · **檔案**: `diagnostics.html`、`assets/diagnostics.js`、`DiagnosticsRoutes.cjs`、`ScriptLib/diagnostics/store.cjs`、`Server.js`、`WebAssets.cjs`、`monitor.html`、`runtime.html`、`log.html`、`lang/*.json`、`.env.example`、`Test/diagnostics.test.cjs`、`Test/diagnostics_ui.cjs`、`Docs/RUNTIME.md`、`Docs/TEST_FILES_REFERENCE.md`diff --git a/Docs/DIAGNOSTICS.md b/Docs/DIAGNOSTICS.mdnew file mode 100644index 0000000..a13afa0--- /dev/null+++ b/Docs/DIAGNOSTICS.md@@ -0,0 +1,72 @@+# 應用診斷(/diagnostics)++## 目的++集中查看外部應用(目前是 ReplyKit)的診斷與日誌,用於排查錄影、推流、擴展或 log I/O 問題。資料只是暫存,不是正式 log 檔。++## 兩種來源++### HTTP log bridge++與 ReplyKit `LogServer.js` 相容,POST 到:++```text+http://<TTWChat主機>:3332/post+```++接受:++```json+{ "logs": [{ "time": "...", "title": "ReplyKit", "body": "..." }] }+```++單一 log 物件或陣列也接受。若設定 `DIAGNOSTICS_TOKEN`:++```text+http://<TTWChat主機>:3332/post?token=<token>+```++或使用 `x-diagnostics-token`/`Authorization: Bearer <token>`。++### Socket 外部事件訂閱++ReplyKit 的 SocketServer 預設 port `9322`。連線目標只在環境變數設定一次:++```text+DIAGNOSTICS_SOCKET_HOST=192.168.0.242+DIAGNOSTICS_SOCKET_PORT=9322+# 可選:主服務啟動時自動訂閱+DIAGNOSTICS_SOCKET_ENABLED=1+```++`/diagnostics` 只保留「訂閱外部事件」開關,不再要求填 host/port。TTWChat 會:++1. 第一次啟用時建立 TCP 連線到設定的 host/port。+2. 在既有連線上送 `{"type":"subscribeExternalLogs","enabled":true}`。+3. 接收 `externalLogSubscription`、`externalLogBatch`、`diagnostic`。+4. 取消時只送 `enabled:false`,保留連線;再次啟用直接在同一條連線重新訂閱。+5. 已啟用時若連線斷開,自動重連並重新訂閱。++TTWChat 只送訂閱訊息,不送 `log`/`logbatch`/`videoHealth`/`audioHealth`/`reconnectStatus` 等 extension 專用訊息,因此維持外部工具角色。++## Log I/O 是什麼++頁面上的 **Log I/O** 卡片專指 `type:"diagnostic"`、`subsystem:"logio"` 的摘要,也就是應用程式**寫入 log 檔失敗**的次數與最後錯誤,不是 socket 外部日誌。++Socket 外部日誌會出現在下方「應用日誌」,與 HTTP log bridge 合併顯示。++## 邊界++- 只存在記憶體:logs 最多 2000、diagnostic 最多 100。+- 不寫 log 檔、SQLite、聊天、traffic、AutoClip 或通知。+- 未知 socket `type` 忽略,不中斷連線。+- `/diagnostics` 需要主服務登入;`/post` 只有選填 token,請勿直接暴露到不可信網路。+- Socket 角色識別不是身份驗證。++## 驗證++```bash+node --test Test/diagnostics.test.cjs+node Test/diagnostics_ui.cjs+npm run lint+```diff --git a/Docs/RUNTIME.md b/Docs/RUNTIME.mdindex ef64802..9257cf0 100644--- a/Docs/RUNTIME.md+++ b/Docs/RUNTIME.md@@ -33,7 +33,7 @@ Socket 使用既有 SOCKET_API 設定(可再用 SOCKET_APIS 加開多個端點  工具列會顯示子程序維護的本場加入/點讚/聊天累計。數字隨 socket payload 的 `session` metadata 更新;點讚以原始 `data.count` 累加,因此與每位使用者的 60 秒顯示計數不同。點讚的 Bark/ntfy 通知會像加入事件一樣合併 3 秒,monitor 仍保留逐筆點讚列。 -應用診斷(ReplyKit/外部 HTTP log bridge 與帶外 logio)顯示在 `/diagnostics`;只存在記憶體,不寫 log/SQLite/聊天歷史。+應用診斷(ReplyKit/外部 HTTP log bridge 與帶外 logio)顯示在 `/diagnostics`;可另外訂閱 ReplyKit SocketServer 的 external logs,只存在記憶體,不寫 log/SQLite/聊天歷史。詳見 `Docs/DIAGNOSTICS.md`。  圖片(TikTok/Twitch 頭像、禮物圖與贊助 overlay 圖片)由 MediaCache 持久化到 `cache/media/` 與 `data/media.sqlite`,訊息改傳 `MEDIA_BASE_URL/media/<hash>`;詳見 `Docs/MEDIA_CACHE.md`。 diff --git a/Docs/TEST_FILES_REFERENCE.md b/Docs/TEST_FILES_REFERENCE.mdindex bba817a..cc826a3 100644--- a/Docs/TEST_FILES_REFERENCE.md+++ b/Docs/TEST_FILES_REFERENCE.md@@ -65,8 +65,9 @@ node Test/xxx.mjs | `Test/media_cache.test.cjs` | 圖片快取本地 URL、下載、SVG/私有位址拒絕、路由與 SocketHub 改寫 | | `Test/inline_image.test.cjs` | data URI 圖片格式、大小/數量限制與通知剝離 | | `Test/socket_test_routes.test.cjs` | Raw Socket 測試入口的登入、子程序檢查與 payload 轉送 |-| `Test/diagnostics.test.cjs` | 診斷記憶體 store、LogServer 相容 `/post`、diagnostic 與 token |+| `Test/diagnostics.test.cjs` | 診斷 store、`/post`、diagnostic、token、Socket 外部事件訂閱與 API | | `Test/diagnostics_ui.cjs` | `/diagnostics` 日誌順序、自動滾到最新、logio 卡片與無 page error |+| `Test/youtube_stop.test.mjs` | 未啟用 YouTube 時收尾不輸出聊天室斷線 | | `Test/notify.test.mjs` | 通知格式化與 Bark/ntfy 逐通道結果 | | `Test/traffic_planner_ui.cjs` | 人流分類、批次補分類、預覽/套用/復原與手機寬度 | | `Test/traffic_planner_collecting_ui.cjs` | 資料累積建議、時段分組提醒與一週計畫 UI |diff --git a/ScriptLib/diagnostics/socket-client.cjs b/ScriptLib/diagnostics/socket-client.cjsnew file mode 100644index 0000000..1857fee--- /dev/null+++ b/ScriptLib/diagnostics/socket-client.cjs@@ -0,0 +1,186 @@+const net = require('node:net');++const MAX_LINE = 4 * 1024 * 1024;++/**+ * 訂閱 ReplyKit SocketServer 的外部日誌/帶外 diagnostic。+ * 只送 subscribeExternalLogs,不送 extension 專用訊息,維持外部工具角色。+ */+class DiagnosticsSocketClient {+    constructor({ store, reconnectMs = 3000, logger = console } = {}) {+        this.store = store;+        this.reconnectMs = reconnectMs;+        this.logger = logger;+        this.socket = null;+        this.buffer = '';+        this.retryTimer = null;+        this.host = '';+        this.port = 0;+        this.enabled = false;+        this.subscribed = false;+        this.state = 'off';+        this.lastError = '';+        this.lastMessageAt = null;+        this.attempts = 0;+    }++    snapshot() {+        return {+            enabled: this.enabled,+            host: this.host,+            port: this.port,+            state: this.state,+            subscribed: this.subscribed,+            lastError: this.lastError,+            lastMessageAt: this.lastMessageAt,+            attempts: this.attempts+        };+    }++    clearRetry() {+        if (this.retryTimer) clearTimeout(this.retryTimer);+        this.retryTimer = null;+    }++    send(payload) {+        if (!this.socket || this.socket.destroyed || !this.socket.writable) return false;+        try {+            this.socket.write(JSON.stringify(payload) + '\n');+            return true;+        } catch (error) {+            this.lastError = error.message;+            return false;+        }+    }++    subscribe(enabled) {+        return this.send({ type: 'subscribeExternalLogs', enabled: enabled === true });+    }++    start(host, port) {+        const nextHost = String(host || '').trim();+        const nextPort = Number(port) || 0;+        if (!nextHost || nextPort < 1 || nextPort > 65535) {+            this.lastError = '需要有效的 host 與 port';+            this.enabled = false;+            this.state = 'error';+            return false;+        }+        const sameTarget = this.host === nextHost && this.port === nextPort;+        this.host = nextHost;+        this.port = nextPort;+        this.enabled = true;+        this.attempts = 0;+        this.lastError = '';+        // 已建立同一條連線時只重新訂閱,不重連。+        if (sameTarget && this.socket && !this.socket.destroyed && this.socket.writable) {+            this.subscribe(true);+            if (!this.subscribed) this.state = 'connected';+            return true;+        }+        this.closeSocket();+        this.connect();+        return true;+    }++    unsubscribe() {+        this.enabled = false;+        this.subscribed = false;+        this.clearRetry();+        this.subscribe(false);+        this.state = this.socket && !this.socket.destroyed ? 'connected' : 'off';+    }++    stop() {+        this.enabled = false;+        this.subscribed = false;+        this.clearRetry();+        this.subscribe(false);+        this.closeSocket();+        this.state = 'off';+    }++    closeSocket() {+        if (!this.socket) return;+        const socket = this.socket;+        this.socket = null;+        try { socket.destroy(); } catch { /* ignore */ }+    }++    connect() {+        if (!this.enabled) return;+        this.clearRetry();+        this.state = 'connecting';+        this.attempts += 1;+        const socket = net.createConnection({ host: this.host, port: this.port });+        this.socket = socket;+        socket.setNoDelay(true);+        socket.setEncoding('utf8');+        socket.on('connect', () => {+            this.state = 'connected';+            this.lastError = '';+            this.buffer = '';+            if (this.enabled) this.subscribe(true);+        });+        socket.on('data', chunk => this.onData(chunk));+        socket.on('error', error => {+            this.lastError = error.message;+        });+        socket.on('close', () => {+            if (this.socket !== socket) return;+            this.socket = null;+            this.subscribed = false;+            if (!this.enabled) {+                this.state = 'off';+                return;+            }+            this.state = 'reconnecting';+            this.retryTimer = setTimeout(() => {+                this.retryTimer = null;+                this.connect();+            }, this.reconnectMs);+        });+    }++    onData(chunk) {+        this.buffer += chunk;+        if (this.buffer.length > MAX_LINE) {+            this.buffer = '';+            this.lastError = '單行資料過大,已重置緩衝';+            return;+        }+        let index;+        while ((index = this.buffer.indexOf('\n')) >= 0) {+            const line = this.buffer.slice(0, index).trim();+            this.buffer = this.buffer.slice(index + 1);+            if (line) this.onLine(line);+        }+    }++    onLine(line) {+        let data;+        try { data = JSON.parse(line); } catch {+            this.lastError = '收到非 JSON 資料';+            return;+        }+        if (!data || typeof data !== 'object') return;+        if (data.type === 'externalLogSubscription') {+            this.subscribed = data.enabled === true;+            this.state = this.subscribed ? 'subscribed' : 'connected';+            this.lastMessageAt = Date.now();+            return;+        }+        if (data.type === 'externalLogBatch') {+            if (Array.isArray(data.entries)) this.store.ingestLogs({ logs: data.entries });+            this.lastMessageAt = Date.now();+            return;+        }+        if (data.type === 'diagnostic') {+            this.store.ingestDiagnostic(data);+            this.lastMessageAt = Date.now();+        }+        // 其他 type 忽略,不中斷連線。+    }+}++module.exports = { DiagnosticsSocketClient };diff --git a/Server.js b/Server.jsindex 851904b..8d55a9d 100644--- a/Server.js+++ b/Server.js@@ -62,10 +62,23 @@ const mediaRoutes = require('./MediaRoutes.cjs'); const serveMedia = mediaRoutes.createMediaRoutes(); const serveMediaAdmin = mediaRoutes.createMediaAdminRoutes(); const { DiagnosticsStore } = require('./ScriptLib/diagnostics/store.cjs');+const { DiagnosticsSocketClient } = require('./ScriptLib/diagnostics/socket-client.cjs'); const { createDiagnosticsRoutes } = require('./DiagnosticsRoutes.cjs');-const diagnosticsRoutes = createDiagnosticsRoutes(new DiagnosticsStore(), {-    token: process.env.DIAGNOSTICS_TOKEN || ''+const diagnosticsStore = new DiagnosticsStore();+const diagnosticsSocket = new DiagnosticsSocketClient({ store: diagnosticsStore });+const diagnosticsRoutes = createDiagnosticsRoutes(diagnosticsStore, {+    token: process.env.DIAGNOSTICS_TOKEN || '',+    socketClient: diagnosticsSocket,+    socketDefaults: {+        host: process.env.DIAGNOSTICS_SOCKET_HOST || '',+        port: Number(process.env.DIAGNOSTICS_SOCKET_PORT) || 9322+    } });+if (/^(1|true|yes|on)$/i.test(process.env.DIAGNOSTICS_SOCKET_ENABLED || '')+    && process.env.DIAGNOSTICS_SOCKET_HOST) {+    diagnosticsSocket.start(process.env.DIAGNOSTICS_SOCKET_HOST,+        Number(process.env.DIAGNOSTICS_SOCKET_PORT) || 9322);+} const audiencePrune = setInterval(() => {     try { audience.prune(180); } catch (error) { console.error('[Audience] 清理失敗:', error.message); } }, 24 * 3600000);@@ -633,6 +646,7 @@ const server = http.createServer((req, res) => {     if (diagnosticsRoutes.serveIntake(req, res)) return;     if (diagnosticsRoutes.servePage(req, res)) return;     if (diagnosticsRoutes.serveApi(req, res, isValidToken(parseCookies(req).authToken))) return;+    if (diagnosticsRoutes.serveSocket(req, res, isValidToken(parseCookies(req).authToken))) return;     if (serveMediaAdmin(req, res, isValidToken(parseCookies(req).authToken))) return;     if (serveSocketTest(req, res, isValidToken(parseCookies(req).authToken))) return;     if (serveMedia(req, res)) return;diff --git a/Test/diagnostics.test.cjs b/Test/diagnostics.test.cjsindex 4cc592c..975ef15 100644--- a/Test/diagnostics.test.cjs+++ b/Test/diagnostics.test.cjs@@ -1,7 +1,9 @@ const test = require('node:test'); const assert = require('node:assert/strict');+const net = require('node:net'); const { EventEmitter } = require('node:events'); const { DiagnosticsStore } = require('../ScriptLib/diagnostics/store.cjs');+const { DiagnosticsSocketClient } = require('../ScriptLib/diagnostics/socket-client.cjs'); const { createDiagnosticsRoutes } = require('../DiagnosticsRoutes.cjs');  function request(route, { url = '/post', method = 'POST', body = '', headers = {} } = {}) {@@ -90,3 +92,89 @@ test('診斷 API 需要登入並回傳 snapshot', async () => {     assert.equal(allowed.status, 200);     assert.equal(allowed.body.diagnostics.length, 1); });++test('Socket client 訂閱 externalLogs 並接收 batch/diagnostic', async () => {+    const store = new DiagnosticsStore();+    const received = [];+    const server = net.createServer(socket => {+        socket.on('error', () => {});+        socket.setEncoding('utf8');+        let buffer = '';+        socket.on('data', chunk => {+            buffer += chunk;+            let index;+            while ((index = buffer.indexOf('\n')) >= 0) {+                const line = buffer.slice(0, index);+                buffer = buffer.slice(index + 1);+                if (!line) continue;+                received.push(JSON.parse(line));+                if (JSON.parse(line).type === 'subscribeExternalLogs' && !socket.destroyed) {+                    try {+                        socket.write(JSON.stringify({ type: 'externalLogSubscription', enabled: true }) + '\n');+                        socket.write(JSON.stringify({+                            type: 'externalLogBatch', source: 'mainapp',+                            entries: [{ time: 'now', title: 'ReplyKit', body: 'socket log' }]+                        }) + '\n');+                        socket.write(JSON.stringify({+                            type: 'diagnostic', subsystem: 'logio', source: 'mainapp', count: 2, lastError: 'disk'+                        }) + '\n');+                    } catch { /* client 已關閉 */ }+                }+            }+        });+    });+    await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));+    const client = new DiagnosticsSocketClient({ store, reconnectMs: 50, logger: { log() {}, error() {} } });+    try {+        assert.equal(client.start('127.0.0.1', server.address().port), true);+        await new Promise(resolve => {+            const timer = setInterval(() => {+                const snap = store.snapshot();+                if (snap.logs.length && snap.diagnostics.length) {+                    clearInterval(timer);+                    resolve();+                }+            }, 10);+        });+        assert.deepEqual(received[0], { type: 'subscribeExternalLogs', enabled: true });+        assert.equal(client.snapshot().subscribed, true);+        assert.equal(store.snapshot().logs[0].body, 'socket log');+        assert.equal(store.snapshot().diagnostics[0].count, 2);+    } finally {+        client.stop();+        await new Promise(resolve => server.close(resolve));+    }+});++test('Socket 訂閱 API 需登入,GET 狀態與 POST 開關', async () => {+    const calls = [];+    const fake = {+        snapshot: () => ({ enabled: true, host: 'h', port: 9322, state: 'subscribed', subscribed: true }),+        start: (host, port) => { calls.push(['start', host, port]); return true; },+        unsubscribe: () => calls.push(['unsubscribe'])+    };+    const routes = createDiagnosticsRoutes(new DiagnosticsStore(), {+        socketClient: fake, socketDefaults: { host: 'default', port: 9322 }+    });+    const call = (method, body, authorized = true) => new Promise(resolve => {+        const req = new EventEmitter();+        req.url = '/api/diagnostics/socket';+        req.method = method;+        req.headers = {};+        const res = { writeHead(status) { this.status = status; }, end(value) { resolve({ status: this.status, body: JSON.parse(value) }); } };+        const served = routes.serveSocket(req, res, authorized);+        if (!served) { resolve({ served: false }); return; }+        setImmediate(() => {+            if (body) req.emit('data', Buffer.from(JSON.stringify(body)));+            req.emit('end');+        });+    });+    assert.equal((await call('GET', null, false)).status, 401);+    const status = await call('GET');+    assert.equal(status.body.subscribed, true);+    const enabled = await call('POST', { enabled: true });+    assert.equal(enabled.status, 200);+    assert.deepEqual(calls[0], ['start', 'default', 9322]);+    await call('POST', { enabled: false });+    assert.deepEqual(calls[1], ['unsubscribe']);+});diff --git a/Test/diagnostics_ui.cjs b/Test/diagnostics_ui.cjsindex 22e662d..c89ee93 100644--- a/Test/diagnostics_ui.cjs+++ b/Test/diagnostics_ui.cjs@@ -9,6 +9,7 @@ const { browserPath } = require('./browser_path.cjs');  async function main() {     const pageHtml = fs.readFileSync(path.join(__dirname, '../diagnostics.html'));+    const socketPosts = [];     const server = http.createServer((req, res) => {         const url = new URL(req.url, 'http://127.0.0.1');         if (url.pathname === '/diagnostics') {@@ -16,6 +17,28 @@ async function main() {             res.end(pageHtml);             return;         }+        if (url.pathname === '/api/diagnostics/socket') {+            if (req.method === 'POST') {+                let body = '';+                req.on('data', chunk => { body += chunk; });+                req.on('end', () => {+                    const data = JSON.parse(body);+                    socketPosts.push(data);+                    res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });+                    res.end(JSON.stringify({+                        ok: true, enabled: data.enabled, host: data.host, port: data.port,+                        state: data.enabled ? 'connecting' : 'off', subscribed: false, defaults: {}+                    }));+                });+                return;+            }+            res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });+            res.end(JSON.stringify({+                ok: true, enabled: false, host: '', port: 9322, state: 'off', subscribed: false,+                defaults: { host: '192.168.0.242', port: 9322 }+            }));+            return;+        }         if (url.pathname === '/api/diagnostics') {             res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });             res.end(JSON.stringify({@@ -60,6 +83,14 @@ async function main() {         assert.equal(result.atBottom, true);         assert.match(result.diagnostic, /logio/);         assert.equal(result.loginHidden, true);+        await page.waitForFunction(() => document.querySelector('#diagSocketStatus').textContent.length > 0);+        assert.match(await page.$eval('#diagSocketStatus', node => node.textContent), /未啟用/);+        await page.click('#diagSocketEnabled');+        await new Promise(resolve => setTimeout(resolve, 2300));+        assert.equal(await page.$eval('#diagSocketEnabled', node => node.checked), true);+        await page.click('#diagSocketApply');+        await page.waitForFunction(() => document.querySelector('#diagSocketStatus').textContent.includes('連線中'));+        assert.deepEqual(socketPosts[0], { enabled: true });         assert.deepEqual(errors, []);         console.log('Diagnostics UI passed: log stream, logio card, and no page errors.');     } finally {diff --git a/assets/app.css.build.json b/assets/app.css.build.jsonindex 1fb52cd..30a5051 100644--- a/assets/app.css.build.json+++ b/assets/app.css.build.json@@ -1,5 +1,5 @@ {   "schema": 2,-  "inputHash": "ce3d9ac0160ed455da2fa5193ed580f4f819a84137146044e9628e8a941a1111",+  "inputHash": "488adc478e073ed24b999b76981e5a0ed1e2daf13394d1522e8a9df1d8ce316b",   "outputHash": "5e8e8ac9b7a490acf78fc06ee9e9c0e63e69a0a32e695f93025dce6a24930dce" }diff --git a/assets/diagnostics.js b/assets/diagnostics.jsindex 6792126..b1d08d1 100644--- a/assets/diagnostics.js+++ b/assets/diagnostics.js@@ -11,10 +11,14 @@     const auto = $('diagAuto');     const autoScroll = $('diagAutoScroll');     const refresh = $('diagRefresh');+    const socketEnabled = $('diagSocketEnabled');+    const socketApply = $('diagSocketApply');+    const socketStatus = $('diagSocketStatus');     let state = { logs: [], diagnostics: [], recentDiagnostics: [], stats: {}, lastReceived: null };     let timer = null;     let searchTimer = null;     let loading = false;+    let socketDirty = false;      function text(node, value) {         node.textContent = value == null ? '' : String(value);@@ -49,7 +53,7 @@      function renderCards() {         cards.replaceChildren();-        const diagnostics = state.diagnostics || [];+        const diagnostics = (state.diagnostics || []).filter(item => item.subsystem === 'logio');         if (!diagnostics.length) {             const empty = document.createElement('div');             empty.className = 'diag-empty';@@ -154,6 +158,65 @@         statusEl.textContent = `最後收到:${relative(state.lastReceived)} · 緩衝 ${stats.bufferedLogs || 0} 則 · 診斷 ${stats.bufferedDiagnostics || 0} 筆`;     } +    function socketLabel(data) {+        const stateLabels = {+            off: '未啟用', connecting: '連線中', connected: '已連線', subscribed: '已訂閱',+            reconnecting: '重連中', error: '錯誤'+        };+        const label = stateLabels[data.state] || data.state || '未知';+        const where = data.host ? `${data.host}:${data.port}` : '未設定';+        const extra = data.lastError ? ` · ${data.lastError}` : '';+        return `${where} · ${label}${data.subscribed ? '(外部事件)' : ''}${extra}`;+    }++    async function loadSocketStatus() {+        try {+            const response = await fetch('/api/diagnostics/socket', { cache: 'no-store' });+            if (response.status === 401) {+                socketStatus.textContent = '需要登入';+                return;+            }+            const data = await response.json();+            if (!response.ok || !data.ok) {+                socketStatus.textContent = data.error || ('HTTP ' + response.status);+                return;+            }+            // 使用者正在編輯時不要用輪詢結果蓋掉勾選狀態。+            if (!socketDirty) socketEnabled.checked = data.enabled === true;+            socketStatus.textContent = socketLabel(data);+        } catch (error) {+            socketStatus.textContent = '狀態讀取失敗:' + error.message;+        }+    }++    async function applySocketSubscription() {+        socketApply.disabled = true;+        try {+            const body = { enabled: socketEnabled.checked };+            const response = await fetch('/api/diagnostics/socket', {+                method: 'POST',+                headers: { 'Content-Type': 'application/json' },+                body: JSON.stringify(body)+            });+            const data = await response.json().catch(() => ({}));+            if (response.status === 401) {+                showCallout('Socket 訂閱需要主服務登入。', '/login');+                return;+            }+            if (!response.ok || !data.ok) {+                socketStatus.textContent = data.error || ('HTTP ' + response.status);+                return;+            }+            socketDirty = false;+            socketEnabled.checked = data.enabled === true;+            socketStatus.textContent = socketLabel(data);+        } catch (error) {+            socketStatus.textContent = '套用失敗:' + error.message;+        } finally {+            socketApply.disabled = false;+        }+    }+     async function load() {         if (loading) return;         loading = true;@@ -186,7 +249,10 @@         timer = null;         if (!auto.checked) return;         timer = setInterval(() => {-            if (document.visibilityState === 'visible') load();+            if (document.visibilityState === 'visible') {+                load();+                loadSocketStatus();+            }         }, 2000);     } @@ -194,14 +260,23 @@         clearTimeout(searchTimer);         searchTimer = setTimeout(load, 250);     });-    refresh.addEventListener('click', load);+    refresh.addEventListener('click', () => {+        load();+        loadSocketStatus();+    });     auto.addEventListener('change', schedule);     autoScroll.addEventListener('change', () => {         if (autoScroll.checked) scrollLogsToLatest();     });+    socketApply.addEventListener('click', applySocketSubscription);+    socketEnabled.addEventListener('change', () => { socketDirty = true; });     document.addEventListener('visibilitychange', () => {-        if (document.visibilityState === 'visible' && auto.checked) load();+        if (document.visibilityState === 'visible' && auto.checked) {+            load();+            loadSocketStatus();+        }     });     schedule();     load();+    loadSocketStatus(); })();diff --git a/diagnostics.html b/diagnostics.htmlindex 3eaa843..d44b1db 100644--- a/diagnostics.html+++ b/diagnostics.html@@ -34,6 +34,16 @@      <p id="diagCallout" class="diag-card mb-4" hidden></p> +    <section class="diag-card mb-4">+      <h2>Socket 外部事件</h2>+      <div class="flex items-center flex-wrap gap-3 text-xs">+        <label class="inline-flex items-center gap-1"><input id="diagSocketEnabled" type="checkbox"> 訂閱外部事件</label>+        <button id="diagSocketApply" class="bg-gray-700 hover:bg-gray-600 px-2 py-1 rounded">套用</button>+        <span id="diagSocketStatus" class="text-xs text-gray-400"></span>+      </div>+      <small class="text-xs text-gray-500">連線目標由 DIAGNOSTICS_SOCKET_HOST/PORT 設定;切換只送 subscribeExternalLogs,已建立的連線不會重連。Log I/O 是寫檔失敗摘要,Socket 外部事件是另一條日誌串流。</small>+    </section>+     <div class="grid gap-3 md:grid-cols-2 mb-4">       <section class="diag-card">         <h2>Log I/O</h2>diff --git a/todo.md b/todo.mdindex 299c7d8..481c67d 100644--- a/todo.md+++ b/todo.md@@ -26,6 +26,8 @@ - [x] Python overlay 支援 img/giftImg/AdOverlay icon 與訊息內嵌 data URI。 - [x] monitor MediaCache 檢視/本地連結複製與 Raw Socket 測試入口。 - [x] 應用診斷 `/diagnostics`、LogServer 相容 `/post` 與可選 token。+- [x] 診斷頁 Socket 外部事件訂閱開關(subscribeExternalLogs、自動重連)。+- [x] 未啟用 YouTube 時不輸出假斷線;handleExit 冪等。 - [ ] 正式環境以實際 child 程序驗證 IPC 結果與倒數(目前 monitor UI 使用假 SSE/fake `/chat`)。  ## 表情映射後續(2026-11-06)