diff --git a/console-ui/public/portal/index.html b/console-ui/public/portal/index.html
index b484d37..d1669f4 100644
--- a/console-ui/public/portal/index.html
+++ b/console-ui/public/portal/index.html
@@ -1306,6 +1306,19 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
if (!adminLibs.length) { $('ad-libs').innerHTML = '
' +
'
' +
'' + esc(l.display_name || l.name) + '' +
@@ -1455,6 +1468,22 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
.catch(function (e) { t.disabled = false; toast(friendlyErr(e)); });
return;
}
+ // t52:把「自動出現的庫」正式登記進目錄(之後就能改顯示名/設圖譜來源)
+ if (act === 'lib-adopt') {
+ var lname = t.getAttribute('data-name') || '';
+ if (!lname) return;
+ t.disabled = true;
+ adminApi('POST', '/portal/admin/libraries', { name: lname, display_name: lname, description: '同步小幫手看守的資料夾' })
+ .then(function (x) {
+ t.disabled = false;
+ if (guard401(x.status)) return;
+ if (!x.ok && x.status !== 409) { toast(x.d.error || ('登記失敗(HTTP ' + x.status + ')')); return; }
+ toast('已登記到目錄');
+ loadAdmin();
+ })
+ .catch(function (e) { t.disabled = false; toast(friendlyErr(e)); });
+ return;
+ }
if (act === 'lib-status' || act === 'lib-graph') {
var lid = t.getAttribute('data-lid');
var body2 = {};
diff --git a/cypher-executor/node_modules b/cypher-executor/node_modules
new file mode 120000
index 0000000..5b81570
--- /dev/null
+++ b/cypher-executor/node_modules
@@ -0,0 +1 @@
+/Users/youlinhsieh/Documents/tech_projects/InkStoneCo/matrix/arcrun/cypher-executor/node_modules
\ No newline at end of file
diff --git a/cypher-executor/src/routes/portal.ts b/cypher-executor/src/routes/portal.ts
index 8c1d872..2a99f49 100644
--- a/cypher-executor/src/routes/portal.ts
+++ b/cypher-executor/src/routes/portal.ts
@@ -699,6 +699,62 @@ function toPublicLibrary(rec: PortalRecord) {
};
}
+// POST /portal/daemon/libraries — body {email, password, libraries:[{name, display_name?}]}。
+// t52(leo 2026-07-26:「用戶可以看到我有 2 個庫,地端雲端都是 2 個,如果只有一個一定被罵」):
+// 小幫手回報它看守的資料夾各自對應的庫,雲端**自動登記**——庫目錄與地端資料夾一比一。
+// 認證=同 /portal/daemon/config(用戶帳密)。已存在的庫略過(冪等),不覆寫顯示名。
+portalRouter.post('/portal/daemon/libraries', (c) =>
+ run(c, async () => {
+ const body = (await c.req.json().catch(() => null)) as
+ | { email?: string; password?: string; libraries?: { name?: string; display_name?: string }[] }
+ | null;
+ const email = String(body?.email ?? '').trim().toLowerCase();
+ const password = String(body?.password ?? '');
+ if (!email || !password) return c.json({ error: 'email 與 password 必填' }, 400);
+ if (await isLocked(c.env, email)) return c.json({ error: '登入失敗次數過多,請稍後再試' }, 429);
+ const recordId = await findUserRecordId(c.env, email);
+ const rec = recordId ? await getRecordById(c.env, recordId) : null;
+ if (!rec || (rec.values.status ?? '') !== 'active'
+ || !(await verifyPassword(password, rec.values.password_hash ?? ''))) {
+ await recordLoginFail(c.env, email);
+ return c.json({ error: 'email 或密碼錯誤' }, 401);
+ }
+ await clearLoginFail(c.env, email);
+
+ const wanted = Array.isArray(body?.libraries) ? body!.libraries! : [];
+ const seeded = await ensurePortalTemplates(c.env);
+ if (seeded.errors.length > 0) {
+ return c.json({ error: `portal templates seed 失敗:${seeded.errors.join('; ')}` }, 502);
+ }
+ const existing = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE);
+ const have = new Set(existing.map((l) => String(l.values.name ?? '')));
+ const ns = portalNamespace(c.env);
+ const created: string[] = [];
+ for (const item of wanted) {
+ const name = String(item?.name ?? '').trim();
+ if (!isValidLibraryName(name) || name === '*' || have.has(name)) continue;
+ const res = await kbdbFetch(c.env, '/records', {
+ method: 'POST',
+ body: JSON.stringify({
+ template: LIBRARY_TEMPLATE,
+ owner_id: ns,
+ values: {
+ name,
+ display_name: String(item?.display_name ?? '').trim() || name,
+ description: '同步小幫手看守的資料夾',
+ status: 'active',
+ },
+ }),
+ });
+ if (!res.ok) throw new KbdbError(`POST /records(portal_library)→ ${res.status}`);
+ have.add(name);
+ created.push(name);
+ }
+ const after = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE);
+ return c.json({ success: true, created, libraries: after.map(toPublicLibrary) });
+ }),
+);
+
// POST /portal/daemon/config — body {email, password}。同步小幫手憑「用戶剛設的帳密」
// 直接換到自己的設定(t54,leo 07-25:「最好的就是把它的帳密直接輸入」)——
// 用戶不必再下載 config.json 丟隱藏資料夾,托盤第一次開啟輸入網址+帳密就上工。
@@ -782,12 +838,32 @@ portalRouter.post('/portal/admin/chat-key', (c) =>
);
// GET /portal/admin/libraries — 庫目錄列表。
+// t52(leo 2026-07-26:「地端 2 個資料夾、雲端就要 2 個庫,只有一個一定被罵」):
+// 除了登記簿裡的庫,**也把資料裡實際蓋過章的庫一併列出**(標 auto:true)——
+// 蓋章即現身,用戶不必先去登記;登記簿只負責顯示名/圖譜來源這些額外設定。
portalRouter.get('/portal/admin/libraries', (c) =>
run(c, async () => {
const auth = await requirePortalAdmin(c);
if (!auth.ok) return auth.res;
const libs = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE);
- return c.json({ success: true, libraries: libs.map(toPublicLibrary), count: libs.length });
+ const out = libs.map(toPublicLibrary);
+ const known = new Set(out.map((l) => l.name));
+ // 資料面實際出現的庫(來自 ingest 蓋章的 metadata.library)
+ try {
+ const res = await kbdbFetch(c.env, `/entries/libraries?owner_id=${encodeURIComponent(portalTenant(c.env))}`);
+ if (res.ok) {
+ const body = (await res.json()) as { libraries?: string[] };
+ for (const name of body.libraries ?? []) {
+ const n = String(name ?? '').trim();
+ if (!n || known.has(n)) continue;
+ known.add(n);
+ out.push({ record_id: '', name: n, display_name: n, description: '資料同步時自動出現(可在此補顯示名)', status: 'active', graph_source: false, auto: true });
+ }
+ }
+ } catch {
+ // 資料面查不到不擋登記簿(誠實降級:至少顯示已登記的庫)
+ }
+ return c.json({ success: true, libraries: out, count: out.length });
}),
);
diff --git a/kbdb/node_modules b/kbdb/node_modules
new file mode 120000
index 0000000..f6f53f2
--- /dev/null
+++ b/kbdb/node_modules
@@ -0,0 +1 @@
+/Users/youlinhsieh/Documents/tech_projects/InkStoneCo/matrix/arcrun/kbdb/node_modules
\ No newline at end of file
diff --git a/kbdb/src/routes/entries.ts b/kbdb/src/routes/entries.ts
index b578c7d..e02f928 100644
--- a/kbdb/src/routes/entries.ts
+++ b/kbdb/src/routes/entries.ts
@@ -31,6 +31,25 @@ entryRoutes.post('/', async (c) => {
return c.json({ success: true, entry });
});
+// GET /entries/libraries?owner_id=... — 這個租戶的資料裡實際出現過哪些庫(distinct)。
+// t52(leo 2026-07-26:地端幾個資料夾=雲端幾個庫):庫由 ingest 蓋章決定,這裡直接從
+// 資料反查,讓「蓋了章的庫」一定看得到,不必依賴任何登記動作。未蓋章的舊資料=general。
+// 註冊在 '/' 之前——Hono 路由先到先比,放後面會被 '/:id' 之類的樣式吃掉。
+entryRoutes.get('/libraries', async (c) => {
+ const owner = c.req.query('owner_id') || '';
+ const rows = await c.env.DB.prepare(
+ `SELECT DISTINCT COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general') AS library
+ FROM entries
+ WHERE (?1 = '' OR owner_id = ?1)
+ AND COALESCE(json_extract(metadata_json, '$.status'), '') != 'deprecated'
+ ORDER BY library`,
+ )
+ .bind(owner)
+ .all<{ library: string }>();
+ const libraries = (rows.results ?? []).map((r) => r.library).filter(Boolean);
+ return c.json({ success: true, libraries, count: libraries.length });
+});
+
// GET /entries — list with filters (entry_type, owner_id, parent_id, page_name, source, q/search)
// e.g. list workflows under a project: ?parent_id=PROJECT&entry_type=workflow
// e.g. get one by idempotency key: ?page_name=skill-rag_with_arcrun