Spaces:
Sleeping
Sleeping
| "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>“{newEntityPopover.selection}”</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> | |
| ); | |
| } | |