diff --git a/.env.template b/.env.template index 82f44216a..4efaa2ff8 100644 --- a/.env.template +++ b/.env.template @@ -1,12 +1,20 @@ # Your openai api key. (required) OPENAI_API_KEY=sk-xxxx +# DeepSeek Api Key. (Optional) +DEEPSEEK_API_KEY= + # Access password, separated by comma. (optional) CODE=your-password # You can start service behind a proxy. (optional) PROXY_URL=http://localhost:7890 +# Enable MCP functionality (optional) +# Default: Empty (disabled) +# Set to "true" to enable MCP functionality +ENABLE_MCP= + # (optional) # Default: Empty # Google Gemini Pro API key, set if you want to use Google Gemini Pro API. @@ -66,4 +74,10 @@ ANTHROPIC_API_VERSION= ANTHROPIC_URL= ### (optional) -WHITE_WEBDAV_ENDPOINTS= \ No newline at end of file +WHITE_WEBDAV_ENDPOINTS= + +### siliconflow Api key (optional) +SILICONFLOW_API_KEY= + +### siliconflow Api url (optional) +SILICONFLOW_URL= diff --git a/.eslintignore b/.eslintignore index 089752554..61e76e59a 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1 +1,3 @@ -public/serviceWorker.js \ No newline at end of file +public/serviceWorker.js +app/mcp/mcp_config.json +app/mcp/mcp_config.default.json \ No newline at end of file diff --git a/.github/workflows/deploy_preview.yml b/.github/workflows/deploy_preview.yml index 30d9b85b4..b98845243 100644 --- a/.github/workflows/deploy_preview.yml +++ b/.github/workflows/deploy_preview.yml @@ -3,9 +3,7 @@ name: VercelPreviewDeployment on: pull_request_target: types: - - opened - - synchronize - - reopened + - review_requested env: VERCEL_TEAM: ${{ secrets.VERCEL_TEAM }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 000000000..faf7205d9 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,39 @@ +name: Run Tests + +on: + push: + branches: + - main + tags: + - "!*" + pull_request: + types: + - review_requested + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: 18 + cache: "yarn" + + - name: Cache node_modules + uses: actions/cache@v4 + with: + path: node_modules + key: ${{ runner.os }}-node_modules-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-node_modules- + + - name: Install dependencies + run: yarn install + + - name: Run Jest tests + run: yarn test:ci diff --git a/.gitignore b/.gitignore index 2ff556f64..b1c2bfefa 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,6 @@ dev *.key.pub masks.json + +# mcp config +app/mcp/mcp_config.json diff --git a/Dockerfile b/Dockerfile index ae9a17cdd..d3e4193ee 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,12 +34,16 @@ ENV PROXY_URL="" ENV OPENAI_API_KEY="" ENV GOOGLE_API_KEY="" ENV CODE="" +ENV ENABLE_MCP="" COPY --from=builder /app/public ./public COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/.next/server ./.next/server +RUN mkdir -p /app/app/mcp && chmod 777 /app/app/mcp +COPY --from=builder /app/app/mcp/mcp_config.default.json /app/app/mcp/mcp_config.json + EXPOSE 3000 CMD if [ -n "$PROXY_URL" ]; then \ diff --git a/LICENSE b/LICENSE index 047f9431e..4864ab00d 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2023-2024 Zhang Yifei +Copyright (c) 2023-2025 NextChat Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index d370000fa..fbc087697 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,20 @@
{props.children}
- {showToggle && collapsed && (
- diff --git a/app/components/realtime-chat/index.ts b/app/components/realtime-chat/index.ts new file mode 100644 index 000000000..fdf090f41 --- /dev/null +++ b/app/components/realtime-chat/index.ts @@ -0,0 +1 @@ +export * from "./realtime-chat"; diff --git a/app/components/realtime-chat/realtime-chat.module.scss b/app/components/realtime-chat/realtime-chat.module.scss new file mode 100644 index 000000000..ef58bebb6 --- /dev/null +++ b/app/components/realtime-chat/realtime-chat.module.scss @@ -0,0 +1,74 @@ +.realtime-chat { + width: 100%; + justify-content: center; + align-items: center; + position: relative; + display: flex; + flex-direction: column; + height: 100%; + padding: 20px; + box-sizing: border-box; + .circle-mic { + width: 150px; + height: 150px; + border-radius: 50%; + background: linear-gradient(to bottom right, #a0d8ef, #f0f8ff); + display: flex; + justify-content: center; + align-items: center; + } + .icon-center { + font-size: 24px; + } + + .bottom-icons { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + position: absolute; + bottom: 20px; + box-sizing: border-box; + padding: 0 20px; + } + + .icon-left, + .icon-right { + width: 46px; + height: 46px; + font-size: 36px; + background: var(--second); + border-radius: 50%; + padding: 2px; + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + &:hover { + opacity: 0.8; + } + } + + &.mobile { + display: none; + } +} + +.pulse { + animation: pulse 1.5s infinite; +} + +@keyframes pulse { + 0% { + transform: scale(1); + opacity: 0.7; + } + 50% { + transform: scale(1.1); + opacity: 1; + } + 100% { + transform: scale(1); + opacity: 0.7; + } +} diff --git a/app/components/realtime-chat/realtime-chat.tsx b/app/components/realtime-chat/realtime-chat.tsx new file mode 100644 index 000000000..faa36373a --- /dev/null +++ b/app/components/realtime-chat/realtime-chat.tsx @@ -0,0 +1,359 @@ +import VoiceIcon from "@/app/icons/voice.svg"; +import VoiceOffIcon from "@/app/icons/voice-off.svg"; +import PowerIcon from "@/app/icons/power.svg"; + +import styles from "./realtime-chat.module.scss"; +import clsx from "clsx"; + +import { useState, useRef, useEffect } from "react"; + +import { useChatStore, createMessage, useAppConfig } from "@/app/store"; + +import { IconButton } from "@/app/components/button"; + +import { + Modality, + RTClient, + RTInputAudioItem, + RTResponse, + TurnDetection, +} from "rt-client"; +import { AudioHandler } from "@/app/lib/audio"; +import { uploadImage } from "@/app/utils/chat"; +import { VoicePrint } from "@/app/components/voice-print"; + +interface RealtimeChatProps { + onClose?: () => void; + onStartVoice?: () => void; + onPausedVoice?: () => void; +} + +export function RealtimeChat({ + onClose, + onStartVoice, + onPausedVoice, +}: RealtimeChatProps) { + const chatStore = useChatStore(); + const session = chatStore.currentSession(); + const config = useAppConfig(); + const [status, setStatus] = useState(""); + const [isRecording, setIsRecording] = useState(false); + const [isConnected, setIsConnected] = useState(false); + const [isConnecting, setIsConnecting] = useState(false); + const [modality, setModality] = useState("audio"); + const [useVAD, setUseVAD] = useState(true); + const [frequencies, setFrequencies] = useState(); + + const clientRef = useRef (null); + const audioHandlerRef = useRef (null); + const initRef = useRef(false); + + const temperature = config.realtimeConfig.temperature; + const apiKey = config.realtimeConfig.apiKey; + const model = config.realtimeConfig.model; + const azure = config.realtimeConfig.provider === "Azure"; + const azureEndpoint = config.realtimeConfig.azure.endpoint; + const azureDeployment = config.realtimeConfig.azure.deployment; + const voice = config.realtimeConfig.voice; + + const handleConnect = async () => { + if (isConnecting) return; + if (!isConnected) { + try { + setIsConnecting(true); + clientRef.current = azure + ? new RTClient( + new URL(azureEndpoint), + { key: apiKey }, + { deployment: azureDeployment }, + ) + : new RTClient({ key: apiKey }, { model }); + const modalities: Modality[] = + modality === "audio" ? ["text", "audio"] : ["text"]; + const turnDetection: TurnDetection = useVAD + ? { type: "server_vad" } + : null; + await clientRef.current.configure({ + instructions: "", + voice, + input_audio_transcription: { model: "whisper-1" }, + turn_detection: turnDetection, + tools: [], + temperature, + modalities, + }); + startResponseListener(); + + setIsConnected(true); + // TODO + // try { + // const recentMessages = chatStore.getMessagesWithMemory(); + // for (const message of recentMessages) { + // const { role, content } = message; + // if (typeof content === "string") { + // await clientRef.current.sendItem({ + // type: "message", + // role: role as any, + // content: [ + // { + // type: (role === "assistant" ? "text" : "input_text") as any, + // text: content as string, + // }, + // ], + // }); + // } + // } + // // await clientRef.current.generateResponse(); + // } catch (error) { + // console.error("Set message failed:", error); + // } + } catch (error) { + console.error("Connection failed:", error); + setStatus("Connection failed"); + } finally { + setIsConnecting(false); + } + } else { + await disconnect(); + } + }; + + const disconnect = async () => { + if (clientRef.current) { + try { + await clientRef.current.close(); + clientRef.current = null; + setIsConnected(false); + } catch (error) { + console.error("Disconnect failed:", error); + } + } + }; + + const startResponseListener = async () => { + if (!clientRef.current) return; + + try { + for await (const serverEvent of clientRef.current.events()) { + if (serverEvent.type === "response") { + await handleResponse(serverEvent); + } else if (serverEvent.type === "input_audio") { + await handleInputAudio(serverEvent); + } + } + } catch (error) { + if (clientRef.current) { + console.error("Response iteration error:", error); + } + } + }; + + const handleResponse = async (response: RTResponse) => { + for await (const item of response) { + if (item.type === "message" && item.role === "assistant") { + const botMessage = createMessage({ + role: item.role, + content: "", + }); + // add bot message first + chatStore.updateTargetSession(session, (session) => { + session.messages = session.messages.concat([botMessage]); + }); + let hasAudio = false; + for await (const content of item) { + if (content.type === "text") { + for await (const text of content.textChunks()) { + botMessage.content += text; + } + } else if (content.type === "audio") { + const textTask = async () => { + for await (const text of content.transcriptChunks()) { + botMessage.content += text; + } + }; + const audioTask = async () => { + audioHandlerRef.current?.startStreamingPlayback(); + for await (const audio of content.audioChunks()) { + hasAudio = true; + audioHandlerRef.current?.playChunk(audio); + } + }; + await Promise.all([textTask(), audioTask()]); + } + // update message.content + chatStore.updateTargetSession(session, (session) => { + session.messages = session.messages.concat(); + }); + } + if (hasAudio) { + // upload audio get audio_url + const blob = audioHandlerRef.current?.savePlayFile(); + uploadImage(blob!).then((audio_url) => { + botMessage.audio_url = audio_url; + // update text and audio_url + chatStore.updateTargetSession(session, (session) => { + session.messages = session.messages.concat(); + }); + }); + } + } + } + }; + + const handleInputAudio = async (item: RTInputAudioItem) => { + await item.waitForCompletion(); + if (item.transcription) { + const userMessage = createMessage({ + role: "user", + content: item.transcription, + }); + chatStore.updateTargetSession(session, (session) => { + session.messages = session.messages.concat([userMessage]); + }); + // save input audio_url, and update session + const { audioStartMillis, audioEndMillis } = item; + // upload audio get audio_url + const blob = audioHandlerRef.current?.saveRecordFile( + audioStartMillis, + audioEndMillis, + ); + uploadImage(blob!).then((audio_url) => { + userMessage.audio_url = audio_url; + chatStore.updateTargetSession(session, (session) => { + session.messages = session.messages.concat(); + }); + }); + } + // stop streaming play after get input audio. + audioHandlerRef.current?.stopStreamingPlayback(); + }; + + const toggleRecording = async () => { + if (!isRecording && clientRef.current) { + try { + if (!audioHandlerRef.current) { + audioHandlerRef.current = new AudioHandler(); + await audioHandlerRef.current.initialize(); + } + await audioHandlerRef.current.startRecording(async (chunk) => { + await clientRef.current?.sendAudio(chunk); + }); + setIsRecording(true); + } catch (error) { + console.error("Failed to start recording:", error); + } + } else if (audioHandlerRef.current) { + try { + audioHandlerRef.current.stopRecording(); + if (!useVAD) { + const inputAudio = await clientRef.current?.commitAudio(); + await handleInputAudio(inputAudio!); + await clientRef.current?.generateResponse(); + } + setIsRecording(false); + } catch (error) { + console.error("Failed to stop recording:", error); + } + } + }; + + useEffect(() => { + // 防止重复初始化 + if (initRef.current) return; + initRef.current = true; + + const initAudioHandler = async () => { + const handler = new AudioHandler(); + await handler.initialize(); + audioHandlerRef.current = handler; + await handleConnect(); + await toggleRecording(); + }; + + initAudioHandler().catch((error) => { + setStatus(error); + console.error(error); + }); + + return () => { + if (isRecording) { + toggleRecording(); + } + audioHandlerRef.current?.close().catch(console.error); + disconnect(); + }; + }, []); + + useEffect(() => { + let animationFrameId: number; + + if (isConnected && isRecording) { + const animationFrame = () => { + if (audioHandlerRef.current) { + const freqData = audioHandlerRef.current.getByteFrequencyData(); + setFrequencies(freqData); + } + animationFrameId = requestAnimationFrame(animationFrame); + }; + + animationFrameId = requestAnimationFrame(animationFrame); + } else { + setFrequencies(undefined); + } + + return () => { + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + } + }; + }, [isConnected, isRecording]); + + // update session params + useEffect(() => { + clientRef.current?.configure({ voice }); + }, [voice]); + useEffect(() => { + clientRef.current?.configure({ temperature }); + }, [temperature]); + + const handleClose = async () => { + onClose?.(); + if (isRecording) { + await toggleRecording(); + } + disconnect().catch(console.error); + }; + + return ( + ++ ); +} diff --git a/app/components/realtime-chat/realtime-config.tsx b/app/components/realtime-chat/realtime-config.tsx new file mode 100644 index 000000000..08809afda --- /dev/null +++ b/app/components/realtime-chat/realtime-config.tsx @@ -0,0 +1,173 @@ +import { RealtimeConfig } from "@/app/store"; + +import Locale from "@/app/locales"; +import { ListItem, Select, PasswordInput } from "@/app/components/ui-lib"; + +import { InputRange } from "@/app/components/input-range"; +import { Voice } from "rt-client"; +import { ServiceProvider } from "@/app/constant"; + +const providers = [ServiceProvider.OpenAI, ServiceProvider.Azure]; + +const models = ["gpt-4o-realtime-preview-2024-10-01"]; + +const voice = ["alloy", "shimmer", "echo"]; + +export function RealtimeConfigList(props: { + realtimeConfig: RealtimeConfig; + updateConfig: (updater: (config: RealtimeConfig) => void) => void; +}) { + const azureConfigComponent = props.realtimeConfig.provider === + ServiceProvider.Azure && ( + <> +++ ++ ++++: } + onClick={toggleRecording} + disabled={!isConnected} + shadow + bordered + /> + {status}+++} + onClick={handleClose} + shadow + bordered + /> + + { + props.updateConfig( + (config) => (config.azure.endpoint = e.currentTarget.value), + ); + }} + /> + ++ { + props.updateConfig( + (config) => (config.azure.deployment = e.currentTarget.value), + ); + }} + /> + + > + ); + + return ( + <> ++ + props.updateConfig( + (config) => (config.enable = e.currentTarget.checked), + ) + } + > + + + {props.realtimeConfig.enable && ( + <> ++ + ++ + ++ + {azureConfigComponent} +{ + props.updateConfig( + (config) => (config.apiKey = e.currentTarget.value), + ); + }} + /> + + + ++ + > + )} + > + ); +} diff --git a/app/components/sd/sd-panel.tsx b/app/components/sd/sd-panel.tsx index a71e560dd..15aff0ab6 100644 --- a/app/components/sd/sd-panel.tsx +++ b/app/components/sd/sd-panel.tsx @@ -4,6 +4,7 @@ import { Select } from "@/app/components/ui-lib"; import { IconButton } from "@/app/components/button"; import Locale from "@/app/locales"; import { useSdStore } from "@/app/store/sd"; +import clsx from "clsx"; export const params = [ { @@ -136,7 +137,7 @@ export function ControlParamItem(props: { className?: string; }) { return ( -{ + props.updateConfig( + (config) => + (config.temperature = e.currentTarget.valueAsNumber), + ); + }} + > ++diff --git a/app/components/sd/sd.tsx b/app/components/sd/sd.tsx index 0ace62a83..1ccc0647e 100644 --- a/app/components/sd/sd.tsx +++ b/app/components/sd/sd.tsx @@ -36,6 +36,7 @@ import { removeImage } from "@/app/utils/chat"; import { SideBar } from "./sd-sidebar"; import { WindowContent } from "@/app/components/home"; import { params } from "./sd-panel"; +import clsx from "clsx"; function getSdTaskStatus(item: any) { let s: string; @@ -104,7 +105,7 @@ export function Sd() { return ( <> -+ @@ -121,7 +122,10 @@ export function Sd() {)}Stability AIdiff --git a/app/components/settings.tsx b/app/components/settings.tsx index 9f338718e..68ebcf084 100644 --- a/app/components/settings.tsx +++ b/app/components/settings.tsx @@ -49,7 +49,7 @@ import Locale, { changeLang, getLang, } from "../locales"; -import { copyToClipboard } from "../utils"; +import { copyToClipboard, clientUpdate, semverCompare } from "../utils"; import Link from "next/link"; import { Anthropic, @@ -59,6 +59,7 @@ import { ByteDance, Alibaba, Moonshot, + XAI, Google, GoogleSafetySettingsThreshold, OPENAI_BASE_URL, @@ -71,6 +72,9 @@ import { Stability, Iflytek, SAAS_CHAT_URL, + ChatGLM, + DeepSeek, + SiliconFlow, } from "../constant"; import { Prompt, SearchService, usePromptStore } from "../store/prompt"; import { ErrorBoundary } from "./error"; @@ -83,6 +87,7 @@ import { nanoid } from "nanoid"; import { useMaskStore } from "../store/mask"; import { ProviderType } from "../utils/cloud"; import { TTSConfigList } from "./tts-config"; +import { RealtimeConfigList } from "./realtime-chat/realtime-config"; function EditPromptModal(props: { id: string; onClose: () => void }) { const promptStore = usePromptStore(); @@ -585,7 +590,7 @@ export function Settings() { const [checkingUpdate, setCheckingUpdate] = useState(false); const currentVersion = updateStore.formatVersion(updateStore.version); const remoteId = updateStore.formatVersion(updateStore.remoteVersion); - const hasNewVersion = currentVersion !== remoteId; + const hasNewVersion = semverCompare(currentVersion, remoteId) === -1; const updateUrl = getClientConfig()?.isApp ? RELEASE_URL : UPDATE_URL; function checkUpdate(force = false) { @@ -1194,6 +1199,167 @@ export function Settings() { > ); + const deepseekConfigComponent = accessStore.provider === + ServiceProvider.DeepSeek && ( + <> ++ + accessStore.update( + (access) => (access.deepseekUrl = e.currentTarget.value), + ) + } + > + ++ + > + ); + + const XAIConfigComponent = accessStore.provider === ServiceProvider.XAI && ( + <> +{ + accessStore.update( + (access) => (access.deepseekApiKey = e.currentTarget.value), + ); + }} + /> + + + accessStore.update( + (access) => (access.xaiUrl = e.currentTarget.value), + ) + } + > + ++ + > + ); + + const chatglmConfigComponent = accessStore.provider === + ServiceProvider.ChatGLM && ( + <> +{ + accessStore.update( + (access) => (access.xaiApiKey = e.currentTarget.value), + ); + }} + /> + + + accessStore.update( + (access) => (access.chatglmUrl = e.currentTarget.value), + ) + } + > + ++ + > + ); + const siliconflowConfigComponent = accessStore.provider === + ServiceProvider.SiliconFlow && ( + <> +{ + accessStore.update( + (access) => (access.chatglmApiKey = e.currentTarget.value), + ); + }} + /> + + + accessStore.update( + (access) => (access.siliconflowUrl = e.currentTarget.value), + ) + } + > + ++ + > + ); + const stabilityConfigComponent = accessStore.provider === ServiceProvider.Stability && ( <> @@ -1357,9 +1523,17 @@ export function Settings() { {checkingUpdate ? ({ + accessStore.update( + (access) => (access.siliconflowApiKey = e.currentTarget.value), + ); + }} + /> + ) : hasNewVersion ? ( - - {Locale.Settings.Update.GoToUpdate} - + clientConfig?.isApp ? ( + } + text={Locale.Settings.Update.GoToUpdate} + onClick={() => clientUpdate()} + /> + ) : ( + + {Locale.Settings.Update.GoToUpdate} + + ) ) : ( } @@ -1509,6 +1683,22 @@ export function Settings() { } > + + + updateConfig( + (config) => (config.enableCodeFold = e.currentTarget.checked), + ) + } + > + @@ -1626,8 +1816,12 @@ export function Settings() { {alibabaConfigComponent} {tencentConfigComponent} {moonshotConfigComponent} + {deepseekConfigComponent} {stabilityConfigComponent} {lflytekConfigComponent} + {XAIConfigComponent} + {chatglmConfigComponent} + {siliconflowConfigComponent} > )} > @@ -1662,9 +1856,11 @@ export function Settings() { setShowPromptModal(false)} /> )} - + +
{ + const realtimeConfig = { ...config.realtimeConfig }; + updater(realtimeConfig); + config.update( + (config) => (config.realtimeConfig = realtimeConfig), + ); + }} + /> +
(await import("./chat-list")).ChatList, { loading: () => null, @@ -127,6 +135,7 @@ export function useDragSideBar() { shouldNarrow, }; } + export function SideBarContainer(props: { children: React.ReactNode; onDragStart: (e: MouseEvent) => void; @@ -141,9 +150,9 @@ export function SideBarContainer(props: { const { children, className, onDragStart, shouldNarrow } = props; return ( -+{children} @@ -212,10 +227,21 @@ export function SideBarTail(props: { export function SideBar(props: { className?: string }) { useHotKey(); const { onDragStart, shouldNarrow } = useDragSideBar(); - const [showPluginSelector, setShowPluginSelector] = useState(false); + const [showDiscoverySelector, setshowDiscoverySelector] = useState(false); const navigate = useNavigate(); const config = useAppConfig(); const chatStore = useChatStore(); + const [mcpEnabled, setMcpEnabled] = useState(false); + + useEffect(() => { + // 检查 MCP 是否启用 + const checkMcpStatus = async () => { + const enabled = await isMcpEnabled(); + setMcpEnabled(enabled); + console.log("[SideBar] MCP enabled:", enabled); + }; + checkMcpStatus(); + }, []); return (-{title}{subTitle}{logo}+{logo}} + shouldNarrow={shouldNarrow} > - {showPluginSelector && ( + {showDiscoverySelector && (+ {mcpEnabled && ( + } + 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} className={styles["sidebar-bar-button"]} - onClick={() => setShowPluginSelector(true)} + onClick={() => setshowDiscoverySelector(true)} shadow /> { + ...DISCOVERY.map((item) => { return { title: item.name, value: item.path, }; }), ]} - onClose={() => setShowPluginSelector(false)} + onClose={() => setshowDiscoverySelector(false)} onSelection={(s) => { navigate(s[0], { state: { fromHome: true } }); }} @@ -279,7 +317,7 @@ export function SideBar(props: { className?: string }) { - +} onClick={async () => { diff --git a/app/components/ui-lib.tsx b/app/components/ui-lib.tsx index 4af37dbba..7b9f5ace0 100644 --- a/app/components/ui-lib.tsx +++ b/app/components/ui-lib.tsx @@ -23,6 +23,8 @@ import React, { useRef, } from "react"; import { IconButton } from "./button"; +import { Avatar } from "./emoji"; +import clsx from "clsx"; export function Popover(props: { children: JSX.Element; @@ -45,7 +47,7 @@ export function Popover(props: { export function Card(props: { children: JSX.Element[]; className?: string }) { return ( - {props.children}+{props.children}); } @@ -60,11 +62,13 @@ export function ListItem(props: { }) { return (@@ -135,9 +139,9 @@ export function Modal(props: ModalProps) { return ({props.title}@@ -260,7 +264,7 @@ export function Input(props: InputProps) { return ( ); } @@ -301,9 +305,13 @@ export function Select( const { className, children, align, ...otherProps } = props; return (