This commit is contained in:
seb
2026-10-04 02:13:50 +02:00
parent 0d0c5dfdfb
commit f2fce9097b
19 changed files with 3087 additions and 9 deletions

843
src/automats/chatAutomat.js Normal file
View 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;

View File

@@ -73,3 +73,16 @@ export {
export { default as articleHistoryGridAutomat } from './articleHistoryAutomat';
export { default as globalHistoryGridAutomat } from './globalHistoryAutomat';
export { default as chatAutomat } from './chatAutomat';
export {
selectChatIsOpen,
selectChatState,
selectChatMessages,
selectChatConversations,
selectSelectedConversationId,
selectSseStatus,
selectPendingAskUser,
selectHasPendingAsk,
} from './chatAutomat';

View File

@@ -8,11 +8,13 @@ import { Automat } from 'react-automat';
* @typedef {Object} UserSettingsState
* @property {ViewerPosition} viewerPosition Preferred docking position of the
* attachment viewer (text editor / PDF preview) relative to the article list.
* @property {number} chatDrawerWidth Preferred width of the chat drawer in pixels.
*/
/** @type {UserSettingsState} */
const initialState = {
viewerPosition: 'top',
chatDrawerWidth: 460,
};
/**
@@ -37,6 +39,20 @@ const userSettingsAutomat = new Automat(
}
userSettingsAutomat.setState({ viewerPosition: position });
},
/**
* Set the width of the chat drawer in pixels.
*
* @param {number} width
* @returns {void}
*/
setChatDrawerWidth(width) {
const num = Number(width);
if (Number.isNaN(num) || num < 280) {
return;
}
userSettingsAutomat.setState({ chatDrawerWidth: Math.round(num) });
},
},
{
name: 'userSettings',
@@ -45,4 +61,5 @@ const userSettingsAutomat = new Automat(
);
export default userSettingsAutomat;
export const { setViewerPosition } = userSettingsAutomat.actions;
export const { setViewerPosition, setChatDrawerWidth } = userSettingsAutomat.actions;
export const selectChatDrawerWidth = () => userSettingsAutomat.state.chatDrawerWidth || 460;