@schlessera/brain-ui-react 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +70 -0
- package/dist/components/chat/ask-user-card.d.ts +12 -0
- package/dist/components/chat/ask-user-card.d.ts.map +1 -0
- package/dist/components/chat/ask-user-card.js +185 -0
- package/dist/components/chat/ask-user-card.js.map +1 -0
- package/dist/components/chat/brain-markdown.d.ts +35 -0
- package/dist/components/chat/brain-markdown.d.ts.map +1 -0
- package/dist/components/chat/brain-markdown.js +363 -0
- package/dist/components/chat/brain-markdown.js.map +1 -0
- package/dist/components/chat/chat-page.d.ts +2 -0
- package/dist/components/chat/chat-page.d.ts.map +1 -0
- package/dist/components/chat/chat-page.js +442 -0
- package/dist/components/chat/chat-page.js.map +1 -0
- package/dist/components/chat/command-palette.d.ts +5 -0
- package/dist/components/chat/command-palette.d.ts.map +1 -0
- package/dist/components/chat/command-palette.js +18 -0
- package/dist/components/chat/command-palette.js.map +1 -0
- package/dist/components/chat/markdown-content.d.ts +4 -0
- package/dist/components/chat/markdown-content.d.ts.map +1 -0
- package/dist/components/chat/markdown-content.js +6 -0
- package/dist/components/chat/markdown-content.js.map +1 -0
- package/dist/components/chat/message-bubble.d.ts +9 -0
- package/dist/components/chat/message-bubble.d.ts.map +1 -0
- package/dist/components/chat/message-bubble.js +129 -0
- package/dist/components/chat/message-bubble.js.map +1 -0
- package/dist/components/chat/message-share.d.ts +10 -0
- package/dist/components/chat/message-share.d.ts.map +1 -0
- package/dist/components/chat/message-share.js +65 -0
- package/dist/components/chat/message-share.js.map +1 -0
- package/dist/components/chat/renderers/claude-tools.d.ts +3 -0
- package/dist/components/chat/renderers/claude-tools.d.ts.map +1 -0
- package/dist/components/chat/renderers/claude-tools.js +38 -0
- package/dist/components/chat/renderers/claude-tools.js.map +1 -0
- package/dist/components/chat/renderers/generic.d.ts +9 -0
- package/dist/components/chat/renderers/generic.d.ts.map +1 -0
- package/dist/components/chat/renderers/generic.js +68 -0
- package/dist/components/chat/renderers/generic.js.map +1 -0
- package/dist/components/chat/renderers/index.d.ts +5 -0
- package/dist/components/chat/renderers/index.d.ts.map +1 -0
- package/dist/components/chat/renderers/index.js +17 -0
- package/dist/components/chat/renderers/index.js.map +1 -0
- package/dist/components/chat/risk-hints.d.ts +20 -0
- package/dist/components/chat/risk-hints.d.ts.map +1 -0
- package/dist/components/chat/risk-hints.js +81 -0
- package/dist/components/chat/risk-hints.js.map +1 -0
- package/dist/components/chat/session-drawer.d.ts +6 -0
- package/dist/components/chat/session-drawer.d.ts.map +1 -0
- package/dist/components/chat/session-drawer.js +96 -0
- package/dist/components/chat/session-drawer.js.map +1 -0
- package/dist/components/chat/share-block.d.ts +9 -0
- package/dist/components/chat/share-block.d.ts.map +1 -0
- package/dist/components/chat/share-block.js +70 -0
- package/dist/components/chat/share-block.js.map +1 -0
- package/dist/components/chat/tool-call-timeline.d.ts +8 -0
- package/dist/components/chat/tool-call-timeline.d.ts.map +1 -0
- package/dist/components/chat/tool-call-timeline.js +100 -0
- package/dist/components/chat/tool-call-timeline.js.map +1 -0
- package/dist/components/chat/tool-views.d.ts +116 -0
- package/dist/components/chat/tool-views.d.ts.map +1 -0
- package/dist/components/chat/tool-views.js +628 -0
- package/dist/components/chat/tool-views.js.map +1 -0
- package/dist/components/chat/welcome-state.d.ts +4 -0
- package/dist/components/chat/welcome-state.d.ts.map +1 -0
- package/dist/components/chat/welcome-state.js +10 -0
- package/dist/components/chat/welcome-state.js.map +1 -0
- package/dist/components/connectivity/connection-gate.d.ts +5 -0
- package/dist/components/connectivity/connection-gate.d.ts.map +1 -0
- package/dist/components/connectivity/connection-gate.js +42 -0
- package/dist/components/connectivity/connection-gate.js.map +1 -0
- package/dist/components/connectivity/login-screen.d.ts +9 -0
- package/dist/components/connectivity/login-screen.d.ts.map +1 -0
- package/dist/components/connectivity/login-screen.js +99 -0
- package/dist/components/connectivity/login-screen.js.map +1 -0
- package/dist/components/files/file-panel.d.ts +5 -0
- package/dist/components/files/file-panel.d.ts.map +1 -0
- package/dist/components/files/file-panel.js +46 -0
- package/dist/components/files/file-panel.js.map +1 -0
- package/dist/components/files/file-tree.d.ts +2 -0
- package/dist/components/files/file-tree.d.ts.map +1 -0
- package/dist/components/files/file-tree.js +63 -0
- package/dist/components/files/file-tree.js.map +1 -0
- package/dist/components/files/file-viewer-binary.d.ts +5 -0
- package/dist/components/files/file-viewer-binary.d.ts.map +1 -0
- package/dist/components/files/file-viewer-binary.js +20 -0
- package/dist/components/files/file-viewer-binary.js.map +1 -0
- package/dist/components/files/file-viewer-html.d.ts +4 -0
- package/dist/components/files/file-viewer-html.d.ts.map +1 -0
- package/dist/components/files/file-viewer-html.js +5 -0
- package/dist/components/files/file-viewer-html.js.map +1 -0
- package/dist/components/files/file-viewer-markdown.d.ts +4 -0
- package/dist/components/files/file-viewer-markdown.d.ts.map +1 -0
- package/dist/components/files/file-viewer-markdown.js +10 -0
- package/dist/components/files/file-viewer-markdown.js.map +1 -0
- package/dist/components/files/file-viewer-raw.d.ts +5 -0
- package/dist/components/files/file-viewer-raw.d.ts.map +1 -0
- package/dist/components/files/file-viewer-raw.js +15 -0
- package/dist/components/files/file-viewer-raw.js.map +1 -0
- package/dist/components/files/file-viewer.d.ts +2 -0
- package/dist/components/files/file-viewer.d.ts.map +1 -0
- package/dist/components/files/file-viewer.js +175 -0
- package/dist/components/files/file-viewer.js.map +1 -0
- package/dist/components/files/frontmatter-panel.d.ts +5 -0
- package/dist/components/files/frontmatter-panel.d.ts.map +1 -0
- package/dist/components/files/frontmatter-panel.js +12 -0
- package/dist/components/files/frontmatter-panel.js.map +1 -0
- package/dist/components/layout/app-shell.d.ts +5 -0
- package/dist/components/layout/app-shell.d.ts.map +1 -0
- package/dist/components/layout/app-shell.js +10 -0
- package/dist/components/layout/app-shell.js.map +1 -0
- package/dist/components/layout/mobile-tab-bar.d.ts +2 -0
- package/dist/components/layout/mobile-tab-bar.d.ts.map +1 -0
- package/dist/components/layout/mobile-tab-bar.js +16 -0
- package/dist/components/layout/mobile-tab-bar.js.map +1 -0
- package/dist/components/layout/side-rail.d.ts +2 -0
- package/dist/components/layout/side-rail.d.ts.map +1 -0
- package/dist/components/layout/side-rail.js +26 -0
- package/dist/components/layout/side-rail.js.map +1 -0
- package/dist/components/layout/slide-panel.d.ts +9 -0
- package/dist/components/layout/slide-panel.d.ts.map +1 -0
- package/dist/components/layout/slide-panel.js +18 -0
- package/dist/components/layout/slide-panel.js.map +1 -0
- package/dist/components/quick-actions/streaming-modal.d.ts +8 -0
- package/dist/components/quick-actions/streaming-modal.d.ts.map +1 -0
- package/dist/components/quick-actions/streaming-modal.js +92 -0
- package/dist/components/quick-actions/streaming-modal.js.map +1 -0
- package/dist/components/quick-actions/whatsup-modal.d.ts +5 -0
- package/dist/components/quick-actions/whatsup-modal.d.ts.map +1 -0
- package/dist/components/quick-actions/whatsup-modal.js +82 -0
- package/dist/components/quick-actions/whatsup-modal.js.map +1 -0
- package/dist/components/settings/passkey-panel.d.ts +10 -0
- package/dist/components/settings/passkey-panel.d.ts.map +1 -0
- package/dist/components/settings/passkey-panel.js +118 -0
- package/dist/components/settings/passkey-panel.js.map +1 -0
- package/dist/components/share/share-menu.d.ts +23 -0
- package/dist/components/share/share-menu.d.ts.map +1 -0
- package/dist/components/share/share-menu.js +58 -0
- package/dist/components/share/share-menu.js.map +1 -0
- package/dist/components/voice/dictation-sheet.d.ts +6 -0
- package/dist/components/voice/dictation-sheet.d.ts.map +1 -0
- package/dist/components/voice/dictation-sheet.js +91 -0
- package/dist/components/voice/dictation-sheet.js.map +1 -0
- package/dist/components/voice/mic-button.d.ts +6 -0
- package/dist/components/voice/mic-button.d.ts.map +1 -0
- package/dist/components/voice/mic-button.js +9 -0
- package/dist/components/voice/mic-button.js.map +1 -0
- package/dist/components/voice/review-card.d.ts +8 -0
- package/dist/components/voice/review-card.d.ts.map +1 -0
- package/dist/components/voice/review-card.js +8 -0
- package/dist/components/voice/review-card.js.map +1 -0
- package/dist/config.d.ts +19 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +10 -0
- package/dist/config.js.map +1 -0
- package/dist/hooks/use-vpn-status.d.ts +2 -0
- package/dist/hooks/use-vpn-status.d.ts.map +1 -0
- package/dist/hooks/use-vpn-status.js +71 -0
- package/dist/hooks/use-vpn-status.js.map +1 -0
- package/dist/hooks/use-websocket.d.ts +8 -0
- package/dist/hooks/use-websocket.d.ts.map +1 -0
- package/dist/hooks/use-websocket.js +366 -0
- package/dist/hooks/use-websocket.js.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +31 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/api-client.d.ts +115 -0
- package/dist/lib/api-client.d.ts.map +1 -0
- package/dist/lib/api-client.js +70 -0
- package/dist/lib/api-client.js.map +1 -0
- package/dist/lib/backend.d.ts +7 -0
- package/dist/lib/backend.d.ts.map +1 -0
- package/dist/lib/backend.js +33 -0
- package/dist/lib/backend.js.map +1 -0
- package/dist/lib/frontmatter.d.ts +21 -0
- package/dist/lib/frontmatter.d.ts.map +1 -0
- package/dist/lib/frontmatter.js +88 -0
- package/dist/lib/frontmatter.js.map +1 -0
- package/dist/lib/image-attachments.d.ts +36 -0
- package/dist/lib/image-attachments.d.ts.map +1 -0
- package/dist/lib/image-attachments.js +175 -0
- package/dist/lib/image-attachments.js.map +1 -0
- package/dist/lib/passkeys.d.ts +18 -0
- package/dist/lib/passkeys.d.ts.map +1 -0
- package/dist/lib/passkeys.js +36 -0
- package/dist/lib/passkeys.js.map +1 -0
- package/dist/lib/share.d.ts +53 -0
- package/dist/lib/share.d.ts.map +1 -0
- package/dist/lib/share.js +144 -0
- package/dist/lib/share.js.map +1 -0
- package/dist/lib/strip-markdown.d.ts +10 -0
- package/dist/lib/strip-markdown.d.ts.map +1 -0
- package/dist/lib/strip-markdown.js +37 -0
- package/dist/lib/strip-markdown.js.map +1 -0
- package/dist/lib/tool-names.d.ts +17 -0
- package/dist/lib/tool-names.d.ts.map +1 -0
- package/dist/lib/tool-names.js +23 -0
- package/dist/lib/tool-names.js.map +1 -0
- package/dist/lib/utils.d.ts +3 -0
- package/dist/lib/utils.d.ts.map +1 -0
- package/dist/lib/utils.js +6 -0
- package/dist/lib/utils.js.map +1 -0
- package/dist/lib/ws-client.d.ts +26 -0
- package/dist/lib/ws-client.d.ts.map +1 -0
- package/dist/lib/ws-client.js +93 -0
- package/dist/lib/ws-client.js.map +1 -0
- package/dist/stores/chat-store.d.ts +131 -0
- package/dist/stores/chat-store.d.ts.map +1 -0
- package/dist/stores/chat-store.js +376 -0
- package/dist/stores/chat-store.js.map +1 -0
- package/dist/stores/connection-store.d.ts +11 -0
- package/dist/stores/connection-store.d.ts.map +1 -0
- package/dist/stores/connection-store.js +8 -0
- package/dist/stores/connection-store.js.map +1 -0
- package/dist/stores/file-store.d.ts +53 -0
- package/dist/stores/file-store.d.ts.map +1 -0
- package/dist/stores/file-store.js +284 -0
- package/dist/stores/file-store.js.map +1 -0
- package/dist/stores/provider-store.d.ts +24 -0
- package/dist/stores/provider-store.d.ts.map +1 -0
- package/dist/stores/provider-store.js +56 -0
- package/dist/stores/provider-store.js.map +1 -0
- package/dist/stores/ui-store.d.ts +21 -0
- package/dist/stores/ui-store.d.ts.map +1 -0
- package/dist/stores/ui-store.js +23 -0
- package/dist/stores/ui-store.js.map +1 -0
- package/dist/styles.css +2 -0
- package/dist/voice/asr-clients.d.ts +2 -0
- package/dist/voice/asr-clients.d.ts.map +1 -0
- package/dist/voice/asr-clients.js +33 -0
- package/dist/voice/asr-clients.js.map +1 -0
- package/dist/voice/asr-deepgram.d.ts +38 -0
- package/dist/voice/asr-deepgram.d.ts.map +1 -0
- package/dist/voice/asr-deepgram.js +307 -0
- package/dist/voice/asr-deepgram.js.map +1 -0
- package/dist/voice/asr-webspeech.d.ts +23 -0
- package/dist/voice/asr-webspeech.d.ts.map +1 -0
- package/dist/voice/asr-webspeech.js +93 -0
- package/dist/voice/asr-webspeech.js.map +1 -0
- package/dist/voice/use-dictation.d.ts +6 -0
- package/dist/voice/use-dictation.d.ts.map +1 -0
- package/dist/voice/use-dictation.js +174 -0
- package/dist/voice/use-dictation.js.map +1 -0
- package/dist/voice/voice-store.d.ts +26 -0
- package/dist/voice/voice-store.d.ts.map +1 -0
- package/dist/voice/voice-store.js +27 -0
- package/dist/voice/voice-store.js.map +1 -0
- package/package.json +71 -0
- package/src/components/chat/ask-user-card.tsx +451 -0
- package/src/components/chat/brain-markdown.tsx +547 -0
- package/src/components/chat/chat-page.tsx +820 -0
- package/src/components/chat/command-palette.tsx +68 -0
- package/src/components/chat/markdown-content.tsx +5 -0
- package/src/components/chat/message-bubble.tsx +362 -0
- package/src/components/chat/message-share.ts +78 -0
- package/src/components/chat/renderers/claude-tools.tsx +53 -0
- package/src/components/chat/renderers/generic.tsx +85 -0
- package/src/components/chat/renderers/index.ts +19 -0
- package/src/components/chat/risk-hints.ts +95 -0
- package/src/components/chat/session-drawer.tsx +213 -0
- package/src/components/chat/share-block.tsx +128 -0
- package/src/components/chat/tool-call-timeline.tsx +322 -0
- package/src/components/chat/tool-views.tsx +898 -0
- package/src/components/chat/welcome-state.tsx +105 -0
- package/src/components/connectivity/connection-gate.tsx +137 -0
- package/src/components/connectivity/login-screen.tsx +169 -0
- package/src/components/files/file-panel.tsx +116 -0
- package/src/components/files/file-tree.tsx +143 -0
- package/src/components/files/file-viewer-binary.tsx +63 -0
- package/src/components/files/file-viewer-html.tsx +10 -0
- package/src/components/files/file-viewer-markdown.tsx +14 -0
- package/src/components/files/file-viewer-raw.tsx +44 -0
- package/src/components/files/file-viewer.tsx +290 -0
- package/src/components/files/frontmatter-panel.tsx +78 -0
- package/src/components/layout/app-shell.tsx +25 -0
- package/src/components/layout/mobile-tab-bar.tsx +54 -0
- package/src/components/layout/side-rail.tsx +128 -0
- package/src/components/layout/slide-panel.tsx +64 -0
- package/src/components/quick-actions/streaming-modal.tsx +168 -0
- package/src/components/quick-actions/whatsup-modal.tsx +149 -0
- package/src/components/settings/passkey-panel.tsx +281 -0
- package/src/components/share/share-menu.tsx +121 -0
- package/src/components/voice/dictation-sheet.tsx +217 -0
- package/src/components/voice/mic-button.tsx +31 -0
- package/src/components/voice/review-card.tsx +66 -0
- package/src/config.ts +27 -0
- package/src/hooks/use-vpn-status.ts +75 -0
- package/src/hooks/use-websocket.ts +403 -0
- package/src/index.ts +46 -0
- package/src/lib/api-client.ts +173 -0
- package/src/lib/backend.ts +35 -0
- package/src/lib/frontmatter.ts +99 -0
- package/src/lib/image-attachments.ts +221 -0
- package/src/lib/passkeys.ts +50 -0
- package/src/lib/share.ts +167 -0
- package/src/lib/strip-markdown.ts +38 -0
- package/src/lib/tool-names.ts +27 -0
- package/src/lib/utils.ts +6 -0
- package/src/lib/ws-client.ts +109 -0
- package/src/stores/chat-store.ts +619 -0
- package/src/stores/connection-store.ts +23 -0
- package/src/stores/file-store.ts +344 -0
- package/src/stores/provider-store.ts +85 -0
- package/src/stores/ui-store.ts +48 -0
- package/src/styles.css +4 -0
- package/src/theme.css +425 -0
- package/src/voice/asr-clients.ts +45 -0
- package/src/voice/asr-deepgram.ts +325 -0
- package/src/voice/asr-webspeech.ts +135 -0
- package/src/voice/use-dictation.ts +187 -0
- package/src/voice/voice-store.ts +55 -0
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
2
|
+
import Markdown from "react-markdown";
|
|
3
|
+
import remarkGfm from "remark-gfm";
|
|
4
|
+
import rehypeHighlight from "rehype-highlight";
|
|
5
|
+
import { Check, Copy } from "lucide-react";
|
|
6
|
+
import {
|
|
7
|
+
classifyRepoPath,
|
|
8
|
+
isInternalRepoDir,
|
|
9
|
+
isInternalRepoPath,
|
|
10
|
+
useFileStore,
|
|
11
|
+
} from "../../stores/file-store.js";
|
|
12
|
+
import { useUIStore } from "../../stores/ui-store.js";
|
|
13
|
+
import { ShareBlock, type ShareBlockFormat } from "./share-block.js";
|
|
14
|
+
|
|
15
|
+
const ENTITY_TAGS: Record<string, string> = {
|
|
16
|
+
co: "entity-co",
|
|
17
|
+
p: "entity-p",
|
|
18
|
+
proj: "entity-proj",
|
|
19
|
+
ev: "entity-ev",
|
|
20
|
+
d: "entity-d",
|
|
21
|
+
st: "entity-st",
|
|
22
|
+
f: "entity-f",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Use a unicode marker that won't appear in normal text to wrap entities
|
|
26
|
+
// so they survive markdown parsing without relying on rehypeRaw
|
|
27
|
+
const ENTITY_START = ""; // zero-width spaces as delimiter
|
|
28
|
+
const ENTITY_SEP = "";
|
|
29
|
+
const ENTITY_END = "";
|
|
30
|
+
|
|
31
|
+
function renderEntityTags(md: string): string {
|
|
32
|
+
// Convert entity tags to marker-based format: tagcontent
|
|
33
|
+
let result = md.replace(
|
|
34
|
+
/<(co|p|proj|ev|d|st|f)>([\s\S]*?)<\/\1>/g,
|
|
35
|
+
(_match, tag, content) => `${ENTITY_START}${tag}${ENTITY_SEP}${content}${ENTITY_END}`
|
|
36
|
+
);
|
|
37
|
+
// Clean up any orphaned/unmatched entity tags
|
|
38
|
+
result = result.replace(/<\/?(?:co|p|proj|ev|d|st|f)>/g, "");
|
|
39
|
+
|
|
40
|
+
// Log and strip any remaining non-standard HTML-like tags the LLM may have output
|
|
41
|
+
result = result.replace(/<\/?([a-z][a-z0-9]*)\b[^>]*>/gi, (match, tag) => {
|
|
42
|
+
const allowed = [
|
|
43
|
+
"span", "strong", "em", "b", "i", "a", "br", "hr", "p",
|
|
44
|
+
"h1", "h2", "h3", "h4", "h5", "h6",
|
|
45
|
+
"ul", "ol", "li", "code", "pre", "blockquote",
|
|
46
|
+
"table", "thead", "tbody", "tr", "th", "td",
|
|
47
|
+
"div", "img", "del", "sup", "sub",
|
|
48
|
+
];
|
|
49
|
+
if (allowed.includes(tag.toLowerCase())) return match;
|
|
50
|
+
console.warn("[entity-tags] Stripping unrecognized tag:", match);
|
|
51
|
+
return "";
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Parse entity markers in a text string. Each entity content is run through
|
|
59
|
+
* `innerTransform` so file-path linkification (or any other inline transform)
|
|
60
|
+
* applies inside `<f>...</f>` spans too.
|
|
61
|
+
*/
|
|
62
|
+
function renderEntitiesInText(
|
|
63
|
+
text: string,
|
|
64
|
+
innerTransform: (s: string) => React.ReactNode = (s) => s
|
|
65
|
+
): React.ReactNode[] {
|
|
66
|
+
const pattern = new RegExp(
|
|
67
|
+
`${ENTITY_START}(co|p|proj|ev|d|st|f)${ENTITY_SEP}(.*?)${ENTITY_END}`,
|
|
68
|
+
"g"
|
|
69
|
+
);
|
|
70
|
+
const nodes: React.ReactNode[] = [];
|
|
71
|
+
let lastIndex = 0;
|
|
72
|
+
let match;
|
|
73
|
+
let key = 0;
|
|
74
|
+
|
|
75
|
+
while ((match = pattern.exec(text)) !== null) {
|
|
76
|
+
if (match.index > lastIndex) {
|
|
77
|
+
nodes.push(innerTransform(text.slice(lastIndex, match.index)));
|
|
78
|
+
}
|
|
79
|
+
const cls = ENTITY_TAGS[match[1]] || "";
|
|
80
|
+
nodes.push(
|
|
81
|
+
<span key={key++} className={cls}>
|
|
82
|
+
{innerTransform(match[2])}
|
|
83
|
+
</span>
|
|
84
|
+
);
|
|
85
|
+
lastIndex = pattern.lastIndex;
|
|
86
|
+
}
|
|
87
|
+
if (lastIndex < text.length) {
|
|
88
|
+
nodes.push(innerTransform(text.slice(lastIndex)));
|
|
89
|
+
}
|
|
90
|
+
return nodes;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function CopyButton({ getText }: { getText: () => string }) {
|
|
94
|
+
const [copied, setCopied] = useState(false);
|
|
95
|
+
return (
|
|
96
|
+
<button
|
|
97
|
+
type="button"
|
|
98
|
+
onClick={() => {
|
|
99
|
+
navigator.clipboard.writeText(getText());
|
|
100
|
+
setCopied(true);
|
|
101
|
+
setTimeout(() => setCopied(false), 2000);
|
|
102
|
+
}}
|
|
103
|
+
className="absolute right-2 top-2 flex h-7 w-7 items-center justify-center rounded-md bg-surface-raised/80 text-muted-foreground opacity-0 transition-all hover:bg-surface-overlay hover:text-foreground group-hover:opacity-100"
|
|
104
|
+
>
|
|
105
|
+
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
|
106
|
+
</button>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
interface BrainMarkdownProps {
|
|
111
|
+
content: string;
|
|
112
|
+
className?: string;
|
|
113
|
+
entityTags?: boolean;
|
|
114
|
+
fileLinks?: boolean;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
type ShareSegment = { kind: "share"; format: ShareBlockFormat; title?: string; body: string };
|
|
118
|
+
type TextSegment = { kind: "text"; text: string };
|
|
119
|
+
type Segment = TextSegment | ShareSegment;
|
|
120
|
+
|
|
121
|
+
const SHARE_BLOCK_RE = /<share\b([^>]*)>([\s\S]*?)<\/share>/gi;
|
|
122
|
+
const VALID_FORMATS: ReadonlySet<ShareBlockFormat> = new Set([
|
|
123
|
+
"image",
|
|
124
|
+
"pdf",
|
|
125
|
+
"text",
|
|
126
|
+
"markdown",
|
|
127
|
+
"richtext",
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
function parseShareAttrs(raw: string): { format: ShareBlockFormat; title?: string } {
|
|
131
|
+
const attrs: Record<string, string> = {};
|
|
132
|
+
const re = /(\w+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/g;
|
|
133
|
+
let m: RegExpExecArray | null;
|
|
134
|
+
while ((m = re.exec(raw)) !== null) {
|
|
135
|
+
attrs[m[1].toLowerCase()] = m[2] ?? m[3] ?? m[4] ?? "";
|
|
136
|
+
}
|
|
137
|
+
const format = (attrs.format ?? "image").toLowerCase() as ShareBlockFormat;
|
|
138
|
+
return {
|
|
139
|
+
format: VALID_FORMATS.has(format) ? format : "image",
|
|
140
|
+
title: attrs.title,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function splitShareBlocks(input: string): Segment[] {
|
|
145
|
+
const out: Segment[] = [];
|
|
146
|
+
let last = 0;
|
|
147
|
+
let m: RegExpExecArray | null;
|
|
148
|
+
SHARE_BLOCK_RE.lastIndex = 0;
|
|
149
|
+
while ((m = SHARE_BLOCK_RE.exec(input)) !== null) {
|
|
150
|
+
if (m.index > last) {
|
|
151
|
+
out.push({ kind: "text", text: input.slice(last, m.index) });
|
|
152
|
+
}
|
|
153
|
+
const { format, title } = parseShareAttrs(m[1] ?? "");
|
|
154
|
+
out.push({ kind: "share", format, title, body: m[2].trim() });
|
|
155
|
+
last = SHARE_BLOCK_RE.lastIndex;
|
|
156
|
+
}
|
|
157
|
+
if (last < input.length) {
|
|
158
|
+
out.push({ kind: "text", text: input.slice(last) });
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Wrap any element's render to process entity markers + file paths in its text children */
|
|
164
|
+
function withTextProcessing<T extends keyof React.JSX.IntrinsicElements>(
|
|
165
|
+
Tag: T,
|
|
166
|
+
opts: { entityTags: boolean; fileLinks: boolean }
|
|
167
|
+
) {
|
|
168
|
+
return ({ children, ...props }: React.ComponentPropsWithoutRef<T> & { children?: React.ReactNode }) => (
|
|
169
|
+
React.createElement(Tag, props as any, processChildText(children, opts))
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function BrainMarkdown({ content, className, entityTags = false, fileLinks = false }: BrainMarkdownProps) {
|
|
174
|
+
const segments = splitShareBlocks(content);
|
|
175
|
+
if (segments.length > 1 || (segments.length === 1 && segments[0].kind === "share")) {
|
|
176
|
+
return (
|
|
177
|
+
<>
|
|
178
|
+
{segments.map((seg, i) =>
|
|
179
|
+
seg.kind === "share" ? (
|
|
180
|
+
<ShareBlock key={i} body={seg.body} format={seg.format} title={seg.title} />
|
|
181
|
+
) : seg.text.trim() ? (
|
|
182
|
+
<BrainMarkdownInner
|
|
183
|
+
key={i}
|
|
184
|
+
content={seg.text}
|
|
185
|
+
className={className}
|
|
186
|
+
entityTags={entityTags}
|
|
187
|
+
fileLinks={fileLinks}
|
|
188
|
+
/>
|
|
189
|
+
) : null
|
|
190
|
+
)}
|
|
191
|
+
</>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
return (
|
|
195
|
+
<BrainMarkdownInner
|
|
196
|
+
content={content}
|
|
197
|
+
className={className}
|
|
198
|
+
entityTags={entityTags}
|
|
199
|
+
fileLinks={fileLinks}
|
|
200
|
+
/>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function BrainMarkdownInner({ content, className, entityTags = false, fileLinks = false }: BrainMarkdownProps) {
|
|
205
|
+
const processed = entityTags ? renderEntityTags(content) : content;
|
|
206
|
+
const ensureWikilinks = useFileStore((s) => s.ensureWikilinks);
|
|
207
|
+
|
|
208
|
+
useEffect(() => {
|
|
209
|
+
if (fileLinks) void ensureWikilinks();
|
|
210
|
+
}, [fileLinks, ensureWikilinks]);
|
|
211
|
+
|
|
212
|
+
// Override block/inline elements that can contain text. We need to do this
|
|
213
|
+
// whenever either entityTags or fileLinks is enabled so we can scan text nodes.
|
|
214
|
+
const useTextProcessing = entityTags || fileLinks;
|
|
215
|
+
const opts = { entityTags, fileLinks };
|
|
216
|
+
const textComponents = useTextProcessing
|
|
217
|
+
? {
|
|
218
|
+
p: withTextProcessing("p", opts),
|
|
219
|
+
li: withTextProcessing("li", opts),
|
|
220
|
+
strong: withTextProcessing("strong", opts),
|
|
221
|
+
em: withTextProcessing("em", opts),
|
|
222
|
+
h1: withTextProcessing("h1", opts),
|
|
223
|
+
h2: withTextProcessing("h2", opts),
|
|
224
|
+
h3: withTextProcessing("h3", opts),
|
|
225
|
+
h4: withTextProcessing("h4", opts),
|
|
226
|
+
td: withTextProcessing("td", opts),
|
|
227
|
+
th: withTextProcessing("th", opts),
|
|
228
|
+
// Paths and wikilinks frequently appear inside inline code spans
|
|
229
|
+
// in brain notes (e.g. `talks/_index.md`). Process code elements
|
|
230
|
+
// too. Note: this also linkifies paths inside fenced code blocks,
|
|
231
|
+
// which is harmless in the brain UI context (paths in shell
|
|
232
|
+
// snippets remain clickable). The known downside is that literal
|
|
233
|
+
// `[[slug]]` syntax shown for teaching purposes will attempt to
|
|
234
|
+
// resolve as a wikilink.
|
|
235
|
+
code: withTextProcessing("code", opts),
|
|
236
|
+
}
|
|
237
|
+
: {};
|
|
238
|
+
|
|
239
|
+
return (
|
|
240
|
+
<div className={className ?? "brain-prose"}>
|
|
241
|
+
<Markdown
|
|
242
|
+
remarkPlugins={[remarkGfm]}
|
|
243
|
+
rehypePlugins={entityTags ? [] : [rehypeHighlight]}
|
|
244
|
+
components={{
|
|
245
|
+
...textComponents,
|
|
246
|
+
pre: ({ children, ...props }) => {
|
|
247
|
+
const ref = useRef<HTMLPreElement>(null);
|
|
248
|
+
return (
|
|
249
|
+
<div className="group relative">
|
|
250
|
+
<pre
|
|
251
|
+
ref={ref}
|
|
252
|
+
className="overflow-x-auto rounded-lg border border-border bg-surface p-4 font-[family-name:var(--font-mono)] text-[13px] leading-relaxed"
|
|
253
|
+
{...props}
|
|
254
|
+
>
|
|
255
|
+
{children}
|
|
256
|
+
</pre>
|
|
257
|
+
<CopyButton getText={() => ref.current?.textContent ?? ""} />
|
|
258
|
+
</div>
|
|
259
|
+
);
|
|
260
|
+
},
|
|
261
|
+
table: ({ children, ...props }) => (
|
|
262
|
+
<div className="overflow-x-auto rounded-lg border border-border">
|
|
263
|
+
<table {...props}>{children}</table>
|
|
264
|
+
</div>
|
|
265
|
+
),
|
|
266
|
+
a: ({ href, children, ...props }) => {
|
|
267
|
+
if (fileLinks) {
|
|
268
|
+
const kind = classifyRepoPath(href);
|
|
269
|
+
if (kind === "file") {
|
|
270
|
+
return <FileLink path={href as string}>{children}</FileLink>;
|
|
271
|
+
}
|
|
272
|
+
if (kind === "dir") {
|
|
273
|
+
return <DirLink path={href as string}>{children}</DirLink>;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return (
|
|
277
|
+
<a target="_blank" rel="noopener noreferrer" href={href} {...props}>
|
|
278
|
+
{children}
|
|
279
|
+
</a>
|
|
280
|
+
);
|
|
281
|
+
},
|
|
282
|
+
}}
|
|
283
|
+
>
|
|
284
|
+
{processed}
|
|
285
|
+
</Markdown>
|
|
286
|
+
</div>
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Click target for a repo-relative file reference. Opens the file panel + viewer. */
|
|
291
|
+
export function FileLink({ path, children }: { path: string; children?: React.ReactNode }) {
|
|
292
|
+
const openFile = useFileStore((s) => s.openFile);
|
|
293
|
+
const setFilePanelOpen = useUIStore((s) => s.setFilePanelOpen);
|
|
294
|
+
return (
|
|
295
|
+
<a
|
|
296
|
+
href={`#/files/${path}`}
|
|
297
|
+
onClick={(e) => {
|
|
298
|
+
e.preventDefault();
|
|
299
|
+
setFilePanelOpen(true);
|
|
300
|
+
void openFile(path);
|
|
301
|
+
}}
|
|
302
|
+
className="brain-file-link"
|
|
303
|
+
title={path}
|
|
304
|
+
>
|
|
305
|
+
{children ?? path}
|
|
306
|
+
</a>
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Resolve an obsidian-style `[[target]]` to a repo-relative path using the
|
|
312
|
+
* wikilink map. Targets containing `/` are treated as paths directly; bare
|
|
313
|
+
* slugs are looked up by basename. An `#anchor` suffix is stripped before
|
|
314
|
+
* lookup.
|
|
315
|
+
*/
|
|
316
|
+
function resolveWikilinkTarget(
|
|
317
|
+
raw: string,
|
|
318
|
+
slugMap: Record<string, string>
|
|
319
|
+
): string | null {
|
|
320
|
+
const hashIdx = raw.indexOf("#");
|
|
321
|
+
const noAnchor = hashIdx >= 0 ? raw.slice(0, hashIdx).trim() : raw.trim();
|
|
322
|
+
if (!noAnchor) return null;
|
|
323
|
+
if (noAnchor.includes("/")) {
|
|
324
|
+
if (noAnchor.endsWith("/")) return null;
|
|
325
|
+
return /\.[a-z0-9]{1,8}$/i.test(noAnchor) ? noAnchor : `${noAnchor}.md`;
|
|
326
|
+
}
|
|
327
|
+
return slugMap[noAnchor.toLowerCase()] ?? null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Click target for `[[slug]]` / `[[slug|Label]]`. */
|
|
331
|
+
export function WikiLink({
|
|
332
|
+
target,
|
|
333
|
+
label,
|
|
334
|
+
}: {
|
|
335
|
+
target: string;
|
|
336
|
+
label?: string;
|
|
337
|
+
}) {
|
|
338
|
+
const slugMap = useFileStore((s) => s.wikilinkMap);
|
|
339
|
+
const loaded = useFileStore((s) => s.wikilinkLoaded);
|
|
340
|
+
const openFile = useFileStore((s) => s.openFile);
|
|
341
|
+
const setFilePanelOpen = useUIStore((s) => s.setFilePanelOpen);
|
|
342
|
+
|
|
343
|
+
const resolved = resolveWikilinkTarget(target, slugMap);
|
|
344
|
+
const display = label ?? target;
|
|
345
|
+
|
|
346
|
+
if (!resolved) {
|
|
347
|
+
return (
|
|
348
|
+
<span
|
|
349
|
+
className="brain-wiki-link brain-wiki-link--unresolved"
|
|
350
|
+
title={loaded ? `Unresolved wikilink: ${target}` : "Resolving…"}
|
|
351
|
+
>
|
|
352
|
+
{display}
|
|
353
|
+
</span>
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return (
|
|
358
|
+
<a
|
|
359
|
+
href={`#/files/${resolved}`}
|
|
360
|
+
onClick={(e) => {
|
|
361
|
+
e.preventDefault();
|
|
362
|
+
setFilePanelOpen(true);
|
|
363
|
+
void openFile(resolved);
|
|
364
|
+
}}
|
|
365
|
+
className="brain-file-link brain-wiki-link"
|
|
366
|
+
title={resolved}
|
|
367
|
+
>
|
|
368
|
+
{display}
|
|
369
|
+
</a>
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Click target for a repo-relative directory reference. Opens the file
|
|
375
|
+
* panel, expands the tree down to the directory, and highlights it so
|
|
376
|
+
* the user sees its contents.
|
|
377
|
+
*/
|
|
378
|
+
export function DirLink({ path, children }: { path: string; children?: React.ReactNode }) {
|
|
379
|
+
const openDir = useFileStore((s) => s.openDir);
|
|
380
|
+
const setFilePanelOpen = useUIStore((s) => s.setFilePanelOpen);
|
|
381
|
+
const trimmed = path.replace(/\/+$/, "");
|
|
382
|
+
const display = `${trimmed}/`;
|
|
383
|
+
return (
|
|
384
|
+
<a
|
|
385
|
+
href={`#/files/${display}`}
|
|
386
|
+
onClick={(e) => {
|
|
387
|
+
e.preventDefault();
|
|
388
|
+
setFilePanelOpen(true);
|
|
389
|
+
void openDir(trimmed);
|
|
390
|
+
}}
|
|
391
|
+
className="brain-file-link brain-file-link--dir"
|
|
392
|
+
title={display}
|
|
393
|
+
>
|
|
394
|
+
{children ?? display}
|
|
395
|
+
</a>
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Bare-path tokens in prose. Three passes:
|
|
400
|
+
// - files: "notes/projects/foo.md" — ends with .ext
|
|
401
|
+
// - dirs: "notes/projects/post-templates/" — ends with /
|
|
402
|
+
// - wikilinks: "[[slug]]" or "[[slug|Display]]" — obsidian-style
|
|
403
|
+
const BARE_FILE_RE =
|
|
404
|
+
/\b([a-z][a-z0-9_-]*(?:\/[a-z0-9._-]+)+\.[a-z0-9]{1,8})\b/gi;
|
|
405
|
+
const BARE_DIR_RE =
|
|
406
|
+
/\b([a-z][a-z0-9_-]*(?:\/[a-z0-9._-]+)+\/)(?=$|[\s,;:!?)\]])/gi;
|
|
407
|
+
const WIKILINK_RE = /\[\[([^\[\]\n|]+?)(?:\|([^\[\]\n]+?))?\]\]/g;
|
|
408
|
+
|
|
409
|
+
interface PathMatch {
|
|
410
|
+
start: number;
|
|
411
|
+
end: number;
|
|
412
|
+
kind: "file" | "dir" | "wikilink";
|
|
413
|
+
/** For file/dir kinds. */
|
|
414
|
+
path?: string;
|
|
415
|
+
/** Raw target inside the brackets (may include `#anchor`). For wikilink. */
|
|
416
|
+
target?: string;
|
|
417
|
+
/** Display label override (after the pipe). For wikilink. */
|
|
418
|
+
label?: string;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function scanBarePaths(text: string): PathMatch[] {
|
|
422
|
+
const out: PathMatch[] = [];
|
|
423
|
+
BARE_FILE_RE.lastIndex = 0;
|
|
424
|
+
let m: RegExpExecArray | null;
|
|
425
|
+
while ((m = BARE_FILE_RE.exec(text)) !== null) {
|
|
426
|
+
if (isInternalRepoPath(m[1])) {
|
|
427
|
+
out.push({
|
|
428
|
+
start: m.index,
|
|
429
|
+
end: BARE_FILE_RE.lastIndex,
|
|
430
|
+
path: m[1],
|
|
431
|
+
kind: "file",
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
BARE_DIR_RE.lastIndex = 0;
|
|
436
|
+
while ((m = BARE_DIR_RE.exec(text)) !== null) {
|
|
437
|
+
if (isInternalRepoDir(m[1])) {
|
|
438
|
+
out.push({
|
|
439
|
+
start: m.index,
|
|
440
|
+
end: BARE_DIR_RE.lastIndex,
|
|
441
|
+
path: m[1],
|
|
442
|
+
kind: "dir",
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
WIKILINK_RE.lastIndex = 0;
|
|
447
|
+
while ((m = WIKILINK_RE.exec(text)) !== null) {
|
|
448
|
+
out.push({
|
|
449
|
+
start: m.index,
|
|
450
|
+
end: WIKILINK_RE.lastIndex,
|
|
451
|
+
kind: "wikilink",
|
|
452
|
+
target: m[1].trim(),
|
|
453
|
+
label: m[2]?.trim(),
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
out.sort((a, b) => a.start - b.start);
|
|
457
|
+
|
|
458
|
+
// Drop overlapping matches — keep the first (earlier) when ranges collide
|
|
459
|
+
const merged: PathMatch[] = [];
|
|
460
|
+
let cursor = 0;
|
|
461
|
+
for (const cand of out) {
|
|
462
|
+
if (cand.start >= cursor) {
|
|
463
|
+
merged.push(cand);
|
|
464
|
+
cursor = cand.end;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return merged;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function renderBarePathsInText(text: string): React.ReactNode[] {
|
|
471
|
+
const matches = scanBarePaths(text);
|
|
472
|
+
if (matches.length === 0) return [text];
|
|
473
|
+
const nodes: React.ReactNode[] = [];
|
|
474
|
+
let last = 0;
|
|
475
|
+
let key = 0;
|
|
476
|
+
for (const m of matches) {
|
|
477
|
+
if (m.start > last) nodes.push(text.slice(last, m.start));
|
|
478
|
+
if (m.kind === "file" && m.path) {
|
|
479
|
+
nodes.push(
|
|
480
|
+
<FileLink key={`p${key++}`} path={m.path}>
|
|
481
|
+
{m.path}
|
|
482
|
+
</FileLink>
|
|
483
|
+
);
|
|
484
|
+
} else if (m.kind === "dir" && m.path) {
|
|
485
|
+
nodes.push(
|
|
486
|
+
<DirLink key={`p${key++}`} path={m.path.replace(/\/+$/, "")}>
|
|
487
|
+
{m.path}
|
|
488
|
+
</DirLink>
|
|
489
|
+
);
|
|
490
|
+
} else if (m.kind === "wikilink" && m.target) {
|
|
491
|
+
nodes.push(
|
|
492
|
+
<WikiLink key={`p${key++}`} target={m.target} label={m.label} />
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
last = m.end;
|
|
496
|
+
}
|
|
497
|
+
if (last < text.length) nodes.push(text.slice(last));
|
|
498
|
+
return nodes;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Public helper: turn any raw text blob into React nodes with bare-path
|
|
503
|
+
* tokens replaced by FileLink elements. Use this in non-markdown surfaces
|
|
504
|
+
* (tool outputs, streaming logs, plain user text).
|
|
505
|
+
*/
|
|
506
|
+
export function linkifyPaths(text: string): React.ReactNode {
|
|
507
|
+
if (!text) return text;
|
|
508
|
+
const parts = renderBarePathsInText(text);
|
|
509
|
+
if (parts.length === 1 && typeof parts[0] === "string") return parts[0];
|
|
510
|
+
return <>{parts}</>;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** Process a text string for entity markers and/or bare file paths. */
|
|
514
|
+
function transformTextString(
|
|
515
|
+
text: string,
|
|
516
|
+
opts: { entityTags: boolean; fileLinks: boolean }
|
|
517
|
+
): React.ReactNode {
|
|
518
|
+
const linkify = opts.fileLinks
|
|
519
|
+
? (s: string): React.ReactNode => {
|
|
520
|
+
if (!s) return s;
|
|
521
|
+
const parts = renderBarePathsInText(s);
|
|
522
|
+
if (parts.length === 1 && typeof parts[0] === "string") return parts[0];
|
|
523
|
+
return <>{parts}</>;
|
|
524
|
+
}
|
|
525
|
+
: (s: string): React.ReactNode => s;
|
|
526
|
+
|
|
527
|
+
if (opts.entityTags && text.includes(ENTITY_START)) {
|
|
528
|
+
return <>{renderEntitiesInText(text, opts.fileLinks ? (s) => linkify(s) : undefined)}</>;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
return linkify(text);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** Recursively process React children to expand entity markers / file paths in text nodes */
|
|
535
|
+
function processChildText(children: React.ReactNode, opts: { entityTags: boolean; fileLinks: boolean }): React.ReactNode {
|
|
536
|
+
if (typeof children === "string") {
|
|
537
|
+
return transformTextString(children, opts);
|
|
538
|
+
}
|
|
539
|
+
if (Array.isArray(children)) {
|
|
540
|
+
return children.map((child, i) =>
|
|
541
|
+
typeof child === "string"
|
|
542
|
+
? <React.Fragment key={i}>{transformTextString(child, opts)}</React.Fragment>
|
|
543
|
+
: child
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
return children;
|
|
547
|
+
}
|