- Módulo HITL (/cases): 6 patrones de formularios dinámicos para 53 tipos de caso con validación Zod - Módulo Monitor (/monitor): streaming token-a-token en tiempo real, auto-scroll y notas internas vía WebSocket - Arquitectura híbrida: REST (canal autoritativo) + WebSocket (difusión/streaming) - MSW para desarrollo sin backend, hooks de notificaciones/sonido/título preservados - Backend legacy movido a legacy/, archivos residuales eliminados de raíz
435 lines
14 KiB
TypeScript
435 lines
14 KiB
TypeScript
import { create } from 'zustand';
|
|
import type { CaseRequest, Conversation, Message } from '@/types';
|
|
import { api, type CaseFilters } from '@/services/api';
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
// Types
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
export type SidebarTab = 'all' | 'pending' | 'resolved';
|
|
export type WsStatus = 'connected' | 'disconnected' | 'reconnecting';
|
|
|
|
interface AppState {
|
|
// ── Cases slice ──────────────────────────────────────────
|
|
cases: CaseRequest[];
|
|
selectedCaseId: string | number | null;
|
|
totalCases: number;
|
|
fetchCases: (filters?: CaseFilters) => Promise<void>;
|
|
upsertCase: (c: CaseRequest) => void;
|
|
resolveCase: (
|
|
id: string | number,
|
|
data: { action: string; payload: Record<string, unknown>; note?: string },
|
|
) => Promise<void>;
|
|
|
|
// ── Conversations slice ──────────────────────────────────
|
|
conversations: Conversation[];
|
|
selectedConversationId: string | null;
|
|
fetchConversations: () => Promise<void>;
|
|
upsertConversation: (c: Conversation) => void;
|
|
addMessage: (convId: string, msg: Message) => void;
|
|
appendToken: (convId: string, msgId: string, token: string, index: number) => void;
|
|
completeStream: (convId: string, msgId: string, fullContent: string) => void;
|
|
setSelectedConversationId: (convId: string | null) => void;
|
|
removeConversation: (convId: string) => void;
|
|
|
|
// ── UI slice ─────────────────────────────────────────────
|
|
sidebarTab: SidebarTab;
|
|
searchQuery: string;
|
|
applicativeFilter: string | null;
|
|
isDarkMode: boolean;
|
|
wsStatus: WsStatus;
|
|
setSidebarTab: (tab: SidebarTab) => void;
|
|
setSearchQuery: (q: string) => void;
|
|
setApplicativeFilter: (app: string | null) => void;
|
|
toggleDarkMode: () => void;
|
|
setWsStatus: (status: WsStatus) => void;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
// Helpers
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Read initial dark mode from localStorage, defaulting to false.
|
|
*/
|
|
function readDarkMode(): boolean {
|
|
try {
|
|
const stored = localStorage.getItem('claro-cases:darkMode');
|
|
return stored === 'true';
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Persist dark mode preference to localStorage.
|
|
*/
|
|
function persistDarkMode(value: boolean): void {
|
|
try {
|
|
localStorage.setItem('claro-cases:darkMode', String(value));
|
|
} catch {
|
|
// localStorage may be unavailable (private browsing, quota, etc.)
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
// Token Streaming Buffer (Regla 4 — 50ms throttling, 20 fps)
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
interface PendingToken {
|
|
msgId: string;
|
|
token: string;
|
|
index: number;
|
|
}
|
|
|
|
interface ConversationBufferEntry {
|
|
pending: PendingToken[];
|
|
timer: ReturnType<typeof setTimeout> | null;
|
|
}
|
|
|
|
/**
|
|
* External buffer map — NOT stored in Zustand state to avoid
|
|
* triggering re-renders on every chunk. Each conversation gets
|
|
* its own entry with a pending queue and a 50ms flush timer.
|
|
*/
|
|
const conversationBuffers = new Map<string, ConversationBufferEntry>();
|
|
|
|
/**
|
|
* Flush all pending tokens for a given conversation into the store
|
|
* with a SINGLE `set()` call. Only updates the store if this
|
|
* conversation is the actively selected one (Regla 4: solo
|
|
* re-renderizar conversación seleccionada).
|
|
*/
|
|
function flushBuffer(
|
|
convId: string,
|
|
get: () => AppState,
|
|
set: (partial: AppState | ((state: AppState) => Partial<AppState>)) => void,
|
|
): void {
|
|
const entry = conversationBuffers.get(convId);
|
|
if (!entry) return;
|
|
|
|
// Clear the timer reference first
|
|
entry.timer = null;
|
|
|
|
// If the conversation no longer exists in the store, clean up the buffer
|
|
const currentState = get();
|
|
const convExists = currentState.conversations.some((c) => c.id === convId);
|
|
if (!convExists) {
|
|
conversationBuffers.delete(convId);
|
|
return;
|
|
}
|
|
|
|
// If nothing is pending, delete the entry and bail out
|
|
if (entry.pending.length === 0) {
|
|
conversationBuffers.delete(convId);
|
|
return;
|
|
}
|
|
|
|
// Only update the store for the selected conversation (Regla 4)
|
|
if (currentState.selectedConversationId !== convId) {
|
|
// Keep tokens in buffer — they'll be flushed when this conversation
|
|
// becomes selected, or cleared by completeStream.
|
|
return;
|
|
}
|
|
|
|
// Atomically take and clear the pending queue
|
|
const pendingToProcess = entry.pending;
|
|
entry.pending = [];
|
|
|
|
// Sort by index to guarantee correct order even with out-of-order delivery
|
|
pendingToProcess.sort((a, b) => a.index - b.index);
|
|
|
|
// Single batched set() call — ALL accumulated chunks in one update
|
|
set((state) => {
|
|
const convIndex = state.conversations.findIndex((c) => c.id === convId);
|
|
if (convIndex < 0) return state;
|
|
|
|
const conv = state.conversations[convIndex];
|
|
const messages = [...conv.messages];
|
|
let hasChanges = false;
|
|
|
|
for (const pending of pendingToProcess) {
|
|
const msgIndex = messages.findIndex((m) => m.id === pending.msgId);
|
|
if (msgIndex < 0) continue;
|
|
|
|
const msg = { ...messages[msgIndex] };
|
|
const existingChunks: Array<{ token: string; index: number }> =
|
|
(msg.metadata?._chunks as Array<{ token: string; index: number }>) ?? [];
|
|
|
|
const newChunks = [
|
|
...existingChunks,
|
|
{ token: pending.token, index: pending.index },
|
|
];
|
|
newChunks.sort((a, b) => a.index - b.index);
|
|
|
|
messages[msgIndex] = {
|
|
...msg,
|
|
content: newChunks.map((ch) => ch.token).join(''),
|
|
metadata: { ...msg.metadata, _chunks: newChunks },
|
|
isStreaming: true,
|
|
};
|
|
hasChanges = true;
|
|
}
|
|
|
|
if (!hasChanges) return state;
|
|
|
|
return {
|
|
conversations: state.conversations.map((c, i) =>
|
|
i === convIndex ? { ...conv, messages } : c,
|
|
),
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Schedule a flush for the given conversation in ~50ms.
|
|
* Does nothing if a timer is already pending for this conversation.
|
|
*/
|
|
function scheduleBufferFlush(
|
|
convId: string,
|
|
get: () => AppState,
|
|
set: (partial: AppState | ((state: AppState) => Partial<AppState>)) => void,
|
|
): void {
|
|
const entry = conversationBuffers.get(convId);
|
|
if (!entry || entry.timer !== null) return;
|
|
|
|
entry.timer = setTimeout(() => {
|
|
flushBuffer(convId, get, set);
|
|
}, 50);
|
|
}
|
|
|
|
/**
|
|
* Immediately flush all pending tokens for the given conversation.
|
|
* Used when switching to a conversation mid-stream.
|
|
*/
|
|
function forceFlushBuffer(
|
|
convId: string,
|
|
get: () => AppState,
|
|
set: (partial: AppState | ((state: AppState) => Partial<AppState>)) => void,
|
|
): void {
|
|
const entry = conversationBuffers.get(convId);
|
|
if (!entry) return;
|
|
|
|
if (entry.timer !== null) {
|
|
clearTimeout(entry.timer);
|
|
}
|
|
flushBuffer(convId, get, set);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
// Store
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
export const useAppStore = create<AppState>((set, get) => ({
|
|
// ── Cases initial state ──────────────────────────────────
|
|
cases: [],
|
|
selectedCaseId: null,
|
|
totalCases: 0,
|
|
|
|
fetchCases: async (filters: CaseFilters = {}) => {
|
|
try {
|
|
const response = await api.getCases(filters);
|
|
set({ cases: response.items, totalCases: response.total });
|
|
} catch (err) {
|
|
console.error('[Store] fetchCases failed:', err);
|
|
// On failure, keep current state (or set empty)
|
|
set({ cases: [], totalCases: 0 });
|
|
}
|
|
},
|
|
|
|
upsertCase: (c: CaseRequest) =>
|
|
set((state) => {
|
|
const index = state.cases.findIndex(
|
|
(existing) => existing.id === c.id,
|
|
);
|
|
if (index >= 0) {
|
|
// Replace existing
|
|
const updated = [...state.cases];
|
|
updated[index] = c;
|
|
return { cases: updated };
|
|
}
|
|
// Prepend new case
|
|
return { cases: [c, ...state.cases] };
|
|
}),
|
|
|
|
resolveCase: async (id, data) => {
|
|
try {
|
|
const updatedCase = await api.resolveCase(id, data);
|
|
set((state) => {
|
|
const index = state.cases.findIndex(
|
|
(existing) => existing.id === id,
|
|
);
|
|
if (index >= 0) {
|
|
const updated = [...state.cases];
|
|
updated[index] = updatedCase;
|
|
return { cases: updated };
|
|
}
|
|
return state;
|
|
});
|
|
} catch (err) {
|
|
console.error('[Store] resolveCase failed:', err);
|
|
throw err; // re-throw so calling code can handle
|
|
}
|
|
},
|
|
|
|
// ── Conversations initial state ──────────────────────────
|
|
conversations: [],
|
|
selectedConversationId: null,
|
|
|
|
fetchConversations: async () => {
|
|
try {
|
|
const conversations = await api.getActiveConversations();
|
|
set({ conversations });
|
|
} catch (err) {
|
|
console.error('[Store] fetchConversations failed:', err);
|
|
set({ conversations: [] });
|
|
}
|
|
},
|
|
|
|
upsertConversation: (c: Conversation) =>
|
|
set((state) => {
|
|
const index = state.conversations.findIndex(
|
|
(existing) => existing.id === c.id,
|
|
);
|
|
if (index >= 0) {
|
|
const updated = [...state.conversations];
|
|
updated[index] = c;
|
|
return { conversations: updated };
|
|
}
|
|
return { conversations: [...state.conversations, c] };
|
|
}),
|
|
|
|
addMessage: (convId: string, msg: Message) =>
|
|
set((state) => {
|
|
const convIndex = state.conversations.findIndex(
|
|
(c) => c.id === convId,
|
|
);
|
|
if (convIndex < 0) return state;
|
|
|
|
const updated = [...state.conversations];
|
|
updated[convIndex] = {
|
|
...updated[convIndex],
|
|
messages: [...updated[convIndex].messages, msg],
|
|
};
|
|
return { conversations: updated };
|
|
}),
|
|
|
|
appendToken: (convId: string, msgId: string, token: string, index: number) => {
|
|
// Step 1: Add chunk to the conversation's external buffer
|
|
let entry = conversationBuffers.get(convId);
|
|
if (!entry) {
|
|
entry = { pending: [], timer: null };
|
|
conversationBuffers.set(convId, entry);
|
|
}
|
|
entry.pending.push({ msgId, token, index });
|
|
|
|
// Step 2: Schedule a flush only if this is the selected conversation
|
|
// (non-selected conversations accumulate in buffer without triggering re-renders)
|
|
const state = get();
|
|
if (state.selectedConversationId === convId) {
|
|
scheduleBufferFlush(convId, get, set);
|
|
}
|
|
},
|
|
|
|
completeStream: (convId: string, msgId: string, fullContent: string) => {
|
|
// Step 1: Clear the conversation's buffer — no more tokens expected
|
|
const entry = conversationBuffers.get(convId);
|
|
if (entry) {
|
|
if (entry.timer !== null) {
|
|
clearTimeout(entry.timer);
|
|
}
|
|
conversationBuffers.delete(convId);
|
|
}
|
|
|
|
// Step 2: Perform a single store update to set the final content
|
|
set((state) => {
|
|
const convIndex = state.conversations.findIndex(
|
|
(c) => c.id === convId,
|
|
);
|
|
if (convIndex < 0) return state;
|
|
|
|
const conv = state.conversations[convIndex];
|
|
const msgIndex = conv.messages.findIndex((m) => m.id === msgId);
|
|
if (msgIndex < 0) return state;
|
|
|
|
const messages = [...conv.messages];
|
|
const msg = { ...messages[msgIndex] };
|
|
|
|
// Clear chunk buffer — rebuild metadata without _chunks
|
|
const cleanMetadata: Record<string, unknown> = {};
|
|
if (msg.metadata) {
|
|
for (const [key, value] of Object.entries(msg.metadata)) {
|
|
if (key !== '_chunks') {
|
|
cleanMetadata[key] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
messages[msgIndex] = {
|
|
...msg,
|
|
content: fullContent,
|
|
isStreaming: false,
|
|
metadata: cleanMetadata,
|
|
};
|
|
|
|
return {
|
|
conversations: state.conversations.map((c, i) =>
|
|
i === convIndex ? { ...conv, messages } : c,
|
|
),
|
|
};
|
|
});
|
|
},
|
|
|
|
setSelectedConversationId: (convId: string | null) => {
|
|
// Force-flush any pending buffer for the newly selected conversation
|
|
const prevSelected = get().selectedConversationId;
|
|
set({ selectedConversationId: convId });
|
|
|
|
if (convId !== null && convId !== prevSelected) {
|
|
// If switching to a conversation that has buffered tokens, flush them immediately
|
|
forceFlushBuffer(convId, get, set);
|
|
}
|
|
},
|
|
|
|
removeConversation: (convId: string) => {
|
|
// Clear the buffer for this conversation
|
|
const entry = conversationBuffers.get(convId);
|
|
if (entry) {
|
|
if (entry.timer !== null) {
|
|
clearTimeout(entry.timer);
|
|
}
|
|
conversationBuffers.delete(convId);
|
|
}
|
|
|
|
set((state) => ({
|
|
conversations: state.conversations.filter((c) => c.id !== convId),
|
|
selectedConversationId:
|
|
state.selectedConversationId === convId
|
|
? null
|
|
: state.selectedConversationId,
|
|
}));
|
|
},
|
|
|
|
// ── UI initial state ──────────────────────────────────
|
|
sidebarTab: 'all',
|
|
searchQuery: '',
|
|
applicativeFilter: null,
|
|
isDarkMode: readDarkMode(),
|
|
wsStatus: 'disconnected',
|
|
|
|
setSidebarTab: (tab) => set({ sidebarTab: tab }),
|
|
|
|
setSearchQuery: (q) => set({ searchQuery: q }),
|
|
|
|
setApplicativeFilter: (app) => set({ applicativeFilter: app }),
|
|
|
|
toggleDarkMode: () =>
|
|
set((state) => {
|
|
const next = !state.isDarkMode;
|
|
persistDarkMode(next);
|
|
return { isDarkMode: next };
|
|
}),
|
|
|
|
setWsStatus: (status) => set({ wsStatus: status }),
|
|
}));
|