Files
Arcrun/cypher-executor/src/routes/console.ts
T
Leo abf232327c feat(console): API Key 介面錯位收斂為簡單 email+password 登入 (Arcrun#3 發現②)
背景: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>
2026-07-03 09:01:04 +00:00

395 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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
*
* 認證(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_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>① 登入</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 &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);
// ── 登入狀態(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) => ({ '&': '&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 = '請先登入(上方 ①)'; 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 — 控制台頁 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));
});