diff --git a/src/api/chatSessions.ts b/src/api/chatSessions.ts index 26361b3..640ac9e 100644 --- a/src/api/chatSessions.ts +++ b/src/api/chatSessions.ts @@ -5,8 +5,8 @@ * `CreateChatSessionDto`、`ChatSessionRowDto`、`ChatSessionListResponseDto`、 * `ChatMessageRowDto`、`ChatMessageListResponseDto`。 */ -import { getJson, postJson } from "./http"; -import { ApiPath, chatSessionMessagesPath } from "./paths"; +import { deleteJson, getJson, patchJson, postJson } from "./http"; +import { ApiPath, chatSessionMessagesPath, chatSessionPath, chatSessionTitlePath } from "./paths"; /** `CreateChatSessionDto` */ export type CreateChatSessionBody = { @@ -63,6 +63,12 @@ export type ListMessagesQuery = { limit?: number; }; +/** `UpdateChatSessionTitleDto` */ +export type UpdateChatSessionTitleBody = { + /** 会话标题(可传空字符串清空) */ + title: string; +}; + /** 与首页消息列表结构一致,便于 `setMessages` */ export type ChatTurnForUi = { id: string; @@ -113,6 +119,22 @@ export async function listChatSessionMessages( return data.items; } +/** 修改会话标题 */ +export async function updateChatSessionTitle( + sessionId: string, + body: UpdateChatSessionTitleBody, + signal?: AbortSignal, +): Promise { + const path = chatSessionTitlePath(sessionId); + return patchJson(path, body, signal); +} + +/** 删除会话(级联删除消息) */ +export async function deleteChatSession(sessionId: string, signal?: AbortSignal): Promise { + const path = chatSessionPath(sessionId); + await deleteJson(path, signal); +} + /** 将接口消息行规范为前端聊天列表项(忽略 `system` 等角色) */ export function normalizeSessionMessages(rows: ChatMessageRowDto[]): ChatTurnForUi[] { const out: ChatTurnForUi[] = []; diff --git a/src/api/http.ts b/src/api/http.ts index e6b29a9..e3e86ea 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -159,6 +159,33 @@ export async function postJson( } } +/** PATCH JSON,返回解析后的响应体 `data`。 */ +export async function patchJson( + path: string, + body: JsonBody, + signal?: AbortSignal, +): Promise { + try { + const response = await httpClient.patch(path, body, { signal }); + return response.data; + } catch (error) { + throw toRequestError(error, "Request failed"); + } +} + +/** DELETE JSON,返回解析后的响应体 `data`(若接口无返回体可使用 `void` 类型接收)。 */ +export async function deleteJson( + path: string, + signal?: AbortSignal, +): Promise { + try { + const response = await httpClient.delete(path, { signal }); + return response.data; + } catch (error) { + throw toRequestError(error, "Request failed"); + } +} + /** * POST JSON,返回原生 `Response`(用于需要直接读 body stream 的场景)。 * 注意:不走 axios 拦截器以外的逻辑;若需鉴权请自行在 headers 中传入。 diff --git a/src/api/paths.ts b/src/api/paths.ts index 30b0c8c..6b8f5bb 100644 --- a/src/api/paths.ts +++ b/src/api/paths.ts @@ -34,5 +34,15 @@ export function chatSessionMessagesPath(sessionId: string): string { return `/api/client/v1/chat/sessions/${encodeURIComponent(sessionId)}/messages`; } +/** 单个会话资源路径(Swagger:`/api/client/v1/chat/sessions/{sessionId}`) */ +export function chatSessionPath(sessionId: string): string { + return `/api/client/v1/chat/sessions/${encodeURIComponent(sessionId)}`; +} + +/** 会话标题更新路径(Swagger:`/api/client/v1/chat/sessions/{sessionId}/title`) */ +export function chatSessionTitlePath(sessionId: string): string { + return `/api/client/v1/chat/sessions/${encodeURIComponent(sessionId)}/title`; +} + /** 请求拦截器跳过附加 Bearer 的路径(与 `ApiPath` 保持一致) */ export const pathsWithoutBearerAuth: readonly string[] = [ApiPath.authRefresh, ApiPath.authLogout]; diff --git a/src/api/qwenChat.ts b/src/api/qwenChat.ts index c168690..15a94ff 100644 --- a/src/api/qwenChat.ts +++ b/src/api/qwenChat.ts @@ -38,6 +38,10 @@ export interface StreamOptions { model?: string; /** 与会话绑定流式补全时传入(若后端支持) */ sessionId?: string; + /** 是否启用联网搜索(OpenAPI: `enableWebSearch`) */ + enableWebSearch?: boolean; + /** 是否启用深度思考(OpenAPI: `enableThinking`) */ + enableThinking?: boolean; onToken: (token: string) => void; signal?: AbortSignal; /** 超时后中止请求(与业务 `signal` 合并) */ @@ -146,6 +150,8 @@ export async function streamQwenChat(options: StreamOptions): Promise { messages: options.messages, ...(options.model ? { model: options.model } : {}), ...(options.sessionId ? { sessionId: options.sessionId } : {}), + enableWebSearch: !!options.enableWebSearch, + enableThinking: !!options.enableThinking, }), signal: mergedSignal, openWhenHidden: true, diff --git a/src/components/StreamMessage.tsx b/src/components/StreamMessage.tsx index 134635b..f3dcea8 100644 --- a/src/components/StreamMessage.tsx +++ b/src/components/StreamMessage.tsx @@ -1,3 +1,6 @@ +import { useState } from "react"; +import { CheckOutlined, CopyOutlined } from "@ant-design/icons"; +import { Button, message } from "antd"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import remarkMath from "remark-math"; @@ -5,19 +8,68 @@ import rehypeKatex from "rehype-katex"; import rehypeHighlight from "rehype-highlight"; import "katex/dist/katex.min.css"; import "highlight.js/styles/github.css"; +import type { ReactNode } from "react"; type StreamMessageProps = { content: string; }; +function extractText(node: ReactNode): string { + if (typeof node === "string") return node; + if (typeof node === "number") return String(node); + if (!node) return ""; + if (Array.isArray(node)) return node.map((item) => extractText(item)).join(""); + if (typeof node === "object" && "props" in node) { + const child = (node as { props?: { children?: ReactNode } }).props?.children; + return extractText(child); + } + return ""; +} + +function MarkdownPreBlock(props: { children: ReactNode }) { + const [copied, setCopied] = useState(false); + const codeText = extractText(props.children).replace(/\n$/, ""); + + const onCopy = async () => { + try { + await navigator.clipboard.writeText(codeText); + setCopied(true); + message.success("代码已复制"); + window.setTimeout(() => setCopied(false), 1200); + } catch { + message.error("复制失败"); + } + }; + + return ( +
+
+ +
+
{props.children}
+
+ ); +} + export default function StreamMessage(props: StreamMessageProps) { return ( -
-
+
+
{children}, table: ({ children }) => (
{children}
diff --git a/src/index.css b/src/index.css index ab28670..68d8d1f 100644 --- a/src/index.css +++ b/src/index.css @@ -1,10 +1,36 @@ @import "tailwindcss"; +*, +*::before, +*::after { + box-sizing: border-box; +} + +p { + margin: 0; +} + html, body { margin: 0; height: 100%; font-size: 14px; + line-height: 1.5; + color: #171717; + background: #ffffff; + font-family: + "Inter", + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + Roboto, + "Helvetica Neue", + "PingFang SC", + "Hiragino Sans GB", + "Microsoft YaHei", + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; overflow: hidden; } @@ -31,11 +57,29 @@ body { :root { --ds-bg-main: #ffffff; --ds-bg-sider: #f7f7f8; - --ds-border: #ececec; - --ds-text-secondary: #8b8b8b; + --ds-border: #e9e9eb; + --ds-text-secondary: #8a8f99; --ds-user-bubble: #e8f3ff; --ds-user-border: #cce7ff; - --ds-active-item: #e3f2fd; - --ds-send: #4dabf7; - --ds-send-hover: #339af0; + --ds-active-item: #eaf3ff; + --ds-accent: #4a90e2; + --ds-send: #4a90e2; + --ds-send-hover: #3b7fd0; +} + +/* 覆盖 highlight.js github 主题默认白底,保持代码块容器背景一致 */ +pre code.hljs { + background: transparent !important; +} + +/* 全局 Dropdown 菜单样式 */ +.ant-dropdown-menu { + border-radius: 14px !important; + padding: 4px !important; +} + +.ant-dropdown-menu-item, +.ant-dropdown-menu-submenu-title, +.ant-dropdown-menu-item-danger { + border-radius: 12px !important; } diff --git a/src/main.tsx b/src/main.tsx index 1c5b870..e1954cd 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,5 +1,6 @@ import React from "react"; import ReactDOM from "react-dom/client"; +import { ConfigProvider, theme } from "antd"; import { BrowserRouter } from "react-router-dom"; import "antd/dist/reset.css"; import "./index.css"; @@ -7,8 +8,50 @@ import App from "./App"; ReactDOM.createRoot(document.getElementById("root")!).render( - - - + + + + + , ); diff --git a/src/pages/index.tsx b/src/pages/index.tsx index d916436..32fb304 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -2,18 +2,23 @@ import type { CSSProperties } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { ArrowUpOutlined, + CopyOutlined, + DeleteOutlined, + EditOutlined, LogoutOutlined, MenuFoldOutlined, MenuUnfoldOutlined, - MessageOutlined, MobileOutlined, MoreOutlined, PaperClipOutlined, PlusOutlined, QuestionCircleOutlined, + RedoOutlined, SearchOutlined, + ShareAltOutlined, SettingOutlined, ThunderboltOutlined, + VerticalAlignTopOutlined, UserOutlined, } from "@ant-design/icons"; import { @@ -24,7 +29,9 @@ import { Dropdown, Input, Layout, + Modal, Select, + Tooltip, Typography, message, } from "antd"; @@ -33,11 +40,13 @@ import { useNavigate, useParams } from "react-router-dom"; import { ACCESS_TOKEN_KEY, SessionExpiredError, USER_KEY, logout } from "../api/auth"; import { createChatSession, + deleteChatSession, listChatSessionMessages, listChatSessions, normalizeSessionMessages, sessionDisplayTitle, sessionStableId, + updateChatSessionTitle, type ChatSession, type ChatTurnForUi, } from "../api/chatSessions"; @@ -49,22 +58,18 @@ const MOBILE_WIDTH = 750; type UiMessage = ChatTurnForUi; +type StoredUserProfile = { + nickname: string; + avatarUrl: string; +}; + function parseStoredUserProfileJson(raw: string | null): { displayName: string; avatar?: string } { try { if (!raw) return { displayName: "用户" }; - const u = JSON.parse(raw) as Record; - const nickname = typeof u.nickname === "string" && u.nickname.trim() ? u.nickname.trim() : ""; - const displayName = - nickname || - (typeof u.name === "string" && u.name) || - (typeof u.username === "string" && u.username) || - (typeof u.phone === "string" && u.phone) || - "用户"; - const avatar = - (typeof u.avatar === "string" && u.avatar) || - (typeof u.avatarUrl === "string" && u.avatarUrl) || - undefined; - return { displayName: String(displayName), avatar }; + const user = JSON.parse(raw) as StoredUserProfile; + const displayName = user.nickname.trim() || "用户"; + const avatar = user.avatarUrl || undefined; + return { displayName, avatar }; } catch { return { displayName: "用户" }; } @@ -94,35 +99,70 @@ const INITIAL_MESSAGES: UiMessage[] = [ const STREAM_TIMEOUT_MS = 90000; const MAX_STREAM_RETRY = 1; const CONTEXT_WINDOW_SIZE = 8; +const DEEP_THINK_STORAGE_KEY = "chatone-deep-think-enabled"; +const SMART_SEARCH_STORAGE_KEY = "chatone-smart-search-enabled"; +const SELECTED_MODEL_STORAGE_KEY = "chatone-selected-model"; /** 展示名与请求 model 字段;需与后端实际支持的模型 id 一致 */ -const DEFAULT_QWEN_MODEL = "qwen3.5-flash"; +const DEFAULT_QWEN_MODEL = "qwen3.6-plus"; const QWEN_MODEL_OPTIONS: { value: string; label: string }[] = [ { value: "qwen3-max", label: "Qwen3-Max" }, - { value: "qwen3.6-plus", label: "Qwen3.6-Plus" }, - { value: DEFAULT_QWEN_MODEL, label: "Qwen3.5-Flash" }, + { value: DEFAULT_QWEN_MODEL, label: "Qwen3.6-Plus" }, + { value: "qwen3.5-flash", label: "Qwen3.5-Flash" }, + { value: "qwen-plus", label: "Qwen-Plus" }, + { value: "deepseek-r1", label: "DeepSeek-R1" }, + { value: "kimi-k2.6", label: "Kimi-K2.6" }, ]; +function replaceSessionUrl(sessionId: string) { + const base = import.meta.env.BASE_URL ?? "/"; + const prefix = base === "/" ? "" : base.replace(/\/$/, ""); + const nextPath = `${prefix}/s/${encodeURIComponent(sessionId)}`; + window.history.replaceState(window.history.state, "", nextPath); +} + export default function HomePage() { + const [modal, modalContextHolder] = Modal.useModal(); const navigate = useNavigate(); const { sessionId: routeSessionId } = useParams<{ sessionId?: string }>(); const [collapsed, setCollapsed] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); - const [viewportWidth, setViewportWidth] = useState(0); const [isMobile, setIsMobile] = useState(false); const [inputValue, setInputValue] = useState(""); const [messages, setMessages] = useState(INITIAL_MESSAGES); const [isSending, setIsSending] = useState(false); - const [deepThink, setDeepThink] = useState(false); - const [smartSearch, setSmartSearch] = useState(false); - const [selectedModel, setSelectedModel] = useState(DEFAULT_QWEN_MODEL); + const [deepThink, setDeepThink] = useState(() => { + try { + return window.localStorage.getItem(DEEP_THINK_STORAGE_KEY) === "1"; + } catch { + return false; + } + }); + const [smartSearch, setSmartSearch] = useState(() => { + try { + return window.localStorage.getItem(SMART_SEARCH_STORAGE_KEY) === "1"; + } catch { + return false; + } + }); + const [selectedModel, setSelectedModel] = useState(() => { + try { + const raw = window.localStorage.getItem(SELECTED_MODEL_STORAGE_KEY); + const supported = QWEN_MODEL_OPTIONS.some((item) => item.value === raw); + return supported && raw ? raw : DEFAULT_QWEN_MODEL; + } catch { + return DEFAULT_QWEN_MODEL; + } + }); const messageListRef = useRef(null); const abortRef = useRef(null); const [sessions, setSessions] = useState([]); const [sessionsLoading, setSessionsLoading] = useState(false); const [sessionMutationBusy, setSessionMutationBusy] = useState(false); + const [sessionActionBusyId, setSessionActionBusyId] = useState(null); const [activeSessionId, setActiveSessionId] = useState(null); + const activeSessionIdRef = useRef(null); const userJsonSnapshot = useSyncExternalStore( subscribeUserProfile, @@ -134,6 +174,22 @@ export default function HomePage() { [userJsonSnapshot], ); + useEffect(() => { + activeSessionIdRef.current = activeSessionId; + }, [activeSessionId]); + + useEffect(() => { + window.localStorage.setItem(DEEP_THINK_STORAGE_KEY, deepThink ? "1" : "0"); + }, [deepThink]); + + useEffect(() => { + window.localStorage.setItem(SMART_SEARCH_STORAGE_KEY, smartSearch ? "1" : "0"); + }, [smartSearch]); + + useEffect(() => { + window.localStorage.setItem(SELECTED_MODEL_STORAGE_KEY, selectedModel); + }, [selectedModel]); + /** 用户信息下拉:略增大行高与项间距 */ const userMenuItemStyle: CSSProperties = { height: "auto", @@ -189,7 +245,6 @@ export default function HomePage() { window.innerWidth, document.documentElement.clientWidth || window.innerWidth, ); - setViewportWidth(w); const mobile = w < MOBILE_WIDTH; setIsMobile(mobile); if (!mobile) setMobileSidebarOpen(false); @@ -229,6 +284,10 @@ export default function HomePage() { void refreshSessionList(); }, [refreshSessionList]); + /** + * 打开指定会话并加载历史消息。 + * 默认会把当前会话 id 同步到路由;当由路由参数驱动时可关闭 URL 更新,避免重复跳转。 + */ const openSession = useCallback( async (sessionId: string, options?: { updateUrl?: boolean }) => { if (options?.updateUrl ?? true) { @@ -253,32 +312,140 @@ export default function HomePage() { setMessages(INITIAL_MESSAGES.map((m) => ({ ...m }))); return; } + // 路由已与当前会话一致时,不重复加载,避免覆盖流式中的本地消息状态。 + if (routeSessionId === activeSessionIdRef.current) 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); - } + setActiveSessionId(null); + setMessages(INITIAL_MESSAGES.map((m) => ({ ...m }))); + navigate("/"); }; + const handleRenameSession = useCallback( + (session: ChatSession) => { + const sid = sessionStableId(session); + if (!sid || sessionActionBusyId) return; + let nextTitle = session.title; + modal.confirm({ + centered: true, + title: "重命名会话", + content: ( + { + nextTitle = event.target.value; + }} + onPressEnter={(event) => { + event.preventDefault(); + }} + /> + ), + okText: "保存", + cancelText: "取消", + async onOk() { + if (nextTitle === session.title) return; + setSessionActionBusyId(sid); + try { + await updateChatSessionTitle(sid, { title: nextTitle }); + await refreshSessionList(); + message.success("会话已重命名"); + } catch (error) { + const text = error instanceof Error ? error.message : "重命名会话失败"; + message.error(text); + throw error; + } finally { + setSessionActionBusyId(null); + } + }, + }); + }, + [modal, refreshSessionList, sessionActionBusyId], + ); + + const handleDeleteSession = useCallback( + (session: ChatSession) => { + const sid = sessionStableId(session); + if (!sid || sessionActionBusyId) return; + modal.confirm({ + centered: true, + title: "删除会话", + content: "确认删除该会话?删除后不可恢复。", + okText: "删除", + okType: "danger", + cancelText: "取消", + async onOk() { + setSessionActionBusyId(sid); + try { + await deleteChatSession(sid); + if (activeSessionId === sid) { + abortRef.current?.abort(); + setIsSending(false); + setActiveSessionId(null); + setMessages(INITIAL_MESSAGES.map((m) => ({ ...m }))); + navigate("/"); + } + await refreshSessionList(); + message.success("会话已删除"); + } catch (error) { + const text = error instanceof Error ? error.message : "删除会话失败"; + message.error(text); + throw error; + } finally { + setSessionActionBusyId(null); + } + }, + }); + }, + [activeSessionId, modal, navigate, refreshSessionList, sessionActionBusyId], + ); + + const handleCopyUserMessage = useCallback(async (content: string) => { + try { + await navigator.clipboard.writeText(content); + message.success("已复制"); + } catch { + message.error("复制失败,请检查浏览器权限"); + } + }, []); + + const handleCopyAssistantMessage = useCallback(async (content: string) => { + try { + await navigator.clipboard.writeText(content); + message.success("已复制"); + } catch { + message.error("复制失败,请检查浏览器权限"); + } + }, []); + const sendMessage = async () => { const trimmed = inputValue.trim(); if (!trimmed || isSending) return; - if (!activeSessionId) { - message.warning("请先新建会话"); - return; + let targetSessionId = activeSessionId; + if (!targetSessionId) { + if (sessionMutationBusy) return; + setSessionMutationBusy(true); + try { + const row = await createChatSession({ + // 首条提问作为会话初始标题(后端限制 200)。 + title: trimmed.slice(0, 200), + }); + targetSessionId = row.id; + setActiveSessionId(row.id); + replaceSessionUrl(row.id); + // 侧栏列表后台刷新,不阻塞当前消息发送。 + void refreshSessionList(); + } catch (e) { + const text = e instanceof Error ? e.message : "新建会话失败"; + message.error(text); + return; + } finally { + setSessionMutationBusy(false); + } } setInputValue(""); @@ -330,7 +497,9 @@ export default function HomePage() { await streamQwenChat({ messages: retryMessages, model: selectedModel, - sessionId: activeSessionId ?? undefined, + sessionId: targetSessionId ?? undefined, + enableWebSearch: smartSearch, + enableThinking: deepThink, signal: controller.signal, timeoutMs: STREAM_TIMEOUT_MS, onToken: (token) => { @@ -429,21 +598,52 @@ export default function HomePage() { if (!sid) return null; const title = sessionDisplayTitle(s); const active = sid === activeSessionId; + const itemMenu: MenuProps = { + items: [ + { key: "rename", icon: , label: "重命名" }, + { key: "pin", icon: , label: "置顶" }, + { key: "delete", icon: , danger: true, label: "删除" }, + ], + onClick: ({ key, domEvent }) => { + domEvent.stopPropagation(); + if (key === "rename") void handleRenameSession(s); + if (key === "pin") message.info("置顶功能开发中"); + if (key === "delete") void handleDeleteSession(s); + }, + }; return ( - + + {(!collapsed || isMobile) && ( + +
); })}
@@ -487,6 +687,7 @@ export default function HomePage() { className="chat-one-shell flex h-dvh max-h-dvh overflow-hidden" style={{ background: "var(--ds-bg-main)" }} > + {modalContextHolder} {!isMobile && ( -
+
{messages.map((item) => (
+ {item.role === "user" && ( +
+ {item.content} +
+ )} {item.role === "assistant" && item.thinking && ( ) : ( -
-
- 正在回复 - - - - - -
+
+ + + + +
))} - {item.role === "user" && item.content} + {item.role === "assistant" && item.content && ( + <> +

+ 本回答由 AI 生成,内容仅供参考,请仔细甄别。 +

+
+ +
+ + )} + {item.role === "user" && ( +
+
+ )}
))}
-
-
-
+
+
+
setInputValue(e.target.value)} @@ -653,7 +917,7 @@ export default function HomePage() { }} placeholder="给 ChatOne 发送消息" variant="borderless" - autoSize={{ minRows: 1, maxRows: 6 }} + autoSize={{ minRows: 2, maxRows: 8 }} className="!px-1 !text-[15px] placeholder:text-neutral-400" />
@@ -706,11 +970,9 @@ export default function HomePage() {
- {import.meta.env.DEV && ( - - W:{viewportWidth}px · {isMobile ? "mobile" : "desktop"} - - )} +

+ 内容由 AI 生成,请注意核实 +