feat(monitor): Raw Socket 控制與複製回饋
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.md--- 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.cjs--- 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.json--- 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.js--- 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.json--- 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.json--- 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.html--- 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>