chore: D22 落地——docs/SDD/wiki/CLAUDE.md 進 repo(Gitea private 預設全 push)

頂層 D22 決策(leo 2026-07-03 拍板):推什麼由開發環境歸屬決定,
Gitea private=除機敏值/build 產物/.github 外全 push。
解 T1.5 卡點:雲端工人 clone 拿得到 credential-store-migration.md,可就地改寫 SDD。
機敏掃描兩輪通過(新增 189 檔約 2.1MB,node_modules/dist/wasm 照舊排除)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-07-03 07:13:15 +08:00
parent c830150da1
commit 5d00e71275
190 changed files with 39486 additions and 14 deletions
+199
View File
@@ -0,0 +1,199 @@
'use client';
// Mira 對話核心元件(河道右側 dock 與 /mira/chat 單頁共用)
// SDD: polaris/mira/.agents/specs/mira-app/design.md §3.6.5
// RAG:提問先語義搜尋 KBDBwiki + 河道)取 context → claude-api daemon。
// 重要:context 空時 prompt 明確要求「只說沒有相關筆記」,避免 daemon 自由發揮(曾幻想 OpenWebUI
import { useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import { MarkdownView } from './markdown';
const KBDB_BASE = 'https://kbdb.finally.click';
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://cypher.arcrun.dev';
const CLAUDE_API = 'https://claude-api.arcrun.dev';
type Source = { label: string; href: string };
type Msg = { role: 'user' | 'mira'; text: string; sources?: Source[]; pending?: boolean };
type SearchMatch = {
score: number;
type: 'block' | 'triplet';
block: { id: string; page_name: string | null; content: string | null; type: string } | null;
triplet: { subject?: string; predicate?: string; object?: string } | null;
};
// 繁體異體字正規化(臺→台 等),讓 query 跟 KB 內容(多用「台」)對得上
function normalizeQuery(q: string): string {
return q.replace(/臺/g, '台');
}
async function fetchContext(apiKey: string, query: string): Promise<{ context: string; sources: Source[] }> {
try {
const res = await fetch(`${KBDB_BASE}/search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({ query: normalizeQuery(query), type: 'semantic', topK: 8 }),
});
if (!res.ok) return { context: '', sources: [] };
const data = (await res.json()) as { matches?: SearchMatch[] };
const parts: string[] = [];
const sources: Source[] = [];
for (const m of data.matches ?? []) {
if (m.block?.content) {
const b = m.block;
parts.push(`### ${b.type}${b.page_name ?? b.id}\n${b.content}`);
if (b.type === 'wiki-page' && b.page_name) {
sources.push({ label: `📚 ${(b.content || b.page_name).slice(0, 20)}`, href: `/mira/wiki/${encodeURIComponent(b.page_name)}` });
} else if (b.page_name) {
sources.push({ label: `🌊 ${(b.content || '').slice(0, 20) || b.page_name}`, href: `/mira/feed#page=${encodeURIComponent(b.page_name)}` });
}
} else if (m.triplet) {
const t = m.triplet;
parts.push(`關係:${t.subject} >> ${t.predicate} >> ${t.object}`);
}
}
const seen = new Set<string>();
const uniq = sources.filter(s => (seen.has(s.href) ? false : (seen.add(s.href), true)));
return { context: parts.join('\n\n'), sources: uniq.slice(0, 5) };
} catch {
return { context: '', sources: [] };
}
}
function buildPrompt(history: Msg[], context: string, question: string): string {
const convo = history
.filter(m => !m.pending)
.map(m => `${m.role === 'user' ? 'leo' : 'Mira'}${m.text}`)
.join('\n');
const persona =
`你是 Mira,leo 的個人知識庫副駕 AI。你只能根據下方「知識庫」與「對話脈絡」回答,` +
`沒有任何外部系統存取權(沒有 OpenWebUI、沒有檔案系統、沒有別的工具)。\n\n`;
const kb = context
? `## 知識庫(跟本次提問相關的 wiki / 河道內容)\n\n${context}\n\n`
: `## 知識庫\n(這次在 leo 的筆記裡找不到相關內容。)\n\n`;
const rules = context
? `規則:繁體中文(台灣用語)、務實不客套、優先引用上方知識庫並說「你之前寫過⋯」、簡短切題。`
: `規則:繁體中文。**明確告訴 leo「你的筆記裡目前沒有關於這個的內容」**,` +
`可以再用常識補一兩句(要標明那不是來自他的筆記),不要假裝有資料、不要編造系統或工具。`;
return (
persona +
kb +
(convo ? `## 對話脈絡\n${convo}\n\n` : '') +
`---\n\nleo 現在問:「${question}\n\n${rules}`
);
}
export default function MiraChat({ compact = false }: { compact?: boolean }) {
const [apiKey, setApiKey] = useState<string | null>(null);
const [msgs, setMsgs] = useState<Msg[]>([]);
const [input, setInput] = useState('');
const [sending, setSending] = useState(false);
const logRef = useRef<HTMLDivElement>(null);
useEffect(() => {
fetch(`${API_BASE}/me`, { credentials: 'include' })
.then(r => (r.ok ? r.json() : null))
.then((me: { api_key: string } | null) => { if (me?.api_key) setApiKey(me.api_key); })
.catch(() => {});
}, []);
useEffect(() => {
logRef.current?.scrollTo({ top: logRef.current.scrollHeight, behavior: 'smooth' });
}, [msgs]);
const send = async () => {
const question = input.trim();
if (!question || sending || !apiKey) return;
setInput('');
setSending(true);
const history = msgs;
setMsgs(m => [...m, { role: 'user', text: question }, { role: 'mira', text: '', pending: true }]);
try {
const { context, sources } = await fetchContext(apiKey, question);
const prompt = buildPrompt(history, context, question);
const res = await fetch(CLAUDE_API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, timeout_ms: 60000 }),
});
const data = (await res.json()) as { success?: boolean; pending?: boolean; data?: { text?: string }; error?: string };
let text: string;
if (!res.ok || !data.success) text = `(回答失敗:${data.error ?? res.status}`;
else if (data.pending) text = 'Mira 還在想,daemon 切到背景模式,請稍後再問一次)';
else text = data.data?.text ?? '(沒有內容)';
setMsgs(m => {
const next = [...m];
next[next.length - 1] = { role: 'mira', text, sources: sources.length ? sources : undefined };
return next;
});
} catch (e) {
setMsgs(m => {
const next = [...m];
next[next.length - 1] = { role: 'mira', text: `(錯誤:${e instanceof Error ? e.message : String(e)}` };
return next;
});
} finally {
setSending(false);
}
};
return (
<div className={`mira-chat${compact ? ' is-compact' : ''}`}>
<div className="mira-chat-log" ref={logRef}>
{msgs.length === 0 && (
<div className="empty-state" style={{ marginTop: 28 }}>
<div style={{ fontSize: 40, marginBottom: 8 }}>💬</div>
<p style={{ color: 'var(--mira-text-2)', fontSize: 13 }}> Mira </p>
</div>
)}
{msgs.map((m, i) => (
<div key={i} className={`mira-chat-msg ${m.role === 'user' ? 'is-user' : 'is-mira'}`}>
<div className="mira-chat-bubble">
{m.pending ? (
<span className="mira-thinking-dots">Mira </span>
) : m.role === 'mira' ? (
<MarkdownView text={m.text} />
) : (
m.text
)}
{m.sources && m.sources.length > 0 && (
<div className="mira-chat-sources">
{m.sources.map((s, j) => (
<span key={j}>
{j > 0 && ' · '}
<Link href={s.href}>{s.label}</Link>
</span>
))}
</div>
)}
</div>
</div>
))}
</div>
<div className="mira-chat-input-row">
<textarea
className="mira-chat-input"
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); void send(); }
}}
placeholder="問 Mira…(⌘+Enter 送出)"
rows={2}
disabled={sending || !apiKey}
/>
<button
type="button"
className="mira-btn-primary"
onClick={() => void send()}
disabled={sending || !apiKey || !input.trim()}
>
{sending ? '⋯' : '送出'}
</button>
</div>
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
'use client';
// Mira 右側常駐對話 dock(桌機,所有頁簽都在;手機隱藏改走 /mira/chat 單頁)
// 可收合,狀態存 localStorage。SDD: design.md §3.6.5 對話內建化
import { useEffect, useState } from 'react';
import MiraChat from './MiraChat';
const LS_KEY = 'mira-chat-dock-open';
export default function MiraChatDock() {
const [open, setOpen] = useState(false);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
setOpen(localStorage.getItem(LS_KEY) === '1');
}, []);
const toggle = () => {
setOpen(o => {
const next = !o;
localStorage.setItem(LS_KEY, next ? '1' : '0');
return next;
});
};
// SSR/首渲不輸出,避免 hydration 閃爍
if (!mounted) return null;
return (
<div className={`mira-chat-dock${open ? ' is-open' : ''}`}>
{open ? (
<>
<header className="mira-chat-dock-head">
<span>💬 Mira </span>
<button type="button" className="mira-chat-dock-close" onClick={toggle} aria-label="收合對話"></button>
</header>
<MiraChat compact />
</>
) : (
<button type="button" className="mira-chat-dock-fab" onClick={toggle} title="開啟 Mira 對話">
💬
</button>
)}
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
'use client';
// Mira 側邊欄(桌機左固定欄 / 手機底部 tab bar)
// SDD: polaris/mira/.agents/specs/mira-app/design.md §5.2 v2.1 + §3.7.4
// 對應 task: 10B0.1 / 10B0.2 / 10B0.4
// 現階段入口手寫;detector framework#8)上線後改讀 detector view 規格動態生成
import { useState } from 'react';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
type NavItem = {
href: string;
icon: string;
label: string;
status: 'live' | 'planned';
};
// 側邊欄入口 = 各 detector 的 view(§3.7.4)。河道是固定輸入入口,其餘是 detector 產物頁。
const NAV_ITEMS: NavItem[] = [
{ href: '/mira/feed', icon: '🌊', label: '河道', status: 'live' },
{ href: '/mira/chat', icon: '💬', label: '對話', status: 'live' },
{ href: '/mira/wiki', icon: '📚', label: 'Wiki', status: 'live' },
{ href: '/mira/projects', icon: '📋', label: '專案', status: 'live' },
{ href: '/mira/dissent', icon: '⚔️', label: '異見牆', status: 'planned' },
];
function isActive(pathname: string | null, href: string): boolean {
if (!pathname) return false;
if (href === '/mira/feed') return pathname === '/mira/feed' || pathname === '/mira';
return pathname === href || pathname.startsWith(href + '/');
}
export default function MiraSidebar() {
const pathname = usePathname();
const router = useRouter();
const [q, setQ] = useState('');
const submitSearch = (e: React.FormEvent) => {
e.preventDefault();
const query = q.trim();
if (!query) return;
router.push(`/mira/search?q=${encodeURIComponent(query)}`);
};
return (
<nav className="mira-sidebar" aria-label="Mira 導覽">
<Link href="/mira/feed" className="mira-sidebar-logo">
<span className="mira-sidebar-logo-icon">🦔</span>
<span className="mira-sidebar-logo-text">Mira</span>
</Link>
{/* 桌機:側欄輸入框 */}
<form className="mira-sidebar-search" onSubmit={submitSearch}>
<input
value={q}
onChange={e => setQ(e.target.value)}
placeholder="🔍 搜尋 wiki…"
aria-label="搜尋"
/>
</form>
{/* 手機:底部 bar 已滿,搜尋收成一個放大鏡 icon → 去搜尋頁輸入 */}
<Link
href="/mira/search"
className={`mira-sidebar-search-icon${isActive(pathname, '/mira/search') ? ' is-active' : ''}`}
aria-label="搜尋"
title="搜尋"
>
🔍
</Link>
<ul className="mira-sidebar-list">
{NAV_ITEMS.map(item => {
const active = isActive(pathname, item.href);
const planned = item.status === 'planned';
const className = `mira-sidebar-item${active ? ' is-active' : ''}${planned ? ' is-planned' : ''}`;
const inner = (
<>
<span className="mira-sidebar-icon">{item.icon}</span>
<span className="mira-sidebar-label">{item.label}</span>
{planned && <span className="mira-sidebar-badge"></span>}
</>
);
return (
<li key={item.href}>
{planned ? (
<span className={className} aria-disabled="true" title="即將開放">
{inner}
</span>
) : (
<Link href={item.href} className={className} aria-current={active ? 'page' : undefined}>
{inner}
</Link>
)}
</li>
);
})}
</ul>
</nav>
);
}
+93
View File
@@ -0,0 +1,93 @@
'use client';
// Mira 共用 Markdown 渲染器(河道 + Wiki 共用)
// SDD: polaris/mira/.agents/specs/mira-app/design.md §3.5.7
import { useMemo } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
export function MarkdownView({ text }: { text: string }) {
// 三階段預處理:1. strip Logseq metadata2. [[entity]] 轉 link3. raw:<uuid> 轉河道 deep-link
const cleaned = useMemo(
() => expandRawRefs(expandWikilinks(stripLogseqMeta(text))),
[text],
);
return (
<div className="mira-md">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ href, children, ...rest }) => {
const isInternal =
typeof href === 'string' &&
(href.startsWith('/mira/wiki/') || href.startsWith('/mira/feed'));
return (
<a
href={href}
{...(isInternal ? {} : { target: '_blank', rel: 'noopener noreferrer' })}
className="wiki-link"
{...rest}
>
{children}
</a>
);
},
// 圖片不直接 inline 顯示(避免大圖打亂 feed),改成連結
img: ({ src, alt }) => {
const href = typeof src === 'string' ? src : '';
return href ? (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="wiki-link"
style={{ fontStyle: 'italic' }}
>
🖼 {alt || 'image'}
</a>
) : null;
},
}}
>
{cleaned}
</ReactMarkdown>
</div>
);
}
// Strip Logseq 專屬語法
// - 屬性行:`xxx:: yyy`、`collapsed:: true`、`id:: ...`、`logseq.order-list-type:: ...`
// - block ref`((uuid))` 暫時保留為純文字
export function stripLogseqMeta(text: string): string {
return text
.split('\n')
.filter((line) => {
const trimmed = line.trimStart();
if (/^[a-zA-Z][a-zA-Z0-9_.-]*::\s/.test(trimmed)) return false;
return true;
})
.join('\n');
}
// 把 [[entity]] 轉成 markdown link 指向 /mira/wiki/wiki-{entity}
// 對應 mira-app design.md §3.6.2 + tasks.md backlog #12
export function expandWikilinks(text: string): string {
return text.replace(/\[\[([^\[\]\n]+?)\]\]/g, (_, entity: string) => {
const e = entity.trim();
if (!e) return '[[]]';
const url = `/mira/wiki/${encodeURIComponent('wiki-' + e)}`;
return `[${e}](${url})`;
});
}
// index-entry / wiki backlink 區塊內的 `raw:<uuid>` bare 文字轉成可點河道 deep-link。
// 對應 leo 反饋 #3index 顯示 uuid 無法點擊。河道 hash handler 認得 #raw=<id> 並解析回 page_name。
// 已在 markdown link 內([..](..))的不重複處理;只抓裸 raw:uuid。
const UUID_RE = '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}';
export function expandRawRefs(text: string): string {
return text.replace(
new RegExp(`(?<![\\(\\[\\w])raw:(${UUID_RE})`, 'g'),
(_m, id: string) => `[raw:${id.slice(0, 8)}…](/mira/feed#raw=${id})`,
);
}