Files
jbergner f7c1486ff9
Some checks failed
release-tag / release-image (push) Failing after 9m22s
9.1.2
2026-08-29 20:22:17 +02:00

135 lines
48 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const $=(q,root=document)=>root.querySelector(q);const $$=(q,root=document)=>[...root.querySelectorAll(q)];
let lastTraceId='';let state={tasks:[],events:[],reminders:[],groceries:[],recipes:[],mealPlans:[],kanban:[],documents:[],chat:[],activity:[],system:{}};
let bootstrappedChat=false,pttRecorder=null,pttStream=null,pttChunks=[],pttContext='',wakeEnabled=false,wakeStream=null,wakeToken=0,wakeArmedUntil=0,wakeBusy=false,speaking=false,commandBusy=false,toastTimer,dialogType='',dialogEditing=null;
const esc=s=>String(s??'').replace(/[&<>'"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c]));
const localDateKey=d=>`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
const parseDate=v=>{if(!v)return null;if(/^\d{4}-\d{2}-\d{2}$/.test(v)){const[a,b,c]=v.split('-').map(Number);return new Date(a,b-1,c)}const d=new Date(v);return Number.isNaN(d.getTime())?null:d};
const fmtTime=iso=>{const d=parseDate(iso);if(!d)return iso||'—';return new Intl.DateTimeFormat('de-DE',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}).format(d)};
const fmtClock=iso=>{const d=parseDate(iso);if(!d)return'—';return /^\d{4}-\d{2}-\d{2}$/.test(iso)?'GANZTÄGIG':d.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'})};
const fmtDay=d=>d.toLocaleDateString('de-DE',{weekday:'short',day:'2-digit',month:'2-digit'});
const mealName=m=>({breakfast:'FRÜH',lunch:'MITTAG',dinner:'ABEND',snack:'SNACK'}[m]||String(m||'ABEND').toUpperCase());
const taskOrigin=t=>t.source==='jarvis-command'?'JARVIS':t.aiManaged?'AI SCOUT':'MANUAL';
const messageHTML=text=>esc(text).replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>').replace(/`([^`]+)`/g,'<code>$1</code>').replace(/\n/g,'<br>');
function voiceSetupMessage(voice={},kind='stt'){if(kind==='stt'){if(!voice.ffmpegReady)return 'FFmpeg fehlt im Container';if(voice.whisperBinReady===false)return `Whisper-Binary fehlt: ${voice.whisperBin||'nicht gefunden'}`;if(voice.whisperModelReady===false)return `Whisper-Modell fehlt: ${voice.whisperModel||'/models/ggml-small.bin'}`;return voice.whisperError||voice.whisperModelError||'Lokales Whisper ist noch nicht konfiguriert'}if(voice.piperBinReady===false)return `Piper-Binary fehlt: ${voice.piperBin||'/tools/piper'}`;if(voice.piperModelReady===false)return `Piper-Modell fehlt: ${voice.piperModel||'/models/de_DE-thorsten-medium.onnx'}`;return voice.piperError||voice.piperModelError||'Lokales Piper ist noch nicht konfiguriert'}
function sttStatusLabel(voice={}){return voice.sttReady?'LOCAL':(!voice.whisperBinReady?'BIN':(!voice.whisperModelReady?'MODEL':(!voice.ffmpegReady?'FFMPEG':'SETUP')))}
function ttsStatusLabel(voice={}){return voice.ttsReady?'LOCAL':(!voice.piperBinReady?'BIN':(!voice.piperModelReady?'MODEL':'SETUP'))}
function safeRender(name,fn){try{fn()}catch(e){console.error(`[JARVIS UI] ${name} render failed`,e)}}
const THEME_KEY='jarvis-ui-theme',VIEW_KEY='jarvis-clean-view';
const cleanViews={
home:{eyebrow:'Übersicht',title:'Home',subtitle:'Dein Zuhause, Termine und Aufgaben auf einen Blick.'},
calendar:{eyebrow:'Planung',title:'Kalender',subtitle:'Termine und Erinnerungen in einer ruhigen Zeitansicht.'},
tasks:{eyebrow:'Fokus',title:'Aufgaben',subtitle:'Offene Punkte priorisieren, abhaken und per Sprache verwalten.'},
kanban:{eyebrow:'Workflow',title:'Kanban',subtitle:'Arbeit sichtbar machen und den KI-Organizer gezielt einsetzen.'},
food:{eyebrow:'Haushalt',title:'Essen & Einkauf',subtitle:'Essensplan, Einkaufsliste und Rezepte an einem Ort.'},
knowledge:{eyebrow:'System',title:'Wissen & System',subtitle:'Dokumentwissen, Sprache, Modelle und Aktivität des lokalen Systems.'}
};
function applyCleanView(view,persist=true){const v=cleanViews[view]?view:'home';document.body.dataset.view=v;$$('[data-clean-view]').forEach(btn=>btn.classList.toggle('active',btn.dataset.cleanView===v));const m=cleanViews[v];if($('#cleanViewEyebrow'))$('#cleanViewEyebrow').textContent=m.eyebrow;if($('#cleanViewTitle'))$('#cleanViewTitle').textContent=m.title;if($('#cleanViewSubtitle'))$('#cleanViewSubtitle').textContent=m.subtitle;if(persist)try{localStorage.setItem(VIEW_KEY,v)}catch(_){}window.scrollTo({top:0,behavior:'smooth'});}
function applyTheme(theme,persist=false){const t=theme==='apple'?'apple':'jarvis';document.body.dataset.theme=t;document.documentElement.style.colorScheme=t==='apple'?'light':'dark';const meta=document.querySelector('meta[name="theme-color"]');if(meta)meta.setAttribute('content',t==='apple'?'#f4f5f7':'#02060a');$$('.theme-option').forEach(btn=>btn.classList.toggle('active',btn.dataset.theme===t));if(t==='apple'&&!document.body.dataset.view){let view='home';try{view=localStorage.getItem(VIEW_KEY)||view}catch(_){}applyCleanView(view,false)}if(persist)try{localStorage.setItem(THEME_KEY,t)}catch(_){} }
function initTheme(){let theme='jarvis',view='home';try{theme=localStorage.getItem(THEME_KEY)||theme;view=localStorage.getItem(VIEW_KEY)||view}catch(_){}document.body.dataset.view=cleanViews[view]?view:'home';applyTheme(theme,false);applyCleanView(document.body.dataset.view,false);$$('.theme-option').forEach(btn=>btn.onclick=()=>applyTheme(btn.dataset.theme,true));$$('[data-clean-view]').forEach(btn=>btn.onclick=()=>applyCleanView(btn.dataset.cleanView,true));$$('[data-clean-jump]').forEach(btn=>btn.onclick=()=>applyCleanView(btn.dataset.cleanJump,true));if($('#cleanOpenHome'))$('#cleanOpenHome').onclick=()=>applyCleanView('home',true);if($('#cleanQuickMic'))$('#cleanQuickMic').onclick=()=>startPTT('');}
async function api(url,options={}){const res=await fetch(url,options);if(!res.ok){throw new Error((await res.text()).trim()||`HTTP ${res.status}`)}const type=res.headers.get('content-type')||'';return type.includes('application/json')?res.json():res}
function toast(msg){const el=$('#toast');el.textContent=msg;el.classList.add('show');clearTimeout(toastTimer);toastTimer=setTimeout(()=>el.classList.remove('show'),2800)}
function tickClock(){const n=new Date();$('#clock').textContent=n.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit',second:'2-digit'});$('#date').textContent=n.toLocaleDateString('de-DE',{weekday:'short',day:'2-digit',month:'short',year:'numeric'}).toUpperCase();if($('#cleanTopDate'))$('#cleanTopDate').textContent=n.toLocaleDateString('de-DE',{weekday:'long',day:'numeric',month:'long',year:'numeric'})}setInterval(tickClock,1000);tickClock();
function setChip(id,ok,label){const dot=$(id+'Dot');if(!dot)return;const chip=dot.closest('.status-chip');chip.classList.toggle('online',!!ok);chip.classList.toggle('offline',ok===false);$(id+'Status').textContent=label}
async function refresh(){try{state=await api('/api/state');renderState();setChip('#core',true,'ONLINE')}catch(e){setChip('#core',false,'OFFLINE')}}
function renderSystemStatus(){
const sys=state.system||{},voice=sys.voice||{},ollama=sys.ollama||{},chunks=Number(sys.ragChunks||0);
const chatReady=ollama.chatReady!==false&&!!(ollama.model||sys.model),embedReady=ollama.embeddingReady!==false&&!!(ollama.embeddingModel||sys.embeddingModel);
setChip('#core',ollama.reachable===false?false:chatReady,chatReady?'LOCAL':(ollama.reachable===false?'OLLAMA OFF':'NO MODEL'));
const coreStatus=$('#coreStatus');if(coreStatus)coreStatus.title=chatReady?`ACTIVE MODEL: ${ollama.model||sys.model}`:(ollama.lastError||'Kein Chat-Modell erkannt');
setChip('#rag',embedReady?(chunks>0?true:null):false,embedReady?(chunks?`${chunks} CHUNKS`:'EMPTY'):'NO EMBED');
const sttLabel=sttStatusLabel(voice),ttsLabel=ttsStatusLabel(voice);
setChip('#stt',!!voice.sttReady,sttLabel);setChip('#tts',!!voice.ttsReady,ttsLabel);
const sttStatus=$('#sttStatus'),ttsStatus=$('#ttsStatus');
const sttChip=sttStatus?.closest('.status-chip'),ttsChip=ttsStatus?.closest('.status-chip');
if(sttChip)sttChip.title=voice.sttReady?`Whisper: ${voice.whisperBin||'whisper-cli'} · Modell: ${voice.whisperModel||''}`:voiceSetupMessage(voice,'stt');
if(ttsChip)ttsChip.title=voice.ttsReady?`Piper: ${voice.piperBin||'piper'} · Modell: ${voice.piperModel||''}`:voiceSetupMessage(voice,'tts');
const mt=$('#modelTag'),ctxN=Number(sys.context?.numCtx||0),ctxLabel=ctxN>=1024?`${Math.round(ctxN/1024)}K`:(ctxN||'—');
if(mt){mt.textContent=chatReady?`LOCAL · ${String(ollama.model||sys.model).toUpperCase()} · CTX ${ctxLabel}`:'LOCAL · DEGRADED';mt.title=`Skill Agent: ${sys.agent?.mode||'unknown'} · ${sys.skills?.action_count||sys.agent?.tools?.length||0} actions · ${sys.skills?.plugin_count||0} plugins · max ${sys.agent?.maxSteps||0} steps · Conversation ${sys.context?.historyTokens||0} tokens`;}
const scout=$('#scoutBtn'),kai=$('#kanbanAI'),reindex=$('#reindexBtn');
if(scout){scout.disabled=!chatReady;scout.title=chatReady?'Lokalen Task-Scout starten':'Benötigt ein lokales Ollama-Chatmodell'}
if(kai){kai.disabled=!chatReady;kai.title=chatReady?'Kanban mit lokalem Modell organisieren':'Benötigt ein lokales Ollama-Chatmodell'}
if(reindex){reindex.disabled=!embedReady;reindex.title=embedReady?'RAG neu indexieren':'Benötigt ein lokales Embedding-Modell'}
const chunkCount=$('#chunkCount'),memoryBar=$('#memoryBar'),wakeWord=$('#wakeWord'),whisperDiag=$('#whisperDiag'),piperDiag=$('#piperDiag');
if(chunkCount)chunkCount.textContent=chunks;if(memoryBar)memoryBar.style.width=`${Math.min(100,5+Math.log2(chunks+1)*12)}%`;if(wakeWord)wakeWord.textContent=String(voice.wakeWord||'jarvis').toUpperCase();
if(whisperDiag){whisperDiag.textContent=voice.sttReady?'ONLINE':sttLabel;whisperDiag.title=voice.sttReady?'Whisper bereit':voiceSetupMessage(voice,'stt');whisperDiag.className=voice.sttReady?'good':'bad'}
if(piperDiag){piperDiag.textContent=voice.ttsReady?'ONLINE':ttsLabel;piperDiag.title=voice.ttsReady?'Piper bereit':voiceSetupMessage(voice,'tts');piperDiag.className=voice.ttsReady?'good':'bad'}
}
function renderState(){
safeRender('system-status',renderSystemStatus);
safeRender('clean-home',renderCleanHome);safeRender('brief',renderBrief);safeRender('calendar',renderCalendar);safeRender('tasks',renderTasks);safeRender('kanban',renderKanban);safeRender('groceries',renderGroceries);safeRender('meals',renderMeals);safeRender('recipes',renderRecipes);safeRender('reminders',renderReminders);safeRender('documents',renderDocuments);safeRender('skills',renderSkills);safeRender('skill-mesh',renderSkillMesh);safeRender('activity',renderActivity);
const sys=state.system||{},dbg=sys.debug||{},dstat=$('#debugTraceStatus');if(dstat){dstat.classList.toggle('off',!dbg.enabled);dstat.innerHTML=`<i></i> ${dbg.enabled?'DEBUG TRACE':'TRACE OFF'}`};if($('#debugExportBtn'))$('#debugExportBtn').disabled=!dbg.enabled;if($('#debugClearBtn'))$('#debugClearBtn').disabled=!dbg.enabled;
if(!bootstrappedChat){safeRender('initial-chat',renderInitialChat);bootstrappedChat=true}
}
function renderCleanHome(){
const now=new Date(),today=new Date();today.setHours(0,0,0,0);const tomorrow=new Date(today);tomorrow.setDate(tomorrow.getDate()+1);const weekEnd=new Date(today);weekEnd.setDate(weekEnd.getDate()+7);
const events=[...(state.events||[])].filter(e=>{const d=parseDate(e.start);return d&&d>=today}).sort((a,b)=>String(a.start).localeCompare(String(b.start)));
const tasks=[...(state.tasks||[])].filter(t=>t.status!=='done').sort((a,b)=>(b.priority==='high')-(a.priority==='high')||String(a.due||'z').localeCompare(String(b.due||'z')));
const mealOrder={breakfast:0,lunch:1,dinner:2,snack:3};const meals=[...(state.mealPlans||[])].filter(m=>{const d=parseDate(m.date);return d&&d>=today}).sort((a,b)=>String(a.date).localeCompare(String(b.date))+(mealOrder[a.meal]||0)-(mealOrder[b.meal]||0));
const hour=now.getHours(),greet=hour<11?'Guten Morgen':hour<17?'Guten Tag':'Guten Abend';if($('#cleanGreetingLabel'))$('#cleanGreetingLabel').textContent=greet;
const eventWeek=events.filter(e=>{const d=parseDate(e.start);return d&&d<weekEnd}).length,openTasks=tasks.length,groceryOpen=(state.groceries||[]).filter(g=>!g.checked).length;
if($('#cleanGreeting'))$('#cleanGreeting').textContent=openTasks||eventWeek?'Das Wichtigste ist vorbereitet.':'Alles ruhig. Du bist auf dem aktuellen Stand.';
if($('#cleanHomeSummary'))$('#cleanHomeSummary').textContent=`${eventWeek} Termin${eventWeek===1?'':'e'} in den nächsten 7 Tagen · ${openTasks} offene Aufgabe${openTasks===1?'':'n'} · ${groceryOpen} Einkauf${groceryOpen===1?'':'seinträge'}.`;
if($('#homeCalendarQuick'))$('#homeCalendarQuick').textContent=`${eventWeek} ${eventWeek===1?'Termin':'Termine'}`;if($('#homeTasksQuick'))$('#homeTasksQuick').textContent=`${openTasks} offen`;if($('#homeKanbanQuick'))$('#homeKanbanQuick').textContent=`${(state.kanban||[]).length} Karten`;
const nextMeal=meals[0];if($('#homeFoodQuick'))$('#homeFoodQuick').textContent=nextMeal?`${fmtDay(parseDate(nextMeal.date))} · ${mealName(nextMeal.meal)}`:'Nichts geplant';
const ev=events[0],evEl=$('#cleanNextEvent');if(evEl)evEl.innerHTML=ev?`<strong>${esc(ev.title)}</strong><p>${esc(fmtDay(parseDate(ev.start)))} · ${esc(ev.allDay?'Ganztägig':fmtClock(ev.start))}${ev.location?' · '+esc(ev.location):''}</p>`:'<strong>Keine Termine</strong><p>Dein Kalender ist frei.</p>';
const task=tasks[0],taskEl=$('#cleanNextTask');if(taskEl)taskEl.innerHTML=task?`<strong>${esc(task.title)}</strong><p>${task.priority==='high'?'Hohe Priorität':'Offen'}${task.due?' · '+esc(fmtTime(task.due)):''}</p>`:'<strong>Keine offenen Aufgaben</strong><p>Du bist auf dem aktuellen Stand.</p>';
const meal=nextMeal,mealEl=$('#cleanNextMeal');if(mealEl)mealEl.innerHTML=meal?`<strong>${esc(meal.title||meal.recipeTitle||'Geplante Mahlzeit')}</strong><p>${esc(fmtDay(parseDate(meal.date)))} · ${esc(mealName(meal.meal))}${meal.servings?` · ${meal.servings} Portionen`:''}</p>`:'<strong>Noch nichts geplant</strong><p>Plane eine Mahlzeit für heute oder morgen.</p>';
const sys=state.system||{},ollama=sys.ollama||{},voice=sys.voice||{};if($('#cleanCoreQuick'))$('#cleanCoreQuick').textContent=ollama.chatReady===false?'Nicht bereit':String(ollama.model||sys.model||'Lokal');if($('#cleanRagQuick'))$('#cleanRagQuick').textContent=Number(sys.ragChunks||0)?`${sys.ragChunks} Chunks`:'Leer';if($('#cleanVoiceQuick')){const cv=$('#cleanVoiceQuick'),label=sttStatusLabel(voice);cv.textContent=voice.sttReady?'Bereit':label;cv.title=voice.sttReady?'Lokales Whisper bereit':voiceSetupMessage(voice,'stt')}if($('#cleanModelStatus'))$('#cleanModelStatus').textContent=ollama.chatReady===false?'Modell fehlt':String(ollama.model||sys.model||'Online');if($('#cleanSkillsQuick'))$('#cleanSkillsQuick').textContent=`${Number(sys.mesh?.online_count||0)} Worker · ${Number(sys.skills?.action_count||0)} Actions`;
}
function renderBrief(){const start=new Date();start.setHours(0,0,0,0);const until=new Date(start);until.setDate(until.getDate()+7);$('#briefEvents').textContent=(state.events||[]).filter(e=>{const d=parseDate(e.start);return d&&d>=start&&d<until}).length;$('#briefTasks').textContent=(state.tasks||[]).filter(t=>t.status!=='done').length;$('#briefGroceries').textContent=(state.groceries||[]).filter(g=>!g.checked).length;$('#briefReminders').textContent=(state.reminders||[]).filter(r=>!r.done).length}
function renderCalendar(){const today=new Date();today.setHours(0,0,0,0);const end=new Date(today);end.setDate(end.getDate()+7);const events=[...(state.events||[])].sort((a,b)=>String(a.start).localeCompare(String(b.start)));const days=[];for(let i=0;i<7;i++){const d=new Date(today);d.setDate(d.getDate()+i);const key=localDateKey(d),count=events.filter(e=>{const ed=parseDate(e.start);return ed&&localDateKey(ed)===key}).length;days.push(`<div class="day-cell ${i===0?'today':''}"><span>${d.toLocaleDateString('de-DE',{weekday:'short'}).toUpperCase()}</span><strong>${d.getDate()}</strong>${count?`<b>${count}</b>`:''}</div>`)}$('#weekRail').innerHTML=days.join('');const visible=events.filter(e=>{const d=parseDate(e.start);return d&&d>=today&&d<end}).slice(0,18);$('#agenda').innerHTML=visible.length?visible.map(e=>`<div class="agenda-item"><div class="agenda-time">${esc(e.allDay?'GANZTÄGIG':fmtClock(e.start))}</div><div><strong>${esc(e.title)}</strong><span>${esc(fmtDay(parseDate(e.start)))}${e.location?' · '+esc(e.location):''} · ${e.aiManaged?'JARVIS':'MANUAL'}</span></div><button class="icon-delete" data-delete-event="${esc(e.id)}">×</button></div>`).join(''):'<div class="empty">KEINE TERMINE IN DEN NÄCHSTEN 7 TAGEN</div>';$$('[data-delete-event]').forEach(b=>b.onclick=()=>deleteEntity('/api/events/',b.dataset.deleteEvent,'Termin entfernt'))}
function renderTasks(){const tasks=state.tasks||[],open=tasks.filter(t=>t.status!=='done');$('#taskOpen').textContent=open.length;$('#taskAI').textContent=open.filter(t=>t.aiManaged).length;$('#taskHigh').textContent=open.filter(t=>t.priority==='high').length;const ordered=[...tasks].sort((a,b)=>(a.status==='done')-(b.status==='done')||(b.priority==='high')-(a.priority==='high')||String(a.due||'z').localeCompare(String(b.due||'z')));$('#taskList').innerHTML=ordered.length?ordered.slice(0,24).map(t=>`<div class="task ${t.status==='done'?'done':''}"><input class="task-check" type="checkbox" ${t.status==='done'?'checked':''} data-task-check="${esc(t.id)}"><div class="task-main"><strong>${esc(t.title)}</strong><div class="task-meta"><span class="tag ${t.aiManaged?'ai':''}">${taskOrigin(t)}</span><span class="tag ${t.priority==='high'?'high':''}">${esc(t.priority||'normal')}</span>${t.due?`<span class="tag">DUE ${esc(fmtTime(t.due))}</span>`:''}</div>${t.aiManaged?`<div class="confidence">CONFIDENCE ${Math.round((t.confidence||0)*100)}%${t.source?' · '+esc(t.source):''}</div>`:''}</div><button class="icon-delete" data-task-delete="${esc(t.id)}">×</button></div>`).join(''):'<div class="empty">ACTION QUEUE CLEAR</div>';$$('[data-task-check]').forEach(el=>el.onchange=async()=>{const t=state.tasks.find(x=>x.id===el.dataset.taskCheck);if(!t)return;try{await post('/api/tasks',{...t,status:el.checked?'done':'open'});await refresh()}catch(e){toast(e.message)}});$$('[data-task-delete]').forEach(b=>b.onclick=()=>deleteEntity('/api/tasks/',b.dataset.taskDelete,'Aufgabe entfernt'))}
const columns=[['inbox','INBOX'],['next','NEXT'],['doing','DOING'],['waiting','WAITING'],['done','DONE']];
function renderKanban(){const cards=state.kanban||[];$('#kanbanBoard').innerHTML=columns.map(([key,label])=>{const list=cards.filter(c=>c.column===key);return `<div class="kanban-col" data-kanban-col="${key}"><div class="kanban-col-head"><strong>${label}</strong><span>${list.length}</span></div><div class="kanban-cards">${list.map(c=>`<article class="kanban-card ${c.aiManaged?'':'manual'}" draggable="true" data-card-id="${esc(c.id)}"><strong>${esc(c.title)}</strong>${c.notes?`<p>${esc(c.notes)}</p>`:''}<div class="kanban-foot"><span>${c.aiManaged?'AI '+Math.round((c.confidence||0)*100)+'%':'MANUAL'} · ${esc(c.priority||'normal')}${c.due?' · '+esc(fmtTime(c.due)):''}</span><button data-card-delete="${esc(c.id)}">×</button></div></article>`).join('')}</div></div>`}).join('');$$('.kanban-card').forEach(card=>card.ondragstart=e=>{e.dataTransfer.setData('text/plain',card.dataset.cardId);e.dataTransfer.effectAllowed='move'});$$('[data-kanban-col]').forEach(col=>{col.ondragover=e=>{e.preventDefault();col.classList.add('drag-over')};col.ondragleave=()=>col.classList.remove('drag-over');col.ondrop=async e=>{e.preventDefault();col.classList.remove('drag-over');const id=e.dataTransfer.getData('text/plain'),card=state.kanban.find(c=>c.id===id);if(!card)return;try{await post('/api/kanban',{...card,column:col.dataset.kanbanCol,aiManaged:false,source:'manual-override'});toast('Karte manuell fixiert');await refresh()}catch(err){toast(err.message)}}});$$('[data-card-delete]').forEach(b=>b.onclick=e=>{e.stopPropagation();deleteEntity('/api/kanban/',b.dataset.cardDelete,'Karte entfernt')})}
function renderGroceries(){const xs=state.groceries||[],open=xs.filter(x=>!x.checked);$('#groceryOpen').textContent=open.length;$('#groceryBar').style.width=`${xs.length?Math.round(open.length/xs.length*100):0}%`;const sorted=[...xs].sort((a,b)=>a.checked-b.checked||String(a.category).localeCompare(String(b.category)));$('#groceryList').innerHTML=sorted.length?sorted.map(g=>`<div class="grocery-item ${g.checked?'done':''}"><input class="grocery-check" type="checkbox" ${g.checked?'checked':''} data-grocery-check="${esc(g.id)}"><div class="grocery-main"><strong>${esc([g.quantity,g.unit,g.name].filter(Boolean).join(' '))}</strong><span>${esc(g.category||'Allgemein')} · ${g.aiManaged?'AI/AUTO':'MANUAL'}${g.source?.startsWith('mealplan:auto:')?' · ESSENSPLAN':''}</span></div><button class="icon-delete" data-grocery-delete="${esc(g.id)}">×</button></div>`).join(''):'<div class="empty">EINKAUFSLISTE LEER</div>';$$('[data-grocery-check]').forEach(el=>el.onchange=async()=>{const g=state.groceries.find(x=>x.id===el.dataset.groceryCheck);if(!g)return;try{await post('/api/groceries',{...g,checked:el.checked});await refresh()}catch(e){toast(e.message)}});$$('[data-grocery-delete]').forEach(b=>b.onclick=()=>deleteEntity('/api/groceries/',b.dataset.groceryDelete,'Eintrag entfernt'))}
function renderMeals(){const today=new Date();today.setHours(0,0,0,0);const plans=state.mealPlans||[];const out=[];for(let i=0;i<7;i++){const d=new Date(today);d.setDate(d.getDate()+i);const key=localDateKey(d),list=plans.filter(m=>m.date===key).sort((a,b)=>String(a.meal).localeCompare(String(b.meal)));out.push(`<div class="meal-day ${i===0?'today':''}"><header><span>${d.toLocaleDateString('de-DE',{weekday:'short'}).toUpperCase()}</span><strong>${d.getDate()}.${d.getMonth()+1}.</strong></header>${list.length?list.map(m=>`<div class="meal-card"><span>${mealName(m.meal)}</span><strong>${esc(m.title)}</strong><small>${m.servings||2} PORT. ${m.aiManaged?'· AI':''}</small><button data-meal-delete="${esc(m.id)}">×</button></div>`).join(''):'<div class="empty">OPEN</div>'}</div>`)}$('#mealTimeline').innerHTML=out.join('');$$('[data-meal-delete]').forEach(b=>b.onclick=()=>deleteEntity('/api/meal-plans/',b.dataset.mealDelete,'Plan entfernt'))}
function renderRecipes(){const rs=state.recipes||[];$('#recipeList').innerHTML=rs.length?rs.slice(0,40).map(r=>`<article class="recipe-card"><strong>${esc(r.title)}</strong><p>${esc(r.description||`${(r.ingredients||[]).length} Zutaten · ${(r.steps||[]).length} Schritte`)}</p><footer>${r.prepMinutes?`${r.prepMinutes} MIN · `:''}${r.servings||2} PORT. ${r.aiManaged?'· AI':''}</footer><div class="recipe-actions"><button data-recipe-edit="${esc(r.id)}" title="Rezept bearbeiten">✎</button><button data-recipe-delete="${esc(r.id)}" title="Rezept löschen">×</button></div></article>`).join(''):'<div class="empty">NO CULINARY MEMORY</div>';$$('[data-recipe-edit]').forEach(b=>b.onclick=()=>{const r=state.recipes.find(x=>x.id===b.dataset.recipeEdit);if(r)openDialog('recipe',r)});$$('[data-recipe-delete]').forEach(b=>b.onclick=()=>deleteEntity('/api/recipes/',b.dataset.recipeDelete,'Rezept entfernt'))}
function renderReminders(){const rs=[...(state.reminders||[])].sort((a,b)=>a.done-b.done||String(a.at).localeCompare(String(b.at)));$('#reminderList').innerHTML=rs.length?rs.slice(0,24).map(r=>`<div class="reminder ${r.done?'done':''}"><input class="reminder-check" type="checkbox" ${r.done?'checked':''} data-reminder-check="${esc(r.id)}"><div class="reminder-main"><strong>${esc(r.title)}</strong><span>${esc(fmtTime(r.at))} · ${r.aiManaged?'AI/AUTO':'MANUAL'}${r.linkedType==='event'?' · KALENDER':''}</span></div><button class="icon-delete" data-reminder-delete="${esc(r.id)}">×</button></div>`).join(''):'<div class="empty">ALERT GRID CLEAR</div>';$$('[data-reminder-check]').forEach(el=>el.onchange=async()=>{const r=state.reminders.find(x=>x.id===el.dataset.reminderCheck);if(!r)return;try{await post('/api/reminders',{...r,done:el.checked});await refresh()}catch(e){toast(e.message)}});$$('[data-reminder-delete]').forEach(b=>b.onclick=()=>deleteEntity('/api/reminders/',b.dataset.reminderDelete,'Erinnerung entfernt'))}
function renderDocuments(){const docs=state.documents||[];$('#documentList').innerHTML=docs.length?docs.slice(0,14).map(d=>`<div class="doc"><div class="doc-icon">${esc((d.name.split('.').pop()||'DOC').slice(0,4).toUpperCase())}</div><div><strong title="${esc(d.name)}">${esc(d.name)}</strong><span>${d.chunkCount||0} chunks · ${d.indexedAt?'INDEXED':'PENDING'}</span></div><button class="icon-delete" data-doc-delete="${esc(d.id)}">×</button></div>`).join(''):'<div class="empty">KNOWLEDGE VAULT EMPTY</div>';$$('[data-doc-delete]').forEach(b=>b.onclick=()=>deleteEntity('/api/documents/',b.dataset.docDelete,'Dokument entfernt'))}
function renderSkills(){const skills=state.system?.skills||{},mods=skills.modules||[],el=$('#skillList'),tag=$('#skillCountTag');if(tag)tag.innerHTML=`<i></i> ${Number(skills.action_count||0)} ACTIONS`;if(!el)return;if(!mods.length){el.innerHTML='<div class="empty">KEINE SKILLS GELADEN</div>';return}const sorted=[...mods].sort((a,b)=>(a.source==='plugin'?0:1)-(b.source==='plugin'?0:1)||String(a.name||a.id).localeCompare(String(b.name||b.id)));el.innerHTML=sorted.map(m=>{const actions=(m.actions||[]).map(a=>`<span class="skill-action-chip ${a.mutates?'mutates':''} ${a.requires_confirmation?'confirm':''}" title="${esc(a.tool_name||'')}">${esc(a.name||a.tool_name||'action')}</span>`).join('');const perms=m.source==='plugin'?`Runtime ${esc(m.runtime||'process')} · Network ${m.permissions?.network?'ja':'nein'} · System Exec ${m.permissions?.system_exec?'ja':'nein'}`:m.source==='remote'?`Remote HTTP · ${(m.actions||[]).length} Actions`:`${(m.actions||[]).length} Core Actions`;return `<article class="skill-card"><div class="skill-card-head"><strong>${esc(m.name||m.id)}</strong><span>${esc(m.source||'plugin')} · ${esc(m.version||'')}</span></div>${m.description?`<p>${esc(m.description)}</p>`:''}${m.error?`<p class="skill-error">${esc(m.error)}</p>`:''}<div class="skill-actions">${actions}</div><div class="skill-perms">${perms}</div></article>`}).join('')}
function renderSkillMesh(){const mesh=state.system?.mesh||{},docker=state.system?.dockerSkills||{},workers=mesh.workers||[],services=docker.services||[],tag=$('#meshCountTag'),wel=$('#meshWorkerList'),del=$('#dockerSkillList');if(tag)tag.innerHTML=`<i></i> ${Number(mesh.online_count||0)} / ${Number(mesh.worker_count||0)} WORKERS`;if(wel)wel.innerHTML=workers.length?workers.map(w=>`<article class="mesh-card"><div class="mesh-card-head"><div><strong>${esc(w.name||w.id)}</strong><small>${esc(w.runtime||'runtime')}${w.runtime_version?' · '+esc(w.runtime_version):''}</small></div><small>${esc(w.state||'offline').toUpperCase()}</small></div><div class="mesh-meta"><span class="${w.state==='online'?'online':'offline'}">${esc(w.state||'offline')}</span><span>${Number(w.skill_count||0)} Skills</span><span>${Number(w.action_count||0)} Actions</span><span title="${esc(w.endpoint||'')}">${esc((w.endpoint||'').replace(/^https?:\/\//,''))}</span></div>${w.state==='online'?`<div class="docker-actions"><button data-worker-reload="${esc(w.id)}">RELOAD SKILLS</button></div>`:''}</article>`).join(''):'<div class="empty">KEINE REMOTE WORKER ENROLLED</div>';$$('[data-worker-reload]').forEach(b=>b.onclick=async()=>{b.disabled=true;try{const out=await api(`/api/mesh/workers/${encodeURIComponent(b.dataset.workerReload)}/reload`,{method:'POST'});toast(out.ok===false?(out.error||'Worker-Reload fehlgeschlagen'):'Worker-Skills neu geladen');setTimeout(refresh,500)}catch(e){toast('Worker: '+e.message)}finally{b.disabled=false}});if(del){if(!docker.enabled){del.innerHTML='<div class="empty">DOCKER CONTROLLER DEAKTIVIERT</div>'}else if(!docker.available){del.innerHTML=`<div class="empty">DOCKER SOCK NICHT VERFÜGBAR${docker.error?' · '+esc(docker.error):''}</div>`}else{del.innerHTML=services.length?services.map(c=>`<article class="mesh-card"><div class="mesh-card-head"><div><strong>${esc(c.worker_name||c.name||c.id)}</strong><small>${esc(c.runtime||'skill')} · ${esc(c.image||'')}</small></div><small>${esc((c.state||'unknown').toUpperCase())}</small></div><div class="mesh-meta"><span class="${c.state==='running'?'online':'offline'}">${esc(c.status||c.state||'')}</span></div><div class="docker-actions">${c.state==='running'?`<button class="danger" data-docker-action="stop" data-docker-id="${esc(c.id)}">STOP</button><button data-docker-action="restart" data-docker-id="${esc(c.id)}">RESTART</button>`:`<button data-docker-action="start" data-docker-id="${esc(c.id)}">START</button>`}</div></article>`).join(''):'<div class="empty">KEINE GELABELTEN SKILL-CONTAINER</div>';$$('[data-docker-action]').forEach(b=>b.onclick=async()=>{b.disabled=true;try{const out=await api(`/api/docker/skill-services/${encodeURIComponent(b.dataset.dockerId)}/${encodeURIComponent(b.dataset.dockerAction)}`,{method:'POST'});toast(out.ok?`Container ${b.dataset.dockerAction}`:(out.error||'Docker-Aktion fehlgeschlagen'));setTimeout(refresh,700)}catch(e){toast('Docker: '+e.message)}finally{b.disabled=false}})}}}
function renderActivity(){const xs=state.activity||[];$('#activityList').innerHTML=xs.length?xs.slice(0,20).map(a=>`<div class="activity ${esc(a.kind)}"><i></i><div><strong>${esc(a.message)}</strong><span>${esc(fmtTime(a.createdAt))} · ${esc(String(a.kind||'event').toUpperCase())}</span></div></div>`).join(''):'<div class="empty">NO TELEMETRY EVENTS</div>'}
function renderInitialChat(){const conv=$('#conversation'),turns=(state.chat||[]).slice(-12);if(!turns.length)return;conv.innerHTML='';turns.forEach(t=>addMessage(t.content,t.role==='user'?'user':'assistant',false));conv.scrollTop=conv.scrollHeight}
function addMessage(text,role='assistant',animate=true,mode=''){const conv=$('#conversation'),el=document.createElement('div');el.className=`message ${role} ${mode||''}`.trim();const label=role==='user'?'OPERATOR':mode==='action'?'JARVIS // COMMIT':mode==='clarification'?'JARVIS // CLARIFY':'JARVIS';el.innerHTML=`<div class="avatar">${role==='user'?'U':'J'}</div><div><small>${label}</small><p></p></div>`;$('p',el).innerHTML=messageHTML(text);if(animate)el.style.opacity='0';conv.appendChild(el);conv.scrollTop=conv.scrollHeight;if(animate)requestAnimationFrame(()=>{el.style.transition='.22s';el.style.opacity='1'});return el}
function setCoreMode(mode,caption){$('#voiceModeLabel').textContent=mode;const active=!['STANDBY','READY'].includes(mode);$('#coreVisual').classList.toggle('active',active);if(caption)$('#overlayTranscript').dataset.caption=caption}
async function post(url,obj){return api(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(obj)})}
async function deleteEntity(prefix,id,msg){try{await api(prefix+encodeURIComponent(id),{method:'DELETE'});toast(msg);await refresh()}catch(e){toast(e.message)}}
async function sendCommand(message,fromVoice=false,contextName=''){const raw=String(message||'').trim();if(!raw)return;if(commandBusy){toast('JARVIS verarbeitet bereits einen Befehl');return}commandBusy=true;const exec=$('#commandForm .execute');exec.disabled=true;exec.textContent='PROCESSING';$('#commandInput').value='';addMessage(contextName?`[${contextName}] ${raw}`:raw,'user');setCoreMode('THINKING');const pending=addMessage('Agent → Tool wählen → JSON validieren → ausführen …','assistant');pending.classList.add('pending');const routed=contextName?`Im Kontext des Moduls ${contextName}: ${raw}`:raw;try{const out=await post('/api/ai/chat',{message:routed});lastTraceId=out.traceId||'';pending.remove();addMessage(out.reply,'assistant',true,out.mode||'');renderSources(out.sources||[],out.automations||[],out.mode||'chat',lastTraceId,out.toolResults||[],out.plan||null);if(out.automations?.length)toast(out.automations.map(a=>a.message).join(' · '));setCoreMode(out.mode==='clarification'?'AWAITING INPUT':'READY');if(fromVoice)await speakLocal(out.reply);await refresh()}catch(e){pending.remove();addMessage(`Systemfehler: ${e.message}`,'assistant');setCoreMode('STANDBY')}finally{commandBusy=false;exec.disabled=false;exec.innerHTML='EXECUTE <span>↗</span>'}setTimeout(()=>setCoreMode(wakeEnabled?'WAKE ARMED':'STANDBY'),1400)}
function renderSources(sources,actions=[],mode='chat',traceId='',toolResults=[],plan=null){const trace=traceId?`<span class="source-chip trace-chip" title="${esc(traceId)}">TRACE ${esc(traceId.slice(-12).toUpperCase())}</span>`:'';const planTitle=plan?.steps?.length?plan.steps.map((s,i)=>`${i+1}. ${s.task||s.tool}`).join('\n'):'';const planChip=plan?.steps?.length?`<span class="source-chip plan-chip" title="${esc(planTitle)}"><b>PLAN</b> · ${plan.steps.length} SCHRITTE</span>`:'';const toolChips=(toolResults||[]).slice(0,8).map(t=>`<span class="source-chip" title="${esc(t.request_id||'')}">${esc(String(t.tool||'TOOL').toUpperCase())}${t.success===false?' · ERROR':''}</span>`).join('');if(actions.length){$('#sourceRail').innerHTML=`<span class="source-chip committed"><b>TOOL COMMIT</b> · ${actions.length} MUTATION${actions.length===1?'':'S'}</span>`+planChip+toolChips+trace;return}if(toolChips||planChip){$('#sourceRail').innerHTML=`<span class="source-chip"><b>TOOL AGENT</b></span>`+planChip+toolChips+trace;return}$('#sourceRail').innerHTML=(sources.length?sources.slice(0,7).map(s=>`<span class="source-chip"><b>${esc(s.name)}</b> · ${Math.round((s.score||0)*100)}%</span>`).join(''):`<span class="source-chip">${mode==='clarification'?'AGENT AWAITS INPUT':'LOCAL TOOL CORE'}</span>`)+trace}
$('#commandForm').onsubmit=e=>{e.preventDefault();sendCommand($('#commandInput').value)};$('#commandInput').onkeydown=e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();$('#commandForm').requestSubmit()}};
$('#clearChatBtn').onclick=async()=>{try{await api('/api/chat',{method:'DELETE'});state.chat=[];const conv=$('#conversation');conv.innerHTML='';addMessage('Command channel bereinigt. Home-Daten, Kalender, Aufgaben und RAG bleiben unverändert.','assistant',false,'action');renderSources([],[],'chat');toast('Chatverlauf geleert')}catch(e){toast(e.message)}};
$('#debugExportBtn').onclick=()=>{const a=document.createElement('a');a.href='/api/debug/export?limit=10000';a.download='jarvis-debug.json';document.body.appendChild(a);a.click();a.remove();toast(lastTraceId?`Debug-Export · letzter Trace ${lastTraceId.slice(-12)}`:'Debug-Export wird erstellt')};
$('#debugClearBtn').onclick=async()=>{if(!confirm('Nur den Debug-Trace löschen? Home-Daten und Chat bleiben erhalten.'))return;try{await api('/api/debug',{method:'DELETE'});toast('Debug-Trace geleert')}catch(e){toast(e.message)}};
$('#automationBtn').onclick=async()=>{const b=$('#automationBtn');b.disabled=true;b.textContent='SYNCHRONIZING';try{const o=await api('/api/automation/run',{method:'POST'});toast(`Einkauf ${o.groceries} · Reminder ${o.reminders} · Kanban ${o.kanban}`);await refresh()}catch(e){toast(e.message)}finally{b.disabled=false;b.textContent='RUN HOME AUTOMATION'}};
$('#scoutBtn').onclick=async()=>{const b=$('#scoutBtn');b.disabled=true;b.textContent='SCANNING';setCoreMode('SCANNING');try{const out=await api('/api/ai/scout',{method:'POST'});toast(`${out.created} neue Aufgabe(n)`);await refresh()}catch(e){toast(e.message)}finally{b.disabled=false;b.textContent='SCAN FOR TASKS';setCoreMode('STANDBY')}};
$('#kanbanAI').onclick=async()=>{const b=$('#kanbanAI');b.disabled=true;b.textContent='ORGANIZING';try{const out=await api('/api/ai/kanban',{method:'POST'});toast(`${out.updated} KI-Karten gepflegt`);await refresh()}catch(e){toast(e.message)}finally{b.disabled=false;b.textContent='AI ORGANIZE'}};
const drop=$('#dropzone'),fileInput=$('#fileInput');['dragenter','dragover'].forEach(ev=>drop.addEventListener(ev,e=>{e.preventDefault();drop.classList.add('drag')}));['dragleave','drop'].forEach(ev=>drop.addEventListener(ev,e=>{e.preventDefault();drop.classList.remove('drag')}));drop.ondrop=e=>{const f=e.dataTransfer.files?.[0];if(f)uploadDocument(f)};fileInput.onchange=()=>{if(fileInput.files?.[0])uploadDocument(fileInput.files[0]);fileInput.value=''};
async function uploadDocument(file){const fd=new FormData();fd.append('file',file);toast('Dokument wird lokal indexiert …');setCoreMode('INDEXING');try{const out=await api('/api/documents',{method:'POST',body:fd});toast(out.indexed?'Dokument im RAG':'Text gespeichert, Indexierung fehlgeschlagen');await refresh()}catch(e){toast(e.message)}finally{setCoreMode('STANDBY')}}
if($('#reloadSkillsBtn'))$('#reloadSkillsBtn').onclick=async()=>{try{const out=await api('/api/skills/reload',{method:'POST'});toast(out.ok?'Skills neu geladen':(out.warning||'Skills mit Warnungen geladen'));await refresh()}catch(e){toast('Skills: '+e.message)}};
if($('#refreshMeshBtn'))$('#refreshMeshBtn').onclick=async()=>{try{await refresh();toast('Skill Mesh aktualisiert')}catch(e){toast('Mesh: '+e.message)}};
$('#reindexBtn').onclick=async()=>{const b=$('#reindexBtn');b.disabled=true;b.textContent='INDEXING';try{const o=await api('/api/rag/reindex',{method:'POST'});toast(`${o.indexed} Dokument(e), ${o.chunks} Chunks`);await refresh()}catch(e){toast(e.message)}finally{b.disabled=false;b.textContent='REINDEX'}};
const dialogDefs={
task:{title:'Neue Aufgabe',code:'ACTION QUEUE',endpoint:'/api/tasks',fields:[['title','Titel','text',true],['priority','Priorität','select',false,['normal','high','low']],['due','Fällig','datetime-local'],['notes','Notizen','textarea']]},
event:{title:'Neuer Termin',code:'TEMPORAL MATRIX',endpoint:'/api/events',fields:[['title','Titel','text',true],['start','Start','datetime-local',true],['end','Ende','datetime-local'],['location','Ort','text'],['notes','Notizen','textarea']]},
reminder:{title:'Neue Erinnerung',code:'ALERT GRID',endpoint:'/api/reminders',fields:[['title','Titel','text',true],['at','Zeitpunkt','datetime-local',true],['notes','Notizen','textarea']]},
grocery:{title:'Einkauf ergänzen',code:'SUPPLY MATRIX',endpoint:'/api/groceries',fields:[['name','Artikel','text',true],['quantity','Menge','text'],['unit','Einheit','text'],['category','Kategorie','text']]},
meal:{title:'Essen planen',code:'NUTRITION SCHEDULE',endpoint:'/api/meal-plans',fields:[['date','Datum','date',true],['meal','Mahlzeit','select',false,['breakfast','lunch','dinner','snack']],['recipeId','Rezept','recipe-select'],['title','Freies Gericht','text'],['servings','Portionen','number'],['notes','Notizen','textarea']]},
recipe:{title:'Rezept speichern',code:'CULINARY MEMORY',endpoint:'/api/recipes',fields:[['title','Titel','text',true],['servings','Portionen','number'],['prepMinutes','Zubereitung (Min.)','number'],['description','Beschreibung','textarea'],['ingredientsText','Zutaten · je Zeile: Menge | Einheit | Name','textarea'],['stepsText','Schritte · einer je Zeile','textarea'],['tagsText','Tags · kommagetrennt','text']]},
kanban:{title:'Manuelle Kanban-Karte',code:'FLOW ENGINE',endpoint:'/api/kanban',fields:[['title','Titel','text',true],['column','Spalte','select',false,['inbox','next','doing','waiting','done']],['priority','Priorität','select',false,['normal','high','low']],['due','Fällig','datetime-local'],['notes','Notizen','textarea']]}
};
function fieldHTML(f){const[name,label,type,required,opts]=f,req=required?'required':'';if(type==='textarea')return `<div class="field full"><label>${esc(label)}</label><textarea name="${name}" ${req}></textarea></div>`;if(type==='select')return `<div class="field"><label>${esc(label)}</label><select name="${name}">${opts.map(o=>`<option value="${o}">${esc(o.toUpperCase())}</option>`).join('')}</select></div>`;if(type==='recipe-select')return `<div class="field"><label>${esc(label)}</label><select name="${name}"><option value="">— FREIES GERICHT —</option>${(state.recipes||[]).map(r=>`<option value="${esc(r.id)}">${esc(r.title)}</option>`).join('')}</select></div>`;return `<div class="field"><label>${esc(label)}</label><input name="${name}" type="${type}" ${req}></div>`}
function openDialog(type,existing=null){const d=dialogDefs[type];if(!d)return;dialogType=type;dialogEditing=existing?structuredClone(existing):null;$('#dialogCode').textContent=d.code;$('#dialogTitle').textContent=existing&&type==='recipe'?'Rezept bearbeiten':d.title;$('#dialogFields').innerHTML=d.fields.map(fieldHTML).join('');const form=$('#entityForm');form.reset();if(type==='meal')form.elements.date.value=localDateKey(new Date());if(type==='task'||type==='kanban')form.elements.priority.value='normal';if(type==='kanban')form.elements.column.value='inbox';if(type==='meal'){form.elements.meal.value='dinner';form.elements.servings.value='2'}if(type==='recipe')form.elements.servings.value='2';if(existing){for(const [k,val] of Object.entries(existing)){if(form.elements[k]&&val!=null)form.elements[k].value=val}if(type==='recipe'){form.elements.ingredientsText.value=(existing.ingredients||[]).map(i=>[i.quantity||'',i.unit||'',i.name||''].join(' | ')).join('\n');form.elements.stepsText.value=(existing.steps||[]).join('\n');form.elements.tagsText.value=(existing.tags||[]).join(', ')}}$('#entityDialog').showModal()}
$$('[data-add]').forEach(b=>b.onclick=()=>openDialog(b.dataset.add));const syncMealGroceriesBtn=$('#syncMealGroceriesBtn');if(syncMealGroceriesBtn)syncMealGroceriesBtn.onclick=async()=>{try{const out=await post('/api/automation/run',{});toast(`Einkauf aus Essensplan aktualisiert${out?.groceries!=null?` · ${out.groceries} Änderungen`:''}`);await refresh()}catch(e){toast(e.message)}};$('#dialogClose').onclick=$('#dialogCancel').onclick=()=>{dialogEditing=null;$('#entityDialog').close()};
$('#entityForm').onsubmit=async e=>{e.preventDefault();const def=dialogDefs[dialogType],fd=new FormData(e.currentTarget),raw=Object.fromEntries(fd.entries()),v=dialogEditing?{...dialogEditing,...raw}:raw;['due','start','end','at'].forEach(k=>{if(v[k])v[k]=new Date(v[k]).toISOString();else if(k in v)delete v[k]});['servings','prepMinutes'].forEach(k=>{if(v[k])v[k]=Number(v[k])});if(dialogType==='recipe'){v.ingredients=String(v.ingredientsText||'').split('\n').map(x=>x.trim()).filter(Boolean).map(line=>{const p=line.split('|').map(x=>x.trim());return p.length>=3?{quantity:p[0],unit:p[1],name:p.slice(2).join(' | ')}:{name:line}});v.steps=String(v.stepsText||'').split('\n').map(x=>x.trim()).filter(Boolean);v.tags=String(v.tagsText||'').split(',').map(x=>x.trim()).filter(Boolean);delete v.ingredientsText;delete v.stepsText;delete v.tagsText}if(dialogType==='meal'&&v.recipeId){const r=state.recipes.find(x=>x.id===v.recipeId);if(r&&!v.title)v.title=r.title}if(dialogType==='task'){v.status='open';v.aiManaged=false}if(dialogType==='kanban'){v.aiManaged=false;v.source='manual'}try{await post(def.endpoint,v);dialogEditing=null;$('#entityDialog').close();toast('Gespeichert');await refresh()}catch(err){toast(err.message)}};
function mediaOptions(){for(const t of['audio/webm;codecs=opus','audio/webm','audio/ogg;codecs=opus','audio/mp4'])if(window.MediaRecorder?.isTypeSupported?.(t))return{mimeType:t};return{}}function extFor(type){if(type.includes('ogg'))return'.ogg';if(type.includes('mp4'))return'.mp4';return'.webm'}async function ensureMic(){if(!navigator.mediaDevices?.getUserMedia)throw new Error('Browser unterstützt keinen Mikrofonzugriff');return navigator.mediaDevices.getUserMedia({audio:{echoCancellation:true,noiseSuppression:true,autoGainControl:true}})}async function transcribeBlob(blob){const fd=new FormData();fd.append('audio',blob,'voice'+extFor(blob.type||'audio/webm'));const out=await api('/api/voice/transcribe',{method:'POST',body:fd});return String(out.text||'').trim()}
async function startPTT(contextName=''){if(pttRecorder){stopPTT();return}if(!(state.system?.voice?.sttReady)){toast(voiceSetupMessage(state.system?.voice||{},'stt'));return}pttContext=contextName;const resumeWake=wakeEnabled;if(resumeWake)await stopWake(false);try{pttStream=await ensureMic();pttChunks=[];pttRecorder=new MediaRecorder(pttStream,mediaOptions());pttRecorder.ondataavailable=e=>{if(e.data?.size)pttChunks.push(e.data)};pttRecorder.onstop=async()=>{const rec=pttRecorder;pttRecorder=null;const blob=new Blob(pttChunks,{type:rec?.mimeType||'audio/webm'});pttChunks=[];pttStream?.getTracks().forEach(t=>t.stop());pttStream=null;$('#globalMic').classList.remove('recording');$('#voiceOverlay').classList.remove('show');setCoreMode('TRANSCRIBE');try{const text=await transcribeBlob(blob);$('#overlayTranscript').textContent=text||'Kein Sprachsignal erkannt';if(text)await sendCommand(text,true,pttContext)}catch(e){toast(e.message);setCoreMode('STANDBY')}pttContext='';if(resumeWake&&$('#wakeToggle').checked)startWake()};pttRecorder.start();$('#globalMic').classList.add('recording');$('#voiceOverlay').classList.add('show');$('#voiceContextLabel').textContent=(contextName||'GLOBAL').toUpperCase();$('#overlayStatus').textContent='LISTENING';$('#overlayTranscript').textContent='Sprich jetzt · erneut drücken zum Senden';setCoreMode('LISTENING')}catch(e){toast(e.message);pttContext='';if(resumeWake&&$('#wakeToggle').checked)startWake()}}
function stopPTT(){if(pttRecorder&&pttRecorder.state!=='inactive'){$('#overlayStatus').textContent='PROCESSING';pttRecorder.stop()}}$('#globalMic').onclick=()=>startPTT('');$('#inlineMic').onclick=()=>startPTT('');$$('.module-mic').forEach(b=>b.onclick=()=>startPTT(b.dataset.voiceContext||''));
function recordFixedChunk(stream,ms){return new Promise((resolve,reject)=>{let chunks=[],rec;try{rec=new MediaRecorder(stream,mediaOptions())}catch(e){reject(e);return}rec.ondataavailable=e=>{if(e.data?.size)chunks.push(e.data)};rec.onerror=e=>reject(e.error||new Error('Recorder error'));rec.onstop=()=>resolve(new Blob(chunks,{type:rec.mimeType||'audio/webm'}));rec.start();setTimeout(()=>{if(rec.state!=='inactive')rec.stop()},ms)})}
async function startWake(){if(wakeEnabled)return;if(!(state.system?.voice?.sttReady)){$('#wakeToggle').checked=false;toast(voiceSetupMessage(state.system?.voice||{},'stt'));return}wakeEnabled=true;const token=++wakeToken;try{wakeStream=await ensureMic();$('#micDiag').textContent='HOT';$('#micDiag').className='good';$('#wakeHelp').textContent=`Lokaler Wake-Scan aktiv. Sage „${String(state.system.voice.wakeWord||'jarvis').toUpperCase()}“ und danach deinen Befehl.`;setCoreMode('WAKE ARMED')}catch(e){wakeEnabled=false;$('#wakeToggle').checked=false;toast(e.message);return}while(wakeEnabled&&token===wakeToken){try{const blob=await recordFixedChunk(wakeStream,3000);if(!wakeEnabled||token!==wakeToken||speaking||blob.size<1000)continue;if(wakeBusy)continue;wakeBusy=true;let text='';try{text=await transcribeBlob(blob)}catch(e){if(wakeEnabled)console.debug(e.message)}finally{wakeBusy=false}if(text)processWakeTranscript(text)}catch(e){if(wakeEnabled)toast('Wake-Word: '+e.message);break}}}
async function stopWake(updateToggle=true){wakeEnabled=false;++wakeToken;wakeStream?.getTracks().forEach(t=>t.stop());wakeStream=null;wakeArmedUntil=0;$('#micDiag').textContent='READY';$('#micDiag').className='';if(updateToggle)$('#wakeToggle').checked=false;$('#wakeHelp').textContent='Wake-Word aus. Jeder Modul-Button und der globale Voice-Core starten Push-to-talk.';setCoreMode('STANDBY')}
function processWakeTranscript(raw){const text=raw.trim(),wake=String(state.system?.voice?.wakeWord||'jarvis').toLowerCase(),lower=text.toLowerCase(),idx=lower.indexOf(wake);if(idx>=0){const after=text.slice(idx+wake.length).replace(/^[\s,.:;!\-]+/,'').trim();wakeArmedUntil=Date.now()+8500;$('#voiceOverlay').classList.add('show');$('#voiceContextLabel').textContent='GLOBAL';$('#overlayStatus').textContent='WAKE CONFIRMED';$('#overlayTranscript').textContent=after||'Ich höre …';setCoreMode('COMMAND');if(after.length>1){wakeArmedUntil=0;$('#voiceOverlay').classList.remove('show');sendCommand(after,true)}else setTimeout(()=>{if(Date.now()>wakeArmedUntil)$('#voiceOverlay').classList.remove('show')},9000);return}if(Date.now()<wakeArmedUntil&&text.length>1){wakeArmedUntil=0;$('#voiceOverlay').classList.remove('show');sendCommand(text,true)}}$('#wakeToggle').onchange=e=>{if(e.target.checked)startWake();else stopWake()};
async function speakLocal(text){if(!(state.system?.voice?.ttsReady))return;speaking=true;setCoreMode('SPEAKING');try{const res=await fetch('/api/voice/speak',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text})});if(!res.ok)throw new Error((await res.text()).trim()||`TTS HTTP ${res.status}`);const blob=await res.blob(),url=URL.createObjectURL(blob),audio=new Audio(url);await new Promise((resolve,reject)=>{audio.onended=resolve;audio.onerror=reject;audio.play().catch(reject)});URL.revokeObjectURL(url)}catch(e){toast('TTS: '+e.message)}finally{speaking=false;setCoreMode(wakeEnabled?'WAKE ARMED':'STANDBY')}}
window.addEventListener('beforeunload',()=>{pttStream?.getTracks().forEach(t=>t.stop());wakeStream?.getTracks().forEach(t=>t.stop())});initTheme();refresh();setInterval(refresh,9000);