From 5cd790c94aaaca2c22e9eed48d4d0f13b59ead79 Mon Sep 17 00:00:00 2001 From: richblack Date: Wed, 8 Jul 2026 22:44:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(console):=20=E6=94=B6=E4=BB=B6=E5=A4=BE?= =?UTF-8?q?=E6=94=B9=E8=A3=9D=EF=BC=9D=E5=85=A8=E9=83=A8=E5=BE=85=E8=BE=A6?= =?UTF-8?q?=E5=88=86=E6=B5=81=E5=8F=B0=EF=BC=88Arcrun#9=EF=BC=8Ckb-ingest?= =?UTF-8?q?=20SDD=20R7=20=E6=B6=88=E8=B2=BB=E7=AB=AF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit console 頁 7 從 Telegram 訊息清單改裝成三欄分流台: - 資料源二合一:entry_type=todo(Logseq 萃取,Arcrun#8 ingest 線)+ entry_type=inbox (Telegram),/console/inbox-data → /console/triage-data(唯一消費者是本頁,一起改 裝不留死端點;仍鎖 console session——待辦原文屬機敏) - 三欄 80/15/5:ai=AI 會幫我搞定/collab=協作/leo=非我不可;owner_tier 缺席或看 不懂(triage engine 還沒判)→ 歸 collab 欄+「未分流」徽章,不冒領 ai、不誤判 leo - 各項顯示 text/project 徽章/source/marker;status:done 預設隱藏可切換 - per-project chips 過濾(全部/各 project/未分項),前端同包資料過濾零額外請求 - 手機直排沿 console 單檔薄殼;讀 KBDB 走既有 kbdbBase HTTP 慣例,不新增 binding 契約解析/分欄/計數=純函式 lib/console-triage-model.ts(比照 console-dashboard-model 模式,route 只做 IO),tests/console-triage-model.test.ts 11 測全綠(紅→綠驗證:抽掉 實作模組同測試即紅)。全套 vitest 98 passed,唯一失敗為 pre-existing executor 「不存在的零件」訊息斷言(main 同紅,與本 PR 無關)。 [總管-subagent] Co-Authored-By: Claude Fable 5 --- .../src/lib/console-triage-model.ts | 132 +++++++++++++++++ cypher-executor/src/routes/console-auth.ts | 2 +- .../src/routes/console-dashboard.ts | 33 ++--- cypher-executor/src/routes/console.ts | 140 ++++++++++++------ .../tests/console-triage-model.test.ts | 134 +++++++++++++++++ 5 files changed, 377 insertions(+), 64 deletions(-) create mode 100644 cypher-executor/src/lib/console-triage-model.ts create mode 100644 cypher-executor/tests/console-triage-model.test.ts diff --git a/cypher-executor/src/lib/console-triage-model.ts b/cypher-executor/src/lib/console-triage-model.ts new file mode 100644 index 0000000..c70f890 --- /dev/null +++ b/cypher-executor/src/lib/console-triage-model.ts @@ -0,0 +1,132 @@ +/** + * console「分流台」純函式層(Arcrun#9 收件夾改裝=全部待辦分流台,2026-07-08 總管派工; + * 頂層 SDD kb-ingest-architecture R7 消費端) + * + * 為什麼獨立成 lib(比照 console-dashboard-model.ts):route 只做 IO(fetch KBDB entries), + * 「兩種 entry 契約 → 統一分流項」的解析/分欄/分項判定全在這裡,vitest 直接餵資料驗證。 + * + * 資料來源二合一(R7): + * - entry_type=todo:Logseq 萃取灌入(Arcrun#8 ingest 線),content JSON 契約(已定)= + * {"text","marker","owner_tier":"ai|collab|leo","project","source","status":"new|done"} + * - entry_type=inbox:Telegram 指令(inbox_receive),沿用 {"text","from","status"} + * + * 三欄分流(80/15/5,北極星「減 leo 負」): + * ai = AI 會幫我搞定(80%) + * collab = AI 要我做完它接手(15%) + * leo = 非 leo 不可(5%) + * 無 owner_tier / 看不懂的值(triage engine 還沒判到)→ 歸 collab 欄但標 unclassified, + * UI 顯「未分流」徽章——不冒領「AI 會搞定」、也不誤判成「非 leo 不可」,且欄位維持三欄 + * 不另開第四欄(80/15/5 的故事不破)。issue 原文的 mira80/collab15/leo5 詞彙當同義吃進來 + * (ingest 端若照 issue 舊詞灌,UI 不至於整欄蒸發)。 + */ + +import { type KbdbEntry, parseCreatedAtMs, parseJsonContent } from './console-dashboard-model'; + +export type TriageTier = 'ai' | 'collab' | 'leo'; + +/** 契約值 → 三欄。issue 原文舊詞(mira80/collab15/leo5)當同義收;其餘=未分流。 */ +const TIER_ALIASES: Record = { + ai: 'ai', + collab: 'collab', + leo: 'leo', + mira80: 'ai', + collab15: 'collab', + leo5: 'leo', +}; + +export interface TriageItem { + id: string; + text: string; + tier: TriageTier; + /** true=owner_tier 缺席/看不懂(triage engine 未判),暫歸 collab 欄+UI 標「未分流」 */ + unclassified: boolean; + /** '' = 未分項(UI 過濾 chips 用「未分項」承接) */ + project: string; + /** todo 來源=content.source(如 notes);inbox 一律 telegram(可帶 from) */ + source: string; + /** Logseq marker(TODO/DOING/LATER/NOW…);inbox 無='' */ + marker: string; + status: 'new' | 'done'; + origin: 'todo' | 'inbox'; + at: string | number; + at_ms: number | null; +} + +export interface TriageModel { + /** 全部項(created_at 新→舊);分欄/過濾在 UI 端做(資料一次給齊,chips 切換零請求) */ + items: TriageItem[]; + /** 出現過的 project(去重、字典序;不含 ''——「未分項」由 UI 依 items 判斷要不要出 chip) */ + projects: string[]; + /** 各欄未完成數(status=new)+整體計數,供欄位標頭/收件計數用 */ + counts: { ai: number; collab: number; leo: number; unclassified: number; done: number; total: number }; +} + +function normalizeStatus(v: unknown): 'new' | 'done' { + return v === 'done' ? 'done' : 'new'; +} + +function normalizeTier(v: unknown): { tier: TriageTier; unclassified: boolean } { + const mapped = typeof v === 'string' ? TIER_ALIASES[v.trim()] : undefined; + return mapped ? { tier: mapped, unclassified: false } : { tier: 'collab', unclassified: true }; +} + +function str(v: unknown): string { + return typeof v === 'string' ? v.trim() : ''; +} + +/** todo entry → 分流項。content 非 JSON(不合契約的殘資料)→ 原文當 text 誠實顯示,不丟棄。 */ +export function todoToTriageItem(e: KbdbEntry): TriageItem { + const j = parseJsonContent(e); + const { tier, unclassified } = normalizeTier(j?.owner_tier); + return { + id: e.id, + text: str(j?.text) || (e.content ?? ''), + tier, + unclassified, + project: str(j?.project), + source: str(j?.source), + marker: str(j?.marker), + status: normalizeStatus(j?.status), + origin: 'todo', + at: e.created_at, + at_ms: parseCreatedAtMs(e.created_at), + }; +} + +/** inbox entry → 分流項(沿用 {"text","from","status"} 契約)。無 tier =未分流歸 collab。 */ +export function inboxToTriageItem(e: KbdbEntry): TriageItem { + const j = parseJsonContent(e); + const from = str(j?.from); + return { + id: e.id, + text: str(j?.text) || (e.content ?? ''), + tier: 'collab', + unclassified: true, + project: '', + source: from ? `telegram・${from}` : 'telegram', + marker: '', + status: normalizeStatus(j?.status), + origin: 'inbox', + at: e.created_at, + at_ms: parseCreatedAtMs(e.created_at), + }; +} + +/** 二源合一 → 分流台模型。排序=created_at 新→舊(時間不明沉底);分欄計數只算未完成。 */ +export function buildTriageModel(todoEntries: KbdbEntry[], inboxEntries: KbdbEntry[]): TriageModel { + const items = [ + ...todoEntries.map(todoToTriageItem), + ...inboxEntries.map(inboxToTriageItem), + ].sort((a, b) => (b.at_ms ?? -1) - (a.at_ms ?? -1)); + const projects = [...new Set(items.map((i) => i.project).filter(Boolean))].sort(); + const counts = { ai: 0, collab: 0, leo: 0, unclassified: 0, done: 0, total: items.length }; + for (const it of items) { + if (it.status === 'done') { + counts.done++; + continue; + } + counts[it.tier]++; + if (it.unclassified) counts.unclassified++; + } + return { items, projects, counts }; +} diff --git a/cypher-executor/src/routes/console-auth.ts b/cypher-executor/src/routes/console-auth.ts index d5215ee..14ff625 100644 --- a/cypher-executor/src/routes/console-auth.ts +++ b/cypher-executor/src/routes/console-auth.ts @@ -30,7 +30,7 @@ const SESSION_PREFIX = 'console_sess:'; const SESSION_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 天 /** - * 驗證 console session(給其他 route 共用,如 /console/inbox-data)。 + * 驗證 console session(給其他 route 共用,如 /console/triage-data)。 * authHeader 形如 "Bearer ";有效回 true。 */ export async function validateConsoleSession(env: Bindings, authHeader: string | undefined): Promise { diff --git a/cypher-executor/src/routes/console-dashboard.ts b/cypher-executor/src/routes/console-dashboard.ts index ada14d9..79c29b4 100644 --- a/cypher-executor/src/routes/console-dashboard.ts +++ b/cypher-executor/src/routes/console-dashboard.ts @@ -68,6 +68,7 @@ import { sortWaitingItems, taipeiDayKey, } from '../lib/console-dashboard-model'; +import { buildTriageModel } from '../lib/console-triage-model'; import { TAIPEI_CLIENT_JS } from '../lib/taipei-time'; export const consoleDashboardRouter = new Hono<{ Bindings: Bindings }>(); @@ -454,30 +455,22 @@ consoleDashboardRouter.get('/console/settings-data', (c) => { }); }); -// GET /console/inbox-data — 收件匣清單(Mira Console 頁 7)。**需 console session**(Bearer): -// dashboard-data 只吐計數可免登入;這裡吐訊息原文(leo 丟給 Telegram bot 的指令內容)屬機敏,鎖登入。 -// 資料源同 dashboard-data 的 inbox entries(entry_type=inbox,content JSON {"text","status","result"?})。 -consoleDashboardRouter.get('/console/inbox-data', async (c) => { +// GET /console/triage-data — 分流台資料(Mira Console 頁 7,Arcrun#9 收件夾改裝;原 +// /console/inbox-data 的後繼——唯一消費者是 console 頁本身,一起改裝,不留死端點)。 +// **需 console session**(Bearer):dashboard-data 只吐計數可免登入;這裡吐待辦/訊息原文屬機敏,鎖登入。 +// 資料源二合一(kb-ingest SDD R7):entry_type=todo(Logseq 萃取,Arcrun#8 ingest 線)+ +// entry_type=inbox(Telegram)。契約解析/三欄分流/計數=純函式 lib/console-triage-model.ts。 +consoleDashboardRouter.get('/console/triage-data', async (c) => { const ok = await validateConsoleSession(c.env, c.req.header('authorization')); if (!ok) return c.json({ error: '需要登入(console session)' }, 401); const tenant = c.env.CONSOLE_TENANT || 'leo'; - const entries = await fetchEntries(c.env, tenant, 'inbox', 200); - const items = entries.map((e) => { - const j = parseJsonContent(e); - return { - id: e.id, - text: typeof j?.text === 'string' ? (j.text as string) : (e.content ?? ''), - status: j && j.status === 'done' ? 'done' : 'new', - result: typeof j?.result === 'string' ? (j.result as string) : '', - at: e.created_at, - }; - }); - return c.json({ - items, - new_count: items.filter((i) => i.status !== 'done').length, - total: items.length, - }); + const [todoEntries, inboxEntries] = await Promise.all([ + fetchEntries(c.env, tenant, 'todo', 500), + fetchEntries(c.env, tenant, 'inbox', 200), + ]); + const model = buildTriageModel(todoEntries, inboxEntries); + return c.json({ ...model, generated_at: new Date().toISOString() }); }); function renderDashboardHtml(): string { diff --git a/cypher-executor/src/routes/console.ts b/cypher-executor/src/routes/console.ts index 0992a20..257cdc0 100644 --- a/cypher-executor/src/routes/console.ts +++ b/cypher-executor/src/routes/console.ts @@ -7,7 +7,8 @@ * 手機優先 390px + ≥1024px 側欄、頁面本體不出水平捲軸。 * * 資訊架構:brief 8 頁(docs/1-vision/mira-console-design-brief.md)= - * 1 登入/首次設定 2 駕駛艙 3 總庫搜尋 4 卡片詳頁+關聯 5 工作流 6 憑證管理 7 收件匣 8 設定。 + * 1 登入/首次設定 2 駕駛艙 3 總庫搜尋 4 卡片詳頁+關聯 5 工作流 6 憑證管理 + * 7 分流台(原收件匣,Arcrun#9 改裝) 8 設定。 * 單檔 HTML + 原生 JS hash routing(#/cockpit …),零外部資源、零 build step。 * * 薄殼(rule 07):零業務邏輯,只 fetch 既有 API 渲染。各頁資料源(誠實原則,無源的顯示 @@ -23,7 +24,10 @@ * + GET /workflows/:name/executions(最近執行);釘選存 localStorage * - 憑證:GET /credentials/catalog(D1 目錄唯讀,絕不含密文)+ POST /credentials(新增) * + PUT /credentials/:name(整筆替換);刪除後端未接 D1(T9 範圍)→ 標「即將開通」 - * - 收件匣:GET /console/inbox-data(需 session——訊息原文屬機敏,計數才免登入) + * - 分流台:GET /console/triage-data(需 session——待辦/訊息原文屬機敏,計數才免登入)。 + * 資料源二合一(kb-ingest SDD R7 / Arcrun#9):entry_type=todo(Logseq 萃取)+ + * entry_type=inbox(Telegram);三欄 80/15/5(ai/collab/leo)+ per-project chips + * +已完成預設隱藏可切換。分欄判定在 lib/console-triage-model.ts(純函式),頁面只渲染。 * - 設定:vectorize 狀態探測(讀 /kbdb/search 回應的 mode/capability_hint,無新端點; * 開關本體需部署端 config kbdb_embed + acr update → 誠實文案講清楚、不假裝能遠端開) * + MCP token TTL 誠實佔位(/console/settings-data;可調功能=Arcrun#19) @@ -231,7 +235,7 @@ function renderConsoleHtml(registryBase: string): string { .gcenter span { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 13.5px; font-weight: 600; line-height: 1.35; color: #241804; word-break: break-word; } .nbrow { display: flex; align-items: center; gap: 11px; padding: 12px 14px; border-radius: 10px; cursor: pointer; background: rgba(var(--ink-rgb),.045); border: 1px solid rgba(var(--ink-rgb),.1); font-size: 15.5px; } - /* ── 工作流 / 憑證 / 收件匣 清單 ── */ + /* ── 工作流 / 憑證 / 分流台 清單 ── */ .listcol { display: flex; flex-direction: column; gap: 10px; padding: 16px 0 8px; } .rowhead { display: flex; align-items: center; gap: 12px; padding: 16px 18px; cursor: pointer; } .rowbody { padding: 0 18px 18px; border-top: 1px solid rgba(var(--ink-rgb),.1); } @@ -241,10 +245,23 @@ function renderConsoleHtml(registryBase: string): string { .urlbox .u { font-family: ui-monospace, Menlo, monospace; font-size: 13.5px; color: var(--amber); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } .avatar { width: 38px; height: 38px; border-radius: 10px; background: rgba(var(--amber-rgb),.1); border: 1px solid rgba(var(--amber-rgb),.25); display: grid; place-items: center; font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 17px; color: var(--amber); flex: none; } .maskline { display: flex; align-items: center; gap: 10px; padding: 14px; border-radius: 10px; background: var(--well); border: 1px solid rgba(var(--ink-rgb),.1); } - .inbox-item { padding: 16px 18px; border-radius: 12px; } - .inbox-item.new { background: rgba(var(--amber-rgb),.07); border: 1px solid rgba(var(--amber-rgb),.28); } - .inbox-item.done { background: rgba(var(--ink-rgb),.04); border: 1px solid rgba(var(--ink-rgb),.1); } - .pulse-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--amber); flex: none; animation: breatheAmber 2.6s ease-in-out infinite; } + /* 分流台(Arcrun#9):三欄 80/15/5 手機直排;欄色點 ai=綠 collab=琥珀 leo=紅 */ + .tri-sec { margin-top: 24px; } + .tri-sechead { display: flex; align-items: baseline; gap: 9px; } + .tri-sechead .dot { flex: none; width: 9px; height: 9px; border-radius: 50%; align-self: center; } + .tri-sec.ai .dot { background: var(--ok); } .tri-sec.collab .dot { background: var(--amber); } .tri-sec.leo .dot { background: var(--err); } + .tri-sechead .nm { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 17px; letter-spacing: .14em; } + .tri-sechead .pc { font-size: 12.5px; color: rgba(var(--ink-rgb),.4); } + .tri-sechead .ct { margin-left: auto; font-family: ui-monospace, Menlo, monospace; font-size: 14px; color: rgba(var(--ink-rgb),.55); } + .tri-item { padding: 14px 16px; border-radius: 12px; margin-top: 9px; } + .tri-item.new { background: rgba(var(--amber-rgb),.07); border: 1px solid rgba(var(--amber-rgb),.28); } + .tri-sec.leo .tri-item.new { background: rgba(var(--err-rgb),.06); border-color: rgba(var(--err-rgb),.3); } + .tri-item.done { background: rgba(var(--ink-rgb),.04); border: 1px solid rgba(var(--ink-rgb),.1); opacity: .7; } + .tri-item.done .tx { text-decoration: line-through; color: rgba(var(--ink-rgb),.6); } + .tri-item .tx { font-size: 16.5px; line-height: 1.6; word-break: break-word; } + .tri-item .mt { margin-top: 9px; display: flex; gap: 7px; align-items: center; flex-wrap: wrap; font-size: 12.5px; color: rgba(var(--ink-rgb),.45); } + .tag.red { background: rgba(var(--err-rgb),.12); color: var(--err); } + .tag.dim { background: rgba(var(--ink-rgb),.08); color: rgba(var(--ink-rgb),.55); } /* ── 設定 ── */ .setcol { display: flex; flex-direction: column; gap: 14px; padding: 20px 0 40px; } @@ -303,7 +320,7 @@ function renderConsoleHtml(registryBase: string): string { - +
@@ -417,10 +434,11 @@ function renderConsoleHtml(registryBase: string): string {
載入中…
- +
-
收件匣Telegram 指令
-
載入中…
+
分流台全部待辦・80/15/5
+
+
載入中…
@@ -489,7 +507,7 @@ function renderConsoleHtml(registryBase: string): string {
駕駛艙
搜尋
工作流
-
收件匣
+
分流台
設定
@@ -631,7 +649,7 @@ function renderConsoleHtml(registryBase: string): string { }); window.scrollTo(0, 0); } - var LOADERS = { cockpit: loadCockpit, search: initSearchOnce, card: loadCard, workflows: loadWorkflows, creds: loadCreds, inbox: loadInbox, settings: loadSettings }; + var LOADERS = { cockpit: loadCockpit, search: initSearchOnce, card: loadCard, workflows: loadWorkflows, creds: loadCreds, inbox: loadTriage, settings: loadSettings }; function route() { var r = currentRoute(); if (r.view === 'card' && r.arg) S.cardId = decodeURIComponent(r.arg); @@ -1239,42 +1257,78 @@ function renderConsoleHtml(registryBase: string): string { } }); - // ── 收件匣 ── - function loadInbox() { - var out = $('ib-out'); + // ── 分流台(原收件匣,Arcrun#9:todo+inbox 二源、三欄 80/15/5、per-project chips)── + // 頁面只渲染 /console/triage-data 的結果(分欄/計數判定在後端純函式);chips 切換與 + // 「顯示已完成」是同一包資料的前端過濾,零額外請求(leo 手機看,省來回)。 + var TG = { data: null, project: '', showDone: false }; + var TG_TIERS = [ + { key: 'ai', nm: 'AI 會幫我搞定', pc: '80%', empty: '這欄空的——還沒有分給 AI 的待辦' }, + { key: 'collab', nm: '協作・AI 做完你接手', pc: '15%', empty: '這欄空的——沒有要你配合的事' }, + { key: 'leo', nm: '非我不可', pc: '5%', empty: '這欄空的——沒有非你不可的事' } + ]; + function triItem(m) { + var meta = ''; + if (m.marker) meta += '' + esc(m.marker) + ''; + meta += '' + esc(m.project || '未分項') + ''; + if (m.source) meta += '' + esc(m.source) + ''; + if (m.unclassified && m.origin === 'todo') meta += '未分流'; + meta += '' + esc(fmtDateTime(m.at)) + ''; + return '
' + esc(m.text) + '
' + meta + '
'; + } + function triChip(label, val, on) { + return ''; + } + function renderTriage() { + var d = TG.data || {}; + var items = d.items || []; + var out = $('tg-out'); + // per-project chips:全部+各 project+(有未分項才出)未分項 + var chips = triChip('全部', '', TG.project === ''); + (d.projects || []).forEach(function (p) { chips += triChip(p, p, TG.project === p); }); + if (items.some(function (i) { return !i.project; })) chips += triChip('未分項', '__none__', TG.project === '__none__'); + $('tg-chips').innerHTML = items.length ? chips : ''; + if (!items.length) { + out.innerHTML = '
分流台是空的
資料源=KBDB todo entries(Logseq 萃取,ingest 灌入中)+ inbox entries(Telegram)。
寫入方尚未灌資料時這裡誠實顯示空。
'; + return; + } + var vis = items.filter(function (i) { + return TG.project === '' || (TG.project === '__none__' ? !i.project : i.project === TG.project); + }); + var doneN = vis.filter(function (i) { return i.status === 'done'; }).length; + var html = ''; + TG_TIERS.forEach(function (t) { + var news = vis.filter(function (i) { return i.tier === t.key && i.status !== 'done'; }); + var dones = TG.showDone ? vis.filter(function (i) { return i.tier === t.key && i.status === 'done'; }) : []; + html += '
' + t.nm + '' + t.pc + '' + news.length + ' 件
'; + if (!news.length && !dones.length) html += '
' + t.empty + '
'; + html += news.map(triItem).join('') + dones.map(triItem).join(''); + html += '
'; + }); + html += '
'; + out.innerHTML = html; + $('tg-donetoggle').addEventListener('click', function () { TG.showDone = !TG.showDone; renderTriage(); }); + } + $('tg-chips').addEventListener('click', function (ev) { + var t = ev.target.closest('[data-triproj]'); + if (!t) return; + TG.project = t.getAttribute('data-triproj'); + renderTriage(); + }); + function loadTriage() { + var out = $('tg-out'); out.innerHTML = '
載入中…
'; - fetch('/console/inbox-data', { headers: { Authorization: 'Bearer ' + S.token } }) + fetch('/console/triage-data', { headers: { Authorization: 'Bearer ' + S.token } }) .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) .then(function (x) { if (!x.ok) { - out.innerHTML = '
讀不到收件匣
' + esc(x.d.error || ('HTTP ' + x.status)) + '
'; + out.innerHTML = '
讀不到分流台
' + esc(x.d.error || ('HTTP ' + x.status)) + '
'; return; } - var items = x.d.items || []; - if (!items.length) { - out.innerHTML = '
收件匣是空的
從 Telegram 丟指令給機器人,這裡會列出「新的」與「已處理」。
(資料源:KBDB inbox entries——寫入方尚未灌資料時這裡誠實顯示空。)
'; - return; - } - var news = items.filter(function (i) { return i.status !== 'done'; }); - var done = items.filter(function (i) { return i.status === 'done'; }); - var html = ''; - html += '
新的・' + news.length + ' 條
'; - html += news.length - ? '
' + news.map(function (m) { - return '
' + esc(m.text) + '
' + - '
等待 AI 處理' + esc(fmtDateTime(m.at)) + '
'; - }).join('') + '
' - : '
沒有待處理的指令
'; - html += '
已處理・' + done.length + ' 條
'; - html += done.length - ? '
' + done.map(function (m) { - return '
' + esc(m.text) + '
' + - '
' + - '' + esc(m.result || '已處理') + '' + - '' + esc(fmtDateTime(m.at)) + '
'; - }).join('') + '
' - : '
還沒有已處理的紀錄
'; - out.innerHTML = html; + TG.data = x.d; + // 記著的 project 篩選若已不存在(資料變了)→ 退回全部,別讓頁面空得不明不白 + if (TG.project && TG.project !== '__none__' && (x.d.projects || []).indexOf(TG.project) < 0) TG.project = ''; + renderTriage(); }) .catch(function (e) { out.innerHTML = '
請求失敗:' + esc(friendlyErr(e)) + '
'; }); } diff --git a/cypher-executor/tests/console-triage-model.test.ts b/cypher-executor/tests/console-triage-model.test.ts new file mode 100644 index 0000000..6f8eeeb --- /dev/null +++ b/cypher-executor/tests/console-triage-model.test.ts @@ -0,0 +1,134 @@ +/** + * 分流台純函式測試(Arcrun#9 收件夾改裝=全部待辦分流台,2026-07-08) + * + * 驗證兩份 content 契約 → 統一分流項的判定: + * 1. todo 契約 {"text","marker","owner_tier","project","source","status"} 照 render + * 2. inbox 契約 {"text","from","status"} 無 tier → 歸 collab 欄+unclassified 標記 + * 3. 三欄計數只算未完成(status:done 不佔欄)、per-project 清單去重排序 + * 4. 防禦:owner_tier 看不懂/content 非 JSON 不得讓項目蒸發或冒領 ai 欄 + * + * 樣本形態對照 leo21c live KBDB(epoch 秒 created_at)與 kb-ingest SDD R5/R7 的 + * Logseq marker 詞彙(TODO/DOING/LATER/NOW),非憑空編造。 + */ +import { describe, it, expect } from 'vitest'; +import { todoToTriageItem, inboxToTriageItem, buildTriageModel } from '../src/lib/console-triage-model'; +import type { KbdbEntry } from '../src/lib/console-dashboard-model'; + +const SEC = (iso: string) => Math.floor(Date.parse(iso) / 1000); + +function entry(created: string, content: unknown, type = 'todo'): KbdbEntry { + return { + id: crypto.randomUUID(), + entry_type: type, + content: typeof content === 'string' ? content : JSON.stringify(content), + created_at: SEC(created), + }; +} + +describe('todoToTriageItem — Logseq todo 契約', () => { + it('契約全欄照收(text/marker/owner_tier/project/source/status)', () => { + const it_ = todoToTriageItem( + entry('2026-07-08T02:00:00Z', { + text: '把 KBDB 憑證輪替', + marker: 'TODO', + owner_tier: 'ai', + project: 'Arcrun', + source: 'notes', + status: 'new', + }), + ); + expect(it_.text).toBe('把 KBDB 憑證輪替'); + expect(it_.marker).toBe('TODO'); + expect(it_.tier).toBe('ai'); + expect(it_.unclassified).toBe(false); + expect(it_.project).toBe('Arcrun'); + expect(it_.source).toBe('notes'); + expect(it_.status).toBe('new'); + expect(it_.origin).toBe('todo'); + expect(it_.at_ms).toBe(Date.parse('2026-07-08T02:00:00Z')); + }); + it('三欄值 ai/collab/leo 都認;issue 舊詞 mira80/collab15/leo5 當同義收', () => { + const tier = (v: unknown) => todoToTriageItem(entry('2026-07-08T02:00:00Z', { text: 'x', owner_tier: v })); + expect(tier('collab').tier).toBe('collab'); + expect(tier('leo').tier).toBe('leo'); + expect(tier('leo').unclassified).toBe(false); + expect(tier('mira80').tier).toBe('ai'); + expect(tier('collab15').tier).toBe('collab'); + expect(tier('leo5').tier).toBe('leo'); + }); + it('owner_tier 缺席/看不懂 → 歸 collab 欄+unclassified,不冒領 ai、不蒸發', () => { + const noTier = todoToTriageItem(entry('2026-07-08T02:00:00Z', { text: '還沒 triage 的', marker: 'LATER' })); + expect(noTier.tier).toBe('collab'); + expect(noTier.unclassified).toBe(true); + const weird = todoToTriageItem(entry('2026-07-08T02:00:00Z', { text: 'x', owner_tier: 'boss' })); + expect(weird.tier).toBe('collab'); + expect(weird.unclassified).toBe(true); + }); + it('content 非 JSON(不合契約殘資料)→ 原文當 text 誠實顯示,不丟棄', () => { + const it_ = todoToTriageItem(entry('2026-07-08T02:00:00Z', 'TODO 裸文字殘資料')); + expect(it_.text).toBe('TODO 裸文字殘資料'); + expect(it_.tier).toBe('collab'); + expect(it_.unclassified).toBe(true); + expect(it_.status).toBe('new'); + }); + it('status 只有 done 算完成,其餘(含缺席/怪值)一律 new', () => { + expect(todoToTriageItem(entry('2026-07-08T02:00:00Z', { text: 'x', status: 'done' })).status).toBe('done'); + expect(todoToTriageItem(entry('2026-07-08T02:00:00Z', { text: 'x', status: 'wip' })).status).toBe('new'); + expect(todoToTriageItem(entry('2026-07-08T02:00:00Z', { text: 'x' })).status).toBe('new'); + }); +}); + +describe('inboxToTriageItem — Telegram inbox 契約', () => { + it('{"text","from","status"} 沿用:無 tier 歸 collab+unclassified,source 帶 from', () => { + const it_ = inboxToTriageItem(entry('2026-07-08T03:00:00Z', { text: '幫我查憑證', from: 'leo', status: 'new' }, 'inbox')); + expect(it_.text).toBe('幫我查憑證'); + expect(it_.tier).toBe('collab'); + expect(it_.unclassified).toBe(true); + expect(it_.source).toBe('telegram・leo'); + expect(it_.project).toBe(''); + expect(it_.marker).toBe(''); + expect(it_.origin).toBe('inbox'); + }); + it('無 from → source 仍標 telegram;status done 照收', () => { + const it_ = inboxToTriageItem(entry('2026-07-08T03:00:00Z', { text: 'x', status: 'done' }, 'inbox')); + expect(it_.source).toBe('telegram'); + expect(it_.status).toBe('done'); + }); +}); + +describe('buildTriageModel — 二源合一 / 三欄計數 / per-project', () => { + const todo = [ + entry('2026-07-08T02:00:00Z', { text: 'A', owner_tier: 'ai', project: 'Arcrun', status: 'new' }), + entry('2026-07-08T01:00:00Z', { text: 'B', owner_tier: 'ai', project: 'Arcrun', status: 'done' }), + entry('2026-07-07T23:00:00Z', { text: 'C', owner_tier: 'leo', project: 'mira', status: 'new' }), + entry('2026-07-07T22:00:00Z', { text: 'D', project: '', status: 'new' }), // 未分流+未分項 + ]; + const inbox = [ + entry('2026-07-08T04:00:00Z', { text: 'E', from: 'leo', status: 'new' }, 'inbox'), + entry('2026-07-07T21:00:00Z', { text: 'F', status: 'done' }, 'inbox'), + ]; + + it('合併排序=created_at 新→舊,跨兩源交錯', () => { + const m = buildTriageModel(todo, inbox); + expect(m.items.map((i) => i.text)).toEqual(['E', 'A', 'B', 'C', 'D', 'F']); + expect(m.counts.total).toBe(6); + }); + it('三欄計數只算未完成:done 不佔欄;未分流(todo 無 tier + inbox 全部)歸 collab', () => { + const m = buildTriageModel(todo, inbox); + expect(m.counts.ai).toBe(1); // A(B 是 done) + expect(m.counts.collab).toBe(2); // D(未分流)+ E(inbox) + expect(m.counts.leo).toBe(1); // C + expect(m.counts.unclassified).toBe(2); // D + E + expect(m.counts.done).toBe(2); // B + F + }); + it('projects 去重、字典序、不含空字串(未分項由 UI 承接)', () => { + const m = buildTriageModel(todo, inbox); + expect(m.projects).toEqual(['Arcrun', 'mira']); + }); + it('空庫誠實回空,不編資料', () => { + const m = buildTriageModel([], []); + expect(m.items).toHaveLength(0); + expect(m.projects).toEqual([]); + expect(m.counts).toEqual({ ai: 0, collab: 0, leo: 0, unclassified: 0, done: 0, total: 0 }); + }); +}); -- 2.52.0