1060 lines
37 KiB
TypeScript
1060 lines
37 KiB
TypeScript
import React, {
|
||
useState,
|
||
useEffect,
|
||
} from "react";
|
||
import type { EvaluationPoint } 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': 规则内任一条件满足即可
|
||
*/
|
||
|
||
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,
|
||
}: ExtractionSettingsProps) {
|
||
|
||
// 核心数据状态
|
||
const [formData, setFormData] = useState<EvaluationPoint>({
|
||
// 字段配置
|
||
extraction_config: {
|
||
llm: initialData?.extraction_config?.llm ?? {
|
||
fields: [],
|
||
prompt_setting: {
|
||
type: "system",
|
||
template: "",
|
||
},
|
||
},
|
||
vlm: initialData?.extraction_config?.vlm ?? {
|
||
fields: [],
|
||
prompt_setting: {
|
||
type: "system",
|
||
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('default');
|
||
// 提示词类型
|
||
const [promptType, setPromptType] = useState({
|
||
llm: initialData?.extraction_config?.llm?.prompt_setting?.type || 'system',
|
||
vlm: initialData?.extraction_config?.vlm?.prompt_setting?.type || 'system'
|
||
});
|
||
// 提示词模板
|
||
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);
|
||
};
|
||
|
||
// 自动保存字段变更状态
|
||
// 这个效果确保添加字段后自动保存到组件状态,但不自动提交更新
|
||
useEffect(() => {
|
||
setHasPendingChanges(true);
|
||
}, [fields, regexFields, promptContent])
|
||
|
||
// 处理字段输入变化
|
||
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];
|
||
inputs.forEach(input => {
|
||
if (!newFields.includes(input)) {
|
||
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) {
|
||
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);
|
||
};
|
||
|
||
// 获取VLM字段信息
|
||
const getFieldInfo = (fieldString: string) => {
|
||
const parts = fieldString.split('_');
|
||
const fieldName = parts[0];
|
||
const fieldType = parts.length > 1 ? parts[1] : 'default';
|
||
|
||
let typeName, badgeClass;
|
||
switch (fieldType) {
|
||
case 'currency':
|
||
typeName = '货币';
|
||
badgeClass = 'bg-green-100 text-green-800';
|
||
break;
|
||
case 'print':
|
||
typeName = '打印';
|
||
badgeClass = 'bg-blue-100 text-blue-800';
|
||
break;
|
||
case 'seal':
|
||
typeName = '印章';
|
||
badgeClass = 'bg-red-100 text-red-800';
|
||
break;
|
||
case 'cross-seal':
|
||
typeName = '骑缝章';
|
||
badgeClass = 'bg-orange-100 text-orange-800';
|
||
break;
|
||
case 'english':
|
||
typeName = '英文';
|
||
badgeClass = 'bg-purple-100 text-purple-800';
|
||
break;
|
||
case 'number':
|
||
typeName = '数字';
|
||
badgeClass = 'bg-yellow-100 text-yellow-800';
|
||
break;
|
||
case 'handwriting':
|
||
typeName = '手写';
|
||
badgeClass = 'bg-pink-100 text-pink-800';
|
||
break;
|
||
default:
|
||
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)}
|
||
>
|
||
{EVALUATION_OPTIONS.promptTypeOptions.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)}
|
||
>
|
||
{EVALUATION_OPTIONS.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() !== '');
|
||
|
||
// 收集所有字段数据
|
||
const updatedFormData = {
|
||
...formData,
|
||
extraction_config: {
|
||
llm: {
|
||
fields: fields.llm,
|
||
prompt_setting: {
|
||
type: promptType.llm,
|
||
template: promptType.llm === 'custom' ? promptContent.llm : ''
|
||
}
|
||
},
|
||
vlm: {
|
||
fields: fields.vlm,
|
||
prompt_setting: {
|
||
type: promptType.vlm,
|
||
template: promptType.vlm === 'custom' ? promptContent.vlm : ''
|
||
}
|
||
},
|
||
regex: {
|
||
fields: validRegexFields
|
||
}
|
||
}
|
||
};
|
||
|
||
// 验证字段唯一性
|
||
const allFieldNames = [
|
||
...fields.llm,
|
||
...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);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="ant-card">
|
||
<div className="ant-card-header">
|
||
<h3>抽取设置</h3>
|
||
</div>
|
||
<div className="ant-card-body">
|
||
<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 mb-2">
|
||
<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="chips-container" id="fields-container">
|
||
{fields.llm.map((field, index) => (
|
||
<div className="chip" key={`llm-field-${index}`}>
|
||
{field}
|
||
<span
|
||
className="close-btn"
|
||
onClick={() => removeField("llm", index)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" || e.key === " ")
|
||
removeField("llm", index);
|
||
}}
|
||
role="button"
|
||
tabIndex={0}
|
||
aria-label={`删除字段 ${field}`}
|
||
>
|
||
×
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="form-tip mt-1 text-xs">
|
||
支持一次输入多个字段,用逗号、空格或顿号分隔
|
||
</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 === "system" ? "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}
|
||
</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 mb-2">
|
||
<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="chips-container" id="fields-container-vlm">
|
||
{fields.vlm.map((field, index) => {
|
||
const { fieldName, fieldType, typeName, badgeClass } =
|
||
getFieldInfo(
|
||
typeof field === "string"
|
||
? field
|
||
: `${field.name}_${field.type}`
|
||
);
|
||
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 className="form-tip mt-1 text-xs">
|
||
请为每个字段选择适当的抽取类型,有助于提高识别准确率
|
||
</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="multimodal-prompt-settings"
|
||
>
|
||
提示词设置
|
||
</label>
|
||
<div
|
||
className="flex items-center mb-2"
|
||
id="multimodal-prompt-settings"
|
||
>
|
||
{renderPromptTypeSelect(promptType.vlm, "vlm")}
|
||
</div>
|
||
<div
|
||
className="bg-gray-50 p-2 rounded text-xs text-gray-600 mb-2"
|
||
id="multimodal-system-prompt-info"
|
||
style={{
|
||
display: promptType.vlm === "system" ? "block" : "none",
|
||
}}
|
||
>
|
||
系统将根据评查点类型和抽取目标自动生成适合的提示词,支持图表、印章等图像内容抽取。
|
||
</div>
|
||
|
||
<div
|
||
id="multimodal-custom-prompt-container"
|
||
style={{
|
||
display: promptType.vlm === "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="multimodal-prompt-template"
|
||
>
|
||
选择提示词模板
|
||
</label>
|
||
<select
|
||
className="form-select"
|
||
id="multimodal-prompt-template"
|
||
value={selectedTemplate.vlm}
|
||
onChange={(e) => handleTemplateChange(e, "vlm")}
|
||
>
|
||
<option value="">请选择模板</option>
|
||
<option value="7">多模态-印章识别模板</option>
|
||
<option value="8">多模态-表格抽取模板</option>
|
||
<option value="9">多模态-手写内容识别模板</option>
|
||
</select>
|
||
</div>
|
||
<div className="mb-2">
|
||
<label
|
||
className="form-label mb-1 text-sm"
|
||
htmlFor="multimodal-prompt-content"
|
||
>
|
||
提示词内容
|
||
</label>
|
||
<textarea
|
||
className="form-textarea"
|
||
id="multimodal-prompt-content"
|
||
rows={4}
|
||
placeholder="选择模板后自动填充,您也可以进行修改..."
|
||
value={promptContent.vlm}
|
||
onChange={(e) => handlePromptContentChange(e, "vlm")}
|
||
readOnly={!selectedTemplate.vlm}
|
||
></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={() => applyVariableToPrompt(variable, "vlm")}
|
||
>
|
||
{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>
|
||
<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>
|
||
);
|
||
}
|