You've already forked DataMate
feat(annotation): 替换模板配置表单为树形编辑器组件
- 移除 TemplateConfigurationForm 组件并引入 TemplateConfigurationTreeEditor - 使用 useTagConfig Hook 获取标签配置 - 将自定义XML状态 customXml 替换为 labelConfig - 删除模板编辑标签页和选择模板状态管理 - 更新XML解析逻辑支持更多对象和标注控件类型 - 添加配置验证功能确保至少包含数据对象和标注控件 - 在模板详情页面使用树形编辑器显示配置详情 - 更新任务创建页面集成新的树形配置编辑器 - 调整预览数据生成功能适配新的XML解析方式
This commit is contained in:
@@ -1,180 +1,108 @@
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, Button, Input, Select, Divider, Form, message, Radio } from "antd";
|
||||
import { Button, Input, Select, Form, message, Radio } from "antd";
|
||||
import TextArea from "antd/es/input/TextArea";
|
||||
import {
|
||||
DatabaseOutlined,
|
||||
CheckOutlined,
|
||||
PlusOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { mockTemplates } from "@/mock/annotation";
|
||||
import CustomTemplateDialog from "./components/CustomTemplateDialog";
|
||||
import TemplateConfigurationForm from "../components/TemplateConfigurationForm";
|
||||
import { DatabaseOutlined } from "@ant-design/icons";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { queryDatasetsUsingGet } from "../../DataManagement/dataset.api";
|
||||
import { mapDataset } from "@/pages/DataManagement/dataset.const";
|
||||
import type { Dataset } from "@/pages/DataManagement/dataset.model";
|
||||
import {
|
||||
DatasetType,
|
||||
type Dataset,
|
||||
} from "@/pages/DataManagement/dataset.model";
|
||||
|
||||
interface Template {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
description: string;
|
||||
type: "text" | "image";
|
||||
preview?: string;
|
||||
icon: React.ReactNode;
|
||||
isCustom?: boolean;
|
||||
}
|
||||
|
||||
const templateCategories = ["Computer Vision", "Natural Language Processing"];
|
||||
createAnnotationTaskUsingPost,
|
||||
queryAnnotationTemplatesUsingGet,
|
||||
} from "../annotation.api";
|
||||
import type { AnnotationTemplate } from "../annotation.model";
|
||||
import TemplateConfigurationTreeEditor from "../components/TemplateConfigurationTreeEditor";
|
||||
|
||||
export default function AnnotationTaskCreate() {
|
||||
const navigate = useNavigate();
|
||||
const [form] = Form.useForm();
|
||||
const [showCustomTemplateDialog, setShowCustomTemplateDialog] =
|
||||
useState(false);
|
||||
const [selectedCategory, setSelectedCategory] = useState("Computer Vision");
|
||||
const [configMode, setConfigMode] = useState<"template" | "custom">("template");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [datasetFilter, setDatasetFilter] = useState("all");
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(
|
||||
null
|
||||
);
|
||||
const [datasets, setDatasets] = useState<Dataset[]>([]);
|
||||
const [selectedDataset, setSelectedDataset] = useState<Dataset | null>(null);
|
||||
|
||||
// 用于Form的受控数据
|
||||
const [formValues, setFormValues] = useState({
|
||||
name: "",
|
||||
description: "",
|
||||
datasetId: "",
|
||||
templateId: "",
|
||||
});
|
||||
const [templates, setTemplates] = useState<AnnotationTemplate[]>([]);
|
||||
const [labelConfig, setLabelConfig] = useState("");
|
||||
const [configMode, setConfigMode] = useState<"template" | "custom">("template");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const fetchDatasets = async () => {
|
||||
const { data } = await queryDatasetsUsingGet();
|
||||
setDatasets(data.results || []);
|
||||
try {
|
||||
const { data } = await queryDatasetsUsingGet({ page: 0, pageSize: 1000 });
|
||||
const list = data?.content || [];
|
||||
setDatasets(list.map((item: any) => mapDataset(item)) || []);
|
||||
} catch (error) {
|
||||
console.error("加载数据集失败:", error);
|
||||
message.error("加载数据集失败");
|
||||
}
|
||||
};
|
||||
|
||||
const fetchTemplates = async () => {
|
||||
try {
|
||||
const response = await queryAnnotationTemplatesUsingGet({ page: 1, size: 200 });
|
||||
if (response.code === 200 && response.data) {
|
||||
setTemplates(response.data.content || []);
|
||||
} else {
|
||||
message.error(response.message || "加载模板失败");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("加载模板失败:", error);
|
||||
message.error("加载模板失败");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchDatasets();
|
||||
fetchTemplates();
|
||||
}, []);
|
||||
|
||||
const filteredTemplates = mockTemplates.filter(
|
||||
(template) => template.category === selectedCategory
|
||||
);
|
||||
|
||||
const handleDatasetSelect = (datasetId: string) => {
|
||||
const dataset = datasets.find((ds) => ds.id === datasetId) || null;
|
||||
setSelectedDataset(dataset);
|
||||
setFormValues((prev) => ({ ...prev, datasetId }));
|
||||
if (dataset?.type === DatasetType.PRETRAIN_IMAGE) {
|
||||
setSelectedCategory("Computer Vision");
|
||||
} else if (dataset?.type === DatasetType.PRETRAIN_TEXT) {
|
||||
setSelectedCategory("Natural Language Processing");
|
||||
const handleTemplateSelect = (value?: string) => {
|
||||
if (!value) {
|
||||
setLabelConfig("");
|
||||
return;
|
||||
}
|
||||
setSelectedTemplate(null);
|
||||
setFormValues((prev) => ({ ...prev, templateId: "" }));
|
||||
const selectedTemplate = templates.find((template) => template.id === value);
|
||||
setLabelConfig(selectedTemplate?.labelConfig || "");
|
||||
};
|
||||
|
||||
const handleTemplateSelect = (template: Template) => {
|
||||
setSelectedTemplate(template);
|
||||
setFormValues((prev) => ({ ...prev, templateId: template.id }));
|
||||
};
|
||||
|
||||
const handleValuesChange = (_, allValues) => {
|
||||
setFormValues({ ...formValues, ...allValues });
|
||||
};
|
||||
|
||||
const handleConfigModeChange = (e) => {
|
||||
const handleConfigModeChange = (e: any) => {
|
||||
const mode = e.target.value;
|
||||
setConfigMode(mode);
|
||||
if (mode === "custom") {
|
||||
// Initialize default values for custom configuration
|
||||
form.setFieldsValue({
|
||||
objects: [{ name: "image", type: "Image", value: "$image" }],
|
||||
labels: [],
|
||||
});
|
||||
// Clear template selection
|
||||
setSelectedTemplate(null);
|
||||
setFormValues((prev) => ({ ...prev, templateId: "" }));
|
||||
form.setFieldsValue({ templateId: undefined });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const dataset = datasets.find((ds) => ds.id === values.datasetId);
|
||||
|
||||
let template;
|
||||
if (configMode === "template") {
|
||||
template = mockTemplates.find(
|
||||
(tpl) => tpl.id === values.templateId
|
||||
);
|
||||
if (!template) {
|
||||
message.error("请选择标注模板");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Custom configuration
|
||||
const objects = values.objects;
|
||||
const labels = values.labels;
|
||||
if (!objects || objects.length === 0) {
|
||||
message.error("请至少配置一个数据对象");
|
||||
return;
|
||||
}
|
||||
if (!labels || labels.length === 0) {
|
||||
message.error("请至少配置一个标签控件");
|
||||
return;
|
||||
}
|
||||
|
||||
// Construct a temporary custom template object
|
||||
template = {
|
||||
id: `custom-${Date.now()}`,
|
||||
name: "自定义配置",
|
||||
description: "任务特定的自定义配置",
|
||||
type: selectedDataset?.type === DatasetType.PRETRAIN_TEXT ? "text" : "image",
|
||||
isCustom: true,
|
||||
configuration: {
|
||||
objects,
|
||||
labels
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (!dataset) {
|
||||
message.error("请选择数据集");
|
||||
if (!labelConfig.trim()) {
|
||||
message.error("请配置标注模板");
|
||||
return;
|
||||
}
|
||||
|
||||
const taskData = {
|
||||
setSubmitting(true);
|
||||
await createAnnotationTaskUsingPost({
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
dataset,
|
||||
template,
|
||||
};
|
||||
// onCreateTask(taskData); // 实际创建逻辑
|
||||
console.log("Submitting task data:", taskData);
|
||||
datasetId: values.datasetId,
|
||||
templateId: configMode === "template" ? values.templateId : undefined,
|
||||
labelConfig: labelConfig.trim(),
|
||||
});
|
||||
message.success("标注任务创建成功");
|
||||
navigate("/data/annotation");
|
||||
} catch (e) {
|
||||
// 校验失败
|
||||
console.error(e);
|
||||
} catch (error: any) {
|
||||
if (error?.errorFields) {
|
||||
message.error("请完善必填信息");
|
||||
} else {
|
||||
const msg = error?.message || error?.data?.message || "创建失败,请稍后重试";
|
||||
message.error(msg);
|
||||
console.error(error);
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveCustomTemplate = (templateData: any) => {
|
||||
setSelectedTemplate(templateData);
|
||||
setFormValues((prev) => ({ ...prev, templateId: templateData.id }));
|
||||
message.success(`自定义模板 "${templateData.name}" 已创建`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-overflow-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center mb-2">
|
||||
<Link to="/data/annotation">
|
||||
<Button type="text">
|
||||
@@ -186,13 +114,7 @@ export default function AnnotationTaskCreate() {
|
||||
|
||||
<div className="flex-overflow-auto bg-white rounded-lg shadow-sm">
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<Form
|
||||
form={form}
|
||||
initialValues={formValues}
|
||||
onValuesChange={handleValuesChange}
|
||||
layout="vertical"
|
||||
>
|
||||
{/* 基本信息 */}
|
||||
<Form form={form} layout="vertical">
|
||||
<h2 className="font-medium text-gray-900 text-lg mb-2">基本信息</h2>
|
||||
<Form.Item
|
||||
label="任务名称"
|
||||
@@ -201,11 +123,7 @@ export default function AnnotationTaskCreate() {
|
||||
>
|
||||
<Input placeholder="输入任务名称" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="任务描述"
|
||||
name="description"
|
||||
rules={[{ required: true, message: "请输入任务描述" }]}
|
||||
>
|
||||
<Form.Item label="任务描述" name="description">
|
||||
<TextArea placeholder="详细描述标注任务的要求和目标" rows={3} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
@@ -215,8 +133,6 @@ export default function AnnotationTaskCreate() {
|
||||
>
|
||||
<Select
|
||||
optionFilterProp="children"
|
||||
value={formValues.datasetId}
|
||||
onChange={handleDatasetSelect}
|
||||
placeholder="请选择数据集"
|
||||
size="large"
|
||||
options={datasets.map((dataset) => ({
|
||||
@@ -236,10 +152,9 @@ export default function AnnotationTaskCreate() {
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* 模板配置 */}
|
||||
<div className="flex items-center justify-between mt-6 mb-2">
|
||||
<h2 className="font-medium text-gray-900 text-lg flex items-center gap-2">
|
||||
模板配置
|
||||
标注配置
|
||||
</h2>
|
||||
<Radio.Group value={configMode} onChange={handleConfigModeChange} buttonStyle="solid">
|
||||
<Radio.Button value="template">选择现有模板</Radio.Button>
|
||||
@@ -247,163 +162,56 @@ export default function AnnotationTaskCreate() {
|
||||
</Radio.Group>
|
||||
</div>
|
||||
|
||||
{configMode === "template" ? (
|
||||
{configMode === "template" && (
|
||||
<Form.Item
|
||||
label="加载现有模板"
|
||||
name="templateId"
|
||||
rules={[{ required: true, message: "请选择标注模板" }]}
|
||||
>
|
||||
<div className="flex">
|
||||
{/* Category Sidebar */}
|
||||
<div className="w-64 pr-6 border-r border-gray-200">
|
||||
<div className="space-y-2">
|
||||
{templateCategories.map((category) => {
|
||||
const isAvailable =
|
||||
selectedDataset?.type === "image"
|
||||
? category === "Computer Vision"
|
||||
: category === "Natural Language Processing";
|
||||
return (
|
||||
<Button
|
||||
key={category}
|
||||
type={
|
||||
selectedCategory === category && isAvailable
|
||||
? "primary"
|
||||
: "default"
|
||||
}
|
||||
block
|
||||
disabled={!isAvailable}
|
||||
onClick={() =>
|
||||
isAvailable && setSelectedCategory(category)
|
||||
}
|
||||
style={{ textAlign: "left", marginBottom: 8 }}
|
||||
>
|
||||
{category}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
type="dashed"
|
||||
block
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setShowCustomTemplateDialog(true)}
|
||||
>
|
||||
自定义模板
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Template Grid */}
|
||||
<div className="flex-1 pl-6">
|
||||
<div className="max-h-96 overflow-auto">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredTemplates.map((template) => (
|
||||
<div
|
||||
key={template.id}
|
||||
className={`border rounded-lg cursor-pointer transition-all hover:shadow-md ${
|
||||
formValues.templateId === template.id
|
||||
? "border-blue-500 bg-blue-50"
|
||||
: "border-gray-200"
|
||||
}`}
|
||||
onClick={() => handleTemplateSelect(template)}
|
||||
>
|
||||
{template.preview && (
|
||||
<div className="aspect-video bg-gray-100 rounded-t-lg overflow-hidden">
|
||||
<img
|
||||
src={template.preview || "/placeholder.svg"}
|
||||
alt={template.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
{template.icon}
|
||||
<span className="font-medium text-sm">
|
||||
{template.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600">
|
||||
{template.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* Custom Template Option */}
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg cursor-pointer transition-all hover:border-gray-400 ${
|
||||
selectedTemplate?.isCustom
|
||||
? "border-blue-500 bg-blue-50"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
onClick={() => setShowCustomTemplateDialog(true)}
|
||||
>
|
||||
<div className="aspect-video bg-gray-50 rounded-t-lg flex items-center justify-center">
|
||||
<PlusOutlined
|
||||
style={{ fontSize: 32, color: "#bbb" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<PlusOutlined />
|
||||
<span className="font-medium text-sm">
|
||||
自定义模板
|
||||
</span>
|
||||
</div>
|
||||
{selectedTemplate?.isCustom && (
|
||||
<CheckOutlined style={{ color: "#1677ff" }} />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-600">
|
||||
创建符合特定需求的标注模板
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
placeholder="选择一个模板作为基础"
|
||||
showSearch
|
||||
allowClear
|
||||
optionFilterProp="label"
|
||||
options={templates.map((template) => ({
|
||||
label: template.name,
|
||||
value: template.id,
|
||||
title: template.description,
|
||||
config: template.labelConfig,
|
||||
}))}
|
||||
onChange={handleTemplateSelect}
|
||||
optionRender={(option) => (
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{option.label}</div>
|
||||
{option.data.title && (
|
||||
<div style={{ fontSize: 12, color: "#999", marginTop: 2 }}>
|
||||
{option.data.title}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{selectedTemplate && (
|
||||
<div className="mt-4 p-3 bg-blue-50 rounded-lg">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span
|
||||
className="text-sm font-medium"
|
||||
style={{ color: "#1677ff" }}
|
||||
>
|
||||
已选择模板
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
className="text-sm"
|
||||
style={{ color: "#1677ff", marginTop: 4 }}
|
||||
>
|
||||
{selectedTemplate.name} - {selectedTemplate.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : (
|
||||
<div className="border border-gray-200 rounded-lg p-6 bg-gray-50">
|
||||
<TemplateConfigurationForm form={form} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-gray-50 p-4 rounded-md border border-gray-200">
|
||||
<TemplateConfigurationTreeEditor
|
||||
value={labelConfig}
|
||||
onChange={setLabelConfig}
|
||||
height={420}
|
||||
/>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end border-t border-gray-200 p-6">
|
||||
<Button onClick={() => navigate("/data/annotation")}>取消</Button>
|
||||
<Button type="primary" onClick={handleSubmit}>
|
||||
<Button onClick={() => navigate("/data/annotation")} disabled={submitting}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="primary" onClick={handleSubmit} loading={submitting}>
|
||||
创建任务
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Template Dialog */}
|
||||
<CustomTemplateDialog
|
||||
open={showCustomTemplateDialog}
|
||||
onOpenChange={setShowCustomTemplateDialog}
|
||||
onSaveTemplate={handleSaveCustomTemplate}
|
||||
datasetType={selectedDataset?.type || "image"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
import { type Dataset } from "@/pages/DataManagement/dataset.model";
|
||||
import type { AnnotationTemplate, AnnotationTask } from "../../annotation.model";
|
||||
import LabelStudioEmbed from "@/components/business/LabelStudioEmbed";
|
||||
import TemplateConfigurationForm from "../../components/TemplateConfigurationForm";
|
||||
import TemplateConfigurationTreeEditor from "../../components/TemplateConfigurationTreeEditor";
|
||||
import { useTagConfig } from "@/hooks/useTagConfig";
|
||||
|
||||
interface AnnotationTaskDialogProps {
|
||||
open: boolean;
|
||||
@@ -37,15 +38,10 @@ export default function CreateAnnotationTask({
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [nameManuallyEdited, setNameManuallyEdited] = useState(false);
|
||||
|
||||
// Custom template state
|
||||
const [customXml, setCustomXml] = useState("");
|
||||
const [labelConfig, setLabelConfig] = useState("");
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [previewTaskData, setPreviewTaskData] = useState<Record<string, any>>({});
|
||||
const [configMode, setConfigMode] = useState<"template" | "custom">("template");
|
||||
// 模板编辑模式切换(可视化 vs XML)
|
||||
const [templateEditTab, setTemplateEditTab] = useState<"visual" | "xml">("visual");
|
||||
// 是否已选择模板(用于启用受限编辑模式)
|
||||
const [hasSelectedTemplate, setHasSelectedTemplate] = useState(false);
|
||||
|
||||
// 数据集预览相关状态
|
||||
const [datasetPreviewVisible, setDatasetPreviewVisible] = useState(false);
|
||||
@@ -63,6 +59,7 @@ export default function CreateAnnotationTask({
|
||||
|
||||
// 任务详情加载状态(编辑模式)
|
||||
const [taskDetailLoading, setTaskDetailLoading] = useState(false);
|
||||
const { config: tagConfig } = useTagConfig(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -101,7 +98,7 @@ export default function CreateAnnotationTask({
|
||||
if (open) {
|
||||
manualForm.resetFields();
|
||||
setNameManuallyEdited(false);
|
||||
setCustomXml("");
|
||||
setLabelConfig("");
|
||||
setShowPreview(false);
|
||||
setPreviewTaskData({});
|
||||
setDatasetPreviewData([]);
|
||||
@@ -122,23 +119,14 @@ export default function CreateAnnotationTask({
|
||||
setSelectedDatasetId(taskDetail.datasetId);
|
||||
|
||||
// 获取实际的 labelConfig(优先使用任务自身的配置,回退到模板配置)
|
||||
const labelConfig = taskDetail.labelConfig || taskDetail.template?.labelConfig;
|
||||
const configXml = taskDetail.labelConfig || taskDetail.template?.labelConfig;
|
||||
|
||||
// 设置 XML 配置用于预览
|
||||
if (labelConfig) {
|
||||
setCustomXml(labelConfig);
|
||||
// 始终从 XML 解析配置,确保数据一致性
|
||||
const parsed = parseXmlToConfig(labelConfig);
|
||||
manualForm.setFieldsValue({
|
||||
objects: parsed.objects,
|
||||
labels: parsed.labels,
|
||||
});
|
||||
if (configXml) {
|
||||
setLabelConfig(configXml);
|
||||
}
|
||||
|
||||
// 编辑模式始终使用 custom 配置模式(不改变结构,只改标签)
|
||||
// 编辑模式始终使用 custom 配置模式(不改变结构,只改属性)
|
||||
setConfigMode("custom");
|
||||
// 编辑模式下启用受限编辑
|
||||
setHasSelectedTemplate(true);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -151,7 +139,6 @@ export default function CreateAnnotationTask({
|
||||
} else {
|
||||
// 创建模式:重置为默认状态
|
||||
setConfigMode("template");
|
||||
setHasSelectedTemplate(false);
|
||||
setSelectedDatasetId(null);
|
||||
}
|
||||
}
|
||||
@@ -243,229 +230,203 @@ export default function CreateAnnotationTask({
|
||||
}
|
||||
};
|
||||
|
||||
// 从 Label Studio XML 配置解析出 objects 和 labels
|
||||
const parseXmlToConfig = (xml: string): { objects: any[], labels: any[] } => {
|
||||
const objects: any[] = [];
|
||||
const labels: any[] = [];
|
||||
const DEFAULT_OBJECT_TAGS = [
|
||||
"Image",
|
||||
"Text",
|
||||
"Audio",
|
||||
"Video",
|
||||
"HyperText",
|
||||
"PDF",
|
||||
"Markdown",
|
||||
"Paragraphs",
|
||||
"Table",
|
||||
"AudioPlus",
|
||||
"Timeseries",
|
||||
"TimeSeries",
|
||||
"Vector",
|
||||
"Chat",
|
||||
];
|
||||
const DEFAULT_LABELING_CONTROL_TAGS = [
|
||||
"Choices",
|
||||
"Labels",
|
||||
"RectangleLabels",
|
||||
"PolygonLabels",
|
||||
"EllipseLabels",
|
||||
"KeyPointLabels",
|
||||
"BrushLabels",
|
||||
"TextArea",
|
||||
"Number",
|
||||
"DateTime",
|
||||
"Rating",
|
||||
"Taxonomy",
|
||||
"ParagraphLabels",
|
||||
"HyperTextLabels",
|
||||
"Relations",
|
||||
"Relation",
|
||||
"Pairwise",
|
||||
"TimeseriesLabels",
|
||||
"TimeSeriesLabels",
|
||||
"VectorLabels",
|
||||
"VideoRectangle",
|
||||
"MagicWand",
|
||||
"BitmaskLabels",
|
||||
];
|
||||
|
||||
const resolveObjectTags = () => {
|
||||
const configTags = tagConfig?.objects ? Object.keys(tagConfig.objects) : [];
|
||||
return new Set(configTags.length > 0 ? configTags : DEFAULT_OBJECT_TAGS);
|
||||
};
|
||||
|
||||
const resolveLabelingControlTags = () => {
|
||||
if (tagConfig?.controls) {
|
||||
const labelingTags = Object.entries(tagConfig.controls)
|
||||
.filter(([, config]) => config.category === "labeling")
|
||||
.map(([tag]) => tag);
|
||||
if (labelingTags.length > 0) {
|
||||
return new Set(labelingTags);
|
||||
}
|
||||
}
|
||||
return new Set(DEFAULT_LABELING_CONTROL_TAGS);
|
||||
};
|
||||
|
||||
const parseXmlElements = (xml: string): Element[] => {
|
||||
if (!xml) return [];
|
||||
try {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(xml, "text/xml");
|
||||
|
||||
// 数据对象类型列表
|
||||
const objectTypes = ["Image", "Text", "Audio", "Video", "HyperText", "Header", "Paragraphs", "TimeSeries", "TimeSeriesChannel"];
|
||||
// 标签控件类型列表
|
||||
const controlTypes = ["Choices", "Labels", "RectangleLabels", "PolygonLabels", "EllipseLabels", "KeyPointLabels", "BrushLabels", "TextArea", "Number", "DateTime", "Rating", "Taxonomy"];
|
||||
|
||||
// 解析数据对象
|
||||
objectTypes.forEach(type => {
|
||||
const elements = doc.getElementsByTagName(type);
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const el = elements[i];
|
||||
const name = el.getAttribute("name") || "";
|
||||
const value = el.getAttribute("value") || "";
|
||||
if (name) {
|
||||
objects.push({ name, type, value });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 解析标签控件
|
||||
controlTypes.forEach(type => {
|
||||
const elements = doc.getElementsByTagName(type);
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const el = elements[i];
|
||||
const fromName = el.getAttribute("name") || "";
|
||||
const toName = el.getAttribute("toName") || "";
|
||||
const required = el.getAttribute("required") === "true";
|
||||
|
||||
if (fromName) {
|
||||
const label: any = {
|
||||
fromName,
|
||||
toName,
|
||||
type,
|
||||
required,
|
||||
};
|
||||
|
||||
// 解析选项/标签值
|
||||
if (type === "Choices") {
|
||||
const choices: string[] = [];
|
||||
const choiceElements = el.getElementsByTagName("Choice");
|
||||
for (let j = 0; j < choiceElements.length; j++) {
|
||||
const value = choiceElements[j].getAttribute("value");
|
||||
if (value) choices.push(value);
|
||||
}
|
||||
label.options = choices;
|
||||
} else if (["Labels", "RectangleLabels", "PolygonLabels", "EllipseLabels", "KeyPointLabels", "BrushLabels"].includes(type)) {
|
||||
const labelValues: string[] = [];
|
||||
const labelElements = el.getElementsByTagName("Label");
|
||||
for (let j = 0; j < labelElements.length; j++) {
|
||||
const value = labelElements[j].getAttribute("value");
|
||||
if (value) labelValues.push(value);
|
||||
}
|
||||
label.labels = labelValues;
|
||||
}
|
||||
|
||||
labels.push(label);
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to parse XML config:", e);
|
||||
if (doc.getElementsByTagName("parsererror").length > 0) {
|
||||
return [];
|
||||
}
|
||||
return Array.from(doc.getElementsByTagName("*"));
|
||||
} catch (error) {
|
||||
console.error("解析 XML 失败", error);
|
||||
return [];
|
||||
}
|
||||
|
||||
return { objects, labels };
|
||||
};
|
||||
|
||||
const generateXmlFromConfig = (objects: any[], labels: any[]) => {
|
||||
let xml = '<View>\n';
|
||||
|
||||
// Objects
|
||||
if (objects) {
|
||||
objects.forEach((obj: any) => {
|
||||
xml += ` <${obj.type} name="${obj.name}" value="${obj.value}" />\n`;
|
||||
});
|
||||
}
|
||||
|
||||
// Controls
|
||||
if (labels) {
|
||||
labels.forEach((lbl: any) => {
|
||||
let attrs = `name="${lbl.fromName}" toName="${lbl.toName}"`;
|
||||
if (lbl.required) attrs += ' required="true"';
|
||||
|
||||
xml += ` <${lbl.type} ${attrs}>\n`;
|
||||
|
||||
const options = lbl.type === 'Choices' ? lbl.options : lbl.labels;
|
||||
if (options && options.length) {
|
||||
options.forEach((opt: string) => {
|
||||
if (lbl.type === 'Choices') {
|
||||
xml += ` <Choice value="${opt}" />\n`;
|
||||
} else {
|
||||
xml += ` <Label value="${opt}" />\n`;
|
||||
}
|
||||
});
|
||||
}
|
||||
xml += ` </${lbl.type}>\n`;
|
||||
});
|
||||
}
|
||||
|
||||
xml += '</View>';
|
||||
return xml;
|
||||
const extractObjectsFromLabelConfig = (xml: string) => {
|
||||
const objectTags = resolveObjectTags();
|
||||
const elements = parseXmlElements(xml);
|
||||
return elements
|
||||
.filter((element) => objectTags.has(element.tagName))
|
||||
.map((element) => ({
|
||||
name: element.getAttribute("name") || "",
|
||||
type: element.tagName,
|
||||
value: element.getAttribute("value") || "",
|
||||
}))
|
||||
.filter((item) => item.name || item.value);
|
||||
};
|
||||
|
||||
// 根据 objects 配置生成预览用的示例数据
|
||||
const generateExampleData = (objects: any[]) => {
|
||||
const exampleUrls: Record<string, string> = {
|
||||
const generatePreviewTaskDataFromLabelConfig = (xml: string) => {
|
||||
const exampleDataByType: Record<string, any> = {
|
||||
Image: "https://labelstud.io/images/opa-header.png",
|
||||
Audio: "https://labelstud.io/files/sample.wav",
|
||||
AudioPlus: "https://labelstud.io/files/sample.wav",
|
||||
Video: "https://labelstud.io/files/sample.mp4",
|
||||
};
|
||||
const exampleTexts: Record<string, string> = {
|
||||
Text: "这是示例文本,用于预览标注界面。",
|
||||
HyperText: "<p>这是示例 HTML 内容</p>",
|
||||
Header: "示例标题",
|
||||
Markdown: "# 示例标题\n\n这里是示例 Markdown 内容。",
|
||||
Paragraphs: "段落一\n\n段落二\n\n段落三",
|
||||
PDF: "https://labelstud.io/files/sample.pdf",
|
||||
Table: [
|
||||
{ key: "字段A", value: "示例值A" },
|
||||
{ key: "字段B", value: "示例值B" },
|
||||
],
|
||||
Chat: [
|
||||
{ text: "你好,我想了解一下产品。", author: "user" },
|
||||
{ text: "当然可以,请告诉我你的需求。", author: "assistant" },
|
||||
],
|
||||
Timeseries: "https://labelstud.io/files/sample.csv",
|
||||
TimeSeries: "https://labelstud.io/files/sample.csv",
|
||||
Vector: [0.12, 0.52, 0.33],
|
||||
};
|
||||
|
||||
const data: Record<string, any> = {};
|
||||
|
||||
if (!objects || objects.length === 0) {
|
||||
// 默认数据
|
||||
const objects = extractObjectsFromLabelConfig(xml);
|
||||
if (objects.length === 0) {
|
||||
return {
|
||||
image: exampleUrls.Image,
|
||||
text: exampleTexts.Text,
|
||||
audio: exampleUrls.Audio,
|
||||
image: exampleDataByType.Image,
|
||||
text: exampleDataByType.Text,
|
||||
audio: exampleDataByType.Audio,
|
||||
};
|
||||
}
|
||||
|
||||
objects.forEach((obj: any) => {
|
||||
if (!obj?.name || !obj?.value) return;
|
||||
// 变量名从 $varName 中提取
|
||||
const varName = obj.value.startsWith("$") ? obj.value.slice(1) : obj.name;
|
||||
const data: Record<string, any> = {};
|
||||
objects.forEach((obj) => {
|
||||
const name = obj.name || "";
|
||||
const value = obj.value || "";
|
||||
const varName = value.startsWith("$") ? value.slice(1) : name || value;
|
||||
if (!varName) return;
|
||||
|
||||
if (exampleUrls[obj.type]) {
|
||||
data[varName] = exampleUrls[obj.type];
|
||||
} else if (exampleTexts[obj.type]) {
|
||||
data[varName] = exampleTexts[obj.type];
|
||||
if (exampleDataByType[obj.type]) {
|
||||
data[varName] = exampleDataByType[obj.type];
|
||||
return;
|
||||
}
|
||||
|
||||
const lowerName = varName.toLowerCase();
|
||||
if (lowerName.includes("image") || lowerName.includes("img")) {
|
||||
data[varName] = exampleDataByType.Image;
|
||||
} else if (lowerName.includes("audio") || lowerName.includes("sound")) {
|
||||
data[varName] = exampleDataByType.Audio;
|
||||
} else if (lowerName.includes("video")) {
|
||||
data[varName] = exampleDataByType.Video;
|
||||
} else if (lowerName.includes("chat")) {
|
||||
data[varName] = exampleDataByType.Chat;
|
||||
} else {
|
||||
// 未知类型,尝试根据名称猜测
|
||||
const lowerName = varName.toLowerCase();
|
||||
if (lowerName.includes("image") || lowerName.includes("img")) {
|
||||
data[varName] = exampleUrls.Image;
|
||||
} else if (lowerName.includes("audio") || lowerName.includes("sound")) {
|
||||
data[varName] = exampleUrls.Audio;
|
||||
} else if (lowerName.includes("video")) {
|
||||
data[varName] = exampleUrls.Video;
|
||||
} else {
|
||||
data[varName] = exampleTexts.Text;
|
||||
}
|
||||
data[varName] = exampleDataByType.Text;
|
||||
}
|
||||
});
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
// 当选择模板时,加载模板配置到表单
|
||||
// 当选择模板时,加载 XML 配置到树编辑器(仅快速填充)
|
||||
const handleTemplateSelect = (value: string, option: any) => {
|
||||
// 处理清除选择的情况
|
||||
if (!value) {
|
||||
setHasSelectedTemplate(false);
|
||||
setCustomXml("");
|
||||
setLabelConfig("");
|
||||
return;
|
||||
}
|
||||
|
||||
setHasSelectedTemplate(true);
|
||||
const selectedTemplate = templates.find((template) => template.id === value);
|
||||
const configXml = selectedTemplate?.labelConfig || option?.config || "";
|
||||
setLabelConfig(configXml);
|
||||
};
|
||||
|
||||
if (option && option.config) {
|
||||
setCustomXml(option.config);
|
||||
const validateLabelConfigForSubmit = () => {
|
||||
const xml = labelConfig.trim();
|
||||
if (!xml) {
|
||||
message.error("请配置标注模板");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 从模板列表中找到完整的模板数据
|
||||
const selectedTemplate = templates.find(t => t.id === value);
|
||||
if (selectedTemplate?.configuration) {
|
||||
const { objects, labels } = selectedTemplate.configuration;
|
||||
manualForm.setFieldsValue({
|
||||
objects: objects || [{ name: "image", type: "Image", value: "$image" }],
|
||||
labels: labels || [],
|
||||
});
|
||||
} else if (option && option.config) {
|
||||
// 如果没有结构化配置,设置默认值
|
||||
manualForm.setFieldsValue({
|
||||
objects: [{ name: "image", type: "Image", value: "$image" }],
|
||||
labels: [],
|
||||
});
|
||||
const elements = parseXmlElements(xml);
|
||||
if (elements.length === 0) {
|
||||
message.error("标注配置 XML 格式有误");
|
||||
return false;
|
||||
}
|
||||
|
||||
const objectTags = resolveObjectTags();
|
||||
const labelingControlTags = resolveLabelingControlTags();
|
||||
const objectCount = elements.filter((element) => objectTags.has(element.tagName)).length;
|
||||
const labelingControlCount = elements.filter((element) => labelingControlTags.has(element.tagName)).length;
|
||||
|
||||
if (objectCount === 0) {
|
||||
message.error("至少需要一个数据对象标签");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (labelingControlCount === 0) {
|
||||
message.error("至少需要一个标注控件标签");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleManualSubmit = async () => {
|
||||
try {
|
||||
const values = await manualForm.validateFields();
|
||||
|
||||
let finalLabelConfig = "";
|
||||
const objects = values.objects;
|
||||
const labels = values.labels;
|
||||
|
||||
if (configMode === "template") {
|
||||
// 模板模式:优先使用可视化配置生成 XML,回退到直接使用 XML 编辑器内容
|
||||
if (templateEditTab === "visual" && objects && objects.length > 0) {
|
||||
finalLabelConfig = generateXmlFromConfig(objects, labels || []);
|
||||
} else if (customXml.trim()) {
|
||||
finalLabelConfig = customXml;
|
||||
} else {
|
||||
message.error("请配置标注模板或选择一个现有模板");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// 自定义模式
|
||||
if (!objects || objects.length === 0) {
|
||||
message.error("请至少配置一个数据对象");
|
||||
return;
|
||||
}
|
||||
if (!labels || labels.length === 0) {
|
||||
message.error("请至少配置一个标签控件");
|
||||
return;
|
||||
}
|
||||
finalLabelConfig = generateXmlFromConfig(objects, labels);
|
||||
if (!validateLabelConfigForSubmit()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
@@ -473,13 +434,8 @@ export default function CreateAnnotationTask({
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
datasetId: values.datasetId,
|
||||
templateId: configMode === 'template' ? values.templateId : undefined,
|
||||
labelConfig: finalLabelConfig,
|
||||
// 编辑模式需要传递配置结构,用于后端保存
|
||||
configuration: {
|
||||
objects: objects || [],
|
||||
labels: labels || [],
|
||||
},
|
||||
templateId: configMode === "template" ? values.templateId : undefined,
|
||||
labelConfig: labelConfig.trim(),
|
||||
};
|
||||
|
||||
if (isEditMode && editTask) {
|
||||
@@ -505,17 +461,8 @@ export default function CreateAnnotationTask({
|
||||
const handleConfigModeChange = (e: any) => {
|
||||
const mode = e.target.value;
|
||||
setConfigMode(mode);
|
||||
// 两种模式都需要初始化默认值
|
||||
const currentObjects = manualForm.getFieldValue("objects");
|
||||
if (!currentObjects || currentObjects.length === 0) {
|
||||
manualForm.setFieldsValue({
|
||||
objects: [{ name: "image", type: "Image", value: "$image" }],
|
||||
labels: [],
|
||||
});
|
||||
}
|
||||
// 切换到模板模式时,重置 tab 到可视化
|
||||
if (mode === "template") {
|
||||
setTemplateEditTab("visual");
|
||||
if (mode === "custom") {
|
||||
manualForm.setFieldsValue({ templateId: undefined });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -647,19 +594,13 @@ export default function CreateAnnotationTask({
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
const objects = manualForm.getFieldValue("objects");
|
||||
const labels = manualForm.getFieldValue("labels");
|
||||
|
||||
// 生成 XML
|
||||
if (objects && objects.length > 0) {
|
||||
const xml = generateXmlFromConfig(objects, labels || []);
|
||||
setCustomXml(xml);
|
||||
if (!labelConfig.trim()) {
|
||||
message.warning("请先配置标注模板");
|
||||
return;
|
||||
}
|
||||
|
||||
// 生成适配的示例数据
|
||||
const exampleData = generateExampleData(objects);
|
||||
const exampleData = generatePreviewTaskDataFromLabelConfig(labelConfig);
|
||||
setPreviewTaskData(exampleData);
|
||||
|
||||
setShowPreview(true);
|
||||
}}
|
||||
>
|
||||
@@ -672,14 +613,14 @@ export default function CreateAnnotationTask({
|
||||
// 编辑模式:只允许修改标签取值
|
||||
<div className="bg-gray-50 p-4 rounded-md border border-gray-200">
|
||||
<div className="text-sm text-gray-500 mb-3 bg-blue-50 p-2 rounded border border-blue-200">
|
||||
编辑模式下,模板结构(数据对象、控件类型等)不可修改,仅可修改来源名称、标签/选项的取值。
|
||||
</div>
|
||||
<div style={{ maxHeight: '350px', overflowY: 'auto' }}>
|
||||
<TemplateConfigurationForm
|
||||
form={manualForm}
|
||||
restrictedMode={true}
|
||||
/>
|
||||
编辑模式下,模板结构(数据对象、控件类型等)不可修改,仅可修改属性值(来源名称、标签/选项取值等)。
|
||||
</div>
|
||||
<TemplateConfigurationTreeEditor
|
||||
value={labelConfig}
|
||||
onChange={setLabelConfig}
|
||||
readOnlyStructure={true}
|
||||
height={360}
|
||||
/>
|
||||
</div>
|
||||
) : configMode === 'template' ? (
|
||||
<div className="bg-gray-50 p-4 rounded-md border border-gray-200">
|
||||
@@ -688,6 +629,7 @@ export default function CreateAnnotationTask({
|
||||
name="templateId"
|
||||
style={{ marginBottom: 12 }}
|
||||
help="选择模板后,配置将自动填充到可视化编辑器中,您可以继续修改。"
|
||||
rules={[{ required: true, message: "请选择标注模板" }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="选择一个模板作为基础"
|
||||
@@ -714,16 +656,19 @@ export default function CreateAnnotationTask({
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<div style={{ maxHeight: '350px', overflowY: 'auto' }}>
|
||||
<TemplateConfigurationForm
|
||||
form={manualForm}
|
||||
restrictedMode={hasSelectedTemplate}
|
||||
/>
|
||||
</div>
|
||||
<TemplateConfigurationTreeEditor
|
||||
value={labelConfig}
|
||||
onChange={setLabelConfig}
|
||||
height={360}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-gray-50 p-4 rounded-md border border-gray-200" style={{ maxHeight: '400px', overflowY: 'auto' }}>
|
||||
<TemplateConfigurationForm form={manualForm} />
|
||||
<div className="bg-gray-50 p-4 rounded-md border border-gray-200">
|
||||
<TemplateConfigurationTreeEditor
|
||||
value={labelConfig}
|
||||
onChange={setLabelConfig}
|
||||
height={360}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -745,7 +690,7 @@ export default function CreateAnnotationTask({
|
||||
<div style={{ height: '600px', overflow: 'hidden' }}>
|
||||
{showPreview && (
|
||||
<LabelStudioEmbed
|
||||
config={customXml}
|
||||
config={labelConfig}
|
||||
task={{
|
||||
id: 1,
|
||||
data: previewTaskData,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import React from "react";
|
||||
import { Modal, Descriptions, Tag, Space, Divider, Card, Typography } from "antd";
|
||||
import { Modal, Descriptions, Tag, Divider, Card } from "antd";
|
||||
import type { AnnotationTemplate } from "../annotation.model";
|
||||
|
||||
const { Text, Paragraph } = Typography;
|
||||
import TemplateConfigurationTreeEditor from "../components/TemplateConfigurationTreeEditor";
|
||||
|
||||
interface TemplateDetailProps {
|
||||
visible: boolean;
|
||||
@@ -64,88 +63,26 @@ const TemplateDetail: React.FC<TemplateDetailProps> = ({
|
||||
|
||||
<Divider>配置详情</Divider>
|
||||
|
||||
<Card title="数据对象" size="small" style={{ marginBottom: 16 }}>
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
{template.configuration.objects.map((obj, index) => (
|
||||
<Card key={index} size="small" type="inner">
|
||||
<Space>
|
||||
<Text strong>名称:</Text>
|
||||
<Tag>{obj.name}</Tag>
|
||||
<Text strong>类型:</Text>
|
||||
<Tag color="blue">{obj.type}</Tag>
|
||||
<Text strong>值:</Text>
|
||||
<Tag color="green">{obj.value}</Tag>
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card title="标注控件" size="small" style={{ marginBottom: 16 }}>
|
||||
<Space direction="vertical" style={{ width: "100%" }} size="middle">
|
||||
{template.configuration.labels.map((label, index) => (
|
||||
<Card key={index} size="small" type="inner" title={`控件 ${index + 1}`}>
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
<div>
|
||||
<Text strong>来源名称:</Text>
|
||||
<Tag>{label.fromName}</Tag>
|
||||
|
||||
<Text strong style={{ marginLeft: 16 }}>目标名称:</Text>
|
||||
<Tag>{label.toName}</Tag>
|
||||
|
||||
<Text strong style={{ marginLeft: 16 }}>类型:</Text>
|
||||
<Tag color="purple">{label.type}</Tag>
|
||||
|
||||
{label.required && <Tag color="red">必填</Tag>}
|
||||
</div>
|
||||
|
||||
{label.description && (
|
||||
<div>
|
||||
<Text strong>描述:</Text>
|
||||
<Text type="secondary">{label.description}</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{label.options && label.options.length > 0 && (
|
||||
<div>
|
||||
<Text strong>选项:</Text>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
{label.options.map((opt, i) => (
|
||||
<Tag key={i} color="cyan">{opt}</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{label.labels && label.labels.length > 0 && (
|
||||
<div>
|
||||
<Text strong>标签:</Text>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
{label.labels.map((lbl, i) => (
|
||||
<Tag key={i} color="geekblue">{lbl}</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
<Card title="标注配置树" size="small" style={{ marginBottom: 16 }}>
|
||||
<TemplateConfigurationTreeEditor
|
||||
value={template.labelConfig || ""}
|
||||
readOnly={true}
|
||||
readOnlyStructure={true}
|
||||
height={360}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{template.labelConfig && (
|
||||
<Card title="Label Studio XML 配置" size="small">
|
||||
<Paragraph>
|
||||
<pre style={{
|
||||
background: "#f5f5f5",
|
||||
padding: 12,
|
||||
borderRadius: 4,
|
||||
overflow: "auto",
|
||||
maxHeight: 300
|
||||
}}>
|
||||
{template.labelConfig}
|
||||
</pre>
|
||||
</Paragraph>
|
||||
<pre style={{
|
||||
background: "#f5f5f5",
|
||||
padding: 12,
|
||||
borderRadius: 4,
|
||||
overflow: "auto",
|
||||
maxHeight: 300
|
||||
}}>
|
||||
{template.labelConfig}
|
||||
</pre>
|
||||
</Card>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from "../annotation.api";
|
||||
import type { AnnotationTemplate } from "../annotation.model";
|
||||
import { DataTypeMap, ClassificationMap, AnnotationTypeMap } from "../annotation.const";
|
||||
import TemplateConfigurationForm from "../components/TemplateConfigurationForm";
|
||||
import TemplateConfigurationTreeEditor from "../components/TemplateConfigurationTreeEditor";
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { Option } = Select;
|
||||
@@ -35,6 +35,7 @@ const TemplateForm: React.FC<TemplateFormProps> = ({
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [labelConfig, setLabelConfig] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && template && mode === "edit") {
|
||||
@@ -45,24 +46,26 @@ const TemplateForm: React.FC<TemplateFormProps> = ({
|
||||
labelingType: template.labelingType,
|
||||
style: template.style,
|
||||
category: template.category,
|
||||
labels: template.configuration.labels,
|
||||
objects: template.configuration.objects,
|
||||
});
|
||||
setLabelConfig(template.labelConfig || "");
|
||||
} else if (visible && mode === "create") {
|
||||
form.resetFields();
|
||||
// Set default values
|
||||
form.setFieldsValue({
|
||||
style: "horizontal",
|
||||
category: "custom",
|
||||
labels: [],
|
||||
objects: [{ name: "image", type: "Image", value: "$image" }],
|
||||
});
|
||||
setLabelConfig("");
|
||||
}
|
||||
}, [visible, template, mode, form]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
if (!labelConfig.trim()) {
|
||||
message.error("请配置标注模板");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
|
||||
console.log("Form values:", values);
|
||||
@@ -74,10 +77,7 @@ const TemplateForm: React.FC<TemplateFormProps> = ({
|
||||
labelingType: values.labelingType,
|
||||
style: values.style,
|
||||
category: values.category,
|
||||
configuration: {
|
||||
labels: values.labels,
|
||||
objects: values.objects,
|
||||
},
|
||||
labelConfig: labelConfig.trim(),
|
||||
};
|
||||
|
||||
console.log("Request data:", requestData);
|
||||
@@ -190,7 +190,13 @@ const TemplateForm: React.FC<TemplateFormProps> = ({
|
||||
</Form.Item>
|
||||
</Space>
|
||||
|
||||
<TemplateConfigurationForm form={form} />
|
||||
<div className="bg-gray-50 p-4 rounded-md border border-gray-200">
|
||||
<TemplateConfigurationTreeEditor
|
||||
value={labelConfig}
|
||||
onChange={setLabelConfig}
|
||||
height={420}
|
||||
/>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -61,7 +61,7 @@ export interface AnnotationTemplate {
|
||||
description?: string;
|
||||
dataType: string;
|
||||
labelingType: string;
|
||||
configuration: TemplateConfiguration;
|
||||
configuration?: TemplateConfiguration;
|
||||
labelConfig?: string;
|
||||
style: string;
|
||||
category: string;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
export interface TagAttributeConfig {
|
||||
type?: "boolean" | "number" | "string";
|
||||
values?: string[];
|
||||
default?: any;
|
||||
default?: unknown;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ export function parseTagConfig(
|
||||
);
|
||||
|
||||
const controlOptions: TagOption[] = Object.entries(config.controls)
|
||||
.filter(([_, value]) => {
|
||||
.filter(([, value]) => {
|
||||
// If includeLabelingOnly is true, filter out layout controls
|
||||
if (includeLabelingOnly) {
|
||||
return value.category === "labeling";
|
||||
@@ -103,6 +103,7 @@ export function getControlDisplayName(controlType: string): string {
|
||||
BrushLabels: "画笔分割",
|
||||
EllipseLabels: "椭圆",
|
||||
KeyPointLabels: "关键点",
|
||||
HyperTextLabels: "HTML实体标注",
|
||||
Rectangle: "矩形",
|
||||
Polygon: "多边形",
|
||||
Ellipse: "椭圆",
|
||||
@@ -151,7 +152,7 @@ export function getControlGroups(): Record<
|
||||
return {
|
||||
classification: {
|
||||
label: "分类标注",
|
||||
controls: ["Choices", "Taxonomy", "Labels", "Rating"],
|
||||
controls: ["Choices", "Taxonomy", "Labels", "Rating", "Ranker", "List"],
|
||||
},
|
||||
detection: {
|
||||
label: "目标检测",
|
||||
@@ -172,7 +173,7 @@ export function getControlGroups(): Record<
|
||||
},
|
||||
text: {
|
||||
label: "文本输入",
|
||||
controls: ["TextArea", "Number", "DateTime"],
|
||||
controls: ["TextArea", "Number", "DateTime", "HyperTextLabels"],
|
||||
},
|
||||
other: {
|
||||
label: "其他",
|
||||
@@ -181,6 +182,8 @@ export function getControlGroups(): Record<
|
||||
"VectorLabels",
|
||||
"ParagraphLabels",
|
||||
"VideoRectangle",
|
||||
"Relations",
|
||||
"Pairwise",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,860 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Tag,
|
||||
Tree,
|
||||
Typography,
|
||||
} from "antd";
|
||||
import type { DataNode, TreeProps } from "antd/es/tree";
|
||||
import {
|
||||
PlusOutlined,
|
||||
DeleteOutlined,
|
||||
BranchesOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useTagConfig } from "@/hooks/useTagConfig";
|
||||
import {
|
||||
getControlDisplayName,
|
||||
getObjectDisplayName,
|
||||
type LabelStudioTagConfig,
|
||||
} from "../annotation.tagconfig";
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
interface XmlNode {
|
||||
id: string;
|
||||
tag: string;
|
||||
attrs: Record<string, string>;
|
||||
children: XmlNode[];
|
||||
text?: string;
|
||||
}
|
||||
|
||||
interface ValidationIssue {
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
interface TemplateConfigurationTreeEditorProps {
|
||||
value?: string;
|
||||
onChange?: (xml: string) => void;
|
||||
readOnly?: boolean;
|
||||
readOnlyStructure?: boolean;
|
||||
height?: number | string;
|
||||
}
|
||||
|
||||
const DEFAULT_ROOT_TAG = "View";
|
||||
const CHILD_TAGS = ["Label", "Choice", "Relation", "Item", "Path", "Channel"];
|
||||
|
||||
const createId = () =>
|
||||
`node_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
const createEmptyTree = (): XmlNode => ({
|
||||
id: createId(),
|
||||
tag: DEFAULT_ROOT_TAG,
|
||||
attrs: {},
|
||||
children: [],
|
||||
});
|
||||
|
||||
const escapeAttributeValue = (value: string) =>
|
||||
value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
|
||||
const escapeTextValue = (value: string) =>
|
||||
value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
|
||||
const parseXmlToTree = (xml: string): { tree: XmlNode | null; error?: string } => {
|
||||
try {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(xml, "text/xml");
|
||||
const parserError = doc.getElementsByTagName("parsererror");
|
||||
if (parserError.length > 0) {
|
||||
return { tree: null, error: "XML 格式错误,请检查标签闭合与层级。" };
|
||||
}
|
||||
|
||||
const root = doc.documentElement;
|
||||
if (!root || root.tagName !== DEFAULT_ROOT_TAG) {
|
||||
return { tree: null, error: "根节点必须是 <View>。" };
|
||||
}
|
||||
|
||||
const buildNode = (element: Element): XmlNode => {
|
||||
const attrs: Record<string, string> = {};
|
||||
Array.from(element.attributes).forEach((attr) => {
|
||||
attrs[attr.name] = attr.value;
|
||||
});
|
||||
|
||||
const elementChildren = Array.from(element.childNodes).filter(
|
||||
(node) => node.nodeType === Node.ELEMENT_NODE
|
||||
) as Element[];
|
||||
|
||||
const textNodes = Array.from(element.childNodes).filter(
|
||||
(node) => node.nodeType === Node.TEXT_NODE && node.textContent?.trim()
|
||||
);
|
||||
|
||||
const children = elementChildren.map(buildNode);
|
||||
const text =
|
||||
children.length === 0
|
||||
? textNodes.map((node) => node.textContent ?? "").join("").trim()
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id: createId(),
|
||||
tag: element.tagName,
|
||||
attrs,
|
||||
children,
|
||||
text: text || undefined,
|
||||
};
|
||||
};
|
||||
|
||||
return { tree: buildNode(root) };
|
||||
} catch (error) {
|
||||
console.error("解析 XML 失败", error);
|
||||
return { tree: null, error: "解析 XML 失败,请检查配置内容。" };
|
||||
}
|
||||
};
|
||||
|
||||
const serializeTreeToXml = (node: XmlNode, indent = 0): string => {
|
||||
const pad = " ".repeat(indent);
|
||||
const attrs = Object.entries(node.attrs)
|
||||
.filter(([key]) => key.trim())
|
||||
.map(([key, value]) => ` ${key}="${escapeAttributeValue(value)}"`)
|
||||
.join("");
|
||||
|
||||
if (node.children.length === 0 && !node.text) {
|
||||
return `${pad}<${node.tag}${attrs} />`;
|
||||
}
|
||||
|
||||
const lines: string[] = [`${pad}<${node.tag}${attrs}>`];
|
||||
|
||||
if (node.text) {
|
||||
lines.push(`${pad} ${escapeTextValue(node.text)}`);
|
||||
}
|
||||
|
||||
node.children.forEach((child) => {
|
||||
lines.push(serializeTreeToXml(child, indent + 1));
|
||||
});
|
||||
|
||||
lines.push(`${pad}</${node.tag}>`);
|
||||
return lines.join("\n");
|
||||
};
|
||||
|
||||
const updateNode = (
|
||||
node: XmlNode,
|
||||
targetId: string,
|
||||
updater: (current: XmlNode) => XmlNode
|
||||
): XmlNode => {
|
||||
if (node.id === targetId) {
|
||||
return updater(node);
|
||||
}
|
||||
let changed = false;
|
||||
const children = node.children.map((child) => {
|
||||
const updated = updateNode(child, targetId, updater);
|
||||
if (updated !== child) changed = true;
|
||||
return updated;
|
||||
});
|
||||
return changed ? { ...node, children } : node;
|
||||
};
|
||||
|
||||
const removeNodeById = (
|
||||
node: XmlNode,
|
||||
targetId: string
|
||||
): { node: XmlNode; removed?: XmlNode } => {
|
||||
const index = node.children.findIndex((child) => child.id === targetId);
|
||||
if (index >= 0) {
|
||||
const removed = node.children[index];
|
||||
const children = node.children.filter((child) => child.id !== targetId);
|
||||
return { node: { ...node, children }, removed };
|
||||
}
|
||||
|
||||
let removed: XmlNode | undefined;
|
||||
const children = node.children.map((child) => {
|
||||
const result = removeNodeById(child, targetId);
|
||||
if (result.removed) removed = result.removed;
|
||||
return result.node;
|
||||
});
|
||||
if (removed) {
|
||||
return { node: { ...node, children }, removed };
|
||||
}
|
||||
return { node };
|
||||
};
|
||||
|
||||
const findNodeWithParent = (
|
||||
node: XmlNode,
|
||||
targetId: string,
|
||||
parent?: XmlNode
|
||||
): { node?: XmlNode; parent?: XmlNode; index?: number } => {
|
||||
if (node.id === targetId) {
|
||||
return { node, parent, index: parent?.children.findIndex((c) => c.id === targetId) };
|
||||
}
|
||||
for (const child of node.children) {
|
||||
const result = findNodeWithParent(child, targetId, node);
|
||||
if (result.node) return result;
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
const isDescendant = (node: XmlNode, targetId: string): boolean => {
|
||||
if (node.id === targetId) return true;
|
||||
return node.children.some((child) => isDescendant(child, targetId));
|
||||
};
|
||||
|
||||
const getNodeLabel = (node: XmlNode) => {
|
||||
const name = node.attrs.name || node.attrs.value;
|
||||
return name ? `${node.tag} (${name})` : node.tag;
|
||||
};
|
||||
|
||||
const getDefaultName = (tag: string) => {
|
||||
const lower = tag.toLowerCase();
|
||||
if (lower.includes("text")) return "text";
|
||||
if (lower.includes("image")) return "image";
|
||||
if (lower.includes("audio")) return "audio";
|
||||
if (lower.includes("video")) return "video";
|
||||
if (lower.includes("pdf")) return "pdf";
|
||||
if (lower.includes("chat")) return "chat";
|
||||
if (lower.includes("table")) return "table";
|
||||
if (lower.includes("timeseries") || lower.includes("time")) return "ts";
|
||||
return lower;
|
||||
};
|
||||
|
||||
const createNode = (
|
||||
tag: string,
|
||||
config: LabelStudioTagConfig | null,
|
||||
objectNames: string[]
|
||||
): XmlNode => {
|
||||
const attrs: Record<string, string> = {};
|
||||
const controlConfig = config?.controls?.[tag];
|
||||
const objectConfig = config?.objects?.[tag];
|
||||
const requiredAttrs = controlConfig?.required_attrs || objectConfig?.required_attrs || [];
|
||||
|
||||
requiredAttrs.forEach((attr) => {
|
||||
attrs[attr] = "";
|
||||
});
|
||||
|
||||
if (objectConfig && attrs.name !== undefined) {
|
||||
const name = getDefaultName(tag);
|
||||
attrs.name = name;
|
||||
if (attrs.value !== undefined) {
|
||||
attrs.value = `$${name}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (controlConfig && attrs.name !== undefined) {
|
||||
attrs.name = getDefaultName(tag);
|
||||
if (attrs.toName !== undefined) {
|
||||
attrs.toName = objectNames[0] || "";
|
||||
}
|
||||
}
|
||||
|
||||
if (CHILD_TAGS.includes(tag)) {
|
||||
attrs.value = attrs.value || "";
|
||||
}
|
||||
|
||||
const node: XmlNode = {
|
||||
id: createId(),
|
||||
tag,
|
||||
attrs,
|
||||
children: [],
|
||||
};
|
||||
|
||||
if (controlConfig?.requires_children && controlConfig.child_tag) {
|
||||
node.children.push({
|
||||
id: createId(),
|
||||
tag: controlConfig.child_tag,
|
||||
attrs: { value: "" },
|
||||
children: [],
|
||||
});
|
||||
}
|
||||
|
||||
if (tag === "Style") {
|
||||
node.text = "";
|
||||
}
|
||||
|
||||
return node;
|
||||
};
|
||||
|
||||
const collectObjectNames = (node: XmlNode, config: LabelStudioTagConfig | null): string[] => {
|
||||
const names: string[] = [];
|
||||
const objectTags = new Set(Object.keys(config?.objects || {}));
|
||||
const traverse = (current: XmlNode) => {
|
||||
if (objectTags.has(current.tag) && current.attrs.name) {
|
||||
names.push(current.attrs.name);
|
||||
}
|
||||
current.children.forEach(traverse);
|
||||
};
|
||||
traverse(node);
|
||||
return names;
|
||||
};
|
||||
|
||||
const validateTree = (
|
||||
node: XmlNode,
|
||||
config: LabelStudioTagConfig | null
|
||||
): Record<string, ValidationIssue> => {
|
||||
const issues: Record<string, ValidationIssue> = {};
|
||||
const objectTags = new Set(Object.keys(config?.objects || {}));
|
||||
const controlTags = new Set(Object.keys(config?.controls || {}));
|
||||
const objectNames = new Set(collectObjectNames(node, config));
|
||||
|
||||
const ensureIssue = (id: string) => {
|
||||
if (!issues[id]) {
|
||||
issues[id] = { errors: [], warnings: [] };
|
||||
}
|
||||
return issues[id];
|
||||
};
|
||||
|
||||
let labelingControlCount = 0;
|
||||
let objectCount = 0;
|
||||
|
||||
const validateNode = (current: XmlNode) => {
|
||||
const issue = ensureIssue(current.id);
|
||||
const controlConfig = config?.controls?.[current.tag];
|
||||
const objectConfig = config?.objects?.[current.tag];
|
||||
const isObject = objectTags.has(current.tag);
|
||||
const isControl = controlTags.has(current.tag);
|
||||
const isLabelingControl = isControl && controlConfig?.category === "labeling";
|
||||
|
||||
if (isObject) {
|
||||
objectCount += 1;
|
||||
const requiredAttrs = objectConfig?.required_attrs || [];
|
||||
requiredAttrs.forEach((attr) => {
|
||||
if (!current.attrs[attr]) {
|
||||
issue.errors.push(`对象缺少必填属性: ${attr}`);
|
||||
}
|
||||
});
|
||||
if (current.attrs.value && !current.attrs.value.startsWith("$")) {
|
||||
issue.errors.push("对象 value 应以 $ 开头");
|
||||
}
|
||||
}
|
||||
|
||||
if (isControl) {
|
||||
if (isLabelingControl) {
|
||||
labelingControlCount += 1;
|
||||
const requiredAttrs = controlConfig?.required_attrs || [];
|
||||
requiredAttrs.forEach((attr) => {
|
||||
if (!current.attrs[attr]) {
|
||||
issue.errors.push(`控件缺少必填属性: ${attr}`);
|
||||
}
|
||||
});
|
||||
if (current.attrs.toName) {
|
||||
const toNames = current.attrs.toName
|
||||
.split(",")
|
||||
.map((name) => name.trim())
|
||||
.filter(Boolean);
|
||||
const invalid = toNames.filter((name) => !objectNames.has(name));
|
||||
if (invalid.length > 0) {
|
||||
issue.errors.push(`toName 未找到对象: ${invalid.join(", ")}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const requiredAttrs = controlConfig?.required_attrs || [];
|
||||
requiredAttrs.forEach((attr) => {
|
||||
if (!current.attrs[attr]) {
|
||||
issue.warnings.push(`布局标签缺少建议属性: ${attr}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (controlConfig?.requires_children && controlConfig.child_tag) {
|
||||
const children = current.children.filter(
|
||||
(child) => child.tag === controlConfig.child_tag
|
||||
);
|
||||
if (children.length === 0) {
|
||||
const message = `${current.tag} 需要至少一个 <${controlConfig.child_tag}> 子节点`;
|
||||
if (isLabelingControl) {
|
||||
issue.errors.push(message);
|
||||
} else {
|
||||
issue.warnings.push(message);
|
||||
}
|
||||
}
|
||||
children.forEach((child) => {
|
||||
if (!child.attrs.value) {
|
||||
const message = `<${child.tag}> 缺少 value 属性`;
|
||||
const childIssue = ensureIssue(child.id);
|
||||
if (isLabelingControl) {
|
||||
childIssue.errors.push(message);
|
||||
} else {
|
||||
childIssue.warnings.push(message);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
current.children.forEach(validateNode);
|
||||
};
|
||||
|
||||
validateNode(node);
|
||||
|
||||
if (node.tag === DEFAULT_ROOT_TAG) {
|
||||
const rootIssue = ensureIssue(node.id);
|
||||
if (objectCount === 0) {
|
||||
rootIssue.errors.push("至少需要一个数据对象标签");
|
||||
}
|
||||
if (labelingControlCount === 0) {
|
||||
rootIssue.errors.push("至少需要一个标注控件标签");
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
};
|
||||
|
||||
const TemplateConfigurationTreeEditor = ({
|
||||
value,
|
||||
onChange,
|
||||
readOnly = false,
|
||||
readOnlyStructure = false,
|
||||
height = 420,
|
||||
}: TemplateConfigurationTreeEditorProps) => {
|
||||
const { config } = useTagConfig(false);
|
||||
const [tree, setTree] = useState<XmlNode>(() => createEmptyTree());
|
||||
const [selectedId, setSelectedId] = useState<string>(tree.id);
|
||||
const [parseError, setParseError] = useState<string | null>(null);
|
||||
const lastSerialized = useRef<string>("");
|
||||
const [addChildTag, setAddChildTag] = useState<string | undefined>();
|
||||
const [addSiblingTag, setAddSiblingTag] = useState<string | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
const empty = createEmptyTree();
|
||||
setTree(empty);
|
||||
setSelectedId(empty.id);
|
||||
setParseError(null);
|
||||
lastSerialized.current = "";
|
||||
return;
|
||||
}
|
||||
|
||||
if (value === lastSerialized.current) return;
|
||||
const result = parseXmlToTree(value);
|
||||
if (result.tree) {
|
||||
setTree(result.tree);
|
||||
setSelectedId(result.tree.id);
|
||||
setParseError(null);
|
||||
lastSerialized.current = value;
|
||||
} else if (result.error) {
|
||||
setParseError(result.error);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
const xml = serializeTreeToXml(tree);
|
||||
lastSerialized.current = xml;
|
||||
onChange?.(xml);
|
||||
}, [tree, onChange]);
|
||||
|
||||
const objectNames = useMemo(
|
||||
() => collectObjectNames(tree, config || null),
|
||||
[tree, config]
|
||||
);
|
||||
const validationIssues = useMemo(
|
||||
() => validateTree(tree, config || null),
|
||||
[tree, config]
|
||||
);
|
||||
|
||||
const selectedNode = useMemo(() => {
|
||||
const result = findNodeWithParent(tree, selectedId);
|
||||
return result.node || tree;
|
||||
}, [tree, selectedId]);
|
||||
|
||||
const selectedIssue = validationIssues[selectedNode.id] || {
|
||||
errors: [],
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
const isStructureLocked = readOnly || readOnlyStructure;
|
||||
const canEditAttributeValues = !readOnly && selectedNode.id !== tree.id;
|
||||
const canEditAttributeKeys = canEditAttributeValues && !readOnlyStructure;
|
||||
const canEditTagName = canEditAttributeKeys;
|
||||
const canEditText = canEditAttributeValues && !readOnlyStructure;
|
||||
|
||||
const controlOptions = useMemo(() => {
|
||||
if (!config?.controls) return { labeling: [], layout: [] };
|
||||
const labeling = Object.entries(config.controls)
|
||||
.filter(([, item]) => item.category === "labeling")
|
||||
.map(([tag]) => ({ value: tag, label: getControlDisplayName(tag) }));
|
||||
const layout = Object.entries(config.controls)
|
||||
.filter(([, item]) => item.category === "layout")
|
||||
.map(([tag]) => ({ value: tag, label: tag }));
|
||||
return { labeling, layout };
|
||||
}, [config]);
|
||||
|
||||
const objectOptions = useMemo(() => {
|
||||
if (!config?.objects) return [];
|
||||
return Object.keys(config.objects).map((tag) => ({
|
||||
value: tag,
|
||||
label: getObjectDisplayName(tag),
|
||||
}));
|
||||
}, [config]);
|
||||
|
||||
const tagOptions = useMemo(() => {
|
||||
const options = [] as {
|
||||
label: string;
|
||||
options: { value: string; label: string }[];
|
||||
}[];
|
||||
options.push({
|
||||
label: "容器",
|
||||
options: [{ value: "View", label: "View" }],
|
||||
});
|
||||
if (objectOptions.length > 0) {
|
||||
options.push({ label: "数据对象", options: objectOptions });
|
||||
}
|
||||
if (controlOptions.labeling.length > 0) {
|
||||
options.push({ label: "标注控件", options: controlOptions.labeling });
|
||||
}
|
||||
if (controlOptions.layout.length > 0) {
|
||||
options.push({ label: "布局标签", options: controlOptions.layout });
|
||||
}
|
||||
options.push({
|
||||
label: "子标签",
|
||||
options: CHILD_TAGS.map((tag) => ({ value: tag, label: tag })),
|
||||
});
|
||||
return options;
|
||||
}, [objectOptions, controlOptions]);
|
||||
|
||||
const handleAddNode = (tag: string, mode: "child" | "sibling") => {
|
||||
if (isStructureLocked) return;
|
||||
const newNode = createNode(tag, config || null, objectNames);
|
||||
|
||||
if (mode === "child") {
|
||||
setTree((prev) =>
|
||||
updateNode(prev, selectedNode.id, (current) => ({
|
||||
...current,
|
||||
children: [...current.children, newNode],
|
||||
}))
|
||||
);
|
||||
setSelectedId(newNode.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const { parent } = findNodeWithParent(tree, selectedNode.id);
|
||||
if (!parent) return;
|
||||
setTree((prev) =>
|
||||
updateNode(prev, parent.id, (current) => {
|
||||
const index = current.children.findIndex(
|
||||
(child) => child.id === selectedNode.id
|
||||
);
|
||||
const children = [...current.children];
|
||||
children.splice(index + 1, 0, newNode);
|
||||
return { ...current, children };
|
||||
})
|
||||
);
|
||||
setSelectedId(newNode.id);
|
||||
};
|
||||
|
||||
const handleDeleteNode = () => {
|
||||
if (isStructureLocked || selectedNode.id === tree.id) return;
|
||||
const { node: nextTree } = removeNodeById(tree, selectedNode.id);
|
||||
setTree(nextTree);
|
||||
setSelectedId(nextTree.id);
|
||||
};
|
||||
|
||||
const handleAttrKeyChange = (oldKey: string, newKey: string) => {
|
||||
if (!canEditAttributeKeys) return;
|
||||
setTree((prev) =>
|
||||
updateNode(prev, selectedNode.id, (current) => {
|
||||
const attrs = { ...current.attrs };
|
||||
const value = attrs[oldKey] ?? "";
|
||||
delete attrs[oldKey];
|
||||
if (newKey) {
|
||||
attrs[newKey] = value;
|
||||
}
|
||||
return { ...current, attrs };
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleAttrValueChange = (key: string, value: string) => {
|
||||
if (!canEditAttributeValues) return;
|
||||
setTree((prev) =>
|
||||
updateNode(prev, selectedNode.id, (current) => ({
|
||||
...current,
|
||||
attrs: { ...current.attrs, [key]: value },
|
||||
}))
|
||||
);
|
||||
};
|
||||
|
||||
const handleAddAttribute = () => {
|
||||
if (!canEditAttributeKeys) return;
|
||||
setTree((prev) =>
|
||||
updateNode(prev, selectedNode.id, (current) => {
|
||||
const attrs = { ...current.attrs };
|
||||
let index = 1;
|
||||
let key = "attr";
|
||||
while (attrs[key]) {
|
||||
index += 1;
|
||||
key = `attr${index}`;
|
||||
}
|
||||
attrs[key] = "";
|
||||
return { ...current, attrs };
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleRemoveAttribute = (key: string) => {
|
||||
if (!canEditAttributeKeys) return;
|
||||
setTree((prev) =>
|
||||
updateNode(prev, selectedNode.id, (current) => {
|
||||
const attrs = { ...current.attrs };
|
||||
delete attrs[key];
|
||||
return { ...current, attrs };
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleTagNameChange = (value: string) => {
|
||||
if (!canEditTagName) return;
|
||||
setTree((prev) =>
|
||||
updateNode(prev, selectedNode.id, (current) => ({
|
||||
...current,
|
||||
tag: value || current.tag,
|
||||
}))
|
||||
);
|
||||
};
|
||||
|
||||
const handleTextChange = (value: string) => {
|
||||
if (!canEditText) return;
|
||||
setTree((prev) =>
|
||||
updateNode(prev, selectedNode.id, (current) => ({
|
||||
...current,
|
||||
text: value,
|
||||
}))
|
||||
);
|
||||
};
|
||||
|
||||
const treeData: DataNode[] = useMemo(() => {
|
||||
const build = (node: XmlNode): DataNode => {
|
||||
const issue = validationIssues[node.id];
|
||||
const hasError = issue?.errors?.length > 0;
|
||||
const hasWarning = issue?.warnings?.length > 0;
|
||||
return {
|
||||
key: node.id,
|
||||
title: (
|
||||
<Space size={6}>
|
||||
<span className={hasError ? "text-red-600" : undefined}>
|
||||
{getNodeLabel(node)}
|
||||
</span>
|
||||
{hasError && <Tag color="red">错误</Tag>}
|
||||
{!hasError && hasWarning && <Tag color="gold">提示</Tag>}
|
||||
</Space>
|
||||
),
|
||||
children: node.children.map(build),
|
||||
draggable: !isStructureLocked && node.id !== tree.id,
|
||||
};
|
||||
};
|
||||
return [build(tree)];
|
||||
}, [tree, validationIssues, isStructureLocked]);
|
||||
|
||||
const onDrop: TreeProps["onDrop"] = (info) => {
|
||||
if (isStructureLocked) return;
|
||||
const dragId = String(info.dragNode.key);
|
||||
const dropId = String(info.node.key);
|
||||
|
||||
if (dragId === tree.id) return;
|
||||
if (dragId === dropId) return;
|
||||
|
||||
const dragInfo = findNodeWithParent(tree, dragId);
|
||||
const dropInfo = findNodeWithParent(tree, dropId);
|
||||
if (!dragInfo.node || !dropInfo.node) return;
|
||||
|
||||
if (isDescendant(dragInfo.node, dropId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const removedResult = removeNodeById(tree, dragId);
|
||||
let nextTree = removedResult.node;
|
||||
const movingNode = removedResult.removed;
|
||||
if (!movingNode) return;
|
||||
|
||||
if (!info.dropToGap) {
|
||||
nextTree = updateNode(nextTree, dropId, (current) => ({
|
||||
...current,
|
||||
children: [...current.children, movingNode],
|
||||
}));
|
||||
} else {
|
||||
const { parent } = findNodeWithParent(nextTree, dropId);
|
||||
if (!parent) return;
|
||||
nextTree = updateNode(nextTree, parent.id, (current) => {
|
||||
const index = current.children.findIndex((child) => child.id === dropId);
|
||||
const children = [...current.children];
|
||||
const insertIndex = info.dropPosition > 0 ? index + 1 : index;
|
||||
children.splice(insertIndex, 0, movingNode);
|
||||
return { ...current, children };
|
||||
});
|
||||
}
|
||||
|
||||
setTree(nextTree);
|
||||
setSelectedId(movingNode.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4" style={{ minHeight: 360 }}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<BranchesOutlined />
|
||||
<span>结构树</span>
|
||||
</Space>
|
||||
}
|
||||
size="small"
|
||||
style={{ height }}
|
||||
bodyStyle={{ height: "100%", overflow: "auto" }}
|
||||
>
|
||||
{parseError && (
|
||||
<Alert message={parseError} type="error" showIcon style={{ marginBottom: 8 }} />
|
||||
)}
|
||||
<Tree
|
||||
treeData={treeData}
|
||||
selectedKeys={[selectedId]}
|
||||
onSelect={(keys) => {
|
||||
if (keys.length > 0) setSelectedId(String(keys[0]));
|
||||
}}
|
||||
draggable={!isStructureLocked}
|
||||
onDrop={onDrop}
|
||||
blockNode
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="节点配置" size="small" style={{ height }} bodyStyle={{ height: "100%", overflow: "auto" }}>
|
||||
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
|
||||
<div>
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
{selectedNode.tag}
|
||||
</Title>
|
||||
<Text type="secondary">{selectedNode.id}</Text>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Select
|
||||
placeholder="添加子节点"
|
||||
options={tagOptions}
|
||||
value={addChildTag}
|
||||
onChange={(value) => {
|
||||
setAddChildTag(undefined);
|
||||
handleAddNode(value, "child");
|
||||
}}
|
||||
disabled={isStructureLocked}
|
||||
/>
|
||||
<Select
|
||||
placeholder="添加同级节点"
|
||||
options={tagOptions}
|
||||
value={addSiblingTag}
|
||||
onChange={(value) => {
|
||||
setAddSiblingTag(undefined);
|
||||
handleAddNode(value, "sibling");
|
||||
}}
|
||||
disabled={isStructureLocked || selectedNode.id === tree.id}
|
||||
/>
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={handleDeleteNode}
|
||||
disabled={isStructureLocked || selectedNode.id === tree.id}
|
||||
>
|
||||
删除节点
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text>标签名称</Text>
|
||||
<Input
|
||||
value={selectedNode.tag}
|
||||
onChange={(e) => handleTagNameChange(e.target.value)}
|
||||
disabled={!canEditTagName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedNode.tag === "Style" || selectedNode.text !== undefined ? (
|
||||
<div>
|
||||
<Text>文本内容</Text>
|
||||
<Input.TextArea
|
||||
value={selectedNode.text}
|
||||
onChange={(e) => handleTextChange(e.target.value)}
|
||||
rows={4}
|
||||
disabled={!canEditText}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<Space direction="vertical" size="small" style={{ width: "100%" }}>
|
||||
<Space align="center" style={{ width: "100%", justifyContent: "space-between" }}>
|
||||
<Text>属性配置</Text>
|
||||
<Button size="small" onClick={handleAddAttribute} disabled={!canEditAttributeKeys} icon={<PlusOutlined />}>
|
||||
添加属性
|
||||
</Button>
|
||||
</Space>
|
||||
{Object.entries(selectedNode.attrs).length === 0 && (
|
||||
<Text type="secondary">暂无属性</Text>
|
||||
)}
|
||||
{Object.entries(selectedNode.attrs).map(([key, value]) => (
|
||||
<Space key={key} align="baseline" style={{ width: "100%" }}>
|
||||
<Input
|
||||
value={key}
|
||||
onChange={(e) => handleAttrKeyChange(key, e.target.value)}
|
||||
placeholder="属性名"
|
||||
style={{ width: 120 }}
|
||||
disabled={!canEditAttributeKeys}
|
||||
/>
|
||||
{key === "toName" ? (
|
||||
<Select
|
||||
mode="multiple"
|
||||
style={{ flex: 1 }}
|
||||
placeholder="选择对象"
|
||||
value={value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)}
|
||||
onChange={(values) => handleAttrValueChange(key, values.join(","))}
|
||||
options={objectNames.map((name) => ({ value: name, label: name }))}
|
||||
disabled={!canEditAttributeValues}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => handleAttrValueChange(key, e.target.value)}
|
||||
placeholder="属性值"
|
||||
style={{ flex: 1 }}
|
||||
disabled={!canEditAttributeValues}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
onClick={() => handleRemoveAttribute(key)}
|
||||
disabled={!canEditAttributeKeys}
|
||||
>
|
||||
移除
|
||||
</Button>
|
||||
</Space>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{(selectedIssue.errors.length > 0 || selectedIssue.warnings.length > 0) && (
|
||||
<div>
|
||||
<Text strong>校验提示</Text>
|
||||
<div className="mt-2 space-y-1">
|
||||
{selectedIssue.errors.map((err, index) => (
|
||||
<Tag key={`err-${index}`} color="red">
|
||||
{err}
|
||||
</Tag>
|
||||
))}
|
||||
{selectedIssue.warnings.map((warn, index) => (
|
||||
<Tag key={`warn-${index}`} color="gold">
|
||||
{warn}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplateConfigurationTreeEditor;
|
||||
@@ -16,8 +16,8 @@ class AnnotationTemplate(Base):
|
||||
description = Column(String(500), nullable=True, comment="模板描述")
|
||||
data_type = Column(String(50), nullable=False, comment="数据类型: image/text/audio/video/timeseries/pdf/chat/html/table")
|
||||
labeling_type = Column(String(50), nullable=False, comment="标注类型: asr/ner/object-detection/等")
|
||||
configuration = Column(JSON, nullable=False, comment="标注配置(包含labels定义等)")
|
||||
label_config = Column(Text, nullable=True, comment="Label Studio XML配置(内置模板预定义,自定义模板自动生成)")
|
||||
configuration = Column(JSON, nullable=True, comment="标注配置(兼容字段,主配置为 label_config)")
|
||||
label_config = Column(Text, nullable=True, comment="Label Studio XML配置(模板主配置)")
|
||||
style = Column(String(32), nullable=False, comment="样式配置: horizontal/vertical")
|
||||
category = Column(String(50), default='custom', comment="模板分类: audio-speech/chat/computer-vision/nlp/等")
|
||||
built_in = Column(Boolean, default=False, comment="是否系统内置模板")
|
||||
|
||||
@@ -23,11 +23,6 @@ objects:
|
||||
required_attrs: [name, value]
|
||||
optional_attrs: []
|
||||
category: document
|
||||
ParagraphLabels:
|
||||
description: "Display paragraphs with label support"
|
||||
required_attrs: [name, value]
|
||||
optional_attrs: []
|
||||
category: text
|
||||
Timeseries:
|
||||
description: "Display timeseries data"
|
||||
required_attrs: [name, value]
|
||||
@@ -118,7 +113,7 @@ controls:
|
||||
default: 3
|
||||
description: "Maximum depth of taxonomy tree"
|
||||
requires_children: true
|
||||
child_tag: Path
|
||||
child_tag: Choice
|
||||
child_required_attrs: [value]
|
||||
category: labeling
|
||||
|
||||
@@ -135,7 +130,7 @@ controls:
|
||||
requires_children: true
|
||||
child_tag: Choice
|
||||
child_required_attrs: [value]
|
||||
category: layout
|
||||
category: labeling
|
||||
|
||||
List:
|
||||
description: "List selection control"
|
||||
@@ -150,11 +145,11 @@ controls:
|
||||
requires_children: true
|
||||
child_tag: Item
|
||||
child_required_attrs: [value]
|
||||
category: layout
|
||||
category: labeling
|
||||
|
||||
Filter:
|
||||
description: "Filter control for annotation"
|
||||
required_attrs: [name, toName]
|
||||
required_attrs: []
|
||||
optional_attrs:
|
||||
required:
|
||||
type: boolean
|
||||
@@ -163,7 +158,7 @@ controls:
|
||||
|
||||
Collapse:
|
||||
description: "Collapsible UI section"
|
||||
required_attrs: [name]
|
||||
required_attrs: []
|
||||
optional_attrs:
|
||||
collapsed:
|
||||
type: boolean
|
||||
@@ -173,18 +168,18 @@ controls:
|
||||
|
||||
Header:
|
||||
description: "Section header for UI grouping"
|
||||
required_attrs: [name]
|
||||
required_attrs: [value]
|
||||
optional_attrs:
|
||||
level:
|
||||
size:
|
||||
type: number
|
||||
default: 1
|
||||
description: "Header level (1-6)"
|
||||
default: 3
|
||||
description: "Header size"
|
||||
requires_children: false
|
||||
category: layout
|
||||
|
||||
Shortcut:
|
||||
description: "Keyboard shortcut definition"
|
||||
required_attrs: [name, toName]
|
||||
required_attrs: []
|
||||
optional_attrs:
|
||||
key:
|
||||
type: string
|
||||
@@ -194,11 +189,8 @@ controls:
|
||||
|
||||
Style:
|
||||
description: "Custom style for annotation UI"
|
||||
required_attrs: [name]
|
||||
optional_attrs:
|
||||
value:
|
||||
type: string
|
||||
description: "CSS style value"
|
||||
required_attrs: []
|
||||
optional_attrs: {}
|
||||
requires_children: false
|
||||
category: layout
|
||||
|
||||
@@ -247,23 +239,14 @@ controls:
|
||||
child_required_attrs: [value]
|
||||
category: labeling
|
||||
|
||||
Relation:
|
||||
description: "Draw relation between objects"
|
||||
required_attrs: [name, toName]
|
||||
optional_attrs:
|
||||
required:
|
||||
type: boolean
|
||||
requires_children: false
|
||||
category: layout
|
||||
|
||||
Relations:
|
||||
description: "Draw multiple relations between objects"
|
||||
required_attrs: [name, toName]
|
||||
optional_attrs:
|
||||
required:
|
||||
type: boolean
|
||||
requires_children: false
|
||||
category: layout
|
||||
required_attrs: []
|
||||
optional_attrs: {}
|
||||
requires_children: true
|
||||
child_tag: Relation
|
||||
child_required_attrs: [value]
|
||||
category: labeling
|
||||
|
||||
Pairwise:
|
||||
description: "Pairwise comparison control"
|
||||
@@ -272,7 +255,7 @@ controls:
|
||||
required:
|
||||
type: boolean
|
||||
requires_children: false
|
||||
category: layout
|
||||
category: labeling
|
||||
|
||||
DateTime:
|
||||
description: "Date and time input"
|
||||
@@ -350,6 +333,15 @@ controls:
|
||||
child_required_attrs: [value]
|
||||
category: labeling
|
||||
|
||||
HyperTextLabels:
|
||||
description: "Labels for hypertext entities"
|
||||
required_attrs: [name, toName]
|
||||
optional_attrs: [required]
|
||||
requires_children: true
|
||||
child_tag: Label
|
||||
child_required_attrs: [value]
|
||||
category: labeling
|
||||
|
||||
KeyPointLabels:
|
||||
description: "Keypoint annotations with labels"
|
||||
required_attrs: [name, toName]
|
||||
|
||||
@@ -35,7 +35,7 @@ async def create_template(
|
||||
- **description**: 模板描述(可选,最多500字符)
|
||||
- **dataType**: 数据类型(必填)
|
||||
- **labelingType**: 标注类型(必填)
|
||||
- **configuration**: 标注配置(必填,包含labels和objects)
|
||||
- **labelConfig**: Label Studio XML 配置(必填)
|
||||
- **style**: 样式配置(默认horizontal)
|
||||
- **category**: 模板分类(默认custom)
|
||||
"""
|
||||
|
||||
@@ -45,7 +45,7 @@ class CreateAnnotationTemplateRequest(BaseModel):
|
||||
description: Optional[str] = Field(None, max_length=500, description="模板描述")
|
||||
data_type: str = Field(alias="dataType", description="数据类型")
|
||||
labeling_type: str = Field(alias="labelingType", description="标注类型")
|
||||
configuration: TemplateConfiguration = Field(..., description="标注配置")
|
||||
label_config: str = Field(alias="labelConfig", description="Label Studio XML 配置")
|
||||
style: str = Field(default="horizontal", description="样式配置")
|
||||
category: str = Field(default="custom", description="模板分类")
|
||||
|
||||
@@ -58,7 +58,7 @@ class UpdateAnnotationTemplateRequest(BaseModel):
|
||||
description: Optional[str] = Field(None, max_length=500, description="模板描述")
|
||||
data_type: Optional[str] = Field(None, alias="dataType", description="数据类型")
|
||||
labeling_type: Optional[str] = Field(None, alias="labelingType", description="标注类型")
|
||||
configuration: Optional[TemplateConfiguration] = Field(None, description="标注配置")
|
||||
label_config: Optional[str] = Field(None, alias="labelConfig", description="Label Studio XML 配置")
|
||||
style: Optional[str] = Field(None, description="样式配置")
|
||||
category: Optional[str] = Field(None, description="模板分类")
|
||||
|
||||
@@ -72,8 +72,8 @@ class AnnotationTemplateResponse(BaseModel):
|
||||
description: Optional[str] = Field(None, description="模板描述")
|
||||
data_type: str = Field(alias="dataType", description="数据类型")
|
||||
labeling_type: str = Field(alias="labelingType", description="标注类型")
|
||||
configuration: TemplateConfiguration = Field(..., description="标注配置")
|
||||
label_config: Optional[str] = Field(None, alias="labelConfig", description="生成的Label Studio XML配置")
|
||||
configuration: Optional[TemplateConfiguration] = Field(None, description="标注配置")
|
||||
label_config: Optional[str] = Field(None, alias="labelConfig", description="Label Studio XML配置")
|
||||
style: str = Field(..., description="样式配置")
|
||||
category: str = Field(..., description="模板分类")
|
||||
built_in: bool = Field(alias="builtIn", description="是否内置模板")
|
||||
|
||||
@@ -114,28 +114,20 @@ class AnnotationTemplateService:
|
||||
Returns:
|
||||
创建的模板响应
|
||||
"""
|
||||
# 验证配置JSON
|
||||
config_dict = request.configuration.model_dump(mode='json', by_alias=False)
|
||||
valid, error = LabelStudioConfigValidator.validate_configuration_json(config_dict)
|
||||
if not valid:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid configuration: {error}")
|
||||
|
||||
# 生成Label Studio XML配置(用于验证,但不存储)
|
||||
label_config = self.generate_label_studio_config(request.configuration)
|
||||
|
||||
# 验证生成的XML
|
||||
label_config = request.label_config
|
||||
valid, error = LabelStudioConfigValidator.validate_xml(label_config)
|
||||
if not valid:
|
||||
raise HTTPException(status_code=400, detail=f"Generated XML is invalid: {error}")
|
||||
|
||||
# 创建模板对象(不包含label_config字段)
|
||||
raise HTTPException(status_code=400, detail=f"Invalid labelConfig: {error}")
|
||||
|
||||
# 创建模板对象
|
||||
template = AnnotationTemplate(
|
||||
id=str(uuid4()),
|
||||
name=request.name,
|
||||
description=request.description,
|
||||
data_type=request.data_type,
|
||||
labeling_type=request.labeling_type,
|
||||
configuration=config_dict,
|
||||
configuration=None,
|
||||
label_config=label_config,
|
||||
style=request.style,
|
||||
category=request.category,
|
||||
built_in=False,
|
||||
@@ -280,24 +272,11 @@ class AnnotationTemplateService:
|
||||
update_data = request.model_dump(exclude_unset=True, by_alias=False)
|
||||
|
||||
for field, value in update_data.items():
|
||||
if field == 'configuration' and value is not None:
|
||||
# 验证配置JSON
|
||||
config = value if isinstance(value, TemplateConfiguration) else TemplateConfiguration.model_validate(value)
|
||||
config_dict = config.model_dump(mode='json', by_alias=False)
|
||||
valid, error = LabelStudioConfigValidator.validate_configuration_json(config_dict)
|
||||
if field == "label_config" and value is not None:
|
||||
valid, error = LabelStudioConfigValidator.validate_xml(value)
|
||||
if not valid:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid configuration: {error}")
|
||||
|
||||
# 重新生成Label Studio XML配置(用于验证)
|
||||
label_config = self.generate_label_studio_config(config)
|
||||
|
||||
# 验证生成的XML
|
||||
valid, error = LabelStudioConfigValidator.validate_xml(label_config)
|
||||
if not valid:
|
||||
raise HTTPException(status_code=400, detail=f"Generated XML is invalid: {error}")
|
||||
|
||||
# 只更新configuration字段,不存储label_config
|
||||
setattr(template, field, config_dict)
|
||||
raise HTTPException(status_code=400, detail=f"Invalid labelConfig: {error}")
|
||||
setattr(template, field, value)
|
||||
else:
|
||||
setattr(template, field, value)
|
||||
|
||||
@@ -350,20 +329,17 @@ class AnnotationTemplateService:
|
||||
Returns:
|
||||
模板响应对象
|
||||
"""
|
||||
# 将配置JSON转换为TemplateConfiguration对象
|
||||
from typing import cast, Dict, Any
|
||||
config_dict = cast(Dict[str, Any], template.configuration)
|
||||
config = TemplateConfiguration(**config_dict)
|
||||
config = None
|
||||
if template.configuration:
|
||||
try:
|
||||
from typing import cast, Dict, Any
|
||||
config_dict = cast(Dict[str, Any], template.configuration)
|
||||
config = TemplateConfiguration(**config_dict)
|
||||
except Exception:
|
||||
config = None
|
||||
|
||||
# 优先使用预定义的 label_config,否则动态生成
|
||||
if template.label_config:
|
||||
label_config = template.label_config
|
||||
else:
|
||||
label_config = self.generate_label_studio_config(config)
|
||||
|
||||
# 使用model_validate从ORM对象创建响应对象
|
||||
response = AnnotationTemplateResponse.model_validate(template)
|
||||
response.configuration = config
|
||||
response.label_config = label_config # type: ignore
|
||||
response.label_config = template.label_config # type: ignore
|
||||
|
||||
return response
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Label Studio Configuration Validation Utilities
|
||||
"""
|
||||
from typing import Dict, List, Tuple, Optional
|
||||
from typing import Dict, List, Tuple, Optional, Set
|
||||
import xml.etree.ElementTree as ET
|
||||
from app.module.annotation.config import LabelStudioTagConfig
|
||||
|
||||
@@ -13,6 +13,23 @@ class LabelStudioConfigValidator:
|
||||
def _get_config() -> LabelStudioTagConfig:
|
||||
"""获取标签配置实例"""
|
||||
return LabelStudioTagConfig()
|
||||
|
||||
@staticmethod
|
||||
def _get_required_attrs(tag_type: str, is_control: bool) -> List[str]:
|
||||
config = LabelStudioConfigValidator._get_config()
|
||||
tag_config = (
|
||||
config.get_control_config(tag_type)
|
||||
if is_control
|
||||
else config.get_object_config(tag_type)
|
||||
)
|
||||
required_attrs = tag_config.get("required_attrs", []) if tag_config else []
|
||||
return required_attrs if isinstance(required_attrs, list) else []
|
||||
|
||||
@staticmethod
|
||||
def _get_control_category(tag_type: str) -> Optional[str]:
|
||||
config = LabelStudioConfigValidator._get_config()
|
||||
tag_config = config.get_control_config(tag_type) or {}
|
||||
return tag_config.get("category")
|
||||
|
||||
@staticmethod
|
||||
def validate_xml(xml_string: str) -> Tuple[bool, Optional[str]]:
|
||||
@@ -33,24 +50,49 @@ class LabelStudioConfigValidator:
|
||||
if root.tag != 'View':
|
||||
return False, "Root element must be <View>"
|
||||
|
||||
# 检查是否有对象定义
|
||||
object_types = config.get_object_types()
|
||||
objects = [elem for elem in root.iter() if elem is not root and elem.tag in object_types]
|
||||
control_types = config.get_control_types()
|
||||
|
||||
objects = [elem for elem in root.iter() if elem.tag in object_types]
|
||||
controls = [elem for elem in root.iter() if elem.tag in control_types]
|
||||
|
||||
labeling_controls = [
|
||||
control
|
||||
for control in controls
|
||||
if LabelStudioConfigValidator._get_control_category(control.tag) == "labeling"
|
||||
]
|
||||
|
||||
if not objects:
|
||||
return False, "No data objects (Image, Text, etc.) found"
|
||||
|
||||
# 检查是否有控件定义
|
||||
control_types = config.get_control_types()
|
||||
controls = [elem for elem in root.iter() if elem is not root and elem.tag in control_types]
|
||||
if not controls:
|
||||
return False, "No annotation controls found"
|
||||
|
||||
# 验证每个控件
|
||||
|
||||
if not labeling_controls:
|
||||
return False, "No labeling controls found"
|
||||
|
||||
object_names = {
|
||||
obj.get("name") for obj in objects if obj.get("name")
|
||||
}
|
||||
|
||||
# 校验对象必填属性
|
||||
for obj in objects:
|
||||
required_attrs = LabelStudioConfigValidator._get_required_attrs(
|
||||
obj.tag, is_control=False
|
||||
)
|
||||
for attr in required_attrs:
|
||||
if not obj.attrib.get(attr):
|
||||
return False, f"Object {obj.tag} missing '{attr}' attribute"
|
||||
if obj.attrib.get("value") and not obj.attrib.get("value", "").startswith("$"):
|
||||
return False, "Object value must start with '$' (e.g., '$image')"
|
||||
|
||||
# 校验控件(布局类仅提示,不作为失败条件)
|
||||
for control in controls:
|
||||
valid, error = LabelStudioConfigValidator._validate_control(control)
|
||||
if not valid:
|
||||
category = LabelStudioConfigValidator._get_control_category(control.tag)
|
||||
strict = category == "labeling"
|
||||
valid, error = LabelStudioConfigValidator._validate_control(
|
||||
control, object_names, strict
|
||||
)
|
||||
if not valid and strict:
|
||||
return False, f"Control {control.tag}: {error}"
|
||||
|
||||
|
||||
return True, None
|
||||
|
||||
except ET.ParseError as e:
|
||||
@@ -59,7 +101,11 @@ class LabelStudioConfigValidator:
|
||||
return False, f"Validation error: {str(e)}"
|
||||
|
||||
@staticmethod
|
||||
def _validate_control(control: ET.Element) -> Tuple[bool, Optional[str]]:
|
||||
def _validate_control(
|
||||
control: ET.Element,
|
||||
object_names: Set[str],
|
||||
strict: bool
|
||||
) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
验证单个控件元素
|
||||
|
||||
@@ -72,26 +118,37 @@ class LabelStudioConfigValidator:
|
||||
config = LabelStudioConfigValidator._get_config()
|
||||
|
||||
# 检查必需属性
|
||||
if 'name' not in control.attrib:
|
||||
return False, "Missing 'name' attribute"
|
||||
|
||||
if 'toName' not in control.attrib:
|
||||
return False, "Missing 'toName' attribute"
|
||||
required_attrs = LabelStudioConfigValidator._get_required_attrs(
|
||||
control.tag, is_control=True
|
||||
)
|
||||
for attr in required_attrs:
|
||||
if not control.attrib.get(attr):
|
||||
return (False, f"Missing '{attr}' attribute") if strict else (True, None)
|
||||
|
||||
# 校验 toName 指向对象
|
||||
if strict and control.attrib.get("toName"):
|
||||
to_names = [
|
||||
name.strip()
|
||||
for name in control.attrib.get("toName", "").split(",")
|
||||
if name.strip()
|
||||
]
|
||||
invalid = [name for name in to_names if name not in object_names]
|
||||
if invalid:
|
||||
return False, f"toName references unknown object(s): {', '.join(invalid)}"
|
||||
|
||||
# 检查控件是否需要子元素
|
||||
if config.requires_children(control.tag):
|
||||
child_tag = config.get_child_tag(control.tag)
|
||||
if not child_tag:
|
||||
return False, f"Configuration error: no child_tag defined for {control.tag}"
|
||||
|
||||
return (False, f"Configuration error: no child_tag defined for {control.tag}") if strict else (True, None)
|
||||
|
||||
children = control.findall(child_tag)
|
||||
if not children:
|
||||
return False, f"{control.tag} must have at least one <{child_tag}> child"
|
||||
|
||||
# 检查每个子元素是否有value
|
||||
return (False, f"{control.tag} must have at least one <{child_tag}> child") if strict else (True, None)
|
||||
|
||||
for child in children:
|
||||
if 'value' not in child.attrib:
|
||||
return False, f"{child_tag} missing 'value' attribute"
|
||||
if "value" not in child.attrib or not child.attrib.get("value"):
|
||||
return (False, f"{child_tag} missing 'value' attribute") if strict else (True, None)
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@ CREATE TABLE IF NOT EXISTS t_dm_annotation_templates (
|
||||
description VARCHAR(500) COMMENT '模板描述',
|
||||
data_type VARCHAR(50) NOT NULL COMMENT '数据类型: text/image/audio/video/pdf/timeseries/chat/html/table',
|
||||
labeling_type VARCHAR(50) NOT NULL COMMENT '标注类型',
|
||||
configuration JSON NOT NULL COMMENT '标注配置(包含labels定义等)',
|
||||
label_config TEXT COMMENT 'Label Studio XML配置(内置模板预定义)',
|
||||
configuration JSON NULL COMMENT '标注配置(兼容字段,主配置为label_config)',
|
||||
label_config TEXT COMMENT 'Label Studio XML配置(模板主配置)',
|
||||
style VARCHAR(32) NOT NULL COMMENT '样式配置: horizontal/vertical',
|
||||
category VARCHAR(50) DEFAULT 'custom' COMMENT '模板分类',
|
||||
built_in BOOLEAN DEFAULT FALSE COMMENT '是否系统内置模板',
|
||||
@@ -776,19 +776,17 @@ INSERT INTO t_dm_annotation_templates (
|
||||
'对文本进行分类,适用于情感分析、主题分类、垃圾邮件检测等场景。关联模型:BERT、RoBERTa、DistilBERT',
|
||||
'text',
|
||||
'text-classification',
|
||||
JSON_OBJECT(
|
||||
'labels', JSON_ARRAY(
|
||||
JSON_OBJECT('fromName', 'sentiment', 'toName', 'text', 'type', 'Choices', 'options', JSON_ARRAY('Positive', 'Negative', 'Neutral'), 'required', true)
|
||||
),
|
||||
'objects', JSON_ARRAY(JSON_OBJECT('name', 'text', 'type', 'Text', 'value', '$text'))
|
||||
),
|
||||
NULL,
|
||||
'<View>
|
||||
<Text name="text" value="$text"/>
|
||||
<Choices name="sentiment" toName="text" choice="single-radio" showInLine="true">
|
||||
<Choice value="Positive"/>
|
||||
<Choice value="Negative"/>
|
||||
<Choice value="Neutral"/>
|
||||
</Choices>
|
||||
<View style="box-shadow: 2px 2px 5px #999; padding: 20px; margin-top: 2em; border-radius: 5px;">
|
||||
<Header value="选择文本情感"/>
|
||||
<Choices name="sentiment" toName="text" choice="single" showInline="true">
|
||||
<Choice value="Positive"/>
|
||||
<Choice value="Negative"/>
|
||||
<Choice value="Neutral"/>
|
||||
</Choices>
|
||||
</View>
|
||||
</View>',
|
||||
'vertical',
|
||||
'nlp',
|
||||
@@ -806,12 +804,7 @@ INSERT INTO t_dm_annotation_templates (
|
||||
'在文本中标注命名实体,适用于信息抽取、知识图谱构建、智能问答等场景。关联模型:BERT-NER、SpaCy、Flair',
|
||||
'text',
|
||||
'ner',
|
||||
JSON_OBJECT(
|
||||
'labels', JSON_ARRAY(
|
||||
JSON_OBJECT('fromName', 'label', 'toName', 'text', 'type', 'Labels', 'labels', JSON_ARRAY('PER', 'ORG', 'LOC', 'MISC'))
|
||||
),
|
||||
'objects', JSON_ARRAY(JSON_OBJECT('name', 'text', 'type', 'Text', 'value', '$text'))
|
||||
),
|
||||
NULL,
|
||||
'<View>
|
||||
<Labels name="label" toName="text">
|
||||
<Label value="PER" background="red"/>
|
||||
@@ -837,23 +830,16 @@ INSERT INTO t_dm_annotation_templates (
|
||||
'标注实体间的关系,适用于知识图谱构建、信息抽取等场景。关联模型:BERT、GPT、OpenIE',
|
||||
'text',
|
||||
'relation-extraction',
|
||||
JSON_OBJECT(
|
||||
'labels', JSON_ARRAY(
|
||||
JSON_OBJECT('fromName', 'label', 'toName', 'text', 'type', 'Labels', 'labels', JSON_ARRAY('PER', 'ORG', 'LOC')),
|
||||
JSON_OBJECT('fromName', 'relation', 'toName', 'text', 'type', 'Relations')
|
||||
),
|
||||
'objects', JSON_ARRAY(JSON_OBJECT('name', 'text', 'type', 'Text', 'value', '$text'))
|
||||
),
|
||||
NULL,
|
||||
'<View>
|
||||
<Relations>
|
||||
<Relation value="works_for"/>
|
||||
<Relation value="lives_in"/>
|
||||
<Relation value="located_in"/>
|
||||
<Relation value="org:founded_by"/>
|
||||
<Relation value="org:founded"/>
|
||||
</Relations>
|
||||
<Labels name="label" toName="text">
|
||||
<Label value="PER" background="red"/>
|
||||
<Label value="ORG" background="darkorange"/>
|
||||
<Label value="LOC" background="orange"/>
|
||||
<Label value="Organization" background="orange"/>
|
||||
<Label value="Person" background="green"/>
|
||||
<Label value="Datetime" background="blue"/>
|
||||
</Labels>
|
||||
<Text name="text" value="$text"/>
|
||||
</View>',
|
||||
@@ -873,22 +859,15 @@ INSERT INTO t_dm_annotation_templates (
|
||||
'翻译文本内容,适用于翻译质量评估、机器翻译后编辑等场景',
|
||||
'text',
|
||||
'machine-translation',
|
||||
JSON_OBJECT(
|
||||
'labels', JSON_ARRAY(
|
||||
JSON_OBJECT('fromName', 'translation', 'toName', 'text', 'type', 'TextArea', 'required', true)
|
||||
),
|
||||
'objects', JSON_ARRAY(JSON_OBJECT('name', 'text', 'type', 'Text', 'value', '$text'))
|
||||
),
|
||||
NULL,
|
||||
'<View>
|
||||
<View style="display: grid; grid-template-columns: 1fr 1fr; grid-column-gap: 1em">
|
||||
<View>
|
||||
<Header value="原文"/>
|
||||
<Text name="text" value="$text"/>
|
||||
</View>
|
||||
<View>
|
||||
<Header value="翻译"/>
|
||||
<TextArea name="translation" toName="text" rows="5" editable="true" maxSubmissions="1"/>
|
||||
</View>
|
||||
<View style="display: grid; grid-template: auto/1fr 1fr; column-gap: 1em">
|
||||
<Header value="原文" />
|
||||
<Header value="翻译" />
|
||||
<Text name="text" value="$text" />
|
||||
<TextArea name="translation" toName="text"
|
||||
showSubmitButton="true" maxSubmissions="1" editable="true"
|
||||
required="true" />
|
||||
</View>
|
||||
</View>',
|
||||
'vertical',
|
||||
@@ -907,16 +886,14 @@ INSERT INTO t_dm_annotation_templates (
|
||||
'编写文本摘要,适用于新闻摘要、文档摘要、会议纪要等场景',
|
||||
'text',
|
||||
'text-summarization',
|
||||
JSON_OBJECT(
|
||||
'labels', JSON_ARRAY(
|
||||
JSON_OBJECT('fromName', 'summary', 'toName', 'text', 'type', 'TextArea', 'required', true)
|
||||
),
|
||||
'objects', JSON_ARRAY(JSON_OBJECT('name', 'text', 'type', 'Text', 'value', '$text'))
|
||||
),
|
||||
NULL,
|
||||
'<View>
|
||||
<Text name="text" value="$text"/>
|
||||
<Header value="编写摘要"/>
|
||||
<TextArea name="summary" toName="text" rows="4" editable="true" maxSubmissions="1"/>
|
||||
<Header value="请阅读文本" />
|
||||
<Text name="text" value="$text" />
|
||||
<Header value="提供一句话摘要" />
|
||||
<TextArea name="summary" toName="text"
|
||||
showSubmitButton="true" maxSubmissions="1" editable="true"
|
||||
required="true" />
|
||||
</View>',
|
||||
'vertical',
|
||||
'nlp',
|
||||
@@ -934,21 +911,15 @@ INSERT INTO t_dm_annotation_templates (
|
||||
'基于上下文回答问题或标注答案,适用于阅读理解、智能客服等场景',
|
||||
'text',
|
||||
'question-answering',
|
||||
JSON_OBJECT(
|
||||
'labels', JSON_ARRAY(
|
||||
JSON_OBJECT('fromName', 'answer', 'toName', 'text', 'type', 'Labels', 'labels', JSON_ARRAY('Answer'))
|
||||
),
|
||||
'objects', JSON_ARRAY(
|
||||
JSON_OBJECT('name', 'text', 'type', 'Text', 'value', '$text'),
|
||||
JSON_OBJECT('name', 'question', 'type', 'Text', 'value', '$question')
|
||||
)
|
||||
),
|
||||
NULL,
|
||||
'<View>
|
||||
<Header value="请阅读文本" />
|
||||
<Text name="text" value="$text" granularity="word"/>
|
||||
<Header value="选择回答问题的文本片段" />
|
||||
<Text name="question" value="$question"/>
|
||||
<Labels name="answer" toName="text">
|
||||
<Label value="Answer" background="green"/>
|
||||
<Label value="Answer" maxUsage="1" background="red"/>
|
||||
</Labels>
|
||||
<Text name="text" value="$text"/>
|
||||
</View>',
|
||||
'vertical',
|
||||
'nlp',
|
||||
@@ -966,31 +937,16 @@ INSERT INTO t_dm_annotation_templates (
|
||||
'使用层级分类法对文本进行分类,适用于电商商品分类、文档归档等场景',
|
||||
'text',
|
||||
'taxonomy',
|
||||
JSON_OBJECT(
|
||||
'labels', JSON_ARRAY(
|
||||
JSON_OBJECT('fromName', 'taxonomy', 'toName', 'text', 'type', 'Taxonomy')
|
||||
),
|
||||
'objects', JSON_ARRAY(JSON_OBJECT('name', 'text', 'type', 'Text', 'value', '$text'))
|
||||
),
|
||||
NULL,
|
||||
'<View>
|
||||
<Text name="text" value="$text"/>
|
||||
<Taxonomy name="taxonomy" toName="text">
|
||||
<Choice value="Archaea">
|
||||
<Choice value="Euryarchaeota"/>
|
||||
<Choice value="Crenarchaeota"/>
|
||||
</Choice>
|
||||
<Choice value="Bacteria">
|
||||
<Choice value="Actinobacteria"/>
|
||||
<Choice value="Proteobacteria"/>
|
||||
</Choice>
|
||||
<Choice value="Archaea" />
|
||||
<Choice value="Bacteria" />
|
||||
<Choice value="Eukarya">
|
||||
<Choice value="Animalia">
|
||||
<Choice value="Chordata">
|
||||
<Choice value="Mammalia"/>
|
||||
<Choice value="Aves"/>
|
||||
</Choice>
|
||||
</Choice>
|
||||
<Choice value="Plantae"/>
|
||||
<Choice value="Human" />
|
||||
<Choice value="Oppossum" />
|
||||
<Choice value="Extraterrestial" />
|
||||
</Choice>
|
||||
</Taxonomy>
|
||||
</View>',
|
||||
@@ -1014,16 +970,7 @@ INSERT INTO t_dm_annotation_templates (
|
||||
'收集人类偏好用于RLHF训练,对两个LLM响应进行排名',
|
||||
'text',
|
||||
'rlhf-preference',
|
||||
JSON_OBJECT(
|
||||
'labels', JSON_ARRAY(
|
||||
JSON_OBJECT('fromName', 'preference', 'toName', 'prompt', 'type', 'Pairwise')
|
||||
),
|
||||
'objects', JSON_ARRAY(
|
||||
JSON_OBJECT('name', 'prompt', 'type', 'Text', 'value', '$prompt'),
|
||||
JSON_OBJECT('name', 'response1', 'type', 'Text', 'value', '$response1'),
|
||||
JSON_OBJECT('name', 'response2', 'type', 'Text', 'value', '$response2')
|
||||
)
|
||||
),
|
||||
NULL,
|
||||
'<View>
|
||||
<Style>
|
||||
.prompt-box { background: #f5f5f5; padding: 15px; border-radius: 8px; margin-bottom: 20px; }
|
||||
@@ -1060,17 +1007,7 @@ INSERT INTO t_dm_annotation_templates (
|
||||
'对LLM生成的响应进行多维度评分',
|
||||
'text',
|
||||
'llm-grading',
|
||||
JSON_OBJECT(
|
||||
'labels', JSON_ARRAY(
|
||||
JSON_OBJECT('fromName', 'relevance', 'toName', 'response', 'type', 'Rating'),
|
||||
JSON_OBJECT('fromName', 'coherence', 'toName', 'response', 'type', 'Rating'),
|
||||
JSON_OBJECT('fromName', 'fluency', 'toName', 'response', 'type', 'Rating')
|
||||
),
|
||||
'objects', JSON_ARRAY(
|
||||
JSON_OBJECT('name', 'prompt', 'type', 'Text', 'value', '$prompt'),
|
||||
JSON_OBJECT('name', 'response', 'type', 'Text', 'value', '$response')
|
||||
)
|
||||
),
|
||||
NULL,
|
||||
'<View>
|
||||
<Header value="Prompt"/>
|
||||
<Text name="prompt" value="$prompt"/>
|
||||
@@ -1099,12 +1036,7 @@ INSERT INTO t_dm_annotation_templates (
|
||||
'为LLM监督微调收集高质量指令-响应对',
|
||||
'text',
|
||||
'sft',
|
||||
JSON_OBJECT(
|
||||
'labels', JSON_ARRAY(
|
||||
JSON_OBJECT('fromName', 'response', 'toName', 'instruction', 'type', 'TextArea', 'required', true)
|
||||
),
|
||||
'objects', JSON_ARRAY(JSON_OBJECT('name', 'instruction', 'type', 'Text', 'value', '$instruction'))
|
||||
),
|
||||
NULL,
|
||||
'<View>
|
||||
<Header value="指令"/>
|
||||
<Text name="instruction" value="$instruction"/>
|
||||
@@ -1131,16 +1063,7 @@ INSERT INTO t_dm_annotation_templates (
|
||||
'对两个项目进行成对比较分类',
|
||||
'text',
|
||||
'pairwise-classification',
|
||||
JSON_OBJECT(
|
||||
'labels', JSON_ARRAY(
|
||||
JSON_OBJECT('fromName', 'comparison', 'toName', 'text', 'type', 'Pairwise')
|
||||
),
|
||||
'objects', JSON_ARRAY(
|
||||
JSON_OBJECT('name', 'text', 'type', 'Text', 'value', '$prompt'),
|
||||
JSON_OBJECT('name', 'option1', 'type', 'Text', 'value', '$option1'),
|
||||
JSON_OBJECT('name', 'option2', 'type', 'Text', 'value', '$option2')
|
||||
)
|
||||
),
|
||||
NULL,
|
||||
'<View>
|
||||
<Text name="text" value="$prompt"/>
|
||||
<Pairwise name="comparison" toName="text" leftText="选项A" rightText="选项B">
|
||||
@@ -1164,15 +1087,7 @@ INSERT INTO t_dm_annotation_templates (
|
||||
'对搜索结果进行相关性排名,适用于搜索引擎优化、信息检索等场景',
|
||||
'text',
|
||||
'serp-ranking',
|
||||
JSON_OBJECT(
|
||||
'labels', JSON_ARRAY(
|
||||
JSON_OBJECT('fromName', 'relevance', 'toName', 'result', 'type', 'Rating')
|
||||
),
|
||||
'objects', JSON_ARRAY(
|
||||
JSON_OBJECT('name', 'query', 'type', 'Text', 'value', '$query'),
|
||||
JSON_OBJECT('name', 'result', 'type', 'Text', 'value', '$result')
|
||||
)
|
||||
),
|
||||
NULL,
|
||||
'<View>
|
||||
<Header value="搜索查询"/>
|
||||
<Text name="query" value="$query"/>
|
||||
@@ -1438,12 +1353,7 @@ INSERT INTO t_dm_annotation_templates (
|
||||
'在文本中标注共指关系和实体链接',
|
||||
'text',
|
||||
'coreference-resolution',
|
||||
JSON_OBJECT(
|
||||
'labels', JSON_ARRAY(
|
||||
JSON_OBJECT('fromName', 'label', 'toName', 'text', 'type', 'Labels', 'labels', JSON_ARRAY('Noun', 'Pronoun'))
|
||||
),
|
||||
'objects', JSON_ARRAY(JSON_OBJECT('name', 'text', 'type', 'Text', 'value', '$text'))
|
||||
),
|
||||
NULL,
|
||||
'<View>
|
||||
<Labels name="label" toName="text">
|
||||
<Label value="Noun" background="red"/>
|
||||
@@ -1467,13 +1377,7 @@ INSERT INTO t_dm_annotation_templates (
|
||||
'构建任务导向对话系统,选择对话意图并提取槽实体',
|
||||
'text',
|
||||
'slot-filling',
|
||||
JSON_OBJECT(
|
||||
'labels', JSON_ARRAY(
|
||||
JSON_OBJECT('fromName', 'entity_slot', 'toName', 'dialogue', 'type', 'ParagraphLabels', 'labels', JSON_ARRAY('Person', 'Organization', 'Location', 'Datetime', 'Quantity')),
|
||||
JSON_OBJECT('fromName', 'intent', 'toName', 'dialogue', 'type', 'Choices', 'options', JSON_ARRAY('Greeting', 'Customer request', 'Small talk'))
|
||||
),
|
||||
'objects', JSON_ARRAY(JSON_OBJECT('name', 'dialogue', 'type', 'Paragraphs', 'value', '$dialogue'))
|
||||
),
|
||||
NULL,
|
||||
'<View>
|
||||
<ParagraphLabels name="entity_slot" toName="dialogue">
|
||||
<Label value="Person" />
|
||||
@@ -1505,12 +1409,7 @@ INSERT INTO t_dm_annotation_templates (
|
||||
'通过生成下一个对话响应来收集聊天机器人训练数据',
|
||||
'text',
|
||||
'response-generation',
|
||||
JSON_OBJECT(
|
||||
'labels', JSON_ARRAY(
|
||||
JSON_OBJECT('fromName', 'response', 'toName', 'chat', 'type', 'TextArea', 'required', true)
|
||||
),
|
||||
'objects', JSON_ARRAY(JSON_OBJECT('name', 'chat', 'type', 'Paragraphs', 'value', '$dialogue'))
|
||||
),
|
||||
NULL,
|
||||
'<View>
|
||||
<Paragraphs name="chat" value="$dialogue" layout="dialogue" />
|
||||
<Header value="提供响应" />
|
||||
|
||||
Reference in New Issue
Block a user