Commit 2279d4b4 authored by jan.koester's avatar jan.koester
Browse files

new ai ui

parent 2f0efe33
Loading
Loading
Loading
Loading
+83 −2
Changes for editor/html/js/api.js: 83 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -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
+121 −12
Changes for editor/html/js/editor.js: 121 added lines, 12 removed lines.
Original line number Diff line number Diff line
@@ -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 = [];
@@ -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();
@@ -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';