ui: 接入千问流式聊天并增强请求稳定性;
All checks were successful
CI / build (push) Successful in 1m52s

将首页从 mock 数据切换为真实流式会话,统一 API 网络层并支持 SSE 增量解析、超时与断线重试,提升前端在跨域代理和网络抖动场景下的可用性。

Made-with: Cursor
This commit is contained in:
2026-04-15 23:03:54 +08:00
parent f46b50fc1e
commit 038dc5e918
6 changed files with 409 additions and 66 deletions

View File

@@ -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"

64
src/api/http.ts Normal file
View File

@@ -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<string, unknown>;
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<unknown>;
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<string, unknown>).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<TResponse>(
path: string,
body: JsonBody,
signal?: AbortSignal,
): Promise<TResponse> {
try {
const response = await httpClient.post<TResponse>(path, body, { signal });
return response.data;
} catch (error) {
throw toRequestError(error, "Request failed");
}
}
export async function postStream(
path: string,
body: JsonBody,
signal?: AbortSignal,
): Promise<Response> {
// 流式响应仍使用原生 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;
}

119
src/api/qwenChat.ts Normal file
View File

@@ -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, unknown>): 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<string, unknown>;
const delta = first.delta as Record<string, unknown> | undefined;
if (delta && typeof delta.content === "string") {
return delta.content;
}
const message = first.message as Record<string, unknown> | 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 | undefined>): 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<void> {
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<string, unknown>;
const token = pickTokenFromJson(parsed);
if (token) options.onToken(token);
} catch {
// 非 JSON 片段兜底按纯文本追加。
options.onToken(raw);
}
},
onerror(error) {
throw error;
},
});
}

View File

@@ -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:
"听起来节奏很紧凑。建议你在总结里分成「会议与协作」「工程交付」「阻塞与风险」三块,每块用 23 条要点,领导扫一眼就能抓住重点。",
},
{ 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)",

View File

@@ -12,4 +12,14 @@ export default defineConfig({
}),
tailwindcss(),
],
server: {
// 允许局域网通过 IP 访问开发服务
host: "0.0.0.0",
proxy: {
"/api": {
target: "http://localhost:3000",
changeOrigin: true,
},
},
},
});

View File

@@ -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"