补齐会话重命名/删除与会话菜单能力,新增聊天参数透传与本地开关持久化,并统一输入区、消息区、代码块和弹框等关键交互样式。 Made-with: Cursor
This commit is contained in:
@@ -5,8 +5,8 @@
|
|||||||
* `CreateChatSessionDto`、`ChatSessionRowDto`、`ChatSessionListResponseDto`、
|
* `CreateChatSessionDto`、`ChatSessionRowDto`、`ChatSessionListResponseDto`、
|
||||||
* `ChatMessageRowDto`、`ChatMessageListResponseDto`。
|
* `ChatMessageRowDto`、`ChatMessageListResponseDto`。
|
||||||
*/
|
*/
|
||||||
import { getJson, postJson } from "./http";
|
import { deleteJson, getJson, patchJson, postJson } from "./http";
|
||||||
import { ApiPath, chatSessionMessagesPath } from "./paths";
|
import { ApiPath, chatSessionMessagesPath, chatSessionPath, chatSessionTitlePath } from "./paths";
|
||||||
|
|
||||||
/** `CreateChatSessionDto` */
|
/** `CreateChatSessionDto` */
|
||||||
export type CreateChatSessionBody = {
|
export type CreateChatSessionBody = {
|
||||||
@@ -63,6 +63,12 @@ export type ListMessagesQuery = {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** `UpdateChatSessionTitleDto` */
|
||||||
|
export type UpdateChatSessionTitleBody = {
|
||||||
|
/** 会话标题(可传空字符串清空) */
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
/** 与首页消息列表结构一致,便于 `setMessages` */
|
/** 与首页消息列表结构一致,便于 `setMessages` */
|
||||||
export type ChatTurnForUi = {
|
export type ChatTurnForUi = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -113,6 +119,22 @@ export async function listChatSessionMessages(
|
|||||||
return data.items;
|
return data.items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 修改会话标题 */
|
||||||
|
export async function updateChatSessionTitle(
|
||||||
|
sessionId: string,
|
||||||
|
body: UpdateChatSessionTitleBody,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<ChatSessionRowDto> {
|
||||||
|
const path = chatSessionTitlePath(sessionId);
|
||||||
|
return patchJson<ChatSessionRowDto>(path, body, signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除会话(级联删除消息) */
|
||||||
|
export async function deleteChatSession(sessionId: string, signal?: AbortSignal): Promise<void> {
|
||||||
|
const path = chatSessionPath(sessionId);
|
||||||
|
await deleteJson<void>(path, signal);
|
||||||
|
}
|
||||||
|
|
||||||
/** 将接口消息行规范为前端聊天列表项(忽略 `system` 等角色) */
|
/** 将接口消息行规范为前端聊天列表项(忽略 `system` 等角色) */
|
||||||
export function normalizeSessionMessages(rows: ChatMessageRowDto[]): ChatTurnForUi[] {
|
export function normalizeSessionMessages(rows: ChatMessageRowDto[]): ChatTurnForUi[] {
|
||||||
const out: ChatTurnForUi[] = [];
|
const out: ChatTurnForUi[] = [];
|
||||||
|
|||||||
@@ -159,6 +159,33 @@ export async function postJson<TResponse>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** PATCH JSON,返回解析后的响应体 `data`。 */
|
||||||
|
export async function patchJson<TResponse>(
|
||||||
|
path: string,
|
||||||
|
body: JsonBody,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<TResponse> {
|
||||||
|
try {
|
||||||
|
const response = await httpClient.patch<TResponse>(path, body, { signal });
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw toRequestError(error, "Request failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DELETE JSON,返回解析后的响应体 `data`(若接口无返回体可使用 `void` 类型接收)。 */
|
||||||
|
export async function deleteJson<TResponse>(
|
||||||
|
path: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<TResponse> {
|
||||||
|
try {
|
||||||
|
const response = await httpClient.delete<TResponse>(path, { signal });
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw toRequestError(error, "Request failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST JSON,返回原生 `Response`(用于需要直接读 body stream 的场景)。
|
* POST JSON,返回原生 `Response`(用于需要直接读 body stream 的场景)。
|
||||||
* 注意:不走 axios 拦截器以外的逻辑;若需鉴权请自行在 headers 中传入。
|
* 注意:不走 axios 拦截器以外的逻辑;若需鉴权请自行在 headers 中传入。
|
||||||
|
|||||||
@@ -34,5 +34,15 @@ export function chatSessionMessagesPath(sessionId: string): string {
|
|||||||
return `/api/client/v1/chat/sessions/${encodeURIComponent(sessionId)}/messages`;
|
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` 保持一致) */
|
/** 请求拦截器跳过附加 Bearer 的路径(与 `ApiPath` 保持一致) */
|
||||||
export const pathsWithoutBearerAuth: readonly string[] = [ApiPath.authRefresh, ApiPath.authLogout];
|
export const pathsWithoutBearerAuth: readonly string[] = [ApiPath.authRefresh, ApiPath.authLogout];
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ export interface StreamOptions {
|
|||||||
model?: string;
|
model?: string;
|
||||||
/** 与会话绑定流式补全时传入(若后端支持) */
|
/** 与会话绑定流式补全时传入(若后端支持) */
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
|
/** 是否启用联网搜索(OpenAPI: `enableWebSearch`) */
|
||||||
|
enableWebSearch?: boolean;
|
||||||
|
/** 是否启用深度思考(OpenAPI: `enableThinking`) */
|
||||||
|
enableThinking?: boolean;
|
||||||
onToken: (token: string) => void;
|
onToken: (token: string) => void;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
/** 超时后中止请求(与业务 `signal` 合并) */
|
/** 超时后中止请求(与业务 `signal` 合并) */
|
||||||
@@ -146,6 +150,8 @@ export async function streamQwenChat(options: StreamOptions): Promise<void> {
|
|||||||
messages: options.messages,
|
messages: options.messages,
|
||||||
...(options.model ? { model: options.model } : {}),
|
...(options.model ? { model: options.model } : {}),
|
||||||
...(options.sessionId ? { sessionId: options.sessionId } : {}),
|
...(options.sessionId ? { sessionId: options.sessionId } : {}),
|
||||||
|
enableWebSearch: !!options.enableWebSearch,
|
||||||
|
enableThinking: !!options.enableThinking,
|
||||||
}),
|
}),
|
||||||
signal: mergedSignal,
|
signal: mergedSignal,
|
||||||
openWhenHidden: true,
|
openWhenHidden: true,
|
||||||
|
|||||||
@@ -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 ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import remarkMath from "remark-math";
|
import remarkMath from "remark-math";
|
||||||
@@ -5,19 +8,68 @@ import rehypeKatex from "rehype-katex";
|
|||||||
import rehypeHighlight from "rehype-highlight";
|
import rehypeHighlight from "rehype-highlight";
|
||||||
import "katex/dist/katex.min.css";
|
import "katex/dist/katex.min.css";
|
||||||
import "highlight.js/styles/github.css";
|
import "highlight.js/styles/github.css";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
type StreamMessageProps = {
|
type StreamMessageProps = {
|
||||||
content: string;
|
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 (
|
||||||
|
<div className="my-2 overflow-hidden rounded-xl bg-neutral-50">
|
||||||
|
<div className="flex items-center justify-end px-2 py-1">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={copied ? <CheckOutlined /> : <CopyOutlined />}
|
||||||
|
className="text-neutral-600 hover:bg-black/5! hover:text-neutral-800!"
|
||||||
|
onClick={() => {
|
||||||
|
void onCopy();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copied ? "已复制" : "复制"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<pre className="m-0 overflow-x-auto p-3 text-[13px] text-neutral-800">{props.children}</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function StreamMessage(props: StreamMessageProps) {
|
export default function StreamMessage(props: StreamMessageProps) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-[var(--ds-border)] bg-neutral-50/80 px-4 py-2.5 text-[15px] leading-relaxed text-neutral-800">
|
<div className="rounded-2xl px-4 py-2.5 text-[15px] leading-relaxed text-neutral-800">
|
||||||
<div className="prose prose-sm max-w-none break-words prose-neutral prose-pre:my-2 prose-pre:rounded-lg prose-pre:border prose-pre:border-[var(--ds-border)] prose-pre:bg-neutral-950 prose-pre:p-3 prose-code:before:content-none prose-code:after:content-none">
|
<div className="prose prose-sm max-w-none break-words prose-neutral prose-code:before:content-none prose-code:after:content-none">
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[remarkGfm, remarkMath]}
|
remarkPlugins={[remarkGfm, remarkMath]}
|
||||||
rehypePlugins={[rehypeKatex, rehypeHighlight]}
|
rehypePlugins={[rehypeKatex, rehypeHighlight]}
|
||||||
components={{
|
components={{
|
||||||
|
pre: ({ children }) => <MarkdownPreBlock>{children}</MarkdownPreBlock>,
|
||||||
table: ({ children }) => (
|
table: ({ children }) => (
|
||||||
<div className="my-2 overflow-x-auto rounded-lg border border-[var(--ds-border)]">
|
<div className="my-2 overflow-x-auto rounded-lg border border-[var(--ds-border)]">
|
||||||
<table className="w-full border-collapse text-sm">{children}</table>
|
<table className="w-full border-collapse text-sm">{children}</table>
|
||||||
|
|||||||
@@ -1,10 +1,36 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
font-size: 14px;
|
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;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,11 +57,29 @@ body {
|
|||||||
:root {
|
:root {
|
||||||
--ds-bg-main: #ffffff;
|
--ds-bg-main: #ffffff;
|
||||||
--ds-bg-sider: #f7f7f8;
|
--ds-bg-sider: #f7f7f8;
|
||||||
--ds-border: #ececec;
|
--ds-border: #e9e9eb;
|
||||||
--ds-text-secondary: #8b8b8b;
|
--ds-text-secondary: #8a8f99;
|
||||||
--ds-user-bubble: #e8f3ff;
|
--ds-user-bubble: #e8f3ff;
|
||||||
--ds-user-border: #cce7ff;
|
--ds-user-border: #cce7ff;
|
||||||
--ds-active-item: #e3f2fd;
|
--ds-active-item: #eaf3ff;
|
||||||
--ds-send: #4dabf7;
|
--ds-accent: #4a90e2;
|
||||||
--ds-send-hover: #339af0;
|
--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;
|
||||||
}
|
}
|
||||||
|
|||||||
43
src/main.tsx
43
src/main.tsx
@@ -1,5 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { ConfigProvider, theme } from "antd";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import "antd/dist/reset.css";
|
import "antd/dist/reset.css";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
@@ -7,8 +8,50 @@ import App from "./App";
|
|||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
<ConfigProvider
|
||||||
|
theme={{
|
||||||
|
algorithm: theme.defaultAlgorithm,
|
||||||
|
token: {
|
||||||
|
colorPrimary: "#4A90E2",
|
||||||
|
colorBgBase: "#F7F7F8",
|
||||||
|
colorBgContainer: "#FFFFFF",
|
||||||
|
colorBorder: "#E9E9EB",
|
||||||
|
colorText: "#1F2329",
|
||||||
|
colorTextSecondary: "#8A8F99",
|
||||||
|
borderRadius: 12,
|
||||||
|
borderRadiusLG: 18,
|
||||||
|
controlHeight: 36,
|
||||||
|
fontSize: 14,
|
||||||
|
boxShadowSecondary:
|
||||||
|
"0 18px 42px rgba(15, 23, 42, 0.12), 0 2px 8px rgba(15, 23, 42, 0.06)",
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
Button: {
|
||||||
|
borderRadius: 10,
|
||||||
|
controlHeight: 36,
|
||||||
|
},
|
||||||
|
Input: {
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
Select: {
|
||||||
|
borderRadius: 10,
|
||||||
|
},
|
||||||
|
Dropdown: {
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
Modal: {
|
||||||
|
contentBg: "#FFFFFF",
|
||||||
|
headerBg: "transparent",
|
||||||
|
footerBg: "transparent",
|
||||||
|
titleColor: "#1F2329",
|
||||||
|
titleFontSize: 16,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<App />
|
<App />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
</ConfigProvider>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,18 +2,23 @@ import type { CSSProperties } from "react";
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||||
import {
|
import {
|
||||||
ArrowUpOutlined,
|
ArrowUpOutlined,
|
||||||
|
CopyOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
EditOutlined,
|
||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
MenuFoldOutlined,
|
MenuFoldOutlined,
|
||||||
MenuUnfoldOutlined,
|
MenuUnfoldOutlined,
|
||||||
MessageOutlined,
|
|
||||||
MobileOutlined,
|
MobileOutlined,
|
||||||
MoreOutlined,
|
MoreOutlined,
|
||||||
PaperClipOutlined,
|
PaperClipOutlined,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
QuestionCircleOutlined,
|
QuestionCircleOutlined,
|
||||||
|
RedoOutlined,
|
||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
|
ShareAltOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
ThunderboltOutlined,
|
ThunderboltOutlined,
|
||||||
|
VerticalAlignTopOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import {
|
import {
|
||||||
@@ -24,7 +29,9 @@ import {
|
|||||||
Dropdown,
|
Dropdown,
|
||||||
Input,
|
Input,
|
||||||
Layout,
|
Layout,
|
||||||
|
Modal,
|
||||||
Select,
|
Select,
|
||||||
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
message,
|
message,
|
||||||
} from "antd";
|
} 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 { ACCESS_TOKEN_KEY, SessionExpiredError, USER_KEY, logout } from "../api/auth";
|
||||||
import {
|
import {
|
||||||
createChatSession,
|
createChatSession,
|
||||||
|
deleteChatSession,
|
||||||
listChatSessionMessages,
|
listChatSessionMessages,
|
||||||
listChatSessions,
|
listChatSessions,
|
||||||
normalizeSessionMessages,
|
normalizeSessionMessages,
|
||||||
sessionDisplayTitle,
|
sessionDisplayTitle,
|
||||||
sessionStableId,
|
sessionStableId,
|
||||||
|
updateChatSessionTitle,
|
||||||
type ChatSession,
|
type ChatSession,
|
||||||
type ChatTurnForUi,
|
type ChatTurnForUi,
|
||||||
} from "../api/chatSessions";
|
} from "../api/chatSessions";
|
||||||
@@ -49,22 +58,18 @@ const MOBILE_WIDTH = 750;
|
|||||||
|
|
||||||
type UiMessage = ChatTurnForUi;
|
type UiMessage = ChatTurnForUi;
|
||||||
|
|
||||||
|
type StoredUserProfile = {
|
||||||
|
nickname: string;
|
||||||
|
avatarUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
function parseStoredUserProfileJson(raw: string | null): { displayName: string; avatar?: string } {
|
function parseStoredUserProfileJson(raw: string | null): { displayName: string; avatar?: string } {
|
||||||
try {
|
try {
|
||||||
if (!raw) return { displayName: "用户" };
|
if (!raw) return { displayName: "用户" };
|
||||||
const u = JSON.parse(raw) as Record<string, unknown>;
|
const user = JSON.parse(raw) as StoredUserProfile;
|
||||||
const nickname = typeof u.nickname === "string" && u.nickname.trim() ? u.nickname.trim() : "";
|
const displayName = user.nickname.trim() || "用户";
|
||||||
const displayName =
|
const avatar = user.avatarUrl || undefined;
|
||||||
nickname ||
|
return { displayName, avatar };
|
||||||
(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 };
|
|
||||||
} catch {
|
} catch {
|
||||||
return { displayName: "用户" };
|
return { displayName: "用户" };
|
||||||
}
|
}
|
||||||
@@ -94,35 +99,70 @@ const INITIAL_MESSAGES: UiMessage[] = [
|
|||||||
const STREAM_TIMEOUT_MS = 90000;
|
const STREAM_TIMEOUT_MS = 90000;
|
||||||
const MAX_STREAM_RETRY = 1;
|
const MAX_STREAM_RETRY = 1;
|
||||||
const CONTEXT_WINDOW_SIZE = 8;
|
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 一致 */
|
/** 展示名与请求 model 字段;需与后端实际支持的模型 id 一致 */
|
||||||
const DEFAULT_QWEN_MODEL = "qwen3.5-flash";
|
const DEFAULT_QWEN_MODEL = "qwen3.6-plus";
|
||||||
const QWEN_MODEL_OPTIONS: { value: string; label: string }[] = [
|
const QWEN_MODEL_OPTIONS: { value: string; label: string }[] = [
|
||||||
{ value: "qwen3-max", label: "Qwen3-Max" },
|
{ value: "qwen3-max", label: "Qwen3-Max" },
|
||||||
{ value: "qwen3.6-plus", label: "Qwen3.6-Plus" },
|
{ value: DEFAULT_QWEN_MODEL, label: "Qwen3.6-Plus" },
|
||||||
{ value: DEFAULT_QWEN_MODEL, label: "Qwen3.5-Flash" },
|
{ 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() {
|
export default function HomePage() {
|
||||||
|
const [modal, modalContextHolder] = Modal.useModal();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { sessionId: routeSessionId } = useParams<{ sessionId?: string }>();
|
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 [isMobile, setIsMobile] = useState(false);
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
const [inputValue, setInputValue] = useState("");
|
const [inputValue, setInputValue] = useState("");
|
||||||
const [messages, setMessages] = useState<UiMessage[]>(INITIAL_MESSAGES);
|
const [messages, setMessages] = useState<UiMessage[]>(INITIAL_MESSAGES);
|
||||||
const [isSending, setIsSending] = useState(false);
|
const [isSending, setIsSending] = useState(false);
|
||||||
const [deepThink, setDeepThink] = useState(false);
|
const [deepThink, setDeepThink] = useState(() => {
|
||||||
const [smartSearch, setSmartSearch] = useState(false);
|
try {
|
||||||
const [selectedModel, setSelectedModel] = useState(DEFAULT_QWEN_MODEL);
|
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<HTMLDivElement | null>(null);
|
const messageListRef = useRef<HTMLDivElement | null>(null);
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
const [sessions, setSessions] = useState<ChatSession[]>([]);
|
const [sessions, setSessions] = useState<ChatSession[]>([]);
|
||||||
const [sessionsLoading, setSessionsLoading] = useState(false);
|
const [sessionsLoading, setSessionsLoading] = useState(false);
|
||||||
const [sessionMutationBusy, setSessionMutationBusy] = useState(false);
|
const [sessionMutationBusy, setSessionMutationBusy] = useState(false);
|
||||||
|
const [sessionActionBusyId, setSessionActionBusyId] = useState<string | null>(null);
|
||||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
||||||
|
const activeSessionIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const userJsonSnapshot = useSyncExternalStore(
|
const userJsonSnapshot = useSyncExternalStore(
|
||||||
subscribeUserProfile,
|
subscribeUserProfile,
|
||||||
@@ -134,6 +174,22 @@ export default function HomePage() {
|
|||||||
[userJsonSnapshot],
|
[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 = {
|
const userMenuItemStyle: CSSProperties = {
|
||||||
height: "auto",
|
height: "auto",
|
||||||
@@ -189,7 +245,6 @@ export default function HomePage() {
|
|||||||
window.innerWidth,
|
window.innerWidth,
|
||||||
document.documentElement.clientWidth || window.innerWidth,
|
document.documentElement.clientWidth || window.innerWidth,
|
||||||
);
|
);
|
||||||
setViewportWidth(w);
|
|
||||||
const mobile = w < MOBILE_WIDTH;
|
const mobile = w < MOBILE_WIDTH;
|
||||||
setIsMobile(mobile);
|
setIsMobile(mobile);
|
||||||
if (!mobile) setMobileSidebarOpen(false);
|
if (!mobile) setMobileSidebarOpen(false);
|
||||||
@@ -229,6 +284,10 @@ export default function HomePage() {
|
|||||||
void refreshSessionList();
|
void refreshSessionList();
|
||||||
}, [refreshSessionList]);
|
}, [refreshSessionList]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开指定会话并加载历史消息。
|
||||||
|
* 默认会把当前会话 id 同步到路由;当由路由参数驱动时可关闭 URL 更新,避免重复跳转。
|
||||||
|
*/
|
||||||
const openSession = useCallback(
|
const openSession = useCallback(
|
||||||
async (sessionId: string, options?: { updateUrl?: boolean }) => {
|
async (sessionId: string, options?: { updateUrl?: boolean }) => {
|
||||||
if (options?.updateUrl ?? true) {
|
if (options?.updateUrl ?? true) {
|
||||||
@@ -253,32 +312,140 @@ export default function HomePage() {
|
|||||||
setMessages(INITIAL_MESSAGES.map((m) => ({ ...m })));
|
setMessages(INITIAL_MESSAGES.map((m) => ({ ...m })));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 路由已与当前会话一致时,不重复加载,避免覆盖流式中的本地消息状态。
|
||||||
|
if (routeSessionId === activeSessionIdRef.current) return;
|
||||||
void openSession(routeSessionId, { updateUrl: false });
|
void openSession(routeSessionId, { updateUrl: false });
|
||||||
}, [openSession, routeSessionId]);
|
}, [openSession, routeSessionId]);
|
||||||
|
|
||||||
const handleNewSession = async () => {
|
const handleNewSession = async () => {
|
||||||
if (sessionMutationBusy) return;
|
setActiveSessionId(null);
|
||||||
setSessionMutationBusy(true);
|
|
||||||
try {
|
|
||||||
const row = await createChatSession();
|
|
||||||
await refreshSessionList();
|
|
||||||
setActiveSessionId(row.id);
|
|
||||||
setMessages(INITIAL_MESSAGES.map((m) => ({ ...m })));
|
setMessages(INITIAL_MESSAGES.map((m) => ({ ...m })));
|
||||||
navigate(`/s/${encodeURIComponent(row.id)}`);
|
navigate("/");
|
||||||
} catch (e) {
|
|
||||||
const text = e instanceof Error ? e.message : "新建会话失败";
|
|
||||||
message.error(text);
|
|
||||||
} finally {
|
|
||||||
setSessionMutationBusy(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRenameSession = useCallback(
|
||||||
|
(session: ChatSession) => {
|
||||||
|
const sid = sessionStableId(session);
|
||||||
|
if (!sid || sessionActionBusyId) return;
|
||||||
|
let nextTitle = session.title;
|
||||||
|
modal.confirm({
|
||||||
|
centered: true,
|
||||||
|
title: "重命名会话",
|
||||||
|
content: (
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
maxLength={200}
|
||||||
|
defaultValue={session.title}
|
||||||
|
placeholder="请输入会话标题"
|
||||||
|
onChange={(event) => {
|
||||||
|
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 sendMessage = async () => {
|
||||||
const trimmed = inputValue.trim();
|
const trimmed = inputValue.trim();
|
||||||
if (!trimmed || isSending) return;
|
if (!trimmed || isSending) return;
|
||||||
if (!activeSessionId) {
|
let targetSessionId = activeSessionId;
|
||||||
message.warning("请先新建会话");
|
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;
|
return;
|
||||||
|
} finally {
|
||||||
|
setSessionMutationBusy(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setInputValue("");
|
setInputValue("");
|
||||||
@@ -330,7 +497,9 @@ export default function HomePage() {
|
|||||||
await streamQwenChat({
|
await streamQwenChat({
|
||||||
messages: retryMessages,
|
messages: retryMessages,
|
||||||
model: selectedModel,
|
model: selectedModel,
|
||||||
sessionId: activeSessionId ?? undefined,
|
sessionId: targetSessionId ?? undefined,
|
||||||
|
enableWebSearch: smartSearch,
|
||||||
|
enableThinking: deepThink,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
timeoutMs: STREAM_TIMEOUT_MS,
|
timeoutMs: STREAM_TIMEOUT_MS,
|
||||||
onToken: (token) => {
|
onToken: (token) => {
|
||||||
@@ -429,21 +598,52 @@ export default function HomePage() {
|
|||||||
if (!sid) return null;
|
if (!sid) return null;
|
||||||
const title = sessionDisplayTitle(s);
|
const title = sessionDisplayTitle(s);
|
||||||
const active = sid === activeSessionId;
|
const active = sid === activeSessionId;
|
||||||
|
const itemMenu: MenuProps = {
|
||||||
|
items: [
|
||||||
|
{ key: "rename", icon: <EditOutlined />, label: "重命名" },
|
||||||
|
{ key: "pin", icon: <VerticalAlignTopOutlined />, label: "置顶" },
|
||||||
|
{ key: "delete", icon: <DeleteOutlined />, 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 (
|
return (
|
||||||
<button
|
<div
|
||||||
key={sid}
|
key={sid}
|
||||||
|
className="mb-1 flex w-full items-center gap-1 rounded-xl pr-1 text-[14px] text-neutral-700 transition-colors hover:bg-black/5"
|
||||||
|
style={{
|
||||||
|
background: active ? "var(--ds-active-item)" : undefined,
|
||||||
|
color: active ? "var(--ds-accent)" : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void openSession(sid);
|
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"
|
className="flex min-h-12 min-w-0 flex-1 items-center rounded-xl px-3 py-3 text-left"
|
||||||
style={{
|
|
||||||
background: active ? "var(--ds-active-item)" : undefined,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<MessageOutlined className="shrink-0 text-neutral-400" />
|
|
||||||
{(!collapsed || isMobile) && <span className="truncate">{title}</span>}
|
{(!collapsed || isMobile) && <span className="truncate">{title}</span>}
|
||||||
</button>
|
</button>
|
||||||
|
{(!collapsed || isMobile) && (
|
||||||
|
<Dropdown menu={itemMenu} trigger={["click"]} placement="bottomRight">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<MoreOutlined />}
|
||||||
|
loading={sessionActionBusyId === sid}
|
||||||
|
className="text-neutral-500 hover:bg-black/5!"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Dropdown>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -487,6 +687,7 @@ export default function HomePage() {
|
|||||||
className="chat-one-shell flex h-dvh max-h-dvh overflow-hidden"
|
className="chat-one-shell flex h-dvh max-h-dvh overflow-hidden"
|
||||||
style={{ background: "var(--ds-bg-main)" }}
|
style={{ background: "var(--ds-bg-main)" }}
|
||||||
>
|
>
|
||||||
|
{modalContextHolder}
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<Sider
|
<Sider
|
||||||
trigger={null}
|
trigger={null}
|
||||||
@@ -574,7 +775,7 @@ export default function HomePage() {
|
|||||||
ref={messageListRef}
|
ref={messageListRef}
|
||||||
className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-4 pt-6 pb-0 md:px-8 md:pt-6 md:pb-0"
|
className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-4 pt-6 pb-0 md:px-8 md:pt-6 md:pb-0"
|
||||||
>
|
>
|
||||||
<div className="mx-auto flex max-w-3xl flex-col gap-5">
|
<div className="mx-auto flex max-w-4xl flex-col gap-5">
|
||||||
{messages.map((item) => (
|
{messages.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
@@ -582,24 +783,22 @@ export default function HomePage() {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
item.role === "user"
|
item.role === "user" ? "group max-w-full px-4 " : "group w-full space-y-2"
|
||||||
? "max-w-[85%] rounded-2xl border px-4 py-2.5 text-[15px] leading-relaxed text-neutral-800"
|
|
||||||
: "max-w-[85%] space-y-2"
|
|
||||||
}
|
|
||||||
style={
|
|
||||||
item.role === "user"
|
|
||||||
? {
|
|
||||||
background: "var(--ds-user-bubble)",
|
|
||||||
borderColor: "var(--ds-user-border)",
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{item.role === "user" && (
|
||||||
|
<div
|
||||||
|
className="rounded-2xl px-4 py-2.5 text-[15px] leading-relaxed text-neutral-800"
|
||||||
|
style={{ background: "var(--ds-user-bubble)" }}
|
||||||
|
>
|
||||||
|
{item.content}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{item.role === "assistant" && item.thinking && (
|
{item.role === "assistant" && item.thinking && (
|
||||||
<Collapse
|
<Collapse
|
||||||
bordered={false}
|
bordered={false}
|
||||||
size="small"
|
size="small"
|
||||||
className="!mb-2 !rounded-xl !bg-neutral-50 !border !border-[var(--ds-border)]"
|
className="!mb-2 !rounded-xl !bg-transparent"
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
key: "think",
|
key: "think",
|
||||||
@@ -622,27 +821,92 @@ export default function HomePage() {
|
|||||||
(item.content ? (
|
(item.content ? (
|
||||||
<StreamMessage content={item.content} />
|
<StreamMessage content={item.content} />
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-2xl border border-[var(--ds-border)] bg-neutral-50/80 px-4 py-3 text-[14px] text-neutral-500">
|
<div className="rounded-2xl px-4 py-3 text-[14px] text-neutral-500">
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span>正在回复</span>
|
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-neutral-400 [animation-delay:-0.3s]" />
|
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-neutral-400 [animation-delay:-0.3s]" />
|
||||||
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-neutral-400 [animation-delay:-0.15s]" />
|
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-neutral-400 [animation-delay:-0.15s]" />
|
||||||
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-neutral-400" />
|
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-neutral-400" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
{item.role === "user" && item.content}
|
{item.role === "assistant" && item.content && (
|
||||||
|
<>
|
||||||
|
<p className="mt-3 px-4 text-[12px] text-neutral-500 italic">
|
||||||
|
本回答由 AI 生成,内容仅供参考,请仔细甄别。
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex h-8 items-center gap-1 px-4 text-neutral-500 opacity-0 pointer-events-none transition-opacity group-hover:opacity-100 group-hover:pointer-events-auto">
|
||||||
|
<Tooltip title="复制">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
className="!px-1 text-neutral-500 hover:bg-black/5!"
|
||||||
|
aria-label="复制回复"
|
||||||
|
onClick={() => {
|
||||||
|
void handleCopyAssistantMessage(item.content);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="重新生成">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<RedoOutlined />}
|
||||||
|
className="!px-1 text-neutral-500 hover:bg-black/5!"
|
||||||
|
aria-label="重新生成"
|
||||||
|
onClick={() => {
|
||||||
|
message.info("重新生成功能开发中");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="分享">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<ShareAltOutlined />}
|
||||||
|
className="!px-1 text-neutral-500 hover:bg-black/5!"
|
||||||
|
aria-label="分享"
|
||||||
|
onClick={() => {
|
||||||
|
message.info("分享功能开发中");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{item.role === "user" && (
|
||||||
|
<div className="mt-2 flex h-7 items-center justify-end gap-1 text-neutral-500 opacity-0 pointer-events-none transition-opacity group-hover:opacity-100 group-hover:pointer-events-auto">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
className="text-neutral-500 hover:bg-black/5!"
|
||||||
|
aria-label="复制消息"
|
||||||
|
onClick={() => {
|
||||||
|
void handleCopyUserMessage(item.content);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
className="text-neutral-500 hover:bg-black/5!"
|
||||||
|
aria-label="编辑消息"
|
||||||
|
onClick={() => {
|
||||||
|
message.info("编辑功能开发中");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shrink-0 bg-[var(--ds-bg-main)] px-4 pb-4 pt-0 md:px-8 md:pb-4 md:pt-0">
|
<div className="shrink-0 bg-[var(--ds-bg-main)] px-4 pt-0 md:px-8 md:pt-0">
|
||||||
<div className="mx-auto max-w-3xl">
|
<div className="mx-auto max-w-4xl">
|
||||||
<div className="rounded-2xl border border-[var(--ds-border)] bg-neutral-50/50 p-3 shadow-sm">
|
<div className="rounded-3xl border border-[var(--ds-border)] bg-neutral-50/50 p-3 shadow-sm">
|
||||||
<Input.TextArea
|
<Input.TextArea
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={(e) => setInputValue(e.target.value)}
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
@@ -653,7 +917,7 @@ export default function HomePage() {
|
|||||||
}}
|
}}
|
||||||
placeholder="给 ChatOne 发送消息"
|
placeholder="给 ChatOne 发送消息"
|
||||||
variant="borderless"
|
variant="borderless"
|
||||||
autoSize={{ minRows: 1, maxRows: 6 }}
|
autoSize={{ minRows: 2, maxRows: 8 }}
|
||||||
className="!px-1 !text-[15px] placeholder:text-neutral-400"
|
className="!px-1 !text-[15px] placeholder:text-neutral-400"
|
||||||
/>
|
/>
|
||||||
<div className="mt-2 flex flex-wrap items-center justify-between gap-2">
|
<div className="mt-2 flex flex-wrap items-center justify-between gap-2">
|
||||||
@@ -706,11 +970,9 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{import.meta.env.DEV && (
|
<p className="m-0 py-2 text-center text-[11px] text-neutral-500">
|
||||||
<Typography.Text type="secondary" className="mt-2 block text-center text-[11px]">
|
内容由 AI 生成,请注意核实
|
||||||
W:{viewportWidth}px · {isMobile ? "mobile" : "desktop"}
|
</p>
|
||||||
</Typography.Text>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user