feat(mira): [[entity]] wikilink — 顯式建檔 + autocomplete + render link
對應 tasks.md backlog #12 / design.md §3.6.2。leo 反饋:要像 Logseq 那樣 寫 [[X]] 立刻建檔,下次 [[X 自動補完,render 變連結。 helpers: - parseWikilinks(text) → 抽 [[X]] entity list - fetchAllEntityNames(apiKey) → 合 index-entry + wiki-page entity 名稱 - getEntityNamesCached → 30s session cache for autocomplete - ensureEntitiesExist → 新 entity 立刻建 wiki-page placeholder - tags 含 entity-type:book/url/concept(guessEntityType 推測:《》→ book / http → url) - source: leo-explicit - expandWikilinks(text) → render 時把 [[X]] 轉 markdown link to /mira/wiki/wiki-X UI: - <WikilinkAutocomplete>:textarea cursor 在 `[[query` 未閉合時,下拉顯示既存 entity(substring filter)+「⊕ 建立 [[query]]」option,↑↓選 / Enter 確認 / Esc 取消。fixed-position below textarea bottom(cursor tracking 留下輪) - PostComposer compact + popup 兩個 textarea 都掛 autocomplete - EditingArea(PostEditor / BlockEditor / ReplyLine / PageReplyComposer 共用) 加 apiKey prop,內部 textarea + popup 都掛 autocomplete submit hook: - PostComposer.submit:postBlockId 建好後 ensureEntitiesExist(wikilinks) - BlockEditor.submitReply / ReplyLine.submitReply 同樣建檔 - 在 wiki_synthesis trigger 前先建,避免 race render: - markdown.tsx expandWikilinks 取代 stripLogseqMeta 前處理(兩階段) - 內部 wiki link(/mira/wiki/...)不開 _target=_blank(不離開頁面) 留下輪:metadata 補完 (作者/出版社) / cursor tracking / PostEditor.save 也建檔
This commit is contained in:
+282
-10
@@ -176,6 +176,97 @@ function hasMiraMention(text: string): boolean {
|
||||
return /(^|[^\w])@mira\b/i.test(text);
|
||||
}
|
||||
|
||||
// ── Backlog #12:[[entity]] 統一引用 syntax ──────────────────────────────────
|
||||
// design.md §3.6.2 ZK 進化版(防雙胞胎)
|
||||
|
||||
// 抽 [[X]] 出來
|
||||
function parseWikilinks(text: string): string[] {
|
||||
const matches = text.matchAll(/\[\[([^\[\]\n]+?)\]\]/g);
|
||||
const out: string[] = [];
|
||||
for (const m of matches) {
|
||||
const entity = m[1].trim();
|
||||
if (entity) out.push(entity);
|
||||
}
|
||||
return Array.from(new Set(out)); // unique
|
||||
}
|
||||
|
||||
// 撈所有既存 entity 名稱(從 wiki-page + index-entry 合集)
|
||||
async function fetchAllEntityNames(apiKey: string): Promise<Set<string>> {
|
||||
try {
|
||||
const headers = { Authorization: `Bearer ${apiKey}` };
|
||||
const [idxRes, pageRes] = await Promise.all([
|
||||
fetch(`${KBDB_BASE}/blocks?type=index-entry&limit=200`, { headers }),
|
||||
fetch(`${KBDB_BASE}/blocks?type=wiki-page&source=ai-canon-wiki&limit=200`, { headers }),
|
||||
]);
|
||||
type B = { page_name: string | null; content: string };
|
||||
const idxData = idxRes.ok ? await idxRes.json() as { blocks?: B[] } : { blocks: [] };
|
||||
const pageData = pageRes.ok ? await pageRes.json() as { blocks?: B[] } : { blocks: [] };
|
||||
const set = new Set<string>();
|
||||
for (const b of (idxData.blocks ?? [])) {
|
||||
const e = (b.page_name ?? '').replace(/^index-/, '').trim();
|
||||
if (e) set.add(e);
|
||||
}
|
||||
for (const b of (pageData.blocks ?? [])) {
|
||||
const e = (b.content ?? '').trim();
|
||||
if (e) set.add(e);
|
||||
}
|
||||
return set;
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
// Cache:每個 session 只 fetch 一次 entity list(autocomplete 用)
|
||||
let cachedEntityNames: Set<string> | null = null;
|
||||
let cachedEntityFetchedAt = 0;
|
||||
async function getEntityNamesCached(apiKey: string): Promise<Set<string>> {
|
||||
const now = Date.now();
|
||||
// 30s TTL — leo 持續寫貼文時新建的 entity 也能很快被 autocomplete 看到
|
||||
if (cachedEntityNames && now - cachedEntityFetchedAt < 30_000) return cachedEntityNames;
|
||||
cachedEntityNames = await fetchAllEntityNames(apiKey);
|
||||
cachedEntityFetchedAt = now;
|
||||
return cachedEntityNames;
|
||||
}
|
||||
|
||||
// 簡單 type 推測:《X》 → 書;http(s)://X → URL;其他 → 概念
|
||||
function guessEntityType(name: string): 'book' | 'url' | 'concept' {
|
||||
if (/^《.*》$|《[^》]+》/.test(name)) return 'book';
|
||||
if (/^https?:\/\//.test(name)) return 'url';
|
||||
return 'concept';
|
||||
}
|
||||
|
||||
// 確保 [[X]] 命中的 entity 都存在 KBDB(不存在則新建 wiki-page 占位)
|
||||
// 後續 wiki_synthesis 會把該 entity 的 raw 寫進 paragraphs
|
||||
// 對應 leo 期望:寫 [[X]] 立刻建檔(Logseq 行為)
|
||||
async function ensureEntitiesExist(apiKey: string, entities: string[]): Promise<void> {
|
||||
if (entities.length === 0) return;
|
||||
const existing = await fetchAllEntityNames(apiKey);
|
||||
const toCreate = entities.filter(e => !existing.has(e));
|
||||
if (toCreate.length === 0) return;
|
||||
for (const entity of toCreate) {
|
||||
const type = guessEntityType(entity);
|
||||
const tags = ['mira-wiki', 'leo-explicit', `entity-type:${type}`];
|
||||
try {
|
||||
await fetch(KBDB_CREATE, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
api_key: apiKey,
|
||||
content: entity,
|
||||
type: 'wiki-page',
|
||||
page_name: `wiki-${entity}`,
|
||||
source: 'leo-explicit',
|
||||
user_id: 'inkstone_mira_post',
|
||||
tags_json: JSON.stringify(tags),
|
||||
}),
|
||||
});
|
||||
console.log(`[wikilink] created entity placeholder: ${entity} (type=${type})`);
|
||||
} catch (e) {
|
||||
console.warn(`[wikilink] create failed: ${entity}`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 撈相關 wiki context 給 mira reply 用(簡單 RAG)
|
||||
// 策略:拿全部 index-entry + wiki-page,雙向 substring match against post content
|
||||
// → 取 top 5 餵進 prompt
|
||||
@@ -396,6 +487,11 @@ function PostComposer({
|
||||
});
|
||||
onAiTriggered(pageName);
|
||||
}
|
||||
// Backlog #12:[[X]] 顯式建檔 — 解析貼文 wikilinks,不存在的 entity 立刻建 wiki-page placeholder
|
||||
const wikilinks = parseWikilinks(trimmed);
|
||||
if (wikilinks.length > 0) {
|
||||
void ensureEntitiesExist(me.api_key, wikilinks);
|
||||
}
|
||||
// 7B.3h:fire-and-forget 觸發 wiki_synthesis(browser → cypher.arcrun.dev,arcrun-native)
|
||||
// 跟 @mira 無關,每篇都跑 — 河道書寫永遠進 wiki KB 副本(不顯示 thinking indicator,背景靜默跑)
|
||||
void triggerWikiSynthesis({ apiKey: me.api_key, rawBlockId: postBlockId });
|
||||
@@ -427,11 +523,12 @@ function PostComposer({
|
||||
}
|
||||
handleTabIndent(e, text, setText);
|
||||
}}
|
||||
placeholder="現在想分享什麼?(@mira 呼叫 Mira 回覆)"
|
||||
placeholder="現在想分享什麼?(@mira 呼叫 Mira 回覆 · [[X]] 引用 entity)"
|
||||
rows={2}
|
||||
disabled={submitting}
|
||||
className="mira-composer-textarea"
|
||||
/>
|
||||
<WikilinkAutocomplete textareaRef={taRef} value={text} setValue={setText} apiKey={me.api_key} />
|
||||
</div>
|
||||
<div className="mira-composer-actions">
|
||||
<MarkdownToolbar textareaRef={taRef} value={text} setValue={setText} compact />
|
||||
@@ -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"
|
||||
/>
|
||||
<WikilinkAutocomplete textareaRef={popupTaRef} value={text} setValue={setText} apiKey={me.api_key} />
|
||||
<div className="mira-composer-popup-actions">
|
||||
<MarkdownToolbar textareaRef={popupTaRef} value={text} setValue={setText} />
|
||||
{err && <span className="mira-msg-error">{err}</span>}
|
||||
@@ -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<HTMLTextAreaElement | null>;
|
||||
value: string;
|
||||
setValue: (v: string) => void;
|
||||
apiKey: string;
|
||||
}) {
|
||||
const [entities, setEntities] = useState<string[]>([]);
|
||||
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 (
|
||||
<div
|
||||
className="mira-wikilink-popover"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: rect.bottom + 4,
|
||||
left: rect.left,
|
||||
minWidth: Math.max(220, rect.width / 2),
|
||||
maxWidth: 380,
|
||||
background: 'var(--mira-bg-2)',
|
||||
border: '1px solid var(--mira-line)',
|
||||
borderRadius: 6,
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
|
||||
zIndex: 1100,
|
||||
maxHeight: 280,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
{items.map((it, i) => (
|
||||
<div
|
||||
key={`${it.kind}-${it.entity}`}
|
||||
onClick={() => 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}
|
||||
</div>
|
||||
))}
|
||||
<div style={{ padding: '6px 12px', fontSize: 11, color: 'var(--mira-text-3)', borderTop: '1px solid var(--mira-line-soft)' }}>
|
||||
↑↓ 選擇 · Enter / Tab 確認 · Esc 取消
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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({
|
||||
<em style={{ color: 'var(--mira-text-3)' }}>(空)</em>
|
||||
)}
|
||||
{mode === 'edit' && (
|
||||
<EditingArea draft={draft} setDraft={setDraft} onSubmit={saveEdit} onCancel={cancel} saving={saving} err={err} submitLabel="儲存" />
|
||||
<EditingArea draft={draft} setDraft={setDraft} onSubmit={saveEdit} onCancel={cancel} saving={saving} err={err} submitLabel="儲存" apiKey={apiKey} />
|
||||
)}
|
||||
{mode === 'reply' && (
|
||||
<EditingArea draft={draft} setDraft={setDraft} onSubmit={submitReply} onCancel={cancel} saving={saving} err={err} submitLabel="回覆" placeholder="回覆⋯⋯" />
|
||||
<EditingArea draft={draft} setDraft={setDraft} onSubmit={submitReply} onCancel={cancel} saving={saving} err={err} submitLabel="回覆" placeholder="回覆⋯⋯(@mira 呼叫 Mira · [[X]] 引用)" apiKey={apiKey} />
|
||||
)}
|
||||
</div>
|
||||
{mode === 'view' && <MoreMenu items={menu} />}
|
||||
@@ -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({
|
||||
</div>
|
||||
)}
|
||||
{mode === 'edit' && (
|
||||
<EditingArea draft={draft} setDraft={setDraft} onSubmit={save} onCancel={cancel} saving={saving} err={err} submitLabel="儲存" />
|
||||
<EditingArea draft={draft} setDraft={setDraft} onSubmit={save} onCancel={cancel} saving={saving} err={err} submitLabel="儲存" apiKey={apiKey} />
|
||||
)}
|
||||
{mode === 'reply' && (
|
||||
<EditingArea draft={draft} setDraft={setDraft} onSubmit={submitReply} onCancel={cancel} saving={saving} err={err} submitLabel="回覆" placeholder="回覆這則留言⋯⋯" />
|
||||
<EditingArea draft={draft} setDraft={setDraft} onSubmit={submitReply} onCancel={cancel} saving={saving} err={err} submitLabel="回覆" placeholder="回覆這則留言⋯⋯(@mira 呼叫 Mira · [[X]] 引用)" apiKey={apiKey} />
|
||||
)}
|
||||
{/* 巢狀留言 */}
|
||||
{depth < 3 && (
|
||||
@@ -1484,13 +1753,13 @@ function PageReplyComposer({
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<EditingArea draft={draft} setDraft={setDraft} onSubmit={submit} onCancel={onCancel} saving={saving} err={err} submitLabel="留言" placeholder="寫留言⋯⋯" />
|
||||
<EditingArea draft={draft} setDraft={setDraft} onSubmit={submit} onCancel={onCancel} saving={saving} err={err} submitLabel="留言" placeholder="寫留言⋯⋯(@mira 呼叫 Mira · [[X]] 引用)" apiKey={apiKey} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EditingArea({
|
||||
draft, setDraft, onSubmit, onCancel, saving, err, submitLabel, placeholder,
|
||||
draft, setDraft, onSubmit, onCancel, saving, err, submitLabel, placeholder, apiKey,
|
||||
}: {
|
||||
draft: string;
|
||||
setDraft: (s: string) => void;
|
||||
@@ -1500,6 +1769,7 @@ function EditingArea({
|
||||
err: string | null;
|
||||
submitLabel: string;
|
||||
placeholder?: string;
|
||||
apiKey?: string; // 給 WikilinkAutocomplete 抓 entity 清單用;不傳則 autocomplete 不生效
|
||||
}) {
|
||||
const taRef = useRef<HTMLTextAreaElement>(null);
|
||||
const popupTaRef = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -1522,6 +1792,7 @@ function EditingArea({
|
||||
placeholder={placeholder}
|
||||
className="mira-edit-textarea"
|
||||
/>
|
||||
{apiKey && <WikilinkAutocomplete textareaRef={taRef} value={draft} setValue={setDraft} apiKey={apiKey} />}
|
||||
<div className="mira-edit-actions">
|
||||
<button
|
||||
type="button"
|
||||
@@ -1560,10 +1831,11 @@ function EditingArea({
|
||||
if (e.key === 'Escape') { e.preventDefault(); setExpanded(false); return; }
|
||||
handleTabIndent(e, draft, setDraft);
|
||||
}}
|
||||
placeholder={placeholder ?? '寫點什麼…(Esc 收起、⌘+Enter 送出)'}
|
||||
placeholder={placeholder ?? '寫點什麼…(Esc 收起、⌘+Enter 送出 · [[X]] 引用 entity)'}
|
||||
disabled={saving}
|
||||
className="mira-composer-popup-textarea"
|
||||
/>
|
||||
{apiKey && <WikilinkAutocomplete textareaRef={popupTaRef} value={draft} setValue={setDraft} apiKey={apiKey} />}
|
||||
<div className="mira-composer-popup-actions">
|
||||
<MarkdownToolbar textareaRef={popupTaRef} value={draft} setValue={setDraft} />
|
||||
{err && <span className="mira-msg-error">{err}</span>}
|
||||
|
||||
Reference in New Issue
Block a user