diff --git a/landing/app/mira/_shared/markdown.tsx b/landing/app/mira/_shared/markdown.tsx
index caead1a..b4fbd05 100644
--- a/landing/app/mira/_shared/markdown.tsx
+++ b/landing/app/mira/_shared/markdown.tsx
@@ -8,23 +8,26 @@ import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
export function MarkdownView({ text }: { text: string }) {
- const cleaned = useMemo(() => stripLogseqMeta(text), [text]);
+ // 兩階段預處理:1. strip Logseq metadata;2. [[entity]] 轉成 markdown link
+ const cleaned = useMemo(() => expandWikilinks(stripLogseqMeta(text)), [text]);
return (
@@ -500,10 +597,11 @@ function PostComposer({
}
handleTabIndent(e, text, setText);
}}
- placeholder="現在想分享什麼?(Esc 收起、⌘+Enter 發布)"
+ placeholder="現在想分享什麼?(Esc 收起、⌘+Enter 發布、[[X]] 引用 entity)"
disabled={submitting}
className="mira-composer-popup-textarea"
/>
+
{err &&
{err}}
@@ -550,6 +648,170 @@ function Avatar({ me, size = 40 }: { me: Me; size?: number }) {
);
}
+// ── WikilinkAutocomplete ─────────────────────────────────────────────────────
+// 偵測 textarea 內 cursor 位置左側是否有未閉合的 [[query
+// 若是 → 顯示下拉清單(既存 entity + 「⊕ 建立 [[query]]」option)
+// 對應 tasks.md backlog #12 / design.md §3.6.2
+function WikilinkAutocomplete({
+ textareaRef, value, setValue, apiKey,
+}: {
+ textareaRef: React.RefObject
;
+ value: string;
+ setValue: (v: string) => void;
+ apiKey: string;
+}) {
+ const [entities, setEntities] = useState([]);
+ const [matchInfo, setMatchInfo] = useState<{ start: number; query: string } | null>(null);
+ const [selectedIdx, setSelectedIdx] = useState(0);
+
+ // 載入 entity 清單(cached 30s)
+ useEffect(() => {
+ getEntityNamesCached(apiKey).then(set => setEntities(Array.from(set).sort()));
+ }, [apiKey]);
+
+ // 監聽 textarea 變化 / cursor 移動 → 重算 matchInfo
+ useEffect(() => {
+ const ta = textareaRef.current;
+ if (!ta) return;
+ const update = () => {
+ const cursorPos = ta.selectionStart;
+ // 從 cursor 往前找 `[[`,且該 `[[` 之後到 cursor 之間沒有 `]]` 或換行
+ const before = value.slice(0, cursorPos);
+ const lastOpen = before.lastIndexOf('[[');
+ if (lastOpen === -1) { setMatchInfo(null); return; }
+ const inside = before.slice(lastOpen + 2);
+ if (inside.includes(']]') || inside.includes('\n')) { setMatchInfo(null); return; }
+ setMatchInfo({ start: lastOpen, query: inside });
+ setSelectedIdx(0);
+ };
+ update();
+ ta.addEventListener('keyup', update);
+ ta.addEventListener('click', update);
+ ta.addEventListener('input', update);
+ return () => {
+ ta.removeEventListener('keyup', update);
+ ta.removeEventListener('click', update);
+ ta.removeEventListener('input', update);
+ };
+ }, [textareaRef, value]);
+
+ // filter 既存 entity by query(substring + 寬鬆 — 包含中文字片段匹配)
+ const filtered = useMemo(() => {
+ if (!matchInfo) return [];
+ const q = matchInfo.query.toLowerCase();
+ if (!q) return entities.slice(0, 8);
+ return entities
+ .filter(e => e.toLowerCase().includes(q))
+ .slice(0, 8);
+ }, [matchInfo, entities]);
+
+ // create-new option(總顯示,當有 query 且 query 不完全等於既存 entity 時)
+ const showCreate = !!matchInfo && matchInfo.query.trim() !== '' &&
+ !entities.some(e => e.toLowerCase() === matchInfo.query.toLowerCase());
+
+ const items = useMemo(() => {
+ if (!matchInfo) return [] as Array<{ kind: 'create' | 'existing'; label: string; entity: string }>;
+ const list: Array<{ kind: 'create' | 'existing'; label: string; entity: string }> = [];
+ if (showCreate) list.push({ kind: 'create', label: `⊕ 建立 [[${matchInfo.query}]]`, entity: matchInfo.query });
+ for (const e of filtered) list.push({ kind: 'existing', label: e, entity: e });
+ return list;
+ }, [matchInfo, filtered, showCreate]);
+
+ // keyboard handler(綁在 textarea 的 onKeyDown,但需要 capture 階段;改用全域 listener while open)
+ useEffect(() => {
+ if (!matchInfo || items.length === 0) return;
+ const ta = textareaRef.current;
+ if (!ta) return;
+ const handler = (e: KeyboardEvent) => {
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ setSelectedIdx(i => (i + 1) % items.length);
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ setSelectedIdx(i => (i - 1 + items.length) % items.length);
+ } else if (e.key === 'Enter' || e.key === 'Tab') {
+ e.preventDefault();
+ commitSelection(items[selectedIdx]);
+ } else if (e.key === 'Escape') {
+ e.preventDefault();
+ setMatchInfo(null);
+ }
+ };
+ ta.addEventListener('keydown', handler);
+ return () => ta.removeEventListener('keydown', handler);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [matchInfo, items, selectedIdx, textareaRef]);
+
+ function commitSelection(item: { entity: string }) {
+ if (!matchInfo) return;
+ const before = value.slice(0, matchInfo.start);
+ const after = value.slice(matchInfo.start + 2 + matchInfo.query.length);
+ const inserted = `[[${item.entity}]]`;
+ const newValue = before + inserted + after;
+ setValue(newValue);
+ setMatchInfo(null);
+ // refresh cache 以納入新建 entity(但其實要等 ensureEntitiesExist 跑完才會在 KBDB;先 invalidate cache 就夠)
+ cachedEntityNames = null;
+ // 下個 tick 把 cursor 移到 ]] 之後
+ setTimeout(() => {
+ const ta = textareaRef.current;
+ if (ta) {
+ const pos = before.length + inserted.length;
+ ta.selectionStart = ta.selectionEnd = pos;
+ ta.focus();
+ }
+ }, 0);
+ }
+
+ if (!matchInfo || items.length === 0) return null;
+
+ // 位置:textarea 下方
+ const ta = textareaRef.current;
+ const rect = ta?.getBoundingClientRect();
+ if (!rect) return null;
+
+ return (
+
+ {items.map((it, i) => (
+
commitSelection(it)}
+ onMouseEnter={() => setSelectedIdx(i)}
+ style={{
+ padding: '8px 12px',
+ cursor: 'pointer',
+ background: i === selectedIdx ? 'var(--mira-bg-3)' : 'transparent',
+ color: it.kind === 'create' ? 'var(--mira-accent)' : 'var(--mira-text-1)',
+ fontSize: 14,
+ borderBottom: i < items.length - 1 ? '1px solid var(--mira-line-soft)' : 'none',
+ }}
+ >
+ {it.label}
+
+ ))}
+
+ ↑↓ 選擇 · Enter / Tab 確認 · Esc 取消
+
+
+ );
+}
+
// Mira 自有頭像(區別於 leo),用機器人 emoji + 紫色圓
// 對應 design.md §3.6.5 + tasks.md backlog #5a (P0)
function MiraAvatar({ size = 40 }: { size?: number }) {
@@ -1094,7 +1356,8 @@ function PostEditor({
saving={saving}
err={err}
submitLabel="儲存"
- placeholder="編輯內容⋯(@mira 呼叫 Mira 回覆)"
+ placeholder="編輯內容⋯(@mira 呼叫 Mira 回覆 · [[X]] 引用 entity)"
+ apiKey={apiKey}
/>
);
}
@@ -1215,6 +1478,9 @@ function BlockLine({
});
triggerThinking?.(docPageName);
}
+ // Backlog #12: reply 含 [[X]] 也建檔
+ const wikilinks = parseWikilinks(trimmed);
+ if (wikilinks.length > 0) void ensureEntitiesExist(apiKey, wikilinks);
setMode('view'); setDraft('');
} catch (e) { setErr(e instanceof Error ? e.message : String(e)); }
finally { setSaving(false); }
@@ -1250,10 +1516,10 @@ function BlockLine({
(空)
)}
{mode === 'edit' && (
-
+
)}
{mode === 'reply' && (
-
+
)}
{mode === 'view' &&
}
@@ -1345,6 +1611,9 @@ function ReplyLine({
});
triggerThinking?.(docPageName);
}
+ // Backlog #12: reply 含 [[X]] 也建檔
+ const wikilinks = parseWikilinks(trimmed);
+ if (wikilinks.length > 0) void ensureEntitiesExist(apiKey, wikilinks);
setMode('view'); setDraft('');
} catch (e) { setErr(e instanceof Error ? e.message : String(e)); }
finally { setSaving(false); }
@@ -1377,10 +1646,10 @@ function ReplyLine({
)}
{mode === 'edit' && (
-