From 554ec7a91e56e454058baf8bb67fb8951fbb4e82 Mon Sep 17 00:00:00 2001 From: Henry Li Date: Mon, 9 Feb 2026 19:02:21 +0800 Subject: [PATCH] feat: basic implmenetation --- .../workspace/messages/markdown-content.tsx | 13 +- .../workspace/messages/message-list-item.tsx | 127 ++++++++++++++---- .../workspace/messages/message-list.tsx | 4 +- 3 files changed, 110 insertions(+), 34 deletions(-) diff --git a/frontend/src/components/workspace/messages/markdown-content.tsx b/frontend/src/components/workspace/messages/markdown-content.tsx index 12465de..d6e1ebe 100644 --- a/frontend/src/components/workspace/messages/markdown-content.tsx +++ b/frontend/src/components/workspace/messages/markdown-content.tsx @@ -1,7 +1,6 @@ "use client"; -import type { ImgHTMLAttributes } from "react"; -import type { ReactNode } from "react"; +import { useMemo } from "react"; import { MessageResponse, @@ -16,7 +15,7 @@ export type MarkdownContentProps = { className?: string; remarkPlugins?: MessageResponseProps["remarkPlugins"]; isHuman?: boolean; - img?: (props: ImgHTMLAttributes & { threadId?: string; maxWidth?: string }) => ReactNode; + components?: MessageResponseProps["components"]; }; /** Renders markdown content. */ @@ -25,10 +24,14 @@ export function MarkdownContent({ rehypePlugins, className, remarkPlugins = streamdownPlugins.remarkPlugins, - img, + components: componentsFromProps, }: MarkdownContentProps) { + const components = useMemo(() => { + return { + ...componentsFromProps, + }; + }, [componentsFromProps]); if (!content) return null; - const components = img ? { img } : undefined; return ( @@ -81,13 +87,13 @@ function MessageImage({ if (!src) return null; const imgClassName = cn("overflow-hidden rounded-lg", `max-w-[${maxWidth}]`); - + if (typeof src !== "string") { return {alt}; } - + const url = src.startsWith("/mnt/") ? resolveArtifactURL(src, threadId) : src; - + return ( {alt} @@ -107,12 +113,42 @@ function MessageContent_({ const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading); const isHuman = message.type === "human"; const { thread_id } = useParams<{ thread_id: string }>(); + const components = useMemo(() => { + return { + a: (props: HTMLAttributes) => { + if (typeof props.children === "string") { + // const match = /^\$(\d+):(.+)$/.exec(props.children); + const match = /^citation:(.+)$/.exec(props.children); + if (match) { + const [, text] = match; + return ( + + Citation - {text} + + ); + } + } + return ; + }, + img: (props: ImgHTMLAttributes) => { + return ; + }, + }; + }, [thread_id]); const rawContent = extractContentFromMessage(message); const reasoningContent = extractReasoningContentFromMessage(message); const { contentToParse, uploadedFiles } = useMemo(() => { if (!isLoading && reasoningContent && !rawContent) { - return { contentToParse: reasoningContent, uploadedFiles: [] as UploadedFile[] }; + return { + contentToParse: reasoningContent, + uploadedFiles: [] as UploadedFile[], + }; } if (isHuman && rawContent) { const { files, cleanContent: contentWithoutFiles } = @@ -125,15 +161,17 @@ function MessageContent_({ }; }, [isLoading, rawContent, reasoningContent, isHuman]); - const filesList = uploadedFiles.length > 0 && thread_id ? ( - - ) : null; + const filesList = + uploadedFiles.length > 0 && thread_id ? ( + + ) : null; if (isHuman) { const messageResponse = contentToParse ? ( {contentToParse} @@ -159,13 +197,7 @@ function MessageContent_({ rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: "html" }]]} className="my-3" isHuman={false} - img={(props) => ( - - )} + components={components} /> ); @@ -174,14 +206,33 @@ function MessageContent_({ /** * Get file extension and check helpers */ -const getFileExt = (filename: string) => filename.split(".").pop()?.toLowerCase() ?? ""; +const getFileExt = (filename: string) => + filename.split(".").pop()?.toLowerCase() ?? ""; const FILE_TYPE_MAP: Record = { - json: "JSON", csv: "CSV", txt: "TXT", md: "Markdown", - py: "Python", js: "JavaScript", ts: "TypeScript", tsx: "TSX", jsx: "JSX", - html: "HTML", css: "CSS", xml: "XML", yaml: "YAML", yml: "YAML", - pdf: "PDF", png: "PNG", jpg: "JPG", jpeg: "JPEG", gif: "GIF", - svg: "SVG", zip: "ZIP", tar: "TAR", gz: "GZ", + json: "JSON", + csv: "CSV", + txt: "TXT", + md: "Markdown", + py: "Python", + js: "JavaScript", + ts: "TypeScript", + tsx: "TSX", + jsx: "JSX", + html: "HTML", + css: "CSS", + xml: "XML", + yaml: "YAML", + yml: "YAML", + pdf: "PDF", + png: "PNG", + jpg: "JPG", + jpeg: "JPEG", + gif: "GIF", + svg: "SVG", + zip: "ZIP", + tar: "TAR", + gz: "GZ", }; const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"]; @@ -198,13 +249,23 @@ function isImageFile(filename: string): boolean { /** * Uploaded files list component */ -function UploadedFilesList({ files, threadId }: { files: UploadedFile[]; threadId: string }) { +function UploadedFilesList({ + files, + threadId, +}: { + files: UploadedFile[]; + threadId: string; +}) { if (files.length === 0) return null; return (
{files.map((file, index) => ( - + ))}
); @@ -213,7 +274,13 @@ function UploadedFilesList({ files, threadId }: { files: UploadedFile[]; threadI /** * Single uploaded file card component */ -function UploadedFileCard({ file, threadId }: { file: UploadedFile; threadId: string }) { +function UploadedFileCard({ + file, + threadId, +}: { + file: UploadedFile; + threadId: string; +}) { if (!threadId) return null; const isImage = isImageFile(file.filename); @@ -240,12 +307,18 @@ function UploadedFileCard({ file, threadId }: { file: UploadedFile; threadId: st
- + {file.filename}
- + {getFileTypeLabel(file.filename)} {file.size} diff --git a/frontend/src/components/workspace/messages/message-list.tsx b/frontend/src/components/workspace/messages/message-list.tsx index 9c0b1b0..8f577fd 100644 --- a/frontend/src/components/workspace/messages/message-list.tsx +++ b/frontend/src/components/workspace/messages/message-list.tsx @@ -1,3 +1,4 @@ +import type { Message } from "@langchain/langgraph-sdk"; import type { UseStream } from "@langchain/langgraph-sdk/react"; import { @@ -18,15 +19,14 @@ import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; import type { Subtask } from "@/core/tasks"; import { useUpdateSubtask } from "@/core/tasks/context"; import type { AgentThreadState } from "@/core/threads"; -import type { Message } from "@langchain/langgraph-sdk"; import { cn } from "@/lib/utils"; import { ArtifactFileList } from "../artifacts/artifact-file-list"; import { StreamingIndicator } from "../streaming-indicator"; +import { MarkdownContent } from "./markdown-content"; import { MessageGroup } from "./message-group"; import { MessageListItem } from "./message-list-item"; -import { MarkdownContent } from "./markdown-content"; import { MessageListSkeleton } from "./skeleton"; import { SubtaskCard } from "./subtask-card";