diff --git a/frontend/public/lsf/lsf.html b/frontend/public/lsf/lsf.html index 9b0cff0..f77d933 100644 --- a/frontend/public/lsf/lsf.html +++ b/frontend/public/lsf/lsf.html @@ -59,6 +59,151 @@ let lsInstance = null; let currentTask = null; + let broadcastTimer = null; + let lastSelectedRegionId = null; + let regionSelectionBound = false; + + /** + * Build display-friendly region info from a serialized result item. + */ + function buildRegionDisplay(item) { + if (!item || typeof item !== "object") return { displayText: "", displayLabel: "" }; + var val = item.value || {}; + var displayLabel = ""; + var displayText = ""; + + // Extract label + if (Array.isArray(val.labels) && val.labels.length > 0) { + displayLabel = val.labels.join(", "); + } else if (Array.isArray(val.choices) && val.choices.length > 0) { + displayLabel = val.choices.join(", "); + } else if (Array.isArray(val.taxonomy) && val.taxonomy.length > 0) { + displayLabel = val.taxonomy.map(function(t) { return Array.isArray(t) ? t.join("/") : String(t); }).join(", "); + } else if (val.rating !== undefined) { + displayLabel = "Rating: " + val.rating; + } else { + displayLabel = item.type || ""; + } + + // Extract text + if (typeof val.text === "string") { + displayText = val.text.length > 80 ? val.text.substring(0, 80) + "..." : val.text; + } else if (typeof val.textarea === "string") { + displayText = val.textarea.length > 80 ? val.textarea.substring(0, 80) + "..." : val.textarea; + } else if (Array.isArray(val.text)) { + displayText = val.text.join(" ").substring(0, 80); + } else if (val.x !== undefined && val.y !== undefined) { + displayText = "(" + Math.round(val.x) + ", " + Math.round(val.y) + ")"; + if (val.width !== undefined) displayText += " " + Math.round(val.width) + "x" + Math.round(val.height); + } else if (val.start !== undefined && val.end !== undefined) { + displayText = "[" + val.start + ":" + val.end + "]"; + } + + return { displayText: displayText, displayLabel: displayLabel }; + } + + /** + * Check and broadcast region selection changes. + * LS 1.7.1 has no onSelectRegion callback, so we poll the store. + */ + function checkRegionSelection() { + try { + if (!lsInstance) return; + var store = pickAnnotationStore(lsInstance); + var annotation = resolveSelectedAnnotation(store); + if (!annotation) return; + // LS stores the selected region on the annotation object + var selected = annotation.highlightedNode || null; + var regionId = (selected && selected.id) ? String(selected.id) : null; + var taskId = typeof currentTask?.id === "number" ? currentTask.id : Number(currentTask?.id) || null; + if (regionId !== lastSelectedRegionId) { + lastSelectedRegionId = regionId; + postToParent("LS_REGION_SELECTED", { regionId: regionId, taskId: taskId }); + } + } catch (_) {} + } + + function handleLsClick() { + setTimeout(checkRegionSelection, 50); + } + function handleLsMouseup() { + setTimeout(checkRegionSelection, 100); + } + + function bindRegionSelectionListeners() { + if (regionSelectionBound) return; + var lsRoot = document.getElementById("label-studio"); + if (!lsRoot) return; + lsRoot.addEventListener("click", handleLsClick); + lsRoot.addEventListener("mouseup", handleLsMouseup); + regionSelectionBound = true; + } + + function unbindRegionSelectionListeners() { + var lsRoot = document.getElementById("label-studio"); + if (lsRoot) { + lsRoot.removeEventListener("click", handleLsClick); + lsRoot.removeEventListener("mouseup", handleLsMouseup); + } + regionSelectionBound = false; + } + + /** + * Broadcast current annotation state to parent (debounced). + */ + function broadcastAnnotationState() { + if (broadcastTimer) clearTimeout(broadcastTimer); + broadcastTimer = setTimeout(function() { + broadcastTimer = null; + try { + if (!lsInstance) return; + var store = pickAnnotationStore(lsInstance); + if (!store) return; + var selected = resolveSelectedAnnotation(store); + if (!selected) return; + + var serialized = null; + if (typeof selected.serializeAnnotation === "function") { + serialized = selected.serializeAnnotation(); + } else if (typeof selected.serialize === "function") { + serialized = selected.serialize(); + } + + var result = []; + if (Array.isArray(serialized)) { + result = serialized; + } else if (serialized && typeof serialized === "object") { + result = Array.isArray(serialized.result) ? serialized.result : []; + } + + var taskId = typeof currentTask?.id === "number" ? currentTask.id : Number(currentTask?.id) || null; + + // Build region info (exclude relations) + var regions = []; + for (var i = 0; i < result.length; i++) { + var item = result[i]; + if (item && item.type !== "relation") { + var display = buildRegionDisplay(item); + regions.push({ + id: item.id || ("r" + i), + type: item.type || "", + from_name: item.from_name || "", + to_name: item.to_name || "", + value: item.value || {}, + displayText: display.displayText, + displayLabel: display.displayLabel, + }); + } + } + + postToParent("LS_ANNOTATION_CHANGED", { + taskId: taskId, + result: result, + regions: regions, + }); + } catch (_) {} + }, 150); + } function postToParent(type, payload) { window.parent.postMessage({ type, payload }, ORIGIN); @@ -78,6 +223,8 @@ }); function destroyLabelStudio() { + unbindRegionSelectionListeners(); + try { if (lsInstance && typeof lsInstance.destroy === "function") { lsInstance.destroy(); @@ -86,6 +233,7 @@ lsInstance = null; currentTask = null; + lastSelectedRegionId = null; const root = document.getElementById("label-studio"); if (root) root.innerHTML = ""; @@ -359,9 +507,15 @@ } } catch (_) {} + // Broadcast initial annotation state to parent panel + broadcastAnnotationState(); + + // Bind region selection listeners (idempotent - only binds once) + bindRegionSelectionListeners(); + postToParent("LS_READY", { taskId: task?.id || null }); }, - // 让内嵌编辑器的“提交/保存”按钮也能触发父页面保存 + // 让内嵌编辑器的"提交/保存"按钮也能触发父页面保存 onSubmitAnnotation: function () { try { const raw = exportSelectedAnnotation(); @@ -370,6 +524,23 @@ postToParent("LS_ERROR", { message: e?.message || String(e) }); } }, + // Annotation change callbacks -> broadcast to parent panel + onUpdateAnnotation: function () { + broadcastAnnotationState(); + setTimeout(checkRegionSelection, 50); + }, + onEntityCreate: function () { + broadcastAnnotationState(); + setTimeout(checkRegionSelection, 50); + }, + onEntityDelete: function () { + broadcastAnnotationState(); + setTimeout(checkRegionSelection, 50); + }, + onSelectAnnotation: function () { + broadcastAnnotationState(); + setTimeout(checkRegionSelection, 50); + }, }); } @@ -415,6 +586,26 @@ postToParent("LS_PONG", {}); return; } + + if (msg.type === "LS_SELECT_REGION") { + var regionId = msg.payload && msg.payload.regionId; + if (regionId && lsInstance) { + var regionStore = pickAnnotationStore(lsInstance); + var annotation = resolveSelectedAnnotation(regionStore); + if (annotation && annotation.regionStore) { + var regions = annotation.regionStore.regions || []; + for (var ri = 0; ri < regions.length; ri++) { + if (String(regions[ri].id) === String(regionId)) { + if (typeof regions[ri].selectRegion === "function") { + regions[ri].selectRegion(); + } + break; + } + } + } + } + return; + } } catch (e) { postToParent("LS_ERROR", { message: e?.message || String(e) }); } diff --git a/frontend/src/pages/DataAnnotation/Annotate/LabelStudioTextEditor.tsx b/frontend/src/pages/DataAnnotation/Annotate/LabelStudioTextEditor.tsx index 2d3f326..52fada9 100644 --- a/frontend/src/pages/DataAnnotation/Annotate/LabelStudioTextEditor.tsx +++ b/frontend/src/pages/DataAnnotation/Annotate/LabelStudioTextEditor.tsx @@ -14,6 +14,13 @@ import { type UseNewVersionResponse, } from "../annotation.api"; import { AnnotationResultStatus } from "../annotation.model"; +import AnnotationResultPanel from "./components/AnnotationResultPanel"; +import type { + LSResultItem, + LSRegionInfo, + LSAnnotationChangedPayload, + PanelRelation, +} from "./annotation-result.types"; type EditorProjectInfo = { projectId: string; @@ -225,6 +232,34 @@ const normalizeTaskListResponse = ( }; }; +/** 合并面板管理的关系到标注 annotation 中 */ +const mergePanelRelations = ( + annotation: Record, + relations: PanelRelation[], +): Record => { + const result = Array.isArray(annotation.result) ? [...(annotation.result as LSResultItem[])] : []; + // 始终移除旧的面板关系(即使 relations 为空也要清除,否则删光关系后保存不生效) + const filtered = result.filter( + (item) => !(item.type === "relation" && item._source === "panel"), + ); + // 添加当前面板关系 + for (const rel of relations) { + filtered.push({ + id: rel.id, + from_name: "relation", + to_name: "relation", + type: "relation", + from_id: rel.fromRegionId, + to_id: rel.toRegionId, + direction: rel.direction, + labels: rel.labels, + value: {}, + _source: "panel", + }); + } + return { ...annotation, result: filtered }; +}; + export default function LabelStudioTextEditor() { const { projectId = "" } = useParams(); const navigate = useNavigate(); @@ -280,6 +315,13 @@ export default function LabelStudioTextEditor() { const [, setCheckingFileVersion] = useState(false); const [usingNewVersion, setUsingNewVersion] = useState(false); + // 自定义标注结果面板状态 + const [annotationResult, setAnnotationResult] = useState([]); + const [lsRegions, setLsRegions] = useState([]); + const [selectedRegionId, setSelectedRegionId] = useState(null); + const [panelRelations, setPanelRelations] = useState([]); + const [resultPanelCollapsed, setResultPanelCollapsed] = useState(false); + const focusIframe = useCallback(() => { const iframe = iframeRef.current; if (!iframe) return; @@ -293,6 +335,22 @@ export default function LabelStudioTextEditor() { win.postMessage({ type, payload }, origin); }, [origin]); + const handleSelectRegionInIframe = useCallback((regionId: string) => { + postToIframe("LS_SELECT_REGION", { regionId }); + setSelectedRegionId(regionId); + }, [postToIframe]); + + // 从 labelConfig 解析可用关系标签 + const availableRelationLabels = useMemo(() => { + if (!project?.labelConfig) return []; + const labels: string[] = []; + const matches = project.labelConfig.matchAll(/]*value="([^"]+)"/gi); + for (const m of matches) { + if (m[1]) labels.push(m[1]); + } + return labels; + }, [project?.labelConfig]); + const confirmEmptyAnnotationStatus = useCallback(() => { return new Promise((resolve) => { let resolved = false; @@ -520,6 +578,25 @@ export default function LabelStudioTextEditor() { savedSnapshotsRef.current[buildSnapshotKey(fileId, segmentIndex)] = buildAnnotationSnapshot(initialAnnotation); + // 重置自定义面板状态 + setAnnotationResult([]); + setLsRegions([]); + setSelectedRegionId(null); + // 从已有标注中提取面板管理的关系 + const existingResult = Array.isArray(initialAnnotation?.result) ? initialAnnotation.result as LSResultItem[] : []; + setPanelRelations( + existingResult + .filter((item) => item.type === "relation" && item._source === "panel" && item.from_id && item.to_id) + .map((item) => ({ + id: item.id, + fromRegionId: item.from_id!, + toRegionId: item.to_id!, + direction: item.direction || "right", + labels: item.labels || [], + source: "panel" as const, + })), + ); + expectedTaskIdRef.current = Number(taskForIframe?.id) || null; postToIframe("LS_INIT", { labelConfig: project.labelConfig, @@ -532,8 +609,8 @@ export default function LabelStudioTextEditor() { "update", // 更新按钮 "submit", // 提交按钮 "controls", // 控制面板 - // 侧边栏(包含 Outliner 和 Details) - "side-column", + // 侧边栏已由自定义面板替代,不再启用 + // "side-column", // 标注管理 "annotations:tabs", "annotations:menu", @@ -716,7 +793,9 @@ export default function LabelStudioTextEditor() { const currentTask = tasks.find((item) => item.fileId === String(fileId)); const currentStatus = currentTask?.annotationStatus; let resolvedStatus: AnnotationResultStatus; - if (isAnnotationResultEmpty(annotationRecord)) { + // 判断是否为空标注:LS 结果为空且面板关系也为空 + const isEmpty = isAnnotationResultEmpty(annotationRecord) && panelRelations.length === 0; + if (isEmpty) { if ( currentStatus === AnnotationResultStatus.NO_ANNOTATION || currentStatus === AnnotationResultStatus.NOT_APPLICABLE @@ -733,8 +812,11 @@ export default function LabelStudioTextEditor() { setSaving(true); try { + // 合并面板管理的关系到标注结果中 + const mergedAnnotation = mergePanelRelations(annotationRecord, panelRelations); + const resp = (await upsertEditorAnnotationUsingPut(projectId, String(fileId), { - annotation, + annotation: mergedAnnotation, segmentIndex, annotationStatus: resolvedStatus, })) as ApiResponse; @@ -773,6 +855,7 @@ export default function LabelStudioTextEditor() { confirmEmptyAnnotationStatus, currentSegmentIndex, message, + panelRelations, projectId, segmented, selectedFileId, @@ -819,6 +902,11 @@ export default function LabelStudioTextEditor() { setSegmented(false); setCurrentSegmentIndex(0); setSegmentTotal(0); + // 重置自定义面板状态 + setAnnotationResult([]); + setLsRegions([]); + setSelectedRegionId(null); + setPanelRelations([]); savedSnapshotsRef.current = {}; if (exportCheckRef.current?.timer) { window.clearTimeout(exportCheckRef.current.timer); @@ -911,6 +999,31 @@ export default function LabelStudioTextEditor() { return; } + // 自定义面板:标注变更同步 + if (msg.type === "LS_ANNOTATION_CHANGED") { + const changed = msg.payload as LSAnnotationChangedPayload | undefined; + if (changed) { + // 校验 taskId:只要当前有预期 taskId,就要求消息携带且匹配,否则丢弃 + if (expectedTaskIdRef.current) { + if (!changed.taskId || Number(changed.taskId) !== expectedTaskIdRef.current) return; + } + setAnnotationResult(changed.result || []); + setLsRegions(changed.regions || []); + } + return; + } + + // 自定义面板:区域选中同步 + if (msg.type === "LS_REGION_SELECTED") { + const regionPayload = msg.payload as { regionId?: string | null; taskId?: number | null } | undefined; + // 校验 taskId,防止旧任务的延迟消息影响当前面板 + if (expectedTaskIdRef.current) { + if (!regionPayload?.taskId || Number(regionPayload.taskId) !== expectedTaskIdRef.current) return; + } + setSelectedRegionId(regionPayload?.regionId || null); + return; + } + if (msg.type === "LS_ERROR") { const payloadMessage = resolvePayloadMessage(msg.payload); message.error(payloadMessage || "编辑器发生错误"); @@ -1058,6 +1171,11 @@ export default function LabelStudioTextEditor() { > 保存 + + + + {relations.length === 0 ? ( + + + + ) : ( +
+ {relations.map((rel) => { + const fromRegion = findRegion(regions, rel.fromRegionId); + const toRegion = findRegion(regions, rel.toRegionId); + const editable = rel.source === "panel"; + + return ( +
+ {/* From region */} + + + {fromRegion?.displayLabel || rel.fromRegionId.slice(0, 6)} + + + + {/* Direction */} + + {directionIcon(rel.direction)} + + + {/* To region */} + + + {toRegion?.displayLabel || rel.toRegionId.slice(0, 6)} + + + + {/* Relation labels */} +
+ {rel.labels.map((label) => ( + + {label} + + ))} +
+ + {/* Actions */} + {editable && ( +
+
+ )} + {!editable && ( + LS + )} +
+ ); + })} +
+ )} + + ); +}