feat: 接入会话列表并支持路由记忆当前会话
All checks were successful
CI / build (push) Successful in 1m59s

新增会话与消息查询 API,并将首页改为真实会话驱动;当前选中会话会同步到 URL 参数,刷新或直达链接可恢复上下文。

Made-with: Cursor
This commit is contained in:
2026-04-22 23:32:07 +08:00
parent 9a1b59663b
commit b3fdb0ad4b
7 changed files with 311 additions and 39 deletions

View File

@@ -1,5 +1,5 @@
import type { CSSProperties } from "react";
import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import {
ArrowUpOutlined,
LogoutOutlined,
@@ -29,20 +29,25 @@ import {
message,
} from "antd";
import type { MenuProps } from "antd";
import { useNavigate } from "react-router-dom";
import { useNavigate, useParams } from "react-router-dom";
import { ACCESS_TOKEN_KEY, SessionExpiredError, USER_KEY, logout } from "../api/auth";
import {
createChatSession,
listChatSessionMessages,
listChatSessions,
normalizeSessionMessages,
sessionDisplayTitle,
sessionStableId,
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 = {
id: string;
role: "user" | "assistant";
content: string;
thinking?: string;
};
type UiMessage = ChatTurnForUi;
function parseStoredUserProfileJson(raw: string | null): { displayName: string; avatar?: string } {
try {
@@ -100,6 +105,7 @@ const QWEN_MODEL_OPTIONS: { value: string; label: string }[] = [
export default function HomePage() {
const navigate = useNavigate();
const { sessionId: routeSessionId } = useParams<{ sessionId?: string }>();
const [collapsed, setCollapsed] = useState(false);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [viewportWidth, setViewportWidth] = useState(0);
@@ -113,13 +119,10 @@ export default function HomePage() {
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 入门"] },
],
[],
);
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [sessionsLoading, setSessionsLoading] = useState(false);
const [sessionMutationBusy, setSessionMutationBusy] = useState(false);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const userJsonSnapshot = useSyncExternalStore(
subscribeUserProfile,
@@ -206,9 +209,77 @@ export default function HomePage() {
el.scrollTop = el.scrollHeight;
}, [messages]);
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]);
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;
}
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);
}
};
const sendMessage = async () => {
const trimmed = inputValue.trim();
if (!trimmed || isSending) return;
if (!activeSessionId) {
message.warning("请先新建会话");
return;
}
setInputValue("");
setIsSending(true);
@@ -259,6 +330,7 @@ export default function HomePage() {
await streamQwenChat({
messages: retryMessages,
model: selectedModel,
sessionId: activeSessionId ?? undefined,
signal: controller.signal,
timeoutMs: STREAM_TIMEOUT_MS,
onToken: (token) => {
@@ -323,38 +395,59 @@ export default function HomePage() {
<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={<PlusOutlined />}
onClick={() => {
void handleNewSession();
}}
>
{(!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">
{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)" }}
>
{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>
))}
{sessions.map((s) => {
const sid = sessionStableId(s);
if (!sid) return null;
const title = sessionDisplayTitle(s);
const active = sid === activeSessionId;
return (
<button
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"
style={{
background: active ? "var(--ds-active-item)" : undefined,
}}
>
<MessageOutlined className="shrink-0 text-neutral-400" />
{(!collapsed || isMobile) && <span className="truncate">{title}</span>}
</button>
);
})}
</div>
))}
)}
</div>
<div className="shrink-0 border-t border-[var(--ds-border)] p-3">

View File

@@ -0,0 +1 @@
export { default } from "../index";