zhou zhou
7 小时以前 05148eeef860d33232874a640dbd67ba43ac5686
#AI.配置中心可运营化
11个文件已添加
19个文件已修改
1030 ■■■■■ 已修改文件
rsf-admin/src/api/ai/configCenter.js 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/api/ai/mcpMount.js 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/page/system/aiMcpMount/AiMcpMountForm.jsx 67 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/page/system/aiMcpMount/AiMcpMountList.jsx 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/page/system/aiParam/AiParamForm.jsx 81 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/page/system/aiParam/AiParamList.jsx 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/page/system/aiPrompt/AiPromptForm.jsx 113 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/page/system/aiPrompt/AiPromptList.jsx 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/page/system/aiShared/AiRuntimeSummary.jsx 104 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/config/AiDefaults.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiConfigOpsController.java 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiMcpMountController.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiParamController.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiPromptController.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiConfigSummaryDto.java 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiParamValidateResultDto.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiPromptPreviewDto.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiPromptPreviewRequest.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiParam.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiConfigOpsService.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiMcpMountService.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiParamService.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiPromptService.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiConfigOpsServiceImpl.java 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiMcpMountServiceImpl.java 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiParamServiceImpl.java 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiParamValidationSupport.java 106 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiPromptRenderSupport.java 81 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiPromptServiceImpl.java 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
version/db/ai_feature.sql 72 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/api/ai/configCenter.js
New file
@@ -0,0 +1,28 @@
import request from "@/utils/request";
export const getAiConfigSummary = async (promptCode = "home.default") => {
    const res = await request.get("ai/config/summary", { params: { promptCode } });
    const { code, msg, data } = res.data;
    if (code === 200) {
        return data;
    }
    throw new Error(msg || "获取 AI 运行态摘要失败");
};
export const validateAiParamDraft = async (payload) => {
    const res = await request.post("aiParam/validate-draft", payload);
    const { code, msg, data } = res.data;
    if (code === 200) {
        return data;
    }
    throw new Error(msg || "AI 参数验证失败");
};
export const renderAiPromptPreview = async (payload) => {
    const res = await request.post("aiPrompt/render-preview", payload);
    const { code, msg, data } = res.data;
    if (code === 200) {
        return data;
    }
    throw new Error(msg || "Prompt 预览失败");
};
rsf-admin/src/api/ai/mcpMount.js
@@ -18,6 +18,15 @@
    throw new Error(msg || "连通性测试失败");
};
export const validateDraftMcpConnectivity = async (payload) => {
    const res = await request.post("aiMcpMount/connectivity/validate-draft", payload);
    const { code, msg, data } = res.data;
    if (code === 200) {
        return data;
    }
    throw new Error(msg || "草稿连通性测试失败");
};
export const testMcpTool = async (mountId, payload) => {
    const res = await request.post(`aiMcpMount/${mountId}/tool/test`, payload);
    const { code, msg, data } = res.data;
rsf-admin/src/page/system/aiMcpMount/AiMcpMountForm.jsx
@@ -1,18 +1,72 @@
import React from "react";
import React, { useState } from "react";
import {
    FormDataConsumer,
    NumberInput,
    SelectInput,
    TextInput,
    useNotify,
} from "react-admin";
import { Grid, Typography } from "@mui/material";
import { Alert, Button, Grid, Stack, Typography } from "@mui/material";
import StatusSelectInput from "@/page/components/StatusSelectInput";
import { validateDraftMcpConnectivity } from "@/api/ai/mcpMount";
const transportChoices = [
    { id: "SSE_HTTP", name: "SSE_HTTP" },
    { id: "STDIO", name: "STDIO" },
    { id: "BUILTIN", name: "BUILTIN" },
];
const AiMcpDraftTestSection = ({ formData, readOnly }) => {
    const notify = useNotify();
    const [loading, setLoading] = useState(false);
    const [result, setResult] = useState(null);
    const handleValidate = async () => {
        setLoading(true);
        try {
            const data = await validateDraftMcpConnectivity(formData);
            setResult(data);
            notify(data?.message || "草稿连通性测试完成");
        } catch (error) {
            const nextResult = {
                healthStatus: "UNHEALTHY",
                message: error?.message || "草稿连通性测试失败",
            };
            setResult(nextResult);
            notify(nextResult.message, { type: "error" });
        } finally {
            setLoading(false);
        }
    };
    if (readOnly) {
        return null;
    }
    return (
        <>
            <Grid item xs={12}>
                <Stack direction="row" spacing={1} alignItems="center">
                    <Button variant="outlined" onClick={handleValidate} disabled={loading}>
                        {loading ? "测试中..." : "保存前测试"}
                    </Button>
                    <Typography variant="body2" color="text.secondary">
                        用当前草稿配置直接校验连通性,不会落库。
                    </Typography>
                </Stack>
            </Grid>
            {result && (
                <Grid item xs={12}>
                    <Alert severity={result.healthStatus === "HEALTHY" ? "success" : "error"}>
                        {result.message}
                        {result.initElapsedMs ? ` · ${result.initElapsedMs} ms` : ""}
                        {result.testedAt ? ` · ${result.testedAt}` : ""}
                    </Alert>
                </Grid>
            )}
        </>
    );
};
const AiMcpMountForm = ({ readOnly = false }) => (
    <Grid container spacing={2} width={{ xs: "100%", xl: "80%" }}>
@@ -87,6 +141,9 @@
        <Grid item xs={12}>
            <TextInput source="memo" label="备注" fullWidth multiline minRows={3} disabled={readOnly} />
        </Grid>
        <FormDataConsumer>
            {({ formData }) => <AiMcpDraftTestSection formData={formData} readOnly={readOnly} />}
        </FormDataConsumer>
        <Grid item xs={12}>
            <Typography variant="h6">运行态信息</Typography>
        </Grid>
@@ -102,6 +159,12 @@
        <Grid item xs={12}>
            <TextInput source="lastTestMessage" label="最近测试结果" fullWidth multiline minRows={3} disabled />
        </Grid>
        <Grid item xs={12} md={6}>
            <TextInput source="updateBy" label="最近更新人" fullWidth disabled />
        </Grid>
        <Grid item xs={12} md={6}>
            <TextInput source="updateTime$" label="最近更新时间" fullWidth disabled />
        </Grid>
    </Grid>
);
rsf-admin/src/page/system/aiMcpMount/AiMcpMountList.jsx
@@ -33,6 +33,7 @@
import AiConfigDialog from "../aiShared/AiConfigDialog";
import AiMcpMountToolsPanel from "./AiMcpMountToolsPanel";
import { testMcpConnectivity } from "@/api/ai/mcpMount";
import AiRuntimeSummary from "../aiShared/AiRuntimeSummary";
const filters = [
    <SearchInput source="condition" alwaysOn />,
@@ -298,6 +299,7 @@
                    </TopToolbar>
                )}
            >
                <AiRuntimeSummary />
                <AiMcpMountCards
                    onView={(id) => openDialog("show", id)}
                    onEdit={(id) => openDialog("edit", id)}
rsf-admin/src/page/system/aiParam/AiParamForm.jsx
@@ -1,16 +1,69 @@
import React from "react";
import React, { useState } from "react";
import {
    BooleanInput,
    FormDataConsumer,
    NumberInput,
    SelectInput,
    TextInput,
    useNotify,
} from "react-admin";
import { Grid, Typography } from "@mui/material";
import { Alert, Button, Grid, Stack, Typography } from "@mui/material";
import StatusSelectInput from "@/page/components/StatusSelectInput";
import { validateAiParamDraft } from "@/api/ai/configCenter";
const providerChoices = [
    { id: "OPENAI_COMPATIBLE", name: "OPENAI_COMPATIBLE" },
];
const AiParamValidateSection = ({ formData, readOnly }) => {
    const notify = useNotify();
    const [loading, setLoading] = useState(false);
    const [result, setResult] = useState(null);
    const handleValidate = async () => {
        setLoading(true);
        try {
            const data = await validateAiParamDraft(formData);
            setResult(data);
            notify(data?.message || "AI 参数验证成功");
        } catch (error) {
            const nextResult = {
                status: "INVALID",
                message: error?.message || "AI 参数验证失败",
            };
            setResult(nextResult);
            notify(nextResult.message, { type: "error" });
        } finally {
            setLoading(false);
        }
    };
    return (
        <>
            {!readOnly && (
                <Grid item xs={12}>
                    <Stack direction="row" spacing={1} alignItems="center">
                        <Button variant="outlined" onClick={handleValidate} disabled={loading}>
                            {loading ? "验证中..." : "保存前验证"}
                        </Button>
                        <Typography variant="body2" color="text.secondary">
                            会直接校验当前 Base URL、API Key 与模型是否可调用。
                        </Typography>
                    </Stack>
                </Grid>
            )}
            {result && (
                <Grid item xs={12}>
                    <Alert severity={result.status === "VALID" ? "success" : "error"}>
                        {result.message}
                        {result.elapsedMs ? ` · ${result.elapsedMs} ms` : ""}
                        {result.validatedAt ? ` · ${result.validatedAt}` : ""}
                    </Alert>
                </Grid>
            )}
        </>
    );
};
const AiParamForm = ({ readOnly = false }) => (
    <Grid container spacing={2} width={{ xs: "100%", xl: "80%" }}>
@@ -53,6 +106,30 @@
        <Grid item xs={12}>
            <TextInput source="memo" label="备注" fullWidth multiline minRows={3} disabled={readOnly} />
        </Grid>
        <FormDataConsumer>
            {({ formData }) => <AiParamValidateSection formData={formData} readOnly={readOnly} />}
        </FormDataConsumer>
        <Grid item xs={12}>
            <Typography variant="h6">运行与审计信息</Typography>
        </Grid>
        <Grid item xs={12} md={3}>
            <TextInput source="validateStatus" label="最近校验状态" fullWidth disabled />
        </Grid>
        <Grid item xs={12} md={3}>
            <TextInput source="lastValidateElapsedMs" label="最近校验耗时(ms)" fullWidth disabled />
        </Grid>
        <Grid item xs={12} md={3}>
            <TextInput source="lastValidateTime$" label="最近校验时间" fullWidth disabled />
        </Grid>
        <Grid item xs={12} md={3}>
            <TextInput source="updateBy" label="最近更新人" fullWidth disabled />
        </Grid>
        <Grid item xs={12}>
            <TextInput source="lastValidateMessage" label="最近校验结果" fullWidth multiline minRows={3} disabled />
        </Grid>
        <Grid item xs={12}>
            <TextInput source="updateTime$" label="最近更新时间" fullWidth disabled />
        </Grid>
    </Grid>
);
rsf-admin/src/page/system/aiParam/AiParamList.jsx
@@ -31,6 +31,7 @@
import MyExportButton from "@/page/components/MyExportButton";
import AiParamForm from "./AiParamForm";
import AiConfigDialog from "../aiShared/AiConfigDialog";
import AiRuntimeSummary from "../aiShared/AiRuntimeSummary";
const filters = [
    <SearchInput source="condition" alwaysOn />,
@@ -232,6 +233,7 @@
                    </TopToolbar>
                )}
            >
                <AiRuntimeSummary />
                <AiParamCards
                    onView={(id) => openDialog("show", id)}
                    onEdit={(id) => openDialog("edit", id)}
rsf-admin/src/page/system/aiPrompt/AiPromptForm.jsx
@@ -1,9 +1,106 @@
import React from "react";
import React, { useState } from "react";
import {
    FormDataConsumer,
    TextInput,
    useNotify,
} from "react-admin";
import { Grid, Typography } from "@mui/material";
import { Alert, Button, Grid, Stack, TextField, Typography } from "@mui/material";
import StatusSelectInput from "@/page/components/StatusSelectInput";
import { renderAiPromptPreview } from "@/api/ai/configCenter";
const AiPromptPreviewSection = ({ formData }) => {
    const notify = useNotify();
    const [input, setInput] = useState("请帮我总结当前页面能做什么");
    const [metadataText, setMetadataText] = useState("{\"path\":\"/system/aiPrompt\"}");
    const [preview, setPreview] = useState(null);
    const [loading, setLoading] = useState(false);
    const handlePreview = async () => {
        setLoading(true);
        try {
            const metadata = metadataText ? JSON.parse(metadataText) : {};
            const data = await renderAiPromptPreview({
                ...formData,
                input,
                metadata,
            });
            setPreview(data);
            notify("Prompt 预览完成");
        } catch (error) {
            setPreview(null);
            notify(error?.message || "Prompt 预览失败", { type: "error" });
        } finally {
            setLoading(false);
        }
    };
    return (
        <>
            <Grid item xs={12}>
                <Typography variant="h6">Prompt 预览</Typography>
            </Grid>
            <Grid item xs={12}>
                <TextField
                    label="示例输入"
                    value={input}
                    onChange={(event) => setInput(event.target.value)}
                    fullWidth
                    multiline
                    minRows={3}
                />
            </Grid>
            <Grid item xs={12}>
                <TextField
                    label="示例元数据 JSON"
                    value={metadataText}
                    onChange={(event) => setMetadataText(event.target.value)}
                    fullWidth
                    multiline
                    minRows={3}
                />
            </Grid>
            <Grid item xs={12}>
                <Stack direction="row" spacing={1} alignItems="center">
                    <Button variant="outlined" onClick={handlePreview} disabled={loading}>
                        {loading ? "预览中..." : "预览渲染"}
                    </Button>
                    <Typography variant="body2" color="text.secondary">
                        用当前表单内容渲染 System Prompt 和 User Prompt。
                    </Typography>
                </Stack>
            </Grid>
            {preview && (
                <>
                    <Grid item xs={12}>
                        <Alert severity="success">
                            已解析变量:{(preview.resolvedVariables || []).join(", ") || "无"}
                        </Alert>
                    </Grid>
                    <Grid item xs={12}>
                        <TextField
                            label="渲染后的 System Prompt"
                            value={preview.renderedSystemPrompt || ""}
                            fullWidth
                            multiline
                            minRows={5}
                            InputProps={{ readOnly: true }}
                        />
                    </Grid>
                    <Grid item xs={12}>
                        <TextField
                            label="渲染后的 User Prompt"
                            value={preview.renderedUserPrompt || ""}
                            fullWidth
                            multiline
                            minRows={5}
                            InputProps={{ readOnly: true }}
                        />
                    </Grid>
                </>
            )}
        </>
    );
};
const AiPromptForm = ({ readOnly = false }) => (
    <Grid container spacing={2} width={{ xs: "100%", xl: "80%" }}>
@@ -31,6 +128,18 @@
        <Grid item xs={12}>
            <TextInput source="memo" label="备注" fullWidth multiline minRows={3} disabled={readOnly} />
        </Grid>
        <FormDataConsumer>
            {({ formData }) => <AiPromptPreviewSection formData={formData} />}
        </FormDataConsumer>
        <Grid item xs={12}>
            <Typography variant="h6">运行与审计信息</Typography>
        </Grid>
        <Grid item xs={12} md={6}>
            <TextInput source="updateBy" label="最近更新人" fullWidth disabled />
        </Grid>
        <Grid item xs={12} md={6}>
            <TextInput source="updateTime$" label="最近更新时间" fullWidth disabled />
        </Grid>
    </Grid>
);
rsf-admin/src/page/system/aiPrompt/AiPromptList.jsx
@@ -31,6 +31,7 @@
import MyExportButton from "@/page/components/MyExportButton";
import AiPromptForm from "./AiPromptForm";
import AiConfigDialog from "../aiShared/AiConfigDialog";
import AiRuntimeSummary from "../aiShared/AiRuntimeSummary";
const filters = [
    <SearchInput source="condition" alwaysOn />,
@@ -203,6 +204,7 @@
                    </TopToolbar>
                )}
            >
                <AiRuntimeSummary />
                <AiPromptCards
                    onView={(id) => openDialog("show", id)}
                    onEdit={(id) => openDialog("edit", id)}
rsf-admin/src/page/system/aiShared/AiRuntimeSummary.jsx
New file
@@ -0,0 +1,104 @@
import React, { useEffect, useState } from "react";
import { Alert, Box, Card, CardContent, Chip, CircularProgress, Grid, Stack, Typography } from "@mui/material";
import { getAiConfigSummary } from "@/api/ai/configCenter";
const AiRuntimeSummary = ({ promptCode = "home.default" }) => {
    const [summary, setSummary] = useState(null);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState("");
    useEffect(() => {
        let active = true;
        setLoading(true);
        setError("");
        getAiConfigSummary(promptCode)
            .then((data) => {
                if (!active) {
                    return;
                }
                setSummary(data);
            })
            .catch((err) => {
                if (!active) {
                    return;
                }
                setError(err?.message || "获取运行态摘要失败");
            })
            .finally(() => {
                if (active) {
                    setLoading(false);
                }
            });
        return () => {
            active = false;
        };
    }, [promptCode]);
    return (
        <Box px={2} pt={2}>
            <Card
                variant="outlined"
                sx={{
                    borderRadius: 3,
                    boxShadow: "0 8px 24px rgba(15, 23, 42, 0.06)",
                }}
            >
                <CardContent>
                    <Stack direction="row" justifyContent="space-between" alignItems="center" mb={2}>
                        <Box>
                            <Typography variant="h6">当前运行态</Typography>
                            <Typography variant="body2" color="text.secondary">
                                展示当前生效的模型、Prompt 与 MCP 挂载信息。
                            </Typography>
                        </Box>
                        {loading && <CircularProgress size={24} />}
                    </Stack>
                    {error && <Alert severity="error">{error}</Alert>}
                    {!loading && !error && summary && (
                        <Grid container spacing={2}>
                            <Grid item xs={12} md={4}>
                                <Typography variant="caption" color="text.secondary">当前模型</Typography>
                                <Typography variant="body1">{summary.activeModel || "--"}</Typography>
                                <Typography variant="body2" color="text.secondary">
                                    {summary.activeParamName || "--"}
                                </Typography>
                                <Stack direction="row" spacing={1} mt={1} flexWrap="wrap" useFlexGap>
                                    <Chip size="small" label={`校验 ${summary.activeParamValidateStatus || "--"}`} />
                                    <Chip size="small" variant="outlined" label={summary.activeParamValidatedAt || "未校验"} />
                                </Stack>
                            </Grid>
                            <Grid item xs={12} md={4}>
                                <Typography variant="caption" color="text.secondary">当前 Prompt</Typography>
                                <Typography variant="body1">{summary.promptName || "--"}</Typography>
                                <Typography variant="body2" color="text.secondary">
                                    {summary.promptCode || "--"} / {summary.promptScene || "--"}
                                </Typography>
                                <Typography variant="body2" color="text.secondary" mt={1}>
                                    最近更新:{summary.activePromptUpdatedAt || "--"} / {summary.activePromptUpdatedBy || "--"}
                                </Typography>
                            </Grid>
                            <Grid item xs={12} md={4}>
                                <Typography variant="caption" color="text.secondary">已启用 MCP</Typography>
                                <Typography variant="body1">{summary.enabledMcpCount ?? 0} 个</Typography>
                                <Stack direction="row" spacing={1} mt={1} flexWrap="wrap" useFlexGap>
                                    {(summary.enabledMcpNames || []).map((name) => (
                                        <Chip key={name} size="small" variant="outlined" label={name} />
                                    ))}
                                </Stack>
                            </Grid>
                            {summary.activeParamValidateMessage && (
                                <Grid item xs={12}>
                                    <Alert severity={summary.activeParamValidateStatus === "VALID" ? "success" : "warning"}>
                                        {summary.activeParamValidateMessage}
                                    </Alert>
                                </Grid>
                            )}
                        </Grid>
                    )}
                </CardContent>
            </Card>
        </Box>
    );
};
export default AiRuntimeSummary;
rsf-server/src/main/java/com/vincent/rsf/server/ai/config/AiDefaults.java
@@ -17,6 +17,9 @@
    public static final String MCP_HEALTH_NOT_TESTED = "NOT_TESTED";
    public static final String MCP_HEALTH_HEALTHY = "HEALTHY";
    public static final String MCP_HEALTH_UNHEALTHY = "UNHEALTHY";
    public static final String PARAM_VALIDATE_NOT_TESTED = "NOT_TESTED";
    public static final String PARAM_VALIDATE_VALID = "VALID";
    public static final String PARAM_VALIDATE_INVALID = "INVALID";
    public static final long SSE_TIMEOUT_MS = 0L;
    public static final int DEFAULT_TIMEOUT_MS = 60000;
    public static final double DEFAULT_TEMPERATURE = 0.7D;
rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiConfigOpsController.java
New file
@@ -0,0 +1,32 @@
package com.vincent.rsf.server.ai.controller;
import com.vincent.rsf.framework.common.R;
import com.vincent.rsf.server.ai.dto.AiPromptPreviewRequest;
import com.vincent.rsf.server.ai.service.AiConfigOpsService;
import com.vincent.rsf.server.system.controller.BaseController;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequiredArgsConstructor
public class AiConfigOpsController extends BaseController {
    private final AiConfigOpsService aiConfigOpsService;
    @PreAuthorize("hasAnyAuthority('system:aiParam:list','system:aiPrompt:list','system:aiMcpMount:list')")
    @GetMapping("/ai/config/summary")
    public R getSummary(@RequestParam(value = "promptCode", required = false) String promptCode) {
        return R.ok().add(aiConfigOpsService.getSummary(promptCode, getTenantId()));
    }
    @PreAuthorize("hasAnyAuthority('system:aiPrompt:list','system:aiPrompt:save','system:aiPrompt:update')")
    @PostMapping("/ai/config/prompt/render-preview")
    public R renderPromptPreview(@RequestBody AiPromptPreviewRequest request) {
        return R.ok().add(aiConfigOpsService.renderPromptPreview(request, getTenantId()));
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiMcpMountController.java
@@ -76,6 +76,13 @@
        return R.ok().add(aiMcpMountService.testConnectivity(id, getLoginUserId(), getTenantId()));
    }
    @PreAuthorize("hasAnyAuthority('system:aiMcpMount:save','system:aiMcpMount:update')")
    @PostMapping("/aiMcpMount/connectivity/validate-draft")
    public R testDraftConnectivity(@RequestBody AiMcpMount mount) {
        mount.setTenantId(getTenantId());
        return R.ok().add(aiMcpMountService.testDraftConnectivity(mount, getLoginUserId(), getTenantId()));
    }
    @PreAuthorize("hasAuthority('system:aiMcpMount:update')")
    @PostMapping("/aiMcpMount/{id}/tool/test")
    public R testTool(@PathVariable("id") Long id, @RequestBody AiMcpToolTestRequest request) {
rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiParamController.java
@@ -63,6 +63,13 @@
                .last("limit 1")));
    }
    @PreAuthorize("hasAnyAuthority('system:aiParam:save','system:aiParam:update')")
    @PostMapping("/aiParam/validate-draft")
    public R validateDraft(@RequestBody AiParam aiParam) {
        aiParam.setTenantId(getTenantId());
        return R.ok().add(aiParamService.validateDraft(aiParam, getTenantId()));
    }
    @PreAuthorize("hasAuthority('system:aiParam:save')")
    @OperationLog("Create AiParam")
    @PostMapping("/aiParam/save")
rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiPromptController.java
@@ -2,6 +2,7 @@
import com.vincent.rsf.framework.common.R;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.vincent.rsf.server.ai.dto.AiPromptPreviewRequest;
import com.vincent.rsf.server.ai.entity.AiPrompt;
import com.vincent.rsf.server.ai.service.AiPromptService;
import com.vincent.rsf.server.common.annotation.OperationLog;
@@ -63,6 +64,12 @@
                .last("limit 1")));
    }
    @PreAuthorize("hasAnyAuthority('system:aiPrompt:list','system:aiPrompt:save','system:aiPrompt:update')")
    @PostMapping("/aiPrompt/render-preview")
    public R renderPreview(@RequestBody AiPromptPreviewRequest request) {
        return R.ok().add(aiPromptService.renderPreview(request, getTenantId()));
    }
    @PreAuthorize("hasAuthority('system:aiPrompt:save')")
    @OperationLog("Create AiPrompt")
    @PostMapping("/aiPrompt/save")
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiConfigSummaryDto.java
New file
@@ -0,0 +1,39 @@
package com.vincent.rsf.server.ai.dto;
import lombok.Builder;
import lombok.Data;
import java.util.List;
@Data
@Builder
public class AiConfigSummaryDto {
    private String promptCode;
    private String promptName;
    private String promptScene;
    private String activeParamName;
    private String activeModel;
    private String activeParamUpdatedAt;
    private Long activeParamUpdatedBy;
    private String activeParamValidateStatus;
    private String activeParamValidateMessage;
    private String activeParamValidatedAt;
    private Integer enabledMcpCount;
    private List<String> enabledMcpNames;
    private String activePromptUpdatedAt;
    private Long activePromptUpdatedBy;
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiParamValidateResultDto.java
New file
@@ -0,0 +1,19 @@
package com.vincent.rsf.server.ai.dto;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class AiParamValidateResultDto {
    private String status;
    private String message;
    private String model;
    private Long elapsedMs;
    private String validatedAt;
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiPromptPreviewDto.java
New file
@@ -0,0 +1,17 @@
package com.vincent.rsf.server.ai.dto;
import lombok.Builder;
import lombok.Data;
import java.util.List;
@Data
@Builder
public class AiPromptPreviewDto {
    private String renderedSystemPrompt;
    private String renderedUserPrompt;
    private List<String> resolvedVariables;
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiPromptPreviewRequest.java
New file
@@ -0,0 +1,25 @@
package com.vincent.rsf.server.ai.dto;
import lombok.Data;
import java.util.Map;
@Data
public class AiPromptPreviewRequest {
    private Long id;
    private String name;
    private String code;
    private String scene;
    private String systemPrompt;
    private String userPromptTemplate;
    private String input;
    private Map<String, Object> metadata;
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiParam.java
@@ -54,6 +54,20 @@
    @ApiModelProperty(value = "streamingEnabled")
    private Boolean streamingEnabled;
    @ApiModelProperty(value = "最近校验状态")
    private String validateStatus;
    @ApiModelProperty(value = "最近校验信息")
    private String lastValidateMessage;
    @ApiModelProperty(value = "最近校验耗时")
    private Long lastValidateElapsedMs;
    @ApiModelProperty(value = "最近校验时间")
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    private Date lastValidateTime;
    @ApiModelProperty(value = "状态")
    private Integer status;
@@ -106,4 +120,11 @@
        }
        return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(this.updateTime);
    }
    public String getLastValidateTime$() {
        if (this.lastValidateTime == null) {
            return "";
        }
        return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(this.lastValidateTime);
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiConfigOpsService.java
New file
@@ -0,0 +1,12 @@
package com.vincent.rsf.server.ai.service;
import com.vincent.rsf.server.ai.dto.AiConfigSummaryDto;
import com.vincent.rsf.server.ai.dto.AiPromptPreviewDto;
import com.vincent.rsf.server.ai.dto.AiPromptPreviewRequest;
public interface AiConfigOpsService {
    AiConfigSummaryDto getSummary(String promptCode, Long tenantId);
    AiPromptPreviewDto renderPromptPreview(AiPromptPreviewRequest request, Long tenantId);
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiMcpMountService.java
@@ -21,5 +21,7 @@
    AiMcpConnectivityTestDto testConnectivity(Long mountId, Long userId, Long tenantId);
    AiMcpConnectivityTestDto testDraftConnectivity(AiMcpMount mount, Long userId, Long tenantId);
    AiMcpToolTestDto testTool(Long mountId, Long userId, Long tenantId, AiMcpToolTestRequest request);
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiParamService.java
@@ -1,6 +1,7 @@
package com.vincent.rsf.server.ai.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.vincent.rsf.server.ai.dto.AiParamValidateResultDto;
import com.vincent.rsf.server.ai.entity.AiParam;
public interface AiParamService extends IService<AiParam> {
@@ -10,4 +11,6 @@
    void validateBeforeSave(AiParam aiParam, Long tenantId);
    void validateBeforeUpdate(AiParam aiParam, Long tenantId);
    AiParamValidateResultDto validateDraft(AiParam aiParam, Long tenantId);
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiPromptService.java
@@ -1,6 +1,8 @@
package com.vincent.rsf.server.ai.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.vincent.rsf.server.ai.dto.AiPromptPreviewDto;
import com.vincent.rsf.server.ai.dto.AiPromptPreviewRequest;
import com.vincent.rsf.server.ai.entity.AiPrompt;
public interface AiPromptService extends IService<AiPrompt> {
@@ -10,4 +12,6 @@
    void validateBeforeSave(AiPrompt aiPrompt, Long tenantId);
    void validateBeforeUpdate(AiPrompt aiPrompt, Long tenantId);
    AiPromptPreviewDto renderPreview(AiPromptPreviewRequest request, Long tenantId);
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiConfigOpsServiceImpl.java
New file
@@ -0,0 +1,57 @@
package com.vincent.rsf.server.ai.service.impl;
import com.vincent.rsf.server.ai.config.AiDefaults;
import com.vincent.rsf.server.ai.dto.AiConfigSummaryDto;
import com.vincent.rsf.server.ai.dto.AiPromptPreviewDto;
import com.vincent.rsf.server.ai.dto.AiPromptPreviewRequest;
import com.vincent.rsf.server.ai.entity.AiMcpMount;
import com.vincent.rsf.server.ai.entity.AiParam;
import com.vincent.rsf.server.ai.entity.AiPrompt;
import com.vincent.rsf.server.ai.service.AiConfigOpsService;
import com.vincent.rsf.server.ai.service.AiMcpMountService;
import com.vincent.rsf.server.ai.service.AiParamService;
import com.vincent.rsf.server.ai.service.AiPromptService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.List;
@Service
@RequiredArgsConstructor
public class AiConfigOpsServiceImpl implements AiConfigOpsService {
    private final AiParamService aiParamService;
    private final AiPromptService aiPromptService;
    private final AiMcpMountService aiMcpMountService;
    private final AiPromptRenderSupport aiPromptRenderSupport;
    @Override
    public AiConfigSummaryDto getSummary(String promptCode, Long tenantId) {
        String finalPromptCode = StringUtils.hasText(promptCode) ? promptCode : AiDefaults.DEFAULT_PROMPT_CODE;
        AiParam activeParam = aiParamService.getActiveParam(tenantId);
        AiPrompt activePrompt = aiPromptService.getActivePrompt(finalPromptCode, tenantId);
        List<AiMcpMount> mounts = aiMcpMountService.listActiveMounts(tenantId);
        return AiConfigSummaryDto.builder()
                .promptCode(activePrompt.getCode())
                .promptName(activePrompt.getName())
                .promptScene(activePrompt.getScene())
                .activeParamName(activeParam.getName())
                .activeModel(activeParam.getModel())
                .activeParamUpdatedAt(activeParam.getUpdateTime$())
                .activeParamUpdatedBy(activeParam.getUpdateBy())
                .activeParamValidateStatus(activeParam.getValidateStatus())
                .activeParamValidateMessage(activeParam.getLastValidateMessage())
                .activeParamValidatedAt(activeParam.getLastValidateTime$())
                .enabledMcpCount(mounts.size())
                .enabledMcpNames(mounts.stream().map(AiMcpMount::getName).toList())
                .activePromptUpdatedAt(activePrompt.getUpdateTime$())
                .activePromptUpdatedBy(activePrompt.getUpdateBy())
                .build();
    }
    @Override
    public AiPromptPreviewDto renderPromptPreview(AiPromptPreviewRequest request, Long tenantId) {
        return aiPromptService.renderPreview(request, tenantId);
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiMcpMountServiceImpl.java
@@ -121,6 +121,48 @@
    }
    @Override
    public AiMcpConnectivityTestDto testDraftConnectivity(AiMcpMount mount, Long userId, Long tenantId) {
        ensureTenantId(tenantId);
        if (userId == null) {
            throw new CoolException("当前登录用户不存在");
        }
        if (mount == null) {
            throw new CoolException("MCP 挂载参数不能为空");
        }
        mount.setTenantId(tenantId);
        fillDefaults(mount);
        ensureRequiredFields(mount, tenantId);
        long startedAt = System.currentTimeMillis();
        try (McpMountRuntimeFactory.McpMountRuntime runtime = mcpMountRuntimeFactory.create(List.of(mount), userId)) {
            long elapsedMs = System.currentTimeMillis() - startedAt;
            if (!runtime.getErrors().isEmpty()) {
                return AiMcpConnectivityTestDto.builder()
                        .mountId(mount.getId())
                        .mountName(mount.getName())
                        .healthStatus(AiDefaults.MCP_HEALTH_UNHEALTHY)
                        .message(String.join(";", runtime.getErrors()))
                        .initElapsedMs(elapsedMs)
                        .toolCount(runtime.getToolCallbacks().length)
                        .testedAt(new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()))
                        .build();
            }
            return AiMcpConnectivityTestDto.builder()
                    .mountId(mount.getId())
                    .mountName(mount.getName())
                    .healthStatus(AiDefaults.MCP_HEALTH_HEALTHY)
                    .message("草稿连通性测试成功,解析出 " + runtime.getToolCallbacks().length + " 个工具")
                    .initElapsedMs(elapsedMs)
                    .toolCount(runtime.getToolCallbacks().length)
                    .testedAt(new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()))
                    .build();
        } catch (CoolException e) {
            throw e;
        } catch (Exception e) {
            throw new CoolException("草稿连通性测试失败: " + e.getMessage());
        }
    }
    @Override
    public AiMcpToolTestDto testTool(Long mountId, Long userId, Long tenantId, AiMcpToolTestRequest request) {
        if (userId == null) {
            throw new CoolException("当前登录用户不存在");
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiParamServiceImpl.java
@@ -4,15 +4,23 @@
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.vincent.rsf.framework.exception.CoolException;
import com.vincent.rsf.server.ai.config.AiDefaults;
import com.vincent.rsf.server.ai.dto.AiParamValidateResultDto;
import com.vincent.rsf.server.ai.entity.AiParam;
import com.vincent.rsf.server.ai.mapper.AiParamMapper;
import com.vincent.rsf.server.ai.service.AiParamService;
import com.vincent.rsf.server.system.enums.StatusType;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.text.SimpleDateFormat;
import java.util.Date;
@Service("aiParamService")
@RequiredArgsConstructor
public class AiParamServiceImpl extends ServiceImpl<AiParamMapper, AiParam> implements AiParamService {
    private final AiParamValidationSupport aiParamValidationSupport;
    @Override
    public AiParam getActiveParam(Long tenantId) {
@@ -35,6 +43,7 @@
        fillDefaults(aiParam);
        ensureBaseFields(aiParam);
        ensureSingleActive(tenantId, null, aiParam.getStatus());
        applyValidation(aiParam);
    }
    @Override
@@ -48,6 +57,15 @@
        aiParam.setTenantId(current.getTenantId());
        ensureBaseFields(aiParam);
        ensureSingleActive(tenantId, aiParam.getId(), aiParam.getStatus());
        applyValidation(aiParam);
    }
    @Override
    public AiParamValidateResultDto validateDraft(AiParam aiParam, Long tenantId) {
        ensureTenantId(tenantId);
        fillDefaults(aiParam);
        ensureBaseFields(aiParam);
        return aiParamValidationSupport.validate(aiParam);
    }
    private void ensureBaseFields(AiParam aiParam) {
@@ -118,8 +136,33 @@
        if (aiParam.getStreamingEnabled() == null) {
            aiParam.setStreamingEnabled(Boolean.TRUE);
        }
        if (!StringUtils.hasText(aiParam.getValidateStatus())) {
            aiParam.setValidateStatus(AiDefaults.PARAM_VALIDATE_NOT_TESTED);
        }
        if (aiParam.getStatus() == null) {
            aiParam.setStatus(StatusType.ENABLE.val);
        }
    }
    private void applyValidation(AiParam aiParam) {
        AiParamValidateResultDto validateResult = aiParamValidationSupport.validate(aiParam);
        aiParam.setValidateStatus(validateResult.getStatus());
        aiParam.setLastValidateMessage(validateResult.getMessage());
        aiParam.setLastValidateElapsedMs(validateResult.getElapsedMs());
        aiParam.setLastValidateTime(parseDate(validateResult.getValidatedAt()));
        if (!AiDefaults.PARAM_VALIDATE_VALID.equals(validateResult.getStatus())) {
            throw new CoolException(validateResult.getMessage());
        }
    }
    private Date parseDate(String dateTime) {
        if (!StringUtils.hasText(dateTime)) {
            return null;
        }
        try {
            return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse(dateTime);
        } catch (Exception e) {
            throw new CoolException("解析校验时间失败: " + e.getMessage());
        }
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiParamValidationSupport.java
New file
@@ -0,0 +1,106 @@
package com.vincent.rsf.server.ai.service.impl;
import com.vincent.rsf.framework.exception.CoolException;
import com.vincent.rsf.server.ai.config.AiDefaults;
import com.vincent.rsf.server.ai.dto.AiParamValidateResultDto;
import com.vincent.rsf.server.ai.entity.AiParam;
import io.micrometer.observation.ObservationRegistry;
import lombok.RequiredArgsConstructor;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.model.tool.DefaultToolCallingManager;
import org.springframework.ai.model.tool.ToolCallingManager;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.ai.tool.execution.DefaultToolExecutionExceptionProcessor;
import org.springframework.ai.tool.resolution.SpringBeanToolCallbackResolver;
import org.springframework.ai.util.json.schema.SchemaType;
import org.springframework.context.support.GenericApplicationContext;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestClient;
import org.springframework.web.reactive.function.client.WebClient;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
@Component
@RequiredArgsConstructor
public class AiParamValidationSupport {
    private final GenericApplicationContext applicationContext;
    private final ObservationRegistry observationRegistry;
    public AiParamValidateResultDto validate(AiParam aiParam) {
        long startedAt = System.currentTimeMillis();
        try {
            OpenAiChatModel chatModel = createChatModel(aiParam);
            ChatResponse response = chatModel.call(new Prompt(List.of(new UserMessage("请回复 OK"))));
            if (response == null || response.getResult() == null || response.getResult().getOutput() == null
                    || !StringUtils.hasText(response.getResult().getOutput().getText())) {
                throw new CoolException("模型已连接,但未返回有效响应");
            }
            long elapsedMs = System.currentTimeMillis() - startedAt;
            return AiParamValidateResultDto.builder()
                    .status(AiDefaults.PARAM_VALIDATE_VALID)
                    .message("模型连通成功")
                    .model(aiParam.getModel())
                    .elapsedMs(elapsedMs)
                    .validatedAt(formatDate(new Date()))
                    .build();
        } catch (Exception e) {
            long elapsedMs = System.currentTimeMillis() - startedAt;
            String message = e instanceof CoolException ? e.getMessage() : "模型验证失败: " + e.getMessage();
            return AiParamValidateResultDto.builder()
                    .status(AiDefaults.PARAM_VALIDATE_INVALID)
                    .message(message)
                    .model(aiParam.getModel())
                    .elapsedMs(elapsedMs)
                    .validatedAt(formatDate(new Date()))
                    .build();
        }
    }
    private OpenAiChatModel createChatModel(AiParam aiParam) {
        OpenAiApi openAiApi = buildOpenAiApi(aiParam);
        ToolCallingManager toolCallingManager = DefaultToolCallingManager.builder()
                .observationRegistry(observationRegistry)
                .toolCallbackResolver(new SpringBeanToolCallbackResolver(applicationContext, SchemaType.OPEN_API_SCHEMA))
                .toolExecutionExceptionProcessor(new DefaultToolExecutionExceptionProcessor(false))
                .build();
        return new OpenAiChatModel(
                openAiApi,
                OpenAiChatOptions.builder()
                        .model(aiParam.getModel())
                        .temperature(aiParam.getTemperature())
                        .topP(aiParam.getTopP())
                        .maxTokens(aiParam.getMaxTokens())
                        .streamUsage(true)
                        .build(),
                toolCallingManager,
                org.springframework.retry.support.RetryTemplate.builder().maxAttempts(1).build(),
                observationRegistry
        );
    }
    private OpenAiApi buildOpenAiApi(AiParam aiParam) {
        int timeoutMs = aiParam.getTimeoutMs() == null ? AiDefaults.DEFAULT_TIMEOUT_MS : aiParam.getTimeoutMs();
        SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
        requestFactory.setConnectTimeout(timeoutMs);
        requestFactory.setReadTimeout(timeoutMs);
        return OpenAiApi.builder()
                .baseUrl(aiParam.getBaseUrl())
                .apiKey(aiParam.getApiKey())
                .restClientBuilder(RestClient.builder().requestFactory(requestFactory))
                .webClientBuilder(WebClient.builder())
                .build();
    }
    private String formatDate(Date date) {
        return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(date);
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiPromptRenderSupport.java
New file
@@ -0,0 +1,81 @@
package com.vincent.rsf.server.ai.service.impl;
import com.vincent.rsf.server.ai.dto.AiPromptPreviewDto;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Component
public class AiPromptRenderSupport {
    private static final Pattern VARIABLE_PATTERN = Pattern.compile("\\{\\{?([a-zA-Z0-9_.-]+)}}?");
    public AiPromptPreviewDto render(String systemPrompt, String userPromptTemplate, String input, Map<String, Object> metadata) {
        String finalInput = input == null ? "" : input;
        return AiPromptPreviewDto.builder()
                .renderedSystemPrompt(renderTemplate(systemPrompt, finalInput, metadata))
                .renderedUserPrompt(renderUserPrompt(userPromptTemplate, finalInput, metadata))
                .resolvedVariables(resolveVariables(systemPrompt, userPromptTemplate, metadata))
                .build();
    }
    public String renderUserPrompt(String userPromptTemplate, String input, Map<String, Object> metadata) {
        if (!StringUtils.hasText(userPromptTemplate)) {
            return input;
        }
        String rendered = replaceTemplateVariables(userPromptTemplate, input, metadata);
        if (Objects.equals(rendered, userPromptTemplate)) {
            return userPromptTemplate + "\n\n" + input;
        }
        return rendered;
    }
    private String renderTemplate(String template, String input, Map<String, Object> metadata) {
        if (!StringUtils.hasText(template)) {
            return template;
        }
        return replaceTemplateVariables(template, input, metadata);
    }
    private String replaceTemplateVariables(String template, String input, Map<String, Object> metadata) {
        String rendered = template
                .replace("{{input}}", input)
                .replace("{input}", input);
        if (metadata == null || metadata.isEmpty()) {
            return rendered;
        }
        for (Map.Entry<String, Object> entry : metadata.entrySet()) {
            String value = entry.getValue() == null ? "" : String.valueOf(entry.getValue());
            rendered = rendered.replace("{{" + entry.getKey() + "}}", value);
            rendered = rendered.replace("{" + entry.getKey() + "}", value);
        }
        return rendered;
    }
    private List<String> resolveVariables(String systemPrompt, String userPromptTemplate, Map<String, Object> metadata) {
        LinkedHashSet<String> variables = new LinkedHashSet<>();
        collectVariables(variables, systemPrompt);
        collectVariables(variables, userPromptTemplate);
        if (metadata != null && !metadata.isEmpty()) {
            variables.addAll(metadata.keySet());
        }
        return new ArrayList<>(variables);
    }
    private void collectVariables(LinkedHashSet<String> variables, String template) {
        if (!StringUtils.hasText(template)) {
            return;
        }
        Matcher matcher = VARIABLE_PATTERN.matcher(template);
        while (matcher.find()) {
            variables.add(matcher.group(1));
        }
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiPromptServiceImpl.java
@@ -3,15 +3,21 @@
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.vincent.rsf.framework.exception.CoolException;
import com.vincent.rsf.server.ai.dto.AiPromptPreviewDto;
import com.vincent.rsf.server.ai.dto.AiPromptPreviewRequest;
import com.vincent.rsf.server.ai.entity.AiPrompt;
import com.vincent.rsf.server.ai.mapper.AiPromptMapper;
import com.vincent.rsf.server.ai.service.AiPromptService;
import com.vincent.rsf.server.system.enums.StatusType;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
@Service("aiPromptService")
@RequiredArgsConstructor
public class AiPromptServiceImpl extends ServiceImpl<AiPromptMapper, AiPrompt> implements AiPromptService {
    private final AiPromptRenderSupport aiPromptRenderSupport;
    @Override
    public AiPrompt getActivePrompt(String code, Long tenantId) {
@@ -48,6 +54,23 @@
        ensureUniqueCode(aiPrompt.getCode(), tenantId, aiPrompt.getId());
    }
    @Override
    public AiPromptPreviewDto renderPreview(AiPromptPreviewRequest request, Long tenantId) {
        ensureTenantId(tenantId);
        if (request == null) {
            throw new CoolException("Prompt 预览参数不能为空");
        }
        if (!StringUtils.hasText(request.getSystemPrompt())) {
            throw new CoolException("系统 Prompt 不能为空");
        }
        return aiPromptRenderSupport.render(
                request.getSystemPrompt(),
                request.getUserPromptTemplate(),
                request.getInput(),
                request.getMetadata()
        );
    }
    private void ensureRequiredFields(AiPrompt aiPrompt) {
        if (!StringUtils.hasText(aiPrompt.getName())) {
            throw new CoolException("Prompt 名称不能为空");
version/db/ai_feature.sql
@@ -13,6 +13,10 @@
  `max_tokens` int(11) DEFAULT NULL COMMENT '最大Token',
  `timeout_ms` int(11) DEFAULT NULL COMMENT '超时时间',
  `streaming_enabled` tinyint(1) DEFAULT '1' COMMENT '是否启用流式响应',
  `validate_status` varchar(32) DEFAULT 'NOT_TESTED' COMMENT '最近校验状态',
  `last_validate_message` varchar(500) DEFAULT NULL COMMENT '最近校验信息',
  `last_validate_elapsed_ms` bigint(20) DEFAULT NULL COMMENT '最近校验耗时',
  `last_validate_time` datetime DEFAULT NULL COMMENT '最近校验时间',
  `tenant_id` bigint(20) DEFAULT NULL COMMENT '租户',
  `status` int(11) DEFAULT '1' COMMENT '状态',
  `deleted` int(11) DEFAULT '0' COMMENT '删除标记',
@@ -109,6 +113,74 @@
  KEY `idx_sys_ai_chat_message_session_seq` (`session_id`,`seq_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI 对话消息';
SET @ai_param_validate_status_exists := (
  SELECT COUNT(1)
  FROM `information_schema`.`COLUMNS`
  WHERE `TABLE_SCHEMA` = DATABASE()
    AND `TABLE_NAME` = 'sys_ai_param'
    AND `COLUMN_NAME` = 'validate_status'
);
SET @ai_param_validate_status_sql := IF(
  @ai_param_validate_status_exists = 0,
  'ALTER TABLE `sys_ai_param` ADD COLUMN `validate_status` varchar(32) DEFAULT ''NOT_TESTED'' COMMENT ''最近校验状态'' AFTER `streaming_enabled`',
  'SELECT 1'
);
PREPARE ai_param_validate_status_stmt FROM @ai_param_validate_status_sql;
EXECUTE ai_param_validate_status_stmt;
DEALLOCATE PREPARE ai_param_validate_status_stmt;
SET @ai_param_last_validate_message_exists := (
  SELECT COUNT(1)
  FROM `information_schema`.`COLUMNS`
  WHERE `TABLE_SCHEMA` = DATABASE()
    AND `TABLE_NAME` = 'sys_ai_param'
    AND `COLUMN_NAME` = 'last_validate_message'
);
SET @ai_param_last_validate_message_sql := IF(
  @ai_param_last_validate_message_exists = 0,
  'ALTER TABLE `sys_ai_param` ADD COLUMN `last_validate_message` varchar(500) DEFAULT NULL COMMENT ''最近校验信息'' AFTER `validate_status`',
  'SELECT 1'
);
PREPARE ai_param_last_validate_message_stmt FROM @ai_param_last_validate_message_sql;
EXECUTE ai_param_last_validate_message_stmt;
DEALLOCATE PREPARE ai_param_last_validate_message_stmt;
SET @ai_param_last_validate_elapsed_exists := (
  SELECT COUNT(1)
  FROM `information_schema`.`COLUMNS`
  WHERE `TABLE_SCHEMA` = DATABASE()
    AND `TABLE_NAME` = 'sys_ai_param'
    AND `COLUMN_NAME` = 'last_validate_elapsed_ms'
);
SET @ai_param_last_validate_elapsed_sql := IF(
  @ai_param_last_validate_elapsed_exists = 0,
  'ALTER TABLE `sys_ai_param` ADD COLUMN `last_validate_elapsed_ms` bigint(20) DEFAULT NULL COMMENT ''最近校验耗时'' AFTER `last_validate_message`',
  'SELECT 1'
);
PREPARE ai_param_last_validate_elapsed_stmt FROM @ai_param_last_validate_elapsed_sql;
EXECUTE ai_param_last_validate_elapsed_stmt;
DEALLOCATE PREPARE ai_param_last_validate_elapsed_stmt;
SET @ai_param_last_validate_time_exists := (
  SELECT COUNT(1)
  FROM `information_schema`.`COLUMNS`
  WHERE `TABLE_SCHEMA` = DATABASE()
    AND `TABLE_NAME` = 'sys_ai_param'
    AND `COLUMN_NAME` = 'last_validate_time'
);
SET @ai_param_last_validate_time_sql := IF(
  @ai_param_last_validate_time_exists = 0,
  'ALTER TABLE `sys_ai_param` ADD COLUMN `last_validate_time` datetime DEFAULT NULL COMMENT ''最近校验时间'' AFTER `last_validate_elapsed_ms`',
  'SELECT 1'
);
PREPARE ai_param_last_validate_time_stmt FROM @ai_param_last_validate_time_sql;
EXECUTE ai_param_last_validate_time_stmt;
DEALLOCATE PREPARE ai_param_last_validate_time_stmt;
UPDATE `sys_ai_param`
SET `validate_status` = 'NOT_TESTED'
WHERE `validate_status` IS NULL OR `validate_status` = '';
SET @builtin_code_exists := (
  SELECT COUNT(1)
  FROM `information_schema`.`COLUMNS`