mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-19 12:24:46 +08:00
feat: use tailwindcss-typography as markdown styling
This commit is contained in:
@@ -39,7 +39,10 @@ export function Markdown({
|
|||||||
}, [animate]);
|
}, [animate]);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(className, "markdown flex flex-col gap-4")}
|
className={cn(
|
||||||
|
className,
|
||||||
|
"prose dark:prose-invert prose-p:my-0 prose-img:mt-0 flex flex-col gap-4",
|
||||||
|
)}
|
||||||
style={style}
|
style={style}
|
||||||
>
|
>
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
|
|||||||
@@ -316,13 +316,13 @@ function PlanCard({
|
|||||||
<Card className={cn("w-full", className)}>
|
<Card className={cn("w-full", className)}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>
|
<CardTitle>
|
||||||
<h1 className="text-xl font-medium">
|
<Markdown animate>
|
||||||
<Markdown animate>
|
{`### ${
|
||||||
{plan.title !== undefined && plan.title !== ""
|
plan.title !== undefined && plan.title !== ""
|
||||||
? plan.title
|
? plan.title
|
||||||
: "Deep Research"}
|
: "Deep Research"
|
||||||
</Markdown>
|
}`}
|
||||||
</h1>
|
</Markdown>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
import { useRef } from "react";
|
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
|
||||||
|
|
||||||
import ReportEditor from "~/components/editor";
|
import ReportEditor from "~/components/editor";
|
||||||
import { useMessage } from "~/core/store";
|
import { useMessage, useStore } from "~/core/store";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
import { LoadingAnimation } from "./loading-animation";
|
import { LoadingAnimation } from "./loading-animation";
|
||||||
@@ -19,15 +19,45 @@ export function ResearchReportBlock({
|
|||||||
messageId: string;
|
messageId: string;
|
||||||
}) {
|
}) {
|
||||||
const message = useMessage(messageId);
|
const message = useMessage(messageId);
|
||||||
|
const handleMarkdownChange = useCallback(
|
||||||
|
(markdown: string) => {
|
||||||
|
if (message) {
|
||||||
|
message.content = markdown;
|
||||||
|
useStore.setState({
|
||||||
|
messages: new Map(useStore.getState().messages).set(
|
||||||
|
message.id,
|
||||||
|
message,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[message],
|
||||||
|
);
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
const isCompleted = message?.isStreaming === false && message?.content !== "";
|
const isCompleted = message?.isStreaming === false && message?.content !== "";
|
||||||
|
// TODO: scroll to top when completed, but it's not working
|
||||||
|
// useEffect(() => {
|
||||||
|
// if (isCompleted && contentRef.current) {
|
||||||
|
// setTimeout(() => {
|
||||||
|
// contentRef
|
||||||
|
// .current!.closest("[data-radix-scroll-area-viewport]")
|
||||||
|
// ?.scrollTo({
|
||||||
|
// top: 0,
|
||||||
|
// behavior: "smooth",
|
||||||
|
// });
|
||||||
|
// }, 500);
|
||||||
|
// }
|
||||||
|
// }, [isCompleted]);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
className={cn("relative flex flex-col pb-8", className)}
|
className={cn("relative flex flex-col pt-4 pb-8", className)}
|
||||||
>
|
>
|
||||||
{isCompleted ? (
|
{isCompleted ? (
|
||||||
<ReportEditor content={message?.content} />
|
<ReportEditor
|
||||||
|
content={message?.content}
|
||||||
|
onMarkdownChange={handleMarkdownChange}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Markdown animate>{message?.content}</Markdown>
|
<Markdown animate>{message?.content}</Markdown>
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export function AISelector({ onOpenChange }: AISelectorProps) {
|
|||||||
{hasCompletion && (
|
{hasCompletion && (
|
||||||
<div className="flex max-h-[400px]">
|
<div className="flex max-h-[400px]">
|
||||||
<ScrollArea>
|
<ScrollArea>
|
||||||
<div className="prose prose-sm p-2 px-4">
|
<div className="prose prose-sm dark:prose-invert p-2 px-4">
|
||||||
<Markdown>{completion}</Markdown>
|
<Markdown>{completion}</Markdown>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|||||||
@@ -38,12 +38,12 @@ const extensions = [...defaultExtensions, slashCommand];
|
|||||||
|
|
||||||
export interface ReportEditorProps {
|
export interface ReportEditorProps {
|
||||||
content: Content;
|
content: Content;
|
||||||
|
onMarkdownChange?: (markdown: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ReportEditor = ({ content }: ReportEditorProps) => {
|
const ReportEditor = ({ content, onMarkdownChange }: ReportEditorProps) => {
|
||||||
const [initialContent, setInitialContent] = useState<Content>(() => content);
|
const [initialContent, setInitialContent] = useState<Content>(() => content);
|
||||||
const [saveStatus, setSaveStatus] = useState("Saved");
|
const [saveStatus, setSaveStatus] = useState("Saved");
|
||||||
const [charsCount, setCharsCount] = useState();
|
|
||||||
|
|
||||||
const [openNode, setOpenNode] = useState(false);
|
const [openNode, setOpenNode] = useState(false);
|
||||||
const [openColor, setOpenColor] = useState(false);
|
const [openColor, setOpenColor] = useState(false);
|
||||||
@@ -63,17 +63,21 @@ const ReportEditor = ({ content }: ReportEditorProps) => {
|
|||||||
|
|
||||||
const debouncedUpdates = useDebouncedCallback(
|
const debouncedUpdates = useDebouncedCallback(
|
||||||
async (editor: EditorInstance) => {
|
async (editor: EditorInstance) => {
|
||||||
const json = editor.getJSON();
|
// const json = editor.getJSON();
|
||||||
setCharsCount(editor.storage.characterCount.words());
|
// // setCharsCount(editor.storage.characterCount.words());
|
||||||
window.localStorage.setItem(
|
// window.localStorage.setItem(
|
||||||
"html-content",
|
// "html-content",
|
||||||
highlightCodeblocks(editor.getHTML()),
|
// highlightCodeblocks(editor.getHTML()),
|
||||||
);
|
// );
|
||||||
window.localStorage.setItem("novel-content", JSON.stringify(json));
|
// window.localStorage.setItem("novel-content", JSON.stringify(json));
|
||||||
window.localStorage.setItem(
|
// window.localStorage.setItem(
|
||||||
"markdown",
|
// "markdown",
|
||||||
editor.storage.markdown.getMarkdown(),
|
// editor.storage.markdown.getMarkdown(),
|
||||||
);
|
// );
|
||||||
|
if (onMarkdownChange) {
|
||||||
|
const markdown = editor.storage.markdown.getMarkdown();
|
||||||
|
onMarkdownChange(markdown);
|
||||||
|
}
|
||||||
setSaveStatus("Saved");
|
setSaveStatus("Saved");
|
||||||
},
|
},
|
||||||
500,
|
500,
|
||||||
@@ -89,26 +93,12 @@ const ReportEditor = ({ content }: ReportEditorProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full">
|
<div className="relative w-full">
|
||||||
<div className="absolute top-5 right-5 z-10 mb-5 flex gap-2">
|
|
||||||
<div className="bg-accent text-muted-foreground rounded-lg px-2 py-1 text-sm">
|
|
||||||
{saveStatus}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
charsCount
|
|
||||||
? "bg-accent text-muted-foreground rounded-lg px-2 py-1 text-sm"
|
|
||||||
: "hidden"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{charsCount} Words
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<EditorRoot>
|
<EditorRoot>
|
||||||
<EditorContent
|
<EditorContent
|
||||||
immediatelyRender={false}
|
immediatelyRender={false}
|
||||||
initialContent={initialContent as JSONContent}
|
initialContent={initialContent as JSONContent}
|
||||||
extensions={extensions}
|
extensions={extensions}
|
||||||
className="border-muted bg-background relative h-full w-full overflow-auto sm:mb-[calc(20vh)] sm:border sm:shadow-lg"
|
className="border-muted relative h-full w-full"
|
||||||
editorProps={{
|
editorProps={{
|
||||||
handleDOMEvents: {
|
handleDOMEvents: {
|
||||||
keydown: (_view, event) => handleCommandNavigation(event),
|
keydown: (_view, event) => handleCommandNavigation(event),
|
||||||
|
|||||||
@@ -1,32 +1,18 @@
|
|||||||
import {
|
import {
|
||||||
Brain,
|
|
||||||
CheckSquare,
|
CheckSquare,
|
||||||
Code,
|
Code,
|
||||||
Heading1,
|
Heading1,
|
||||||
Heading2,
|
Heading2,
|
||||||
Heading3,
|
Heading3,
|
||||||
ImageIcon,
|
|
||||||
List,
|
List,
|
||||||
ListOrdered,
|
ListOrdered,
|
||||||
MessageSquarePlus,
|
|
||||||
Text,
|
Text,
|
||||||
TextQuote,
|
TextQuote,
|
||||||
Twitter,
|
|
||||||
Youtube,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Command, createSuggestionItems, renderItems } from "novel";
|
import { Command, createSuggestionItems, renderItems } from "novel";
|
||||||
import { uploadFn } from "./image-upload";
|
// import { uploadFn } from "./image-upload";
|
||||||
|
|
||||||
export const suggestionItems = createSuggestionItems([
|
export const suggestionItems = createSuggestionItems([
|
||||||
{
|
|
||||||
title: "Send Feedback",
|
|
||||||
description: "Let us know how we can improve.",
|
|
||||||
icon: <MessageSquarePlus size={18} />,
|
|
||||||
command: ({ editor, range }) => {
|
|
||||||
editor.chain().focus().deleteRange(range).run();
|
|
||||||
window.open("/feedback", "_blank");
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: "Text",
|
title: "Text",
|
||||||
description: "Just start typing with plain text.",
|
description: "Just start typing with plain text.",
|
||||||
@@ -132,83 +118,83 @@ export const suggestionItems = createSuggestionItems([
|
|||||||
command: ({ editor, range }) =>
|
command: ({ editor, range }) =>
|
||||||
editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
|
editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
title: "Image",
|
// title: "Image",
|
||||||
description: "Upload an image from your computer.",
|
// description: "Upload an image from your computer.",
|
||||||
searchTerms: ["photo", "picture", "media"],
|
// searchTerms: ["photo", "picture", "media"],
|
||||||
icon: <ImageIcon size={18} />,
|
// icon: <ImageIcon size={18} />,
|
||||||
command: ({ editor, range }) => {
|
// command: ({ editor, range }) => {
|
||||||
editor.chain().focus().deleteRange(range).run();
|
// editor.chain().focus().deleteRange(range).run();
|
||||||
// upload image
|
// // upload image
|
||||||
const input = document.createElement("input");
|
// const input = document.createElement("input");
|
||||||
input.type = "file";
|
// input.type = "file";
|
||||||
input.accept = "image/*";
|
// input.accept = "image/*";
|
||||||
input.onchange = async () => {
|
// input.onchange = async () => {
|
||||||
if (input.files?.length) {
|
// if (input.files?.length) {
|
||||||
const file = input.files[0];
|
// const file = input.files[0];
|
||||||
if (!file) return;
|
// if (!file) return;
|
||||||
const pos = editor.view.state.selection.from;
|
// const pos = editor.view.state.selection.from;
|
||||||
uploadFn(file, editor.view, pos);
|
// uploadFn(file, editor.view, pos);
|
||||||
}
|
// }
|
||||||
};
|
// };
|
||||||
input.click();
|
// input.click();
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
title: "Youtube",
|
// title: "Youtube",
|
||||||
description: "Embed a Youtube video.",
|
// description: "Embed a Youtube video.",
|
||||||
searchTerms: ["video", "youtube", "embed"],
|
// searchTerms: ["video", "youtube", "embed"],
|
||||||
icon: <Youtube size={18} />,
|
// icon: <Youtube size={18} />,
|
||||||
command: ({ editor, range }) => {
|
// command: ({ editor, range }) => {
|
||||||
const videoLink = prompt("Please enter Youtube Video Link");
|
// const videoLink = prompt("Please enter Youtube Video Link");
|
||||||
//From https://regexr.com/3dj5t
|
// //From https://regexr.com/3dj5t
|
||||||
const ytRegex = new RegExp(
|
// const ytRegex = new RegExp(
|
||||||
/^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|v\/)?)([\w\-]+)(\S+)?$/,
|
// /^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|v\/)?)([\w\-]+)(\S+)?$/,
|
||||||
);
|
// );
|
||||||
|
|
||||||
if (videoLink && ytRegex.test(videoLink)) {
|
// if (videoLink && ytRegex.test(videoLink)) {
|
||||||
editor
|
// editor
|
||||||
.chain()
|
// .chain()
|
||||||
.focus()
|
// .focus()
|
||||||
.deleteRange(range)
|
// .deleteRange(range)
|
||||||
.setYoutubeVideo({
|
// .setYoutubeVideo({
|
||||||
src: videoLink,
|
// src: videoLink,
|
||||||
})
|
// })
|
||||||
.run();
|
// .run();
|
||||||
} else {
|
// } else {
|
||||||
if (videoLink !== null) {
|
// if (videoLink !== null) {
|
||||||
alert("Please enter a correct Youtube Video Link");
|
// alert("Please enter a correct Youtube Video Link");
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
title: "Twitter",
|
// title: "Twitter",
|
||||||
description: "Embed a Tweet.",
|
// description: "Embed a Tweet.",
|
||||||
searchTerms: ["twitter", "embed"],
|
// searchTerms: ["twitter", "embed"],
|
||||||
icon: <Twitter size={18} />,
|
// icon: <Twitter size={18} />,
|
||||||
command: ({ editor, range }) => {
|
// command: ({ editor, range }) => {
|
||||||
const tweetLink = prompt("Please enter Twitter Link");
|
// const tweetLink = prompt("Please enter Twitter Link");
|
||||||
const tweetRegex = new RegExp(
|
// const tweetRegex = new RegExp(
|
||||||
/^https?:\/\/(www\.)?x\.com\/([a-zA-Z0-9_]{1,15})(\/status\/(\d+))?(\/\S*)?$/,
|
// /^https?:\/\/(www\.)?x\.com\/([a-zA-Z0-9_]{1,15})(\/status\/(\d+))?(\/\S*)?$/,
|
||||||
);
|
// );
|
||||||
|
|
||||||
if (tweetLink && tweetRegex.test(tweetLink)) {
|
// if (tweetLink && tweetRegex.test(tweetLink)) {
|
||||||
editor
|
// editor
|
||||||
.chain()
|
// .chain()
|
||||||
.focus()
|
// .focus()
|
||||||
.deleteRange(range)
|
// .deleteRange(range)
|
||||||
.setTweet({
|
// .setTweet({
|
||||||
src: tweetLink,
|
// src: tweetLink,
|
||||||
})
|
// })
|
||||||
.run();
|
// .run();
|
||||||
} else {
|
// } else {
|
||||||
if (tweetLink !== null) {
|
// if (tweetLink !== null) {
|
||||||
alert("Please enter a correct Twitter Link");
|
// alert("Please enter a correct Twitter Link");
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const slashCommand = Command.configure({
|
export const slashCommand = Command.configure({
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export async function* chatStream(
|
|||||||
options: { abortSignal?: AbortSignal } = {},
|
options: { abortSignal?: AbortSignal } = {},
|
||||||
) {
|
) {
|
||||||
if (location.search.includes("mock") || location.search.includes("replay=")) {
|
if (location.search.includes("mock") || location.search.includes("replay=")) {
|
||||||
return chatReplayStream(userMessage, params, options);
|
return yield* chatReplayStream(userMessage, params, options);
|
||||||
}
|
}
|
||||||
const stream = fetchStream(resolveServiceURL("chat/stream"), {
|
const stream = fetchStream(resolveServiceURL("chat/stream"), {
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|||||||
@@ -201,94 +201,6 @@ textarea {
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown {
|
|
||||||
line-height: 1.75;
|
|
||||||
|
|
||||||
a {
|
|
||||||
text-decoration: underline;
|
|
||||||
text-decoration-style: dotted;
|
|
||||||
text-underline-position: from-font;
|
|
||||||
text-decoration-color: var(--muted-foreground);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
@apply text-2xl font-bold;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
@apply text-xl font-bold;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
@apply text-lg font-bold;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h4 {
|
|
||||||
@apply text-base font-bold;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h5 {
|
|
||||||
@apply text-sm font-bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
h6 {
|
|
||||||
@apply text-xs font-bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
@apply list-disc pl-4;
|
|
||||||
}
|
|
||||||
|
|
||||||
ol {
|
|
||||||
@apply list-decimal pl-4;
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
display: block;
|
|
||||||
max-width: 100%;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
@apply w-full;
|
|
||||||
table-layout: fixed;
|
|
||||||
border-collapse: collapse;
|
|
||||||
|
|
||||||
th,
|
|
||||||
td {
|
|
||||||
padding: 4px 8px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
@apply bg-muted;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
code:not([class*="language-"]) {
|
|
||||||
@apply bg-muted;
|
|
||||||
@apply rounded-md;
|
|
||||||
@apply px-1;
|
|
||||||
@apply py-0.5;
|
|
||||||
@apply border;
|
|
||||||
}
|
|
||||||
|
|
||||||
blockquote {
|
|
||||||
@apply border-muted-foreground text-muted-foreground border-l-2 pl-2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
@import "./globals.css";
|
@import "./globals.css";
|
||||||
|
|
||||||
.ProseMirror {
|
.ProseMirror {
|
||||||
@apply p-12 px-8 sm:px-12;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror .is-editor-empty:first-child::before {
|
.ProseMirror .is-editor-empty:first-child::before {
|
||||||
|
|||||||
Reference in New Issue
Block a user