@schlessera/brain-ui-react 0.33.1 → 0.34.1
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/README.md +5 -0
- package/dist/components/activity/activity-page.d.ts.map +1 -1
- package/dist/components/activity/activity-page.js +8 -149
- package/dist/components/activity/activity-page.js.map +1 -1
- package/dist/components/activity/activity-rollups.d.ts +5 -0
- package/dist/components/activity/activity-rollups.d.ts.map +1 -0
- package/dist/components/activity/activity-rollups.js +39 -0
- package/dist/components/activity/activity-rollups.js.map +1 -0
- package/dist/components/activity/activity-run-detail.d.ts +11 -0
- package/dist/components/activity/activity-run-detail.d.ts.map +1 -0
- package/dist/components/activity/activity-run-detail.js +111 -0
- package/dist/components/activity/activity-run-detail.js.map +1 -0
- package/dist/components/activity/activity-run-list.d.ts +15 -0
- package/dist/components/activity/activity-run-list.d.ts.map +1 -0
- package/dist/components/activity/activity-run-list.js +25 -0
- package/dist/components/activity/activity-run-list.js.map +1 -0
- package/dist/components/chat/brain-markdown-code.d.ts +4 -0
- package/dist/components/chat/brain-markdown-code.d.ts.map +1 -0
- package/dist/components/chat/brain-markdown-code.js +47 -0
- package/dist/components/chat/brain-markdown-code.js.map +1 -0
- package/dist/components/chat/brain-markdown-entities.d.ts +14 -0
- package/dist/components/chat/brain-markdown-entities.d.ts.map +1 -0
- package/dist/components/chat/brain-markdown-entities.js +93 -0
- package/dist/components/chat/brain-markdown-entities.js.map +1 -0
- package/dist/components/chat/brain-markdown-links.d.ts +36 -0
- package/dist/components/chat/brain-markdown-links.d.ts.map +1 -0
- package/dist/components/chat/brain-markdown-links.js +176 -0
- package/dist/components/chat/brain-markdown-links.js.map +1 -0
- package/dist/components/chat/brain-markdown-share.d.ts +15 -0
- package/dist/components/chat/brain-markdown-share.d.ts.map +1 -0
- package/dist/components/chat/brain-markdown-share.js +40 -0
- package/dist/components/chat/brain-markdown-share.js.map +1 -0
- package/dist/components/chat/brain-markdown.d.ts +1 -34
- package/dist/components/chat/brain-markdown.d.ts.map +1 -1
- package/dist/components/chat/brain-markdown.js +9 -402
- package/dist/components/chat/brain-markdown.js.map +1 -1
- package/dist/components/chat/composer.d.ts.map +1 -1
- package/dist/components/chat/composer.js +14 -1
- package/dist/components/chat/composer.js.map +1 -1
- package/dist/components/chat/use-markdown-highlight.d.ts +12 -0
- package/dist/components/chat/use-markdown-highlight.d.ts.map +1 -0
- package/dist/components/chat/use-markdown-highlight.js +56 -0
- package/dist/components/chat/use-markdown-highlight.js.map +1 -0
- package/dist/components/connectivity/connection-gate.d.ts.map +1 -1
- package/dist/components/connectivity/connection-gate.js +41 -7
- package/dist/components/connectivity/connection-gate.js.map +1 -1
- package/dist/components/connectivity/connection-state.d.ts +12 -0
- package/dist/components/connectivity/connection-state.d.ts.map +1 -0
- package/dist/components/connectivity/connection-state.js +23 -0
- package/dist/components/connectivity/connection-state.js.map +1 -0
- package/dist/components/graph/graph-canvas-lazy.d.ts +2 -0
- package/dist/components/graph/graph-canvas-lazy.d.ts.map +1 -0
- package/dist/components/graph/graph-canvas-lazy.js +4 -0
- package/dist/components/graph/graph-canvas-lazy.js.map +1 -0
- package/dist/components/graph/graph-controls.js +4 -1
- package/dist/components/graph/graph-controls.js.map +1 -1
- package/dist/components/graph/graph-maintenance.d.ts +8 -0
- package/dist/components/graph/graph-maintenance.d.ts.map +1 -0
- package/dist/components/graph/graph-maintenance.js +115 -0
- package/dist/components/graph/graph-maintenance.js.map +1 -0
- package/dist/components/graph/graph-page.d.ts.map +1 -1
- package/dist/components/graph/graph-page.js +11 -306
- package/dist/components/graph/graph-page.js.map +1 -1
- package/dist/components/graph/graph-scene.d.ts +8 -0
- package/dist/components/graph/graph-scene.d.ts.map +1 -0
- package/dist/components/graph/graph-scene.js +208 -0
- package/dist/components/graph/graph-scene.js.map +1 -0
- package/dist/components/graph/graph-spinner.d.ts +2 -0
- package/dist/components/graph/graph-spinner.d.ts.map +1 -0
- package/dist/components/graph/graph-spinner.js +6 -0
- package/dist/components/graph/graph-spinner.js.map +1 -0
- package/dist/hooks/use-vpn-status.d.ts +5 -0
- package/dist/hooks/use-vpn-status.d.ts.map +1 -1
- package/dist/hooks/use-vpn-status.js +61 -29
- package/dist/hooks/use-vpn-status.js.map +1 -1
- package/dist/hooks/use-websocket.d.ts +3 -2
- package/dist/hooks/use-websocket.d.ts.map +1 -1
- package/dist/hooks/use-websocket.js +61 -252
- package/dist/hooks/use-websocket.js.map +1 -1
- package/dist/hooks/websocket-handlers/activity.d.ts +5 -0
- package/dist/hooks/websocket-handlers/activity.d.ts.map +1 -0
- package/dist/hooks/websocket-handlers/activity.js +10 -0
- package/dist/hooks/websocket-handlers/activity.js.map +1 -0
- package/dist/hooks/websocket-handlers/chat.d.ts +17 -0
- package/dist/hooks/websocket-handlers/chat.d.ts.map +1 -0
- package/dist/hooks/websocket-handlers/chat.js +175 -0
- package/dist/hooks/websocket-handlers/chat.js.map +1 -0
- package/dist/hooks/websocket-handlers/connection.d.ts +6 -0
- package/dist/hooks/websocket-handlers/connection.d.ts.map +1 -0
- package/dist/hooks/websocket-handlers/connection.js +27 -0
- package/dist/hooks/websocket-handlers/connection.js.map +1 -0
- package/dist/hooks/websocket-handlers/index.d.ts +4 -0
- package/dist/hooks/websocket-handlers/index.d.ts.map +1 -0
- package/dist/hooks/websocket-handlers/index.js +19 -0
- package/dist/hooks/websocket-handlers/index.js.map +1 -0
- package/dist/hooks/websocket-handlers/mask.d.ts +4 -0
- package/dist/hooks/websocket-handlers/mask.d.ts.map +1 -0
- package/dist/hooks/websocket-handlers/mask.js +14 -0
- package/dist/hooks/websocket-handlers/mask.js.map +1 -0
- package/dist/hooks/websocket-handlers/provider.d.ts +4 -0
- package/dist/hooks/websocket-handlers/provider.d.ts.map +1 -0
- package/dist/hooks/websocket-handlers/provider.js +31 -0
- package/dist/hooks/websocket-handlers/provider.js.map +1 -0
- package/dist/hooks/websocket-handlers/types.d.ts +24 -0
- package/dist/hooks/websocket-handlers/types.d.ts.map +1 -0
- package/dist/hooks/websocket-handlers/types.js +2 -0
- package/dist/hooks/websocket-handlers/types.js.map +1 -0
- package/dist/stores/activity-store-helpers.d.ts +50 -0
- package/dist/stores/activity-store-helpers.d.ts.map +1 -0
- package/dist/stores/activity-store-helpers.js +143 -0
- package/dist/stores/activity-store-helpers.js.map +1 -0
- package/dist/stores/activity-store-types.d.ts +48 -0
- package/dist/stores/activity-store-types.d.ts.map +1 -0
- package/dist/stores/activity-store-types.js +2 -0
- package/dist/stores/activity-store-types.js.map +1 -0
- package/dist/stores/activity-store.d.ts +2 -78
- package/dist/stores/activity-store.d.ts.map +1 -1
- package/dist/stores/activity-store.js +22 -142
- package/dist/stores/activity-store.js.map +1 -1
- package/dist/stores/connection-store.d.ts +14 -2
- package/dist/stores/connection-store.d.ts.map +1 -1
- package/dist/stores/connection-store.js +17 -0
- package/dist/stores/connection-store.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +2 -2
- package/src/components/activity/activity-page.tsx +6 -477
- package/src/components/activity/activity-rollups.tsx +94 -0
- package/src/components/activity/activity-run-detail.tsx +301 -0
- package/src/components/activity/activity-run-list.tsx +97 -0
- package/src/components/chat/brain-markdown-code.tsx +60 -0
- package/src/components/chat/brain-markdown-entities.tsx +116 -0
- package/src/components/chat/brain-markdown-links.tsx +247 -0
- package/src/components/chat/brain-markdown-share.ts +47 -0
- package/src/components/chat/brain-markdown.tsx +27 -535
- package/src/components/chat/composer.tsx +14 -1
- package/src/components/chat/use-markdown-highlight.ts +63 -0
- package/src/components/connectivity/connection-gate.tsx +64 -13
- package/src/components/connectivity/connection-state.ts +37 -0
- package/src/components/graph/graph-canvas-lazy.tsx +4 -0
- package/src/components/graph/graph-controls.tsx +4 -1
- package/src/components/graph/graph-maintenance.tsx +304 -0
- package/src/components/graph/graph-page.tsx +11 -840
- package/src/components/graph/graph-scene.tsx +548 -0
- package/src/components/graph/graph-spinner.tsx +9 -0
- package/src/hooks/use-vpn-status.ts +64 -31
- package/src/hooks/use-websocket.ts +70 -273
- package/src/hooks/websocket-handlers/activity.ts +13 -0
- package/src/hooks/websocket-handlers/chat.ts +210 -0
- package/src/hooks/websocket-handlers/connection.ts +30 -0
- package/src/hooks/websocket-handlers/index.ts +59 -0
- package/src/hooks/websocket-handlers/mask.ts +17 -0
- package/src/hooks/websocket-handlers/provider.ts +35 -0
- package/src/hooks/websocket-handlers/types.ts +27 -0
- package/src/stores/activity-store-helpers.ts +164 -0
- package/src/stores/activity-store-types.ts +55 -0
- package/src/stores/activity-store.ts +21 -217
- package/src/stores/connection-store.ts +33 -2
|
@@ -1,176 +1,35 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
useEffect,
|
|
5
|
-
useMemo,
|
|
6
|
-
useRef,
|
|
7
|
-
useSyncExternalStore,
|
|
8
|
-
} from "react";
|
|
9
|
-
import Markdown, { type Options as MarkdownOptions } from "react-markdown";
|
|
10
|
-
import remarkGfm from "remark-gfm";
|
|
1
|
+
import React, { memo, useEffect, useMemo } from "react";
|
|
2
|
+
import Markdown from "react-markdown";
|
|
3
|
+
|
|
11
4
|
import {
|
|
12
5
|
classifyRepoPath,
|
|
13
|
-
isInternalRepoDir,
|
|
14
|
-
isInternalRepoPath,
|
|
15
6
|
useFileStore,
|
|
16
7
|
} from "../../stores/file-store.js";
|
|
17
|
-
import {
|
|
18
|
-
import { ShareBlock, type ShareBlockFormat } from "./share-block.js";
|
|
19
|
-
import { CopyButton } from "./copy-button.js";
|
|
8
|
+
import { ShareBlock } from "./share-block.js";
|
|
20
9
|
import { ZoomableImage } from "../images/zoomable-image.js";
|
|
21
|
-
import {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
(_match, tag, content) => `${ENTITY_START}${tag}${ENTITY_SEP}${content}${ENTITY_END}`
|
|
45
|
-
);
|
|
46
|
-
// Clean up any orphaned/unmatched entity tags
|
|
47
|
-
result = result.replace(/<\/?(?:co|p|proj|ev|d|st|f)>/g, "");
|
|
48
|
-
|
|
49
|
-
// Log and strip any remaining non-standard HTML-like tags the LLM may have output
|
|
50
|
-
result = result.replace(/<\/?([a-z][a-z0-9]*)\b[^>]*>/gi, (match, tag) => {
|
|
51
|
-
const allowed = [
|
|
52
|
-
"span", "strong", "em", "b", "i", "a", "br", "hr", "p",
|
|
53
|
-
"h1", "h2", "h3", "h4", "h5", "h6",
|
|
54
|
-
"ul", "ol", "li", "code", "pre", "blockquote",
|
|
55
|
-
"table", "thead", "tbody", "tr", "th", "td",
|
|
56
|
-
"div", "img", "del", "sup", "sub",
|
|
57
|
-
];
|
|
58
|
-
if (allowed.includes(tag.toLowerCase())) return match;
|
|
59
|
-
console.warn("[entity-tags] Stripping unrecognized tag:", match);
|
|
60
|
-
return "";
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
return result;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Turn a markdown image source into something the browser can actually load.
|
|
68
|
-
*
|
|
69
|
-
* Anything already absolute — a `data:` URI, an http(s) URL, or a path that is
|
|
70
|
-
* already an API route — passes through. A repo-relative path is rewritten to
|
|
71
|
-
* the files endpoint, which is where brain content is served from.
|
|
72
|
-
*/
|
|
73
|
-
export function repoImageSrc(src: string): string {
|
|
74
|
-
const trimmed = src.trim();
|
|
75
|
-
if (!trimmed) return src;
|
|
76
|
-
if (/^(data:|blob:|https?:\/\/)/i.test(trimmed)) return trimmed;
|
|
77
|
-
if (trimmed.startsWith("/api/")) return trimmed;
|
|
78
|
-
const rel = trimmed.replace(/^\.\//, "").replace(/^\/+/, "");
|
|
79
|
-
return `${apiBase()}/files/content?path=${encodeURIComponent(rel)}&raw=1`;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Parse entity markers in a text string. Each entity content is run through
|
|
84
|
-
* `innerTransform` so file-path linkification (or any other inline transform)
|
|
85
|
-
* applies inside `<f>...</f>` spans too.
|
|
86
|
-
*/
|
|
87
|
-
function renderEntitiesInText(
|
|
88
|
-
text: string,
|
|
89
|
-
innerTransform: (s: string) => React.ReactNode = (s) => s
|
|
90
|
-
): React.ReactNode[] {
|
|
91
|
-
const pattern = new RegExp(
|
|
92
|
-
`${ENTITY_START}(co|p|proj|ev|d|st|f)${ENTITY_SEP}(.*?)${ENTITY_END}`,
|
|
93
|
-
"g"
|
|
94
|
-
);
|
|
95
|
-
const nodes: React.ReactNode[] = [];
|
|
96
|
-
let lastIndex = 0;
|
|
97
|
-
let match;
|
|
98
|
-
let key = 0;
|
|
99
|
-
|
|
100
|
-
while ((match = pattern.exec(text)) !== null) {
|
|
101
|
-
if (match.index > lastIndex) {
|
|
102
|
-
nodes.push(innerTransform(text.slice(lastIndex, match.index)));
|
|
103
|
-
}
|
|
104
|
-
const cls = ENTITY_TAGS[match[1]] || "";
|
|
105
|
-
nodes.push(
|
|
106
|
-
<span key={key++} className={cls}>
|
|
107
|
-
{innerTransform(match[2])}
|
|
108
|
-
</span>
|
|
109
|
-
);
|
|
110
|
-
lastIndex = pattern.lastIndex;
|
|
111
|
-
}
|
|
112
|
-
if (lastIndex < text.length) {
|
|
113
|
-
nodes.push(innerTransform(text.slice(lastIndex)));
|
|
114
|
-
}
|
|
115
|
-
return nodes;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/** Concatenate all text descendants of a React node tree. */
|
|
119
|
-
function extractText(node: React.ReactNode): string {
|
|
120
|
-
if (typeof node === "string") return node;
|
|
121
|
-
if (typeof node === "number") return String(node);
|
|
122
|
-
if (Array.isArray(node)) return node.map(extractText).join("");
|
|
123
|
-
if (React.isValidElement(node)) {
|
|
124
|
-
return extractText((node.props as { children?: React.ReactNode }).children);
|
|
125
|
-
}
|
|
126
|
-
return "";
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* If a <pre>'s children are a ```mermaid / ```mmd fence, recover the raw
|
|
131
|
-
* diagram source. Text is extracted recursively because rehype-highlight
|
|
132
|
-
* and the linkify pass may have wrapped parts of it in elements.
|
|
133
|
-
*/
|
|
134
|
-
function mermaidSourceFrom(children: React.ReactNode): string | null {
|
|
135
|
-
for (const child of React.Children.toArray(children)) {
|
|
136
|
-
if (!React.isValidElement(child)) continue;
|
|
137
|
-
const props = child.props as { className?: string; children?: React.ReactNode };
|
|
138
|
-
if (typeof props.className === "string" && /\blanguage-(?:mermaid|mmd)\b/.test(props.className)) {
|
|
139
|
-
return extractText(props.children).replace(/\n$/, "");
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
return null;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function MarkdownPre({ children, ...props }: React.ComponentPropsWithoutRef<"pre">) {
|
|
146
|
-
const mermaid = mermaidSourceFrom(children);
|
|
147
|
-
if (mermaid !== null) return <MermaidBlock source={mermaid} />;
|
|
148
|
-
return <CodePre {...props}>{children}</CodePre>;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function CodePre({ children, ...props }: React.ComponentPropsWithoutRef<"pre">) {
|
|
152
|
-
const ref = useRef<HTMLPreElement>(null);
|
|
153
|
-
return (
|
|
154
|
-
<div className="group relative">
|
|
155
|
-
<pre
|
|
156
|
-
ref={ref}
|
|
157
|
-
className="overflow-x-auto rounded-lg border border-border bg-surface p-4 font-[family-name:var(--font-mono)] text-[13px] leading-relaxed"
|
|
158
|
-
{...props}
|
|
159
|
-
>
|
|
160
|
-
{children}
|
|
161
|
-
</pre>
|
|
162
|
-
<CopyButton getText={() => ref.current?.textContent ?? ""} />
|
|
163
|
-
</div>
|
|
164
|
-
);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function MarkdownTable({ children, ...props }: React.ComponentPropsWithoutRef<"table">) {
|
|
168
|
-
return (
|
|
169
|
-
<div className="overflow-x-auto rounded-lg border border-border">
|
|
170
|
-
<table {...props}>{children}</table>
|
|
171
|
-
</div>
|
|
172
|
-
);
|
|
173
|
-
}
|
|
10
|
+
import { MarkdownPre, MarkdownTable } from "./brain-markdown-code.js";
|
|
11
|
+
import {
|
|
12
|
+
processChildText,
|
|
13
|
+
renderEntityTags,
|
|
14
|
+
} from "./brain-markdown-entities.js";
|
|
15
|
+
import {
|
|
16
|
+
DirLink,
|
|
17
|
+
FileLink,
|
|
18
|
+
repoImageSrc,
|
|
19
|
+
} from "./brain-markdown-links.js";
|
|
20
|
+
import { splitShareBlocks } from "./brain-markdown-share.js";
|
|
21
|
+
import {
|
|
22
|
+
REMARK_PLUGINS,
|
|
23
|
+
useRehypePlugins,
|
|
24
|
+
} from "./use-markdown-highlight.js";
|
|
25
|
+
|
|
26
|
+
export {
|
|
27
|
+
DirLink,
|
|
28
|
+
FileLink,
|
|
29
|
+
WikiLink,
|
|
30
|
+
linkifyPaths,
|
|
31
|
+
repoImageSrc,
|
|
32
|
+
} from "./brain-markdown-links.js";
|
|
174
33
|
|
|
175
34
|
interface BrainMarkdownProps {
|
|
176
35
|
content: string;
|
|
@@ -178,53 +37,6 @@ interface BrainMarkdownProps {
|
|
|
178
37
|
entityTags?: boolean;
|
|
179
38
|
fileLinks?: boolean;
|
|
180
39
|
}
|
|
181
|
-
|
|
182
|
-
type ShareSegment = { kind: "share"; format: ShareBlockFormat; title?: string; body: string };
|
|
183
|
-
type TextSegment = { kind: "text"; text: string };
|
|
184
|
-
type Segment = TextSegment | ShareSegment;
|
|
185
|
-
|
|
186
|
-
const SHARE_BLOCK_RE = /<share\b([^>]*)>([\s\S]*?)<\/share>/gi;
|
|
187
|
-
const VALID_FORMATS: ReadonlySet<ShareBlockFormat> = new Set([
|
|
188
|
-
"image",
|
|
189
|
-
"pdf",
|
|
190
|
-
"text",
|
|
191
|
-
"markdown",
|
|
192
|
-
"richtext",
|
|
193
|
-
]);
|
|
194
|
-
|
|
195
|
-
function parseShareAttrs(raw: string): { format: ShareBlockFormat; title?: string } {
|
|
196
|
-
const attrs: Record<string, string> = {};
|
|
197
|
-
const re = /(\w+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/g;
|
|
198
|
-
let m: RegExpExecArray | null;
|
|
199
|
-
while ((m = re.exec(raw)) !== null) {
|
|
200
|
-
attrs[m[1].toLowerCase()] = m[2] ?? m[3] ?? m[4] ?? "";
|
|
201
|
-
}
|
|
202
|
-
const format = (attrs.format ?? "image").toLowerCase() as ShareBlockFormat;
|
|
203
|
-
return {
|
|
204
|
-
format: VALID_FORMATS.has(format) ? format : "image",
|
|
205
|
-
title: attrs.title,
|
|
206
|
-
};
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
function splitShareBlocks(input: string): Segment[] {
|
|
210
|
-
const out: Segment[] = [];
|
|
211
|
-
let last = 0;
|
|
212
|
-
let m: RegExpExecArray | null;
|
|
213
|
-
SHARE_BLOCK_RE.lastIndex = 0;
|
|
214
|
-
while ((m = SHARE_BLOCK_RE.exec(input)) !== null) {
|
|
215
|
-
if (m.index > last) {
|
|
216
|
-
out.push({ kind: "text", text: input.slice(last, m.index) });
|
|
217
|
-
}
|
|
218
|
-
const { format, title } = parseShareAttrs(m[1] ?? "");
|
|
219
|
-
out.push({ kind: "share", format, title, body: m[2].trim() });
|
|
220
|
-
last = SHARE_BLOCK_RE.lastIndex;
|
|
221
|
-
}
|
|
222
|
-
if (last < input.length) {
|
|
223
|
-
out.push({ kind: "text", text: input.slice(last) });
|
|
224
|
-
}
|
|
225
|
-
return out;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
40
|
/** Wrap any element's render to process entity markers + file paths in its text children */
|
|
229
41
|
function withTextProcessing<T extends keyof React.JSX.IntrinsicElements>(
|
|
230
42
|
Tag: T,
|
|
@@ -234,67 +46,6 @@ function withTextProcessing<T extends keyof React.JSX.IntrinsicElements>(
|
|
|
234
46
|
React.createElement(Tag, props as any, processChildText(children, opts))
|
|
235
47
|
);
|
|
236
48
|
}
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
* Plugin lists, hoisted out of render.
|
|
240
|
-
*
|
|
241
|
-
* react-markdown builds a fresh unified processor on every render and reruns
|
|
242
|
-
* the whole parse — there is no internal memoization to lean on. Fresh array
|
|
243
|
-
* literals in the JSX made that unavoidable; module constants let the memo
|
|
244
|
-
* below actually hold.
|
|
245
|
-
*/
|
|
246
|
-
const REMARK_PLUGINS = [remarkGfm];
|
|
247
|
-
const NO_REHYPE_PLUGINS: NonNullable<MarkdownOptions["rehypePlugins"]> = [];
|
|
248
|
-
|
|
249
|
-
/**
|
|
250
|
-
* Syntax highlighting arrives after first paint.
|
|
251
|
-
*
|
|
252
|
-
* rehype-highlight pulls in highlight.js, 166 KB of the entry bundle — more
|
|
253
|
-
* than every markdown parser here put together — and nothing on the first paint
|
|
254
|
-
* needs it. It is fetched once, on the first mount of a highlighting renderer;
|
|
255
|
-
* until it lands, code blocks render as plain text and then gain their colours.
|
|
256
|
-
* In practice it is loaded long before the first assistant message arrives.
|
|
257
|
-
*
|
|
258
|
-
* The module-level cache means the fetch happens once per page, not once per
|
|
259
|
-
* message, and useSyncExternalStore is what lets every mounted renderer pick up
|
|
260
|
-
* the plugin the moment it does.
|
|
261
|
-
*/
|
|
262
|
-
let highlightPlugins: NonNullable<MarkdownOptions["rehypePlugins"]> | null = null;
|
|
263
|
-
let highlightRequested = false;
|
|
264
|
-
const highlightListeners = new Set<() => void>();
|
|
265
|
-
|
|
266
|
-
function subscribeHighlight(onChange: () => void): () => void {
|
|
267
|
-
highlightListeners.add(onChange);
|
|
268
|
-
return () => {
|
|
269
|
-
highlightListeners.delete(onChange);
|
|
270
|
-
};
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
function readHighlightPlugins(): NonNullable<MarkdownOptions["rehypePlugins"]> {
|
|
274
|
-
return highlightPlugins ?? NO_REHYPE_PLUGINS;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
function useRehypePlugins(enabled: boolean) {
|
|
278
|
-
const plugins = useSyncExternalStore(
|
|
279
|
-
subscribeHighlight,
|
|
280
|
-
readHighlightPlugins,
|
|
281
|
-
readHighlightPlugins
|
|
282
|
-
);
|
|
283
|
-
useEffect(() => {
|
|
284
|
-
if (!enabled || highlightRequested) return;
|
|
285
|
-
highlightRequested = true;
|
|
286
|
-
import("rehype-highlight")
|
|
287
|
-
.then((mod) => {
|
|
288
|
-
highlightPlugins = [mod.default];
|
|
289
|
-
for (const listener of highlightListeners) listener();
|
|
290
|
-
})
|
|
291
|
-
.catch(() => {
|
|
292
|
-
// Soft-fail: code blocks stay readable, just uncoloured.
|
|
293
|
-
});
|
|
294
|
-
}, [enabled]);
|
|
295
|
-
return enabled ? plugins : NO_REHYPE_PLUGINS;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
49
|
/**
|
|
299
50
|
* Memoized because rendering it means parsing markdown, and the transcript
|
|
300
51
|
* re-renders whenever anything about the surrounding message changes. All four
|
|
@@ -429,262 +180,3 @@ const BrainMarkdownInner = memo(function BrainMarkdownInner({ content, className
|
|
|
429
180
|
</div>
|
|
430
181
|
);
|
|
431
182
|
});
|
|
432
|
-
|
|
433
|
-
/** Click target for a repo-relative file reference. Opens the file panel + viewer. */
|
|
434
|
-
export function FileLink({ path, children }: { path: string; children?: React.ReactNode }) {
|
|
435
|
-
const openFile = useFileStore((s) => s.openFile);
|
|
436
|
-
const setFilePanelOpen = useUIStore((s) => s.setFilePanelOpen);
|
|
437
|
-
return (
|
|
438
|
-
<a
|
|
439
|
-
href={`#/files/${path}`}
|
|
440
|
-
onClick={(e) => {
|
|
441
|
-
e.preventDefault();
|
|
442
|
-
setFilePanelOpen(true);
|
|
443
|
-
void openFile(path);
|
|
444
|
-
}}
|
|
445
|
-
className="brain-file-link"
|
|
446
|
-
title={path}
|
|
447
|
-
>
|
|
448
|
-
{children ?? path}
|
|
449
|
-
</a>
|
|
450
|
-
);
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
/**
|
|
454
|
-
* Resolve an obsidian-style `[[target]]` to a repo-relative path using the
|
|
455
|
-
* wikilink map. Targets containing `/` are treated as paths directly; bare
|
|
456
|
-
* slugs are looked up by basename. An `#anchor` suffix is stripped before
|
|
457
|
-
* lookup.
|
|
458
|
-
*/
|
|
459
|
-
function resolveWikilinkTarget(
|
|
460
|
-
raw: string,
|
|
461
|
-
slugMap: Record<string, string>
|
|
462
|
-
): string | null {
|
|
463
|
-
const hashIdx = raw.indexOf("#");
|
|
464
|
-
const noAnchor = hashIdx >= 0 ? raw.slice(0, hashIdx).trim() : raw.trim();
|
|
465
|
-
if (!noAnchor) return null;
|
|
466
|
-
if (noAnchor.includes("/")) {
|
|
467
|
-
if (noAnchor.endsWith("/")) return null;
|
|
468
|
-
return /\.[a-z0-9]{1,8}$/i.test(noAnchor) ? noAnchor : `${noAnchor}.md`;
|
|
469
|
-
}
|
|
470
|
-
return slugMap[noAnchor.toLowerCase()] ?? null;
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
/** Click target for `[[slug]]` / `[[slug|Label]]`. */
|
|
474
|
-
export function WikiLink({
|
|
475
|
-
target,
|
|
476
|
-
label,
|
|
477
|
-
}: {
|
|
478
|
-
target: string;
|
|
479
|
-
label?: string;
|
|
480
|
-
}) {
|
|
481
|
-
const slugMap = useFileStore((s) => s.wikilinkMap);
|
|
482
|
-
const loaded = useFileStore((s) => s.wikilinkLoaded);
|
|
483
|
-
const openFile = useFileStore((s) => s.openFile);
|
|
484
|
-
const setFilePanelOpen = useUIStore((s) => s.setFilePanelOpen);
|
|
485
|
-
|
|
486
|
-
const resolved = resolveWikilinkTarget(target, slugMap);
|
|
487
|
-
const display = label ?? target;
|
|
488
|
-
|
|
489
|
-
if (!resolved) {
|
|
490
|
-
return (
|
|
491
|
-
<span
|
|
492
|
-
className="brain-wiki-link brain-wiki-link--unresolved"
|
|
493
|
-
title={loaded ? `Unresolved wikilink: ${target}` : "Resolving…"}
|
|
494
|
-
>
|
|
495
|
-
{display}
|
|
496
|
-
</span>
|
|
497
|
-
);
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
return (
|
|
501
|
-
<a
|
|
502
|
-
href={`#/files/${resolved}`}
|
|
503
|
-
onClick={(e) => {
|
|
504
|
-
e.preventDefault();
|
|
505
|
-
setFilePanelOpen(true);
|
|
506
|
-
void openFile(resolved);
|
|
507
|
-
}}
|
|
508
|
-
className="brain-file-link brain-wiki-link"
|
|
509
|
-
title={resolved}
|
|
510
|
-
>
|
|
511
|
-
{display}
|
|
512
|
-
</a>
|
|
513
|
-
);
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
/**
|
|
517
|
-
* Click target for a repo-relative directory reference. Opens the file
|
|
518
|
-
* panel, expands the tree down to the directory, and highlights it so
|
|
519
|
-
* the user sees its contents.
|
|
520
|
-
*/
|
|
521
|
-
export function DirLink({ path, children }: { path: string; children?: React.ReactNode }) {
|
|
522
|
-
const openDir = useFileStore((s) => s.openDir);
|
|
523
|
-
const setFilePanelOpen = useUIStore((s) => s.setFilePanelOpen);
|
|
524
|
-
const trimmed = path.replace(/\/+$/, "");
|
|
525
|
-
const display = `${trimmed}/`;
|
|
526
|
-
return (
|
|
527
|
-
<a
|
|
528
|
-
href={`#/files/${display}`}
|
|
529
|
-
onClick={(e) => {
|
|
530
|
-
e.preventDefault();
|
|
531
|
-
setFilePanelOpen(true);
|
|
532
|
-
void openDir(trimmed);
|
|
533
|
-
}}
|
|
534
|
-
className="brain-file-link brain-file-link--dir"
|
|
535
|
-
title={display}
|
|
536
|
-
>
|
|
537
|
-
{children ?? display}
|
|
538
|
-
</a>
|
|
539
|
-
);
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
// Bare-path tokens in prose. Three passes:
|
|
543
|
-
// - files: "notes/projects/foo.md" — ends with .ext
|
|
544
|
-
// - dirs: "notes/projects/post-templates/" — ends with /
|
|
545
|
-
// - wikilinks: "[[slug]]" or "[[slug|Display]]" — obsidian-style
|
|
546
|
-
const BARE_FILE_RE =
|
|
547
|
-
/\b([a-z][a-z0-9_-]*(?:\/[a-z0-9._-]+)+\.[a-z0-9]{1,8})\b/gi;
|
|
548
|
-
const BARE_DIR_RE =
|
|
549
|
-
/\b([a-z][a-z0-9_-]*(?:\/[a-z0-9._-]+)+\/)(?=$|[\s,;:!?)\]])/gi;
|
|
550
|
-
const WIKILINK_RE = /\[\[([^\[\]\n|]+?)(?:\|([^\[\]\n]+?))?\]\]/g;
|
|
551
|
-
|
|
552
|
-
interface PathMatch {
|
|
553
|
-
start: number;
|
|
554
|
-
end: number;
|
|
555
|
-
kind: "file" | "dir" | "wikilink";
|
|
556
|
-
/** For file/dir kinds. */
|
|
557
|
-
path?: string;
|
|
558
|
-
/** Raw target inside the brackets (may include `#anchor`). For wikilink. */
|
|
559
|
-
target?: string;
|
|
560
|
-
/** Display label override (after the pipe). For wikilink. */
|
|
561
|
-
label?: string;
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
function scanBarePaths(text: string): PathMatch[] {
|
|
565
|
-
const out: PathMatch[] = [];
|
|
566
|
-
BARE_FILE_RE.lastIndex = 0;
|
|
567
|
-
let m: RegExpExecArray | null;
|
|
568
|
-
while ((m = BARE_FILE_RE.exec(text)) !== null) {
|
|
569
|
-
if (isInternalRepoPath(m[1])) {
|
|
570
|
-
out.push({
|
|
571
|
-
start: m.index,
|
|
572
|
-
end: BARE_FILE_RE.lastIndex,
|
|
573
|
-
path: m[1],
|
|
574
|
-
kind: "file",
|
|
575
|
-
});
|
|
576
|
-
}
|
|
577
|
-
}
|
|
578
|
-
BARE_DIR_RE.lastIndex = 0;
|
|
579
|
-
while ((m = BARE_DIR_RE.exec(text)) !== null) {
|
|
580
|
-
if (isInternalRepoDir(m[1])) {
|
|
581
|
-
out.push({
|
|
582
|
-
start: m.index,
|
|
583
|
-
end: BARE_DIR_RE.lastIndex,
|
|
584
|
-
path: m[1],
|
|
585
|
-
kind: "dir",
|
|
586
|
-
});
|
|
587
|
-
}
|
|
588
|
-
}
|
|
589
|
-
WIKILINK_RE.lastIndex = 0;
|
|
590
|
-
while ((m = WIKILINK_RE.exec(text)) !== null) {
|
|
591
|
-
out.push({
|
|
592
|
-
start: m.index,
|
|
593
|
-
end: WIKILINK_RE.lastIndex,
|
|
594
|
-
kind: "wikilink",
|
|
595
|
-
target: m[1].trim(),
|
|
596
|
-
label: m[2]?.trim(),
|
|
597
|
-
});
|
|
598
|
-
}
|
|
599
|
-
out.sort((a, b) => a.start - b.start);
|
|
600
|
-
|
|
601
|
-
// Drop overlapping matches — keep the first (earlier) when ranges collide
|
|
602
|
-
const merged: PathMatch[] = [];
|
|
603
|
-
let cursor = 0;
|
|
604
|
-
for (const cand of out) {
|
|
605
|
-
if (cand.start >= cursor) {
|
|
606
|
-
merged.push(cand);
|
|
607
|
-
cursor = cand.end;
|
|
608
|
-
}
|
|
609
|
-
}
|
|
610
|
-
return merged;
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
function renderBarePathsInText(text: string): React.ReactNode[] {
|
|
614
|
-
const matches = scanBarePaths(text);
|
|
615
|
-
if (matches.length === 0) return [text];
|
|
616
|
-
const nodes: React.ReactNode[] = [];
|
|
617
|
-
let last = 0;
|
|
618
|
-
let key = 0;
|
|
619
|
-
for (const m of matches) {
|
|
620
|
-
if (m.start > last) nodes.push(text.slice(last, m.start));
|
|
621
|
-
if (m.kind === "file" && m.path) {
|
|
622
|
-
nodes.push(
|
|
623
|
-
<FileLink key={`p${key++}`} path={m.path}>
|
|
624
|
-
{m.path}
|
|
625
|
-
</FileLink>
|
|
626
|
-
);
|
|
627
|
-
} else if (m.kind === "dir" && m.path) {
|
|
628
|
-
nodes.push(
|
|
629
|
-
<DirLink key={`p${key++}`} path={m.path.replace(/\/+$/, "")}>
|
|
630
|
-
{m.path}
|
|
631
|
-
</DirLink>
|
|
632
|
-
);
|
|
633
|
-
} else if (m.kind === "wikilink" && m.target) {
|
|
634
|
-
nodes.push(
|
|
635
|
-
<WikiLink key={`p${key++}`} target={m.target} label={m.label} />
|
|
636
|
-
);
|
|
637
|
-
}
|
|
638
|
-
last = m.end;
|
|
639
|
-
}
|
|
640
|
-
if (last < text.length) nodes.push(text.slice(last));
|
|
641
|
-
return nodes;
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
/**
|
|
645
|
-
* Public helper: turn any raw text blob into React nodes with bare-path
|
|
646
|
-
* tokens replaced by FileLink elements. Use this in non-markdown surfaces
|
|
647
|
-
* (tool outputs, streaming logs, plain user text).
|
|
648
|
-
*/
|
|
649
|
-
export function linkifyPaths(text: string): React.ReactNode {
|
|
650
|
-
if (!text) return text;
|
|
651
|
-
const parts = renderBarePathsInText(text);
|
|
652
|
-
if (parts.length === 1 && typeof parts[0] === "string") return parts[0];
|
|
653
|
-
return <>{parts}</>;
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
/** Process a text string for entity markers and/or bare file paths. */
|
|
657
|
-
function transformTextString(
|
|
658
|
-
text: string,
|
|
659
|
-
opts: { entityTags: boolean; fileLinks: boolean }
|
|
660
|
-
): React.ReactNode {
|
|
661
|
-
const linkify = opts.fileLinks
|
|
662
|
-
? (s: string): React.ReactNode => {
|
|
663
|
-
if (!s) return s;
|
|
664
|
-
const parts = renderBarePathsInText(s);
|
|
665
|
-
if (parts.length === 1 && typeof parts[0] === "string") return parts[0];
|
|
666
|
-
return <>{parts}</>;
|
|
667
|
-
}
|
|
668
|
-
: (s: string): React.ReactNode => s;
|
|
669
|
-
|
|
670
|
-
if (opts.entityTags && text.includes(ENTITY_START)) {
|
|
671
|
-
return <>{renderEntitiesInText(text, opts.fileLinks ? (s) => linkify(s) : undefined)}</>;
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
return linkify(text);
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
/** Recursively process React children to expand entity markers / file paths in text nodes */
|
|
678
|
-
function processChildText(children: React.ReactNode, opts: { entityTags: boolean; fileLinks: boolean }): React.ReactNode {
|
|
679
|
-
if (typeof children === "string") {
|
|
680
|
-
return transformTextString(children, opts);
|
|
681
|
-
}
|
|
682
|
-
if (Array.isArray(children)) {
|
|
683
|
-
return children.map((child, i) =>
|
|
684
|
-
typeof child === "string"
|
|
685
|
-
? <React.Fragment key={i}>{transformTextString(child, opts)}</React.Fragment>
|
|
686
|
-
: child
|
|
687
|
-
);
|
|
688
|
-
}
|
|
689
|
-
return children;
|
|
690
|
-
}
|
|
@@ -14,6 +14,7 @@ import type { ClientMessage } from "@schlessera/brain-ui-sdk/protocol";
|
|
|
14
14
|
import { useChatStore, activeChat } from "../../stores/chat-store.js";
|
|
15
15
|
import { uiConfig } from "../../config.js";
|
|
16
16
|
import { useConnectionStore } from "../../stores/connection-store.js";
|
|
17
|
+
import { deriveConnectionIssue } from "../connectivity/connection-state.js";
|
|
17
18
|
import { useProviderStore } from "../../stores/provider-store.js";
|
|
18
19
|
import {
|
|
19
20
|
fileToAttachment,
|
|
@@ -75,6 +76,14 @@ export function Composer({ send }: { send: (msg: ClientMessage) => void }) {
|
|
|
75
76
|
const isStreaming = useChatStore((s) => activeChat(s).isStreaming);
|
|
76
77
|
const sessionId = useChatStore((s) => s.activeSessionId);
|
|
77
78
|
const wsStatus = useConnectionStore((s) => s.wsStatus);
|
|
79
|
+
const vpnStatus = useConnectionStore((s) => s.vpnStatus);
|
|
80
|
+
const handshakeFailures = useConnectionStore((s) => s.handshakeFailures);
|
|
81
|
+
const lastCloseCode = useConnectionStore((s) => s.lastCloseCode);
|
|
82
|
+
const connectionIssue = deriveConnectionIssue({
|
|
83
|
+
vpnStatus,
|
|
84
|
+
handshakeFailures,
|
|
85
|
+
lastCloseCode,
|
|
86
|
+
});
|
|
78
87
|
|
|
79
88
|
const providers = useProviderStore((s) => s.available);
|
|
80
89
|
const selectedProviderId = useProviderStore((s) => s.selectedId);
|
|
@@ -461,7 +470,11 @@ export function Composer({ send }: { send: (msg: ClientMessage) => void }) {
|
|
|
461
470
|
}}
|
|
462
471
|
placeholder={
|
|
463
472
|
wsStatus !== "connected"
|
|
464
|
-
? "
|
|
473
|
+
? connectionIssue === "capacity"
|
|
474
|
+
? "Server connection limit reached"
|
|
475
|
+
: connectionIssue === "refused"
|
|
476
|
+
? "Server refused the connection"
|
|
477
|
+
: "Connecting..."
|
|
465
478
|
: uiConfig.composerPlaceholder
|
|
466
479
|
}
|
|
467
480
|
disabled={wsStatus !== "connected"}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { useEffect, useSyncExternalStore } from "react";
|
|
2
|
+
import type { Options as MarkdownOptions } from "react-markdown";
|
|
3
|
+
import remarkGfm from "remark-gfm";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Plugin lists, hoisted out of render.
|
|
7
|
+
*
|
|
8
|
+
* react-markdown builds a fresh unified processor on every render and reruns
|
|
9
|
+
* the whole parse — there is no internal memoization to lean on. Fresh array
|
|
10
|
+
* literals in the JSX made that unavoidable; module constants let the memo
|
|
11
|
+
* below actually hold.
|
|
12
|
+
*/
|
|
13
|
+
export const REMARK_PLUGINS = [remarkGfm];
|
|
14
|
+
const NO_REHYPE_PLUGINS: NonNullable<MarkdownOptions["rehypePlugins"]> = [];
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Syntax highlighting arrives after first paint.
|
|
18
|
+
*
|
|
19
|
+
* rehype-highlight pulls in highlight.js, 166 KB of the entry bundle — more
|
|
20
|
+
* than every markdown parser here put together — and nothing on the first paint
|
|
21
|
+
* needs it. It is fetched once, on the first mount of a highlighting renderer;
|
|
22
|
+
* until it lands, code blocks render as plain text and then gain their colours.
|
|
23
|
+
* In practice it is loaded long before the first assistant message arrives.
|
|
24
|
+
*
|
|
25
|
+
* The module-level cache means the fetch happens once per page, not once per
|
|
26
|
+
* message, and useSyncExternalStore is what lets every mounted renderer pick up
|
|
27
|
+
* the plugin the moment it does.
|
|
28
|
+
*/
|
|
29
|
+
let highlightPlugins: NonNullable<MarkdownOptions["rehypePlugins"]> | null = null;
|
|
30
|
+
let highlightRequested = false;
|
|
31
|
+
const highlightListeners = new Set<() => void>();
|
|
32
|
+
|
|
33
|
+
function subscribeHighlight(onChange: () => void): () => void {
|
|
34
|
+
highlightListeners.add(onChange);
|
|
35
|
+
return () => {
|
|
36
|
+
highlightListeners.delete(onChange);
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function readHighlightPlugins(): NonNullable<MarkdownOptions["rehypePlugins"]> {
|
|
41
|
+
return highlightPlugins ?? NO_REHYPE_PLUGINS;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function useRehypePlugins(enabled: boolean) {
|
|
45
|
+
const plugins = useSyncExternalStore(
|
|
46
|
+
subscribeHighlight,
|
|
47
|
+
readHighlightPlugins,
|
|
48
|
+
readHighlightPlugins
|
|
49
|
+
);
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (!enabled || highlightRequested) return;
|
|
52
|
+
highlightRequested = true;
|
|
53
|
+
import("rehype-highlight")
|
|
54
|
+
.then((mod) => {
|
|
55
|
+
highlightPlugins = [mod.default];
|
|
56
|
+
for (const listener of highlightListeners) listener();
|
|
57
|
+
})
|
|
58
|
+
.catch(() => {
|
|
59
|
+
// Soft-fail: code blocks stay readable, just uncoloured.
|
|
60
|
+
});
|
|
61
|
+
}, [enabled]);
|
|
62
|
+
return enabled ? plugins : NO_REHYPE_PLUGINS;
|
|
63
|
+
}
|