新增会话与消息查询 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>;
|
export type AuthUser = Record<string, unknown>;
|
||||||
|
|
||||||
|
/** `ClientAuthUserDto`(与 OpenAPI 一致) */
|
||||||
|
export type ClientAuthUserDto = {
|
||||||
|
id: string;
|
||||||
|
phone: string;
|
||||||
|
nickname: string;
|
||||||
|
avatarUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
/** 发送短信验证码时 `scene` 取值(与后端约定一致) */
|
/** 发送短信验证码时 `scene` 取值(与后端约定一致) */
|
||||||
export const AUTH_SMS_SCENE_LOGIN = "login";
|
export const AUTH_SMS_SCENE_LOGIN = "login";
|
||||||
|
|
||||||
/** `POST .../auth/sms/send` 响应(联调环境可能返回 `testCode`) */
|
/** `ClientSendSmsResponseDto` */
|
||||||
export type SmsSendResponse = {
|
export type SmsSendResponse = {
|
||||||
|
requestId: string;
|
||||||
|
phone: string;
|
||||||
|
scene: string;
|
||||||
|
provider: string;
|
||||||
|
expireIn: number;
|
||||||
testCode?: string;
|
testCode?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/** `ClientLoginResponseDto` */
|
||||||
* `POST .../auth/sms/login` 成功响应(与后端一致:根级 token + `user`)。
|
|
||||||
* 例:`{ accessToken, refreshToken, user: { id, phone, nickname, avatarUrl } }`
|
|
||||||
*/
|
|
||||||
export type SmsLoginResponse = {
|
export type SmsLoginResponse = {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
user?: AuthUser;
|
user: ClientAuthUserDto;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** `POST .../auth/refresh` 成功响应 */
|
/** `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);
|
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`。
|
* POST JSON,返回解析后的响应体 `data`。
|
||||||
* @param path 以 `/` 开头的相对路径
|
* @param path 以 `/` 开头的相对路径
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* 客户端 API 路径(均以 `/` 开头)。
|
* 客户端 API 路径(均以 `/` 开头)。
|
||||||
* 与 `VITE_API_BASE_URL`、Vite 代理拼接后请求后端。
|
* 与 `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 = {
|
export const ApiPath = {
|
||||||
@@ -20,7 +25,14 @@ export const ApiPath = {
|
|||||||
authLogout: "/api/client/v1/auth/logout",
|
authLogout: "/api/client/v1/auth/logout",
|
||||||
/** 聊天补全(SSE 流式)。POST body: `{ messages, model? }` */
|
/** 聊天补全(SSE 流式)。POST body: `{ messages, model? }` */
|
||||||
chatCompletionsStream: "/api/client/v1/chat/completions/stream",
|
chatCompletionsStream: "/api/client/v1/chat/completions/stream",
|
||||||
|
/** 会话列表/创建(Swagger 已声明,业务接入后调用) */
|
||||||
|
chatSessions: "/api/client/v1/chat/sessions",
|
||||||
} as const;
|
} 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` 保持一致) */
|
/** 请求拦截器跳过附加 Bearer 的路径(与 `ApiPath` 保持一致) */
|
||||||
export const pathsWithoutBearerAuth: readonly string[] = [ApiPath.authRefresh, ApiPath.authLogout];
|
export const pathsWithoutBearerAuth: readonly string[] = [ApiPath.authRefresh, ApiPath.authLogout];
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ export interface StreamOptions {
|
|||||||
messages: ChatMessagePayload[];
|
messages: ChatMessagePayload[];
|
||||||
/** OpenAI 兼容字段;不传则由服务端默认模型 */
|
/** OpenAI 兼容字段;不传则由服务端默认模型 */
|
||||||
model?: string;
|
model?: string;
|
||||||
|
/** 与会话绑定流式补全时传入(若后端支持) */
|
||||||
|
sessionId?: string;
|
||||||
onToken: (token: string) => void;
|
onToken: (token: string) => void;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
/** 超时后中止请求(与业务 `signal` 合并) */
|
/** 超时后中止请求(与业务 `signal` 合并) */
|
||||||
@@ -143,6 +145,7 @@ export async function streamQwenChat(options: StreamOptions): Promise<void> {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
messages: options.messages,
|
messages: options.messages,
|
||||||
...(options.model ? { model: options.model } : {}),
|
...(options.model ? { model: options.model } : {}),
|
||||||
|
...(options.sessionId ? { sessionId: options.sessionId } : {}),
|
||||||
}),
|
}),
|
||||||
signal: mergedSignal,
|
signal: mergedSignal,
|
||||||
openWhenHidden: true,
|
openWhenHidden: true,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||||
import {
|
import {
|
||||||
ArrowUpOutlined,
|
ArrowUpOutlined,
|
||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
@@ -29,20 +29,25 @@ import {
|
|||||||
message,
|
message,
|
||||||
} from "antd";
|
} from "antd";
|
||||||
import type { MenuProps } 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 { 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 { isAbortLikeError, streamQwenChat, type ChatMessagePayload } from "../api/qwenChat";
|
||||||
import StreamMessage from "../components/StreamMessage";
|
import StreamMessage from "../components/StreamMessage";
|
||||||
|
|
||||||
const { Header, Sider, Content } = Layout;
|
const { Header, Sider, Content } = Layout;
|
||||||
const MOBILE_WIDTH = 750;
|
const MOBILE_WIDTH = 750;
|
||||||
|
|
||||||
type UiMessage = {
|
type UiMessage = ChatTurnForUi;
|
||||||
id: string;
|
|
||||||
role: "user" | "assistant";
|
|
||||||
content: string;
|
|
||||||
thinking?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function parseStoredUserProfileJson(raw: string | null): { displayName: string; avatar?: string } {
|
function parseStoredUserProfileJson(raw: string | null): { displayName: string; avatar?: string } {
|
||||||
try {
|
try {
|
||||||
@@ -100,6 +105,7 @@ const QWEN_MODEL_OPTIONS: { value: string; label: string }[] = [
|
|||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { sessionId: routeSessionId } = useParams<{ sessionId?: string }>();
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||||
const [viewportWidth, setViewportWidth] = useState(0);
|
const [viewportWidth, setViewportWidth] = useState(0);
|
||||||
@@ -113,13 +119,10 @@ export default function HomePage() {
|
|||||||
const messageListRef = useRef<HTMLDivElement | null>(null);
|
const messageListRef = useRef<HTMLDivElement | null>(null);
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
const historyGroups = useMemo(
|
const [sessions, setSessions] = useState<ChatSession[]>([]);
|
||||||
() => [
|
const [sessionsLoading, setSessionsLoading] = useState(false);
|
||||||
{ title: "30 天内", keys: ["前端 CI 工具对比", "Nginx SPA 配置"] },
|
const [sessionMutationBusy, setSessionMutationBusy] = useState(false);
|
||||||
{ title: "2025-12", keys: ["Gitea Actions 入门"] },
|
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
||||||
],
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const userJsonSnapshot = useSyncExternalStore(
|
const userJsonSnapshot = useSyncExternalStore(
|
||||||
subscribeUserProfile,
|
subscribeUserProfile,
|
||||||
@@ -206,9 +209,77 @@ export default function HomePage() {
|
|||||||
el.scrollTop = el.scrollHeight;
|
el.scrollTop = el.scrollHeight;
|
||||||
}, [messages]);
|
}, [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 sendMessage = async () => {
|
||||||
const trimmed = inputValue.trim();
|
const trimmed = inputValue.trim();
|
||||||
if (!trimmed || isSending) return;
|
if (!trimmed || isSending) return;
|
||||||
|
if (!activeSessionId) {
|
||||||
|
message.warning("请先新建会话");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setInputValue("");
|
setInputValue("");
|
||||||
setIsSending(true);
|
setIsSending(true);
|
||||||
@@ -259,6 +330,7 @@ export default function HomePage() {
|
|||||||
await streamQwenChat({
|
await streamQwenChat({
|
||||||
messages: retryMessages,
|
messages: retryMessages,
|
||||||
model: selectedModel,
|
model: selectedModel,
|
||||||
|
sessionId: activeSessionId ?? undefined,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
timeoutMs: STREAM_TIMEOUT_MS,
|
timeoutMs: STREAM_TIMEOUT_MS,
|
||||||
onToken: (token) => {
|
onToken: (token) => {
|
||||||
@@ -323,38 +395,59 @@ export default function HomePage() {
|
|||||||
<div className="shrink-0 px-3 pt-3 pb-2">
|
<div className="shrink-0 px-3 pt-3 pb-2">
|
||||||
<Button
|
<Button
|
||||||
block
|
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!"
|
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 />}
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
void handleNewSession();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{(!collapsed || isMobile) && "新建会话"}
|
{(!collapsed || isMobile) && "新建会话"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-auto px-2 py-2">
|
<div className="min-h-0 flex-1 overflow-auto px-2 py-2">
|
||||||
{historyGroups.map((group) => (
|
{sessionsLoading ? (
|
||||||
<div key={group.title} className="mb-4">
|
<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
|
<Typography.Text
|
||||||
className="mb-2 block px-2 text-[11px] uppercase tracking-wide"
|
className="mb-2 block px-2 text-[11px] uppercase tracking-wide"
|
||||||
style={{ color: "var(--ds-text-secondary)" }}
|
style={{ color: "var(--ds-text-secondary)" }}
|
||||||
>
|
>
|
||||||
{group.title}
|
会话
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
{group.keys.map((key) => (
|
{sessions.map((s) => {
|
||||||
<button
|
const sid = sessionStableId(s);
|
||||||
key={key}
|
if (!sid) return null;
|
||||||
type="button"
|
const title = sessionDisplayTitle(s);
|
||||||
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"
|
const active = sid === activeSessionId;
|
||||||
style={{
|
return (
|
||||||
background:
|
<button
|
||||||
key === historyGroups[0].keys[0] ? "var(--ds-active-item)" : undefined,
|
key={sid}
|
||||||
}}
|
type="button"
|
||||||
>
|
onClick={() => {
|
||||||
<MessageOutlined className="shrink-0 text-neutral-400" />
|
void openSession(sid);
|
||||||
{(!collapsed || isMobile) && <span className="truncate">{key}</span>}
|
}}
|
||||||
</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: active ? "var(--ds-active-item)" : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MessageOutlined className="shrink-0 text-neutral-400" />
|
||||||
|
{(!collapsed || isMobile) && <span className="truncate">{title}</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shrink-0 border-t border-[var(--ds-border)] p-3">
|
<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