/* Tenant-Detailseite: eine Navigation (links) · Formular (Mitte) · Vorschau/Agent (rechts) */
let S = null;
let SCHEMA = null;
const ui = {sec: 'overview', mode: 'edit', open: true, pvKind: 'web', pvSrc: 'draft', pvDev: '', agentSrc: null};
const SECTIONS = [
['overview', 'Übersicht', null, false],
['brand', 'Marke & Farben', 'Gestaltung', true], ['texts', 'Absender & PDF-Texte', 'Gestaltung', true], ['pdf', 'PDF-Layout', 'Gestaltung', true],
['prompt', 'Prompt', 'Inhalt', true], ['files', 'Dateien', 'Inhalt', true],
['func', 'Funktion & STT', 'Verhalten', true], ['code', 'Hook-Code', 'Verhalten', true], ['adv', 'tenant.json', 'Verhalten', true],
['domain', 'Domain & Login', 'Betrieb', false], ['secrets', 'Secrets', 'Betrieb', false], ['deploy', 'Deploy', 'Betrieb', false], ['versions', 'Verlauf', 'Betrieb', false],
];
const A = p => `/api/tenants/${TID}${p}`;
const $ = s => document.querySelector(s);
const getPath = (o, p) => p.split('.').reduce((a, k) => (a == null ? a : a[k]), o);
function setPath(o, p, v){ const ks = p.split('.'); let c = o; ks.slice(0,-1).forEach(k => c = (c[k] ??= {})); c[ks.at(-1)] = v; }
function unsetPath(o, p){ const ks = p.split('.'); const chain = [o]; let c = o; for(const k of ks.slice(0,-1)){ c = c?.[k]; if(c == null) return; chain.push(c); } delete c[ks.at(-1)]; for(let i = ks.length - 2; i >= 0; i--){ if(Object.keys(chain[i+1]).length === 0) delete chain[i][ks[i]]; else break; } }
function debounce(fn, ms){ let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }
function shade(hex, f){ const n=parseInt(hex.slice(1),16); const c=[n>>16,(n>>8)&255,n&255].map(x=>Math.max(0,Math.min(255,Math.round(x*f)))); return '#'+c.map(x=>x.toString(16).padStart(2,'0')).join('').toUpperCase(); }
function tint(hex, a){ const n=parseInt(hex.slice(1),16); const c=[n>>16,(n>>8)&255,n&255].map(x=>Math.round(x+(255-x)*a)); return '#'+c.map(x=>x.toString(16).padStart(2,'0')).join('').toUpperCase(); }
const copyText = t => navigator.clipboard.writeText(t).then(()=>toast('Kopiert'));
const secDef = k => SECTIONS.find(s => s[0] === k);
const kindLabel = {'dry-run':'Dry-Run','deploy':'Deploy','offboard-dry':'Offboard (Dry-Run)','offboard':'Offboard'};
/* ───────── Laden & Rahmen ───────── */
async function loadSchema(){ try{ const c = await api('GET', '/api/contract'); SCHEMA = c.schema; }catch(e){ SCHEMA = null; } }
async function load(keep){
S = await api('GET', A('/state'));
renderHead(); renderNav();
if(!keep) route();
}
function route(){
const h = (location.hash||'').slice(1);
if(secDef(h)) ui.sec = h;
renderSection();
}
window.addEventListener('hashchange', route);
function renderHead(){
$('#h-name').textContent = S.name;
const st = S.status !== 'active' ? `${S.status} ` : '';
$('#h-badges').innerHTML = `${S.has_draft?'Entwurf ':''}${st}v${S.current_version}
deployt v${S.deployed_version??'–'}
Quelle: ${S.source==='admin'?'Admin':'Git'} `;
$('#h-actions').innerHTML = S.has_draft
? `Verwerfen Prüfen & veröffentlichen ` : '';
document.querySelectorAll('#h-panel button').forEach(b => b.classList.toggle('on', b.dataset.p === ui.mode));
}
function renderNav(extra){
let g = null, out = '';
const nfiles = Object.keys(S.files).length;
const badges = {files: nfiles ? `${nfiles} ` : '', versions: `${S.current_version} `,
secrets: extra?.missing ? ` ` : '', code: S.files['overrides.py'] ? ' ' : '',
deploy: S.deployed_version !== S.current_version ? ' ' : ''};
for(const [k, l, grp] of SECTIONS){
if(grp !== g){ g = grp; if(grp) out += `
${grp} `; }
out += `${l}${badges[k]||''} `;
}
$('#snav').innerHTML = out;
}
function gotoSec(k){ ui.mode = 'edit'; if(location.hash === '#'+k) route(); else location.hash = k; }
/* Layout: Mitte = Formular ODER Agent-Chat; die Live-Vorschau rechts bleibt in beiden Fällen sichtbar (Betriebsbereiche ohne Vorschau). */
function layout(){
ui.open = secDef(ui.sec)[3] || ui.mode === 'agent';
$('#tl').classList.toggle('open', ui.open);
$('#pane').classList.toggle('hide', ui.mode === 'agent');
$('#ag-wrap').classList.toggle('hide', ui.mode !== 'agent');
document.querySelectorAll('#h-panel button').forEach(b => b.classList.toggle('on', b.dataset.p === ui.mode));
if(ui.open) refreshPreview();
}
$('#h-panel').addEventListener('click', e => { const b = e.target.closest('button'); if(!b) return; ui.mode = b.dataset.p; renderNav(); layout(); if(ui.mode === 'agent') $('#chat-text').focus(); });
function renderSection(){
renderNav(); layout();
({overview: sOverview, brand: sBrand, texts: sTexts, pdf: sPdf, prompt: sPrompt, files: sFiles, func: sFunc, code: sCode, adv: sAdv,
domain: sDomain, secrets: sSecrets, deploy: sDeploy, versions: sVersions})[ui.sec]();
}
/* ───────── Speichern (Entwurf) ───────── */
const saveState = txt => { const e = $('#save-ind'); if(e) e.textContent = txt; };
function afterSave(r){
S.has_draft = r.has_draft; renderHead(); saveState(r.has_draft ? 'Entwurf gespeichert' : 'Entspricht der veröffentlichten Version');
refreshPreviewSoon();
}
const saveConfigNow = async () => { try { saveState('Speichere …'); afterSave(await api('PUT', A('/config'), S.config)); } catch(e){ toast(e.message,1); saveState('Fehler'); } };
const saveConfig = debounce(saveConfigNow, 500);
async function saveFileNow(path, content){ try { saveState('Speichere …'); afterSave(await api('PUT', A('/files/'+path), {content})); } catch(e){ toast(e.message,1); saveState(e.message); } }
const saveFile = debounce(saveFileNow, 600);
async function deleteFile(path){ afterSave(await api('DELETE', A('/files/'+path))); await load(true); renderSection(); }
async function uploadFile(input, path){
const f = input.files[0]; if(!f) return;
const fd = new FormData(); fd.append('file', f);
const r = await fetch(A('/upload?path='+encodeURIComponent(path||f.name)), {method:'POST', body: fd});
if(!r.ok){ toast((await r.json()).detail||'Upload fehlgeschlagen',1); return; }
await load(true); renderSection(); refreshPreviewSoon(); toast('Hochgeladen'); headLogo(); renderHead();
}
function headLogo(){ const n = S.files['logo.svg']?'logo.svg':S.files['logo.png']?'logo.png':null; $('#h-logo').innerHTML = n?` `:''; }
async function discardDraft(){
if(!confirm('Alle Änderungen im Entwurf verwerfen?')) return;
await api('POST', A('/draft/discard')); await load(true); renderSection(); headLogo(); refreshPreview(true); toast('Entwurf verworfen');
}
const saveInd = () => `${S.has_draft?'Entwurf vorhanden':'Entspricht der veröffentlichten Version'} `;
const title = (t, sub) => `
${t}${sub?` ${sub} `:''} ${saveInd()}`;
/* ───────── Live-Vorschau ───────── */
let pvSeq = 0;
async function refreshPreview(force){
if(!ui.open) return;
const seq = ++pvSeq, kind = ui.pvKind;
$('#f-web').style.display = kind==='web'?'':'none'; $('#f-pdf').style.display = kind==='pdf'?'':'none';
$('#pv-frame').className = 'pv-frame ' + (kind==='web'?ui.pvDev:'');
$('#pv-dev').style.display = kind==='web'?'':'none';
$('#pv-load').classList.add('on'); $('#pv-err').classList.remove('on');
const t0 = performance.now();
try {
const src = ui.pvSrc === 'agent' ? (ui.agentSrc || 'draft') : ui.pvSrc;
const r = await fetch(`/preview/${TID}/${kind}?source=${encodeURIComponent(src)}&_=${force?Date.now():''}`);
if(seq !== pvSeq) return;
if(!r.ok){ $('#pv-err').textContent = await r.text(); $('#pv-err').classList.add('on'); }
else if(kind==='web'){ $('#f-web').srcdoc = await r.text(); }
else { const b = await r.blob(); $('#f-pdf').src = URL.createObjectURL(b) + '#toolbar=0&navpanes=0'; }
$('#pv-info').textContent = `Core ${S.core_version} · ${Math.round(performance.now()-t0)} ms`;
} catch(e){ $('#pv-err').textContent = String(e); $('#pv-err').classList.add('on'); }
$('#pv-load').classList.remove('on');
}
const refreshPreviewSoon = debounce(() => refreshPreview(), 900);
const refreshPreviewLive = debounce(() => refreshPreview(), 250);
function setPvSrc(src){
ui.pvSrc = src; $('#pv-agent').classList.toggle('hide', !ui.agentSrc);
document.querySelectorAll('#pv-src button').forEach(x => x.classList.toggle('on', x.dataset.s === src));
}
document.addEventListener('click', e => {
const b = e.target.closest('#pv-kind button, #pv-dev button, #pv-src button'); if(!b) return;
const g = b.parentElement.id;
b.parentElement.querySelectorAll('button').forEach(x => x.classList.toggle('on', x===b));
if(g==='pv-kind') ui.pvKind = b.dataset.k; if(g==='pv-dev') ui.pvDev = b.dataset.d; if(g==='pv-src') ui.pvSrc = b.dataset.s;
refreshPreview();
});
/* ───────── Übersicht ───────── */
async function sOverview(){
const c = S.config, col = c.colors||{};
$('#pane').innerHTML = `
Status
URL ${esc(S.host)} ${S.domain_status==='verified'?'Domain ok':S.domain_status==='pending'?'DNS offen':'nicht eingerichtet'}
Version v${S.current_version} veröffentlicht · v${S.deployed_version??'–'} deployt ${S.deployed_version!==S.current_version?'veraltet ':''}
Quelle ${S.source==='admin'?'Admin (Bundle zur Laufzeit)':'Git – ins Image gebacken'}
Core :${esc(String(S.core.tag).slice(0,12))} ${S.core.pin?'gepinnt ':'folgt Rollouts '} · Admin-Vorschau: ${esc(S.core.sha)}
Läuft?
STT ${esc((c.stt||{}).provider||'mistral')} ${esc((c.stt||{}).model||'')}
Dateien ${Object.keys(S.files).map(f=>`${esc(f)} `).join(' ')||'–'}
Marke ${Object.entries(col).map(([k,v])=>` `).join('')}
Cut-over auf die Admin-Plattform
Bis alles grün ist, bleibt der Tenant auf Git . Umschalten = TENANT_SOURCE=admin beim nächsten Deploy.
Gefahrenbereich
Offboard entfernt Service, Volume und Ingress aus dem Mittwald-Stack (Dry-Run zeigt vorher, was passiert). Löschen entfernt nur die Daten in dieser Datenbank – und nur für archivierte Tenants.
Offboard-Dry-Run Offboard (live)
OIDC-Client im Login löschen … Tenant in der DB löschen …
`;
const [cut, mode, sec] = await Promise.all([api('GET', A('/cutover')), api('GET', '/api/deploy/mode'), api('GET', A('/secrets'))]);
renderNav({missing: sec.some(s => s.required && !s.set)});
$('#b-off')?.classList.toggle('disabled', !mode.live);
const ok = cut.checks.every(x=>x.ok);
$('#cut').innerHTML = cut.checks.map(x=>`${x.ok?'✓':'!'} ${esc(x.label)}
`).join('') +
`${cut.source==='admin'
? `Quelle: Admin Zurück auf Git `
: `Auf Admin-Quelle umstellen ${ok?'':'noch nicht alle Bedingungen erfüllt '}`}
`;
}
async function savePin(){ try{ await api('PUT', A('/core-pin'), {pin: $('#m-pin').value}); await load(true); toast('Core-Pin gespeichert – wirksam beim nächsten Deploy'); sOverview(); }catch(e){ toast(e.message,1) } }
async function probeMe(){
$('#run-state').innerHTML = 'prüfe … ';
try{ const r = (await api('GET', '/api/status?refresh=1'))[TID]; $('#run-state').innerHTML = !r ? '' : r.reachable && !r.drift.length ? `läuft: Core ${esc((r.core?.version||'?').slice(0,10))}${r.bundle?.source==='admin'?' · Bundle v'+r.bundle.version:''} ` : `${esc(r.drift.join(' · ')||'nicht erreichbar')} `; }
catch(e){ $('#run-state').innerHTML = `${esc(e.message)} `; }
}
async function saveMeta(){ try{ await api('PUT', A('/meta'), {cpu:$('#m-cpu').value, memory:$('#m-mem').value, status:$('#m-status').value, notes:$('#m-notes').value}); await load(true); toast('Gespeichert'); sOverview(); }catch(e){ toast(e.message,1) } }
async function setSource(s){ await api('PUT', A('/source'), {source:s}); await load(true); sOverview(); toast('TENANT_SOURCE='+s+' – wirksam beim nächsten Deploy'); }
async function runValidate(){ $('#val').innerHTML='Validiere mit dem echten Core … '; $('#val').innerHTML = valHtml(await api('POST', A('/validate?source=current'))); }
const valHtml = r => `${r.ok?'✔ Validierung bestanden':'✘ Fehler'}${(r.errors||[]).map(e=>`
${esc(e)}
`).join('')}${(r.warnings||[]).map(e=>`
⚠ ${esc(e)}
`).join('')}
`;
async function offboard(dry){
if(!dry && !confirm('Service, Volume und Ingress dieses Tenants wirklich vom Mittwald-Stack entfernen?')) return;
try{ const r = await api('POST', A('/offboard'), {dry_run:dry}); followDeploy(r.id, $('#con-off')); }catch(e){ toast(e.message,1) }
}
async function deleteOidcApp(){
const id = prompt('OIDC-Client dieses Tenants im Login-System LÖSCHEN (nicht umkehrbar, beendet alle Logins der App). Zur Bestätigung die Tenant-ID eingeben:'); if(id===null) return;
try{ await api('DELETE', A(`/oidc/app?confirm=${encodeURIComponent(id)}`)); toast('OIDC-Client gelöscht'); await load(true); sOverview(); }catch(e){ toast(e.message,1) }
}
async function deleteTenant(){
const id = prompt('Alle Daten dieses Tenants in der Datenbank löschen (Versionen, Secrets, Chat). Zur Bestätigung die ID eingeben:'); if(id===null) return;
try{ await api('DELETE', `/api/tenants/${TID}?confirm=${encodeURIComponent(id)}`); location.href='/'; }catch(e){ toast(e.message,1) }
}
/* ───────── Formular-Helfer ───────── */
function bindCfg(root){
root.querySelectorAll('[data-cfg]').forEach(el => {
el.addEventListener('input', () => {
const p = el.dataset.cfg;
const v = el.type==='checkbox' ? el.checked : el.type==='number' ? (el.value === '' ? '' : Number(el.value)) : el.value;
if(v === '' && !['name'].includes(p) && !p.startsWith('office.') && !p.startsWith('colors.')) unsetPath(S.config, p); else setPath(S.config, p, v);
if(el.type==='color'){ const t = root.querySelector(`[data-cfg="${p}"][type=text]`); if(t) t.value = v; }
if(el.type==='text' && el.dataset.color){ const c = root.querySelector(`[data-cfg="${p}"][type=color]`); if(c && /^#[0-9a-f]{6}$/i.test(v)) c.value = v; }
saveConfig();
});
});
}
const fld = (label, path, o={}) => `${label} `;
/* ───────── Gestaltung ───────── */
function sBrand(){
const col = S.config.colors||{};
const logo = S.files['logo.svg']?'logo.svg':S.files['logo.png']?'logo.png':null;
$('#pane').innerHTML = title('Marke & Farben') + `
Name & Titel ${fld('Name','name')}${fld('Seitentitel (Browser-Tab)','title')}${fld('Beschreibung','description',{full:1})}
Logo & Favicon
${logo?`
`:'
kein Logo '}
Logo (SVG/PNG)
Word-Export braucht zusätzlich logo.png .
${S.files['favicon.svg']?`
`:'
– '}
Favicon (SVG)
Farben Töne aus Akzent ableiten
`;
bindCfg($('#pane'));
}
function deriveFromAccent(){
const a = (S.config.colors||{}).accent; if(!/^#[0-9a-f]{6}$/i.test(a)) return toast('Akzent muss #RRGGBB sein',1);
Object.assign(S.config.colors, {accent_dark:shade(a,.72), accent_mid:shade(a,1.12), accent_light:tint(a,.88), accent_xlight:tint(a,.94)});
saveConfigNow(); sBrand();
}
function sTexts(){
$('#pane').innerHTML = title('Absender & PDF-Texte') + `
Absender / Kopf- & Fußzeile Erscheint im PDF und im Word-Export.
${fld('Name','office.name')}${fld('Zusatz (Rechtsform)','office.subtitle')}${fld('Adresse','office.address',{full:1})}${fld('Telefon','office.tel')}${fld('E-Mail','office.email')}${fld('Website','office.website',{full:1})}
PDF-Beschriftungen ${fld('Bezeichnung „Projekt“ (z. B. „Thema“)','pdf_projekt_label',{ph:'Projekt'})}${fld('Fußzeilen-Hinweis','pdf_footer_note',{ph:'Standard des Cores'})}
`;
bindCfg($('#pane'));
}
/* Text-Datei-Editor mit „Standard des Cores“ */
async function fileEditor(path, o){
const has = !!S.files[path]; let body = '';
try { const r = await fetch(A('/files/'+path)+'?source=draft'); if(r.ok) body = await r.text(); } catch(e){}
const w = document.createElement('div'); w.className = 'card';
w.innerHTML = `
${esc(o.title)}
${has ? `eigene Datei Auf Core-Standard ` : `Core-Standard Eigene Version anlegen `}
${o.sub?`${o.sub}
`:''}
`;
const ta = w.querySelector('textarea');
ta.addEventListener('input', () => saveFile(path, ta.value));
ta.addEventListener('keydown', e => { if(e.key==='Tab'){ e.preventDefault(); ta.setRangeText(' ', ta.selectionStart, ta.selectionEnd, 'end'); ta.dispatchEvent(new Event('input')); } });
w.querySelector('[data-act=custom]')?.addEventListener('click', async () => { await saveFileNow(path, body); await load(true); renderSection(); });
w.querySelector('[data-act=reset]')?.addEventListener('click', async () => { if(confirm('Eigene Datei löschen und den Core-Standard verwenden?')) await deleteFile(path); });
return w;
}
async function sPdf(){
$('#pane').innerHTML = title('PDF-Layout') + `HTML + CSS (WeasyPrint). Datenfelder bleiben gleich, nur das Aussehen ist frei. Die Vorschau nutzt das Beispielprotokoll unten.
Beispielprotokoll für die Vorschau
Speichern & neu rendern Format wie vom LLM geliefert; gilt nur für die Vorschau.
`;
$('#e1').appendChild(await fileEditor('protocol.html', {title:'protocol.html', sub:'Jinja-Template des PDFs.', h:300}));
$('#e2').appendChild(await fileEditor('protocol.css', {title:'protocol.css', sub:'Farben kommen als Jinja-Variablen aus der Marke.', h:300}));
}
async function saveSample(){ await api('PUT', A('/sample'), {sample: $('#sample').value}); S.sample = $('#sample').value; toast('Beispiel gespeichert'); ui.pvKind='pdf'; document.querySelectorAll('#pv-kind button').forEach(b=>b.classList.toggle('on',b.dataset.k==='pdf')); refreshPreview(true); }
/* ───────── Inhalt ───────── */
async function sPrompt(){
$('#pane').innerHTML = title('Prompt') + `
Selbstbeschreibung Ersetzt @@ORG@@ · @@ORG_SHORT@@ · @@ORG_PROFILE@@ im Standard-Prompt.
${fld('Organisation (voll)','ai_detect_org')}${fld('Kurzname im Protokolltext','org_short')}${fld('Ein Satz: was die Organisation macht','org_profile',{full:1})}
Prompt testen (echter LLM-Lauf) kostet Cortecs-Guthaben
${S.llm_ready ? '' : '
Kein Cortecs-Key hinterlegt – unter Einstellungen → Geteilte Secrets setzen.
'}
Termin-Art (optional) Projektname (optional)
Beispieltranskript
LLM-Lauf mit Entwurfs-Prompt Finalen Prompt anzeigen
`;
bindCfg($('#pane'));
$('#ed').appendChild(await fileEditor('prompt.txt', {title:'prompt.txt', sub:'Ohne eigene Datei gilt der Standard-Prompt des Cores. Hook-Code kann den Prompt zusätzlich erweitern (siehe „Finalen Prompt anzeigen“).', h:420}));
}
async function promptTest(){
$('#pt-go').disabled = true; $('#pt-info').textContent = 'LLM läuft … (bis zu 1–2 Minuten)'; $('#pt-out').innerHTML = '';
try{
const r = await api('POST', A('/prompt-test'), {transcript:$('#pt-tr').value, terminart:$('#pt-art').value, projekt:$('#pt-proj').value});
$('#pt-info').textContent = `${r.model} · ${r.input_tokens}→${r.output_tokens} Tokens · Prompt ${r.prompt_chars} Zeichen${r.truncated?' · ⚠ abgeschnitten (max_tokens)':''}`;
window._ptText = r.text;
$('#pt-out').innerHTML = `${esc(r.text)} Als PDF-Beispiel übernehmen & ansehen
`;
}catch(e){ $('#pt-info').textContent = ''; $('#pt-out').innerHTML = `${esc(e.message)}
`; }
$('#pt-go').disabled = !S.llm_ready;
}
async function usePtAsSample(){ await api('PUT', A('/sample'), {sample: window._ptText}); S.sample = window._ptText; ui.pvKind='pdf'; document.querySelectorAll('#pv-kind button').forEach(b=>b.classList.toggle('on',b.dataset.k==='pdf')); setPvSrc('draft'); refreshPreview(true); toast('Als Beispiel gesetzt'); }
async function showFinalPrompt(){
try{ const r = await api('GET', A('/final-prompt')); $('#pt-out').innerHTML = `${r.chars} Zeichen – so geht der Prompt (nach Platzhaltern und Hooks) an das LLM:
${esc(r.prompt)} `; }
catch(e){ $('#pt-out').innerHTML = `${esc(e.message)}
`; }
}
function sFiles(){
const rows = Object.entries(S.files).map(([p,m]) => `${esc(p)} ${m.personal?'personenbezogen ':''} ${(m.size/1024).toFixed(1)} KB ${m.text?'Text':'Binär'} ${m.text&&!['prompt.txt','protocol.html','protocol.css','overrides.py'].includes(p)?`Bearbeiten `:''}Löschen `).join('');
$('#pane').innerHTML = title('Dateien') + ``;
}
async function openTextFile(p){ const w = await fileEditor(p, {title:p, h:260}); $('#tf').innerHTML=''; $('#tf').appendChild(w); }
/* ───────── Verhalten ───────── */
/* Felder, die der Core im Schema deklariert, aber keine eigene Maske haben, erscheinen automatisch hier (neue Core-Optionen ohne Admin-Änderung). */
const BESPOKE_PATHS = ['name','title','description','pdf_projekt_label','pdf_footer_note','ai_detect_org','org_short','org_profile'];
const isBespoke = p => BESPOKE_PATHS.includes(p) || p.startsWith('colors.') || p.startsWith('office.');
function schemaField(f){
const raw = getPath(S.config, f.path), cur = raw === undefined ? f.default : raw;
const help = f.help ? ` ${esc(f.help)} ` : '';
if(f.type === 'bool') return `${esc(f.label)} ${help} `;
if(f.type === 'enum') return `${esc(f.label)}Standard (${esc(f.default ?? 'Core')}) ${(f.options||[]).map(o=>`${esc(o)} `).join('')} ${f.help?`${esc(f.help)} `:''} `;
if(f.type === 'color') return `${esc(f.label)} `;
if(f.type === 'text') return `${esc(f.label)}${f.help?`${esc(f.help)} `:''} `;
return `${esc(f.label)} ${f.help?`${esc(f.help)} `:''} `;
}
function sFunc(){
if(!SCHEMA) return sFuncLegacy();
const groups = {};
SCHEMA.fields.filter(f => !isBespoke(f.path)).forEach(f => (groups[f.group] ??= []).push(f));
const order = Object.keys(groups);
const known = ['Funktion', 'Spracherkennung'];
const card = (g) => { const fs = groups[g], basic = fs.filter(f=>!f.advanced), adv = fs.filter(f=>f.advanced);
const grid = list => `${list.map(schemaField).join('')}
`;
return `${esc(g)}${known.includes(g)?'':' neu im Core '} ${basic.length?grid(basic):''}${adv.length?`
Erweitert ${grid(adv)}
`:''}
${g==='Spracherkennung'?'
Bei „cortecs“ wird der geteilte CORTECS_API_KEY genutzt – ein eigener Mistral-Key ist nicht nötig.
':''}
`; };
$('#pane').innerHTML = title('Funktion & STT') + `Aus dem Schema des Cores (Version ${esc(S.core?.sha||'')}) erzeugt – neue Optionen erscheinen hier automatisch.
` + order.map(card).join('');
bindCfg($('#pane'));
}
function sFuncLegacy(){
const stt = S.config.stt||{};
const chk = (path, label, hint, def) => { const v = getPath(S.config,path); return `${label} ${hint} `; };
$('#pane').innerHTML = title('Funktion & STT') + `Dieser Core liefert kein Schema (Vertrag) – feste Maske.
Funktionen (Flags)
${chk('flags.ui_kontakt_field','Feld „Ansprechpartner Kunde“','Aus: Feld in der Web-UI ausgeblendet (z. B. Vereine).',true)}
${chk('flags.multi_audio','Mehrere Audiodateien (bis 5)','Aus: nur eine Datei pro Protokoll.',true)}
Spracherkennung (STT)
ProviderStandard (mistral) cortecs mistral
${fld('Modell','stt.model',{ph:'voxtral-mini-transcribe-2602'})}
`;
bindCfg($('#pane'));
}
async function sCode(){
const has = !!S.files['overrides.py'];
$('#pane').innerHTML = title('Hook-Code') + `Code läuft im Tenant-Container mit dessen Secrets. In der Vorschau läuft er nur in einer Sandbox ohne Secrets (CPU-/Datei-Limits); Veröffentlichen verlangt eine ausdrückliche Freigabe. Erst Flags/Dateien nutzen; Wiederkehrendes gehört in den Core.
Verfügbare Hooks build_prompt Prompt vor dem LLM-Aufruf anpassen transform_transcript Transkript nachbearbeiten pdf_context Daten fürs PDF-Template ändern extra_routes zusätzliche FastAPI-Routen
`;
if(has) $('#ed').appendChild(await fileEditor('overrides.py', {title:'overrides.py', sub:'Syntax wird beim Speichern geprüft.', h:460}));
else $('#ed').innerHTML = `Dieser Tenant nutzt keinen Hook-Code.overrides.py anlegen
`;
}
async function createHook(){ await saveFileNow('overrides.py', `"""Tenant-Hooks für ${S.id}."""\nfrom core.hooks import TenantHooks\n\n\nclass Hooks(TenantHooks):\n def build_prompt(self, base_prompt: str, ctx: dict) -> str:\n return base_prompt\n`); await load(true); renderSection(); }
function sAdv(){
$('#pane').innerHTML = title('tenant.json') + `Alle Felder, auch solche ohne Formular. Gültiges JSON wird automatisch übernommen. Der deploy -Block ist nicht Teil des Bundles (Domain/Ressourcen verwaltet die Plattform).
`;
$('#rawjson').addEventListener('input', e => { try { S.config = JSON.parse(e.target.value); $('#rawerr').textContent=''; saveConfig(); } catch(x){ $('#rawerr').textContent = 'Ungültiges JSON: '+x.message; } });
}
/* ───────── Veröffentlichen ───────── */
async function openPublish(){
modal('m-pub', true); $('#pub-body').innerHTML = 'Validiere (echter Core) und berechne Änderungen …
'; $('#pub-go').disabled = true;
const [val, diff] = await Promise.all([api('POST', A('/validate?source=draft')), api('GET', A('/diff?a=current&b=draft'))]);
const code = diff.items.some(i => i.path==='overrides.py');
$('#pub-body').innerHTML = valHtml(val) + `${diff.items.length} geänderte Datei(en) ` + diff.items.map(diffHtml).join('') +
(code ? `Freigabe Hook-Code: Ich habe die Änderungen an overrides.py geprüft. ` : '') +
`Commit-Nachricht `;
const upd = () => { $('#pub-go').disabled = !val.ok || (code && !$('#codeok').checked); };
$('#codeok')?.addEventListener('change', upd); upd();
}
const diffHtml = i => `${esc(i.path)} ${i.kind} ${i.added!=null?`+${i.added} −${i.removed} `:''}
${i.diff?`
${i.diff.map(l=>`
${esc(l)||' '}
`).join('')}
`:'
Binärdatei
'}
`;
async function publish(){
try { const r = await api('POST', A('/publish'), {message: $('#pubmsg').value}); modal('m-pub', false); await load(true); renderSection(); toast('Version v'+r.version+' veröffentlicht'); }
catch(e){ toast(e.message, 1); }
}
/* ───────── Agent ───────── */
const CHIPS = ['Setze die Akzentfarbe auf #0B6E4F','Füge dem Prompt hinzu: Nenne Beträge immer in Euro mit Tausenderpunkt.','Lege ein eigenes PDF-CSS an und mache die Überschriften größer.','Prüfe den Tenant auf Probleme.'];
async function initAgent(){
$('#ag-chips').innerHTML = CHIPS.map(s=>`${esc(s)} `).join('');
$('#chat-text').addEventListener('keydown', e => { if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); sendChat(); } });
const st = await api('GET', '/api/llm/status?tenant=' + TID);
const src = {shared:'geteiltes Secret', env:'Env/.env', tenant:'Key des Tenants'}[st.source];
$('#ag-mode').textContent = st.configured ? `${st.model} · ${st.api}` : 'Demo-Modus – kein Key gefunden';
$('#ag-mode').className = 'badge ' + (st.configured ? 'cyan' : 'warn');
$('#ag-mode').title = st.configured ? 'Cortecs-Key aus: ' + src : (st.error || 'Key unter Einstellungen → Geteilte Secrets setzen oder Env CORTECS_API_KEY');
if(st.error) $('#ag-usage').textContent = '⚠ ' + st.error;
const h = await api('GET', A('/chat')); const box = $('#msgs');
if(!h.length) box.innerHTML = 'Der Agent arbeitet auf einem Entwurf und schlägt Änderungen nur vor – du gibst sie frei.
';
else h.forEach(m => box.appendChild(msgEl(m))); box.scrollTop = 1e9;
}
function stepsHtml(steps){ return (steps||[]).map(s=>`${esc(s.tool)} ${esc(s.arg)} → ${esc(s.result)}
`).join(''); }
function propHtml(m){
if(!m.proposal) return '';
const items = (m.proposal.items||[]).map(i=>`${esc(i.path)} `).join(' ');
return `Vorschlag: ${esc(m.proposal.summary)}
${items}
${m.proposal_status==='open'
? `Diff ansehen In Entwurf übernehmen Verwerfen `
: `${m.proposal_status==='applied'?'übernommen – prüfen & veröffentlichen':'verworfen'} `}
`;
}
function msgEl(m){
const d = document.createElement('div'); d.className = 'bub '+m.role; d.id = 'm'+(m.id||'');
const u = m.usage && m.usage.in ? `${esc(m.usage.model||'')} · ${m.usage.in}→${m.usage.out} Tokens
` : '';
d.innerHTML = (m.steps&&m.steps.length?`${stepsHtml(m.steps)}
`:'') + esc(m.content) + propHtml(m) + u; return d;
}
async function sendChat(text){
const ta = $('#chat-text'); text = text || ta.value.trim(); if(!text) return; ta.value = '';
ui.mode = 'agent'; layout();
const box = $('#msgs'); box.querySelector('.empty')?.remove();
box.appendChild(msgEl({role:'user', content:text}));
const live = document.createElement('div'); live.className = 'bub assistant'; live.innerHTML = '
Agent arbeitet …
'; box.appendChild(live); box.scrollTop = 1e9;
$('#chat-send').disabled = true;
try {
const r = await fetch(A('/chat'), {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({message:text})});
if(!r.ok) throw new Error((await r.json()).detail || 'Fehler '+r.status);
const rd = r.body.getReader(), dec = new TextDecoder(); let buf = '';
while(true){
const {value, done} = await rd.read(); if(done) break; buf += dec.decode(value, {stream:true});
let i; while((i = buf.indexOf('\n\n')) >= 0){
const ev = JSON.parse(buf.slice(6, i)); buf = buf.slice(i+2);
if(ev.type==='mode'){ ui.agentSrc = 'ws:' + ev.run; $('#pv-agent').textContent = 'Agent-Stand (live)'; setPvSrc('agent'); }
else if(ev.type==='changed'){ refreshPreviewLive(); }
else if(ev.type==='step') live.querySelector('.steps').insertAdjacentHTML('beforeend', stepsHtml([ev]));
else if(ev.type==='thought') live.querySelector('.steps').insertAdjacentHTML('beforeend', `${esc(ev.text)}
`);
else if(ev.type==='error') live.querySelector('.typing').textContent = '⚠ ' + ev.text;
else if(ev.type==='done'){ live.replaceWith(msgEl(ev.message));
if(ev.message.proposal){ ui.agentSrc = 'proposal:' + ev.message.id; $('#pv-agent').textContent = 'Agent-Vorschlag'; setPvSrc('agent'); } else { ui.agentSrc = null; setPvSrc('draft'); }
refreshPreview(true); }
box.scrollTop = 1e9;
}
}
} catch(e){ live.remove(); toast(e.message, 1); }
$('#chat-send').disabled = false;
}
async function decide(id, d){
const m = await api('POST', A(`/chat/${id}/${d}`)); $('#m'+id).replaceWith(msgEl(m));
ui.agentSrc = null; setPvSrc('draft');
if(d==='apply'){ await load(true); renderNav(); headLogo(); if(ui.sec==='overview') sOverview(); toast('Im Entwurf – Vorschau zeigt jetzt den Entwurf'); }
refreshPreview(true);
}
async function showProposalDiff(id){ const d = await api('GET', A(`/chat/${id}/diff`)); $('#diff-title').textContent = 'Vorschlag des Agenten'; $('#diff-body').innerHTML = d.items.length ? d.items.map(diffHtml).join('') : 'Keine Unterschiede zum Entwurf.
'; modal('m-diff', true); }
async function clearChat(){ if(!confirm('Chatverlauf dieses Tenants löschen?')) return; await api('DELETE', A('/chat')); $('#msgs').innerHTML = 'Verlauf geleert.
'; }
/* ───────── Betrieb ───────── */
async function sDomain(){
const dnsTarget = `${S.subdomain}.${S.base_domain}`;
const o = await api('GET', A('/oidc')); window._oidc = o;
const logout = `https://${S.host}/`;
const modeHelp = {off:'Nichts im Login anfassen (Client wird manuell gepflegt).', adopt:'Bestehenden Client übernehmen: fehlende Redirect-URIs ergänzen, sonst nichts ändern.', managed:'Client vollständig aus dieser Konfiguration verwalten (anlegen, Redirect-URIs, auth/totp, Freigaben).'};
$('#pane').innerHTML = `
Domain & Login
OIDC-Client · Abgleich über die Login-Developer-API bei jedem Deploy
API-Key ${o.api_key?'vorhanden':'fehlt'}
Schreiben ${o.writes_allowed?'erlaubt':'gesperrt'}
${o.client_id?esc(o.client_id):'noch kein Client'}
Secret ${o.secret_set?'gesetzt':'fehlt'}
Redirect-URIs (Soll):
${o.redirect_uris.map(u=>`
${esc(u)}
`).join('')}
Logout-Ziel: ${esc(logout)} (Core: OIDC_LOGOUT_REDIRECT_URI)
Speichern
Gegen Login prüfen
Jetzt abgleichen
Secret rotieren
${o.last_sync?`
Letzter Abgleich: ${fmt(o.last_sync.ts)} · ${o.last_sync.ok?'✔ ok':'✘ '+esc(o.last_sync.error||'')} ${o.last_sync.created?' · Client neu angelegt':''}
`:''}
Freigaben
Aus Login übernehmen
Wer darf die App nutzen (bei auth_mode „required“). Nutzer müssen im Login bereits existieren.
+ Nutzer
Abgleichnur ergänzen exakt (zusätzliche entziehen)
Bundle-Abruf durch den Container So holt der Container beim Start seine Konfiguration (ohne Secrets); Token = ADMIN_BUNDLE_TOKEN . Der Core kann das noch nicht – Schnittstelle steht.
curl -H "Authorization: Bearer $ADMIN_BUNDLE_TOKEN" \\\n ${location.origin}/api/tenants/${TID}/bundle -o bundle.tar.gz `;
renderMembers(o.members);
$('#oc-mode').addEventListener('change', e => { $('#oc-modehelp').textContent = modeHelp[e.target.value]; });
}
function renderMembers(ms){
window._members = ms.map(m => ({...m}));
$('#oc-members tbody').innerHTML = window._members.length ? window._members.map((m,i)=>`
${['viewer','editor','admin'].map(r=>`${r} `).join('')}
✕ `).join('') : 'Keine Freigaben gepflegt – bestehende bleiben unverändert (Abgleich „nur ergänzen“). ';
}
function addMember(){ window._members.push({email:'', role:'viewer'}); renderMembers(window._members); }
async function saveOidc(toastOk){
try{
await api('PUT', A('/oidc'), {mode:$('#oc-mode').value, auth_mode:$('#oc-auth').value, totp_mode:$('#oc-totp').value, allow_registration:$('#oc-reg').checked,
backchannel_logout_uri:$('#oc-bc').value, members:window._members.filter(m=>m.email), members_mode:$('#oc-mm').value});
if(toastOk) { toast('OIDC-Konfiguration gespeichert'); sDomain(); } return true;
}catch(e){ toast(e.message,1); return false; }
}
async function syncOidc(apply, rotate){
if(!await saveOidc(false)) return;
if(rotate && !confirm('Secret rotieren? Das alte Secret ist sofort ungültig – der Container braucht danach einen Deploy.')) return;
if(apply && !rotate && !confirm('Jetzt im Login-System schreiben (Client anlegen/anpassen, Freigaben setzen)?')) return;
$('#oc-out').innerHTML = 'Frage das Login-System … ';
try{
const r = await api('POST', A('/oidc/sync'), {apply, rotate});
const acts = r.actions.map(a=>`• ${esc(a.detail)}
`).join('') || (r.skipped?`${esc(r.skipped)}
`:'✔ Alles abgeglichen – keine Änderungen nötig.
');
$('#oc-out').innerHTML = `${apply?'Abgleich':'Prüfung (nur lesen)'}: ${r.ok?'':'✘ '+esc(r.error)}${acts}${r.warnings.map(w=>`
⚠ ${esc(w)}
`).join('')}
`;
if(apply){ const keep = $('#oc-out').innerHTML; await load(true); await sDomain(); $('#oc-out').innerHTML = keep; }
}catch(e){ $('#oc-out').innerHTML = `${esc(e.message)}
`; }
}
async function importMembers(){ try{ const o = await api('POST', A('/oidc/import-members')); renderMembers(o.members); toast(o.members.length+' Freigaben übernommen – speichern nicht vergessen'); }catch(e){ toast(e.message,1) } }
async function saveManual(){ const id=$('#o-id').value, sc=$('#o-sec').value; if(id) await api('PUT',A('/secrets/OIDC_CLIENT_ID'),{value:id}); if(sc) await api('PUT',A('/secrets/OIDC_CLIENT_SECRET'),{value:sc}); toast('Gespeichert'); sDomain(); }
async function saveDomain(){ try{ await api('PUT', A('/domain'), {subdomain:$('#d-sub').value, custom_domain:$('#d-cust').value}); await load(true); sDomain(); toast('Domain gespeichert'); }catch(e){ toast(e.message,1) } }
async function checkDomain(){ $('#dns-msg').textContent='Prüfe …'; const r = await api('POST', A('/domain/check')); await load(true); await sDomain(); $('#dns-msg').textContent = r.message; }
async function sSecrets(){
const sec = await api('GET', A('/secrets')); renderNav({missing: sec.some(s=>s.required&&!s.set)});
$('#pane').innerHTML = `
Secrets Verschlüsselt in der Datenbank. Werte werden nie angezeigt und gelangen nie in Agent-Kontext oder Bundle – nur beim Deploy als Env an den Container.
Schlüssel Status Bedeutung ${sec.map(s=>`${esc(s.key)}${s.required?' Pflicht ':''}
${s.set?'gesetzt ':s.source==='sops-import'?'noch in SOPS ':'fehlt '} ${esc(s.help)}
Wert setzen ${['SESSION_SECRET','ADMIN_BUNDLE_TOKEN'].includes(s.key)?`Neu erzeugen `:''} `).join('')}
+ Weiteres Secret Bestehende Tenants: python scripts/import_tenants.py --with-secrets liest SOPS und verschlüsselt neu.
`;
}
async function setSecret(k){ const v = prompt('Neuer Wert für '+k+':'); if(v===null) return; await api('PUT', A('/secrets/'+k), {value:v}); toast('Gespeichert'); sSecrets(); }
async function addSecret(){ const k = prompt('Name (z. B. MISTRAL_API_KEY):'); if(!k) return; await setSecret(k.trim().toUpperCase()); }
async function genSecret(k){ if(!confirm(k+' neu erzeugen? Der Container braucht danach einen Deploy.')) return; await api('PUT', A('/secrets/'+k), {generate:true}); toast('Neu erzeugt'); sSecrets(); }
async function followDeploy(id, con){
con.classList.remove('hide'); con.style.display = 'block';
const tick = async () => { const d = await api('GET','/api/deployments/'+id); con.innerHTML = esc(d.log).replace(/\[DRY-RUN\]/g,'[DRY-RUN] '); con.scrollTop = 1e9;
if(d.status==='running'||d.status==='queued') setTimeout(tick,500); else { await load(true); if(ui.sec==='deploy') sDeploy(true); } };
tick();
}
async function sDeploy(keepConsole){
const [plan, hist] = await Promise.all([api('GET', A('/plan')), api('GET', '/api/deployments')]);
const mine = hist.filter(d => d.tenant === TID); const m = plan.mode;
const old = keepConsole ? $('#con')?.innerHTML : null;
$('#pane').innerHTML = `
Deploy
${m.live?`Live-Modus aktiv · freigegebene Tenants: ${esc(m.allowed)} ${m.tenant_allowed?'– dieser Tenant darf live deployen.':'– dieser Tenant ist nicht freigegeben (nur Dry-Run).'}
`:'Echte Deployments sind gesperrt (DEPLOY_LIVE nicht aktiv). Dry-Run läuft mit demselben Code und zeigt, was geschrieben würde.
'}
Vorflug-Check · ist alles bereit für den Live-Deploy? (liest nur) Prüfen Noch nicht ausgeführt.
Geplanter Rollout
Service ${esc(plan.service)} Image ${esc(plan.patch.image)} Version v${S.current_version} ${S.has_draft?'Entwurf wird nicht deployt ':''} Ingress ${esc(plan.ingress.host)} → ${esc(plan.ingress.target)}
Voraussetzungen API-Token Projekt-ID ${S.source==='admin'?`Core-Bundle `:''}
OIDC: ${plan.oidc.mode}${plan.oidc.mode!=='off'&&!plan.oidc.key?' · API-Key fehlt':''}
${plan.missing_secrets.length?`
Secrets ohne Wert: ${esc(plan.missing_secrets.join(', '))}
`:''}
Service-Definition (PATCH-Body, Secrets maskiert) ${esc(JSON.stringify(plan.patch,null,2))}
Dry-Run Health erneut prüfen Zurückrollen Deployen
Verlauf ${mine.length?`
Zeit Art Version Status ${mine.map(d=>`${fmt(d.created_at)} ${kindLabel[d.kind]||d.kind} ${d.simulated?'Dry-Run ':'live '} v${d.version} ${d.status} `).join('')}
`:'
Noch keine Deployments für diesen Tenant.
'}
`;
if(old){ $('#con').classList.remove('hide'); $('#con').innerHTML = old; }
}
async function runPreflight(){
$('#pf').innerHTML = 'Prüfe (liest Mittwald-Stack, Login-API, Rendering) … ';
try{
const r = await api('GET', A('/preflight'));
const dot = {ok:'ok', fail:'no', warn:'no', info:''}; const ic = {ok:'✓', fail:'✕', warn:'!', info:'i'};
$('#pf').innerHTML = `${r.ready?'✔ Bereit für den Live-Deploy (keine harten Fehler)':'✘ Noch nicht bereit – bitte die roten Punkte beheben'} · Ziel ${esc(r.host)} · Core :${esc(String(r.tag).slice(0,12))}
`+
r.items.map(i=>`${ic[i.level]} ${esc(i.label)} ${esc(i.detail)} ${i.fix?`→ ${esc(i.fix)}
`:''} `).join('');
}catch(e){ $('#pf').innerHTML = `${esc(e.message)}
`; }
}
async function recheckHealth(){
const con = $('#con'); con.classList.remove('hide'); con.style.display='block'; con.textContent = 'Prüfe https://' + S.host + '/health … (bis ca. 40 s)';
try{ const r = await api('POST', A('/health-check')); con.innerHTML = esc(r.log.join('\n')) + (r.ok ? '\n✔ Health ok' : ''); if(r.ok){ await load(true); sDeploy(true); } }catch(e){ con.textContent = e.message; }
}
async function rollbackTenant(){ if(!confirm('Auf den vorherigen Core-Tag zurückrollen?')) return; try{ const r = await api('POST', A('/rollback')); toast('Rollback auf :'+String(r.tag).slice(0,10)+' gestartet'); followDeploy(r.id, $('#con')); }catch(e){ toast(e.message,1) } }
async function startDeploy(dry){
if(!dry && !confirm('Tenant jetzt auf Mittwald deployen?')) return;
try{ const r = await api('POST', A('/deploy'), {dry_run:dry}); followDeploy(r.id, $('#con')); }catch(e){ toast(e.message,1) }
}
/* ───────── Verlauf ───────── */
async function sVersions(){
const v = await api('GET', A('/versions'));
$('#pane').innerHTML = `
Verlauf Jede Veröffentlichung ist ein unveränderlicher Snapshot. Wiederherstellen legt die alte Version als Entwurf an – nichts wird überschrieben.
Version Nachricht Autor Zeit Dateien ${v.map(x=>`v${x.number} ${x.current?'aktuell ':''} ${x.deployed?'deployt ':''}${esc(x.message)} ${esc(x.author)} ${fmt(x.created_at)} ${x.files}
${x.current?'':`Diff Als Entwurf `} `).join('')}
`;
}
async function showDiff(a,b,t){ const d = await api('GET', A(`/diff?a=${a}&b=${b}`)); $('#diff-title').textContent = 'Diff '+t; $('#diff-body').innerHTML = d.items.length ? d.items.map(diffHtml).join('') : 'Keine Unterschiede.
'; modal('m-diff', true); }
async function restoreV(n){ if(!confirm('v'+n+' als Entwurf wiederherstellen? Ein bestehender Entwurf wird ersetzt.')) return; await api('POST', A(`/versions/${n}/restore`)); await load(true); location.hash = 'brand'; renderSection(); toast('v'+n+' liegt jetzt im Entwurf'); }
(async () => { await loadSchema(); await load(); initAgent(); })();