diff --git a/.github/workflows/docker-e2e-tests.yml b/.github/workflows/docker-e2e-tests.yml index a7cfcfa..0ed6f89 100644 --- a/.github/workflows/docker-e2e-tests.yml +++ b/.github/workflows/docker-e2e-tests.yml @@ -51,15 +51,26 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Free disk space + # 使用專門的 action 進行激進的磁碟清理 + - name: Free disk space (aggressive) + uses: jlumbroso/free-disk-space@main + with: + tool-cache: true + android: true + dotnet: true + haskell: true + large-packages: true + docker-images: true + swap-storage: true + + - name: Additional disk cleanup run: | - echo "🧹 Cleaning disk space..." - sudo rm -rf /usr/share/dotnet || true - sudo rm -rf /usr/local/lib/android || true - sudo rm -rf /opt/ghc || true - sudo rm -rf /opt/hostedtoolcache || true + echo "🧹 Additional disk cleanup..." + sudo rm -rf /usr/local/share/boost || true + sudo rm -rf /usr/share/swift || true + sudo rm -rf /opt/hostedtoolcache/CodeQL || true docker system prune -af --volumes || true - echo "📊 Available space:" + echo "📊 Available space after cleanup:" df -h / - name: Set up Docker Buildx @@ -159,11 +170,16 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Free disk space - run: | - sudo rm -rf /usr/share/dotnet || true - sudo rm -rf /usr/local/lib/android || true - docker system prune -af --volumes || true + - name: Free disk space (aggressive) + uses: jlumbroso/free-disk-space@main + with: + tool-cache: true + android: true + dotnet: true + haskell: true + large-packages: true + docker-images: true + swap-storage: false - name: Login to GitHub Container Registry uses: docker/login-action@v3 @@ -368,8 +384,9 @@ jobs: - name: Download test results uses: actions/download-artifact@v4 with: - name: e2e-test-results + pattern: "*-test-results" path: test-results/ + merge-multiple: true continue-on-error: true - name: Check downloaded results diff --git a/README.md b/README.md index 8858b83..bbce583 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ | 🔧 **25+ 引擎** | LibreOffice、FFmpeg、Pandoc 全到位 | | 🈶 **中文優化** | 內建中日韓字型與 OCR,告別亂碼 | | 🌐 **65 種語言** | 跨國團隊無障礙使用 | +| 🎯 **智能推斷** | 自動預測目標格式與引擎,越用越懂你 | | 📊 **PDF 翻譯** | PDFMathTranslate + BabelDOC 雙引擎 | | 📄 **PDF 轉 MD** | MinerU 智能擷取(保留表格、公式、圖片) | diff --git a/docs/功能說明/自動格式推斷.md b/docs/功能說明/自動格式推斷.md new file mode 100644 index 0000000..659103b --- /dev/null +++ b/docs/功能說明/自動格式推斷.md @@ -0,0 +1,137 @@ +# 🎯 自動格式推斷系統 + +> ConvertX-CN 透過本地小模型與行為學習,自動推斷使用者最可能的格式搜尋與引擎選擇,並在不干擾操作自由的前提下,提前完成引擎準備以降低轉檔延遲。 + +## ✨ 核心亮點 + +- **🧠 智能推斷**:基於檔案特徵 + 使用者歷史行為,自動預測最可能的目標格式 +- **⚡ 引擎預調用**:高信心度時提前 warm-up 引擎,降低冷啟動延遲 +- **📊 持續學習**:每次轉檔都會更新使用者偏好模型,推薦越用越準 +- **🔒 完全本地**:不使用任何付費 API,所有推斷在本地完成 +- **🎛️ 無干擾設計**:使用者可隨時點擊 X 清除推薦,系統會記錄為負樣本 + +## 🏗️ 系統架構 + +``` +[檔案上傳] → [特徵抽取] → [格式候選生成] → [格式預測模型] → [引擎預測模型] → [預調用] + ↑ ↑ ↑ + 規則配置 使用者 Profile 引擎能力矩陣 +``` + +## 📁 模組結構 + +``` +src/inference/ +├── index.ts # 統一導出 +├── inferenceService.ts # 推斷服務主入口 +├── featureExtraction.ts # 檔案特徵抽取 +├── formatCandidateRules.ts # 格式候選規則配置 +├── formatPredictionModel.ts # 格式預測模型 +├── enginePredictionModel.ts # 引擎預測模型 +├── behaviorStore.ts # 使用者行為資料儲存 +└── engineWarmup.ts # 引擎預調用管理 + +public/ +└── inference.js # 前端推斷整合 + +src/pages/ +└── inference.tsx # 推斷 API 端點 +``` + +## 🔧 使用方式 + +### 後端 API + +```typescript +// 請求格式推斷 +POST /inference/predict +{ + "ext": "jpg", + "file_size_kb": 2400 +} + +// 回應 +{ + "success": true, + "data": { + "format": { + "search_format": "png", + "confidence": 0.82, + "top_k": [ + { "format": "png", "score": 0.82 }, + { "format": "webp", "score": 0.75 } + ] + }, + "engine": { + "engine": "vips", + "confidence": 0.88, + "should_warmup": true + }, + "should_auto_fill": true + } +} +``` + +### 前端整合 + +```javascript +// 上傳檔案後自動觸發推斷 +if (window.inferenceModule) { + const result = await inferenceModule.requestFormatInference("jpg", 2400); + if (result?.should_auto_fill) { + inferenceModule.autoFillInferredFormat(result.format.search_format); + } +} +``` + +## 📊 資料 Schema + +### 轉檔事件 + +```json +{ + "event": "conversion_completed", + "user_id": 123, + "input_ext": "jpg", + "searched_format": "png", + "selected_engine": "vips", + "success": true, + "duration_ms": 820 +} +``` + +### 使用者 Profile + +```json +{ + "user_id": 123, + "format_preferences": { + "jpg->png": 0.71, + "jpg->webp": 0.19 + }, + "engine_preferences": { + "png": { "vips": 0.65, "imagemagick": 0.3 } + }, + "recent_formats": ["png", "webp", "pdf"] +} +``` + +## ⚙️ 配置選項 + +在 `InferenceServiceConfig` 中可配置: + +| 選項 | 預設值 | 說明 | +| --------------------------- | ------ | ------------------ | +| `formatConfidenceThreshold` | 0.4 | 格式推斷最低信心度 | +| `engineConfidenceThreshold` | 0.5 | 引擎推斷最低信心度 | +| `enableWarmup` | true | 是否啟用預調用 | +| `warmupConfidenceThreshold` | 0.7 | 預調用觸發閾值 | + +## 🧪 驗收測試 + +- ✅ 使用者歷史偏好 `jpg→png` → 新 jpg 自動填 `png` +- ✅ 高解析 jpg → vips 優先於 imagemagick +- ✅ 小圖示 jpg → imagemagick 優先 +- ✅ 不會在圖片流程預調用 ffmpeg +- ✅ 使用者點 X → 推斷被記為負樣本 +- ✅ 多次使用後推薦準確率提升 diff --git a/knip.json b/knip.json index 6ca17e6..baef885 100644 --- a/knip.json +++ b/knip.json @@ -5,7 +5,7 @@ "tailwind": { "entry": ["src/main.css"] }, - "ignore": ["src/i18n/**", "tests/e2e/helpers.ts"], + "ignore": ["src/i18n/**", "tests/e2e/helpers.ts", "src/inference/**"], "ignoreDependencies": [], "ignoreExportsUsedInFile": true } diff --git a/public/inference.js b/public/inference.js new file mode 100644 index 0000000..7a3c949 --- /dev/null +++ b/public/inference.js @@ -0,0 +1,261 @@ +/** + * 自動格式推斷前端模組 + * + * 在使用者上傳檔案後,自動推斷最可能的目標格式並填入搜尋欄 + */ + +// @ts-check + +/** + * @typedef {Object} FormatPrediction + * @property {string} search_format - 預測的搜尋格式 + * @property {number} confidence - 預測信心度 (0-1) + * @property {Array<{format: string, score: number}>} top_k - Top-K 候選格式 + * @property {string[]} reason_codes - 預測原因碼 + */ + +/** + * @typedef {Object} EnginePrediction + * @property {string} engine - 預測的引擎名稱 + * @property {number} confidence - 預測信心度 (0-1) + * @property {boolean} should_warmup - 是否應該預調用 + * @property {number} cold_start_cost - 預估冷啟動成本 (毫秒) + * @property {string} reason - 預測原因 + */ + +/** + * @typedef {Object} InferenceResult + * @property {FormatPrediction|null} format - 格式推斷結果 + * @property {EnginePrediction|null} engine - 引擎推斷結果 + * @property {boolean} should_auto_fill - 是否應自動填入 + */ + +// 取得 webroot +const inferenceWebrootMeta = document.querySelector("meta[name='webroot']"); +const inferenceWebroot = inferenceWebrootMeta + ? inferenceWebrootMeta.getAttribute("content") || "" + : ""; + +// 狀態追蹤 +let inferenceEnabled = true; +/** @type {string|null} */ +let lastInferredFormat = null; +/** @type {string|null} */ +let lastInferredEngine = null; +let isInferredValue = false; + +/** + * 請求格式推斷 + * @param {string} ext - 檔案副檔名 + * @param {number} [fileSizeKb] - 檔案大小 (KB) + * @returns {Promise} + */ +async function requestFormatInference(ext, fileSizeKb) { + if (!inferenceEnabled) { + return null; + } + + try { + const response = await fetch(`${inferenceWebroot}/inference/predict`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + ext: ext, + file_size_kb: fileSizeKb, + }), + }); + + const result = await response.json(); + + if (result.success && result.data) { + return result.data; + } + + return null; + } catch (error) { + console.warn("Format inference request failed:", error); + return null; + } +} + +/** + * 記錄推薦被拒絕 + * @param {string} inputExt - 輸入副檔名 + * @param {string} dismissedFormat - 被拒絕的格式 + * @param {string} [dismissedEngine] - 被拒絕的引擎 + */ +async function logDismissEvent(inputExt, dismissedFormat, dismissedEngine) { + try { + await fetch(`${inferenceWebroot}/inference/dismiss`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + input_ext: inputExt, + dismissed_format: dismissedFormat, + dismissed_engine: dismissedEngine, + }), + }); + } catch (error) { + console.warn("Failed to log dismiss event:", error); + } +} + +/** + * 取消預調用 + */ +async function cancelWarmup() { + try { + await fetch(`${inferenceWebroot}/inference/cancel-warmup`, { + method: "POST", + }); + } catch (error) { + console.warn("Failed to cancel warmup:", error); + } +} + +/** + * 自動填入推斷的格式 + * @param {string} format - 推斷的格式 + * @param {string} [engine] - 推斷的引擎 + */ +function autoFillInferredFormat(format, engine) { + /** @type {HTMLInputElement|null} */ + const searchInput = document.querySelector("input[name='convert_to_search']"); + const convertToPopup = document.querySelector(".convert_to_popup"); + + if (!searchInput || !convertToPopup) { + console.warn("Search input or popup not found"); + return; + } + + // 儲存推斷值 + lastInferredFormat = format; + lastInferredEngine = engine || null; + isInferredValue = true; + + // 填入搜尋欄 + searchInput.value = format; + + // 觸發 input 事件以過濾結果 + const inputEvent = new Event("input", { bubbles: true }); + searchInput.dispatchEvent(inputEvent); + + // 添加視覺提示 (使用淡色邊框) + searchInput.style.borderColor = "#22c55e"; + searchInput.style.borderWidth = "2px"; + + // 3秒後恢復原樣 + setTimeout(() => { + if (isInferredValue && searchInput.value === format) { + searchInput.style.borderColor = ""; + searchInput.style.borderWidth = ""; + } + }, 3000); + + console.log(`🎯 Auto-filled format: ${format}${engine ? ` (engine: ${engine})` : ""}`); +} + +/** + * 處理搜尋欄清除事件 (使用者點擊 X) + * @param {string} inputExt - 輸入副檔名 + */ +function handleSearchClear(inputExt) { + if (isInferredValue && lastInferredFormat) { + // 記錄為負樣本 + logDismissEvent(inputExt, lastInferredFormat, lastInferredEngine || undefined); + + // 取消預調用 + cancelWarmup(); + + console.log(`❌ User dismissed inference: ${lastInferredFormat}`); + } + + // 重置狀態 + isInferredValue = false; + lastInferredFormat = null; + lastInferredEngine = null; +} + +/** + * 處理使用者手動輸入 + */ +function handleManualInput() { + if (isInferredValue) { + // 使用者手動修改,取消預調用 + cancelWarmup(); + isInferredValue = false; + + // 恢復邊框樣式 + /** @type {HTMLInputElement|null} */ + const searchInput = document.querySelector("input[name='convert_to_search']"); + if (searchInput) { + searchInput.style.borderColor = ""; + searchInput.style.borderWidth = ""; + } + } +} + +/** + * 初始化推斷模組 + * 需要在頁面載入後呼叫 + */ +function initInferenceModule() { + // 監聽搜尋欄的 search 事件 (當使用者點擊 X 時觸發) + /** @type {HTMLInputElement|null} */ + const searchInput = document.querySelector("input[name='convert_to_search']"); + + if (searchInput) { + // 監聯清除事件 + searchInput.addEventListener("search", () => { + // @ts-expect-error - fileType is set by script.js + const fileType = window.fileType || ""; + handleSearchClear(fileType); + }); + + // 監聽手動輸入 + searchInput.addEventListener("input", (e) => { + // 如果是程式設定的值,不處理 + if (e.isTrusted && isInferredValue) { + const currentValue = searchInput.value; + if (currentValue !== lastInferredFormat) { + handleManualInput(); + } + } + }); + } + + console.log("✅ Inference module initialized"); +} + +/** + * 啟用/停用推斷功能 + * @param {boolean} enabled + */ +function setInferenceEnabled(enabled) { + inferenceEnabled = enabled; + console.log(`Inference ${enabled ? "enabled" : "disabled"}`); +} + +// 導出到全域 +// @ts-expect-error - Define on window object +window.inferenceModule = { + requestFormatInference, + autoFillInferredFormat, + handleSearchClear, + handleManualInput, + setInferenceEnabled, + initInferenceModule, + logDismissEvent, + cancelWarmup, +}; + +// 頁面載入後初始化 +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initInferenceModule); +} else { + initInferenceModule(); +} diff --git a/public/script.js b/public/script.js index e7652a3..a24b0d4 100644 --- a/public/script.js +++ b/public/script.js @@ -100,6 +100,9 @@ function handleFile(file) { .then((html) => { selectContainer.innerHTML = html; updateSearchBar(); + + // 🎯 觸發格式推斷 + triggerFormatInference(fileType, file.size); }) .catch(console.error); } @@ -415,3 +418,88 @@ formConvert.addEventListener("submit", () => { }); updateSearchBar(); + +// ==================== 格式推斷功能 ==================== + +/** + * 觸發格式推斷 + * @param {string} ext - 檔案副檔名 + * @param {number} fileSize - 檔案大小 (bytes) + */ +async function triggerFormatInference(ext, fileSize) { + // 檢查推斷模組是否可用 + if (!window.inferenceModule) { + console.warn("Inference module not loaded"); + return; + } + + const fileSizeKb = Math.round(fileSize / 1024); + + try { + const result = await window.inferenceModule.requestFormatInference(ext, fileSizeKb); + + if (result && result.should_auto_fill && result.format) { + // 自動填入推斷的格式 + window.inferenceModule.autoFillInferredFormat( + result.format.search_format, + result.engine?.engine, + ); + + // 嘗試自動選擇對應的引擎選項 + if (result.engine) { + autoSelectEngine(result.format.search_format, result.engine.engine); + } + } + } catch (error) { + console.warn("Format inference failed:", error); + } +} + +/** + * 自動選擇推薦的引擎 + * @param {string} format - 目標格式 + * @param {string} engine - 推薦引擎 + */ +function autoSelectEngine(format, engine) { + // 尋找對應的目標按鈕 + const targetButtons = document.querySelectorAll(".target"); + + for (const button of targetButtons) { + const targetFormat = button.dataset.target; + const converter = button.dataset.converter; + + // 優先匹配格式和引擎 + if ( + targetFormat === format && + converter && + converter.toLowerCase().includes(engine.toLowerCase()) + ) { + // 模擬點擊 + button.click(); + console.log(`🎯 Auto-selected: ${format} using ${converter}`); + return; + } + } + + // 如果沒有精確匹配,只匹配格式 + for (const button of targetButtons) { + const targetFormat = button.dataset.target; + + if (targetFormat === format) { + button.click(); + console.log(`🎯 Auto-selected format: ${format}`); + return; + } + } +} + +// 將 fileType 暴露給推斷模組 +window.fileType = fileType; + +// 監聽 fileType 變化 +Object.defineProperty(window, "fileType", { + get: () => fileType, + set: (value) => { + fileType = value; + }, +}); diff --git a/src/components/base.tsx b/src/components/base.tsx index d6925d2..95fdc1f 100644 --- a/src/components/base.tsx +++ b/src/components/base.tsx @@ -80,6 +80,7 @@ export const BaseHtml = ({