Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 36cdc8fba6 | |||
| 0d49989c19 | |||
| c735b911a0 |
@@ -92,7 +92,12 @@
|
||||
.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>
|
||||
<script src="/config.js"></script>
|
||||
<script>window.ARCRUN_API_BASE = (window.ARCRUN_CONFIG && window.ARCRUN_CONFIG.apiBase) || "https://cypher.arcrun.dev";</script>
|
||||
<script>
|
||||
// 2026-08-01(arcrun-rag#10 同族):拔掉寫死中央位址的 fallback。
|
||||
// apiBase 由 worker 動態產生的 /config.js 注入;缺它就讓它明顯壞掉,
|
||||
// **不要靜默把請求(可能含金鑰)送去中央實例**。
|
||||
window.ARCRUN_API_BASE = (window.ARCRUN_CONFIG && window.ARCRUN_CONFIG.apiBase) || "";
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
|
||||
@@ -220,7 +220,12 @@
|
||||
.kvline { display: flex; justify-content: space-between; gap: 12px; font-size: 15px; margin: 5px 0; }
|
||||
</style>
|
||||
<script src="/config.js"></script>
|
||||
<script>window.ARCRUN_API_BASE = (window.ARCRUN_CONFIG && window.ARCRUN_CONFIG.apiBase) || "https://cypher.arcrun.dev";</script>
|
||||
<script>
|
||||
// 2026-08-01(arcrun-rag#10 同族):拔掉寫死中央位址的 fallback。
|
||||
// apiBase 由 worker 動態產生的 /config.js 注入;缺它就讓它明顯壞掉,
|
||||
// **不要靜默把請求(可能含金鑰)送去中央實例**。
|
||||
window.ARCRUN_API_BASE = (window.ARCRUN_CONFIG && window.ARCRUN_CONFIG.apiBase) || "";
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
||||
@@ -164,7 +164,23 @@
|
||||
.kvline { display: flex; justify-content: space-between; gap: 12px; font-size: 15px; margin: 5px 0; }
|
||||
</style>
|
||||
<script src="/config.js"></script>
|
||||
<script>window.ARCRUN_API_BASE = (window.ARCRUN_CONFIG && window.ARCRUN_CONFIG.apiBase) || "https://cypher.arcrun.dev";</script>
|
||||
<script>
|
||||
// apiBase 由安裝器注入的 config.js 提供(實查用戶實例:
|
||||
// window.ARCRUN_CONFIG = { apiBase: "https://arcrun-cypher-executor.<subdomain>.workers.dev" })。
|
||||
// 🔴 2026-08-01 拔掉舊的 `|| "https://cypher.arcrun.dev"` fallback:
|
||||
// 那是「靜默打到別人家」的未爆彈——config.js 一旦沒載入/被擋/改名,
|
||||
// 前端會安靜地把請求(含**用戶金鑰**)送去中央實例,而不是明顯壞掉。
|
||||
// **寧可明顯失敗,不要靜默錯置。**
|
||||
window.ARCRUN_API_BASE = (window.ARCRUN_CONFIG && window.ARCRUN_CONFIG.apiBase) || "";
|
||||
if (!window.ARCRUN_API_BASE) {
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var b = document.createElement('div');
|
||||
b.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:99999;background:#b4462f;color:#fff;padding:12px 16px;font:14px/1.5 system-ui;text-align:center';
|
||||
b.textContent = '設定檔沒載入(config.js),這個頁面連不到你的服務。請重新整理;若持續發生,請重跑一次安裝。';
|
||||
document.body.appendChild(b);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -355,6 +371,15 @@
|
||||
<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>
|
||||
<!-- 已存狀態(leo 2026-08-01 規格):唯讀顯示「已輸入」+「修改」鈕,**不顯示 key 本身**。
|
||||
舊版只把 placeholder 改成藍色小字「已設定」⇒ 讀起來像臨時提示、不像已保存的記錄,
|
||||
這正是它看起來像 UI bug 的原因(arcrun-rag#10 附帶修正)。 -->
|
||||
<div id="st-ai-key-saved" style="display:none;align-items:center;gap:10px">
|
||||
<span style="flex:1;padding:9px 12px;border:1px solid rgba(var(--ink-rgb),.14);border-radius:8px;background:rgba(var(--ink-rgb),.03);font-size:14px">
|
||||
<b>已輸入</b><span style="color:rgba(var(--ink-rgb),.45)">(金鑰已安全保存,不顯示內容)</span>
|
||||
</span>
|
||||
<button type="button" class="btn" id="st-ai-key-edit" style="flex:none">修改</button>
|
||||
</div>
|
||||
<input type="password" id="st-ai-key" class="txt" placeholder="貼上 Gemini API Key" autocomplete="off">
|
||||
</div>
|
||||
<!-- 第二格:Claude 加強版(選填,依 daemon 回報 enable) -->
|
||||
@@ -796,6 +821,34 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
|
||||
// t131 AI 設定(合併 Gemini API Key+Claude 加強版)
|
||||
(function () {
|
||||
/**
|
||||
* 切換金鑰欄的「已存 / 可輸入」兩態(leo 2026-08-01 規格,arcrun-rag#10):
|
||||
* 已存 → 唯讀顯示「已輸入」+「修改」鈕(**不顯示 key 本身**,D36)
|
||||
* 未存 / 按了修改 → 一般輸入框
|
||||
* 為什麼要這樣:舊版只把 placeholder 改成「已設定」=看起來像臨時提示,
|
||||
* 用戶讀不出「這是已保存的記錄」⇒ 以為沒存到、反覆重填。
|
||||
*/
|
||||
function setAiKeySaved(saved) {
|
||||
var box = $('st-ai-key-saved');
|
||||
var input = $('st-ai-key');
|
||||
if (!box || !input) return;
|
||||
if (saved) {
|
||||
box.style.display = 'flex';
|
||||
input.style.display = 'none';
|
||||
input.value = '';
|
||||
} else {
|
||||
box.style.display = 'none';
|
||||
input.style.display = '';
|
||||
}
|
||||
}
|
||||
// 按「修改」→ 變回可輸入框並聚焦(留空送出=不變更,由後端 body 判斷)
|
||||
var editBtn = $('st-ai-key-edit');
|
||||
if (editBtn) editBtn.addEventListener('click', function () {
|
||||
setAiKeySaved(false);
|
||||
var input = $('st-ai-key');
|
||||
if (input) { input.placeholder = '貼上新的 Gemini API Key(留空取消變更)'; input.focus(); }
|
||||
});
|
||||
|
||||
// 進入設定頁時讀取現有設定(GET /portal/admin/ai)
|
||||
function loadAiConfig() {
|
||||
if (!(S.profile && S.profile.role === 'admin')) return;
|
||||
@@ -803,8 +856,8 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
.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 = '已設定(留空=不變更)';
|
||||
// leo 2026-08-01 規格:已存 → 唯讀「已輸入」+「修改」鈕;未存 → 一般輸入框
|
||||
setAiKeySaved(!!d.has_key);
|
||||
var cb = $('st-ai-use-claude');
|
||||
if (cb) {
|
||||
// 有 claude 才能勾;沒有則停用並顯示提示
|
||||
@@ -850,7 +903,9 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
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 = '已設定(留空=不變更)'; }
|
||||
// 存好了才切回「已輸入」唯讀態。只有真的送了新 key 才切;
|
||||
// 單純改 Claude 勾選(k 為空)時維持現狀,不誤報「已輸入」。
|
||||
if (k) setAiKeySaved(true);
|
||||
var claudeOn = x.d && x.d.use_claude_for_extract;
|
||||
m.textContent = claudeOn
|
||||
? '已儲存,萃取改用 Claude Code。小幫手請重連一次生效。'
|
||||
|
||||
@@ -25,6 +25,9 @@ import { kbdbBase } from './kbdb-proxy';
|
||||
import { validateConsoleSession } from './console-auth';
|
||||
import { hashPassword, verifyPassword, randomHex, generatePassword } from '../lib/portal-auth';
|
||||
import { PORTAL_TEMPLATE_SEEDS } from '../lib/portal-seeds';
|
||||
// arcrun-rag#10:/portal/admin/ai 存 Gemini key 走 credentials.ts 的**唯一**寫入路徑,
|
||||
// 不在 portal 這層另造第二套儲存(D36:值進 Workers Secret,D1 只留 ref)。
|
||||
import { storeCredential } from './credentials';
|
||||
|
||||
export const portalRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -807,3 +810,114 @@ portalRouter.patch('/portal/admin/libraries/:id', (c) =>
|
||||
return c.json({ success: true, library: toPublicLibrary(updated) });
|
||||
}),
|
||||
);
|
||||
|
||||
// ── AI 設定(arcrun-rag#10)────────────────────────────────────────────────────
|
||||
//
|
||||
// 🔴 為什麼這段存在(2026-08-01 真因,別再讓它消失):
|
||||
// 前端設定頁**一直**在打 `GET|POST /portal/admin/ai`,但**後端從來沒有這條 route**
|
||||
// ⇒ 用戶填 Gemini key → 404 → **key 從來沒被存進任何地方**,畫面卻像存好了(藍字=假綠)。
|
||||
// leo 實撞成「重裝後 key 不見」,但真相是「從來沒存進去,所以重填也沒用」。
|
||||
// 產物層鐵證:bundle tier2/ui grep 'portal/admin/ai'=1、tier2/cypher=0。
|
||||
//
|
||||
// 設計約束:
|
||||
// - **不另造第二套儲存**:POST 內部轉呼 credentials.ts 既有的 `storeCredential()`
|
||||
// (唯一寫入路徑=Workers Secret 明文 + D1 目錄列 ref)。
|
||||
// - **永不回傳 key 本身**(D36):GET 只回 `has_key` 布林。
|
||||
// - credential 的 `api_key` 欄=租戶 slug(`portalTenant`),與安裝器 seedCredential
|
||||
// 寫 `kbdb_internal_token` 用的 ns 同一個值 ⇒ 兩者落在同一租戶分區,查得到彼此。
|
||||
// - `use_claude_for_extract` 存 KV(單一布林偏好,不值得為它開 KBDB template)。
|
||||
// - `claude_available` 由小幫手(daemon)回報;目前後端無此訊號 ⇒ **誠實回 false**,
|
||||
// 不假裝有(前端已有對應文案:「連上小幫手後才知道…」)。
|
||||
|
||||
const AI_PREF_KEY = (tenantSlug: string) => `portal_ai_pref:${tenantSlug}`;
|
||||
|
||||
/** 讀 AI 偏好(KV)。壞掉/沒有一律回預設,不擋頁面。 */
|
||||
async function readAiPref(env: Bindings, tenantSlug: string): Promise<{ use_claude_for_extract: boolean }> {
|
||||
try {
|
||||
const raw = await env.WEBHOOKS.get(AI_PREF_KEY(tenantSlug));
|
||||
if (!raw) return { use_claude_for_extract: false };
|
||||
const p = JSON.parse(raw) as { use_claude_for_extract?: boolean };
|
||||
return { use_claude_for_extract: !!p.use_claude_for_extract };
|
||||
} catch {
|
||||
return { use_claude_for_extract: false };
|
||||
}
|
||||
}
|
||||
|
||||
// GET /portal/admin/ai — 回 AI 設定現況(role=admin 閘)。**只回 has_key 布林,永不回 key**。
|
||||
portalRouter.get('/portal/admin/ai', (c) =>
|
||||
run(c, async () => {
|
||||
const auth = await requirePortalAdmin(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
|
||||
const tenantSlug = portalTenant(c.env);
|
||||
let hasKey = false;
|
||||
try {
|
||||
const row = await c.env.CREDENTIALS_DB
|
||||
.prepare('SELECT 1 FROM credentials WHERE api_key = ? AND name = ? LIMIT 1')
|
||||
.bind(tenantSlug, 'gemini_api_key')
|
||||
.first();
|
||||
hasKey = !!row;
|
||||
} catch {
|
||||
// D1 未就緒 ⇒ 當作沒設定(不擋頁面),但也不假裝有
|
||||
hasKey = false;
|
||||
}
|
||||
|
||||
const pref = await readAiPref(c.env, tenantSlug);
|
||||
return c.json({
|
||||
success: true,
|
||||
has_key: hasKey,
|
||||
// 目前後端沒有「這台電腦有沒有 Claude Code」的訊號來源(該由小幫手回報)
|
||||
// ⇒ 誠實回 false,前端會顯示「連上小幫手後才知道…」,不假綠。
|
||||
claude_available: false,
|
||||
use_claude_for_extract: pref.use_claude_for_extract,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// POST /portal/admin/ai — 存 Gemini key 與/或 Claude 偏好(role=admin 閘)。
|
||||
// body: { gemini_api_key?: string, use_claude_for_extract?: boolean }
|
||||
// 兩者皆選填(前端「留空=不變更」);兩者都沒給 → 400,避免看起來成功但什麼都沒做。
|
||||
portalRouter.post('/portal/admin/ai', (c) =>
|
||||
run(c, async () => {
|
||||
const auth = await requirePortalAdmin(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
|
||||
const body = await c.req.json().catch(() => null) as
|
||||
| { gemini_api_key?: unknown; use_claude_for_extract?: unknown }
|
||||
| null;
|
||||
const rawKey = typeof body?.gemini_api_key === 'string' ? body.gemini_api_key.trim() : '';
|
||||
const hasPref = typeof body?.use_claude_for_extract === 'boolean';
|
||||
if (!rawKey && !hasPref) {
|
||||
return c.json({ error: '沒有要變更的項目(金鑰留空且未改設定)' }, 400);
|
||||
}
|
||||
|
||||
const tenantSlug = portalTenant(c.env);
|
||||
|
||||
if (rawKey) {
|
||||
try {
|
||||
// 唯一寫入路徑(credentials.ts):Workers Secret 存值 + D1 存 ref。
|
||||
await storeCredential(c.env, tenantSlug, 'gemini_api_key', rawKey, 'gemini');
|
||||
} catch (e) {
|
||||
// 誠實回報寫入失敗——這正是本 bug 的教訓:不能讓前端以為存好了。
|
||||
return c.json(
|
||||
{ error: `金鑰儲存失敗:${e instanceof Error ? e.message : String(e)}` },
|
||||
502,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasPref) {
|
||||
await c.env.WEBHOOKS.put(
|
||||
AI_PREF_KEY(tenantSlug),
|
||||
JSON.stringify({ use_claude_for_extract: !!body!.use_claude_for_extract }),
|
||||
);
|
||||
}
|
||||
|
||||
const pref = await readAiPref(c.env, tenantSlug);
|
||||
return c.json({
|
||||
success: true,
|
||||
has_key: rawKey ? true : undefined,
|
||||
use_claude_for_extract: pref.use_claude_for_extract,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* GET|POST /portal/admin/ai —— arcrun-rag#10 迴歸守衛
|
||||
*
|
||||
* 🔴 為什麼有這支測試(別刪):
|
||||
* 這條 route **以前根本不存在**,但前端設定頁一直在打它 ⇒ 用戶填 Gemini key → 404
|
||||
* ⇒ **key 從來沒被存進任何地方**,畫面卻像存好了(藍字=假綠)。
|
||||
* leo 實撞成「重裝後 key 不見」,真相是「從來沒存進去,所以重填也沒用」。
|
||||
* 產物層鐵證(修復前):bundle tier2/ui grep 'portal/admin/ai'=1、tier2/cypher=**0**。
|
||||
* ⇒ 這支測試的存在本身就是防線:**route 消失=測試紅**。
|
||||
*
|
||||
* 覆蓋:
|
||||
* 1. 未登入 → 401;非 admin → 403(不是 404=route 真的在)
|
||||
* 2. GET 回 has_key 布林,**永不回傳 key 本身**(D36)
|
||||
* 3. POST 空 body → 400(不假裝成功)
|
||||
* 4. POST 只改 Claude 偏好(不帶 key)→ 成功,且不碰 credential
|
||||
*/
|
||||
import { SELF, env, fetchMock } from 'cloudflare:test';
|
||||
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
|
||||
import { hashPassword } from '../src/lib/portal-auth';
|
||||
|
||||
const KBDB = 'https://kbdb.test';
|
||||
|
||||
let storedHash: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
fetchMock.activate();
|
||||
fetchMock.disableNetConnect();
|
||||
storedHash = await hashPassword('unit-test-pw-1', 10_000);
|
||||
});
|
||||
afterEach(() => fetchMock.assertNoPendingInterceptors());
|
||||
|
||||
function json(method: string, path: string, body?: unknown, headers: Record<string, string> = {}) {
|
||||
return SELF.fetch(`http://localhost${path}`, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function mockGetRecord(recordId: string, values: Record<string, string>) {
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: `/records/${recordId}`, method: 'GET' })
|
||||
.reply(200, { success: true, record: { record_id: recordId, template_id: 'tpl_pu', values } });
|
||||
}
|
||||
|
||||
function adminValues(overrides: Record<string, string> = {}): Record<string, string> {
|
||||
return {
|
||||
email: 'admin@example.com',
|
||||
display_name: '管理員',
|
||||
status: 'active',
|
||||
role: 'admin',
|
||||
password_hash: storedHash,
|
||||
libraries: '["*"]',
|
||||
created_at: '2026-07-14T00:00:00.000Z',
|
||||
updated_at: '2026-07-14T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function seedSession(token: string, recordId: string) {
|
||||
await env.SESSIONS_KV.put(`portal_sess:${token}`, JSON.stringify({ record_id: recordId }));
|
||||
}
|
||||
|
||||
const authHdr = (t: string) => ({ Authorization: `Bearer ${t}` });
|
||||
|
||||
describe('GET /portal/admin/ai — 認證閘(route 存在的證明)', () => {
|
||||
it('未登入 → 401(不是 404 ⇒ route 真的在)', async () => {
|
||||
const res = await json('GET', '/portal/admin/ai');
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.status).not.toBe(404);
|
||||
});
|
||||
|
||||
it('非 admin → 403', async () => {
|
||||
await seedSession('tok-user', 'rec_user');
|
||||
mockGetRecord('rec_user', adminValues({ role: 'user', email: 'u@example.com' }));
|
||||
const res = await json('GET', '/portal/admin/ai', undefined, authHdr('tok-user'));
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /portal/admin/ai — 回應形狀(D36:永不回傳 key)', () => {
|
||||
it('回 has_key 布林+claude 旗標,且回應完全不含金鑰值', async () => {
|
||||
await seedSession('tok-a1', 'rec_admin');
|
||||
mockGetRecord('rec_admin', adminValues());
|
||||
const res = await json('GET', '/portal/admin/ai', undefined, authHdr('tok-a1'));
|
||||
expect(res.status).toBe(200);
|
||||
const raw = await res.text();
|
||||
const d = JSON.parse(raw) as Record<string, unknown>;
|
||||
|
||||
expect(typeof d.has_key).toBe('boolean');
|
||||
expect(typeof d.claude_available).toBe('boolean');
|
||||
expect(typeof d.use_claude_for_extract).toBe('boolean');
|
||||
|
||||
// D36:回應裡不得出現任何疑似金鑰的欄位
|
||||
expect(raw).not.toContain('gemini_api_key_value');
|
||||
expect(d).not.toHaveProperty('key');
|
||||
expect(d).not.toHaveProperty('value');
|
||||
expect(d).not.toHaveProperty('secret_ref');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /portal/admin/ai — 不假裝成功', () => {
|
||||
it('空 body(沒 key 也沒偏好)→ 400,不回 success', async () => {
|
||||
await seedSession('tok-a2', 'rec_admin');
|
||||
mockGetRecord('rec_admin', adminValues());
|
||||
const res = await json('POST', '/portal/admin/ai', {}, authHdr('tok-a2'));
|
||||
expect(res.status).toBe(400);
|
||||
const d = (await res.json()) as Record<string, unknown>;
|
||||
expect(d.success).toBeUndefined();
|
||||
expect(String(d.error)).toContain('沒有要變更');
|
||||
});
|
||||
|
||||
it('只改 Claude 偏好(不帶 key)→ 成功並回存後的值', async () => {
|
||||
await seedSession('tok-a3', 'rec_admin');
|
||||
mockGetRecord('rec_admin', adminValues());
|
||||
const res = await json('POST', '/portal/admin/ai', { use_claude_for_extract: true }, authHdr('tok-a3'));
|
||||
expect(res.status).toBe(200);
|
||||
const d = (await res.json()) as Record<string, unknown>;
|
||||
expect(d.success).toBe(true);
|
||||
expect(d.use_claude_for_extract).toBe(true);
|
||||
// 沒送 key ⇒ 不得回報 has_key(避免誤報「已輸入」)
|
||||
expect(d.has_key).toBeUndefined();
|
||||
});
|
||||
|
||||
// 註:vitest-pool-workers 預設 isolatedStorage=true ⇒ **每個 it 之間 KV 會還原**,
|
||||
// 所以「寫在上一個 it、讀在下一個 it」測不出來(那是測試框架語意,不是程式缺陷)。
|
||||
// 要驗來回一致,必須在**同一個 it** 內完成寫→讀。
|
||||
it('偏好可讀回:同一測試內 POST 寫入 → GET 讀得到同一值', async () => {
|
||||
await seedSession('tok-a4', 'rec_admin');
|
||||
mockGetRecord('rec_admin', adminValues()); // POST 的 requirePortalAdmin 回讀
|
||||
mockGetRecord('rec_admin', adminValues()); // GET 的 requirePortalAdmin 回讀
|
||||
|
||||
const post = await json('POST', '/portal/admin/ai', { use_claude_for_extract: true }, authHdr('tok-a4'));
|
||||
expect(post.status).toBe(200);
|
||||
|
||||
const get = await json('GET', '/portal/admin/ai', undefined, authHdr('tok-a4'));
|
||||
const d = (await get.json()) as Record<string, unknown>;
|
||||
expect(d.use_claude_for_extract).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -46,6 +46,9 @@ export async function handleMcpRequest(
|
||||
"(配 `switch` 的每個 case/`try_catch` 的 try·catch)。",
|
||||
"🔴 **需要判斷時用分支邊,不要寫 code 判斷**——查零件的回應會附 `branch_hint`",
|
||||
"(哪些邊型+可照抄範例),照著接即可。",
|
||||
"🔴 **分支跑完怎麼判斷成功**:看 `verdict`(`arcrun_get_execution_trace` 或",
|
||||
"`GET /workflows/<name>/executions`)。**走 true 路時 false 路的節點不出現=正確行為**,",
|
||||
"不是失敗——別因為「只有一條路有輸出」就以為壞掉而改寫成 code(2026-08-01 實撞)。",
|
||||
"第一個節點固定是 `input`。",
|
||||
].join("\n");
|
||||
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { toolName } from "../brand.js";
|
||||
import { z } from "zod";
|
||||
import { Env } from "../types.js";
|
||||
|
||||
/**
|
||||
* arcrun_publish_component — 提交 TinyGo WASM 零件至 Component Registry
|
||||
*
|
||||
* AI 工作流:
|
||||
* 1. 先呼叫 arcrun_get_component_guide 取得開發指引
|
||||
* 2. 依指引用 TinyGo 撰寫零件(stdin/stdout JSON I/O)
|
||||
* 3. 編譯為 .wasm,base64 編碼後提交
|
||||
* 4. Registry 自動執行沙盒驗收(體積、syscall 掃描、Gherkin 測試)
|
||||
*/
|
||||
export function registerPublishComponent(server: McpServer, env: Env, orgNamespace: string) {
|
||||
server.tool(
|
||||
toolName("publish_component"),
|
||||
"提交 TinyGo WASM 零件至 Component Registry。需提供 component.contract.yaml 內容與編譯後的 .wasm base64。提交前請先呼叫 arcrun_get_component_guide 取得開發規範。",
|
||||
{
|
||||
contract: z.object({
|
||||
canonical_id: z.string().describe("零件功能名稱(小寫底線,如 validate_json)"),
|
||||
display_name: z.string().describe("顯示名稱(可自由命名)"),
|
||||
category: z.enum(["logic", "api", "ui", "style", "anim"]).describe("零件分類"),
|
||||
version: z.string().describe("版本(格式 vN,如 v1)"),
|
||||
wasi_target: z.literal("preview1"),
|
||||
stability: z.enum(["floating", "stable", "pinned"]).default("floating"),
|
||||
runtime_compat: z.array(z.string()).describe("相容 runtime,如 [\"cf-workers\",\"wazero\"]"),
|
||||
constraints: z.object({
|
||||
max_size_kb: z.number().default(2048),
|
||||
max_cold_start_ms: z.number().default(50),
|
||||
no_network_syscall: z.boolean().default(true),
|
||||
io_model: z.literal("stdin_stdout_json"),
|
||||
}),
|
||||
input_schema: z.record(z.unknown()).describe("JSON Schema"),
|
||||
output_schema: z.record(z.unknown()).describe("JSON Schema"),
|
||||
gherkin_tests: z.array(z.object({
|
||||
scenario: z.string(),
|
||||
given: z.string().describe("JSON 字串"),
|
||||
then_contains: z.string().describe("預期輸出包含的字串"),
|
||||
})).min(2).describe("至少一個 happy path 和一個 error path"),
|
||||
description: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
}).describe("component.contract.yaml 內容"),
|
||||
wasm_base64: z.string().describe("編譯後的 .wasm 檔案 base64 編碼"),
|
||||
},
|
||||
async ({ contract, wasm_base64 }) => {
|
||||
try {
|
||||
if (!env.COMPONENT_REGISTRY) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: COMPONENT_REGISTRY service binding is not configured." }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await env.COMPONENT_REGISTRY.fetch("http://component-registry/components", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ contract, wasm_base64 }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
return {
|
||||
content: [{ type: "text", text: `Publish failed: ${errorText}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json() as Record<string, unknown>;
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `零件 ${contract.canonical_id} v${contract.version} 提交成功:\n${JSON.stringify(result, null, 2)}`,
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Internal Error: ${error instanceof Error ? error.message : String(error)}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { Env } from "../types.js";
|
||||
import { registerSearchComponents } from "./arcrun_search_components.js";
|
||||
import { registerPublishComponent } from "./arcrun_publish_component.js";
|
||||
import { registerSearchWorkflows } from "./arcrun_search_workflows.js";
|
||||
import { registerListComponents } from "./arcrun_list_components.js";
|
||||
import { registerGetComponent } from "./arcrun_get_component.js";
|
||||
@@ -25,10 +24,10 @@ import { registerWhoami } from "./arcrun_whoami.js";
|
||||
export function registerAllTools(server: McpServer, env: Env, orgNamespace: string, partnerToken: string) {
|
||||
registerSearchComponents(server, env, orgNamespace);
|
||||
// 🔴 2026-07-21 leo 拍板停用:零件走 PR、專業等級;recipe/workflow/app 誰都可以做。
|
||||
// 這兩個工具對一般使用者是「誤導危機」——搜不到東西時把人推向「去造零件」,
|
||||
// 那是最難、最該擋的那條路(總管實測:問 foreach 怎麼用,回傳 TinyGo 寫 WASM 教學)。
|
||||
// 想貢獻零件 → 見 Arcrun repo 的 CONTRIBUTING-components.md。
|
||||
// registerPublishComponent(server, env, orgNamespace);
|
||||
// 零件貢獻**只有一條路=PR 人審**(leo 2026-08-01:「已經沒有 publish 了,
|
||||
// 零件等級一律走 PR,這條路封了」;Arcrun#23 已關)。實作路徑已 git rm
|
||||
// (arcrun_publish_component.ts),不留死代碼當錯誤環境信號。
|
||||
// 想貢獻零件 → repo `Leo/arcrun-components` fork→PR→人審,見 CONTRIBUTING-components.md。
|
||||
registerSearchWorkflows(server, env, orgNamespace, partnerToken); // workflow-discovery R2
|
||||
registerListComponents(server, env, orgNamespace);
|
||||
registerGetComponent(server, env, orgNamespace);
|
||||
|
||||
@@ -60,6 +60,25 @@ input >> ON_SUCCESS >> <第一步> >> ... → 丟 /cypher/search → 系統回
|
||||
|
||||
⚠️ 仍然**不要寫 `ON_FAILURE`**(沒有這種邊;要處理失敗用 `try_catch` + `ON_BRANCH(catch)`)。
|
||||
|
||||
### 2.2 怎麼確認分支真的走對了(**別看不懂就以為壞掉**)
|
||||
|
||||
分支工作流「有沒有成功」看兩件事,**不是看某條沒走的路沒有輸出**:
|
||||
|
||||
1. **`verdict`**:`GET /workflows/<name>/executions?limit=1`
|
||||
→ `data.executions[0].verdict === "success"` 就是成功了。
|
||||
2. **`trace` 裡有沒有出現該走的節點**:走 TRUE 路時 FALSE 路的節點**本來就不該出現**
|
||||
——**那是正確行為,不是失敗**。
|
||||
|
||||
```
|
||||
# 條件成立 → 只有 true 那條的節點在 trace
|
||||
{"amount": 5000} → if_control 回 branch="true" → 走 ON_TRUE 那條
|
||||
{"amount": 100} → if_control 回 branch="false" → 走 ON_FALSE 那條
|
||||
```
|
||||
|
||||
🔴 **實撞(2026-08-01 考試)**:有考生的分支工作流**其實完全正常**
|
||||
(`amount=5000`→true、`amount=100`→false 都對),但它以為「跑不通」而放棄改寫成 code。
|
||||
**看到只有一條路有輸出=分支正在正確運作**,不要因此判定失敗。
|
||||
|
||||
## 3. 第一個節點固定是 `input`
|
||||
|
||||
所有真範本都以 `input` 起頭——那是「觸發時帶進來的資料」。
|
||||
@@ -151,7 +170,9 @@ curl -s -X POST https://arcrun-cypher-executor.<subdomain>.workers.dev/cypher/se
|
||||
|
||||
## 7. 常犯的錯
|
||||
|
||||
1. **用不存在的邊**(`ON_FAILURE`/`ON_TRUE`)→ 只有 `ON_SUCCESS` 與 `對每個 X`
|
||||
1. **用不存在的邊**(`ON_FAILURE`)→ 沒有這種邊;要處理失敗用 `try_catch` + `ON_BRANCH(catch)`
|
||||
⚠️ `ON_TRUE`/`ON_FALSE`/`ON_BRANCH` **是存在的**(2026-08-01 起),見 §2.1——
|
||||
本行以前寫「ON_TRUE 不存在」是舊世代,已更正
|
||||
2. **第一個節點不是 `input`**
|
||||
3. **把 recipe 當零件寫**——`telegram_send`/`gmail`/`kbdb_get` 是 **recipe** 不是零件
|
||||
→ 寫成 `http_request` + 該 recipe
|
||||
|
||||
Reference in New Issue
Block a user