diff --git a/src/api/auth.ts b/src/api/auth.ts index 3a336c1..ad920f5 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -15,22 +15,32 @@ export const USER_KEY = "user"; export type AuthUser = Record; +/** `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` 成功响应 */ diff --git a/src/api/chatSessions.ts b/src/api/chatSessions.ts new file mode 100644 index 0000000..26361b3 --- /dev/null +++ b/src/api/chatSessions.ts @@ -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 { + return postJson(ApiPath.chatSessions, body, signal); +} + +/** 分页拉取会话列表 */ +export async function listChatSessions( + query?: ListSessionsQuery, + signal?: AbortSignal, +): Promise { + const data = await getJson(ApiPath.chatSessions, query, signal); + return data.items; +} + +/** 分页拉取某会话下的消息 */ +export async function listChatSessionMessages( + sessionId: string, + query?: ListMessagesQuery, + signal?: AbortSignal, +): Promise { + const path = chatSessionMessagesPath(sessionId); + const data = await getJson(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; +} diff --git a/src/api/http.ts b/src/api/http.ts index 67e04a3..e6b29a9 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -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( + path: string, + query?: Record, + signal?: AbortSignal, +): Promise { + 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(url, { signal }); + return response.data; + } catch (error) { + throw toRequestError(error, "Request failed"); + } +} + /** * POST JSON,返回解析后的响应体 `data`。 * @param path 以 `/` 开头的相对路径 diff --git a/src/api/paths.ts b/src/api/paths.ts index bd69321..30b0c8c 100644 --- a/src/api/paths.ts +++ b/src/api/paths.ts @@ -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]; diff --git a/src/api/qwenChat.ts b/src/api/qwenChat.ts index 39dc6a6..c168690 100644 --- a/src/api/qwenChat.ts +++ b/src/api/qwenChat.ts @@ -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 { body: JSON.stringify({ messages: options.messages, ...(options.model ? { model: options.model } : {}), + ...(options.sessionId ? { sessionId: options.sessionId } : {}), }), signal: mergedSignal, openWhenHidden: true, diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 87d7f2c..d916436 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -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(null); const abortRef = useRef(null); - const historyGroups = useMemo( - () => [ - { title: "30 天内", keys: ["前端 CI 工具对比", "Nginx SPA 配置"] }, - { title: "2025-12", keys: ["Gitea Actions 入门"] }, - ], - [], - ); + const [sessions, setSessions] = useState([]); + const [sessionsLoading, setSessionsLoading] = useState(false); + const [sessionMutationBusy, setSessionMutationBusy] = useState(false); + const [activeSessionId, setActiveSessionId] = useState(null); const userJsonSnapshot = useSyncExternalStore( subscribeUserProfile, @@ -206,9 +209,77 @@ export default function HomePage() { el.scrollTop = el.scrollHeight; }, [messages]); + const refreshSessionList = useCallback(async (): Promise => { + 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() {
- {historyGroups.map((group) => ( -
+ {sessionsLoading ? ( + + 加载会话… + + ) : sessions.length === 0 ? ( + + 暂无会话,点击「新建会话」开始 + + ) : ( +
- {group.title} + 会话 - {group.keys.map((key) => ( - - ))} + {sessions.map((s) => { + const sid = sessionStableId(s); + if (!sid) return null; + const title = sessionDisplayTitle(s); + const active = sid === activeSessionId; + return ( + + ); + })}
- ))} + )}
diff --git a/src/pages/s/[sessionId].tsx b/src/pages/s/[sessionId].tsx new file mode 100644 index 0000000..be85ac9 --- /dev/null +++ b/src/pages/s/[sessionId].tsx @@ -0,0 +1 @@ +export { default } from "../index";