From 45169ab25e974085aaaebaceeb4a67f785239d1b Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Sat, 15 Aug 2026 00:17:03 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(build):=20=E4=B8=80=E5=80=8B=E5=AF=A6?= =?UTF-8?q?=E4=BE=8B=E6=9C=83=E7=94=A8=E5=88=B0=E7=9A=84=E6=AF=8F=E4=B8=80?= =?UTF-8?q?=E9=A1=86=20worker=20=E9=83=BD=E5=9C=A8=20Arcrun=20=E7=B7=A8?= =?UTF-8?q?=EF=BC=88D91=EF=BC=8FArcrun#125=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 官方編譯點從 5 顆擴到 23 顆,並把 portal 前端的產生器從 arcrun-rag 搬回來。 為什麼不是「順手多編幾顆」: - 只編 5 顆 ⇒ 下游 bundle 裡就只有 5 顆 ⇒「零件用到才下載」沒有貨可以下載。 而 auth_static_key 是每一支產品工作流解 {{credential.X}} 都會打的那一顆 ⇒ 走網頁安裝器裝出來的實例,工作流一跑就 500(Arcrun#124 的 error code: 1042)。 - portal 前端(arcrun-rag-ui)當時仍由 arcrun-rag 自己拼裝原始碼 ⇒ 使用者拿到的那顆 worker,沒有任何 Arcrun commit 說得出它的來源。 leo:「今天開始出貨一律不准在 arcrun rag 或任何別的地方 build,這就是 arcrun 的專屬工作。」 做法: - 零件 worker 改用**掃描** .component-builds/(不是手寫清單——手寫清單就是 arcrun-rag#27/D48 那個病的形狀),worker 名一律讀該零件自己的 wrangler.toml。 - 可出貨的判準=**部署位元組在版控裡**。.gitignore 明文排除的三顆 (claude_api/km_writer/kbdb_upsert_block,DECISIONS §1「錯做成零件」) 因此自動落在公庫外,而且是**寫進 manifest.excluded 的**,不是安靜跳過。 - 輸出目錄改成每次只留這一輪該有的,清掉舊成品(拔掉棘輪)。 - UI 產生器原樣搬過來,兩道閘(世代閘、前端 JS 語法閘)一起搬—— 它們驗的是本 repo 的 console-ui/public,本來就該長在來源這一側。 實測(本機 worktree,依 lockfile 重裝依賴後): - 23/23 編成功;四顆既有引擎成品**位元組完全重現** committed 值 (cypher 8e6487478bc8/kbdb f15e16cca6ce/code 751634a3fc9a/mcp 9bc8dffb34d0/ http-request cdd97364f277)⇒ 這次擴充沒有動到既有零件。 - arcrun-rag-ui 的產出與搬家前逐位元比對,**只差第一行註解** (那行必須改:它原本指向一支不再產生它的腳本)。 Co-Authored-By: Claude Opus 5 --- scripts/build-ui-worker.mjs | 208 +++++++++++++++++++++++++++++ scripts/build-worker-artifacts.mjs | 159 ++++++++++++++++++++-- 2 files changed, 357 insertions(+), 10 deletions(-) create mode 100644 scripts/build-ui-worker.mjs diff --git a/scripts/build-ui-worker.mjs b/scripts/build-ui-worker.mjs new file mode 100644 index 0000000..1ce075f --- /dev/null +++ b/scripts/build-ui-worker.mjs @@ -0,0 +1,208 @@ +#!/usr/bin/env node +/** + * build-ui-worker.mjs — 把 `console-ui/public` 內嵌成一顆單檔 worker(`arcrun-rag-ui`)。 + * + * 🔴 2026-08-15(D91/Arcrun#125):這支是**從 arcrun-rag 搬過來的**。 + * leo 原話:「今天開始出貨一律不准在 arcrun rag 或任何別的地方 build, + * **這就是 arcrun 的專屬工作**。你告訴我要把 cypher 搬到 arcrun,我說好, + * 結果搞到現在還用違反的方式。」 + * + * 實況(2026-08-14 查):五顆 tier2 worker 早就改成「向 Arcrun 取用成品」了 + * (`build-worker-artifacts.mjs` → `.worker-builds/`),**只有這顆 UI 沒搬**—— + * arcrun-rag 的 `installer/scripts/build-ui-bundle.mjs` 仍然自己讀本 repo 的 + * `console-ui/public`、自己拼裝出 worker 原始碼。於是: + * · 用戶拿到的 portal 前端,其位元組沒有任何一個 Arcrun commit 說得出來源 + * · 「成品只有一個產地」這句話對六顆零件裡的五顆成立,對第六顆不成立 + * ⇒ 規則不是被誰違反,是**它從來沒有被機械驗證過**(同 D54/D64/D90 的形狀)。 + * + * 搬過來之後,UI 與其他零件走同一條路: + * 產地(本 repo)→ `.worker-builds/arcrun-rag-ui/worker.mjs` + manifest 指紋 + * → arcrun-rag 只做「複製 + 算版本」,不再有任何一行產生 worker 原始碼。 + * + * ⚠️ 內嵌邏輯與兩道閘(世代閘、前端 JS 語法閘)**原樣搬過來,一個字沒改**—— + * 它們驗的是本 repo 的 `console-ui/public`,本來就該長在來源這一側。 + * 唯一改動:產出物開頭那行註解改成指向本檔(不留「描述不存在機制的註解」)。 + * + * 對外只有一個 API:`buildUiWorker({ uiDir })` → `{ source, fingerprint, fileCount }`。 + * 寫檔與 manifest 由 `build-worker-artifacts.mjs` 統一處理(產物格式與其他零件一致)。 + */ +import { readdirSync, statSync, readFileSync, writeFileSync } from 'node:fs'; +import { join, relative, extname } from 'node:path'; +import { createHash } from 'node:crypto'; + +/** + * @param {{ uiDir: string }} opts uiDir=`console-ui/public` 的絕對路徑 + * @returns {Promise<{ source: string, fingerprint: string, fileCount: number }>} + */ +export async function buildUiWorker({ uiDir }) { + const UI = uiDir; + + const TYPES = { + '.html': 'text/html; charset=utf-8', + '.js': 'application/javascript; charset=utf-8', + '.mjs': 'application/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.ico': 'image/x-icon', + }; + const TEXT_EXT = new Set(['.html', '.js', '.mjs', '.css', '.json', '.svg']); + // config.js 由 worker 動態產生(apiBase 注入),不吃靜態版 + const SKIP = new Set(['/config.js']); + + // ⚠️ 一定要 sort():指紋是 JSON.stringify(files) 算的,鍵的順序=目錄列舉順序。 + // 不排序的話同樣的內容在不同機器會得到不同指紋(B 版就漏了這一步)。 + function walk(dir, base, out) { + for (const name of readdirSync(dir).sort()) { + const p = join(dir, name); + if (statSync(p).isDirectory()) { walk(p, base, out); continue; } + const key = '/' + relative(base, p).split('\\').join('/'); + if (SKIP.has(key)) continue; + const ext = extname(name); + const type = TYPES[ext]; + if (!type) continue; // 未知型別不進 bundle + if (TEXT_EXT.has(ext)) out[key] = { type, b64: false, data: readFileSync(p, 'utf8') }; + else out[key] = { type, b64: true, data: readFileSync(p).toString('base64') }; + } + } + + // ── 閘① t160 世代閘(B 版)───────────────────────────────────────────── + // 打包前驗 portal 頁指紋——舊世代(缺「不需要人工新增」文案/含人工建庫「登記新庫」表單) + // 直接拒打包。病史:t159 重打包吃了停在舊世代的分支 public/,把 leo 實例的 UI 打回 + // 被淘汰的人工登記典範。 + // 🔴 2026-08-02 修誤判:原本直接對整份原始碼 grep「登記新庫」,但**現行世代的 HTML + // 註解裡就寫著「07-27 leo:拿掉『登記新庫』…」**——那是「已經拿掉了」的紀錄, + // 是新世代的證據,卻被當成舊世代特徵 ⇒ 閘把對的東西擋下來。 + // 正解:**先剝掉註解再驗**,只看用戶真的會看到的 UI 內容。 + { + const portalRaw = readFileSync(join(UI, 'portal', 'index.html'), 'utf8'); + const portal = portalRaw.replace(//g, ''); + if (!portal.includes('不需要人工新增') || portal.includes('登記新庫')) { + console.error('✘ 世代閘:--ui 指向的 public 不是現行世代(缺「不需要人工新增」或含「登記新庫」)——拒絕打包舊 UI。'); + console.error(` 剝註解後:不需要人工新增=${portal.includes('不需要人工新增')}/登記新庫=${portal.includes('登記新庫')}`); + process.exit(1); + } + } + + const files = {}; + walk(UI, UI, files); + if (!files['/portal/index.html']) throw new Error(`/portal/index.html 不在 ${UI} — 路徑錯了?`); + + // ── 閘② t131 前端 JS 語法閘(A 版)──────────────────────────────────── + // 2026-07-29 事故:誤刪一個 `})();` ⇒ portal 白畫面,驗收沒抓到—— + // 因為測試只測後端、grep 只看字串,**沒有人驗過前端 JS 語法**。 + // 打包前用 node --check 驗每個 HTML 內嵌 script,語法錯就拒絕產出 bundle。 + { + const { execFileSync } = await import('node:child_process'); + const { mkdtempSync } = await import('node:fs'); + const { tmpdir } = await import('node:os'); + const tmp = mkdtempSync(join(tmpdir(), 'uicheck-')); + for (const [name, f] of Object.entries(files)) { + if (!name.endsWith('.html') || f.b64) continue; + const blocks = [...String(f.data).matchAll(/]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/g)]; + blocks.forEach((m, i) => { + const jsPath = join(tmp, `${name.replace(/[^\w]/g, '_')}_${i}.js`); + writeFileSync(jsPath, m[1]); + try { + execFileSync(process.execPath, ['--check', jsPath], { stdio: 'pipe' }); + } catch (e) { + const msg = String(e.stderr || e.message).split('\n').slice(0, 6).join('\n'); + throw new Error(`❌ ${name} 的第 ${i + 1} 個 \n\n\n\n\n\n
\n
\n
Arcrun 駕駛艙
\n
\n
\n \n
\n
\n
\n
\n
\n
載入中
\n
\n
\n
\n
\n
\n
今日完成
\n
/
\n
\n
\n
\n
收件匣未處理
\n
\n
來自 Telegram
\n
\n
\n
\n
等你的事
\n
載入中…
\n
\n
\n
今日路線
\n
  • 載入中…
\n
本週
\n
    \n
    系統狀況live 健康信號
    \n
    載入中…
    \n
    每 60 秒自動刷新
    \n 進入完整控制台 ›\n
    \n\n\n\n"},"/console/index.html":{"type":"text/html; charset=utf-8","b64":false,"data":"\n\n\n\n\nArcrun Console\n\n\n\n\n\n\n\n\n
    \n
    \n
    \n
    Arcrun
    \n
    私人系統・僅供擁有者進入
    \n
    \n
    \n \n \n \n
    \n
    \n
    這是一個人的智慧總部。
    若你不是擁有者,這裡沒有你要找的東西。
    \n \n \n
    \n
    \n\n\n
    \n
    \n
    \n
    Arcrun
    \n
    首次設定
    \n
    系統偵測到尚未設定擁有者帳號。
    設定一組 Email 與密碼,之後只有你能進入。
    \n
    \n
    \n \n \n \n \n
    \n
    \n \n
    \n
    \n\n\n
    \n
    \n
    Arcrun
    \n \n
    總庫搜尋
    \n
    工作流
    \n \n
    設定
    \n
    ☾ 切深色
    \n
    \n
    \n
    \n\n \n
    \n
    Arcrun 控制台
    \n
    \n
    \n
    \n
    \n
    \n
    載入中
    \n
    \n
    \n
    \n
    \n
    \n
    今日完成
    \n
    /
    \n
    \n
    \n
    \n
    收件匣未處理
    \n
    \n
    來自 Telegram
    \n
    \n
    \n
    \n
    等你的事
    \n
    載入中…
    \n
    \n
    \n
    \n
    \n
    \n 今日路線狀態即時同步\n
    \n
    • 載入中…
    \n
    本週
    \n
      \n
      每 60 秒自動刷新
      \n
      \n
      \n
      \n\n \n
      \n
      總庫搜尋
      \n
      \n
      \n
      \n 藏書地圖\n
      \n
      地圖載入中…
      \n
      \n
      \n
      \n \n \n
      \n
      \n
      \n
      \n
      \n
      \n
      \n
      \n\n \n
      \n
      \n \n 知識卡片\n
      \n
      \n
      載入中…
      \n
      \n
      關聯視圖
      \n
      \n
      \n
      \n
      \n
      \n\n \n
      \n
      工作流
      \n \n
      載入中…
      \n
      \n
      零件與 Recipes(框架資源)
      \n
      \n \n \n
      \n
      \n
      \n
      \n\n \n
      \n
      憑證管理
      \n
      \n 🛡 保險箱原則:系統只保存目錄與加密後的值,任何頁面都看不到密文內容。只能整筆替換或刪除。\n
      \n
      \n
      新增憑證
      \n
      \n \n \n \n \n
      \n
      \n
      \n
      載入中…
      \n
      \n\n \n
      \n
      分流台全部待辦・80/15/5
      \n
      \n
      載入中…
      \n
      \n\n \n
      \n
      設定
      \n
      \n
      \n
      \n
      \n
      深色模式
      \n
      預設淺色(紙感)。切換立即生效,選擇記在這台裝置(localStorage)。
      \n
      \n
      \n
      \n
      \n
      \n \n
      語意搜尋
      \n
      狀態偵測中…
      \n
      \n
      \n
      \n
      MCP token 有效期(TTL)
      \n
      讀取中…
      \n
      \n 誠實佔位:此頁還不能改這個值——可調功能=Arcrun#19(設定頁改→存 KBDB→發 token 時讀)。實作前要調整請在部署端 env MCP_TOKEN_TTL(mcp worker)設定。\n
      \n
      \n
      \n
      更換帳號密碼
      \n
      需輸入舊密碼驗證身分
      \n
      \n \n \n \n \n
      \n
      \n
      \n \n
      \n
      Portal 帳號密碼救援
      \n
      忘記某個 Portal(RAG 搜尋頁)帳號的密碼,包含你自己那組管理員帳號——不需要先登進 Portal。輸入該帳號的 Email,會產生一組新密碼,只顯示這一次,請立刻抄下並拿去 Portal 登入頁使用。
      \n
      \n \n \n
      \n
      \n
      \n
      \n
      系統資訊
      \n
      載入中…
      \n
      \n \n
      \n
      \n\n
      \n
      \n\n\n
      \n \n
      搜尋
      \n
      工作流
      \n \n
      設定
      \n
      \n\n
      \n\n\n\n\n"},"/favicon.ico":{"type":"image/x-icon","b64":true,"data":"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"},"/favicon.svg":{"type":"image/svg+xml","b64":false,"data":"arcrun icon"},"/index.html":{"type":"text/html; charset=utf-8","b64":false,"data":"\n\n\n\nArcrun RAG\n\n\n\n\n\n\n\n

      正在前往搜尋頁… 沒有自動跳轉請點這裡

      \n\n\n"},"/portal/index.html":{"type":"text/html; charset=utf-8","b64":false,"data":"\n\n\n\n\nArcrun Portal\n\n\n\n\n\n\n\n\n\n\n\n
      \n
      \n
      \n
      \"arc>run\" width=\"986\" height=\"176\">\"arc>run\" width=\"986\" height=\"176\">
      \n
      知識入口・Portal
      \n
      \n
      \n \n \n \n
      \n
      \n \n \n
      \n \n \n
      \n
      \n \n
      \n
      \n\n\n
      \n
      \n
      \n
      設定新密碼
      \n
      \n
      \n
      \n \n
      \n
      \n
      \n\n\n
      \n
      \n
      \n
      \"arc>run\" width=\"986\" height=\"176\">\"arc>run\" width=\"986\" height=\"176\">
      \n
      歡迎!先建立你的帳號
      \n
      \n
      \n \n \n \n \n
      \n \n \n
      \n
      這組帳密就是之後登入知識庫用的,只需要設定這一次。
      \n
      \n
      \n\n\n
      \n
      \n
      \"arcrun\"\"arcrun\"
      \n
      搜尋
      \n
      總圖
      \n
      上傳
      \n
      工作流
      \n
      管理
      \n
      設定
      \n
      ☾ 切深色
      \n
      \n
      \n
      \n\n \n
      \n
      搜尋
      \n
      \n
      \n \n \n
      \n
      \n \n \n \n
      \n \n
      \n
      AI 問答・答案附出處,可回搜尋驗證
      \n
      \n \n \n
      \n
      \n
      \n
      \n
      \n
      \n
      \n
      \n
      \n
      \n
      \n
      \n\n \n
      \n
      總圖
      \n
      \n
      \n
      \n 點節點可跳到該實體的圖譜搜尋。這張圖由知識庫的三元組即時算出——同一份地圖的文字版\n (給 AI 注入用的總庫目錄)。\n
      \n
      \n
      \n\n \n
      \n
      \n \n 知識卡片\n
      \n
      載入中…
      \n
      \n\n \n
      \n
      上傳Markdown / 純文字
      \n
      \n 上傳的文件會進入知識庫收件管線:約 1 分鐘後可在搜尋頁找到;AI 整理後會以 wiki 卡形式出現。支援 .md / .txt(一律以 .md 收件)。\n
      \n
      \n
      把 .md / .txt 檔案拖放到這裡
      \n
      \n \n \n
      \n
      \n
      \n\n \n
      \n
      工作流唯讀・系統狀態
      \n
      \n 此頁只顯示系統裡的工作流與最近執行狀態,不能觸發執行(觸發屬系統擁有者權限)。\n
      \n
      載入中…
      \n
      \n\n \n
      \n
      設定
      \n
      \n \n
      \n
      \n
      \n
      \n 版本\n \n
      \n
      查詢中…
      \n
      \n \n
      \n
      \n
      \n
      我的帳號
      \n
      載入中…
      \n
      \n
      \n
      \n
      \n
      深色模式
      \n
      預設淺色(紙感)。切換立即生效,選擇記在這台裝置。
      \n
      \n
      \n
      \n
      \n \n
      \n
      更改密碼
      \n
      需輸入舊密碼驗證身分;新密碼至少 8 碼
      \n
      \n \n \n \n \n
      \n
      \n
      \n \n
      \n \n
      \n 你的知識庫網址(小幫手連線用)\n \n \n
      \n
      同步小幫手
      \n
      把你電腦上的資料夾變成知識庫。換電腦或重裝時可以再下載一次。
      \n
      \n 下載 Mac 版\n
      封測版未簽章,第一次請右鍵→打開。裝好第一次開啟時,貼上這個網址+你的帳號密碼就連上了。
      \n
      \n
      \n \n
      \n
      \n 你的 MCP 網址(給你的 AI 連線用)\n \n \n
      \n
      接上你的 AI(MCP)
      \n
      把上面這串網址貼到 Claude、ChatGPT 等 AI 的「新增自訂連接器」欄位,就能讓你的 AI 直接查這個知識庫。
      \n
      \n \n
      \n
      AI 設定
      \n
      \n 這裡不需要任何設定。聊天問答用的是你自己 Cloudflare 帳號內建的 AI,裝好就能直接問。
      \n 文件整理成知識卡的部分,請在同步小幫手(電腦上的托盤圖示)的「AI 設定…」填一把 Gemini API Key。\n
      \n
      \n \n
      \n
      疑難排解
      \n
      要回報問題,請到你電腦上的 Arcrun(同步小幫手)「版本與更新」頁——那裡的「疑難排解」按一下就能匯出完整診斷檔給我們(只有統計數字,不含你的任何文件內容)。
      \n
      \n \n
      \n
      \n\n \n
      \n
      管理帳號與知識庫授權
      \n\n
      執行紀錄保留期
      \n
      \n
      保留天數
      \n
      執行紀錄是稽核資料,超過保留天數會被每日自動清除;預設 90 天(3 個月),也可設為「不刪除」(企業稽核用途)。
      \n
      \n \n \n \n
      \n
      \n
      \n
      \n\n
      帳號管理
      \n
      \n
      新增同仁帳號
      \n
      建立後會產生一組一次性密碼——只顯示這一次,請當場轉交同仁(同仁可在「設定」自行改密)。
      \n
      \n \n \n \n \n
      \n
      \n
      \n
      \n
      載入中…
      \n\n
      庫目錄管理
      \n
      \n 你的知識庫網址(小幫手連線用)\n \n \n
      \n
      \n \n 裝好同步小幫手並選好要看守的資料夾之後,每個資料夾會自動成為一個「庫」出現在下面——不需要人工新增。下面還是空的,代表小幫手還沒裝好或還沒選資料夾。\n
      \n
      載入中…
      \n
      \n\n
      \n
      \n\n\n
      \n
      搜尋
      \n
      總圖
      \n
      上傳
      \n
      工作流
      \n
      管理
      \n
      設定
      \n
      \n\n
      \n\n\n\n"},"/portal/os-split.test.mjs":{"type":"application/javascript; charset=utf-8","b64":false,"data":"import fs from 'node:fs';\nconst html = fs.readFileSync(new URL('./index.html', import.meta.url).pathname,'utf8');\n// 抽出 daemonPick 相關函式(從 DAEMON_BASE_DEFAULT 到 daemonHint 結尾)\n//\n// 🔴 2026-08-05:結尾標記本來寫死 daemonHint 的**整句文案**,於是同日改 Mac 提示語\n// (zip→DMG 的步驟不同)就讓這支自測直接炸「抽不到函式區塊」,而且沒人發現。\n// ⇒ 改成錨定「函式結束」這個結構,不再綁文案——文案本來就會改,測試不該為此壞掉。\nconst start = html.indexOf('var DAEMON_BASE_DEFAULT');\nconst hintAt = html.indexOf('function daemonHint', start);\nconst endMark = '\\n }';\nconst end = hintAt < 0 ? -1 : html.indexOf(endMark, hintAt) + endMark.length;\nif (start < 0 || hintAt < 0 || end < start) throw new Error('抽不到函式區塊');\nconst src = html.slice(start, end);\n\nconst cases = [\n ['Windows', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36'],\n ['Mac', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Safari/605.1.15'],\n ['iPhone', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Safari/604.1'],\n ['Linux', 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120 Safari/537.36'],\n];\nlet pass=0, fail=0;\nconst chk=(l,c,extra='')=>{ if(c){console.log('PASS:',l);pass++;} else {console.log('FAIL:',l,extra);fail++;} };\n\nfor (const [name, ua] of cases) {\n const fn = new Function('navigator','window', src + '; return {daemonPick:daemonPick, daemonHint:daemonHint, daemonBase:daemonBase};');\n const api = fn({userAgent: ua}, {});\n const d = api.daemonPick();\n const label = d.sure ? d.pick.label : '(兩個都給)';\n const url = d.sure ? d.pick.url : d.mac.url + ' + ' + d.win.url;\n console.log(`\\n[${name}] sure=${d.sure} → ${label}`);\n console.log(` url: ${url}`);\n if (name==='Windows') {\n chk('Windows 給 win zip', d.sure && d.pick.url.endsWith('ArcrunRAG-win-unsigned.zip'), d.pick&&d.pick.url);\n chk('Windows 另一版是 Mac', d.other && d.other.url.endsWith('ArcrunRAG-mac.dmg'));\n chk('Windows 話術提 藍色視窗', api.daemonHint('win').includes('仍要執行'));\n }\n if (name==='Mac') {\n // 2026-08-05:Mac 一律給 DMG(拖進 Applications 的標準安裝畫面),不再給 zip\n // ——zip 解開就是一個裸 .app,使用者會直接在「下載」資料夾雙擊執行,自更新會蓋錯位置。\n chk('Mac 給 dmg(不是 zip)', d.sure && d.pick.url.endsWith('ArcrunRAG-mac.dmg'));\n chk('Mac 另一版是 Windows', d.other && d.other.url.endsWith('win-unsigned.zip'));\n chk('Mac 話術提 右鍵打開', api.daemonHint('mac').includes('右鍵'));\n }\n if (name==='iPhone' || name==='Linux') {\n // iPhone 含 \"Mac OS X\" 但不是桌機 Mac;Linux 兩者皆非 → 都該落在「不確定=兩個都給」\n if (name==='Linux') chk('Linux 判不出來→兩個都給', d.sure===false);\n if (name==='iPhone') chk('iPhone 不該被判成 Mac(手機→兩個都給)', d.sure===false, 'sure='+d.sure);\n }\n}\n// 舊 key 相容\nconst fn2 = new Function('navigator','window', src + '; return daemonBase();');\nconsole.log('\\n[相容] daemonDownload 舊 key →', fn2({userAgent:''},{ARCRUN_CONFIG:{daemonDownload:'https://x.dev/d/ArcrunRAG-mac-unsigned.zip'}}));\nchk('舊 key 推得出目錄', fn2({userAgent:''},{ARCRUN_CONFIG:{daemonDownload:'https://x.dev/d/ArcrunRAG-mac-unsigned.zip'}})==='https://x.dev/d/');\nchk('daemonBase 新 key 優先', fn2({userAgent:''},{ARCRUN_CONFIG:{daemonBase:'https://y.dev/z'}})==='https://y.dev/z/');\nconsole.log(`\\n=== ${pass} passed, ${fail} failed ===`);\nprocess.exit(fail?1:0);\n"},"/portal/safejson.test.mjs":{"type":"application/javascript; charset=utf-8","b64":false,"data":"import fs from 'node:fs';\nconst html = fs.readFileSync(new URL('./index.html', import.meta.url).pathname,'utf8');\n\n// 抽出 safeJson 與 friendlyErr 求值\nconst grab = (name) => {\n const i = html.indexOf(`function ${name}(`);\n if (i < 0) throw new Error(`找不到 ${name}`);\n let d=0, j=html.indexOf('{', i);\n for (let k=j;k{c?(console.log('PASS:',l),pass++):(console.log('FAIL:',l,e),fail++)};\n\n// ① safeJson:非 JSON 不可拋例外(同事撞到的 404 HTML 頁)\nconst html404 = '404 Not Found';\nawait fn.safeJson({ text: () => Promise.resolve(html404) })\n .then(d => t('404 HTML → 回空物件不拋錯', typeof d === 'object' && d !== null))\n .catch(e => t('404 HTML → 不該拋錯', false, e.message));\n\nawait fn.safeJson({ text: () => Promise.resolve('') })\n .then(d => t('空回應 → 回空物件', JSON.stringify(d)==='{}'))\n .catch(() => t('空回應 → 不該拋錯', false));\n\nawait fn.safeJson({ text: () => Promise.resolve('{\"error\":\"帳號或密碼不對\"}') })\n .then(d => t('正常 JSON 仍要解析得出來', d.error === '帳號或密碼不對'), )\n .catch(() => t('正常 JSON 不該拋錯', false));\n\n// ② friendlyErr:不可把技術訊息噴給使用者\nconst leak = fn.friendlyErr(new Error('Unexpected non-whitespace character after JSON at position 4'));\nt('JSON 錯誤 → 不外洩原文', !/JSON|position/i.test(leak), `實得: ${leak}`);\nt('JSON 錯誤 → 說人話', /伺服器回應異常/.test(leak), `實得: ${leak}`);\n\nconst net = fn.friendlyErr(new Error('Failed to fetch'));\nt('網路錯誤 → 既有訊息保留', /連線中斷/.test(net), `實得: ${net}`);\n\nconst ours = fn.friendlyErr(new Error('帳號或密碼不對——用你在知識庫網站設定的那組'));\nt('我們自己的中文訊息 → 原樣顯示', /帳號或密碼不對/.test(ours), `實得: ${ours}`);\n\nconst stack = fn.friendlyErr(new Error('TypeError: Cannot read properties of undefined'));\nt('英文技術訊息 → 收斂不外洩', !/TypeError|undefined/.test(stack), `實得: ${stack}`);\n\nconsole.log(`\\n=== ${pass} passed, ${fail} failed ===`);\nprocess.exit(fail?1:0);\n"}}; +const UI_FINGERPRINT = "82bc636990e60172663f7ce90c75d8b434193b8532b21f1d4b57c5fb330cbe11"; +function b64ToBytes(b64) { const bin = atob(b64); const a = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) a[i] = bin.charCodeAt(i); return a; } +export default { + async fetch(request, env) { + const url = new URL(request.url); + let p = url.pathname; + // 🔴 UI 自報版本(安裝器據此判斷要不要重推,取代「猜 favicon 特徵」)。 + // 比照 cypher 的 /health。舊世代的 UI 沒有這條路由 ⇒ SPA fallback 回 HTML + // ⇒ 安裝器解析 JSON 失敗=判定為舊版=重推。**天然向後相容,不需額外世代判斷。** + // bundle_version 由安裝器以 ARCRUN_BUNDLE_VERSION 注入(cypher /health 同一個值); + // 沒注入就回空字串,安裝器那端把空值一律當「舊的」處理。 + if (p === '/__version') { + // 🔴 2026-08-08:多報 worker_subdomain / config_ok 兩格。 + // 這條路由是**安裝器唯一會去問實例的地方**(probeInstanceStale), + // 以前它只報版本 ⇒「版本對、但 apiBase 空」的實例在安裝器眼中是「已是最新」 + // ⇒ 永遠不會被重推=壞掉的實例自己好不了。報了之後,安裝器把 + // config_ok:false 一律當成 stale ⇒ **下一次安裝/更新就自動治好它**。 + return new Response(JSON.stringify({ + ok: true, + ui_fingerprint: UI_FINGERPRINT, + bundle_version: env.ARCRUN_BUNDLE_VERSION || '', + worker_subdomain: env.WORKER_SUBDOMAIN || '', + config_ok: Boolean(env.WORKER_SUBDOMAIN), + }), { + headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }, + }); + } + // config.js 動態產生:apiBase 指向同帳號的 cypher(WORKER_SUBDOMAIN 由安裝器注入) + // + // 🔴 2026-08-08 事故(leo 的 youlin 實例整頁不能用,頂端紅字「設定檔沒載入」): + // 有人用 wrangler deploy 手動補這顆 worker ⇒ 繞過安裝器的變數注入 + // ⇒ WORKER_SUBDOMAIN 空 ⇒ 這裡**安靜地**退成 apiBase:""(而且回 HTTP 200!) + // ⇒ 前端連不到自己的後端,但**部署是「成功」的**:worker 活著、curl 200、 + // /__version 版本也對——只有真人打開瀏覽器才看得出壞了。 + // 正解不是「退一個合理的預設值」(沒有合理預設,猜錯更慘), + // 是**大聲失敗**:回 500+專用標頭,讓機器檢查一眼看穿。 + // 瀏覽器不執行非 2xx 的