Files
TanWenyan f525707358 feat(rules): VLM 字段补齐多实体逐字段开关,对齐 LLM 字段行为
- 多实体总开关开启后,VLM 字段可点击单独切换 multi_entity(绿色=已开启)
- 新增 VLM 字段时默认写入 multi_entity: false
- 删除按钮 stopPropagation 避免触发多实体切换

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 15:12:17 +08:00

1243 lines
46 KiB
TypeScript
Raw Permalink 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 React, {
useState,
useEffect,
} from "react";
import type { EvaluationPoint, LLMFieldType } from "~/models/evaluation_points";
import { EVALUATION_OPTIONS, VLMFieldType } from "~/models/evaluation_points";
/**
* ExtractionSettings 组件
*
* 功能:
* - 提供三种抽取设置方式:大模型抽取、多模态抽取和正则抽取
* - 允许在三个标签页中添加不同类型的字段
* - 统一的更新机制,确保点击"更新全部字段"按钮时,所有三种类型的字段都会被收集和更新
*
* 优化后的交互逻辑:
* 1. 用户可以在三个标签页之间切换,在每个标签页中添加对应类型的字段
* 2. 添加字段后,会自动标记为"有未保存更改"状态
* 3. 无论当前在哪个标签页,点击底部的"更新全部字段"按钮都会收集所有三种类型的字段
* 4. 更新成功后会显示详细的字段数量统计信息,包括每种类型的字段数
* 5. 系统会自动检查字段名重复,确保所有字段名唯一
*
* 注意:
* - 仅当点击"更新全部字段"按钮后,字段才会真正提交给父组件和规则上下文
* - 用户必须手动点击更新按钮,才能在评查设置中使用这些字段
*
* 类型定义:
* - LogicType: 'and' | 'or' | 'custom' - 用于评查配置中多个规则的组合逻辑
* - 'and': 所有规则都必须满足
* - 'or': 任一规则满足即可
* - 'custom': 自定义逻辑表达式,如 "(规则1 AND 规则2) OR 规则3"
*
* - LogicOperator: 'and' | 'or' - 用于单个规则内的条件组合
* - 'and': 规则内所有条件都必须满足
* - 'or': 规则内任一条件满足即可
*/
/** 获取 LLM 字段名称 */
const getLLMFieldName = (field: LLMFieldType): string =>
typeof field === 'string' ? field : field.name;
/** 获取 LLM 字段的 multi_entity 状态 */
const isLLMFieldMultiEntity = (field: LLMFieldType): boolean =>
typeof field === 'string' ? false : !!field.multi_entity;
interface ExtractionSettingsProps {
onChange: (data: Record<string, unknown>) => void;
initialData: EvaluationPoint;
promptTypeOptions?: Array<{ value: string; label: string }>;
vlmFieldTypeOptions?: Array<{ value: string; label: string }>;
}
export function ExtractionSettings({
onChange,
initialData,
vlmFieldTypeOptions = EVALUATION_OPTIONS.vlmFieldTypeOptions,
}: ExtractionSettingsProps) {
// 多实体抽取开关状态
const [multiEntityEnabled, setMultiEntityEnabled] = useState<boolean>(
initialData?.extraction_config?.multi_entity?.enabled ?? false
);
// 核心数据状态
const [formData, setFormData] = useState<EvaluationPoint>({
// 字段配置
extraction_config: {
multi_entity: initialData?.extraction_config?.multi_entity ?? {
enabled: false,
expand_mode: 'awareness',
},
llm: initialData?.extraction_config?.llm ?? {
fields: [],
prompt_setting: {
type: "llm_default_prompt",
template: "",
},
},
vlm: initialData?.extraction_config?.vlm ?? {
fields: [],
prompt_setting: {
type: "vlm_default_prompt",
template: "",
},
},
regex: initialData?.extraction_config?.regex ?? {
fields: [],
},
},
});
// 当前选中的标签页
const [currentTab, setCurrentTab] = useState<string>("llm");
// 字段输入值
const [inputValue, setInputValue] = useState({
llm: '',
vlm: ''
});
// 字段列表
const [fields, setFields] = useState({
llm: initialData?.extraction_config?.llm?.fields || [],
vlm: initialData?.extraction_config?.vlm?.fields || []
});
// VLM字段类型
const [selectedVlmFieldType, setSelectedVlmFieldType] = useState(() => {
// 使用传入的选项中的第一个作为默认值,如果没有则使用 vlm_default_prompt
return vlmFieldTypeOptions.length > 0 ? vlmFieldTypeOptions[0].value : 'vlm_default_prompt';
});
// 自定义字段的提示词模板
const [customVlmPrompt, setCustomVlmPrompt] = useState('请识别文档中的印章信息,提取以下字段');
// 提示词类型
const [promptType, setPromptType] = useState({
llm: initialData?.extraction_config?.llm?.prompt_setting?.type || 'llm_default_prompt',
vlm: initialData?.extraction_config?.vlm?.prompt_setting?.type || 'vlm_default_prompt'
});
// 提示词模板
const [selectedTemplate, setSelectedTemplate] = useState({
llm: '',
vlm: ''
});
// 提示词内容
const [promptContent, setPromptContent] = useState({
llm: initialData?.extraction_config?.llm?.prompt_setting?.template || '',
vlm: initialData?.extraction_config?.vlm?.prompt_setting?.template || ''
});
// 正则表达式字段
const [regexFields, setRegexFields] = useState(
initialData?.extraction_config?.regex?.fields || []
);
// 状态消息
const [statusMessage, setStatusMessage] = useState<{id: string, message: string} | null>(null);
// 是否有未保存更改
const [hasPendingChanges, setHasPendingChanges] = useState(false);
// 更新状态
const [updateStatus, setUpdateStatus] = useState<{success: boolean, message: string} | null>(null);
const handleTabChange = (tab: string) => {
setCurrentTab(tab);
};
// 当 vlmFieldTypeOptions 加载完成时,更新默认选中的类型
useEffect(() => {
if (vlmFieldTypeOptions.length > 0 && !vlmFieldTypeOptions.find(opt => opt.value === selectedVlmFieldType)) {
// 如果当前选中的类型不在新的选项列表中,选择第一个选项
setSelectedVlmFieldType(vlmFieldTypeOptions[0].value);
}
}, [vlmFieldTypeOptions, selectedVlmFieldType]);
// 初始化自定义字段的提示词
useEffect(() => {
// 在编辑模式下,如果有自定义类型的字段,加载其 template
const vlmFields = initialData?.extraction_config?.vlm?.fields || [];
const customField = vlmFields.find(
(f: string | { name: string; type: string; template?: string }) =>
typeof f === 'object' && f.type === 'custom' && f.template
);
if (customField && typeof customField === 'object' && customField.template) {
setCustomVlmPrompt(customField.template);
}
}, [initialData]);
// 自动保存字段变更状态
// 这个效果确保添加字段后自动保存到组件状态,但不自动提交更新
useEffect(() => {
// 初始加载时不设置hasPendingChanges为true
// 仅当用户进行了实际修改后才标记为有变更
const initialLlmFields = initialData?.extraction_config?.llm?.fields || [];
const initialVlmFields = initialData?.extraction_config?.vlm?.fields || [];
const initialRegexFields = initialData?.extraction_config?.regex?.fields || [];
const initialLlmPrompt = initialData?.extraction_config?.llm?.prompt_setting?.template || '';
const initialVlmPrompt = initialData?.extraction_config?.vlm?.prompt_setting?.template || '';
// 检查是否有实际变化
const hasLlmFieldsChanged = JSON.stringify(fields.llm) !== JSON.stringify(initialLlmFields);
const hasVlmFieldsChanged = JSON.stringify(fields.vlm) !== JSON.stringify(initialVlmFields);
const hasRegexFieldsChanged = JSON.stringify(regexFields) !== JSON.stringify(initialRegexFields);
const hasPromptContentChanged =
promptContent.llm !== initialLlmPrompt ||
promptContent.vlm !== initialVlmPrompt;
// 只有实际发生变化时才设置为true
if (hasLlmFieldsChanged || hasVlmFieldsChanged || hasRegexFieldsChanged || hasPromptContentChanged) {
setHasPendingChanges(true);
}
}, [fields, regexFields, promptContent, initialData])
// 处理字段输入变化
const handleFieldInputChange = (
e: React.ChangeEvent<HTMLInputElement>,
type: 'llm' | 'vlm'
) => {
setInputValue({
...inputValue,
[type]: e.target.value
});
};
// 处理添加字段
const addField = (type: 'llm' | 'vlm') => {
if (!inputValue[type]) return;
// 处理多个字段输入
const inputs = inputValue[type].split(/[,\s]+/).filter(Boolean);
if (type === 'llm') {
const newFields = [...fields.llm] as LLMFieldType[];
inputs.forEach(input => {
const exists = newFields.some(f => getLLMFieldName(f) === input);
if (!exists) {
if (multiEntityEnabled) {
// 多实体模式:新字段默认 multi_entity=true
newFields.push({ name: input, multi_entity: true });
} else {
newFields.push(input);
}
}
});
setFields({ ...fields, llm: newFields });
} else {
const newFields = [...fields.vlm];
inputs.forEach(input => {
const exists = newFields.some(field =>
typeof field === 'string'
? field === input
: field.name === input
);
if (!exists) {
// 如果是自定义类型,添加 template 字段
if (selectedVlmFieldType === 'custom') {
newFields.push({
name: input,
type: selectedVlmFieldType as VLMFieldType,
template: customVlmPrompt,
multi_entity: false,
});
} else {
newFields.push({
name: input,
type: selectedVlmFieldType as VLMFieldType,
multi_entity: false,
});
}
}
});
setFields({ ...fields, vlm: newFields });
}
// 清空输入框
setInputValue({
...inputValue,
[type]: ''
});
setHasPendingChanges(true);
};
// 处理键盘事件
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>, type: 'llm' | 'vlm') => {
if (e.key === 'Enter') {
e.preventDefault();
addField(type);
}
};
// 处理删除字段
const removeField = (type: 'llm' | 'vlm', index: number) => {
if (type === 'llm') {
const newFields = [...fields.llm];
newFields.splice(index, 1);
setFields({ ...fields, llm: newFields });
} else {
const newFields = [...fields.vlm];
newFields.splice(index, 1);
setFields({ ...fields, vlm: newFields });
}
setHasPendingChanges(true);
};
// 切换 LLM 字段的多实体状态
const toggleLLMFieldMultiEntity = (index: number) => {
if (!multiEntityEnabled) return; // 多实体未开启时不允许切换
const newFields = [...fields.llm] as LLMFieldType[];
const field = newFields[index];
const name = getLLMFieldName(field);
const currentMulti = isLLMFieldMultiEntity(field);
newFields[index] = { name, multi_entity: !currentMulti };
setFields({ ...fields, llm: newFields });
setHasPendingChanges(true);
};
// 切换 VLM 字段的多实体状态
const toggleVLMFieldMultiEntity = (index: number) => {
if (!multiEntityEnabled) return; // 多实体未开启时不允许切换
const newFields = [...fields.vlm];
const field = newFields[index];
if (typeof field === 'object') {
newFields[index] = { ...field, multi_entity: !field.multi_entity };
setFields({ ...fields, vlm: newFields });
setHasPendingChanges(true);
}
};
// 获取VLM字段信息
const getFieldInfo = (field: string | { name: string, type: string, template?: string }) => {
let fieldName, fieldType, typeName, badgeClass;
if (typeof field === 'string') {
const parts = field.split('_');
fieldName = parts[0];
fieldType = parts.length > 1 ? parts[1] : 'vlm_default_prompt';
} else {
fieldName = field.name;
fieldType = field.type;
}
// 首先尝试从 vlmFieldTypeOptions 中查找对应的标签
const optionItem = vlmFieldTypeOptions.find(opt => opt.value === fieldType);
if (optionItem) {
typeName = optionItem.label;
// 根据不同类型设置不同的颜色
switch (fieldType) {
case 'vlm_default_prompt':
badgeClass = 'bg-gray-100 text-gray-800';
break;
case 'custom':
badgeClass = 'bg-indigo-100 text-indigo-800';
break;
default:
// 对于从数据库获取的类型,使用统一的蓝色系
badgeClass = 'bg-blue-100 text-blue-800';
}
} else {
// 如果找不到,使用默认值
typeName = '未知类型';
badgeClass = 'bg-gray-100 text-gray-800';
}
return { fieldName, fieldType, typeName, badgeClass };
};
// 渲染提示词类型选择
const renderPromptTypeSelect = (value: string, type: 'llm' | 'vlm') => {
return (
<select
className="form-select"
id={`${type}-prompt-type`}
value={value}
onChange={(e) => handlePromptTypeChange(e, type)}
>
{type === 'llm' && EVALUATION_OPTIONS.llmPromptTypeOptions.map((option) => (
<option key={`${type}-${option.value}`} value={option.value}>
{option.label}
</option>
))}
{type === 'vlm' && EVALUATION_OPTIONS.vlmPromptTypeOptions.map((option) => (
<option key={`${type}-${option.value}`} value={option.value}>
{option.label}
</option>
))}
</select>
);
};
// 处理提示词类型变化
const handlePromptTypeChange = (
e: React.ChangeEvent<HTMLSelectElement>,
type: 'llm' | 'vlm'
) => {
setPromptType({
...promptType,
[type]: e.target.value
});
setHasPendingChanges(true);
};
// 处理提示词模板变化
const handleTemplateChange = (
e: React.ChangeEvent<HTMLSelectElement>,
type: 'llm' | 'vlm'
) => {
const templateId = e.target.value;
setSelectedTemplate({
...selectedTemplate,
[type]: templateId
});
// 这里可以根据模板ID获取模板内容
const templateContent = getTemplateContent(templateId);
if (templateContent) {
setPromptContent({
...promptContent,
[type]: templateContent
});
}
setHasPendingChanges(true);
};
// 获取模板内容
const getTemplateContent = (templateId: string) => {
// 模拟模板内容,实际应从API获取或配置中读取
const templates: Record<string, string> = {
'1': '请从以下文档中提取关键信息,以JSON格式返回以下字段:${fieldsList}',
'4': '请从以下采购合同中提取乙方资质信息,包括:${fieldsList}',
'5': '请从以下合同中提取关键条款,包括:${fieldsList}',
'6': '请从以下烟草许可证中提取信息:${fieldsList}',
'7': '请识别文档中的印章信息,提取以下字段:${fieldsList}',
'8': '请从文档表格中提取以下信息:${fieldsList}',
'9': '请识别文档中的手写内容,提取以下字段:${fieldsList}'
};
return templates[templateId] || '';
};
// 处理提示词内容变化
const handlePromptContentChange = (
e: React.ChangeEvent<HTMLTextAreaElement>,
type: 'llm' | 'vlm'
) => {
setPromptContent({
...promptContent,
[type]: e.target.value
});
setHasPendingChanges(true);
};
// 将变量应用到提示词
const applyVariableToPrompt = (variable: string, type: 'llm' | 'vlm') => {
const variableText = `\${${variable}}`;
setPromptContent({
...promptContent,
[type]: promptContent[type] + variableText
});
setHasPendingChanges(true);
};
// 渲染VLM字段类型选择
const renderVlmFieldTypeSelect = () => {
return (
<select
className="form-select w-1/4 mr-2"
id="vlm-field-type"
value={selectedVlmFieldType}
onChange={(e) => setSelectedVlmFieldType(e.target.value)}
>
{vlmFieldTypeOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
);
};
// 添加正则字段行
const addRegexFieldRow = () => {
const newField = {
field: '',
pattern: ''
};
setRegexFields([...regexFields, newField]);
setHasPendingChanges(true);
};
// 移除正则字段行
const removeRegexFieldRow = (index: number) => {
const newFields = [...regexFields];
newFields.splice(index, 1);
setRegexFields(newFields);
setHasPendingChanges(true);
};
// 更新正则字段
const updateRegexField = (index: number, property: 'field' | 'pattern', value: string) => {
const newFields = [...regexFields];
newFields[index] = {
...newFields[index],
[property]: value
};
setRegexFields(newFields);
setHasPendingChanges(true);
};
// 处理正则字段失去焦点
const handleRegexFieldBlur = (index: number, property: 'field' | 'pattern') => {
// 显示暂时状态消息
if (property === 'pattern' && regexFields[index].pattern) {
setStatusMessage({
id: `field-${index}`,
message: '正则表达式已更新'
});
// 3秒后清除消息
setTimeout(() => {
setStatusMessage(null);
}, 3000);
}
};
// 应用正则模板
const applyRegexTemplate = (regex: string) => {
// 如果有字段,应用到最后一个字段
if (regexFields.length > 0) {
const lastIndex = regexFields.length - 1;
updateRegexField(lastIndex, 'pattern', regex);
}
setHasPendingChanges(true);
};
// 处理更新全部字段
const handleUpdateFields = () => {
// 过滤掉没有字段名的正则字段
const validRegexFields = regexFields.filter(field => field.field.trim() !== '');
// 更新所有自定义类型字段的 template
const updatedVlmFields = fields.vlm.map(field => {
if (typeof field === 'object' && field.type === 'custom') {
return {
...field,
template: customVlmPrompt
};
}
return field;
});
// 收集所有字段数据
const updatedFormData = {
...formData,
extraction_config: {
multi_entity: {
enabled: multiEntityEnabled,
expand_mode: 'awareness' as const
},
llm: {
fields: fields.llm,
prompt_setting: {
type: promptType.llm || 'llm_default_prompt',
template: promptType.llm === 'custom' ? promptContent.llm : ''
}
},
vlm: {
fields: updatedVlmFields,
prompt_setting: {
type: promptType.vlm || 'vlm_default_prompt',
template: promptType.vlm === 'custom' ? promptContent.vlm : ''
}
},
regex: {
fields: validRegexFields
}
}
};
// 验证字段唯一性
const allFieldNames = [
...fields.llm.map(f => getLLMFieldName(f)),
...fields.vlm.map(f => typeof f === 'string' ? f : f.name),
...validRegexFields.map(f => f.field)
];
const duplicates = allFieldNames.filter((item, index) =>
allFieldNames.indexOf(item) !== index
);
if (duplicates.length > 0) {
setUpdateStatus({
success: false,
message: `发现重复字段名:${duplicates.join(', ')},请修改后再提交`
});
return;
}
// 更新数据
setFormData(updatedFormData);
// 调用父组件的onChange回调
if (onChange) {
onChange(updatedFormData);
// 同时通过RuleContext上下文更新字段列表,确保评查设置组件能立即使用
if (typeof window !== 'undefined') {
// 使用setTimeout确保在React更新周期之外执行
setTimeout(() => {
// 触发一个自定义事件,通知RuleContext更新
const event = new CustomEvent('extraction-fields-updated', {
detail: { fields: allFieldNames }
});
window.dispatchEvent(event);
}, 0);
}
// 显示成功消息
setUpdateStatus({
success: true,
message: `更新成功! 共更新 ${fields.llm.length} 个大模型字段, ${fields.vlm.length} 个多模态字段, ${validRegexFields.length} 个正则字段`
});
// 重置更改状态
setHasPendingChanges(false);
// 3秒后清除状态消息
setTimeout(() => {
setUpdateStatus(null);
}, 3000);
}
};
// 处理多实体抽取开关变化
const handleMultiEntityToggle = () => {
const newValue = !multiEntityEnabled;
setMultiEntityEnabled(newValue);
if (newValue) {
// 开启:将所有字符串字段转为 dict(默认 multi_entity=true
const converted = fields.llm.map(f =>
typeof f === 'string' ? { name: f, multi_entity: true } : f
);
setFields({ ...fields, llm: converted });
} else {
// 关闭:将所有字段转回字符串
const simplified = fields.llm.map(f => getLLMFieldName(f));
setFields({ ...fields, llm: simplified });
}
setHasPendingChanges(true);
};
return (
<div className="ant-card">
<div className="ant-card-header">
<h3></h3>
</div>
<div className="ant-card-body">
{/* 多实体抽取开关 */}
<div className="mb-6 p-3 bg-gray-50 rounded-md border border-gray-200 w-fit">
<div className="flex items-center gap-5">
<div className="flex items-center">
<i className="ri-group-line text-lg mr-2 text-gray-600"></i>
<div>
<span className="font-medium text-gray-800"></span>
<span className="text-xs text-gray-500 ml-3">绿=</span>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
checked={multiEntityEnabled}
onChange={handleMultiEntityToggle}
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-green-700/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-green-700"></div>
<span className="ml-2 text-sm font-medium text-gray-700">
{multiEntityEnabled ? '已启用' : '已禁用'}
</span>
</label>
</div>
</div>
<div className="mb-6">
<div className="tab-nav mb-4" id="extraction-method-tabs">
<button
className={`tab-nav-item ${currentTab === "llm" ? "active" : ""}`}
onClick={() => handleTabChange("llm")}
type="button"
>
<i className="ri-brain-line mr-1"></i>
</button>
<button
className={`tab-nav-item ${currentTab === "vlm" ? "active" : ""}`}
onClick={() => handleTabChange("vlm")}
type="button"
>
<i className="ri-scan-line mr-1"></i>
</button>
<button
className={`tab-nav-item ${
currentTab === "regex" ? "active" : ""
}`}
onClick={() => handleTabChange("regex")}
type="button"
>
<i className="ri-code-box-line mr-1"></i>
</button>
</div>
</div>
<div
className={`extraction-config ${
currentTab !== "llm" ? "hidden" : ""
}`}
id="llm-config"
>
<div className="grid grid-cols-1 gap-3">
<div className="col-span-1">
<label className="form-label mb-1" htmlFor="field-input">
</label>
<div className="flex">
<input
type="text"
className="form-input mr-2"
id="field-input"
placeholder="请输入字段名,多个字段可用、或,或空格分隔"
value={inputValue.llm}
onChange={(e) => handleFieldInputChange(e, "llm")}
onKeyDown={(e) => handleKeyDown(e, "llm")}
autoComplete="off"
/>
<button
className="ant-btn ant-btn-default"
id="add-field-btn"
type="button"
onClick={() => addField("llm")}
>
</button>
</div>
<div className="form-tip mb-2 text-xs">
</div>
<div className="flex flex-wrap gap-2" id="fields-container">
{fields.llm.map((field, index) => {
const name = getLLMFieldName(field);
const isMulti = isLLMFieldMultiEntity(field);
return (
<button
type="button"
key={`llm-field-${index}`}
className="ant-btn ant-btn-default tag-button"
style={multiEntityEnabled && isMulti ? { backgroundColor: '#00684a', borderColor: '#00684a', color: '#fff' } : undefined}
onClick={() => multiEntityEnabled && toggleLLMFieldMultiEntity(index)}
title={multiEntityEnabled ? (isMulti ? '点击关闭多实体展开' : '点击开启多实体展开') : name}
>
{name}
<span
className="ml-1 cursor-pointer hover:text-red-500"
onClick={(e) => { e.stopPropagation(); removeField("llm", index); }}
role="button"
tabIndex={0}
>
×
</span>
</button>
);
})}
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-3 mt-3">
<div className="col-span-1">
<label className="form-label mb-1" htmlFor="llm-prompt-settings">
</label>
<div className="flex items-center mb-2" id="llm-prompt-settings">
{renderPromptTypeSelect(promptType.llm, "llm")}
</div>
<div
className="bg-gray-50 p-2 rounded text-xs text-gray-600 mb-2"
id="llm-system-prompt-info"
style={{
display: promptType.llm === "llm_default_prompt" ? "block" : "none",
}}
>
</div>
<div
id="llm-custom-prompt-container"
style={{
display: promptType.llm === "custom" ? "block" : "none",
}}
className="border border-dashed border-gray-300 p-3 rounded-md"
>
<div className="mb-2">
<label
className="form-label mb-1 text-sm"
htmlFor="llm-prompt-template"
>
</label>
<select
className="form-select"
id="llm-prompt-template"
value={selectedTemplate.llm}
onChange={(e) => handleTemplateChange(e, "llm")}
>
<option value=""></option>
<option value="1">-</option>
<option value="4">-</option>
<option value="5">-</option>
<option value="6">-</option>
</select>
</div>
<div className="mb-2">
<label
className="form-label mb-1 text-sm"
htmlFor="llm-prompt-content"
>
</label>
<textarea
className="form-textarea"
id="llm-prompt-content"
rows={4}
placeholder="选择模板后自动填充,您也可以进行修改..."
value={promptContent.llm}
onChange={(e) => handlePromptContentChange(e, "llm")}
readOnly={!selectedTemplate.llm}
></textarea>
<div className="form-tip mt-1 bg-gray-50 p-2 rounded text-xs">
<p className="mb-1">
<strong></strong>
</p>
<div className="flex flex-wrap gap-1">
{[
"docType",
"fieldsList",
"companyName",
"documentId",
"date",
"industry",
"ocrText",
].map((variable) => (
<button
key={variable}
type="button"
className="var-tag"
onClick={() => applyVariableToPrompt(variable, "llm")}
>
{variable=='docType' ? '文档类型:{docType}':
variable=='fieldsList' ? '抽取字段列表:{fieldsList}':
variable=='companyName' ? '公司名称:{companyName}':
variable=='documentId' ? '文档编号:{documentId}':
variable=='date' ? '日期:{date}':
variable=='industry' ? '行业:{industry}':
variable=='ocrText' ? 'OCR文本:{ocrText}':
variable}
</button>
))}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div
className={`extraction-config ${
currentTab !== "vlm" ? "hidden" : ""
}`}
id="vlm-config"
>
<div className="grid grid-cols-1 gap-3">
<div className="col-span-1">
<label className="form-label mb-1" htmlFor="field-input-vlm">
</label>
<div className="flex">
<input
type="text"
className="form-input mr-2"
id="field-input-vlm"
placeholder="请输入字段名"
value={inputValue.vlm}
onChange={(e) => handleFieldInputChange(e, "vlm")}
onKeyDown={(e) => handleKeyDown(e, "vlm")}
autoComplete="off"
/>
{renderVlmFieldTypeSelect()}
<button
className="ant-btn ant-btn-default"
id="add-field-btn-vlm"
type="button"
onClick={() => addField("vlm")}
>
</button>
</div>
<div className="form-tip mb-2 text-xs">
</div>
<div className="chips-container" id="fields-container-vlm">
{fields.vlm.map((field, index) => {
const { fieldName, fieldType, typeName, badgeClass } = getFieldInfo(field);
const isMulti = typeof field === 'object' && field.multi_entity === true;
return (
<div
className="chip"
key={`vlm-field-${index}`}
style={multiEntityEnabled && isMulti ? { backgroundColor: '#00684a', color: '#fff', borderColor: '#00684a' } : undefined}
onClick={() => toggleVLMFieldMultiEntity(index)}
role={multiEntityEnabled ? 'button' : undefined}
title={multiEntityEnabled ? (isMulti ? '点击关闭多实体展开' : '点击开启多实体展开') : fieldName}
>
{fieldName}
<span
className={`badge ${badgeClass} text-xs ml-1`}
data-type={fieldType}
style={multiEntityEnabled && isMulti ? { backgroundColor: 'rgba(255,255,255,0.25)', color: '#fff' } : undefined}
>
{typeName}
</span>
<span
className="close-btn"
onClick={(e) => { e.stopPropagation(); removeField("vlm", index); }}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ")
removeField("vlm", index);
}}
role="button"
tabIndex={0}
aria-label={`删除字段 ${fieldName}`}
style={multiEntityEnabled && isMulti ? { color: 'rgba(255,255,255,0.8)' } : undefined}
>
×
</span>
</div>
);
})}
</div>
</div>
</div>
{/* 只有当选择了自定义类型时才显示提示词设置 */}
{selectedVlmFieldType === 'custom' && (
<div className="grid grid-cols-1 gap-3 mt-3">
<div className="col-span-1">
<label
className="form-label mb-1"
htmlFor="multimodal-prompt-settings"
>
</label>
<div className="border border-dashed border-gray-300 p-3 rounded-md">
<div className="mb-2">
<label
className="form-label mb-1 text-sm"
htmlFor="multimodal-prompt-type"
>
</label>
<input
type="text"
className="form-input bg-gray-50"
id="multimodal-prompt-type"
value="使用自定义提示词"
readOnly
/>
</div>
<div className="mb-2">
<label
className="form-label mb-1 text-sm"
htmlFor="custom-vlm-prompt-content"
>
</label>
<textarea
className="form-textarea"
id="custom-vlm-prompt-content"
rows={4}
placeholder="请输入自定义提示词..."
value={customVlmPrompt}
onChange={(e) => {
setCustomVlmPrompt(e.target.value);
setHasPendingChanges(true);
}}
></textarea>
<div className="form-tip mt-1 bg-gray-50 p-2 rounded text-xs">
<p className="mb-1">
<strong></strong>
</p>
<div className="flex flex-wrap gap-1">
{[
"docType",
"fieldsList",
"companyName",
"documentId",
"date",
"industry",
"contentType",
"pageRange",
"colorMode",
"ocrText",
].map((variable) => (
<button
key={variable}
type="button"
className="var-tag"
onClick={() => {
const variableText = `\${${variable}}`;
setCustomVlmPrompt(customVlmPrompt + variableText);
setHasPendingChanges(true);
}}
>
{variable=='docType' ? '文档类型:{docType}':
variable=='fieldsList' ? '抽取字段列表:{fieldsList}':
variable=='companyName' ? '公司名称:{companyName}':
variable=='documentId' ? '文档编号:{documentId}':
variable=='date' ? '日期:{date}':
variable=='industry' ? '行业:{industry}':
variable=='contentType' ? '内容类型:{contentType}':
variable=='pageRange' ? '页面范围:{pageRange}':
variable=='colorMode' ? '色彩模式:{colorMode}':
variable=='ocrText' ? 'OCR文本:{ocrText}':
variable}
</button>
))}
</div>
</div>
</div>
</div>
</div>
</div>
)}
</div>
<div
className={`extraction-config ${
currentTab !== "regex" ? "hidden" : ""
}`}
id="regex-config"
>
<div className="grid grid-cols-1 gap-3">
<div className="col-span-1">
<div className="mb-2">
<div className="flex justify-between items-center mb-1">
<label
className="form-label m-0"
htmlFor="regex-fields-container"
>
</label>
<button
className="ant-btn ant-btn-default"
id="add-regex-field-row"
type="button"
onClick={addRegexFieldRow}
>
<i className="ri-add-line"></i>
</button>
</div>
<div className="mt-2" id="regex-fields-container">
{regexFields.map((field, index) => (
<div
className={`regex-field-row flex items-start mb-2 border rounded-md p-2 bg-gray-50`}
key={`regex-field-${index}`}
>
<div className="w-3/10 mr-2">
<label
className="text-xs text-gray-600 mb-0 block"
htmlFor={`regex-field-name-${index}`}
>
</label>
<input
type="text"
className={`form-input regex-field-name ${
!field.field ? "border-yellow-300" : ""
}`}
id={`regex-field-name-${index}`}
placeholder="如:合同编号"
value={field.field || ""}
onChange={(e) =>
updateRegexField(
index,
"field",
e.target.value
)
}
onBlur={() =>
handleRegexFieldBlur(index, "field")
}
autoComplete="off"
/>
</div>
<div className="w-7/10 mr-2">
<label
className="text-xs text-gray-600 mb-0 block"
htmlFor={`regex-expression-${index}`}
>
</label>
<input
type="text"
className={`form-input regex-expression ${
!field.pattern ? "border-yellow-300" : ""
}`}
id={`regex-expression-${index}`}
placeholder="如:\\d{4}[-/年](0?[1-9]|1[0-2])[-/月](0?[1-9]|[12][0-9]|3[01])[日]?"
value={field.pattern || ""}
onChange={(e) =>
updateRegexField(
index,
"pattern",
e.target.value
)
}
onBlur={() =>
handleRegexFieldBlur(index, "pattern")
}
autoComplete="off"
/>
{statusMessage && statusMessage.id === `field-${index}` && (
<div className="text-xs mt-1 text-blue-600 transition-opacity duration-300">
{statusMessage.message}
</div>
)}
{!field.pattern && (
<div className="text-xs mt-1 text-yellow-600">
</div>
)}
</div>
<div className="flex flex-col justify-end pt-3">
<button
className="text-red-500 hover:text-red-700 remove-regex-field-row"
type="button"
aria-label="删除"
onClick={() => removeRegexFieldRow(index)}
>
<i className="ri-delete-bin-line"></i>
</button>
</div>
</div>
))}
</div>
</div>
{/* 🔑 只有在添加字段后或本来就有字段时才显示常用正则模板 */}
{regexFields.length > 0 && (
<div className="mt-2">
<label
className="form-label mb-1"
htmlFor="regex-template-container"
>
</label>
<div
className="flex flex-wrap gap-1 mt-1"
id="regex-template-container"
>
{[
{
label: "日期格式:yyyy-mm-dd",
regex:
"\\d{4}[-/年](0?[1-9]|1[0-2])[-/月](0?[1-9]|[12][0-9]|3[01])[日]?",
},
{ label: "合同编号格式", regex: "[A-Z]{2,5}-\\d{4,10}" },
{
label: "金额格式",
regex:
"(人民币|RMB)?\\s?(\\d{1,3}(,\\d{3})*(\\.\\d{2})?)\\s?[万元]?",
},
{
label: "座机号码格式",
regex: "\\d{3}-\\d{8}|\\d{4}-\\d{7,8}",
},
{ label: "手机号码格式", regex: "1[3-9]\\d{9}" },
].map(({ label, regex }) => (
<div
key={label}
className="chip cursor-pointer regex-template"
onClick={() => applyRegexTemplate(regex)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ")
applyRegexTemplate(regex);
}}
>
{label}
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
{/* 在所有标签页外部添加统一的更新按钮和状态显示,这样在任何标签页都可见 */}
<div className="border-t border-gray-200 pt-5 mt-5">
<div className="flex flex-col items-center mb-2">
{hasPendingChanges && !updateStatus && (
<div className="text-center text-sm mb-3 p-2 rounded bg-yellow-100 text-yellow-700">
&ldquo;&rdquo;
</div>
)}
{!hasPendingChanges && !updateStatus && (
<div className="text-center text-xs mb-2 text-gray-600">
</div>
)}
<button
className={`ant-btn ${
hasPendingChanges ? "ant-btn-primary" : "ant-btn-default"
} text-base py-2 px-4 font-medium shadow-sm hover:shadow`}
type="button"
onClick={handleUpdateFields}
>
<i className="ri-refresh-line mr-1"></i>
{hasPendingChanges
? "更新全部字段 (有未保存更改)"
: "更新全部字段"}
</button>
</div>
{updateStatus && (
<div
className={`text-center text-sm mt-2 p-2 rounded ${
updateStatus.success
? "bg-green-100 text-green-700"
: "bg-red-100 text-red-700"
}`}
>
{updateStatus.message}
</div>
)}
</div>
</div>
</div>
);
}