feat: initial ChatUI modular con arbol de temas, chat n8n y KeyNotes editables
This commit is contained in:
commit
19aaeb8619
42 changed files with 6155 additions and 0 deletions
1
.env.example
Normal file
1
.env.example
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
VITE_N8N_WEBHOOK_URL=https://your-n8n-host/webhook/chat
|
||||||
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
12
Dockerfile
Normal file
12
Dockerfile
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine AS runtime
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
54
README.md
Normal file
54
README.md
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
# WebChatV2
|
||||||
|
|
||||||
|
ChatUI modular conectado a **n8n**, con árbol de temas (MainTheme → SubTheme),
|
||||||
|
chat central y panel derecho de **KeyNotes** editables (resumen / To-Do / notas).
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┬─────────────────────┬───────────────┐
|
||||||
|
│ Izquierda │ Centro │ Derecha │
|
||||||
|
│ Árbol de │ Chat │ KeyNotes │
|
||||||
|
│ temas │ (mensajes+input) │ (editables) │
|
||||||
|
└─────────────┴─────────────────────┴───────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Izquierda:** MainThemes expandibles con SubThemes. Agregar / renombrar / eliminar,
|
||||||
|
y **drag & drop** para reordenar.
|
||||||
|
- **Centro:** chat con Markdown, indicador "escribiendo…", botones
|
||||||
|
**Enviar / Detener / Reintentar**.
|
||||||
|
- **Derecha:** KeyNotes editables (`summary` | `todo` | `free`), drag & drop, y
|
||||||
|
sincronización con la respuesta de n8n (`source: n8n`).
|
||||||
|
|
||||||
|
Cada **SubTheme** guarda su propia conversación y sus propias KeyNotes.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
React + Vite + TypeScript, Tailwind CSS, Zustand (persistencia LocalStorage),
|
||||||
|
@dnd-kit (drag & drop), react-markdown + remark-gfm.
|
||||||
|
|
||||||
|
## Desarrollo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev # http://localhost:5173
|
||||||
|
npm run build # typecheck + build -> dist/
|
||||||
|
```
|
||||||
|
|
||||||
|
Configura la URL del webhook en `public/config.js` (runtime) o `.env`
|
||||||
|
(`VITE_N8N_WEBHOOK_URL`). Ver `docs/N8N_CONTRACT.md` para el formato request/response.
|
||||||
|
|
||||||
|
## Docker (Dockge)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expone el sitio en `http://localhost:8080`. Para cambiar la URL del webhook sin
|
||||||
|
reconstruir, edita el `config.js` servido por nginx
|
||||||
|
(`/usr/share/nginx/html/config.js`) o monta un volumen:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- ./config.js:/usr/share/nginx/html/config.js:ro
|
||||||
|
```
|
||||||
10
docker-compose.yaml
Normal file
10
docker-compose.yaml
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
services:
|
||||||
|
chat-ui:
|
||||||
|
build: .
|
||||||
|
image: webchatv2:latest
|
||||||
|
container_name: webchatv2
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
volumes:
|
||||||
|
- ./public/config.js:/usr/share/nginx/html/config.js:ro
|
||||||
114
docs/N8N_CONTRACT.md
Normal file
114
docs/N8N_CONTRACT.md
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
# Contrato de integración con n8n — WebChatV2
|
||||||
|
|
||||||
|
El frontend hace un `POST` al **webhook de n8n** con el mensaje del usuario y el historial,
|
||||||
|
y espera una respuesta JSON con el texto del asistente y, opcionalmente, KeyNotes
|
||||||
|
(resumen / To-Do) que se insertan automáticamente en el panel derecho y que el usuario
|
||||||
|
puede editar después.
|
||||||
|
|
||||||
|
## 1. Configuración de la URL
|
||||||
|
|
||||||
|
La URL del webhook se resuelve en **tiempo de ejecución** (no se "hornea" al compilar),
|
||||||
|
para que puedas cambiarla sin reconstruir la imagen Docker:
|
||||||
|
|
||||||
|
1. `public/config.js` → define `window.APP_CONFIG.N8N_WEBHOOK_URL` (prioridad alta).
|
||||||
|
2. Si no existe, se usa la variable de entorno `VITE_N8N_WEBHOOK_URL` (definida al compilar).
|
||||||
|
|
||||||
|
En Docker, edita el archivo `config.js` servido por nginx (o monta un volumen sobre
|
||||||
|
`/usr/share/nginx/html/config.js`) y recarga la página.
|
||||||
|
|
||||||
|
## 2. Request — `POST {N8N_WEBHOOK_URL}`
|
||||||
|
|
||||||
|
`Content-Type: application/json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"themeId": "theme_abc123",
|
||||||
|
"subThemeId": "sub_def456",
|
||||||
|
"themeTitle": "Tema Principal",
|
||||||
|
"subThemeTitle": "Subtema 1",
|
||||||
|
"history": [
|
||||||
|
{ "role": "user", "content": "Hola" },
|
||||||
|
{ "role": "assistant", "content": "¿En qué puedo ayudarte?" }
|
||||||
|
],
|
||||||
|
"message": "Explícame cómo funciona esto"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Campo | Tipo | Descripción |
|
||||||
|
| --------------- | -------- | ------------------------------------------------------------------ |
|
||||||
|
| `themeId` | string | ID del MainTheme activo. Útil para guardar contexto en n8n/DB. |
|
||||||
|
| `subThemeId` | string | ID del SubTheme activo (la conversación actual). |
|
||||||
|
| `themeTitle` | string | Título del tema (legible). |
|
||||||
|
| `subThemeTitle` | string | Título del subtema (legible). |
|
||||||
|
| `history` | array | Mensajes previos `{role, content}` (orden cronológico). |
|
||||||
|
| `message` | string | Texto que el usuario acaba de escribir (NO está incluido en history).|
|
||||||
|
|
||||||
|
`role` es `"user"` | `"assistant"`.
|
||||||
|
|
||||||
|
## 3. Response — `200 OK` (JSON)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"reply": "Respuesta en **Markdown** del asistente…",
|
||||||
|
"keyNotes": [
|
||||||
|
{
|
||||||
|
"type": "summary",
|
||||||
|
"title": "Resumen de la conversación",
|
||||||
|
"content": "Puntos clave tratados…"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "todo",
|
||||||
|
"title": "Tareas pendientes",
|
||||||
|
"items": [
|
||||||
|
{ "text": "Revisar la documentación", "done": false },
|
||||||
|
{ "text": "Configurar el webhook", "done": true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reglas
|
||||||
|
|
||||||
|
- `reply` (**obligatorio**, `string`): texto de respuesta. Se renderiza como **Markdown**
|
||||||
|
(soporta GFM: listas, tablas, bloques de código, etc.).
|
||||||
|
- `keyNotes` (**opcional**, `array`): notas que se añaden al SubTheme activo con
|
||||||
|
`source: "n8n"`. El usuario puede editarlas/eliminarlas libremente.
|
||||||
|
|
||||||
|
### Tipos de KeyNote
|
||||||
|
|
||||||
|
| `type` | Requiere |
|
||||||
|
| --------- | -------------------------------------------------------- |
|
||||||
|
| `summary` | `title` + `content` (string, Markdown plano) |
|
||||||
|
| `todo` | `title` + `items[]` con `{ text: string, done?: boolean }` |
|
||||||
|
| `free` | `title` + `content` |
|
||||||
|
|
||||||
|
`done` es opcional y por defecto `false`.
|
||||||
|
|
||||||
|
## 4. Errores y timeouts
|
||||||
|
|
||||||
|
- Cualquier status HTTP fuera del rango 2xx se trata como error y se marca el mensaje
|
||||||
|
como `status: "error"`. El usuario puede pulsar **Reintentar**.
|
||||||
|
- Timeout por defecto: **120 s**. La solicitud puede cancelarse con el botón **Detener**
|
||||||
|
(usa `AbortController`), lo que aborta el fetch hacia n8n.
|
||||||
|
|
||||||
|
## 5. Ejemplo de workflow n8n (orientativo)
|
||||||
|
|
||||||
|
```
|
||||||
|
Webhook (POST /webhook/chat)
|
||||||
|
→ Set (extraer body.message, body.history, body.themeTitle…)
|
||||||
|
→ [tu LLM / lógica]
|
||||||
|
→ Set (construir JSON { reply, keyNotes })
|
||||||
|
→ Respond to Webhook (JSON, 200)
|
||||||
|
```
|
||||||
|
|
||||||
|
> En n8n usa el nodo **Respond to Webhook** con `Respond With: JSON` y en `Response Body`
|
||||||
|
> pega el objeto `{ "reply": ..., "keyNotes": [...] }`.
|
||||||
|
|
||||||
|
## 6. Evolución futura (streaming / persistencia en DB)
|
||||||
|
|
||||||
|
- **Streaming:** el contrato queda preparado para añadir SSE (`text/event-stream`)
|
||||||
|
más adelante sin romper la UI (el indicador "escribiendo…" ya existe).
|
||||||
|
- **Persistencia:** el estado vive hoy en `localStorage`. Para sincronizar con una DB
|
||||||
|
vía n8n, añade endpoints `GET/POST /webhook/state` y reemplaza el adapter en
|
||||||
|
`src/store/persistence.ts` (punto único de intercambio).
|
||||||
13
index.html
Normal file
13
index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>WebChatV2</title>
|
||||||
|
<script src="/config.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
20
nginx.conf
Normal file
20
nginx.conf
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /config.js {
|
||||||
|
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||||
|
expires off;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~* \.(?:js|css|svg|png|jpg|jpeg|gif|ico|woff2?)$ {
|
||||||
|
expires 7d;
|
||||||
|
add_header Cache-Control "public, max-age=604800";
|
||||||
|
}
|
||||||
|
}
|
||||||
4316
package-lock.json
generated
Normal file
4316
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
33
package.json
Normal file
33
package.json
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
{
|
||||||
|
"name": "webchatv2",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.1.0",
|
||||||
|
"@dnd-kit/sortable": "^8.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-markdown": "^9.0.1",
|
||||||
|
"remark-gfm": "^4.0.0",
|
||||||
|
"zustand": "^5.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@vitejs/plugin-react": "^4.3.3",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.47",
|
||||||
|
"tailwindcss": "^3.4.14",
|
||||||
|
"typescript": "^5.6.3",
|
||||||
|
"vite": "^5.4.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
3
public/config.js
Normal file
3
public/config.js
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
window.APP_CONFIG = {
|
||||||
|
N8N_WEBHOOK_URL: "https://your-n8n-host/webhook/chat",
|
||||||
|
};
|
||||||
31
src/App.tsx
Normal file
31
src/App.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useTreeStore } from './store/store'
|
||||||
|
import { Sidebar } from './components/layout/Sidebar'
|
||||||
|
import { ChatPanel } from './components/layout/ChatPanel'
|
||||||
|
import { KeyNotesPanel } from './components/layout/KeyNotesPanel'
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const themes = useTreeStore((s) => s.themes)
|
||||||
|
const addMainTheme = useTreeStore((s) => s.addMainTheme)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (themes.length === 0) {
|
||||||
|
const id = addMainTheme('Tema Principal')
|
||||||
|
useTreeStore.getState().addSubTheme(id, 'Subtema 1')
|
||||||
|
}
|
||||||
|
}, [themes.length, addMainTheme])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full">
|
||||||
|
<aside className="w-72 shrink-0 border-r border-slate-700/60 bg-slate-950/40">
|
||||||
|
<Sidebar />
|
||||||
|
</aside>
|
||||||
|
<main className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<ChatPanel />
|
||||||
|
</main>
|
||||||
|
<aside className="w-96 shrink-0 border-l border-slate-700/60 bg-slate-950/40">
|
||||||
|
<KeyNotesPanel />
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
61
src/components/chat/ChatInput.tsx
Normal file
61
src/components/chat/ChatInput.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { useState, type KeyboardEvent } from 'react'
|
||||||
|
import { useChat } from '../../hooks/useChat'
|
||||||
|
|
||||||
|
export function ChatInput({ disabled }: { disabled: boolean }) {
|
||||||
|
const { send, stop, retry, isSending } = useChat()
|
||||||
|
const [text, setText] = useState('')
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
const v = text.trim()
|
||||||
|
if (!v || isSending || disabled) return
|
||||||
|
void send(v)
|
||||||
|
setText('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
submit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<textarea
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={disabled ? 'Selecciona un subtema para escribir…' : 'Escribe un mensaje (Enter para enviar, Shift+Enter para salto de línea)'}
|
||||||
|
rows={2}
|
||||||
|
className="w-full resize-none rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={retry}
|
||||||
|
disabled={isSending || disabled}
|
||||||
|
className="rounded px-3 py-1.5 text-xs text-slate-300 hover:bg-slate-800 disabled:opacity-40"
|
||||||
|
title="Reintentar último mensaje"
|
||||||
|
>
|
||||||
|
↻ Reintentar
|
||||||
|
</button>
|
||||||
|
{isSending ? (
|
||||||
|
<button
|
||||||
|
onClick={stop}
|
||||||
|
className="rounded bg-red-600 px-3 py-1.5 text-xs text-white hover:bg-red-500"
|
||||||
|
>
|
||||||
|
■ Detener
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={submit}
|
||||||
|
disabled={!text.trim() || disabled}
|
||||||
|
className="rounded bg-indigo-600 px-4 py-1.5 text-xs font-medium text-white hover:bg-indigo-500 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Enviar
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
15
src/components/chat/MarkdownRenderer.tsx
Normal file
15
src/components/chat/MarkdownRenderer.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import ReactMarkdown from 'react-markdown'
|
||||||
|
import remarkGfm from 'remark-gfm'
|
||||||
|
|
||||||
|
export function MarkdownRenderer({ children }: { children: string }) {
|
||||||
|
return (
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
components={{
|
||||||
|
a: ({ node, ...props }) => <a {...props} target="_blank" rel="noreferrer noopener" />,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ReactMarkdown>
|
||||||
|
)
|
||||||
|
}
|
||||||
36
src/components/chat/MessageBubble.tsx
Normal file
36
src/components/chat/MessageBubble.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import type { Message } from '../../types'
|
||||||
|
import { MarkdownRenderer } from './MarkdownRenderer'
|
||||||
|
import { TypingIndicator } from './TypingIndicator'
|
||||||
|
|
||||||
|
export function MessageBubble({ message, showTyping }: { message: Message; showTyping?: boolean }) {
|
||||||
|
const isUser = message.role === 'user'
|
||||||
|
const isError = message.status === 'error'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}>
|
||||||
|
<div
|
||||||
|
className={`max-w-[85%] rounded-lg px-3 py-2 text-sm ${
|
||||||
|
isUser
|
||||||
|
? 'bg-indigo-600 text-white'
|
||||||
|
: isError
|
||||||
|
? 'bg-red-950/40 text-red-200 ring-1 ring-red-700/50'
|
||||||
|
: 'bg-slate-800 text-slate-100 ring-1 ring-slate-700/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="mb-1 flex items-center gap-2 text-[10px] uppercase tracking-wide opacity-70">
|
||||||
|
{isUser ? 'Tú' : 'Asistente'}
|
||||||
|
{isError && <span className="text-red-300">— error</span>}
|
||||||
|
</div>
|
||||||
|
{showTyping || (message.content === '' && message.status === 'pending') ? (
|
||||||
|
<TypingIndicator />
|
||||||
|
) : isUser ? (
|
||||||
|
<p className="whitespace-pre-wrap break-words">{message.content}</p>
|
||||||
|
) : (
|
||||||
|
<div className="prose prose-sm prose-invert max-w-none break-words prose-pre:bg-slate-900 prose-pre:ring-1 prose-pre:ring-slate-700">
|
||||||
|
<MarkdownRenderer>{message.content}</MarkdownRenderer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
31
src/components/chat/MessageList.tsx
Normal file
31
src/components/chat/MessageList.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import type { Message } from '../../types'
|
||||||
|
import { MessageBubble } from './MessageBubble'
|
||||||
|
import { TypingIndicator } from './TypingIndicator'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
messages: Message[]
|
||||||
|
isSending: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageList({ messages, isSending }: Props) {
|
||||||
|
const endRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
endRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
|
||||||
|
}, [messages, isSending])
|
||||||
|
|
||||||
|
const lastAssistantPending =
|
||||||
|
isSending && messages[messages.length - 1]?.role === 'assistant' && messages[messages.length - 1]?.status === 'pending'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex max-w-3xl flex-col gap-3 p-4">
|
||||||
|
{messages.map((m, i) => {
|
||||||
|
const isPendingLast =
|
||||||
|
lastAssistantPending && i === messages.length - 1
|
||||||
|
return <MessageBubble key={m.id} message={m} showTyping={isPendingLast} />
|
||||||
|
})}
|
||||||
|
<div ref={endRef} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
10
src/components/chat/TypingIndicator.tsx
Normal file
10
src/components/chat/TypingIndicator.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
export function TypingIndicator() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1 py-1">
|
||||||
|
<span className="h-2 w-2 animate-bounce rounded-full bg-slate-400 [animation-delay:-0.3s]" />
|
||||||
|
<span className="h-2 w-2 animate-bounce rounded-full bg-slate-400 [animation-delay:-0.15s]" />
|
||||||
|
<span className="h-2 w-2 animate-bounce rounded-full bg-slate-400" />
|
||||||
|
<span className="ml-2 text-xs text-slate-400">escribiendo…</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
76
src/components/keynotes/KeyNoteCard.tsx
Normal file
76
src/components/keynotes/KeyNoteCard.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
import { useSortable } from '@dnd-kit/sortable'
|
||||||
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
|
import { useTreeStore } from '../../store/store'
|
||||||
|
import type { KeyNote } from '../../types'
|
||||||
|
import { KeyNoteEditor } from './KeyNoteEditor'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
subThemeId: string
|
||||||
|
note: KeyNote
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeBadge: Record<KeyNote['type'], { label: string; cls: string }> = {
|
||||||
|
summary: { label: 'Resumen', cls: 'bg-sky-900/60 text-sky-200 ring-sky-700/50' },
|
||||||
|
todo: { label: 'To-Do', cls: 'bg-emerald-900/60 text-emerald-200 ring-emerald-700/50' },
|
||||||
|
free: { label: 'Nota', cls: 'bg-slate-700/60 text-slate-200 ring-slate-600/50' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KeyNoteCard({ subThemeId, note }: Props) {
|
||||||
|
const updateKeyNote = useTreeStore((s) => s.updateKeyNote)
|
||||||
|
const deleteKeyNote = useTreeStore((s) => s.deleteKeyNote)
|
||||||
|
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
|
id: note.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.6 : 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
const badge = typeBadge[note.type]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className="rounded-lg border border-slate-700/70 bg-slate-900/70 p-3 shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
className="cursor-grab text-slate-600 hover:text-slate-300"
|
||||||
|
title="Arrastrar para reordenar"
|
||||||
|
>
|
||||||
|
⠿
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
value={note.title}
|
||||||
|
onChange={(e) => updateKeyNote(subThemeId, note.id, { title: e.target.value })}
|
||||||
|
className="min-w-0 flex-1 rounded bg-transparent px-1 py-0.5 text-sm font-semibold text-slate-100 outline-none focus:bg-slate-800"
|
||||||
|
/>
|
||||||
|
<span className={`rounded px-1.5 py-0.5 text-[10px] uppercase ring-1 ${badge.cls}`}>
|
||||||
|
{badge.label}
|
||||||
|
</span>
|
||||||
|
{note.source === 'n8n' && (
|
||||||
|
<span className="rounded bg-purple-900/50 px-1.5 py-0.5 text-[10px] uppercase text-purple-200 ring-1 ring-purple-700/40">
|
||||||
|
n8n
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm('¿Eliminar esta KeyNote?')) deleteKeyNote(subThemeId, note.id)
|
||||||
|
}}
|
||||||
|
className="text-slate-500 hover:text-red-300"
|
||||||
|
title="Eliminar"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<KeyNoteEditor subThemeId={subThemeId} note={note} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
156
src/components/keynotes/KeyNoteEditor.tsx
Normal file
156
src/components/keynotes/KeyNoteEditor.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
useSortable,
|
||||||
|
} from '@dnd-kit/sortable'
|
||||||
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
PointerSensor,
|
||||||
|
closestCenter,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
type DragEndEvent,
|
||||||
|
} from '@dnd-kit/core'
|
||||||
|
import { useTreeStore } from '../../store/store'
|
||||||
|
import type { KeyNote } from '../../types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
subThemeId: string
|
||||||
|
note: KeyNote
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KeyNoteEditor({ subThemeId, note }: Props) {
|
||||||
|
const updateKeyNote = useTreeStore((s) => s.updateKeyNote)
|
||||||
|
|
||||||
|
if (note.type === 'todo') {
|
||||||
|
return <TodoEditor subThemeId={subThemeId} note={note} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
value={note.content}
|
||||||
|
onChange={(e) => updateKeyNote(subThemeId, note.id, { content: e.target.value })}
|
||||||
|
placeholder={note.type === 'summary' ? 'Escribe el resumen…' : 'Escribe tu nota…'}
|
||||||
|
rows={note.type === 'summary' ? 4 : 3}
|
||||||
|
className="w-full resize-y rounded bg-slate-950/50 px-2 py-1.5 text-sm text-slate-200 outline-none ring-1 ring-slate-700/60 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TodoEditor({ subThemeId, note }: Props) {
|
||||||
|
const addTodoItem = useTreeStore((s) => s.addTodoItem)
|
||||||
|
const updateTodoItem = useTreeStore((s) => s.updateTodoItem)
|
||||||
|
const deleteTodoItem = useTreeStore((s) => s.deleteTodoItem)
|
||||||
|
const reorderTodoItems = useTreeStore((s) => s.reorderTodoItems)
|
||||||
|
|
||||||
|
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }))
|
||||||
|
|
||||||
|
function onDragEnd(e: DragEndEvent) {
|
||||||
|
const { active, over } = e
|
||||||
|
if (!over || active.id === over.id) return
|
||||||
|
const items = note.items ?? []
|
||||||
|
const ids = items.map((it) => it.id)
|
||||||
|
const from = ids.indexOf(String(active.id))
|
||||||
|
const to = ids.indexOf(String(over.id))
|
||||||
|
if (from === -1 || to === -1) return
|
||||||
|
const next = [...ids]
|
||||||
|
const [moved] = next.splice(from, 1)
|
||||||
|
next.splice(to, 0, moved)
|
||||||
|
reorderTodoItems(subThemeId, note.id, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = note.items ?? []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
||||||
|
<SortableContext items={items.map((it) => it.id)} strategy={verticalListSortingStrategy}>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{items.map((it) => (
|
||||||
|
<TodoRow
|
||||||
|
key={it.id}
|
||||||
|
subThemeId={subThemeId}
|
||||||
|
noteId={note.id}
|
||||||
|
itemId={it.id}
|
||||||
|
text={it.text}
|
||||||
|
done={it.done}
|
||||||
|
onToggle={() => updateTodoItem(subThemeId, note.id, it.id, { done: !it.done })}
|
||||||
|
onText={(t) => updateTodoItem(subThemeId, note.id, it.id, { text: t })}
|
||||||
|
onDelete={() => deleteTodoItem(subThemeId, note.id, it.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
<button
|
||||||
|
onClick={() => addTodoItem(subThemeId, note.id, 'Nueva tarea')}
|
||||||
|
className="mt-2 w-full rounded border border-dashed border-slate-700 px-2 py-1 text-xs text-slate-400 hover:border-slate-500 hover:text-slate-200"
|
||||||
|
>
|
||||||
|
+ Añadir tarea
|
||||||
|
</button>
|
||||||
|
</DndContext>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TodoRow({
|
||||||
|
subThemeId,
|
||||||
|
noteId,
|
||||||
|
itemId,
|
||||||
|
text,
|
||||||
|
done,
|
||||||
|
onToggle,
|
||||||
|
onText,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
subThemeId: string
|
||||||
|
noteId: string
|
||||||
|
itemId: string
|
||||||
|
text: string
|
||||||
|
done: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
onText: (t: string) => void
|
||||||
|
onDelete: () => void
|
||||||
|
}) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
|
id: itemId,
|
||||||
|
})
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.6 : 1,
|
||||||
|
}
|
||||||
|
void subThemeId
|
||||||
|
void noteId
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={setNodeRef} style={style} className="group flex items-center gap-2 rounded px-1 py-1 hover:bg-slate-800/40">
|
||||||
|
<button
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
className="cursor-grab text-slate-600 hover:text-slate-300"
|
||||||
|
title="Arrastrar"
|
||||||
|
>
|
||||||
|
⠿
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={done}
|
||||||
|
onChange={onToggle}
|
||||||
|
className="h-4 w-4 accent-emerald-500"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => onText(e.target.value)}
|
||||||
|
className={`min-w-0 flex-1 rounded bg-transparent px-1 py-0.5 text-sm outline-none focus:bg-slate-800 ${
|
||||||
|
done ? 'text-slate-500 line-through' : 'text-slate-200'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={onDelete}
|
||||||
|
className="text-slate-600 opacity-0 hover:text-red-300 group-hover:opacity-100"
|
||||||
|
title="Eliminar tarea"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
50
src/components/keynotes/KeyNotesDropZone.tsx
Normal file
50
src/components/keynotes/KeyNotesDropZone.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from '@dnd-kit/sortable'
|
||||||
|
import {
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
PointerSensor,
|
||||||
|
closestCenter,
|
||||||
|
DndContext,
|
||||||
|
type DragEndEvent,
|
||||||
|
} from '@dnd-kit/core'
|
||||||
|
import { useTreeStore } from '../../store/store'
|
||||||
|
import type { KeyNote } from '../../types'
|
||||||
|
import { KeyNoteCard } from './KeyNoteCard'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
subThemeId: string
|
||||||
|
notes: KeyNote[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KeyNotesDropZone({ subThemeId, notes }: Props) {
|
||||||
|
const reorderKeyNotes = useTreeStore((s) => s.reorderKeyNotes)
|
||||||
|
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }))
|
||||||
|
|
||||||
|
function onDragEnd(e: DragEndEvent) {
|
||||||
|
const { active, over } = e
|
||||||
|
if (!over || active.id === over.id) return
|
||||||
|
const ids = notes.map((n) => n.id)
|
||||||
|
const from = ids.indexOf(String(active.id))
|
||||||
|
const to = ids.indexOf(String(over.id))
|
||||||
|
if (from === -1 || to === -1) return
|
||||||
|
const next = [...ids]
|
||||||
|
const [moved] = next.splice(from, 1)
|
||||||
|
next.splice(to, 0, moved)
|
||||||
|
reorderKeyNotes(subThemeId, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
||||||
|
<SortableContext items={notes.map((n) => n.id)} strategy={verticalListSortingStrategy}>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{notes.map((n) => (
|
||||||
|
<KeyNoteCard key={n.id} subThemeId={subThemeId} note={n} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
)
|
||||||
|
}
|
||||||
52
src/components/layout/ChatPanel.tsx
Normal file
52
src/components/layout/ChatPanel.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { MessageList } from '../chat/MessageList'
|
||||||
|
import { ChatInput } from '../chat/ChatInput'
|
||||||
|
import { useTreeStore, selectActiveSubTheme, selectActiveMainTheme } from '../../store/store'
|
||||||
|
import { useChat } from '../../hooks/useChat'
|
||||||
|
|
||||||
|
export function ChatPanel() {
|
||||||
|
const activeSubTheme = useTreeStore(selectActiveSubTheme)
|
||||||
|
const activeMainTheme = useTreeStore(selectActiveMainTheme)
|
||||||
|
const { isSending, error, clearError } = useChat()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
|
<header className="flex items-center justify-between border-b border-slate-700/60 px-4 py-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-semibold text-slate-100">
|
||||||
|
{activeMainTheme?.title ?? '—'}
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-xs text-slate-400">
|
||||||
|
{activeSubTheme?.title ?? 'Selecciona o crea un subtema'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
|
{activeSubTheme ? (
|
||||||
|
<MessageList messages={activeSubTheme.messages} isSending={isSending} />
|
||||||
|
) : (
|
||||||
|
<EmptyState />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mx-4 mb-2 flex items-start justify-between gap-2 rounded border border-red-700/60 bg-red-950/40 px-3 py-2 text-xs text-red-200">
|
||||||
|
<span>{error}</span>
|
||||||
|
<button onClick={clearError} className="text-red-300 hover:text-red-100">×</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="border-t border-slate-700/60 p-3">
|
||||||
|
<ChatInput disabled={!activeSubTheme} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center p-8 text-center text-sm text-slate-500">
|
||||||
|
Crea un Tema y un Subtema en el panel izquierdo para empezar a chatear.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
68
src/components/layout/KeyNotesPanel.tsx
Normal file
68
src/components/layout/KeyNotesPanel.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { useTreeStore, selectActiveSubTheme } from '../../store/store'
|
||||||
|
import { KeyNotesDropZone } from '../keynotes/KeyNotesDropZone'
|
||||||
|
|
||||||
|
export function KeyNotesPanel() {
|
||||||
|
const activeSubTheme = useTreeStore(selectActiveSubTheme)
|
||||||
|
const addKeyNote = useTreeStore((s) => s.addKeyNote)
|
||||||
|
|
||||||
|
if (!activeSubTheme) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<PanelHeader />
|
||||||
|
<div className="flex flex-1 items-center justify-center p-6 text-center text-xs text-slate-500">
|
||||||
|
Selecciona un subtema para ver y editar sus KeyNotes.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions = (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => addKeyNote(activeSubTheme.id, { type: 'summary', title: 'Nuevo Resumen' })}
|
||||||
|
className="rounded px-2 py-1 text-[11px] text-slate-300 hover:bg-slate-800"
|
||||||
|
title="Añadir resumen"
|
||||||
|
>
|
||||||
|
+ Resumen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => addKeyNote(activeSubTheme.id, { type: 'todo', title: 'Nuevo To-Do' })}
|
||||||
|
className="rounded px-2 py-1 text-[11px] text-slate-300 hover:bg-slate-800"
|
||||||
|
title="Añadir To-Do"
|
||||||
|
>
|
||||||
|
+ To-Do
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => addKeyNote(activeSubTheme.id, { type: 'free', title: 'Nueva Nota' })}
|
||||||
|
className="rounded px-2 py-1 text-[11px] text-slate-300 hover:bg-slate-800"
|
||||||
|
title="Añadir nota libre"
|
||||||
|
>
|
||||||
|
+ Nota
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<PanelHeader right={actions} />
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||||
|
<KeyNotesDropZone subThemeId={activeSubTheme.id} notes={activeSubTheme.keyNotes} />
|
||||||
|
{activeSubTheme.keyNotes.length === 0 && (
|
||||||
|
<div className="rounded border border-dashed border-slate-700 p-4 text-center text-xs text-slate-500">
|
||||||
|
Sin KeyNotes. Crea una manualmente o pide a n8n que devuelva <code>keyNotes</code>.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PanelHeader({ right }: { right?: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-700/60 px-3 py-3">
|
||||||
|
<h2 className="text-sm font-semibold text-slate-200">KeyNotes</h2>
|
||||||
|
{right}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
15
src/components/layout/Sidebar.tsx
Normal file
15
src/components/layout/Sidebar.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { TreeView } from '../tree/TreeView'
|
||||||
|
|
||||||
|
export function Sidebar() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="flex items-center justify-between px-3 py-3 border-b border-slate-700/60">
|
||||||
|
<h1 className="text-sm font-semibold tracking-wide text-slate-200">WebChatV2</h1>
|
||||||
|
<span className="text-[10px] uppercase text-slate-500">Temas</span>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||||
|
<TreeView />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
95
src/components/tree/MainThemeNode.tsx
Normal file
95
src/components/tree/MainThemeNode.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useSortable } from '@dnd-kit/sortable'
|
||||||
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
|
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
|
||||||
|
import { useTreeStore } from '../../store/store'
|
||||||
|
import type { MainTheme } from '../../types'
|
||||||
|
import { SubThemeNode } from './SubThemeNode'
|
||||||
|
import { ThemeContextMenu } from './ThemeContextMenu'
|
||||||
|
|
||||||
|
export function MainThemeNode({ theme }: { theme: MainTheme }) {
|
||||||
|
const toggleMainTheme = useTreeStore((s) => s.toggleMainTheme)
|
||||||
|
const addSubTheme = useTreeStore((s) => s.addSubTheme)
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
|
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
|
id: theme.id,
|
||||||
|
data: { kind: 'main' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.6 : 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={setNodeRef} style={style} className="rounded border border-slate-800 bg-slate-900/60">
|
||||||
|
<div className="flex items-center gap-1 px-2 py-2">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleMainTheme(theme.id)}
|
||||||
|
className="w-4 shrink-0 text-slate-400 hover:text-slate-200"
|
||||||
|
title={theme.expanded ? 'Contraer' : 'Desplegar'}
|
||||||
|
>
|
||||||
|
{theme.expanded ? '▾' : '▸'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
className="cursor-grab text-slate-600 hover:text-slate-300"
|
||||||
|
title="Arrastrar para reordenar"
|
||||||
|
>
|
||||||
|
⠿
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
className="min-w-0 flex-1 truncate text-sm font-medium text-slate-100"
|
||||||
|
onDoubleClick={() => setMenuOpen(true)}
|
||||||
|
>
|
||||||
|
{theme.title}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setMenuOpen((v) => !v)}
|
||||||
|
className="text-slate-500 hover:text-slate-200"
|
||||||
|
title="Opciones"
|
||||||
|
>
|
||||||
|
⋯
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => addSubTheme(theme.id)}
|
||||||
|
className="text-slate-500 hover:text-slate-200"
|
||||||
|
title="Añadir subtema"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{menuOpen && (
|
||||||
|
<ThemeContextMenu
|
||||||
|
mode="main"
|
||||||
|
initialTitle={theme.title}
|
||||||
|
onClose={() => setMenuOpen(false)}
|
||||||
|
onRename={(title) => useTreeStore.getState().renameMainTheme(theme.id, title)}
|
||||||
|
onDelete={() => useTreeStore.getState().deleteMainTheme(theme.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{theme.expanded && (
|
||||||
|
<div className="border-t border-slate-800 px-1 py-1">
|
||||||
|
<SortableContext
|
||||||
|
items={theme.subThemes.map((s) => s.id)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1 pl-5">
|
||||||
|
{theme.subThemes.map((s) => (
|
||||||
|
<SubThemeNode key={s.id} sub={s} parentId={theme.id} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
{theme.subThemes.length === 0 && (
|
||||||
|
<div className="pl-5 py-1 text-xs text-slate-600">Sin subtemas</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
67
src/components/tree/SubThemeNode.tsx
Normal file
67
src/components/tree/SubThemeNode.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useSortable } from '@dnd-kit/sortable'
|
||||||
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
|
import { useTreeStore } from '../../store/store'
|
||||||
|
import type { SubTheme } from '../../types'
|
||||||
|
import { ThemeContextMenu } from './ThemeContextMenu'
|
||||||
|
|
||||||
|
export function SubThemeNode({ sub, parentId }: { sub: SubTheme; parentId: string }) {
|
||||||
|
const activeSubThemeId = useTreeStore((s) => s.activeSubThemeId)
|
||||||
|
const setActiveSubTheme = useTreeStore((s) => s.setActiveSubTheme)
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
|
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
|
id: sub.id,
|
||||||
|
data: { kind: 'sub', parentId },
|
||||||
|
})
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.6 : 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
const isActive = activeSubThemeId === sub.id
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={`group flex items-center gap-1 rounded px-2 py-1.5 text-sm ${
|
||||||
|
isActive ? 'bg-indigo-600/30 text-indigo-100 ring-1 ring-indigo-500/50' : 'text-slate-300 hover:bg-slate-800/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
className="cursor-grab text-slate-600 opacity-60 hover:text-slate-300"
|
||||||
|
title="Arrastrar para reordenar"
|
||||||
|
>
|
||||||
|
⠿
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveSubTheme(sub.id)}
|
||||||
|
className="min-w-0 flex-1 truncate text-left"
|
||||||
|
title={sub.title}
|
||||||
|
>
|
||||||
|
{sub.title}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setMenuOpen((v) => !v)}
|
||||||
|
className="text-slate-500 opacity-0 group-hover:opacity-100 hover:text-slate-200"
|
||||||
|
title="Opciones"
|
||||||
|
>
|
||||||
|
⋯
|
||||||
|
</button>
|
||||||
|
{menuOpen && (
|
||||||
|
<ThemeContextMenu
|
||||||
|
mode="sub"
|
||||||
|
initialTitle={sub.title}
|
||||||
|
onClose={() => setMenuOpen(false)}
|
||||||
|
onRename={(title) => useTreeStore.getState().renameSubTheme(sub.id, title)}
|
||||||
|
onDelete={() => useTreeStore.getState().deleteSubTheme(sub.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
76
src/components/tree/ThemeContextMenu.tsx
Normal file
76
src/components/tree/ThemeContextMenu.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
mode: 'main' | 'sub'
|
||||||
|
initialTitle: string
|
||||||
|
onClose: () => void
|
||||||
|
onRename: (title: string) => void
|
||||||
|
onDelete: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeContextMenu({ mode, initialTitle, onClose, onRename, onDelete }: Props) {
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [value, setValue] = useState(initialTitle)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editing) inputRef.current?.focus()
|
||||||
|
}, [editing])
|
||||||
|
|
||||||
|
function commit() {
|
||||||
|
const v = value.trim()
|
||||||
|
if (v) onRename(v)
|
||||||
|
setEditing(false)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute z-20 ml-6 mt-1 w-48 rounded border border-slate-700 bg-slate-800 p-2 shadow-lg">
|
||||||
|
{editing ? (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') commit()
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setEditing(false)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full rounded bg-slate-900 px-2 py-1 text-sm text-slate-100 outline-none ring-1 ring-slate-600"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button onClick={commit} className="flex-1 rounded bg-indigo-600 px-2 py-1 text-xs text-white hover:bg-indigo-500">
|
||||||
|
Guardar
|
||||||
|
</button>
|
||||||
|
<button onClick={onClose} className="rounded px-2 py-1 text-xs text-slate-300 hover:bg-slate-700">
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<button
|
||||||
|
onClick={() => setEditing(true)}
|
||||||
|
className="rounded px-2 py-1 text-left text-xs text-slate-200 hover:bg-slate-700"
|
||||||
|
>
|
||||||
|
Renombrar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`¿Eliminar este ${mode === 'main' ? 'tema y todos sus subtemas' : 'subtema'}?`)) {
|
||||||
|
onDelete()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="rounded px-2 py-1 text-left text-xs text-red-300 hover:bg-red-950/50"
|
||||||
|
>
|
||||||
|
Eliminar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
66
src/components/tree/TreeView.tsx
Normal file
66
src/components/tree/TreeView.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
PointerSensor,
|
||||||
|
closestCenter,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
type DragEndEvent,
|
||||||
|
} from '@dnd-kit/core'
|
||||||
|
import { useTreeStore } from '../../store/store'
|
||||||
|
import { MainThemeNode } from './MainThemeNode'
|
||||||
|
|
||||||
|
export function TreeView() {
|
||||||
|
const themes = useTreeStore((s) => s.themes)
|
||||||
|
const reorderMainThemes = useTreeStore((s) => s.reorderMainThemes)
|
||||||
|
const reorderSubThemes = useTreeStore((s) => s.reorderSubThemes)
|
||||||
|
const addMainTheme = useTreeStore((s) => s.addMainTheme)
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
||||||
|
)
|
||||||
|
|
||||||
|
function onDragEnd(e: DragEndEvent) {
|
||||||
|
const { active, over } = e
|
||||||
|
if (!over || active.id === over.id) return
|
||||||
|
const kind = active.data.current?.kind
|
||||||
|
if (kind === 'main') {
|
||||||
|
const ids = themes.map((t) => t.id)
|
||||||
|
const from = ids.indexOf(String(active.id))
|
||||||
|
const to = ids.indexOf(String(over.id))
|
||||||
|
if (from === -1 || to === -1) return
|
||||||
|
const next = [...ids]
|
||||||
|
const [moved] = next.splice(from, 1)
|
||||||
|
next.splice(to, 0, moved)
|
||||||
|
reorderMainThemes(next)
|
||||||
|
} else if (kind === 'sub') {
|
||||||
|
const parentId = active.data.current?.parentId
|
||||||
|
if (!parentId) return
|
||||||
|
const parent = themes.find((t) => t.id === parentId)
|
||||||
|
if (!parent) return
|
||||||
|
const ids = parent.subThemes.map((s) => s.id)
|
||||||
|
const from = ids.indexOf(String(active.id))
|
||||||
|
const to = ids.indexOf(String(over.id))
|
||||||
|
if (from === -1 || to === -1) return
|
||||||
|
const next = [...ids]
|
||||||
|
const [moved] = next.splice(from, 1)
|
||||||
|
next.splice(to, 0, moved)
|
||||||
|
reorderSubThemes(parentId, next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{themes.map((t) => (
|
||||||
|
<MainThemeNode key={t.id} theme={t} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => addMainTheme()}
|
||||||
|
className="mt-3 w-full rounded border border-dashed border-slate-700 px-2 py-2 text-xs text-slate-400 hover:border-slate-500 hover:text-slate-200"
|
||||||
|
>
|
||||||
|
+ Añadir Tema Principal
|
||||||
|
</button>
|
||||||
|
</DndContext>
|
||||||
|
)
|
||||||
|
}
|
||||||
11
src/hooks/useChat.ts
Normal file
11
src/hooks/useChat.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { useChatStore } from '../store/chatStore'
|
||||||
|
|
||||||
|
export function useChat() {
|
||||||
|
const send = useChatStore((s) => s.send)
|
||||||
|
const stop = useChatStore((s) => s.stop)
|
||||||
|
const retry = useChatStore((s) => s.retry)
|
||||||
|
const isSending = useChatStore((s) => s.isSending)
|
||||||
|
const error = useChatStore((s) => s.error)
|
||||||
|
const clearError = useChatStore((s) => s.clearError)
|
||||||
|
return { send, stop, retry, isSending, error, clearError }
|
||||||
|
}
|
||||||
15
src/index.css
Normal file
15
src/index.css
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-slate-900 text-slate-100;
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
}
|
||||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
65
src/services/n8nService.ts
Normal file
65
src/services/n8nService.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import type { ChatRequest, ChatResponse } from '../types'
|
||||||
|
import { getRuntimeConfig } from '../utils/config'
|
||||||
|
|
||||||
|
export class N8nError extends Error {
|
||||||
|
status?: number
|
||||||
|
constructor(message: string, status?: number) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'N8nError'
|
||||||
|
this.status = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendOptions {
|
||||||
|
signal?: AbortSignal
|
||||||
|
timeoutMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendChatMessage(
|
||||||
|
req: ChatRequest,
|
||||||
|
opts: SendOptions = {},
|
||||||
|
): Promise<ChatResponse> {
|
||||||
|
const { n8nWebhookUrl } = getRuntimeConfig()
|
||||||
|
if (!n8nWebhookUrl) {
|
||||||
|
throw new N8nError(
|
||||||
|
'No se configuró la URL del webhook de n8n. Edita public/config.js (o la variable VITE_N8N_WEBHOOK_URL) y recarga.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeout = opts.timeoutMs ?? 120000
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeout)
|
||||||
|
const signal = opts.signal
|
||||||
|
if (signal) {
|
||||||
|
if (signal.aborted) controller.abort()
|
||||||
|
else signal.addEventListener('abort', () => controller.abort(), { once: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(n8nWebhookUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(req),
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '')
|
||||||
|
throw new N8nError(`n8n respondió ${res.status}: ${text || res.statusText}`, res.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as ChatResponse
|
||||||
|
if (typeof data?.reply !== 'string') {
|
||||||
|
throw new N8nError('La respuesta de n8n no contiene el campo "reply" (string).')
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof N8nError) throw err
|
||||||
|
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||||
|
throw new N8nError('Solicitud cancelada o timeout alcanzado.')
|
||||||
|
}
|
||||||
|
throw new N8nError((err as Error)?.message ?? 'Error de red al contactar n8n.')
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}
|
||||||
117
src/store/chatStore.ts
Normal file
117
src/store/chatStore.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
import { create } from 'zustand'
|
||||||
|
import { useTreeStore } from './store'
|
||||||
|
import { sendChatMessage, N8nError } from '../services/n8nService'
|
||||||
|
import type { ChatResponseKeyNote, KeyNote, KeyNoteType } from '../types'
|
||||||
|
import { uid } from '../utils/id'
|
||||||
|
|
||||||
|
interface ChatSessionState {
|
||||||
|
isSending: boolean
|
||||||
|
error: string | null
|
||||||
|
abortController: AbortController | null
|
||||||
|
send: (text: string) => Promise<void>
|
||||||
|
stop: () => void
|
||||||
|
retry: () => Promise<void>
|
||||||
|
clearError: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromN8nKeyNote(kn: ChatResponseKeyNote): Partial<KeyNote> {
|
||||||
|
const type: KeyNoteType = kn.type
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
title: kn.title || defaultTitle(type),
|
||||||
|
content: kn.content ?? '',
|
||||||
|
items:
|
||||||
|
kn.items?.map((it) => ({ id: uid('todo'), text: it.text, done: !!it.done })) ??
|
||||||
|
(type === 'todo' ? [] : undefined),
|
||||||
|
source: 'n8n',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultTitle(type: KeyNoteType): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'summary':
|
||||||
|
return 'Resumen'
|
||||||
|
case 'todo':
|
||||||
|
return 'To-Do'
|
||||||
|
default:
|
||||||
|
return 'Nota'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSend(text: string, set: (p: Partial<ChatSessionState>) => void, get: () => ChatSessionState) {
|
||||||
|
const tree = useTreeStore.getState()
|
||||||
|
const subId = tree.activeSubThemeId
|
||||||
|
if (!subId) return
|
||||||
|
const sub = tree.themes
|
||||||
|
.flatMap((t) => t.subThemes)
|
||||||
|
.find((s) => s.id === subId)
|
||||||
|
const main = tree.themes.find((t) => t.subThemes.some((s) => s.id === subId))
|
||||||
|
if (!sub || !main) return
|
||||||
|
|
||||||
|
const content = text.trim()
|
||||||
|
if (!content || get().isSending) return
|
||||||
|
|
||||||
|
set({ error: null })
|
||||||
|
|
||||||
|
const userMsgId = tree.addMessage(sub.id, { role: 'user', content, status: 'sent' })
|
||||||
|
const history = sub.messages
|
||||||
|
.filter((m) => m.id !== userMsgId)
|
||||||
|
.map((m) => ({ role: m.role, content: m.content }))
|
||||||
|
|
||||||
|
const assistantMsgId = tree.addMessage(sub.id, { role: 'assistant', content: '', status: 'pending' })
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
set({ isSending: true, abortController: controller })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await sendChatMessage(
|
||||||
|
{
|
||||||
|
themeId: main.id,
|
||||||
|
subThemeId: sub.id,
|
||||||
|
themeTitle: main.title,
|
||||||
|
subThemeTitle: sub.title,
|
||||||
|
history,
|
||||||
|
message: content,
|
||||||
|
},
|
||||||
|
{ signal: controller.signal },
|
||||||
|
)
|
||||||
|
|
||||||
|
tree.updateMessage(sub.id, assistantMsgId, { content: data.reply, status: 'sent' })
|
||||||
|
|
||||||
|
if (data.keyNotes && data.keyNotes.length > 0) {
|
||||||
|
for (const kn of data.keyNotes) {
|
||||||
|
tree.addKeyNote(sub.id, fromN8nKeyNote(kn))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof N8nError ? err.message : (err as Error)?.message ?? 'Error desconocido'
|
||||||
|
set({ error: message })
|
||||||
|
tree.updateMessage(sub.id, assistantMsgId, { content: '', status: 'error' })
|
||||||
|
} finally {
|
||||||
|
set({ isSending: false, abortController: null })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useChatStore = create<ChatSessionState>((set, get) => ({
|
||||||
|
isSending: false,
|
||||||
|
error: null,
|
||||||
|
abortController: null,
|
||||||
|
send: (text) => runSend(text, set, get),
|
||||||
|
stop: () => {
|
||||||
|
get().abortController?.abort()
|
||||||
|
},
|
||||||
|
retry: async () => {
|
||||||
|
if (get().isSending) return
|
||||||
|
const tree = useTreeStore.getState()
|
||||||
|
const subId = tree.activeSubThemeId
|
||||||
|
if (!subId) return
|
||||||
|
const sub = tree.themes.flatMap((t) => t.subThemes).find((s) => s.id === subId)
|
||||||
|
if (!sub) return
|
||||||
|
const lastUser = [...sub.messages].reverse().find((m) => m.role === 'user')
|
||||||
|
if (!lastUser) return
|
||||||
|
const assistant = [...sub.messages].reverse().find((m) => m.role === 'assistant' && m.status === 'error')
|
||||||
|
if (assistant) tree.deleteMessage(sub.id, assistant.id)
|
||||||
|
await runSend(lastUser.content, set, get)
|
||||||
|
},
|
||||||
|
clearError: () => set({ error: null }),
|
||||||
|
}))
|
||||||
15
src/store/persistence.ts
Normal file
15
src/store/persistence.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { createJSONStorage, type StateStorage } from 'zustand/middleware'
|
||||||
|
|
||||||
|
const localStorageAdapter: StateStorage = {
|
||||||
|
getItem: (name) => (typeof window !== 'undefined' ? window.localStorage.getItem(name) : null),
|
||||||
|
setItem: (name, value) => {
|
||||||
|
if (typeof window !== 'undefined') window.localStorage.setItem(name, value)
|
||||||
|
},
|
||||||
|
removeItem: (name) => {
|
||||||
|
if (typeof window !== 'undefined') window.localStorage.removeItem(name)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const persistenceStorage = createJSONStorage(() => localStorageAdapter)
|
||||||
|
|
||||||
|
export const PERSISTENCE_KEY = 'webchatv2-tree'
|
||||||
291
src/store/store.ts
Normal file
291
src/store/store.ts
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
import { create } from 'zustand'
|
||||||
|
import { persist } from 'zustand/middleware'
|
||||||
|
import type { MainTheme, SubTheme, Message, KeyNote, KeyNoteType, TodoItem } from '../types'
|
||||||
|
import { uid } from '../utils/id'
|
||||||
|
import { persistenceStorage, PERSISTENCE_KEY } from './persistence'
|
||||||
|
|
||||||
|
interface TreeState {
|
||||||
|
themes: MainTheme[]
|
||||||
|
activeSubThemeId: string | null
|
||||||
|
|
||||||
|
addMainTheme: (title?: string) => string
|
||||||
|
renameMainTheme: (id: string, title: string) => void
|
||||||
|
deleteMainTheme: (id: string) => void
|
||||||
|
toggleMainTheme: (id: string, expanded?: boolean) => void
|
||||||
|
reorderMainThemes: (ids: string[]) => void
|
||||||
|
|
||||||
|
addSubTheme: (parentId: string, title?: string) => string
|
||||||
|
renameSubTheme: (id: string, title: string) => void
|
||||||
|
deleteSubTheme: (id: string) => void
|
||||||
|
reorderSubThemes: (parentId: string, ids: string[]) => void
|
||||||
|
setActiveSubTheme: (id: string | null) => void
|
||||||
|
|
||||||
|
addMessage: (subThemeId: string, msg: Omit<Message, 'id' | 'timestamp'>) => string
|
||||||
|
updateMessage: (subThemeId: string, messageId: string, patch: Partial<Message>) => void
|
||||||
|
deleteMessage: (subThemeId: string, messageId: string) => void
|
||||||
|
|
||||||
|
addKeyNote: (subThemeId: string, note?: Partial<KeyNote>) => string
|
||||||
|
updateKeyNote: (subThemeId: string, noteId: string, patch: Partial<KeyNote>) => void
|
||||||
|
deleteKeyNote: (subThemeId: string, noteId: string) => void
|
||||||
|
reorderKeyNotes: (subThemeId: string, ids: string[]) => void
|
||||||
|
|
||||||
|
addTodoItem: (subThemeId: string, noteId: string, text: string) => void
|
||||||
|
updateTodoItem: (subThemeId: string, noteId: string, itemId: string, patch: Partial<TodoItem>) => void
|
||||||
|
deleteTodoItem: (subThemeId: string, noteId: string, itemId: string) => void
|
||||||
|
reorderTodoItems: (subThemeId: string, noteId: string, ids: string[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function findSubTheme(themes: MainTheme[], id: string): SubTheme | undefined {
|
||||||
|
for (const t of themes) {
|
||||||
|
const s = t.subThemes.find((s) => s.id === id)
|
||||||
|
if (s) return s
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function mutateSubTheme(
|
||||||
|
themes: MainTheme[],
|
||||||
|
id: string,
|
||||||
|
fn: (s: SubTheme) => void,
|
||||||
|
): MainTheme[] {
|
||||||
|
return themes.map((t) => ({
|
||||||
|
...t,
|
||||||
|
subThemes: t.subThemes.map((s) => {
|
||||||
|
if (s.id !== id) return s
|
||||||
|
const clone: SubTheme = { ...s, messages: [...s.messages], keyNotes: [...s.keyNotes] }
|
||||||
|
fn(clone)
|
||||||
|
return clone
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const localStorageAdapter = persistenceStorage as unknown as import('zustand/middleware').StateStorage
|
||||||
|
|
||||||
|
export const useTreeStore = create<TreeState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
themes: [],
|
||||||
|
activeSubThemeId: null,
|
||||||
|
|
||||||
|
addMainTheme: (title) => {
|
||||||
|
const id = uid('theme')
|
||||||
|
const theme: MainTheme = {
|
||||||
|
id,
|
||||||
|
title: title?.trim() || 'Nuevo Tema',
|
||||||
|
expanded: true,
|
||||||
|
subThemes: [],
|
||||||
|
createdAt: Date.now(),
|
||||||
|
}
|
||||||
|
set((st) => ({ themes: [...st.themes, theme] }))
|
||||||
|
return id
|
||||||
|
},
|
||||||
|
|
||||||
|
renameMainTheme: (id, title) =>
|
||||||
|
set((st) => ({
|
||||||
|
themes: st.themes.map((t) => (t.id === id ? { ...t, title: title.trim() || t.title } : t)),
|
||||||
|
})),
|
||||||
|
|
||||||
|
deleteMainTheme: (id) =>
|
||||||
|
set((st) => {
|
||||||
|
const themes = st.themes.filter((t) => t.id !== id)
|
||||||
|
const activeSubThemeId = themes.some((t) => t.subThemes.some((s) => s.id === st.activeSubThemeId))
|
||||||
|
? st.activeSubThemeId
|
||||||
|
: null
|
||||||
|
return { themes, activeSubThemeId }
|
||||||
|
}),
|
||||||
|
|
||||||
|
toggleMainTheme: (id, expanded) =>
|
||||||
|
set((st) => ({
|
||||||
|
themes: st.themes.map((t) =>
|
||||||
|
t.id === id ? { ...t, expanded: expanded ?? !t.expanded } : t,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
|
||||||
|
reorderMainThemes: (ids) =>
|
||||||
|
set((st) => {
|
||||||
|
const map = new Map(st.themes.map((t) => [t.id, t]))
|
||||||
|
return { themes: ids.map((id) => map.get(id)!).filter(Boolean) }
|
||||||
|
}),
|
||||||
|
|
||||||
|
addSubTheme: (parentId, title) => {
|
||||||
|
const id = uid('sub')
|
||||||
|
const sub: SubTheme = {
|
||||||
|
id,
|
||||||
|
parentId,
|
||||||
|
title: title?.trim() || 'Nuevo Subtema',
|
||||||
|
messages: [],
|
||||||
|
keyNotes: [],
|
||||||
|
createdAt: Date.now(),
|
||||||
|
}
|
||||||
|
set((st) => ({
|
||||||
|
themes: st.themes.map((t) =>
|
||||||
|
t.id === parentId ? { ...t, subThemes: [...t.subThemes, sub], expanded: true } : t,
|
||||||
|
),
|
||||||
|
activeSubThemeId: id,
|
||||||
|
}))
|
||||||
|
return id
|
||||||
|
},
|
||||||
|
|
||||||
|
renameSubTheme: (id, title) =>
|
||||||
|
set((st) => ({
|
||||||
|
themes: mutateSubTheme(st.themes, id, (s) => {
|
||||||
|
s.title = title.trim() || s.title
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
|
||||||
|
deleteSubTheme: (id) =>
|
||||||
|
set((st) => {
|
||||||
|
const themes = st.themes.map((t) => ({
|
||||||
|
...t,
|
||||||
|
subThemes: t.subThemes.filter((s) => s.id !== id),
|
||||||
|
}))
|
||||||
|
const activeSubThemeId = st.activeSubThemeId === id ? null : st.activeSubThemeId
|
||||||
|
return { themes, activeSubThemeId }
|
||||||
|
}),
|
||||||
|
|
||||||
|
reorderSubThemes: (parentId, ids) =>
|
||||||
|
set((st) => ({
|
||||||
|
themes: st.themes.map((t) => {
|
||||||
|
if (t.id !== parentId) return t
|
||||||
|
const map = new Map(t.subThemes.map((s) => [s.id, s]))
|
||||||
|
return { ...t, subThemes: ids.map((id) => map.get(id)!).filter(Boolean) }
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
|
||||||
|
setActiveSubTheme: (id) => set({ activeSubThemeId: id }),
|
||||||
|
|
||||||
|
addMessage: (subThemeId, msg) => {
|
||||||
|
const id = uid('msg')
|
||||||
|
const message: Message = { id, timestamp: Date.now(), ...msg }
|
||||||
|
set((st) => ({
|
||||||
|
themes: mutateSubTheme(st.themes, subThemeId, (s) => {
|
||||||
|
s.messages.push(message)
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
return id
|
||||||
|
},
|
||||||
|
|
||||||
|
updateMessage: (subThemeId, messageId, patch) =>
|
||||||
|
set((st) => ({
|
||||||
|
themes: mutateSubTheme(st.themes, subThemeId, (s) => {
|
||||||
|
s.messages = s.messages.map((m) => (m.id === messageId ? { ...m, ...patch } : m))
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
|
||||||
|
deleteMessage: (subThemeId, messageId) =>
|
||||||
|
set((st) => ({
|
||||||
|
themes: mutateSubTheme(st.themes, subThemeId, (s) => {
|
||||||
|
s.messages = s.messages.filter((m) => m.id !== messageId)
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
|
||||||
|
addKeyNote: (subThemeId, note) => {
|
||||||
|
const id = uid('note')
|
||||||
|
const kn: KeyNote = {
|
||||||
|
id,
|
||||||
|
type: note?.type ?? 'free',
|
||||||
|
title: note?.title ?? 'Nueva Nota',
|
||||||
|
content: note?.content ?? '',
|
||||||
|
items: note?.items ?? (note?.type === 'todo' ? [] : undefined),
|
||||||
|
source: note?.source ?? 'manual',
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
}
|
||||||
|
set((st) => ({
|
||||||
|
themes: mutateSubTheme(st.themes, subThemeId, (s) => {
|
||||||
|
s.keyNotes.push(kn)
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
return id
|
||||||
|
},
|
||||||
|
|
||||||
|
updateKeyNote: (subThemeId, noteId, patch) =>
|
||||||
|
set((st) => ({
|
||||||
|
themes: mutateSubTheme(st.themes, subThemeId, (s) => {
|
||||||
|
s.keyNotes = s.keyNotes.map((n) =>
|
||||||
|
n.id === noteId ? { ...n, ...patch, updatedAt: Date.now() } : n,
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
|
||||||
|
deleteKeyNote: (subThemeId, noteId) =>
|
||||||
|
set((st) => ({
|
||||||
|
themes: mutateSubTheme(st.themes, subThemeId, (s) => {
|
||||||
|
s.keyNotes = s.keyNotes.filter((n) => n.id !== noteId)
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
|
||||||
|
reorderKeyNotes: (subThemeId, ids) =>
|
||||||
|
set((st) => ({
|
||||||
|
themes: mutateSubTheme(st.themes, subThemeId, (s) => {
|
||||||
|
const map = new Map(s.keyNotes.map((n) => [n.id, n]))
|
||||||
|
s.keyNotes = ids.map((id) => map.get(id)!).filter(Boolean)
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
|
||||||
|
addTodoItem: (subThemeId, noteId, text) =>
|
||||||
|
set((st) => ({
|
||||||
|
themes: mutateSubTheme(st.themes, subThemeId, (s) => {
|
||||||
|
s.keyNotes = s.keyNotes.map((n) =>
|
||||||
|
n.id === noteId
|
||||||
|
? { ...n, items: [...(n.items ?? []), { id: uid('todo'), text, done: false }], updatedAt: Date.now() }
|
||||||
|
: n,
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
|
||||||
|
updateTodoItem: (subThemeId, noteId, itemId, patch) =>
|
||||||
|
set((st) => ({
|
||||||
|
themes: mutateSubTheme(st.themes, subThemeId, (s) => {
|
||||||
|
s.keyNotes = s.keyNotes.map((n) =>
|
||||||
|
n.id === noteId
|
||||||
|
? {
|
||||||
|
...n,
|
||||||
|
items: (n.items ?? []).map((it) => (it.id === itemId ? { ...it, ...patch } : it)),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
}
|
||||||
|
: n,
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
|
||||||
|
deleteTodoItem: (subThemeId, noteId, itemId) =>
|
||||||
|
set((st) => ({
|
||||||
|
themes: mutateSubTheme(st.themes, subThemeId, (s) => {
|
||||||
|
s.keyNotes = s.keyNotes.map((n) =>
|
||||||
|
n.id === noteId
|
||||||
|
? { ...n, items: (n.items ?? []).filter((it) => it.id !== itemId), updatedAt: Date.now() }
|
||||||
|
: n,
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
|
||||||
|
reorderTodoItems: (subThemeId, noteId, ids) =>
|
||||||
|
set((st) => ({
|
||||||
|
themes: mutateSubTheme(st.themes, subThemeId, (s) => {
|
||||||
|
s.keyNotes = s.keyNotes.map((n) => {
|
||||||
|
if (n.id !== noteId) return n
|
||||||
|
const map = new Map((n.items ?? []).map((it) => [it.id, it]))
|
||||||
|
return { ...n, items: ids.map((id) => map.get(id)!).filter(Boolean), updatedAt: Date.now() }
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: PERSISTENCE_KEY,
|
||||||
|
storage: persistenceStorage,
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
export function selectActiveSubTheme(state: TreeState): SubTheme | undefined {
|
||||||
|
if (!state.activeSubThemeId) return undefined
|
||||||
|
return findSubTheme(state.themes, state.activeSubThemeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectActiveMainTheme(state: TreeState): MainTheme | undefined {
|
||||||
|
if (!state.activeSubThemeId) return undefined
|
||||||
|
return state.themes.find((t) => t.subThemes.some((s) => s.id === state.activeSubThemeId))
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { KeyNoteType }
|
||||||
72
src/types/index.ts
Normal file
72
src/types/index.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
export type MessageRole = 'user' | 'assistant'
|
||||||
|
|
||||||
|
export type MessageStatus = 'pending' | 'sent' | 'error'
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: string
|
||||||
|
role: MessageRole
|
||||||
|
content: string
|
||||||
|
timestamp: number
|
||||||
|
status: MessageStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export type KeyNoteType = 'summary' | 'todo' | 'free'
|
||||||
|
export type KeyNoteSource = 'manual' | 'n8n'
|
||||||
|
|
||||||
|
export interface TodoItem {
|
||||||
|
id: string
|
||||||
|
text: string
|
||||||
|
done: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeyNote {
|
||||||
|
id: string
|
||||||
|
type: KeyNoteType
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
items?: TodoItem[]
|
||||||
|
source: KeyNoteSource
|
||||||
|
updatedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubTheme {
|
||||||
|
id: string
|
||||||
|
parentId: string
|
||||||
|
title: string
|
||||||
|
messages: Message[]
|
||||||
|
keyNotes: KeyNote[]
|
||||||
|
createdAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MainTheme {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
expanded: boolean
|
||||||
|
subThemes: SubTheme[]
|
||||||
|
createdAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatRequest {
|
||||||
|
themeId: string
|
||||||
|
subThemeId: string
|
||||||
|
themeTitle: string
|
||||||
|
subThemeTitle: string
|
||||||
|
history: { role: MessageRole; content: string }[]
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatResponseKeyNote {
|
||||||
|
type: KeyNoteType
|
||||||
|
title: string
|
||||||
|
content?: string
|
||||||
|
items?: { text: string; done?: boolean }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatResponse {
|
||||||
|
reply: string
|
||||||
|
keyNotes?: ChatResponseKeyNote[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeConfig {
|
||||||
|
n8nWebhookUrl: string
|
||||||
|
}
|
||||||
8
src/utils/config.ts
Normal file
8
src/utils/config.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import type { RuntimeConfig } from '../types'
|
||||||
|
|
||||||
|
export function getRuntimeConfig(): RuntimeConfig {
|
||||||
|
const fromWindow = typeof window !== 'undefined' ? window.APP_CONFIG?.N8N_WEBHOOK_URL : undefined
|
||||||
|
const fromEnv = import.meta.env.VITE_N8N_WEBHOOK_URL
|
||||||
|
const n8nWebhookUrl = (fromWindow && fromWindow.trim().length > 0 ? fromWindow : fromEnv) || ''
|
||||||
|
return { n8nWebhookUrl }
|
||||||
|
}
|
||||||
5
src/utils/id.ts
Normal file
5
src/utils/id.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
export function uid(prefix = 'id'): string {
|
||||||
|
const rand = Math.random().toString(36).slice(2, 10)
|
||||||
|
const time = Date.now().toString(36)
|
||||||
|
return `${prefix}_${time}${rand}`
|
||||||
|
}
|
||||||
11
src/vite-env.d.ts
vendored
Normal file
11
src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_N8N_WEBHOOK_URL?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Window {
|
||||||
|
APP_CONFIG?: {
|
||||||
|
N8N_WEBHOOK_URL: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
9
tailwind.config.js
Normal file
9
tailwind.config.js
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import typography from '@tailwindcss/typography'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [typography],
|
||||||
|
}
|
||||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
"include": ["src", "vite.config.ts"]
|
||||||
|
}
|
||||||
10
vite.config.ts
Normal file
10
vite.config.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
host: true,
|
||||||
|
port: 5173,
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue