u
This commit is contained in:
13
package-lock.json
generated
13
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -577,8 +577,24 @@ export class ApiClient {
|
||||
/** Absolute URL for <img src> (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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
843
src/automats/chatAutomat.js
Normal file
843
src/automats/chatAutomat.js
Normal file
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
<Outlet />
|
||||
</Box>
|
||||
</Box>
|
||||
<ChatFab />
|
||||
<ChatDrawer />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
144
src/components/chat/AskUserPrompt.jsx
Normal file
144
src/components/chat/AskUserPrompt.jsx
Normal file
@@ -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 (
|
||||
<Box
|
||||
sx={(theme) => ({
|
||||
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,
|
||||
})}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: 'primary.main' }}>
|
||||
<HelpOutlineRoundedIcon sx={{ fontSize: 18 }} />
|
||||
<Typography variant="subtitle2" fontWeight={700} sx={{ color: 'text.primary' }}>
|
||||
{question || 'Frage des Modells'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
||||
{options.map((opt, idx) => (
|
||||
<Button
|
||||
key={idx}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
disabled={disabled}
|
||||
onClick={() => handleSelectOption(opt)}
|
||||
sx={{
|
||||
justifyContent: 'flex-start',
|
||||
textAlign: 'left',
|
||||
textTransform: 'none',
|
||||
borderRadius: 2,
|
||||
px: 1.5,
|
||||
py: 0.75,
|
||||
fontSize: '0.82rem',
|
||||
bgcolor: 'background.paper',
|
||||
'&:hover': {
|
||||
bgcolor: 'action.hover',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{opt}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{!showCustomInput ? (
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
disabled={disabled}
|
||||
onClick={() => setShowCustomInput(true)}
|
||||
sx={{
|
||||
justifyContent: 'flex-start',
|
||||
textAlign: 'left',
|
||||
textTransform: 'none',
|
||||
borderRadius: 2,
|
||||
px: 1.5,
|
||||
py: 0.75,
|
||||
fontSize: '0.82rem',
|
||||
bgcolor: 'background.paper',
|
||||
borderStyle: 'dashed',
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
Eigene Antwort eingeben…
|
||||
</Button>
|
||||
) : (
|
||||
<Box
|
||||
component="form"
|
||||
onSubmit={handleCustomSubmit}
|
||||
sx={{ display: 'flex', gap: 1, mt: 0.5, alignItems: 'center' }}
|
||||
>
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
autoFocus
|
||||
placeholder="Antwort eingeben…"
|
||||
value={customAnswer}
|
||||
onChange={(e) => 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',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
size="small"
|
||||
disabled={disabled || !customAnswer.trim()}
|
||||
endIcon={<SendRoundedIcon sx={{ fontSize: 16 }} />}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
px: 2,
|
||||
py: 0.9,
|
||||
whiteSpace: 'nowrap',
|
||||
textTransform: 'none',
|
||||
}}
|
||||
>
|
||||
Antworten
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
680
src/components/chat/ChatDrawer.jsx
Normal file
680
src/components/chat/ChatDrawer.jsx
Normal file
@@ -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 (
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider',
|
||||
bgcolor: 'background.paper',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 2,
|
||||
bgcolor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<AutoAwesomeRoundedIcon sx={{ fontSize: 18 }} />
|
||||
</Box>
|
||||
<Typography variant="subtitle1" fontWeight={700}>
|
||||
KI-Assistent
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<IconButton size="small" onClick={this.handleClose} aria-label="Chat schließen">
|
||||
<CloseRoundedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{/* Conversation Selector Bar */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel id="chat-conversation-label">Konversation</InputLabel>
|
||||
<Select
|
||||
labelId="chat-conversation-label"
|
||||
value={selectedConversationId || ''}
|
||||
label="Konversation"
|
||||
onChange={this.handleSelectConversation}
|
||||
disabled={loadingConversations || conversations.length === 0}
|
||||
sx={{ borderRadius: 2 }}
|
||||
renderValue={(selectedId) => {
|
||||
const conv = conversations.find((c) => c.id === selectedId);
|
||||
if (!conv) return '';
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, overflow: 'hidden' }}>
|
||||
<Typography variant="body2" fontWeight={600} noWrap>
|
||||
#{conv.id}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" noWrap>
|
||||
{conv.model || 'Standard'}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
>
|
||||
{conversations.map((c) => (
|
||||
<MenuItem key={c.id} value={c.id}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, width: '100%', overflow: 'hidden' }}>
|
||||
<Typography variant="body2" fontWeight={600} noWrap>
|
||||
#{c.id}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" noWrap sx={{ flexGrow: 1 }}>
|
||||
{c.model || 'Standard'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
disabled={!selectedConversationId}
|
||||
onClick={(e) => this.handleDeleteConversation(e, selectedConversationId)}
|
||||
aria-label="Konversation löschen"
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
p: 0.9,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<DeleteOutlineRoundedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
|
||||
<IconButton
|
||||
size="small"
|
||||
color="primary"
|
||||
disabled={!selectedConversationId}
|
||||
onClick={this.handleNewChat}
|
||||
aria-label="Neuer Chat"
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
p: 0.9,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<AddRoundedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
renderBody() {
|
||||
const {
|
||||
conversations,
|
||||
selectedConversationId,
|
||||
draftConversation,
|
||||
conversationState,
|
||||
messages,
|
||||
streamingText,
|
||||
loadingConversations,
|
||||
loadingMessages,
|
||||
model,
|
||||
} = this.state;
|
||||
|
||||
if (loadingConversations && conversations.length === 0) {
|
||||
return (
|
||||
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', p: 3, gap: 2 }}>
|
||||
<CircularProgress size={32} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Lade Konversationen…
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (conversations.length === 0 && !draftConversation) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: '50%',
|
||||
bgcolor: 'primary.light',
|
||||
color: 'primary.main',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<AutoAwesomeRoundedIcon sx={{ fontSize: 32 }} />
|
||||
</Box>
|
||||
<Typography variant="h6" fontWeight={700}>
|
||||
Willkommen im Chat
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ maxWidth: 280 }}>
|
||||
Starte eine Unterhaltung mit dem KI-Modell, um Fragen zu stellen oder Daten abzufragen.
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddRoundedIcon />}
|
||||
onClick={this.handleNewChat}
|
||||
sx={{ borderRadius: 2, mt: 1 }}
|
||||
>
|
||||
Neuen Chat starten
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const pendingAsk = this.getPendingAskUser();
|
||||
|
||||
return (
|
||||
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden' }}>
|
||||
{messages.length === 0 ? (
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
) : (
|
||||
<ChatFlowList
|
||||
messages={
|
||||
streamingText
|
||||
? [
|
||||
...messages,
|
||||
{
|
||||
id: 'streaming',
|
||||
conversation_id: selectedConversationId,
|
||||
role: 'assistant',
|
||||
message: streamingText,
|
||||
streaming: true,
|
||||
},
|
||||
]
|
||||
: messages
|
||||
}
|
||||
conversationState={conversationState}
|
||||
model={model}
|
||||
/>
|
||||
)}
|
||||
|
||||
{pendingAsk && (
|
||||
<AskUserPrompt
|
||||
askData={pendingAsk}
|
||||
onAnswer={this.handleAnswerTool}
|
||||
disabled={this.state.isSending}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Box
|
||||
component="form"
|
||||
onSubmit={this.handleSend}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderTop: 1,
|
||||
borderColor: 'divider',
|
||||
bgcolor: 'background.paper',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{sendError && (
|
||||
<Box
|
||||
sx={{
|
||||
p: 1,
|
||||
borderRadius: 1.5,
|
||||
bgcolor: 'error.light',
|
||||
color: 'error.contrastText',
|
||||
fontSize: '0.75rem',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption">{sendError}</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => chatAutomat.setState({ sendError: null })}
|
||||
sx={{ color: 'inherit', p: 0.2 }}
|
||||
>
|
||||
<CloseRoundedIcon sx={{ fontSize: 14 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-end', gap: 1 }}>
|
||||
<TextField
|
||||
inputRef={this.inputRef}
|
||||
multiline
|
||||
maxRows={4}
|
||||
size="small"
|
||||
fullWidth
|
||||
value={inputText}
|
||||
onChange={(e) => 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',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
type="submit"
|
||||
color="primary"
|
||||
disabled={!canSend}
|
||||
title={
|
||||
isWorking
|
||||
? 'Modell antwortet gerade…'
|
||||
: 'Senden (Enter)'
|
||||
}
|
||||
sx={{
|
||||
bgcolor: isWorking ? 'action.hover' : 'primary.main',
|
||||
color: isWorking ? 'warning.main' : 'primary.contrastText',
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 2.5,
|
||||
'&:hover': {
|
||||
bgcolor: 'primary.dark',
|
||||
},
|
||||
'&.Mui-disabled': {
|
||||
bgcolor: isWorking ? 'action.hover' : 'primary.main',
|
||||
color: isWorking ? 'warning.main' : 'primary.contrastText',
|
||||
cursor: 'default',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{isWorking ? (
|
||||
<CircularProgress size={18} color="inherit" thickness={4.5} />
|
||||
) : (
|
||||
<SendRoundedIcon sx={{ fontSize: 20 }} />
|
||||
)}
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: '0.68rem', textAlign: 'center' }}>
|
||||
Enter zum Senden • Shift+Enter für Zeilenumbruch
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isOpen, drawerWidth, isResizing } = this.state;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer
|
||||
anchor="right"
|
||||
open={isOpen}
|
||||
onClose={this.handleClose}
|
||||
sx={(theme) => ({
|
||||
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 */}
|
||||
<Box
|
||||
onPointerDown={this.handleResizeStart}
|
||||
onMouseDown={this.handleResizeStart}
|
||||
onTouchStart={this.handleResizeStart}
|
||||
onDoubleClick={this.handleResizeDoubleClick}
|
||||
title="Ziehen zum Anpassen der Breite • Doppelklick zum Zurücksetzen"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
width: 16,
|
||||
cursor: 'col-resize',
|
||||
zIndex: 9999,
|
||||
display: { xs: 'none', sm: 'flex' },
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
touchAction: 'none',
|
||||
userSelect: 'none',
|
||||
'&:hover .drawer-resizer-line, &:active .drawer-resizer-line': {
|
||||
bgcolor: 'primary.main',
|
||||
height: 56,
|
||||
width: 4,
|
||||
opacity: 1,
|
||||
boxShadow: '0 0 10px rgba(59, 91, 219, 0.6)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
className="drawer-resizer-line"
|
||||
sx={{
|
||||
width: isResizing ? 4 : 3,
|
||||
height: isResizing ? 56 : 36,
|
||||
borderRadius: 2,
|
||||
bgcolor: isResizing ? 'primary.main' : 'divider',
|
||||
opacity: isResizing ? 1 : 0.6,
|
||||
transition: 'background-color 0.15s, height 0.15s, width 0.15s, opacity 0.15s, box-shadow 0.15s',
|
||||
boxShadow: isResizing ? '0 0 10px rgba(59, 91, 219, 0.6)' : 'none',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{this.renderHeader()}
|
||||
{this.renderBody()}
|
||||
{this.renderFooter()}
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
220
src/components/chat/ChatFab.jsx
Normal file
220
src/components/chat/ChatFab.jsx
Normal file
@@ -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 (
|
||||
<Box
|
||||
sx={(theme) => ({
|
||||
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',
|
||||
})}
|
||||
>
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
{/* Animated pulsing halo when awaiting user answer */}
|
||||
{hasPendingAsk && !isOpen && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -8,
|
||||
left: -8,
|
||||
right: -8,
|
||||
bottom: -8,
|
||||
borderRadius: '50%',
|
||||
bgcolor: '#f59e0b',
|
||||
opacity: 0.5,
|
||||
animation: 'fabAskPulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
'@keyframes fabAskPulse': {
|
||||
'0%': { transform: 'scale(0.95)', opacity: 0.8 },
|
||||
'50%': { transform: 'scale(1.35)', opacity: 0.12 },
|
||||
'100%': { transform: 'scale(0.95)', opacity: 0.8 },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Animated pulsating halo when model is busy thinking/working */}
|
||||
{!hasPendingAsk && isBusy && !isOpen && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -6,
|
||||
left: -6,
|
||||
right: -6,
|
||||
bottom: -6,
|
||||
borderRadius: '50%',
|
||||
bgcolor: 'warning.main',
|
||||
opacity: 0.4,
|
||||
animation: 'fabPulse 1.6s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
'@keyframes fabPulse': {
|
||||
'0%': { transform: 'scale(0.95)', opacity: 0.7 },
|
||||
'50%': { transform: 'scale(1.25)', opacity: 0.15 },
|
||||
'100%': { transform: 'scale(0.95)', opacity: 0.7 },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Circular spinner ring when model is busy */}
|
||||
{!hasPendingAsk && isBusy && !isOpen && (
|
||||
<CircularProgress
|
||||
size={66}
|
||||
thickness={2.5}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -6,
|
||||
left: -6,
|
||||
color: '#f59e0b',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Tooltip title={tooltipTitle} placement="left" arrow disableHoverListener={isOpen}>
|
||||
<Badge
|
||||
badgeContent={hasPendingAsk ? '?' : undefined}
|
||||
color={hasPendingAsk ? 'error' : isBusy ? 'warning' : 'default'}
|
||||
variant={hasPendingAsk ? 'standard' : 'dot'}
|
||||
invisible={!hasPendingAsk && !isBusy}
|
||||
overlap="circular"
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
sx={{
|
||||
'& .MuiBadge-badge': hasPendingAsk
|
||||
? {
|
||||
bgcolor: '#ef4444',
|
||||
color: '#ffffff',
|
||||
fontWeight: 900,
|
||||
fontSize: '0.85rem',
|
||||
minWidth: 22,
|
||||
height: 22,
|
||||
borderRadius: '50%',
|
||||
boxShadow: '0 2px 10px rgba(239, 68, 68, 0.6)',
|
||||
animation: 'badgePop 1.5s ease infinite',
|
||||
'@keyframes badgePop': {
|
||||
'0%, 100%': { transform: 'scale(1)' },
|
||||
'50%': { transform: 'scale(1.2)' },
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<Fab
|
||||
color="primary"
|
||||
aria-label={ariaLabel}
|
||||
onClick={this.handleClick}
|
||||
sx={(theme) => ({
|
||||
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 ? (
|
||||
<CloseRoundedIcon sx={{ fontSize: 24 }} />
|
||||
) : hasPendingAsk ? (
|
||||
<HelpOutlineRoundedIcon
|
||||
sx={{
|
||||
fontSize: 26,
|
||||
animation: 'fabWiggle 2s ease infinite',
|
||||
'@keyframes fabWiggle': {
|
||||
'0%, 100%': { transform: 'rotate(0deg)' },
|
||||
'15%': { transform: 'rotate(-12deg)' },
|
||||
'30%': { transform: 'rotate(12deg)' },
|
||||
'45%': { transform: 'rotate(-8deg)' },
|
||||
'60%': { transform: 'rotate(0deg)' },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : isBusy ? (
|
||||
<AutoAwesomeRoundedIcon sx={{ fontSize: 24 }} />
|
||||
) : (
|
||||
<ChatRoundedIcon sx={{ fontSize: 24 }} />
|
||||
)}
|
||||
</Fab>
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
381
src/components/chat/ChatFlowList.jsx
Normal file
381
src/components/chat/ChatFlowList.jsx
Normal file
@@ -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 (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 0.75, px: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: 'action.hover',
|
||||
color: 'text.secondary',
|
||||
px: 2,
|
||||
py: 0.5,
|
||||
borderRadius: 4,
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 500,
|
||||
maxWidth: '85%',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{textContent}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (isReasoning) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-start', py: 0.5, px: 2 }}>
|
||||
<Box
|
||||
sx={(theme) => ({
|
||||
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',
|
||||
})}
|
||||
>
|
||||
<Box sx={{ fontWeight: 600, fontSize: '0.68rem', mb: 0.25 }}>Gedankengang</Box>
|
||||
{textContent}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-start', py: 0.35, px: 2 }}>
|
||||
<Box
|
||||
sx={(theme) => ({
|
||||
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',
|
||||
})}
|
||||
>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: '0.65rem',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
color: 'text.disabled',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
Werkzeug
|
||||
</Box>
|
||||
<Box component="span" sx={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{formattedCalls}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-start', py: 0.35, px: 2 }}>
|
||||
<Box
|
||||
sx={(theme) => ({
|
||||
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',
|
||||
})}
|
||||
>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: '0.65rem',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
color: 'text.disabled',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
Ergebnis
|
||||
</Box>
|
||||
<Box component="span" sx={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{displayText}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: isUser ? 'flex-end' : 'flex-start',
|
||||
px: 2,
|
||||
py: 0.75,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
maxWidth: '85%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: isUser ? 'flex-end' : 'flex-start',
|
||||
}}
|
||||
>
|
||||
{timeStr ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
mb: 0.3,
|
||||
px: 0.5,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: '0.68rem' }}>
|
||||
{timeStr}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
<Box
|
||||
sx={(theme) => ({
|
||||
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
|
||||
) : (
|
||||
<HealingMarkdown text={textContent} streaming={msg.streaming} />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Box
|
||||
ref={this.containerRef}
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
minHeight: 0,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
py: 1,
|
||||
gap: 0.25,
|
||||
}}
|
||||
>
|
||||
{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 (
|
||||
<MessageRow
|
||||
key={msg.id || index}
|
||||
msg={msg}
|
||||
showTime={showTime}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<div ref={this.bottomRef} style={{ height: 1, flexShrink: 0 }} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
505
src/components/chat/HealingMarkdown.jsx
Normal file
505
src/components/chat/HealingMarkdown.jsx
Normal file
@@ -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 .
|
||||
*/
|
||||
|
||||
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 `` and
|
||||
* `` 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 <span className="md-image-fallback">{alt || '🖼'}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
className="md-image"
|
||||
src={src}
|
||||
alt={alt || ''}
|
||||
title={alt || undefined}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={() => 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 key={key()}>{code}</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 === '**' ? <strong key={k}>{children}</strong> : <del key={k}>{children}</del>);
|
||||
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(<em key={k}>{parseInline(inner, k)}</em>);
|
||||
i = closed ? end + 1 : src.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Images must be tried before links: `` 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(
|
||||
<MarkdownImage key={k} src={resolved} alt={alt.trim()} />,
|
||||
);
|
||||
i = endUrl + 1;
|
||||
continue;
|
||||
}
|
||||
// Unsafe/unresolvable target: fall through and show the alt text only.
|
||||
push(<span key={k}>{parseInline(alt, k)}</span>);
|
||||
i = endUrl + 1;
|
||||
continue;
|
||||
}
|
||||
push(<span key={k}>{parseInline(alt, k)}</span>);
|
||||
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(
|
||||
<a key={k} href={url} target="_blank" rel="noopener noreferrer">
|
||||
{parseInline(label, k)}
|
||||
</a>,
|
||||
);
|
||||
} else {
|
||||
// Half-typed (or unsafe) link: show the label only.
|
||||
push(<span key={k}>{parseInline(label, k)}</span>);
|
||||
}
|
||||
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 (
|
||||
<Box sx={rootSx}>
|
||||
{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 (
|
||||
<Box key={k} className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
{b.headers.map((h, colIdx) => (
|
||||
<th
|
||||
key={`${k}-th-${colIdx}`}
|
||||
style={{ textAlign: b.alignments[colIdx] || 'left' }}
|
||||
>
|
||||
{parseInline(h, `${k}-th-${colIdx}`)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{b.rows.map((row, rowIdx) => {
|
||||
const isLastRow = last && rowIdx === b.rows.length - 1;
|
||||
return (
|
||||
<tr key={`${k}-tr-${rowIdx}`}>
|
||||
{b.headers.map((_, colIdx) => {
|
||||
const isLastCell = isLastRow && colIdx === b.headers.length - 1;
|
||||
const cellValue = row[colIdx] ?? '';
|
||||
return (
|
||||
<td
|
||||
key={`${k}-td-${rowIdx}-${colIdx}`}
|
||||
style={{ textAlign: b.alignments[colIdx] || 'left' }}
|
||||
>
|
||||
{parseInline(cellValue, `${k}-td-${rowIdx}-${colIdx}`)}
|
||||
{isLastCell ? tail : null}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</Box>
|
||||
);
|
||||
case 'code':
|
||||
return (
|
||||
<pre key={k}>
|
||||
<code>
|
||||
{b.text}
|
||||
{tail}
|
||||
</code>
|
||||
</pre>
|
||||
);
|
||||
case 'heading': {
|
||||
const Tag = `h${b.level}`;
|
||||
return (
|
||||
<Tag key={k}>
|
||||
{parseInline(b.text, k)}
|
||||
{tail}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
case 'hr':
|
||||
return <hr key={k} />;
|
||||
case 'quote':
|
||||
return (
|
||||
<blockquote key={k}>
|
||||
{parseInline(b.text, k)}
|
||||
{tail}
|
||||
</blockquote>
|
||||
);
|
||||
case 'list': {
|
||||
const Tag = b.ordered ? 'ol' : 'ul';
|
||||
return (
|
||||
<Tag key={k}>
|
||||
{b.items.map((it, j) => (
|
||||
<li key={j}>
|
||||
{parseInline(it, `${k}-${j}`)}
|
||||
{last && j === b.items.length - 1 ? tail : null}
|
||||
</li>
|
||||
))}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return (
|
||||
<p key={k}>
|
||||
{parseInline(b.text, k)}
|
||||
{tail}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
})}
|
||||
{streaming && blocks.length === 0 ? cursor : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(HealingMarkdown);
|
||||
171
src/components/chat/NewConversationDialog.jsx
Normal file
171
src/components/chat/NewConversationDialog.jsx
Normal file
@@ -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 (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={loading ? undefined : onClose}
|
||||
maxWidth="xs"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
borderRadius: 3,
|
||||
p: 1,
|
||||
boxShadow: '0 20px 40px rgba(0,0,0,0.2)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<form onSubmit={this.handleSubmit}>
|
||||
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1.5, pb: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 2,
|
||||
bgcolor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<AutoAwesomeRoundedIcon fontSize="small" />
|
||||
</Box>
|
||||
<Typography variant="h6" fontWeight={700}>
|
||||
Neuer Chat
|
||||
</Typography>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2.5, pt: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Wähle das Sprachmodell für die neue Konversation aus.
|
||||
</Typography>
|
||||
|
||||
<TextField
|
||||
label="Provider"
|
||||
value={provider}
|
||||
onChange={(e) => this.setState({ provider: e.target.value })}
|
||||
fullWidth
|
||||
size="small"
|
||||
disabled={loading}
|
||||
helperText="Standard: openrouter"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
select
|
||||
label="Modell Vorlage"
|
||||
value={isCustom ? '__custom__' : model}
|
||||
onChange={this.handleModelChange}
|
||||
fullWidth
|
||||
size="small"
|
||||
disabled={loading}
|
||||
>
|
||||
{PRESET_MODELS.map((item) => (
|
||||
<MenuItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
<MenuItem value="__custom__">Anderes Modell eingeben…</MenuItem>
|
||||
</TextField>
|
||||
|
||||
{isCustom && (
|
||||
<TextField
|
||||
label="Modell-Bezeichnung"
|
||||
placeholder="z. B. anthropic/claude-3.5-haiku"
|
||||
value={customModel}
|
||||
onChange={(e) => this.setState({ customModel: e.target.value })}
|
||||
fullWidth
|
||||
size="small"
|
||||
autoFocus
|
||||
disabled={loading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Typography variant="caption" color="error.main" sx={{ mt: -1 }}>
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ px: 3, pb: 2, pt: 1 }}>
|
||||
<Button onClick={onClose} disabled={loading} color="inherit" sx={{ borderRadius: 2 }}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={loading}
|
||||
startIcon={loading ? <CircularProgress size={16} color="inherit" /> : null}
|
||||
sx={{ borderRadius: 2, px: 2.5 }}
|
||||
>
|
||||
Starten
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
4
src/components/chat/index.js
Normal file
4
src/components/chat/index.js
Normal file
@@ -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';
|
||||
@@ -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,
|
||||
}}
|
||||
>
|
||||
<Tooltip title="Automat-Inspector (Alt+Shift+A)">
|
||||
<Fab
|
||||
|
||||
@@ -172,7 +172,7 @@ class ArtikelSearchPage extends Component {
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
document.addEventListener('mousedown', preserveSearchFocus, true);
|
||||
document.addEventListener('mouseup', preserveSearchFocus, true);
|
||||
window.addEventListener('resize', this.handleWindowResize);
|
||||
setLastArticleSku(null);
|
||||
|
||||
@@ -188,7 +188,7 @@ class ArtikelSearchPage extends Component {
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener('mousedown', preserveSearchFocus, true);
|
||||
document.removeEventListener('mouseup', preserveSearchFocus, true);
|
||||
window.removeEventListener('resize', this.handleWindowResize);
|
||||
this.persistQuery();
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ import { createTheme } from '@mui/material/styles';
|
||||
import { MAIN_BACKGROUND } from '../../colors';
|
||||
|
||||
const nav = {
|
||||
background: '#1a3533',
|
||||
background:
|
||||
'linear-gradient(90deg, #183331 0%, #1d3a38 25%, #1a3533 60%, #17302e 100%)',
|
||||
backgroundColor: '#1a3533',
|
||||
mainBackground: MAIN_BACKGROUND,
|
||||
item: '#9cb8b4',
|
||||
itemHover: '#243f3d',
|
||||
|
||||
Reference in New Issue
Block a user