From f2fce9097bcdfb97c67f4277393dd631b925a257 Mon Sep 17 00:00:00 2001 From: seb Date: Sun, 4 Oct 2026 02:13:50 +0200 Subject: [PATCH] u --- package-lock.json | 13 +- package.json | 1 + src/api/ApiClient.js | 16 + src/api/index.js | 52 ++ src/automats/chatAutomat.js | 843 ++++++++++++++++++ src/automats/index.js | 13 + src/automats/userSettingsAutomat.js | 19 +- src/components/AppShell.jsx | 7 +- src/components/article/searchFocus.js | 12 +- src/components/chat/AskUserPrompt.jsx | 144 +++ src/components/chat/ChatDrawer.jsx | 680 ++++++++++++++ src/components/chat/ChatFab.jsx | 220 +++++ src/components/chat/ChatFlowList.jsx | 381 ++++++++ src/components/chat/HealingMarkdown.jsx | 505 +++++++++++ src/components/chat/NewConversationDialog.jsx | 171 ++++ src/components/chat/index.js | 4 + src/devtools/AutomatInspector.jsx | 7 +- src/routes/artikel.jsx | 4 +- src/theme/themes/default.js | 4 +- 19 files changed, 3087 insertions(+), 9 deletions(-) create mode 100644 src/automats/chatAutomat.js create mode 100644 src/components/chat/AskUserPrompt.jsx create mode 100644 src/components/chat/ChatDrawer.jsx create mode 100644 src/components/chat/ChatFab.jsx create mode 100644 src/components/chat/ChatFlowList.jsx create mode 100644 src/components/chat/HealingMarkdown.jsx create mode 100644 src/components/chat/NewConversationDialog.jsx create mode 100644 src/components/chat/index.js diff --git a/package-lock.json b/package-lock.json index f8ced51..e6e7fc3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "react-dom": "^19.3.0", "react-router-dom": "^7.18.4", "react-virtual-aggridautomat": "file:../react-virtual-aggridautomat", + "react-window": "^2.3.3", "sortablejs": "^1.15.7", "thumbhash": "^0.1.1" }, @@ -92,7 +93,7 @@ "marked": "^18.0.14", "prismjs": "^1.30.0", "react": "^19.2.8", - "react-automat": "github:sebseb7/react-automat", + "react-automat": "file:../automat", "react-dom": "^19.2.8" }, "devDependencies": { @@ -7483,6 +7484,16 @@ "resolved": "../react-virtual-aggridautomat", "link": true }, + "node_modules/react-window": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/react-window/-/react-window-2.3.3.tgz", + "integrity": "sha512-uu/dTwysXp+YL+i5GW/cC/3JrqyE7oE6DYXYVlTeMaDSDgCI+UGo+XCKwXfS8uoFPih8TJYTnikqjAnbDvn75Q==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", diff --git a/package.json b/package.json index 4286774..05f4685 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "react-dom": "^19.3.0", "react-router-dom": "^7.18.4", "react-virtual-aggridautomat": "file:../react-virtual-aggridautomat", + "react-window": "^2.3.3", "sortablejs": "^1.15.7", "thumbhash": "^0.1.1" }, diff --git a/src/api/ApiClient.js b/src/api/ApiClient.js index 01aa56e..31fdef2 100644 --- a/src/api/ApiClient.js +++ b/src/api/ApiClient.js @@ -577,8 +577,24 @@ export class ApiClient { /** Absolute URL for (same-origin / cookie auth). */ url: (fullHash) => buildUrl(this.#baseUrl, `/api/thumb/${enc(fullHash)}`), }; + + this.modelconversation = { + /** GET /api/modelconversation → { conversations: [...] } */ + list: () => this.#json('GET', '/api/modelconversation'), + /** GET /api/modelconversation/{id} → { conversation } */ + get: (id) => this.#json('GET', `/api/modelconversation/${enc(id)}`), + /** POST /api/modelconversation body { provider, model } → { conversation } */ + create: (body) => this.#json('POST', '/api/modelconversation', { body }), + /** GET /api/modelconversation/{id}/message → { messages: [...] } */ + listMessages: (id) => this.#json('GET', `/api/modelconversation/${enc(id)}/message`), + /** POST /api/modelconversation/{id}/message body { message } → { message } */ + addMessage: (id, body) => this.#json('POST', `/api/modelconversation/${enc(id)}/message`, { body }), + /** DELETE /api/modelconversation/{id} */ + delete: (id) => this.#json('DELETE', `/api/modelconversation/${enc(id)}`), + }; } + /** Absolute URL for a same-origin API path (e.g. SSE). */ urlFor(path) { return buildUrl(this.#baseUrl, path); diff --git a/src/api/index.js b/src/api/index.js index 5531e57..0b76776 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -1841,6 +1841,56 @@ export const globalHistory = { }, }; +export const modelconversation = { + /** + * GET /api/modelconversation + * Lists the user's model conversations in the selected company. + * → 200 { conversations: [{ id, companyuser_id, provider, model, created_at, updated_at, state }] } + */ + list() { + return http('/api/modelconversation'); + }, + + /** + * GET /api/modelconversation/:id + * Fetch conversation details by ID. + * @param {number|string} id + */ + get(id) { + return http(`/api/modelconversation/${encodeURIComponent(id)}`); + }, + + /** + * POST /api/modelconversation + * Creates a new conversation in state 'idle'. + * Body: { provider, model } + * → 201 { conversation: { id, companyuser_id, provider, model, created_at, updated_at, state } } + */ + create(body) { + return http('/api/modelconversation', { method: 'POST', body }); + }, + + /** + * GET /api/modelconversation/:id/message + * Lists conversation messages, oldest first. + * → 200 { messages: [{ id, conversation_id, created_at, role, message }] } + */ + listMessages(id) { + return http(`/api/modelconversation/${encodeURIComponent(id)}/message`); + }, + + addMessage(id, body) { + return http(`/api/modelconversation/${encodeURIComponent(id)}/message`, { method: 'POST', body }); + }, + + /** + * DELETE /api/modelconversation/:id + */ + delete(id) { + return http(`/api/modelconversation/${encodeURIComponent(id)}`, { method: 'DELETE' }); + }, +}; + export default { auth, article, @@ -1855,6 +1905,7 @@ export default { entity, labeltemplate, ledger, + modelconversation, number, order, posterminal, @@ -1868,3 +1919,4 @@ export default { stock, globalHistory, }; + diff --git a/src/automats/chatAutomat.js b/src/automats/chatAutomat.js new file mode 100644 index 0000000..3c06c9a --- /dev/null +++ b/src/automats/chatAutomat.js @@ -0,0 +1,843 @@ +import { Automat } from 'react-automat'; +import { modelconversation as modelconversationApi } from '../api'; +import authAutomat from './authAutomat'; + +/** + * @typedef {Object} ModelConversation + * @property {number} id + * @property {number} companyuser_id + * @property {string} provider + * @property {string} model + * @property {string} created_at + * @property {string} updated_at + * @property {'idle'|'busy'} state + */ + +/** + * @typedef {Object} ModelMessage + * @property {number|string} id + * @property {number} conversation_id + * @property {string} created_at + * @property {'user'|'assistant'|'system'|'tool'} role + * @property {string} message + */ + +/** + * @typedef {Object} ChatState + * @property {boolean} isOpen + * @property {'idle'|'connecting'|'connected'|'error'} sseStatus + * @property {boolean} sseEstablished + * @property {ModelConversation[]} conversations + * @property {boolean} loadingConversations + * @property {number|null} selectedConversationId + * @property {'idle'|'busy'} conversationState + * @property {string} provider + * @property {string} model + * @property {ModelMessage[]} messages + * @property {boolean} loadingMessages + * @property {boolean} isSending + * @property {string|null} sendError + * @property {string|null} error + * @property {'idle'|'thinking'|'working'} streamPhase Live SSE phase of the assistant + * @property {string} streamingText Partial assistant text received via `delta` events + * @property {{prompt_tokens:number, completion_tokens:number, total_tokens:number, cost:number}|null} lastUsage + */ + +/** Selectable model presets; the first entry is the default for new conversations. */ +export const PRESET_MODELS = [ + { value: 'stealth/space-bunny-alpha', label: 'Space Bunny Alpha' }, +]; +const DEFAULT_MODEL = PRESET_MODELS[0].value; + +/** True once the chat was opened in this browser session (module lifetime). */ +let chatOpenedThisSession = false; + +/** @type {ChatState} */ +const initialState = { + isOpen: false, + sseStatus: 'idle', + sseEstablished: false, + conversations: [], + loadingConversations: false, + selectedConversationId: null, + /** First-open draft: no server conversation yet, created on first send. */ + draftConversation: false, + conversationState: 'idle', + provider: 'openrouter', + model: DEFAULT_MODEL, + messages: [], + loadingMessages: false, + isSending: false, + sendError: null, + error: null, + streamPhase: 'idle', + streamingText: '', + lastUsage: null, +}; + +let chatAutomat; + +/** Company-wide events EventSource (/api/events) */ +let companyEventSource = null; +let companyReconnectTimer = null; + +/** Per-conversation EventSource (/api/modelconversation/:id/stream) */ +let conversationStream = null; +let activeStreamConversationId = null; + +/** + * Connect to company-wide SSE stream (/api/events) to listen for 'modelconversation' events. + */ +function ensureCompanyEventsConnected() { + if (companyEventSource && companyEventSource.readyState !== EventSource.CLOSED) { + return; + } + if (companyEventSource) { + try { + companyEventSource.close(); + } catch {} + companyEventSource = null; + } + if (companyReconnectTimer) { + clearTimeout(companyReconnectTimer); + companyReconnectTimer = null; + } + + const es = new EventSource('/api/events', { withCredentials: true }); + companyEventSource = es; + + es.addEventListener('modelconversation', () => { + void chatAutomat.actions.loadConversations(true); + }); + + es.onerror = () => { + if (chatAutomat.state.sseEstablished) { + if (companyReconnectTimer) clearTimeout(companyReconnectTimer); + companyReconnectTimer = setTimeout(() => { + if (chatAutomat.state.sseEstablished) { + ensureCompanyEventsConnected(); + } + }, 4000); + } + }; +} + +/** + * Connect to the dedicated per-conversation SSE stream (GET /api/modelconversation/:id/stream). + * @param {number|string|null} conversationId + */ +function ensureConversationStream(conversationId) { + if (!conversationId) { + closeConversationStream(); + return; + } + + // If already connected to this conversation stream, keep it + if ( + conversationStream && + activeStreamConversationId === conversationId && + conversationStream.readyState !== EventSource.CLOSED + ) { + return; + } + + closeConversationStream(); + + chatAutomat.setState({ sseStatus: 'connecting', sseEstablished: true }); + activeStreamConversationId = conversationId; + + const streamUrl = `/api/modelconversation/${encodeURIComponent(conversationId)}/stream`; + const es = new EventSource(streamUrl, { withCredentials: true }); + conversationStream = es; + + es.onopen = () => { + if (activeStreamConversationId === conversationId) { + chatAutomat.setState({ sseStatus: 'connected', error: null }); + } + }; + + es.onerror = () => { + if (activeStreamConversationId === conversationId) { + chatAutomat.setState({ sseStatus: 'error' }); + } + }; + + // 1. Live message event from server (role: 'user' | 'assistant' | 'system' | 'tool') + es.addEventListener('message', (event) => { + try { + if (!event.data) return; + const msg = JSON.parse(event.data); + if (msg && (msg.conversation_id === conversationId || !msg.conversation_id)) { + chatAutomat.actions.receiveMessage(msg); + } + } catch (err) { + console.warn('[chatAutomat] Failed to parse message event payload:', err); + } + }); + + // 2. Live state event: thinking -> working -> done (usage on done) + es.addEventListener('state', (event) => { + try { + if (!event.data) return; + const data = JSON.parse(event.data); + if (data?.state) { + chatAutomat.actions.applyStreamState(data.state, data.usage); + } + } catch (err) { + console.warn('[chatAutomat] Failed to parse state event payload:', err); + } + }); + + // 3. Incremental assistant text chunks (`text` holds the full text so far) + es.addEventListener('delta', (event) => { + try { + if (!event.data) return; + const data = JSON.parse(event.data); + if (typeof data?.text === 'string') { + chatAutomat.setState({ streamingText: data.text }); + } + } catch (err) { + console.warn('[chatAutomat] Failed to parse delta event payload:', err); + } + }); +} + +function closeConversationStream() { + if (conversationStream) { + try { + conversationStream.close(); + } catch {} + conversationStream = null; + } + activeStreamConversationId = null; +} + +const actions = { + /** + * Open the chat drawer and initialize connection & conversation list. + */ + openChat() { + chatAutomat.setState({ isOpen: true, sseEstablished: true }); + ensureCompanyEventsConnected(); + if (!chatOpenedThisSession) { + // First open in this browser session: prepare a draft with the first preset + // model. The conversation is only created once the first message is sent. + chatOpenedThisSession = true; + chatAutomat.actions.startDraft(); + } + void chatAutomat.actions.loadConversations(); + }, + + /** + * Start an implicit new conversation with the first preset model. Nothing is + * created on the server until the first message is sent. + */ + startDraft() { + closeConversationStream(); + chatAutomat.setState({ + draftConversation: true, + selectedConversationId: null, + provider: 'openrouter', + model: DEFAULT_MODEL, + messages: [], + conversationState: 'idle', + streamPhase: 'idle', + streamingText: '', + lastUsage: null, + sendError: null, + }); + }, + + /** + * Close the chat drawer (keeps SSE connection alive for background responses). + */ + closeChat() { + chatAutomat.setState({ isOpen: false }); + }, + + /** + * Toggle the chat drawer open/closed. + */ + toggleChat() { + if (chatAutomat.state.isOpen) { + chatAutomat.actions.closeChat(); + } else { + chatAutomat.actions.openChat(); + } + }, + + /** + * Fetch conversations list from GET /api/modelconversation. + * Auto-selects the first conversation if none is selected. + * @param {boolean} [silent=false] + */ + async loadConversations(silent = false) { + if (!silent) { + chatAutomat.setState({ loadingConversations: true, error: null }); + } + try { + const data = await modelconversationApi.list(); + const conversations = data?.conversations || []; + const currentSelectedId = chatAutomat.state.selectedConversationId; + + let nextSelectedId = currentSelectedId; + let nextConvState = chatAutomat.state.conversationState; + let nextProvider = chatAutomat.state.provider; + let nextModel = chatAutomat.state.model; + + if (currentSelectedId) { + const found = conversations.find((c) => c.id === currentSelectedId); + if (found) { + nextConvState = found.state || 'idle'; + nextProvider = found.provider || nextProvider; + nextModel = found.model || nextModel; + } else if (conversations.length > 0) { + nextSelectedId = conversations[0].id; + nextConvState = conversations[0].state || 'idle'; + nextProvider = conversations[0].provider || nextProvider; + nextModel = conversations[0].model || nextModel; + } else { + nextSelectedId = null; + nextConvState = 'idle'; + } + } else if (conversations.length > 0 && !chatAutomat.state.draftConversation) { + nextSelectedId = conversations[0].id; + nextConvState = conversations[0].state || 'idle'; + nextProvider = conversations[0].provider || nextProvider; + nextModel = conversations[0].model || nextModel; + } + + chatAutomat.setState({ + conversations, + selectedConversationId: nextSelectedId, + conversationState: nextConvState, + provider: nextProvider, + model: nextModel, + loadingConversations: false, + }); + + if (nextSelectedId && nextSelectedId !== currentSelectedId) { + void chatAutomat.actions.loadMessages(nextSelectedId); + } else if (nextSelectedId && (!conversationStream || activeStreamConversationId !== nextSelectedId)) { + ensureConversationStream(nextSelectedId); + } + } catch (err) { + chatAutomat.setState({ + loadingConversations: false, + error: err?.message || String(err), + }); + } + }, + + /** + * Switch the active conversation and load its messages history & connect SSE stream. + * @param {number} id + */ + async selectConversation(id) { + const conv = chatAutomat.state.conversations.find((c) => c.id === id); + chatAutomat.setState({ + selectedConversationId: id, + draftConversation: false, + conversationState: conv?.state || 'idle', + provider: conv?.provider || chatAutomat.state.provider, + model: conv?.model || chatAutomat.state.model, + sendError: null, + streamPhase: conv?.state === 'busy' ? 'thinking' : 'idle', + streamingText: '', + lastUsage: null, + }); + + ensureConversationStream(id); + await chatAutomat.actions.loadMessages(id); + }, + + /** + * Load historical messages for a conversation (GET /api/modelconversation/:id/message). + * Also refreshes the conversation state via GET /api/modelconversation/:id. + * @param {number} id + */ + async loadMessages(id) { + if (!id) return; + chatAutomat.setState({ loadingMessages: true }); + try { + const [msgsRes, convRes] = await Promise.all([ + modelconversationApi.listMessages(id), + modelconversationApi.get(id).catch(() => null), + ]); + + const messages = msgsRes?.messages || []; + const updatedConv = convRes?.conversation; + + chatAutomat.setState((prev) => ({ + messages, + loadingMessages: false, + conversationState: updatedConv?.state || prev.conversationState, + conversations: updatedConv + ? prev.conversations.map((c) => (c.id === updatedConv.id ? updatedConv : c)) + : prev.conversations, + })); + } catch (err) { + console.warn('[chatAutomat] loadMessages failed:', err); + chatAutomat.setState({ loadingMessages: false }); + } + }, + + /** + * Create a new model conversation via POST /api/modelconversation. + * Automatically selects the new conversation and connects its stream. + * @param {{ provider?: string, model?: string }} [params] + */ + async createConversation({ provider = 'openrouter', model = DEFAULT_MODEL } = {}) { + chatAutomat.setState({ loadingConversations: true, error: null }); + try { + const res = await modelconversationApi.create({ provider, model }); + const newConv = res?.conversation; + if (!newConv) throw new Error('Unterhaltung konnte nicht erstellt werden'); + + chatAutomat.setState((prev) => ({ + conversations: [newConv, ...prev.conversations.filter((c) => c.id !== newConv.id)], + selectedConversationId: newConv.id, + draftConversation: false, + conversationState: newConv.state || 'idle', + provider: newConv.provider || provider, + model: newConv.model || model, + messages: [], + loadingConversations: false, + sendError: null, + })); + + ensureConversationStream(newConv.id); + return newConv; + } catch (err) { + chatAutomat.setState({ + loadingConversations: false, + error: err?.message || String(err), + }); + throw err; + } + }, + + /** + * Delete a model conversation via DELETE /api/modelconversation/:id. + * If the currently selected conversation was deleted, selects the next available + * or starts a fresh draft. + * @param {number} id + */ + async deleteConversation(id) { + if (!id) return; + try { + await modelconversationApi.delete(id); + } catch (err) { + console.warn('[chatAutomat] deleteConversation API error:', err); + // Even if network fails or already deleted, proceed with local cleanup if 404 + if (err?.status !== 404) { + throw err; + } + } + + const prevConversations = chatAutomat.state.conversations; + const remaining = prevConversations.filter((c) => c.id !== id); + const wasSelected = chatAutomat.state.selectedConversationId === id; + + if (wasSelected) { + if (remaining.length > 0) { + const next = remaining[0]; + chatAutomat.setState({ + conversations: remaining, + selectedConversationId: next.id, + conversationState: next.state || 'idle', + provider: next.provider || 'openrouter', + model: next.model || DEFAULT_MODEL, + draftConversation: false, + messages: [], + streamPhase: 'idle', + streamingText: '', + lastUsage: null, + sendError: null, + }); + ensureConversationStream(next.id); + void chatAutomat.actions.loadMessages(next.id); + } else { + chatAutomat.setState({ + conversations: [], + }); + chatAutomat.actions.startDraft(); + } + } else { + chatAutomat.setState({ + conversations: remaining, + }); + } + }, + + /** + * Update conversation state ('idle' | 'busy') and sync in conversation list. + * @param {'idle'|'busy'} state + */ + setConversationState(state) { + chatAutomat.setState((prev) => ({ + conversationState: state, + conversations: prev.conversations.map((c) => + c.id === prev.selectedConversationId ? { ...c, state } : c + ), + })); + }, + + /** + * Apply a granular SSE state ('thinking' | 'working' | 'done', or legacy 'busy' | 'idle'). + * The DB-level conversationState stays binary (busy while thinking/working). + * @param {string} state + * @param {object} [usage] Token/cost usage, only sent with 'done' + */ + applyStreamState(state, usage) { + /** Local-only system line shown in the chat flow for each state event. */ + const stateLine = (text) => ({ + id: `state-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + conversation_id: chatAutomat.state.selectedConversationId, + role: 'system', + message: text, + created_at: new Date().toISOString(), + }); + + if (state === 'thinking' || state === 'working' || state === 'busy') { + const phase = state === 'busy' ? 'thinking' : state; + const text = phase === 'thinking' ? 'Denkt nach…' : 'Generiert Antwort…'; + chatAutomat.setState((prev) => ({ + streamPhase: phase, + messages: [...prev.messages, stateLine(text)], + })); + chatAutomat.actions.setConversationState('busy'); + } else if (state === 'done' || state === 'idle') { + let text = ''; + if (usage) { + const input = usage.input ?? usage.prompt_tokens ?? 0; + const output = usage.output ?? usage.completion_tokens ?? 0; + const cached = usage.cached ?? 0; + const cost = typeof usage.cost === 'number' ? ` · $${usage.cost.toFixed(6)}` : ''; + const cachedPart = cached ? ` / ${cached} Cache` : ''; + text = `${input} Prompt${cachedPart} / ${output} Antwort${cost}`; + } + chatAutomat.setState((prev) => ({ + streamPhase: 'idle', + streamingText: '', + lastUsage: usage || prev.lastUsage, + messages: state === 'done' && text ? [...prev.messages, stateLine(text)] : prev.messages, + })); + chatAutomat.actions.setConversationState('idle'); + } + }, + + /** + * Send a user message via POST /api/modelconversation/:id/message. + * Rejects immediately if conversation is busy (409 Conflict avoidance). + * @param {string} text + */ + async sendMessage(text) { + const trimmed = text?.trim(); + if (!trimmed) return; + + if (!chatAutomat.state.selectedConversationId && chatAutomat.state.draftConversation) { + // Draft: the conversation really starts now, with the first message. + const { provider, model } = chatAutomat.state; + await chatAutomat.actions.createConversation({ provider, model }); + } + + const { selectedConversationId, conversationState, isSending } = chatAutomat.state; + if (!selectedConversationId) { + throw new Error('Keine Unterhaltung ausgewählt'); + } + if (conversationState === 'busy' || isSending) { + return; + } + + const tempId = `temp-${Date.now()}`; + const optimisticMsg = { + id: tempId, + conversation_id: selectedConversationId, + role: 'user', + message: trimmed, + created_at: new Date().toISOString(), + }; + + chatAutomat.setState((prev) => ({ + messages: [...prev.messages, optimisticMsg], + conversationState: 'busy', + streamPhase: 'thinking', + streamingText: '', + lastUsage: null, + isSending: true, + sendError: null, + })); + + try { + const res = await modelconversationApi.addMessage(selectedConversationId, { message: trimmed }); + const createdMsg = res?.message || optimisticMsg; + chatAutomat.setState((prev) => { + // If SSE already added/replaced this message by real ID, avoid adding again + const alreadyHasRealId = prev.messages.some((m) => m.id === createdMsg.id && m.id !== tempId); + if (alreadyHasRealId) { + return { + messages: prev.messages.filter((m) => m.id !== tempId), + isSending: false, + }; + } + return { + messages: prev.messages.map((m) => (m.id === tempId ? createdMsg : m)), + isSending: false, + }; + }); + } catch (err) { + const isConflict = err?.status === 409; + chatAutomat.setState((prev) => ({ + messages: prev.messages.filter((m) => m.id !== tempId), + conversationState: isConflict ? 'busy' : 'idle', + streamPhase: isConflict ? prev.streamPhase : 'idle', + isSending: false, + sendError: isConflict + ? 'Konversation ist beschäftigt, bitte warten.' + : (err?.message || 'Fehler beim Senden'), + })); + throw err; + } + }, + + /** + * Submit a tool response (e.g. for ask_user) via POST /api/modelconversation/:id/message. + * Body: { role: 'tool', tool_call_id, content, answer } + * @param {{ tool_call_id: string, answer: string }} params + */ + async sendToolResponse({ tool_call_id, answer }) { + const { selectedConversationId, isSending } = chatAutomat.state; + if (!selectedConversationId) { + throw new Error('Keine Unterhaltung ausgewählt'); + } + if (isSending) { + return; + } + + const payload = { + role: 'tool', + tool_call_id, + content: answer, + answer, + }; + + const tempId = `temp-${Date.now()}`; + const optimisticMsg = { + id: tempId, + conversation_id: selectedConversationId, + role: 'tool', + message: JSON.stringify(payload), + created_at: new Date().toISOString(), + }; + + chatAutomat.setState((prev) => ({ + messages: [...prev.messages, optimisticMsg], + conversationState: 'busy', + streamPhase: 'thinking', + streamingText: '', + lastUsage: null, + isSending: true, + sendError: null, + })); + + try { + const res = await modelconversationApi.addMessage(selectedConversationId, payload); + const createdMsg = res?.message || optimisticMsg; + chatAutomat.setState((prev) => { + const alreadyHasRealId = prev.messages.some((m) => m.id === createdMsg.id && m.id !== tempId); + if (alreadyHasRealId) { + return { + messages: prev.messages.filter((m) => m.id !== tempId), + isSending: false, + }; + } + return { + messages: prev.messages.map((m) => (m.id === tempId ? createdMsg : m)), + isSending: false, + }; + }); + } catch (err) { + const isConflict = err?.status === 409; + chatAutomat.setState((prev) => ({ + messages: prev.messages.filter((m) => m.id !== tempId), + conversationState: isConflict ? 'busy' : 'idle', + streamPhase: isConflict ? prev.streamPhase : 'idle', + isSending: false, + sendError: isConflict + ? 'Konversation ist beschäftigt, bitte warten.' + : (err?.message || 'Fehler beim Senden'), + })); + throw err; + } + }, + + /** + * Receive an incoming message (via SSE message event or fallback). + * Prevents duplicates and replaces temporary optimistic messages. + * @param {ModelMessage} message + */ + receiveMessage(message) { + if (!message) return; + chatAutomat.setState((prev) => { + // 1. Check if message already exists by ID + const exists = prev.messages.some((m) => m.id === message.id); + if (exists) return prev; + + // 2. Check if this is the user/tool message echoing back and we have a temp optimistic message + if (message.role === 'user' || message.role === 'tool') { + const tempIdx = prev.messages.findIndex((m) => { + if (!String(m.id).startsWith('temp-')) return false; + if (m.message === message.message) return true; + try { + const parsed = JSON.parse(message.message); + if (parsed && typeof parsed === 'object') { + if (parsed.content === m.message) return true; + if (parsed.tool_call_id && parsed.tool_call_id === message.tool_call_id) return true; + } + } catch {} + return false; + }); + if (tempIdx !== -1) { + const next = [...prev.messages]; + next[tempIdx] = message; + return { messages: next, isSending: false }; + } + } + + const isAssistant = message.role === 'assistant' || message.role === 'model'; + let hasAskUser = false; + if (isAssistant) { + try { + const payload = typeof message.message === 'string' ? JSON.parse(message.message) : message.message; + if (payload?.tool_calls?.some((tc) => tc.function?.name === 'ask_user')) { + hasAskUser = true; + } + } catch {} + } + + const nextConversationState = hasAskUser ? 'idle' : prev.conversationState; + const nextStreamPhase = hasAskUser ? 'idle' : prev.streamPhase; + const nextConversations = hasAskUser + ? prev.conversations.map((c) => + c.id === prev.selectedConversationId ? { ...c, state: 'idle' } : c + ) + : prev.conversations; + + // The 'done' state event marks the end of normal response, but ask_user also transitions to idle. + return { + messages: [...prev.messages, message], + streamingText: isAssistant ? '' : prev.streamingText, + conversationState: nextConversationState, + streamPhase: nextStreamPhase, + conversations: nextConversations, + }; + }); + }, + + /** + * Reset all chat state, close streams, and cancel timers. + */ + reset() { + closeConversationStream(); + if (companyEventSource) { + try { + companyEventSource.close(); + } catch {} + companyEventSource = null; + } + if (companyReconnectTimer) { + clearTimeout(companyReconnectTimer); + companyReconnectTimer = null; + } + chatAutomat.setState(initialState); + }, +}; + +chatAutomat = new Automat(initialState, actions, { name: 'chat' }); + +// Listen to auth changes: reset on logout or company change +authAutomat.subscribe((auth) => { + const companyId = auth.user?.selectedCompanyId; + if (auth.status === 'anon' || !companyId) { + chatAutomat.actions.reset(); + } else if (auth.status === 'authed' && companyId) { + if (chatAutomat.state.sseEstablished) { + ensureCompanyEventsConnected(); + void chatAutomat.actions.loadConversations(true); + } + } +}); + +// Plain helper selectors +export const selectChatIsOpen = () => chatAutomat.state.isOpen; +export const selectChatState = () => chatAutomat.state.conversationState; +export const selectChatMessages = () => chatAutomat.state.messages; +export const selectChatConversations = () => chatAutomat.state.conversations; +export const selectSelectedConversationId = () => chatAutomat.state.selectedConversationId; +export const selectSseStatus = () => chatAutomat.state.sseStatus; + +/** + * Extract any pending unanswered ask_user tool call from the given/current chat state. + * @param {ChatState} [state] + * @returns {{ toolCallId: string, question: string, options: string[] } | null} + */ +export function selectPendingAskUser(state = chatAutomat.state) { + const { messages, streamingText } = state; + if (streamingText || !messages || messages.length === 0) return null; + + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg.role === 'system') continue; + if (msg.role === 'tool' || msg.role === 'user') return null; + + if (msg.role === 'assistant' || msg.role === 'model') { + let payload = null; + if (typeof msg.message === 'string') { + try { + payload = JSON.parse(msg.message); + } catch {} + } else if (typeof msg.message === 'object' && msg.message !== null) { + payload = msg.message; + } + if (!payload?.tool_calls || !Array.isArray(payload.tool_calls) || payload.tool_calls.length === 0) { + return null; + } + + const askCall = payload.tool_calls.find((tc) => tc.function?.name === 'ask_user'); + if (!askCall) return null; + + const answered = messages.slice(i + 1).some((m) => { + if (m.role !== 'tool') return false; + try { + const p = typeof m.message === 'string' ? JSON.parse(m.message) : m.message; + return p?.tool_call_id === askCall.id; + } catch { + return false; + } + }); + if (answered) return null; + + let args = {}; + try { + if (typeof askCall.function?.arguments === 'string') { + args = JSON.parse(askCall.function.arguments); + } else if (askCall.function?.arguments) { + args = askCall.function.arguments; + } + } catch {} + + return { + toolCallId: askCall.id, + question: args.question || '', + options: Array.isArray(args.options) ? args.options : [], + }; + } + return null; + } + return null; +} + +export const selectHasPendingAsk = (state = chatAutomat.state) => Boolean(selectPendingAskUser(state)); + +export default chatAutomat; diff --git a/src/automats/index.js b/src/automats/index.js index e69455e..aad69ec 100644 --- a/src/automats/index.js +++ b/src/automats/index.js @@ -73,3 +73,16 @@ export { export { default as articleHistoryGridAutomat } from './articleHistoryAutomat'; export { default as globalHistoryGridAutomat } from './globalHistoryAutomat'; +export { default as chatAutomat } from './chatAutomat'; +export { + selectChatIsOpen, + selectChatState, + selectChatMessages, + selectChatConversations, + selectSelectedConversationId, + selectSseStatus, + selectPendingAskUser, + selectHasPendingAsk, +} from './chatAutomat'; + + diff --git a/src/automats/userSettingsAutomat.js b/src/automats/userSettingsAutomat.js index badfdea..d19a3df 100644 --- a/src/automats/userSettingsAutomat.js +++ b/src/automats/userSettingsAutomat.js @@ -8,11 +8,13 @@ import { Automat } from 'react-automat'; * @typedef {Object} UserSettingsState * @property {ViewerPosition} viewerPosition Preferred docking position of the * attachment viewer (text editor / PDF preview) relative to the article list. + * @property {number} chatDrawerWidth Preferred width of the chat drawer in pixels. */ /** @type {UserSettingsState} */ const initialState = { viewerPosition: 'top', + chatDrawerWidth: 460, }; /** @@ -37,6 +39,20 @@ const userSettingsAutomat = new Automat( } userSettingsAutomat.setState({ viewerPosition: position }); }, + + /** + * Set the width of the chat drawer in pixels. + * + * @param {number} width + * @returns {void} + */ + setChatDrawerWidth(width) { + const num = Number(width); + if (Number.isNaN(num) || num < 280) { + return; + } + userSettingsAutomat.setState({ chatDrawerWidth: Math.round(num) }); + }, }, { name: 'userSettings', @@ -45,4 +61,5 @@ const userSettingsAutomat = new Automat( ); export default userSettingsAutomat; -export const { setViewerPosition } = userSettingsAutomat.actions; +export const { setViewerPosition, setChatDrawerWidth } = userSettingsAutomat.actions; +export const selectChatDrawerWidth = () => userSettingsAutomat.state.chatDrawerWidth || 460; diff --git a/src/components/AppShell.jsx b/src/components/AppShell.jsx index dae26dd..ed27826 100644 --- a/src/components/AppShell.jsx +++ b/src/components/AppShell.jsx @@ -13,6 +13,8 @@ import { NAV_ITEMS, SUB_NAV, resolveActiveNav, resolveActiveSub } from '../nav'; import { artikelQueryPath } from './article/artikelQuery'; import { defaultCategoryPath } from './category/categoryRoute'; import { navigationAutomat } from '../automats'; +import { ChatFab, ChatDrawer } from './chat'; + const SIDEBAR_WIDTH = 144; const SUB_NAV_WIDTH = 88; @@ -49,7 +51,8 @@ class AppSidebar extends PureComponent { flexShrink: 0, display: 'flex', flexDirection: 'column', - bgcolor: theme.nav.background, + backgroundColor: theme.nav.backgroundColor || '#1a3533', + background: theme.nav.background, position: 'sticky', top: 0, alignSelf: 'flex-start', @@ -254,6 +257,8 @@ export default function AppShell() { + + ); } diff --git a/src/components/article/searchFocus.js b/src/components/article/searchFocus.js index 2db37fe..3c51f02 100644 --- a/src/components/article/searchFocus.js +++ b/src/components/article/searchFocus.js @@ -1,8 +1,16 @@ const FOCUSABLE = 'a, button, input, textarea, select, [role="button"], [role="menuitem"], [tabindex]:not([tabindex="-1"]), [contenteditable="true"]'; -/** Keep the search field focused when clicking non-interactive chrome. */ +/** + * Keep the search field focused when clicking non-interactive chrome. + * + * Attach to `mouseup`, not `mousedown`: preventing default on mousedown also + * blocks text selection. Instead we let the click/drag happen and restore + * focus afterwards, unless the user just selected some text. + */ export function preserveSearchFocus(e) { if (e.target.closest(FOCUSABLE)) return; - e.preventDefault(); + const selection = window.getSelection?.(); + if (selection && !selection.isCollapsed) return; + document.querySelector('input[type="search"]')?.focus({ preventScroll: true }); } diff --git a/src/components/chat/AskUserPrompt.jsx b/src/components/chat/AskUserPrompt.jsx new file mode 100644 index 0000000..9de0fac --- /dev/null +++ b/src/components/chat/AskUserPrompt.jsx @@ -0,0 +1,144 @@ +import React, { useState } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Button from '@mui/material/Button'; +import TextField from '@mui/material/TextField'; +import HelpOutlineRoundedIcon from '@mui/icons-material/HelpOutlineRounded'; +import SendRoundedIcon from '@mui/icons-material/SendRounded'; + +export default function AskUserPrompt({ askData, onAnswer, disabled = false }) { + const { toolCallId, question, options = [] } = askData; + const [customAnswer, setCustomAnswer] = useState(''); + const [showCustomInput, setShowCustomInput] = useState(false); + + const handleSelectOption = (opt) => { + if (disabled) return; + onAnswer({ tool_call_id: toolCallId, answer: opt }); + }; + + const handleCustomSubmit = (e) => { + e?.preventDefault(); + const trimmed = customAnswer.trim(); + if (!trimmed || disabled) return; + onAnswer({ tool_call_id: toolCallId, answer: trimmed }); + }; + + return ( + ({ + mx: 2, + my: 1, + p: 2, + borderRadius: 3, + bgcolor: theme.palette.mode === 'dark' ? 'rgba(59, 91, 219, 0.12)' : '#eef2ff', + border: `1px solid ${theme.palette.mode === 'dark' ? 'rgba(59, 91, 219, 0.3)' : '#c7d2fe'}`, + display: 'flex', + flexDirection: 'column', + gap: 1.5, + })} + > + + + + {question || 'Frage des Modells'} + + + + + {options.map((opt, idx) => ( + + ))} + + {!showCustomInput ? ( + + ) : ( + + setCustomAnswer(e.target.value)} + disabled={disabled} + slotProps={{ + htmlInput: { + spellCheck: false, + autoCorrect: 'off', + autoCapitalize: 'off', + }, + }} + sx={{ + '& .MuiOutlinedInput-root': { + borderRadius: 2, + bgcolor: 'background.paper', + fontSize: '0.82rem', + }, + }} + /> + + + )} + + + ); +} diff --git a/src/components/chat/ChatDrawer.jsx b/src/components/chat/ChatDrawer.jsx new file mode 100644 index 0000000..ac2f753 --- /dev/null +++ b/src/components/chat/ChatDrawer.jsx @@ -0,0 +1,680 @@ +import React, { PureComponent } from 'react'; +import Drawer from '@mui/material/Drawer'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import IconButton from '@mui/material/IconButton'; +import Button from '@mui/material/Button'; +import TextField from '@mui/material/TextField'; +import MenuItem from '@mui/material/MenuItem'; +import Select from '@mui/material/Select'; +import FormControl from '@mui/material/FormControl'; +import InputLabel from '@mui/material/InputLabel'; +import Chip from '@mui/material/Chip'; +import CircularProgress from '@mui/material/CircularProgress'; +import CloseRoundedIcon from '@mui/icons-material/CloseRounded'; +import SendRoundedIcon from '@mui/icons-material/SendRounded'; +import AddRoundedIcon from '@mui/icons-material/AddRounded'; +import AutoAwesomeRoundedIcon from '@mui/icons-material/AutoAwesomeRounded'; +import DeleteOutlineRoundedIcon from '@mui/icons-material/DeleteOutlineRounded'; + +import chatAutomat, { selectPendingAskUser } from '../../automats/chatAutomat'; +import userSettingsAutomat from '../../automats/userSettingsAutomat'; +import ChatFlowList from './ChatFlowList'; +import AskUserPrompt from './AskUserPrompt'; + +const DEFAULT_DRAWER_WIDTH = 460; +const MIN_DRAWER_WIDTH = 320; + +export default class ChatDrawer extends PureComponent { + constructor(props) { + super(props); + const s = chatAutomat.state; + const settings = userSettingsAutomat.state; + this.state = { + isOpen: s.isOpen, + conversations: s.conversations, + selectedConversationId: s.selectedConversationId, + draftConversation: s.draftConversation, + conversationState: s.conversationState, + provider: s.provider, + model: s.model, + messages: s.messages, + loadingMessages: s.loadingMessages, + streamingText: s.streamingText, + isSending: s.isSending, + loadingConversations: s.loadingConversations, + sseStatus: s.sseStatus, + sendError: s.sendError, + inputText: '', + drawerWidth: settings?.chatDrawerWidth || DEFAULT_DRAWER_WIDTH, + isResizing: false, + }; + this.inputRef = React.createRef(); + this.focusTimeout = null; + } + + focusInput = (delay = 50) => { + if (this.focusTimeout) clearTimeout(this.focusTimeout); + this.focusTimeout = setTimeout(() => { + this.inputRef.current?.focus?.(); + }, delay); + }; + + componentDidMount() { + if (this.state.isOpen) { + this.focusInput(150); + } + this.unsub = chatAutomat.subscribe(this, (state) => { + const wasOpen = this.state.isOpen; + const willOpen = state.isOpen; + if (!wasOpen && willOpen) { + this.focusInput(150); + } + return { + isOpen: state.isOpen, + conversations: state.conversations, + selectedConversationId: state.selectedConversationId, + draftConversation: state.draftConversation, + conversationState: state.conversationState, + provider: state.provider, + model: state.model, + messages: state.messages, + loadingMessages: state.loadingMessages, + streamingText: state.streamingText, + isSending: state.isSending, + loadingConversations: state.loadingConversations, + sseStatus: state.sseStatus, + sendError: state.sendError, + }; + }); + + this.unsubSettings = userSettingsAutomat.subscribe(this, (state) => { + if (!this.state.isResizing) { + return { drawerWidth: state.chatDrawerWidth || DEFAULT_DRAWER_WIDTH }; + } + return null; + }); + } + + componentWillUnmount() { + if (this.focusTimeout) clearTimeout(this.focusTimeout); + this.unsub?.(); + this.unsubSettings?.(); + this.cleanupResizeListeners(); + } + + handleClose = () => { + chatAutomat.actions.closeChat(); + }; + + handleResizeStart = (e) => { + if (e.button !== undefined && e.button !== 0) return; + e.preventDefault(); + e.stopPropagation(); + + const startX = e.clientX ?? (e.touches && e.touches[0]?.clientX); + if (startX === undefined) return; + + const startWidth = this.state.drawerWidth; + this.setState({ isResizing: true }); + + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + + let currentWidth = startWidth; + + const onMove = (moveEvt) => { + moveEvt.preventDefault(); + const clientX = moveEvt.clientX ?? (moveEvt.touches && moveEvt.touches[0]?.clientX); + if (clientX === undefined) return; + + const deltaX = startX - clientX; + const maxWidth = Math.min(window.innerWidth - 40, 1400); + const newWidth = Math.max(MIN_DRAWER_WIDTH, Math.min(maxWidth, Math.round(startWidth + deltaX))); + + currentWidth = newWidth; + this.setState({ drawerWidth: newWidth }); + }; + + const onEnd = () => { + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onEnd); + window.removeEventListener('pointercancel', onEnd); + window.removeEventListener('mousemove', onMove); + window.removeEventListener('mouseup', onEnd); + window.removeEventListener('touchmove', onMove); + window.removeEventListener('touchend', onEnd); + + this.setState({ isResizing: false }); + userSettingsAutomat.actions.setChatDrawerWidth(currentWidth); + }; + + window.addEventListener('pointermove', onMove, { passive: false }); + window.addEventListener('pointerup', onEnd); + window.addEventListener('pointercancel', onEnd); + window.addEventListener('mousemove', onMove, { passive: false }); + window.addEventListener('mouseup', onEnd); + window.addEventListener('touchmove', onMove, { passive: false }); + window.addEventListener('touchend', onEnd); + }; + + handleResizeDoubleClick = () => { + this.setState({ drawerWidth: DEFAULT_DRAWER_WIDTH }); + userSettingsAutomat.actions.setChatDrawerWidth(DEFAULT_DRAWER_WIDTH); + }; + + cleanupResizeListeners = () => { + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }; + + handleSelectConversation = (e) => { + const id = Number(e.target.value); + if (id) { + chatAutomat.actions.selectConversation(id); + this.focusInput(50); + } + }; + + handleNewChat = () => { + chatAutomat.actions.startDraft(); + this.focusInput(50); + }; + + handleDeleteConversation = async (e, convId) => { + e?.stopPropagation(); + e?.preventDefault(); + if (!convId) return; + try { + await chatAutomat.actions.deleteConversation(convId); + } catch (err) { + console.error('[ChatDrawer] Failed to delete conversation:', err); + } finally { + this.focusInput(50); + } + }; + + handleSend = async (e) => { + e?.preventDefault(); + const text = this.state.inputText.trim(); + if (!text || this.state.conversationState === 'busy' || this.state.isSending) { + return; + } + this.setState({ inputText: '' }); + try { + await chatAutomat.actions.sendMessage(text); + } catch { + // Error is tracked in chatAutomat state (sendError) + } + }; + + handleKeyDown = (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + void this.handleSend(); + } + }; + + handleAnswerTool = async ({ tool_call_id, answer }) => { + try { + await chatAutomat.actions.sendToolResponse({ tool_call_id, answer }); + } catch (err) { + console.error('[ChatDrawer] Failed to send tool response:', err); + } + }; + + getPendingAskUser = () => selectPendingAskUser(this.state); + + renderHeader() { + const { + conversations, + selectedConversationId, + sseStatus, + loadingConversations, + } = this.state; + + + return ( + + + + + + + + KI-Assistent + + + + + + + + + {/* Conversation Selector Bar */} + + + Konversation + + + + this.handleDeleteConversation(e, selectedConversationId)} + aria-label="Konversation löschen" + sx={{ + borderRadius: 2, + border: 1, + borderColor: 'divider', + p: 0.9, + flexShrink: 0, + }} + > + + + + + + + + + ); + } + + renderBody() { + const { + conversations, + selectedConversationId, + draftConversation, + conversationState, + messages, + streamingText, + loadingConversations, + loadingMessages, + model, + } = this.state; + + if (loadingConversations && conversations.length === 0) { + return ( + + + + Lade Konversationen… + + + ); + } + + if (conversations.length === 0 && !draftConversation) { + return ( + + + + + + Willkommen im Chat + + + Starte eine Unterhaltung mit dem KI-Modell, um Fragen zu stellen oder Daten abzufragen. + + + + ); + } + + const pendingAsk = this.getPendingAskUser(); + + return ( + + {messages.length === 0 ? ( + + ) : ( + + )} + + {pendingAsk && ( + + )} + + ); + } + + renderFooter() { + const { + conversationState, + isSending, + selectedConversationId, + draftConversation, + inputText, + sendError, + } = this.state; + + const pendingAsk = this.getPendingAskUser(); + const isBusy = conversationState === 'busy'; + const isWorking = isBusy || isSending; + const canChat = (Boolean(selectedConversationId) || draftConversation) && !pendingAsk; + const canSend = !isWorking && canChat && Boolean(inputText.trim()); + + return ( + + {sendError && ( + + {sendError} + chatAutomat.setState({ sendError: null })} + sx={{ color: 'inherit', p: 0.2 }} + > + + + + )} + + + this.setState({ inputText: e.target.value })} + onKeyDown={this.handleKeyDown} + disabled={!canChat} + placeholder={ + pendingAsk + ? 'Bitte Frage oben beantworten…' + : 'Nachricht eingeben… (Enter)' + } + slotProps={{ + htmlInput: { + spellCheck: false, + autoCorrect: 'off', + autoCapitalize: 'off', + }, + }} + sx={{ + '& .MuiOutlinedInput-root': { + borderRadius: 2.5, + bgcolor: 'background.default', + fontSize: '0.875rem', + }, + }} + /> + + + {isWorking ? ( + + ) : ( + + )} + + + + + Enter zum Senden • Shift+Enter für Zeilenumbruch + + + ); + } + + render() { + const { isOpen, drawerWidth, isResizing } = this.state; + + return ( + <> + ({ + zIndex: theme.zIndex.drawer + 2, + '& .MuiDrawer-paper': { + width: { xs: '100vw', sm: `${drawerWidth}px` }, + maxWidth: '100vw', + display: 'flex', + flexDirection: 'column', + boxShadow: '-8px 0 32px rgba(0, 0, 0, 0.12)', + position: 'fixed', + overflow: 'visible', + transition: isResizing ? 'none !important' : undefined, + }, + })} + slotProps={{ + transition: { + onEntered: () => this.focusInput(20), + }, + paper: { + sx: { + width: { xs: '100vw', sm: `${drawerWidth}px` }, + maxWidth: '100vw', + display: 'flex', + flexDirection: 'column', + boxShadow: '-8px 0 32px rgba(0, 0, 0, 0.12)', + position: 'fixed', + overflow: 'visible', + transition: isResizing ? 'none !important' : undefined, + }, + }, + }} + > + {/* Draggable Resizer Handle */} + + + + + {this.renderHeader()} + {this.renderBody()} + {this.renderFooter()} + + + ); + } +} diff --git a/src/components/chat/ChatFab.jsx b/src/components/chat/ChatFab.jsx new file mode 100644 index 0000000..f39593f --- /dev/null +++ b/src/components/chat/ChatFab.jsx @@ -0,0 +1,220 @@ +import React, { PureComponent } from 'react'; +import Box from '@mui/material/Box'; +import Fab from '@mui/material/Fab'; +import Badge from '@mui/material/Badge'; +import Tooltip from '@mui/material/Tooltip'; +import CircularProgress from '@mui/material/CircularProgress'; +import ChatRoundedIcon from '@mui/icons-material/ChatRounded'; +import CloseRoundedIcon from '@mui/icons-material/CloseRounded'; +import AutoAwesomeRoundedIcon from '@mui/icons-material/AutoAwesomeRounded'; +import HelpOutlineRoundedIcon from '@mui/icons-material/HelpOutlineRounded'; + +import chatAutomat, { selectPendingAskUser, selectHasPendingAsk } from '../../automats/chatAutomat'; + +export default class ChatFab extends PureComponent { + constructor(props) { + super(props); + const s = chatAutomat.state; + this.state = { + isOpen: s.isOpen, + conversationState: s.conversationState, + streamPhase: s.streamPhase, + isSending: s.isSending, + hasPendingAsk: selectHasPendingAsk(s), + pendingAsk: selectPendingAskUser(s), + sseStatus: s.sseStatus, + }; + } + + componentDidMount() { + this.unsub = chatAutomat.subscribe(this, (state) => ({ + isOpen: state.isOpen, + conversationState: state.conversationState, + streamPhase: state.streamPhase, + isSending: state.isSending, + hasPendingAsk: selectHasPendingAsk(state), + pendingAsk: selectPendingAskUser(state), + sseStatus: state.sseStatus, + })); + } + + componentWillUnmount() { + this.unsub?.(); + } + + handleClick = () => { + chatAutomat.actions.toggleChat(); + }; + + render() { + const { isOpen, conversationState, streamPhase, isSending, hasPendingAsk, pendingAsk } = this.state; + const isBusy = conversationState === 'busy' || streamPhase === 'thinking' || streamPhase === 'working' || isSending; + + let tooltipTitle = 'KI-Chat öffnen'; + let ariaLabel = 'KI-Chat'; + if (hasPendingAsk) { + tooltipTitle = pendingAsk?.question + ? `Antwort erforderlich: „${pendingAsk.question}“` + : 'Antwort erforderlich: Das Modell wartet auf Ihre Eingabe'; + ariaLabel = 'KI-Chat: Antwort erforderlich'; + } else if (isBusy) { + tooltipTitle = 'Modell antwortet gerade…'; + ariaLabel = 'KI-Chat: Modell antwortet'; + } + + return ( + ({ + position: 'fixed', + bottom: { xs: 20, sm: 28 }, + right: { xs: 20, sm: 28 }, + zIndex: theme.zIndex.speedDial || 1050, + opacity: isOpen ? 0 : 1, + transform: isOpen ? 'scale(0)' : 'scale(1)', + pointerEvents: isOpen ? 'none' : 'auto', + transition: 'transform 0.25s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.2s linear', + })} + > + + {/* Animated pulsing halo when awaiting user answer */} + {hasPendingAsk && !isOpen && ( + + )} + + {/* Animated pulsating halo when model is busy thinking/working */} + {!hasPendingAsk && isBusy && !isOpen && ( + + )} + + {/* Circular spinner ring when model is busy */} + {!hasPendingAsk && isBusy && !isOpen && ( + + )} + + + + ({ + width: 54, + height: 54, + boxShadow: hasPendingAsk + ? '0 8px 24px rgba(245, 158, 11, 0.45)' + : isBusy + ? '0 8px 24px rgba(245, 158, 11, 0.35)' + : '0 8px 24px rgba(59, 91, 219, 0.35)', + transition: 'transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1), background 0.2s', + '&:hover': { + transform: 'scale(1.06)', + }, + '&:active': { + transform: 'scale(0.96)', + }, + background: hasPendingAsk + ? 'linear-gradient(135deg, #f59e0b 0%, #ea580c 100%)' + : isBusy + ? 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)' + : `linear-gradient(135deg, ${theme.palette.primary.main} 0%, ${theme.palette.primary.dark} 100%)`, + })} + > + {isOpen ? ( + + ) : hasPendingAsk ? ( + + ) : isBusy ? ( + + ) : ( + + )} + + + + + + ); + } +} diff --git a/src/components/chat/ChatFlowList.jsx b/src/components/chat/ChatFlowList.jsx new file mode 100644 index 0000000..3831395 --- /dev/null +++ b/src/components/chat/ChatFlowList.jsx @@ -0,0 +1,381 @@ +import React, { PureComponent, createRef } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import HealingMarkdown from './HealingMarkdown'; + +function formatMessageTime(isoString) { + if (!isoString) return ''; + try { + const d = new Date(isoString); + if (Number.isNaN(d.getTime())) return ''; + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } catch { + return ''; + } +} + +function parseMessagePayload(raw) { + if (typeof raw !== 'string') return { content: raw }; + try { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object') return parsed; + } catch {} + return { content: raw }; +} + +function MessageRow({ msg, showTime = true }) { + + const payload = parseMessagePayload(msg.message); + const textContent = payload.content !== undefined ? payload.content : msg.message; + const toolCalls = Array.isArray(payload.tool_calls) && payload.tool_calls.length > 0 ? payload.tool_calls : null; + + const isUser = msg.role === 'user'; + const isSystem = msg.role === 'system'; + const isTool = msg.role === 'tool'; + const isReasoning = msg.role === "reasoning" || Boolean(payload.reasoning_content && !toolCalls && payload.content === payload.reasoning_content); + const timeStr = showTime ? formatMessageTime(msg.created_at) : ''; + + if (isSystem) { + return ( + + + {textContent} + + + ); + } + + if (isReasoning) { + return ( + + ({ + bgcolor: 'transparent', + color: 'text.disabled', + px: 1.5, + py: 0.5, + borderRadius: 2, + fontSize: '0.75rem', + fontStyle: 'italic', + maxWidth: '85%', + borderLeft: `2px solid ${theme.palette.divider}`, + whiteSpace: 'pre-wrap', + })} + > + Gedankengang + {textContent} + + + ); + } + + if (toolCalls) { + const formattedCalls = toolCalls.map((tc) => { + let args = {}; + try { + if (typeof tc.function?.arguments === 'string') { + args = JSON.parse(tc.function.arguments); + } else if (tc.function?.arguments) { + args = tc.function.arguments; + } + } catch {} + + if (tc.function?.name === 'ask_user') { + const q = args.question || ''; + return `ask_user: ${q}`; + } + + const method = args.method || (tc.function?.name ? tc.function.name.toUpperCase() : 'CALL'); + const path = args.path || args.url || ''; + if (path) { + return `${method} ${path}`; + } + return `${tc.function?.name || 'tool'} ${JSON.stringify(args)}`; + }).join('\n'); + + return ( + + ({ + bgcolor: theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.03)', + color: 'text.secondary', + px: 1.25, + py: 0.35, + borderRadius: 2, + fontSize: '0.72rem', + maxWidth: '90%', + fontFamily: 'monospace', + border: `1px solid ${theme.palette.divider}`, + display: 'inline-flex', + alignItems: 'center', + gap: 1, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + })} + > + + Werkzeug + + + {formattedCalls} + + + + ); + } + + if (isTool) { + let statusText = ''; + let bodyLength = null; + + let resObj = null; + if (typeof textContent === 'object' && textContent !== null) { + resObj = textContent; + } else if (typeof textContent === 'string') { + try { + const parsed = JSON.parse(textContent); + if (parsed && typeof parsed === 'object') { + resObj = parsed; + } + } catch {} + } + + if (resObj && ('status' in resObj || 'statusCode' in resObj || 'body' in resObj)) { + const status = resObj.status ?? resObj.statusCode ?? 200; + statusText = `Status ${status}`; + if (typeof resObj.body === 'string') { + bodyLength = resObj.body.length; + } else if (resObj.body != null) { + bodyLength = JSON.stringify(resObj.body).length; + } else if (resObj.data != null) { + bodyLength = JSON.stringify(resObj.data).length; + } + } else if (typeof textContent === 'string') { + bodyLength = textContent.length; + } + + let displayText = ''; + if (resObj && ('answer' in resObj || 'content' in resObj) && ('tool_call_id' in resObj) && !('status' in resObj)) { + displayText = resObj.answer || resObj.content || ''; + } else { + displayText = statusText + ? `${statusText}${bodyLength != null ? ` · ${bodyLength} byte` : ''}` + : (bodyLength != null ? `${bodyLength} byte` : String(textContent)); + } + + return ( + + ({ + bgcolor: theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.03)', + color: 'text.secondary', + px: 1.25, + py: 0.35, + borderRadius: 2, + fontSize: '0.72rem', + maxWidth: '90%', + fontFamily: 'monospace', + border: `1px solid ${theme.palette.divider}`, + display: 'inline-flex', + alignItems: 'center', + gap: 1, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + })} + > + + Ergebnis + + + {displayText} + + + + ); + } + + return ( + + + {timeStr ? ( + + + {timeStr} + + + ) : null} + + ({ + px: 2, + py: 1.25, + borderRadius: isUser ? '16px 16px 4px 16px' : '16px 16px 16px 4px', + bgcolor: isUser + ? theme.palette.primary.main + : theme.palette.mode === 'dark' + ? 'background.paper' + : '#f4f6f8', + color: isUser + ? theme.palette.primary.contrastText + : theme.palette.text.primary, + boxShadow: isUser + ? '0 2px 8px rgba(59, 91, 219, 0.25)' + : '0 1px 4px rgba(0, 0, 0, 0.05)', + border: isUser ? 'none' : `1px solid ${theme.palette.divider}`, + wordBreak: 'break-word', + whiteSpace: isUser ? 'pre-wrap' : 'normal', + fontSize: '0.875rem', + lineHeight: 1.5, + })} + > + {isUser ? ( + textContent + ) : ( + + )} + + + + ); +} + +export default class ChatFlowList extends PureComponent { + containerRef = createRef(); + bottomRef = createRef(); + scrollTimeout = null; + + componentDidMount() { + this.scrollToBottom('auto'); + } + + componentDidUpdate(prevProps) { + if ( + this.props.messages !== prevProps.messages || + this.props.messages?.length !== prevProps.messages?.length + ) { + this.scrollToBottom('smooth'); + } + } + + componentWillUnmount() { + if (this.scrollTimeout) { + clearTimeout(this.scrollTimeout); + this.scrollTimeout = null; + } + } + + scrollToBottom = (behavior = 'smooth') => { + const doScroll = () => { + if (this.bottomRef.current?.scrollIntoView) { + this.bottomRef.current.scrollIntoView({ behavior, block: 'end' }); + } + if (this.containerRef.current) { + this.containerRef.current.scrollTop = this.containerRef.current.scrollHeight; + } + }; + + // Immediate frame + requestAnimationFrame(doScroll); + + // Delayed fallback for font/layout reflows + if (this.scrollTimeout) clearTimeout(this.scrollTimeout); + this.scrollTimeout = setTimeout(doScroll, 60); + }; + + render() { + const { messages = [] } = this.props; + + if (messages.length === 0) { + return null; + } + + return ( + + {messages.map((msg, index) => { + const prevMsg = index > 0 ? messages[index - 1] : null; + const currTime = formatMessageTime(msg.created_at); + const prevTime = prevMsg ? formatMessageTime(prevMsg.created_at) : ''; + const showTime = Boolean(currTime && currTime !== prevTime); + + return ( + + ); + })} +
+ + ); + } +} diff --git a/src/components/chat/HealingMarkdown.jsx b/src/components/chat/HealingMarkdown.jsx new file mode 100644 index 0000000..957e9a1 --- /dev/null +++ b/src/components/chat/HealingMarkdown.jsx @@ -0,0 +1,505 @@ +import React, { memo, useState } from 'react'; +import Box from '@mui/material/Box'; + +/** + * Healing markdown renderer. + * + * Markdown arrives token by token, so at any moment the text may end inside an + * unclosed code fence, bold/italic span, inline code or half-typed link. This + * renderer is tolerant: every unterminated construct is rendered as if it had + * been closed right at the end of the text, and a half-typed link shows only + * its label. It renders React elements only (no innerHTML), so it is XSS-safe. + * + * Supported: headings, paragraphs, (nested-less) ordered/unordered lists, + * blockquotes, fenced code, horizontal rules, `code`, **bold**, *italic*, + * ~~strike~~, [links](url) and ![images](url). + */ + +const SAFE_URL = /^(https?:\/\/|mailto:|\/|#)/i; + +/** + * Resolve an image target to something the browser may load. + * + * Bare/relative names are artifacts of the model sandbox virtual filesystem and are + * served by `GET /api/vfs/{*path}` — so `![plot](in_vfs.png)` and + * `![plot](labels/a.png)` resolve to `/api/vfs/in_vfs.png` and `/api/vfs/labels/a.png`. + * Absolute app paths, http(s) URLs and inline data URIs are passed through unchanged. + * Anything else (javascript:, vbscript:, …) yields `null` so it is never rendered. + * + * @param {string} rawUrl + * @returns {string | null} + */ +function resolveImageUrl(rawUrl) { + const url = String(rawUrl ?? '').trim(); + if (!url) return null; + if (/^data:image\//i.test(url)) return url; + if (/^https?:\/\//i.test(url) || url.startsWith('/')) return url; + + // Everything else is a VFS name: strip a leading `./`, encode each segment, keep subdirs. + const segments = url + .replace(/^\.\//, '') + .split('/') + .filter((s) => s && s !== '.') + .map((s) => encodeURIComponent(s)); + if (segments.length === 0 || segments.includes('..')) return null; + return `/api/vfs/${segments.join('/')}`; +} + +/** + * Strip an optional markdown link title: `path "Title"` -> `path`. + * @param {string} raw + * @returns {string} + */ +function stripImageTitle(raw) { + const trimmed = raw.trim(); + const m = trimmed.match(/^(\S*)\s+["'(].*$/); + return (m ? m[1] : trimmed).trim(); +} + +/** + * Image element that degrades to its alt text when the resource cannot be loaded + * (404 while the model is still generating the file, revoked sandbox file, …). + */ +function MarkdownImage({ src, alt }) { + const [failed, setFailed] = useState(false); + + if (!src || failed) { + return {alt || '🖼'}; + } + + return ( + {alt setFailed(true)} + /> + ); +} + +function parseInline(src, keyPrefix = 'i') { + const out = []; + let buf = ''; + let i = 0; + let n = 0; + const flush = () => { + if (buf) { + out.push(buf); + buf = ''; + } + }; + const push = (el) => { + flush(); + out.push(el); + }; + const key = () => `${keyPrefix}-${n++}`; + + while (i < src.length) { + const c = src[i]; + + if (c === '`') { + const end = src.indexOf('`', i + 1); + const code = end === -1 ? src.slice(i + 1) : src.slice(i + 1, end); + if (code) push({code}); + i = end === -1 ? src.length : end + 1; + continue; + } + + if (src.startsWith('**', i) || src.startsWith('~~', i)) { + const marker = src.slice(i, i + 2); + const end = src.indexOf(marker, i + 2); + const inner = end === -1 ? src.slice(i + 2) : src.slice(i + 2, end); + const k = key(); + const children = parseInline(inner, k); + push(marker === '**' ? {children} : {children}); + i = end === -1 ? src.length : end + 2; + continue; + } + + if (c === '*' && src[i + 1] && !/\s/.test(src[i + 1])) { + let end = i + 1; + while (end < src.length && !(src[end] === '*' && src[end + 1] !== '*' && src[end - 1] !== '*')) end++; + const closed = end < src.length; + const inner = src.slice(i + 1, closed ? end : src.length); + const k = key(); + push({parseInline(inner, k)}); + i = closed ? end + 1 : src.length; + continue; + } + + // Images must be tried before links: `![alt](url)` starts with the same `[`. + if (c === '!' && src[i + 1] === '[') { + const closeLabel = src.indexOf(']', i + 2); + if (closeLabel !== -1 && src[closeLabel + 1] === '(') { + const endUrl = src.indexOf(')', closeLabel + 2); + const alt = src.slice(i + 2, closeLabel); + const rawUrl = stripImageTitle( + endUrl === -1 ? src.slice(closeLabel + 2) : src.slice(closeLabel + 2, endUrl), + ); + const k = key(); + // While streaming, the URL may still be half-typed: hold the alt text back until closed. + if (endUrl !== -1) { + const resolved = resolveImageUrl(rawUrl); + if (resolved) { + push( + , + ); + i = endUrl + 1; + continue; + } + // Unsafe/unresolvable target: fall through and show the alt text only. + push({parseInline(alt, k)}); + i = endUrl + 1; + continue; + } + push({parseInline(alt, k)}); + i = src.length; + continue; + } + } + + if (c === '[') { + const close = src.indexOf(']', i + 1); + if (close !== -1) { + const label = src.slice(i + 1, close); + if (src[close + 1] === '(') { + const endUrl = src.indexOf(')', close + 2); + const url = (endUrl === -1 ? src.slice(close + 2) : src.slice(close + 2, endUrl)).trim(); + const k = key(); + if (endUrl !== -1 && SAFE_URL.test(url)) { + push( + + {parseInline(label, k)} + , + ); + } else { + // Half-typed (or unsafe) link: show the label only. + push({parseInline(label, k)}); + } + i = endUrl === -1 ? src.length : endUrl + 1; + continue; + } + } + } + + buf += c; + i++; + } + flush(); + return out; +} + +function isTableDelimiter(line) { + const trimmed = line.trim(); + if (!trimmed.includes('-')) return false; + const parts = trimmed.replace(/^\|/, '').replace(/\|$/, '').split('|'); + return parts.length > 0 && parts.every((p) => /^\s*:?-+:?\s*$/.test(p)); +} + +function parseTableAlignments(line) { + const parts = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|'); + return parts.map((p) => { + const s = p.trim(); + const left = s.startsWith(':'); + const right = s.endsWith(':'); + if (left && right) return 'center'; + if (right) return 'right'; + if (left) return 'left'; + return 'left'; + }); +} + +function parseTableRowCells(line) { + const trimmed = line.trim(); + // Strip outer pipes if present + const content = trimmed.replace(/^\|/, '').replace(/\|$/, ''); + return content.split('|').map((c) => c.trim()); +} + +function parseBlocks(text) { + const lines = String(text ?? '').replace(/\r\n?/g, '\n').split('\n'); + const blocks = []; + let i = 0; + + const isBlockStart = (l) => + /^```/.test(l) || + /^#{1,6}\s/.test(l) || + /^\s*([-*+]|\d+\.)\s+/.test(l) || + /^>/.test(l) || + /^(-{3,}|\*{3,}|_{3,})\s*$/.test(l) || + (l.includes('|') && i + 1 < lines.length && isTableDelimiter(lines[i + 1])); + + while (i < lines.length) { + const line = lines[i]; + + if (!line.trim()) { + i++; + continue; + } + + const fence = line.match(/^```\s*([\w+-]*)/); + if (fence) { + const code = []; + i++; + while (i < lines.length && !/^```\s*$/.test(lines[i])) code.push(lines[i++]); + i++; // closing fence (or end of stream: healed implicitly) + blocks.push({ type: 'code', lang: fence[1], text: code.join('\n') }); + continue; + } + + // Markdown Table check: line has '|' and next line is table delimiter or end of text during stream + if (line.includes('|') && i + 1 < lines.length && isTableDelimiter(lines[i + 1])) { + const headerCells = parseTableRowCells(line); + const alignments = parseTableAlignments(lines[i + 1]); + i += 2; // skip header and delimiter + + const rows = []; + while (i < lines.length && lines[i].trim() && lines[i].includes('|') && !isBlockStart(lines[i])) { + rows.push(parseTableRowCells(lines[i])); + i++; + } + blocks.push({ + type: 'table', + headers: headerCells, + alignments, + rows, + }); + continue; + } + + const heading = line.match(/^(#{1,6})\s+(.*)$/); + if (heading) { + blocks.push({ type: 'heading', level: heading[1].length, text: heading[2] }); + i++; + continue; + } + + if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) { + blocks.push({ type: 'hr' }); + i++; + continue; + } + + if (/^>/.test(line)) { + const quote = []; + while (i < lines.length && /^>/.test(lines[i])) quote.push(lines[i++].replace(/^>\s?/, '')); + blocks.push({ type: 'quote', text: quote.join('\n') }); + continue; + } + + const item = line.match(/^\s*([-*+]|\d+\.)\s+(.*)$/); + if (item) { + const ordered = /\d/.test(item[1]); + const items = []; + while (i < lines.length) { + const m = lines[i].match(/^\s*([-*+]|\d+\.)\s+(.*)$/); + if (!m || /\d/.test(m[1]) !== ordered) break; + items.push(m[2]); + i++; + } + blocks.push({ type: 'list', ordered, items }); + continue; + } + + const para = []; + while (i < lines.length && lines[i].trim() && !(para.length && isBlockStart(lines[i]))) para.push(lines[i++]); + blocks.push({ type: 'p', text: para.join('\n') }); + } + return blocks; +} + +const rootSx = (theme) => ({ + whiteSpace: 'normal', + '& > *:first-of-type': { mt: 0 }, + '& > *:last-child': { mb: 0 }, + '& p': { m: 0, mb: 1, whiteSpace: 'pre-line' }, + '& h1, & h2, & h3, & h4, & h5, & h6': { m: 0, mt: 1.25, mb: 0.75, lineHeight: 1.3, fontWeight: 600 }, + '& h1': { fontSize: '1.2rem' }, + '& h2': { fontSize: '1.1rem' }, + '& h3': { fontSize: '1rem' }, + '& h4, & h5, & h6': { fontSize: '0.9rem' }, + '& ul, & ol': { m: 0, mb: 1, pl: 3 }, + '& li': { mb: 0.25 }, + '& blockquote': { + m: 0, + mb: 1, + pl: 1.5, + borderLeft: `3px solid ${theme.palette.divider}`, + color: 'text.secondary', + whiteSpace: 'pre-line', + }, + '& code': { + fontFamily: 'monospace', + fontSize: '0.82em', + px: 0.5, + borderRadius: 0.75, + bgcolor: theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)', + }, + '& pre': { + m: 0, + mb: 1, + p: 1.25, + borderRadius: 1.5, + overflowX: 'auto', + bgcolor: theme.palette.mode === 'dark' ? 'rgba(0,0,0,0.35)' : 'rgba(0,0,0,0.06)', + }, + '& pre code': { p: 0, bgcolor: 'transparent', whiteSpace: 'pre' }, + '& hr': { border: 0, borderTop: `1px solid ${theme.palette.divider}`, my: 1.5 }, + '& a': { color: 'primary.main' }, + '& .md-image': { + display: 'block', + my: 1, + maxWidth: '100%', + maxHeight: 360, + objectFit: 'contain', + borderRadius: 1.5, + border: `1px solid ${theme.palette.divider}`, + bgcolor: theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.03)', + }, + '& .md-image-fallback': { + display: 'inline-block', + px: 0.5, + borderRadius: 0.75, + fontStyle: 'italic', + color: 'text.disabled', + border: `1px dashed ${theme.palette.divider}`, + }, + '& .table-container': { + width: '100%', + overflowX: 'auto', + my: 1.5, + borderRadius: 1.5, + border: `1px solid ${theme.palette.divider}`, + }, + '& table': { + width: '100%', + borderCollapse: 'collapse', + fontSize: '0.82rem', + textAlign: 'left', + }, + '& th, & td': { + py: 0.75, + px: 1.25, + borderBottom: `1px solid ${theme.palette.divider}`, + borderRight: `1px solid ${theme.palette.divider}`, + '&:last-child': { borderRight: 'none' }, + }, + '& th': { + bgcolor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.04)', + fontWeight: 600, + }, + '& tr:last-child td': { + borderBottom: 'none', + }, +}); + +function HealingMarkdown({ text, streaming = false }) { + const blocks = parseBlocks(text); + const cursor = streaming ? ' ▍' : null; + + return ( + + {blocks.map((b, idx) => { + const last = idx === blocks.length - 1; + const tail = last ? cursor : null; + const k = `b${idx}`; + switch (b.type) { + case 'table': + return ( + + + + + {b.headers.map((h, colIdx) => ( + + ))} + + + + {b.rows.map((row, rowIdx) => { + const isLastRow = last && rowIdx === b.rows.length - 1; + return ( + + {b.headers.map((_, colIdx) => { + const isLastCell = isLastRow && colIdx === b.headers.length - 1; + const cellValue = row[colIdx] ?? ''; + return ( + + ); + })} + + ); + })} + +
+ {parseInline(h, `${k}-th-${colIdx}`)} +
+ {parseInline(cellValue, `${k}-td-${rowIdx}-${colIdx}`)} + {isLastCell ? tail : null} +
+
+ ); + case 'code': + return ( +
+                
+                  {b.text}
+                  {tail}
+                
+              
+ ); + case 'heading': { + const Tag = `h${b.level}`; + return ( + + {parseInline(b.text, k)} + {tail} + + ); + } + case 'hr': + return
; + case 'quote': + return ( +
+ {parseInline(b.text, k)} + {tail} +
+ ); + case 'list': { + const Tag = b.ordered ? 'ol' : 'ul'; + return ( + + {b.items.map((it, j) => ( +
  • + {parseInline(it, `${k}-${j}`)} + {last && j === b.items.length - 1 ? tail : null} +
  • + ))} +
    + ); + } + default: + return ( +

    + {parseInline(b.text, k)} + {tail} +

    + ); + } + })} + {streaming && blocks.length === 0 ? cursor : null} +
    + ); +} + +export default memo(HealingMarkdown); diff --git a/src/components/chat/NewConversationDialog.jsx b/src/components/chat/NewConversationDialog.jsx new file mode 100644 index 0000000..755efb3 --- /dev/null +++ b/src/components/chat/NewConversationDialog.jsx @@ -0,0 +1,171 @@ +import { PureComponent } from 'react'; +import Dialog from '@mui/material/Dialog'; +import DialogTitle from '@mui/material/DialogTitle'; +import DialogContent from '@mui/material/DialogContent'; +import DialogActions from '@mui/material/DialogActions'; +import Button from '@mui/material/Button'; +import TextField from '@mui/material/TextField'; +import MenuItem from '@mui/material/MenuItem'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import CircularProgress from '@mui/material/CircularProgress'; +import AutoAwesomeRoundedIcon from '@mui/icons-material/AutoAwesomeRounded'; + +import { PRESET_MODELS } from '../../automats/chatAutomat'; + +export default class NewConversationDialog extends PureComponent { + constructor(props) { + super(props); + this.state = { + provider: 'openrouter', + model: PRESET_MODELS[0].value, + customModel: '', + isCustom: false, + loading: false, + error: null, + }; + } + + handleModelChange = (e) => { + const val = e.target.value; + if (val === '__custom__') { + this.setState({ isCustom: true }); + } else { + this.setState({ model: val, isCustom: false }); + } + }; + + handleSubmit = async (e) => { + e?.preventDefault(); + const { provider, model, customModel, isCustom } = this.state; + const finalModel = isCustom ? customModel.trim() : model.trim(); + if (!finalModel) { + this.setState({ error: 'Bitte ein Modell angeben.' }); + return; + } + + this.setState({ loading: true, error: null }); + try { + await this.props.onCreate?.({ + provider: provider.trim() || 'openrouter', + model: finalModel, + }); + this.props.onClose?.(); + } catch (err) { + this.setState({ + loading: false, + error: err?.message || 'Erstellung fehlgeschlagen.', + }); + } + }; + + render() { + const { open, onClose } = this.props; + const { provider, model, customModel, isCustom, loading, error } = this.state; + + return ( + +
    + + + + + + Neuer Chat + + + + + + Wähle das Sprachmodell für die neue Konversation aus. + + + this.setState({ provider: e.target.value })} + fullWidth + size="small" + disabled={loading} + helperText="Standard: openrouter" + /> + + + {PRESET_MODELS.map((item) => ( + + {item.label} + + ))} + Anderes Modell eingeben… + + + {isCustom && ( + this.setState({ customModel: e.target.value })} + fullWidth + size="small" + autoFocus + disabled={loading} + /> + )} + + {error && ( + + {error} + + )} + + + + + + +
    +
    + ); + } +} diff --git a/src/components/chat/index.js b/src/components/chat/index.js new file mode 100644 index 0000000..91d239f --- /dev/null +++ b/src/components/chat/index.js @@ -0,0 +1,4 @@ +export { default as ChatFab } from './ChatFab'; +export { default as ChatDrawer } from './ChatDrawer'; +export { default as ChatFlowList } from './ChatFlowList'; +export { default as NewConversationDialog } from './NewConversationDialog'; diff --git a/src/devtools/AutomatInspector.jsx b/src/devtools/AutomatInspector.jsx index c89f07b..5b6f2e1 100644 --- a/src/devtools/AutomatInspector.jsx +++ b/src/devtools/AutomatInspector.jsx @@ -428,7 +428,12 @@ class AutomatInspector extends PureComponent { color="secondary" overlap="circular" invisible={names.length === 0} - sx={{ position: 'fixed', right: 16, bottom: 16, zIndex: (theme) => theme.zIndex.modal + 1 }} + sx={{ + position: 'fixed', + right: { xs: 27, sm: 35 }, + bottom: { xs: 84, sm: 94 }, + zIndex: (theme) => theme.zIndex.drawer - 1, + }} >