新增会话与消息查询 API,并将首页改为真实会话驱动;当前选中会话会同步到 URL 参数,刷新或直达链接可恢复上下文。 Made-with: Cursor
This commit is contained in:
@@ -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">
|
||||
|
||||
1
src/pages/s/[sessionId].tsx
Normal file
1
src/pages/s/[sessionId].tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "../index";
|
||||
Reference in New Issue
Block a user