NLP-intelligence / frontend /src /app /components /AnnotationEditor.tsx
Nomio4640's picture
config error
e51c368
"use client";
import { useState, useCallback, useRef } from "react";
//const API_BASE = "";
const API_BASE = "";
export interface Entity {
word: string;
entity_group: string;
score: number;
start?: number | null;
end?: number | null;
}
export interface DocForEditor {
doc_id: number;
text: string;
entities: Entity[];
sentiment: { label: string; score: number } | null;
}
interface Props {
doc: DocForEditor;
onClose: () => void;
onSaved?: (updated: DocForEditor) => void;
}
const ENTITY_TYPES = ["PER", "ORG", "LOC", "MISC"];
const ENTITY_COLORS: Record<string, string> = {
PER: "#ff6b6b",
ORG: "#4ecdc4",
LOC: "#ffd93d",
MISC: "#a78bfa",
};
const SENTIMENT_LABELS = ["positive", "neutral", "negative"] as const;
const SENTIMENT_MN: Record<string, string> = {
positive: "Эерэг",
neutral: "Саармаг",
negative: "Сөрөг",
};
function color(group: string) {
return ENTITY_COLORS[group] || "#6c63ff";
}
// Split text into plain/entity segments using char offsets
type Segment =
| { kind: "text"; text: string }
| { kind: "entity"; text: string; index: number; entity: Entity };
function buildSegments(text: string, entities: Entity[]): Segment[] {
// Only use entities that have valid start/end offsets
const valid = entities
.map((e, index) => ({ e, index }))
.filter(({ e }) => e.start != null && e.end != null && e.start >= 0 && e.end > e.start)
.sort((a, b) => (a.e.start ?? 0) - (b.e.start ?? 0));
if (valid.length === 0) return [{ kind: "text", text }];
const segments: Segment[] = [];
let cursor = 0;
for (const { e, index } of valid) {
const start = e.start ?? 0;
const end = e.end ?? 0;
if (start < cursor) continue; // overlapping span — skip
if (start > cursor) {
segments.push({ kind: "text", text: text.slice(cursor, start) });
}
segments.push({ kind: "entity", text: text.slice(start, end), index, entity: e });
cursor = end;
}
if (cursor < text.length) {
segments.push({ kind: "text", text: text.slice(cursor) });
}
return segments;
}
export default function AnnotationEditor({ doc, onClose, onSaved }: Props) {
const [entities, setEntities] = useState<Entity[]>(doc.entities);
const [sentimentLabel, setSentimentLabel] = useState(
doc.sentiment?.label ?? "neutral"
);
const [sentimentScore, setSentimentScore] = useState(
doc.sentiment?.score ?? 1.0
);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState("");
// Popover state for entity actions
const [activeIdx, setActiveIdx] = useState<number | null>(null);
const popoverRef = useRef<HTMLDivElement>(null);
// New entity from text selection
const [newEntityPopover, setNewEntityPopover] = useState<{
selection: string;
start: number;
end: number;
} | null>(null);
const [newEntityGroup, setNewEntityGroup] = useState("PER");
const textRef = useRef<HTMLDivElement>(null);
const handleEntityClick = (idx: number) => {
setActiveIdx(activeIdx === idx ? null : idx);
setNewEntityPopover(null);
};
const changeLabel = (idx: number, group: string) => {
setEntities((prev) =>
prev.map((e, i) => (i === idx ? { ...e, entity_group: group } : e))
);
setActiveIdx(null);
};
const removeEntity = (idx: number) => {
setEntities((prev) => prev.filter((_, i) => i !== idx));
setActiveIdx(null);
};
const handleTextMouseUp = useCallback(() => {
const sel = window.getSelection();
if (!sel || sel.isCollapsed || !textRef.current) return;
const range = sel.getRangeAt(0);
// Walk the text container to compute char offset
const container = textRef.current;
let start = 0;
let end = 0;
let charCount = 0;
let foundStart = false;
function walk(node: Node) {
if (node.nodeType === Node.TEXT_NODE) {
const len = node.textContent?.length ?? 0;
if (!foundStart && node === range.startContainer) {
start = charCount + range.startOffset;
foundStart = true;
}
if (node === range.endContainer) {
end = charCount + range.endOffset;
}
charCount += len;
} else {
node.childNodes.forEach(walk);
}
}
walk(container);
const selectedText = sel.toString().trim();
if (!selectedText || start === end) return;
sel.removeAllRanges();
setNewEntityPopover({ selection: selectedText, start, end });
setActiveIdx(null);
}, []);
const addNewEntity = () => {
if (!newEntityPopover) return;
setEntities((prev) => [
...prev,
{
word: newEntityPopover.selection,
entity_group: newEntityGroup,
score: 1.0,
start: newEntityPopover.start,
end: newEntityPopover.end,
},
]);
setNewEntityPopover(null);
};
const save = async () => {
setSaving(true);
setError("");
try {
const res = await fetch(`${API_BASE}/api/documents/${doc.doc_id}`, {
method: "PATCH",
headers: { "ngrok-skip-browser-warning": "true", "Content-Type": "application/json" },
body: JSON.stringify({
entities,
sentiment_label: sentimentLabel,
sentiment_score: sentimentScore,
}),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || "Save failed");
}
setSaved(true);
setTimeout(() => setSaved(false), 2500);
onSaved?.({
...doc,
entities,
sentiment: { label: sentimentLabel, score: sentimentScore },
});
} catch (e: any) {
setError(e.message);
} finally {
setSaving(false);
}
};
const segments = buildSegments(doc.text, entities);
const hasOffsets = entities.some((e) => e.start != null && e.end != null);
return (
<div style={{
position: "fixed", inset: 0, background: "rgba(0,0,0,0.55)",
zIndex: 1000, display: "flex", alignItems: "center", justifyContent: "center",
}}
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div style={{
background: "var(--bg-card)", borderRadius: 12, padding: "1.5rem",
width: "min(860px, 96vw)", maxHeight: "90vh", overflow: "auto",
boxShadow: "0 8px 40px rgba(0,0,0,0.4)", position: "relative",
}}>
{/* Header */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1rem" }}>
<h3 style={{ margin: 0, fontSize: "1rem" }}>Тэмдэглэл засах — Doc #{doc.doc_id}</h3>
<button className="btn btn-secondary" style={{ padding: "0.3rem 0.8rem" }} onClick={onClose}></button>
</div>
{/* Sentiment selector */}
<div style={{ marginBottom: "1rem", display: "flex", gap: "0.5rem", alignItems: "center" }}>
<span style={{ fontSize: "0.8rem", color: "var(--text-muted)" }}>Сэтгэгдэл:</span>
{SENTIMENT_LABELS.map((lbl) => (
<button
key={lbl}
onClick={() => { setSentimentLabel(lbl); setSentimentScore(1.0); }}
className={`badge badge-${lbl}`}
style={{
cursor: "pointer", border: "none", padding: "0.3rem 0.8rem",
opacity: sentimentLabel === lbl ? 1 : 0.35,
fontWeight: sentimentLabel === lbl ? 700 : 400,
}}
>
{SENTIMENT_MN[lbl]}
</button>
))}
</div>
{/* Entity legend */}
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", marginBottom: "0.75rem" }}>
{ENTITY_TYPES.map((t) => (
<span key={t} style={{
fontSize: "0.72rem", padding: "0.15rem 0.5rem", borderRadius: 4,
background: `${color(t)}20`, border: `1px solid ${color(t)}`, color: color(t)
}}>
{t}
</span>
))}
<span style={{ fontSize: "0.72rem", color: "var(--text-muted)", alignSelf: "center" }}>
— текст сонгоход шинэ нэрлэсэн объект нэмнэ
</span>
</div>
{/* Annotated text */}
<div
ref={textRef}
onMouseUp={handleTextMouseUp}
style={{
lineHeight: 2.2, fontSize: "0.95rem", padding: "1rem",
background: "var(--bg-hover)", borderRadius: 8, marginBottom: "1rem",
userSelect: "text", cursor: "text", position: "relative",
}}
>
{hasOffsets ? (
segments.map((seg, si) =>
seg.kind === "text" ? (
<span key={si}>{seg.text}</span>
) : (
<span key={si} style={{ position: "relative", display: "inline" }}>
<mark
onClick={() => handleEntityClick(seg.index)}
style={{
background: `${color(seg.entity.entity_group)}25`,
borderBottom: `2.5px solid ${color(seg.entity.entity_group)}`,
borderRadius: "3px 3px 0 0",
padding: "0 2px", cursor: "pointer",
color: "inherit",
}}
>
{seg.text}
<sup style={{
fontSize: "0.6rem", marginLeft: 2, fontWeight: 700,
color: color(seg.entity.entity_group),
}}>
[{seg.entity.entity_group}]
</sup>
</mark>
{/* Entity action popover */}
{activeIdx === seg.index && (
<span
ref={popoverRef}
style={{
position: "absolute", zIndex: 10, top: "100%", left: 0,
background: "var(--bg-card)", border: "1px solid var(--border)",
borderRadius: 8, padding: "0.5rem", boxShadow: "0 4px 16px rgba(0,0,0,0.3)",
display: "flex", flexDirection: "column", gap: "0.3rem", minWidth: 140,
}}
>
<span style={{ fontSize: "0.7rem", color: "var(--text-muted)", marginBottom: 2 }}>
Шошго өөрчлөх:
</span>
<span style={{ display: "flex", flexWrap: "wrap", gap: "0.25rem" }}>
{ENTITY_TYPES.map((t) => (
<button
key={t}
onClick={() => changeLabel(seg.index, t)}
style={{
fontSize: "0.7rem", padding: "0.2rem 0.5rem", borderRadius: 4,
background: `${color(t)}20`, border: `1px solid ${color(t)}`,
color: color(t), cursor: "pointer",
fontWeight: seg.entity.entity_group === t ? 700 : 400,
}}
>
{t}
</button>
))}
</span>
<button
onClick={() => removeEntity(seg.index)}
style={{
fontSize: "0.7rem", padding: "0.2rem 0.5rem", borderRadius: 4,
background: "var(--negative)20", border: "1px solid var(--negative)",
color: "var(--negative)", cursor: "pointer", marginTop: 2,
}}
>
🗑 Устгах
</button>
</span>
)}
</span>
)
)
) : (
/* Fallback: no offsets — show raw text + entity list below */
<span>{doc.text}</span>
)}
</div>
{/* New entity popover from selection */}
{newEntityPopover && (
<div style={{
background: "var(--bg-hover)", border: "1px solid var(--border)",
borderRadius: 8, padding: "0.75rem", marginBottom: "1rem",
display: "flex", alignItems: "center", gap: "0.75rem", flexWrap: "wrap",
}}>
<span style={{ fontSize: "0.8rem" }}>
Нэмэх: <strong>&ldquo;{newEntityPopover.selection}&rdquo;</strong>
</span>
<select
value={newEntityGroup}
onChange={(e) => setNewEntityGroup(e.target.value)}
style={{
fontSize: "0.8rem", padding: "0.2rem 0.4rem",
background: "var(--bg-card)", border: "1px solid var(--border)",
color: "var(--text-primary)", borderRadius: 4
}}
>
{ENTITY_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
<button className="btn btn-primary" style={{ fontSize: "0.8rem", padding: "0.25rem 0.75rem" }}
onClick={addNewEntity}>Нэмэх</button>
<button className="btn btn-secondary" style={{ fontSize: "0.8rem", padding: "0.25rem 0.75rem" }}
onClick={() => setNewEntityPopover(null)}>Болих</button>
</div>
)}
{/* Entity list (always shown as summary) */}
{!hasOffsets && entities.length > 0 && (
<div style={{ marginBottom: "1rem" }}>
<p style={{ fontSize: "0.8rem", color: "var(--text-muted)", marginBottom: "0.5rem" }}>
Нэрлэсэн объектууд (засахын тулд текст дээр дарна уу):
</p>
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
{entities.map((e, i) => (
<span
key={i}
onClick={() => removeEntity(i)}
title="Устгах"
style={{
padding: "0.25rem 0.6rem", borderRadius: 6, cursor: "pointer",
background: `${color(e.entity_group)}20`,
border: `1px solid ${color(e.entity_group)}`,
color: color(e.entity_group), fontSize: "0.8rem",
display: "flex", alignItems: "center", gap: 4,
}}
>
{e.word}
<sup style={{ fontSize: "0.6rem" }}>[{e.entity_group}]</sup>
<span style={{ opacity: 0.5, fontSize: "0.7rem" }}></span>
</span>
))}
</div>
</div>
)}
{/* Error */}
{error && <p style={{ color: "var(--negative)", fontSize: "0.85rem", marginBottom: "0.5rem" }}>❌ {error}</p>}
{/* Actions */}
<div style={{ display: "flex", gap: "0.75rem", justifyContent: "flex-end", alignItems: "center" }}>
{saved && <span style={{ color: "var(--positive)", fontSize: "0.85rem" }}>✓ Хадгалагдлаа</span>}
<button className="btn btn-secondary" onClick={onClose}>Болих</button>
<button className="btn btn-primary" onClick={save} disabled={saving}>
{saving ? "Хадгалж байна..." : "💾 Хадгалах"}
</button>
</div>
</div>
</div>
);
}