chore: merge dev-chat into master
# Conflicts: # src/web/styles.css
This commit is contained in:
3
src/web/css.d.ts
vendored
3
src/web/css.d.ts
vendored
@@ -1,4 +1 @@
|
||||
declare module "*.css";
|
||||
declare module "react-syntax-highlighter/dist/esm/styles/prism" {
|
||||
export { oneDark, oneLight } from "react-syntax-highlighter";
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
84
src/web/features/chat/parts/CodeBlock.tsx
Normal file
84
src/web/features/chat/parts/CodeBlock.tsx
Normal 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" };
|
||||
}
|
||||
@@ -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("");
|
||||
}
|
||||
9
src/web/features/chat/parts/MarkdownTable.tsx
Normal file
9
src/web/features/chat/parts/MarkdownTable.tsx
Normal 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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
export type EffectiveTheme = "dark" | "light";
|
||||
export type ThemePreference = "dark" | "light" | "system";
|
||||
|
||||
const PREFERENCE_CHANGE_EVENT = "theme-preference-change";
|
||||
|
||||
export const THEME_PREFERENCE_STORAGE_KEY = "theme.preference";
|
||||
export const THEME_MEDIA_QUERY = "(prefers-color-scheme: dark)";
|
||||
|
||||
@@ -44,10 +46,19 @@ export function useThemePreference() {
|
||||
return () => mediaQueryList.removeEventListener("change", handleChange);
|
||||
}, []);
|
||||
|
||||
const setPreference = (nextPreference: ThemePreference) => {
|
||||
useEffect(() => {
|
||||
const handleStorageEvent = (event: CustomEvent) => {
|
||||
const next = parseThemePreference(event.detail);
|
||||
setPreferenceState((prev) => (prev !== next ? next : prev));
|
||||
};
|
||||
window.addEventListener(PREFERENCE_CHANGE_EVENT, handleStorageEvent as EventListener);
|
||||
return () => window.removeEventListener(PREFERENCE_CHANGE_EVENT, handleStorageEvent as EventListener);
|
||||
}, []);
|
||||
|
||||
const setPreference = useCallback((nextPreference: ThemePreference) => {
|
||||
setPreferenceState(nextPreference);
|
||||
writeThemePreference(nextPreference);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { effectiveTheme, preference, setPreference };
|
||||
}
|
||||
@@ -58,4 +69,9 @@ export function writeThemePreference(preference: ThemePreference, storage: Stora
|
||||
} catch {
|
||||
// 存储不可用时仅使用当前内存状态,避免阻断 Dashboard 渲染。
|
||||
}
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent(PREFERENCE_CHANGE_EVENT, { detail: preference }));
|
||||
} catch {
|
||||
// jsdom 等环境可能不支持 CustomEvent
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,16 @@ body {
|
||||
background: var(--ant-color-bg-container);
|
||||
}
|
||||
|
||||
.app-chat-conversations-header {
|
||||
padding: var(--ant-padding-sm);
|
||||
border-bottom: 1px solid var(--ant-color-border-secondary);
|
||||
}
|
||||
|
||||
.app-chat-conversations-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.app-chat-panel {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
@@ -239,32 +249,6 @@ body {
|
||||
--os-handle-interactive-area-offset: 4px;
|
||||
}
|
||||
|
||||
.x-markdown-light table,
|
||||
.x-markdown-dark table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.x-markdown-light th,
|
||||
.x-markdown-light td,
|
||||
.x-markdown-dark th,
|
||||
.x-markdown-dark td {
|
||||
border: 1px solid var(--ant-color-border);
|
||||
padding: 6px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.x-markdown-light th,
|
||||
.x-markdown-dark th {
|
||||
background: var(--ant-color-fill-quaternary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.x-markdown-light .x-md-table-wrap,
|
||||
.x-markdown-dark .x-md-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.app-inbox-page {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
@@ -416,3 +400,74 @@ body {
|
||||
margin-left: 4px;
|
||||
font-size: var(--ant-font-size-sm);
|
||||
}
|
||||
|
||||
/* Markdown 代码块 */
|
||||
.code-block {
|
||||
margin: var(--ant-margin-sm) 0;
|
||||
border: 1px solid var(--ant-color-border-secondary);
|
||||
border-radius: var(--ant-border-radius-lg);
|
||||
overflow: hidden;
|
||||
font-family: var(--ant-font-family-code, monospace);
|
||||
font-size: var(--ant-font-size-sm);
|
||||
}
|
||||
|
||||
.code-block-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--ant-padding-xxs) var(--ant-padding-sm);
|
||||
background: var(--ant-color-bg-container);
|
||||
border-bottom: 1px solid var(--ant-color-border-secondary);
|
||||
}
|
||||
|
||||
.code-block-lang {
|
||||
color: var(--ant-color-text-quaternary);
|
||||
font-size: var(--ant-font-size-xs);
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.code-block-body {
|
||||
margin: 0;
|
||||
padding: var(--ant-padding-sm);
|
||||
overflow-x: auto;
|
||||
background: var(--ant-color-bg-container);
|
||||
}
|
||||
|
||||
.code-block-body code {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
/* Markdown 表格 */
|
||||
.markdown-table {
|
||||
width: 100%;
|
||||
margin: var(--ant-margin-sm) 0;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
border: 1px solid var(--ant-color-border-secondary);
|
||||
border-radius: var(--ant-border-radius-lg);
|
||||
overflow: hidden;
|
||||
font-size: var(--ant-font-size);
|
||||
}
|
||||
|
||||
.markdown-table th,
|
||||
.markdown-table td {
|
||||
padding: var(--ant-padding-xs) var(--ant-padding-sm);
|
||||
border-bottom: 1px solid var(--ant-color-border-secondary);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.markdown-table thead th {
|
||||
background: var(--ant-color-bg-container);
|
||||
color: var(--ant-color-text);
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid var(--ant-color-border-secondary);
|
||||
}
|
||||
|
||||
.markdown-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.markdown-table tbody tr:hover td {
|
||||
background: var(--ant-color-fill-quaternary);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user