Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@
"langfuse-vercel": "^3.38.4",
"linguist-languages": "^9.3.1",
"lucide-react": "^0.517.0",
"mermaid": "^11.15.0",
"micromatch": "^4.0.8",
"minidenticons": "^4.2.1",
"next": "^16.2.6",
Expand All @@ -176,6 +177,7 @@
"react-icons": "^5.3.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^2.1.1",
"react-zoom-pan-pinch": "^4.0.3",
"recharts": "^2.15.3",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
Expand Down
73 changes: 73 additions & 0 deletions packages/web/src/features/chat/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,79 @@ const createPrompt = ({
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.
\`\`\`
</answer_instructions>

<diagram_instructions>
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.

**Diagram type selection** — pick the one that best fits the question:
- \`sequenceDiagram\` — request flows, API call chains, message passing between services or actors
- \`flowchart TD\` or \`flowchart LR\` — pipelines, conditional logic, decision trees, build / deploy flows
- \`graph TD\` — service topology, architecture overviews, component dependencies
- \`classDiagram\` — model / class relationships, inheritance hierarchies
- \`erDiagram\` — database schemas and entity relationships
- \`stateDiagram-v2\` — state machines and lifecycle transitions

**Diagram authoring rules:**
- 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.
- Keep diagrams focused. Prefer two small diagrams (e.g. "high level" + "request flow detail") over one giant one with twenty nodes.
- 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 } })}".
- Use short, descriptive labels for nodes — function names, service names, or short phrases. Avoid full sentences inside nodes.
- 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).

**Label hygiene — CRITICAL for mermaid to parse correctly:**
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.

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:
- WRONG: \`A[Workflows (part_question, part_answer)]\`
- RIGHT: \`A["Workflows (part_question, part_answer)"]\`
- WRONG: \`B[ChatSvc.GetStreamingMessages]\`
- RIGHT: \`B["ChatSvc.GetStreamingMessages"]\`
- OK without quotes: \`C[ProcessRequest]\`, \`D[handle response]\`

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:
- WRONG: \`A->>B: StreamProgressMessageAsync("Semantic Kernel engaged...", "streamStart")\`
- RIGHT: \`A->>B: emit streamStart progress message\`
- WRONG: \`A->>B: IAsyncEnumerable<ChatMessageContent>\`
- RIGHT: \`A->>B: streamed ChatMessageContent\`
- WRONG: \`A->>B: CreateKernel(builder, options)\`
- RIGHT: \`A->>B: CreateKernel\`

3. **Universal restrictions** (apply to ALL diagram types):
- Never use ellipses (\`...\`). Use the word \`etc\` instead.
- Never use the \`+\` or \`-\` characters at the start of a sequenceDiagram message (they activate/deactivate participants).
- Drop generic type parameters: write \`ChatMessageContent stream\` not \`IAsyncEnumerable<ChatMessageContent>\`.
- Prefer short verb phrases over literal code: \`build kernel and register chat service\` rather than mirroring a code snippet.

4. **When in doubt, quote it.** For node labels, an unnecessary pair of double quotes is harmless. A missing pair is fatal.

Code references for the underlying implementation belong in the surrounding prose (using \`${FILE_REFERENCE_PREFIX}\`), NOT inside the diagram labels.

**Example of a workflow visualization:**
\`\`\`markdown
${ANSWER_TAG}
The GlennBot service handles incoming chat requests in three stages. Here's the request flow:

\\\`\\\`\\\`mermaid
sequenceDiagram
participant Client
participant Router as Express Router
participant Auth as Auth Middleware
participant Service as GlennBot Service
participant LLM
Client->>Router: POST /chat
Router->>Auth: validate session
Auth-->>Router: ok
Router->>Service: handleMessage()
Service->>LLM: generate response
LLM-->>Service: streamed reply
Service-->>Client: SSE stream
\\\`\\\`\\\`

- The router is defined in ${fileReferenceToString({ repo: 'repository', path: 'src/router.ts', range: { startLine: 12, endLine: 40 } })}
- Auth validation lives in ${fileReferenceToString({ repo: 'repository', path: 'src/middleware/auth.ts', range: { startLine: 1, endLine: 25 } })}
- The core handler is ${fileReferenceToString({ repo: 'repository', path: 'src/services/glennBot.ts', range: { startLine: 50, endLine: 120 } })}
\`\`\`
</diagram_instructions>
Comment thread
wilson101xx marked this conversation as resolved.
`
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@
import { useExtractTOCItems } from "../../useTOCItems";
import { TableOfContents } from "./tableOfContents";
import { Button } from "@/components/ui/button";
import { TableOfContentsIcon, ThumbsDown, ThumbsUp } from "lucide-react";
import { Sparkles, TableOfContentsIcon, ThumbsDown, ThumbsUp } from "lucide-react";
import { Separator } from "@/components/ui/separator";
import { MarkdownRenderer } from "./markdownRenderer";
import { forwardRef, memo, useCallback, useImperativeHandle, useRef, useState } from "react";
import { forwardRef, memo, useCallback, useImperativeHandle, useMemo, useRef, useState } from "react";
import { Toggle } from "@/components/ui/toggle";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { CopyIconButton } from "@/app/(app)/components/copyIconButton";
Expand All @@ -19,15 +19,32 @@ import { LangfuseWeb } from "langfuse";
import { env } from "@sourcebot/shared/client";
import isEqual from "fast-deep-equal/react";
import { FileSource } from "../../types";
import { SUPPORTED_DIAGRAM_LANGUAGES } from "./diagramRenderer";

interface AnswerCardProps {
answerText: string;
messageId: string;
chatId: string;
traceId?: string;
sources: FileSource[];
/**
* When provided, an "Visualize" button is shown in the answer header. Clicking
* it sends a follow-up message asking the agent to render the answer as a
* diagram. The button is hidden when the answer already contains a diagram
* block, and disabled while a stream is in flight.
*/
Comment thread
coderabbitai[bot] marked this conversation as resolved.
onVisualize?: () => void;
isVisualizeDisabled?: boolean;
}

const diagramFenceRegexes = SUPPORTED_DIAGRAM_LANGUAGES.map(
(lang) => new RegExp(`\`\`\`\\s*${lang}\\b`, 'i'),
);

const answerContainsDiagram = (text: string): boolean => {
return diagramFenceRegexes.some((re) => re.test(text));
};

const langfuseWeb = env.NEXT_PUBLIC_LANGFUSE_PUBLIC_KEY ? new LangfuseWeb({
publicKey: env.NEXT_PUBLIC_LANGFUSE_PUBLIC_KEY,
baseUrl: env.NEXT_PUBLIC_LANGFUSE_BASE_URL,
Expand All @@ -39,6 +56,8 @@ const AnswerCardComponent = forwardRef<HTMLDivElement, AnswerCardProps>(({
chatId,
traceId,
sources,
onVisualize,
isVisualizeDisabled = false,
}, forwardedRef) => {
const markdownRendererRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line react-hooks/refs -- ref.current is passed to a custom hook, not used directly in render output
Expand All @@ -65,6 +84,26 @@ const AnswerCardComponent = forwardRef<HTMLDivElement, AnswerCardProps>(({
return true;
}, [answerText, sources, chatId, captureEvent, toast]);

// Always show the Visualize button when the callback is wired up. The earlier
// "hide when a diagram is already present" heuristic was too aggressive — users
// often want a different angle on the same answer (e.g. a higher-level overview,
// or a sequence diagram instead of an architecture graph), and discoverability
// suffered when the button silently disappeared mid-answer.
const hasDiagram = useMemo(() => answerContainsDiagram(answerText), [answerText]);
const isVisualizeButtonVisible = !!onVisualize;
const visualizeButtonLabel = hasDiagram ? 'New diagram' : 'Visualize';
const visualizeTooltip = hasDiagram
? 'Generate another diagram (different angle or detail level)'
: 'Generate a diagram from this answer';

const onVisualizeClick = useCallback(() => {
if (!onVisualize) {
return;
}
captureEvent('wa_chat_visualize_answer_pressed', { chatId, messageId });
onVisualize();
}, [onVisualize, captureEvent, chatId, messageId]);

const onFeedback = useCallback(async (feedbackType: 'like' | 'dislike') => {
setIsSubmittingFeedback(true);

Expand Down Expand Up @@ -114,6 +153,25 @@ const AnswerCardComponent = forwardRef<HTMLDivElement, AnswerCardProps>(({
<div className="flex items-center justify-between mb-2">
<p className="font-semibold text-muted-foreground">Answer</p>
<div className="flex items-center gap-2">
{isVisualizeButtonVisible && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-muted-foreground gap-1"
onClick={onVisualizeClick}
disabled={isVisualizeDisabled}
>
<Sparkles className="h-3.5 w-3.5" />
{visualizeButtonLabel}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{visualizeTooltip}
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<CopyIconButton
Expand Down Expand Up @@ -155,6 +213,7 @@ const AnswerCardComponent = forwardRef<HTMLDivElement, AnswerCardProps>(({
<MarkdownRenderer
ref={markdownRendererRef}
content={answerText}
chatId={chatId}
// scroll-mt offsets the scroll position for headings to take account
// of the sticky "answer" header.
className="prose prose-sm max-w-none prose-headings:scroll-mt-14"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { CustomSlateEditor } from '@/features/chat/customSlateEditor';
import { AdditionalChatRequestParams, CustomEditor, LanguageModelInfo, SBChatMessage, SearchScope, Source } from '@/features/chat/types';
import { VISUALIZE_ANSWER_PROMPT } from '@/features/chat/constants';
import { createUIMessage, getAllMentionElements, resetEditor, slateContentToString } from '@/features/chat/utils';
import { useChat } from '@ai-sdk/react';
import { CreateUIMessage, DefaultChatTransport } from 'ai';
Expand Down Expand Up @@ -327,6 +328,14 @@ export const ChatThread = ({
resetEditor(editor);
}, [sendMessage, selectedSearchScopes, isAuthenticated, captureEvent, chatId, scrollToBottom]);

const onVisualizeAnswer = useCallback(() => {
// Reuse the user's currently selected search scopes so the re-prompt has
// access to the same repos the original answer was generated against.
const message = createUIMessage(VISUALIZE_ANSWER_PROMPT, [], selectedSearchScopes);
sendMessage(message);
scrollToBottom();
}, [sendMessage, selectedSearchScopes, scrollToBottom]);

const onDuplicate = useCallback(async (newName: string): Promise<string | null> => {
if (!defaultChatId) {
return null;
Expand Down Expand Up @@ -372,6 +381,7 @@ export const ChatThread = ({
{messagePairs.map(([userMessage, assistantMessage], index) => {
const isLastPair = index === messagePairs.length - 1;
const isStreaming = isLastPair && (status === "streaming" || status === "submitted");
const isAnyMessageStreaming = status === "streaming" || status === "submitted";
// Use a stable key based on user message ID
const key = userMessage.id;

Expand All @@ -384,6 +394,8 @@ export const ChatThread = ({
assistantMessage={assistantMessage}
isStreaming={isStreaming}
sources={sources}
onVisualize={isLastPair ? onVisualizeAnswer : undefined}
isAnotherMessageStreaming={isAnyMessageStreaming}
/>
{index !== messagePairs.length - 1 && (
<Separator className="my-12" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ interface ChatThreadListItemProps {
sources: Source[];
chatId: string;
index: number;
/**
* Only provided for the most recent message pair. Sending a follow-up message
* from an older turn would be confusing in the UI, so we deliberately omit
* the visualize action for non-latest turns.
*/
onVisualize?: () => void;
isAnotherMessageStreaming?: boolean;
}

const ChatThreadListItemComponent = forwardRef<HTMLDivElement, ChatThreadListItemProps>(({
Expand All @@ -32,6 +39,8 @@ const ChatThreadListItemComponent = forwardRef<HTMLDivElement, ChatThreadListIte
sources,
chatId,
index,
onVisualize,
isAnotherMessageStreaming = false,
}, ref) => {
const leftPanelRef = useRef<HTMLDivElement>(null);
const [leftPanelHeight, setLeftPanelHeight] = useState<number | null>(null);
Expand Down Expand Up @@ -372,6 +381,8 @@ const ChatThreadListItemComponent = forwardRef<HTMLDivElement, ChatThreadListIte
messageId={assistantMessage.id}
traceId={assistantMessage.metadata?.traceId}
sources={referencedFileSources}
onVisualize={onVisualize}
isVisualizeDisabled={isStreaming || isAnotherMessageStreaming}
/>
) : !isStreaming && (
<p className="text-destructive">Error: No answer response was provided</p>
Expand Down
Loading