Loading editor/html/js/api.js +83 −2 Changes for editor/html/js/api.js: 83 added lines, 2 removed lines. Original line number Diff line number Diff line Loading @@ -306,13 +306,94 @@ var EditorApi = (function() { }); }, aiPrompt: function(prompt, desktop, mobile, includeDocument) { return requestCancellable('POST', '/api/ai/prompt', { // Streams the response as Server-Sent Events (content_delta/tool_call/done/ // error) via onEvent as they arrive; falls back to a single synthesized // "done" event if the server replied with a plain JSON body instead (the // HTTP/2 and HTTP/3 fallback path, which can't do chunked SSE — see // Api::handleAiPrompt). Resolves with {result, applied, tool_calls} either // way once the "done" event has been seen. aiPrompt: function(prompt, desktop, mobile, includeDocument, onEvent) { var controller = new AbortController(); var promise = fetch('/api/ai/prompt'.replace(/^\//, ''), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: prompt, desktop: desktop, mobile: mobile, include_document: includeDocument }), signal: controller.signal }).then(function(response) { if (response.status === 401) { window.location.href = 'login.html'; throw { error: 'Not authenticated' }; } if (!response.ok) { return response.text().then(function(text) { var err; try { err = JSON.parse(text); } catch(e) { /* fall through */ } throw err || { error: response.statusText || 'Request failed' }; }); } var contentType = response.headers.get('content-type') || ''; if (contentType.indexOf('text/event-stream') === -1) { // Non-streaming fallback (HTTP/2 or HTTP/3 connection). return response.json().then(function(data) { var done = { type: 'done', result: data.result, applied: data.applied, tool_calls: data.tool_calls }; if (onEvent) onEvent(done); return { result: data.result, applied: data.applied, tool_calls: data.tool_calls }; }); } var reader = response.body.getReader(); var decoder = new TextDecoder(); var buf = ''; var finalPayload = null; function pump() { return reader.read().then(function(step) { if (step.done) return; buf += decoder.decode(step.value, { stream: true }); var idx; while ((idx = buf.indexOf('\n\n')) !== -1) { var block = buf.slice(0, idx); buf = buf.slice(idx + 2); var lines = block.split('\n'); var dataLine = null; for (var i = 0; i < lines.length; i++) { if (lines[i].indexOf('data:') === 0) { dataLine = lines[i]; break; } } if (!dataLine) continue; var payload = dataLine.slice(5).replace(/^ /, ''); if (!payload || payload === '[DONE]') continue; var ev; try { ev = JSON.parse(payload); } catch(e) { continue; } if (onEvent) onEvent(ev); if (ev.type === 'done') finalPayload = ev; if (ev.type === 'error') throw { error: ev.message || 'AI request failed' }; } return pump(); }); } return pump().then(function() { if (!finalPayload) throw { error: 'Stream ended unexpectedly' }; return { result: finalPayload.result, applied: finalPayload.applied, tool_calls: finalPayload.tool_calls }; }); }).catch(function(err) { if (err && err.name === 'AbortError') throw { error: 'aborted', aborted: true }; throw err; }); return { promise: promise, abort: function() { controller.abort(); } }; }, // AI chat persistence Loading editor/html/js/editor.js +121 −12 Changes for editor/html/js/editor.js: 121 added lines, 12 removed lines. Original line number Diff line number Diff line Loading @@ -1630,8 +1630,9 @@ } addMessage('user', userLabel); // Show status // Show status until the first streamed event arrives var statusEl = addMessage('status', I18n.t('I18N_AI_PROCESSING', 'KI verarbeitet...')); var streamMsg = null; promptEl.value = ''; pendingFiles = []; Loading @@ -1642,26 +1643,38 @@ sendBtn.title = 'Stopp'; sendBtn.classList.add('ai-stop-btn'); var req = EditorApi.aiPrompt(fullPrompt, desktop, mobile, includeDoc); currentAiRequest = req; req.promise.then(function(resp) { currentAiRequest = null; statusEl.remove(); var result = resp.result || ''; if (resp.applied) { var req = EditorApi.aiPrompt(fullPrompt, desktop, mobile, includeDoc, function(ev) { if (statusEl) { statusEl.remove(); statusEl = null; } if (!streamMsg) streamMsg = createStreamingAssistantMessage(); if (ev.type === 'content_delta') { streamMsg.appendText(ev.text || ''); } else if (ev.type === 'tool_call') { streamMsg.setStatus(I18n.t('I18N_AI_CALLING_TOOL', 'Werkzeug wird aufgerufen: ') + (ev.name || '')); } else if (ev.type === 'error') { addMessage('error', I18n.t('I18N_AI_FAILED', 'KI-Anfrage fehlgeschlagen') + ': ' + (ev.message || '')); } else if (ev.type === 'done') { if (ev.applied) { Canvas.clearSelection(); DocumentTree.clearSelection(); PropertiesPanel.clear(); refreshDocument(); } addAssistantMessage(result, false, !!resp.applied, resp.tool_calls || 0); streamMsg.finalize(!!ev.applied, ev.tool_calls || 0); } }); currentAiRequest = req; req.promise.then(function() { currentAiRequest = null; resetSendBtn(); }).catch(function(err) { currentAiRequest = null; statusEl.remove(); if (statusEl) { statusEl.remove(); statusEl = null; } if (err && err.aborted) { addMessage('status', I18n.t('I18N_AI_CANCELLED', 'Abgebrochen')); } else { } else if (!streamMsg) { // If a stream was already open, an in-band "error" event (above) // already reported this -- avoid a second, redundant bubble. addMessage('error', I18n.t('I18N_AI_FAILED', 'KI-Anfrage fehlgeschlagen') + ': ' + (err.error || '')); } resetSendBtn(); Loading @@ -1685,6 +1698,102 @@ return div; } // Live-updating counterpart of addAssistantMessage, used while a // streaming response is still in progress: creates the bubble // immediately (empty), lets the caller append text / show a status // line as SSE events arrive, then finalize() adds the applied-status // line and Copy/Replace/Child-insert actions exactly like // addAssistantMessage does once the "done" event is known. function createStreamingAssistantMessage() { var div = document.createElement('div'); div.className = 'ai-msg assistant'; var content = document.createElement('div'); div.appendChild(content); var statusLine = document.createElement('div'); statusLine.className = 'ai-msg-status'; statusLine.style.display = 'none'; div.appendChild(statusLine); chatHistory.appendChild(div); chatHistory.scrollTop = chatHistory.scrollHeight; var fullText = ''; return { appendText: function(piece) { fullText += piece; content.textContent = fullText; statusLine.style.display = 'none'; chatHistory.scrollTop = chatHistory.scrollHeight; }, setStatus: function(text) { statusLine.textContent = text; statusLine.style.display = text ? '' : 'none'; chatHistory.scrollTop = chatHistory.scrollHeight; }, finalize: function(applied, toolCalls) { if (applied) { statusLine.textContent = '✓ ' + toolCalls + ' ' + I18n.t('I18N_AI_APPLIED', 'Änderung(en) direkt am Dokument angewendet'); statusLine.style.display = ''; } else { statusLine.style.display = 'none'; } var actions = document.createElement('div'); actions.className = 'ai-msg-actions'; var copyBtn = document.createElement('button'); copyBtn.textContent = I18n.t('I18N_COPY', 'Kopieren'); copyBtn.addEventListener('click', function() { navigator.clipboard.writeText(fullText).then(function() { copyBtn.textContent = '✓'; setTimeout(function() { copyBtn.textContent = I18n.t('I18N_COPY', 'Kopieren'); }, 1500); }); }); actions.appendChild(copyBtn); if (!applied) { function doImport(mode) { var htmlContent = extractHtml(fullText); var parentUuid = (mode === 'child') ? Canvas.getSelectedUuid() : null; if (mode === 'child' && !parentUuid) { addMessage('error', 'Kein Element ausgewählt. Bitte im Dokumentbaum ein Element auswählen.'); return; } var importStatusEl = addMessage('status', I18n.t('I18N_AI_PROCESSING', 'Importiere...')); EditorApi.importHtml(htmlContent, mode, parentUuid).then(function() { importStatusEl.remove(); addMessage('status', '✓ ' + I18n.t('I18N_IMPORT_HTML', 'Importiert')); Canvas.clearSelection(); DocumentTree.clearSelection(); PropertiesPanel.clear(); refreshDocument(); }).catch(function(err) { importStatusEl.remove(); addMessage('error', I18n.t('I18N_IMPORT_FAILED', 'Import fehlgeschlagen') + ': ' + (err.error || '')); }); } var replaceBtn = document.createElement('button'); replaceBtn.textContent = '📄 ' + I18n.t('I18N_REPLACE_DOC', 'Dokument ersetzen'); replaceBtn.addEventListener('click', function() { doImport('replace'); }); actions.appendChild(replaceBtn); var childBtn = document.createElement('button'); childBtn.textContent = '⬇ ' + I18n.t('I18N_INSERT_CHILD', 'Child einfügen'); childBtn.addEventListener('click', function() { doImport('child'); }); actions.appendChild(childBtn); } div.appendChild(actions); chatHistory.scrollTop = chatHistory.scrollHeight; saveChat(); } }; } function addAssistantMessage(text, noSave, applied, toolCalls) { var div = document.createElement('div'); div.className = 'ai-msg assistant'; Loading Loading
editor/html/js/api.js +83 −2 Changes for editor/html/js/api.js: 83 added lines, 2 removed lines. Original line number Diff line number Diff line Loading @@ -306,13 +306,94 @@ var EditorApi = (function() { }); }, aiPrompt: function(prompt, desktop, mobile, includeDocument) { return requestCancellable('POST', '/api/ai/prompt', { // Streams the response as Server-Sent Events (content_delta/tool_call/done/ // error) via onEvent as they arrive; falls back to a single synthesized // "done" event if the server replied with a plain JSON body instead (the // HTTP/2 and HTTP/3 fallback path, which can't do chunked SSE — see // Api::handleAiPrompt). Resolves with {result, applied, tool_calls} either // way once the "done" event has been seen. aiPrompt: function(prompt, desktop, mobile, includeDocument, onEvent) { var controller = new AbortController(); var promise = fetch('/api/ai/prompt'.replace(/^\//, ''), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: prompt, desktop: desktop, mobile: mobile, include_document: includeDocument }), signal: controller.signal }).then(function(response) { if (response.status === 401) { window.location.href = 'login.html'; throw { error: 'Not authenticated' }; } if (!response.ok) { return response.text().then(function(text) { var err; try { err = JSON.parse(text); } catch(e) { /* fall through */ } throw err || { error: response.statusText || 'Request failed' }; }); } var contentType = response.headers.get('content-type') || ''; if (contentType.indexOf('text/event-stream') === -1) { // Non-streaming fallback (HTTP/2 or HTTP/3 connection). return response.json().then(function(data) { var done = { type: 'done', result: data.result, applied: data.applied, tool_calls: data.tool_calls }; if (onEvent) onEvent(done); return { result: data.result, applied: data.applied, tool_calls: data.tool_calls }; }); } var reader = response.body.getReader(); var decoder = new TextDecoder(); var buf = ''; var finalPayload = null; function pump() { return reader.read().then(function(step) { if (step.done) return; buf += decoder.decode(step.value, { stream: true }); var idx; while ((idx = buf.indexOf('\n\n')) !== -1) { var block = buf.slice(0, idx); buf = buf.slice(idx + 2); var lines = block.split('\n'); var dataLine = null; for (var i = 0; i < lines.length; i++) { if (lines[i].indexOf('data:') === 0) { dataLine = lines[i]; break; } } if (!dataLine) continue; var payload = dataLine.slice(5).replace(/^ /, ''); if (!payload || payload === '[DONE]') continue; var ev; try { ev = JSON.parse(payload); } catch(e) { continue; } if (onEvent) onEvent(ev); if (ev.type === 'done') finalPayload = ev; if (ev.type === 'error') throw { error: ev.message || 'AI request failed' }; } return pump(); }); } return pump().then(function() { if (!finalPayload) throw { error: 'Stream ended unexpectedly' }; return { result: finalPayload.result, applied: finalPayload.applied, tool_calls: finalPayload.tool_calls }; }); }).catch(function(err) { if (err && err.name === 'AbortError') throw { error: 'aborted', aborted: true }; throw err; }); return { promise: promise, abort: function() { controller.abort(); } }; }, // AI chat persistence Loading
editor/html/js/editor.js +121 −12 Changes for editor/html/js/editor.js: 121 added lines, 12 removed lines. Original line number Diff line number Diff line Loading @@ -1630,8 +1630,9 @@ } addMessage('user', userLabel); // Show status // Show status until the first streamed event arrives var statusEl = addMessage('status', I18n.t('I18N_AI_PROCESSING', 'KI verarbeitet...')); var streamMsg = null; promptEl.value = ''; pendingFiles = []; Loading @@ -1642,26 +1643,38 @@ sendBtn.title = 'Stopp'; sendBtn.classList.add('ai-stop-btn'); var req = EditorApi.aiPrompt(fullPrompt, desktop, mobile, includeDoc); currentAiRequest = req; req.promise.then(function(resp) { currentAiRequest = null; statusEl.remove(); var result = resp.result || ''; if (resp.applied) { var req = EditorApi.aiPrompt(fullPrompt, desktop, mobile, includeDoc, function(ev) { if (statusEl) { statusEl.remove(); statusEl = null; } if (!streamMsg) streamMsg = createStreamingAssistantMessage(); if (ev.type === 'content_delta') { streamMsg.appendText(ev.text || ''); } else if (ev.type === 'tool_call') { streamMsg.setStatus(I18n.t('I18N_AI_CALLING_TOOL', 'Werkzeug wird aufgerufen: ') + (ev.name || '')); } else if (ev.type === 'error') { addMessage('error', I18n.t('I18N_AI_FAILED', 'KI-Anfrage fehlgeschlagen') + ': ' + (ev.message || '')); } else if (ev.type === 'done') { if (ev.applied) { Canvas.clearSelection(); DocumentTree.clearSelection(); PropertiesPanel.clear(); refreshDocument(); } addAssistantMessage(result, false, !!resp.applied, resp.tool_calls || 0); streamMsg.finalize(!!ev.applied, ev.tool_calls || 0); } }); currentAiRequest = req; req.promise.then(function() { currentAiRequest = null; resetSendBtn(); }).catch(function(err) { currentAiRequest = null; statusEl.remove(); if (statusEl) { statusEl.remove(); statusEl = null; } if (err && err.aborted) { addMessage('status', I18n.t('I18N_AI_CANCELLED', 'Abgebrochen')); } else { } else if (!streamMsg) { // If a stream was already open, an in-band "error" event (above) // already reported this -- avoid a second, redundant bubble. addMessage('error', I18n.t('I18N_AI_FAILED', 'KI-Anfrage fehlgeschlagen') + ': ' + (err.error || '')); } resetSendBtn(); Loading @@ -1685,6 +1698,102 @@ return div; } // Live-updating counterpart of addAssistantMessage, used while a // streaming response is still in progress: creates the bubble // immediately (empty), lets the caller append text / show a status // line as SSE events arrive, then finalize() adds the applied-status // line and Copy/Replace/Child-insert actions exactly like // addAssistantMessage does once the "done" event is known. function createStreamingAssistantMessage() { var div = document.createElement('div'); div.className = 'ai-msg assistant'; var content = document.createElement('div'); div.appendChild(content); var statusLine = document.createElement('div'); statusLine.className = 'ai-msg-status'; statusLine.style.display = 'none'; div.appendChild(statusLine); chatHistory.appendChild(div); chatHistory.scrollTop = chatHistory.scrollHeight; var fullText = ''; return { appendText: function(piece) { fullText += piece; content.textContent = fullText; statusLine.style.display = 'none'; chatHistory.scrollTop = chatHistory.scrollHeight; }, setStatus: function(text) { statusLine.textContent = text; statusLine.style.display = text ? '' : 'none'; chatHistory.scrollTop = chatHistory.scrollHeight; }, finalize: function(applied, toolCalls) { if (applied) { statusLine.textContent = '✓ ' + toolCalls + ' ' + I18n.t('I18N_AI_APPLIED', 'Änderung(en) direkt am Dokument angewendet'); statusLine.style.display = ''; } else { statusLine.style.display = 'none'; } var actions = document.createElement('div'); actions.className = 'ai-msg-actions'; var copyBtn = document.createElement('button'); copyBtn.textContent = I18n.t('I18N_COPY', 'Kopieren'); copyBtn.addEventListener('click', function() { navigator.clipboard.writeText(fullText).then(function() { copyBtn.textContent = '✓'; setTimeout(function() { copyBtn.textContent = I18n.t('I18N_COPY', 'Kopieren'); }, 1500); }); }); actions.appendChild(copyBtn); if (!applied) { function doImport(mode) { var htmlContent = extractHtml(fullText); var parentUuid = (mode === 'child') ? Canvas.getSelectedUuid() : null; if (mode === 'child' && !parentUuid) { addMessage('error', 'Kein Element ausgewählt. Bitte im Dokumentbaum ein Element auswählen.'); return; } var importStatusEl = addMessage('status', I18n.t('I18N_AI_PROCESSING', 'Importiere...')); EditorApi.importHtml(htmlContent, mode, parentUuid).then(function() { importStatusEl.remove(); addMessage('status', '✓ ' + I18n.t('I18N_IMPORT_HTML', 'Importiert')); Canvas.clearSelection(); DocumentTree.clearSelection(); PropertiesPanel.clear(); refreshDocument(); }).catch(function(err) { importStatusEl.remove(); addMessage('error', I18n.t('I18N_IMPORT_FAILED', 'Import fehlgeschlagen') + ': ' + (err.error || '')); }); } var replaceBtn = document.createElement('button'); replaceBtn.textContent = '📄 ' + I18n.t('I18N_REPLACE_DOC', 'Dokument ersetzen'); replaceBtn.addEventListener('click', function() { doImport('replace'); }); actions.appendChild(replaceBtn); var childBtn = document.createElement('button'); childBtn.textContent = '⬇ ' + I18n.t('I18N_INSERT_CHILD', 'Child einfügen'); childBtn.addEventListener('click', function() { doImport('child'); }); actions.appendChild(childBtn); } div.appendChild(actions); chatHistory.scrollTop = chatHistory.scrollHeight; saveChat(); } }; } function addAssistantMessage(text, noSave, applied, toolCalls) { var div = document.createElement('div'); div.className = 'ai-msg assistant'; Loading