app · 24834b2

Dev index Recent changes / versions Changelog About TODO

feat(monitor): Raw Socket 控制與複製回饋

24834b2 · 2026-11-11 00:31 · app
Docs/ChangeHistory.mdTest/monitor_audience_ui.cjsassets/app.css.build.jsonassets/monitor.jslang/en.jsonlang/zh-TW.jsonmonitor.html
commit 24834b238f18318b0f45482b2840ac6becdd9eb2Author: Nuclear0709 <agp05432@gmail.com>Date:   Wed Nov 11 00:31:15 2026 +0800     feat(monitor): Raw Socket 控制與複製回饋        - Raw Socket 新增 isMain 與 MediaCache/emoji 圖片選單、預覽    - MediaCache 複製按鈕顯示 Clipboard API/fallback 結果,失敗提供手動複製    - 補 UI 測試與 i18n diff --git a/Docs/ChangeHistory.md b/Docs/ChangeHistory.mdindex 9d335a2..3d9e438 100644--- a/Docs/ChangeHistory.md+++ b/Docs/ChangeHistory.md@@ -1,5 +1,118 @@ # ChangeHistory +## 2026.11.11 00:23 Raw Socket 圖片選單++**類型**: 功能 · **檔案**: `monitor.html`、`assets/monitor.js`、`lang/*.json`、`Test/monitor_audience_ui.cjs`++### 修改++- Raw Socket 測試新增「從 MediaCache/emoji 選」下拉,填入頭像/圖片 URL。+- 選 MediaCache 使用 `/api/media?status=cached` 的本地 `/media/<hash>`。+- 選 emoji 使用目前 `/api/emoji/preview` 的 URL。+- AdOverlay 模式下同一選擇會同步填入 iconURL。+- Raw 圖片欄新增 HTTP(S)/data URI 預覽,選單選擇後立即更新。+- UI 測試補 Raw 圖片選單與預覽。++### 驗證++- `node Test/monitor_audience_ui.cjs`+- `npm run lint`、`npm run check:css`++---++## 2026.11.11 00:12 Raw Socket 測試補 isMain++**類型**: 修正 · **檔案**: `monitor.html`、`assets/monitor.js`、`lang/*.json`++### 修改++- Raw Socket 測試新增「主要訊息(isMain)」開關,預設開啟。+- StreamMessage payload 會帶 `isMain`;AdOverlay 時停用此欄位。+- 補中英文標籤。++### 驗證++- `npm run lint`+- `npm run check:css`++---++## 2026.11.11 00:10 MediaCache 改用 LAN IP++**類型**: 修正 · **檔案**: `ScriptLib/media/cache.cjs`、`Test/media_cache.test.cjs`、`.env.example`、`Docs/MEDIA_CACHE.md`++### 問題++`MEDIA_BASE_URL` 預設 `127.0.0.1`,StreamMessage 傳給其他機器的 `/media` URL 在對端無法解析。++### 修改++- 預設自動偵測私有 IPv4,優先 `192.168.x.x`、其次 `10.x`、`172.16–31.x`。+- 只有找不到私有 IPv4 才退回 `127.0.0.1`。+- `MEDIA_BASE_URL` 仍可明確覆寫;多網卡/反向代理時使用。+- 補偵測與覆寫測試。++### 限制++- 多張網卡可能挑到不是預期的介面;用 `MEDIA_BASE_URL` 固定。+- Windows 防火牆仍需允許 3332。++### 驗證++- `node --test Test/media_cache.test.cjs`+- `npm run lint`++---++## 2026.11.10 23:59 monitor 歷史 materialize 與 MediaCache LRU++**類型**: 功能 · **檔案**: `ScriptLib/media/cache.cjs`、`ScriptLib/media/inline.cjs`、`ScriptLib/monitor/store.cjs`、`Server.js`、`Test/media_cache.test.cjs`、`Test/monitor_history.test.cjs`、`.env.example`、`Docs/MEDIA_CACHE.md`、`Docs/TEST_FILES_REFERENCE.md`++### 修改++- monitor 歷史寫入時把 `message`/`img`/`giftImg` 的 data URI 解碼,存成 content-addressed `/media/<hash>`;SQLite 不再保存 base64。+- 超限/解碼失敗存 placeholder,不切片、不 fallback HTTP。+- MediaCache 新增 `source`/`pinned`:inline 與歷史引用的 HTTP 圖片会被 pin,一般 LRU 不刪。+- `MEDIA_CACHE_MAX_MB`、`MEDIA_CACHE_DAYS`;主服務每小時 prune 非 pinned HTTP 快取。+- monitor 場次/訊息修剪後釋放 inline 檔與 HTTP pin。+- 補 materialize、pin、prune 測試。++### 限制++- 既有歷史中已存在的 base64 不會自動回溯改寫;只影響新寫入與後續修剪。+- inline 檔案生命週期跟隨 monitor 歷史引用。++### 驗證++- `node --test Test/media_cache.test.cjs Test/monitor_history.test.cjs`+- `npm run lint`++---++## 2026.11.10 23:43 MediaCache 複製按鈕回饋++**類型**: 修正 · **檔案**: `assets/monitor.js`、`lang/*.json`++### 問題++MediaCache 的「複製連結」按鈕只更新下方狀態文字,按鈕本身沒有變化,難以感知是否已複製。++### 修改++- 複製成功時按鈕文字變「已複製」、綠色邊框,1.5 秒後恢復。+- 失敗時顯示「複製失敗」與紅色邊框。+- 保留原本的狀態列訊息。+- 只在 secure context(HTTPS/localhost)使用 Clipboard API;LAN HTTP 直接走同步 execCommand fallback,避免 await 消耗使用者手勢。+- 狀態會標示「Clipboard API」或「瀏覽器 fallback」;兩者都失敗時顯示手動複製 prompt。+- 補中英文 i18n 短標籤。++### 驗證++- `npm run lint`+- `npm run check:css`++---+ ## 2026.11.10 22:58 YouTube 未啟用時不再輸出斷線收尾  **類型**: 修正 · **檔案**: `ScriptLib/youtube/chat.mjs`、`TikTok.js`、`Test/youtube_stop.test.mjs`diff --git a/Test/monitor_audience_ui.cjs b/Test/monitor_audience_ui.cjsindex b9a879f..682c450 100644--- a/Test/monitor_audience_ui.cjs+++ b/Test/monitor_audience_ui.cjs@@ -172,6 +172,14 @@ async function main() {         await page.waitForFunction(() => document.querySelectorAll('#monTestAvatarPick option').length === 2);         await page.select('#monTestAvatarPick', base + '/emoji.png');         assert.equal(await page.$eval('#monTestAvatar', node => node.value), base + '/emoji.png');+        await page.waitForFunction(() => document.querySelectorAll('#monRawImagePick option').length >= 2);+        const rawEmoji = await page.$eval('#monRawImagePick option:last-child', node => node.value);+        await page.select('#monRawImagePick', rawEmoji);+        assert.equal(await page.$eval('#monRawImage', node => node.value), rawEmoji);+        await page.waitForFunction(() => {+            const image = document.querySelector('#monRawImagePreview');+            return image && !image.hidden && image.src.endsWith('/emoji.png');+        });         await page.waitForFunction(() => {             const image = document.querySelector('#monTestAvatarPreview');             return image && !image.hidden && image.src.endsWith('/emoji.png');diff --git a/assets/app.css.build.json b/assets/app.css.build.jsonindex 30a5051..0feb633 100644--- a/assets/app.css.build.json+++ b/assets/app.css.build.json@@ -1,5 +1,5 @@ {   "schema": 2,-  "inputHash": "488adc478e073ed24b999b76981e5a0ed1e2daf13394d1522e8a9df1d8ce316b",+  "inputHash": "2fb160324cbdeec2df88a8bb855e3bb50e01fb4d01f6ef3b9362c1269597add5",   "outputHash": "5e8e8ac9b7a490acf78fc06ee9e9c0e63e69a0a32e695f93025dce6a24930dce" }diff --git a/assets/monitor.js b/assets/monitor.jsindex 7b7f28b..cc4b1c9 100644--- a/assets/monitor.js+++ b/assets/monitor.js@@ -84,14 +84,19 @@     const rawCategory = document.getElementById('monRawCategory');     const rawMessage = document.getElementById('monRawMessage');     const rawImage = document.getElementById('monRawImage');+    const rawImagePick = document.getElementById('monRawImagePick');+    const rawImagePreview = document.getElementById('monRawImagePreview');+    const rawImagePreviewStatus = document.getElementById('monRawImagePreviewStatus');     const rawGift = document.getElementById('monRawGift');     const rawNormalize = document.getElementById('monRawNormalize');+    const rawIsMain = document.getElementById('monRawIsMain');     const rawSend = document.getElementById('monRawSend');     const rawStatus = document.getElementById('monRawStatus');     const TEST_PREFS_KEY = 'ttw-monitor-test-prefs';      let emojiMap = null;     let emojiPattern = null;+    let rawMediaItems = [];     let savedTestPrefs = null;     let pendingTestId = null;     let pendingTestTimer = null;@@ -307,6 +312,36 @@         } catch { return false; }     } +    function isImageSource(value) {+        return /^https?:\/\//i.test(value) || /^data:image\/(?:png|jpeg|webp|gif);base64,/i.test(value);+    }++    function updateRawImagePreview() {+        const value = rawImage.value.trim();+        if (!value) {+            rawImagePreview.hidden = true;+            rawImagePreview.removeAttribute('src');+            rawImagePreviewStatus.textContent = '';+            return;+        }+        if (!isImageSource(value)) {+            rawImagePreview.hidden = true;+            rawImagePreviewStatus.textContent = t('monitor.test.avatarPreviewInvalid', {}, '需為 HTTP(S) URL 或 data URI。');+            return;+        }+        rawImagePreview.hidden = false;+        rawImagePreviewStatus.textContent = '';+        rawImagePreview.onerror = () => {+            rawImagePreview.hidden = true;+            rawImagePreviewStatus.textContent = t('monitor.test.avatarPreviewError', {}, '圖片無法載入。');+        };+        rawImagePreview.onload = () => {+            rawImagePreview.hidden = false;+            rawImagePreviewStatus.textContent = '';+        };+        rawImagePreview.src = value;+    }+     function updateAvatarPreview() {         const value = testAvatar.value.trim();         if (!value) {@@ -389,6 +424,54 @@         testAvatarPick.replaceChildren(...options);         testAvatarPick.disabled = byUrl.size === 0;         if (byUrl.has(testAvatar.value)) testAvatarPick.value = testAvatar.value;+        buildRawImageOptions();+    }++    function buildRawImageOptions() {+        const previous = rawImagePick.value;+        const empty = document.createElement('option');+        empty.value = '';+        empty.textContent = t('monitor.test.avatarPickEmpty', {}, '—');+        const options = [empty];+        if (rawMediaItems.length) {+            const group = document.createElement('optgroup');+            group.label = 'MediaCache';+            for (const item of rawMediaItems) {+                const option = document.createElement('option');+                option.value = item.localUrl;+                option.textContent = `${item.hash.slice(0, 10)} · ${item.host || item.contentType || 'media'}`;+                group.append(option);+            }+            options.push(group);+        }+        const emojiByUrl = new Map();+        for (const [code, url] of Object.entries(emojiMap || {}).sort(([a], [b]) => a.localeCompare(b))) {+            if (typeof url === 'string' && !emojiByUrl.has(url)) emojiByUrl.set(url, code);+        }+        if (emojiByUrl.size) {+            const group = document.createElement('optgroup');+            group.label = 'emoji';+            for (const [url, code] of emojiByUrl) {+                const option = document.createElement('option');+                option.value = url;+                option.textContent = code;+                group.append(option);+            }+            options.push(group);+        }+        rawImagePick.replaceChildren(...options);+        rawImagePick.disabled = options.length <= 1;+        if (options.some(option => option.value === previous)) rawImagePick.value = previous;+    }++    async function loadRawMediaOptions() {+        try {+            const response = await fetch('/api/media?pageSize=200&status=cached', { cache: 'no-store' });+            if (!response.ok) return;+            const data = await response.json();+            rawMediaItems = data.ok && Array.isArray(data.items) ? data.items : [];+            buildRawImageOptions();+        } catch { /* 無登入或 API 不可用時只顯示 emoji */ }     }      function clearTestPrefs() {@@ -557,7 +640,8 @@         }         return {             type, category: rawCategory.value.trim() || 'chat', platform: 'unknown', user,-            message, img: rawImage.value.trim(), giftImg: rawGift.value.trim()+            message, img: rawImage.value.trim(), giftImg: rawGift.value.trim(),+            isMain: rawIsMain.checked         };     } @@ -616,12 +700,61 @@         mediaStatusText.className = error ? 'text-xs text-red-400' : 'text-xs text-gray-400';     } -    async function copyMediaLink(link) {+    function copyButtonFeedback(button, label, error = false) {+        if (!button) return;+        clearTimeout(button._copyFeedbackTimer);+        button.textContent = label;+        button.style.color = error ? '#fca5a5' : '#86efac';+        button.style.borderColor = error ? '#b91c1c' : '#15803d';+        button._copyFeedbackTimer = setTimeout(() => {+            button.textContent = t('monitor.media.copy', {}, '複製連結');+            button.style.color = '';+            button.style.borderColor = '';+        }, 1500);+    }++    function copyWithExecCommand(value) {+        const previous = document.activeElement;+        const area = document.createElement('textarea');+        area.value = value;+        area.setAttribute('readonly', '');+        area.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0';+        document.body.append(area);+        try {+            area.focus();+            area.select();+            area.setSelectionRange(0, area.value.length);+            return document.execCommand('copy');+        } finally {+            area.remove();+            if (previous && typeof previous.focus === 'function') previous.focus();+        }+    }++    async function copyText(value) {+        // 只有 secure context(HTTPS/localhost)才有正確的 Clipboard API。+        if (window.isSecureContext && navigator.clipboard?.writeText) {+            try {+                await navigator.clipboard.writeText(value);+                return 'clipboard';+            } catch { /* 權限被拒時再試 fallback */ }+        }+        // 非 secure context 直接同步 fallback,避免 await 消耗使用者手勢。+        if (copyWithExecCommand(value)) return 'fallback';+        throw new Error('copy-unavailable');+    }++    async function copyMediaLink(link, button = null) {         try {-            await navigator.clipboard.writeText(link);-            setMediaStatus(t('monitor.media.copied', {}, '已複製本地連結。'));+            const method = await copyText(link);+            copyButtonFeedback(button, t('monitor.media.copiedShort', {}, '已複製'));+            setMediaStatus(method === 'clipboard'+                ? t('monitor.media.copiedClipboard', {}, '已複製(Clipboard API)。')+                : t('monitor.media.copiedFallback', {}, '已複製(瀏覽器 fallback)。'));         } catch {-            setMediaStatus(t('monitor.media.copyFailed', {}, '複製失敗。'), true);+            copyButtonFeedback(button, t('monitor.media.copyFailedShort', {}, '複製失敗'), true);+            setMediaStatus(t('monitor.media.copyManual', {}, '自動複製失敗,請手動複製。'), true);+            window.prompt(t('monitor.media.copyManualPrompt', {}, '請手動複製以下連結:'), link);         }     } @@ -671,7 +804,7 @@             const copy = document.createElement('button');             copy.type = 'button';             copy.textContent = t('monitor.media.copy', {}, '複製連結');-            copy.addEventListener('click', () => copyMediaLink(item.localUrl));+            copy.addEventListener('click', () => copyMediaLink(item.localUrl, copy));             const open = document.createElement('a');             open.href = item.localUrl;             open.target = '_blank';@@ -1053,7 +1186,15 @@     });     rawType.addEventListener('change', () => {         rawCategory.value = rawType.value === 'AdOverlay' ? 'ad' : 'chat';+        rawIsMain.disabled = rawType.value === 'AdOverlay';+    });+    rawImagePick.addEventListener('change', () => {+        if (!rawImagePick.value) return;+        rawImage.value = rawImagePick.value;+        if (rawType.value === 'AdOverlay') rawGift.value = rawImagePick.value;+        updateRawImagePreview();     });+    rawImage.addEventListener('input', updateRawImagePreview);     rawSend.addEventListener('click', sendRawSocketTest);      testAdd.addEventListener('click', addTestMessage);@@ -1079,6 +1220,7 @@         buildTestCategories();         buildAvatarOptions();         updateAvatarPreview();+        updateRawImagePreview();         if (state.media.loaded) renderMedia();         if (state.mode === 'history') {             loadHistorySessions();@@ -1102,6 +1244,8 @@     historyBarEl.hidden = true;     setStatus('monitor.reconnecting'); +    updateRawImagePreview();+    loadRawMediaOptions();     fetch('/api/emoji/preview')         .then(response => response.ok ? response.json() : null)         .then(data => {diff --git a/lang/en.json b/lang/en.jsonindex f1ac915..fb9301c 100644--- a/lang/en.json+++ b/lang/en.json@@ -259,12 +259,20 @@   "monitor.media.copy": "Copy link",   "monitor.media.open": "Open",   "monitor.media.copied": "Local link copied.",+  "monitor.media.copiedShort": "Copied",+  "monitor.media.copiedClipboard": "Copied (Clipboard API).",+  "monitor.media.copiedFallback": "Copied (browser fallback).",+  "monitor.media.copyManual": "Automatic copy failed; copy manually.",+  "monitor.media.copyManualPrompt": "Copy this link manually:",+  "monitor.media.copyFailedShort": "Copy failed",   "monitor.media.copyFailed": "Copy failed.",   "monitor.raw.title": "Raw Socket test (bypass /chat)",   "monitor.raw.type": "Payload",   "monitor.raw.category": "Category",   "monitor.raw.giftIcon": "Gift / Icon URL",   "monitor.raw.normalize": "Apply production MediaCache/emoji normalization",+  "monitor.raw.isMain": "Primary message (isMain)",+  "monitor.raw.imagePick": "Pick from MediaCache / emoji",   "monitor.raw.send": "Send Raw Socket",   "monitor.raw.note": "Sent directly through socketHub, bypassing /chat, filters, dedup, traffic and AutoClip. Sign-in required.",   "monitor.raw.waiting": "Waiting for Raw Socket result…",diff --git a/lang/zh-TW.json b/lang/zh-TW.jsonindex abf80cc..96d758e 100644--- a/lang/zh-TW.json+++ b/lang/zh-TW.json@@ -259,12 +259,20 @@   "monitor.media.copy": "複製連結",   "monitor.media.open": "開啟",   "monitor.media.copied": "已複製本地連結。",+  "monitor.media.copiedShort": "已複製",+  "monitor.media.copiedClipboard": "已複製(Clipboard API)。",+  "monitor.media.copiedFallback": "已複製(瀏覽器 fallback)。",+  "monitor.media.copyManual": "自動複製失敗,請手動複製。",+  "monitor.media.copyManualPrompt": "請手動複製以下連結:",+  "monitor.media.copyFailedShort": "複製失敗",   "monitor.media.copyFailed": "複製失敗。",   "monitor.raw.title": "Raw Socket 測試(繞過 /chat)",   "monitor.raw.type": "Payload",   "monitor.raw.category": "分類",   "monitor.raw.giftIcon": "禮物/Icon URL",   "monitor.raw.normalize": "套用 MediaCache/emoji 正式轉換",+  "monitor.raw.isMain": "主要訊息(isMain)",+  "monitor.raw.imagePick": "從 MediaCache/emoji 選",   "monitor.raw.send": "送出 Raw Socket",   "monitor.raw.note": "直接送進 socketHub,不經 /chat、filter、去重、traffic、AutoClip;仍需登入。",   "monitor.raw.waiting": "等待 Raw Socket 結果…",diff --git a/monitor.html b/monitor.htmlindex dd0937c..2d6c43b 100644--- a/monitor.html+++ b/monitor.html@@ -138,8 +138,11 @@         <label><span data-i18n="monitor.raw.category">分類</span><input id="monRawCategory" type="text" maxlength="40" value="chat" autocomplete="off"></label>         <label class="mon-test-message"><span data-i18n="monitor.test.message">訊息</span><textarea id="monRawMessage" rows="2" maxlength="786432"></textarea></label>         <label><span data-i18n="monitor.test.avatar">頭像/圖片 URL</span><input id="monRawImage" type="text" maxlength="262144" autocomplete="off"></label>+        <label><span data-i18n="monitor.raw.imagePick">從 MediaCache/emoji 選</span><select id="monRawImagePick"><option value="">—</option></select></label>+        <label class="mon-test-avatar-preview-wrap"><span data-i18n="monitor.test.avatarPreview">頭貼預覽</span><span class="mon-test-avatar-preview-line"><img id="monRawImagePreview" class="mon-test-avatar-preview" alt="" referrerpolicy="no-referrer" hidden><small id="monRawImagePreviewStatus"></small></span></label>         <label><span data-i18n="monitor.raw.giftIcon">禮物/Icon URL</span><input id="monRawGift" type="text" maxlength="262144" autocomplete="off"></label>         <label class="mon-raw-normalize"><input id="monRawNormalize" type="checkbox" checked> <span data-i18n="monitor.raw.normalize">套用 MediaCache/emoji 正式轉換</span></label>+        <label class="mon-raw-normalize"><input id="monRawIsMain" type="checkbox" checked> <span data-i18n="monitor.raw.isMain">主要訊息(isMain)</span></label>         <div class="mon-test-actions">           <button id="monRawSend" type="button" class="mon-test-send" data-i18n="monitor.raw.send">送出 Raw Socket</button>           <span id="monRawStatus" role="status"></span>