新增会话与消息查询 API,并将首页改为真实会话驱动;当前选中会话会同步到 URL 参数,刷新或直达链接可恢复上下文。 Made-with: Cursor
This commit is contained in:
@@ -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
128
src/api/chatSessions.ts
Normal 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;
|
||||
}
|
||||
@@ -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 以 `/` 开头的相对路径
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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