44e93f08ee
Failure markers now carry finish_reason and usage tokens (prompt / completion / reasoning) pulled from the chat-completions response, so the case-page "Technische Details" block can show why an analysis truncated — e.g. gpt-oss-120b hitting max_completion_tokens with the chain-of-thought before producing any visible answer. The worker also emits a new CaseEventKind::AnalysisFailed after writing the marker, so subscribed browsers reload immediately instead of leaving the page on the "Wird analysiert …" placeholder.
842 lines
31 KiB
HTML
842 lines
31 KiB
HTML
{%- import "partials/oneliner.html" as ol -%}
|
|
{%- import "partials/csrf_field.html" as csrf -%}
|
|
<!doctype html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<title>Doctate — Fall {{ case_id_short }}</title>
|
|
<style>
|
|
body {
|
|
font-family: sans-serif;
|
|
max-width: 900px;
|
|
margin: 2em auto;
|
|
padding: 0 1em;
|
|
}
|
|
header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
header form {
|
|
margin: 0;
|
|
}
|
|
.back {
|
|
color: #4a90e2;
|
|
text-decoration: none;
|
|
}
|
|
h1 {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.6em;
|
|
flex-wrap: wrap;
|
|
margin-bottom: 0.2em;
|
|
}
|
|
h1 .title {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.6em;
|
|
flex-wrap: wrap;
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
h1 .delete-form {
|
|
margin: 0 0 0 auto;
|
|
}
|
|
.oneliner-edit {
|
|
cursor: pointer;
|
|
}
|
|
.oneliner-edit:hover .oneliner-text {
|
|
text-decoration: underline dotted;
|
|
text-underline-offset: 0.2em;
|
|
}
|
|
.oneliner-edit.editing {
|
|
cursor: default;
|
|
}
|
|
.oneliner-input {
|
|
font: inherit;
|
|
padding: 0.1em 0.3em;
|
|
min-width: 18em;
|
|
border: 1px solid #4a90e2;
|
|
border-radius: 3px;
|
|
background: white;
|
|
}
|
|
.oneliner-edit.saving .oneliner-input {
|
|
opacity: 0.6;
|
|
}
|
|
.oneliner-edit.error .oneliner-input {
|
|
border-color: #e24a4a;
|
|
background: #fff0f0;
|
|
}
|
|
.manual-marker {
|
|
font-size: 0.7em;
|
|
opacity: 0.55;
|
|
margin-left: 0.1em;
|
|
cursor: help;
|
|
vertical-align: middle;
|
|
}
|
|
.case-time {
|
|
color: #666;
|
|
font-size: 1.05em;
|
|
margin: 0 0 1em;
|
|
}
|
|
.meta {
|
|
color: #999;
|
|
font-family: monospace;
|
|
font-size: 0.8em;
|
|
margin: 0 0 1em;
|
|
}
|
|
.status-badge {
|
|
display: inline-block;
|
|
padding: 0.1em 0.6em;
|
|
border-radius: 999px;
|
|
font-size: 0.6em;
|
|
font-weight: bold;
|
|
color: white;
|
|
vertical-align: middle;
|
|
}
|
|
.status-badge.open {
|
|
background: #4a90e2;
|
|
}
|
|
.status-badge.done {
|
|
background: #7ed321;
|
|
}
|
|
.status-badge.fehler {
|
|
background: #e24a4a;
|
|
}
|
|
.actions {
|
|
margin: 1em 0 1.5em;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.4em;
|
|
}
|
|
.actions form {
|
|
margin: 0;
|
|
display: inline;
|
|
}
|
|
.actions button {
|
|
font-size: 1em;
|
|
padding: 0.5em 1em;
|
|
}
|
|
.actions .analyzing {
|
|
color: #888;
|
|
font-style: italic;
|
|
}
|
|
.actions .llm-missing {
|
|
color: #a66;
|
|
font-style: italic;
|
|
}
|
|
.actions:empty {
|
|
display: none;
|
|
}
|
|
.delete-btn {
|
|
padding: 0.4em;
|
|
background: transparent;
|
|
border: none;
|
|
color: #888;
|
|
cursor: pointer;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
line-height: 0;
|
|
border-radius: 4px;
|
|
transition:
|
|
color 0.15s,
|
|
background 0.15s;
|
|
font-size: 1em;
|
|
}
|
|
.delete-btn:hover {
|
|
color: #e24a4a;
|
|
background: #fff0f0;
|
|
}
|
|
.placeholder {
|
|
color: #888;
|
|
font-style: italic;
|
|
margin: 1em 0;
|
|
}
|
|
.empty,
|
|
.pending {
|
|
color: #888;
|
|
font-style: italic;
|
|
font-weight: normal;
|
|
}
|
|
.status-panel {
|
|
margin: 1em 0;
|
|
padding: 0.8em 1em;
|
|
background: #f6f6f6;
|
|
border-radius: 4px;
|
|
}
|
|
.failure-banner {
|
|
margin: 1em 0;
|
|
padding: 1em 1.2em;
|
|
background: #fdecea;
|
|
border: 1px solid #f5c6cb;
|
|
border-radius: 4px;
|
|
color: #842029;
|
|
}
|
|
.failure-banner strong {
|
|
font-size: 1.05em;
|
|
}
|
|
.failure-banner .failure-time {
|
|
display: block;
|
|
margin-top: 0.4em;
|
|
font-size: 0.9em;
|
|
color: #6b1f25;
|
|
}
|
|
.failure-banner details {
|
|
margin-top: 0.6em;
|
|
font-size: 0.9em;
|
|
}
|
|
.failure-banner details summary {
|
|
cursor: pointer;
|
|
color: #6b1f25;
|
|
}
|
|
.failure-banner pre.failure-reason {
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
margin: 0.4em 0 0 0;
|
|
padding: 0.6em 0.8em;
|
|
background: #fff;
|
|
border: 1px solid #f5c6cb;
|
|
border-radius: 3px;
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
font-size: 0.85em;
|
|
color: #4a1014;
|
|
}
|
|
.failure-banner dl.failure-details {
|
|
display: grid;
|
|
grid-template-columns: max-content 1fr;
|
|
gap: 0.2em 0.8em;
|
|
margin: 0.4em 0 0.6em 0;
|
|
padding: 0.5em 0.8em;
|
|
background: #fff;
|
|
border: 1px solid #f5c6cb;
|
|
border-radius: 3px;
|
|
font-size: 0.85em;
|
|
color: #4a1014;
|
|
}
|
|
.failure-banner dl.failure-details dt {
|
|
font-weight: 600;
|
|
color: #6b1f25;
|
|
}
|
|
.failure-banner dl.failure-details dd {
|
|
margin: 0;
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
}
|
|
.failure-banner form {
|
|
margin: 0.8em 0 0 0;
|
|
}
|
|
.failure-banner button {
|
|
padding: 0.4em 1em;
|
|
background: #b02a37;
|
|
color: #fff;
|
|
border: none;
|
|
border-radius: 3px;
|
|
cursor: pointer;
|
|
font-size: 0.95em;
|
|
}
|
|
.failure-banner button:hover {
|
|
background: #842029;
|
|
}
|
|
.recordings-link {
|
|
display: inline-block;
|
|
margin: 1em 0;
|
|
color: #4a90e2;
|
|
text-decoration: none;
|
|
font-size: 0.95em;
|
|
}
|
|
.recordings-link:hover {
|
|
text-decoration: underline;
|
|
}
|
|
.doc-content {
|
|
position: relative;
|
|
font-family: serif;
|
|
font-size: 1.05em;
|
|
line-height: 1.55;
|
|
background: #f6f6f6;
|
|
padding: 1em 2em 1em 1.2em;
|
|
border-radius: 4px;
|
|
min-height: 3em;
|
|
}
|
|
.doc-content p {
|
|
margin: 0.8em 0;
|
|
}
|
|
.doc-content p:first-child {
|
|
margin-top: 0;
|
|
}
|
|
.doc-content p:last-child {
|
|
margin-bottom: 0;
|
|
}
|
|
.doc-content mark {
|
|
background: #fff3a8;
|
|
padding: 0 0.2em;
|
|
border-radius: 2px;
|
|
}
|
|
.doc-actions {
|
|
position: absolute;
|
|
bottom: 0.5em;
|
|
right: 0.5em;
|
|
display: flex;
|
|
gap: 0.2em;
|
|
}
|
|
.doc-actions-top {
|
|
position: absolute;
|
|
top: 0.5em;
|
|
right: 0.5em;
|
|
display: flex;
|
|
gap: 0.2em;
|
|
}
|
|
.doc-actions form,
|
|
.doc-actions-top form {
|
|
margin: 0;
|
|
}
|
|
.copy-btn {
|
|
padding: 0.35em;
|
|
background: transparent;
|
|
border: none;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
color: #888;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
line-height: 0;
|
|
opacity: 0.45;
|
|
transition:
|
|
opacity 0.15s,
|
|
color 0.15s;
|
|
}
|
|
.copy-btn:hover {
|
|
color: #4a90e2;
|
|
opacity: 1;
|
|
}
|
|
.copy-btn .icon-check {
|
|
display: none;
|
|
}
|
|
.copy-btn.copied {
|
|
color: #2d8c2d;
|
|
opacity: 1;
|
|
}
|
|
.copy-btn.copied .icon-copy {
|
|
display: none;
|
|
}
|
|
.copy-btn.copied .icon-check {
|
|
display: block;
|
|
}
|
|
|
|
.header-right {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.8em;
|
|
}
|
|
.admin-toggle {
|
|
font-size: 0.85em;
|
|
color: #666;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.3em;
|
|
cursor: pointer;
|
|
user-select: none;
|
|
}
|
|
html.admin-view-off .admin-only {
|
|
display: none !important;
|
|
}
|
|
</style>
|
|
<script>
|
|
try {
|
|
if (localStorage.getItem("admin-view") === "off") {
|
|
document.documentElement.classList.add("admin-view-off");
|
|
}
|
|
} catch (_) {}
|
|
</script>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<div><a class="back" href="/cases">← Fälle</a></div>
|
|
<div class="header-right">
|
|
{% if is_admin %}<label class="admin-toggle"
|
|
><input type="checkbox" id="admin-view-toggle" /> Admin
|
|
view</label
|
|
>{% endif %}
|
|
<form method="post" action="/logout">
|
|
{% call csrf::field(csrf_token) %}
|
|
<button type="submit">Logout</button>
|
|
</form>
|
|
</div>
|
|
</header>
|
|
<h1>
|
|
<span class="title">
|
|
{% if is_closed %}
|
|
<span class="oneliner-text">{% call ol::render(oneliner) %}</span>
|
|
{% else %}
|
|
<span class="oneliner-edit" data-case-id="{{ case_id }}" data-csrf="{{ csrf_token }}">
|
|
<span class="oneliner-text">{% call ol::render(oneliner) %}</span>
|
|
</span>
|
|
{% endif %}
|
|
{% if oneliner_is_manual %}<span class="manual-marker" title="manuell bearbeitet" aria-label="manuell bearbeitet">👤</span>{% endif %}
|
|
{% if has_failed_recording
|
|
%}<span class="status-badge fehler">Fehler</span>{% else if
|
|
has_document %}<span class="status-badge done">ausgewertet</span
|
|
>{% else %}<span class="status-badge open">offen</span>{% endif
|
|
%}
|
|
</span>
|
|
{% if is_closed %}
|
|
<form
|
|
class="delete-form"
|
|
method="post"
|
|
action="/cases/{{ case_id }}/reopen"
|
|
>
|
|
{% call csrf::field(csrf_token) %}
|
|
<button
|
|
type="submit"
|
|
class="delete-btn"
|
|
title="Fall wiedereröffnen"
|
|
aria-label="Fall wiedereröffnen"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 24 24"
|
|
width="18"
|
|
height="18"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
>
|
|
<path
|
|
d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"
|
|
></path>
|
|
<path d="M3 3v5h5"></path>
|
|
</svg>
|
|
</button>
|
|
</form>
|
|
{% else %}
|
|
<form
|
|
class="delete-form"
|
|
method="post"
|
|
action="/cases/{{ case_id }}/close"
|
|
>
|
|
{% call csrf::field(csrf_token) %}
|
|
<button
|
|
type="submit"
|
|
class="delete-btn"
|
|
title="Fall schließen"
|
|
aria-label="Fall schließen"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 24 24"
|
|
width="18"
|
|
height="18"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
>
|
|
<path d="M3 6h18"></path>
|
|
<path
|
|
d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"
|
|
></path>
|
|
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
|
<line x1="10" x2="10" y1="11" y2="17"></line>
|
|
<line x1="14" x2="14" y1="11" y2="17"></line>
|
|
</svg>
|
|
</button>
|
|
</form>
|
|
{% endif %}
|
|
</h1>
|
|
{% match recorded_at_iso %} {% when Some with (iso) %}
|
|
<p class="case-time">
|
|
<time datetime="{{ iso }}">{{ time_hms_utc }}</time>
|
|
</p>
|
|
{% when None %} {% endmatch %} {% if is_admin %}
|
|
<div class="meta admin-only">{{ case_id }}</div>
|
|
{% endif %}
|
|
|
|
<div class="actions">
|
|
{% if can_analyze && !has_document %}
|
|
<form method="post" action="/cases/{{ case_id }}/analyze">
|
|
{% call csrf::field(csrf_token) %}
|
|
<input type="hidden" name="return_to" value="/cases/{{ case_id }}">
|
|
<button type="submit">Analysieren</button>
|
|
</form>
|
|
{% if is_admin %}
|
|
<form class="admin-only" method="post" action="/cases/{{ case_id }}/analyze">
|
|
{% call csrf::field(csrf_token) %}
|
|
<input type="hidden" name="return_to" value="/cases/{{ case_id }}">
|
|
{% for b in backends %}
|
|
<button type="submit" name="backend" value="{{ b.id }}"
|
|
title="Analysieren mit {{ b.label }}">{{ b.label }}</button>
|
|
{% endfor %}
|
|
</form>
|
|
{% endif %}
|
|
{% else if llm_missing && !has_document %}
|
|
<span class="llm-missing">Kein LLM-Backend verfügbar — IONOS_API_KEY setzen.</span>
|
|
{% endif %} {% if is_admin %}
|
|
<form
|
|
class="admin-only"
|
|
method="post"
|
|
action="/cases/{{ case_id }}/reset"
|
|
>
|
|
{% call csrf::field(csrf_token) %}
|
|
<input type="hidden" name="return_to" value="/cases/{{ case_id }}">
|
|
<button type="submit">Reset</button>
|
|
</form>
|
|
<button
|
|
type="button"
|
|
id="debug-copy-btn"
|
|
class="admin-only"
|
|
title="Alle Transkripte + Dokument-Markdown in die Zwischenablage"
|
|
>Debug-Copy</button>
|
|
<script type="application/json" id="debug-copy-data">
|
|
{{ debug_copy_json|safe }}
|
|
</script>
|
|
{% endif %}
|
|
</div>
|
|
|
|
{% match document_html %} {% when Some with (html) %}
|
|
<div id="doc-content" class="doc-content">
|
|
{% if can_analyze %}
|
|
<div class="doc-actions-top">
|
|
<form method="post" action="/cases/{{ case_id }}/analyze">
|
|
{% call csrf::field(csrf_token) %}
|
|
<input type="hidden" name="return_to" value="/cases/{{ case_id }}">
|
|
<button
|
|
type="submit"
|
|
class="copy-btn"
|
|
title="Neu analysieren"
|
|
aria-label="Neu analysieren"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 24 24"
|
|
width="18"
|
|
height="18"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
>
|
|
<polyline points="23 4 23 10 17 10"></polyline>
|
|
<polyline points="1 20 1 14 7 14"></polyline>
|
|
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10"></path>
|
|
<path d="M20.49 15a9 9 0 0 1-14.85 3.36L1 14"></path>
|
|
</svg>
|
|
</button>
|
|
</form>
|
|
{% if is_admin %}
|
|
<form class="admin-only" method="post" action="/cases/{{ case_id }}/analyze">
|
|
{% call csrf::field(csrf_token) %}
|
|
<input type="hidden" name="return_to" value="/cases/{{ case_id }}">
|
|
{% for b in backends %}
|
|
<button
|
|
type="submit"
|
|
name="backend"
|
|
value="{{ b.id }}"
|
|
title="Neu analysieren mit {{ b.label }}"
|
|
>{{ b.label }}</button>
|
|
{% endfor %}
|
|
</form>
|
|
{% endif %}
|
|
</div>
|
|
{% endif %}
|
|
<div class="doc-actions">
|
|
<button
|
|
id="copy-btn"
|
|
type="button"
|
|
class="copy-btn"
|
|
title="In Zwischenablage kopieren"
|
|
hidden
|
|
>
|
|
<svg
|
|
class="icon-copy"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 24 24"
|
|
width="18"
|
|
height="18"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
>
|
|
<rect
|
|
x="9"
|
|
y="9"
|
|
width="13"
|
|
height="13"
|
|
rx="2"
|
|
ry="2"
|
|
></rect>
|
|
<path
|
|
d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"
|
|
></path>
|
|
</svg>
|
|
<svg
|
|
class="icon-check"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 24 24"
|
|
width="18"
|
|
height="18"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
>
|
|
<polyline points="20 6 9 17 4 12"></polyline>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div id="doc-body">{{ html|safe }}</div>
|
|
</div>
|
|
<script>
|
|
(() => {
|
|
const btn = document.getElementById("copy-btn");
|
|
if (!btn) return;
|
|
btn.hidden = false;
|
|
btn.addEventListener("click", async () => {
|
|
const text = document.getElementById("doc-body").innerText;
|
|
let ok = false;
|
|
try {
|
|
if (navigator.clipboard && window.isSecureContext) {
|
|
await navigator.clipboard.writeText(text);
|
|
ok = true;
|
|
}
|
|
} catch (_) {
|
|
/* fall through */
|
|
}
|
|
if (!ok) {
|
|
const ta = document.createElement("textarea");
|
|
ta.value = text;
|
|
ta.setAttribute("readonly", "");
|
|
ta.style.position = "fixed";
|
|
ta.style.top = "-1000px";
|
|
document.body.appendChild(ta);
|
|
ta.select();
|
|
try {
|
|
ok = document.execCommand("copy");
|
|
} catch (_) {}
|
|
ta.remove();
|
|
}
|
|
if (ok) {
|
|
btn.classList.add("copied");
|
|
setTimeout(() => btn.classList.remove("copied"), 1500);
|
|
}
|
|
});
|
|
})();
|
|
</script>
|
|
{% when None %} {% match analysis_failed %} {% when Some with (failure) %}
|
|
<div class="failure-banner" role="alert">
|
|
<strong>Analyse fehlgeschlagen.</strong>
|
|
<span class="failure-time"
|
|
>Zuletzt versucht:
|
|
<time datetime="{{ failure.failed_at }}"
|
|
>{{ failure.failed_at }}</time
|
|
></span
|
|
>
|
|
<details>
|
|
<summary>Technische Details</summary>
|
|
<dl class="failure-details">
|
|
{% if !failure.backend_label.is_empty() %}
|
|
<dt>Backend</dt>
|
|
<dd>{{ failure.backend_label }}</dd>
|
|
{% endif %}
|
|
{% if !failure.model_id.is_empty() %}
|
|
<dt>Modell</dt>
|
|
<dd><code>{{ failure.model_id }}</code></dd>
|
|
{% endif %}
|
|
{% if !failure.finish_reason.is_empty() %}
|
|
<dt>finish_reason</dt>
|
|
<dd><code>{{ failure.finish_reason }}</code></dd>
|
|
{% endif %}
|
|
{% match failure.completion_tokens %}{% when Some with (ct) %}
|
|
<dt>Completion-Tokens</dt>
|
|
<dd>
|
|
{{ ct }}{% match failure.max_completion_tokens %}{% when Some with (cap) %} / {{ cap }}{% when None %}{% endmatch %}
|
|
</dd>
|
|
{% when None %}{% endmatch %}
|
|
{% match failure.reasoning_tokens %}{% when Some with (rt) %}
|
|
<dt>davon Reasoning</dt>
|
|
<dd>{{ rt }}</dd>
|
|
{% when None %}{% endmatch %}
|
|
{% match failure.prompt_tokens %}{% when Some with (pt) %}
|
|
<dt>Prompt-Tokens</dt>
|
|
<dd>{{ pt }}</dd>
|
|
{% when None %}{% endmatch %}
|
|
</dl>
|
|
<pre class="failure-reason">{{ failure.reason }}</pre>
|
|
</details>
|
|
<form
|
|
method="post"
|
|
action="/cases/{{ case_id }}/analyze"
|
|
>
|
|
{% call csrf::field(csrf_token) %}
|
|
<input
|
|
type="hidden"
|
|
name="return_to"
|
|
value="/cases/{{ case_id }}"
|
|
/>
|
|
<button type="submit">Erneut versuchen</button>
|
|
</form>
|
|
{% if is_admin %}
|
|
<form
|
|
class="admin-only"
|
|
method="post"
|
|
action="/cases/{{ case_id }}/analyze"
|
|
>
|
|
{% call csrf::field(csrf_token) %}
|
|
<input
|
|
type="hidden"
|
|
name="return_to"
|
|
value="/cases/{{ case_id }}"
|
|
/>
|
|
{% for b in backends %}
|
|
<button type="submit" name="backend" value="{{ b.id }}">
|
|
Erneut mit {{ b.label }}
|
|
</button>
|
|
{% endfor %}
|
|
</form>
|
|
{% endif %}
|
|
</div>
|
|
{% when None %} {% if analyzing %}
|
|
<div class="placeholder">Wird analysiert …</div>
|
|
{% else if recordings_count == 0 %}
|
|
<div class="placeholder">Noch keine Aufnahmen vorhanden.</div>
|
|
{% else %}
|
|
<div class="status-panel">
|
|
{{ recordings_count }} Aufnahme{% if recordings_count != 1 %}n{%
|
|
endif %}, davon {{ transcribed_count }} transkribiert.
|
|
</div>
|
|
{% endif %} {% endmatch %} {% endmatch %}
|
|
|
|
<div>
|
|
<a
|
|
class="recordings-link"
|
|
href="/cases/{{ case_id }}/recordings"
|
|
>→ Aufnahmen anzeigen ({{ recordings_count }})</a
|
|
>
|
|
</div>
|
|
<script>
|
|
{% include "partials/time_format.js" %}
|
|
</script>
|
|
<script>
|
|
// Format the case timestamp as "Heute HH:MM" / "Gestern HH:MM" / "DD.MM.YYYY HH:MM"
|
|
// in the browser's local timezone and locale. Server-rendered text is only a
|
|
// no-JS fallback (UTC HH:MM).
|
|
(() => {
|
|
const el = document.querySelector(".case-time time[datetime]");
|
|
if (!el) return;
|
|
const d = new Date(el.dateTime);
|
|
if (isNaN(d.getTime())) return;
|
|
el.textContent =
|
|
TimeFmt.dateLabel(d) + " - " + TimeFmt.timeLabel(d);
|
|
})();
|
|
|
|
// Same locale treatment for the failure-banner timestamp.
|
|
// No-JS fallback is the raw RFC3339 UTC string the server
|
|
// rendered into the <time> element.
|
|
(() => {
|
|
const el = document.querySelector(
|
|
".failure-banner time[datetime]",
|
|
);
|
|
if (!el) return;
|
|
const d = new Date(el.dateTime);
|
|
if (isNaN(d.getTime())) return;
|
|
el.textContent =
|
|
TimeFmt.dateLabel(d) + " " + TimeFmt.timeLabel(d);
|
|
})();
|
|
|
|
(() => {
|
|
const cb = document.getElementById("admin-view-toggle");
|
|
if (!cb) return;
|
|
cb.checked =
|
|
!document.documentElement.classList.contains(
|
|
"admin-view-off",
|
|
);
|
|
cb.addEventListener("change", () => {
|
|
const on = cb.checked;
|
|
try {
|
|
localStorage.setItem("admin-view", on ? "on" : "off");
|
|
} catch (_) {}
|
|
document.documentElement.classList.toggle(
|
|
"admin-view-off",
|
|
!on,
|
|
);
|
|
});
|
|
})();
|
|
|
|
// Live-update bus: only events for THIS case trigger a debounced reload.
|
|
(() => {
|
|
const MY_CASE_ID = "{{ case_id }}";
|
|
let timer = null;
|
|
const scheduleReload = () => {
|
|
clearTimeout(timer);
|
|
timer = setTimeout(() => location.reload(), 300);
|
|
};
|
|
const es = new EventSource("/events");
|
|
// Release the socket-pool slot on navigation. Without this, Chrome
|
|
// keeps the SSE connection "draining" after unload; six rapid nav
|
|
// cycles exhaust the 6-per-origin pool and stall further requests.
|
|
window.addEventListener("pagehide", () => es.close());
|
|
es.addEventListener("case", (msg) => {
|
|
let evt;
|
|
try {
|
|
evt = JSON.parse(msg.data);
|
|
} catch (_) {
|
|
return;
|
|
}
|
|
if (evt.case_id === MY_CASE_ID) scheduleReload();
|
|
});
|
|
})();
|
|
</script>
|
|
<script>
|
|
{% include "partials/oneliner_edit.js" %}
|
|
</script>
|
|
{% if is_admin %}
|
|
<script>
|
|
(() => {
|
|
const btn = document.getElementById("debug-copy-btn");
|
|
const data = document.getElementById("debug-copy-data");
|
|
if (!btn || !data) return;
|
|
let payload;
|
|
try {
|
|
payload = JSON.parse(data.textContent);
|
|
} catch (_) {
|
|
return;
|
|
}
|
|
btn.addEventListener("click", async () => {
|
|
const parts = [...(payload.transcripts || [])];
|
|
if (payload.document) parts.push(payload.document);
|
|
const text = parts.join("\n\n---\n\n");
|
|
let ok = false;
|
|
try {
|
|
if (navigator.clipboard && window.isSecureContext) {
|
|
await navigator.clipboard.writeText(text);
|
|
ok = true;
|
|
}
|
|
} catch (_) {}
|
|
if (!ok) {
|
|
const ta = document.createElement("textarea");
|
|
ta.value = text;
|
|
ta.setAttribute("readonly", "");
|
|
ta.style.position = "fixed";
|
|
ta.style.top = "-1000px";
|
|
document.body.appendChild(ta);
|
|
ta.select();
|
|
try { ok = document.execCommand("copy"); } catch (_) {}
|
|
ta.remove();
|
|
}
|
|
if (ok) {
|
|
const original = btn.textContent;
|
|
btn.textContent = "Kopiert!";
|
|
setTimeout(() => { btn.textContent = original; }, 1500);
|
|
}
|
|
});
|
|
})();
|
|
</script>
|
|
{% endif %}
|
|
</body>
|
|
</html>
|