Files
chat-one-web/src/pages/index.tsx
alboped 891f09aa0d
All checks were successful
CI / build (push) Successful in 2m7s
feat(chat): 消息列表回到底部与贴底滚动优化
- 非底部时显示悬浮回到底部按钮,点击平滑滚底并隐藏
- 仅在已处于底部附近时随新消息自动贴底,避免打断上翻阅读
- 按钮使用原生圆钮并相对输入区 max-w-4xl 定位,避免 AntD 按钮宽条问题
- 同步 lucide-react 依赖及流式代码块、全局样式等小改动

Made-with: Cursor
2026-04-24 04:39:24 +08:00

1059 lines
39 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 type { CSSProperties } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import {
ArrowUp,
ChevronDown,
Copy,
Ellipsis,
LogOut,
MessageCirclePlus,
Paperclip,
PanelLeftClose,
PanelLeftOpen,
Pencil,
Pin,
RotateCcw,
Search,
Settings,
Share2,
Smartphone,
Trash2,
User,
Zap,
CircleHelp,
} from "lucide-react";
import {
Avatar,
Button,
Collapse,
Drawer,
Dropdown,
Input,
Layout,
Modal,
Select,
Tooltip,
Typography,
message,
} from "antd";
import type { MenuProps } from "antd";
import { useNavigate, useParams } from "react-router-dom";
import { ACCESS_TOKEN_KEY, SessionExpiredError, USER_KEY, logout } from "../api/auth";
import {
createChatSession,
deleteChatSession,
listChatSessionMessages,
listChatSessions,
normalizeSessionMessages,
sessionDisplayTitle,
sessionStableId,
updateChatSessionTitle,
type ChatSession,
type ChatTurnForUi,
} from "../api/chatSessions";
import { isAbortLikeError, streamQwenChat, type ChatMessagePayload } from "../api/qwenChat";
import StreamMessage from "../components/StreamMessage";
const { Header, Sider, Content } = Layout;
const MOBILE_WIDTH = 750;
type UiMessage = ChatTurnForUi;
type StoredUserProfile = {
nickname: string;
avatarUrl: string;
};
function parseStoredUserProfileJson(raw: string | null): { displayName: string; avatar?: string } {
try {
if (!raw) return { displayName: "用户" };
const user = JSON.parse(raw) as StoredUserProfile;
const displayName = user.nickname.trim() || "用户";
const avatar = user.avatarUrl || undefined;
return { displayName, avatar };
} catch {
return { displayName: "用户" };
}
}
/** 与 `persistUser` 内触发的 `chatone-user-changed` 及跨标签 `storage` 对齐,用于刷新侧栏昵称 */
function subscribeUserProfile(onChange: () => void) {
const onStorage = (e: StorageEvent) => {
if (e.key === USER_KEY || e.key === null) onChange();
};
window.addEventListener("chatone-user-changed", onChange);
window.addEventListener("storage", onStorage);
return () => {
window.removeEventListener("chatone-user-changed", onChange);
window.removeEventListener("storage", onStorage);
};
}
const INITIAL_MESSAGES: UiMessage[] = [];
const STREAM_TIMEOUT_MS = 90000;
const MAX_STREAM_RETRY = 1;
const CONTEXT_WINDOW_SIZE = 8;
const DEEP_THINK_STORAGE_KEY = "chatone-deep-think-enabled";
const SMART_SEARCH_STORAGE_KEY = "chatone-smart-search-enabled";
const SELECTED_MODEL_STORAGE_KEY = "chatone-selected-model";
/** 展示名与请求 model 字段;需与后端实际支持的模型 id 一致 */
const DEFAULT_QWEN_MODEL = "qwen3.6-plus";
const QWEN_MODEL_OPTIONS: { value: string; label: string }[] = [
{ value: "qwen3-max", label: "Qwen3-Max" },
{ value: DEFAULT_QWEN_MODEL, label: "Qwen3.6-Plus" },
{ value: "qwen3.5-flash", label: "Qwen3.5-Flash" },
{ value: "qwen-plus", label: "Qwen-Plus" },
{ value: "deepseek-r1", label: "DeepSeek-R1" },
{ value: "kimi-k2.6", label: "Kimi-K2.6" },
];
function replaceSessionUrl(sessionId: string) {
const base = import.meta.env.BASE_URL ?? "/";
const prefix = base === "/" ? "" : base.replace(/\/$/, "");
const nextPath = `${prefix}/s/${encodeURIComponent(sessionId)}`;
window.history.replaceState(window.history.state, "", nextPath);
}
export default function HomePage() {
const [modal, modalContextHolder] = Modal.useModal();
const navigate = useNavigate();
const { sessionId: routeSessionId } = useParams<{ sessionId?: string }>();
const [collapsed, setCollapsed] = useState(false);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
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(() => {
try {
return window.localStorage.getItem(DEEP_THINK_STORAGE_KEY) === "1";
} catch {
return false;
}
});
const [smartSearch, setSmartSearch] = useState(() => {
try {
return window.localStorage.getItem(SMART_SEARCH_STORAGE_KEY) === "1";
} catch {
return false;
}
});
const [selectedModel, setSelectedModel] = useState(() => {
try {
const raw = window.localStorage.getItem(SELECTED_MODEL_STORAGE_KEY);
const supported = QWEN_MODEL_OPTIONS.some((item) => item.value === raw);
return supported && raw ? raw : DEFAULT_QWEN_MODEL;
} catch {
return DEFAULT_QWEN_MODEL;
}
});
const messageListRef = useRef<HTMLDivElement | null>(null);
const shouldStickToBottomRef = useRef(true);
const abortRef = useRef<AbortController | null>(null);
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [sessionsLoading, setSessionsLoading] = useState(false);
const [sessionMutationBusy, setSessionMutationBusy] = useState(false);
const [sessionActionBusyId, setSessionActionBusyId] = useState<string | null>(null);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const activeSessionIdRef = useRef<string | null>(null);
const userJsonSnapshot = useSyncExternalStore(
subscribeUserProfile,
() => window.localStorage.getItem(USER_KEY) ?? "",
() => "",
);
const userProfile = useMemo(
() => parseStoredUserProfileJson(userJsonSnapshot || null),
[userJsonSnapshot],
);
useEffect(() => {
activeSessionIdRef.current = activeSessionId;
}, [activeSessionId]);
useEffect(() => {
window.localStorage.setItem(DEEP_THINK_STORAGE_KEY, deepThink ? "1" : "0");
}, [deepThink]);
useEffect(() => {
window.localStorage.setItem(SMART_SEARCH_STORAGE_KEY, smartSearch ? "1" : "0");
}, [smartSearch]);
useEffect(() => {
window.localStorage.setItem(SELECTED_MODEL_STORAGE_KEY, selectedModel);
}, [selectedModel]);
/** 用户信息下拉:略增大行高与项间距 */
const userMenuItemStyle: CSSProperties = {
height: "auto",
lineHeight: 1.45,
paddingBlock: 10,
marginBlock: 3,
};
const userMenuDividerStyle: CSSProperties = { margin: "10px 0" };
const userMenuItems: MenuProps["items"] = [
{ key: "app", icon: <Smartphone size={16} />, label: "下载手机应用", style: userMenuItemStyle },
{ key: "settings", icon: <Settings size={16} />, label: "系统设置", style: userMenuItemStyle },
{
key: "help",
icon: <CircleHelp size={16} />,
label: "帮助与反馈",
style: userMenuItemStyle,
},
{ type: "divider", style: userMenuDividerStyle },
{
key: "logout",
icon: <LogOut size={16} />,
label: "退出登录",
danger: true,
style: userMenuItemStyle,
},
];
const onUserMenuClick: MenuProps["onClick"] = async ({ key }) => {
if (key === "logout") {
abortRef.current?.abort();
setIsSending(false);
await logout();
message.success("已退出登录");
navigate("/login", { replace: true });
return;
}
if (key === "app" || key === "settings" || key === "help") {
message.info("功能开发中");
}
};
useEffect(() => {
const accessToken = window.localStorage.getItem(ACCESS_TOKEN_KEY);
if (!accessToken) {
navigate("/login", { replace: true });
}
}, [navigate]);
useEffect(() => {
const updateIsMobile = () => {
const w = Math.min(
window.innerWidth,
document.documentElement.clientWidth || window.innerWidth,
);
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);
};
}, []);
const isMessageListAtBottom = useCallback(() => {
const el = messageListRef.current;
if (!el) return true;
// 允许少量像素误差,避免小数像素造成闪烁。
return el.scrollHeight - el.scrollTop - el.clientHeight <= 24;
}, []);
const scrollMessageListToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
const el = messageListRef.current;
if (!el) return;
el.scrollTo({ top: el.scrollHeight, behavior });
}, []);
useEffect(() => {
const el = messageListRef.current;
if (!el) return;
const onScroll = () => {
const atBottom = isMessageListAtBottom();
shouldStickToBottomRef.current = atBottom;
setShowScrollToBottom(!atBottom);
};
onScroll();
el.addEventListener("scroll", onScroll, { passive: true });
return () => {
el.removeEventListener("scroll", onScroll);
};
}, [isMessageListAtBottom]);
useEffect(() => {
if (shouldStickToBottomRef.current) {
scrollMessageListToBottom("auto");
setShowScrollToBottom(false);
} else {
setShowScrollToBottom(true);
}
}, [messages, scrollMessageListToBottom]);
const refreshSessionList = useCallback(async (): Promise<ChatSession[]> => {
if (!window.localStorage.getItem(ACCESS_TOKEN_KEY)) return [];
setSessionsLoading(true);
try {
const list = await listChatSessions({ limit: 50 });
setSessions(list);
return list;
} catch {
message.error("会话列表加载失败");
return [] as ChatSession[];
} finally {
setSessionsLoading(false);
}
}, []);
useEffect(() => {
if (!window.localStorage.getItem(ACCESS_TOKEN_KEY)) return;
void refreshSessionList();
}, [refreshSessionList]);
/**
* 打开指定会话并加载历史消息。
* 默认会把当前会话 id 同步到路由;当由路由参数驱动时可关闭 URL 更新,避免重复跳转。
*/
const openSession = useCallback(
async (sessionId: string, options?: { updateUrl?: boolean }) => {
if (options?.updateUrl ?? true) {
navigate(`/s/${encodeURIComponent(sessionId)}`);
}
setActiveSessionId(sessionId);
try {
const rows = await listChatSessionMessages(sessionId, { limit: 100 });
const normalized = normalizeSessionMessages(rows);
setMessages(normalized.length > 0 ? normalized : INITIAL_MESSAGES.map((m) => ({ ...m })));
} catch {
message.error("加载会话消息失败");
}
},
[navigate],
);
useEffect(() => {
if (!window.localStorage.getItem(ACCESS_TOKEN_KEY)) return;
if (!routeSessionId) {
setActiveSessionId(null);
setMessages(INITIAL_MESSAGES.map((m) => ({ ...m })));
return;
}
// 路由已与当前会话一致时,不重复加载,避免覆盖流式中的本地消息状态。
if (routeSessionId === activeSessionIdRef.current) return;
void openSession(routeSessionId, { updateUrl: false });
}, [openSession, routeSessionId]);
const handleNewSession = async () => {
setActiveSessionId(null);
setMessages(INITIAL_MESSAGES.map((m) => ({ ...m })));
navigate("/");
};
const handleRenameSession = useCallback(
(session: ChatSession) => {
const sid = sessionStableId(session);
if (!sid || sessionActionBusyId) return;
let nextTitle = session.title;
modal.confirm({
centered: true,
title: "重命名会话",
content: (
<Input
autoFocus
maxLength={200}
defaultValue={session.title}
placeholder="请输入会话标题"
onChange={(event) => {
nextTitle = event.target.value;
}}
onPressEnter={(event) => {
event.preventDefault();
}}
/>
),
okText: "保存",
cancelText: "取消",
async onOk() {
if (nextTitle === session.title) return;
setSessionActionBusyId(sid);
try {
await updateChatSessionTitle(sid, { title: nextTitle });
await refreshSessionList();
message.success("会话已重命名");
} catch (error) {
const text = error instanceof Error ? error.message : "重命名会话失败";
message.error(text);
throw error;
} finally {
setSessionActionBusyId(null);
}
},
});
},
[modal, refreshSessionList, sessionActionBusyId],
);
const handleDeleteSession = useCallback(
(session: ChatSession) => {
const sid = sessionStableId(session);
if (!sid || sessionActionBusyId) return;
modal.confirm({
centered: true,
title: "删除会话",
content: "确认删除该会话?删除后不可恢复。",
okText: "删除",
okType: "danger",
cancelText: "取消",
async onOk() {
setSessionActionBusyId(sid);
try {
await deleteChatSession(sid);
if (activeSessionId === sid) {
abortRef.current?.abort();
setIsSending(false);
setActiveSessionId(null);
setMessages(INITIAL_MESSAGES.map((m) => ({ ...m })));
navigate("/");
}
await refreshSessionList();
message.success("会话已删除");
} catch (error) {
const text = error instanceof Error ? error.message : "删除会话失败";
message.error(text);
throw error;
} finally {
setSessionActionBusyId(null);
}
},
});
},
[activeSessionId, modal, navigate, refreshSessionList, sessionActionBusyId],
);
const handleCopyUserMessage = useCallback(async (content: string) => {
try {
await navigator.clipboard.writeText(content);
message.success("已复制");
} catch {
message.error("复制失败,请检查浏览器权限");
}
}, []);
const handleCopyAssistantMessage = useCallback(async (content: string) => {
try {
await navigator.clipboard.writeText(content);
message.success("已复制");
} catch {
message.error("复制失败,请检查浏览器权限");
}
}, []);
const sendMessage = async () => {
const trimmed = inputValue.trim();
if (!trimmed || isSending) return;
let targetSessionId = activeSessionId;
if (!targetSessionId) {
if (sessionMutationBusy) return;
setSessionMutationBusy(true);
try {
const row = await createChatSession({
// 首条提问作为会话初始标题(后端限制 200
title: trimmed.slice(0, 200),
});
targetSessionId = row.id;
setActiveSessionId(row.id);
replaceSessionUrl(row.id);
// 侧栏列表后台刷新,不阻塞当前消息发送。
void refreshSessionList();
} catch (e) {
const text = e instanceof Error ? e.message : "新建会话失败";
message.error(text);
return;
} finally {
setSessionMutationBusy(false);
}
}
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,
model: selectedModel,
sessionId: targetSessionId ?? undefined,
enableWebSearch: smartSearch,
enableThinking: deepThink,
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) {
if (error instanceof SessionExpiredError) {
lastError = null;
break;
}
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 min-h-0 w-full min-w-0 flex-1 flex-col bg-[var(--ds-bg-sider)]">
<div className="shrink-0 px-3 pt-3 pb-2">
<Button
block
loading={sessionMutationBusy}
disabled={sessionsLoading}
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={<MessageCirclePlus size={14} strokeWidth={2} />}
onClick={() => {
void handleNewSession();
}}
>
{(!collapsed || isMobile) && "新建会话"}
</Button>
</div>
<div className="min-h-0 flex-1 overflow-auto px-2 py-2">
{sessionsLoading ? (
<Typography.Text className="block px-2 text-[12px]" type="secondary">
</Typography.Text>
) : sessions.length === 0 ? (
<Typography.Text className="block px-2 text-[12px]" type="secondary">
</Typography.Text>
) : (
<div className="mb-4">
<Typography.Text
className="mb-2 block px-2 text-[11px] uppercase tracking-wide"
style={{ color: "var(--ds-text-secondary)" }}
>
</Typography.Text>
{sessions.map((s) => {
const sid = sessionStableId(s);
if (!sid) return null;
const title = sessionDisplayTitle(s);
const active = sid === activeSessionId;
const itemMenu: MenuProps = {
items: [
{ key: "rename", icon: <Pencil size={16} />, label: "重命名" },
{ key: "pin", icon: <Pin size={16} />, label: "置顶" },
{ key: "delete", icon: <Trash2 size={16} />, danger: true, label: "删除" },
],
onClick: ({ key, domEvent }) => {
domEvent.stopPropagation();
if (key === "rename") void handleRenameSession(s);
if (key === "pin") message.info("置顶功能开发中");
if (key === "delete") void handleDeleteSession(s);
},
};
return (
<div
key={sid}
className="group mb-1.5 flex w-full items-center gap-1 rounded-2xl pr-1 text-[14px] text-neutral-700 transition-colors hover:bg-black/5"
style={{
background: active ? "var(--ds-active-item)" : undefined,
color: active ? "var(--ds-accent)" : undefined,
}}
>
<button
type="button"
onClick={() => {
void openSession(sid);
}}
className="flex h-11 min-w-0 flex-1 cursor-pointer items-center rounded-2xl px-4 text-left"
>
{(!collapsed || isMobile) && (
<span className="min-w-0 flex-1 truncate text-[14px] leading-none">
{title}
</span>
)}
</button>
{(!collapsed || isMobile) && (
<div className={`${active ? "flex" : "hidden group-hover:flex"}`}>
<Dropdown menu={itemMenu} trigger={["click"]} placement="bottomRight">
<Button
type="text"
size="small"
icon={<Ellipsis size={16} />}
loading={sessionActionBusyId === sid}
className="!flex !h-8 !w-8 !items-center !justify-center rounded-xl text-neutral-500 hover:bg-black/5!"
onClick={(event) => {
event.stopPropagation();
}}
/>
</Dropdown>
</div>
)}
</div>
);
})}
</div>
)}
</div>
<div className="shrink-0 p-3">
<Dropdown
menu={{ items: userMenuItems, onClick: onUserMenuClick }}
placement="topLeft"
trigger={["click"]}
>
<button
type="button"
className={`flex w-full cursor-pointer items-center gap-2 rounded-xl bg-neutral-100 px-2 py-2 text-left transition-colors hover:bg-neutral-200/90 ${
collapsed && !isMobile ? "justify-center py-2.5" : ""
}`}
>
<Avatar
size={36}
src={userProfile.avatar}
icon={!userProfile.avatar ? <User size={18} /> : undefined}
className="shrink-0 bg-neutral-300"
/>
{(!collapsed || isMobile) && (
<>
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-neutral-800">
{userProfile.displayName}
</span>
<Ellipsis size={16} className="shrink-0 text-neutral-500" />
</>
)}
</button>
</Dropdown>
</div>
</div>
);
return (
<Layout
className="chat-one-shell flex h-dvh max-h-dvh overflow-hidden"
style={{ background: "var(--ds-bg-main)" }}
>
{modalContextHolder}
{!isMobile && (
<Sider
trigger={null}
collapsible
collapsed={collapsed}
width={260}
collapsedWidth={72}
theme="light"
className="!min-h-0 !h-full !overflow-hidden !bg-[var(--ds-bg-sider)]"
>
<div className="flex h-full min-h-0 min-w-0 flex-col">
<div className="flex h-14 shrink-0 items-center gap-2 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}
</div>
</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 !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 ? (
<PanelLeftOpen size={16} />
) : collapsed ? (
<PanelLeftOpen size={16} />
) : (
<PanelLeftClose size={16} />
)
}
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"
/>
<Select
className="min-w-[190px]"
variant="borderless"
popupMatchSelectWidth={false}
options={QWEN_MODEL_OPTIONS}
value={selectedModel}
onChange={setSelectedModel}
disabled={isSending}
aria-label="选择对话模型"
/>
</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="relative 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-4xl flex-col gap-5">
{!activeSessionId && messages.length === 0 ? (
<p className="px-4 text-[18px] text-neutral-800"> ChatOne</p>
) : (
messages.map((item) => (
<div
key={item.id}
className={`flex ${item.role === "user" ? "justify-end" : "justify-start"}`}
>
<div
className={
item.role === "user" ? "group max-w-full px-4 " : "group w-full space-y-2"
}
>
{item.role === "user" && (
<div
className="rounded-2xl px-4 py-2.5 text-[15px] leading-relaxed text-neutral-800"
style={{ background: "var(--ds-user-bubble)" }}
>
{item.content}
</div>
)}
{item.role === "assistant" && item.thinking && (
<Collapse
bordered={false}
size="small"
className="!mb-2 !rounded-xl !bg-transparent"
items={[
{
key: "think",
label: (
<span className="text-[13px] text-neutral-600">
<Zap size={14} className="mr-1.5" />
2
</span>
),
children: (
<Typography.Text type="secondary" className="text-[13px]">
{item.thinking}
</Typography.Text>
),
},
]}
/>
)}
{item.role === "assistant" &&
(item.content ? (
<StreamMessage content={item.content} />
) : (
<div className="rounded-2xl px-4 py-3 text-[14px] text-neutral-500">
<span className="inline-flex items-center gap-1">
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-neutral-400 [animation-delay:-0.3s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-neutral-400 [animation-delay:-0.15s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-neutral-400" />
</span>
</div>
))}
{item.role === "assistant" && item.content && (
<>
<p className="mt-3 px-4 text-[12px] text-neutral-500 italic">
AI
</p>
<div className="mt-2 flex h-8 items-center gap-1 px-4 text-neutral-500 opacity-0 pointer-events-none transition-opacity group-hover:opacity-100 group-hover:pointer-events-auto">
<Tooltip title="复制">
<Button
type="text"
size="small"
icon={<Copy size={16} />}
className="!px-1 text-neutral-500 hover:bg-black/5!"
aria-label="复制回复"
onClick={() => {
void handleCopyAssistantMessage(item.content);
}}
/>
</Tooltip>
<Tooltip title="重新生成">
<Button
type="text"
size="small"
icon={<RotateCcw size={16} />}
className="!px-1 text-neutral-500 hover:bg-black/5!"
aria-label="重新生成"
onClick={() => {
message.info("重新生成功能开发中");
}}
/>
</Tooltip>
<Tooltip title="分享">
<Button
type="text"
size="small"
icon={<Share2 size={16} />}
className="!px-1 text-neutral-500 hover:bg-black/5!"
aria-label="分享"
onClick={() => {
message.info("分享功能开发中");
}}
/>
</Tooltip>
</div>
</>
)}
{item.role === "user" && (
<div className="mt-2 flex h-7 items-center justify-end gap-1 text-neutral-500 opacity-0 pointer-events-none transition-opacity group-hover:opacity-100 group-hover:pointer-events-auto">
<Button
type="text"
size="small"
icon={<Copy size={16} />}
className="text-neutral-500 hover:bg-black/5!"
aria-label="复制消息"
onClick={() => {
void handleCopyUserMessage(item.content);
}}
/>
<Button
type="text"
size="small"
icon={<Pencil size={16} />}
className="text-neutral-500 hover:bg-black/5!"
aria-label="编辑消息"
onClick={() => {
message.info("编辑功能开发中");
}}
/>
</div>
)}
</div>
</div>
))
)}
</div>
</div>
<div className="shrink-0 bg-[var(--ds-bg-main)] px-4 pt-0 md:px-8 md:pt-0">
<div className="mx-auto max-w-4xl">
<div className="relative">
{showScrollToBottom && (
<button
type="button"
aria-label="滚动到底部"
className="absolute -top-14 right-3 z-10 flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-neutral-200/90 bg-white text-neutral-900 shadow-md transition-colors hover:bg-neutral-50"
onClick={() => {
shouldStickToBottomRef.current = true;
setShowScrollToBottom(false);
scrollMessageListToBottom("smooth");
}}
>
<ChevronDown color="#666" size={18} strokeWidth={2} aria-hidden />
</button>
)}
<div className="rounded-3xl 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: 2, maxRows: 8 }}
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 cursor-pointer 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"
}`}
>
<Zap size={14} />
</button>
<button
type="button"
onClick={() => setSmartSearch((v) => !v)}
className={`inline-flex cursor-pointer 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"
}`}
>
<Search size={14} />
</button>
</div>
<div className="flex items-center gap-2">
<Button
type="text"
icon={<Paperclip size={16} className="text-neutral-400" />}
className="text-neutral-400"
/>
<Button
type="primary"
shape="circle"
icon={<ArrowUp size={16} />}
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>
</div>
<p className="m-0 py-2 text-center text-[11px] text-neutral-500">
AI
</p>
</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)",
display: "flex",
flexDirection: "column",
height: "100%",
minHeight: 0,
},
}}
>
{sidebarContent}
</Drawer>
</Layout>
);
}