improvement: use frontend markdown rendering for mo.ui.chat so content stays clean (#7066)

This removes the automatic markdown rendering from the backend
(`chat.py`) and instead just renders the markdown on the fly in the
frontend so the `ChatMessage.content` and stay clean in markdown and not
be converted to HTML
This commit is contained in:
Myles Scolnick
2025-11-04 15:33:23 -05:00
committed by GitHub
parent 91159cb4f5
commit 9fc4f08b98
8 changed files with 39 additions and 27 deletions

View File

@@ -10,7 +10,7 @@
import marimo
__generated_with = "0.15.5"
__generated_with = "0.17.6"
app = marimo.App(width="medium")
@@ -19,6 +19,7 @@ def _():
import polars as pl
import marimo as mo
import os
import altair
has_api_key = os.environ.get("OPENAI_API_KEY") is not None
mo.stop(

View File

@@ -42,6 +42,7 @@ export default defineConfig({
define: {
"process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV),
"process.env.LOG": JSON.stringify(""),
"process.env.VSCODE_TEXTMATE_DEBUG": JSON.stringify(false),
"process.env.NODE_DEBUG": JSON.stringify(false),
// Precedence: VITE_MARIMO_VERSION > package.json version > "latest"
"import.meta.env.VITE_MARIMO_VERSION": process.env.VITE_MARIMO_VERSION

View File

@@ -1,4 +1,6 @@
/* Copyright 2024 Marimo. All rights reserved. */
import { Suspense } from "react";
import { z } from "zod";
import { TooltipProvider } from "@/components/ui/tooltip";
import { createPlugin } from "@/plugins/core/builder";
@@ -74,18 +76,20 @@ export const ChatPlugin = createPlugin<{ messages: ChatMessage[] }>(
})
.renderer((props) => (
<TooltipProvider>
<Chatbot
prompts={props.data.prompts}
showConfigurationControls={props.data.showConfigurationControls}
maxHeight={props.data.maxHeight}
allowAttachments={props.data.allowAttachments}
config={props.data.config}
get_chat_history={props.functions.get_chat_history}
delete_chat_history={props.functions.delete_chat_history}
delete_chat_message={props.functions.delete_chat_message}
send_prompt={props.functions.send_prompt}
value={props.value?.messages || Arrays.EMPTY}
setValue={(messages) => props.setValue({ messages })}
/>
<Suspense>
<Chatbot
prompts={props.data.prompts}
showConfigurationControls={props.data.showConfigurationControls}
maxHeight={props.data.maxHeight}
allowAttachments={props.data.allowAttachments}
config={props.data.config}
get_chat_history={props.functions.get_chat_history}
delete_chat_history={props.functions.delete_chat_history}
delete_chat_message={props.functions.delete_chat_message}
send_prompt={props.functions.send_prompt}
value={props.value?.messages || Arrays.EMPTY}
setValue={(messages) => props.setValue({ messages })}
/>
</Suspense>
</TooltipProvider>
));

View File

@@ -17,7 +17,7 @@ import {
Trash2Icon,
X,
} from "lucide-react";
import React, { useEffect, useRef, useState } from "react";
import React, { lazy, useEffect, useRef, useState } from "react";
import { convertToFileUIPart } from "@/components/chat/chat-utils";
import {
type AdditionalCompletions,
@@ -43,7 +43,6 @@ import { Tooltip } from "@/components/ui/tooltip";
import { toast } from "@/components/ui/use-toast";
import { moveToEndOfEditor } from "@/core/codemirror/utils";
import { useAsyncData } from "@/hooks/useAsyncData";
import { renderHTML } from "@/plugins/core/RenderHTML";
import { cn } from "@/utils/cn";
import { copyToClipboard } from "@/utils/copy";
import { Logger } from "@/utils/Logger";
@@ -52,6 +51,10 @@ import { ErrorBanner } from "../common/error-banner";
import type { PluginFunctions } from "./ChatPlugin";
import type { ChatConfig, ChatMessage } from "./types";
const LazyStreamdown = lazy(() =>
import("streamdown").then((module) => ({ default: module.Streamdown })),
);
interface Props extends PluginFunctions {
prompts: string[];
config: ChatConfig;
@@ -194,9 +197,13 @@ export const Chatbot: React.FC<Props> = (props) => {
const textParts = message.parts?.filter((p) => p.type === "text");
const textContent = textParts?.map((p) => p.text).join("\n");
const content =
message.role === "assistant"
? renderHTML({ html: textContent })
: textContent;
message.role === "assistant" ? (
<LazyStreamdown className="mo-markdown-renderer">
{textContent}
</LazyStreamdown>
) : (
textContent
);
const attachments = message.parts?.filter((p) => p.type === "file");

View File

@@ -4,7 +4,7 @@
"tasks": {
"build": {
"outputs": ["dist/**"],
"env": ["VITE_MARIMO_ISLANDS", "NODE_ENV"]
"env": ["VITE_MARIMO_ISLANDS", "VITE_MARIMO_VERSION", "NODE_ENV"]
},
"build-storybook": {
"outputs": ["storybook-static/**"]

View File

@@ -11,7 +11,6 @@ from marimo._ai._types import (
ChatModelConfigDict,
)
from marimo._output.formatting import as_html
from marimo._output.md import md
from marimo._output.rich_help import mddoc
from marimo._plugins.core.web_component import JSONType
from marimo._plugins.ui._core.ui_element import UIElement
@@ -270,7 +269,7 @@ class chat(UIElement[dict[str, Any], list[ChatMessage]]):
# Return the response as HTML
# If the response is a string, convert it to markdown
if isinstance(response, str):
return md(response).text
return response
return as_html(response).text
def _convert_value(self, value: dict[str, Any]) -> list[ChatMessage]:

View File

@@ -11,7 +11,6 @@ from marimo._ai._types import (
ChatModelConfig,
ChatModelConfigDict,
)
from marimo._output.md import md
from marimo._plugins import ui
from marimo._plugins.ui._impl.chat.chat import (
DEFAULT_CONFIG,
@@ -83,7 +82,7 @@ async def test_chat_send_prompt():
)
response: str = await chat._send_prompt(request)
assert response == md("Response to: Hello").text
assert response == "Response to: Hello"
assert len(chat._chat_history) == 2
assert chat._chat_history[0].role == "user"
assert chat._chat_history[0].content == "Hello"
@@ -106,7 +105,7 @@ async def test_chat_send_prompt_async_function():
)
response: str = await chat._send_prompt(request)
assert response == md("Response to: Hello").text
assert response == "Response to: Hello"
assert len(chat._chat_history) == 2
assert chat._chat_history[0].role == "user"
assert chat._chat_history[0].content == "Hello"
@@ -132,7 +131,7 @@ async def test_chat_send_prompt_async_generator():
response: str = await chat._send_prompt(request)
# the last yielded value is the response
assert response == md("2").text
assert response == "2"
assert len(chat._chat_history) == 2
assert chat._chat_history[0].role == "user"
assert chat._chat_history[0].content == "Hello"

View File

@@ -3,7 +3,8 @@
"tasks": {
"build": {
"dependsOn": ["^build", "codegen"],
"outputs": ["dist/**"]
"outputs": ["dist/**"],
"env": ["NODE_ENV", "VITE_MARIMO_VERSION", "VITE_MARIMO_ISLANDS"]
},
"typecheck": {
"dependsOn": ["codegen", "^codegen"]