Files
leaudit-platform-frontend/app/routes/prompts.new.tsx
T

720 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from "react";
import { MetaFunction, LoaderFunctionArgs, ActionFunctionArgs, json, redirect } from "@remix-run/node";
import { Link, useLoaderData, useNavigation, useActionData, Form } from "@remix-run/react";
import { Button } from "~/components/ui/Button";
import newStyles from "~/styles/pages/prompts_new.css?url";
import { getPromptTemplate, createPromptTemplate, updatePromptTemplate, type PromptTemplateUI } from "~/api/prompts/prompts";
// 样式链接
export function links() {
return [{ rel: "stylesheet", href: newStyles }];
}
// 页面元数据
export const meta: MetaFunction = () => {
return [
{ title: "提示词模板编辑 - 中国烟草AI合同及卷宗审核系统" },
{ name: "description", content: "创建或编辑提示词模板" },
];
};
// 面包屑导航
export const handle = {
breadcrumb: (data:LoaderData) => {
if (data.mode === "edit") {
return "编辑提示词模板";
} else if (data.mode === "view") {
return "查看提示词模板";
} else {
return "新增提示词模板";
}
}
};
interface LoaderData {
template: PromptTemplateUI | null;
mode: string;
error?: string;
}
// 定义本地表单数据接口
interface FormDataState extends Omit<PromptTemplateUI, 'variables'> {
variables: string; // 在表单状态中我们保存变量为 JSON 字符串
}
interface ActionData {
success?: boolean;
errors?: {
template_name?: string;
template_type?: string;
template_content?: string;
general?: string;
};
formData?: {
template_name: string;
template_type: 'LLM_Extraction' | 'VLM_Extraction' | 'Evaluation' | 'Summary' | 'Common';
description: string;
template_content: string;
variables: string;
status: "active" | "inactive" | "system";
version: string;
};
}
// 加载函数
export async function loader({ request }: LoaderFunctionArgs) {
try {
const url = new URL(request.url);
const id = url.searchParams.get("id");
const mode = url.searchParams.get("mode") || "create";
// 模板数据,如果是新建则为空
let template = null;
if (id) {
// 从API获取数据
const result = await getPromptTemplate(id);
if (result.error) {
console.error('获取提示词模板失败:', result.error);
throw new Error(result.error);
}
template = result.data || null;
if (!template) {
throw new Error(`未找到ID为${id}的模板`);
}
}
return json<LoaderData>({
template,
mode
});
} catch (error) {
console.error("加载提示词模板失败:", error);
return json<LoaderData>(
{
template: null,
mode: "create",
error: error instanceof Error ? error.message : "加载提示词模板失败"
},
{ status: 500 }
);
}
}
// Action函数 - 处理表单提交
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const id = formData.get("id") as string;
const template_name = formData.get("template_name") as string;
const template_type = formData.get("template_type") as 'LLM_Extraction' | 'VLM_Extraction' | 'Evaluation' | 'Summary' | 'Common';
const description = formData.get("description") as string;
const template_content = formData.get("template_content") as string;
const variables = formData.get("variables") as string;
const status = formData.get("status") as string;
const version = formData.get("version") as string;
const errors: ActionData["errors"] = {};
// 表单验证
if (!template_name || template_name.trim() === "") {
errors.template_name = "模板名称不能为空";
}
if (!template_type) {
errors.template_type = "请选择模板类型";
}
if (!template_content || template_content.trim() === "") {
errors.template_content = "模板内容不能为空";
}
if (Object.keys(errors).length > 0) {
return json({ errors });
}
try {
// 准备变量数据
let variablesData: Record<string, string> = {};
try {
if (variables) {
variablesData = JSON.parse(variables);
}
} catch (e) {
console.error('解析变量JSON失败:', e);
}
// 准备API数据
const apiTemplate: Partial<PromptTemplateUI> = {
template_name,
template_type,
description,
template_content,
variables: variablesData,
status: status === "active" ? "active" : "inactive",
version: version || "v1.0"
};
let result;
if (id) {
// 更新模板
result = await updatePromptTemplate(id, apiTemplate);
} else {
// 创建模板
result = await createPromptTemplate(apiTemplate);
}
if (result.error) {
return json({
errors: { general: result.error },
formData: {
template_name,
template_type,
description,
template_content,
variables,
status,
version
}
});
}
return redirect("/prompts");
} catch (error) {
console.error("保存提示词模板失败:", error);
return json({
errors: {
general: error instanceof Error ? error.message : "保存提示词模板失败"
},
formData: {
template_name,
template_type,
description,
template_content,
variables,
status,
version
}
});
}
}
// 提取变量函数 例如:{var1} {var2} {var3}
const extractVariables = (content: string) => {
const regex = /{([^{}]+)}/g;
const variables: Record<string, string> = {};
let match;
while ((match = regex.exec(content)) !== null) {
const varName = match[1].trim();
if (varName && !variables[varName]) {
variables[varName] = varName;
}
}
return variables;
};
// 页面组件
export default function PromptsNew() {
const { template, mode, error } = useLoaderData<typeof loader>();
const actionData = useActionData<ActionData>();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
// 表单状态
const [formData, setFormData] = useState<FormDataState>({
id: "",
template_name: "",
template_type: "Common",
description: "",
version: "v1.0",
status: "active",
template_content: "",
created_by: 1,
variables: "{}",
created_at: "",
updated_at: ""
});
// 模式状态
const [isViewMode, setIsViewMode] = useState(false);
const [pageTitle, setPageTitle] = useState("新增提示词模板");
// 变量相关状态
// 检测到的变量
const [detectedVariables, setDetectedVariables] = useState<Record<string, string>>({});
// 示例值
const [exampleValues, setExampleValues] = useState<Record<string, string>>({});
// 预览内容
const [previewContent, setPreviewContent] = useState("");
// 初始化表单数据
useEffect(() => {
if (actionData?.formData) {
// 如果有保存失败的表单数据,使用它
setFormData(prev => ({
...prev,
...actionData.formData,
variables: actionData.formData?.variables || "{}",
status: actionData.formData?.status || "active"
}));
} else if (template) {
// 否则使用模板数据
const variablesJson = typeof template.variables === 'string'
? template.variables
: JSON.stringify(template.variables);
const newFormData = {
...template,
id: mode === "clone" ? "" : template.id,
template_name: mode === "clone" ? `${template.template_name} (副本)` : template.template_name,
version: mode === "clone" ? "v1.0" : template.version,
variables: variablesJson
};
setFormData(newFormData);
try {
const vars = typeof template.variables === 'string'
? JSON.parse(template.variables)
: template.variables;
setExampleValues(vars);
} catch (e) {
console.error("解析变量失败:", e);
}
if (template.template_content) {
const vars = extractVariables(template.template_content);
setDetectedVariables(vars);
}
}
setIsViewMode(mode === "view");
if (mode === "view") {
setPageTitle("查看提示词模板");
} else if (mode === "edit") {
setPageTitle("编辑提示词模板");
} else if (mode === "clone") {
setPageTitle("复制创建提示词模板");
} else {
setPageTitle("新增提示词模板");
}
}, [template, mode, actionData?.formData]);
// 处理输入变化
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value } = e.target;
// 如果是模板内容,检测变量
if (name === "template_content") {
const vars = extractVariables(value);
// console.log("检测到的变量:",vars);
setDetectedVariables(vars);
// 更新变量JSON
const varsJson = JSON.stringify(
Object.keys(vars).reduce((acc, key) => {
acc[key] = exampleValues[key] || "";
return acc;
}, {} as Record<string, string>)
);
// console.log("更新变量JSON",varsJson);
setFormData(prev => ({
...prev,
[name]: value,
variables: varsJson
}));
} else {
setFormData(prev => ({
...prev,
[name]: value
}));
}
};
// 处理状态切换
const handleStatusToggle = (e: React.ChangeEvent<HTMLInputElement>) => {
// console.log("状态切换前:", formData.status);
const status = e.target.checked ? "active" : "inactive";
// console.log("新状态值:", status);
// 直接更新formData状态
setFormData(prev => {
const newState = {
...prev,
status: status as "active" | "inactive" | "system"
};
// console.log("状态更新后:", newState.status);
return newState;
});
};
// 处理示例值变更
const handleExampleValueChange = (varName: string, value: string) => {
setExampleValues(prev => ({
...prev,
[varName]: value
}));
};
// 更新预览
const updatePreview = () => {
let content = formData.template_content || "";
// 替换变量
Object.entries(detectedVariables).forEach(([key]) => {
const exampleValue = exampleValues[key] || ``;
const regex = new RegExp(`{${key}}`, 'g');
content = content.replace(regex, exampleValue);
});
setPreviewContent(content);
};
// 当检测到的变量变化时,更新变量JSON
useEffect(() => {
const varsJson = JSON.stringify(
Object.keys(detectedVariables).reduce((acc, key) => {
acc[key] = exampleValues[key] || "";
return acc;
}, {} as Record<string, string>)
);
setFormData(prev => ({
...prev,
variables: varsJson
}));
}, [detectedVariables, exampleValues]);
return (
<div className="prompt-new-page">
{/* 页面头部 */}
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-medium">{pageTitle}</h2>
<div>
<Link to="/prompts" className="mr-2">
<Button type="default" icon="ri-arrow-left-line">
</Button>
</Link>
{!isViewMode && (
<Button
type="primary"
icon="ri-save-line"
disabled={isSubmitting}
form="template-form"
>
{isSubmitting ? "保存中..." : "保存"}
</Button>
)}
</div>
</div>
{/* 错误信息 */}
{error && (
<div className="alert alert-error mb-4">
<i className="ri-error-warning-line"></i>
<div>{error}</div>
</div>
)}
{actionData?.errors?.general && (
<div className="alert alert-error mb-4">
<i className="ri-error-warning-line"></i>
<div>{actionData.errors.general}</div>
</div>
)}
{/* 查看模式提示 */}
{isViewMode && (
<div className="alert alert-info">
<i className="ri-information-line"></i>
<div>
<div>&quot;&quot;</div>
</div>
</div>
)}
{/* 模板表单 */}
<Form id="template-form" method="post">
{/* 模板ID - 隐藏字段 */}
<input type="hidden" name="id" value={formData.id || ''} />
{/* 模板信息卡片 */}
<div className="ant-card">
<div className="ant-card-header"></div>
<div className="ant-card-body p-4">
<div className="grid grid-cols-2 gap-4">
{/* 模板名称 */}
<div className="form-group mb-3">
<label htmlFor="template-name" className="form-label mb-1">
<span className="text-error">*</span>
</label>
<input
type="text"
className={`form-input py-1 ${isViewMode ? 'read-only-field' : ''} ${actionData?.errors?.template_name ? 'input-error' : ''}`}
id="template-name"
name="template_name"
placeholder="请输入模板名称"
value={formData.template_name || ''}
onChange={handleInputChange}
readOnly={isViewMode}
required
/>
{actionData?.errors?.template_name && (
<div className="error-message">{actionData.errors.template_name}</div>
)}
<div className="help-text text-xs">使&quot;-&quot;</div>
</div>
{/* 模板类型 */}
<div className="form-group mb-3">
<label htmlFor="template-type" className="form-label mb-1">
<span className="text-error">*</span>
</label>
<select
className={`form-select py-1 ${isViewMode ? 'read-only-field' : ''} ${actionData?.errors?.template_type ? 'input-error' : ''}`}
id="template-type"
name="template_type"
value={formData.template_type || ''}
onChange={handleInputChange}
disabled={isViewMode}
required
>
<option value=""></option>
<option value="Common">(Common) - </option>
<option value="LLM_Extraction">LLM抽取(LLM_Extraction) - 使LLM从文档中抽取结构化信息</option>
<option value="VLM_Extraction">VLM抽取(VLM_Extraction) - 使VLM从文档中抽取结构化信息</option>
<option value="Evaluation">(Evaluation) - </option>
<option value="Summary">(Summary) - </option>
</select>
{actionData?.errors?.template_type && (
<div className="error-message">{actionData.errors.template_type}</div>
)}
</div>
{/* 模板描述 */}
<div className="form-group mb-3">
<label htmlFor="template-description" className="form-label mb-1">
</label>
<textarea
className={`form-textarea py-1 ${isViewMode ? 'read-only-field' : ''}`}
id="template-description"
name="description"
placeholder="请简要描述此模板的功能和用途"
value={formData.description || ''}
onChange={handleInputChange}
readOnly={isViewMode}
rows={2}
></textarea>
</div>
<div className="grid grid-cols-2 gap-4">
{/* 模板状态 */}
<div className="form-group mb-3">
<label htmlFor="status-toggle" className="form-label mb-1"></label>
<div className="flex items-center mt-1">
<label className="switch mr-2" htmlFor="status-toggle">
<span className="sr-only"></span>
<input
type="checkbox"
id="status-toggle"
checked={formData.status === 'active'}
onChange={handleStatusToggle}
disabled={isViewMode}
/>
{/* 移除name属性,只使用隐藏字段传递状态值 */}
<span className="slider"></span>
</label>
<span id="status-text">{formData.status === 'active' ? '启用' : '停用'}</span>
</div>
{/* 使用隐藏字段传递状态值 */}
<input
type="hidden"
name="status"
value={formData.status}
/>
</div>
{/* 模板版本 */}
<div className="form-group mb-3">
<label htmlFor="template-version" className="form-label mb-1"></label>
<input
type="text"
className={`form-input py-1 ${isViewMode ? 'read-only-field' : ''}`}
id="template-version"
name="version"
placeholder="例如:v1.0"
value={formData.version || 'v1.0'}
onChange={handleInputChange}
readOnly={isViewMode}
required
/>
</div>
</div>
</div>
</div>
</div>
{/* 模板内容卡片 */}
<div className="ant-card">
<div className="ant-card-header"></div>
<div className="ant-card-body">
<div className="alert alert-warning mb-4">
<i className="ri-information-line"></i>
<div>
<div>使 &#123;varName&#125;使</div>
<div className="mt-1">&#123;docType&#125;...</div>
</div>
</div>
{/* 模板内容 */}
<div className="form-group">
<label htmlFor="template-content" className="form-label">
<span className="text-error">*</span>
</label>
<textarea
className={`form-code-editor w-full ${isViewMode ? 'read-only-field' : ''} ${actionData?.errors?.template_content ? 'input-error' : ''}`}
id="template-content"
name="template_content"
placeholder="在此输入提示词模板内容..."
value={formData.template_content || ''}
onChange={handleInputChange}
readOnly={isViewMode}
rows={15}
required
></textarea>
{actionData?.errors?.template_content && (
<div className="error-message">{actionData.errors.template_content}</div>
)}
<div className="help-text">AI完成特定任务的指令</div>
</div>
{/* 变量识别区域 */}
<div className="form-group mt-6">
<label htmlFor="var-container" className="form-label"></label>
<div className="alert alert-info mb-3">
<i className="ri-lightbulb-line"></i>
<div>
<div> &#123;varName&#125; 使</div>
</div>
</div>
<div id="var-container" className="var-container" aria-labelledby="var-container-label">
{Object.keys(detectedVariables).length > 0 ? (
Object.keys(detectedVariables).map(varName => (
<div
className="var-badge"
key={varName}
data-var-name={varName}
>
{varName}
</div>
))
) : (
<div className="text-secondary text-sm italic" id="no-vars-message">
使 &#123;&#125;
</div>
)}
</div>
<input type="hidden" id="variables-json" name="variables" value={formData.variables || '{}'} />
</div>
{/* 模板效果预览 */}
<div className="form-group mt-6">
<label htmlFor="preview-content" className="form-label"></label>
<div className="example-section">
<div className="example-header"></div>
<div id="example-vars" className="mb-3">
{Object.keys(detectedVariables).length > 0 ? (
Object.keys(detectedVariables).map(varName => (
<div className="var-input-group" key={varName}>
<input
type="text"
className="form-input"
value={varName}
readOnly
/>
<input
type="text"
className={`form-input ${isViewMode ? 'read-only-field' : ''}`}
id={`example-${varName}`}
placeholder={`示例值,如 ${varName}`}
value={exampleValues[varName] || ''}
onChange={(e) => handleExampleValueChange(varName, e.target.value)}
readOnly={isViewMode}
/>
</div>
))
) : (
<div className="text-secondary text-sm italic">
</div>
)}
</div>
<div className="example-header"></div>
<div id="preview-content" className="bg-gray-50 p-4 rounded border border-gray-200">
{previewContent ? (
<pre style={{ whiteSpace: 'pre-wrap' }}>{previewContent}</pre>
) : (
<div className="text-gray-400 italic">...</div>
)}
</div>
<div className="flex justify-end mt-3">
<button
type="button"
className="ant-btn ant-btn-default"
onClick={updatePreview}
>
<i className="ri-eye-line"></i>
</button>
</div>
</div>
</div>
</div>
</div>
{/* 底部按钮区域 */}
<div className="flex justify-between mt-6">
<div>
{isViewMode && (
<Link to={`/prompts/new?id=${formData.id}&mode=clone`}>
<button type="button" className="ant-btn ant-btn-default">
<i className="ri-file-copy-line"></i>
</button>
</Link>
)}
</div>
<div>
<Link to="/prompts" className="mr-2">
<button type="button" className="ant-btn ant-btn-default">
<i className="ri-close-line"></i>
</button>
</Link>
{!isViewMode && (
<button
form="template-form"
className="ant-btn ant-btn-primary"
disabled={isSubmitting}
id="save-btn-bottom"
>
<i className="ri-save-line"></i> {isSubmitting ? "保存中..." : "保存"}
</button>
)}
</div>
</div>
</Form>
</div>
);
}