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

3
src/web/css.d.ts vendored
View File

@@ -1,4 +1 @@
declare module "*.css";
declare module "react-syntax-highlighter/dist/esm/styles/prism" {
export { oneDark, oneLight } from "react-syntax-highlighter";
}

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>
);

View File

@@ -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
}
}

View File

@@ -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);
}