将首页从 mock 数据切换为真实流式会话,统一 API 网络层并支持 SSE 增量解析、超时与断线重试,提升前端在跨域代理和网络抖动场景下的可用性。 Made-with: Cursor
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ArrowUpOutlined,
|
||||
MenuFoldOutlined,
|
||||
@@ -12,84 +12,42 @@ import {
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Button, Collapse, Drawer, Input, Layout, Typography } from "antd";
|
||||
import { isAbortLikeError, streamQwenChat, type ChatMessagePayload } from "../api/qwenChat";
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
const MOBILE_WIDTH = 750;
|
||||
|
||||
const mockMessages = [
|
||||
{ role: "assistant" as const, content: "你好,我是 ChatOne 助手,有什么可以帮你?" },
|
||||
{ role: "user" as const, content: "请帮我总结今天的工作内容。" },
|
||||
type UiMessage = {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
thinking?: string;
|
||||
};
|
||||
|
||||
const INITIAL_MESSAGES: UiMessage[] = [
|
||||
{
|
||||
role: "assistant" as const,
|
||||
content: "当然可以,请先告诉我今天完成了哪些任务。",
|
||||
thinking: "正在理解用户意图并检索相关知识…",
|
||||
},
|
||||
{
|
||||
role: "user" as const,
|
||||
content:
|
||||
"上午开了站会,修了 CI 里 yarn 缓存问题;下午写了首页聊天区的布局,把输入框固定在底部,消息区单独滚动。",
|
||||
},
|
||||
{
|
||||
role: "assistant" as const,
|
||||
content:
|
||||
"听起来节奏很紧凑。建议你在总结里分成「会议与协作」「工程交付」「阻塞与风险」三块,每块用 2~3 条要点,领导扫一眼就能抓住重点。",
|
||||
},
|
||||
{ role: "user" as const, content: "阻塞怎么写比较合适?" },
|
||||
{
|
||||
role: "assistant" as const,
|
||||
content:
|
||||
"阻塞建议写清:现象、影响范围、你已尝试的排查、需要的支持(人或权限)、预计解除时间。若没有阻塞,可以明确写「无」或「低风险项:xxx 已可控」。",
|
||||
},
|
||||
{
|
||||
role: "user" as const,
|
||||
content: "再帮我列一下本周前端工程化还可以做的小事,5 条以内。",
|
||||
},
|
||||
{
|
||||
role: "assistant" as const,
|
||||
content:
|
||||
"1)把公共 CI 工作流用 tag 固定版本,避免 main 漂移。\n2)给构建产物目录、构建命令做成入参,方便多包仓库复用。\n3)在 PR 上跑 lint + format:check,合并前强制通过。\n4)给关键页面加最小 smoke(构建成功 + 路由可访问)。\n5)整理一份「本地与 CI 一致」的 Node/Yarn 版本说明。",
|
||||
},
|
||||
{ role: "user" as const, content: "第 3 条我们已经在做了,换一条。" },
|
||||
{
|
||||
role: "assistant" as const,
|
||||
content:
|
||||
"可以改成:为生产构建打开 source map 上传(或错误监控接入),这样线上异常栈可读性会好很多;若担心体积,可只在 CI 产物里保留、发布前剥离。",
|
||||
},
|
||||
{
|
||||
role: "user" as const,
|
||||
content: "聊天页面我想多放点假数据,看下滚动条是不是只在消息区域出现。",
|
||||
},
|
||||
{
|
||||
role: "assistant" as const,
|
||||
content:
|
||||
"可以。做法是:最外层布局用视口高度约束(例如 h-dvh + overflow-hidden),中间消息列表容器用 flex-1 + min-h-0 + overflow-y-auto,输入区用 shrink-0。Ant Design 的 Layout 默认 min-height 较大时,记得用一层 class 把 .ant-layout-content 的 min-height 压成 0,否则内部滚动出不来。",
|
||||
thinking: "回忆 flex 子项 min-height:auto 的常见坑,并对应到 Ant Layout…",
|
||||
},
|
||||
{ role: "user" as const, content: "移动端键盘弹起时会不会把输入框顶没?" },
|
||||
{
|
||||
role: "assistant" as const,
|
||||
content:
|
||||
"有可能。可以后续视情况改用 visualViewport 或 dvh 微调;若仍有问题,再考虑把输入条做成 position: sticky/fixed 并给消息区预留 padding-bottom。当前先用 dvh + 分区滚动验证桌面与常见移动浏览器。",
|
||||
},
|
||||
{
|
||||
role: "user" as const,
|
||||
content: "好的,我先用这批假消息滚两下看看效果。",
|
||||
},
|
||||
{
|
||||
role: "assistant" as const,
|
||||
content:
|
||||
"没问题。若滚动条出现在整页而不是消息列,优先检查父链上是否少了 min-h-0 或是否仍用 min-height 把内容撑开了。需要的话把 DOM 结构发我,我可以帮你对一下。",
|
||||
id: "init-assistant",
|
||||
role: "assistant",
|
||||
content: "你好,我是 ChatOne 助手,有什么可以帮你?",
|
||||
},
|
||||
];
|
||||
|
||||
const STREAM_TIMEOUT_MS = 90000;
|
||||
const MAX_STREAM_RETRY = 1;
|
||||
const CONTEXT_WINDOW_SIZE = 8;
|
||||
|
||||
export default function HomePage() {
|
||||
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<UiMessage[]>(INITIAL_MESSAGES);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [deepThink, setDeepThink] = useState(false);
|
||||
const [smartSearch, setSmartSearch] = useState(false);
|
||||
const messageListRef = useRef<HTMLDivElement | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const historyGroups = useMemo(
|
||||
() => [
|
||||
@@ -119,6 +77,119 @@ export default function HomePage() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = messageListRef.current;
|
||||
if (!el) return;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}, [messages]);
|
||||
|
||||
const sendMessage = async () => {
|
||||
const trimmed = inputValue.trim();
|
||||
if (!trimmed || isSending) return;
|
||||
|
||||
setInputValue("");
|
||||
setIsSending(true);
|
||||
|
||||
const userMessage: UiMessage = {
|
||||
id: `user-${Date.now()}`,
|
||||
role: "user",
|
||||
content: trimmed,
|
||||
};
|
||||
const assistantMessageId = `assistant-${Date.now() + 1}`;
|
||||
const assistantPlaceholder: UiMessage = {
|
||||
id: assistantMessageId,
|
||||
role: "assistant",
|
||||
content: "",
|
||||
thinking: deepThink ? "正在思考并生成答案…" : undefined,
|
||||
};
|
||||
|
||||
const nextMessages = [...messages, userMessage, assistantPlaceholder];
|
||||
setMessages(nextMessages);
|
||||
|
||||
const payloadMessages: ChatMessagePayload[] = nextMessages.map((item) => ({
|
||||
role: item.role,
|
||||
content: item.content,
|
||||
}));
|
||||
|
||||
let assistantText = "";
|
||||
let lastError: unknown = null;
|
||||
|
||||
for (let attempt = 0; attempt <= MAX_STREAM_RETRY; attempt += 1) {
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
let receivedToken = false;
|
||||
|
||||
const retryMessages =
|
||||
attempt === 0
|
||||
? payloadMessages
|
||||
: ([
|
||||
...nextMessages
|
||||
.slice(-CONTEXT_WINDOW_SIZE)
|
||||
.map((item): ChatMessagePayload => ({ role: item.role, content: item.content })),
|
||||
{
|
||||
role: "user" as const,
|
||||
content: `网络抖动导致输出中断。请在不重复已输出内容的前提下继续回答。已输出尾部:${assistantText.slice(-80)}`,
|
||||
},
|
||||
] satisfies ChatMessagePayload[]);
|
||||
|
||||
try {
|
||||
await streamQwenChat({
|
||||
messages: retryMessages,
|
||||
signal: controller.signal,
|
||||
timeoutMs: STREAM_TIMEOUT_MS,
|
||||
onToken: (token) => {
|
||||
receivedToken = true;
|
||||
assistantText += token;
|
||||
setMessages((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === assistantMessageId
|
||||
? { ...item, content: `${item.content}${token}` }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
lastError = null;
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
const isAbortLike = isAbortLikeError(error);
|
||||
const canRetry = attempt < MAX_STREAM_RETRY;
|
||||
if (!canRetry) break;
|
||||
// 网络抖动或超时时自动重连;若已收到部分 token 也保留已输出文本。
|
||||
if (isAbortLike || !receivedToken) {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 300));
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastError) {
|
||||
const message = lastError instanceof Error ? lastError.message : "请求失败,请稍后重试";
|
||||
setMessages((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === assistantMessageId
|
||||
? {
|
||||
...item,
|
||||
content: item.content || `接口调用失败:${message}`,
|
||||
thinking: undefined,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
setIsSending(false);
|
||||
setMessages((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === assistantMessageId ? { ...item, thinking: undefined } : item,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const sidebarContent = (
|
||||
<div className="flex h-full flex-col bg-[var(--ds-bg-sider)]">
|
||||
<div className="shrink-0 px-3 pt-3 pb-2">
|
||||
@@ -257,11 +328,14 @@ export default function HomePage() {
|
||||
|
||||
<Content className="flex min-h-0 flex-1 flex-col overflow-hidden !bg-[var(--ds-bg-main)]">
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<div 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
|
||||
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"
|
||||
>
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-5">
|
||||
{mockMessages.map((item, index) => (
|
||||
{messages.map((item) => (
|
||||
<div
|
||||
key={index}
|
||||
key={item.id}
|
||||
className={`flex ${item.role === "user" ? "justify-end" : "justify-start"}`}
|
||||
>
|
||||
<div
|
||||
@@ -320,6 +394,11 @@ export default function HomePage() {
|
||||
<Input.TextArea
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onPressEnter={(e) => {
|
||||
if (e.shiftKey) return;
|
||||
e.preventDefault();
|
||||
void sendMessage();
|
||||
}}
|
||||
placeholder="给 ChatOne 发送消息"
|
||||
variant="borderless"
|
||||
autoSize={{ minRows: 1, maxRows: 6 }}
|
||||
@@ -362,6 +441,11 @@ export default function HomePage() {
|
||||
type="primary"
|
||||
shape="circle"
|
||||
icon={<ArrowUpOutlined />}
|
||||
loading={isSending}
|
||||
disabled={isSending || !inputValue.trim()}
|
||||
onClick={() => {
|
||||
void sendMessage();
|
||||
}}
|
||||
className="!flex !h-9 !w-9 !items-center !justify-center !border-0 !shadow-none"
|
||||
style={{
|
||||
background: "var(--ds-send)",
|
||||
|
||||
Reference in New Issue
Block a user