feat: 完善会话交互并统一聊天页视觉样式
All checks were successful
CI / build (push) Successful in 10m45s

补齐会话重命名/删除与会话菜单能力,新增聊天参数透传与本地开关持久化,并统一输入区、消息区、代码块和弹框等关键交互样式。

Made-with: Cursor
This commit is contained in:
2026-04-23 23:09:23 +08:00
parent b3fdb0ad4b
commit 3b0b6eac50
8 changed files with 559 additions and 93 deletions

View File

@@ -5,8 +5,8 @@
* `CreateChatSessionDto`、`ChatSessionRowDto`、`ChatSessionListResponseDto`、
* `ChatMessageRowDto`、`ChatMessageListResponseDto`。
*/
import { getJson, postJson } from "./http";
import { ApiPath, chatSessionMessagesPath } from "./paths";
import { deleteJson, getJson, patchJson, postJson } from "./http";
import { ApiPath, chatSessionMessagesPath, chatSessionPath, chatSessionTitlePath } from "./paths";
/** `CreateChatSessionDto` */
export type CreateChatSessionBody = {
@@ -63,6 +63,12 @@ export type ListMessagesQuery = {
limit?: number;
};
/** `UpdateChatSessionTitleDto` */
export type UpdateChatSessionTitleBody = {
/** 会话标题(可传空字符串清空) */
title: string;
};
/** 与首页消息列表结构一致,便于 `setMessages` */
export type ChatTurnForUi = {
id: string;
@@ -113,6 +119,22 @@ export async function listChatSessionMessages(
return data.items;
}
/** 修改会话标题 */
export async function updateChatSessionTitle(
sessionId: string,
body: UpdateChatSessionTitleBody,
signal?: AbortSignal,
): Promise<ChatSessionRowDto> {
const path = chatSessionTitlePath(sessionId);
return patchJson<ChatSessionRowDto>(path, body, signal);
}
/** 删除会话(级联删除消息) */
export async function deleteChatSession(sessionId: string, signal?: AbortSignal): Promise<void> {
const path = chatSessionPath(sessionId);
await deleteJson<void>(path, signal);
}
/** 将接口消息行规范为前端聊天列表项(忽略 `system` 等角色) */
export function normalizeSessionMessages(rows: ChatMessageRowDto[]): ChatTurnForUi[] {
const out: ChatTurnForUi[] = [];

View File

@@ -159,6 +159,33 @@ export async function postJson<TResponse>(
}
}
/** PATCH JSON返回解析后的响应体 `data`。 */
export async function patchJson<TResponse>(
path: string,
body: JsonBody,
signal?: AbortSignal,
): Promise<TResponse> {
try {
const response = await httpClient.patch<TResponse>(path, body, { signal });
return response.data;
} catch (error) {
throw toRequestError(error, "Request failed");
}
}
/** DELETE JSON返回解析后的响应体 `data`(若接口无返回体可使用 `void` 类型接收)。 */
export async function deleteJson<TResponse>(
path: string,
signal?: AbortSignal,
): Promise<TResponse> {
try {
const response = await httpClient.delete<TResponse>(path, { signal });
return response.data;
} catch (error) {
throw toRequestError(error, "Request failed");
}
}
/**
* POST JSON返回原生 `Response`(用于需要直接读 body stream 的场景)。
* 注意:不走 axios 拦截器以外的逻辑;若需鉴权请自行在 headers 中传入。

View File

@@ -34,5 +34,15 @@ export function chatSessionMessagesPath(sessionId: string): string {
return `/api/client/v1/chat/sessions/${encodeURIComponent(sessionId)}/messages`;
}
/** 单个会话资源路径Swagger`/api/client/v1/chat/sessions/{sessionId}` */
export function chatSessionPath(sessionId: string): string {
return `/api/client/v1/chat/sessions/${encodeURIComponent(sessionId)}`;
}
/** 会话标题更新路径Swagger`/api/client/v1/chat/sessions/{sessionId}/title` */
export function chatSessionTitlePath(sessionId: string): string {
return `/api/client/v1/chat/sessions/${encodeURIComponent(sessionId)}/title`;
}
/** 请求拦截器跳过附加 Bearer 的路径(与 `ApiPath` 保持一致) */
export const pathsWithoutBearerAuth: readonly string[] = [ApiPath.authRefresh, ApiPath.authLogout];

View File

@@ -38,6 +38,10 @@ export interface StreamOptions {
model?: string;
/** 与会话绑定流式补全时传入(若后端支持) */
sessionId?: string;
/** 是否启用联网搜索OpenAPI: `enableWebSearch` */
enableWebSearch?: boolean;
/** 是否启用深度思考OpenAPI: `enableThinking` */
enableThinking?: boolean;
onToken: (token: string) => void;
signal?: AbortSignal;
/** 超时后中止请求(与业务 `signal` 合并) */
@@ -146,6 +150,8 @@ export async function streamQwenChat(options: StreamOptions): Promise<void> {
messages: options.messages,
...(options.model ? { model: options.model } : {}),
...(options.sessionId ? { sessionId: options.sessionId } : {}),
enableWebSearch: !!options.enableWebSearch,
enableThinking: !!options.enableThinking,
}),
signal: mergedSignal,
openWhenHidden: true,

View File

@@ -1,3 +1,6 @@
import { useState } from "react";
import { CheckOutlined, CopyOutlined } from "@ant-design/icons";
import { Button, message } from "antd";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
@@ -5,19 +8,68 @@ import rehypeKatex from "rehype-katex";
import rehypeHighlight from "rehype-highlight";
import "katex/dist/katex.min.css";
import "highlight.js/styles/github.css";
import type { ReactNode } from "react";
type StreamMessageProps = {
content: string;
};
function extractText(node: ReactNode): string {
if (typeof node === "string") return node;
if (typeof node === "number") return String(node);
if (!node) return "";
if (Array.isArray(node)) return node.map((item) => extractText(item)).join("");
if (typeof node === "object" && "props" in node) {
const child = (node as { props?: { children?: ReactNode } }).props?.children;
return extractText(child);
}
return "";
}
function MarkdownPreBlock(props: { children: ReactNode }) {
const [copied, setCopied] = useState(false);
const codeText = extractText(props.children).replace(/\n$/, "");
const onCopy = async () => {
try {
await navigator.clipboard.writeText(codeText);
setCopied(true);
message.success("代码已复制");
window.setTimeout(() => setCopied(false), 1200);
} catch {
message.error("复制失败");
}
};
return (
<div className="my-2 overflow-hidden rounded-xl bg-neutral-50">
<div className="flex items-center justify-end px-2 py-1">
<Button
type="text"
size="small"
icon={copied ? <CheckOutlined /> : <CopyOutlined />}
className="text-neutral-600 hover:bg-black/5! hover:text-neutral-800!"
onClick={() => {
void onCopy();
}}
>
{copied ? "已复制" : "复制"}
</Button>
</div>
<pre className="m-0 overflow-x-auto p-3 text-[13px] text-neutral-800">{props.children}</pre>
</div>
);
}
export default function StreamMessage(props: StreamMessageProps) {
return (
<div className="rounded-2xl border border-[var(--ds-border)] bg-neutral-50/80 px-4 py-2.5 text-[15px] leading-relaxed text-neutral-800">
<div className="prose prose-sm max-w-none break-words prose-neutral prose-pre:my-2 prose-pre:rounded-lg prose-pre:border prose-pre:border-[var(--ds-border)] prose-pre:bg-neutral-950 prose-pre:p-3 prose-code:before:content-none prose-code:after:content-none">
<div className="rounded-2xl px-4 py-2.5 text-[15px] leading-relaxed text-neutral-800">
<div className="prose prose-sm max-w-none break-words prose-neutral prose-code:before:content-none prose-code:after:content-none">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex, rehypeHighlight]}
components={{
pre: ({ children }) => <MarkdownPreBlock>{children}</MarkdownPreBlock>,
table: ({ children }) => (
<div className="my-2 overflow-x-auto rounded-lg border border-[var(--ds-border)]">
<table className="w-full border-collapse text-sm">{children}</table>

View File

@@ -1,10 +1,36 @@
@import "tailwindcss";
*,
*::before,
*::after {
box-sizing: border-box;
}
p {
margin: 0;
}
html,
body {
margin: 0;
height: 100%;
font-size: 14px;
line-height: 1.5;
color: #171717;
background: #ffffff;
font-family:
"Inter",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
"Helvetica Neue",
"PingFang SC",
"Hiragino Sans GB",
"Microsoft YaHei",
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow: hidden;
}
@@ -31,11 +57,29 @@ body {
:root {
--ds-bg-main: #ffffff;
--ds-bg-sider: #f7f7f8;
--ds-border: #ececec;
--ds-text-secondary: #8b8b8b;
--ds-border: #e9e9eb;
--ds-text-secondary: #8a8f99;
--ds-user-bubble: #e8f3ff;
--ds-user-border: #cce7ff;
--ds-active-item: #e3f2fd;
--ds-send: #4dabf7;
--ds-send-hover: #339af0;
--ds-active-item: #eaf3ff;
--ds-accent: #4a90e2;
--ds-send: #4a90e2;
--ds-send-hover: #3b7fd0;
}
/* 覆盖 highlight.js github 主题默认白底,保持代码块容器背景一致 */
pre code.hljs {
background: transparent !important;
}
/* 全局 Dropdown 菜单样式 */
.ant-dropdown-menu {
border-radius: 14px !important;
padding: 4px !important;
}
.ant-dropdown-menu-item,
.ant-dropdown-menu-submenu-title,
.ant-dropdown-menu-item-danger {
border-radius: 12px !important;
}

View File

@@ -1,5 +1,6 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { ConfigProvider, theme } from "antd";
import { BrowserRouter } from "react-router-dom";
import "antd/dist/reset.css";
import "./index.css";
@@ -7,8 +8,50 @@ import App from "./App";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
<ConfigProvider
theme={{
algorithm: theme.defaultAlgorithm,
token: {
colorPrimary: "#4A90E2",
colorBgBase: "#F7F7F8",
colorBgContainer: "#FFFFFF",
colorBorder: "#E9E9EB",
colorText: "#1F2329",
colorTextSecondary: "#8A8F99",
borderRadius: 12,
borderRadiusLG: 18,
controlHeight: 36,
fontSize: 14,
boxShadowSecondary:
"0 18px 42px rgba(15, 23, 42, 0.12), 0 2px 8px rgba(15, 23, 42, 0.06)",
},
components: {
Button: {
borderRadius: 10,
controlHeight: 36,
},
Input: {
borderRadius: 12,
},
Select: {
borderRadius: 10,
},
Dropdown: {
borderRadius: 12,
},
Modal: {
contentBg: "#FFFFFF",
headerBg: "transparent",
footerBg: "transparent",
titleColor: "#1F2329",
titleFontSize: 16,
},
},
}}
>
<BrowserRouter>
<App />
</BrowserRouter>
</ConfigProvider>
</React.StrictMode>,
);

View File

@@ -2,18 +2,23 @@ import type { CSSProperties } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import {
ArrowUpOutlined,
CopyOutlined,
DeleteOutlined,
EditOutlined,
LogoutOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
MessageOutlined,
MobileOutlined,
MoreOutlined,
PaperClipOutlined,
PlusOutlined,
QuestionCircleOutlined,
RedoOutlined,
SearchOutlined,
ShareAltOutlined,
SettingOutlined,
ThunderboltOutlined,
VerticalAlignTopOutlined,
UserOutlined,
} from "@ant-design/icons";
import {
@@ -24,7 +29,9 @@ import {
Dropdown,
Input,
Layout,
Modal,
Select,
Tooltip,
Typography,
message,
} from "antd";
@@ -33,11 +40,13 @@ 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";
@@ -49,22 +58,18 @@ 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 u = JSON.parse(raw) as Record<string, unknown>;
const nickname = typeof u.nickname === "string" && u.nickname.trim() ? u.nickname.trim() : "";
const displayName =
nickname ||
(typeof u.name === "string" && u.name) ||
(typeof u.username === "string" && u.username) ||
(typeof u.phone === "string" && u.phone) ||
"用户";
const avatar =
(typeof u.avatar === "string" && u.avatar) ||
(typeof u.avatarUrl === "string" && u.avatarUrl) ||
undefined;
return { displayName: String(displayName), avatar };
const user = JSON.parse(raw) as StoredUserProfile;
const displayName = user.nickname.trim() || "用户";
const avatar = user.avatarUrl || undefined;
return { displayName, avatar };
} catch {
return { displayName: "用户" };
}
@@ -94,35 +99,70 @@ 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.5-flash";
const DEFAULT_QWEN_MODEL = "qwen3.6-plus";
const QWEN_MODEL_OPTIONS: { value: string; label: string }[] = [
{ value: "qwen3-max", label: "Qwen3-Max" },
{ value: "qwen3.6-plus", label: "Qwen3.6-Plus" },
{ value: DEFAULT_QWEN_MODEL, label: "Qwen3.5-Flash" },
{ 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 [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 [selectedModel, setSelectedModel] = useState(DEFAULT_QWEN_MODEL);
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 abortRef = useRef<AbortController | null>(null);
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,
@@ -134,6 +174,22 @@ export default function HomePage() {
[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",
@@ -189,7 +245,6 @@ export default function HomePage() {
window.innerWidth,
document.documentElement.clientWidth || window.innerWidth,
);
setViewportWidth(w);
const mobile = w < MOBILE_WIDTH;
setIsMobile(mobile);
if (!mobile) setMobileSidebarOpen(false);
@@ -229,6 +284,10 @@ export default function HomePage() {
void refreshSessionList();
}, [refreshSessionList]);
/**
* 打开指定会话并加载历史消息。
* 默认会把当前会话 id 同步到路由;当由路由参数驱动时可关闭 URL 更新,避免重复跳转。
*/
const openSession = useCallback(
async (sessionId: string, options?: { updateUrl?: boolean }) => {
if (options?.updateUrl ?? true) {
@@ -253,32 +312,140 @@ export default function HomePage() {
setMessages(INITIAL_MESSAGES.map((m) => ({ ...m })));
return;
}
// 路由已与当前会话一致时,不重复加载,避免覆盖流式中的本地消息状态。
if (routeSessionId === activeSessionIdRef.current) return;
void openSession(routeSessionId, { updateUrl: false });
}, [openSession, routeSessionId]);
const handleNewSession = async () => {
if (sessionMutationBusy) return;
setSessionMutationBusy(true);
try {
const row = await createChatSession();
await refreshSessionList();
setActiveSessionId(row.id);
setMessages(INITIAL_MESSAGES.map((m) => ({ ...m })));
navigate(`/s/${encodeURIComponent(row.id)}`);
} catch (e) {
const text = e instanceof Error ? e.message : "新建会话失败";
message.error(text);
} finally {
setSessionMutationBusy(false);
}
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;
if (!activeSessionId) {
message.warning("请先新建会话");
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("");
@@ -330,7 +497,9 @@ export default function HomePage() {
await streamQwenChat({
messages: retryMessages,
model: selectedModel,
sessionId: activeSessionId ?? undefined,
sessionId: targetSessionId ?? undefined,
enableWebSearch: smartSearch,
enableThinking: deepThink,
signal: controller.signal,
timeoutMs: STREAM_TIMEOUT_MS,
onToken: (token) => {
@@ -429,21 +598,52 @@ export default function HomePage() {
if (!sid) return null;
const title = sessionDisplayTitle(s);
const active = sid === activeSessionId;
const itemMenu: MenuProps = {
items: [
{ key: "rename", icon: <EditOutlined />, label: "重命名" },
{ key: "pin", icon: <VerticalAlignTopOutlined />, label: "置顶" },
{ key: "delete", icon: <DeleteOutlined />, 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 (
<button
<div
key={sid}
type="button"
onClick={() => {
void openSession(sid);
}}
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"
className="mb-1 flex w-full items-center gap-1 rounded-xl 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,
}}
>
<MessageOutlined className="shrink-0 text-neutral-400" />
{(!collapsed || isMobile) && <span className="truncate">{title}</span>}
</button>
<button
type="button"
onClick={() => {
void openSession(sid);
}}
className="flex min-h-12 min-w-0 flex-1 items-center rounded-xl px-3 py-3 text-left"
>
{(!collapsed || isMobile) && <span className="truncate">{title}</span>}
</button>
{(!collapsed || isMobile) && (
<Dropdown menu={itemMenu} trigger={["click"]} placement="bottomRight">
<Button
type="text"
size="small"
icon={<MoreOutlined />}
loading={sessionActionBusyId === sid}
className="text-neutral-500 hover:bg-black/5!"
onClick={(event) => {
event.stopPropagation();
}}
/>
</Dropdown>
)}
</div>
);
})}
</div>
@@ -487,6 +687,7 @@ export default function HomePage() {
className="chat-one-shell flex h-dvh max-h-dvh overflow-hidden"
style={{ background: "var(--ds-bg-main)" }}
>
{modalContextHolder}
{!isMobile && (
<Sider
trigger={null}
@@ -574,7 +775,7 @@ export default function HomePage() {
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">
<div className="mx-auto flex max-w-4xl flex-col gap-5">
{messages.map((item) => (
<div
key={item.id}
@@ -582,24 +783,22 @@ export default function HomePage() {
>
<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 === "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-neutral-50 !border !border-[var(--ds-border)]"
className="!mb-2 !rounded-xl !bg-transparent"
items={[
{
key: "think",
@@ -622,27 +821,92 @@ export default function HomePage() {
(item.content ? (
<StreamMessage content={item.content} />
) : (
<div className="rounded-2xl border border-[var(--ds-border)] bg-neutral-50/80 px-4 py-3 text-[14px] text-neutral-500">
<div className="flex items-center gap-2">
<span></span>
<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>
<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 === "user" && item.content}
{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={<CopyOutlined />}
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={<RedoOutlined />}
className="!px-1 text-neutral-500 hover:bg-black/5!"
aria-label="重新生成"
onClick={() => {
message.info("重新生成功能开发中");
}}
/>
</Tooltip>
<Tooltip title="分享">
<Button
type="text"
size="small"
icon={<ShareAltOutlined />}
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={<CopyOutlined />}
className="text-neutral-500 hover:bg-black/5!"
aria-label="复制消息"
onClick={() => {
void handleCopyUserMessage(item.content);
}}
/>
<Button
type="text"
size="small"
icon={<EditOutlined />}
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 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">
<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="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)}
@@ -653,7 +917,7 @@ export default function HomePage() {
}}
placeholder="给 ChatOne 发送消息"
variant="borderless"
autoSize={{ minRows: 1, maxRows: 6 }}
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">
@@ -706,11 +970,9 @@ export default function HomePage() {
</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>
)}
<p className="m-0 py-2 text-center text-[11px] text-neutral-500">
AI
</p>
</div>
</div>
</div>