bc3e26c93e
- evaluation_points.ts: LLMFieldConfig type, LLMFieldType union,
VLMField.multi_entity
- ExtractionSettings.tsx:
- LLM fields render as colored buttons (green=multi_entity, gray=normal)
- Click to toggle individual field multi_entity when switch is on
- Toggle switch on: converts all string fields to {name, multi_entity:true}
- Toggle switch off: converts back to plain strings
- New fields default to multi_entity:true when switch is on
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1218 lines
45 KiB
TypeScript
1218 lines
45 KiB
TypeScript
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
|
||
});
|
||
} else {
|
||
newFields.push({
|
||
name: input,
|
||
type: selectedVlmFieldType as VLMFieldType
|
||
});
|
||
}
|
||
}
|
||
});
|
||
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 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 ${multiEntityEnabled && isMulti ? 'ant-btn-primary' : 'ant-btn-default tag-button'}`}
|
||
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);
|
||
return (
|
||
<div className="chip" key={`vlm-field-${index}`}>
|
||
{fieldName}
|
||
<span
|
||
className={`badge ${badgeClass} text-xs ml-1`}
|
||
data-type={fieldType}
|
||
>
|
||
{typeName}
|
||
</span>
|
||
<span
|
||
className="close-btn"
|
||
onClick={() => removeField("vlm", index)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" || e.key === " ")
|
||
removeField("vlm", index);
|
||
}}
|
||
role="button"
|
||
tabIndex={0}
|
||
aria-label={`删除字段 ${fieldName}`}
|
||
>
|
||
×
|
||
</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">
|
||
您有未更新的字段变更,请点击下方的“更新全部字段”按钮提交所有标签页的字段变更
|
||
</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>
|
||
);
|
||
}
|