Files
chat-one-web/src/pages/index.tsx
alboped 056c3e648f
All checks were successful
CI / build (push) Successful in 2m12s
ui: 抽离流式消息渲染组件并增强富文本显示;
将助手消息渲染从页面内联逻辑拆分为独立组件,接入 Markdown/GFM、代码高亮与公式渲染,提升流式输出在表格、代码块等主流格式下的展示能力。

Made-with: Cursor
2026-04-16 02:11:54 +08:00

479 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}