Unique persistent chat IDs and support for chat history navigation
- Chat identifiers are now unique and no longer tied to the user session. - Users can navigate between previous chats from the sidebar. - New chats can be created via a dedicated "New Chat" button. - The most recently started chat is automatically selected on load.
This commit is contained in:
parent
73f3ade0f0
commit
4a5a8858ab
@ -1,10 +1,5 @@
|
|||||||
package com.pablotj.ia.chat.boot.adapter.controller;
|
package com.pablotj.ia.chat.boot.adapter.controller;
|
||||||
|
|
||||||
import com.pablotj.ia.chat.boot.application.session.ChatSessionManager;
|
|
||||||
import com.pablotj.ia.chat.boot.application.usecase.ChatUseCase;
|
|
||||||
import com.pablotj.ia.chat.boot.domain.model.ChatMessage;
|
|
||||||
import jakarta.servlet.http.HttpSession;
|
|
||||||
import java.util.List;
|
|
||||||
import org.apache.logging.log4j.LogManager;
|
import org.apache.logging.log4j.LogManager;
|
||||||
import org.apache.logging.log4j.Logger;
|
import org.apache.logging.log4j.Logger;
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
@ -17,26 +12,10 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
|||||||
public class ChatPageController {
|
public class ChatPageController {
|
||||||
|
|
||||||
private static final Logger LOGGER = LogManager.getLogger(ChatPageController.class);
|
private static final Logger LOGGER = LogManager.getLogger(ChatPageController.class);
|
||||||
private final ChatSessionManager chatSessionManager;
|
|
||||||
private final ChatUseCase chatUseCase;
|
|
||||||
|
|
||||||
public ChatPageController(ChatSessionManager chatSessionManager, ChatUseCase chatUseCase) {
|
|
||||||
this.chatSessionManager = chatSessionManager;
|
|
||||||
this.chatUseCase = chatUseCase;
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public String showChat(Model model, HttpSession session) {
|
public String showChat(Model model) {
|
||||||
LOGGER.debug("Accessing to chat");
|
LOGGER.debug("Accessing to chat");
|
||||||
List<ChatMessage> messages = chatSessionManager.getMessages(session);
|
|
||||||
if (messages != null && messages.isEmpty()) {
|
|
||||||
try {
|
|
||||||
messages.add(chatUseCase.processUserPrompt("", session));
|
|
||||||
} catch (Exception e) {
|
|
||||||
LOGGER.error(e.getMessage(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
model.addAttribute("messages", messages);
|
|
||||||
return "chat";
|
return "chat";
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,37 +1,66 @@
|
|||||||
package com.pablotj.ia.chat.boot.adapter.controller;
|
package com.pablotj.ia.chat.boot.adapter.controller;
|
||||||
|
|
||||||
|
import com.pablotj.ia.chat.boot.application.usecase.ChatHistoryUseCase;
|
||||||
import com.pablotj.ia.chat.boot.application.usecase.ChatUseCase;
|
import com.pablotj.ia.chat.boot.application.usecase.ChatUseCase;
|
||||||
|
import com.pablotj.ia.chat.boot.domain.model.ChatIdentity;
|
||||||
import com.pablotj.ia.chat.boot.domain.model.ChatMessage;
|
import com.pablotj.ia.chat.boot.domain.model.ChatMessage;
|
||||||
import jakarta.servlet.http.HttpSession;
|
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
import org.apache.logging.log4j.LogManager;
|
import org.apache.logging.log4j.LogManager;
|
||||||
import org.apache.logging.log4j.Logger;
|
import org.apache.logging.log4j.Logger;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.util.ObjectUtils;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/chat")
|
@RequestMapping("/api/v1/chats")
|
||||||
public class ChatRestController {
|
public class ChatRestController {
|
||||||
|
|
||||||
private static final Logger LOGGER = LogManager.getLogger(ChatRestController.class);
|
private static final Logger LOGGER = LogManager.getLogger(ChatRestController.class);
|
||||||
|
|
||||||
private final ChatUseCase chatUseCase;
|
private final ChatUseCase chatUseCase;
|
||||||
|
private final ChatHistoryUseCase chatHistoryUseCase;
|
||||||
|
|
||||||
public ChatRestController(ChatUseCase chatUseCase) {
|
public ChatRestController(ChatUseCase chatUseCase, ChatHistoryUseCase chatHistoryUseCase) {
|
||||||
this.chatUseCase = chatUseCase;
|
this.chatUseCase = chatUseCase;
|
||||||
|
this.chatHistoryUseCase = chatHistoryUseCase;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping(consumes = "application/x-www-form-urlencoded", produces = "application/json")
|
@GetMapping
|
||||||
public ResponseEntity<ChatMessage> handleChat(@RequestParam("prompt") String prompt, HttpSession session) {
|
public ResponseEntity<List<ChatIdentity>> getChatHistory() {
|
||||||
|
LOGGER.debug("Accessing to chat");
|
||||||
|
return ResponseEntity.ok(chatHistoryUseCase.chats());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("{chatId}")
|
||||||
|
public ResponseEntity<List<ChatMessage>> getChatMessages(@PathVariable("chatId") String chatId) {
|
||||||
|
LOGGER.debug("Accessing to chat messages");
|
||||||
|
return ResponseEntity.ok(chatUseCase.getMessages(chatId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping(produces = "application/json")
|
||||||
|
public ResponseEntity<String> newChat() {
|
||||||
|
return ResponseEntity.ok(UUID.randomUUID().toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping(value = "{chatId}", consumes = "application/x-www-form-urlencoded", produces = "application/json")
|
||||||
|
public ResponseEntity<ChatMessage> handleChat(@PathVariable("chatId") String chatId, @RequestParam("prompt") String prompt) {
|
||||||
|
if (ObjectUtils.isEmpty(chatId)) {
|
||||||
|
throw new IllegalArgumentException("Chat id cannot be empty");
|
||||||
|
}
|
||||||
ChatMessage reply;
|
ChatMessage reply;
|
||||||
try {
|
try {
|
||||||
reply = chatUseCase.processUserPrompt(prompt, session);
|
reply = chatUseCase.processUserPrompt(prompt, chatId);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
LOGGER.error(e.getMessage(), e);
|
LOGGER.error(e.getMessage(), e);
|
||||||
reply = new ChatMessage("bot", e.getMessage(), new Date());
|
reply = new ChatMessage(chatId, "bot", e.getMessage(), new Date());
|
||||||
}
|
}
|
||||||
return ResponseEntity.ok(reply);
|
return ResponseEntity.ok(reply);
|
||||||
}
|
}
|
||||||
|
@ -2,13 +2,11 @@ package com.pablotj.ia.chat.boot.application.session;
|
|||||||
|
|
||||||
import com.pablotj.ia.chat.boot.domain.model.ChatMessage;
|
import com.pablotj.ia.chat.boot.domain.model.ChatMessage;
|
||||||
import com.pablotj.ia.chat.boot.domain.port.ChatMessageStore;
|
import com.pablotj.ia.chat.boot.domain.port.ChatMessageStore;
|
||||||
import jakarta.servlet.http.HttpSession;
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.util.ObjectUtils;
|
import org.springframework.util.ObjectUtils;
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
public class ChatSessionManager {
|
public class ChatSessionManager {
|
||||||
|
|
||||||
@ -18,9 +16,8 @@ public class ChatSessionManager {
|
|||||||
this.chatMessageStore = chatMessageStore;
|
this.chatMessageStore = chatMessageStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<ChatMessage> getMessages(HttpSession session) {
|
public List<ChatMessage> getMessages(String chatId) {
|
||||||
String sessionId = session.getId();
|
List<ChatMessage> messages = chatMessageStore.getMessages(chatId);
|
||||||
List<ChatMessage> messages = chatMessageStore.getMessages(sessionId);
|
|
||||||
List<ChatMessage> filteredMessages = new ArrayList<>(messages);
|
List<ChatMessage> filteredMessages = new ArrayList<>(messages);
|
||||||
|
|
||||||
if (ObjectUtils.isEmpty(filteredMessages)) {
|
if (ObjectUtils.isEmpty(filteredMessages)) {
|
||||||
@ -31,8 +28,8 @@ public class ChatSessionManager {
|
|||||||
return filteredMessages;
|
return filteredMessages;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setMessages(HttpSession session, List<ChatMessage> messages) {
|
public void setMessages(String chatId, List<ChatMessage> messages) {
|
||||||
messages.removeIf(m -> ObjectUtils.isEmpty(m.text()));
|
messages.removeIf(m -> ObjectUtils.isEmpty(m.text()));
|
||||||
chatMessageStore.saveMessages(session.getId(), messages);
|
chatMessageStore.saveMessages(chatId, messages);
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -0,0 +1,20 @@
|
|||||||
|
package com.pablotj.ia.chat.boot.application.usecase;
|
||||||
|
|
||||||
|
import com.pablotj.ia.chat.boot.domain.model.ChatIdentity;
|
||||||
|
import com.pablotj.ia.chat.boot.domain.port.ChatMessageStore;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class ChatHistoryUseCase {
|
||||||
|
|
||||||
|
private final ChatMessageStore chatMessageStore;
|
||||||
|
|
||||||
|
public ChatHistoryUseCase(ChatMessageStore chatMessageStore) {
|
||||||
|
this.chatMessageStore = chatMessageStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ChatIdentity> chats() {
|
||||||
|
return chatMessageStore.getChats();
|
||||||
|
}
|
||||||
|
}
|
@ -5,7 +5,6 @@ import com.pablotj.ia.chat.boot.application.prompt.PromptTemplates;
|
|||||||
import com.pablotj.ia.chat.boot.application.session.ChatSessionManager;
|
import com.pablotj.ia.chat.boot.application.session.ChatSessionManager;
|
||||||
import com.pablotj.ia.chat.boot.domain.model.ChatMessage;
|
import com.pablotj.ia.chat.boot.domain.model.ChatMessage;
|
||||||
import com.pablotj.ia.chat.boot.infraestructure.llm.LlmModelClient;
|
import com.pablotj.ia.chat.boot.infraestructure.llm.LlmModelClient;
|
||||||
import jakarta.servlet.http.HttpSession;
|
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@ -25,9 +24,13 @@ public class ChatUseCase {
|
|||||||
this.sessionManager = sessionManager;
|
this.sessionManager = sessionManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ChatMessage processUserPrompt(String prompt, HttpSession session) {
|
public List<ChatMessage> getMessages(String chatId) {
|
||||||
List<ChatMessage> messages = sessionManager.getMessages(session);
|
return sessionManager.getMessages(chatId);
|
||||||
messages.add(new ChatMessage(ATTR_ROLE_USER, prompt, new Date()));
|
}
|
||||||
|
|
||||||
|
public ChatMessage processUserPrompt(String prompt, String chatId) {
|
||||||
|
List<ChatMessage> messages = sessionManager.getMessages(chatId);
|
||||||
|
messages.add(new ChatMessage(chatId, ATTR_ROLE_USER, prompt, new Date()));
|
||||||
|
|
||||||
PromptBuilder builder = new PromptBuilder(PromptTemplates.getDefault());
|
PromptBuilder builder = new PromptBuilder(PromptTemplates.getDefault());
|
||||||
|
|
||||||
@ -40,9 +43,9 @@ public class ChatUseCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String result = llmModelClient.generate(builder.build());
|
String result = llmModelClient.generate(builder.build());
|
||||||
ChatMessage reply = new ChatMessage(ATTR_ROLE_BOT, result, new Date());
|
ChatMessage reply = new ChatMessage(chatId, ATTR_ROLE_BOT, result, new Date());
|
||||||
messages.add(reply);
|
messages.add(reply);
|
||||||
sessionManager.setMessages(session, messages);
|
sessionManager.setMessages(chatId, messages);
|
||||||
return reply;
|
return reply;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,6 @@
|
|||||||
|
package com.pablotj.ia.chat.boot.domain.model;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
public record ChatIdentity(String id, String name) implements Serializable {
|
||||||
|
}
|
@ -4,7 +4,7 @@ import com.fasterxml.jackson.annotation.JsonFormat;
|
|||||||
import java.io.Serializable;
|
import java.io.Serializable;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
|
|
||||||
public record ChatMessage(String role, String text, Date date) implements Serializable {
|
public record ChatMessage(String chatId, String role, String text, Date date) implements Serializable {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@JsonFormat(pattern = "dd/MM/yyyy HH:mm", timezone = "Europe/Madrid")
|
@JsonFormat(pattern = "dd/MM/yyyy HH:mm", timezone = "Europe/Madrid")
|
||||||
|
@ -1,9 +1,12 @@
|
|||||||
package com.pablotj.ia.chat.boot.domain.port;
|
package com.pablotj.ia.chat.boot.domain.port;
|
||||||
|
|
||||||
|
import com.pablotj.ia.chat.boot.domain.model.ChatIdentity;
|
||||||
import com.pablotj.ia.chat.boot.domain.model.ChatMessage;
|
import com.pablotj.ia.chat.boot.domain.model.ChatMessage;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public interface ChatMessageStore {
|
public interface ChatMessageStore {
|
||||||
List<ChatMessage> getMessages(String sessionId);
|
List<ChatIdentity> getChats();
|
||||||
|
|
||||||
|
List<ChatMessage> getMessages(String chatId);
|
||||||
void saveMessages(String sessionId, List<ChatMessage> messages);
|
void saveMessages(String sessionId, List<ChatMessage> messages);
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,24 @@
|
|||||||
package com.pablotj.ia.chat.boot.persistence;
|
package com.pablotj.ia.chat.boot.persistence;
|
||||||
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
|
||||||
|
|
||||||
public interface ChatMessageJpaRepository extends JpaRepository<ChatMessageEntity, Long> {
|
public interface ChatMessageJpaRepository extends JpaRepository<ChatMessageEntity, Long> {
|
||||||
List<ChatMessageEntity> findBySessionIdOrderByIdAsc(String sessionId);
|
|
||||||
void deleteBySessionId(String sessionId);
|
@Query(value = """
|
||||||
|
SELECT *
|
||||||
|
FROM (
|
||||||
|
SELECT *, ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY id ASC) AS rn
|
||||||
|
FROM chat_messages
|
||||||
|
) sub
|
||||||
|
WHERE rn = 1
|
||||||
|
ORDER BY id desc
|
||||||
|
""", nativeQuery = true)
|
||||||
|
List<ChatMessageEntity> findFirstMessagePerSession();
|
||||||
|
|
||||||
|
List<ChatMessageEntity> findBySessionIdOrderByIdAsc(String chatId);
|
||||||
|
|
||||||
|
void deleteBySessionId(String chatId);
|
||||||
}
|
}
|
@ -1,12 +1,17 @@
|
|||||||
package com.pablotj.ia.chat.boot.persistence;
|
package com.pablotj.ia.chat.boot.persistence;
|
||||||
|
|
||||||
|
import com.pablotj.ia.chat.boot.domain.model.ChatIdentity;
|
||||||
import com.pablotj.ia.chat.boot.domain.model.ChatMessage;
|
import com.pablotj.ia.chat.boot.domain.model.ChatMessage;
|
||||||
|
|
||||||
public class ChatMessageMapper {
|
public class ChatMessageMapper {
|
||||||
|
|
||||||
public static ChatMessageEntity toEntity(String sessionId, ChatMessage message) {
|
private ChatMessageMapper() throws IllegalAccessException {
|
||||||
|
throw new IllegalAccessException("Private access to ChatMessageMapper");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ChatMessageEntity toEntity(String chatId, ChatMessage message) {
|
||||||
ChatMessageEntity entity = new ChatMessageEntity();
|
ChatMessageEntity entity = new ChatMessageEntity();
|
||||||
entity.setSessionId(sessionId);
|
entity.setSessionId(chatId);
|
||||||
entity.setRole(message.role());
|
entity.setRole(message.role());
|
||||||
entity.setText(message.text());
|
entity.setText(message.text());
|
||||||
entity.setDate(message.date());
|
entity.setDate(message.date());
|
||||||
@ -14,6 +19,10 @@ public class ChatMessageMapper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static ChatMessage toDomain(ChatMessageEntity entity) {
|
public static ChatMessage toDomain(ChatMessageEntity entity) {
|
||||||
return new ChatMessage(entity.getRole(), entity.getText(), entity.getDate());
|
return new ChatMessage(entity.getSessionId(), entity.getRole(), entity.getText(), entity.getDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ChatIdentity toEntityId(ChatMessageEntity m) {
|
||||||
|
return new ChatIdentity(m.getSessionId(), m.getText().length() > 35 ? m.getText().substring(0, 35).concat("...") : m.getText());
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,9 +1,10 @@
|
|||||||
package com.pablotj.ia.chat.boot.persistence;
|
package com.pablotj.ia.chat.boot.persistence;
|
||||||
|
|
||||||
|
import com.pablotj.ia.chat.boot.domain.model.ChatIdentity;
|
||||||
import com.pablotj.ia.chat.boot.domain.model.ChatMessage;
|
import com.pablotj.ia.chat.boot.domain.model.ChatMessage;
|
||||||
import com.pablotj.ia.chat.boot.domain.port.ChatMessageStore;
|
import com.pablotj.ia.chat.boot.domain.port.ChatMessageStore;
|
||||||
import org.springframework.stereotype.Repository;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
@ -16,8 +17,15 @@ public class SqliteChatMessageStore implements ChatMessageStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<ChatMessage> getMessages(String sessionId) {
|
public List<ChatIdentity> getChats() {
|
||||||
return repository.findBySessionIdOrderByIdAsc(sessionId)
|
return repository.findFirstMessagePerSession().stream()
|
||||||
|
.map(ChatMessageMapper::toEntityId)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<ChatMessage> getMessages(String chatId) {
|
||||||
|
return repository.findBySessionIdOrderByIdAsc(chatId)
|
||||||
.stream()
|
.stream()
|
||||||
.map(ChatMessageMapper::toDomain)
|
.map(ChatMessageMapper::toDomain)
|
||||||
.toList();
|
.toList();
|
||||||
@ -25,10 +33,10 @@ public class SqliteChatMessageStore implements ChatMessageStore {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public void saveMessages(String sessionId, List<ChatMessage> messages) {
|
public void saveMessages(String chatId, List<ChatMessage> messages) {
|
||||||
repository.deleteBySessionId(sessionId);
|
repository.deleteBySessionId(chatId);
|
||||||
List<ChatMessageEntity> entities = messages.stream()
|
List<ChatMessageEntity> entities = messages.stream()
|
||||||
.map(m -> ChatMessageMapper.toEntity(sessionId, m))
|
.map(m -> ChatMessageMapper.toEntity(chatId, m))
|
||||||
.toList();
|
.toList();
|
||||||
repository.saveAll(entities);
|
repository.saveAll(entities);
|
||||||
}
|
}
|
||||||
|
@ -11,14 +11,74 @@ html, body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: 100vh;
|
||||||
|
background-color: #121217;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Layout general */
|
||||||
|
.layout-container {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Menú lateral izquierdo */
|
||||||
|
#sidebar {
|
||||||
|
width: 25%;
|
||||||
|
min-width: 200px;
|
||||||
|
background-color: #1b1d23;
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-right: 1px solid #2c2e34;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
max-width: 600px;
|
}
|
||||||
margin: 0 auto;
|
|
||||||
height: 100vh;
|
#sidebar h2 {
|
||||||
padding: 2rem 1.25rem 1rem;
|
font-size: 1.25rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: #5a90ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
#chat-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#chat-list li {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#chat-list li:hover {
|
||||||
|
background-color: #2c2f3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
#chat-list li.active {
|
||||||
|
background-color: #3451d1;
|
||||||
|
color: white;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Área principal del chat */
|
||||||
|
#main-content {
|
||||||
|
width: 75%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 2rem 1.25rem 1rem;
|
||||||
background-color: #121217;
|
background-color: #121217;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Título */
|
/* Título */
|
||||||
@ -50,6 +110,7 @@ h1 {
|
|||||||
#chat-log::-webkit-scrollbar {
|
#chat-log::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#chat-log::-webkit-scrollbar-thumb {
|
#chat-log::-webkit-scrollbar-thumb {
|
||||||
background-color: #5a90ff55;
|
background-color: #5a90ff55;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
@ -70,7 +131,6 @@ h1 {
|
|||||||
animation: slideFadeIn 0.3s forwards;
|
animation: slideFadeIn 0.3s forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Animación de entrada */
|
|
||||||
@keyframes slideFadeIn {
|
@keyframes slideFadeIn {
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@ -138,7 +198,7 @@ textarea:focus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Botón */
|
/* Botón */
|
||||||
button {
|
button#send-btn {
|
||||||
background-color: #5a90ff;
|
background-color: #5a90ff;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
@ -151,16 +211,16 @@ button {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:hover:not(:disabled) {
|
button#send-btn:hover:not(:disabled) {
|
||||||
background-color: #4076e0;
|
background-color: #4076e0;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:active:not(:disabled) {
|
button#send-btn:active:not(:disabled) {
|
||||||
transform: translateY(1px);
|
transform: translateY(1px);
|
||||||
background-color: #305dc0;
|
background-color: #305dc0;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:disabled {
|
button#send-btn:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
background-color: #3a4a6a;
|
background-color: #3a4a6a;
|
||||||
@ -205,30 +265,33 @@ button:disabled {
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#new-chat-btn {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#new-chat-btn:hover {
|
||||||
|
background-color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive */
|
/* Responsive */
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 768px) {
|
||||||
body {
|
.layout-container {
|
||||||
max-width: 100%;
|
flex-direction: column;
|
||||||
padding: 1.5rem 1rem 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#chat-log {
|
#sidebar {
|
||||||
padding: 1rem 1.25rem;
|
width: 100%;
|
||||||
border-radius: 12px;
|
border-right: none;
|
||||||
|
border-bottom: 1px solid #2c2e34;
|
||||||
}
|
}
|
||||||
|
|
||||||
form {
|
#main-content {
|
||||||
gap: 8px;
|
width: 100%;
|
||||||
}
|
|
||||||
|
|
||||||
textarea {
|
|
||||||
min-height: 3.5rem;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
padding: 0 1.25rem;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,4 +1,8 @@
|
|||||||
document.addEventListener("DOMContentLoaded", () => {
|
let chatId = '';
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", async () => {
|
||||||
|
const newChatBtn = document.getElementById("new-chat-btn");
|
||||||
|
const chatList = document.getElementById("chat-list");
|
||||||
const form = document.getElementById('chat-form');
|
const form = document.getElementById('chat-form');
|
||||||
const promptInput = document.getElementById('prompt');
|
const promptInput = document.getElementById('prompt');
|
||||||
const chatLog = document.getElementById('chat-log');
|
const chatLog = document.getElementById('chat-log');
|
||||||
@ -6,21 +10,116 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
const spinner = document.getElementById('spinner');
|
const spinner = document.getElementById('spinner');
|
||||||
const thinkingText = document.getElementById('thinking-text');
|
const thinkingText = document.getElementById('thinking-text');
|
||||||
|
|
||||||
|
await loadHistory();
|
||||||
|
|
||||||
|
async function loadHistory() {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/v1/chats", { method: "GET" });
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
chatList.innerHTML = "";
|
||||||
|
|
||||||
|
data.forEach(chat => {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
li.textContent = chat.name;
|
||||||
|
li.setAttribute("data-chat-id", chat.id);
|
||||||
|
if (chat.id === chatId) {
|
||||||
|
li.classList.add("active");
|
||||||
|
}
|
||||||
|
|
||||||
|
li.addEventListener("click", async () => {
|
||||||
|
const selectedId = chat.id;
|
||||||
|
if (selectedId !== chatId) {
|
||||||
|
chatId = selectedId;
|
||||||
|
highlightActiveChat(li);
|
||||||
|
await loadMessages(chatId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
chatList.appendChild(li);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Autoabrir primer chat si no hay chatId activo
|
||||||
|
if (!chatId && data.length > 0) {
|
||||||
|
chatId = data[0].id;
|
||||||
|
const firstLi = chatList.querySelector('li');
|
||||||
|
if (firstLi) {
|
||||||
|
firstLi.classList.add("active");
|
||||||
|
await loadMessages(chatId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error cargando historial:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMessages(chatId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/chats/${chatId}`);
|
||||||
|
const messages = await response.json();
|
||||||
|
|
||||||
|
chatLog.innerHTML = "";
|
||||||
|
|
||||||
|
messages.forEach(msg => {
|
||||||
|
appendMessage(msg.role, msg.text, msg.date);
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
appendMessage("bot", "❌ Error cargando mensajes del chat.");
|
||||||
|
console.error("Error al cargar mensajes:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightActiveChat(selectedLi) {
|
||||||
|
const lis = chatList.querySelectorAll("li");
|
||||||
|
lis.forEach(li => li.classList.remove("active"));
|
||||||
|
selectedLi.classList.add("active");
|
||||||
|
}
|
||||||
|
|
||||||
function appendMessage(role, text, date) {
|
function appendMessage(role, text, date) {
|
||||||
const bubble = document.createElement('div');
|
const bubble = document.createElement('div');
|
||||||
bubble.className = `bubble ${role}`;
|
bubble.className = `bubble ${role}`;
|
||||||
bubble.textContent = text;
|
bubble.textContent = text;
|
||||||
chatLog.appendChild(bubble);
|
chatLog.appendChild(bubble);
|
||||||
|
|
||||||
const timestamp = document.createElement('em');
|
const timestamp = document.createElement('em');
|
||||||
timestamp.className = 'timestamp';
|
timestamp.className = 'timestamp';
|
||||||
timestamp.textContent = date;
|
timestamp.textContent = date;
|
||||||
bubble.appendChild(timestamp);
|
bubble.appendChild(timestamp);
|
||||||
|
|
||||||
chatLog.scrollTop = chatLog.scrollHeight;
|
chatLog.scrollTop = chatLog.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createNewChat() {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/v1/chats", {
|
||||||
|
method: "POST"
|
||||||
|
});
|
||||||
|
chatId = await response.text();
|
||||||
|
|
||||||
|
await loadHistory();
|
||||||
|
await loadMessages(chatId);
|
||||||
|
|
||||||
|
const li = chatList.querySelector(`li[data-chat-id="${chatId}"]`);
|
||||||
|
if (li) highlightActiveChat(li);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error al crear nuevo chat:", error);
|
||||||
|
appendMessage("bot", "❌ Error creando nuevo chat.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newChatBtn.addEventListener("click", async () => {
|
||||||
|
await createNewChat()
|
||||||
|
});
|
||||||
|
|
||||||
form.addEventListener('submit', async function (e) {
|
form.addEventListener('submit', async function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (chatId === undefined || chatId === null || chatId === '') {
|
||||||
|
await createNewChat();
|
||||||
|
}
|
||||||
|
|
||||||
const prompt = promptInput.value.trim();
|
const prompt = promptInput.value.trim();
|
||||||
if (!prompt) return;
|
if (!prompt) return;
|
||||||
|
|
||||||
@ -31,8 +130,8 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
thinkingText.style.display = 'block';
|
thinkingText.style.display = 'block';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/chat", {
|
const response = await fetch(`/api/v1/chats/${chatId}`, {
|
||||||
method: "POST",
|
method: "PUT",
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded'
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
},
|
},
|
||||||
@ -40,7 +139,12 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
chatId = data.chatId;
|
||||||
|
|
||||||
appendMessage("bot", data.text, data.date);
|
appendMessage("bot", data.text, data.date);
|
||||||
|
|
||||||
|
await loadHistory();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
appendMessage("bot", "❌ Error procesando la respuesta.");
|
appendMessage("bot", "❌ Error procesando la respuesta.");
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@ -53,7 +157,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
|
|
||||||
function formatDate(date) {
|
function formatDate(date) {
|
||||||
const day = date.getDate().toString().padStart(2, '0');
|
const day = date.getDate().toString().padStart(2, '0');
|
||||||
const month = (date.getMonth() + 1).toString().padStart(2, '0'); // Enero = 0
|
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||||
const year = date.getFullYear();
|
const year = date.getFullYear();
|
||||||
const hours = date.getHours().toString().padStart(2, '0');
|
const hours = date.getHours().toString().padStart(2, '0');
|
||||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||||
|
@ -6,6 +6,23 @@
|
|||||||
<link rel="stylesheet" th:href="@{/css/styles.css}" />
|
<link rel="stylesheet" th:href="@{/css/styles.css}" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div class="layout-container">
|
||||||
|
|
||||||
|
<!-- Menú lateral izquierdo -->
|
||||||
|
<aside id="sidebar">
|
||||||
|
<h2>🗂️ Chats</h2>
|
||||||
|
<button id="new-chat-btn">➕ Nuevo chat</button>
|
||||||
|
<ul id="chat-list">
|
||||||
|
<li th:each="chat : ${chats}"
|
||||||
|
th:text="${chat.name}"
|
||||||
|
th:attr="data-chat-id=${chat.id}"
|
||||||
|
th:data-chat-id="${chat.id}">
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Área principal del chat -->
|
||||||
|
<main id="main-content">
|
||||||
<h1>🤖 Chat IA Offline</h1>
|
<h1>🤖 Chat IA Offline</h1>
|
||||||
|
|
||||||
<div id="chat-log">
|
<div id="chat-log">
|
||||||
@ -23,6 +40,9 @@
|
|||||||
<textarea aria-label="Pregunta del usuario" id="prompt" name="prompt" placeholder="Escribe tu mensaje..." autocomplete="off" required></textarea>
|
<textarea aria-label="Pregunta del usuario" id="prompt" name="prompt" placeholder="Escribe tu mensaje..." autocomplete="off" required></textarea>
|
||||||
<button id="send-btn" type="submit">Enviar</button>
|
<button id="send-btn" type="submit">Enviar</button>
|
||||||
</form>
|
</form>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
<script th:src="@{/js/main.js}"></script>
|
<script th:src="@{/js/main.js}"></script>
|
||||||
</body>
|
</body>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user