From 4954d3978cf1967729a5a2d5b90f6baef18974da Mon Sep 17 00:00:00 2001
From: zhou zhou <3272660260@qq.com>
Date: 星期一, 23 三月 2026 09:35:10 +0800
Subject: [PATCH] #ai redis+页面优化

---
 rsf-admin/src/layout/AiChatDrawer.jsx |  278 ++++++++++++++++++++++++++++++++++++++++++++++++++++++-
 1 files changed, 271 insertions(+), 7 deletions(-)

diff --git a/rsf-admin/src/layout/AiChatDrawer.jsx b/rsf-admin/src/layout/AiChatDrawer.jsx
index 4eaeea9..92f29df 100644
--- a/rsf-admin/src/layout/AiChatDrawer.jsx
+++ b/rsf-admin/src/layout/AiChatDrawer.jsx
@@ -1,6 +1,8 @@
 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,
@@ -17,11 +19,14 @@
     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";
@@ -48,6 +53,176 @@
     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();
@@ -57,6 +232,7 @@
     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([]);
@@ -80,6 +256,20 @@
     ]), [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 {
@@ -138,19 +328,22 @@
         ]);
     };
 
-    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);
         }
@@ -201,6 +394,24 @@
         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) => {
@@ -433,11 +644,13 @@
 
         let completed = false;
         let completedSessionId = sessionId;
+        let completedAiParamId = selectedAiParamId;
 
         try {
             await streamAiChat(
                 {
                     sessionId,
+                    aiParamId: selectedAiParamId,
                     promptCode,
                     messages: memoryMessages,
                     metadata: {
@@ -449,10 +662,12 @@
                     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 || "");
@@ -490,7 +705,7 @@
             setStreaming(false);
             if (completed) {
                 await Promise.all([
-                    loadRuntime(completedSessionId),
+                    loadRuntime(completedSessionId, completedAiParamId),
                     loadSessions(sessionKeyword),
                 ]);
             }
@@ -887,9 +1102,17 @@
                                             <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>
@@ -925,7 +1148,47 @@
                                 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)}>
@@ -936,6 +1199,7 @@
                                         {translate("ai.drawer.send")}
                                     </Button>
                                 )}
+                                </Stack>
                             </Stack>
                         </Box>
                     </Box>

--
Gitblit v1.9.1