feat: migrar dashboard a React 19 + TypeScript + Vite + Tailwind v4
- 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
This commit is contained in:
@@ -0,0 +1,434 @@
|
||||
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 }),
|
||||
}));
|
||||
Reference in New Issue
Block a user