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:
@@ -0,0 +1,199 @@
|
||||
'use client';
|
||||
|
||||
// Mira 對話核心元件(河道右側 dock 與 /mira/chat 單頁共用)
|
||||
// SDD: polaris/mira/.agents/specs/mira-app/design.md §3.6.5
|
||||
// RAG:提問先語義搜尋 KBDB(wiki + 河道)取 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 metadata;2. [[entity]] 轉 link;3. 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 反饋 #3:index 顯示 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})`,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user