abf232327c
背景:self-hosted 單租戶下 console 原本那格「API Key」其實只是 namespace 明碼字串,不是註冊制 key(leo 原話:理論上根本沒有 API Key 這件事)。leo 拍板: 換成簡單 email+password 登入頁(自己設一組帳密,不用 OAuth),登入成功後端發 session token 存 localStorage;後端 API 呼叫仍用固定租戶字串打 KBDB(登入系統 只擋外人看頁面,不做多租戶)。 租戶字串收斂:發現①已核實 owner_id='leo' 是 D1 中 458,357 筆資料實際使用的 租戶字串(ak_... 只有 2 筆孤兒資料)。CONSOLE_TENANT 預設 "leo",不製造第三個租戶。 新增: - cypher-executor/src/routes/console-auth.ts:/console/auth-status、 /console/setup(首次自助設定帳密,寫入 SESSIONS_KV console:credentials)、 /console/setup/reset(換帳密,需舊密碼)、/console/login、/console/session (驗 session + 回傳固定租戶字串)、/console/logout。密碼用 salt + 3 輪 SHA-256 雜湊,不存明碼。 - cypher-executor/src/types.ts:Bindings 加 CONSOLE_TENANT。 - cypher-executor/wrangler.toml:[vars] 加 CONSOLE_TENANT = "leo"。 - cypher-executor/src/routes/console.ts:① 卡片從「貼 API Key」改成登入/首次 設定表單;查詢函式改用登入後端回的固定租戶字串,使用者不再需要知道任何 namespace 字串。 驗證見 issue #3 留言。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
395 lines
17 KiB
TypeScript
395 lines
17 KiB
TypeScript
/**
|
||
* 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)
|
||
*
|
||
* 認證(v1,Arcrun#3 發現②改版):不再讓使用者貼 API Key(self-hosted 單租戶下那格本來就只是
|
||
* namespace 明碼字串,不是註冊制 key——leo 原話:理論上根本沒有 API Key 這件事)。改用簡單
|
||
* email+password 登入頁(routes/console-auth.ts,自己設一組帳密,無第三方 OAuth)。登入成功後端
|
||
* 發 session token 存 localStorage;**實際打 /kbdb/*、/workflows/search 仍用固定租戶字串**
|
||
* (後端回應帶的 tenant,來自 CONSOLE_TENANT,登入系統只擋外人看頁面,不做多租戶)。
|
||
* 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>① 登入</h2>
|
||
<div id="auth-loading" class="hint">檢查登入狀態中...</div>
|
||
|
||
<div id="auth-setup-form" style="display:none">
|
||
<div class="hint">首次使用,設定你自己的登入帳密(只存在這個 worker 的 KV,非第三方 OAuth)。</div>
|
||
<div class="row" style="margin-top:8px">
|
||
<input type="text" id="setup-email" placeholder="email">
|
||
</div>
|
||
<div class="row" style="margin-top:8px">
|
||
<input type="password" id="setup-password" placeholder="密碼(至少 8 碼)">
|
||
<button id="setup-submit">設定並登入</button>
|
||
</div>
|
||
<div id="setup-status" class="hint"></div>
|
||
</div>
|
||
|
||
<div id="auth-login-form" style="display:none">
|
||
<div class="row">
|
||
<input type="text" id="login-email" placeholder="email">
|
||
</div>
|
||
<div class="row" style="margin-top:8px">
|
||
<input type="password" id="login-password" placeholder="密碼">
|
||
<button id="login-submit">登入</button>
|
||
</div>
|
||
<div id="login-status" class="hint"></div>
|
||
</div>
|
||
|
||
<div id="auth-authed" style="display:none">
|
||
<div class="hint">已登入 <span id="authed-tenant"></span> <button class="secondary" id="logout-btn" style="margin-left:8px">登出</button></div>
|
||
</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);
|
||
|
||
// ── 登入狀態(Arcrun#3 發現②:session token 只擋頁面,實際查詢用後端回的固定租戶字串)──
|
||
let currentTenant = ''; // 登入成功後才有值;查詢函式用它當 X-Arcrun-API-Key
|
||
function getSessionToken() { return localStorage.getItem('arcrun_console_session') || ''; }
|
||
function setSessionToken(v) { localStorage.setItem('arcrun_console_session', v); }
|
||
function clearSessionToken() { localStorage.removeItem('arcrun_console_session'); }
|
||
function getKey() { return currentTenant; } // 給 searchKb/searchWorkflows 沿用既有介面
|
||
|
||
function showAuthPanel(which) {
|
||
['auth-loading', 'auth-setup-form', 'auth-login-form', 'auth-authed'].forEach((id) => {
|
||
$(id).style.display = id === which ? '' : 'none';
|
||
});
|
||
}
|
||
|
||
async function checkAuthStatus() {
|
||
const token = getSessionToken();
|
||
if (token) {
|
||
try {
|
||
const res = await fetch('/console/session', { headers: { Authorization: 'Bearer ' + token } });
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
currentTenant = data.tenant || '';
|
||
$('authed-tenant').textContent = '(租戶:' + currentTenant + ')';
|
||
showAuthPanel('auth-authed');
|
||
return;
|
||
}
|
||
} catch (e) { /* fall through to login */ }
|
||
clearSessionToken();
|
||
}
|
||
try {
|
||
const res = await fetch('/console/auth-status');
|
||
const data = await res.json();
|
||
showAuthPanel(data.configured ? 'auth-login-form' : 'auth-setup-form');
|
||
} catch (e) {
|
||
showAuthPanel('auth-login-form');
|
||
}
|
||
}
|
||
|
||
$('setup-submit').addEventListener('click', async () => {
|
||
const email = $('setup-email').value.trim();
|
||
const password = $('setup-password').value;
|
||
$('setup-status').textContent = '設定中...';
|
||
try {
|
||
const res = await fetch('/console/setup', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ email, password }),
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok) { $('setup-status').textContent = data.error || '設定失敗'; return; }
|
||
setSessionToken(data.session_token);
|
||
currentTenant = data.tenant || '';
|
||
$('authed-tenant').textContent = '(租戶:' + currentTenant + ')';
|
||
showAuthPanel('auth-authed');
|
||
} catch (e) {
|
||
$('setup-status').textContent = '請求失敗:' + e.message;
|
||
}
|
||
});
|
||
|
||
$('login-submit').addEventListener('click', async () => {
|
||
const email = $('login-email').value.trim();
|
||
const password = $('login-password').value;
|
||
$('login-status').textContent = '登入中...';
|
||
try {
|
||
const res = await fetch('/console/login', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ email, password }),
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok) { $('login-status').textContent = data.error || '登入失敗'; return; }
|
||
setSessionToken(data.session_token);
|
||
currentTenant = data.tenant || '';
|
||
$('authed-tenant').textContent = '(租戶:' + currentTenant + ')';
|
||
showAuthPanel('auth-authed');
|
||
} catch (e) {
|
||
$('login-status').textContent = '請求失敗:' + e.message;
|
||
}
|
||
});
|
||
|
||
$('logout-btn').addEventListener('click', async () => {
|
||
const token = getSessionToken();
|
||
if (token) {
|
||
try { await fetch('/console/logout', { method: 'POST', headers: { Authorization: 'Bearer ' + token } }); } catch (e) {}
|
||
}
|
||
clearSessionToken();
|
||
currentTenant = '';
|
||
checkAuthStatus();
|
||
});
|
||
|
||
checkAuthStatus();
|
||
|
||
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 = '請先登入(上方 ①)'; 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 = '請先登入(上方 ①)'; 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));
|
||
});
|