Compare commits

...

10 Commits

Author SHA1 Message Date
uncle6me-web 341bcb13c8 t160 測試跟上規格:人工建庫 POST → 404(庫只從 daemon 同步來)
舊測試驗「POST 建庫 200+重複 409」=已刪端點的規格;vitest 回基線 9 failed/187 passed。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 14:57:22 +08:00
uncle6me-web 832f270f52 Merge branch 'feat/step6-success-rate' into feat/step3-missing-guidance 2026-07-31 14:54:25 +08:00
uncle6me-web 5c5109cd45 Merge branch 'feat/step4-node-substitution' into feat/step3-missing-guidance
# Conflicts:
#	system-dev/docs/3-specs/workflow-discovery/tasks.md
2026-07-31 14:54:25 +08:00
uncle6me-web e744ad1f96 t160 世代債清除(leo:「如果你會搞不清楚,就把錯的東西刪掉」)——repo 只留一個 UI 世代
事故鏈(07-31 深夜,leo 刷新撞到):t159 重打包 staging bundle 時,build-ui-bundle
吃了 feat/step3 分支的 console-ui/public/——該分支基底早於 main d7fab7c
「拿掉登記新庫」,public 停在舊世代 ⇒ youlin 實例 UI 被打回被淘汰的
「登記新庫」人工表單典範(07-27 mistakes 已記帳的 src/public 雙世代債,
這次由「分支副本停舊代」路徑引爆——債沒還,任何一個部署路徑都會引爆它)。

刪掉的(舊世代,git rm 實體刪除):
- console-ui/src/(console.ts/console-dashboard.ts/portal-ui.ts)=07-22 從
  cypher-executor 搬出的 renderer 快照,自 07-27 起與 public/ 真身脫鉤、只會
  重產舊 UI
- console-ui/scripts/build.mjs=從上述舊 renderer 重產 public 的 build 步驟
  (deploy.mjs 原自動跑它=引爆器本體)
- cypher POST /portal/admin/libraries(人工建庫端點)=死端點(e28e190 起 UI
  零呼叫;leo 規格「要直通 daemon,同步,沒有登記這回事」)。GET 列表/PATCH
  管理/t159 daemon 自動登記照舊

新唯一源:console-ui/public/=手改演進的真身(本 commit 同步到 main e28e190
世代+註解字樣改寫達成「登記新庫」全檔 0 命中);deploy.mjs 改為直接託管
public(無 build 步)+世代閘(缺「不需要人工新增」或含「登記新庫」拒部署)。

驗:tsc 全綠;deploy.mjs node --check 過;重打 ui bundle 242KB 過世代閘,
指紋「登記新庫」=0/「不需要人工新增」=1。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 14:36:48 +08:00
uncle6me-web cae0d7be3b 3.9 落帳+pending-changes 提案:workflow export/import 一等公民化(D35 ③,接線待 confirm)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 14:15:33 +08:00
uncle6me-web 062757f1b1 t159 修 portal 庫目錄空:補上 daemon 一直在打、但從未存在的登記端點
診斷更正(重要):kbdb 裡 50+ 筆 entry_type=value(content=資料夾名)**不是孤兒**——
那是 91 筆 triplet records 的 library slot 正常構造(records 全部組裝完整,資料同步
一切正常,無失敗無重試)。真病灶=**POST /portal/daemon/libraries 這個 route
從來不存在**:daemon registerLibraries(arcrun-tray main.go:505,t52「用戶可以看到
我有 2 個庫」)連線精靈時打它 → 404 → 被 daemon「失敗不擋連線」設計靜默吞掉
→ portal_library 登記簿永遠 0 筆 → portal「庫目錄管理」空。

- 新 route 契約照 daemon 既有呼叫:{email, password, libraries:[{name, display_name}]}
- 帳密驗證沿用 /portal/session 同一套(isLocked/findUserRecordId/verifyPassword/
  recordLoginFail;daemon 只在精靈那刻拿帳密不存)
- 冪等:listRecordsByTemplate 比 name,已登記跳過(重跑精靈不堆重複)
- 庫名走 isValidLibraryName 同閘(拒 "*" 與非法字元)

驗:tsc 全綠;youlin 實例資料層已手動補登記(探針 record PATCH 成
youlinhsieh-test1+新建 test2,portal_library count=2);route 部署驗證
隨下批 staging bundle(假帳密應回 401 而非 404)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 14:07:05 +08:00
uncle6me-web 9c9aff0046 t158 P1 素材:workflow export/import 原語(definition 端點+CLI 命令,未接線)
leo 07-31:「你要做的就是一個叫 export,另一個是 import……現在如果我要把我做的
工作流分享給同事,我要怎麼 export?他要如何 import?是缺了功能用 search 來湊嗎?」

- cypher GET /webhooks/named/:name/definition:吐可攜定義(graph+config+description
  =record 原樣)——export 的引擎端;import 端直接 POST /webhooks/named 即送進任何實例
- cli/commands/workflow.ts:acr workflow export <name>(definition→.workflow.yaml,
  flow 從 graph.edges 反推供人讀)/import <file>(graph 直 POST,零編圖零 search
  零存在性驗證=V2 純複製;手寫 yaml 無 graph → 指去 acr push)
- ⚠️ 未接線:index.ts 尚未掛 workflow 命令組(P1 收尾:接線+分享場景 A export→
  B import 驗收+parser 共用模組化)

安裝器側 P0(rag-installer 5a6539d)已用同形狀(打包期預編 graph+純上傳)上 prod。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 13:49:23 +08:00
uncle6me-web d48f83ae6f t159 步驟4 意圖節點→真實零件/recipe 替換+/cypher/search 加 target 指定搜尋對象
CP arcrun-usable 步驟 4(目的:AI 只要填 payload——系統把「傳到 telegram」
翻成 http_request+recipe telegram_send)+leo 07-31 追加:
「難道我不能指定要搜尋工作流或節點或 recipe 嗎?」

替換(只動 discover,t158「部署≠發現」邊界不碰):
- 兩庫 exact 落空後,在 t158 一次抓好的清單記憶體內媒合,零新增 round-trip
- 規則 A 服務詞→recipe:名字全部服務詞命中同一 recipe 且唯一才換
  (google_slides_create 不被 google_sheets 誤吃)
- 規則 B 強欄位斷詞→零件:canonical/display/aliases 強命中×10+弱命中,
  需至少一強命中且分數唯一最高(aes_encrypt 無強命中不換)
- 換到=status resolved+substitution{from,componentId,recipe,reason},
  cypher 圖節點直接帶真實 componentId;換不到照舊 not_found+3.7 指路

target 參數(各走既有機制,不新造第二套搜尋):
- triplets+target=component|recipe=只查該庫
- query+target=名字搜尋:component→registry /components/search(=MCP
  arcrun_search_components 同路);recipe→私庫 RECIPES KV(回應註明公庫走
  arcrun_recipe_search);workflow→新抽 lib/workflow-search.ts,
  GET /workflows/search 與 target=workflow 共用(=arcrun_search_workflows 同路)
- 防呆:compile+target 400/target=workflow 吃 query 不吃 triplets/非法 target 400

驗(本地 wrangler dev,registry 種 20 合約+init/seed 10 recipe):
- 「判斷有沒有新資料 >> ON_SUCCESS >> 傳到 telegram」→ if_control(resolved)
  +telegram_send(substitution.componentId=http_request)=feature 06 驗法過
- 機械考 27/27 全綠(01 組×5+03 組×4 迴歸+06 組×8+target×8+compile 迴歸×2)
- 冷啟第一發 94ms、熱 8–13ms(t158 病史對照:舊 25.7s);compile 39ms unchecked 照舊
- tsc 全綠;vitest 9 failed/179 passed=t158 基線完全相同

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 13:49:09 +08:00
uncle6me-web ea1c0571c1 步驟6 執行統計回寫:每顆零件跑完回寫 success_rate(task 29,design.md「執行統計設計」)
- registry 新增 POST /analytics/record:ANALYTICS_KV 計數器(stats:{hash_id}:{version})
  為唯一真相源,衍生值(success_rate/avg_duration_ms/call_count)回填 comp: 記錄
  ——查詢讀取端讀哪就寫哪,不開第二真相源。KV 無 CAS,誠實標註非原子。
- cypher-executor execution-evaluator 從 stub 改真實作:執行收尾(/cypher/execute
  成功與 ExecutionError 路徑+webhook 路徑)對 trace 裡每顆 Component 節點
  fire-and-forget 回寫,waitUntil 包、不增加執行同步延遲(仿 recordRecipeStats 慣例)。
  成敗判定=trace error 或 output.success===false(makeHttpRunner 非 2xx 不 throw)。
- registry 位置沿 search-nodes 慣例:REGISTRY_BASE_URL 覆蓋,未設走 wasmWorkerUrl。
- 新增單測 13 個全綠;本地雙 wrangler dev 端到端實測:http_request 跑 5 次
  (3 成功+2 失敗)→ success_rate 1→0.6、call_count 0→5,/cypher/search 同步可見。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 13:45:05 +08:00
uncle6me-web 7e631a890a t158 迴歸修復「部署≠發現」:複製路徑回毫秒級純編圖,誠實化只留在 discover
leo 07-31 定調:「這裡只是複製一些工作流的 data 過去,沒有要在這裡驗證,難怪這麼慢。
就算是我自己寫了錯的工作流,也可以跑跑看,如果錯誤就修改,
沒有說有錯誤還要一個個驗證這回事。」

定性=迴歸非新設計:部署路徑純複製是既有設計(arcrun-rag installer/src/index.js:13
「workflows.json 是既有 workflows/*.local.yaml 的搬運(打包期抽 flow/config)」+
arcrun-rag wiki「workflow 打包期預編成 workflows.json(worker 免帶 parser)」)。
5cadc60 起誠實化漏進 /cypher/search ⇒ 複製路徑也逐節點跑兩庫查詢+相似搜尋
(每 missing 節點 1+9 次 HTTP+recipe KV 掃)⇒ 冷實例 8 節點實測 25.7s、
安裝器 15s timeout 必炸(leo stage 實走 rag_takedown_direct aborted)。

改動:
- /cypher/search 加 mode:compile=純編圖零查詢(安裝器/acr push 複製路徑);
  discover=誠實查詢預設(AI 問「有沒有」的既有契約,not_found+分型指路全保留)
- /cypher/execute 一律 compile(存在性由 component-loader 執行時決定=原權威)
- compile 的節點 status 標 unchecked(誠實「沒查」,不回假 found)
- discover 批次化:registry 新增 GET /components/catalog(一次回全目錄含
  input_schema,補 CP2-B「沒有列表端點」缺口)+recipe 清單一次抓,
  存在判定與相似度全記憶體比對;舊 registry 無 catalog 端點 → 退回逐顆(相容);
  registry 整個查不通 → unknown 照舊(不誤判 not_found)
- cli push 帶 mode:compile+拔 missing 擋(push 不看 missing;要問有沒有走 validate)

驗(本地 wrangler dev 誠實環境,registry 種 20 合約):
- compile 編圖:graph_neighbors 39ms/rag_chat(11節點) 3ms/rag_ingest_card 2ms/
  rag_takedown_direct(8節點) 3ms——回迴歸前毫秒級
- 安裝器 pushWorkflowTo(mode:compile)4/4 ok(44/7/7/5ms)
- 故意引用不存在零件的 workflow:部署 ok=true,trigger 執行時誠實報
  「找不到零件…」+可用零件清單(部署≠發現實證)
- discover 契約:邏輯名 missing=2+not_found+suggestion(21ms);
  頂層 verify.sh 01 組 5/5+03 組 4/4 全綠
- cypher+registry tsc 全綠;vitest 9 failed/179 passed=5cadc60 基線完全相同

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 13:31:33 +08:00
32 changed files with 1943 additions and 4318 deletions
+5 -5
View File
@@ -55,10 +55,12 @@ export async function cmdPush(filePath: string): Promise<void> {
const searchSpinner = ora('取得執行圖').start();
let graph: unknown;
try {
// t158「部署≠發現」(leo:「這裡只是複製工作流的 data 過去,沒有要在這裡驗證」):
// push=複製路徑,帶 mode:compile 純編圖——寫錯的 workflow 照樣部署,錯在執行時現形。
const res = await fetch(`${executorUrl}/cypher/search`, {
method: 'POST',
headers,
body: JSON.stringify({ triplets: workflow.flow }),
body: JSON.stringify({ triplets: workflow.flow, mode: 'compile' }),
});
if (!res.ok) {
@@ -68,10 +70,8 @@ export async function cmdPush(filePath: string): Promise<void> {
}
const data = await res.json() as { cypher: { nodes: unknown[]; edges: unknown[] }; missing: string[] };
if (data.missing?.length > 0) {
searchSpinner.fail(chalk.red(`以下零件不存在:${data.missing.join(', ')}\n執行 acr parts 查看可用零件。`));
process.exit(1);
}
// t158push 不看 missingcompile 模式亦恆空)——存在性由執行時 component-loader 決定;
// 要「先問有沒有」用 acr validateMCP 查詢(discover 路徑)。
// 附上 id / name,並將 workflow.config 套入節點(componentId + data
const rawGraph = data.cypher as { nodes: Array<{ id: string; componentId?: string; data?: Record<string, unknown> }>; edges: unknown[] };
+128
View File
@@ -0,0 +1,128 @@
/**
* acr workflow export <name> / acr workflow import <file> — workflow 可攜原語(t158)。
*
* leo 07-31 定調:「你要做的就是一個叫 export,另一個是 import,打包好的幾個工作流
* 準備好直接 import 就好了。現在如果我要把我做的工作流分享給同事,我要怎麼 export?
* 他要如何 import?是缺了功能用 search 來湊嗎?在從前就是寫成幾個 yaml 丟過去
* 讓新的送進 KBDB 不是嗎?」
*
* - exportGET /webhooks/named/:name/definition → 寫成 .workflow.yaml 可攜檔
* name/description/flow[從 graph.edges 反推,供人讀]/config/graph[可執行形,引擎產])。
* - import:讀可攜檔 → **直接 POST /webhooks/named**。零編圖、零 /cypher/search、
* 零存在性驗證(部署≠發現,V2 純複製)——缺件的 workflow 照樣進,跑錯再改。
* 手寫的 yaml(無 graph 欄)請走 acr push(那條才需要編圖)。
* - 安裝器走同一條路:workflows.json 打包期預編 graphpushWorkflow 直接 POST——
* 不准安裝器走私有路徑。
*/
import chalk from 'chalk';
import ora from 'ora';
import yaml from 'js-yaml';
import { readFileSync, writeFileSync } from 'node:fs';
import { loadConfig, getCypherExecutorUrl } from '../lib/config.js';
type GraphShape = {
nodes?: Array<{ id?: string }>;
edges?: Array<{ from?: string; to?: string; type?: string }>;
};
/** graph.edges → flow 三元組(人讀用;graph 才是可執行真相)。 */
function flowFromGraph(graph: GraphShape): string[] {
return (graph.edges ?? [])
.filter(e => e.from && e.to)
.map(e => `${e.from} >> ${e.type ?? 'ON_SUCCESS'} >> ${e.to}`);
}
function requireStandardConfig(): { executorUrl: string; apiKey: string } {
const config = loadConfig();
if (config.mode === 'local') {
console.error(chalk.red('Local 模式不支援 workflow export/import(需要連上實例)。'));
process.exit(1);
}
if (!config.api_key) {
console.error(chalk.red('缺少 api_keyNAMESPACE,請先 acr init。'));
process.exit(1);
}
return { executorUrl: getCypherExecutorUrl(config), apiKey: config.api_key };
}
export async function cmdWorkflowExport(name: string, options: { output?: string }): Promise<void> {
const { executorUrl, apiKey } = requireStandardConfig();
const spinner = ora(`${executorUrl} 匯出 "${name}"`).start();
try {
const res = await fetch(`${executorUrl}/webhooks/named/${encodeURIComponent(name)}/definition`, {
headers: { 'X-Arcrun-API-Key': apiKey },
});
if (!res.ok) {
const err = await res.text();
spinner.fail(chalk.red(`匯出失敗(${res.status}):${err.slice(0, 200)}`));
process.exit(1);
}
const def = await res.json() as {
name: string; description: string;
graph: GraphShape; config: Record<string, unknown>;
};
const out = options.output ?? `${def.name}.workflow.yaml`;
const doc = {
name: def.name,
description: def.description,
// flow=從 graph 反推的可讀視圖;import 用的是 graph(可執行真相)
flow: flowFromGraph(def.graph),
config: def.config ?? {},
graph: def.graph,
};
writeFileSync(out, yaml.dump(doc, { lineWidth: 120, noRefs: true }), 'utf8');
spinner.succeed(chalk.green(`✓ 已匯出 → ${out}`));
console.log(chalk.gray(` 給同事:把這個檔傳過去,對方 acr workflow import ${out} 即可。`));
} catch (e) {
spinner.fail(chalk.red(`網路錯誤:${e instanceof Error ? e.message : e}`));
process.exit(1);
}
}
export async function cmdWorkflowImport(filePath: string): Promise<void> {
const { executorUrl, apiKey } = requireStandardConfig();
let doc: { name?: string; description?: string; config?: Record<string, unknown>; graph?: GraphShape };
try {
doc = yaml.load(readFileSync(filePath, 'utf8')) as typeof doc;
} catch (e) {
console.error(chalk.red(`讀不了 ${filePath}${e instanceof Error ? e.message : e}`));
process.exit(1);
}
if (!doc?.name) {
console.error(chalk.red('檔案缺 name 欄位。'));
process.exit(1);
}
if (!doc.graph || !Array.isArray(doc.graph.nodes)) {
// 手寫 yaml(只有 flow 沒 graph)=acr push 的場景(那條會編圖)。import 專吃 export 檔。
console.error(chalk.red('這個檔沒有 graph 欄位(不是 export 產物)。'));
console.log(chalk.gray('手寫的 workflow.yaml 請改用:acr push ' + filePath));
process.exit(1);
}
const spinner = ora(`匯入 "${doc.name}" → ${executorUrl}`).start();
try {
// 純複製:graph 直接送,不編圖、不打 /cypher/search、不驗零件存在(跑錯再改)。
const res = await fetch(`${executorUrl}/webhooks/named`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Arcrun-API-Key': apiKey },
body: JSON.stringify({
name: doc.name,
graph: { ...doc.graph, id: doc.name, name: doc.name },
config: doc.config ?? {},
description: doc.description ?? '',
}),
});
if (!res.ok) {
const err = await res.text();
spinner.fail(chalk.red(`匯入失敗(${res.status}):${err.slice(0, 200)}`));
process.exit(1);
}
const data = await res.json() as { webhook_url?: string };
spinner.succeed(chalk.green(`✓ "${doc.name}" 已匯入`));
if (data.webhook_url) console.log(chalk.bold(` Webhook URL${chalk.cyan(data.webhook_url)}`));
console.log(chalk.gray(' 沒驗零件存在——跑起來若報「找不到零件」,補上零件/recipe 或改 config 再跑。'));
} catch (e) {
spinner.fail(chalk.red(`網路錯誤:${e instanceof Error ? e.message : e}`));
process.exit(1);
}
}
+3 -4
View File
@@ -2,12 +2,11 @@
"name": "arcrun-console-ui",
"version": "0.1.0",
"private": true,
"description": "Arcrun Console / Portal 靜態前端Cloudflare Pages)——從 cypher-executor 搬出的 UI 層",
"description": "Arcrun Console / Portal 靜態前端——public/ 是唯一世代真身(t160:舊 src/+build 已 git rm,直接託管)",
"scripts": {
"build": "node scripts/build.mjs",
"deploy": "node scripts/deploy.mjs",
"deploy:personal": "node scripts/deploy.mjs personal",
"deploy:enterprise": "node scripts/deploy.mjs enterprise",
"preview": "npm run build && npx serve public"
"preview": "npx serve public"
}
}
}
+27 -19
View File
@@ -412,16 +412,14 @@
</div>
</div>
<div class="panel">
<div style="display:flex;align-items:center;gap:14px">
<div style="min-width:0">
<div style="font-size:17px;font-weight:600">語意搜尋(vectorize</div>
<div id="st-vec" style="margin-top:4px;font-size:14px;line-height:1.65;color:rgba(var(--ink-rgb),.55)">狀態偵測中…</div>
</div>
<div class="switch" id="st-vec-switch" title="此開關不能遠端改,只如實顯示狀態"><i></i></div>
</div>
<div style="margin-top:12px;padding:12px 14px;border-radius:10px;background:rgba(var(--amber-rgb),.07);border:1px dashed rgba(var(--amber-rgb),.35);font-size:14px;line-height:1.7;color:rgba(var(--ink-rgb),.65)">
誠實提示:開關真相=部署端 <code style="font-size:12.5px">~/.arcrun/config.yaml</code><code style="font-size:12.5px">kbdb_embed: true</code> <code style="font-size:12.5px">acr update</code> 重部署(#32 教訓:wrangler 直推改的形態 config 要同步)。Console 只如實顯示狀態,不假裝能遠端開啟。目前狀態:<b style="color:var(--amber)" id="st-vec-state">偵測中…</b>
</div>
<!-- t36leo 2026-07-25 定案:語意搜尋預設開啟、不做開關):
這裡原本是一個「長得像開關、其實不能按」的唯讀狀態燈(title 自承「不能遠端改」),
旁邊還教用戶去部署端改 config.yaml 跑 CLI——對一鍵安裝進來的用戶那是天書,
而且安裝器現在裝機時就把語意索引開好了,那段指示已經不成立。
改成單純的狀態列:能用就說能用,不能用就說原因,不擺一個按不動的開關讓人誤會。 -->
<div style="font-size:17px;font-weight:600">語意搜尋</div>
<div id="st-vec" style="margin-top:4px;font-size:14px;line-height:1.65;color:rgba(var(--ink-rgb),.55)">狀態偵測中…</div>
<div id="st-vec-hint" style="margin-top:12px;padding:12px 14px;border-radius:10px;background:rgba(var(--amber-rgb),.07);border:1px dashed rgba(var(--amber-rgb),.35);font-size:14px;line-height:1.7;color:rgba(var(--ink-rgb),.65);display:none"></div>
</div>
<div class="panel">
<div style="font-size:17px;font-weight:600">MCP token 有效期(TTL</div>
@@ -1451,16 +1449,26 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
fetch(API_BASE + '/kbdb/search?q=mira&mode=semantic', { headers: apiHeaders() })
.then(function (r) { return r.json(); })
.then(function (d) {
// t36:狀態照實顯示(live 探測 mode,不是讀設定值)。啟用時不再顯示任何操作指示——
// 沒有東西要用戶操作;未啟用才給一句人話與下一步。
var on = d.mode === 'semantic';
$('st-vec-switch').classList.toggle('on', on);
$('st-vec').textContent = on
? '已啟用・語意搜尋可用(搜尋頁切「語意」'
: '未啟用・' + (d.capability_hint || '部署端尚未開啟 Vectorizekbdb_embed');
$('st-vec-state').textContent = on ? '已啟用(live 探測 mode=semantic' : '未啟用(live 探測降級 keyword';
? '已啟用——搜尋頁切「語意」就能用意思找資料。'
: '○ 尚未啟用——目前用關鍵字搜尋,不會假裝有語意結果。';
var hint = $('st-vec-hint');
if (on) {
hint.style.display = 'none';
} else {
hint.style.display = '';
hint.innerHTML = '一鍵安裝的實例會在安裝時自動開通語意索引。'
+ '如果你這個實例是較早裝的、或安裝當下開通沒成功,重新跑一次安裝流程即可補上(已建好的資料不會重來)。';
}
})
.catch(function () {
$('st-vec').textContent = '狀態偵測失敗(KBDB 不可達';
$('st-vec-state').textContent = '偵測失敗';
$('st-vec').textContent = '狀態偵測失敗(知識庫服務目前連不上';
var hint = $('st-vec-hint');
hint.style.display = '';
hint.textContent = '這通常是暫時的,稍後重新整理這一頁再看。';
});
// MCP token TTL(誠實佔位:只顯示目前生效值,不假裝能改)
fetch(API_BASE + '/console/settings-data')
@@ -1510,9 +1518,9 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
})
.catch(function (e) { st.innerHTML = '<span class="err">請求失敗:' + esc(friendlyErr(e)) + '</span>'; });
});
$('st-vec-switch').addEventListener('click', function () {
toast('此開關不能遠端改——部署端 config.yaml 開 kbdb_embed 後 acr update 重部署');
});
// t36:原本這裡綁在那顆假開關上(點了只會 toast 一段 CLI 指示)。開關已移除,
// 這個 handler 也必須一起拿掉——留著會讓 $('st-vec-switch') 回 null、addEventListener
// 當場拋錯,把後面所有綁定(含登出)一起打斷。
$('st-logout').addEventListener('click', function () {
var t = S.token;
if (t) fetch(API_BASE + '/console/logout', { method: 'POST', headers: { Authorization: 'Bearer ' + t } }).catch(function () {});
+477 -70
View File
@@ -186,6 +186,27 @@
</div>
</div>
<!-- 首次設定(t49leo 07-25:一鍵安裝的用戶打開專屬網址,第一件事就是在這裡建帳號,
不再需要回安裝器那頁。auth-status 說 configured:false 才會出現。)-->
<div class="authwrap view" id="v-firstsetup">
<div class="authbox">
<div>
<div class="brand">Arcrun</div>
<div class="brand-sub">歡迎!先建立你的帳號</div>
</div>
<div style="display:flex;flex-direction:column;gap:12px;text-align:left">
<input type="email" id="fs-email" class="txt" placeholder="你的 Email" autocomplete="username" style="padding:15px 16px;font-size:17px;border-radius:11px">
<input type="password" id="fs-password" class="txt" placeholder="設定密碼(至少 8 碼)" autocomplete="new-password" style="padding:15px 16px;font-size:17px;border-radius:11px">
<input type="password" id="fs-password2" class="txt" placeholder="再輸入一次密碼" autocomplete="new-password" style="padding:15px 16px;font-size:17px;border-radius:11px">
<button class="btn" id="fs-submit" style="margin-top:6px;padding:15px;font-size:17px;letter-spacing:.12em">建立帳號並進入</button>
<div id="fs-status" class="err" style="font-size:14px;min-height:1.2em"></div>
<!-- leo 07-25 實撞:已設定過(409)時這頁是死路——必須有回登入的路 -->
<button class="btn3" id="fs-tologin" style="padding:9px 16px;font-size:14px;border-radius:999px;align-self:center;cursor:pointer">已經有帳號?改用登入</button>
</div>
<div style="font-size:13.5px;color:rgba(var(--ink-rgb),.4);line-height:1.7">這組帳密就是之後登入知識庫用的,只需要設定這一次。</div>
</div>
</div>
<!-- 主應用 -->
<div id="shell">
<div id="sidenav">
@@ -310,6 +331,50 @@
<div id="st-pw-status" style="font-size:14px;min-height:1.2em"></div>
</div>
</div>
<!-- 07-27 leo:金鑰與下載原本只在一次性的「還差 N 步」卡片裡,按了「稍後再說」
或裝完就再也找不到。換金鑰/換電腦重新下載都需要常駐入口,故搬進設定頁。 -->
<div class="panel">
<!-- t87 07-28 leo:乾淨網址(只有 origin,不含 /portal/# 後綴),供小幫手連線用 -->
<div style="display:flex;align-items:center;gap:8px;margin-bottom:14px;font-size:13.5px">
<span style="color:rgba(var(--ink-rgb),.6);white-space:nowrap">你的知識庫網址(小幫手連線用)</span>
<code id="st-origin-url" style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;color:var(--ink)"></code>
<button class="btn3" id="st-copy-url" style="padding:5px 12px;font-size:13px;white-space:nowrap;flex:none">複製</button>
</div>
<div style="font-size:17px;font-weight:600">同步小幫手</div>
<div style="margin-top:4px;font-size:14px;line-height:1.65;color:rgba(var(--ink-rgb),.55)">把你電腦上的資料夾變成知識庫。換電腦或重裝時可以再下載一次。</div>
<div style="margin-top:12px">
<a class="btn3" id="st-daemon-dl" href="#" style="display:inline-block;padding:11px 18px;border-radius:10px;text-decoration:none">下載 Mac 版</a>
<div style="margin-top:8px;font-size:13px;line-height:1.7;color:rgba(var(--ink-rgb),.5)">封測版未簽章,第一次請右鍵→打開。裝好第一次開啟時,貼上這個網址+你的帳號密碼就連上了。</div>
</div>
</div>
<!-- t131 合併 AI 設定(Gemini API Key 必填;Claude 加強版視 daemon 回報 enable -->
<div class="panel" id="st-ai-panel">
<div style="font-size:17px;font-weight:600">AI 設定</div>
<div style="margin-top:14px;display:flex;flex-direction:column;gap:16px">
<!-- 第一格:Gemini API Key(必填,聊天+萃取共用) -->
<div>
<div style="font-size:14px;font-weight:500;margin-bottom:4px">Gemini API Key</div>
<div style="font-size:13px;line-height:1.6;color:rgba(var(--ink-rgb),.55);margin-bottom:8px">聊天問答與文件萃取都用這一把。<a href="https://aistudio.google.com/apikey" target="_blank" rel="noopener">免費申請</a>,金鑰只存在你自己的知識庫裡。</div>
<input type="password" id="st-ai-key" class="txt" placeholder="貼上 Gemini API Key" autocomplete="off">
</div>
<!-- 第二格:Claude 加強版(選填,依 daemon 回報 enable -->
<div style="padding-top:12px;border-top:1px solid rgba(var(--ink-rgb),.08)">
<div style="font-size:14px;font-weight:500;margin-bottom:6px">讓知識卡整理得更好(選填)</div>
<label style="display:flex;gap:8px;align-items:flex-start;cursor:pointer">
<input type="checkbox" id="st-ai-use-claude" style="margin-top:3px;flex:none" disabled>
<span style="font-size:14px">本地萃取改用 Claude Code</span>
</label>
<div id="st-ai-claude-desc" style="margin-top:8px;font-size:13px;line-height:1.65;color:rgba(var(--ink-rgb),.55)">
讀你文件、整理成知識卡的那個 AI,換成更強的模型。<b style="color:var(--ink)">卡片會更抓得到重點、關聯也連得更準</b>,之後搜尋和問答的品質跟著提升。需要你的電腦已安裝 Claude Code。不填就用上面那把 Gemini,一樣能用。
</div>
<div id="st-ai-claude-hint" style="margin-top:6px;font-size:13px;color:rgba(var(--ink-rgb),.4);display:none"></div>
</div>
<div>
<button class="btn" id="st-ai-save">儲存 AI 設定</button>
<div id="st-ai-status" style="font-size:14px;min-height:1.2em;margin-top:8px"></div>
</div>
</div>
</div>
<button class="btn3" id="st-logout" style="padding:14px;font-size:16px;border-radius:11px">登出</button>
</div>
</div>
@@ -334,18 +399,15 @@
<div class="listcol" id="ad-users"><div class="muted">載入中…</div></div>
<div class="sechead">庫目錄管理</div>
<div style="margin-bottom:12px;padding:12px 15px;border-radius:11px;background:rgba(var(--amber-rgb),.06);border:1px dashed rgba(var(--amber-rgb),.35);font-size:13.5px;line-height:1.7;color:rgba(var(--ink-rgb),.65)">
這裡是「庫」的登記簿——條目歸哪個庫由資料導入(ingest)時蓋章決定;未蓋章的舊資料一律視同 <b style="color:var(--ink)">general</b>。標了「圖譜來源」的庫決定誰能用圖譜模式(全都沒標=預設 general)。
<div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;font-size:13.5px">
<span style="color:rgba(var(--ink-rgb),.6);white-space:nowrap">你的知識庫網址(小幫手連線用)</span>
<code id="ad-origin-url" style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;color:var(--ink)"></code>
<button class="btn3" id="ad-copy-url" style="padding:5px 12px;font-size:13px;white-space:nowrap;flex:none">複製</button>
</div>
<div class="panel">
<div style="font-size:16px;font-weight:600;margin-bottom:12px">登記新庫</div>
<div class="formrow">
<input type="text" id="ad-nl-name" class="txt" placeholder="庫名(英數 _ -,例:finance" autocomplete="off">
<input type="text" id="ad-nl-display" class="txt" placeholder="顯示名(例:財務庫)" autocomplete="off">
<input type="text" id="ad-nl-desc" class="txt" placeholder="描述(可留空)" autocomplete="off">
<button class="btn" id="ad-nl-create" style="flex:none;padding:0 20px">登記</button>
</div>
<div id="ad-nl-status" class="err" style="font-size:14px;min-height:1.2em;margin-top:8px"></div>
<div style="margin-bottom:12px;padding:12px 15px;border-radius:11px;background:rgba(var(--amber-rgb),.06);border:1px dashed rgba(var(--amber-rgb),.35);font-size:13.5px;line-height:1.7;color:rgba(var(--ink-rgb),.65)">
<!-- 07-27 leo:拿掉人工建庫表單——庫由「同步小幫手」看守資料夾時自動蓋章產生,
人工登記只會製造對不上的空庫。這裡只做「已存在的庫」的管理。 -->
裝好<b style="color:var(--ink)">同步小幫手</b>並選好要看守的資料夾之後,每個資料夾會自動成為一個「庫」出現在下面——<b style="color:var(--ink)">不需要人工新增</b>。下面還是空的,代表小幫手還沒裝好或還沒選資料夾。
</div>
<div class="listcol" id="ad-libs"><div class="muted">載入中…</div></div>
</div>
@@ -399,10 +461,32 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
clearTimeout(toast._t);
toast._t = setTimeout(function () { el.style.display = 'none'; }, 2000);
}
// safeJson:把回應當 JSON 解析,**解析不了就回空物件而不是拋例外**(t75 ①,2026-07-28)。
//
// 為什麼需要:原本各處都直接 r.json(),但伺服器不一定回 JSON——404 頁、Cloudflare 錯誤頁、
// 反向代理的 HTML 都是純文字。JSON.parse 一爆,錯誤就沿著 .catch 走到 friendlyErr
// **技術訊息原文被噴到畫面上**。leo 同事實測看到的就是:
// 「Unexpected non-whitespace character after JSON at position 4」
// 真正的原因其實是 `/portal/admin/chat-key` 回 404(實例的 cypher 是舊版沒這端點),
// 但使用者只看得到一句看不懂的英文 ⇒ 無從判斷是自己打錯還是系統壞了。
function safeJson(r) {
return r.text().then(function (t) {
if (!t) return {};
try { return JSON.parse(t); } catch (e) { return {}; }
});
}
// friendlyErr:給使用者看的訊息,**不外洩技術細節**(t75 ①)。
// 原本最後一行是 `return m` =把任何例外訊息原樣顯示,包含 JSON parse 錯誤、
// stack 片段這種對使用者毫無意義、只會嚇到人的東西。
function friendlyErr(e) {
var m = e && e.message ? String(e.message) : String(e);
if (/failed to fetch|load failed|networkerror|network request failed/i.test(m)) return '連線中斷——請檢查網路後重試';
return m;
// JSON 解析類=伺服器回了非預期內容(多半是 404/代理錯誤頁),對使用者說人話。
if (/json|unexpected token|unexpected non-whitespace/i.test(m)) return '伺服器回應異常,請稍後再試一次(若持續發生請回報)';
// 其餘:只在含中文(=我們自己寫的訊息)時原樣顯示;純英文技術訊息一律收斂。
if (/[\u4e00-\u9fff]/.test(m)) return m;
return '操作失敗,請稍後再試一次';
}
function authHeaders() { return S.token ? { 'Authorization': 'Bearer ' + S.token } : {}; }
@@ -548,14 +632,31 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
// ── 認證流 ──
function showAuth() {
$('v-firstsetup').classList.remove('on');
$('v-login').classList.add('on');
$('shell').classList.remove('on');
$('tabbar').classList.remove('on');
// t49:全新實例(還沒有任何帳號)→ 換顯示「首次設定」,不讓用戶對著登入殼發呆。
// 探測走 console/auth-statusconfigured 布林,console 首設同一顆;失敗就保持登入殼=誠實降級)。
fetch(API_BASE + '/console/auth-status')
.then(function (r) { return safeJson(r); })
.then(function (d) {
if (d && d.configured === false) {
$('v-login').classList.remove('on');
$('v-firstsetup').classList.add('on');
}
})
.catch(function () { /* 探測不到就維持登入殼 */ });
}
function showApp() {
$('v-login').classList.remove('on');
$('v-firstsetup').classList.remove('on');
$('shell').classList.add('on');
$('tabbar').classList.add('on');
// t53leo 07-25:「進站要做的事——給金鑰、下載 daemon——不然這個安裝沒完成」):
// 常駐「完成安裝」清單卡,三件做完才消失(localStorage 記進度;admin 才看得到——
// 金鑰與 daemon 設定是裝機者的事,同事帳號不顯示)。
try { renderSetupChecklist(); } catch (e) { /* 清單卡失敗不擋主流程 */ }
var p = S.profile || {};
$('side-foot').innerHTML = esc(p.display_name || '') + '<br>' + esc(location.host);
$('nav-workflows').classList.toggle('hide', !p.workflows_visible);
@@ -582,7 +683,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
function boot() {
if (!S.token) { showAuth(); return; }
fetch(API_BASE + '/portal/session', { headers: authHeaders() })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
if (!x.ok) { dropSession(); return; }
S.profile = x.d;
@@ -607,7 +708,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email, password: password })
})
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
$('login-submit').disabled = false;
if (!x.ok) { $('login-status').textContent = x.d.error || '登入失敗'; return; }
@@ -618,11 +719,308 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
})
.catch(function (e) { $('login-submit').disabled = false; $('login-status').textContent = friendlyErr(e); });
}
// t72 OS 分流(2026-07-27leo:「客戶是用 windows 的」)——在此之前四處寫死「下載 Mac 版」,
// Windows 客戶按下去會拿到 Mac 的 .app=按了連到錯的東西(leo 判準:那不算友善)。
// 判不出 OS 時**兩個都給**,不替用戶猜;且無論判成哪個,頁面都留「不是這個系統?」的另一版連結
// ——UA 會判錯,判錯時用戶要有路可走(施工圖 §3 注意事項 1、3)。
var DAEMON_BASE_DEFAULT = 'https://raw.githubusercontent.com/youlinhsieh/arcrun-rag-bundles/main/daemon/';
var DAEMON_MAC = 'ArcrunRAG-mac-unsigned.zip';
var DAEMON_WIN = 'ArcrunRAG-win-unsigned.zip';
// Mac 那顆 21MB > jsDelivr 單檔 20MB 上限(實測回 "File size exceeded...")→ 一律走 raw
// Windows 13MB 雖在限內,同走 raw 保持單一來源、少一個會壞的地方。
function daemonBase() {
var cfg = (window.ARCRUN_CONFIG || {});
if (cfg.daemonBase) return String(cfg.daemonBase).replace(/\/?$/, '/');
// 舊 key 相容(施工圖 §3 注意事項 2):daemonDownload 是「單一檔案網址」,
// 有設就尊重它當 Mac 版網址,並由它推出目錄給 Windows 版用。
if (cfg.daemonDownload) {
var s = String(cfg.daemonDownload);
return s.slice(0, s.lastIndexOf('/') + 1) || DAEMON_BASE_DEFAULT;
}
return DAEMON_BASE_DEFAULT;
}
function daemonPick() {
var base = daemonBase();
var ua = navigator.userAgent || '';
// 手機/平板先排除:iOS 的 UA 含 "Mac OS X",不排除會把 iPhone 判成 Mac,
// 讓手機用戶下載一個裝不起來的桌面 app(測到才發現,2026-07-27)。
// 手機=判不出桌面 OS → 落到「兩個都給」,用戶回桌機再選。
var isMobile = /iPhone|iPad|iPod|Android|Mobile/i.test(ua);
var isWin = !isMobile && /Windows NT/i.test(ua);
var isMac = !isMobile && /Macintosh|Mac OS X/i.test(ua) && !/Windows/i.test(ua);
var mac = { os: 'mac', label: '下載 Mac 版', url: base + DAEMON_MAC };
var win = { os: 'win', label: '下載 Windows 版', url: base + DAEMON_WIN };
if (isWin) return { pick: win, other: mac, sure: true };
if (isMac) return { pick: mac, other: win, sure: true };
return { pick: null, other: null, sure: false, mac: mac, win: win };
}
// 封測期第一次開啟的擋關提示(未簽章)——Mac/Windows 攔法不同,話術也不同。
function daemonHint(os) {
if (os === 'win') return '(封測版未簽章,Windows 第一次會跳藍色視窗擋下來——點「更多資訊」→「仍要執行」就好)';
return '(封測版未簽章,第一次請右鍵→打開)';
}
// 07-27:設定頁的常駐入口(下載小幫手/換 AI 金鑰)——與一次性卡片同一組 API
(function () {
var dl = $('st-daemon-dl');
if (dl) {
var d = daemonPick();
if (d.sure) {
dl.setAttribute('href', d.pick.url);
dl.textContent = d.pick.label;
// 判對了也要留另一版的路(UA 會判錯)
var alt = document.createElement('span');
alt.className = 'muted';
alt.style.cssText = 'font-size:12.5px;margin-left:8px';
alt.innerHTML = '不是這個系統?<a href="' + d.other.url + '">' + d.other.label + '</a>';
if (dl.parentNode) dl.parentNode.insertBefore(alt, dl.nextSibling);
} else {
// 判不出來=兩個都給,不預設 Mac
dl.setAttribute('href', d.mac.url);
dl.textContent = d.mac.label;
var both = document.createElement('span');
both.className = 'muted';
both.style.cssText = 'font-size:12.5px;margin-left:8px';
both.innerHTML = '或 <a href="' + d.win.url + '">' + d.win.label + '</a>';
if (dl.parentNode) dl.parentNode.insertBefore(both, dl.nextSibling);
}
}
})();
// t131 AI 設定(合併 Gemini API KeyClaude 加強版)
(function () {
// 進入設定頁時讀取現有設定(GET /portal/admin/ai
function loadAiConfig() {
if (!(S.profile && S.profile.role === 'admin')) return;
fetch(API_BASE + '/portal/admin/ai', { headers: authHeaders() })
.then(function (r) { return r.ok ? safeJson(r) : null; })
.then(function (d) {
if (!d) return;
var ki = $('st-ai-key');
if (ki && d.has_key) ki.placeholder = '已設定(留空=不變更)';
var cb = $('st-ai-use-claude');
if (cb) {
// 有 claude 才能勾;沒有則停用並顯示提示
var hint = $('st-ai-claude-hint');
if (d.claude_available) {
cb.disabled = false;
cb.checked = !!d.use_claude_for_extract;
if (hint) hint.style.display = 'none';
} else {
cb.disabled = true;
cb.checked = false;
if (hint) {
hint.style.display = '';
// 區分:從未連上小幫手 vs 有連上但沒裝 Claude
hint.textContent = d.has_key
? '你的電腦沒有偵測到 Claude Code;裝好並讓小幫手重連一次後,這個選項就會開啟。'
: '連上小幫手後才知道你的電腦有沒有 Claude Code。';
}
}
}
})
.catch(function () { /* 讀不到不擋頁面 */ });
}
window._loadAiConfig = loadAiConfig;
// 儲存
var sb = $('st-ai-save');
if (sb) sb.addEventListener('click', function () {
var m = $('st-ai-status');
var k = (($('st-ai-key') && $('st-ai-key').value) || '').trim();
var cb = $('st-ai-use-claude');
var useClaud = cb && !cb.disabled ? cb.checked : undefined;
var body = {};
if (k) body.gemini_api_key = k;
if (useClaud !== undefined) body.use_claude_for_extract = useClaud;
sb.disabled = true; m.textContent = '儲存中…'; m.style.color = '';
fetch(API_BASE + '/portal/admin/ai', {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
body: JSON.stringify(body)
}).then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
sb.disabled = false;
if (guard401(x.status)) return;
if (!x.ok) { m.textContent = (x.d && x.d.error) || '儲存失敗'; m.style.color = '#b4462f'; return; }
if ($('st-ai-key')) { $('st-ai-key').value = ''; $('st-ai-key').placeholder = '已設定(留空=不變更)'; }
var claudeOn = x.d && x.d.use_claude_for_extract;
m.textContent = claudeOn
? '已儲存,萃取改用 Claude Code。小幫手請重連一次生效。'
: '已儲存,AI 問答與萃取皆可使用。';
m.style.color = '#3f7a4f';
})
.catch(function (e) { sb.disabled = false; m.textContent = friendlyErr(e); m.style.color = '#b4462f'; });
});
})();
$('st-logout').addEventListener('click', function () {
fetch(API_BASE + '/portal/logout', { method: 'POST', headers: authHeaders() }).catch(function () { /* 盡力而為 */ });
dropSession();
});
// t87 07-28 leo:知識庫網址 helper(只有 origin,不含 /portal/# 後綴),兩處 UI 共用
function copyOriginUrl(btn) {
var url = location.origin;
var orig = btn.textContent;
if (!navigator.clipboard || !navigator.clipboard.writeText) {
alert('請手動選取並複製:' + url);
return;
}
navigator.clipboard.writeText(url).then(function () {
btn.textContent = '已複製';
setTimeout(function () { btn.textContent = orig; }, 1500);
}).catch(function () {
alert('請手動選取並複製:' + url);
});
}
(function () {
['st-origin-url', 'ad-origin-url'].forEach(function (id) {
var el = $(id); if (el) el.textContent = location.origin;
});
['st-copy-url', 'ad-copy-url'].forEach(function (id) {
var el = $(id); if (el) el.addEventListener('click', function () { copyOriginUrl(this); });
});
})();
// ── t53 完成安裝清單(進站必見,三件做完才消失)─────────────────────────────
function setupSteps() {
try { return JSON.parse(localStorage.getItem('arcrun_setup_steps') || '{}'); } catch (e) { return {}; }
}
function markStep(k) {
var s = setupSteps(); s[k] = 1;
try { localStorage.setItem('arcrun_setup_steps', JSON.stringify(s)); } catch (e) { /* noop */ }
renderSetupChecklist();
}
function renderSetupChecklist() {
var old = document.getElementById('setup-checklist');
if (old) old.remove();
var p = S.profile || {};
if (p.role !== 'admin') return;
var s = setupSteps();
if (s.daemon && s.key) return; // t54 起只剩兩件:設定改由小幫手輸入帳密自取,不再下載檔案
var cfg = window.ARCRUN_CONFIG || {};
var dpick = daemonPick(); // t72 OS 分流(同一組判定,見上面 daemonPick)
var daemonUrl = dpick.sure ? dpick.pick.url : dpick.mac.url;
var row = function (done, id, html) {
return '<div style="display:flex;gap:10px;align-items:baseline;margin-top:10px">'
+ '<span>' + (done ? '✅' : '⬜') + '</span><div style="flex:1">' + html + '</div></div>';
};
var el = document.createElement('div');
el.id = 'setup-checklist';
el.style.cssText = 'position:fixed;right:20px;bottom:20px;z-index:60;max-width:400px;width:calc(100% - 40px);padding:18px 20px;border-radius:14px;background:rgba(var(--amber-rgb),.10);border:1px solid rgba(var(--amber-rgb),.45);backdrop-filter:blur(8px);font-size:14px;line-height:1.65';
el.innerHTML = '<b>還差 ' + (2 - (s.daemon?1:0) - (s.key?1:0)) + ' 步,安裝就真的完成了</b>'
+ row(s.daemon, 'daemon',
'<b>下載同步小幫手</b>(把資料夾變成知識庫)<br>'
+ (daemonUrl
// t72:按鈕字樣+擋關話術都跟著 OS 走;並且一律附「另一個系統」的連結
//(UA 判錯時用戶要有路走;判不出來時=兩個平等並列,不預設 Mac)
? '<a href="' + esc(daemonUrl) + '" id="sc-daemon">'
+ esc(dpick.sure ? dpick.pick.label : dpick.mac.label) + '</a>'
+ '<span class="muted" style="font-size:12.5px">'
+ esc(daemonHint(dpick.sure ? dpick.pick.os : 'mac')) + '</span>'
+ '<div class="muted" style="font-size:12.5px">'
+ (dpick.sure
? '不是這個系統?<a href="' + esc(dpick.other.url) + '">' + esc(dpick.other.label) + '</a>'
: '或 <a href="' + esc(dpick.win.url) + '">' + esc(dpick.win.label) + '</a>')
+ '</div>'
// 誠實降級(leo 判準:按了連到錯的機制=不友善):沒有可用下載點時
// 不放死連結,說明現況並讓這步可手動打勾,不卡住清單。
: '<span class="muted" style="font-size:13px">封測期由我們直接寄給你安裝檔;'
+ '收到後回來按 <a href="#" id="sc-daemon">我已經裝好了</a></span>')
// t54(leo:「最好的就是把它的帳密直接輸入」):設定不再是一個要下載的檔案——
// 小幫手第一次開啟會問網址+帳密,自己去換設定。
+ '<div class="muted" style="font-size:12.5px;margin-top:4px">裝好第一次開啟時,貼上這個網址+你的帳號密碼就連上了,不用下載設定檔。</div>')
+ row(s.key, 'key',
'<b>啟用 AI 問答</b><a href="https://aistudio.google.com" target="_blank" rel="noopener">aistudio.google.com</a> 免費申請)<br>'
+ '<input id="sc-key" type="password" placeholder="貼上 Google AI 金鑰" style="width:60%;padding:6px 8px;border-radius:8px;border:1px solid rgba(var(--ink-rgb),.25);background:rgba(var(--ink-rgb),.04);color:var(--ink)"> '
+ '<button class="btn3" id="sc-key-save" style="padding:6px 12px;border-radius:8px;cursor:pointer">啟用</button>'
+ '<div id="sc-key-msg" style="font-size:12.5px;min-height:1.1em"></div>')
+ '<div style="margin-top:10px;text-align:right"><button id="sc-later" style="border:none;background:none;color:inherit;cursor:pointer;font-size:12.5px;text-decoration:underline;opacity:.65">稍後再說</button></div>';
document.body.appendChild(el);
var dl = document.getElementById('sc-daemon');
if (dl) dl.addEventListener('click', function () { markStep('daemon'); });
// t54config.json 下載鈕已移除(設定由小幫手憑帳密自取)
var kb = document.getElementById('sc-key-save');
if (kb) kb.addEventListener('click', function () {
var k = (document.getElementById('sc-key').value || '').trim();
var m = document.getElementById('sc-key-msg');
if (!k) { m.textContent = '請先貼上金鑰'; return; }
kb.disabled = true; m.textContent = '啟用中…';
fetch(API_BASE + '/portal/admin/ai', {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
body: JSON.stringify({ gemini_api_key: k })
}).then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
kb.disabled = false;
if (!x.ok || !x.d.success) { m.textContent = (x.d && x.d.error) || '啟用失敗,請再試一次'; return; }
markStep('key');
})
.catch(function () { kb.disabled = false; m.textContent = '網路好像有問題,請再試一次'; });
});
var later = document.getElementById('sc-later');
if (later) later.addEventListener('click', function () { el.remove(); }); // 只藏本次,下次進站再提醒
}
// ── t49 首次設定(leo 07-25):console/setup → portal/admin/bootstrap → 預設庫登記 → portal/login
// 四發全是既有端點,一顆按鈕做完;用戶感受=「建一組帳密就進來了」。
$('fs-submit').addEventListener('click', doFirstSetup);
$('fs-password2').addEventListener('keydown', function (ev) { if (ev.key === 'Enter') doFirstSetup(); });
$('fs-tologin').addEventListener('click', function () {
$('v-firstsetup').classList.remove('on');
$('v-login').classList.add('on');
});
function doFirstSetup() {
var email = $('fs-email').value.trim();
var pw = $('fs-password').value;
var st = $('fs-status');
if (!email || pw.length < 8) { st.textContent = '請填 Email,密碼至少 8 碼'; return; }
if (pw !== $('fs-password2').value) { st.textContent = '兩次密碼不一樣'; return; }
$('fs-submit').disabled = true; st.textContent = '';
var post = function (path, body, hdrs) {
return fetch(API_BASE + path, {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json' }, hdrs || {}),
body: JSON.stringify(body)
}).then(function (r) { return r.json().catch(function () { return {}; }).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); });
};
post('/console/setup', { email: email, password: pw })
.then(function (x) {
// 已設定過(409)=這實例其實有人建過了——自動帶去登入,別把用戶困在死路(leo 實撞)
if (x.status === 409) {
$('fs-submit').disabled = false;
$('v-firstsetup').classList.remove('on');
$('v-login').classList.add('on');
$('login-email').value = email;
$('login-status').textContent = '這個知識庫已經設定過帳號了,直接登入即可。';
throw { handled: true };
}
if (!x.ok || !x.d.session_token) throw new Error(x.d.error || '設定沒有成功,請再試一次');
var ownerTok = x.d.session_token;
return post('/portal/admin/bootstrap', { email: email, password: pw, display_name: email.split('@')[0] }, { Authorization: 'Bearer ' + ownerTok })
.then(function (b) {
// 409=已 bootstrap 過(冪等視為就緒);其餘失敗誠實丟出
if (!b.ok && b.status !== 409) throw new Error(b.d.error || '初始化沒有成功,請再試一次');
return post('/portal/login', { email: email, password: pw });
});
})
.then(function (l) {
if (!l.ok || !l.d.session_token) throw new Error(l.d.error || '帳號建好了,但自動登入沒成功——請用剛設定的帳密登入');
S.token = l.d.session_token;
try { localStorage.setItem('arcrun_portal_session', S.token); } catch (e) { /* noop */ }
})
.then(function () {
$('fs-submit').disabled = false;
try { localStorage.setItem('arcrun_onboarding', '1'); } catch (e) { /* noop */ }
boot();
})
.catch(function (e) {
if (e && e.handled) return; // 409 已自動切登入,不再顯示錯誤
$('fs-submit').disabled = false;
st.textContent = (e && e.message) || friendlyErr(e);
});
}
// 任何 data 請求收到 401 → session 失效 → 回登入殼
function guard401(status) {
if (status === 401) { dropSession(); return true; }
@@ -650,7 +1048,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
$('se-results').innerHTML = '';
var url = API_BASE + '/portal/data/search?q=' + encodeURIComponent(q) + '&mode=' + (S.mode === 'semantic' ? 'semantic' : 'keyword');
fetch(url, { headers: authHeaders() })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
if (guard401(x.status)) return;
if (!x.ok) { $('se-count').innerHTML = '<span class="err">' + esc(x.d.error || ('查詢失敗(HTTP ' + x.status + '')) + '</span>'; return; }
@@ -694,7 +1092,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
$('ai-go').disabled = true;
$('ai-out').innerHTML = '<div class="muted" style="margin-top:12px">AI 查閱知識庫中…</div>';
fetch(API_BASE + '/portal/data/chat?question=' + encodeURIComponent(q), { headers: authHeaders() })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
$('ai-go').disabled = false;
if (guard401(x.status)) return;
@@ -743,7 +1141,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
$('se-count').textContent = '查詢關聯中…';
renderGraphEmpty('查詢關聯中…');
fetch(API_BASE + '/portal/data/graph/neighbors/' + encodeURIComponent(name), { headers: authHeaders() })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
if (guard401(x.status)) return;
if (!x.ok) { $('se-count').innerHTML = '<span class="err">' + esc(x.d.error || ('關聯查詢失敗(HTTP ' + x.status + '')) + '</span>'; renderGraphEmpty(x.d.error || '關聯查詢失敗'); return; }
@@ -806,7 +1204,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
if (!S.cardId) { $('cd-main').innerHTML = '<div class="muted">沒有指定卡片——從「搜尋」點一張進來。</div>'; return; }
$('cd-main').innerHTML = '<div class="muted">載入中…</div>';
fetch(API_BASE + '/portal/data/entries/' + encodeURIComponent(S.cardId), { headers: authHeaders() })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
if (guard401(x.status)) return;
if (!x.ok || !x.d.entry) { $('cd-main').innerHTML = '<div class="err">' + esc(x.d.error || '讀不到這張卡片') + '</div>'; return; }
@@ -848,7 +1246,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
? '<a href="' + esc(SOURCE_WEB_BASE + '/system-dev/wiki/00-MAP.md') + '" target="_blank" rel="noopener" style="color:var(--amber)">00-MAP.md ↗</a>'
: '存在知識庫的 00-MAP.md';
fetch(API_BASE + '/portal/data/graph/overview', { headers: authHeaders() })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
if (guard401(x.status)) return;
if (!x.ok) { $('map-box').innerHTML = '<div class="err">' + esc(x.d.error || ('總圖載入失敗(HTTP ' + x.status + '')) + '</div>'; return; }
@@ -954,7 +1352,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
function loadWorkflows() {
$('wf-list').innerHTML = '<div class="muted">載入中…</div>';
fetch(API_BASE + '/portal/data/workflows', { headers: authHeaders() })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
if (guard401(x.status)) return;
if (!x.ok) { $('wf-list').innerHTML = '<div class="err">' + esc(x.d.error || ('讀取失敗(HTTP ' + x.status + '')) + '</div>'; return; }
@@ -1018,7 +1416,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
body: JSON.stringify({ filename: file.name, content_b64: b64FromDataUrl(reader.result) })
})
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
if (guard401(x.status)) return;
if (!x.ok) { setRowStatus(row, false, '失敗', x.d.error || ('HTTP ' + x.status)); return; }
@@ -1053,7 +1451,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
var opt = { method: method, headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()) };
if (body !== undefined) opt.body = JSON.stringify(body);
return fetch(path, opt).then(function (r) {
return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; });
return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; });
});
}
@@ -1128,22 +1526,37 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
}
function renderAdminLibs() {
if (!adminLibs.length) { $('ad-libs').innerHTML = '<div class="muted" style="padding:16px 4px">還沒有登記任何庫。未登記時整個系統視同單一 general 庫</div>'; return; }
if (!adminLibs.length) { $('ad-libs').innerHTML = '<div class="muted" style="padding:16px 4px">同步小幫手還沒送來任何庫。裝好小幫手並選好資料夾後,庫會自動出現在這裡</div>'; return; }
$('ad-libs').innerHTML = adminLibs.map(function (l) {
var disabled = l.status === 'disabled';
return '<div class="card-item"' + (disabled ? ' style="opacity:.6"' : '') + '>' +
var notWatching = l.daemon_watching === false; // daemon 有報告但此庫不在其中
var removeBtn = l.auto
? '<button class="btn2" style="color:#c0392b;border-color:#e57373;margin-left:auto" data-act="lib-remove-auto" data-name="' + esc(l.name) + '">移除</button>'
: '<button class="btn2" style="color:#c0392b;border-color:#e57373;margin-left:auto" data-act="lib-remove" data-id="' + esc(l.record_id) + '" data-name="' + esc(l.display_name || l.name) + '">移除</button>';
// t142:卡數+三元組數顯示(政府驗收用)。兩者皆 0 顯示「還沒有內容」,不顯示「0 張」。
var cardCount = typeof l.card_count === 'number' ? l.card_count : undefined;
var tripletCount = typeof l.triplet_count === 'number' ? l.triplet_count : undefined;
var statsHtml = '';
if (cardCount !== undefined || tripletCount !== undefined) {
var parts = [];
if (cardCount > 0) parts.push(cardCount + ' 張知識卡');
if (tripletCount > 0) parts.push(tripletCount + ' 條關聯');
statsHtml = '<div style="margin-top:5px;font-size:13px;color:rgba(var(--ink-rgb),.5)">' +
(parts.length ? parts.join('・') : '還沒有內容') + '</div>';
}
return '<div class="card-item">' +
'<div style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap">' +
'<span class="serif" style="font-size:17px;font-weight:600">' + esc(l.display_name || l.name) + '</span>' +
'<span class="dim mono" style="font-size:13px">' + esc(l.name) + '</span>' +
'<span class="tag ' + (disabled ? 'dim' : 'green') + '">' + (disabled ? '已停用' : '啟用中') + '</span>' +
(l.graph_source ? '<span class="tag">圖譜來源</span>' : '') + '</div>' +
(l.description ? '<div style="margin-top:8px;font-size:14.5px;line-height:1.65;color:rgba(var(--ink-rgb),.65)">' + esc(l.description) + '</div>' : '') +
'<div class="btnrow">' +
'<button class="btn2" data-act="lib-graph" data-lid="' + esc(l.record_id) + '" data-next="' + (l.graph_source ? 'false' : 'true') + '">' + (l.graph_source ? '取消圖譜來源' : '標為圖譜來源') + '</button>' +
(disabled
? '<button class="btn2" data-act="lib-status" data-next="active" data-lid="' + esc(l.record_id) + '">啟用</button>'
: '<button class="btn2 warn" data-act="lib-status" data-next="disabled" data-lid="' + esc(l.record_id) + '" data-name="' + esc(l.display_name || l.name) + '">停用</button>') +
'</div></div>';
(l.auto
? '<span class="tag green">啟用中</span><span class="tag">同步自動出現</span>'
: '<span class="tag ' + (disabled ? 'dim' : 'green') + '">' + (disabled ? '已停用' : '啟用中') + '</span>') +
removeBtn +
'</div>' +
statsHtml +
(!l.auto && l.description ? '<div style="margin-top:8px;font-size:14.5px;line-height:1.65;color:rgba(var(--ink-rgb),.65)">' + esc(l.description) + '</div>' : '') +
(notWatching ? '<div style="margin-top:6px;font-size:13px;color:rgba(var(--ink-rgb),.45)">小幫手目前沒有在同步這個資料夾</div>' : '') +
'</div>';
}).join('');
}
@@ -1181,28 +1594,6 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
.catch(function (e) { $('ad-nu-create').disabled = false; st.textContent = friendlyErr(e); });
});
// 登記新庫
$('ad-nl-create').addEventListener('click', function () {
var name = $('ad-nl-name').value.trim();
var st = $('ad-nl-status');
if (!name) { st.textContent = '請填庫名(英數 _ -'; return; }
st.textContent = '';
$('ad-nl-create').disabled = true;
adminApi('POST', '/portal/admin/libraries', {
name: name,
display_name: $('ad-nl-display').value.trim(),
description: $('ad-nl-desc').value.trim()
})
.then(function (x) {
$('ad-nl-create').disabled = false;
if (guard401(x.status)) return;
if (!x.ok) { st.textContent = x.d.error || ('登記失敗(HTTP ' + x.status + ''); return; }
$('ad-nl-name').value = ''; $('ad-nl-display').value = ''; $('ad-nl-desc').value = '';
toast('庫已登記');
loadAdmin();
})
.catch(function (e) { $('ad-nl-create').disabled = false; st.textContent = friendlyErr(e); });
});
// 「全部知識庫」勾選 → 其餘庫勾選框連動停用(存的就是 ["*"],個別勾選無意義)
document.addEventListener('change', function (ev) {
@@ -1280,24 +1671,39 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
.catch(function (e) { t.disabled = false; toast(friendlyErr(e)); });
return;
}
if (act === 'lib-status' || act === 'lib-graph') {
var lid = t.getAttribute('data-lid');
var body2 = {};
if (act === 'lib-status') {
var nextS = t.getAttribute('data-next');
if (nextS === 'disabled' && !confirm('確定停用庫「' + (t.getAttribute('data-name') || '') + '」?停用後不再出現在庫勾選清單(既有授權不會被自動改)。')) return;
body2.status = nextS;
} else {
body2.graph_source = t.getAttribute('data-next') === 'true';
}
// 移除已登記庫(有 record_id
if (act === 'lib-remove') {
var libId = t.getAttribute('data-id') || '';
var libName = t.getAttribute('data-name') || libId;
if (!confirm('把「' + libName + '」從目錄移除?\n\n資料不會刪除,重新同步時會再出現。')) return;
t.disabled = true;
adminApi('PATCH', '/portal/admin/libraries/' + encodeURIComponent(lid), body2)
adminApi('DELETE', '/portal/admin/libraries/' + encodeURIComponent(libId))
.then(function (x) {
t.disabled = false;
if (guard401(x.status)) return;
if (!x.ok) { toast(x.d.error || ('更新失敗(HTTP ' + x.status + '')); return; }
toast('庫已更新');
loadAdmin();
if (!x.ok) { toast(x.d.error || ('移除失敗(HTTP ' + x.status + '')); return; }
adminLibs = adminLibs.filter(function (l) { return l.record_id !== libId; });
renderAdminLibs();
toast('已從目錄移除');
})
.catch(function (e) { t.disabled = false; toast(friendlyErr(e)); });
return;
}
// 移除 auto 庫(無 record_identries 標 deprecated
if (act === 'lib-remove-auto') {
var autoName = t.getAttribute('data-name') || '';
var input = window.prompt('移除「' + autoName + '」會讓它的內容不再被搜尋到(資料保留可還原)。\n\n請輸入庫名確認:');
if (input === null) return; // 取消
if (input.trim() !== autoName) { toast('庫名輸入不符,取消移除'); return; }
t.disabled = true;
adminApi('DELETE', '/portal/admin/libraries/by-name/' + encodeURIComponent(autoName), { confirm: autoName })
.then(function (x) {
t.disabled = false;
if (guard401(x.status)) return;
if (!x.ok) { toast(x.d.error || ('移除失敗(HTTP ' + x.status + '')); return; }
adminLibs = adminLibs.filter(function (l) { return l.name !== autoName; });
renderAdminLibs();
toast('已移除(' + (x.d.deprecated_count || 0) + ' 筆標為不可搜)');
})
.catch(function (e) { t.disabled = false; toast(friendlyErr(e)); });
return;
@@ -1312,6 +1718,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
function loadSettings() {
var p = S.profile;
if (!p) { $('st-me').innerHTML = '<div class="muted">載入中…</div>'; return; }
if (window._loadAiConfig) window._loadAiConfig();
var libs = p.libraries || [];
var libHtml = libs.indexOf('*') >= 0
? '<span class="tag green">全部知識庫</span>'
@@ -1337,7 +1744,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
body: JSON.stringify({ current: oldPw, 'new': newPw })
})
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
$('st-pw-save').disabled = false;
if (guard401(x.status)) return;
@@ -0,0 +1,49 @@
import fs from 'node:fs';
const html = fs.readFileSync(new URL('./index.html', import.meta.url).pathname,'utf8');
// 抽出 daemonPick 相關函式(從 DAEMON_BASE_DEFAULT 到 daemonHint 結尾)
const start = html.indexOf('var DAEMON_BASE_DEFAULT');
const endMark = "return '(封測版未簽章,第一次請右鍵→打開)';\n }";
const end = html.indexOf(endMark) + endMark.length;
if (start < 0 || end < start) throw new Error('抽不到函式區塊');
const src = html.slice(start, end);
const cases = [
['Windows', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36'],
['Mac', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Safari/605.1.15'],
['iPhone', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Safari/604.1'],
['Linux', 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120 Safari/537.36'],
];
let pass=0, fail=0;
const chk=(l,c,extra='')=>{ if(c){console.log('PASS:',l);pass++;} else {console.log('FAIL:',l,extra);fail++;} };
for (const [name, ua] of cases) {
const fn = new Function('navigator','window', src + '; return {daemonPick:daemonPick, daemonHint:daemonHint, daemonBase:daemonBase};');
const api = fn({userAgent: ua}, {});
const d = api.daemonPick();
const label = d.sure ? d.pick.label : '(兩個都給)';
const url = d.sure ? d.pick.url : d.mac.url + ' + ' + d.win.url;
console.log(`\n[${name}] sure=${d.sure}${label}`);
console.log(` url: ${url}`);
if (name==='Windows') {
chk('Windows 給 win zip', d.sure && d.pick.url.endsWith('ArcrunRAG-win-unsigned.zip'), d.pick&&d.pick.url);
chk('Windows 另一版是 Mac', d.other && d.other.url.endsWith('mac-unsigned.zip'));
chk('Windows 話術提 藍色視窗', api.daemonHint('win').includes('仍要執行'));
}
if (name==='Mac') {
chk('Mac 給 mac zip', d.sure && d.pick.url.endsWith('ArcrunRAG-mac-unsigned.zip'));
chk('Mac 另一版是 Windows', d.other && d.other.url.endsWith('win-unsigned.zip'));
chk('Mac 話術提 右鍵打開', api.daemonHint('mac').includes('右鍵'));
}
if (name==='iPhone' || name==='Linux') {
// iPhone 含 "Mac OS X" 但不是桌機 Mac;Linux 兩者皆非 → 都該落在「不確定=兩個都給」
if (name==='Linux') chk('Linux 判不出來→兩個都給', d.sure===false);
if (name==='iPhone') chk('iPhone 不該被判成 Mac(手機→兩個都給)', d.sure===false, 'sure='+d.sure);
}
}
// 舊 key 相容
const fn2 = new Function('navigator','window', src + '; return daemonBase();');
console.log('\n[相容] daemonDownload 舊 key →', fn2({userAgent:''},{ARCRUN_CONFIG:{daemonDownload:'https://x.dev/d/ArcrunRAG-mac-unsigned.zip'}}));
chk('舊 key 推得出目錄', fn2({userAgent:''},{ARCRUN_CONFIG:{daemonDownload:'https://x.dev/d/ArcrunRAG-mac-unsigned.zip'}})==='https://x.dev/d/');
chk('daemonBase 新 key 優先', fn2({userAgent:''},{ARCRUN_CONFIG:{daemonBase:'https://y.dev/z'}})==='https://y.dev/z/');
console.log(`\n=== ${pass} passed, ${fail} failed ===`);
process.exit(fail?1:0);
@@ -0,0 +1,46 @@
import fs from 'node:fs';
const html = fs.readFileSync(new URL('./index.html', import.meta.url).pathname,'utf8');
// 抽出 safeJson 與 friendlyErr 求值
const grab = (name) => {
const i = html.indexOf(`function ${name}(`);
if (i < 0) throw new Error(`找不到 ${name}`);
let d=0, j=html.indexOf('{', i);
for (let k=j;k<html.length;k++){ if(html[k]==='{')d++; if(html[k]==='}'){d--; if(!d){ return html.slice(i,k+1);} } }
throw new Error('括號不平衡');
};
const fn = new Function(grab('safeJson') + '\n' + grab('friendlyErr') + '\nreturn {safeJson, friendlyErr};')();
let pass=0, fail=0;
const t=(l,c,e='')=>{c?(console.log('PASS:',l),pass++):(console.log('FAIL:',l,e),fail++)};
// ① safeJson:非 JSON 不可拋例外(同事撞到的 404 HTML 頁)
const html404 = '<!DOCTYPE html><html><body>404 Not Found</body></html>';
await fn.safeJson({ text: () => Promise.resolve(html404) })
.then(d => t('404 HTML → 回空物件不拋錯', typeof d === 'object' && d !== null))
.catch(e => t('404 HTML → 不該拋錯', false, e.message));
await fn.safeJson({ text: () => Promise.resolve('') })
.then(d => t('空回應 → 回空物件', JSON.stringify(d)==='{}'))
.catch(() => t('空回應 → 不該拋錯', false));
await fn.safeJson({ text: () => Promise.resolve('{"error":"帳號或密碼不對"}') })
.then(d => t('正常 JSON 仍要解析得出來', d.error === '帳號或密碼不對'), )
.catch(() => t('正常 JSON 不該拋錯', false));
// ② friendlyErr:不可把技術訊息噴給使用者
const leak = fn.friendlyErr(new Error('Unexpected non-whitespace character after JSON at position 4'));
t('JSON 錯誤 → 不外洩原文', !/JSON|position/i.test(leak), `實得: ${leak}`);
t('JSON 錯誤 → 說人話', /伺服器回應異常/.test(leak), `實得: ${leak}`);
const net = fn.friendlyErr(new Error('Failed to fetch'));
t('網路錯誤 → 既有訊息保留', /連線中斷/.test(net), `實得: ${net}`);
const ours = fn.friendlyErr(new Error('帳號或密碼不對——用你在知識庫網站設定的那組'));
t('我們自己的中文訊息 → 原樣顯示', /帳號或密碼不對/.test(ours), `實得: ${ours}`);
const stack = fn.friendlyErr(new Error('TypeError: Cannot read properties of undefined'));
t('英文技術訊息 → 收斂不外洩', !/TypeError|undefined/.test(stack), `實得: ${stack}`);
console.log(`\n=== ${pass} passed, ${fail} failed ===`);
process.exit(fail?1:0);
-236
View File
@@ -1,236 +0,0 @@
/**
* console-ui build cypher-executor 的三支 UI renderer 建置時跑一次
* 產出純靜態 HTML public/交給 Cloudflare Pages 託管
*
* 為什麼這樣做cypher-ui-split 第一刀
* 原本 console/portal/dashboard HTML cypher-executor Worker 每次請求時
* template literal 組出來 5,240 UI 字串永遠躺在 Worker bundle 748KB
* /health 這種什麼都不做的請求都要付 5-7ms CPU免費層上限 10ms
* UI 是靜態的單檔 HTML原生 JS零外部資源本來就該待在 Pages
*
* 保持原特性leo 反覆強調簡化
* - 零打包工具 npm 依賴本檔只用 node 內建 fs/path正則抽出 renderer
* template literal 後求值不引入 esbuild/vite/rollup
* - 產出仍是單檔 HTML原生 JS hash routing零外部資源
*
* 唯一的行為差異API base
* 原本 UI API 同源fetch 全用相對路徑'/kbdb/search'搬上 Pages 後跨網域
* 故注入 window.ARCRUN_API_BASE並把 fetch 的相對路徑改成 API_BASE + path
* 見下方 rewriteFetchPaths()
*/
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..');
const SRC = join(ROOT, '..', 'cypher-executor', 'src');
const OUT = join(ROOT, 'public');
// ── 建置期組態(原本是 Worker 的 env var,現在是建置參數)────────────────
// Pages 是靜態站,沒有 per-request env;品牌/profile 這類「一個部署一個值」的
// 設定改在建置時決定(要換值=重跑 build 再部署,符合靜態站模型)。
// 具名部署目標(deploy.targets.json):一個目標=帳號+profileapiBase 綁在一起。
// 帶 DEPLOY_TARGET=personal|enterprise 就套用該組值;個別環境變數仍可覆蓋(除錯用)。
// 立此檔的原因見 deploy.targets.json 的 _readme——散在部署指令裡的參數帶漏過三次。
const TARGET_NAME = process.env.DEPLOY_TARGET || '';
let TARGET = {};
if (TARGET_NAME) {
const targets = JSON.parse(readFileSync(join(ROOT, 'deploy.targets.json'), 'utf8'));
TARGET = targets[TARGET_NAME];
if (!TARGET) {
const names = Object.keys(targets).filter((k) => !k.startsWith('_'));
throw new Error(`未知的 DEPLOY_TARGET"${TARGET_NAME}"。可用:${names.join(' / ')}`);
}
console.log(`部署目標:${TARGET_NAME}${TARGET.description}`);
}
const CFG = {
brand: process.env.CONSOLE_BRAND || TARGET.brand || 'Arcrun',
profile: process.env.CONSOLE_PROFILE || TARGET.profile || 'full',
registryBase: process.env.REGISTRY_BASE || 'https://registry.arcrun.dev',
sourceWebBase: process.env.PORTAL_SOURCE_WEB_BASE || '',
// API base 走 runtime 注入(見 public/config.js),這裡只放預設值
apiBase: process.env.ARCRUN_API_BASE || TARGET.apiBase || '',
};
/**
* TS 原始碼並取出整個 renderer 函式的**函式主體**不只 template literal
*
* 取整個 body 而非只取反引號區塊是因為 renderer return 之前會先算區域變數
* console.ts rag/views/home profile 推導只搬模板把那段推導邏輯
* 複製一份到本檔雙份真相會漂移 body 一起求值 推導邏輯永遠只有一份
*/
/**
* renderer 原始檔的位置本專案 `console-ui/src/` 優先找不到才回退 cypher-executor
*
* 為什麼要這層2026-07-22 `5a16484` UI 搬出 cypher-executor
* **刪了 console.ts / portal-ui.ts 卻只搬走 build 產物HTML原始檔沒跟著搬**
* build.mjs 讀不到來源`npm run build` 從那天起就 ENOENT 死掉
* 線上 HTML 是刪檔前烤好的之後再也無法重建profile 改了也不會生效
* 現已從 git 撈回放進 console-ui/src/UI 原始碼跟著 UI 專案走才是那一刀的原意
* console-dashboard.ts 仍在 cypher-executor它同時含 API故保留回退路徑
*/
function resolveSource(file) {
const local = join(ROOT, 'src', file.replace(/^routes\//, ''));
if (existsSync(local)) return local;
return join(SRC, file);
}
function extractRendererBody(file, fnName) {
const code = readFileSync(resolveSource(file), 'utf8');
const start = code.indexOf(`function ${fnName}(`);
if (start < 0) throw new Error(`找不到 ${fnName} in ${file}`);
const braceStart = code.indexOf('{', code.indexOf(')', start));
if (braceStart < 0) throw new Error(`${fnName} 找不到函式主體`);
// 掃到配對的收尾大括號;需略過字串/template literal/註解裡的括號
let i = braceStart + 1;
let depth = 1;
let mode = null; // null | "'" | '"' | '`' | 'line' | 'block'
let tplDepth = 0;
while (i < code.length && depth > 0) {
const ch = code[i];
const nx = code[i + 1];
if (mode === null) {
if (ch === '\\') { i += 2; continue; }
if (ch === '/' && nx === '/') { mode = 'line'; i += 2; continue; }
if (ch === '/' && nx === '*') { mode = 'block'; i += 2; continue; }
if (ch === "'" || ch === '"') { mode = ch; i++; continue; }
if (ch === '`') { mode = '`'; tplDepth = 0; i++; continue; }
if (ch === '{') depth++;
else if (ch === '}') depth--;
i++;
continue;
}
if (mode === 'line') { if (ch === '\n') mode = null; i++; continue; }
if (mode === 'block') { if (ch === '*' && nx === '/') { mode = null; i += 2; continue; } i++; continue; }
if (ch === '\\') { i += 2; continue; }
if (mode === '`') {
// template literal 內的 ${ … } 是真程式碼,其中的引號/括號要照常計數才不會誤判收尾
if (ch === '$' && nx === '{') { tplDepth++; i += 2; continue; }
if (ch === '}' && tplDepth > 0) { tplDepth--; i++; continue; }
if (ch === '`' && tplDepth === 0) { mode = null; i++; continue; }
i++;
continue;
}
if (ch === mode) mode = null;
i++;
}
// 去掉 TS 的型別註記(本 body 只有 `const x: T =` 這種簡單形態)
return code.slice(braceStart + 1, i - 1).replace(/\bconst\s+(\w+):\s*[\w<>[\]|]+\s*=/g, 'const $1 =');
}
/** 取出 lib/taipei-time.ts 匯出的 TAIPEI_CLIENT_JS 字串常數(UI 內嵌的客戶端時間工具)。 */
function extractTaipeiClientJs() {
const code = readFileSync(join(SRC, 'lib', 'taipei-time.ts'), 'utf8');
// 形態=字串陣列 .join('\n')(見 lib/taipei-time.ts),直接求值該陣列表達式
const m = code.match(/export const TAIPEI_CLIENT_JS\s*=\s*(\[[\s\S]*?\]\.join\('\\n'\));/);
if (!m) throw new Error('找不到 TAIPEI_CLIENT_JS');
return new Function(`return ${m[1]};`)();
}
/**
* 求值 renderer 函式主體 new Function 而非 eval只餵建置期組態
* 輸入是本 repo 自己的原始碼非使用者輸入無注入面
*/
function render(body, vars) {
const names = Object.keys(vars);
const fn = new Function(...names, body);
return fn(...names.map((n) => vars[n]));
}
/**
* UI 內原生 JS 的相對路徑 fetch 改成打 API base
*
* 只改 `fetch('/...` `fetch("/...`開頭是單斜線同源絕對路徑這一種形態
* 其餘fetch(url, ) 這類變數形式另由各檔的 url 組法在下面單獨處理
*/
function rewriteFetchPaths(html, file) {
// ① fetch('/xxx → fetch(API_BASE + '/xxx
let out = html.replace(/fetch\((['"])\/(?!\/)/g, 'fetch(API_BASE + $1/');
// ② 變數式 fetch(url, ...)url 由上方 var url = '/kbdb/search?...' 組成 →
// 把這類「以單斜線開頭的路徑字面值指派」也補上 API_BASE
out = out.replace(/(\bvar\s+url\s*=\s*)(['"])\/(?!\/)/g, '$1API_BASE + $2/');
// ③ portal 的 adminApi(method, path, body)path 由呼叫端傳字面值進來,①②
// 都掃不到(8 個呼叫點)。在 helper 內部補前綴=一處修好全部,不必改 8 個呼叫點。
out = out.replace(
/(function adminApi\(method, path, body\) \{)/,
'$1\n path = API_BASE + path;'
);
// 防呆:搬完後不該再有「直接 fetch 同源相對路徑」的殘留。掃到就讓建置失敗,
// 免得漏網的呼叫點在 Pages 上打到 Pages 自己(404)才被發現。
// 註:adminApi 的呼叫端仍是相對路徑字面值——那是對的,前綴由 helper 內部(③)加。
const unprefixed = [...out.matchAll(/fetch\((['"])\/(?!\/)[^'"]*/g)].map((m) => m[0]);
if (unprefixed.length) {
throw new Error(
`${file}:有 ${unprefixed.length} 個相對路徑 fetch 沒被改寫成 API_BASE\n ` +
[...new Set(unprefixed)].join('\n ')
);
}
// adminApi 形態存在時,必須確認 helper 已被加上前綴(否則 8 個呼叫點全會打錯家)
if (/function adminApi\(method, path, body\)/.test(out) && !/path = API_BASE \+ path;/.test(out)) {
throw new Error(`${file}:偵測到 adminApi helper 但前綴注入失敗`);
}
return out;
}
/** 在頁面 <head> 注入 config.jsruntime 決定 API base),並定義 API_BASE 供內嵌 JS 用。 */
function injectApiBase(html) {
const snippet = `<script src="/config.js"></script>
<script>window.ARCRUN_API_BASE = (window.ARCRUN_CONFIG && window.ARCRUN_CONFIG.apiBase) || ${JSON.stringify(CFG.apiBase)};</script>`;
const withCfg = html.replace('</head>', `${snippet}\n</head>`);
// 內嵌的 IIFE 裡宣告 API_BASE(各頁的主 <script> 都是 (function(){ … })() 形態)
return withCfg.replace(
/<script>\s*\(function\s*\(\)\s*\{/,
'<script>\n(function () {\n var API_BASE = window.ARCRUN_API_BASE || \'\';'
);
}
function build(name, file, fnName, vars) {
const body = extractRendererBody(file, fnName);
let html = render(body, vars);
html = rewriteFetchPaths(html, name);
html = injectApiBase(html);
const dest = join(OUT, name);
mkdirSync(dirname(dest), { recursive: true });
writeFileSync(dest, html, 'utf8');
console.log(` ${name.padEnd(24)} ${(Buffer.byteLength(html) / 1024).toFixed(1)} KB`);
}
const TAIPEI_CLIENT_JS = extractTaipeiClientJs();
mkdirSync(OUT, { recursive: true });
console.log('console-ui build →', OUT);
// /console — Admin Console 完整版(console.ts renderConsoleHtml
build('console/index.html', 'routes/console.ts', 'renderConsoleHtml', {
registryBase: CFG.registryBase,
brand: CFG.brand,
profile: CFG.profile,
TAIPEI_CLIENT_JS,
});
// /portal — RAG Portalportal-ui.ts renderPortalHtml
build('portal/index.html', 'routes/portal-ui.ts', 'renderPortalHtml', {
brand: CFG.brand,
sourceWebBase: CFG.sourceWebBase,
TAIPEI_CLIENT_JS,
});
// /console/dashboard — 駕駛艙(console-dashboard.ts renderDashboardHtml
build('console/dashboard/index.html', 'routes/console-dashboard.ts', 'renderDashboardHtml', {
brand: CFG.brand,
TAIPEI_CLIENT_JS,
});
// config.js:部署後可直接改這一檔切 API 目標,不必重 build
writeFileSync(
join(OUT, 'config.js'),
`// Arcrun UI runtime 組態——改這一行就能切 API 目標,不必重新 build。
window.ARCRUN_CONFIG = { apiBase: ${JSON.stringify(CFG.apiBase)} };
`,
'utf8'
);
console.log(' config.js');
console.log('done.');
+11 -2
View File
@@ -39,8 +39,17 @@ console.log(` apiBase ${t.apiBase}\n`);
const env = { ...process.env, DEPLOY_TARGET: name, CLOUDFLARE_ACCOUNT_ID: t.accountId };
const build = spawnSync('node', [join(ROOT, 'scripts', 'build.mjs')], { stdio: 'inherit', env });
if (build.status !== 0) process.exit(build.status ?? 1);
// t160leo 07-31:「如果你會搞不清楚,就把錯的東西刪掉」):build 步驟已隨舊世代
// src/ 一起 git rm——public/ 是唯一世代真身(手改演進),deploy=直接託管它。
// 病史:src/(舊代 renderer 快照)與 public/(新代真身)並存,deploy 自動跑 build
// 從舊 src 重產 public ⇒ 任何一次部署都可能把 UI 打回舊世代(07-27 記帳、07-31 引爆:
// t159 重打包用了舊 public 的分支副本,leo 刷新看到被淘汰的「登記新庫」表單)。
// 世代閘:部署前驗 public 指紋,舊世代(缺新文案/含人工建庫表單)直接拒部。
const portalHtml = readFileSync(join(ROOT, 'public', 'portal', 'index.html'), 'utf8');
if (!portalHtml.includes('不需要人工新增') || portalHtml.includes('登記新庫')) {
console.error('✘ 世代閘:public/portal/index.html 不是現行世代(缺「不需要人工新增」或含「登記新庫」)——拒絕部署舊 UI。');
process.exit(1);
}
// --commit-dirty:本地部署常有未提交變更,不因此中斷
const deploy = spawnSync(
-822
View File
@@ -1,822 +0,0 @@
/**
* arcrun console dashboardT-cockpit Arcrun#3 console 2026-07-04
* 2026-07-07 fix/console-dashboard-live-datastale
*
* //
* - GET /console/dashboard-data JSON
* - GET /console/dashboard HTML console.ts 60
* - GET /console/kb-scale-datawiki //2026-07-07 leo
* console
* - GET /console/settings-dataMCP token TTL
*
* 2026-07-07 fix/console-truth-audit/ sprint
* leo dash_task dash_wait
* sprint ## #36
* Gitea fetch90s
* dash_task fallback commit N
*
* 2026-07-07 live stale
*
* leo
* - dash_wait 2026-07-04****leo清單#11
* 07-05 dashboard InkStoneCo sprint
* ## leo progress-guard routine
* - dash_task scope:"today" 07-04 07-07
* - dash_beat dash_*progress-guard/cloud-worker/watchdog
*
*
* Gitea sprint leo清單 GITEA_BASE_URL var + GITEA_TOKEN secret
* fetch Gitea API GitHub D20 fallback dash_wait
* age + stale dash_wait
* dash_task is_todayN
* sprint dashboard
* live KBDB /health/embed/backfill/statusenabled:false
* kbdb-graph-plugin /triplets/statsworkflow KBDB entry_type=workflow
* KBDB entries wiki_card triplets live API
*
*
* red = blocked > 240 09:00-22:00
* KBDB /health stale ****07-04 blocked 07-07
* yellow = red statuslate/behind
* green =
*
* API rule 07
* lib/console-dashboard-model.ts KBDB HTTPkbdbBase binding
*/
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { kbdbBase, graphBase } from './kbdb-proxy';
import { validateConsoleSession } from './console-auth';
import {
type KbdbEntry,
type WaitingItem,
type WaitingModel,
type CachedWaitingEnvelope,
type SprintBoardTask,
type SprintSnapshot,
GITEA_WAITING_CACHE_TTL_SECONDS,
parseCreatedAtMs,
parseJsonContent,
agoMinutes,
buildRouteModel,
buildSprintRouteModel,
buildWaitingFallback,
parseSprintTaskBoard,
parseSprintWaitingTable,
pickLatestSprintFiles,
reviveWaitingAges,
sortWaitingItems,
taipeiDayKey,
} from '../lib/console-dashboard-model';
import { applyTriageCheck, buildTriageModel, type TriageCheckAction } from '../lib/console-triage-model';
import { TAIPEI_CLIENT_JS } from '../lib/taipei-time';
export const consoleDashboardRouter = new Hono<{ Bindings: Bindings }>();
const STALE_MINUTES = 240;
const JUDGE_START_HOUR = 9; // 台北時間,含
const JUDGE_END_HOUR = 22; // 台北時間,不含
const STANDARD_TASK_STATUS = new Set(['done', 'doing', 'todo', 'blocked']);
async function fetchEntries(env: Bindings, tenant: string, entryType: string, limit: number): Promise<KbdbEntry[]> {
const { base, headers } = kbdbBase(env);
const params = new URLSearchParams({ owner_id: tenant, entry_type: entryType, limit: String(limit) });
try {
const res = await fetch(`${base}/entries?${params.toString()}`, { headers });
if (!res.ok) return [];
const data = (await res.json()) as { entries?: KbdbEntry[] };
return data.entries ?? [];
} catch {
return [];
}
}
/** 泛用 GET JSON(失敗回 null,caller 誠實顯示「讀不到」,不編數字)。 */
async function fetchJson<T>(url: string, headers?: Record<string, string>): Promise<T | null> {
try {
const res = await fetch(url, headers ? { headers } : undefined);
if (!res.ok) return null;
return (await res.json()) as T;
} catch {
return null;
}
}
/** KBDB entries 符合條件的總數(limit=1 只拿 total 欄,不搬資料)。null = 讀不到。 */
async function fetchEntryTotal(env: Bindings, filters: Record<string, string>): Promise<number | null> {
const { base, headers } = kbdbBase(env);
const params = new URLSearchParams({ ...filters, limit: '1' });
const data = await fetchJson<{ total?: unknown }>(`${base}/entries?${params.toString()}`, headers);
return data && typeof data.total === 'number' ? data.total : null;
}
/**
* sprint fetch leo 2026-07-07
* - ## leo progress-guard
* - ## checkbox
* GITEA_BASE_URLvar+ GITEA_TOKENsecret scope
* sprint-*.md sprint /07b
* 🔴 mira [🔄] T-cockpit 07a raw parser
* commit leo清單全解析失敗回 null caller fallback
* dash_wait / dash_task age
*/
async function fetchGiteaSprint(env: Bindings, nowMs: number): Promise<SprintSnapshot | null> {
const base = (env.GITEA_BASE_URL ?? '').replace(/\/$/, '');
const token = env.GITEA_TOKEN;
if (!base || !token) return null;
const repo = env.GITEA_SPRINT_REPO ?? 'Leo/InkStoneCo';
const dir = env.GITEA_SPRINT_DIR ?? 'system-dev/docs/3-specs/autonomy-dispatch';
const headers = { Authorization: `token ${token}` };
try {
const files = await fetchJson<{ name: string }[]>(`${base}/api/v1/repos/${repo}/contents/${encodeURI(dir)}`, headers);
if (!files) return null;
const sprints = pickLatestSprintFiles(files.map((f) => f.name));
if (!sprints.length) return null;
const parsed = await Promise.all(
sprints.map(async (name) => {
const rawRes = await fetch(`${base}/api/v1/repos/${repo}/raw/${encodeURI(`${dir}/${name}`)}`, { headers });
if (!rawRes.ok) return null;
const text = await rawRes.text();
return { waiting: parseSprintWaitingTable(text, name), board: parseSprintTaskBoard(text, name) };
}),
);
const readFiles = sprints.filter((_, i) => parsed[i]?.waiting != null);
const merged = parsed.map((p) => p?.waiting).filter((p): p is WaitingItem[] => p != null).flat();
if (!readFiles.length) return null; // 等leo清單全部解析失敗=誠實 fallback
// 任務板:新→舊合併(現役 sprint 的板先列);兩檔都沒有可解析的板 → nullfallback dash_task
const boardMerged = parsed.map((p) => p?.board).filter((b): b is SprintBoardTask[] => b != null).flat();
// 清單上次維護時間 = 現役 sprint 檔最後 commitprogress-guard 每日 commit>48h 沒動才算 stale
let ago = -1;
const commits = await fetchJson<{ commit?: { committer?: { date?: string } } }[]>(
`${base}/api/v1/repos/${repo}/commits?path=${encodeURIComponent(`${dir}/${readFiles[0]}`)}&limit=1&stat=false&verification=false&files=false`,
headers,
);
const date = commits?.[0]?.commit?.committer?.date;
if (date) {
const ms = Date.parse(date);
if (!Number.isNaN(ms)) ago = agoMinutes(nowMs, ms);
}
return {
waiting: {
items: sortWaitingItems(merged),
source: 'gitea_sprint',
updated_ago_minutes: ago,
stale: ago >= 0 && ago > 48 * 60,
sprint_files: readFiles,
},
board: boardMerged.length ? boardMerged : null,
};
} catch {
return null;
}
}
export type GiteaSprintFetcher = (env: Bindings, nowMs: number) => Promise<SprintSnapshot | null>;
/**
* fetchGiteaSprint #36 CF Cache APIcaches.default
* TTL 90sGITEA_WAITING_CACHE_TTL_SECONDS 60 Gitea
* 3-4 API call1 /90s
*
* - key URLCache API URLhost
* base/repo/dir miss Gitea
* - hit reviveWaitingAges N fetch
* ago completed_days
*
* - ****negative cache 90 caller
* fallback dash_wait / dash_task
* - cache.put waitUntilfetcher
* - cache:'hit'|'miss' waiting_meta curl
* hit
*/
export async function cachedGiteaSprint(
env: Bindings,
nowMs: number,
waitUntil: (p: Promise<unknown>) => void,
fetcher: GiteaSprintFetcher = fetchGiteaSprint,
): Promise<(SprintSnapshot & { cache: 'hit' | 'miss' }) | null> {
if (!env.GITEA_BASE_URL || !env.GITEA_TOKEN) return null;
const repo = env.GITEA_SPRINT_REPO ?? 'Leo/InkStoneCo';
const dir = env.GITEA_SPRINT_DIR ?? 'system-dev/docs/3-specs/autonomy-dispatch';
const cacheKey = new Request(
`https://console-dashboard.arcrun.internal/gitea-waiting?${new URLSearchParams({ base: env.GITEA_BASE_URL, repo, dir }).toString()}`,
);
const cache = caches.default;
try {
const hit = await cache.match(cacheKey);
if (hit) {
const envelope = (await hit.json()) as CachedWaitingEnvelope;
return {
waiting: reviveWaitingAges(envelope.snapshot.waiting, envelope.fetched_at_ms, nowMs),
board: envelope.snapshot.board,
cache: 'hit',
};
}
} catch {
/* cache 故障不致命,走 miss 路徑 */
}
const fresh = await fetcher(env, nowMs);
if (!fresh) return null; // 失敗不快取,caller 誠實 fallback
const envelope: CachedWaitingEnvelope = { snapshot: fresh, fetched_at_ms: nowMs };
try {
waitUntil(
cache.put(
cacheKey,
new Response(JSON.stringify(envelope), {
headers: {
'Content-Type': 'application/json',
'Cache-Control': `public, max-age=${GITEA_WAITING_CACHE_TTL_SECONDS}`,
},
}),
),
);
} catch {
/* put 失敗只是少了快取,不影響本次回應 */
}
return { ...fresh, cache: 'miss' };
}
// GET /console/dashboard-data — 聚合 JSON(無需登入;唯讀、不含機敏值)
consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
const tenant = c.env.CONSOLE_TENANT || 'leo';
const now = Date.now();
const { base: kbdbUrl, headers: kbdbHeaders } = kbdbBase(c.env);
const graphUrl = graphBase(c.env);
const [
beatEntries,
taskEntries,
waitEntries,
inboxEntries,
giteaSprint,
kbdbHealth,
embedStatus,
graphStats,
entriesTotal,
wikiCardTotal,
workflowTotal,
] = await Promise.all([
fetchEntries(c.env, tenant, 'dash_beat', 100),
fetchEntries(c.env, tenant, 'dash_task', 200),
fetchEntries(c.env, tenant, 'dash_wait', 100),
fetchEntries(c.env, tenant, 'inbox', 200),
cachedGiteaSprint(c.env, now, (p) => c.executionCtx.waitUntil(p)),
fetchJson<{ ok?: boolean }>(`${kbdbUrl}/health`, kbdbHeaders),
fetchJson<{ enabled?: boolean; pending?: number; embedded?: number }>(`${kbdbUrl}/embed/backfill/status`, kbdbHeaders),
fetchJson<{ total?: number; recent?: { today?: number; this_week?: number } }>(`${graphUrl}/triplets/stats`),
// owner_id 一律鎖本租戶:原本不帶 owner 會混到別租戶(實測 459,137 vs leo 的 458,732
fetchEntryTotal(c.env, { owner_id: tenant }),
fetchEntryTotal(c.env, { entry_type: 'wiki_card', owner_id: tenant }),
fetchEntryTotal(c.env, { entry_type: 'workflow', owner_id: tenant }),
]);
// dash_beat:每 actor 最新一筆(list 已 created_at DESC → first-seen 即最新)。唯一有活管線的 dash_*。
const beats: { actor: string; event: string; note: string; at: string | number; ago_minutes: number }[] = [];
const seenActors = new Set<string>();
for (const e of beatEntries) {
const j = parseJsonContent(e);
const actor = typeof j?.actor === 'string' ? j.actor : null;
if (!actor || seenActors.has(actor)) continue;
seenActors.add(actor);
const ms = parseCreatedAtMs(e.created_at);
beats.push({
actor,
event: typeof j?.event === 'string' ? (j.event as string) : '',
note: typeof j?.note === 'string' ? (j.note as string) : '',
at: e.created_at,
ago_minutes: agoMinutes(now, ms),
});
}
const lastBeat = beats.filter((b) => b.ago_minutes >= 0).sort((a, b) => a.ago_minutes - b.ago_minutes)[0] ?? null;
// 等你的事:Gitea sprint 等leo清單優先(走 90s 快取);讀不到 fallback dash_wait(帶 age + stale
let waiting: WaitingModel;
let waitingCache: 'hit' | 'miss' | null = null;
if (giteaSprint) {
waiting = giteaSprint.waiting;
waitingCache = giteaSprint.cache;
} else {
waiting = buildWaitingFallback(waitEntries, now);
if (waiting.source === 'kbdb_dash_wait' && !(c.env.GITEA_BASE_URL && c.env.GITEA_TOKEN)) {
waiting.note = 'Gitea sprint 清單未接(缺 GITEA_TOKEN secret)——以下是 dash_wait 殘資料';
} else if (waiting.source === 'kbdb_dash_wait') {
waiting.note = 'Gitea sprint 清單讀取失敗——以下是 dash_wait 殘資料';
}
}
// 今日完成/今日路線:sprint 任務板優先(leo 2026-07-07 拍板——「到底完成了多少事」的
// 真相源=progress-guard/cloud-worker 每日勾選的板,dash_task 沒活管線降 fallback)。
// 板的「今日完成」只認「完成(今天台北日)」標記;板檔今天沒 commit 過 → 誠實標示。
const sprintRoute = giteaSprint?.board ? buildSprintRouteModel(giteaSprint.board, now) : null;
const route = buildRouteModel(taskEntries, now); // fallback 燈號仍吃 dash_task 今日寫入
const boardAgo = giteaSprint ? giteaSprint.waiting.updated_ago_minutes : -1;
const boardUpdatedToday = boardAgo >= 0 && taipeiDayKey(now - boardAgo * 60000) === taipeiDayKey(now);
// inbox:未處理計數(status !== 'done';沒標 status 視為未處理)
const inboxNew = inboxEntries.reduce((n, e) => {
const j = parseJsonContent(e);
return j && j.status !== 'done' ? n + 1 : n;
}, 0);
// 燈號:只吃「今日寫入」的任務 + 心跳 + KBDB 健康(stale 殘任務不再觸發燈號)
const todayWrites = route.tasks.filter((t) => t.is_today_write);
const hasBlocked = todayWrites.some((t) => t.status === 'blocked');
const hasLagMark = todayWrites.some((t) => !STANDARD_TASK_STATUS.has(t.status));
const taipeiHour = new Date(now + 8 * 3600 * 1000).getUTCHours();
const inJudgeWindow = taipeiHour >= JUDGE_START_HOUR && taipeiHour < JUDGE_END_HOUR;
const beatStale = lastBeat === null || lastBeat.ago_minutes > STALE_MINUTES;
const kbdbOk = kbdbHealth?.ok === true;
const light: 'green' | 'yellow' | 'red' =
hasBlocked || (inJudgeWindow && beatStale) || !kbdbOk ? 'red' : hasLagMark ? 'yellow' : 'green';
const lightReason = !kbdbOk
? 'KBDB 基本盤 /health 打不通'
: hasBlocked
? '今日任務有 blocked'
: inJudgeWindow && beatStale
? `心跳超過 ${STALE_MINUTES} 分鐘`
: hasLagMark
? '今日任務有落後標記'
: '';
return c.json({
light,
light_reason: lightReason,
last_beat: lastBeat ? { actor: lastBeat.actor, ago_minutes: lastBeat.ago_minutes, event: lastBeat.event, note: lastBeat.note } : null,
beats,
// 路線:sprint 任務板優先(tasks 欄位形狀與 dash_task 版相容——title/status/scope);
// 板上開著的項 is_today_write=false(燈號沿 #36 原則只吃 dash_task 今日寫入+心跳+KBDB
// 板上掛了幾天的 [!] 不會天天亮紅燈——那是「等裁決」不是「今天卡住」)
tasks: sprintRoute
? sprintRoute.tasks.map((t, i) => ({
title: t.title,
status: t.status,
order: i,
scope: 'today' as const,
age_minutes: boardAgo,
is_today_write: t.status === 'done', // done 項必然是「今天完成」的(模型已濾)
sprint: t.sprint ?? null,
}))
: route.tasks.map((t) => ({
title: t.title,
status: t.status,
order: t.order,
scope: t.scope,
age_minutes: t.age_minutes,
is_today_write: t.is_today_write,
sprint: null,
})),
route_meta: sprintRoute
? {
source: 'gitea_sprint_board',
// is_today=板檔今天(台北)有 commit 過;false → 頁面誠實標「今日任務板未更新」
is_today: boardUpdatedToday,
updated_ago_minutes: boardAgo,
sprint_files: waiting.sprint_files ?? null,
}
: {
source: 'kbdb_dash_task',
is_today: route.is_today,
updated_ago_minutes: route.updated_ago_minutes,
sprint_files: null,
},
today_done: sprintRoute ? sprintRoute.today_done : route.today_done,
today_total: sprintRoute ? sprintRoute.today_total : route.today_total,
done_today_titles: sprintRoute ? sprintRoute.done_today_titles : null,
waiting: waiting.items,
waiting_meta: {
source: waiting.source,
updated_ago_minutes: waiting.updated_ago_minutes,
stale: waiting.stale,
sprint_files: waiting.sprint_files ?? null,
note: waiting.note ?? null,
// Gitea 快取層狀態(hit/missfallback 路徑為 null)——快取生效的客觀證據
cache: waitingCache,
},
inbox_new: inboxNew,
system: {
kbdb_ok: kbdbHealth ? kbdbHealth.ok === true : false,
embed: embedStatus
? { enabled: embedStatus.enabled === true, embedded: embedStatus.embedded ?? null, pending: embedStatus.pending ?? null }
: null,
graph: graphStats ? { ok: true, triplets: graphStats.total ?? null } : { ok: false, triplets: null },
workflow_total: workflowTotal,
},
kb: {
entries_total: entriesTotal,
wiki_card_total: wikiCardTotal,
triplets_total: graphStats?.total ?? null,
},
generated_at: new Date(now).toISOString(),
});
});
// GET /console/kb-scale-data — 總庫「精耕層」規模(leo 2026-07-07 裁:45.8 萬 14-E 搬遷
// blocks 已 deprecated 之後要刪,頭部統計**不再拿遺產數字撐場面**,只顯示真的新的)。
// 免登入(純聚合計數、無內容原文,同 dashboard-data 標準)。3 個 subrequest,全是
// limit=1(只拿 total 欄)或現成 stats 聚合端點——不逐筆掃庫,不撞子請求上限。
// 搜尋功能本身仍可搜全庫(資料不藏),只是規模感不再引用遺產總數。
consoleDashboardRouter.get('/console/kb-scale-data', async (c) => {
const tenant = c.env.CONSOLE_TENANT || 'leo';
const { base, headers } = kbdbBase(c.env);
const graphUrl = graphBase(c.env);
const now = Date.now();
const [wikiCards, graphStats, embedStatus] = await Promise.all([
// limit=1 順手拿最新一筆 created_atlist 為 created_at DESC)=「最近寫入時間」
fetchJson<{ total?: number; entries?: { created_at?: string | number }[] }>(
`${base}/entries?${new URLSearchParams({ owner_id: tenant, entry_type: 'wiki_card', limit: '1' }).toString()}`,
headers,
),
fetchJson<{ total?: number }>(`${graphUrl}/triplets/stats`),
fetchJson<{ enabled?: boolean; embedded?: number; pending?: number }>(`${base}/embed/backfill/status`, headers),
]);
const latestMs = parseCreatedAtMs(wikiCards?.entries?.[0]?.created_at ?? null);
// 讀不到的欄位誠實回 null(頁面顯示「讀不到」),不編數字
return c.json({
wiki_card_total: typeof wikiCards?.total === 'number' ? wikiCards.total : null,
wiki_card_latest_ago_minutes: latestMs === null ? -1 : agoMinutes(now, latestMs),
triplets_total: typeof graphStats?.total === 'number' ? graphStats.total : null,
embedded: embedStatus?.embedded ?? null,
embed_enabled: embedStatus ? embedStatus.enabled === true : null,
generated_at: new Date(now).toISOString(),
});
});
// GET /console/settings-data — 設定頁的誠實系統值(目前只有 MCP token TTL 佔位區塊用)。
// TTL 真相住在 mcp worker 部署端 env `MCP_TOKEN_TTL`mcp/src/types.ts,預設 259200030 天);
// cypher 讀的是自己這份同名 var(deploy 時兩處要一致,#32 形態 config 同步教訓)——
// source 欄位如實標 env/default,頁面不假裝這是能遠端改的設定。
consoleDashboardRouter.get('/console/settings-data', (c) => {
const raw = c.env.MCP_TOKEN_TTL;
const parsed = raw ? parseInt(raw, 10) : NaN;
const fromEnv = Number.isFinite(parsed) && parsed > 0;
return c.json({
mcp_token_ttl_seconds: fromEnv ? parsed : 2592000,
mcp_token_ttl_source: fromEnv ? 'env' : 'default',
});
});
// GET /console/triage-data — 分流台資料(Mira Console 頁 7Arcrun#9 收件夾改裝;原
// /console/inbox-data 的後繼——唯一消費者是 console 頁本身,一起改裝,不留死端點)。
// **需 console session**Bearer):dashboard-data 只吐計數可免登入;這裡吐待辦/訊息原文屬機敏,鎖登入。
// 資料源二合一(kb-ingest SDD R7):entry_type=todoLogseq 萃取,Arcrun#8 ingest 線)+
// entry_type=inboxTelegram)。契約解析/三欄分流/計數=純函式 lib/console-triage-model.ts。
consoleDashboardRouter.get('/console/triage-data', async (c) => {
const ok = await validateConsoleSession(c.env, c.req.header('authorization'));
if (!ok) return c.json({ error: '需要登入(console session' }, 401);
const tenant = c.env.CONSOLE_TENANT || 'leo';
const [todoEntries, inboxEntries] = await Promise.all([
fetchEntries(c.env, tenant, 'todo', 500),
fetchEntries(c.env, tenant, 'inbox', 200),
]);
const model = buildTriageModel(todoEntries, inboxEntries);
return c.json({ ...model, generated_at: new Date().toISOString() });
});
// POST /console/triage-check — 分流台勾掉/還原(leo 2026-07-08 拍板;body: {entry_id, action?})。
// 為什麼開這個小端點而不讓瀏覽器直打 KBDB:瀏覽器沒有 KBDB_INTERNAL_TOKENtoken 只能在
// server 側,同 kbdb-graph proxy 理由),且 console session ≠ X-Arcrun-API-Key。沿用
// triage-data 同款 session 驗證,server 端做 KBDB PATCHkbdbBase 慣例)。
//
// PATCH content 需**整串回寫**KBDB updateEntry 是欄位級覆蓋,content 給什麼存什麼)——
// 先 GET 原 entry、只動 status/checked_* 欄再回寫,防蓋掉 text/marker/owner_tier 等別的欄位。
// 改寫邏輯=lib/console-triage-model.ts applyTriageCheck(純函式,vitest 驗證)。
//
// ── 雙向銷案語意(死循環防呆,與 applyTriageCheck 註解同一套規約,萃取端會配合)──
// console 勾掉=終局(checked_via:"console"):即使 Logseq 原文還是 TODO,萃取端也絕不
// 復活它;Logseq 改 DONE 的由萃取端 PATCH status:donechecked_via:"logseq")。
// console 只需忠實顯示非 done 項;還原=status 回 new + 移除 checked_via/checked_at。
consoleDashboardRouter.post('/console/triage-check', async (c) => {
const ok = await validateConsoleSession(c.env, c.req.header('authorization'));
if (!ok) return c.json({ error: '需要登入(console session' }, 401);
const body = await c.req.json().catch(() => null);
const entryId = typeof body?.entry_id === 'string' ? body.entry_id.trim() : '';
if (!entryId) return c.json({ error: 'entry_id 必填' }, 400);
const action: TriageCheckAction = body?.action === 'restore' ? 'restore' : 'check';
const tenant = c.env.CONSOLE_TENANT || 'leo';
const { base, headers } = kbdbBase(c.env);
// 先 GET 原 entry(整串回寫的前提),順便守兩道邊界:
// 1. owner_id 必須=console 固定租戶(session 只代表 leo 這個租戶,不能改到別人的資料);
// 2. entry_type 限分流台的兩個來源 todo/inbox(這端點不是泛用 entry 改寫器)。
const got = await fetchJson<{ entry?: { owner_id?: string; entry_type?: string; content?: string | null } }>(
`${base}/entries/${encodeURIComponent(entryId)}`,
headers,
);
const entry = got?.entry;
if (!entry) return c.json({ error: '找不到這筆待辦(可能已被刪除)' }, 404);
if (entry.owner_id !== tenant) return c.json({ error: '找不到這筆待辦(可能已被刪除)' }, 404); // 不洩漏他租戶存在性
if (entry.entry_type !== 'todo' && entry.entry_type !== 'inbox') {
return c.json({ error: '只有分流台項目(todo/inbox)能在這裡勾掉' }, 400);
}
const newContent = applyTriageCheck(entry.content, action, new Date().toISOString());
const res = await fetch(`${base}/entries/${encodeURIComponent(entryId)}`, {
method: 'PATCH',
headers,
body: JSON.stringify({ content: newContent }),
});
if (!res.ok) return c.json({ error: `KBDB 回寫失敗(HTTP ${res.status}` }, 502);
return c.json({ success: true, entry_id: entryId, action, status: action === 'restore' ? 'new' : 'done' });
});
function renderDashboardHtml(brand: string): string {
return `<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${brand} </title>
<script>
// 主題預載(防閃色):預設淺色(leo 2026-07-04 明示),與 /console 共用同一 localStorage key
document.documentElement.setAttribute('data-theme', (function () {
try { return localStorage.getItem('arcrun_console_theme') === 'dark' ? 'dark' : 'light'; } catch (e) { return 'light'; }
})());
</script>
<style>
/* Mira Console 稿2aMira Style Guide 2026-07-04
repeating-linear-gradient調
2026-07-04 CSS custom properties 稿 */
* { box-sizing: border-box; }
:root {
--paper-a: #f4eddc; --paper-b: #f1e9d6;
--ink: #2f2a20; --ink-rgb: 30,24,14;
--amber: #8a5f1e; --amber-rgb: 138,95,30;
--ok: #1d7a48; --ok-rgb: 29,122,72;
--err: #b03a26; --err-rgb: 176,58,38;
--track: rgba(30,24,14,.12);
}
:root[data-theme="dark"] {
--paper-a: #191410; --paper-b: #1b1611;
--ink: #ede4d3; --ink-rgb: 237,228,211;
--amber: #e8b45a; --amber-rgb: 232,180,90;
--ok: #7fe0a8; --ok-rgb: 63,190,120;
--err: #e58575; --err-rgb: 217,95,76;
--track: rgba(255,255,255,.08);
}
html, body { margin: 0; background: repeating-linear-gradient(0deg,var(--paper-a) 0px,var(--paper-a) 3px,var(--paper-b) 3px,var(--paper-b) 4px); color: var(--ink);
font-family: -apple-system, "PingFang TC", "Microsoft JhengHei", system-ui, sans-serif; font-size: 16px; -webkit-font-smoothing: antialiased; }
.serif { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; }
main { max-width: 560px; margin: 0 auto; padding: 0 20px 40px; }
.pagehead { padding: 22px 2px 14px; border-bottom: 2px solid rgba(var(--amber-rgb),.4); display: flex; justify-content: space-between; align-items: baseline; }
.pagehead .title { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 23px; letter-spacing: .2em; }
.pagehead .title small { font-size: 14px; letter-spacing: .3em; color: rgba(var(--ink-rgb),.5); }
.pagehead .date { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 14px; color: rgba(var(--ink-rgb),.55); }
.orb-row { display: flex; align-items: center; gap: 20px; padding: 26px 2px 20px; }
.orb { width: 84px; height: 84px; border-radius: 50%; flex: none; display: grid; place-items: center; }
.orb span { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 30px; font-weight: 600; color: rgba(10,20,14,.85); text-shadow: 0 1px 0 rgba(255,255,255,.25); }
.orb-title { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 23px; font-weight: 600; }
.orb-sub { margin-top: 5px; font-size: 15px; color: rgba(var(--ink-rgb),.6); line-height: 1.55; }
@keyframes breatheGreen { 0%,100% { box-shadow: 0 0 24px 6px rgba(var(--ok-rgb),.35); } 50% { box-shadow: 0 0 42px 14px rgba(var(--ok-rgb),.55); } }
@keyframes breatheAmber { 0%,100% { box-shadow: 0 0 24px 6px rgba(var(--amber-rgb),.35); } 50% { box-shadow: 0 0 42px 14px rgba(var(--amber-rgb),.6); } }
@keyframes breatheRed { 0%,100% { box-shadow: 0 0 24px 6px rgba(var(--err-rgb),.4); } 50% { box-shadow: 0 0 44px 16px rgba(var(--err-rgb),.65); } }
.bricks { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.brick { padding: 16px; border-radius: 12px; }
.brick.amber { background: rgba(var(--amber-rgb),.07); border: 1px solid rgba(var(--amber-rgb),.22); }
.brick.plain { background: rgba(var(--ink-rgb),.04); border: 1px solid rgba(var(--ink-rgb),.14); }
.brick .lbl { font-size: 13.5px; color: rgba(var(--ink-rgb),.55); margin-bottom: 6px; }
.brick .num { font-family: ui-monospace, Menlo, monospace; font-size: 26px; color: var(--amber); }
.brick .num small { font-size: 15px; color: rgba(var(--ink-rgb),.5); }
.bar { margin-top: 10px; height: 6px; border-radius: 3px; background: var(--track); }
.bar > i { display: block; height: 100%; border-radius: 3px; background: linear-gradient(90deg,#b98330,#e8b45a); transition: width .6s; }
.wait-box { margin-top: 14px; padding: 20px; border-radius: 12px; border: 1px dashed rgba(var(--ok-rgb),.3); background: rgba(var(--ok-rgb),.05); }
.wait-box.has { border-color: rgba(var(--amber-rgb),.45); background: rgba(var(--amber-rgb),.05); }
.wait-head { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 16px; letter-spacing: .2em; color: rgba(var(--ink-rgb),.6); margin-bottom: 10px; text-align: center; }
.wait-none { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 20px; color: var(--ok); letter-spacing: .08em; text-align: center; }
.wait-item { display: flex; align-items: center; gap: 12px; padding: 12px 14px; margin-top: 8px; border-radius: 10px; background: rgba(var(--amber-rgb),.1); border: 1px solid rgba(var(--amber-rgb),.3); font-size: 16px; line-height: 1.5; }
.wait-item .dm { color: var(--amber); font-size: 17px; flex: none; }
.wait-meta { margin-top: 10px; text-align: center; font-size: 12.5px; color: rgba(var(--ink-rgb),.45); line-height: 1.7; }
.wait-meta .warn { color: var(--err); }
.subhead { display: flex; justify-content: space-between; align-items: baseline; margin: 24px 0 10px; }
.subhead .t { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 16px; letter-spacing: .2em; color: rgba(var(--ink-rgb),.6); }
.subhead .m { font-size: 13px; color: rgba(var(--ink-rgb),.4); }
ul.route { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
ul.route li { display: flex; align-items: flex-start; gap: 12px; padding: 13px 16px; border-radius: 11px; background: rgba(var(--ink-rgb),.045); border: 1px solid transparent; font-size: 16px; line-height: 1.4; }
ul.route li.doing { background: rgba(var(--amber-rgb),.09); border-color: rgba(var(--amber-rgb),.3); }
ul.route li .ic { flex: none; font-size: 15px; margin-top: 2px; }
ul.route li.done { color: rgba(var(--ink-rgb),.65); }
ul.route li.done .ic { color: var(--ok); }
ul.route li.doing .ic { color: var(--amber); }
ul.route li.todo { color: rgba(var(--ink-rgb),.6); }
ul.route li.todo .ic { color: rgba(var(--ink-rgb),.35); }
ul.route li.blocked .ic { color: var(--err); }
ul.route.faded li { opacity: .55; }
.sys { margin-top: 6px; display: flex; flex-direction: column; gap: 6px; }
.sys .row { display: flex; justify-content: space-between; align-items: baseline; padding: 10px 14px; border-radius: 10px; background: rgba(var(--ink-rgb),.04); border: 1px solid rgba(var(--ink-rgb),.12); font-size: 14.5px; }
.sys .row .k { color: rgba(var(--ink-rgb),.6); }
.sys .row .v { font-family: ui-monospace, Menlo, monospace; font-size: 14px; }
.sys .ok { color: var(--ok); }
.sys .bad { color: var(--err); }
.sys .off { color: rgba(var(--ink-rgb),.5); }
.muted { color: rgba(var(--ink-rgb),.45); font-size: 14px; }
.err { color: var(--err); font-size: 14px; }
.stamp { margin: 16px 0 8px; text-align: center; font-size: 12.5px; color: rgba(var(--ink-rgb),.35); line-height: 1.8; }
.enter { display: block; text-align: center; font-size: 13.5px; color: rgba(var(--amber-rgb),.75); text-decoration: none; margin-top: 6px; }
.theme-btn { flex: none; margin-left: 12px; width: 34px; height: 34px; border-radius: 50%; border: 1px solid rgba(var(--ink-rgb),.25); background: none; color: rgba(var(--ink-rgb),.65); font-size: 16px; cursor: pointer; line-height: 1; align-self: center; }
</style>
</head>
<body>
<main>
<div class="pagehead">
<div class="title serif">${brand}<small> </small></div>
<div style="display:flex;align-items:baseline">
<div class="date serif" id="date-str"></div>
<button class="theme-btn" id="theme-btn" title="切換深/淺色"></button>
</div>
</div>
<div class="orb-row">
<div class="orb" id="orb" style="background:radial-gradient(circle at 36% 30%,#8fe8b4,#3fbe78 55%,#22754a 100%)"><span id="orb-char"></span></div>
<div>
<div class="orb-title" id="orb-title"></div>
<div class="orb-sub" id="orb-sub"></div>
</div>
</div>
<div class="bricks">
<div class="brick amber">
<div class="lbl"></div>
<div class="num"><span id="done-n"></span><small> / <span id="total-n"></span> </small></div>
<div class="bar"><i id="bar-fill" style="width:0%"></i></div>
</div>
<div class="brick plain">
<div class="lbl"></div>
<div class="num"><span id="inbox-n"></span><small> </small></div>
<div class="lbl" style="margin:10px 0 0"> Telegram</div>
</div>
</div>
<div class="wait-box" id="wait-box">
<div class="wait-head"></div>
<div id="wait-body" class="wait-none"></div>
<div class="wait-meta" id="wait-meta"></div>
</div>
<div class="subhead"><span class="t"></span><span class="m" id="route-m"></span></div>
<ul class="route" id="today-list"><li class="todo"><span class="ic"></span></li></ul>
<div class="subhead" id="week-head" style="display:none"><span class="t"></span></div>
<ul class="route" id="week-list"></ul>
<div class="subhead"><span class="t"></span><span class="m">live </span></div>
<div class="sys" id="sys-list"><div class="row"><span class="k"></span></div></div>
<div class="stamp" id="stamp"> 60 </div>
<a class="enter" href="/console"> </a>
</main>
<script>
(function () {
// 台北時間 helperlib/taipei-time.ts 注入,與 server 判定同一套——顯示不隨看的裝置時區漂移)
${TAIPEI_CLIENT_JS}
const $ = (id) => document.getElementById(id);
const LIGHT = {
green: { ch: '安', title: '系統運轉中', grad: 'radial-gradient(circle at 36% 30%,#8fe8b4,#3fbe78 55%,#22754a 100%)', anim: 'breatheGreen' },
yellow: { ch: '趕', title: '落後趕工中', grad: 'radial-gradient(circle at 36% 30%,#f2d194,#e8b45a 55%,#8a5f1e 100%)', anim: 'breatheAmber' },
red: { ch: '滯', title: '卡住或斷訊', grad: 'radial-gradient(circle at 36% 30%,#f0a094,#d95f4c 55%,#7e2c20 100%)', anim: 'breatheRed' }
};
const ICONS = { done: '✓', doing: '◐', todo: '○', blocked: '●' };
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
function taskLine(t) {
const cls = ICONS[t.status] ? t.status : 'blocked';
const ic = ICONS[t.status] || '●';
return '<li class="' + cls + '"><span class="ic">' + ic + '</span><span>' + esc(t.title) + '</span></li>';
}
function humanAge(m) {
if (m == null || m < 0) return '時間不明';
if (m < 60) return m + ' 分鐘前';
if (m < 2880) return Math.round(m / 60) + ' 小時前';
return Math.round(m / 1440) + ' 天前';
}
const CNUM = ['零','一','二','三','四','五','六','七','八','九','十'];
function cnDay(n) { return n <= 10 ? CNUM[n] : (n < 20 ? '十' + (n % 10 ? CNUM[n % 10] : '') : CNUM[Math.floor(n / 10)] + '十' + (n % 10 ? CNUM[n % 10] : '')); }
// 頁首日期=台北日(原本用瀏覽器本地時區,換裝置會漂)
const nowTpe = taipeiMonthDay(Date.now());
$('date-str').textContent = CNUM[nowTpe.month] + '月' + cnDay(nowTpe.day) + '日';
// 深/淺切換(與 /console 共用 arcrun_console_theme;預設淺色)
function syncThemeBtn() { $('theme-btn').textContent = document.documentElement.getAttribute('data-theme') === 'dark' ? '☀' : '☾'; }
$('theme-btn').addEventListener('click', () => {
const next = document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
try { localStorage.setItem('arcrun_console_theme', next); } catch (e) { /* 私密模式忽略 */ }
syncThemeBtn();
});
syncThemeBtn();
// fetch 失敗(斷網)的裸訊息 → 友善誠實文案;60 秒定時器常駐,網路恢復自動刷回
function friendlyErr(e) {
const m = e && e.message ? String(e.message) : String(e);
return /failed to fetch|load failed|networkerror|network request failed/i.test(m) ? '連線中斷' : m;
}
function sysRow(k, v, cls) {
return '<div class="row"><span class="k">' + esc(k) + '</span><span class="v ' + cls + '">' + esc(v) + '</span></div>';
}
async function load() {
try {
const res = await fetch('/console/dashboard-data');
if (!res.ok) throw new Error('HTTP ' + res.status);
const d = await res.json();
const cfg = LIGHT[d.light] || LIGHT.green;
const orb = $('orb');
orb.style.background = cfg.grad;
orb.style.animation = cfg.anim + ' 3.4s ease-in-out infinite';
$('orb-char').textContent = cfg.ch;
$('orb-title').textContent = cfg.title;
$('orb-sub').textContent = (d.last_beat
? d.last_beat.actor + '・' + d.last_beat.ago_minutes + ' 分鐘前' + (d.last_beat.note ? '・' + d.last_beat.note : '')
: '尚無心跳資料') + (d.light !== 'green' && d.light_reason ? '' + d.light_reason + '' : '');
const done = d.today_done || 0, total = d.today_total || 0;
$('done-n').textContent = done; $('total-n').textContent = total;
$('bar-fill').style.width = (total ? Math.round((done / total) * 100) : 0) + '%';
$('inbox-n').textContent = d.inbox_new || 0;
// ── 等你的事:來源 + 維護時間攤開講,stale 一定警示 ──
const wb = $('wait-box'), body = $('wait-body'), wmeta = $('wait-meta');
const wm = d.waiting_meta || {};
if (d.waiting && d.waiting.length) {
wb.classList.add('has');
body.className = '';
body.innerHTML = d.waiting.map((w) =>
'<div class="wait-item"><span class="dm">' + (w.urgency ? esc(w.urgency) : '◆') + '</span><span>' +
(w.id ? '<b>#' + esc(w.id) + '</b> ' : '') + esc(w.title) + '</span></div>').join('');
} else {
wb.classList.remove('has');
body.className = 'wait-none';
body.textContent = wm.source === 'none' ? '(管線未接)' : '無,你不用做任何事';
}
let metaTxt = '';
if (wm.source === 'gitea_sprint') {
metaTxt = '來源:sprint 等leo清單(' + esc((wm.sprint_files || []).join('、')) + ')・清單維護於 ' + humanAge(wm.updated_ago_minutes);
if (wm.stale) metaTxt += '<br><span class="warn">⚠ 清單超過 2 天沒維護,可能過時</span>';
} else if (wm.source === 'kbdb_dash_wait') {
metaTxt = '<span class="warn">⚠ ' + esc(wm.note || 'dash_wait 殘資料') + '・上次寫入 ' + humanAge(wm.updated_ago_minutes) + ',可能過時</span>';
} else {
metaTxt = '<span class="warn">管線未接:Gitea sprint 清單與 dash_wait 皆無資料</span>';
}
wmeta.innerHTML = metaTxt;
// ── 今日路線:sprint 任務板優先(來源攤開講);dash_task fallback 沿舊誠實降級 ──
const rm = d.route_meta || {};
const today = (d.tasks || []).filter((t) => t.scope === 'today');
const week = (d.tasks || []).filter((t) => t.scope === 'week');
if (rm.source === 'gitea_sprint_board') {
$('route-m').textContent = '來源 sprint 任務板・更新於 ' + humanAge(rm.updated_ago_minutes);
$('today-list').className = 'route';
const staleHead = rm.is_today ? '' :
'<li class="todo"><span class="ic">○</span><span class="muted">⚠ 今日任務板未更新(最後 ' + humanAge(rm.updated_ago_minutes) + ')——以下是板上現況</span></li>';
$('today-list').innerHTML = staleHead + (today.length
? today.map(taskLine).join('')
: '<li class="todo"><span class="ic">○</span><span class="muted">任務板上沒有可解析的事項</span></li>');
} else if (rm.is_today) {
$('route-m').textContent = '更新於 ' + humanAge(rm.updated_ago_minutes);
$('today-list').className = 'route';
$('today-list').innerHTML = today.length ? today.map(taskLine).join('') : '<li class="todo"><span class="ic">○</span><span class="muted">今日無排定項目</span></li>';
} else if (today.length) {
$('route-m').textContent = '最後路線・' + humanAge(rm.updated_ago_minutes) + '寫入';
$('today-list').className = 'route faded';
$('today-list').innerHTML =
'<li class="todo"><span class="ic">○</span><span class="muted">今日尚無路線寫入——以下是 ' + humanAge(rm.updated_ago_minutes) +
'的殘留路線(sprint 任務板→dashboard 投影管線未接,等leo清單#15 裁決中)</span></li>' + today.map(taskLine).join('');
} else {
$('route-m').textContent = '';
$('today-list').className = 'route';
$('today-list').innerHTML = '<li class="todo"><span class="ic">○</span><span class="muted">無資料——dash_task 管線未接</span></li>';
}
$('week-head').style.display = week.length ? '' : 'none';
$('week-list').innerHTML = week.map(taskLine).join('');
// ── 系統狀況 + 總庫規模(全 live,讀不到就標讀不到)──
const sys = d.system || {}, kb = d.kb || {};
const rows = [];
rows.push(sysRow('KBDB 基本盤', sys.kbdb_ok ? '● 正常' : '● 打不通', sys.kbdb_ok ? 'ok' : 'bad'));
if (sys.embed) {
rows.push(sys.embed.enabled
? sysRow('語意嵌入', '● 啟用(已嵌 ' + (sys.embed.embedded ?? '?') + '・待嵌 ' + (sys.embed.pending ?? '?') + '', 'ok')
: sysRow('語意嵌入', '○ 停用(已嵌 ' + (sys.embed.embedded ?? '?') + '・待嵌 ' + (sys.embed.pending ?? '?') + '', 'bad'));
} else {
rows.push(sysRow('語意嵌入', '狀態讀不到', 'off'));
}
rows.push(sys.graph && sys.graph.ok
? sysRow('知識圖譜', '● 正常・三元組 ' + (sys.graph.triplets == null ? '?' : sys.graph.triplets), 'ok')
: sysRow('知識圖譜', '● 打不通', 'bad'));
rows.push(sysRow('工作流', sys.workflow_total == null ? '讀不到' : sys.workflow_total + ' 條', sys.workflow_total == null ? 'off' : ''));
// 精耕層 wiki 卡(leo 2026-07-07 裁:14-E 遺產總數 deprecated 不再顯示,只顯示真的新的;
// 三元組/已嵌入 已各有一列)
rows.push(sysRow('精耕層 wiki 卡', kb.wiki_card_total == null ? '讀不到' : kb.wiki_card_total + ' 張', kb.wiki_card_total == null ? 'off' : ''));
$('sys-list').innerHTML = rows.join('');
$('stamp').innerHTML = '每 60 秒自動刷新・上次 ' + esc(taipeiTimeStr(Date.parse(d.generated_at))) + '(台北)<br>此頁不含機敏內容,免登入';
} catch (e) {
$('orb-char').textContent = '';
$('orb-title').textContent = '讀不到狀態';
$('orb-sub').innerHTML = '<span class="err">' + esc(friendlyErr(e)) + '・每 60 秒自動重試</span>';
}
}
load();
setInterval(load, 60000);
})();
</script>
</body>
</html>
`;
}
// GET /console/dashboard — 駕駛艙頁(無需登入;純渲染 dashboard-data,無互動、無說明文字)
// 品牌字樣(Arcrun#21):引擎預設 Arcrun,實例可用 CONSOLE_BRAND 覆蓋(如 "Arcrun RAG"
// CONSOLE_PROFILE=ragconsole-profile-trim):駕駛艙不屬企業版頁面 → 302 回 /console。
// 選 302 不選 404:舊書籤/外鏈直接落回產品頁,不給死路(只裁 UI 頁面,資料端點行為不動)。
consoleDashboardRouter.get('/console/dashboard', (c) => {
if ((c.env.CONSOLE_PROFILE || 'full') === 'rag') return c.redirect('/console', 302);
return c.html(renderDashboardHtml(c.env.CONSOLE_BRAND || 'Arcrun'));
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+18 -28
View File
@@ -3,14 +3,16 @@ import { ExecutionError, WorkflowPaused } from '../types';
import { GraphExecutor } from '../graph-executor';
import { graphSchema } from '../lib/schemas';
import { createComponentLoader } from '../lib/component-loader';
import { writeEvaluation, updateComponentStats } from './execution-evaluator';
import { recordComponentStats } from './execution-evaluator';
import { parseTriplets } from './triplet-parser';
import { searchNodes } from './search-nodes';
import { searchNodes, type SearchMode, type SearchTarget } from './search-nodes';
import { buildExecutionGraph } from './graph-builder';
export async function handleCypherSearch(
triplets: unknown[],
env: Bindings,
mode: SearchMode = 'discover',
target?: SearchTarget,
): Promise<{ nodes: Record<string, unknown>; cypher: unknown; missing: string[] }> {
const parsed = parseTriplets(triplets);
if (!parsed) {
@@ -19,7 +21,12 @@ export async function handleCypherSearch(
// 2026-07-30:查 registry 判真實存在(workflow-discovery)。
// `missing` 以前寫死 [],等於告訴 AI「什麼都有」——那是「腹語術」的入口。
const { nodeResults, missingNodes } = await searchNodes(parsed, undefined, env);
//
// t15807-31 迴歸修復,leo:「這裡只是複製一些工作流的 data 過去,沒有要在這裡驗證」):
// 誠實化只屬於 **discover**AI 問「有沒有」);**compile**(部署/推送的複製路徑)
// 純編圖零查詢——那本來就是既有設計(workflows.json=打包期預編的搬運),
// 5cadc60 起誠實化漏進複製路徑=迴歸(冷實例 8 節點 25.7s、安裝器 timeout 炸)。
const { nodeResults, missingNodes } = await searchNodes(parsed, undefined, env, mode, target);
const graph = buildExecutionGraph(parsed, nodeResults, 'cypher-search-result', 'Cypher Search Result');
return { nodes: nodeResults, cypher: { nodes: graph.nodes, edges: graph.edges }, missing: missingNodes };
@@ -52,7 +59,9 @@ export async function handleCypherExecute(
throw new Error('無法解析任何節點');
}
const { nodeResults } = await searchNodes(parsed, config, env);
// t158:執行路徑=compile(零 discovery round-trip)——存在性由 component-loader
// 在載入該節點時決定(原本的權威),查詢層不重複驗。
const { nodeResults } = await searchNodes(parsed, config, env, 'compile');
const graph = buildExecutionGraph(parsed, nodeResults, graphId, graphName, config);
const parseResult = graphSchema.safeParse(graph);
@@ -68,18 +77,8 @@ export async function handleCypherExecute(
const result = await executor.execute(parseResult.data as ExecutionGraph, context ?? {}, env.EXEC_CONTEXT);
const duration_ms = Date.now() - start;
// 非同步記錄統計(Phase 7 補充 analytics,目前為 no-op
const componentId = graph.nodes.find(n => n.componentId)?.componentId ?? graphId;
const runId = `${graphId}-${Date.now()}`;
waitUntil(writeEvaluation(env, {
run_id: runId,
workflow_id: graphId,
component_id: componentId,
verdict: 'success',
duration_ms,
evaluated_at: Date.now(),
}));
waitUntil(updateComponentStats(env, componentId, 'success', duration_ms));
// 非同步回寫每顆零件的執行統計(design.md「執行統計設計」;fire-and-forget 不阻擋回應
waitUntil(recordComponentStats(env, graph.nodes, result.trace));
return { success: true, data: result.data, trace: result.trace, duration_ms, graph };
} catch (err) {
@@ -101,19 +100,10 @@ export async function handleCypherExecute(
}
const errMsg = err instanceof Error ? err.message : String(err);
const componentId = graph.nodes.find(n => n.componentId)?.componentId ?? graphId;
const runId = `${graphId}-${Date.now()}`;
waitUntil(writeEvaluation(env, {
run_id: runId,
workflow_id: graphId,
component_id: componentId,
verdict: 'failed',
duration_ms,
error_message: errMsg.slice(0, 200),
evaluated_at: Date.now(),
}));
waitUntil(updateComponentStats(env, componentId, 'failed', duration_ms));
// 失敗路徑同樣回寫每顆零件統計:ExecutionError 帶完整 trace(失敗節點有 error、
// 之前成功的節點照記成功);非 ExecutionError 無 trace 可歸因 → 不記(誠實:不瞎猜)。
if (err instanceof ExecutionError) {
waitUntil(recordComponentStats(env, graph.nodes, err.trace));
const traceFormatted = err.trace.map(s => ({
node: s.nodeId,
status: s.error ? 'failed' : 'success',
@@ -1,36 +1,96 @@
/**
* Execution Analytics
* Execution Analytics
*
* Phase 1 MVPstub
* Phase 7 fire-and-forget POST registry.arcrun.dev/analytics/record
* SDD: system-dev/docs/3-specs/arcrun-core-mvp/design.md
* cypher-handlers / webhook-handlers ****
* fire-and-forget POST registry `/analytics/record`
* waitUntil 仿 recordRecipeStats / recordTelemetry
*
* traceper-node
* - trace step `error` runner throw
* - output `success === false` makeHttpRunner 2xx throw
* -
* FOREACH trace
*/
import type { Bindings } from '../types';
import type { GraphNode, TraceStep } from '../types';
import { wasmWorkerUrl } from '../lib/component-loader';
export interface EvaluationRecord {
run_id: string;
workflow_id: string;
/** 本模組需要的環境子集(傳整份 Bindings 也相容,仿 SearchNodesEnv 慣例)。 */
export type AnalyticsEnv = {
WORKER_SUBDOMAIN?: string;
/** registry 位置覆蓋(可選;本地 wrangler dev / self-hosted 用)。未設 → wasmWorkerUrl('registry', WORKER_SUBDOMAIN)。 */
REGISTRY_BASE_URL?: string;
};
export interface ComponentVerdict {
component_id: string;
verdict: 'success' | 'failed' | 'timeout';
success: boolean;
duration_ms: number;
error_message?: string;
evaluated_at: number;
}
/** 記錄執行結果(MVPno-opPhase 7 補充 analytics*/
export async function writeEvaluation(
_env: Bindings,
_record: EvaluationRecord,
): Promise<void> {
// Phase 7: POST to registry.arcrun.dev/analytics/record
/** 從執行 trace 導出每顆零件的成敗(只算 type=Component 且有 componentId 的節點)。 */
export function componentVerdictsFromTrace(
nodes: GraphNode[],
trace: TraceStep[],
): ComponentVerdict[] {
const componentByNodeId = new Map<string, string>();
for (const n of nodes) {
if (n.type === 'Component' && n.componentId) componentByNodeId.set(n.id, n.componentId);
}
const verdicts: ComponentVerdict[] = [];
for (const step of trace) {
const componentId = componentByNodeId.get(step.nodeId);
if (!componentId) continue;
const out = step.output;
const outputSaysFailed =
typeof out === 'object' && out !== null && !Array.isArray(out) &&
(out as Record<string, unknown>).success === false;
verdicts.push({
component_id: componentId,
success: !step.error && !outputSaysFailed,
duration_ms: Math.max(0, Number(step.duration_ms) || 0),
});
}
return verdicts;
}
/** 更新零件統計(MVPno-opPhase 7 補充)*/
export async function updateComponentStats(
_env: Bindings,
_componentId: string,
_verdict: 'success' | 'failed' | 'timeout',
_durationMs: number,
/**
* registrydesign.mdAnalytics Record
* throw waitUntil
*/
export async function recordComponentStats(
env: AnalyticsEnv,
nodes: GraphNode[],
trace: TraceStep[],
): Promise<void> {
// Phase 7: update ANALYTICS_KV via registry worker
try {
const base = (
env.REGISTRY_BASE_URL ??
(env.WORKER_SUBDOMAIN ? wasmWorkerUrl('registry', env.WORKER_SUBDOMAIN) : undefined)
)?.replace(/\/$/, '');
if (!base) return;
const verdicts = componentVerdictsFromTrace(nodes, trace);
if (verdicts.length === 0) return;
await Promise.all(
verdicts.map(v =>
fetch(`${base}/analytics/record`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
canonical_id: v.component_id,
success: v.success,
duration_ms: v.duration_ms,
}),
}).catch(() => undefined), // 統計失敗不影響執行
),
);
} catch {
// fire-and-forget:不拋錯,不影響主流程
}
}
+336 -21
View File
@@ -8,7 +8,35 @@ import type { RecipeDefinition } from '../routes/recipes';
* `not_found` `missing`
* `system-dev/docs/3-specs/arcrun-usable/verify.sh` 01 grep `not_found`
*/
export type NodeStatus = 'found' | 'not_found' | 'unknown';
/** `unchecked`compile 模式的誠實標記:沒查、不知道有沒有(≠found 的假信號)。 */
/** `resolved`=意圖節點被媒合替換成真實零件/recipe(步驟 4;≠字面 exact 的 found)。 */
export type NodeStatus = 'found' | 'not_found' | 'unknown' | 'unchecked' | 'resolved';
/**
* /recipe CP 4workflow-discovery 3.x
* CP AI payload telegram
* `http_request`recipe `telegram_send`
*/
export type NodeSubstitution = {
/** 原始意圖節點名(替換前)。 */
from: string;
/**
* component
* recipe `http_request`recipe http_request
*/
componentId: string;
/** recipe 替換時的 canonical_id——workflow config 寫 `component: <此值>` 即可直接用。 */
recipe?: string;
/** 為什麼這樣換(簡單可解釋規則的命中說明,不接 LLM)。 */
reason: string;
};
/**
* leo 07-31 recipe
* `component` registry`recipe` recipe
* `workflow` workflow route /workflows/search
*/
export type SearchTarget = 'component' | 'recipe';
export type NodeInfo = {
status: NodeStatus;
@@ -33,6 +61,8 @@ export type NodeInfo = {
similar_components?: string[];
/** not_found 時的相近 recipe 候選。 */
similar_recipes?: string[];
/** resolved 時的替換明細(步驟 4:意圖節點 → 真實零件/recipe)。 */
substitution?: NodeSubstitution;
};
export type SearchResult = {
@@ -40,6 +70,18 @@ export type SearchResult = {
missingNodes: string[];
};
/**
* t158leo 07-31 調
* data
*
*
* - `compile`**** registry recipe missing
* // workflow
* - `discover``/cypher/search` AI
* not_found
*/
export type SearchMode = 'discover' | 'compile';
/** searchNodes 需要的環境子集(cypher-handlers 傳整份 Bindings 進來也相容)。 */
export type SearchNodesEnv = {
WORKER_SUBDOMAIN?: string;
@@ -79,13 +121,55 @@ export async function searchNodes(
parsed: ParsedTriplets,
config?: Record<string, Record<string, unknown>>,
env?: SearchNodesEnv,
mode: SearchMode = 'discover',
target?: SearchTarget,
): Promise<SearchResult> {
const nodeResults: Record<string, NodeInfo> = {};
const missingNodes: string[] = [];
// ── compile:純編圖,零外部查詢(t158,部署≠發現)─────────────────────────
if (mode === 'compile') {
for (const nodeName of parsed.nodeNames) {
const role = resolveNodeRole(nodeName, parsed);
if ((role === 'Input' || role === 'Output') && isVirtualIoName(nodeName)) {
nodeResults[nodeName] = { status: 'found', componentId: nodeName.toLowerCase(), type: role };
continue;
}
const configComponent = config?.[nodeName]?.component as string | undefined;
// unchecked=誠實「沒查」;存在性由 component-loader 在執行時決定
nodeResults[nodeName] = {
status: configComponent ? 'found' : 'unchecked',
componentId: configComponent ?? nodeName,
type: role,
};
}
return { nodeResults, missingNodes };
}
const sub = env?.WORKER_SUBDOMAIN;
const registryBase = env?.REGISTRY_BASE_URL ?? (sub ? wasmWorkerUrl('registry', sub) : undefined);
// target 限庫(leo 07-31):component=只查零件 registryrecipe=只查 recipe 庫。
// 不給=混搜兩庫(既有行為)。
const wantComponents = target !== 'recipe';
const wantRecipes = target !== 'component';
// ── discover 批次化(t158):兩庫各抓**一次**,之後全在記憶體內比對。────────
// 病史(07-31 stage 實測):舊版對每個 missing 節點各打「1 次逐顆查+最多 9 次
// 相似搜尋+一輪 recipe KV 掃描」⇒ 冷實例 8 節點 /cypher/search 25.7s
// 安裝器 15s timeout 必炸。批次化後每 request 固定 1 次 catalog1 次 recipe 清單。
// 步驟 4 的意圖替換也在**同一份清單**上做——不加任何新 round-trip。
const catalog = !wantComponents
? { status: 'ok' as const, entries: [] } // target=reciperegistry 不參與,不因此回 unknown
: registryBase ? await fetchCatalog(registryBase) : { status: 'unreachable' as const, entries: [] };
const recipes = wantRecipes && env?.RECIPES ? await listAllRecipes(env.RECIPES) : [];
const byId = new Map<string, CatalogFullRecord>();
for (const e of catalog.entries) {
const prev = byId.get(e.canonical_id);
if (!prev || (e.score ?? 0) > (prev.score ?? 0)) byId.set(e.canonical_id, e);
for (const a of e.aliases ?? []) if (!byId.has(a)) byId.set(a, e);
}
for (const nodeName of parsed.nodeNames) {
const role = resolveNodeRole(nodeName, parsed);
@@ -107,34 +191,37 @@ export async function searchNodes(
continue;
}
if (!registryBase) {
// registry 完全查不通(未部署/網路失敗)⇒ 誠實回 unknown。
// **不能誤判 not_found**——那會讓 AI 以為零件不存在而重寫 code,正是要避免的事。
// 舊 registry 沒有 /catalog 端點(no_endpoint)→ 退回逐顆查(相容路徑)。
if (catalog.status === 'unreachable') {
nodeResults[nodeName] = { status: 'unknown', componentId, type: role };
continue;
}
if (catalog.status === 'no_endpoint') {
const legacy = await legacyPerNodeLookup(registryBase!, componentId, nodeName, role, env, recipes);
nodeResults[nodeName] = legacy.info;
if (legacy.missing) missingNodes.push(nodeName);
continue;
}
// ── 第一庫:零件 registry ────────────────────────────────────────────────
const q = await fetchComponent(registryBase, componentId);
if (!q.ok) {
// registry 查不通(未部署/網路失敗)⇒ 誠實回 unknown。
// **不能誤判 not_found**——那會讓 AI 以為零件不存在而重寫 code,正是要避免的事。
nodeResults[nodeName] = { status: 'unknown', componentId, type: role };
continue;
}
if (q.entry) {
// ── 第一庫:零件 catalog(記憶體)────────────────────────────────────────
const hit = byId.get(componentId);
if (hit) {
nodeResults[nodeName] = {
status: 'found',
componentId,
type: role,
source: 'component',
input_schema: q.entry.input_schema,
success_rate: q.entry.success_rate,
stability: q.entry.stability,
input_schema: hit.input_schema,
success_rate: typeof hit.success_rate === 'number' ? hit.success_rate : undefined,
stability: typeof hit.stability === 'string' ? hit.stability : undefined,
};
continue;
}
// ── 第二庫:recipe 庫(task 3.6——「說不出某 recipe 沒有」的病灶就在漏了這步)──
const recipe = env?.RECIPES ? await resolveRecipe(componentId, env.RECIPES) : null;
// ── 第二庫:recipe 清單(記憶體;canonical_id 精確比對)──────────────────
const recipe = recipes.find(r => r.canonical_id === componentId);
if (recipe) {
nodeResults[nodeName] = {
status: 'found',
@@ -147,11 +234,18 @@ export async function searchNodes(
continue;
}
// ── 兩庫都沒有 ⇒ not_found + 分型指路(task 3.7)+ 相近候選 ────────────
const [similarComponents, similarRecipes] = await Promise.all([
searchSimilarComponents(registryBase, nodeName),
env?.RECIPES ? searchSimilarRecipes(env.RECIPES, nodeName) : Promise.resolve([]),
]);
// ── 步驟 4:意圖節點 → 真實零件/recipe 替換(同一份清單、全記憶體)────────
// 字面 exact 兩庫都落空的自然語言節點(例「傳到 telegram」「判斷有沒有新資料」),
// 先試保守的替換規則;換得到=resolved(回應直接可組 workflow),換不到才 not_found。
const substituted = trySubstitution(nodeName, catalog.entries, recipes);
if (substituted) {
nodeResults[nodeName] = { ...substituted, type: role };
continue;
}
// ── 兩庫都沒有 ⇒ not_found + 分型指路(task 3.7)+ 相近候選(全記憶體)──
const similarComponents = similarFromCatalog(catalog.entries, nodeName);
const similarRecipes = similarFromRecipes(recipes, nodeName);
nodeResults[nodeName] = {
status: 'not_found',
@@ -167,6 +261,227 @@ export async function searchNodes(
return { nodeResults, missingNodes };
}
// ── t158 批次化 helpers ────────────────────────────────────────────────────────
type CatalogFullRecord = {
canonical_id: string;
display_name?: string;
description?: string;
aliases?: string[];
tags?: string[];
score?: number;
input_schema?: unknown;
success_rate?: number;
stability?: string;
};
type CatalogFetch = { status: 'ok' | 'no_endpoint' | 'unreachable'; entries: CatalogFullRecord[] };
/** 一次抓 registry 全目錄。404=舊版 registry 沒這端點 → 呼叫端退回逐顆查。 */
async function fetchCatalog(registryBase: string): Promise<CatalogFetch> {
try {
const res = await fetch(`${registryBase}/components/catalog`, { signal: AbortSignal.timeout(10000) });
if (res.status === 404) return { status: 'no_endpoint', entries: [] };
if (!res.ok) return { status: 'unreachable', entries: [] };
const body = (await res.json()) as { data?: { components?: CatalogFullRecord[] } };
return { status: 'ok', entries: body.data?.components ?? [] };
} catch {
return { status: 'unreachable', entries: [] };
}
}
/** recipe recipe exact
* export target=recipe actions/target-search.ts */
export async function listAllRecipes(kv: KVNamespace): Promise<RecipeDefinition[]> {
try {
const list = await kv.list({ prefix: 'recipe:' });
return (await Promise.all(
list.keys.map(k => kv.get(k.name, 'json') as Promise<RecipeDefinition | null>),
)).filter(Boolean) as RecipeDefinition[];
} catch {
return [];
}
}
/** 相似零件(記憶體版):全名 substring 優先,否則斷詞計數 top3——判準與舊 HTTP 版一致。 */
function similarFromCatalog(entries: CatalogFullRecord[], nodeName: string): string[] {
const searchableOf = (e: CatalogFullRecord) =>
[e.canonical_id, e.display_name ?? '', e.description ?? '', ...(e.aliases ?? []), ...(e.tags ?? [])]
.join(' ').toLowerCase();
const full = nodeName.toLowerCase();
const direct = entries.filter(e => searchableOf(e).includes(full)).map(e => e.canonical_id);
if (direct.length > 0) return [...new Set(direct)].slice(0, 3);
const tokens = extractTokens(nodeName);
if (tokens.length === 0) return [];
const count = new Map<string, number>();
for (const e of entries) {
const hay = searchableOf(e);
const hits = tokens.filter(t => hay.includes(t)).length;
if (hits > 0) count.set(e.canonical_id, Math.max(count.get(e.canonical_id) ?? 0, hits));
}
return [...count.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([id]) => id);
}
/** 相似 recipe(記憶體版;判準沿用 searchSimilarRecipes)。 */
function similarFromRecipes(recipes: RecipeDefinition[], nodeName: string): string[] {
const tokens = [nodeName.toLowerCase(), ...extractTokens(nodeName)];
const seen = new Set<string>();
const matched: string[] = [];
for (const r of recipes) {
if (seen.has(r.canonical_id)) continue;
const hay = `${r.canonical_id} ${r.display_name ?? ''} ${r.description ?? ''}`.toLowerCase();
if (tokens.some(t => hay.includes(t))) {
seen.add(r.canonical_id);
matched.push(r.canonical_id);
}
}
return matched.slice(0, 3);
}
/** 舊 registry(無 /catalog 端點)的相容路徑:維持逐顆查語義。 */
async function legacyPerNodeLookup(
registryBase: string,
componentId: string,
nodeName: string,
role: NodeRole,
env: SearchNodesEnv | undefined,
recipes: RecipeDefinition[],
): Promise<{ info: NodeInfo; missing: boolean }> {
const q = await fetchComponent(registryBase, componentId);
if (!q.ok) return { info: { status: 'unknown', componentId, type: role }, missing: false };
if (q.entry) {
return {
info: {
status: 'found', componentId, type: role, source: 'component',
input_schema: q.entry.input_schema, success_rate: q.entry.success_rate, stability: q.entry.stability,
},
missing: false,
};
}
const recipe = recipes.find(r => r.canonical_id === componentId)
?? (env?.RECIPES ? await resolveRecipe(componentId, env.RECIPES) : null);
if (recipe) {
return {
info: {
status: 'found', componentId: recipe.canonical_id, type: role, source: 'recipe',
description: recipe.description, endpoint: recipe.endpoint,
},
missing: false,
};
}
const similarComponents = await searchSimilarComponents(registryBase, nodeName);
const similarRecipes = similarFromRecipes(recipes, nodeName);
return {
info: {
status: 'not_found', componentId, type: role, suggestion: buildSuggestion(componentId),
...(similarComponents.length > 0 ? { similar_components: similarComponents } : {}),
...(similarRecipes.length > 0 ? { similar_recipes: similarRecipes } : {}),
},
missing: true,
};
}
// ── 步驟 4:意圖節點 → 真實零件/recipe 替換 ────────────────────────────────────
//
// 目的(CP arcrun-usable 步驟 4):AI 只要填 payload——系統把「傳到 telegram」翻成
// `http_request`recipe `telegram_send`。媒合在「一次抓好的兩庫清單」記憶體內做,
// 零新增 round-trip;規則沿用 task 3.7 的服務詞判型+既有斷詞媒合(extractTokens),
// 刻意簡單可解釋、不接 LLM。
//
// 兩條規則(保守——換錯比不換更糟,寧可 not_found+候選讓 AI 自己選):
// A) 服務詞規則(recipe 路):節點名含 SERVICE_HINTS 服務詞 → 名字裡**全部**服務詞
// 都命中同一個 recipe、且該 recipe **唯一**才替換。
// 例「傳到 telegram」:服務詞 [telegram] → 唯一命中 telegram_send ⇒ 換。
// 反例「google_slides_create」:服務詞 [google, slides] → google_sheets_* 只中
// google 不中 slides ⇒ 不換(照 3.7 指去寫 recipe)。
// 有服務詞的節點**不落入規則 B**——外部服務就該是 recipe,不硬配零件
// (否則「google_slides」會被 display_name 含 Google 的零件誤吃)。
// B) 強欄位規則(零件路):斷詞後只算**強欄位**canonical_iddisplay_namealiases
// 命中為主:分數=強命中×10+弱命中(descriptiontags)×1
// 需「至少一個強命中」且「分數唯一最高」才替換。
// 例「判斷有沒有新資料」:2-gram「判斷」命中 if_control display_name「條件判斷」
// (強 10 分),try_catch 只在 description 中「判斷」(弱 1 分)⇒ 唯一最高 ⇒ 換。
// 反例「aes_encrypt」:無任何強命中 ⇒ 不換(照 3.7 指去投零件 PR)。
type SubstitutionHit = Pick<
NodeInfo,
'status' | 'componentId' | 'source' | 'substitution' |
'input_schema' | 'success_rate' | 'stability' | 'description' | 'endpoint'
>;
function trySubstitution(
nodeName: string,
catalogEntries: CatalogFullRecord[],
recipes: RecipeDefinition[],
): SubstitutionHit | null {
const lower = nodeName.toLowerCase();
const serviceHits = SERVICE_HINTS.filter(w => lower.includes(w));
// 規則 A:服務詞 → recipe(全部服務詞命中+唯一)
if (serviceHits.length > 0) {
const matched = new Map<string, RecipeDefinition>();
for (const r of recipes) {
const hay = `${r.canonical_id} ${r.display_name ?? ''} ${r.description ?? ''}`.toLowerCase();
if (serviceHits.every(h => hay.includes(h))) matched.set(r.canonical_id, r);
}
if (matched.size !== 1) return null; // 0=真缺件走 not_found;≥2=歧義,候選留給 similar_recipes
const recipe = [...matched.values()][0];
return {
status: 'resolved',
componentId: recipe.canonical_id,
source: 'recipe',
description: recipe.description,
endpoint: recipe.endpoint,
substitution: {
from: nodeName,
componentId: 'http_request', // recipehttp_request+參數模板的具名封裝
recipe: recipe.canonical_id,
reason:
`服務詞「${serviceHits.join('、')}」唯一命中 recipe「${recipe.canonical_id}」;` +
`workflow config 寫 component: ${recipe.canonical_id}(底層零件=http_request),只需填 payload`,
},
};
}
// 規則 B:強欄位斷詞媒合 → 零件(至少一強命中+分數唯一最高)
const tokens = extractTokens(nodeName);
if (tokens.length === 0) return null;
type Scored = { entry: CatalogFullRecord; score: number; strongHits: string[] };
const byCanonical = new Map<string, Scored>();
for (const e of catalogEntries) {
const strongHay = [e.canonical_id, e.display_name ?? '', ...(e.aliases ?? [])].join(' ').toLowerCase();
const weakHay = [e.description ?? '', ...(e.tags ?? [])].join(' ').toLowerCase();
const strongHits = tokens.filter(t => strongHay.includes(t));
const weakCount = tokens.filter(t => weakHay.includes(t)).length;
const score = strongHits.length * 10 + weakCount;
if (score === 0) continue;
const prev = byCanonical.get(e.canonical_id);
if (!prev || score > prev.score) byCanonical.set(e.canonical_id, { entry: e, score, strongHits });
}
const ranked = [...byCanonical.values()].sort((a, b) => b.score - a.score);
const top = ranked[0];
if (!top || top.strongHits.length === 0) return null; // 沒有強命中=證據不足
if (ranked[1] && ranked[1].score >= top.score) return null; // 同分歧義=不硬猜
return {
status: 'resolved',
componentId: top.entry.canonical_id,
source: 'component',
input_schema: top.entry.input_schema,
success_rate: typeof top.entry.success_rate === 'number' ? top.entry.success_rate : undefined,
stability: typeof top.entry.stability === 'string' ? top.entry.stability : undefined,
substitution: {
from: nodeName,
componentId: top.entry.canonical_id,
reason:
`斷詞「${top.strongHits.join('、')}」命中零件「${top.entry.canonical_id}` +
`${top.entry.display_name ?? ''})強欄位且分數唯一最高;只需照 input_schema 填 payload`,
},
};
}
// ── 缺件分型(task 3.7)────────────────────────────────────────────────────────
//
// 分型判準(刻意用簡單可解釋的規則,不接 LLM——查詢端點要快、要可預測):
@@ -0,0 +1,98 @@
/**
* target-search POST /cypher/search t159
*
* leo 07-31search search
* recipe
*
* discover `target`componentrecipeworkflow`query`
* - target=component registry GET /components/searchMCP arcrun_search_components
* - target=recipe RECIPES KV discover **** listAllRecipes
* /public-recipesMCP arcrun_recipe_search
* - target=workflow lib/workflow-search.tsGET /workflows/searchMCP arcrun_search_workflows
*
* API target ****
* flag pull
*/
import { wasmWorkerUrl } from '../lib/component-loader';
import { fetchTenantWorkflowSearch } from '../lib/workflow-search';
import { listAllRecipes, type SearchNodesEnv } from './search-nodes';
export type TargetQueryEnv = SearchNodesEnv & {
KBDB_BASE_URL?: string;
KBDB_INTERNAL_TOKEN?: string;
};
export type TargetQueryResult =
| { ok: true; body: Record<string, unknown> }
| { ok: false; status: 400 | 401 | 502; error: string };
export async function searchByTarget(
target: 'component' | 'recipe' | 'workflow',
query: string,
env: TargetQueryEnv,
apiKey?: string,
): Promise<TargetQueryResult> {
if (target === 'component') {
const sub = env.WORKER_SUBDOMAIN;
const registryBase = env.REGISTRY_BASE_URL ?? (sub ? wasmWorkerUrl('registry', sub) : undefined);
if (!registryBase) return { ok: false, status: 502, error: 'registry 位置未設定(WORKER_SUBDOMAINREGISTRY_BASE_URL 皆缺)' };
try {
const res = await fetch(
`${registryBase}/components/search?q=${encodeURIComponent(query)}`,
{ signal: AbortSignal.timeout(10000) },
);
if (!res.ok) return { ok: false, status: 502, error: `registry 搜尋失敗(HTTP ${res.status}` };
const body = (await res.json()) as { data?: { results?: unknown[]; count?: number } };
return {
ok: true,
body: {
target,
query,
results: body.data?.results ?? [],
count: body.data?.count ?? 0,
},
};
} catch (e) {
return { ok: false, status: 502, error: `registry 查不通:${e instanceof Error ? e.message : String(e)}` };
}
}
if (target === 'recipe') {
if (!env.RECIPES) return { ok: false, status: 502, error: 'RECIPES KV 未綁定' };
const all = await listAllRecipes(env.RECIPES);
const q = query.toLowerCase();
// 與 discover 混搜同一份庫(私庫=workflow 實際引用得到的);子字串比對、canonical 去重
const seen = new Set<string>();
const results: Array<{ canonical_id: string; display_name?: string; description?: string; endpoint: string }> = [];
for (const r of all) {
if (seen.has(r.canonical_id)) continue;
const hay = `${r.canonical_id} ${r.display_name ?? ''} ${r.description ?? ''}`.toLowerCase();
if (!hay.includes(q)) continue;
seen.add(r.canonical_id);
results.push({
canonical_id: r.canonical_id,
display_name: r.display_name,
description: r.description,
endpoint: r.endpoint,
});
}
return {
ok: true,
body: {
target,
query,
results,
count: results.length,
note: '搜的是本部署私庫(workflow 可直接 component: <canonical_id> 引用)。公庫(多作者市場)走 MCP arcrun_recipe_searchGET /public-recipes。',
},
};
}
// target === 'workflow':租戶隔離,必帶 API key(同 GET /workflows/search 的既有契約)
if (!apiKey) return { ok: false, status: 401, error: 'target=workflow 需要 X-Arcrun-API-Key headerworkflow 搜尋限本租戶)' };
const res = await fetchTenantWorkflowSearch(env, apiKey, query);
if (!res.ok) return { ok: false, status: 502, error: `workflow 搜尋失敗(KBDB HTTP ${res.status}` };
const body = (await res.json()) as Record<string, unknown>;
return { ok: true, body: { target, query, ...body } };
}
@@ -4,6 +4,8 @@ import { GraphExecutor } from '../graph-executor';
import { graphSchema } from '../lib/schemas';
import { createComponentLoader } from '../lib/component-loader';
import { recordTelemetry } from '../lib/telemetry';
import { recordComponentStats } from './execution-evaluator';
import type { GraphNode, TraceStep } from '../types';
/**
* kbdb-base §7.1+§7.5.h recipe / KBDB
@@ -96,6 +98,17 @@ export async function executeWebhookGraph(
// kbdb-base §7.1:整體成功 → 用到的 recipe 各記成功一次。
recordRecipeStats(env, executor.usedRecipeKeys, true, Date.now(), ctx);
// arcrun-core-mvp「執行統計設計」:對用到的每顆零件回寫執行結果(fire-and-forget)。
{
const statsPromise = recordComponentStats(
env,
(parsed.data as ExecutionGraph).nodes as GraphNode[],
result.trace as TraceStep[],
);
if (ctx?.waitUntil) ctx.waitUntil(statsPromise);
else void statsPromise;
}
return { success: true, data: result.data, duration_ms };
} catch (err) {
const duration_ms = Date.now() - start;
@@ -117,6 +130,18 @@ export async function executeWebhookGraph(
recordRecipeStats(env, executor.usedRecipeKeys, false, Date.now(), ctx);
}
// 零件統計失敗路徑:ExecutionError 帶完整 trace(失敗節點有 error、先前成功節點照記成功);
// paused 非失敗不記;非 ExecutionError 無 trace 可歸因 → 不記。
if (!isPaused && err instanceof ExecutionError) {
const statsPromise = recordComponentStats(
env,
(parsed.data as ExecutionGraph).nodes as GraphNode[],
err.trace,
);
if (ctx?.waitUntil) ctx.waitUntil(statsPromise);
else void statsPromise;
}
if (err instanceof ExecutionError) {
const traceFormatted = err.trace.map(s => ({
node: s.nodeId,
@@ -0,0 +1,49 @@
/**
* workflow-search workflow ****
*
* workflow-discovery 3.1 KBDB /entries/search
* entry_type=workflow + owner_id=apiKey semanticKBDB
* Vectorize keyword + capability_hint
*
* leo 07-31search search
* recipe API
* - GET /workflows/searchMCP arcrun_search_workflows
* - POST /cypher/search { target: "workflow", query }discover
*
*
* workflow_metadata description slot
* description workflow search entry
* POST /workflows/backfill-search-entries description entry
*
* flag pull/
*/
export type WorkflowSearchEnv = {
KBDB_BASE_URL?: string;
KBDB_INTERNAL_TOKEN?: string;
};
export type WorkflowSearchMode = 'semantic' | 'keyword';
/**
* KBDB /entries/searchentry_type=workflow
* ResponseGET /workflows/search stream
* target=workflow json()
*/
export async function fetchTenantWorkflowSearch(
env: WorkflowSearchEnv,
apiKey: string,
q: string,
mode: WorkflowSearchMode = 'semantic',
): Promise<Response> {
const base = (env.KBDB_BASE_URL ?? 'https://arcrun-kbdb.uncle6-me.workers.dev').replace(/\/$/, '');
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${env.KBDB_INTERNAL_TOKEN}`;
const params = new URLSearchParams({
q,
owner_id: apiKey, // 租戶隔離(只搜本租戶的 workflow)
entry_type: 'workflow', // base 通用 filterQ4),只回 workflow entry
mode,
});
return fetch(`${base}/entries/search?${params.toString()}`, { headers });
}
+42 -3
View File
@@ -1,16 +1,55 @@
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { handleCypherSearch, handleCypherExecute } from '../actions/cypher-handlers';
import { searchByTarget } from '../actions/target-search';
export const cypherRouter = new Hono<{ Bindings: Bindings }>();
const VALID_TARGETS = new Set(['component', 'recipe', 'workflow']);
// POST /cypher/search — 三元組 → 解析節點 → 語意搜尋零件 → 回傳 Cypher JSON (開發友善格式)
//
// t159leo 07-31):加 `target` 指定搜尋對象(componentrecipeworkflow)+`query` 名字搜尋。
// - triplets(不給 target)=混搜兩庫+意圖節點替換(步驟 4)
// - triplets + target=component|recipe=只查該庫
// - query + target=名字搜尋,各自走**既有**機制(registry search/私庫 RECIPESworkflows/search
cypherRouter.post('/cypher/search', async (c) => {
const body = await c.req.json() as { triplets?: unknown };
const body = await c.req.json() as { triplets?: unknown; mode?: unknown; target?: unknown; query?: unknown };
const rawTriplets = body?.triplets;
// ── target 驗證(component / recipe / workflow)─────────────────────────────
const target = typeof body?.target === 'string' ? body.target : undefined;
if (target !== undefined && !VALID_TARGETS.has(target)) {
return c.json({ error: `target 只接受 componentrecipeworkflow,收到「${target}` }, 400);
}
// ── query 名字搜尋分支(需 target)──────────────────────────────────────────
const query = typeof body?.query === 'string' ? body.query.trim() : '';
if (query) {
if (!target) {
return c.json({ error: '給 query 必須同時給 targetcomponentrecipeworkflow),指明要搜哪個庫' }, 400);
}
const apiKey = c.req.header('X-Arcrun-API-Key') ?? undefined;
const r = await searchByTarget(target as 'component' | 'recipe' | 'workflow', query, c.env, apiKey);
if (!r.ok) return c.json({ error: r.error }, r.status);
return c.json(r.body);
}
if (!Array.isArray(rawTriplets) || rawTriplets.length === 0) {
return c.json({ error: 'triplets 必須為非空字串陣列' }, 400);
return c.json({ error: 'triplets 必須為非空字串陣列(或給 query + target 做名字搜尋)' }, 400);
}
// t158「部署≠發現」:mode=compile=純編圖(安裝器/acr push 的複製路徑,零存在性查詢);
// 預設 discover=誠實查詢(AI 問「有沒有」的既有契約,not_found+指路照舊)。
const mode = body?.mode === 'compile' ? 'compile' : 'discover';
// target 限庫只屬於 discovercompile=純複製,不查任何庫,target 無意義)
if (target && mode === 'compile') {
return c.json({ error: 'mode=compile(複製路徑)不查庫,不接受 target;要指定搜尋對象請用 discover(預設)' }, 400);
}
// workflow 是名字搜尋,不參與三元組編圖——請帶 query
if (target === 'workflow') {
return c.json({ error: 'target=workflow 是名字搜尋,請改帶 { target: "workflow", query: "..." }(不吃 triplets' }, 400);
}
try {
@@ -18,7 +57,7 @@ cypherRouter.post('/cypher/search', async (c) => {
const timestamp = now.toISOString();
const versionId = `search-v1-${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}-${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`;
const result = await handleCypherSearch(rawTriplets, c.env);
const result = await handleCypherSearch(rawTriplets, c.env, mode, target as 'component' | 'recipe' | undefined);
const response = {
version: versionId,
+52 -26
View File
@@ -708,41 +708,67 @@ portalRouter.get('/portal/admin/libraries', (c) =>
}),
);
// POST /portal/admin/libraries — 登記一個庫。body {name, display_name?, description?}。
portalRouter.post('/portal/admin/libraries', (c) =>
// t160leo 07-31:「要直通 daemon,同步,**沒有登記這回事**」):
// 人工建庫端點 POST /portal/admin/libraries 已刪——庫只從 daemon 同步自動出現
// /portal/daemon/librariest159)。現行 UIe28e190 起)本就零呼叫此端點(死端點);
// 人工登記只會製造對不上的空庫(07-27 leo 拿掉表單時已定調)。
// GET 列表與 PATCH(管理已存在的庫:改名/停用/graph_source)照舊。
// POST /portal/daemon/libraries — 小幫手(daemon)連線精靈時把看守資料夾的庫報上來自動登記。
// t1592026-07-31 leo prod 實走揪出):daemon registerLibrariesarcrun-tray main.go:505t52
// 一直在打這個端點,但 cypher 從來沒有它 ⇒ 404 被 daemon「失敗不擋連線」靜默吞掉
// ⇒ portal_library 登記簿永遠空 ⇒ portal「庫目錄管理」空(資料同步倒是全正常——
// triplet records 的 library slot 都在,病只在登記簿沒人寫)。
// 契約照 daemon 既有呼叫:body {email, password, libraries:[{name, display_name}]}。
// 帳密驗證=與 /portal/session 同一套(daemon 只在精靈那一刻拿到帳密,不存)。
// 冪等:已登記(同 name)跳過——重跑精靈不堆重複。
portalRouter.post('/portal/daemon/libraries', (c) =>
run(c, async () => {
const auth = await requirePortalAdmin(c);
if (!auth.ok) return auth.res;
const body = await c.req.json().catch(() => null);
const name = String(body?.name ?? '').trim();
if (!isValidLibraryName(name) || name === '*') {
return c.json({ error: '庫名限 A-Za-z0-9_-1-64 字元;"*" 是保留值不可登記)' }, 400);
const email = String(body?.email ?? '').trim().toLowerCase();
const password = String(body?.password ?? '');
if (!email || !password) return c.json({ error: 'email 與 password 必填' }, 400);
const items = Array.isArray(body?.libraries) ? body.libraries : [];
if (items.length === 0) return c.json({ success: true, registered: [], skipped: [] });
// 帳密驗證(沿用 /portal/session 的鎖定與驗證機制)
if (await isLocked(c.env, email)) return c.json({ error: '登入失敗次數過多,請稍後再試' }, 429);
const recordId = await findUserRecordId(c.env, email);
const rec = recordId ? await getRecordById(c.env, recordId) : null;
if (!rec || (rec.values.status ?? '') !== 'active'
|| !(await verifyPassword(password, rec.values.password_hash ?? ''))) {
await recordLoginFail(c.env, email);
return c.json({ error: 'email 或密碼錯誤' }, 401);
}
await clearLoginFail(c.env, email);
const seeded = await ensurePortalTemplates(c.env);
if (seeded.errors.length > 0) {
return c.json({ error: `portal templates seed 失敗:${seeded.errors.join('; ')}` }, 502);
}
const existing = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE);
if (existing.some((l) => (l.values.name ?? '') === name)) {
return c.json({ error: `${name} 已登記` }, 409);
}
const have = new Set(existing.map((l) => l.values.name ?? ''));
const ns = portalNamespace(c.env);
const res = await kbdbFetch(c.env, '/records', {
method: 'POST',
body: JSON.stringify({
template: LIBRARY_TEMPLATE,
owner_id: ns,
values: {
name,
display_name: String(body?.display_name ?? '').trim() || name,
description: String(body?.description ?? '').trim(),
status: 'active',
},
}),
});
if (!res.ok) throw new KbdbError(`POST /recordsportal_library)→ ${res.status}`);
const created = (await res.json()) as { record?: PortalRecord };
return c.json({ success: true, library: created.record ? toPublicLibrary(created.record) : { name } });
const registered: string[] = [];
const skipped: string[] = [];
for (const it of items) {
const name = String(it?.name ?? '').trim();
const displayName = String(it?.display_name ?? '').trim() || name;
if (!isValidLibraryName(name) || name === '*') { skipped.push(name || '(空)'); continue; }
if (have.has(name)) { skipped.push(name); continue; }
const res = await kbdbFetch(c.env, '/records', {
method: 'POST',
body: JSON.stringify({
template: LIBRARY_TEMPLATE,
owner_id: ns,
values: { name, display_name: displayName, description: '', status: 'active' },
}),
});
if (!res.ok) throw new KbdbError(`POST /recordsportal_librarydaemon 登記)→ ${res.status}`);
have.add(name);
registered.push(name);
}
return c.json({ success: true, registered, skipped });
}),
);
+26 -10
View File
@@ -30,6 +30,7 @@ import type { GraphNode } from '../types';
import { extractCronExpr } from '../lib/cron-match';
import { updateCronIndexEntry, CRON_INDEX_KEY } from '../lib/cron-index';
import { recordTelemetry } from '../lib/telemetry';
import { fetchTenantWorkflowSearch } from '../lib/workflow-search';
export const webhooksNamedRouter = new Hono<{ Bindings: Bindings }>();
@@ -177,16 +178,9 @@ webhooksNamedRouter.get('/workflows/search', async (c) => {
// 預設優先語意;caller 傳 mode=keyword 才強制關鍵字。KBDB 端未開 Vectorize 會自動降級。
const mode = c.req.query('mode') === 'keyword' ? 'keyword' : 'semantic';
const base = (c.env.KBDB_BASE_URL ?? 'https://arcrun-kbdb.uncle6-me.workers.dev').replace(/\/$/, '');
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (c.env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`;
const params = new URLSearchParams({
q,
owner_id: apiKey, // 租戶隔離(只搜本租戶的 workflow)
entry_type: 'workflow', // base 通用 filterQ4),只回 workflow entry
mode,
});
const res = await fetch(`${base}/entries/search?${params.toString()}`, { headers });
// KBDB 轉發抽到 lib/workflow-search.tst159 target 參數):本路由與
// POST /cypher/search { target:"workflow" } 共用同一條路,行為必然一致。
const res = await fetchTenantWorkflowSearch(c.env, apiKey, q, mode);
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
});
@@ -471,6 +465,28 @@ webhooksNamedRouter.get('/q/:ns/:name', async (c) => {
return queryNamed(c, c.req.param('ns'), c.req.param('name'), queryStringContext(c));
});
// GET /webhooks/named/:name/definition — 吐 workflow 的可攜定義(t158 export 原語)。
// leo 07-31:「如果我要把我做的工作流分享給同事,我要怎麼 export?他要如何 import
// 在從前就是寫成幾個 yaml 丟過去讓新的送進 KBDB 不是嗎?」
// 回 record 原樣(graphconfigdescription)=import 端可直接 POST /webhooks/named 送進
// 任何實例(acr workflow import/安裝器同一條路)。執行語義不驗證(部署≠發現)。
webhooksNamedRouter.get('/webhooks/named/:name/definition', async (c) => {
const apiKey = c.req.header('X-Arcrun-API-Key');
if (!apiKey) return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
const name = c.req.param('name');
const raw = await c.env.WEBHOOKS.get(kvKey(apiKey, name), 'text');
if (!raw) return c.json({ error: `找不到 workflow "${name}"` }, 404);
const rec = JSON.parse(raw) as NamedWorkflowRecord;
return c.json({
name: rec.name,
description: rec.description ?? '',
graph: rec.graph,
config: rec.config ?? {},
created_at: rec.created_at ?? '',
...(rec.cron_expr ? { cron_expr: rec.cron_expr } : {}),
});
});
// GET /webhooks/named — 列出當前 api_key 下所有 workflow
webhooksNamedRouter.get('/webhooks/named', async (c) => {
const apiKey = c.req.header('X-Arcrun-API-Key');
@@ -0,0 +1,99 @@
// 單元測試:execution-evaluator — 從 trace 導出每顆零件成敗 + 回寫 registry
// SDD: system-dev/docs/3-specs/arcrun-core-mvp/design.md「執行統計設計」
import { describe, it, expect, vi, afterEach } from 'vitest';
import { componentVerdictsFromTrace, recordComponentStats } from '../src/actions/execution-evaluator';
import type { GraphNode, TraceStep } from '../src/types';
const NODES: GraphNode[] = [
{ id: 'input', type: 'Input' },
{ id: 'fetch', type: 'Component', componentId: 'http_request' },
{ id: 'transform', type: 'Component', componentId: 'code' },
{ id: 'output', type: 'Output' },
];
function step(nodeId: string, over: Partial<TraceStep> = {}): TraceStep {
return { nodeId, type: 'Component', input: {}, output: { ok: true }, duration_ms: 10, ...over };
}
describe('componentVerdictsFromTrace', () => {
it('只算 Component 節點;Input/Output 跳過', () => {
const verdicts = componentVerdictsFromTrace(NODES, [
step('input', { type: 'Input' }),
step('fetch'),
step('output', { type: 'Output' }),
]);
expect(verdicts).toEqual([{ component_id: 'http_request', success: true, duration_ms: 10 }]);
});
it('trace 有 error → 該零件記失敗', () => {
const verdicts = componentVerdictsFromTrace(NODES, [
step('fetch', { error: 'boom', output: null }),
]);
expect(verdicts).toEqual([{ component_id: 'http_request', success: false, duration_ms: 10 }]);
});
it('output.success === false → 記失敗(makeHttpRunner 對非 2xx 不 throw', () => {
const verdicts = componentVerdictsFromTrace(NODES, [
step('fetch', { output: { success: false, status: 500, error: 'oops' } }),
]);
expect(verdicts[0].success).toBe(false);
});
it('FOREACH 同節點多筆 trace → 每次執行各記一次樣本', () => {
const verdicts = componentVerdictsFromTrace(NODES, [
step('fetch'),
step('fetch', { error: 'x', output: null }),
step('fetch'),
]);
expect(verdicts).toHaveLength(3);
expect(verdicts.map(v => v.success)).toEqual([true, false, true]);
});
});
describe('recordComponentStats', () => {
afterEach(() => vi.unstubAllGlobals());
it('對每顆零件各發一次 POST /analytics/recordfire-and-forget', async () => {
const calls: Array<{ url: string; body: Record<string, unknown> }> = [];
vi.stubGlobal('fetch', vi.fn(async (url: string, init: RequestInit) => {
calls.push({ url: String(url), body: JSON.parse(String(init.body)) });
return new Response('{}', { status: 200 });
}));
await recordComponentStats(
{ REGISTRY_BASE_URL: 'http://registry.local' },
NODES,
[step('fetch'), step('transform', { error: 'bad', output: null })],
);
expect(calls).toHaveLength(2);
expect(calls[0].url).toBe('http://registry.local/analytics/record');
expect(calls[0].body).toEqual({ canonical_id: 'http_request', success: true, duration_ms: 10 });
expect(calls[1].body).toEqual({ canonical_id: 'code', success: false, duration_ms: 10 });
});
it('registry 打不到也不 throw(統計失敗不影響執行)', async () => {
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('network down'); }));
await expect(
recordComponentStats({ REGISTRY_BASE_URL: 'http://registry.local' }, NODES, [step('fetch')]),
).resolves.toBeUndefined();
});
it('無 REGISTRY_BASE_URL 也無 WORKER_SUBDOMAIN → 靜默略過不打', async () => {
const fetchSpy = vi.fn();
vi.stubGlobal('fetch', fetchSpy);
await recordComponentStats({}, NODES, [step('fetch')]);
expect(fetchSpy).not.toHaveBeenCalled();
});
it('未設 REGISTRY_BASE_URL → 用 wasmWorkerUrl 慣例組 registry URL', async () => {
const calls: string[] = [];
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
calls.push(String(url));
return new Response('{}', { status: 200 });
}));
await recordComponentStats({ WORKER_SUBDOMAIN: 'uncle6-me' }, NODES, [step('fetch')]);
expect(calls[0]).toBe('https://arcrun-registry.uncle6-me.workers.dev/analytics/record');
});
});
+4 -30
View File
@@ -300,42 +300,16 @@ describe('PATCH libraries(每帳號可查庫)', () => {
// ═══════════════ 5. 庫目錄管理 ═══════════════
describe('/portal/admin/libraries', () => {
it('POST 建庫:寫 {tenant}::portal 子 namespace;重複登記 → 409', async () => {
await seedAdminSession();
mockGetRecord('rec_admin', adminValues());
mockTemplatesExist();
mockListByTemplate('portal_library', []);
let recordBody = '';
fetchMock
.get(KBDB)
.intercept({ path: '/records', method: 'POST' })
.reply(200, (opts) => {
recordBody = String(opts.body);
return {
success: true,
record: { record_id: 'rec_lib1', template_id: 'tpl_pl', values: { name: 'finance', display_name: '財務庫', status: 'active' } },
};
});
it('t160:人工建庫端點已刪(leo「沒有登記這回事」)——POST → 404;庫只從 daemon 同步來', async () => {
// 舊測試驗「POST 建庫 200+重複 409」——t160 拔掉人工建庫(e744ad1)後規格為:
// 庫由 /portal/daemon/libraries(連線精靈自動登記,t159)產生,admin 只能 GET/PATCH。
const res = await json(
'POST',
'/portal/admin/libraries',
{ name: 'finance', display_name: '財務庫' },
{ Authorization: 'Bearer tok-admin' },
);
expect(res.status).toBe(200);
const rec = JSON.parse(recordBody) as { owner_id: string; template: string };
expect(rec.owner_id).toBe(NS);
expect(rec.template).toBe('portal_library');
// 重複登記
await seedAdminSession('tok-admin3');
mockGetRecord('rec_admin', adminValues());
mockTemplatesExist();
mockListByTemplate('portal_library', [
{ record_id: 'rec_lib1', values: { name: 'finance', display_name: '財務庫', status: 'active' } },
]);
const dup = await json('POST', '/portal/admin/libraries', { name: 'finance' }, { Authorization: 'Bearer tok-admin3' });
expect(dup.status).toBe(409);
expect(res.status).toBe(404);
});
it('PATCH graph_sourceboolean 進、slot 存字串;非 boolean → 400', async () => {
+3 -2
View File
@@ -137,7 +137,8 @@ export async function searchComponents(
// ── 內部工具函數 ──────────────────────────────────────────────────────────────
function computeScore(v: Record<string, unknown>): number {
// exportrecordAnalytics 選「最優版本」要與 getComponent 同判準(單一評分真相)
export function computeScore(v: Record<string, unknown>): number {
const successRate = parseFloat(String(v.success_rate ?? '1'));
const avgDuration = parseFloat(String(v.avg_duration_ms ?? '10'));
const callCount = parseInt(String(v.call_count ?? '0'), 10);
@@ -145,7 +146,7 @@ function computeScore(v: Record<string, unknown>): number {
return successRate * speedScore * Math.log(callCount + 2);
}
function toComponentRecord(v: Record<string, unknown>): ComponentRecord {
export function toComponentRecord(v: Record<string, unknown>): ComponentRecord {
return {
component_hash_id: String(v.component_hash_id ?? ''),
canonical_id: String(v.canonical_id ?? ''),
+128
View File
@@ -0,0 +1,128 @@
// recordAnalytics — 零件執行結果回寫(POST /analytics/record 的實作)
// SDD: system-dev/docs/3-specs/arcrun-core-mvp/design.md「執行統計設計」
//
// 真相源=ANALYTICS_KV 計數器(key = stats:{hash_id}:{version},見 src/types.ts 註記):
// { total_runs, success_runs, total_ms }
// 讀取端(queryComponents / GET /components/*)讀的是 comp: 記錄上的
// success_rate / avg_duration_ms / call_count 欄位——所以每次記錄後把衍生值
// 回填 comp: 記錄(唯一寫入者是本函式,衍生值不是第二個真相源)。
//
// 尺度註記:design.md:499 寫 success_rate = success_runs / total_runs * 100(百分比顯示)。
// 本 repo comp: 記錄的 success_rate 既有尺度是 0..1(預設 1computeScore 直接相乘),
// 為不破壞既有讀取端與未測零件的預設值,落庫維持 0..1;*100 只是等價的顯示換算。
//
// 誠實限制:CF KV 無 CAS/原子遞增,read-modify-write 併發下偶有丟計數;
// 統計用途可接受(design 的「樂觀鎖」在 KV 上做不到,不假裝做到了)。
import type { Bindings } from '../types';
import { computeScore } from './queryComponents';
export interface AnalyticsRecordInput {
canonical_id: string; // 也接受 cmp_xxxxxxxx hash_id
version?: string; // 不給 → 記到查詢會回的最優版本(getComponent 同判準)
success: boolean;
duration_ms: number;
}
export interface AnalyticsRecordResult {
ok: boolean;
error?: string;
canonical_id?: string;
version?: string;
total_runs?: number;
success_runs?: number;
success_rate?: number; // 0..1,與 comp: 記錄同尺度
avg_duration_ms?: number;
}
interface StatsCounters {
total_runs: number;
success_runs: number;
total_ms: number;
}
export async function recordAnalytics(
input: AnalyticsRecordInput,
env: Bindings,
): Promise<AnalyticsRecordResult> {
// 1. 解析 hash_id(與 queryComponents.resolveHashId 同規則)
const hashId = input.canonical_id.startsWith('cmp_')
? input.canonical_id
: await env.SUBMISSIONS_KV.get(`idx:${input.canonical_id}`);
if (!hashId) {
return { ok: false, error: `零件 ${input.canonical_id} 不在索引` };
}
// 2. 找目標版本記錄:指定 version 就用它;沒指定 → 最優版本(與 getComponent 同判準:score 最高)
const list = await env.SUBMISSIONS_KV.list({ prefix: `comp:${hashId}:` });
let targetKey: string | null = null;
let targetRecord: Record<string, unknown> | null = null;
let bestScore = -Infinity;
for (const key of list.keys) {
const raw = await env.SUBMISSIONS_KV.get(key.name);
if (!raw) continue;
let v: Record<string, unknown>;
try { v = JSON.parse(raw); } catch { continue; }
if (v.status === 'tombstone') continue;
if (input.version) {
if (String(v.version) === input.version) {
targetKey = key.name;
targetRecord = v;
break;
}
continue;
}
const score = computeScore(v);
if (score > bestScore) {
bestScore = score;
targetKey = key.name;
targetRecord = v;
}
}
if (!targetKey || !targetRecord) {
return { ok: false, error: `零件 ${input.canonical_id} 無可用版本記錄` };
}
const version = String(targetRecord.version ?? 'v1');
const canonicalId = String(targetRecord.canonical_id ?? input.canonical_id);
// 3. 更新計數器(真相源,ANALYTICS_KV stats:{hash_id}:{version}
const statsKey = `stats:${hashId}:${version}`;
let counters: StatsCounters = { total_runs: 0, success_runs: 0, total_ms: 0 };
const rawStats = await env.ANALYTICS_KV.get(statsKey);
if (rawStats) {
try {
const parsed = JSON.parse(rawStats) as Partial<StatsCounters>;
counters = {
total_runs: Number(parsed.total_runs) || 0,
success_runs: Number(parsed.success_runs) || 0,
total_ms: Number(parsed.total_ms) || 0,
};
} catch { /* 損毀計數器 → 重新起算 */ }
}
counters.total_runs += 1;
counters.success_runs += input.success ? 1 : 0;
counters.total_ms += Math.max(0, Number(input.duration_ms) || 0);
await env.ANALYTICS_KV.put(statsKey, JSON.stringify(counters));
// 4. 衍生值回填 comp: 記錄(讀取端讀的地方)
const successRate = counters.success_runs / counters.total_runs;
const avgDurationMs = Math.round(counters.total_ms / counters.total_runs);
targetRecord.success_rate = successRate;
targetRecord.avg_duration_ms = avgDurationMs;
targetRecord.call_count = counters.total_runs;
await env.SUBMISSIONS_KV.put(targetKey, JSON.stringify(targetRecord));
return {
ok: true,
canonical_id: canonicalId,
version,
total_runs: counters.total_runs,
success_runs: counters.success_runs,
success_rate: successRate,
avg_duration_ms: avgDurationMs,
};
}
+4
View File
@@ -9,6 +9,7 @@ import validateContractRoute from './routes/validateContract';
import componentsRoute from './routes/components';
import queryRoute from './routes/query';
import initRoute from './routes/init';
import analyticsRoute from './routes/analytics';
const app = new Hono<{ Bindings: Bindings }>();
app.use('*', cors());
@@ -25,4 +26,7 @@ app.route('/components', componentsRoute); // POST /components
// === 初始化端點(建立 tpl-component template===
app.route('/init', initRoute);
// === 執行統計回寫(cypher-executor 執行收尾 fire-and-forget 打進來)===
app.route('/analytics', analyticsRoute); // POST /analytics/record
export default app;
+37
View File
@@ -0,0 +1,37 @@
// POST /analytics/record — 零件執行結果回寫端點
// SDD: system-dev/docs/3-specs/arcrun-core-mvp/design.md「執行統計設計」
// 呼叫方:cypher-executor 執行收尾(fire-and-forget,統計失敗不影響執行)
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { recordAnalytics } from '../actions/recordAnalytics';
const app = new Hono<{ Bindings: Bindings }>();
app.post('/record', async c => {
const body = await c.req.json().catch(() => null) as {
canonical_id?: unknown;
version?: unknown;
success?: unknown;
duration_ms?: unknown;
} | null;
if (!body || typeof body.canonical_id !== 'string' || body.canonical_id.trim() === '') {
return c.json({ ok: false, error: 'canonical_id 必填' }, 400);
}
if (typeof body.success !== 'boolean') {
return c.json({ ok: false, error: 'success 必須為 boolean' }, 400);
}
const result = await recordAnalytics({
canonical_id: body.canonical_id.trim(),
version: typeof body.version === 'string' && body.version !== '' ? body.version : undefined,
success: body.success,
duration_ms: typeof body.duration_ms === 'number' ? body.duration_ms : 0,
}, c.env);
if (!result.ok) return c.json(result, 404);
return c.json(result);
});
export default app;
+25 -1
View File
@@ -5,10 +5,34 @@
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { getComponent, getComponentVersions, searchComponents } from '../actions/queryComponents';
import { getComponent, getComponentVersions, searchComponents, toComponentRecord } from '../actions/queryComponents';
import type { ComponentRecord } from '../actions/queryComponents';
const app = new Hono<{ Bindings: Bindings }>();
// 全清單(t158 批次化):/cypher/search discover 一次抓走整份目錄,
// 節點存在判定+相似度全在 cypher 記憶體內比對——取代「每個 missing 節點
// 各打 1+8 次查詢」的疊爆模式(冷實例 8 節點實測 25.7s 的病根)。
// 也補上 CP2-B 記載的「registry 沒有列表端點」缺口。
// 必須在 /:id 之前,避免 "catalog" 被當作 id。
app.get('/catalog', async c => {
const list = await c.env.SUBMISSIONS_KV.list({ prefix: 'comp:' });
const seen = new Set<string>();
const components: ComponentRecord[] = [];
for (const key of list.keys) {
const raw = await c.env.SUBMISSIONS_KV.get(key.name);
if (!raw) continue;
let v: Record<string, unknown>;
try { v = JSON.parse(raw) as Record<string, unknown>; } catch { continue; }
if (v.status === 'tombstone' || v.visibility !== 'public') continue;
const dedup = `${String(v.component_hash_id ?? '')}:${String(v.version ?? '')}`;
if (seen.has(dedup)) continue;
seen.add(dedup);
components.push(toComponentRecord(v));
}
return c.json({ success: true, data: { components, count: components.length } });
});
// 語意搜尋(必須在 /:id 之前,避免 "search" 被當作 id
app.get('/search', async c => {
const q = c.req.query('q');
+99
View File
@@ -0,0 +1,99 @@
// 單元測試:recordAnalytics — 執行統計回寫
// SDD: system-dev/docs/3-specs/arcrun-core-mvp/design.md「執行統計設計」
import { describe, it, expect, beforeEach } from 'vitest';
import { recordAnalytics } from '../src/actions/recordAnalytics';
import type { Bindings } from '../src/types';
// 最小 KV mockget/put/listIn-memory
function makeKv() {
const store = new Map<string, string>();
return {
store,
async get(key: string) { return store.get(key) ?? null; },
async put(key: string, value: string) { store.set(key, value); },
async list({ prefix }: { prefix: string }) {
return { keys: [...store.keys()].filter(k => k.startsWith(prefix)).map(name => ({ name })) };
},
};
}
describe('recordAnalytics', () => {
let submissions: ReturnType<typeof makeKv>;
let analytics: ReturnType<typeof makeKv>;
let env: Bindings;
beforeEach(() => {
submissions = makeKv();
analytics = makeKv();
env = { SUBMISSIONS_KV: submissions, ANALYTICS_KV: analytics } as unknown as Bindings;
// 種一顆零件(indexOnlyComponent 的記錄形狀)
submissions.store.set('idx:http_request', 'cmp_abc12345');
submissions.store.set('comp:cmp_abc12345:v1', JSON.stringify({
component_hash_id: 'cmp_abc12345',
canonical_id: 'http_request',
display_name: 'HTTP Request',
version: 'v1',
success_rate: 1,
avg_duration_ms: 0,
call_count: 0,
visibility: 'public',
status: 'active',
}));
});
it('N 次成功+M 次失敗 → success_rate = success_runs / total_runs,計數器與 comp 記錄同步', async () => {
// 3 成功 + 2 失敗
for (const success of [true, true, true, false, false]) {
const r = await recordAnalytics({ canonical_id: 'http_request', success, duration_ms: 100 }, env);
expect(r.ok).toBe(true);
}
// 真相源:ANALYTICS_KV 計數器
const counters = JSON.parse(analytics.store.get('stats:cmp_abc12345:v1')!);
expect(counters).toEqual({ total_runs: 5, success_runs: 3, total_ms: 500 });
// 讀取端:comp 記錄被回填衍生值
const record = JSON.parse(submissions.store.get('comp:cmp_abc12345:v1')!);
expect(record.success_rate).toBeCloseTo(3 / 5);
expect(record.avg_duration_ms).toBe(100);
expect(record.call_count).toBe(5);
});
it('接受 cmp_ hash_id 直接記錄', async () => {
const r = await recordAnalytics({ canonical_id: 'cmp_abc12345', success: true, duration_ms: 50 }, env);
expect(r.ok).toBe(true);
expect(r.canonical_id).toBe('http_request');
expect(r.total_runs).toBe(1);
});
it('指定 version 時記到該版本', async () => {
submissions.store.set('comp:cmp_abc12345:v2', JSON.stringify({
component_hash_id: 'cmp_abc12345',
canonical_id: 'http_request',
version: 'v2',
success_rate: 1, avg_duration_ms: 0, call_count: 0,
visibility: 'public', status: 'active',
}));
const r = await recordAnalytics({ canonical_id: 'http_request', version: 'v2', success: false, duration_ms: 30 }, env);
expect(r.ok).toBe(true);
expect(r.version).toBe('v2');
expect(analytics.store.has('stats:cmp_abc12345:v2')).toBe(true);
expect(analytics.store.has('stats:cmp_abc12345:v1')).toBe(false);
});
it('不在索引的零件回 ok:false(誠實 404,不假綠)', async () => {
const r = await recordAnalytics({ canonical_id: 'no_such_component', success: true, duration_ms: 1 }, env);
expect(r.ok).toBe(false);
expect(r.error).toContain('不在索引');
});
it('tombstone 版本不被記錄', async () => {
submissions.store.set('comp:cmp_abc12345:v1', JSON.stringify({
component_hash_id: 'cmp_abc12345', canonical_id: 'http_request', version: 'v1', status: 'tombstone',
}));
const r = await recordAnalytics({ canonical_id: 'http_request', success: true, duration_ms: 1 }, env);
expect(r.ok).toBe(false);
});
});
@@ -171,9 +171,13 @@
- [x] 28.3 `/webhooks/:token/trigger` 路由已補上 `waitUntil(writeExecutionVerdict(...))`
- _Requirements: 7.2_
- [ ] 29. registry Worker analytics 端點
- [ ] 29.1 新增 `POST /analytics/record` 路由,原子更新 `ANALYTICS_KV`
- [ ] 29.2 `GET /components` 回傳加入 `total_runs``success_rate``avg_duration_ms`
- [x] 29. registry Worker analytics 端點2026-07-31CP arcrun-usable 步驟 6
- [x] 29.1 新增 `POST /analytics/record` 路由,更新 `ANALYTICS_KV` 計數器(`stats:{hash_id}:{version}`
- 註:KV 無 CAS,「原子更新」在 KV 上做不到——read-modify-write,併發偶有丟計數,統計用途可接受(誠實限制)
- 衍生值(success_rate 0..1avg_duration_mscall_count)回填 `comp:` 記錄(查詢讀取端),計數器是唯一真相源
- [x] 29.2 `GET /components/:id``/components/search` 回傳的 `success_rate``avg_duration_ms``call_count` 隨執行更新(欄位既存,本次讓它有真資料)
- [x] 29.3 cypher-executor 執行收尾對用到的**每顆零件**回寫(`execution-evaluator.ts` 從 stub 改真實作;`/cypher/execute` 與 webhook 路徑都掛,waitUntil fire-and-forget;成敗判定=trace error 或 output.success===false
- 實測(本地 wrangler dev ×2registry 8788cypher 8787REGISTRY_BASE_URL 指本地):同一零件 http_request 跑 5 次(3 成功+2 失敗 404)→ `GET /components/http_request` success_rate 1→0.6、call_count 0→5`/cypher/search` 節點回 `success_rate: 0.6`
- _Requirements: 7.3, 7.6_
- [x] 30. `author` 欄位已加入 contract.yaml 規格
@@ -421,3 +421,18 @@ leo 的判準(2026-07-30):
### ⏸ 等 leo confirm
- confirm 後依 D35:兩份 SDD 目前皆 `paused`,要動需先處理單一活性
(現行 active`workflow-discovery`
## 提案:workflow export/import 一等公民化(2026-07-31,總管代 leo 口頭定調立案)
- **來源**leo 07-31 原話(見 workflow-discovery tasks.md 3.9 引文)——分享 workflow
給同事的正路=export 檔→import,不是「用 search 湊」。
- **變更面**:① cypher `GET /webhooks/named/:name/definition`(已實作,staging 試點中,
零新資料模型/同 list 認證)② `acr workflow export <name>``acr workflow import <file>`
新 CLI 命令(已寫未接線)③ 安裝器 workflows.jsonexport 檔同形狀(graph 預編,已上 prod)。
- **不動的牆**KBDB 零接觸(export 讀 WEBHOOKS KV workflow recordimport 打既有
POST /webhooks/named);「部署≠發現」(import 不驗零件存在,執行時現形);
description 必填閘照舊。
- **影響分析**:新公開端點 1 個(租戶 key 認證,吐的是該租戶自己部署的 workflow 定義;
無跨租戶讀);CLI 新命令 2 個(薄殼,能力全在既有 API);無 schema migration、無新金鑰面。
- **狀態**:⏳ 待 leo confirm 後 3.9b 接線。
@@ -83,6 +83,53 @@
有的照常編圖不必報告;缺的要兩庫(零件+recipe)都搜過後點名+給正確指示。
驗收兩層:機械(verify 01/03)綠 → haiku 真考(只讀回覆就能說出缺什麼、該做什麼)。
## 3.9 追加(2026-07-31 深夜,t158 後續;leo 定調 export/import 原語)
> leo:「你要做的就是一個叫 export,另一個是 import,打包好的幾個工作流準備好直接
> import 就好了。現在如果我要把我做的工作流分享給同事,我要怎麼 export?他要如何
> import?是缺了功能用 search 來湊嗎?在從前就是寫成幾個 yaml 丟過去讓新的送進
> KBDB 不是嗎?」+「絕對不能違背原來的堅持(不能違規)」。
- [x] 3.9a `GET /webhooks/named/:name/definition`export 引擎端):吐 workflow record
原樣(graphconfigdescription)——與既有 list 同認證(X-Arcrun-API-Key)、
同租戶 key 前綴,**零新資料模型**。062757f 已入 staging bundle 試點。
KBDB 牆自證:讀的是 WEBHOOKS KVworkflow 記錄),不碰 KBDB。
- [ ] 3.9b `acr workflow export/import` CLI 接線(命令已寫 cli/commands/workflow.ts
**未接進 index.ts**——D35 ③:新命令面屬規格層,已走 pending-changes 提案,
confirm 後才接)。importgraph 直 POST /webhooks/named(既有部署端點,
零編圖零 search);手寫 yaml 指去 acr push。
- [ ] 3.9c 安裝器同一條路驗證:workflows.json 打包期預編 graph+純上傳(rag-installer
5a6539d 已上 prod)——安裝器不走私有路徑的機械檢查(copy-contract 級)留此收。
- [x] 3.10 意圖節點→真實零件/recipe 替換(CP arcrun-usable **步驟 4**;頂層交棒 t159
— 2026-07-31 search-nodes.ts `trySubstitution`discover 混搜兩庫 exact 落空後,
在 t158「一次抓好的兩庫清單」**記憶體內**媒合(零新增 round-trip)。兩條保守規則
(不接 LLM,規則在 code 註解):A 服務詞→recipe(名字裡全部服務詞命中同一 recipe
且唯一才換;「google_slides_create」不會被 google_sheets 誤吃);B 強欄位斷詞→零件
canonical/display/aliases 強命中×10description/tags 弱命中,需至少一強命中且
分數唯一最高;「aes_encrypt」無強命中不換)。換到=status `resolved``substitution`
欄(from/componentId/recipe/reason),cypher 圖節點直接帶真實 componentId、不列
missing;換不到照舊 not_found3.7 指路+候選。**只動 discover**——compile(部署/
推送複製路徑)零替換零查詢(t158 邊界不動)。
驗(本地 wrangler devregistry 種 20 合約+/init/seed 10 recipe):
「判斷有沒有新資料 >> ON_SUCCESS >> 傳到 telegram」→ if_controlresolved)+
telegram_sendresolvedsubstitution.componentId=http_request)=feature 06 驗法過;
機械考 27/27 全綠(01 組 503 組 4 迴歸+06 組 8target 8compile 迴歸 2);
冷啟第一發 94ms、熱 8–13mst158 病史對照:舊 25.7s
- [x] 3.10 `/cypher/search``target` 指定搜尋對象(leo 07-31:「難道我不能指定要搜尋
工作流或節點或 recipe 嗎?」)— componentrecipeworkflow
tripletstarget=component|recipe=只查該庫;querytarget=名字搜尋,各走**既有**
機制不新造(component→registry /components/searchMCP arcrun_search_components
同一條路;recipe→私庫 RECIPES KV 同 discover 第二庫讀法,回應註明公庫走
arcrun_recipe_searchworkflow→新抽 lib/workflow-search.tsGET /workflows/search
與 target=workflow 共用同一 KBDB 轉發=MCP arcrun_search_workflows 同一條路)。
防呆:mode=compiletarget → 400target=workflow 吃 query 不吃 triplets400 指路);
非法 target → 400。已知缺口如實透傳:workflow 無 description 搜不到(capability_hint
照舊),補救仍走 /workflows/backfill-search-entries。
MCP 三分型工具盤點:search_componentssearch_workflowsrecipe_search 均註冊活著;
前兩者與 target 走同一條路;recipe_search 搜公庫 vs target=recipe 搜私庫=語料不同
是設計(installed vs marketplace),回應互相指路,非行為漂移
---
## 跨任務鐵律提醒