All checks were successful
CI / build (push) Successful in 2m12s
将助手消息渲染从页面内联逻辑拆分为独立组件,接入 Markdown/GFM、代码高亮与公式渲染,提升流式输出在表格、代码块等主流格式下的展示能力。 Made-with: Cursor
479 lines
17 KiB
TypeScript
479 lines
17 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
||
import {
|
||
ArrowUpOutlined,
|
||
MenuFoldOutlined,
|
||
MenuUnfoldOutlined,
|
||
MessageOutlined,
|
||
PaperClipOutlined,
|
||
PlusOutlined,
|
||
SearchOutlined,
|
||
SettingOutlined,
|
||
ThunderboltOutlined,
|
||
UserOutlined,
|
||
} from "@ant-design/icons";
|
||
import { Button, Collapse, Drawer, Input, Layout, Typography } from "antd";
|
||
import { isAbortLikeError, streamQwenChat, type ChatMessagePayload } from "../api/qwenChat";
|
||
import StreamMessage from "../components/StreamMessage";
|
||
|
||
const { Header, Sider, Content } = Layout;
|
||
const MOBILE_WIDTH = 750;
|
||
|
||
type UiMessage = {
|
||
id: string;
|
||
role: "user" | "assistant";
|
||
content: string;
|
||
thinking?: string;
|
||
};
|
||
|
||
const INITIAL_MESSAGES: UiMessage[] = [
|
||
{
|
||
id: "init-assistant",
|
||
role: "assistant",
|
||
content: "你好,我是 ChatOne 助手,有什么可以帮你?",
|
||
},
|
||
];
|
||
|
||
const STREAM_TIMEOUT_MS = 90000;
|
||
const MAX_STREAM_RETRY = 1;
|
||
const CONTEXT_WINDOW_SIZE = 8;
|
||
|
||
export default function HomePage() {
|
||
const [collapsed, setCollapsed] = useState(false);
|
||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||
const [viewportWidth, setViewportWidth] = useState(0);
|
||
const [isMobile, setIsMobile] = useState(false);
|
||
const [inputValue, setInputValue] = useState("");
|
||
const [messages, setMessages] = useState<UiMessage[]>(INITIAL_MESSAGES);
|
||
const [isSending, setIsSending] = useState(false);
|
||
const [deepThink, setDeepThink] = useState(false);
|
||
const [smartSearch, setSmartSearch] = useState(false);
|
||
const messageListRef = useRef<HTMLDivElement | null>(null);
|
||
const abortRef = useRef<AbortController | null>(null);
|
||
|
||
const historyGroups = useMemo(
|
||
() => [
|
||
{ title: "30 天内", keys: ["前端 CI 工具对比", "Nginx SPA 配置"] },
|
||
{ title: "2025-12", keys: ["Gitea Actions 入门"] },
|
||
],
|
||
[],
|
||
);
|
||
|
||
useEffect(() => {
|
||
const updateIsMobile = () => {
|
||
const w = Math.min(
|
||
window.innerWidth,
|
||
document.documentElement.clientWidth || window.innerWidth,
|
||
);
|
||
setViewportWidth(w);
|
||
const mobile = w < MOBILE_WIDTH;
|
||
setIsMobile(mobile);
|
||
if (!mobile) setMobileSidebarOpen(false);
|
||
};
|
||
updateIsMobile();
|
||
window.addEventListener("resize", updateIsMobile);
|
||
window.addEventListener("orientationchange", updateIsMobile);
|
||
return () => {
|
||
window.removeEventListener("resize", updateIsMobile);
|
||
window.removeEventListener("orientationchange", updateIsMobile);
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const el = messageListRef.current;
|
||
if (!el) return;
|
||
el.scrollTop = el.scrollHeight;
|
||
}, [messages]);
|
||
|
||
const sendMessage = async () => {
|
||
const trimmed = inputValue.trim();
|
||
if (!trimmed || isSending) return;
|
||
|
||
setInputValue("");
|
||
setIsSending(true);
|
||
|
||
const userMessage: UiMessage = {
|
||
id: `user-${Date.now()}`,
|
||
role: "user",
|
||
content: trimmed,
|
||
};
|
||
const assistantMessageId = `assistant-${Date.now() + 1}`;
|
||
const assistantPlaceholder: UiMessage = {
|
||
id: assistantMessageId,
|
||
role: "assistant",
|
||
content: "",
|
||
thinking: deepThink ? "正在思考并生成答案…" : undefined,
|
||
};
|
||
|
||
const nextMessages = [...messages, userMessage, assistantPlaceholder];
|
||
setMessages(nextMessages);
|
||
|
||
const payloadMessages: ChatMessagePayload[] = nextMessages.map((item) => ({
|
||
role: item.role,
|
||
content: item.content,
|
||
}));
|
||
|
||
let assistantText = "";
|
||
let lastError: unknown = null;
|
||
|
||
for (let attempt = 0; attempt <= MAX_STREAM_RETRY; attempt += 1) {
|
||
const controller = new AbortController();
|
||
abortRef.current = controller;
|
||
let receivedToken = false;
|
||
|
||
const retryMessages =
|
||
attempt === 0
|
||
? payloadMessages
|
||
: ([
|
||
...nextMessages
|
||
.slice(-CONTEXT_WINDOW_SIZE)
|
||
.map((item): ChatMessagePayload => ({ role: item.role, content: item.content })),
|
||
{
|
||
role: "user" as const,
|
||
content: `网络抖动导致输出中断。请在不重复已输出内容的前提下继续回答。已输出尾部:${assistantText.slice(-80)}`,
|
||
},
|
||
] satisfies ChatMessagePayload[]);
|
||
|
||
try {
|
||
await streamQwenChat({
|
||
messages: retryMessages,
|
||
signal: controller.signal,
|
||
timeoutMs: STREAM_TIMEOUT_MS,
|
||
onToken: (token) => {
|
||
receivedToken = true;
|
||
assistantText += token;
|
||
setMessages((prev) =>
|
||
prev.map((item) =>
|
||
item.id === assistantMessageId
|
||
? { ...item, content: `${item.content}${token}` }
|
||
: item,
|
||
),
|
||
);
|
||
},
|
||
});
|
||
lastError = null;
|
||
break;
|
||
} catch (error) {
|
||
lastError = error;
|
||
const isAbortLike = isAbortLikeError(error);
|
||
const canRetry = attempt < MAX_STREAM_RETRY;
|
||
if (!canRetry) break;
|
||
// 网络抖动或超时时自动重连;若已收到部分 token 也保留已输出文本。
|
||
if (isAbortLike || !receivedToken) {
|
||
await new Promise((resolve) => window.setTimeout(resolve, 300));
|
||
continue;
|
||
}
|
||
break;
|
||
} finally {
|
||
abortRef.current = null;
|
||
}
|
||
}
|
||
|
||
if (lastError) {
|
||
const message = lastError instanceof Error ? lastError.message : "请求失败,请稍后重试";
|
||
setMessages((prev) =>
|
||
prev.map((item) =>
|
||
item.id === assistantMessageId
|
||
? {
|
||
...item,
|
||
content: item.content || `接口调用失败:${message}`,
|
||
thinking: undefined,
|
||
}
|
||
: item,
|
||
),
|
||
);
|
||
}
|
||
|
||
setIsSending(false);
|
||
setMessages((prev) =>
|
||
prev.map((item) =>
|
||
item.id === assistantMessageId ? { ...item, thinking: undefined } : item,
|
||
),
|
||
);
|
||
};
|
||
|
||
const sidebarContent = (
|
||
<div className="flex h-full flex-col bg-[var(--ds-bg-sider)]">
|
||
<div className="shrink-0 px-3 pt-3 pb-2">
|
||
<Button
|
||
block
|
||
className="h-10! rounded-xl! border-[var(--ds-border)]! bg-[var(--ds-bg-main)]! text-[13px]! font-medium shadow-none hover:border-sky-300! hover:text-sky-600!"
|
||
icon={<PlusOutlined />}
|
||
>
|
||
{(!collapsed || isMobile) && "新建会话"}
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="min-h-0 flex-1 overflow-auto px-2 py-2">
|
||
{historyGroups.map((group) => (
|
||
<div key={group.title} className="mb-4">
|
||
<Typography.Text
|
||
className="mb-2 block px-2 text-[11px] uppercase tracking-wide"
|
||
style={{ color: "var(--ds-text-secondary)" }}
|
||
>
|
||
{group.title}
|
||
</Typography.Text>
|
||
{group.keys.map((key) => (
|
||
<button
|
||
key={key}
|
||
type="button"
|
||
className="mb-1 flex w-full items-center gap-2 rounded-xl px-3 py-2.5 text-left text-[13px] text-neutral-700 transition-colors hover:bg-black/5"
|
||
style={{
|
||
background:
|
||
key === historyGroups[0].keys[0] ? "var(--ds-active-item)" : undefined,
|
||
}}
|
||
>
|
||
<MessageOutlined className="shrink-0 text-neutral-400" />
|
||
{(!collapsed || isMobile) && <span className="truncate">{key}</span>}
|
||
</button>
|
||
))}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="shrink-0 border-t border-[var(--ds-border)] p-3">
|
||
<Button
|
||
type="text"
|
||
className="h-10 w-full justify-start! rounded-xl! px-2 text-neutral-600 hover:bg-black/5!"
|
||
icon={<SettingOutlined />}
|
||
>
|
||
{(!collapsed || isMobile) && "设置"}
|
||
</Button>
|
||
<div className="mt-2 flex items-center gap-2 rounded-xl px-2 py-1.5">
|
||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-200 text-neutral-500">
|
||
<UserOutlined />
|
||
</div>
|
||
{(!collapsed || isMobile) && (
|
||
<Typography.Text className="truncate text-[13px]">用户</Typography.Text>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<Layout
|
||
className="chat-one-shell flex h-dvh max-h-dvh overflow-hidden"
|
||
style={{ background: "var(--ds-bg-main)" }}
|
||
>
|
||
{!isMobile && (
|
||
<Sider
|
||
trigger={null}
|
||
collapsible
|
||
collapsed={collapsed}
|
||
width={260}
|
||
collapsedWidth={72}
|
||
theme="light"
|
||
className="!min-h-0 !overflow-hidden !bg-[var(--ds-bg-sider)] !border-r !border-[var(--ds-border)]"
|
||
>
|
||
<div className="flex h-14 items-center gap-2 border-b border-[var(--ds-border)] px-3">
|
||
<img
|
||
src="/logo.png"
|
||
alt=""
|
||
width={32}
|
||
height={32}
|
||
className="h-8 w-8 shrink-0 object-contain"
|
||
decoding="async"
|
||
/>
|
||
{!collapsed && (
|
||
<Typography.Title level={5} className="!m-0 !truncate !text-[15px] !font-semibold">
|
||
ChatOne
|
||
</Typography.Title>
|
||
)}
|
||
</div>
|
||
{sidebarContent}
|
||
</Sider>
|
||
)}
|
||
|
||
<Layout
|
||
className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden"
|
||
style={{ background: "var(--ds-bg-main)" }}
|
||
>
|
||
<Header
|
||
className="!flex !h-14 !shrink-0 !items-center !justify-between !border-b !border-[var(--ds-border)] !bg-[var(--ds-bg-main)] !px-5"
|
||
style={{ lineHeight: "56px" }}
|
||
>
|
||
<div className="flex items-center gap-3">
|
||
<Button
|
||
type="text"
|
||
className="text-neutral-500 hover:bg-black/5!"
|
||
icon={
|
||
isMobile ? (
|
||
<MenuUnfoldOutlined />
|
||
) : collapsed ? (
|
||
<MenuUnfoldOutlined />
|
||
) : (
|
||
<MenuFoldOutlined />
|
||
)
|
||
}
|
||
onClick={() => {
|
||
if (isMobile) setMobileSidebarOpen(true);
|
||
else setCollapsed((v) => !v);
|
||
}}
|
||
/>
|
||
<img
|
||
src="/logo.png"
|
||
alt=""
|
||
width={24}
|
||
height={24}
|
||
className="h-6 w-6 shrink-0 object-contain md:hidden"
|
||
decoding="async"
|
||
/>
|
||
<Typography.Text className="text-[15px] font-medium text-neutral-800">
|
||
国内前端主流 CI 工具
|
||
</Typography.Text>
|
||
</div>
|
||
<span className="rounded-full border border-[var(--ds-border)] bg-neutral-50 px-3 py-1 text-xs text-neutral-500">
|
||
快速模式
|
||
</span>
|
||
</Header>
|
||
|
||
<Content className="flex min-h-0 flex-1 flex-col overflow-hidden !bg-[var(--ds-bg-main)]">
|
||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||
<div
|
||
ref={messageListRef}
|
||
className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-4 pt-6 pb-0 md:px-8 md:pt-6 md:pb-0"
|
||
>
|
||
<div className="mx-auto flex max-w-3xl flex-col gap-5">
|
||
{messages.map((item) => (
|
||
<div
|
||
key={item.id}
|
||
className={`flex ${item.role === "user" ? "justify-end" : "justify-start"}`}
|
||
>
|
||
<div
|
||
className={
|
||
item.role === "user"
|
||
? "max-w-[85%] rounded-2xl border px-4 py-2.5 text-[15px] leading-relaxed text-neutral-800"
|
||
: "max-w-[85%] space-y-2"
|
||
}
|
||
style={
|
||
item.role === "user"
|
||
? {
|
||
background: "var(--ds-user-bubble)",
|
||
borderColor: "var(--ds-user-border)",
|
||
}
|
||
: undefined
|
||
}
|
||
>
|
||
{item.role === "assistant" && item.thinking && (
|
||
<Collapse
|
||
bordered={false}
|
||
size="small"
|
||
className="!mb-2 !rounded-xl !bg-neutral-50 !border !border-[var(--ds-border)]"
|
||
items={[
|
||
{
|
||
key: "think",
|
||
label: (
|
||
<span className="text-[13px] text-neutral-600">
|
||
<ThunderboltOutlined className="mr-1.5" />
|
||
已思考(用时约 2 秒)
|
||
</span>
|
||
),
|
||
children: (
|
||
<Typography.Text type="secondary" className="text-[13px]">
|
||
{item.thinking}
|
||
</Typography.Text>
|
||
),
|
||
},
|
||
]}
|
||
/>
|
||
)}
|
||
{item.role === "assistant" && <StreamMessage content={item.content} />}
|
||
{item.role === "user" && item.content}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="shrink-0 bg-[var(--ds-bg-main)] px-4 pb-4 pt-0 md:px-8 md:pb-4 md:pt-0">
|
||
<div className="mx-auto max-w-3xl">
|
||
<div className="rounded-2xl border border-[var(--ds-border)] bg-neutral-50/50 p-3 shadow-sm">
|
||
<Input.TextArea
|
||
value={inputValue}
|
||
onChange={(e) => setInputValue(e.target.value)}
|
||
onPressEnter={(e) => {
|
||
if (e.shiftKey) return;
|
||
e.preventDefault();
|
||
void sendMessage();
|
||
}}
|
||
placeholder="给 ChatOne 发送消息"
|
||
variant="borderless"
|
||
autoSize={{ minRows: 1, maxRows: 6 }}
|
||
className="!px-1 !text-[15px] placeholder:text-neutral-400"
|
||
/>
|
||
<div className="mt-2 flex flex-wrap items-center justify-between gap-2">
|
||
<div className="flex flex-wrap gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setDeepThink((v) => !v)}
|
||
className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs transition-colors ${
|
||
deepThink
|
||
? "border-sky-300 bg-sky-50 text-sky-700"
|
||
: "border-[var(--ds-border)] bg-[var(--ds-bg-main)] text-neutral-600 hover:border-neutral-300"
|
||
}`}
|
||
>
|
||
<ThunderboltOutlined />
|
||
深度思考
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setSmartSearch((v) => !v)}
|
||
className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs transition-colors ${
|
||
smartSearch
|
||
? "border-sky-300 bg-sky-50 text-sky-700"
|
||
: "border-[var(--ds-border)] bg-[var(--ds-bg-main)] text-neutral-600 hover:border-neutral-300"
|
||
}`}
|
||
>
|
||
<SearchOutlined />
|
||
智能搜索
|
||
</button>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
type="text"
|
||
icon={<PaperClipOutlined className="text-neutral-400" />}
|
||
className="text-neutral-400"
|
||
/>
|
||
<Button
|
||
type="primary"
|
||
shape="circle"
|
||
icon={<ArrowUpOutlined />}
|
||
loading={isSending}
|
||
disabled={isSending || !inputValue.trim()}
|
||
onClick={() => {
|
||
void sendMessage();
|
||
}}
|
||
className="!flex !h-9 !w-9 !items-center !justify-center !border-0 !shadow-none"
|
||
style={{
|
||
background: "var(--ds-send)",
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{import.meta.env.DEV && (
|
||
<Typography.Text type="secondary" className="mt-2 block text-center text-[11px]">
|
||
W:{viewportWidth}px · {isMobile ? "mobile" : "desktop"}
|
||
</Typography.Text>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Content>
|
||
</Layout>
|
||
|
||
<Drawer
|
||
title="ChatOne"
|
||
placement="left"
|
||
closable
|
||
onClose={() => setMobileSidebarOpen(false)}
|
||
open={isMobile && mobileSidebarOpen}
|
||
width={280}
|
||
styles={{ body: { padding: 0, background: "var(--ds-bg-sider)" } }}
|
||
>
|
||
{sidebarContent}
|
||
</Drawer>
|
||
</Layout>
|
||
);
|
||
}
|