diff --git a/package.json b/package.json index b9b0d52..0cdc871 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,9 @@ "*.{css,json,md,html,yml,yaml,js,mjs,cjs}": "prettier --write" }, "dependencies": { + "@microsoft/fetch-event-source": "^2.0.1", "antd": "^6.3.5", + "axios": "^1.15.0", "react": "^19.2.4", "react-dom": "^19.2.4", "react-router-dom": "^7.13.2" diff --git a/src/api/http.ts b/src/api/http.ts new file mode 100644 index 0000000..8d231ce --- /dev/null +++ b/src/api/http.ts @@ -0,0 +1,64 @@ +import axios, { AxiosError } from "axios"; + +// Dev 默认走 Vite 同源代理,避免浏览器跨域。 +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? ""; + +type JsonBody = Record; + +export const httpClient = axios.create({ + baseURL: API_BASE_URL, + timeout: 30000, +}); + +// 统一把 axios 错误转为可读 Error,避免上层感知库细节。 +function toRequestError(error: unknown, fallback: string): Error { + if (axios.isAxiosError(error)) { + const axiosError = error as AxiosError; + const responseData = axiosError.response?.data; + if (typeof responseData === "string" && responseData) { + return new Error(responseData); + } + if (responseData && typeof responseData === "object" && "message" in responseData) { + const message = (responseData as Record).message; + if (typeof message === "string" && message) return new Error(message); + } + return new Error(axiosError.message || fallback); + } + return error instanceof Error ? error : new Error(fallback); +} + +export async function postJson( + path: string, + body: JsonBody, + signal?: AbortSignal, +): Promise { + try { + const response = await httpClient.post(path, body, { signal }); + return response.data; + } catch (error) { + throw toRequestError(error, "Request failed"); + } +} + +export async function postStream( + path: string, + body: JsonBody, + signal?: AbortSignal, +): Promise { + // 流式响应仍使用原生 fetch,便于直接读取 ReadableStream。 + const response = await fetch(`${API_BASE_URL}${path}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + signal, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || `Stream request failed with status ${response.status}`); + } + + return response; +} diff --git a/src/api/qwenChat.ts b/src/api/qwenChat.ts new file mode 100644 index 0000000..2bf0675 --- /dev/null +++ b/src/api/qwenChat.ts @@ -0,0 +1,119 @@ +import { fetchEventSource } from "@microsoft/fetch-event-source"; + +export type ChatRole = "user" | "assistant" | "system"; + +export interface ChatMessagePayload { + role: ChatRole; + content: string; +} + +interface StreamOptions { + messages: ChatMessagePayload[]; + onToken: (token: string) => void; + signal?: AbortSignal; + timeoutMs?: number; +} + +// 兼容不同后端返回结构,提取可渲染的增量文本。 +function pickTokenFromJson(payload: Record): string { + const directDelta = payload.delta; + if (typeof directDelta === "string") return directDelta; + + const directContent = payload.content; + if (typeof directContent === "string") return directContent; + + const text = payload.text; + if (typeof text === "string") return text; + + const choices = payload.choices; + if (Array.isArray(choices) && choices.length > 0) { + const first = choices[0] as Record; + const delta = first.delta as Record | undefined; + if (delta && typeof delta.content === "string") { + return delta.content; + } + const message = first.message as Record | undefined; + if (message && typeof message.content === "string") { + return message.content; + } + } + + return ""; +} + +// Dev 默认走 Vite 同源代理,避免浏览器跨域。 +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? ""; + +export function isAbortLikeError(error: unknown): boolean { + if (!error) return false; + if (error instanceof DOMException && error.name === "AbortError") return true; + if (error instanceof Error) { + const message = error.message.toLowerCase(); + return message.includes("aborted") || message.includes("aborterror"); + } + return false; +} + +function createTimeoutAbortSignal(timeoutMs: number): AbortSignal { + const controller = new AbortController(); + window.setTimeout( + () => controller.abort(new DOMException("Request timeout", "AbortError")), + timeoutMs, + ); + return controller.signal; +} + +function mergeAbortSignals(signals: Array): AbortSignal { + const controller = new AbortController(); + const onAbort = (event: Event) => { + const source = event.target as AbortSignal; + if (!controller.signal.aborted) controller.abort(source.reason); + }; + for (const signal of signals) { + if (!signal) continue; + if (signal.aborted) { + controller.abort(signal.reason); + break; + } + signal.addEventListener("abort", onAbort, { once: true }); + } + return controller.signal; +} + +export async function streamQwenChat(options: StreamOptions): Promise { + const timeoutSignal = createTimeoutAbortSignal(options.timeoutMs ?? 90000); + const mergedSignal = mergeAbortSignals([options.signal, timeoutSignal]); + + await fetchEventSource(`${API_BASE_URL}/api/qwen/chat/stream`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ messages: options.messages }), + signal: mergedSignal, + openWhenHidden: true, + async onopen(response) { + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || `Stream request failed with status ${response.status}`); + } + }, + onmessage(event) { + // 标准 SSE 结束标记,直接忽略。 + const raw = event.data?.trim(); + if (!raw || raw === "[DONE]") return; + + try { + const parsed = JSON.parse(raw) as Record; + const token = pickTokenFromJson(parsed); + if (token) options.onToken(token); + } catch { + // 非 JSON 片段兜底按纯文本追加。 + options.onToken(raw); + } + }, + onerror(error) { + throw error; + }, + }); +} diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 5dccfe3..11f937e 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -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(INITIAL_MESSAGES); + const [isSending, setIsSending] = useState(false); const [deepThink, setDeepThink] = useState(false); const [smartSearch, setSmartSearch] = useState(false); + const messageListRef = useRef(null); + const abortRef = useRef(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 = (
@@ -257,11 +328,14 @@ export default function HomePage() {
-
+
- {mockMessages.map((item, index) => ( + {messages.map((item) => (
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={} + 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)", diff --git a/vite.config.ts b/vite.config.ts index 4495d28..df404a7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -12,4 +12,14 @@ export default defineConfig({ }), tailwindcss(), ], + server: { + // 允许局域网通过 IP 访问开发服务 + host: "0.0.0.0", + proxy: { + "/api": { + target: "http://localhost:3000", + changeOrigin: true, + }, + }, + }, }); diff --git a/yarn.lock b/yarn.lock index a94dc5b..42aaaa2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -367,6 +367,11 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@microsoft/fetch-event-source@^2.0.1": + version "2.0.1" + resolved "https://registry.npmmirror.com/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz#9ceecc94b49fbaa15666e38ae8587f64acce007d" + integrity sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA== + "@napi-rs/wasm-runtime@^1.1.1": version "1.1.2" resolved "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz#e25454b4d44cfabd21d1bc801705359870e33ecc" @@ -1301,6 +1306,11 @@ async-function@^1.0.0: resolved "https://registry.npmmirror.com/async-function/-/async-function-1.0.0.tgz#509c9fca60eaf85034c6829838188e4e4c8ffb2b" integrity sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA== +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== + available-typed-arrays@^1.0.7: version "1.0.7" resolved "https://registry.npmmirror.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz#a5cc375d6a03c2efc87a553f3e0b1522def14846" @@ -1308,6 +1318,15 @@ available-typed-arrays@^1.0.7: dependencies: possible-typed-array-names "^1.0.0" +axios@^1.15.0: + version "1.15.0" + resolved "https://registry.npmmirror.com/axios/-/axios-1.15.0.tgz#0fcee91ef03d386514474904b27863b2c683bf4f" + integrity sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q== + dependencies: + follow-redirects "^1.15.11" + form-data "^4.0.5" + proxy-from-env "^2.1.0" + balanced-match@^1.0.0: version "1.0.2" resolved "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" @@ -1437,6 +1456,13 @@ colorette@^2.0.20: resolved "https://registry.npmmirror.com/colorette/-/colorette-2.0.20.tgz#9eb793e6833067f7235902fcd3b09917a000a95a" integrity sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w== +combined-stream@^1.0.8: + version "1.0.8" + resolved "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + commander@^14.0.3: version "14.0.3" resolved "https://registry.npmmirror.com/commander/-/commander-14.0.3.tgz#425d79b48f9af82fcd9e4fc1ea8af6c5ec07bbc2" @@ -1548,6 +1574,11 @@ define-properties@^1.1.3, define-properties@^1.2.1: has-property-descriptors "^1.0.0" object-keys "^1.1.1" +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== + dequal@^2.0.3: version "2.0.3" resolved "https://registry.npmmirror.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" @@ -1959,6 +1990,11 @@ flatted@^3.2.9: resolved "https://registry.npmmirror.com/flatted/-/flatted-3.4.2.tgz#f5c23c107f0f37de8dbdf24f13722b3b98d52726" integrity sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA== +follow-redirects@^1.15.11: + version "1.16.0" + resolved "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz#28474a159d3b9d11ef62050a14ed60e4df6d61bc" + integrity sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw== + for-each@^0.3.3, for-each@^0.3.5: version "0.3.5" resolved "https://registry.npmmirror.com/for-each/-/for-each-0.3.5.tgz#d650688027826920feeb0af747ee7b9421a41d47" @@ -1966,6 +2002,17 @@ for-each@^0.3.3, for-each@^0.3.5: dependencies: is-callable "^1.2.7" +form-data@^4.0.5: + version "4.0.5" + resolved "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz#b49e48858045ff4cbf6b03e1805cebcad3679053" + integrity sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + es-set-tostringtag "^2.1.0" + hasown "^2.0.2" + mime-types "^2.1.12" + fsevents@~2.3.3: version "2.3.3" resolved "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" @@ -2618,6 +2665,18 @@ micromatch@^4.0.8: braces "^3.0.3" picomatch "^2.3.1" +mime-db@1.52.0: + version "1.52.0" + resolved "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" + integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== + +mime-types@^2.1.12: + version "2.1.35" + resolved "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== + dependencies: + mime-db "1.52.0" + mimic-function@^5.0.0: version "5.0.1" resolved "https://registry.npmmirror.com/mimic-function/-/mimic-function-5.0.1.tgz#acbe2b3349f99b9deaca7fb70e48b83e94e67076" @@ -2874,6 +2933,11 @@ prop-types@^15.8.1: object-assign "^4.1.1" react-is "^16.13.1" +proxy-from-env@^2.1.0: + version "2.1.0" + resolved "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz#a7487568adad577cfaaa7e88c49cab3ab3081aba" + integrity sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA== + punycode@^2.1.0: version "2.3.1" resolved "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"