| | |
| | | import React, { useEffect, useMemo, useRef, useState } from "react"; |
| | | import { useLocation, useNavigate } from "react-router-dom"; |
| | | import { useNotify, useTranslate } from "react-admin"; |
| | | import ReactMarkdown from "react-markdown"; |
| | | import remarkGfm from "remark-gfm"; |
| | | import { |
| | | Alert, |
| | | Box, |
| | |
| | | List, |
| | | ListItemButton, |
| | | ListItemText, |
| | | MenuItem, |
| | | Paper, |
| | | Stack, |
| | | TextField, |
| | | Typography, |
| | | } from "@mui/material"; |
| | | import { Light as SyntaxHighlighter } from "react-syntax-highlighter"; |
| | | import { atomOneLight } from "react-syntax-highlighter/dist/esm/styles/hljs"; |
| | | import SmartToyOutlinedIcon from "@mui/icons-material/SmartToyOutlined"; |
| | | import SendRoundedIcon from "@mui/icons-material/SendRounded"; |
| | | import StopCircleOutlinedIcon from "@mui/icons-material/StopCircleOutlined"; |
| | |
| | | ANSWER: 2, |
| | | }; |
| | | |
| | | const normalizeMarkdownContent = (content) => { |
| | | if (!content) { |
| | | return ""; |
| | | } |
| | | return content |
| | | .replace(/\r\n/g, "\n") |
| | | .replace(/(\n[-*] .+)\n{2,}(?=[-*] )/g, "$1\n") |
| | | .replace(/(\n\d+\. .+)\n{2,}(?=\d+\. )/g, "$1\n") |
| | | .replace(/([^\n])\n{3,}/g, "$1\n\n"); |
| | | }; |
| | | |
| | | const markdownSx = { |
| | | width: "100%", |
| | | fontSize: "0.84rem", |
| | | "& > *:first-of-type": { |
| | | mt: 0, |
| | | }, |
| | | "& > *:last-child": { |
| | | mb: 0, |
| | | }, |
| | | "& p": { |
| | | m: 0, |
| | | lineHeight: 1.28, |
| | | }, |
| | | "& p + p": { |
| | | mt: 0.1, |
| | | }, |
| | | "& h1, & h2, & h3, & h4, & h5, & h6": { |
| | | mt: 0.25, |
| | | mb: 0.04, |
| | | lineHeight: 1.16, |
| | | fontWeight: 700, |
| | | }, |
| | | "& h1": { |
| | | fontSize: "0.96rem", |
| | | }, |
| | | "& h2": { |
| | | fontSize: "0.92rem", |
| | | }, |
| | | "& h3": { |
| | | fontSize: "0.89rem", |
| | | }, |
| | | "& ul, & ol": { |
| | | my: 0.02, |
| | | pl: 1.3, |
| | | }, |
| | | "& ul > li, & ol > li": { |
| | | lineHeight: 1.2, |
| | | }, |
| | | "& li + li": { |
| | | mt: 0, |
| | | }, |
| | | "& li > p": { |
| | | display: "inline", |
| | | m: 0, |
| | | lineHeight: "inherit", |
| | | }, |
| | | "& li::marker": { |
| | | fontSize: "0.78rem", |
| | | }, |
| | | "& blockquote": { |
| | | m: 0, |
| | | mt: 0.18, |
| | | px: 0.7, |
| | | py: 0.25, |
| | | borderLeft: "3px solid rgba(25, 118, 210, 0.35)", |
| | | bgcolor: "rgba(25, 118, 210, 0.06)", |
| | | }, |
| | | "& hr": { |
| | | my: 0.25, |
| | | border: 0, |
| | | borderTop: "1px solid rgba(0, 0, 0, 0.12)", |
| | | }, |
| | | "& table": { |
| | | width: "100%", |
| | | borderCollapse: "collapse", |
| | | mt: 0.18, |
| | | mb: 0.04, |
| | | fontSize: "0.78rem", |
| | | }, |
| | | "& th, & td": { |
| | | border: "1px solid rgba(0, 0, 0, 0.12)", |
| | | px: 0.4, |
| | | py: 0.22, |
| | | textAlign: "left", |
| | | verticalAlign: "top", |
| | | }, |
| | | "& th": { |
| | | bgcolor: "rgba(0, 0, 0, 0.04)", |
| | | fontWeight: 700, |
| | | }, |
| | | "& a": { |
| | | color: "primary.main", |
| | | textDecoration: "underline", |
| | | wordBreak: "break-all", |
| | | }, |
| | | "& img": { |
| | | maxWidth: "100%", |
| | | borderRadius: 1.5, |
| | | }, |
| | | "& code": { |
| | | fontFamily: "'Consolas', 'Monaco', monospace", |
| | | }, |
| | | }; |
| | | |
| | | const AiMarkdownContent = ({ content }) => ( |
| | | <Box sx={markdownSx}> |
| | | <ReactMarkdown |
| | | remarkPlugins={[remarkGfm]} |
| | | components={{ |
| | | p: ({ children }) => <Typography variant="body2">{children}</Typography>, |
| | | li: ({ children }) => <Box component="li" sx={{ fontSize: "0.875rem" }}>{children}</Box>, |
| | | blockquote: ({ children }) => <Box component="blockquote">{children}</Box>, |
| | | a: ({ href, children }) => ( |
| | | <Box |
| | | component="a" |
| | | href={href} |
| | | target="_blank" |
| | | rel="noreferrer" |
| | | > |
| | | {children} |
| | | </Box> |
| | | ), |
| | | code({ inline, className, children, ...props }) { |
| | | const match = /language-(\w+)/.exec(className || ""); |
| | | const code = String(children).replace(/\n$/, ""); |
| | | if (!inline) { |
| | | return ( |
| | | <Box sx={{ mt: 0.7, mb: 0.2, borderRadius: 1.5, overflow: "hidden" }}> |
| | | <SyntaxHighlighter |
| | | language={match?.[1]} |
| | | style={atomOneLight} |
| | | customStyle={{ |
| | | margin: 0, |
| | | padding: "6px 8px", |
| | | borderRadius: 12, |
| | | fontSize: "0.74rem", |
| | | }} |
| | | wrapLongLines |
| | | PreTag="div" |
| | | {...props} |
| | | > |
| | | {code} |
| | | </SyntaxHighlighter> |
| | | </Box> |
| | | ); |
| | | } |
| | | return ( |
| | | <Box |
| | | component="code" |
| | | sx={{ |
| | | px: 0.45, |
| | | py: "1px", |
| | | borderRadius: 0.75, |
| | | bgcolor: "rgba(0, 0, 0, 0.08)", |
| | | fontSize: "0.74em", |
| | | }} |
| | | {...props} |
| | | > |
| | | {children} |
| | | </Box> |
| | | ); |
| | | }, |
| | | }} |
| | | > |
| | | {normalizeMarkdownContent(content)} |
| | | </ReactMarkdown> |
| | | </Box> |
| | | ); |
| | | |
| | | const AiChatDrawer = ({ open, onClose }) => { |
| | | const navigate = useNavigate(); |
| | | const location = useLocation(); |
| | |
| | | const messagesContainerRef = useRef(null); |
| | | const messagesBottomRef = useRef(null); |
| | | const [runtime, setRuntime] = useState(null); |
| | | const [selectedAiParamId, setSelectedAiParamId] = useState(null); |
| | | const [sessionId, setSessionId] = useState(null); |
| | | const [sessions, setSessions] = useState([]); |
| | | const [persistedMessages, setPersistedMessages] = useState([]); |
| | |
| | | ]), [translate]); |
| | | |
| | | const promptCode = runtime?.promptCode || DEFAULT_PROMPT_CODE; |
| | | const selectableModelOptions = useMemo(() => { |
| | | if (runtime?.modelOptions?.length) { |
| | | return runtime.modelOptions; |
| | | } |
| | | if (runtime?.model) { |
| | | return [{ |
| | | aiParamId: runtime?.aiParamId ?? "CURRENT_MODEL", |
| | | name: runtime.model, |
| | | model: runtime.model, |
| | | active: true, |
| | | }]; |
| | | } |
| | | return []; |
| | | }, [runtime]); |
| | | |
| | | const runtimeSummary = useMemo(() => { |
| | | return { |
| | |
| | | ]); |
| | | }; |
| | | |
| | | const loadRuntime = async (targetSessionId = null) => { |
| | | const loadRuntime = async (targetSessionId = null, targetAiParamId = selectedAiParamId) => { |
| | | setLoadingRuntime(true); |
| | | setDrawerError(""); |
| | | try { |
| | | const data = await getAiRuntime(DEFAULT_PROMPT_CODE, targetSessionId); |
| | | const data = await getAiRuntime(DEFAULT_PROMPT_CODE, targetSessionId, targetAiParamId); |
| | | const historyMessages = data?.persistedMessages || []; |
| | | setRuntime(data); |
| | | setSelectedAiParamId(data?.aiParamId ?? null); |
| | | setSessionId(data?.sessionId || null); |
| | | setPersistedMessages(historyMessages); |
| | | setMessages(historyMessages); |
| | | return data; |
| | | } catch (error) { |
| | | const message = error.message || translate("ai.drawer.runtimeFailed"); |
| | | setDrawerError(message); |
| | | return null; |
| | | } finally { |
| | | setLoadingRuntime(false); |
| | | } |
| | |
| | | setThinkingEvents([]); |
| | | setThinkingExpanded(true); |
| | | await loadRuntime(targetSessionId); |
| | | }; |
| | | |
| | | const handleModelChange = async (event) => { |
| | | if (streaming) { |
| | | return; |
| | | } |
| | | const rawValue = event.target.value; |
| | | const nextAiParamId = rawValue === "" ? null : Number(rawValue); |
| | | if (nextAiParamId === selectedAiParamId) { |
| | | return; |
| | | } |
| | | const previousAiParamId = selectedAiParamId; |
| | | setSelectedAiParamId(nextAiParamId); |
| | | const data = await loadRuntime(sessionId, nextAiParamId); |
| | | if (!data) { |
| | | setSelectedAiParamId(previousAiParamId); |
| | | notify(translate("ai.drawer.modelSwitchFailed"), { type: "error" }); |
| | | } |
| | | }; |
| | | |
| | | const handleDeleteSession = async (targetSessionId) => { |
| | |
| | | |
| | | let completed = false; |
| | | let completedSessionId = sessionId; |
| | | let completedAiParamId = selectedAiParamId; |
| | | |
| | | try { |
| | | await streamAiChat( |
| | | { |
| | | sessionId, |
| | | aiParamId: selectedAiParamId, |
| | | promptCode, |
| | | messages: memoryMessages, |
| | | metadata: { |
| | |
| | | onEvent: (eventName, payload) => { |
| | | if (eventName === "start") { |
| | | setRuntime(payload); |
| | | setSelectedAiParamId(payload?.aiParamId ?? null); |
| | | if (payload?.sessionId) { |
| | | setSessionId(payload.sessionId); |
| | | completedSessionId = payload.sessionId; |
| | | } |
| | | completedAiParamId = payload?.aiParamId ?? completedAiParamId; |
| | | } |
| | | if (eventName === "delta") { |
| | | appendAssistantDelta(payload?.content || ""); |
| | |
| | | setStreaming(false); |
| | | if (completed) { |
| | | await Promise.all([ |
| | | loadRuntime(completedSessionId), |
| | | loadRuntime(completedSessionId, completedAiParamId), |
| | | loadSessions(sessionKeyword), |
| | | ]); |
| | | } |
| | |
| | | <Typography variant="caption" display="block" sx={{ opacity: 0.72, mb: 0.5 }}> |
| | | {message.role === "user" ? translate("ai.drawer.userRole") : translate("ai.drawer.assistantRole")} |
| | | </Typography> |
| | | <Typography variant="body2"> |
| | | {message.content || (streaming && index === messages.length - 1 ? translate("ai.drawer.thinking") : "")} |
| | | </Typography> |
| | | {message.role === "assistant" ? ( |
| | | <AiMarkdownContent |
| | | content={message.content || (streaming && index === messages.length - 1 |
| | | ? translate("ai.drawer.thinking") |
| | | : "")} |
| | | /> |
| | | ) : ( |
| | | <Typography variant="body2" sx={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}> |
| | | {message.content || ""} |
| | | </Typography> |
| | | )} |
| | | </Paper> |
| | | </Stack> |
| | | </Box> |
| | |
| | | maxRows={6} |
| | | placeholder={translate("ai.drawer.inputPlaceholder")} |
| | | /> |
| | | <Stack direction="row" spacing={1} justifyContent="flex-end" mt={1.25}> |
| | | <Stack |
| | | direction={{ xs: "column", sm: "row" }} |
| | | spacing={1} |
| | | justifyContent="space-between" |
| | | alignItems={{ xs: "stretch", sm: "center" }} |
| | | mt={1.25} |
| | | > |
| | | {!!selectableModelOptions.length && ( |
| | | <TextField |
| | | select |
| | | size="small" |
| | | label={translate("ai.drawer.modelSelectorLabel")} |
| | | value={selectedAiParamId ?? runtime?.aiParamId ?? selectableModelOptions[0]?.aiParamId ?? ""} |
| | | onChange={handleModelChange} |
| | | disabled={streaming || loadingRuntime || selectableModelOptions.length <= 1} |
| | | SelectProps={{ |
| | | MenuProps: { |
| | | disableScrollLock: true, |
| | | sx: { |
| | | zIndex: 1605, |
| | | }, |
| | | PaperProps: { |
| | | sx: { |
| | | zIndex: 1606, |
| | | }, |
| | | }, |
| | | }, |
| | | }} |
| | | sx={{ |
| | | minWidth: { xs: "100%", sm: 260 }, |
| | | maxWidth: { xs: "100%", sm: 320 }, |
| | | }} |
| | | > |
| | | {selectableModelOptions.map((item) => ( |
| | | <MenuItem key={String(item.aiParamId)} value={item.aiParamId}> |
| | | {`${item.name || item.model || "--"}${item.model && item.name !== item.model ? ` / ${item.model}` : ""}${item.active ? ` ${translate("ai.drawer.defaultModelSuffix")}` : ""}`} |
| | | </MenuItem> |
| | | ))} |
| | | </TextField> |
| | | )} |
| | | <Stack direction="row" spacing={1} justifyContent="flex-end"> |
| | | <Button onClick={() => setInput("")}>{translate("ai.drawer.clearInput")}</Button> |
| | | {streaming ? ( |
| | | <Button variant="outlined" color="warning" startIcon={<StopCircleOutlinedIcon />} onClick={() => stopStream(true)}> |
| | |
| | | {translate("ai.drawer.send")} |
| | | </Button> |
| | | )} |
| | | </Stack> |
| | | </Stack> |
| | | </Box> |
| | | </Box> |