feat(console): 搜尋/控制台頁 v0(Arcrun#3,T-loop1d 雲端壓測)

cypher-executor 新 route GET /console:單檔 HTML+原生 JS 薄殼,三查詢區
(KBDB/workflows/components+recipes)全打既有端點,無新業務邏輯。
components 改打「同帳號」arcrun-registry worker(WORKER_SUBDOMAIN 現算),
不硬打官方 registry.arcrun.dev,避免自架用戶查到別人的零件庫。
vectorize 狀態直接讀 KBDB 既有 mode/capability_hint,不開新 config 端點。

已部署 leo21c 帳號驗證:/console 200、三查詢 curl 皆 200 真回資料
(public-recipes 命中 telegram_send 真實紀錄)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-07-02 09:08:27 +00:00
parent f6c773a9b5
commit 981dc25086
2 changed files with 287 additions and 0 deletions
+2
View File
@@ -21,6 +21,7 @@ import { resumeRouter } from './routes/resume';
import { executionsRouter } from './routes/executions';
import { initSeedRouter } from './routes/init-seed';
import { kbdbProxyRouter } from './routes/kbdb-proxy';
import { consoleRouter } from './routes/console';
const app = new Hono<{ Bindings: Bindings }>();
@@ -50,6 +51,7 @@ app.route('/', resumeRouter);
app.route('/', executionsRouter); // LI SDD M2.1: /executions/* + /workflows/:name/executions
app.route('/', initSeedRouter); // 薄殼原則:seed recipe 是 API 行為(rule 07,壓測 §4.1
app.route('/', kbdbProxyRouter); // kbdb-base 9.5KBDB 資料層 proxy(讓 CLI 透過 cypher 達 KBDB,純轉發)
app.route('/', consoleRouter); // Arcrun#3:搜尋/控制台頁 v0(單檔 HTML+原生 JS,薄殼)
// Worker 導出(fetch + scheduled
// scheduled handler 對應 wrangler.toml [triggers].crons,每分鐘 tick
+285
View File
@@ -0,0 +1,285 @@
/**
* arcrun 控制台頁 v0Gitea Arcrun#32026-07-02 leo 交辦)
*
* 薄殼(rule 07):單檔 HTML + 原生 JS,無框架、無 build step,零業務邏輯——
* 只 fetch 既有 API 並渲染。缺端點不在這裡拼裝補,回報 issue。
*
* 三個查詢區各打「既有」端點,單一真相源(禁硬編清單重演 parts.ts stale 教訓):
* - 知識庫:同源 GET /kbdb/searchkbdb-proxy.tsX-Arcrun-API-Key
* - workflows:同源 GET /workflows/searchwebhooks-named.ts,同 header
* - components/recipescomponents 打「同帳號」的 arcrun-registry worker(跟 MCP
* COMPONENT_REGISTRY service binding 同一顆——self-hosted 各自一份,不是硬打官方
* registry.arcrun.dev,否則自架用戶查到的是別人的零件庫)。URL 用既有 WORKER_SUBDOMAIN
* env var 現算(同 KBDB_BASE_URL 那套 arcrun-{name}.{subdomain}.workers.dev 慣例),
* 不新增 bindingCORS 該 worker已開;不需 auth。
* recipes 打同源 GET /public-recipesrecipes.ts,公庫,不需 auth
*
* 認證:頁面請用戶貼一次 X-Arcrun-API-Keyself-hostednamespace 明碼),存 localStorage。
* knowledge/workflows 兩區需要;components/recipes 是公開資料,不需要。
*
* config 區(v0 唯讀):vectorize 狀態不是新端點——直接讀「知識庫」查詢回應本身的 mode/
* capability_hintkbdb entries.ts 既有機制:要求 semantic、缺 Vectorize 就誠實降級並帶 hint)。
*/
import { Hono } from 'hono';
import type { Bindings } from '../types';
export const consoleRouter = new Hono<{ Bindings: Bindings }>();
function renderConsoleHtml(registryBase: 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>arcrun Console</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body { margin: 0; font-family: -apple-system, "PingFang TC", "Noto Sans TC", sans-serif; background: #0f1115; color: #e6e6e6; }
header { padding: 20px 24px; border-bottom: 1px solid #262a33; }
header h1 { margin: 0 0 4px; font-size: 20px; }
header p { margin: 0; color: #9aa0aa; font-size: 13px; }
main { padding: 20px 24px; display: grid; gap: 20px; max-width: 980px; margin: 0 auto; }
.card { background: #161922; border: 1px solid #262a33; border-radius: 10px; padding: 16px 18px; }
.card h2 { margin: 0 0 10px; font-size: 15px; color: #dfe3ea; }
.row { display: flex; gap: 8px; }
input[type=text], input[type=password] { flex: 1; background: #0f1115; border: 1px solid #333844; color: #e6e6e6; border-radius: 6px; padding: 8px 10px; font-size: 14px; }
button { background: #3a5bfd; color: #fff; border: none; border-radius: 6px; padding: 8px 14px; font-size: 14px; cursor: pointer; }
button:hover { background: #2f4be0; }
button.secondary { background: #262a33; }
button.secondary:hover { background: #333844; }
.hint { font-size: 12px; color: #9aa0aa; margin-top: 6px; line-height: 1.5; }
.badge { display: inline-block; font-size: 11px; padding: 2px 8px; border-radius: 999px; margin-left: 6px; }
.badge.on { background: #16352a; color: #5ee6a8; }
.badge.off { background: #3a2a16; color: #f0b45e; }
ul.results { list-style: none; margin: 10px 0 0; padding: 0; display: grid; gap: 8px; }
ul.results li { background: #0f1115; border: 1px solid #262a33; border-radius: 8px; padding: 10px 12px; font-size: 13px; }
ul.results li .meta { color: #9aa0aa; font-size: 11px; margin-bottom: 4px; }
.empty { color: #6b7280; font-size: 13px; padding: 8px 0; }
.error { color: #f06565; font-size: 13px; padding: 8px 0; }
code { background: #0f1115; border: 1px solid #262a33; border-radius: 4px; padding: 1px 6px; }
.subhead { font-size: 12px; color: #9aa0aa; margin: 10px 0 4px; text-transform: uppercase; letter-spacing: .04em; }
</style>
</head>
<body>
<header>
<h1>arcrun Console <span id="vectorize-badge"></span></h1>
<p>裝了就有的搜尋台——知識庫 / workflows / components &amp; recipes,一頁查完。</p>
</header>
<main>
<div class="card" id="auth-card">
<h2>① API Key</h2>
<div class="row">
<input type="password" id="api-key" placeholder="貼你的 X-Arcrun-API-Keyself-hostednamespace 明碼)">
<button id="save-key">存起來</button>
</div>
<div class="hint">存在瀏覽器 localStorage,不送去別的地方。知識庫/workflows 查詢需要它;componentsrecipes 是公庫資料不用。</div>
</div>
<div class="card">
<h2>② 知識庫(KBDB</h2>
<div class="row">
<input type="text" id="kb-q" placeholder="關鍵字或問句,如「credential 遷移」">
<button data-search="kb">搜</button>
</div>
<div id="kb-status" class="hint"></div>
<ul class="results" id="kb-results"></ul>
</div>
<div class="card">
<h2>③ Workflows</h2>
<div class="row">
<input type="text" id="wf-q" placeholder="自然語言描述要找的工作流,如「把資料寫進 Google Sheets」">
<button data-search="wf">搜</button>
</div>
<div id="wf-status" class="hint"></div>
<ul class="results" id="wf-results"></ul>
</div>
<div class="card">
<h2>④ Components &amp; Recipes</h2>
<div class="row">
<input type="text" id="cr-q" placeholder="如「telegram send」「gsheets append」">
<button data-search="cr">搜</button>
</div>
<div id="cr-status" class="hint"></div>
<div class="subhead">Components</div>
<ul class="results" id="comp-results"></ul>
<div class="subhead">Recipes</div>
<ul class="results" id="recipe-results"></ul>
</div>
</main>
<script>
(function () {
const REGISTRY_BASE = ${JSON.stringify(registryBase)};
const $ = (id) => document.getElementById(id);
function getKey() { return localStorage.getItem('arcrun_console_api_key') || ''; }
function setKey(v) { localStorage.setItem('arcrun_console_api_key', v); }
$('api-key').value = getKey();
$('save-key').addEventListener('click', () => {
setKey($('api-key').value.trim());
$('api-key').placeholder = '已存';
});
function escapeHtml(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
function renderEmpty(ul, msg) {
ul.innerHTML = '<li class="empty">' + escapeHtml(msg) + '</li>';
}
function renderError(ul, msg) {
ul.innerHTML = '<li class="error">' + escapeHtml(msg) + '</li>';
}
function renderEntries(ul, entries, emptyMsg) {
if (!entries || entries.length === 0) { renderEmpty(ul, emptyMsg); return; }
ul.innerHTML = entries.map((e) => {
const type = e.entry_type || e.type || '';
const when = e.created_at || '';
const body = e.content || e.description || e.name || '(無內容預覽)';
return '<li><div class="meta">' + escapeHtml(type) + (when ? ' · ' + escapeHtml(when) : '') + '</div>' + escapeHtml(body) + '</li>';
}).join('');
}
function setVectorizeBadge(mode) {
const el = $('vectorize-badge');
if (mode === 'semantic') {
el.innerHTML = '<span class="badge on">語義搜尋:開</span>';
} else if (mode === 'keyword') {
el.innerHTML = '<span class="badge off">語義搜尋:關(僅關鍵字)</span>';
}
}
async function searchKb() {
const q = $('kb-q').value.trim();
const statusEl = $('kb-status');
const ul = $('kb-results');
if (!q) { statusEl.textContent = '請輸入查詢字'; return; }
const key = getKey();
if (!key) { statusEl.textContent = '請先貼 API Key(上方 ①)'; return; }
statusEl.textContent = '查詢中...';
try {
const res = await fetch('/kbdb/search?q=' + encodeURIComponent(q) + '&mode=semantic', {
headers: { 'X-Arcrun-API-Key': key },
});
const data = await res.json();
if (!res.ok) {
statusEl.textContent = '';
renderError(ul, data.error || ('查詢失敗(HTTP ' + res.status + ''));
return;
}
setVectorizeBadge(data.mode);
if (data.mode === 'keyword' && data.capability_hint) {
statusEl.textContent = '僅關鍵字搜尋 — ' + data.capability_hint;
} else {
statusEl.textContent = '模式:' + (data.mode || 'keyword');
}
renderEntries(ul, data.entries, '沒查到符合的知識庫條目');
} catch (e) {
statusEl.textContent = '';
renderError(ul, '請求失敗:' + e.message);
}
}
async function searchWorkflows() {
const q = $('wf-q').value.trim();
const statusEl = $('wf-status');
const ul = $('wf-results');
if (!q) { statusEl.textContent = '請輸入查詢字'; return; }
const key = getKey();
if (!key) { statusEl.textContent = '請先貼 API Key(上方 ①)'; return; }
statusEl.textContent = '查詢中...';
try {
const res = await fetch('/workflows/search?q=' + encodeURIComponent(q), {
headers: { 'X-Arcrun-API-Key': key },
});
const data = await res.json();
if (!res.ok) {
statusEl.textContent = '';
renderError(ul, data.error || ('查詢失敗(HTTP ' + res.status + ''));
return;
}
statusEl.textContent = '模式:' + (data.mode || 'keyword');
renderEntries(ul, data.entries, '沒查到符合的 workflow');
} catch (e) {
statusEl.textContent = '';
renderError(ul, '請求失敗:' + e.message);
}
}
async function searchComponentsAndRecipes() {
const q = $('cr-q').value.trim();
const statusEl = $('cr-status');
const compUl = $('comp-results');
const recipeUl = $('recipe-results');
if (!q) { statusEl.textContent = '請輸入查詢字'; return; }
statusEl.textContent = '查詢中...';
const [compResult, recipeResult] = await Promise.allSettled([
fetch(REGISTRY_BASE + '/components/search?q=' + encodeURIComponent(q)).then((r) => r.json().then((d) => ({ ok: r.ok, d }))),
fetch('/public-recipes?q=' + encodeURIComponent(q)).then((r) => r.json().then((d) => ({ ok: r.ok, d }))),
]);
statusEl.textContent = '';
if (compResult.status === 'fulfilled' && compResult.value.ok) {
const results = (compResult.value.d.data && compResult.value.d.data.results) || [];
renderEntries(compUl, results.map((c) => ({
entry_type: c.canonical_id || c.id,
content: c.description || c.name || JSON.stringify(c),
})), '沒查到符合的 component');
} else {
renderError(compUl, 'components 查詢失敗(零件庫 ' + REGISTRY_BASE + ' 不可達)');
}
if (recipeResult.status === 'fulfilled' && recipeResult.value.ok) {
const d = recipeResult.value.d;
if (d.found === false) {
renderEmpty(recipeUl, d.hint || '沒查到符合的 recipe');
} else {
renderEntries(recipeUl, (d.recipes || []).map((r) => ({
entry_type: r.canonical_id + (r.author ? ' · ' + r.author : ''),
content: r.description || r.display_name || r.canonical_id,
})), '沒查到符合的 recipe');
}
} else {
renderError(recipeUl, 'recipes 查詢失敗');
}
}
document.querySelectorAll('button[data-search]').forEach((btn) => {
btn.addEventListener('click', () => {
const kind = btn.getAttribute('data-search');
if (kind === 'kb') searchKb();
else if (kind === 'wf') searchWorkflows();
else if (kind === 'cr') searchComponentsAndRecipes();
});
});
['kb-q', 'wf-q', 'cr-q'].forEach((id) => {
$(id).addEventListener('keydown', (ev) => {
if (ev.key === 'Enter') {
const map = { 'kb-q': 'kb', 'wf-q': 'wf', 'cr-q': 'cr' };
document.querySelector('button[data-search="' + map[id] + '"]').click();
}
});
});
})();
</script>
</body>
</html>
`;
}
// GET /console — 控制台頁 v0Arcrun#3)。registry base 現算:同帳號 arcrun-registry worker
// WORKER_SUBDOMAIN 沿用既有 KBDB_BASE_URL 那套組法),沒設就退回官方公開 registry。
consoleRouter.get('/console', (c) => {
const subdomain = c.env.WORKER_SUBDOMAIN;
const registryBase = subdomain
? `https://arcrun-registry.${subdomain}.workers.dev`
: 'https://registry.arcrun.dev';
return c.html(renderConsoleHtml(registryBase));
});