Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
Menu
Open sidebar
OpenDAS
ollama
Commits
a42f826a
Unverified
Commit
a42f826a
authored
Nov 10, 2025
by
Eva H
Committed by
GitHub
Nov 10, 2025
Browse files
app/ui: using streamdown AI elements for markdown rendering
parents
e10a3533
3501a4bd
Changes
10
Expand all
Hide whitespace changes
Inline
Side-by-side
Showing
10 changed files
with
1849 additions
and
2635 deletions
+1849
-2635
app/ui/app/package-lock.json
app/ui/app/package-lock.json
+1476
-44
app/ui/app/package.json
app/ui/app/package.json
+1
-0
app/ui/app/src/components/StreamingMarkdownContent.stories.tsx
...i/app/src/components/StreamingMarkdownContent.stories.tsx
+0
-614
app/ui/app/src/components/StreamingMarkdownContent.test.tsx
app/ui/app/src/components/StreamingMarkdownContent.test.tsx
+0
-522
app/ui/app/src/components/StreamingMarkdownContent.tsx
app/ui/app/src/components/StreamingMarkdownContent.tsx
+210
-193
app/ui/app/src/components/Thinking.tsx
app/ui/app/src/components/Thinking.tsx
+6
-4
app/ui/app/src/index.css
app/ui/app/src/index.css
+0
-787
app/ui/app/src/lib/highlighter.ts
app/ui/app/src/lib/highlighter.ts
+156
-0
app/ui/app/src/utils/processStreamingMarkdown.ts
app/ui/app/src/utils/processStreamingMarkdown.ts
+0
-24
app/ui/app/src/utils/remarkStreamingMarkdown.ts
app/ui/app/src/utils/remarkStreamingMarkdown.ts
+0
-447
No files found.
app/ui/app/package-lock.json
View file @
a42f826a
This diff is collapsed.
Click to expand it.
app/ui/app/package.json
View file @
a42f826a
...
@@ -34,6 +34,7 @@
...
@@ -34,6 +34,7 @@
"rehype-raw"
:
"^7.0.0"
,
"rehype-raw"
:
"^7.0.0"
,
"rehype-sanitize"
:
"^6.0.0"
,
"rehype-sanitize"
:
"^6.0.0"
,
"remark-math"
:
"^6.0.0"
,
"remark-math"
:
"^6.0.0"
,
"streamdown"
:
"^1.4.0"
,
"unist-builder"
:
"^4.0.0"
,
"unist-builder"
:
"^4.0.0"
,
"unist-util-parents"
:
"^3.0.0"
"unist-util-parents"
:
"^3.0.0"
},
},
...
...
app/ui/app/src/components/StreamingMarkdownContent.stories.tsx
deleted
100644 → 0
View file @
e10a3533
This diff is collapsed.
Click to expand it.
app/ui/app/src/components/StreamingMarkdownContent.test.tsx
deleted
100644 → 0
View file @
e10a3533
import
{
expect
,
test
,
suite
}
from
"
vitest
"
;
import
{
processStreamingMarkdown
}
from
"
@/utils/processStreamingMarkdown
"
;
suite
(
"
common llm outputs that cause issues
"
,
()
=>
{
test
(
"
prefix of bolded list item shouldn't make a horizontal line
"
,
()
=>
{
// we're going to go in order of incrementally adding characters. This
// happens really commonly with LLMs that like to make lists like so:
//
// * **point 1**: explanatory text
// * **point 2**: more explanatory text
//
// Partial rendering of `*` (A), followed by `* *` (B), followed by `* **`
// (C) is a total mess. (A) renders as a single bullet point in an
// otherwise empty list, (B) renders as two nested lists (and therefore
// two bullet points, styled differently by default in html), and (C)
// renders as a horizontal line because in markdown apparently `***` or `*
// * *` horizontal rules don't have as strict whitespace rules as I
// expected them to
// these are alone (i.e., they would be the first list item)
expect
(
processStreamingMarkdown
(
"
*
"
)).
toBe
(
""
);
expect
(
processStreamingMarkdown
(
"
* *
"
)).
toBe
(
""
);
expect
(
processStreamingMarkdown
(
"
* **
"
)).
toBe
(
""
);
// expect(processStreamingMarkdown("* **b")).toBe("* **b**");
// with a list item before them
expect
(
processStreamingMarkdown
(
// prettier-ignore
[
"
* abc
"
,
"
*
"
].
join
(
"
\n
"
),
),
).
toBe
(
"
* abc
"
);
expect
(
processStreamingMarkdown
(
// prettier-ignore
[
"
* abc
"
,
"
* *
"
].
join
(
"
\n
"
),
),
).
toBe
(
"
* abc
"
);
expect
(
processStreamingMarkdown
(
// prettier-ignore
[
"
* abc
"
,
"
* **
"
].
join
(
"
\n
"
),
),
).
toBe
(
"
* abc
"
);
});
test
(
"
bolded list items with text should be rendered properly
"
,
()
=>
{
expect
(
processStreamingMarkdown
(
"
* **abc**
"
)).
toBe
(
"
* **abc**
"
);
});
test
(
"
partially bolded list items should be autoclosed
"
,
()
=>
{
expect
(
processStreamingMarkdown
(
"
* **abc
"
)).
toBe
(
"
* **abc**
"
);
});
suite
(
"
partially bolded list items should be autoclosed, even if the last node isn't a text node
"
,
()
=>
{
test
(
"
inline code
"
,
()
=>
{
expect
(
processStreamingMarkdown
(
"
* **Asynchronous Function `async`*
"
),
).
toBe
(
"
* **Asynchronous Function `async`**
"
);
});
},
);
});
suite
(
"
autoclosing bold
"
,
()
=>
{
suite
(
"
endings with no asterisks
"
,
()
=>
{
test
(
"
should autoclose bold
"
,
()
=>
{
expect
(
processStreamingMarkdown
(
"
**abc
"
)).
toBe
(
"
**abc**
"
);
expect
(
processStreamingMarkdown
(
"
abc **abc
"
)).
toBe
(
"
abc **abc**
"
);
});
suite
(
"
should autoclose, even if the last node isn't a text node
"
,
()
=>
{
test
(
"
inline code
"
,
()
=>
{
expect
(
processStreamingMarkdown
(
"
* **Asynchronous Function `async`
"
),
).
toBe
(
"
* **Asynchronous Function `async`**
"
);
});
test
(
"
opening ** is at the end of the text
"
,
()
=>
{
expect
(
processStreamingMarkdown
(
"
abc **`def` jhk [lmn](opq)
"
)).
toBe
(
"
abc **`def` jhk [lmn](opq)**
"
,
);
});
test
(
"
if there's a space after the **, it should NOT be autoclosed
"
,
()
=>
{
expect
(
processStreamingMarkdown
(
"
abc ** `def` jhk [lmn](opq)
"
)).
toBe
(
"
abc
\\
*
\\
* `def` jhk [lmn](opq)
"
,
);
});
});
test
(
"
should autoclose bold, even if the last node isn't a text node
"
,
()
=>
{
expect
(
processStreamingMarkdown
(
"
* **Asynchronous Function ( `async`
"
),
).
toBe
(
"
* **Asynchronous Function ( `async`**
"
);
});
test
(
"
whitespace fakeouts should not be modified
"
,
()
=>
{
expect
(
processStreamingMarkdown
(
"
** abc
"
)).
toBe
(
"
\\
*
\\
* abc
"
);
});
// TODO(drifkin): arguably this should just be removed entirely, but empty
// isn't so bad
test
(
"
should handle empty bolded items
"
,
()
=>
{
expect
(
processStreamingMarkdown
(
"
**
"
)).
toBe
(
""
);
});
});
suite
(
"
partially closed bolded items
"
,
()
=>
{
test
(
"
simple partial
"
,
()
=>
{
expect
(
processStreamingMarkdown
(
"
**abc*
"
)).
toBe
(
"
**abc**
"
);
});
test
(
"
partial with non-text node at end
"
,
()
=>
{
expect
(
processStreamingMarkdown
(
"
**abc`def`*
"
)).
toBe
(
"
**abc`def`**
"
);
});
test
(
"
partial with multiply nested ending nodes
"
,
()
=>
{
expect
(
processStreamingMarkdown
(
"
**abc[abc](`def`)*
"
)).
toBe
(
"
**abc[abc](`def`)**
"
,
);
});
test
(
"
normal emphasis should not be affected
"
,
()
=>
{
expect
(
processStreamingMarkdown
(
"
*abc*
"
)).
toBe
(
"
*abc*
"
);
});
test
(
"
normal emphasis with nested code should not be affected
"
,
()
=>
{
expect
(
processStreamingMarkdown
(
"
*`abc`*
"
)).
toBe
(
"
*`abc`*
"
);
});
});
test
.
skip
(
"
shouldn't autoclose immediately if there's a space before the closing *
"
,
()
=>
{
expect
(
processStreamingMarkdown
(
"
**abc *
"
)).
toBe
(
"
**abc**
"
);
});
// skipping for now because this requires partial link completion as well
suite
.
skip
(
"
nested blocks that each need autoclosing
"
,
()
=>
{
test
(
"
emph nested in link nested in strong nested in list item
"
,
()
=>
{
expect
(
processStreamingMarkdown
(
"
* **[abc **def
"
)).
toBe
(
"
* **[abc **def**]()**
"
,
);
});
test
(
"
* **[ab *`def`
"
,
()
=>
{
expect
(
processStreamingMarkdown
(
"
* **[ab *`def`
"
)).
toBe
(
"
* **[ab *`def`*]()**
"
,
);
});
});
});
suite
(
"
numbered list items
"
,
()
=>
{
test
(
"
should remove trailing numbers
"
,
()
=>
{
expect
(
processStreamingMarkdown
(
"
1. First
\n
2
"
)).
toBe
(
"
1. First
"
);
});
test
(
"
should remove trailing numbers with breaks before
"
,
()
=>
{
expect
(
processStreamingMarkdown
(
"
1. First
\n
2
"
)).
toBe
(
"
1. First
"
);
});
test
(
"
should remove trailing numbers that form a new paragraph
"
,
()
=>
{
expect
(
processStreamingMarkdown
(
"
1. First
\n\n
2
"
)).
toBe
(
"
1. First
"
);
});
test
(
"
but should leave list items separated by two newlines
"
,
()
=>
{
expect
(
processStreamingMarkdown
(
"
1. First
\n\n
2. S
"
)).
toBe
(
"
1. First
\n\n
2. S
"
,
);
});
});
// TODO(drifkin):slop tests ahead, some are decent, but need to manually go
// through them as I implement
/*
describe("StreamingMarkdownContent - processStreamingMarkdown", () => {
describe("Ambiguous endings removal", () => {
it("should remove list markers at the end", () => {
expect(processStreamingMarkdown("Some text\n* ")).toBe("Some text");
expect(processStreamingMarkdown("Some text\n*")).toBe("Some text");
expect(processStreamingMarkdown("* Item 1\n- ")).toBe("* Item 1");
expect(processStreamingMarkdown("* Item 1\n-")).toBe("* Item 1");
expect(processStreamingMarkdown("Text\n+ ")).toBe("Text");
expect(processStreamingMarkdown("Text\n+")).toBe("Text");
expect(processStreamingMarkdown("1. First\n2. ")).toBe("1. First");
});
it("should remove heading markers at the end", () => {
expect(processStreamingMarkdown("Some text\n# ")).toBe("Some text");
expect(processStreamingMarkdown("Some text\n#")).toBe("Some text\n#"); // # without space is not removed
expect(processStreamingMarkdown("# Title\n## ")).toBe("# Title");
expect(processStreamingMarkdown("# Title\n##")).toBe("# Title\n##"); // ## without space is not removed
});
it("should remove ambiguous bold markers at the end", () => {
expect(processStreamingMarkdown("Text **")).toBe("Text ");
expect(processStreamingMarkdown("Some text\n**")).toBe("Some text");
});
it("should remove code block markers at the end", () => {
expect(processStreamingMarkdown("Text\n```")).toBe("Text");
expect(processStreamingMarkdown("```")).toBe("");
});
it("should remove single backtick at the end", () => {
expect(processStreamingMarkdown("Text `")).toBe("Text ");
expect(processStreamingMarkdown("`")).toBe("");
});
it("should remove single asterisk at the end", () => {
expect(processStreamingMarkdown("Text *")).toBe("Text ");
expect(processStreamingMarkdown("*")).toBe("");
});
it("should handle empty content", () => {
expect(processStreamingMarkdown("")).toBe("");
});
it("should handle single line removals correctly", () => {
expect(processStreamingMarkdown("* ")).toBe("");
expect(processStreamingMarkdown("# ")).toBe("");
expect(processStreamingMarkdown("**")).toBe("");
expect(processStreamingMarkdown("`")).toBe("");
});
it("shouldn't have this regexp capture group bug", () => {
expect(
processStreamingMarkdown("Here's a shopping list:\n*"),
).not.toContain("0*");
expect(processStreamingMarkdown("Here's a shopping list:\n*")).toBe(
"Here's a shopping list:",
);
});
});
describe("List markers", () => {
it("should preserve complete list items", () => {
expect(processStreamingMarkdown("* Complete item")).toBe(
"* Complete item",
);
expect(processStreamingMarkdown("- Another item")).toBe("- Another item");
expect(processStreamingMarkdown("+ Plus item")).toBe("+ Plus item");
expect(processStreamingMarkdown("1. Numbered item")).toBe(
"1. Numbered item",
);
});
it("should handle indented list markers", () => {
expect(processStreamingMarkdown(" * ")).toBe(" ");
expect(processStreamingMarkdown(" - ")).toBe(" ");
expect(processStreamingMarkdown("\t+ ")).toBe("\t");
});
});
describe("Heading markers", () => {
it("should preserve complete headings", () => {
expect(processStreamingMarkdown("# Complete Heading")).toBe(
"# Complete Heading",
);
expect(processStreamingMarkdown("## Subheading")).toBe("## Subheading");
expect(processStreamingMarkdown("### H3 Title")).toBe("### H3 Title");
});
it("should not affect # in other contexts", () => {
expect(processStreamingMarkdown("C# programming")).toBe("C# programming");
expect(processStreamingMarkdown("Issue #123")).toBe("Issue #123");
});
});
describe("Bold text", () => {
it("should close incomplete bold text", () => {
expect(processStreamingMarkdown("This is **bold text")).toBe(
"This is **bold text**",
);
expect(processStreamingMarkdown("Start **bold and more")).toBe(
"Start **bold and more**",
);
expect(processStreamingMarkdown("**just bold")).toBe("**just bold**");
});
it("should not affect complete bold text", () => {
expect(processStreamingMarkdown("**complete bold**")).toBe(
"**complete bold**",
);
expect(processStreamingMarkdown("Text **bold** more")).toBe(
"Text **bold** more",
);
});
it("should handle nested bold correctly", () => {
expect(processStreamingMarkdown("**bold** and **another")).toBe(
"**bold** and **another**",
);
});
});
describe("Italic text", () => {
it("should close incomplete italic text", () => {
expect(processStreamingMarkdown("This is *italic text")).toBe(
"This is *italic text*",
);
expect(processStreamingMarkdown("Start *italic and more")).toBe(
"Start *italic and more*",
);
});
it("should differentiate between list markers and italic", () => {
expect(processStreamingMarkdown("* Item\n* ")).toBe("* Item");
expect(processStreamingMarkdown("Some *italic text")).toBe(
"Some *italic text*",
);
expect(processStreamingMarkdown("*just italic")).toBe("*just italic*");
});
it("should not affect complete italic text", () => {
expect(processStreamingMarkdown("*complete italic*")).toBe(
"*complete italic*",
);
expect(processStreamingMarkdown("Text *italic* more")).toBe(
"Text *italic* more",
);
});
});
describe("Code blocks", () => {
it("should close incomplete code blocks", () => {
expect(processStreamingMarkdown("```javascript\nconst x = 42;")).toBe(
"```javascript\nconst x = 42;\n```",
);
expect(processStreamingMarkdown("```\ncode here")).toBe(
"```\ncode here\n```",
);
});
it("should not affect complete code blocks", () => {
expect(processStreamingMarkdown("```\ncode\n```")).toBe("```\ncode\n```");
expect(processStreamingMarkdown("```js\nconst x = 1;\n```")).toBe(
"```js\nconst x = 1;\n```",
);
});
it("should handle nested code blocks correctly", () => {
expect(processStreamingMarkdown("```\ncode\n```\n```python")).toBe(
"```\ncode\n```\n```python\n```",
);
});
it("should not process markdown inside code blocks", () => {
expect(processStreamingMarkdown("```\n* not a list\n**not bold**")).toBe(
"```\n* not a list\n**not bold**\n```",
);
});
});
describe("Inline code", () => {
it("should close incomplete inline code", () => {
expect(processStreamingMarkdown("This is `inline code")).toBe(
"This is `inline code`",
);
expect(processStreamingMarkdown("Use `console.log")).toBe(
"Use `console.log`",
);
});
it("should not affect complete inline code", () => {
expect(processStreamingMarkdown("`complete code`")).toBe(
"`complete code`",
);
expect(processStreamingMarkdown("Use `code` here")).toBe(
"Use `code` here",
);
});
it("should handle multiple inline codes correctly", () => {
expect(processStreamingMarkdown("`code` and `more")).toBe(
"`code` and `more`",
);
});
it("should not confuse inline code with code blocks", () => {
expect(processStreamingMarkdown("```\nblock\n```\n`inline")).toBe(
"```\nblock\n```\n`inline`",
);
});
});
describe("Complex streaming scenarios", () => {
it("should handle progressive streaming of a heading", () => {
const steps = [
{ input: "#", expected: "#" }, // # alone is not removed (needs space)
{ input: "# ", expected: "" },
{ input: "# H", expected: "# H" },
{ input: "# Hello", expected: "# Hello" },
];
steps.forEach(({ input, expected }) => {
expect(processStreamingMarkdown(input)).toBe(expected);
});
});
it("should handle progressive streaming of bold text", () => {
const steps = [
{ input: "*", expected: "" },
{ input: "**", expected: "" },
{ input: "**b", expected: "**b**" },
{ input: "**bold", expected: "**bold**" },
{ input: "**bold**", expected: "**bold**" },
];
steps.forEach(({ input, expected }) => {
expect(processStreamingMarkdown(input)).toBe(expected);
});
});
it("should handle multiline content with various patterns", () => {
const multiline = `# Title
This is a paragraph with **bold text** and *italic text*.
* Item 1
* Item 2
* `;
const expected = `# Title
This is a paragraph with **bold text** and *italic text*.
* Item 1
* Item 2`;
expect(processStreamingMarkdown(multiline)).toBe(expected);
});
it("should only fix the last line", () => {
expect(processStreamingMarkdown("# Complete\n# Another\n# ")).toBe(
"# Complete\n# Another",
);
expect(processStreamingMarkdown("* Item 1\n* Item 2\n* ")).toBe(
"* Item 1\n* Item 2",
);
});
it("should handle mixed content correctly", () => {
const input = `# Header
This has **bold** text and *italic* text.
\`\`\`js
const x = 42;
\`\`\`
Now some \`inline code\` and **unclosed bold`;
const expected = `# Header
This has **bold** text and *italic* text.
\`\`\`js
const x = 42;
\`\`\`
Now some \`inline code\` and **unclosed bold**`;
expect(processStreamingMarkdown(input)).toBe(expected);
});
});
describe("Edge cases with escaping", () => {
it("should handle escaped asterisks (future enhancement)", () => {
// Note: Current implementation doesn't handle escaping
// This is a known limitation - escaped characters still trigger closing
expect(processStreamingMarkdown("Text \\*not italic")).toBe(
"Text \\*not italic*",
);
});
it("should handle escaped backticks (future enhancement)", () => {
// Note: Current implementation doesn't handle escaping
// This is a known limitation - escaped characters still trigger closing
expect(processStreamingMarkdown("Text \\`not code")).toBe(
"Text \\`not code`",
);
});
});
describe("Code block edge cases", () => {
it("should handle triple backticks in the middle of lines", () => {
expect(processStreamingMarkdown("Text ``` in middle")).toBe(
"Text ``` in middle\n```",
);
expect(processStreamingMarkdown("```\nText ``` in code\nmore")).toBe(
"```\nText ``` in code\nmore\n```",
);
});
it("should properly close code blocks with language specifiers", () => {
expect(processStreamingMarkdown("```typescript")).toBe(
"```typescript\n```",
);
expect(processStreamingMarkdown("```typescript\nconst x = 1")).toBe(
"```typescript\nconst x = 1\n```",
);
});
it("should remove a completely empty partial code block", () => {
expect(processStreamingMarkdown("```\n")).toBe("");
});
});
});
*/
app/ui/app/src/components/StreamingMarkdownContent.tsx
View file @
a42f826a
import
React
from
"
react
"
;
import
React
from
"
react
"
;
import
Markdown
from
"
react-markdown
"
;
import
{
Streamdown
,
defaultRemarkPlugins
}
from
"
streamdown
"
;
import
remarkGfm
from
"
remark-gfm
"
;
import
remarkMath
from
"
remark-math
"
;
import
rehypeRaw
from
"
rehype-raw
"
;
import
rehypeSanitize
,
{
defaultSchema
}
from
"
rehype-sanitize
"
;
import
rehypePrismPlus
from
"
rehype-prism-plus
"
;
import
rehypeKatex
from
"
rehype-katex
"
;
import
remarkStreamingMarkdown
,
{
type
LastNodeInfo
,
}
from
"
@/utils/remarkStreamingMarkdown
"
;
import
type
{
PluggableList
}
from
"
unified
"
;
import
remarkCitationParser
from
"
@/utils/remarkCitationParser
"
;
import
remarkCitationParser
from
"
@/utils/remarkCitationParser
"
;
import
CopyButton
from
"
./CopyButton
"
;
import
CopyButton
from
"
./CopyButton
"
;
import
type
{
BundledLanguage
}
from
"
shiki
"
;
import
{
highlighter
}
from
"
@/lib/highlighter
"
;
interface
StreamingMarkdownContentProps
{
interface
StreamingMarkdownContentProps
{
content
:
string
;
content
:
string
;
isStreaming
?:
boolean
;
isStreaming
?:
boolean
;
size
?:
"
sm
"
|
"
md
"
|
"
lg
"
;
size
?:
"
sm
"
|
"
md
"
|
"
lg
"
;
onLastNode
?:
(
info
:
LastNodeInfo
)
=>
void
;
browserToolResult
?:
any
;
// TODO: proper type
browserToolResult
?:
any
;
// TODO: proper type
}
}
// Helper to extract text from React nodes
const
extractText
=
(
node
:
React
.
ReactNode
):
string
=>
{
if
(
typeof
node
===
"
string
"
)
return
node
;
if
(
typeof
node
===
"
number
"
)
return
String
(
node
);
if
(
!
node
)
return
""
;
if
(
React
.
isValidElement
(
node
))
{
const
props
=
node
.
props
as
any
;
if
(
props
?.
children
)
{
return
extractText
(
props
.
children
as
React
.
ReactNode
);
}
}
if
(
Array
.
isArray
(
node
))
{
return
node
.
map
(
extractText
).
join
(
""
);
}
return
""
;
};
const
CodeBlock
=
React
.
memo
(
const
CodeBlock
=
React
.
memo
(
({
children
,
className
,
...
props
}:
React
.
HTMLAttributes
<
HTMLPreElement
>
)
=>
{
({
children
}:
React
.
HTMLAttributes
<
HTMLPreElement
>
)
=>
{
const
extractText
=
React
.
useCallback
((
node
:
React
.
ReactNode
):
string
=>
{
// Extract code and language from children
if
(
typeof
node
===
"
string
"
)
return
node
;
const
codeElement
=
children
as
React
.
ReactElement
<
{
if
(
typeof
node
===
"
number
"
)
return
String
(
node
);
className
?:
string
;
if
(
!
node
)
return
""
;
children
:
React
.
ReactNode
;
}
>
;
const
language
=
codeElement
.
props
.
className
?.
replace
(
/language-/
,
""
)
||
""
;
const
codeText
=
extractText
(
codeElement
.
props
.
children
);
if
(
React
.
isValidElement
(
node
))
{
// Synchronously highlight code using the pre-loaded highlighter
if
(
const
tokens
=
React
.
useMemo
(()
=>
{
node
.
props
&&
if
(
!
highlighter
)
return
null
;
typeof
node
.
props
===
"
object
"
&&
"
children
"
in
node
.
props
)
{
return
extractText
(
node
.
props
.
children
as
React
.
ReactNode
);
}
}
if
(
Array
.
isArray
(
node
))
{
try
{
return
node
.
map
(
extractText
).
join
(
""
);
return
{
light
:
highlighter
.
codeToTokensBase
(
codeText
,
{
lang
:
language
as
BundledLanguage
,
theme
:
"
one-light
"
as
any
,
}),
dark
:
highlighter
.
codeToTokensBase
(
codeText
,
{
lang
:
language
as
BundledLanguage
,
theme
:
"
one-dark
"
as
any
,
}),
};
}
catch
(
error
)
{
console
.
error
(
"
Failed to highlight code:
"
,
error
);
return
null
;
}
}
},
[
codeText
,
language
]);
return
""
;
},
[]);
const
language
=
className
?.
replace
(
/language-/
,
""
)
||
""
;
return
(
return
(
<
div
className
=
"relative bg-neutral-100 dark:bg-neutral-800 rounded-2xl overflow-hidden my-6"
>
<
div
className
=
"relative bg-neutral-100 dark:bg-neutral-800 rounded-2xl overflow-hidden my-6"
>
<
div
className
=
"flex justify-between select-none"
>
<
div
className
=
"flex select-none"
>
<
div
className
=
"text-[13px] text-neutral-500 dark:text-neutral-400 font-mono px-4 py-2"
>
{
language
&&
(
{
language
}
<
div
className
=
"text-[13px] text-neutral-500 dark:text-neutral-400 font-mono px-4 py-2"
>
</
div
>
{
language
}
</
div
>
)
}
<
CopyButton
<
CopyButton
content
=
{
extractText
(
children
)
}
content
=
{
codeText
}
showLabels
=
{
true
}
showLabels
=
{
true
}
className
=
"copy-button text-neutral-500 dark:text-neutral-400 bg-neutral-100 dark:bg-neutral-800"
className
=
"copy-button text-neutral-500 dark:text-neutral-400 bg-neutral-100 dark:bg-neutral-800
ml-auto
"
/>
/>
</
div
>
</
div
>
<
pre
className
=
{
className
}
{
...
props
}
>
{
/* Light mode */
}
{
children
}
<
pre
className
=
"dark:hidden m-0 bg-neutral-100 text-sm overflow-x-auto p-4"
>
<
code
className
=
"font-mono text-sm"
>
{
tokens
?.
light
?
tokens
.
light
.
map
((
line
:
any
,
i
:
number
)
=>
(
<
React
.
Fragment
key
=
{
i
}
>
{
line
.
map
((
token
:
any
,
j
:
number
)
=>
(
<
span
key
=
{
j
}
style
=
{
{
color
:
token
.
color
,
}
}
>
{
token
.
content
}
</
span
>
))
}
{
i
<
tokens
.
light
.
length
-
1
&&
"
\n
"
}
</
React
.
Fragment
>
))
:
codeText
}
</
code
>
</
pre
>
{
/* Dark mode */
}
<
pre
className
=
"hidden dark:block m-0 bg-neutral-800 text-sm overflow-x-auto p-4"
>
<
code
className
=
"font-mono text-sm"
>
{
tokens
?.
dark
?
tokens
.
dark
.
map
((
line
:
any
,
i
:
number
)
=>
(
<
React
.
Fragment
key
=
{
i
}
>
{
line
.
map
((
token
:
any
,
j
:
number
)
=>
(
<
span
key
=
{
j
}
style
=
{
{
color
:
token
.
color
,
}
}
>
{
token
.
content
}
</
span
>
))
}
{
i
<
tokens
.
dark
.
length
-
1
&&
"
\n
"
}
</
React
.
Fragment
>
))
:
codeText
}
</
code
>
</
pre
>
</
pre
>
</
div
>
</
div
>
);
);
...
@@ -68,65 +125,19 @@ const CodeBlock = React.memo(
...
@@ -68,65 +125,19 @@ const CodeBlock = React.memo(
);
);
const
StreamingMarkdownContent
:
React
.
FC
<
StreamingMarkdownContentProps
>
=
const
StreamingMarkdownContent
:
React
.
FC
<
StreamingMarkdownContentProps
>
=
React
.
memo
(
React
.
memo
(({
content
,
isStreaming
=
false
,
size
,
browserToolResult
})
=>
{
({
content
,
isStreaming
=
false
,
size
,
onLastNode
,
browserToolResult
})
=>
{
// Build the remark plugins array - keep default GFM and Math, add citations
// Build the remark plugins array
const
remarkPlugins
=
React
.
useMemo
(()
=>
{
const
remarkPlugins
=
React
.
useMemo
(()
=>
{
return
[
const
plugins
:
PluggableList
=
[
defaultRemarkPlugins
.
gfm
,
remarkGfm
,
defaultRemarkPlugins
.
math
,
[
remarkMath
,
{
singleDollarTextMath
:
false
}],
remarkCitationParser
,
remarkCitationParser
,
];
];
},
[]);
// Add streaming plugin when in streaming mode
if
(
isStreaming
)
{
plugins
.
push
([
remarkStreamingMarkdown
,
{
debug
:
true
,
onLastNode
}]);
}
return
plugins
;
},
[
isStreaming
,
onLastNode
]);
// Create a custom sanitization schema that allows math elements
const
sanitizeSchema
=
React
.
useMemo
(()
=>
{
return
{
...
defaultSchema
,
attributes
:
{
...
defaultSchema
.
attributes
,
span
:
[
...(
defaultSchema
.
attributes
?.
span
||
[]),
[
"
className
"
,
/^katex/
],
],
div
:
[
...(
defaultSchema
.
attributes
?.
div
||
[]),
[
"
className
"
,
/^katex/
],
],
"
ol-citation
"
:
[
"
cursor
"
,
"
start
"
,
"
end
"
],
},
tagNames
:
[
...(
defaultSchema
.
tagNames
||
[]),
"
math
"
,
"
mrow
"
,
"
mi
"
,
"
mo
"
,
"
mn
"
,
"
msup
"
,
"
msub
"
,
"
mfrac
"
,
"
mover
"
,
"
munder
"
,
"
msqrt
"
,
"
mroot
"
,
"
merror
"
,
"
mspace
"
,
"
mpadded
"
,
"
ol-citation
"
,
],
};
},
[]);
return
(
return
(
<
div
<
div
className
=
{
`
className
=
{
`
max-w-full
max-w-full
${
size
===
"
sm
"
?
"
prose-sm
"
:
size
===
"
lg
"
?
"
prose-lg
"
:
""
}
${
size
===
"
sm
"
?
"
prose-sm
"
:
size
===
"
lg
"
?
"
prose-lg
"
:
""
}
prose
prose
...
@@ -144,7 +155,27 @@ const StreamingMarkdownContent: React.FC<StreamingMarkdownContentProps> =
...
@@ -144,7 +155,27 @@ const StreamingMarkdownContent: React.FC<StreamingMarkdownContentProps> =
prose-pre:my-0
prose-pre:my-0
prose-pre:max-w-full
prose-pre:max-w-full
prose-pre:pt-1
prose-pre:pt-1
[&_code:not(pre_code)]:text-neutral-700
[&_table]:border-collapse
[&_table]:w-full
[&_table]:border
[&_table]:border-neutral-200
[&_table]:rounded-lg
[&_table]:overflow-hidden
[&_th]:px-3
[&_th]:py-2
[&_th]:text-left
[&_th]:font-semibold
[&_th]:border-b
[&_th]:border-r
[&_th]:border-neutral-200
[&_th:last-child]:border-r-0
[&_td]:px-3
[&_td]:py-2
[&_td]:border-r
[&_td]:border-neutral-200
[&_td:last-child]:border-r-0
[&_tbody_tr:not(:last-child)_td]:border-b
[&_code:not(pre_code)]:text-neutral-700
[&_code:not(pre_code)]:bg-neutral-100
[&_code:not(pre_code)]:bg-neutral-100
[&_code:not(pre_code)]:font-normal
[&_code:not(pre_code)]:font-normal
[&_code:not(pre_code)]:px-1.5
[&_code:not(pre_code)]:px-1.5
...
@@ -160,6 +191,10 @@ const StreamingMarkdownContent: React.FC<StreamingMarkdownContentProps> =
...
@@ -160,6 +191,10 @@ const StreamingMarkdownContent: React.FC<StreamingMarkdownContentProps> =
dark:prose-strong:text-neutral-200
dark:prose-strong:text-neutral-200
dark:prose-pre:text-neutral-200
dark:prose-pre:text-neutral-200
dark:prose:pre:text-neutral-200
dark:prose:pre:text-neutral-200
dark:[&_table]:border-neutral-700
dark:[&_thead]:bg-neutral-800
dark:[&_th]:border-neutral-700
dark:[&_td]:border-neutral-700
dark:[&_code:not(pre_code)]:text-neutral-200
dark:[&_code:not(pre_code)]:text-neutral-200
dark:[&_code:not(pre_code)]:bg-neutral-800
dark:[&_code:not(pre_code)]:bg-neutral-800
dark:[&_code:not(pre_code)]:font-normal
dark:[&_code:not(pre_code)]:font-normal
...
@@ -167,104 +202,86 @@ const StreamingMarkdownContent: React.FC<StreamingMarkdownContentProps> =
...
@@ -167,104 +202,86 @@ const StreamingMarkdownContent: React.FC<StreamingMarkdownContentProps> =
dark:prose-li:marker:text-neutral-300
dark:prose-li:marker:text-neutral-300
break-words
break-words
`
}
`
}
>
<
StreamingMarkdownErrorBoundary
content
=
{
content
}
isStreaming
=
{
isStreaming
}
>
>
<
StreamingMarkdownErrorBoundary
<
Streamdown
content
=
{
content
}
parseIncompleteMarkdown
=
{
isStreaming
}
isStreaming
=
{
isStreaming
}
isAnimating
=
{
isStreaming
}
>
remarkPlugins
=
{
remarkPlugins
}
<
Markdown
controls
=
{
false
}
remarkPlugins
=
{
remarkPlugins
}
components
=
{
{
rehypePlugins
=
{
pre
:
CodeBlock
,
[
table
:
({
[
rehypeRaw
,
{
allowDangerousHtml
:
true
}],
children
,
[
rehypeSanitize
,
sanitizeSchema
],
...
props
[
rehypePrismPlus
,
{
ignoreMissing
:
true
}],
}:
React
.
HTMLAttributes
<
HTMLTableElement
>
)
=>
(
[
<
div
className
=
"overflow-x-auto max-w-full"
>
rehypeKatex
,
<
table
{
{
...
props
}
errorColor
:
"
#000000
"
,
// Black instead of red for errors
className
=
"border-collapse w-full border border-neutral-200 dark:border-neutral-700 rounded-lg overflow-hidden"
strict
:
false
,
// Be more lenient with parsing
>
throwOnError
:
false
,
{
children
}
},
</
table
>
],
</
div
>
]
as
PluggableList
),
}
// @ts-expect-error: custom citation type
components
=
{
{
"
ol-citation
"
:
({
pre
:
CodeBlock
,
cursor
,
table
:
({
}:
{
children
,
cursor
:
number
;
...
props
start
:
number
;
}:
React
.
HTMLAttributes
<
HTMLTableElement
>
)
=>
(
end
:
number
;
<
div
className
=
"overflow-x-auto max-w-full"
>
})
=>
{
<
table
{
...
props
}
>
{
children
}
</
table
>
const
pageStack
=
browserToolResult
?.
page_stack
;
</
div
>
const
hasValidPage
=
pageStack
&&
cursor
<
pageStack
.
length
;
),
const
pageUrl
=
hasValidPage
?
pageStack
[
cursor
]
:
null
;
// @ts-expect-error: custom type
"
ol-citation
"
:
({
cursor
,
// start,
// end,
}:
{
cursor
:
number
;
start
:
number
;
end
:
number
;
})
=>
{
// Check if we have a page_stack and if the cursor is valid
const
pageStack
=
browserToolResult
?.
page_stack
;
const
hasValidPage
=
pageStack
&&
cursor
<
pageStack
.
length
;
const
pageUrl
=
hasValidPage
?
pageStack
[
cursor
]
:
null
;
// Extract a readable title from the URL if possible
const
getPageTitle
=
(
url
:
string
)
=>
{
const
getPageTitle
=
(
url
:
string
)
=>
{
if
(
url
.
startsWith
(
"
search_results_
"
))
{
if
(
url
.
startsWith
(
"
search_results_
"
))
{
const
searchTerm
=
url
.
substring
(
"
search_results_
"
.
length
);
const
searchTerm
=
url
.
substring
(
return
`Search:
${
searchTerm
}
`
;
"
search_results_
"
.
length
,
}
);
try
{
return
`Search:
${
searchTerm
}
`
;
const
urlObj
=
new
URL
(
url
);
}
return
urlObj
.
hostname
;
// For regular URLs, try to extract domain or use full URL
}
catch
{
try
{
return
url
;
const
urlObj
=
new
URL
(
url
);
}
return
urlObj
.
hostname
;
};
}
catch
{
// If not a valid URL, return as is
return
url
;
}
};
const
citationElement
=
(
const
citationElement
=
(
<
span
className
=
"text-xs text-neutral-500 dark:text-neutral-400 bg-neutral-100 dark:bg-neutral-800 rounded-full px-2 py-1 ml-1"
>
<
span
className
=
"text-xs text-neutral-500 dark:text-neutral-400 bg-neutral-100 dark:bg-neutral-800 rounded-full px-2 py-1 ml-1"
>
[
{
cursor
}
]
[
{
cursor
}
]
</
span
>
</
span
>
);
);
// If we have a valid page URL, wrap in a link
if
(
pageUrl
&&
pageUrl
.
startsWith
(
"
http
"
))
{
if
(
pageUrl
&&
pageUrl
.
startsWith
(
"
http
"
))
{
return
(
return
(
<
a
<
a
href
=
{
pageUrl
}
href
=
{
pageUrl
}
target
=
"_blank"
target
=
"_blank"
rel
=
"noopener noreferrer"
rel
=
"noopener noreferrer"
className
=
"inline-flex items-center hover:opacity-80 transition-opacity no-underline"
className
=
"inline-flex items-center hover:opacity-80 transition-opacity no-underline"
title
=
{
getPageTitle
(
pageUrl
)
}
title
=
{
getPageTitle
(
pageUrl
)
}
>
>
{
citationElement
}
{
citationElement
}
</
a
>
</
a
>
);
);
}
}
// Otherwise, just return the citation without a link
return
citationElement
;
return
citationElement
;
},
},
}
}
}
}
>
>
{
content
}
{
content
}
</
Streamdown
>
</
Markdown
>
</
StreamingMarkdownErrorBoundary
>
</
StreamingMarkdownErrorBoundary
>
</
div
>
</
div
>
);
);
});
},
);
interface
StreamingMarkdownErrorBoundaryProps
{
interface
StreamingMarkdownErrorBoundaryProps
{
content
:
string
;
content
:
string
;
...
...
app/ui/app/src/components/Thinking.tsx
View file @
a42f826a
...
@@ -73,8 +73,9 @@ export default function Thinking({
...
@@ -73,8 +73,9 @@ export default function Thinking({
// Calculate max height for smooth animations
// Calculate max height for smooth animations
const
getMaxHeight
=
()
=>
{
const
getMaxHeight
=
()
=>
{
if
(
isCollapsed
)
{
if
(
isCollapsed
)
{
return
finishedThinking
?
"
0px
"
:
"
12rem
"
;
// 8rem = 128px (same as max-h-32)
return
finishedThinking
?
"
0px
"
:
"
12rem
"
;
}
}
// When expanded, use the content height or grow naturally
return
contentHeight
?
`
${
contentHeight
}
px`
:
"
none
"
;
return
contentHeight
?
`
${
contentHeight
}
px`
:
"
none
"
;
};
};
...
@@ -131,10 +132,11 @@ export default function Thinking({
...
@@ -131,10 +132,11 @@ export default function Thinking({
</
div
>
</
div
>
<
div
<
div
ref
=
{
wrapperRef
}
ref
=
{
wrapperRef
}
className
=
{
`text-xs text-neutral-500 dark:text-neutral-500 rounded-md overflow-hidden
className
=
{
`text-xs text-neutral-500 dark:text-neutral-500 rounded-md
transition-[max-height,opacity] duration-300 ease-in-out relative ml-6 mt-2`
}
transition-[max-height,opacity] duration-300 ease-in-out relative ml-6 mt-2
${
isCollapsed
?
"
overflow-hidden
"
:
"
overflow-y-auto
"
}
`
}
style
=
{
{
style
=
{
{
maxHeight
:
getMaxHeight
(),
maxHeight
:
isCollapsed
?
getMaxHeight
()
:
undefined
,
opacity
:
isCollapsed
&&
finishedThinking
?
0
:
1
,
opacity
:
isCollapsed
&&
finishedThinking
?
0
:
1
,
}
}
}
}
>
>
...
...
app/ui/app/src/index.css
View file @
a42f826a
This diff is collapsed.
Click to expand it.
app/ui/app/src/lib/highlighter.ts
0 → 100644
View file @
a42f826a
import
{
createHighlighter
}
from
"
shiki
"
;
import
type
{
ThemeRegistration
}
from
"
shiki
"
;
const
oneLightTheme
:
ThemeRegistration
=
{
name
:
"
one-light
"
,
type
:
"
light
"
,
colors
:
{
"
editor.background
"
:
"
#fafafa
"
,
"
editor.foreground
"
:
"
#383a42
"
,
},
tokenColors
:
[
{
scope
:
[
"
comment
"
,
"
punctuation.definition.comment
"
],
settings
:
{
foreground
:
"
#a0a1a7
"
},
},
{
scope
:
[
"
keyword
"
,
"
storage.type
"
,
"
storage.modifier
"
],
settings
:
{
foreground
:
"
#a626a4
"
},
},
{
scope
:
[
"
string
"
,
"
string.quoted
"
],
settings
:
{
foreground
:
"
#50a14f
"
}
},
{
scope
:
[
"
function
"
,
"
entity.name.function
"
,
"
support.function
"
],
settings
:
{
foreground
:
"
#4078f2
"
},
},
{
scope
:
[
"
constant.numeric
"
,
"
constant.language
"
,
"
constant.character
"
,
"
number
"
,
],
settings
:
{
foreground
:
"
#c18401
"
},
},
{
scope
:
[
"
variable
"
,
"
support.variable
"
],
settings
:
{
foreground
:
"
#e45649
"
},
},
{
scope
:
[
"
entity.name.tag
"
,
"
entity.name.type
"
,
"
entity.name.class
"
],
settings
:
{
foreground
:
"
#e45649
"
},
},
{
scope
:
[
"
entity.other.attribute-name
"
],
settings
:
{
foreground
:
"
#c18401
"
},
},
{
scope
:
[
"
keyword.operator
"
,
"
operator
"
],
settings
:
{
foreground
:
"
#a626a4
"
},
},
{
scope
:
[
"
punctuation
"
],
settings
:
{
foreground
:
"
#383a42
"
}
},
{
scope
:
[
"
markup.heading
"
],
settings
:
{
foreground
:
"
#e45649
"
,
fontStyle
:
"
bold
"
},
},
{
scope
:
[
"
markup.bold
"
],
settings
:
{
foreground
:
"
#c18401
"
,
fontStyle
:
"
bold
"
},
},
{
scope
:
[
"
markup.italic
"
],
settings
:
{
foreground
:
"
#a626a4
"
,
fontStyle
:
"
italic
"
},
},
],
};
const
oneDarkTheme
:
ThemeRegistration
=
{
name
:
"
one-dark
"
,
type
:
"
dark
"
,
colors
:
{
"
editor.background
"
:
"
#282c34
"
,
"
editor.foreground
"
:
"
#abb2bf
"
,
},
tokenColors
:
[
{
scope
:
[
"
comment
"
,
"
punctuation.definition.comment
"
],
settings
:
{
foreground
:
"
#5c6370
"
},
},
{
scope
:
[
"
keyword
"
,
"
storage.type
"
,
"
storage.modifier
"
],
settings
:
{
foreground
:
"
#c678dd
"
},
},
{
scope
:
[
"
string
"
,
"
string.quoted
"
],
settings
:
{
foreground
:
"
#98c379
"
}
},
{
scope
:
[
"
function
"
,
"
entity.name.function
"
,
"
support.function
"
],
settings
:
{
foreground
:
"
#61afef
"
},
},
{
scope
:
[
"
constant.numeric
"
,
"
constant.language
"
,
"
constant.character
"
,
"
number
"
,
],
settings
:
{
foreground
:
"
#d19a66
"
},
},
{
scope
:
[
"
variable
"
,
"
support.variable
"
],
settings
:
{
foreground
:
"
#e06c75
"
},
},
{
scope
:
[
"
entity.name.tag
"
,
"
entity.name.type
"
,
"
entity.name.class
"
],
settings
:
{
foreground
:
"
#e06c75
"
},
},
{
scope
:
[
"
entity.other.attribute-name
"
],
settings
:
{
foreground
:
"
#d19a66
"
},
},
{
scope
:
[
"
keyword.operator
"
,
"
operator
"
],
settings
:
{
foreground
:
"
#c678dd
"
},
},
{
scope
:
[
"
punctuation
"
],
settings
:
{
foreground
:
"
#abb2bf
"
}
},
{
scope
:
[
"
markup.heading
"
],
settings
:
{
foreground
:
"
#e06c75
"
,
fontStyle
:
"
bold
"
},
},
{
scope
:
[
"
markup.bold
"
],
settings
:
{
foreground
:
"
#d19a66
"
,
fontStyle
:
"
bold
"
},
},
{
scope
:
[
"
markup.italic
"
],
settings
:
{
foreground
:
"
#c678dd
"
,
fontStyle
:
"
italic
"
},
},
],
};
export
let
highlighter
:
Awaited
<
ReturnType
<
typeof
createHighlighter
>>
|
null
=
null
;
export
const
highlighterPromise
=
createHighlighter
({
themes
:
[
oneLightTheme
,
oneDarkTheme
],
langs
:
[
"
javascript
"
,
"
typescript
"
,
"
python
"
,
"
bash
"
,
"
shell
"
,
"
json
"
,
"
html
"
,
"
css
"
,
"
tsx
"
,
"
jsx
"
,
"
go
"
,
"
rust
"
,
"
java
"
,
"
c
"
,
"
cpp
"
,
"
sql
"
,
"
yaml
"
,
"
markdown
"
,
],
}).
then
((
h
)
=>
{
highlighter
=
h
;
return
h
;
});
app/ui/app/src/utils/processStreamingMarkdown.ts
deleted
100644 → 0
View file @
e10a3533
import
{
remark
}
from
"
remark
"
;
import
remarkStringify
from
"
remark-stringify
"
;
import
remarkStreamingMarkdown
from
"
./remarkStreamingMarkdown
"
;
/**
* Process markdown content for streaming display using the remark plugin.
* This is primarily used for testing the remark plugin with string inputs/outputs.
*/
export
function
processStreamingMarkdown
(
content
:
string
):
string
{
if
(
!
content
)
return
content
;
const
result
=
remark
()
.
use
(
remarkStreamingMarkdown
,
{
debug
:
false
})
.
use
(
remarkStringify
)
.
processSync
(
content
);
// remove trailing newline to keep tests cleaner
let
output
=
result
.
toString
();
if
(
output
.
endsWith
(
"
\n
"
))
{
output
=
output
.
slice
(
0
,
-
1
);
}
return
output
;
}
app/ui/app/src/utils/remarkStreamingMarkdown.ts
deleted
100644 → 0
View file @
e10a3533
This diff is collapsed.
Click to expand it.
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment