5 new features: dark mode, shortcuts, SSE tasks, compare, compose
Dark mode: - body.dark overrides CSS variables (navy→cream reverse) - Persisted in localStorage, applied before paint to avoid flash - Toggle button in nav (moon/sun icon), Shift+D shortcut Keyboard shortcuts: - g h/n/u/t/s/c/w/d/k for page navigation - n for new case, ? for help (Shift+/) - Esc closes any open dialog, blurs focused input - Help modal via showShortcutsHelp() with styled kbd elements SSE tasks stream: - /api/system/tasks/stream pushes snapshots whenever _progress changes - Client uses EventSource instead of 3s polling - Auto-reconnect after 5s on error - 15s heartbeat keeps proxies alive Compare decisions (new #/compare page): - /api/training/compare?a=id&b=id serializes both decisions' metadata, section breakdown from document_chunks, and three buckets of patterns (only in A, only in B, shared) using variant matching - Two-column header with section-length breakdown + patterns count - Three-column diff row (only_a / shared / only_b) Compose with suggestions (new #/compose page): - Large RTL justified textarea with Hebrew display font title input - Sidebar lists all 47 style_patterns grouped by type with filter chips - Click a pattern → inserts at cursor, replacing [placeholders] with ___ - Live section guess (פתיחה / רקע / טענות / דיון / סוף דבר) based on most-recent 400 chars - Auto-save draft to localStorage every second; restore on page load - "העתק טקסט" copies title+body to clipboard Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
162
web/app.py
162
web/app.py
@@ -770,6 +770,125 @@ async def training_style_report():
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/training/compare")
|
||||
async def training_compare(a: str, b: str):
|
||||
"""Compare two decisions from style_corpus by ID.
|
||||
|
||||
Returns side-by-side data: basic metadata, length, section breakdown,
|
||||
which patterns appear in each, shared/unique patterns.
|
||||
"""
|
||||
try:
|
||||
ida, idb = UUID(a), UUID(b)
|
||||
except ValueError:
|
||||
raise HTTPException(400, "invalid id(s)")
|
||||
|
||||
pool = await db.get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"SELECT id, decision_number, decision_date, subject_categories, "
|
||||
" full_text, length(full_text) as chars "
|
||||
"FROM style_corpus WHERE id = ANY($1::uuid[])",
|
||||
[ida, idb],
|
||||
)
|
||||
if len(rows) != 2:
|
||||
raise HTTPException(404, "אחת ההחלטות לא נמצאה")
|
||||
|
||||
by_id = {r["id"]: r for r in rows}
|
||||
row_a = by_id[ida]
|
||||
row_b = by_id[idb]
|
||||
|
||||
patterns = await conn.fetch(
|
||||
"SELECT id, pattern_type, pattern_text, context "
|
||||
"FROM style_patterns WHERE frequency > 0"
|
||||
)
|
||||
|
||||
# Section breakdown via document_chunks
|
||||
async def section_stats(corpus_row):
|
||||
nm = corpus_row["decision_number"]
|
||||
if not nm:
|
||||
return []
|
||||
rows2 = await conn.fetch(
|
||||
"SELECT dc.section_type, sum(length(dc.content))::int as chars "
|
||||
"FROM document_chunks dc JOIN documents d ON dc.document_id=d.id "
|
||||
"WHERE d.title LIKE $1 AND dc.section_type IS NOT NULL "
|
||||
"GROUP BY dc.section_type ORDER BY chars DESC",
|
||||
f"%{nm}%",
|
||||
)
|
||||
return [{"type": r["section_type"], "chars": r["chars"]} for r in rows2]
|
||||
|
||||
sections_a = await section_stats(row_a)
|
||||
sections_b = await section_stats(row_b)
|
||||
|
||||
# Pattern matching via variant extraction
|
||||
def _strip_nikud_local(t: str) -> str:
|
||||
import unicodedata
|
||||
return "".join(c for c in unicodedata.normalize("NFD", t) if not unicodedata.combining(c))
|
||||
|
||||
def _variants(pt: str) -> list[str]:
|
||||
alts = re.split(r"\s*/\s*|\s+או\s+", pt)
|
||||
out = []
|
||||
for a in alts:
|
||||
a = re.sub(r"\[[^\]]*\]", "|", a)
|
||||
a = re.sub(r"\.{2,}", "|", a).replace("…", "|")
|
||||
segs = [s.strip(" ,.:;\"'") for s in a.split("|")]
|
||||
good = [s for s in segs if len(s) >= 4]
|
||||
if good:
|
||||
out.append(max(good, key=len))
|
||||
return list(dict.fromkeys(out))
|
||||
|
||||
text_a = _strip_nikud_local(row_a["full_text"])
|
||||
text_b = _strip_nikud_local(row_b["full_text"])
|
||||
|
||||
in_a, in_b = [], []
|
||||
for p in patterns:
|
||||
vs = _variants(_strip_nikud_local(p["pattern_text"]))
|
||||
if not vs:
|
||||
continue
|
||||
in_a_flag = any(v in text_a for v in vs)
|
||||
in_b_flag = any(v in text_b for v in vs)
|
||||
entry = {
|
||||
"id": str(p["id"]),
|
||||
"type": p["pattern_type"],
|
||||
"text": p["pattern_text"],
|
||||
"context": p["context"] or "",
|
||||
}
|
||||
if in_a_flag:
|
||||
in_a.append(entry)
|
||||
if in_b_flag:
|
||||
in_b.append(entry)
|
||||
|
||||
set_a = {p["id"] for p in in_a}
|
||||
set_b = {p["id"] for p in in_b}
|
||||
shared_ids = set_a & set_b
|
||||
only_a_ids = set_a - set_b
|
||||
only_b_ids = set_b - set_a
|
||||
|
||||
def serialize(row, sections, patterns_list):
|
||||
cats = row["subject_categories"]
|
||||
if isinstance(cats, str):
|
||||
try:
|
||||
cats = json.loads(cats)
|
||||
except Exception:
|
||||
cats = []
|
||||
return {
|
||||
"id": str(row["id"]),
|
||||
"decision_number": row["decision_number"] or "",
|
||||
"decision_date": str(row["decision_date"]) if row["decision_date"] else "",
|
||||
"chars": row["chars"],
|
||||
"subjects": cats or [],
|
||||
"sections": sections,
|
||||
"patterns_count": len(patterns_list),
|
||||
}
|
||||
|
||||
return {
|
||||
"a": serialize(row_a, sections_a, in_a),
|
||||
"b": serialize(row_b, sections_b, in_b),
|
||||
"shared": [p for p in in_a if p["id"] in shared_ids],
|
||||
"only_a": [p for p in in_a if p["id"] in only_a_ids],
|
||||
"only_b": [p for p in in_b if p["id"] in only_b_ids],
|
||||
}
|
||||
|
||||
|
||||
@app.delete("/api/training/corpus/{corpus_id}")
|
||||
async def training_corpus_delete(corpus_id: str):
|
||||
"""Remove a decision from the style corpus."""
|
||||
@@ -811,13 +930,11 @@ async def training_corpus_list():
|
||||
]
|
||||
|
||||
|
||||
@app.get("/api/system/tasks")
|
||||
async def system_tasks():
|
||||
"""List all active background tasks (from in-memory _progress dict)."""
|
||||
def _get_active_tasks() -> list[dict]:
|
||||
"""Extract active (non-terminal) tasks from _progress dict."""
|
||||
items = []
|
||||
for task_id, data in list(_progress.items()):
|
||||
status = data.get("status", "unknown")
|
||||
# Skip terminal states older than this request
|
||||
if status in ("completed", "failed"):
|
||||
continue
|
||||
items.append({
|
||||
@@ -827,9 +944,46 @@ async def system_tasks():
|
||||
"filename": data.get("filename", ""),
|
||||
"error": data.get("error", ""),
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
@app.get("/api/system/tasks")
|
||||
async def system_tasks():
|
||||
"""List all active background tasks (one-shot)."""
|
||||
items = _get_active_tasks()
|
||||
return {"active": items, "count": len(items)}
|
||||
|
||||
|
||||
@app.get("/api/system/tasks/stream")
|
||||
async def system_tasks_stream():
|
||||
"""SSE stream — pushes active-task snapshots when anything changes.
|
||||
|
||||
Replaces client-side polling. Clients connect once and receive
|
||||
events whenever the task set changes. Also sends a heartbeat every
|
||||
15s to keep proxies from timing out.
|
||||
"""
|
||||
async def event_gen():
|
||||
last_snapshot: str | None = None
|
||||
last_heartbeat = time.time()
|
||||
# Emit initial state immediately
|
||||
while True:
|
||||
snapshot = json.dumps(
|
||||
{"active": _get_active_tasks(), "count": len(_get_active_tasks())},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
now = time.time()
|
||||
if snapshot != last_snapshot:
|
||||
yield f"event: tasks\ndata: {snapshot}\n\n"
|
||||
last_snapshot = snapshot
|
||||
last_heartbeat = now
|
||||
elif now - last_heartbeat > 15:
|
||||
yield ": heartbeat\n\n"
|
||||
last_heartbeat = now
|
||||
await asyncio.sleep(1)
|
||||
|
||||
return StreamingResponse(event_gen(), media_type="text/event-stream")
|
||||
|
||||
|
||||
@app.get("/api/progress/{task_id}")
|
||||
async def progress_stream(task_id: str):
|
||||
"""SSE stream of processing progress."""
|
||||
|
||||
@@ -108,6 +108,54 @@
|
||||
--t-slow: 280ms var(--ease-out);
|
||||
}
|
||||
|
||||
/* ── Dark theme overrides ────────────────────────────── */
|
||||
body.dark {
|
||||
--color-navy: #f5f1e8;
|
||||
--color-navy-soft: #e8e0c8;
|
||||
--color-navy-dim: #c7bc9a;
|
||||
|
||||
--color-cream: #0a0f1c;
|
||||
--color-cream-deep: #121a2e;
|
||||
--color-parchment: #161f36;
|
||||
|
||||
--color-gold: #d4a55a;
|
||||
--color-gold-deep: #e8bc6f;
|
||||
--color-gold-soft: #c89a56;
|
||||
--color-gold-wash: rgba(212, 165, 90, 0.08);
|
||||
|
||||
--color-ink: #f5f1e8;
|
||||
--color-ink-soft: #d8d2c0;
|
||||
--color-ink-muted: #9a9380;
|
||||
--color-ink-light: #6a6458;
|
||||
|
||||
--color-rule: #2a3352;
|
||||
--color-rule-soft: #1e2a45;
|
||||
|
||||
--color-surface: #141b2f;
|
||||
--color-surface-raised: #1a2238;
|
||||
--color-bg: #0a0f1c;
|
||||
|
||||
--color-success: #5a9a6a;
|
||||
--color-success-bg: rgba(90, 154, 106, 0.12);
|
||||
--color-warn: #c79956;
|
||||
--color-warn-bg: rgba(199, 153, 86, 0.12);
|
||||
--color-danger: #c16565;
|
||||
--color-danger-bg: rgba(193, 101, 101, 0.12);
|
||||
--color-info: #6d8bab;
|
||||
--color-info-bg: rgba(109, 139, 171, 0.12);
|
||||
|
||||
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.35), 0 1px 2px rgba(0, 0, 0, 0.25);
|
||||
--shadow: 0 2px 6px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.25);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.45), 0 2px 4px rgba(0, 0, 0, 0.25);
|
||||
--shadow-lg: 0 10px 30px rgba(0, 0, 0, 0.5), 0 2px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
body.dark header {
|
||||
background: #060a18;
|
||||
border-bottom-color: var(--color-gold);
|
||||
}
|
||||
|
||||
/* ── Base overrides ──────────────────────────────────── */
|
||||
|
||||
html { font-size: 16px; }
|
||||
|
||||
@@ -83,6 +83,21 @@ header nav a.active::after {
|
||||
background: var(--color-gold);
|
||||
border-radius: 2px 2px 0 0;
|
||||
}
|
||||
.theme-toggle {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(169, 125, 58, 0.4);
|
||||
color: var(--color-gold-soft);
|
||||
cursor: pointer;
|
||||
font-size: 1.05em;
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius);
|
||||
margin-right: var(--space-3);
|
||||
transition: all var(--t);
|
||||
}
|
||||
.theme-toggle:hover {
|
||||
background: rgba(169, 125, 58, 0.15);
|
||||
border-color: var(--color-gold);
|
||||
}
|
||||
|
||||
/* Main */
|
||||
.main { flex: 1; max-width: 1200px; width: 100%; margin: 0 auto; padding: var(--space-7) var(--space-6); }
|
||||
@@ -876,6 +891,247 @@ header nav a.active::after {
|
||||
.diag-row-title { font-weight: 600; color: var(--color-navy); margin-bottom: 2px; }
|
||||
.diag-row-meta { font-size: 0.88em; color: var(--color-ink-muted); }
|
||||
|
||||
/* ── Compare Decisions ───────────────────────────────── */
|
||||
.compare-pickers {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr auto;
|
||||
gap: var(--space-4);
|
||||
align-items: end;
|
||||
}
|
||||
.compare-vs {
|
||||
font-size: 1.5em;
|
||||
color: var(--color-gold);
|
||||
padding-bottom: 10px;
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
.compare-result-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-5);
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
.compare-column {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-rule-soft);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-6);
|
||||
border-top: 4px solid var(--color-gold);
|
||||
}
|
||||
.compare-col-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.2em;
|
||||
font-weight: 800;
|
||||
color: var(--color-navy);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
.compare-col-meta {
|
||||
font-size: 0.82em;
|
||||
color: var(--color-ink-muted);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--space-4);
|
||||
padding-bottom: var(--space-3);
|
||||
border-bottom: 1px solid var(--color-rule-soft);
|
||||
}
|
||||
.compare-col-stat {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 6px 0;
|
||||
font-size: 0.88em;
|
||||
}
|
||||
.compare-col-stat strong { color: var(--color-navy); }
|
||||
|
||||
.compare-diff-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.compare-diff-col {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-rule-soft);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-5);
|
||||
}
|
||||
.compare-diff-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
color: var(--color-navy);
|
||||
margin-bottom: var(--space-3);
|
||||
padding-bottom: var(--space-2);
|
||||
border-bottom: 2px solid var(--color-gold);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
}
|
||||
.compare-diff-count {
|
||||
background: var(--color-gold-wash);
|
||||
color: var(--color-gold-deep);
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 0.78em;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
.compare-diff-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.compare-diff-item {
|
||||
padding: 8px 12px;
|
||||
background: var(--color-cream);
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.82em;
|
||||
color: var(--color-ink);
|
||||
border-right: 2px solid var(--color-gold);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ── Compose (writing assistant) ─────────────────────── */
|
||||
.compose-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
gap: var(--space-5);
|
||||
min-height: 70vh;
|
||||
}
|
||||
.compose-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.compose-toolbar {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
}
|
||||
.compose-title-input {
|
||||
flex: 1;
|
||||
padding: 12px 16px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.15em;
|
||||
font-weight: 600;
|
||||
color: var(--color-navy);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-rule);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.compose-title-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-gold);
|
||||
box-shadow: 0 0 0 3px var(--color-gold-wash);
|
||||
}
|
||||
.compose-textarea {
|
||||
flex: 1;
|
||||
min-height: 60vh;
|
||||
padding: var(--space-6) var(--space-7);
|
||||
font-family: var(--font-body);
|
||||
font-size: 1.02em;
|
||||
line-height: 1.75;
|
||||
color: var(--color-ink);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-rule);
|
||||
border-radius: var(--radius-lg);
|
||||
resize: vertical;
|
||||
text-align: justify;
|
||||
text-justify: inter-word;
|
||||
direction: rtl;
|
||||
}
|
||||
.compose-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-gold);
|
||||
box-shadow: 0 0 0 3px var(--color-gold-wash);
|
||||
}
|
||||
.compose-stats {
|
||||
font-size: 0.82em;
|
||||
color: var(--color-ink-muted);
|
||||
padding: 6px 14px;
|
||||
background: var(--color-parchment);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--color-rule-soft);
|
||||
}
|
||||
.compose-stats span { color: var(--color-navy); font-weight: 600; }
|
||||
|
||||
.compose-sidebar {
|
||||
background: var(--color-parchment);
|
||||
border: 1px solid var(--color-rule);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
.compose-sidebar-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.05em;
|
||||
font-weight: 700;
|
||||
color: var(--color-navy);
|
||||
padding-bottom: var(--space-3);
|
||||
border-bottom: 2px solid var(--color-gold);
|
||||
}
|
||||
.compose-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.compose-filter {
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid var(--color-rule);
|
||||
background: var(--color-surface);
|
||||
font-size: 0.76em;
|
||||
cursor: pointer;
|
||||
transition: all var(--t-fast);
|
||||
}
|
||||
.compose-filter:hover { background: var(--color-gold-wash); }
|
||||
.compose-filter.active {
|
||||
background: var(--color-navy);
|
||||
color: var(--color-parchment);
|
||||
border-color: var(--color-navy);
|
||||
}
|
||||
.compose-patterns {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
.compose-pattern {
|
||||
padding: 10px 12px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-rule-soft);
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.84em;
|
||||
cursor: pointer;
|
||||
transition: all var(--t-fast);
|
||||
border-right: 3px solid var(--color-gold);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.compose-pattern:hover {
|
||||
background: var(--color-gold-wash);
|
||||
transform: translateX(-2px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.compose-pattern-text {
|
||||
color: var(--color-navy);
|
||||
font-weight: 500;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.compose-pattern-meta {
|
||||
font-size: 0.78em;
|
||||
color: var(--color-ink-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.compose-layout { grid-template-columns: 1fr; }
|
||||
.compare-result-grid, .compare-diff-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* ── Local files (research, drafts, proofread) ───────── */
|
||||
.local-file-group { margin-bottom: 12px; }
|
||||
.local-file-group-header { font-size: 0.82em; font-weight: 600; color: #666; margin-bottom: 6px; padding-bottom: 4px; border-bottom: 1px solid #eee; }
|
||||
@@ -1366,8 +1622,43 @@ header nav a.active::after {
|
||||
max-height: 300px; overflow-y: auto;
|
||||
}
|
||||
.phrase-modal-example {
|
||||
padding: 10px 14px; background: #fafafa; border-right: 3px solid var(--color-gold);
|
||||
font-size: 0.86em; line-height: 1.5; color: #333; border-radius: 4px;
|
||||
padding: 10px 14px; background: var(--color-cream); border-right: 3px solid var(--color-gold);
|
||||
font-size: 0.86em; line-height: 1.5; color: var(--color-ink); border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* Shortcuts help dialog */
|
||||
.shortcuts-dialog { max-width: 460px; }
|
||||
.shortcuts-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.88em;
|
||||
}
|
||||
.shortcuts-table tr { border-bottom: 1px solid var(--color-rule-soft); }
|
||||
.shortcuts-table tr:last-child { border-bottom: none; }
|
||||
.shortcuts-table td {
|
||||
padding: 10px 8px;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
.shortcut-keys {
|
||||
text-align: left;
|
||||
direction: ltr;
|
||||
unicode-bidi: embed;
|
||||
white-space: nowrap;
|
||||
width: 120px;
|
||||
}
|
||||
kbd {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
background: var(--color-parchment);
|
||||
border: 1px solid var(--color-rule);
|
||||
border-bottom-width: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85em;
|
||||
color: var(--color-navy);
|
||||
margin: 0 2px;
|
||||
min-width: 22px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
@@ -1391,8 +1682,13 @@ header nav a.active::after {
|
||||
<a href="#/upload" id="navUpload">העלאה</a>
|
||||
<a href="#/training" id="navTraining">אימון סגנון</a>
|
||||
<a href="#/style-report" id="navStyleReport">הסגנון שלי</a>
|
||||
<a href="#/compare" id="navCompare">השוואה</a>
|
||||
<a href="#/compose" id="navCompose">כתיבה</a>
|
||||
<a href="#/skills" id="navSkills">Skills</a>
|
||||
<a href="#/diagnostics" id="navDiagnostics">מצב מערכת</a>
|
||||
<button class="theme-toggle" id="themeToggle" onclick="toggleTheme()" title="החלף ערכת צבעים (Shift+D)">
|
||||
<span id="themeIcon">🌙</span>
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -1827,6 +2123,60 @@ header nav a.active::after {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══ Page: Compare Decisions ══ -->
|
||||
<div class="page" id="page-compare">
|
||||
<div class="page-header">
|
||||
<h2>השוואת שתי החלטות</h2>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="compare-pickers">
|
||||
<div class="form-group">
|
||||
<label>החלטה א'</label>
|
||||
<select id="compareSelectA"><option value="">בחרי החלטה...</option></select>
|
||||
</div>
|
||||
<div class="compare-vs">⟷</div>
|
||||
<div class="form-group">
|
||||
<label>החלטה ב'</label>
|
||||
<select id="compareSelectB"><option value="">בחרי החלטה...</option></select>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="runCompare()">השווה</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="compareResult"></div>
|
||||
</div>
|
||||
|
||||
<!-- ══ Page: Compose (writing with live suggestions) ══ -->
|
||||
<div class="page" id="page-compose">
|
||||
<div class="page-header">
|
||||
<h2>כתיבת החלטה — עם הצעות סגנון</h2>
|
||||
</div>
|
||||
<div class="compose-layout">
|
||||
<div class="compose-main">
|
||||
<div class="compose-toolbar">
|
||||
<input type="text" id="composeTitle" placeholder="כותרת ההחלטה (למשל: ערר 1045/25 — יעקב כהן)" class="compose-title-input">
|
||||
<button class="btn btn-ghost btn-sm" onclick="copyComposeText()">העתק טקסט</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick="saveComposeDraft()">שמור טיוטה</button>
|
||||
</div>
|
||||
<textarea id="composeTextarea" class="compose-textarea"
|
||||
placeholder="התחילי לכתוב כאן. הצעות סגנון יופיעו בצד בהתאם לתוכן..."></textarea>
|
||||
<div class="compose-stats">
|
||||
<span id="composeCharCount">0</span> תווים ·
|
||||
<span id="composeWordCount">0</span> מילים ·
|
||||
<span id="composeSectionGuess">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="compose-sidebar">
|
||||
<h3 class="compose-sidebar-title">ביטויים של דפנה</h3>
|
||||
<div class="compose-filters" id="composeFilters"></div>
|
||||
<div class="compose-patterns" id="composePatterns">
|
||||
<div class="empty" style="padding:20px">טוען...</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══ Page: Diagnostics ══ -->
|
||||
<div class="page" id="page-diagnostics">
|
||||
<div class="page-header">
|
||||
@@ -1936,16 +2286,133 @@ function handleRoute() {
|
||||
document.getElementById('navDiagnostics').classList.add('active');
|
||||
subtitle = 'מצב מערכת';
|
||||
loadDiagnostics();
|
||||
} else if (hash === '#/compare') {
|
||||
document.getElementById('page-compare').classList.add('active');
|
||||
document.getElementById('navCompare').classList.add('active');
|
||||
subtitle = 'השוואת החלטות';
|
||||
initComparePage();
|
||||
} else if (hash === '#/compose') {
|
||||
document.getElementById('page-compose').classList.add('active');
|
||||
document.getElementById('navCompose').classList.add('active');
|
||||
subtitle = 'כתיבת החלטה';
|
||||
initComposePage();
|
||||
}
|
||||
|
||||
document.getElementById('pageSubtitle').textContent = subtitle;
|
||||
}
|
||||
|
||||
window.addEventListener('hashchange', handleRoute);
|
||||
// ── Keyboard shortcuts ──────────────────────────────
|
||||
|
||||
const SHORTCUTS = [
|
||||
{ keys: ['g', 'h'], label: 'דף הבית', action: () => location.hash = '#/' },
|
||||
{ keys: ['g', 'n'], label: 'תיק חדש', action: () => location.hash = '#/new' },
|
||||
{ keys: ['g', 'u'], label: 'העלאה', action: () => location.hash = '#/upload' },
|
||||
{ keys: ['g', 't'], label: 'אימון סגנון', action: () => location.hash = '#/training' },
|
||||
{ keys: ['g', 's'], label: 'הסגנון שלי', action: () => location.hash = '#/style-report' },
|
||||
{ keys: ['g', 'c'], label: 'השוואה', action: () => location.hash = '#/compare' },
|
||||
{ keys: ['g', 'w'], label: 'כתיבת החלטה', action: () => location.hash = '#/compose' },
|
||||
{ keys: ['g', 'd'], label: 'מצב מערכת', action: () => location.hash = '#/diagnostics' },
|
||||
{ keys: ['g', 'k'], label: 'Skills', action: () => location.hash = '#/skills' },
|
||||
{ keys: ['n'], label: 'תיק חדש', action: () => location.hash = '#/new' },
|
||||
{ keys: ['D'], label: 'Dark mode', shiftKey: true, action: toggleTheme },
|
||||
{ keys: ['?'], label: 'עזרה', shiftKey: true, action: () => showShortcutsHelp() },
|
||||
{ keys: ['Escape'], label: 'סגור', action: () => {
|
||||
document.querySelectorAll('dialog[open]').forEach(d => d.close());
|
||||
}},
|
||||
];
|
||||
|
||||
let _pendingKey = null;
|
||||
let _pendingTimer = null;
|
||||
|
||||
function setupKeyboardShortcuts() {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Ignore when typing in inputs
|
||||
const tag = (e.target.tagName || '').toLowerCase();
|
||||
if (tag === 'input' || tag === 'textarea' || tag === 'select' || e.target.isContentEditable) {
|
||||
if (e.key === 'Escape') e.target.blur();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle immediate-single shortcuts
|
||||
for (const s of SHORTCUTS) {
|
||||
if (s.keys.length === 1 && s.keys[0] === e.key) {
|
||||
if (s.shiftKey !== undefined && e.shiftKey !== s.shiftKey) continue;
|
||||
e.preventDefault();
|
||||
s.action();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle 2-key chords starting with 'g'
|
||||
if (e.key === 'g' && !_pendingKey) {
|
||||
_pendingKey = 'g';
|
||||
_pendingTimer = setTimeout(() => { _pendingKey = null; }, 1500);
|
||||
return;
|
||||
}
|
||||
if (_pendingKey === 'g') {
|
||||
clearTimeout(_pendingTimer);
|
||||
_pendingKey = null;
|
||||
for (const s of SHORTCUTS) {
|
||||
if (s.keys.length === 2 && s.keys[0] === 'g' && s.keys[1] === e.key) {
|
||||
e.preventDefault();
|
||||
s.action();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showShortcutsHelp() {
|
||||
let dialog = document.getElementById('shortcutsDialog');
|
||||
if (!dialog) {
|
||||
dialog = document.createElement('dialog');
|
||||
dialog.id = 'shortcutsDialog';
|
||||
dialog.className = 'phrase-modal shortcuts-dialog';
|
||||
document.body.appendChild(dialog);
|
||||
}
|
||||
const rows = SHORTCUTS.map(s => {
|
||||
const keysDisplay = s.keys.map(k => `<kbd>${k}</kbd>`).join(' ');
|
||||
return `<tr><td class="shortcut-keys">${keysDisplay}</td><td>${esc(s.label)}</td></tr>`;
|
||||
}).join('');
|
||||
dialog.innerHTML = `
|
||||
<div class="phrase-modal-header">
|
||||
<span class="phrase-modal-type">קיצורי מקלדת</span>
|
||||
<button class="btn-icon" onclick="document.getElementById('shortcutsDialog').close()">✕</button>
|
||||
</div>
|
||||
<table class="shortcuts-table">${rows}</table>
|
||||
`;
|
||||
dialog.showModal();
|
||||
}
|
||||
|
||||
// ── Theme (dark/light) ──────────────────────────────
|
||||
|
||||
function applyTheme(dark) {
|
||||
document.body.classList.toggle('dark', dark);
|
||||
const icon = document.getElementById('themeIcon');
|
||||
if (icon) icon.textContent = dark ? '☀️' : '🌙';
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const dark = !document.body.classList.contains('dark');
|
||||
applyTheme(dark);
|
||||
try { localStorage.setItem('theme', dark ? 'dark' : 'light'); } catch (e) {}
|
||||
}
|
||||
|
||||
// Apply saved theme immediately (before onload to prevent flash)
|
||||
(function initTheme() {
|
||||
try {
|
||||
const saved = localStorage.getItem('theme');
|
||||
if (saved === 'dark') applyTheme(true);
|
||||
} catch (e) {}
|
||||
})();
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
handleRoute();
|
||||
loadStatus();
|
||||
startProcessPanelPolling();
|
||||
setupKeyboardShortcuts();
|
||||
});
|
||||
|
||||
// ── Case List ────────────────────────────────────────────
|
||||
@@ -2702,6 +3169,271 @@ async function loadStatus() {
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// ── Compare Decisions ────────────────────────────────
|
||||
|
||||
async function initComparePage() {
|
||||
const selA = document.getElementById('compareSelectA');
|
||||
const selB = document.getElementById('compareSelectB');
|
||||
if (selA.options.length > 1) return; // already loaded
|
||||
|
||||
try {
|
||||
const res = await fetch(API + '/training/corpus');
|
||||
const rows = await res.json();
|
||||
const options = rows.map(r => {
|
||||
const label = `${r.decision_number || '(ללא מספר)'} · ${r.decision_date || '—'}`;
|
||||
return `<option value="${esc(r.id)}">${esc(label)}</option>`;
|
||||
}).join('');
|
||||
selA.innerHTML = '<option value="">בחרי החלטה...</option>' + options;
|
||||
selB.innerHTML = '<option value="">בחרי החלטה...</option>' + options;
|
||||
} catch (e) {
|
||||
toast('שגיאה בטעינת רשימת החלטות', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function runCompare() {
|
||||
const a = document.getElementById('compareSelectA').value;
|
||||
const b = document.getElementById('compareSelectB').value;
|
||||
if (!a || !b) { toast('בחרי שתי החלטות', 'error'); return; }
|
||||
if (a === b) { toast('יש לבחור שתי החלטות שונות', 'error'); return; }
|
||||
|
||||
const result = document.getElementById('compareResult');
|
||||
result.innerHTML = '<div class="card"><div class="card-body"><div class="empty">משווה...</div></div></div>';
|
||||
|
||||
try {
|
||||
const res = await fetch(API + '/training/compare?a=' + encodeURIComponent(a) + '&b=' + encodeURIComponent(b));
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const data = await res.json();
|
||||
renderCompareResult(data);
|
||||
} catch (e) {
|
||||
result.innerHTML = `<div class="card"><div class="card-body"><div class="empty">שגיאה: ${esc(e.message)}</div></div></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderCompareResult(data) {
|
||||
const container = document.getElementById('compareResult');
|
||||
const a = data.a, b = data.b;
|
||||
|
||||
const headerCard = (row, label) => {
|
||||
const secTotal = row.sections.reduce((sum, s) => sum + s.chars, 0) || 1;
|
||||
const sectionsHtml = row.sections.map(s => {
|
||||
const pct = Math.round((s.chars / secTotal) * 100);
|
||||
const labelHeb = ({
|
||||
intro: 'פתיחה', facts: 'רקע', appellant_claims: 'טענות עורר',
|
||||
respondent_claims: 'טענות משיב', legal_analysis: 'דיון משפטי',
|
||||
ruling: 'הכרעה', conclusion: 'סוף דבר',
|
||||
})[s.type] || s.type;
|
||||
return `<div class="compare-col-stat"><span>${esc(labelHeb)}</span><strong>${s.chars.toLocaleString('he-IL')} (${pct}%)</strong></div>`;
|
||||
}).join('');
|
||||
return `
|
||||
<div class="compare-column">
|
||||
<div class="compare-col-title">${esc(row.decision_number || '(ללא מספר)')}</div>
|
||||
<div class="compare-col-meta">
|
||||
<span>${esc(row.decision_date || '—')}</span>
|
||||
<span>${row.chars.toLocaleString('he-IL')} תווים</span>
|
||||
</div>
|
||||
<div class="compare-col-stat"><span>דפוסים שזוהו</span><strong>${row.patterns_count}</strong></div>
|
||||
${sectionsHtml}
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
const diffColumn = (title, items, colorClass) => {
|
||||
if (!items.length) {
|
||||
return `
|
||||
<div class="compare-diff-col">
|
||||
<div class="compare-diff-title">${esc(title)} <span class="compare-diff-count">0</span></div>
|
||||
<div class="empty" style="padding:16px">—</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
const itemsHtml = items.map(p => {
|
||||
let display = p.text.replace(/\[[^\]]*\]/g, '').replace(/\s+/g, ' ').trim();
|
||||
display = display.split(' / ')[0].split(' או ')[0].trim();
|
||||
if (display.length > 80) display = display.substring(0, 77) + '...';
|
||||
return `<div class="compare-diff-item">${esc(display)}</div>`;
|
||||
}).join('');
|
||||
return `
|
||||
<div class="compare-diff-col">
|
||||
<div class="compare-diff-title">${esc(title)} <span class="compare-diff-count">${items.length}</span></div>
|
||||
<div class="compare-diff-list">${itemsHtml}</div>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="compare-result-grid">
|
||||
${headerCard(a)}
|
||||
${headerCard(b)}
|
||||
</div>
|
||||
<div class="compare-diff-row">
|
||||
${diffColumn('רק ב-' + (a.decision_number || 'א'), data.only_a)}
|
||||
${diffColumn('משותפים', data.shared)}
|
||||
${diffColumn('רק ב-' + (b.decision_number || 'ב'), data.only_b)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Compose (writing with suggestions) ───────────────
|
||||
|
||||
let _composePatterns = [];
|
||||
let _composeFilter = 'all';
|
||||
|
||||
async function initComposePage() {
|
||||
// Restore draft if any
|
||||
try {
|
||||
const saved = localStorage.getItem('composeDraft');
|
||||
const title = localStorage.getItem('composeTitle');
|
||||
if (saved) document.getElementById('composeTextarea').value = saved;
|
||||
if (title) document.getElementById('composeTitle').value = title;
|
||||
} catch (e) {}
|
||||
|
||||
updateComposeStats();
|
||||
document.getElementById('composeTextarea').addEventListener('input', onComposeInput);
|
||||
|
||||
if (_composePatterns.length) {
|
||||
renderComposePatterns();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(API + '/training/patterns');
|
||||
const data = await res.json();
|
||||
_composePatterns = [];
|
||||
Object.entries(data.by_type || {}).forEach(([type, items]) => {
|
||||
items.forEach(p => _composePatterns.push({ type, ...p }));
|
||||
});
|
||||
// Sort by frequency desc
|
||||
_composePatterns.sort((a, b) => (b.frequency || 0) - (a.frequency || 0));
|
||||
renderComposeFilters();
|
||||
renderComposePatterns();
|
||||
} catch (e) {
|
||||
document.getElementById('composePatterns').innerHTML = '<div class="empty">שגיאה בטעינה</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderComposeFilters() {
|
||||
const types = [...new Set(_composePatterns.map(p => p.type))];
|
||||
const typeLabels = {
|
||||
opening_formula: 'פתיחה',
|
||||
closing_formula: 'סיום',
|
||||
transition: 'מעברים',
|
||||
characteristic_phrase: 'ביטויים',
|
||||
argument_flow: 'טיעון',
|
||||
analysis_structure: 'מבנה',
|
||||
evidence_handling: 'ראיות',
|
||||
citation_style: 'ציטוט',
|
||||
};
|
||||
const all = [{ id: 'all', label: 'הכל' }]
|
||||
.concat(types.map(t => ({ id: t, label: typeLabels[t] || t })));
|
||||
document.getElementById('composeFilters').innerHTML = all.map(f => `
|
||||
<div class="compose-filter ${f.id === _composeFilter ? 'active' : ''}"
|
||||
onclick="setComposeFilter('${f.id}')">${esc(f.label)}</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function setComposeFilter(f) {
|
||||
_composeFilter = f;
|
||||
renderComposeFilters();
|
||||
renderComposePatterns();
|
||||
}
|
||||
|
||||
function renderComposePatterns() {
|
||||
const filtered = _composeFilter === 'all'
|
||||
? _composePatterns
|
||||
: _composePatterns.filter(p => p.type === _composeFilter);
|
||||
|
||||
const typeLabels = {
|
||||
opening_formula: 'פתיחה', closing_formula: 'סיום', transition: 'מעבר',
|
||||
characteristic_phrase: 'ביטוי', argument_flow: 'טיעון',
|
||||
analysis_structure: 'מבנה', evidence_handling: 'ראיות', citation_style: 'ציטוט',
|
||||
};
|
||||
|
||||
document.getElementById('composePatterns').innerHTML = filtered.map((p, idx) => {
|
||||
let display = p.pattern_text || p.text || '';
|
||||
let clean = display.replace(/\[[^\]]*\]/g, '').replace(/\s+/g, ' ').trim();
|
||||
clean = clean.split(' / ')[0].split(' או ')[0].trim();
|
||||
if (clean.length > 90) clean = clean.substring(0, 87) + '...';
|
||||
const origIdx = _composePatterns.indexOf(p);
|
||||
return `
|
||||
<div class="compose-pattern" onclick="insertComposePattern(${origIdx})">
|
||||
<div class="compose-pattern-text">${esc(clean)}</div>
|
||||
<div class="compose-pattern-meta">${esc(typeLabels[p.type] || p.type)} · ${p.frequency || 0}/24</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('') || '<div class="empty" style="padding:20px">אין דפוסים בקטגוריה זו</div>';
|
||||
}
|
||||
|
||||
function insertComposePattern(idx) {
|
||||
const p = _composePatterns[idx];
|
||||
if (!p) return;
|
||||
let text = (p.pattern_text || p.text || '').replace(/\[[^\]]*\]/g, '___').replace(/\s+/g, ' ').trim();
|
||||
text = text.split(' / ')[0].split(' או ')[0].trim();
|
||||
|
||||
const ta = document.getElementById('composeTextarea');
|
||||
const start = ta.selectionStart;
|
||||
const end = ta.selectionEnd;
|
||||
const before = ta.value.substring(0, start);
|
||||
const after = ta.value.substring(end);
|
||||
// Add space before if needed
|
||||
const prefix = (before && !/\s$/.test(before)) ? ' ' : '';
|
||||
const suffix = (after && !/^\s/.test(after)) ? ' ' : '';
|
||||
const insert = prefix + text + suffix;
|
||||
ta.value = before + insert + after;
|
||||
const newPos = start + insert.length;
|
||||
ta.selectionStart = ta.selectionEnd = newPos;
|
||||
ta.focus();
|
||||
onComposeInput();
|
||||
}
|
||||
|
||||
function onComposeInput() {
|
||||
updateComposeStats();
|
||||
// Auto-save draft every second (debounced)
|
||||
clearTimeout(onComposeInput._t);
|
||||
onComposeInput._t = setTimeout(() => {
|
||||
try {
|
||||
localStorage.setItem('composeDraft', document.getElementById('composeTextarea').value);
|
||||
localStorage.setItem('composeTitle', document.getElementById('composeTitle').value);
|
||||
} catch (e) {}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function updateComposeStats() {
|
||||
const ta = document.getElementById('composeTextarea');
|
||||
if (!ta) return;
|
||||
const text = ta.value || '';
|
||||
const chars = text.length;
|
||||
const words = (text.match(/\S+/g) || []).length;
|
||||
document.getElementById('composeCharCount').textContent = chars.toLocaleString('he-IL');
|
||||
document.getElementById('composeWordCount').textContent = words.toLocaleString('he-IL');
|
||||
|
||||
// Guess section from recent text
|
||||
const recent = text.slice(-400);
|
||||
let section = '—';
|
||||
if (/בפנינו|לפנינו|עניינו|עסקינן/.test(recent)) section = 'פתיחה';
|
||||
else if (/רקע|העובדות|תכנית/.test(recent)) section = 'רקע';
|
||||
else if (/טענות העורר|טוען העורר|המערער/.test(recent)) section = 'טענות עורר';
|
||||
else if (/טענות המשיב|טוענת המשיבה|טוענים המשיבים/.test(recent)) section = 'טענות משיב';
|
||||
else if (/דיון|הכרעה|לאחר שבחנו|נחדד|נזכיר/.test(recent)) section = 'דיון והכרעה';
|
||||
else if (/סוף דבר|לאור כל האמור|הערר נדחה|הערר מתקבל|ניתנה פה/.test(recent)) section = 'סוף דבר';
|
||||
document.getElementById('composeSectionGuess').textContent = section;
|
||||
}
|
||||
|
||||
function copyComposeText() {
|
||||
const ta = document.getElementById('composeTextarea');
|
||||
const title = document.getElementById('composeTitle').value;
|
||||
const text = (title ? title + '\n\n' : '') + ta.value;
|
||||
navigator.clipboard.writeText(text).then(() => toast('הועתק ללוח', 'success'));
|
||||
}
|
||||
|
||||
function saveComposeDraft() {
|
||||
try {
|
||||
localStorage.setItem('composeDraft', document.getElementById('composeTextarea').value);
|
||||
localStorage.setItem('composeTitle', document.getElementById('composeTitle').value);
|
||||
toast('הטיוטה נשמרה', 'success');
|
||||
} catch (e) {
|
||||
toast('שגיאה בשמירה', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Diagnostics Page ─────────────────────────────────
|
||||
|
||||
async function loadDiagnostics() {
|
||||
@@ -2783,22 +3515,31 @@ async function loadDiagnostics() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Processing Visibility Panel ──────────────────────
|
||||
// ── Processing Visibility Panel (SSE push) ───────────
|
||||
|
||||
let _processPollTimer = null;
|
||||
let _tasksEventSource = null;
|
||||
|
||||
function startProcessPanelPolling() {
|
||||
if (_processPollTimer) return;
|
||||
pollProcessPanel();
|
||||
_processPollTimer = setInterval(pollProcessPanel, 3000);
|
||||
}
|
||||
|
||||
async function pollProcessPanel() {
|
||||
if (_tasksEventSource) return;
|
||||
try {
|
||||
const res = await fetch(API + '/system/tasks');
|
||||
const data = await res.json();
|
||||
renderProcessPanel(data.active || []);
|
||||
} catch (e) {}
|
||||
const es = new EventSource(API + '/system/tasks/stream');
|
||||
es.addEventListener('tasks', (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
renderProcessPanel(data.active || []);
|
||||
} catch (err) {}
|
||||
});
|
||||
es.onerror = () => {
|
||||
// Reconnect after a delay
|
||||
es.close();
|
||||
_tasksEventSource = null;
|
||||
setTimeout(startProcessPanelPolling, 5000);
|
||||
};
|
||||
_tasksEventSource = es;
|
||||
} catch (e) {
|
||||
// Browsers without EventSource fall back to one-shot fetch
|
||||
fetch(API + '/system/tasks').then(r => r.json()).then(d => renderProcessPanel(d.active || [])).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
const STEP_LABELS = {
|
||||
|
||||
Reference in New Issue
Block a user