135 lines
48 KiB
JavaScript
135 lines
48 KiB
JavaScript
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=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[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);
|