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:
@@ -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.5:KBDB 資料層 proxy(讓 CLI 透過 cypher 達 KBDB,純轉發)
|
||||
app.route('/', consoleRouter); // Arcrun#3:搜尋/控制台頁 v0(單檔 HTML+原生 JS,薄殼)
|
||||
|
||||
// Worker 導出(fetch + scheduled)
|
||||
// scheduled handler 對應 wrangler.toml [triggers].crons,每分鐘 tick;
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* arcrun 控制台頁 v0(Gitea Arcrun#3,2026-07-02 leo 交辦)
|
||||
*
|
||||
* 薄殼(rule 07):單檔 HTML + 原生 JS,無框架、無 build step,零業務邏輯——
|
||||
* 只 fetch 既有 API 並渲染。缺端點不在這裡拼裝補,回報 issue。
|
||||
*
|
||||
* 三個查詢區各打「既有」端點,單一真相源(禁硬編清單重演 parts.ts stale 教訓):
|
||||
* - 知識庫:同源 GET /kbdb/search(kbdb-proxy.ts,X-Arcrun-API-Key)
|
||||
* - workflows:同源 GET /workflows/search(webhooks-named.ts,同 header)
|
||||
* - components/recipes:components 打「同帳號」的 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 慣例),
|
||||
* 不新增 binding;CORS 該 worker已開;不需 auth。
|
||||
* recipes 打同源 GET /public-recipes(recipes.ts,公庫,不需 auth)
|
||||
*
|
||||
* 認證:頁面請用戶貼一次 X-Arcrun-API-Key(self-hosted=namespace 明碼),存 localStorage。
|
||||
* knowledge/workflows 兩區需要;components/recipes 是公開資料,不需要。
|
||||
*
|
||||
* config 區(v0 唯讀):vectorize 狀態不是新端點——直接讀「知識庫」查詢回應本身的 mode/
|
||||
* capability_hint(kbdb 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 & 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-Key(self-hosted=namespace 明碼)">
|
||||
<button id="save-key">存起來</button>
|
||||
</div>
|
||||
<div class="hint">存在瀏覽器 localStorage,不送去別的地方。知識庫/workflows 查詢需要它;components/recipes 是公庫資料不用。</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 & 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 — 控制台頁 v0(Arcrun#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));
|
||||
});
|
||||
Reference in New Issue
Block a user