diff --git a/.eslintignore b/.eslintignore index 08975255475..8109e6bec48 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1 +1,2 @@ -public/serviceWorker.js \ No newline at end of file +public/serviceWorker.js +app/mcp/mcp_config.json \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2ff556f646e..b1c2bfefad3 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,6 @@ dev *.key.pub masks.json + +# mcp config +app/mcp/mcp_config.json diff --git a/README_CN.md b/README_CN.md index 8173b9c4d1c..31b596f0bbd 100644 --- a/README_CN.md +++ b/README_CN.md @@ -6,7 +6,7 @@

NextChat

-一键免费部署你的私人 ChatGPT 网页应用,支持 GPT3, GPT4 & Gemini Pro 模型。 +一键免费部署你的私人 ChatGPT 网页应用,支持 Claude, GPT4 & Gemini Pro 模型。 [NextChatAI](https://nextchat.dev/chat?utm_source=readme) / [企业版](#%E4%BC%81%E4%B8%9A%E7%89%88) / [演示 Demo](https://chat-gpt-next-web.vercel.app/) / [反馈 Issues](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) / [加入 Discord](https://discord.gg/zrhvHCr79N) diff --git a/app/components/chat.tsx b/app/components/chat.tsx index 51fe74fe7be..bbc4444f6bd 100644 --- a/app/components/chat.tsx +++ b/app/components/chat.tsx @@ -1,17 +1,18 @@ import { useDebouncedCallback } from "use-debounce"; import React, { - useState, - useRef, - useEffect, - useMemo, - useCallback, Fragment, RefObject, + useCallback, + useEffect, + useMemo, + useRef, + useState, } from "react"; import SendWhiteIcon from "../icons/send-white.svg"; import BrainIcon from "../icons/brain.svg"; import RenameIcon from "../icons/rename.svg"; +import EditIcon from "../icons/rename.svg"; import ExportIcon from "../icons/share.svg"; import ReturnIcon from "../icons/return.svg"; import CopyIcon from "../icons/copy.svg"; @@ -24,11 +25,11 @@ import MaskIcon from "../icons/mask.svg"; import MaxIcon from "../icons/max.svg"; import MinIcon from "../icons/min.svg"; import ResetIcon from "../icons/reload.svg"; +import ReloadIcon from "../icons/reload.svg"; import BreakIcon from "../icons/break.svg"; import SettingsIcon from "../icons/chat-settings.svg"; import DeleteIcon from "../icons/clear.svg"; import PinIcon from "../icons/pin.svg"; -import EditIcon from "../icons/rename.svg"; import ConfirmIcon from "../icons/confirm.svg"; import CloseIcon from "../icons/close.svg"; import CancelIcon from "../icons/cancel.svg"; @@ -45,33 +46,32 @@ import QualityIcon from "../icons/hd.svg"; import StyleIcon from "../icons/palette.svg"; import PluginIcon from "../icons/plugin.svg"; import ShortcutkeyIcon from "../icons/shortcutkey.svg"; -import ReloadIcon from "../icons/reload.svg"; import HeadphoneIcon from "../icons/headphone.svg"; import { - ChatMessage, - SubmitKey, - useChatStore, BOT_HELLO, + ChatMessage, createMessage, - useAccessStore, - Theme, - useAppConfig, DEFAULT_TOPIC, ModelType, + SubmitKey, + Theme, + useAccessStore, + useAppConfig, + useChatStore, usePluginStore, } from "../store"; import { - copyToClipboard, - selectOrCopy, autoGrowTextArea, - useMobileScreen, - getMessageTextContent, + copyToClipboard, getMessageImages, - isVisionModel, + getMessageTextContent, isDalle3, - showPlugins, + isVisionModel, safeLocalStorage, + selectOrCopy, + showPlugins, + useMobileScreen, } from "../utils"; import { uploadImage as uploadImageRemote } from "@/app/utils/chat"; @@ -79,7 +79,7 @@ import { uploadImage as uploadImageRemote } from "@/app/utils/chat"; import dynamic from "next/dynamic"; import { ChatControllerPool } from "../client/controller"; -import { DalleSize, DalleQuality, DalleStyle } from "../typing"; +import { DalleQuality, DalleSize, DalleStyle } from "../typing"; import { Prompt, usePromptStore } from "../store/prompt"; import Locale from "../locales"; @@ -102,8 +102,8 @@ import { ModelProvider, Path, REQUEST_TIMEOUT_MS, - UNFINISHED_INPUT, ServiceProvider, + UNFINISHED_INPUT, } from "../constant"; import { Avatar } from "./emoji"; import { ContextPrompts, MaskAvatar, MaskConfig } from "./mask"; @@ -113,9 +113,7 @@ import { prettyObject } from "../utils/format"; import { ExportMessageModal } from "./exporter"; import { getClientConfig } from "../config/client"; import { useAllModels } from "../utils/hooks"; -import { MultimodalContent } from "../client/api"; - -import { ClientApi } from "../client/api"; +import { ClientApi, MultimodalContent } from "../client/api"; import { createTTSPlayer } from "../utils/audio"; import { MsEdgeTTS, OUTPUT_FORMAT } from "../utils/ms_edge_tts"; @@ -423,11 +421,11 @@ export function ChatAction(props: { function useScrollToBottom( scrollRef: RefObject, detach: boolean = false, + messages: ChatMessage[], ) { // for auto-scroll - const [autoScroll, setAutoScroll] = useState(true); - function scrollDomToBottom() { + const scrollDomToBottom = useCallback(() => { const dom = scrollRef.current; if (dom) { requestAnimationFrame(() => { @@ -435,7 +433,7 @@ function useScrollToBottom( dom.scrollTo(0, dom.scrollHeight); }); } - } + }, [scrollRef]); // auto scroll useEffect(() => { @@ -444,6 +442,15 @@ function useScrollToBottom( } }); + // auto scroll when messages length changes + const lastMessagesLength = useRef(messages.length); + useEffect(() => { + if (messages.length > lastMessagesLength.current && !detach) { + scrollDomToBottom(); + } + lastMessagesLength.current = messages.length; + }, [messages.length, detach, scrollDomToBottom]); + return { scrollRef, autoScroll, @@ -473,6 +480,7 @@ export function ChatActions(props: { // switch themes const theme = config.theme; + function nextTheme() { const themes = [Theme.Auto, Theme.Light, Theme.Dark]; const themeIndex = themes.indexOf(theme); @@ -978,6 +986,7 @@ function _Chat() { const { setAutoScroll, scrollDomToBottom } = useScrollToBottom( scrollRef, (isScrolledToBottom || isAttachWithTop) && !isTyping, + session.messages, ); const [hitBottom, setHitBottom] = useState(true); const isMobileScreen = useMobileScreen(); @@ -1237,6 +1246,7 @@ function _Chat() { const accessStore = useAccessStore(); const [speechStatus, setSpeechStatus] = useState(false); const [speechLoading, setSpeechLoading] = useState(false); + async function openaiSpeech(text: string) { if (speechStatus) { ttsPlayer.stop(); @@ -1336,6 +1346,7 @@ function _Chat() { const [msgRenderIndex, _setMsgRenderIndex] = useState( Math.max(0, renderMessages.length - CHAT_PAGE_SIZE), ); + function setMsgRenderIndex(newIndex: number) { newIndex = Math.min(renderMessages.length - CHAT_PAGE_SIZE, newIndex); newIndex = Math.max(0, newIndex); @@ -1371,6 +1382,7 @@ function _Chat() { setHitBottom(isHitBottom); setAutoScroll(isHitBottom); }; + function scrollToBottom() { setMsgRenderIndex(renderMessages.length - CHAT_PAGE_SIZE); scrollDomToBottom(); @@ -1712,252 +1724,264 @@ function _Chat() { setAutoScroll(false); }} > - {messages.map((message, i) => { - const isUser = message.role === "user"; - const isContext = i < context.length; - const showActions = - i > 0 && - !(message.preview || message.content.length === 0) && - !isContext; - const showTyping = message.preview || message.streaming; - - const shouldShowClearContextDivider = - i === clearContextIndex - 1; - - return ( - -
-
-
-
-
- } - aria={Locale.Chat.Actions.Edit} - onClick={async () => { - const newMessage = await showPrompt( - Locale.Chat.Actions.Edit, - getMessageTextContent(message), - 10, - ); - let newContent: string | MultimodalContent[] = - newMessage; - const images = getMessageImages(message); - if (images.length > 0) { - newContent = [ - { type: "text", text: newMessage }, - ]; - for (let i = 0; i < images.length; i++) { - newContent.push({ - type: "image_url", - image_url: { - url: images[i], - }, - }); + {messages + // TODO + // .filter((m) => !m.isMcpResponse) + .map((message, i) => { + const isUser = message.role === "user"; + const isContext = i < context.length; + const showActions = + i > 0 && + !(message.preview || message.content.length === 0) && + !isContext; + const showTyping = message.preview || message.streaming; + + const shouldShowClearContextDivider = + i === clearContextIndex - 1; + + return ( + +
+
+
+
+
+ } + aria={Locale.Chat.Actions.Edit} + onClick={async () => { + const newMessage = await showPrompt( + Locale.Chat.Actions.Edit, + getMessageTextContent(message), + 10, + ); + let newContent: + | string + | MultimodalContent[] = newMessage; + const images = getMessageImages(message); + if (images.length > 0) { + newContent = [ + { type: "text", text: newMessage }, + ]; + for (let i = 0; i < images.length; i++) { + newContent.push({ + type: "image_url", + image_url: { + url: images[i], + }, + }); + } } - } - chatStore.updateTargetSession( - session, - (session) => { - const m = session.mask.context - .concat(session.messages) - .find((m) => m.id === message.id); - if (m) { - m.content = newContent; + chatStore.updateTargetSession( + session, + (session) => { + const m = session.mask.context + .concat(session.messages) + .find((m) => m.id === message.id); + if (m) { + m.content = newContent; + } + }, + ); + }} + > +
+ {isUser ? ( + + ) : ( + <> + {["system"].includes(message.role) ? ( + + ) : ( + + /> + )} + + )}
- {isUser ? ( - - ) : ( - <> - {["system"].includes(message.role) ? ( - - ) : ( - - )} - + {!isUser && ( +
+ {message.model} +
)} -
- {!isUser && ( -
- {message.model} -
- )} - {showActions && ( -
-
- {message.streaming ? ( - } - onClick={() => onUserStop(message.id ?? i)} - /> - ) : ( - <> + {showActions && ( +
+
+ {message.streaming ? ( } - onClick={() => onResend(message)} - /> - - } - onClick={() => onDelete(message.id ?? i)} - /> - - } - onClick={() => onPinMessage(message)} - /> - } + text={Locale.Chat.Actions.Stop} + icon={} onClick={() => - copyToClipboard( - getMessageTextContent(message), - ) + onUserStop(message.id ?? i) } /> - {config.ttsConfig.enable && ( + ) : ( + <> - ) : ( - - ) + text={Locale.Chat.Actions.Retry} + icon={} + onClick={() => onResend(message)} + /> + + } + onClick={() => + onDelete(message.id ?? i) } + /> + + } + onClick={() => onPinMessage(message)} + /> + } onClick={() => - openaiSpeech( + copyToClipboard( getMessageTextContent(message), ) } /> - )} - - )} + {config.ttsConfig.enable && ( + + ) : ( + + ) + } + onClick={() => + openaiSpeech( + getMessageTextContent(message), + ) + } + /> + )} + + )} +
+ )} +
+ {message?.tools?.length == 0 && showTyping && ( +
+ {Locale.Chat.Typing}
)} -
- {message?.tools?.length == 0 && showTyping && ( -
- {Locale.Chat.Typing} -
- )} - {/*@ts-ignore*/} - {message?.tools?.length > 0 && ( -
- {message?.tools?.map((tool) => ( + {/*@ts-ignore*/} + {message?.tools?.length > 0 && ( +
+ {message?.tools?.map((tool) => ( +
+ {tool.isError === false ? ( + + ) : tool.isError === true ? ( + + ) : ( + + )} + {tool?.function?.name} +
+ ))} +
+ )} +
+ onRightClick(e, message)} // hard to use + onDoubleClickCapture={() => { + if (!isMobileScreen) return; + setUserInput(getMessageTextContent(message)); + }} + fontSize={fontSize} + fontFamily={fontFamily} + parentRef={scrollRef} + defaultShow={i >= messages.length - 6} + /> + {getMessageImages(message).length == 1 && ( + + )} + {getMessageImages(message).length > 1 && (
- {tool.isError === false ? ( - - ) : tool.isError === true ? ( - - ) : ( - + {getMessageImages(message).map( + (image, index) => { + return ( + + ); + }, )} - {tool?.function?.name}
- ))} + )}
- )} -
- onRightClick(e, message)} // hard to use - onDoubleClickCapture={() => { - if (!isMobileScreen) return; - setUserInput(getMessageTextContent(message)); - }} - fontSize={fontSize} - fontFamily={fontFamily} - parentRef={scrollRef} - defaultShow={i >= messages.length - 6} - /> - {getMessageImages(message).length == 1 && ( - - )} - {getMessageImages(message).length > 1 && ( -
- {getMessageImages(message).map((image, index) => { - return ( - - ); - })} + {message?.audio_url && ( +
+
)} -
- {message?.audio_url && ( -
-
- )} -
- {isContext - ? Locale.Chat.IsContext - : message.date.toLocaleString()} +
+ {isContext + ? Locale.Chat.IsContext + : message.date.toLocaleString()} +
-
- {shouldShowClearContextDivider && } - - ); - })} + {shouldShowClearContextDivider && } + + ); + })}
(await import("./sd")).Sd, { loading: () => , }); +const McpMarketPage = dynamic( + async () => (await import("./mcp-market")).McpMarketPage, + { + loading: () => , + }, +); + export function useSwitchTheme() { const config = useAppConfig(); @@ -193,6 +200,7 @@ function Screen() { } /> } /> } /> + } /> diff --git a/app/components/mcp-market.module.scss b/app/components/mcp-market.module.scss new file mode 100644 index 00000000000..5e4b6e9b015 --- /dev/null +++ b/app/components/mcp-market.module.scss @@ -0,0 +1,612 @@ +@import "../styles/animation.scss"; + +.mcp-market-page { + height: 100%; + display: flex; + flex-direction: column; + + .loading-indicator { + font-size: 12px; + color: var(--primary); + margin-left: 8px; + font-weight: normal; + opacity: 0.8; + } + + .mcp-market-page-body { + padding: 20px; + overflow-y: auto; + + .mcp-market-filter { + width: 100%; + max-width: 100%; + margin-bottom: 20px; + animation: slide-in ease 0.3s; + height: 40px; + display: flex; + + .search-bar { + flex-grow: 1; + max-width: 100%; + min-width: 0; + } + } + + .server-list { + display: flex; + flex-direction: column; + gap: 1px; + } + + .mcp-market-item { + display: flex; + justify-content: space-between; + padding: 20px; + border: var(--border-in-light); + animation: slide-in ease 0.3s; + background-color: var(--white); + transition: all 0.3s ease; + + &.disabled { + opacity: 0.7; + pointer-events: none; + } + + &:not(:last-child) { + border-bottom: 0; + } + + &:first-child { + border-top-left-radius: 10px; + border-top-right-radius: 10px; + } + + &:last-child { + border-bottom-left-radius: 10px; + border-bottom-right-radius: 10px; + } + + .mcp-market-header { + display: flex; + align-items: center; + + .mcp-market-title { + .mcp-market-name { + font-size: 14px; + font-weight: bold; + display: flex; + align-items: center; + gap: 8px; + + .server-status { + font-size: 12px; + padding: 2px 6px; + border-radius: 4px; + margin-left: 8px; + background-color: #10b981; + color: white; + + &.error { + background-color: #ef4444; + } + + &.waiting { + background-color: #f59e0b; + } + + .error-message { + font-size: 11px; + opacity: 0.9; + margin-left: 4px; + } + } + } + + .mcp-market-info { + font-size: 12px; + color: var(--black-50); + margin-top: 4px; + } + } + } + + .mcp-market-actions { + display: flex; + gap: 8px; + align-items: center; + + :global(.icon-button) { + transition: all 0.3s ease; + border: 1px solid transparent; + + &:hover { + transform: translateY(-1px); + filter: brightness(1.1); + } + + &.action-primary { + background-color: var(--primary); + color: white; + + svg { + filter: brightness(2); + } + + &:hover { + background-color: var(--primary); + border-color: var(--primary); + } + } + + &.action-warning { + background-color: var(--warning); + color: white; + + svg { + filter: brightness(2); + } + + &:hover { + background-color: var(--warning); + border-color: var(--warning); + } + } + + &.action-danger { + background-color: transparent; + color: var(--danger); + border-color: var(--danger); + + &:hover { + background-color: var(--danger); + color: white; + + svg { + filter: brightness(2); + } + } + } + + &.action-error { + color: #ef4444 !important; + border-color: #ef4444 !important; + } + } + } + + @media screen and (max-width: 600px) { + flex-direction: column; + gap: 10px; + + .mcp-market-actions { + justify-content: flex-end; + } + } + } + } + + .array-input { + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; + padding: 16px; + border: 1px solid var(--gray-200); + border-radius: 10px; + background-color: var(--white); + + .array-input-item { + display: flex; + gap: 8px; + align-items: center; + width: 100%; + padding: 0; + + input { + width: 100%; + padding: 8px 12px; + background-color: var(--gray-50); + border-radius: 6px; + transition: all 0.3s ease; + font-size: 13px; + border: 1px solid var(--gray-200); + + &:hover { + background-color: var(--gray-100); + border-color: var(--gray-300); + } + + &:focus { + background-color: var(--white); + border-color: var(--primary); + outline: none; + box-shadow: 0 0 0 2px var(--primary-10); + } + + &::placeholder { + color: var(--gray-300); + } + } + + :global(.icon-button) { + width: 32px; + height: 32px; + padding: 0; + border-radius: 6px; + background-color: transparent; + border: 1px solid var(--gray-200); + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + background-color: var(--gray-100); + border-color: var(--gray-300); + } + + svg { + width: 16px; + height: 16px; + opacity: 0.7; + } + } + } + + :global(.icon-button.add-path-button) { + width: 100%; + background-color: var(--primary); + color: white; + padding: 8px 12px; + border-radius: 6px; + transition: all 0.3s ease; + margin-top: 8px; + display: flex; + align-items: center; + justify-content: center; + border: none; + height: 36px; + + &:hover { + background-color: var(--primary-dark); + } + + svg { + width: 16px; + height: 16px; + margin-right: 4px; + filter: brightness(2); + } + } + } + + .path-list { + width: 100%; + display: flex; + flex-direction: column; + gap: 10px; + + .path-item { + display: flex; + gap: 10px; + width: 100%; + + input { + flex: 1; + width: 100%; + max-width: 100%; + padding: 10px; + border: var(--border-in-light); + border-radius: 10px; + box-sizing: border-box; + font-size: 14px; + background-color: var(--white); + color: var(--black); + + &:hover { + border-color: var(--gray-300); + } + + &:focus { + border-color: var(--primary); + outline: none; + box-shadow: 0 0 0 2px var(--primary-10); + } + + &::placeholder { + color: var(--gray-300) !important; + opacity: 1; + } + } + + .browse-button { + padding: 8px; + border: var(--border-in-light); + border-radius: 10px; + background-color: transparent; + color: var(--black-50); + + &:hover { + border-color: var(--primary); + color: var(--primary); + background-color: transparent; + } + + svg { + width: 16px; + height: 16px; + } + } + + .delete-button { + padding: 8px; + border: var(--border-in-light); + border-radius: 10px; + background-color: transparent; + color: var(--black-50); + + &:hover { + border-color: var(--danger); + color: var(--danger); + background-color: transparent; + } + + svg { + width: 16px; + height: 16px; + } + } + + .file-input { + display: none; + } + } + + .add-button { + align-self: flex-start; + display: flex; + align-items: center; + gap: 5px; + padding: 8px 12px; + background-color: transparent; + border: var(--border-in-light); + border-radius: 10px; + color: var(--black); + font-size: 12px; + margin-top: 5px; + + &:hover { + border-color: var(--primary); + color: var(--primary); + background-color: transparent; + } + + svg { + width: 16px; + height: 16px; + } + } + } + + .config-section { + width: 100%; + + .config-header { + margin-bottom: 12px; + + .config-title { + font-size: 14px; + font-weight: 600; + color: var(--black); + text-transform: capitalize; + } + + .config-description { + font-size: 12px; + color: var(--gray-500); + margin-top: 4px; + } + } + + .array-input { + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; + padding: 16px; + border: 1px solid var(--gray-200); + border-radius: 10px; + background-color: var(--white); + + .array-input-item { + display: flex; + gap: 8px; + align-items: center; + width: 100%; + padding: 0; + + input { + width: 100%; + padding: 8px 12px; + background-color: var(--gray-50); + border-radius: 6px; + transition: all 0.3s ease; + font-size: 13px; + border: 1px solid var(--gray-200); + + &:hover { + background-color: var(--gray-100); + border-color: var(--gray-300); + } + + &:focus { + background-color: var(--white); + border-color: var(--primary); + outline: none; + box-shadow: 0 0 0 2px var(--primary-10); + } + + &::placeholder { + color: var(--gray-300); + } + } + + :global(.icon-button) { + width: 32px; + height: 32px; + padding: 0; + border-radius: 6px; + background-color: transparent; + border: 1px solid var(--gray-200); + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + background-color: var(--gray-100); + border-color: var(--gray-300); + } + + svg { + width: 16px; + height: 16px; + opacity: 0.7; + } + } + } + + :global(.icon-button.add-path-button) { + width: 100%; + background-color: var(--primary); + color: white; + padding: 8px 12px; + border-radius: 6px; + transition: all 0.3s ease; + margin-top: 8px; + display: flex; + align-items: center; + justify-content: center; + border: none; + height: 36px; + + &:hover { + background-color: var(--primary-dark); + } + + svg { + width: 16px; + height: 16px; + margin-right: 4px; + filter: brightness(2); + } + } + } + } + + .input-item { + width: 100%; + + input { + width: 100%; + padding: 10px; + border: var(--border-in-light); + border-radius: 10px; + box-sizing: border-box; + font-size: 14px; + background-color: var(--white); + color: var(--black); + + &:hover { + border-color: var(--gray-300); + } + + &:focus { + border-color: var(--primary); + outline: none; + box-shadow: 0 0 0 2px var(--primary-10); + } + + &::placeholder { + color: var(--gray-300) !important; + opacity: 1; + } + } + } + + .primitives-list { + display: flex; + flex-direction: column; + gap: 16px; + width: 100%; + padding: 20px; + max-width: 100%; + overflow-x: hidden; + word-break: break-word; + box-sizing: border-box; + + .primitive-item { + width: 100%; + box-sizing: border-box; + + .primitive-name { + font-size: 14px; + font-weight: 600; + color: var(--black); + margin-bottom: 8px; + padding-left: 12px; + border-left: 3px solid var(--primary); + box-sizing: border-box; + width: 100%; + } + + .primitive-description { + font-size: 13px; + color: var(--gray-500); + line-height: 1.6; + padding-left: 15px; + box-sizing: border-box; + width: 100%; + } + } + } + + :global { + .modal-content { + margin-top: 20px; + max-width: 100%; + overflow-x: hidden; + } + + .list { + padding: 10px; + margin-bottom: 10px; + background-color: var(--white); + } + + .list-item { + border: none; + background-color: transparent; + border-radius: 10px; + padding: 10px; + margin-bottom: 10px; + + .list-header { + margin-bottom: 10px; + + .list-title { + font-size: 14px; + font-weight: bold; + text-transform: capitalize; + color: var(--black); + } + + .list-sub-title { + font-size: 12px; + color: var(--gray-500); + margin-top: 4px; + } + } + } + } +} diff --git a/app/components/mcp-market.tsx b/app/components/mcp-market.tsx new file mode 100644 index 00000000000..926e64b297b --- /dev/null +++ b/app/components/mcp-market.tsx @@ -0,0 +1,566 @@ +import { IconButton } from "./button"; +import { ErrorBoundary } from "./error"; +import styles from "./mcp-market.module.scss"; +import EditIcon from "../icons/edit.svg"; +import AddIcon from "../icons/add.svg"; +import CloseIcon from "../icons/close.svg"; +import DeleteIcon from "../icons/delete.svg"; +import RestartIcon from "../icons/reload.svg"; +import EyeIcon from "../icons/eye.svg"; +import { List, ListItem, Modal, showToast } from "./ui-lib"; +import { useNavigate } from "react-router-dom"; +import { useState, useEffect } from "react"; +import presetServersJson from "../mcp/preset-server.json"; +const presetServers = presetServersJson as PresetServer[]; +import { + getMcpConfig, + updateMcpConfig, + getClientPrimitives, + restartAllClients, + getClientErrors, + refreshClientStatus, +} from "../mcp/actions"; +import { McpConfig, PresetServer, ServerConfig } from "../mcp/types"; +import clsx from "clsx"; + +interface ConfigProperty { + type: string; + description?: string; + required?: boolean; + minItems?: number; +} + +export function McpMarketPage() { + const navigate = useNavigate(); + const [searchText, setSearchText] = useState(""); + const [config, setConfig] = useState({ mcpServers: {} }); + const [editingServerId, setEditingServerId] = useState(); + const [viewingServerId, setViewingServerId] = useState(); + const [primitives, setPrimitives] = useState([]); + const [userConfig, setUserConfig] = useState>({}); + const [isLoading, setIsLoading] = useState(false); + const [clientErrors, setClientErrors] = useState< + Record + >({}); + + // 更新服务器状态 + const updateServerStatus = async () => { + await refreshClientStatus(); + const errors = await getClientErrors(); + setClientErrors(errors); + }; + + // 初始加载配置 + useEffect(() => { + const init = async () => { + try { + setIsLoading(true); + const data = await getMcpConfig(); + setConfig(data); + await updateServerStatus(); + } catch (error) { + showToast("Failed to load configuration"); + console.error(error); + } finally { + setIsLoading(false); + } + }; + init().then(); + }, []); + + // 保存配置 + const saveConfig = async (newConfig: McpConfig) => { + try { + setIsLoading(true); + await updateMcpConfig(newConfig); + setConfig(newConfig); + // 配置改变时需要重新初始化 + await restartAllClients(); + await updateServerStatus(); + showToast("Configuration saved successfully"); + } catch (error) { + showToast("Failed to save configuration"); + console.error(error); + } finally { + setIsLoading(false); + } + }; + + // 检查服务器是否已添加 + const isServerAdded = (id: string) => { + return id in config.mcpServers; + }; + + // 加载当前编辑服务器的配置 + useEffect(() => { + if (editingServerId) { + const currentConfig = config.mcpServers[editingServerId]; + if (currentConfig) { + // 从当前配置中提取用户配置 + const preset = presetServers.find((s) => s.id === editingServerId); + if (preset?.configSchema) { + const userConfig: Record = {}; + Object.entries(preset.argsMapping || {}).forEach(([key, mapping]) => { + if (mapping.type === "spread") { + // 对于 spread 类型,从 args 中提取数组 + const startPos = mapping.position ?? 0; + userConfig[key] = currentConfig.args.slice(startPos); + } else if (mapping.type === "single") { + // 对于 single 类型,获取单个值 + userConfig[key] = currentConfig.args[mapping.position ?? 0]; + } else if ( + mapping.type === "env" && + mapping.key && + currentConfig.env + ) { + // 对于 env 类型,从环境变量中获取值 + userConfig[key] = currentConfig.env[mapping.key]; + } + }); + setUserConfig(userConfig); + } + } else { + setUserConfig({}); + } + } + }, [editingServerId, config.mcpServers]); + + // 保存服务器配置 + const saveServerConfig = async () => { + const preset = presetServers.find((s) => s.id === editingServerId); + if (!preset || !preset.configSchema || !editingServerId) return; + + try { + // 构建服务器配置 + const args = [...preset.baseArgs]; + const env: Record = {}; + + Object.entries(preset.argsMapping || {}).forEach(([key, mapping]) => { + const value = userConfig[key]; + if (mapping.type === "spread" && Array.isArray(value)) { + const pos = mapping.position ?? 0; + args.splice(pos, 0, ...value); + } else if ( + mapping.type === "single" && + mapping.position !== undefined + ) { + args[mapping.position] = value; + } else if ( + mapping.type === "env" && + mapping.key && + typeof value === "string" + ) { + env[mapping.key] = value; + } + }); + + const serverConfig: ServerConfig = { + command: preset.command, + args, + ...(Object.keys(env).length > 0 ? { env } : {}), + }; + + // 更新配置 + const newConfig = { + ...config, + mcpServers: { + ...config.mcpServers, + [editingServerId]: serverConfig, + }, + }; + + await saveConfig(newConfig); + setEditingServerId(undefined); + showToast("Server configuration saved successfully"); + } catch (error) { + showToast( + error instanceof Error ? error.message : "Failed to save configuration", + ); + } + }; + + // 渲染配置表单 + const renderConfigForm = () => { + const preset = presetServers.find((s) => s.id === editingServerId); + if (!preset?.configSchema) return null; + + return Object.entries(preset.configSchema.properties).map( + ([key, prop]: [string, ConfigProperty]) => { + if (prop.type === "array") { + const currentValue = userConfig[key as keyof typeof userConfig] || []; + return ( + +
+ {(currentValue as string[]).map( + (value: string, index: number) => ( +
+ { + const newValue = [...currentValue] as string[]; + newValue[index] = e.target.value; + setUserConfig({ ...userConfig, [key]: newValue }); + }} + /> + } + className={styles["delete-button"]} + onClick={() => { + const newValue = [...currentValue] as string[]; + newValue.splice(index, 1); + setUserConfig({ ...userConfig, [key]: newValue }); + }} + /> +
+ ), + )} + } + text="Add Path" + className={styles["add-button"]} + bordered + onClick={() => { + const newValue = [...currentValue, ""] as string[]; + setUserConfig({ ...userConfig, [key]: newValue }); + }} + /> +
+
+ ); + } else if (prop.type === "string") { + const currentValue = userConfig[key as keyof typeof userConfig] || ""; + return ( + +
+ { + setUserConfig({ ...userConfig, [key]: e.target.value }); + }} + /> +
+
+ ); + } + return null; + }, + ); + }; + + // 获取服务器的 Primitives + const loadPrimitives = async (id: string) => { + try { + setIsLoading(true); + const result = await getClientPrimitives(id); + if (result) { + setPrimitives(result); + } else { + showToast("Server is not running"); + setPrimitives([]); + } + } catch (error) { + showToast("Failed to load primitives"); + console.error(error); + setPrimitives([]); + } finally { + setIsLoading(false); + } + }; + + // 重启所有客户端 + const handleRestart = async () => { + try { + setIsLoading(true); + await restartAllClients(); + await updateServerStatus(); + showToast("All clients restarted successfully"); + } catch (error) { + showToast("Failed to restart clients"); + console.error(error); + } finally { + setIsLoading(false); + } + }; + + // 添加服务器 + const addServer = async (preset: PresetServer) => { + if (!preset.configurable) { + try { + setIsLoading(true); + showToast("Creating MCP client..."); + // 如果服务器不需要配置,直接添加 + const serverConfig: ServerConfig = { + command: preset.command, + args: [...preset.baseArgs], + }; + const newConfig = { + ...config, + mcpServers: { + ...config.mcpServers, + [preset.id]: serverConfig, + }, + }; + await saveConfig(newConfig); + } finally { + setIsLoading(false); + } + } else { + // 如果需要配置,打开配置对话框 + setEditingServerId(preset.id); + setUserConfig({}); + } + }; + + // 移除服务器 + const removeServer = async (id: string) => { + try { + setIsLoading(true); + const { [id]: _, ...rest } = config.mcpServers; + const newConfig = { + ...config, + mcpServers: rest, + }; + await saveConfig(newConfig); + } finally { + setIsLoading(false); + } + }; + + return ( + +
+
+
+
+ MCP Market + {isLoading && ( + Loading... + )} +
+
+ {Object.keys(config.mcpServers).length} servers configured +
+
+ +
+
+ } + bordered + onClick={handleRestart} + text="Restart All" + disabled={isLoading} + /> +
+
+ } + bordered + onClick={() => navigate(-1)} + disabled={isLoading} + /> +
+
+
+ +
+
+ setSearchText(e.currentTarget.value)} + /> +
+ +
+ {presetServers + .filter( + (m) => + searchText.length === 0 || + m.name.toLowerCase().includes(searchText.toLowerCase()) || + m.description + .toLowerCase() + .includes(searchText.toLowerCase()), + ) + .sort((a, b) => { + const aAdded = isServerAdded(a.id); + const bAdded = isServerAdded(b.id); + const aError = clientErrors[a.id] !== null; + const bError = clientErrors[b.id] !== null; + + if (aAdded !== bAdded) { + return aAdded ? -1 : 1; + } + if (aAdded && bAdded) { + if (aError !== bError) { + return aError ? -1 : 1; + } + } + return 0; + }) + .map((server) => ( +
+
+
+
+ {server.name} + {isServerAdded(server.id) && ( + + {clientErrors[server.id] === null + ? "Active" + : "Error"} + {clientErrors[server.id] && ( + + : {clientErrors[server.id]} + + )} + + )} +
+
+ {server.description} +
+
+
+
+ {isServerAdded(server.id) ? ( + <> + {server.configurable && ( + } + text="Configure" + className={clsx({ + [styles["action-error"]]: + clientErrors[server.id] !== null, + })} + onClick={() => setEditingServerId(server.id)} + disabled={isLoading} + /> + )} + {isServerAdded(server.id) && ( + } + text="Tools" + onClick={async () => { + if (clientErrors[server.id] !== null) { + showToast("Server is not running"); + return; + } + setViewingServerId(server.id); + await loadPrimitives(server.id); + }} + disabled={isLoading} + /> + )} + } + text="Remove" + className={styles["action-danger"]} + onClick={() => removeServer(server.id)} + disabled={isLoading} + /> + + ) : ( + } + text="Add" + className={styles["action-primary"]} + onClick={() => addServer(server)} + disabled={isLoading} + /> + )} +
+
+ ))} +
+
+ + {editingServerId && ( +
+ !isLoading && setEditingServerId(undefined)} + actions={[ + setEditingServerId(undefined)} + bordered + disabled={isLoading} + />, + , + ]} + > + {renderConfigForm()} + +
+ )} + + {viewingServerId && ( +
+ setViewingServerId(undefined)} + actions={[ + setViewingServerId(undefined)} + bordered + />, + ]} + > +
+ {isLoading ? ( +
Loading...
+ ) : primitives.filter((p) => p.type === "tool").length > 0 ? ( + primitives + .filter((p) => p.type === "tool") + .map((primitive, index) => ( +
+
+ {primitive.value.name} +
+ {primitive.value.description && ( +
+ {primitive.value.description} +
+ )} +
+ )) + ) : ( +
No tools available
+ )} +
+
+
+ )} +
+
+ ); +} diff --git a/app/components/sidebar.tsx b/app/components/sidebar.tsx index a5e33b15ea3..84b0973bd93 100644 --- a/app/components/sidebar.tsx +++ b/app/components/sidebar.tsx @@ -9,6 +9,7 @@ import ChatGptIcon from "../icons/chatgpt.svg"; import AddIcon from "../icons/add.svg"; import DeleteIcon from "../icons/delete.svg"; import MaskIcon from "../icons/mask.svg"; +import McpIcon from "../icons/mcp.svg"; import DragIcon from "../icons/drag.svg"; import DiscoveryIcon from "../icons/discovery.svg"; @@ -250,6 +251,15 @@ export function SideBar(props: { className?: string }) { }} shadow /> + } + text={shouldNarrow ? undefined : Locale.Mcp.Name} + className={styles["sidebar-bar-button"]} + onClick={() => { + navigate(Path.McpMarket, { state: { fromHome: true } }); + }} + shadow + /> } text={shouldNarrow ? undefined : Locale.Discovery.Name} diff --git a/app/constant.ts b/app/constant.ts index 5759411af17..3c0ff6213aa 100644 --- a/app/constant.ts +++ b/app/constant.ts @@ -47,6 +47,7 @@ export enum Path { SdNew = "/sd-new", Artifacts = "/artifacts", SearchChat = "/search-chat", + McpMarket = "/mcp-market", } export enum ApiPath { @@ -253,6 +254,117 @@ Latex inline: \\(x^2\\) Latex block: $$e=mc^2$$ `; +export const MCP_PRIMITIVES_TEMPLATE = ` +[clientId] +{{ clientId }} +[primitives] +{{ primitives }} +`; + +export const MCP_SYSTEM_TEMPLATE = ` +You are an AI assistant with access to system tools. Your role is to help users by combining natural language understanding with tool operations when needed. + +1. TOOLS AVAILABLE: +{{ MCP_PRIMITIVES }} + +2. WHEN TO USE TOOLS: + - ALWAYS USE TOOLS when they can help answer user questions + - DO NOT just describe what you could do - TAKE ACTION immediately + - If you're not sure whether to use a tool, USE IT + - Common triggers for tool use: + * Questions about files or directories + * Requests to check, list, or manipulate system resources + * Any query that can be answered with available tools + +3. HOW TO USE TOOLS: + A. Tool Call Format: + - Use markdown code blocks with format: \`\`\`json:mcp:{clientId}\`\`\` + - Always include: + * method: "tools/call" + * params: + - name: must match an available primitive name + - arguments: required parameters for the primitive + + B. Response Format: + - Tool responses will come as user messages + - Format: \`\`\`json:mcp-response:{clientId}\`\`\` + - Wait for response before making another tool call + + C. Important Rules: + - Only ONE tool call per message + - ALWAYS TAKE ACTION instead of just describing what you could do + - Include the correct clientId in code block language tag + - Verify arguments match the primitive's requirements + +4. INTERACTION FLOW: + A. When user makes a request: + - IMMEDIATELY use appropriate tool if available + - DO NOT ask if user wants you to use the tool + - DO NOT just describe what you could do + B. After receiving tool response: + - Explain results clearly + - Take next appropriate action if needed + C. If tools fail: + - Explain the error + - Try alternative approach immediately + +5. EXAMPLE INTERACTION: + User: "What files do I have on my desktop?" + Assistant: "I'll check which directories I have access to. + \`\`\`json:mcp:filesystem + { + "method": "tools/call", + "params": { + "name": "list_allowed_directories", + "arguments": {} + } + } + \`\`\`" + + User: "\`\`\`json:mcp-response:filesystem + { + "directories": ["/path/to/desktop"] + } + \`\`\`" + + Assistant: "I can see that I have access to your desktop directory. Let me list its contents for you. + \`\`\`json:mcp:filesystem + { + "method": "tools/call", + "params": { + "name": "list_directory", + "arguments": { + "path": "/path/to/desktop" + } + } + } + \`\`\`" + + User: "\`\`\`json:mcp-response:filesystem + { + "content": [ + { + "type": "text", + "text": "[FILE] document.txt\n[DIR] folder1\n[DIR] folder2\n[FILE] image.png\n[FILE] notes.md" + } + ] + } + \`\`\`" + + Assistant: "I've found the contents of your desktop. Here's what you have: + + Files: + - document.txt + - image.png + - notes.md + + Directories: + - folder1 + - folder2 + + Would you like to explore any of these directories or perform other operations with these files?" +`; + export const SUMMARIZE_MODEL = "gpt-4o-mini"; export const GEMINI_SUMMARIZE_MODEL = "gemini-pro"; diff --git a/app/icons/mcp.svg b/app/icons/mcp.svg new file mode 100644 index 00000000000..aaf0bbc7431 --- /dev/null +++ b/app/icons/mcp.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/locales/cn.ts b/app/locales/cn.ts index 47be019a809..bd8b530603e 100644 --- a/app/locales/cn.ts +++ b/app/locales/cn.ts @@ -626,6 +626,9 @@ const cn = { Discovery: { Name: "发现", }, + Mcp: { + Name: "MCP", + }, FineTuned: { Sysmessage: "你是一个助手", }, diff --git a/app/mcp/actions.ts b/app/mcp/actions.ts new file mode 100644 index 00000000000..bf38dcc6311 --- /dev/null +++ b/app/mcp/actions.ts @@ -0,0 +1,236 @@ +"use server"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { + createClient, + executeRequest, + listPrimitives, + Primitive, +} from "./client"; +import { MCPClientLogger } from "./logger"; +import { McpRequestMessage, McpConfig, ServerConfig } from "./types"; +import fs from "fs/promises"; +import path from "path"; + +const logger = new MCPClientLogger("MCP Actions"); + +// Use Map to store all clients +const clientsMap = new Map< + string, + { client: Client | null; primitives: Primitive[]; errorMsg: string | null } +>(); + +// Whether initialized +let initialized = false; + +// Store failed clients +let errorClients: string[] = []; + +const CONFIG_PATH = path.join(process.cwd(), "app/mcp/mcp_config.json"); + +// 获取 MCP 配置 +export async function getMcpConfig(): Promise { + try { + const configStr = await fs.readFile(CONFIG_PATH, "utf-8"); + return JSON.parse(configStr); + } catch (error) { + console.error("Failed to read MCP config:", error); + return { mcpServers: {} }; + } +} + +// 更新 MCP 配置 +export async function updateMcpConfig(config: McpConfig): Promise { + try { + await fs.writeFile(CONFIG_PATH, JSON.stringify(config, null, 2)); + } catch (error) { + console.error("Failed to write MCP config:", error); + throw error; + } +} + +// 重新初始化所有客户端 +export async function reinitializeMcpClients() { + logger.info("Reinitializing MCP clients..."); + // 遍历所有客户端,关闭 + try { + for (const [clientId, clientData] of clientsMap.entries()) { + clientData.client?.close(); + } + } catch (error) { + logger.error(`Failed to close clients: ${error}`); + } + // 清空状态 + clientsMap.clear(); + errorClients = []; + initialized = false; + // 重新初始化 + return initializeMcpClients(); +} + +// Initialize all configured clients +export async function initializeMcpClients() { + // If already initialized, return + if (initialized) { + return { errorClients }; + } + + logger.info("Starting to initialize MCP clients..."); + errorClients = []; + + const config = await getMcpConfig(); + // Initialize all clients, key is clientId, value is client config + for (const [clientId, serverConfig] of Object.entries(config.mcpServers)) { + try { + logger.info(`Initializing MCP client: ${clientId}`); + const client = await createClient(serverConfig as ServerConfig, clientId); + const primitives = await listPrimitives(client); + clientsMap.set(clientId, { client, primitives, errorMsg: null }); + logger.success( + `Client [${clientId}] initialized, ${primitives.length} primitives supported`, + ); + } catch (error) { + errorClients.push(clientId); + clientsMap.set(clientId, { + client: null, + primitives: [], + errorMsg: error instanceof Error ? error.message : String(error), + }); + logger.error(`Failed to initialize client ${clientId}: ${error}`); + } + } + + initialized = true; + + if (errorClients.length > 0) { + logger.warn(`Failed to initialize clients: ${errorClients.join(", ")}`); + } else { + logger.success("All MCP clients initialized"); + } + + const availableClients = await getAvailableClients(); + logger.info(`Available clients: ${availableClients.join(",")}`); + + return { errorClients }; +} + +// Execute MCP request +export async function executeMcpAction( + clientId: string, + request: McpRequestMessage, +) { + try { + // Find the corresponding client + const client = clientsMap.get(clientId)?.client; + if (!client) { + logger.error(`Client ${clientId} not found`); + return; + } + + logger.info(`Executing MCP request for ${clientId}`); + + // Execute request and return result + return await executeRequest(client, request); + } catch (error) { + logger.error(`MCP execution error: ${error}`); + throw error; + } +} + +// Get all available client IDs +export async function getAvailableClients() { + return Array.from(clientsMap.entries()) + .filter(([_, data]) => data.errorMsg === null) + .map(([clientId]) => clientId); +} + +// Get all primitives from all clients +export async function getAllPrimitives(): Promise< + { + clientId: string; + primitives: Primitive[]; + }[] +> { + return Array.from(clientsMap.entries()).map(([clientId, { primitives }]) => ({ + clientId, + primitives, + })); +} + +// 获取客户端的 Primitives +export async function getClientPrimitives(clientId: string) { + try { + const clientData = clientsMap.get(clientId); + if (!clientData) { + console.warn(`Client ${clientId} not found in map`); + return null; + } + if (clientData.errorMsg) { + console.warn(`Client ${clientId} has error: ${clientData.errorMsg}`); + return null; + } + return clientData.primitives; + } catch (error) { + console.error(`Failed to get primitives for client ${clientId}:`, error); + return null; + } +} + +// 重启所有客户端 +export async function restartAllClients() { + logger.info("Restarting all MCP clients..."); + + // 清空状态 + clientsMap.clear(); + errorClients = []; + initialized = false; + + // 重新初始化 + await initializeMcpClients(); + + return { + success: errorClients.length === 0, + errorClients, + }; +} + +// 获取所有客户端状态 +export async function getAllClientStatus(): Promise< + Record +> { + const status: Record = {}; + for (const [clientId, data] of clientsMap.entries()) { + status[clientId] = data.errorMsg; + } + return status; +} + +// 检查客户端状态 +export async function getClientErrors(): Promise< + Record +> { + const errors: Record = {}; + for (const [clientId, data] of clientsMap.entries()) { + errors[clientId] = data.errorMsg; + } + return errors; +} + +// 获取客户端状态,不重新初始化 +export async function refreshClientStatus() { + logger.info("Refreshing client status..."); + + // 如果还没初始化过,则初始化 + if (!initialized) { + return initializeMcpClients(); + } + + // 否则只更新错误状态 + errorClients = []; + for (const [clientId, clientData] of clientsMap.entries()) { + if (clientData.errorMsg !== null) { + errorClients.push(clientId); + } + } + + return { errorClients }; +} diff --git a/app/mcp/client.ts b/app/mcp/client.ts new file mode 100644 index 00000000000..6650f9e2b42 --- /dev/null +++ b/app/mcp/client.ts @@ -0,0 +1,88 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { MCPClientLogger } from "./logger"; +import { McpRequestMessage } from "./types"; +import { z } from "zod"; + +export interface ServerConfig { + command: string; + args?: string[]; + env?: Record; +} + +const logger = new MCPClientLogger(); + +export async function createClient( + serverConfig: ServerConfig, + name: string, +): Promise { + logger.info(`Creating client for server ${name}`); + + const transport = new StdioClientTransport({ + command: serverConfig.command, + args: serverConfig.args, + env: serverConfig.env, + }); + const client = new Client( + { + name: `nextchat-mcp-client-${name}`, + version: "1.0.0", + }, + { + capabilities: { + // roots: { + // listChanged: true, + // }, + }, + }, + ); + await client.connect(transport); + return client; +} + +export interface Primitive { + type: "resource" | "tool" | "prompt"; + value: any; +} + +/** List all resources, tools, and prompts */ +export async function listPrimitives(client: Client): Promise { + const capabilities = client.getServerCapabilities(); + const primitives: Primitive[] = []; + const promises = []; + if (capabilities?.resources) { + promises.push( + client.listResources().then(({ resources }) => { + resources.forEach((item) => + primitives.push({ type: "resource", value: item }), + ); + }), + ); + } + if (capabilities?.tools) { + promises.push( + client.listTools().then(({ tools }) => { + tools.forEach((item) => primitives.push({ type: "tool", value: item })); + }), + ); + } + if (capabilities?.prompts) { + promises.push( + client.listPrompts().then(({ prompts }) => { + prompts.forEach((item) => + primitives.push({ type: "prompt", value: item }), + ); + }), + ); + } + await Promise.all(promises); + return primitives; +} + +/** Execute a request */ +export async function executeRequest( + client: Client, + request: McpRequestMessage, +) { + return client.request(request, z.any()); +} diff --git a/app/mcp/example.ts b/app/mcp/example.ts new file mode 100644 index 00000000000..f3b91fb8cbd --- /dev/null +++ b/app/mcp/example.ts @@ -0,0 +1,31 @@ +import { createClient, listPrimitives } from "@/app/mcp/client"; +import { MCPClientLogger } from "@/app/mcp/logger"; +import conf from "./mcp_config.json"; + +const logger = new MCPClientLogger("MCP Server Example", true); + +const TEST_SERVER = "everything"; + +async function main() { + logger.info(`All MCP servers: ${Object.keys(conf.mcpServers).join(", ")}`); + + logger.info(`Connecting to server ${TEST_SERVER}...`); + + const client = await createClient(conf.mcpServers[TEST_SERVER], TEST_SERVER); + const primitives = await listPrimitives(client); + + logger.success(`Connected to server ${TEST_SERVER}`); + + logger.info( + `${TEST_SERVER} supported primitives:\n${JSON.stringify( + primitives.filter((i) => i.type === "tool"), + null, + 2, + )}`, + ); +} + +main().catch((error) => { + logger.error(error); + process.exit(1); +}); diff --git a/app/mcp/logger.ts b/app/mcp/logger.ts new file mode 100644 index 00000000000..25129c592c3 --- /dev/null +++ b/app/mcp/logger.ts @@ -0,0 +1,65 @@ +// ANSI color codes for terminal output +const colors = { + reset: "\x1b[0m", + bright: "\x1b[1m", + dim: "\x1b[2m", + green: "\x1b[32m", + yellow: "\x1b[33m", + red: "\x1b[31m", + blue: "\x1b[34m", +}; + +export class MCPClientLogger { + private readonly prefix: string; + private readonly debugMode: boolean; + + constructor( + prefix: string = "NextChat MCP Client", + debugMode: boolean = false, + ) { + this.prefix = prefix; + this.debugMode = debugMode; + } + + info(message: any) { + this.print(colors.blue, message); + } + + success(message: any) { + this.print(colors.green, message); + } + + error(message: any) { + this.print(colors.red, message); + } + + warn(message: any) { + this.print(colors.yellow, message); + } + + debug(message: any) { + if (this.debugMode) { + this.print(colors.dim, message); + } + } + + /** + * Format message to string, if message is object, convert to JSON string + */ + private formatMessage(message: any): string { + return typeof message === "object" + ? JSON.stringify(message, null, 2) + : message; + } + + /** + * Print formatted message to console + */ + private print(color: string, message: any) { + const formattedMessage = this.formatMessage(message); + const logMessage = `${color}${colors.bright}[${this.prefix}]${colors.reset} ${formattedMessage}`; + + // 只使用 console.log,这样日志会显示在 Tauri 的终端中 + console.log(logMessage); + } +} diff --git a/app/mcp/mcp_config.json b/app/mcp/mcp_config.json new file mode 100644 index 00000000000..da39e4ffafe --- /dev/null +++ b/app/mcp/mcp_config.json @@ -0,0 +1,3 @@ +{ + "mcpServers": {} +} diff --git a/app/mcp/preset-server.json b/app/mcp/preset-server.json new file mode 100644 index 00000000000..0daec9aebf1 --- /dev/null +++ b/app/mcp/preset-server.json @@ -0,0 +1,206 @@ +[ + { + "id": "filesystem", + "name": "Filesystem", + "description": "Secure file operations with configurable access controls", + "command": "npx", + "baseArgs": ["-y", "@modelcontextprotocol/server-filesystem"], + "configurable": true, + "configSchema": { + "properties": { + "paths": { + "type": "array", + "description": "Allowed file system paths", + "required": true, + "minItems": 1 + } + } + }, + "argsMapping": { + "paths": { + "type": "spread", + "position": 2 + } + } + }, + { + "id": "github", + "name": "GitHub", + "description": "Repository management, file operations, and GitHub API integration", + "command": "npx", + "baseArgs": ["-y", "@modelcontextprotocol/server-github"], + "configurable": true, + "configSchema": { + "properties": { + "token": { + "type": "string", + "description": "GitHub Personal Access Token", + "required": true + } + } + }, + "argsMapping": { + "token": { + "type": "env", + "key": "GITHUB_PERSONAL_ACCESS_TOKEN" + } + } + }, + { + "id": "gdrive", + "name": "Google Drive", + "description": "File access and search capabilities for Google Drive", + "command": "npx", + "baseArgs": ["-y", "@modelcontextprotocol/server-gdrive"], + "configurable": false + }, + { + "id": "playwright", + "name": "Playwright", + "description": "Browser automation and webscrapping with Playwright", + "command": "npx", + "baseArgs": ["-y", "@executeautomation/playwright-mcp-server"], + "configurable": false + }, + { + "id": "mongodb", + "name": "MongoDB", + "description": "Direct interaction with MongoDB databases", + "command": "node", + "baseArgs": ["dist/index.js"], + "configurable": true, + "configSchema": { + "properties": { + "connectionString": { + "type": "string", + "description": "MongoDB connection string", + "required": true + } + } + }, + "argsMapping": { + "connectionString": { + "type": "single", + "position": 1 + } + } + }, + { + "id": "difyworkflow", + "name": "Dify Workflow", + "description": "Tools to query and execute Dify workflows", + "command": "mcp-difyworkflow-server", + "baseArgs": ["-base-url"], + "configurable": true, + "configSchema": { + "properties": { + "baseUrl": { + "type": "string", + "description": "Dify API base URL", + "required": true + }, + "workflowName": { + "type": "string", + "description": "Dify workflow name", + "required": true + }, + "apiKeys": { + "type": "string", + "description": "Comma-separated Dify API keys", + "required": true + } + } + }, + "argsMapping": { + "baseUrl": { + "type": "single", + "position": 1 + }, + "workflowName": { + "type": "env", + "key": "DIFY_WORKFLOW_NAME" + }, + "apiKeys": { + "type": "env", + "key": "DIFY_API_KEYS" + } + } + }, + { + "id": "postgres", + "name": "PostgreSQL", + "description": "Read-only database access with schema inspection", + "command": "docker", + "baseArgs": ["run", "-i", "--rm", "mcp/postgres"], + "configurable": true, + "configSchema": { + "properties": { + "connectionString": { + "type": "string", + "description": "PostgreSQL connection string", + "required": true + } + } + }, + "argsMapping": { + "connectionString": { + "type": "single", + "position": 4 + } + } + }, + { + "id": "brave-search", + "name": "Brave Search", + "description": "Web and local search using Brave's Search API", + "command": "npx", + "baseArgs": ["-y", "@modelcontextprotocol/server-brave-search"], + "configurable": true, + "configSchema": { + "properties": { + "apiKey": { + "type": "string", + "description": "Brave Search API Key", + "required": true + } + } + }, + "argsMapping": { + "apiKey": { + "type": "env", + "key": "BRAVE_API_KEY" + } + } + }, + { + "id": "google-maps", + "name": "Google Maps", + "description": "Location services, directions, and place details", + "command": "npx", + "baseArgs": ["-y", "@modelcontextprotocol/server-google-maps"], + "configurable": true, + "configSchema": { + "properties": { + "apiKey": { + "type": "string", + "description": "Google Maps API Key", + "required": true + } + } + }, + "argsMapping": { + "apiKey": { + "type": "env", + "key": "GOOGLE_MAPS_API_KEY" + } + } + }, + { + "id": "docker-mcp", + "name": "Docker", + "description": "Run and manage docker containers, docker compose, and logs", + "command": "uvx", + "baseArgs": ["docker-mcp"], + "configurable": false + } +] diff --git a/app/mcp/types.ts b/app/mcp/types.ts new file mode 100644 index 00000000000..a97c94e059a --- /dev/null +++ b/app/mcp/types.ts @@ -0,0 +1,99 @@ +// ref: https://spec.modelcontextprotocol.io/specification/basic/messages/ + +import { z } from "zod"; + +export interface McpRequestMessage { + jsonrpc?: "2.0"; + id?: string | number; + method: "tools/call" | string; + params?: { + [key: string]: unknown; + }; +} + +export const McpRequestMessageSchema: z.ZodType = z.object({ + jsonrpc: z.literal("2.0").optional(), + id: z.union([z.string(), z.number()]).optional(), + method: z.string(), + params: z.record(z.unknown()).optional(), +}); + +export interface McpResponseMessage { + jsonrpc?: "2.0"; + id?: string | number; + result?: { + [key: string]: unknown; + }; + error?: { + code: number; + message: string; + data?: unknown; + }; +} + +export const McpResponseMessageSchema: z.ZodType = z.object( + { + jsonrpc: z.literal("2.0").optional(), + id: z.union([z.string(), z.number()]).optional(), + result: z.record(z.unknown()).optional(), + error: z + .object({ + code: z.number(), + message: z.string(), + data: z.unknown().optional(), + }) + .optional(), + }, +); + +export interface McpNotifications { + jsonrpc?: "2.0"; + method: string; + params?: { + [key: string]: unknown; + }; +} + +export const McpNotificationsSchema: z.ZodType = z.object({ + jsonrpc: z.literal("2.0").optional(), + method: z.string(), + params: z.record(z.unknown()).optional(), +}); + +// MCP 服务器配置相关类型 +export interface ServerConfig { + command: string; + args: string[]; + env?: Record; +} + +export interface McpConfig { + mcpServers: Record; +} + +export interface ArgsMapping { + type: "spread" | "single" | "env"; + position?: number; + key?: string; +} + +export interface PresetServer { + id: string; + name: string; + description: string; + command: string; + baseArgs: string[]; + configurable: boolean; + configSchema?: { + properties: Record< + string, + { + type: string; + description?: string; + required?: boolean; + minItems?: number; + } + >; + }; + argsMapping?: Record; +} diff --git a/app/mcp/utils.ts b/app/mcp/utils.ts new file mode 100644 index 00000000000..b74509881ef --- /dev/null +++ b/app/mcp/utils.ts @@ -0,0 +1,11 @@ +export function isMcpJson(content: string) { + return content.match(/```json:mcp:([^{\s]+)([\s\S]*?)```/); +} + +export function extractMcpJson(content: string) { + const match = content.match(/```json:mcp:([^{\s]+)([\s\S]*?)```/); + if (match && match.length === 3) { + return { clientId: match[1], mcp: JSON.parse(match[2]) }; + } + return null; +} diff --git a/app/page.tsx b/app/page.tsx index b3f169a9b74..d4ba2a27613 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,12 +1,13 @@ import { Analytics } from "@vercel/analytics/react"; - import { Home } from "./components/home"; - import { getServerSideConfig } from "./config/server"; +import { initializeMcpClients } from "./mcp/actions"; const serverConfig = getServerSideConfig(); export default async function App() { + await initializeMcpClients(); + return ( <> diff --git a/app/store/chat.ts b/app/store/chat.ts index 63d7394ece6..4a70c9296c3 100644 --- a/app/store/chat.ts +++ b/app/store/chat.ts @@ -1,4 +1,9 @@ -import { getMessageTextContent, trimTopic } from "../utils"; +import { + getMessageTextContent, + isDalle3, + safeLocalStorage, + trimTopic, +} from "../utils"; import { indexedDBStorage } from "@/app/utils/indexedDB-storage"; import { nanoid } from "nanoid"; @@ -14,14 +19,15 @@ import { DEFAULT_INPUT_TEMPLATE, DEFAULT_MODELS, DEFAULT_SYSTEM_TEMPLATE, + GEMINI_SUMMARIZE_MODEL, KnowledgeCutOffDate, + MCP_PRIMITIVES_TEMPLATE, + MCP_SYSTEM_TEMPLATE, + ServiceProvider, StoreKey, SUMMARIZE_MODEL, - GEMINI_SUMMARIZE_MODEL, - ServiceProvider, } from "../constant"; import Locale, { getLang } from "../locales"; -import { isDalle3, safeLocalStorage } from "../utils"; import { prettyObject } from "../utils/format"; import { createPersistStore } from "../utils/store"; import { estimateTokenLength } from "../utils/token"; @@ -29,6 +35,8 @@ import { ModelConfig, ModelType, useAppConfig } from "./config"; import { useAccessStore } from "./access"; import { collectModelsWithDefaultModel } from "../utils/model"; import { createEmptyMask, Mask } from "./mask"; +import { executeMcpAction, getAllPrimitives } from "../mcp/actions"; +import { extractMcpJson, isMcpJson } from "../mcp/utils"; const localStorage = safeLocalStorage(); @@ -53,6 +61,7 @@ export type ChatMessage = RequestMessage & { model?: ModelType; tools?: ChatMessageTool[]; audio_url?: string; + isMcpResponse?: boolean; }; export function createMessage(override: Partial): ChatMessage { @@ -189,6 +198,26 @@ function fillTemplateWith(input: string, modelConfig: ModelConfig) { return output; } +async function getMcpSystemPrompt(): Promise { + let primitives = await getAllPrimitives(); + primitives = primitives.filter((i) => + i.primitives.some((p) => p.type === "tool"), + ); + + let primitivesString = ""; + primitives.forEach((i) => { + primitivesString += MCP_PRIMITIVES_TEMPLATE.replace( + "{{ clientId }}", + i.clientId, + ).replace( + "{{ primitives }}", + i.primitives.map((p) => JSON.stringify(p, null, 2)).join("\n"), + ); + }); + + return MCP_SYSTEM_TEMPLATE.replace("{{ MCP_PRIMITIVES }}", primitivesString); +} + const DEFAULT_CHAT_STATE = { sessions: [createEmptySession()], currentSessionIndex: 0, @@ -358,24 +387,30 @@ export const useChatStore = createPersistStore( session.messages = session.messages.concat(); session.lastUpdate = Date.now(); }); + get().updateStat(message, targetSession); + + get().checkMcpJson(message); + get().summarizeSession(false, targetSession); }, - async onUserInput(content: string, attachImages?: string[]) { + async onUserInput( + content: string, + attachImages?: string[], + isMcpResponse?: boolean, + ) { const session = get().currentSession(); const modelConfig = session.mask.modelConfig; - const userContent = fillTemplateWith(content, modelConfig); - console.log("[User Input] after template: ", userContent); - - let mContent: string | MultimodalContent[] = userContent; + // MCP Response no need to fill template + let mContent: string | MultimodalContent[] = isMcpResponse + ? content + : fillTemplateWith(content, modelConfig); - if (attachImages && attachImages.length > 0) { + if (!isMcpResponse && attachImages && attachImages.length > 0) { mContent = [ - ...(userContent - ? [{ type: "text" as const, text: userContent }] - : []), + ...(content ? [{ type: "text" as const, text: content }] : []), ...attachImages.map((url) => ({ type: "image_url" as const, image_url: { url }, @@ -386,6 +421,7 @@ export const useChatStore = createPersistStore( let userMessage: ChatMessage = createMessage({ role: "user", content: mContent, + isMcpResponse, }); const botMessage: ChatMessage = createMessage({ @@ -395,7 +431,7 @@ export const useChatStore = createPersistStore( }); // get recent messages - const recentMessages = get().getMessagesWithMemory(); + const recentMessages = await get().getMessagesWithMemory(); const sendMessages = recentMessages.concat(userMessage); const messageIndex = session.messages.length + 1; @@ -425,7 +461,7 @@ export const useChatStore = createPersistStore( session.messages = session.messages.concat(); }); }, - onFinish(message) { + async onFinish(message) { botMessage.streaming = false; if (message) { botMessage.content = message; @@ -494,7 +530,7 @@ export const useChatStore = createPersistStore( } }, - getMessagesWithMemory() { + async getMessagesWithMemory() { const session = get().currentSession(); const modelConfig = session.mask.modelConfig; const clearContextIndex = session.clearContextIndex ?? 0; @@ -510,18 +546,26 @@ export const useChatStore = createPersistStore( (session.mask.modelConfig.model.startsWith("gpt-") || session.mask.modelConfig.model.startsWith("chatgpt-")); + const mcpSystemPrompt = await getMcpSystemPrompt(); + var systemPrompts: ChatMessage[] = []; systemPrompts = shouldInjectSystemPrompts ? [ createMessage({ role: "system", - content: fillTemplateWith("", { - ...modelConfig, - template: DEFAULT_SYSTEM_TEMPLATE, - }), + content: + fillTemplateWith("", { + ...modelConfig, + template: DEFAULT_SYSTEM_TEMPLATE, + }) + mcpSystemPrompt, }), ] - : []; + : [ + createMessage({ + role: "system", + content: mcpSystemPrompt, + }), + ]; if (shouldInjectSystemPrompts) { console.log( "[Global System Prompt] ", @@ -764,6 +808,36 @@ export const useChatStore = createPersistStore( lastInput, }); }, + + /** check if the message contains MCP JSON and execute the MCP action */ + checkMcpJson(message: ChatMessage) { + const content = getMessageTextContent(message); + if (isMcpJson(content)) { + try { + const mcpRequest = extractMcpJson(content); + if (mcpRequest) { + console.debug("[MCP Request]", mcpRequest); + + executeMcpAction(mcpRequest.clientId, mcpRequest.mcp) + .then((result) => { + console.log("[MCP Response]", result); + const mcpResponse = + typeof result === "object" + ? JSON.stringify(result) + : String(result); + get().onUserInput( + `\`\`\`json:mcp-response:${mcpRequest.clientId}\n${mcpResponse}\n\`\`\``, + [], + true, + ); + }) + .catch((error) => showToast("MCP execution failed", error)); + } + } catch (error) { + console.error("[Check MCP JSON]", error); + } + } + }, }; return methods; diff --git a/next.config.mjs b/next.config.mjs index 2bb6bc4f4b2..0e1105d5647 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -71,8 +71,10 @@ if (mode !== "export") { // }, { // https://{resource_name}.openai.azure.com/openai/deployments/{deploy_name}/chat/completions - source: "/api/proxy/azure/:resource_name/deployments/:deploy_name/:path*", - destination: "https://:resource_name.openai.azure.com/openai/deployments/:deploy_name/:path*", + source: + "/api/proxy/azure/:resource_name/deployments/:deploy_name/:path*", + destination: + "https://:resource_name.openai.azure.com/openai/deployments/:deploy_name/:path*", }, { source: "/api/proxy/google/:path*", @@ -99,7 +101,7 @@ if (mode !== "export") { destination: "https://dashscope.aliyuncs.com/api/:path*", }, ]; - + return { beforeFiles: ret, }; diff --git a/package.json b/package.json index e081567a4b1..0efe27b391a 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "export:dev": "concurrently -r \"yarn mask:watch\" \"cross-env BUILD_MODE=export BUILD_APP=1 next dev\"", "app:dev": "concurrently -r \"yarn mask:watch\" \"yarn tauri dev\"", "app:build": "yarn mask && yarn tauri build", + "app:clear": "yarn tauri dev", "prompts": "node ./scripts/fetch-prompts.mjs", "prepare": "husky install", "proxy-dev": "sh ./scripts/init-proxy.sh && proxychains -f ./scripts/proxychains.conf yarn dev", @@ -22,6 +23,7 @@ "dependencies": { "@fortaine/fetch-event-source": "^3.0.6", "@hello-pangea/dnd": "^16.5.0", + "@modelcontextprotocol/sdk": "^1.0.4", "@next/third-parties": "^14.1.0", "@svgr/webpack": "^6.5.1", "@vercel/analytics": "^0.1.11", @@ -49,14 +51,15 @@ "remark-breaks": "^3.0.2", "remark-gfm": "^3.0.1", "remark-math": "^5.1.1", + "rt-client": "https://github.com/Azure-Samples/aoai-realtime-audio-sdk/releases/download/js/v0.5.0/rt-client-0.5.0.tgz", "sass": "^1.59.2", "spark-md5": "^3.0.2", "use-debounce": "^9.0.4", - "zustand": "^4.3.8", - "rt-client": "https://github.com/Azure-Samples/aoai-realtime-audio-sdk/releases/download/js/v0.5.0/rt-client-0.5.0.tgz" + "zod": "^3.24.1", + "zustand": "^4.3.8" }, "devDependencies": { - "@tauri-apps/api": "^1.6.0", + "@tauri-apps/api": "^2.1.1", "@tauri-apps/cli": "1.5.11", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", diff --git a/tsconfig.json b/tsconfig.json index c73eef3e876..6d24b42f1de 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "ES2015", + "target": "ES2022", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, @@ -23,6 +23,6 @@ "@/*": ["./*"] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "app/calcTextareaHeight.ts"], + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "exclude": ["node_modules"] } diff --git a/yarn.lock b/yarn.lock index dffc35e9cb7..a99ff08041d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1797,6 +1797,15 @@ "@jridgewell/resolve-uri" "3.1.0" "@jridgewell/sourcemap-codec" "1.4.14" +"@modelcontextprotocol/sdk@^1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@modelcontextprotocol/sdk/-/sdk-1.0.4.tgz#34ad1edd3db7dd7154e782312dfb29d2d0c11d21" + integrity sha512-C+jw1lF6HSGzs7EZpzHbXfzz9rj9him4BaoumlTciW/IDDgIpweF/qiCWKlP02QKg5PPcgY6xY2WCt5y2tpYow== + dependencies: + content-type "^1.0.5" + raw-body "^3.0.0" + zod "^3.23.8" + "@next/env@14.1.1": version "14.1.1" resolved "https://registry.yarnpkg.com/@next/env/-/env-14.1.1.tgz#80150a8440eb0022a73ba353c6088d419b908bac" @@ -2029,10 +2038,10 @@ dependencies: tslib "^2.4.0" -"@tauri-apps/api@^1.6.0": - version "1.6.0" - resolved "https://registry.npmjs.org/@tauri-apps/api/-/api-1.6.0.tgz#745b7e4e26782c3b2ad9510d558fa5bb2cf29186" - integrity sha512-rqI++FWClU5I2UBp4HXFvl+sBWkdigBkxnpJDQUWttNyG7IZP4FwQGhTNL5EOw0vI8i6eSAJ5frLqO7n7jbJdg== +"@tauri-apps/api@^2.1.1": + version "2.1.1" + resolved "https://registry.yarnpkg.com/@tauri-apps/api/-/api-2.1.1.tgz#77d4ddb683d31072de4e6a47c8613d9db011652b" + integrity sha512-fzUfFFKo4lknXGJq8qrCidkUcKcH2UHhfaaCNt4GzgzGaW2iS26uFOg4tS3H4P8D6ZEeUxtiD5z0nwFF0UN30A== "@tauri-apps/cli-darwin-arm64@1.5.11": version "1.5.11" @@ -3039,6 +3048,11 @@ busboy@1.6.0: dependencies: streamsearch "^1.1.0" +bytes@3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" + integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== + call-bind@^1.0.0, call-bind@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c" @@ -3062,15 +3076,10 @@ camelcase@^6.2.0: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== -caniuse-lite@^1.0.30001449, caniuse-lite@^1.0.30001503, caniuse-lite@^1.0.30001579: - version "1.0.30001617" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001617.tgz#809bc25f3f5027ceb33142a7d6c40759d7a901eb" - integrity sha512-mLyjzNI9I+Pix8zwcrpxEbGlfqOkF9kM3ptzmKNw5tizSyYwMe+nGLTqMK9cO+0E+Bh6TsBxNAaHWEM8xwSsmA== - -caniuse-lite@^1.0.30001646: - version "1.0.30001649" - resolved "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001649.tgz#3ec700309ca0da2b0d3d5fb03c411b191761c992" - integrity sha512-fJegqZZ0ZX8HOWr6rcafGr72+xcgJKI9oWfDW5DrD7ExUtgZC7a7R7ZYmZqplh7XDocFdGeIFn7roAxhOeYrPQ== +caniuse-lite@^1.0.30001449, caniuse-lite@^1.0.30001503, caniuse-lite@^1.0.30001579, caniuse-lite@^1.0.30001646: + version "1.0.30001692" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001692.tgz" + integrity sha512-A95VKan0kdtrsnMubMKxEKUKImOPSuCpYgxSQBo036P5YYgVIcOYJEgt/txJWqObiRQeISNCfef9nvlQ0vbV7A== ccount@^2.0.0: version "2.0.1" @@ -3285,6 +3294,11 @@ concurrently@^8.2.2: tree-kill "^1.2.2" yargs "^17.7.2" +content-type@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" + integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== + convert-source-map@^1.7.0: version "1.9.0" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.9.0.tgz#7faae62353fb4213366d0ca98358d22e8368b05f" @@ -3849,6 +3863,11 @@ delayed-stream@~1.0.0: resolved "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== +depd@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" + integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== + dequal@^2.0.0, dequal@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" @@ -5007,6 +5026,17 @@ html-to-image@^1.11.11: resolved "https://registry.npmmirror.com/html-to-image/-/html-to-image-1.11.11.tgz#c0f8a34dc9e4b97b93ff7ea286eb8562642ebbea" integrity sha512-9gux8QhvjRO/erSnDPv28noDZcPZmYE7e1vFsBLKLlRlKDSqNJYebj6Qz1TGd5lsRV+X+xYyjCKjuZdABinWjA== +http-errors@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" + integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== + dependencies: + depd "2.0.0" + inherits "2.0.4" + setprototypeof "1.2.0" + statuses "2.0.1" + toidentifier "1.0.1" + http-proxy-agent@^5.0.0: version "5.0.0" resolved "https://registry.npmmirror.com/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz#5129800203520d434f142bc78ff3c170800f2b43" @@ -5095,7 +5125,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2: +inherits@2, inherits@2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -7138,6 +7168,16 @@ randombytes@^2.1.0: dependencies: safe-buffer "^5.1.0" +raw-body@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-3.0.0.tgz#25b3476f07a51600619dae3fe82ddc28a36e5e0f" + integrity sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g== + dependencies: + bytes "3.1.2" + http-errors "2.0.0" + iconv-lite "0.6.3" + unpipe "1.0.0" + react-dom@^18.2.0: version "18.2.0" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d" @@ -7569,6 +7609,11 @@ serialize-javascript@^6.0.1: dependencies: randombytes "^2.1.0" +setprototypeof@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" + integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== + shebang-command@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" @@ -7699,6 +7744,11 @@ stack-utils@^2.0.3: dependencies: escape-string-regexp "^2.0.0" +statuses@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" + integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== + stop-iteration-iterator@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz#6a60be0b4ee757d1ed5254858ec66b10c49285e4" @@ -7977,6 +8027,11 @@ to-regex-range@^5.0.1: dependencies: is-number "^7.0.0" +toidentifier@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" + integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== + tough-cookie@^4.1.2: version "4.1.4" resolved "https://registry.npmmirror.com/tough-cookie/-/tough-cookie-4.1.4.tgz#945f1461b45b5a8c76821c33ea49c3ac192c1b36" @@ -8219,6 +8274,11 @@ universalify@^0.2.0: resolved "https://registry.npmmirror.com/universalify/-/universalify-0.2.0.tgz#6451760566fa857534745ab1dde952d1b1761be0" integrity sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg== +unpipe@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" + integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== + update-browserslist-db@^1.0.10: version "1.0.10" resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz#0f54b876545726f17d00cd9a2561e6dade943ff3" @@ -8572,6 +8632,11 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== +zod@^3.23.8, zod@^3.24.1: + version "3.24.1" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.24.1.tgz#27445c912738c8ad1e9de1bea0359fa44d9d35ee" + integrity sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A== + zustand@^4.3.8: version "4.3.8" resolved "https://registry.yarnpkg.com/zustand/-/zustand-4.3.8.tgz#37113df8e9e1421b0be1b2dca02b49b76210e7c4"