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-dom": "^19.3.0",
|
||||||
"react-router-dom": "^7.18.4",
|
"react-router-dom": "^7.18.4",
|
||||||
"react-virtual-aggridautomat": "file:../react-virtual-aggridautomat",
|
"react-virtual-aggridautomat": "file:../react-virtual-aggridautomat",
|
||||||
|
"react-window": "^2.3.3",
|
||||||
"sortablejs": "^1.15.7",
|
"sortablejs": "^1.15.7",
|
||||||
"thumbhash": "^0.1.1"
|
"thumbhash": "^0.1.1"
|
||||||
},
|
},
|
||||||
@@ -92,7 +93,7 @@
|
|||||||
"marked": "^18.0.14",
|
"marked": "^18.0.14",
|
||||||
"prismjs": "^1.30.0",
|
"prismjs": "^1.30.0",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-automat": "github:sebseb7/react-automat",
|
"react-automat": "file:../automat",
|
||||||
"react-dom": "^19.2.8"
|
"react-dom": "^19.2.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -7483,6 +7484,16 @@
|
|||||||
"resolved": "../react-virtual-aggridautomat",
|
"resolved": "../react-virtual-aggridautomat",
|
||||||
"link": true
|
"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": {
|
"node_modules/readable-stream": {
|
||||||
"version": "3.6.2",
|
"version": "3.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
"react-dom": "^19.3.0",
|
"react-dom": "^19.3.0",
|
||||||
"react-router-dom": "^7.18.4",
|
"react-router-dom": "^7.18.4",
|
||||||
"react-virtual-aggridautomat": "file:../react-virtual-aggridautomat",
|
"react-virtual-aggridautomat": "file:../react-virtual-aggridautomat",
|
||||||
|
"react-window": "^2.3.3",
|
||||||
"sortablejs": "^1.15.7",
|
"sortablejs": "^1.15.7",
|
||||||
"thumbhash": "^0.1.1"
|
"thumbhash": "^0.1.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -577,8 +577,24 @@ export class ApiClient {
|
|||||||
/** Absolute URL for <img src> (same-origin / cookie auth). */
|
/** Absolute URL for <img src> (same-origin / cookie auth). */
|
||||||
url: (fullHash) => buildUrl(this.#baseUrl, `/api/thumb/${enc(fullHash)}`),
|
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). */
|
/** Absolute URL for a same-origin API path (e.g. SSE). */
|
||||||
urlFor(path) {
|
urlFor(path) {
|
||||||
return buildUrl(this.#baseUrl, 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 {
|
export default {
|
||||||
auth,
|
auth,
|
||||||
article,
|
article,
|
||||||
@@ -1855,6 +1905,7 @@ export default {
|
|||||||
entity,
|
entity,
|
||||||
labeltemplate,
|
labeltemplate,
|
||||||
ledger,
|
ledger,
|
||||||
|
modelconversation,
|
||||||
number,
|
number,
|
||||||
order,
|
order,
|
||||||
posterminal,
|
posterminal,
|
||||||
@@ -1868,3 +1919,4 @@ export default {
|
|||||||
stock,
|
stock,
|
||||||
globalHistory,
|
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 articleHistoryGridAutomat } from './articleHistoryAutomat';
|
||||||
export { default as globalHistoryGridAutomat } from './globalHistoryAutomat';
|
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
|
* @typedef {Object} UserSettingsState
|
||||||
* @property {ViewerPosition} viewerPosition Preferred docking position of the
|
* @property {ViewerPosition} viewerPosition Preferred docking position of the
|
||||||
* attachment viewer (text editor / PDF preview) relative to the article list.
|
* attachment viewer (text editor / PDF preview) relative to the article list.
|
||||||
|
* @property {number} chatDrawerWidth Preferred width of the chat drawer in pixels.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** @type {UserSettingsState} */
|
/** @type {UserSettingsState} */
|
||||||
const initialState = {
|
const initialState = {
|
||||||
viewerPosition: 'top',
|
viewerPosition: 'top',
|
||||||
|
chatDrawerWidth: 460,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,6 +39,20 @@ const userSettingsAutomat = new Automat(
|
|||||||
}
|
}
|
||||||
userSettingsAutomat.setState({ viewerPosition: position });
|
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',
|
name: 'userSettings',
|
||||||
@@ -45,4 +61,5 @@ const userSettingsAutomat = new Automat(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export default userSettingsAutomat;
|
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 { artikelQueryPath } from './article/artikelQuery';
|
||||||
import { defaultCategoryPath } from './category/categoryRoute';
|
import { defaultCategoryPath } from './category/categoryRoute';
|
||||||
import { navigationAutomat } from '../automats';
|
import { navigationAutomat } from '../automats';
|
||||||
|
import { ChatFab, ChatDrawer } from './chat';
|
||||||
|
|
||||||
|
|
||||||
const SIDEBAR_WIDTH = 144;
|
const SIDEBAR_WIDTH = 144;
|
||||||
const SUB_NAV_WIDTH = 88;
|
const SUB_NAV_WIDTH = 88;
|
||||||
@@ -49,7 +51,8 @@ class AppSidebar extends PureComponent {
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
bgcolor: theme.nav.background,
|
backgroundColor: theme.nav.backgroundColor || '#1a3533',
|
||||||
|
background: theme.nav.background,
|
||||||
position: 'sticky',
|
position: 'sticky',
|
||||||
top: 0,
|
top: 0,
|
||||||
alignSelf: 'flex-start',
|
alignSelf: 'flex-start',
|
||||||
@@ -254,6 +257,8 @@ export default function AppShell() {
|
|||||||
<Outlet />
|
<Outlet />
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
<ChatFab />
|
||||||
|
<ChatDrawer />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
const FOCUSABLE =
|
const FOCUSABLE =
|
||||||
'a, button, input, textarea, select, [role="button"], [role="menuitem"], [tabindex]:not([tabindex="-1"]), [contenteditable="true"]';
|
'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) {
|
export function preserveSearchFocus(e) {
|
||||||
if (e.target.closest(FOCUSABLE)) return;
|
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"
|
color="secondary"
|
||||||
overlap="circular"
|
overlap="circular"
|
||||||
invisible={names.length === 0}
|
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)">
|
<Tooltip title="Automat-Inspector (Alt+Shift+A)">
|
||||||
<Fab
|
<Fab
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ class ArtikelSearchPage extends Component {
|
|||||||
};
|
};
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
document.addEventListener('mousedown', preserveSearchFocus, true);
|
document.addEventListener('mouseup', preserveSearchFocus, true);
|
||||||
window.addEventListener('resize', this.handleWindowResize);
|
window.addEventListener('resize', this.handleWindowResize);
|
||||||
setLastArticleSku(null);
|
setLastArticleSku(null);
|
||||||
|
|
||||||
@@ -188,7 +188,7 @@ class ArtikelSearchPage extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
document.removeEventListener('mousedown', preserveSearchFocus, true);
|
document.removeEventListener('mouseup', preserveSearchFocus, true);
|
||||||
window.removeEventListener('resize', this.handleWindowResize);
|
window.removeEventListener('resize', this.handleWindowResize);
|
||||||
this.persistQuery();
|
this.persistQuery();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { createTheme } from '@mui/material/styles';
|
|||||||
import { MAIN_BACKGROUND } from '../../colors';
|
import { MAIN_BACKGROUND } from '../../colors';
|
||||||
|
|
||||||
const nav = {
|
const nav = {
|
||||||
background: '#1a3533',
|
background:
|
||||||
|
'linear-gradient(90deg, #183331 0%, #1d3a38 25%, #1a3533 60%, #17302e 100%)',
|
||||||
|
backgroundColor: '#1a3533',
|
||||||
mainBackground: MAIN_BACKGROUND,
|
mainBackground: MAIN_BACKGROUND,
|
||||||
item: '#9cb8b4',
|
item: '#9cb8b4',
|
||||||
itemHover: '#243f3d',
|
itemHover: '#243f3d',
|
||||||
|
|||||||
Reference in New Issue
Block a user