All checks were successful
CI / build (push) Successful in 2m7s
- 非底部时显示悬浮回到底部按钮,点击平滑滚底并隐藏 - 仅在已处于底部附近时随新消息自动贴底,避免打断上翻阅读 - 按钮使用原生圆钮并相对输入区 max-w-4xl 定位,避免 AntD 按钮宽条问题 - 同步 lucide-react 依赖及流式代码块、全局样式等小改动 Made-with: Cursor
1059 lines
39 KiB
TypeScript
1059 lines
39 KiB
TypeScript
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>
|
||
);
|
||
}
|