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

@@ -15,22 +15,32 @@ export const USER_KEY = "user";
export type AuthUser = Record<string, unknown>;
/** `ClientAuthUserDto`(与 OpenAPI 一致) */
export type ClientAuthUserDto = {
id: string;
phone: string;
nickname: string;
avatarUrl: string;
};
/** 发送短信验证码时 `scene` 取值(与后端约定一致) */
export const AUTH_SMS_SCENE_LOGIN = "login";
/** `POST .../auth/sms/send` 响应(联调环境可能返回 `testCode` */
/** `ClientSendSmsResponseDto` */
export type SmsSendResponse = {
requestId: string;
phone: string;
scene: string;
provider: string;
expireIn: number;
testCode?: string;
};
/**
* `POST .../auth/sms/login` 成功响应(与后端一致:根级 token + `user`)。
* 例:`{ accessToken, refreshToken, user: { id, phone, nickname, avatarUrl } }`
*/
/** `ClientLoginResponseDto` */
export type SmsLoginResponse = {
accessToken: string;
refreshToken: string;
user?: AuthUser;
user: ClientAuthUserDto;
};
/** `POST .../auth/refresh` 成功响应 */

128
src/api/chatSessions.ts Normal file
View File

@@ -0,0 +1,128 @@
/**
* 会话与历史消息Client Chat
*
* 类型与 `http://localhost:3000/docs` 中 components schemas 对齐:
* `CreateChatSessionDto`、`ChatSessionRowDto`、`ChatSessionListResponseDto`、
* `ChatMessageRowDto`、`ChatMessageListResponseDto`。
*/
import { getJson, postJson } from "./http";
import { ApiPath, chatSessionMessagesPath } from "./paths";
/** `CreateChatSessionDto` */
export type CreateChatSessionBody = {
/** 会话标题,可选,最长 200 */
title?: string;
};
/** `ChatSessionRowDto` */
export type ChatSessionRowDto = {
id: string;
userId: string;
title: string;
createdAt: string;
updatedAt: string;
};
/** 侧栏与会话列表中的「一条会话」 */
export type ChatSession = ChatSessionRowDto;
/** `ChatSessionListResponseDto` */
export type ChatSessionListResponseDto = {
items: ChatSessionRowDto[];
total: number;
limit: number;
offset: number;
};
/** `ChatMessageRowDto` */
export type ChatMessageRowDto = {
id: string;
role: string;
content: string;
tokenCount: number;
provider?: string | null;
createdAt: string;
};
/** `ChatMessageListResponseDto` */
export type ChatMessageListResponseDto = {
sessionId: string;
items: ChatMessageRowDto[];
total: number;
limit: number;
offset: number;
};
export type ListSessionsQuery = {
offset?: number;
limit?: number;
};
export type ListMessagesQuery = {
offset?: number;
limit?: number;
};
/** 与首页消息列表结构一致,便于 `setMessages` */
export type ChatTurnForUi = {
id: string;
role: "user" | "assistant";
content: string;
thinking?: string;
};
export function sessionStableId(s: ChatSessionRowDto): string {
return s.id;
}
/** 侧栏展示标题(`title` 可能为空字符串) */
export function sessionDisplayTitle(s: ChatSessionRowDto): string {
const t = s.title.trim();
if (t) return t;
return `会话 ${s.id.slice(0, 8)}`;
}
/**
* 创建会话(可选标题)。
* 响应为 `ChatSessionRowDto`OpenAPI 标注为 200
*/
export async function createChatSession(
body: CreateChatSessionBody = {},
signal?: AbortSignal,
): Promise<ChatSessionRowDto> {
return postJson<ChatSessionRowDto>(ApiPath.chatSessions, body, signal);
}
/** 分页拉取会话列表 */
export async function listChatSessions(
query?: ListSessionsQuery,
signal?: AbortSignal,
): Promise<ChatSessionRowDto[]> {
const data = await getJson<ChatSessionListResponseDto>(ApiPath.chatSessions, query, signal);
return data.items;
}
/** 分页拉取某会话下的消息 */
export async function listChatSessionMessages(
sessionId: string,
query?: ListMessagesQuery,
signal?: AbortSignal,
): Promise<ChatMessageRowDto[]> {
const path = chatSessionMessagesPath(sessionId);
const data = await getJson<ChatMessageListResponseDto>(path, query, signal);
return data.items;
}
/** 将接口消息行规范为前端聊天列表项(忽略 `system` 等角色) */
export function normalizeSessionMessages(rows: ChatMessageRowDto[]): ChatTurnForUi[] {
const out: ChatTurnForUi[] = [];
for (const row of rows) {
if (row.role !== "user" && row.role !== "assistant") continue;
out.push({
id: row.id,
role: row.role,
content: row.content,
});
}
return out;
}

View File

@@ -117,6 +117,31 @@ function toRequestError(error: unknown, fallback: string): Error {
return error instanceof Error ? error : new Error(fallback);
}
/**
* GET JSON支持 query值为 `undefined` 的键不拼接)。
*/
export async function getJson<TResponse>(
path: string,
query?: Record<string, string | number | undefined>,
signal?: AbortSignal,
): Promise<TResponse> {
const sp = new URLSearchParams();
if (query) {
for (const [k, v] of Object.entries(query)) {
if (v === undefined) continue;
sp.set(k, String(v));
}
}
const qs = sp.toString();
const url = qs ? `${path}?${qs}` : path;
try {
const response = await httpClient.get<TResponse>(url, { signal });
return response.data;
} catch (error) {
throw toRequestError(error, "Request failed");
}
}
/**
* POST JSON返回解析后的响应体 `data`。
* @param path 以 `/` 开头的相对路径

View File

@@ -1,6 +1,11 @@
/**
* 客户端 API 路径(均以 `/` 开头)。
* 与 `VITE_API_BASE_URL`、Vite 代理拼接后请求后端。
*
* 与本地 Swagger`http://localhost:3000/docs` → `swagger-ui-init.js` 内嵌 `swaggerDoc`)对照:
* 已对齐 sms/send、sms/login、auth/refresh、chat/completions/stream
* 另有 `chat/sessions`、`chat/sessions/{sessionId}/messages`(见 `chatSessions` 与 `chatSessionMessagesPath`)。
* `auth/logout` 为前端预留,当前嵌入的 OpenAPI 片段中未出现。
*/
export const ApiPath = {
@@ -20,7 +25,14 @@ export const ApiPath = {
authLogout: "/api/client/v1/auth/logout",
/** 聊天补全SSE 流式。POST body: `{ messages, model? }` */
chatCompletionsStream: "/api/client/v1/chat/completions/stream",
/** 会话列表/创建Swagger 已声明,业务接入后调用) */
chatSessions: "/api/client/v1/chat/sessions",
} as const;
/** 某会话下的消息列表路径Swagger`/api/client/v1/chat/sessions/{sessionId}/messages` */
export function chatSessionMessagesPath(sessionId: string): string {
return `/api/client/v1/chat/sessions/${encodeURIComponent(sessionId)}/messages`;
}
/** 请求拦截器跳过附加 Bearer 的路径(与 `ApiPath` 保持一致) */
export const pathsWithoutBearerAuth: readonly string[] = [ApiPath.authRefresh, ApiPath.authLogout];

View File

@@ -36,6 +36,8 @@ export interface StreamOptions {
messages: ChatMessagePayload[];
/** OpenAI 兼容字段;不传则由服务端默认模型 */
model?: string;
/** 与会话绑定流式补全时传入(若后端支持) */
sessionId?: string;
onToken: (token: string) => void;
signal?: AbortSignal;
/** 超时后中止请求(与业务 `signal` 合并) */
@@ -143,6 +145,7 @@ export async function streamQwenChat(options: StreamOptions): Promise<void> {
body: JSON.stringify({
messages: options.messages,
...(options.model ? { model: options.model } : {}),
...(options.sessionId ? { sessionId: options.sessionId } : {}),
}),
signal: mergedSignal,
openWhenHidden: true,

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";