Skip to content

Commit 3d5aac3

Browse files
adam.wilsoncursoragent
andcommitted
feat(web): render mermaid diagrams in chat answers
Add inline mermaid diagram rendering to chat answers with a "Visualize" button on the answer card to generate diagrams on demand, plus a fullscreen pan/zoom modal. The renderer is registry-based so a future React Flow engine can be plugged in without changing the markdown wiring or the agent prompt. Refs #1240 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 96af7e8 commit 3d5aac3

12 files changed

Lines changed: 1714 additions & 19 deletions

File tree

packages/web/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@
153153
"langfuse-vercel": "^3.38.4",
154154
"linguist-languages": "^9.3.1",
155155
"lucide-react": "^0.517.0",
156+
"mermaid": "^11.15.0",
156157
"micromatch": "^4.0.8",
157158
"minidenticons": "^4.2.1",
158159
"next": "^16.2.6",
@@ -176,6 +177,7 @@
176177
"react-icons": "^5.3.0",
177178
"react-markdown": "^10.1.0",
178179
"react-resizable-panels": "^2.1.1",
180+
"react-zoom-pan-pinch": "^4.0.3",
179181
"recharts": "^2.15.3",
180182
"rehype-raw": "^7.0.0",
181183
"rehype-sanitize": "^6.0.0",

packages/web/src/features/chat/agent.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,79 @@ const createPrompt = ({
317317
Authentication in Sourcebot is built on NextAuth.js with a session-based approach using JWT tokens and Prisma as the database adapter ${fileReferenceToString({ repo: 'github.com/sourcebot-dev/sourcebot', path: 'auth.ts', range: { startLine: 135, endLine: 140 } })}. The system supports multiple authentication providers and implements organization-based authorization with role-defined permissions.
318318
\`\`\`
319319
</answer_instructions>
320+
321+
<diagram_instructions>
322+
When the user asks for a diagram, visual, workflow, architecture, sequence, flow, "show me a visual", "draw", or similar visual request, you MUST include a \`mermaid\` fenced code block in your answer. You may also include a diagram on your own initiative when the answer involves a multi-step workflow, a request/response sequence, or relationships between services or modules that are clearer visually than in prose.
323+
324+
**Diagram type selection** — pick the one that best fits the question:
325+
- \`sequenceDiagram\` — request flows, API call chains, message passing between services or actors
326+
- \`flowchart TD\` or \`flowchart LR\` — pipelines, conditional logic, decision trees, build / deploy flows
327+
- \`graph TD\` — service topology, architecture overviews, component dependencies
328+
- \`classDiagram\` — model / class relationships, inheritance hierarchies
329+
- \`erDiagram\` — database schemas and entity relationships
330+
- \`stateDiagram-v2\` — state machines and lifecycle transitions
331+
332+
**Diagram authoring rules:**
333+
- ONLY include nodes and edges that are supported by code you have actually read via tool calls. Do NOT invent components, services, or relationships. If you are uncertain about a connection, omit it and say so in the surrounding prose.
334+
- Keep diagrams focused. Prefer two small diagrams (e.g. "high level" + "request flow detail") over one giant one with twenty nodes.
335+
- For every meaningful node in the diagram, cite its source location in the surrounding prose using the existing \`${FILE_REFERENCE_PREFIX}\` format, e.g. "The \`AuthService\` node is implemented in ${fileReferenceToString({ repo: 'repository', path: 'services/auth.ts', range: { startLine: 1, endLine: 30 } })}".
336+
- Use short, descriptive labels for nodes — function names, service names, or short phrases. Avoid full sentences inside nodes.
337+
- Make sure the mermaid syntax parses. If unsure about a particular feature, use the simplest form (e.g. \`A --> B\` rather than complex edge styling).
338+
339+
**Label hygiene — CRITICAL for mermaid to parse correctly:**
340+
Mermaid's tokenizer is fragile. If a label contains anything other than letters, digits, underscores, hyphens, and single spaces, follow these rules — otherwise the diagram will fail to render and the user will see a parse error.
341+
342+
1. **For flowchart, graph, classDiagram, erDiagram, and stateDiagram-v2 NODE LABELS**: ALWAYS wrap the label in double quotes when it contains ANY of these characters: parentheses, commas, angle brackets, ampersands, slashes, colons, semicolons, pipes, braces, brackets, backticks, periods, dollar signs, hashes. The quotes make the content opaque to the parser. Examples:
343+
- WRONG: \`A[Workflows (part_question, part_answer)]\`
344+
- RIGHT: \`A["Workflows (part_question, part_answer)"]\`
345+
- WRONG: \`B[ChatSvc.GetStreamingMessages]\`
346+
- RIGHT: \`B["ChatSvc.GetStreamingMessages"]\`
347+
- OK without quotes: \`C[ProcessRequest]\`, \`D[handle response]\`
348+
349+
2. **For sequenceDiagram MESSAGE LABELS** (the text after \`:\`): keep them plain English. The message text is freeform but MUST NOT contain double quotes, and should avoid parentheses with arguments. Describe the call instead of writing its signature:
350+
- WRONG: \`A->>B: StreamProgressMessageAsync("Semantic Kernel engaged...", "streamStart")\`
351+
- RIGHT: \`A->>B: emit streamStart progress message\`
352+
- WRONG: \`A->>B: IAsyncEnumerable<ChatMessageContent>\`
353+
- RIGHT: \`A->>B: streamed ChatMessageContent\`
354+
- WRONG: \`A->>B: CreateKernel(builder, options)\`
355+
- RIGHT: \`A->>B: CreateKernel\`
356+
357+
3. **Universal restrictions** (apply to ALL diagram types):
358+
- Never use ellipses (\`...\`). Use the word \`etc\` instead.
359+
- Never use the \`+\` or \`-\` characters at the start of a sequenceDiagram message (they activate/deactivate participants).
360+
- Drop generic type parameters: write \`ChatMessageContent stream\` not \`IAsyncEnumerable<ChatMessageContent>\`.
361+
- Prefer short verb phrases over literal code: \`build kernel and register chat service\` rather than mirroring a code snippet.
362+
363+
4. **When in doubt, quote it.** For node labels, an unnecessary pair of double quotes is harmless. A missing pair is fatal.
364+
365+
Code references for the underlying implementation belong in the surrounding prose (using \`${FILE_REFERENCE_PREFIX}\`), NOT inside the diagram labels.
366+
367+
**Example of a workflow visualization:**
368+
\`\`\`markdown
369+
${ANSWER_TAG}
370+
The GlennBot service handles incoming chat requests in three stages. Here's the request flow:
371+
372+
\\\`\\\`\\\`mermaid
373+
sequenceDiagram
374+
participant Client
375+
participant Router as Express Router
376+
participant Auth as Auth Middleware
377+
participant Service as GlennBot Service
378+
participant LLM
379+
Client->>Router: POST /chat
380+
Router->>Auth: validate session
381+
Auth-->>Router: ok
382+
Router->>Service: handleMessage()
383+
Service->>LLM: generate response
384+
LLM-->>Service: streamed reply
385+
Service-->>Client: SSE stream
386+
\\\`\\\`\\\`
387+
388+
- The router is defined in ${fileReferenceToString({ repo: 'repository', path: 'src/router.ts', range: { startLine: 12, endLine: 40 } })}
389+
- Auth validation lives in ${fileReferenceToString({ repo: 'repository', path: 'src/middleware/auth.ts', range: { startLine: 1, endLine: 25 } })}
390+
- The core handler is ${fileReferenceToString({ repo: 'repository', path: 'src/services/glennBot.ts', range: { startLine: 50, endLine: 120 } })}
391+
\`\`\`
392+
</diagram_instructions>
320393
`
321394
}
322395

packages/web/src/features/chat/components/chatThread/answerCard.tsx

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33
import { useExtractTOCItems } from "../../useTOCItems";
44
import { TableOfContents } from "./tableOfContents";
55
import { Button } from "@/components/ui/button";
6-
import { TableOfContentsIcon, ThumbsDown, ThumbsUp } from "lucide-react";
6+
import { Sparkles, TableOfContentsIcon, ThumbsDown, ThumbsUp } from "lucide-react";
77
import { Separator } from "@/components/ui/separator";
88
import { MarkdownRenderer } from "./markdownRenderer";
9-
import { forwardRef, memo, useCallback, useImperativeHandle, useRef, useState } from "react";
9+
import { forwardRef, memo, useCallback, useImperativeHandle, useMemo, useRef, useState } from "react";
1010
import { Toggle } from "@/components/ui/toggle";
1111
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
1212
import { CopyIconButton } from "@/app/(app)/components/copyIconButton";
@@ -19,15 +19,32 @@ import { LangfuseWeb } from "langfuse";
1919
import { env } from "@sourcebot/shared/client";
2020
import isEqual from "fast-deep-equal/react";
2121
import { FileSource } from "../../types";
22+
import { SUPPORTED_DIAGRAM_LANGUAGES } from "./diagramRenderer";
2223

2324
interface AnswerCardProps {
2425
answerText: string;
2526
messageId: string;
2627
chatId: string;
2728
traceId?: string;
2829
sources: FileSource[];
30+
/**
31+
* When provided, an "Visualize" button is shown in the answer header. Clicking
32+
* it sends a follow-up message asking the agent to render the answer as a
33+
* diagram. The button is hidden when the answer already contains a diagram
34+
* block, and disabled while a stream is in flight.
35+
*/
36+
onVisualize?: () => void;
37+
isVisualizeDisabled?: boolean;
2938
}
3039

40+
const diagramFenceRegexes = SUPPORTED_DIAGRAM_LANGUAGES.map(
41+
(lang) => new RegExp(`\`\`\`\\s*${lang}\\b`, 'i'),
42+
);
43+
44+
const answerContainsDiagram = (text: string): boolean => {
45+
return diagramFenceRegexes.some((re) => re.test(text));
46+
};
47+
3148
const langfuseWeb = env.NEXT_PUBLIC_LANGFUSE_PUBLIC_KEY ? new LangfuseWeb({
3249
publicKey: env.NEXT_PUBLIC_LANGFUSE_PUBLIC_KEY,
3350
baseUrl: env.NEXT_PUBLIC_LANGFUSE_BASE_URL,
@@ -39,6 +56,8 @@ const AnswerCardComponent = forwardRef<HTMLDivElement, AnswerCardProps>(({
3956
chatId,
4057
traceId,
4158
sources,
59+
onVisualize,
60+
isVisualizeDisabled = false,
4261
}, forwardedRef) => {
4362
const markdownRendererRef = useRef<HTMLDivElement>(null);
4463
// eslint-disable-next-line react-hooks/refs -- ref.current is passed to a custom hook, not used directly in render output
@@ -65,6 +84,26 @@ const AnswerCardComponent = forwardRef<HTMLDivElement, AnswerCardProps>(({
6584
return true;
6685
}, [answerText, sources, chatId, captureEvent, toast]);
6786

87+
// Always show the Visualize button when the callback is wired up. The earlier
88+
// "hide when a diagram is already present" heuristic was too aggressive — users
89+
// often want a different angle on the same answer (e.g. a higher-level overview,
90+
// or a sequence diagram instead of an architecture graph), and discoverability
91+
// suffered when the button silently disappeared mid-answer.
92+
const hasDiagram = useMemo(() => answerContainsDiagram(answerText), [answerText]);
93+
const isVisualizeButtonVisible = !!onVisualize;
94+
const visualizeButtonLabel = hasDiagram ? 'New diagram' : 'Visualize';
95+
const visualizeTooltip = hasDiagram
96+
? 'Generate another diagram (different angle or detail level)'
97+
: 'Generate a diagram from this answer';
98+
99+
const onVisualizeClick = useCallback(() => {
100+
if (!onVisualize) {
101+
return;
102+
}
103+
captureEvent('wa_chat_visualize_answer_pressed', { chatId, messageId });
104+
onVisualize();
105+
}, [onVisualize, captureEvent, chatId, messageId]);
106+
68107
const onFeedback = useCallback(async (feedbackType: 'like' | 'dislike') => {
69108
setIsSubmittingFeedback(true);
70109

@@ -114,6 +153,25 @@ const AnswerCardComponent = forwardRef<HTMLDivElement, AnswerCardProps>(({
114153
<div className="flex items-center justify-between mb-2">
115154
<p className="font-semibold text-muted-foreground">Answer</p>
116155
<div className="flex items-center gap-2">
156+
{isVisualizeButtonVisible && (
157+
<Tooltip>
158+
<TooltipTrigger asChild>
159+
<Button
160+
variant="ghost"
161+
size="sm"
162+
className="h-6 px-2 text-xs text-muted-foreground gap-1"
163+
onClick={onVisualizeClick}
164+
disabled={isVisualizeDisabled}
165+
>
166+
<Sparkles className="h-3.5 w-3.5" />
167+
{visualizeButtonLabel}
168+
</Button>
169+
</TooltipTrigger>
170+
<TooltipContent side="bottom">
171+
{visualizeTooltip}
172+
</TooltipContent>
173+
</Tooltip>
174+
)}
117175
<Tooltip>
118176
<TooltipTrigger asChild>
119177
<CopyIconButton
@@ -155,6 +213,7 @@ const AnswerCardComponent = forwardRef<HTMLDivElement, AnswerCardProps>(({
155213
<MarkdownRenderer
156214
ref={markdownRendererRef}
157215
content={answerText}
216+
chatId={chatId}
158217
// scroll-mt offsets the scroll position for headings to take account
159218
// of the sticky "answer" header.
160219
className="prose prose-sm max-w-none prose-headings:scroll-mt-14"

packages/web/src/features/chat/components/chatThread/chatThread.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
55
import { Separator } from '@/components/ui/separator';
66
import { CustomSlateEditor } from '@/features/chat/customSlateEditor';
77
import { AdditionalChatRequestParams, CustomEditor, LanguageModelInfo, SBChatMessage, SearchScope, Source } from '@/features/chat/types';
8+
import { VISUALIZE_ANSWER_PROMPT } from '@/features/chat/constants';
89
import { createUIMessage, getAllMentionElements, resetEditor, slateContentToString } from '@/features/chat/utils';
910
import { useChat } from '@ai-sdk/react';
1011
import { CreateUIMessage, DefaultChatTransport } from 'ai';
@@ -327,6 +328,14 @@ export const ChatThread = ({
327328
resetEditor(editor);
328329
}, [sendMessage, selectedSearchScopes, isAuthenticated, captureEvent, chatId, scrollToBottom]);
329330

331+
const onVisualizeAnswer = useCallback(() => {
332+
// Reuse the user's currently selected search scopes so the re-prompt has
333+
// access to the same repos the original answer was generated against.
334+
const message = createUIMessage(VISUALIZE_ANSWER_PROMPT, [], selectedSearchScopes);
335+
sendMessage(message);
336+
scrollToBottom();
337+
}, [sendMessage, selectedSearchScopes, scrollToBottom]);
338+
330339
const onDuplicate = useCallback(async (newName: string): Promise<string | null> => {
331340
if (!defaultChatId) {
332341
return null;
@@ -372,6 +381,7 @@ export const ChatThread = ({
372381
{messagePairs.map(([userMessage, assistantMessage], index) => {
373382
const isLastPair = index === messagePairs.length - 1;
374383
const isStreaming = isLastPair && (status === "streaming" || status === "submitted");
384+
const isAnyMessageStreaming = status === "streaming" || status === "submitted";
375385
// Use a stable key based on user message ID
376386
const key = userMessage.id;
377387

@@ -384,6 +394,8 @@ export const ChatThread = ({
384394
assistantMessage={assistantMessage}
385395
isStreaming={isStreaming}
386396
sources={sources}
397+
onVisualize={isLastPair ? onVisualizeAnswer : undefined}
398+
isAnotherMessageStreaming={isAnyMessageStreaming}
387399
/>
388400
{index !== messagePairs.length - 1 && (
389401
<Separator className="my-12" />

packages/web/src/features/chat/components/chatThread/chatThreadListItem.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@ interface ChatThreadListItemProps {
2323
sources: Source[];
2424
chatId: string;
2525
index: number;
26+
/**
27+
* Only provided for the most recent message pair. Sending a follow-up message
28+
* from an older turn would be confusing in the UI, so we deliberately omit
29+
* the visualize action for non-latest turns.
30+
*/
31+
onVisualize?: () => void;
32+
isAnotherMessageStreaming?: boolean;
2633
}
2734

2835
const ChatThreadListItemComponent = forwardRef<HTMLDivElement, ChatThreadListItemProps>(({
@@ -32,6 +39,8 @@ const ChatThreadListItemComponent = forwardRef<HTMLDivElement, ChatThreadListIte
3239
sources,
3340
chatId,
3441
index,
42+
onVisualize,
43+
isAnotherMessageStreaming = false,
3544
}, ref) => {
3645
const leftPanelRef = useRef<HTMLDivElement>(null);
3746
const [leftPanelHeight, setLeftPanelHeight] = useState<number | null>(null);
@@ -372,6 +381,8 @@ const ChatThreadListItemComponent = forwardRef<HTMLDivElement, ChatThreadListIte
372381
messageId={assistantMessage.id}
373382
traceId={assistantMessage.metadata?.traceId}
374383
sources={referencedFileSources}
384+
onVisualize={onVisualize}
385+
isVisualizeDisabled={isStreaming || isAnotherMessageStreaming}
375386
/>
376387
) : !isStreaming && (
377388
<p className="text-destructive">Error: No answer response was provided</p>

0 commit comments

Comments
 (0)