zhou zhou
17 小时以前 4954d3978cf1967729a5a2d5b90f6baef18974da
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>