chore: merge dev-chat into master

# Conflicts:
#	src/web/styles.css
This commit is contained in:
2026-06-03 17:56:02 +08:00
15 changed files with 534 additions and 277 deletions

View File

@@ -1,7 +1,7 @@
import { DeleteOutlined, MoreOutlined } from "@ant-design/icons";
import { DeleteOutlined, MoreOutlined, PlusOutlined } from "@ant-design/icons";
import { Conversations } from "@ant-design/x";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { App, Spin } from "antd";
import { App, Button, Spin } from "antd";
import { useMemo, useState } from "react";
import type { Conversation } from "../../../shared/api";
@@ -52,23 +52,30 @@ export function ChatPage() {
return (
<div className="app-chat-page">
<div className="app-chat-conversations">
<div className="app-chat-conversations-header">
<Button
block
icon={<PlusOutlined />}
onClick={() => {
void createConversation(project.id, defaultModelId ?? undefined)
.then((conv) => {
void queryClient.invalidateQueries({ queryKey: CONVERSATIONS_KEY });
setActiveConversationId(conv.id);
})
.catch((err: Error) => {
void message.error(`创建会话失败:${err.message}`);
});
}}
type="primary"
>
</Button>
</div>
{isLoading ? (
<Spin />
) : (
<Conversations
activeKey={activeConversationId ?? ""}
creation={{
onClick: () => {
void createConversation(project.id, defaultModelId ?? undefined)
.then((conv) => {
void queryClient.invalidateQueries({ queryKey: CONVERSATIONS_KEY });
setActiveConversationId(conv.id);
})
.catch((err: Error) => {
void message.error(`创建会话失败:${err.message}`);
});
},
}}
items={conversations}
menu={(conv) => ({
items: [
@@ -85,6 +92,7 @@ export function ChatPage() {
trigger: <MoreOutlined />,
})}
onActiveChange={(key) => setActiveConversationId(key)}
rootClassName="app-chat-conversations-list"
/>
)}
</div>

View File

@@ -0,0 +1,84 @@
import type { ReactNode } from "react";
import { CopyOutlined } from "@ant-design/icons";
import { Button, message } from "antd";
import { useCallback, useEffect, useState } from "react";
import { codeToHtml } from "shiki";
import { useIsDark } from "../../../shared/hooks/use-is-dark";
interface CodeBlockProps {
children: ReactNode;
className?: string;
isStreaming: boolean;
}
export function CodeBlock({ children, className: _className, isStreaming }: CodeBlockProps) {
const isDark = useIsDark();
const [highlighted, setHighlighted] = useState<null | string>(null);
const { codeText, lang } = extractCode(children);
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(codeText).then(
() => message.success("已复制"),
() => message.error("复制失败"),
);
}, [codeText]);
useEffect(() => {
if (isStreaming || !codeText) return;
let cancelled = false;
codeToHtml(codeText, {
lang,
theme: isDark ? "github-dark" : "github-light",
})
.then((html) => {
if (!cancelled) setHighlighted(html);
})
.catch(() => {
if (!cancelled) setHighlighted(null);
});
return () => {
cancelled = true;
};
}, [codeText, lang, isDark, isStreaming]);
if (isStreaming) {
return (
<pre className="code-block">
<code>{codeText}</code>
</pre>
);
}
return (
<div className="code-block">
<div className="code-block-header">
<span className="code-block-lang">{lang}</span>
<Button icon={<CopyOutlined />} onClick={handleCopy} size="small" type="text" />
</div>
{highlighted ? (
<div className="code-block-body" dangerouslySetInnerHTML={{ __html: highlighted }} />
) : (
<pre className="code-block-body">
<code>{codeText}</code>
</pre>
)}
</div>
);
}
function extractCode(children: ReactNode): { codeText: string; lang: string } {
if (children && typeof children === "object" && "props" in children && children.props) {
const props = children.props as Record<string, unknown>;
const codeText = typeof props["children"] === "string" ? props["children"] : "";
const codeClassName = typeof props["className"] === "string" ? props["className"] : "";
const classes = codeClassName.split(/\s+/);
const langClass = classes.find((c) => c.startsWith("lang-") || c.startsWith("language-")) ?? "";
const lang = langClass.replace(/^(language|lang)-/, "") || "text";
return { codeText, lang };
}
return { codeText: typeof children === "string" ? children : "", lang: "text" };
}

View File

@@ -1,82 +0,0 @@
import { CopyOutlined } from "@ant-design/icons";
import { CodeHighlighter } from "@ant-design/x";
import { App, Button, Flex, Typography } from "antd";
import React from "react";
import { oneDark, oneLight } from "react-syntax-highlighter/dist/esm/styles/prism";
import { useIsDark } from "../../../shared/hooks/use-is-dark";
type SyntaxTheme = Record<string, Record<string, null | number | string>>;
const customOneDark: SyntaxTheme = {
...(oneDark as SyntaxTheme),
'pre[class*="language-"]': {
...(oneDark as SyntaxTheme)['pre[class*="language-"]'],
margin: 0,
},
};
const customOneLight: SyntaxTheme = {
...(oneLight as SyntaxTheme),
'pre[class*="language-"]': {
...(oneLight as SyntaxTheme)['pre[class*="language-"]'],
margin: 0,
},
};
interface CodeBlockWithCopyProps {
block?: boolean;
children?: React.ReactNode;
className?: string;
lang?: string;
streamStatus?: "done" | "loading";
}
export function CodeBlockWithCopy({ block, children, className, lang }: CodeBlockWithCopyProps) {
const { message } = App.useApp();
const isDark = useIsDark();
if (!block) {
return <code className={className}>{children}</code>;
}
const codeText = extractText(children);
const displayLang = lang ?? "plaintext";
const handleCopy = () => {
void navigator.clipboard.writeText(codeText).then(() => {
void message.success("已复制");
});
};
const header = (
<Flex align="center" justify="space-between" style={{ padding: "0 4px" }}>
<Typography.Text style={{ color: "var(--ant-color-text-quaternary)", fontSize: 12 }}>
{displayLang}
</Typography.Text>
<Button
icon={<CopyOutlined />}
onClick={handleCopy}
size="small"
style={{ color: "var(--ant-color-text-quaternary)" }}
type="text"
/>
</Flex>
);
return (
<CodeHighlighter
header={header}
highlightProps={{ style: (isDark ? customOneDark : customOneLight) as React.CSSProperties }}
lang={displayLang}
>
{codeText}
</CodeHighlighter>
);
}
function extractText(children: React.ReactNode): string {
return React.Children.toArray(children)
.map((child) => (typeof child === "string" ? child : ""))
.join("");
}

View File

@@ -0,0 +1,9 @@
import type { ReactNode } from "react";
interface MarkdownTableProps {
children: ReactNode;
}
export function MarkdownTable({ children }: MarkdownTableProps) {
return <table className="markdown-table">{children}</table>;
}

View File

@@ -1,38 +1,35 @@
import { XMarkdown } from "@ant-design/x-markdown";
import "@ant-design/x-markdown/themes/dark.css";
import "@ant-design/x-markdown/themes/light.css";
import { Typography } from "antd";
import Markdown from "markdown-to-jsx/react";
import type { PartProps } from "./types";
import { useIsDark } from "../../../shared/hooks/use-is-dark";
import { CodeBlockWithCopy } from "./CodeBlockWithCopy";
import { CodeBlock } from "./CodeBlock";
import { MarkdownTable } from "./MarkdownTable";
interface TextPartProps extends PartProps {
isStreaming: boolean;
role: string;
}
const xmarkdownComponents = {
code: CodeBlockWithCopy,
pre: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
};
export function TextPart({ isStreaming, part, role }: TextPartProps) {
const text = typeof part["text"] === "string" ? part["text"] : "";
const isDark = useIsDark();
return (
<div className="part-body">
{role === "user" ? (
<Typography.Paragraph className="message-body-text">{text}</Typography.Paragraph>
) : (
<XMarkdown
className={isDark ? "x-markdown-dark" : "x-markdown-light"}
components={xmarkdownComponents}
content={text}
streaming={{ hasNextChunk: isStreaming }}
/>
<Markdown
options={{
optimizeForStreaming: isStreaming,
overrides: {
pre: { component: CodeBlock, props: { isStreaming } },
table: MarkdownTable,
},
}}
>
{text}
</Markdown>
)}
</div>
);