7.29 redis存储历史聊天记录,替代本地浏览器缓存

This commit is contained in:
zhangsan 2025-07-29 15:46:53 +08:00
parent 37b696e61b
commit 8914ef7393
17 changed files with 1406 additions and 727 deletions

View File

@ -0,0 +1,145 @@
package edu.whut.trigger.http;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.*;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.Duration;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.StreamSupport;
@Slf4j
@RestController
@CrossOrigin(origins = "*")
@RequestMapping("/api/v1/chat")
@RequiredArgsConstructor
public class ChatController {
private static final String CHAT_PREFIX = "chat:"; // chat:{chatId}
private static final String META_PREFIX = "chat_meta:"; // chat_meta:{chatId}
private static final Duration TTL = Duration.ofDays(30);
private final RedissonClient redisson;
private final ObjectMapper mapper;
/** ==== 数据模型 ==== */
public record ChatMessage(String role, String content, Long ts) { }
public record ChatNameUpdate(String name) { }
/** GET /api/v1/chat → 返回所有 chatId */
@GetMapping
public Set<String> listAllChats() {
return StreamSupport.stream(
redisson.getKeys().getKeysByPattern(CHAT_PREFIX + "*").spliterator(),
false
).map(k -> k.substring(CHAT_PREFIX.length())).collect(Collectors.toSet());
}
/** GET /api/v1/chat/{chatId}/name → 获取聊天标题 */
@GetMapping("{chatId}/name")
public String getChatName(@PathVariable String chatId) {
RBucket<String> bucket = redisson.getBucket(META_PREFIX + chatId);
return bucket.get() != null ?
bucket.get() :
"聊天 " + new Date(Long.parseLong(chatId)).toLocaleString();
}
/** POST /api/v1/chat/{chatId}/rename → 重命名聊天 */
@PostMapping("{chatId}/rename")
public ResponseEntity<Void> renameChat(
@PathVariable String chatId,
@RequestBody ChatNameUpdate request) {
RBucket<String> bucket = redisson.getBucket(META_PREFIX + chatId);
bucket.set(request.name());
bucket.expire(TTL);
log.info("重命名会话 {} 为 {}", chatId, request.name());
return ResponseEntity.ok().build();
}
/** POST /api/v1/chat/{chatId}/message → 追加消息 */
@PostMapping("{chatId}/message")
public ResponseEntity<Void> addMessage(
@PathVariable String chatId,
@RequestBody ChatMessage msg) throws Exception {
String key = CHAT_PREFIX + chatId;
RList<String> list = redisson.getList(key);
list.add(mapper.writeValueAsString(msg));
if (list.remainTimeToLive() == -1) {
list.expire(TTL);
}
log.debug("追加消息到会话 {},当前长度 {}", chatId, list.size());
return ResponseEntity.ok().build();
}
/** GET /api/v1/chat/{chatId} → 获取聊天历史 */
@GetMapping("{chatId}")
public List<ChatMessage> getHistory(@PathVariable String chatId) throws Exception {
RList<String> list = redisson.getList(CHAT_PREFIX + chatId);
if (!list.isExists()) return Collections.emptyList();
return list.readAll().stream()
.map(json -> {
try {
return mapper.readValue(json, ChatMessage.class);
} catch (Exception e) {
log.warn("反序列化消息失败,已跳过:{}", json, e);
return null;
}
})
.filter(Objects::nonNull)
.sorted(Comparator.comparingLong(ChatMessage::ts)) // 按时间戳排序
.collect(Collectors.toList());
}
/** DELETE /api/v1/chat/{chatId} → 删除聊天 */
@DeleteMapping("{chatId}")
public ResponseEntity<Void> deleteChat(@PathVariable String chatId) {
// 删除消息记录
redisson.getKeys().delete(CHAT_PREFIX + chatId);
// 删除元数据
redisson.getKeys().delete(META_PREFIX + chatId);
log.info("已删除会话 {}", chatId);
return ResponseEntity.noContent().build();
}
/** POST /api/v1/chat/migrate → 迁移本地数据(可选) */
@PostMapping("/migrate")
public ResponseEntity<Void> migrateLocalData(
@RequestBody List<LocalChatData> localData) throws Exception {
for (LocalChatData data : localData) {
// 保存元数据
RBucket<String> metaBucket = redisson.getBucket(META_PREFIX + data.chatId());
metaBucket.set(data.name());
metaBucket.expire(TTL);
// 保存消息
RList<String> list = redisson.getList(CHAT_PREFIX + data.chatId());
for (LocalMessage msg : data.messages()) {
ChatMessage chatMsg = new ChatMessage(
msg.isAssistant() ? "assistant" : "user",
msg.content(),
msg.timestamp()
);
list.add(mapper.writeValueAsString(chatMsg));
}
list.expire(TTL);
}
return ResponseEntity.ok().build();
}
/** ==== 迁移数据模型 ==== */
public record LocalChatData(String chatId, String name, List<LocalMessage> messages) {}
public record LocalMessage(String content, boolean isAssistant, long timestamp) {}
}

View File

@ -63,21 +63,6 @@ services:
networks:
- ai-rag-knowledge-network
# pg 管理工具
pgadmin:
image: registry.cn-hangzhou.aliyuncs.com/xfg-studio/pgadmin4:9.1.0
container_name: vector_db_admin
restart: unless-stopped
ports:
- "5050:80"
environment:
PGADMIN_DEFAULT_EMAIL: admin@qq.com
PGADMIN_DEFAULT_PASSWORD: admin
depends_on:
- vector_db
networks:
- ai-rag-knowledge-network
networks:
ai-rag-knowledge-network:
driver: bridge

View File

@ -0,0 +1,41 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
# (可选)如果你有上游定义,也写在这里
upstream ai_rag_backend {
server ai-rag-knowledge-app:8095;
}
# 你的 server
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://ai-rag-knowledge-app:8095;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location / {
try_files $uri /index.html;
}
}
}

View File

@ -0,0 +1,10 @@
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!
Theme: GitHub
Description: Light theme as seen on github.com
Author: github.com
Maintainer: @Hirse
Updated: 2021-05-15
Outdated base version: https://github.com/primer/github-syntax-light
Current colors taken from GitHub's CSS
*/.hljs{color:#24292e;background:#fff}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#d73a49}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#6f42c1}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#005cc5}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#032f62}.hljs-built_in,.hljs-symbol{color:#e36209}.hljs-code,.hljs-comment,.hljs-formula{color:#6a737d}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#22863a}.hljs-subst{color:#24292e}.hljs-section{color:#005cc5;font-weight:700}.hljs-bullet{color:#735c0f}.hljs-emphasis{color:#24292e;font-style:italic}.hljs-strong{color:#24292e;font-weight:700}.hljs-addition{color:#22863a;background-color:#f0fff4}.hljs-deletion{color:#b31d28;background-color:#ffeef0}

View File

@ -0,0 +1,52 @@
/* 旋转动画loading 图标) */
.loader {
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg) }
100% { transform: rotate(360deg) }
}
/* 拖拽高亮:与 Tailwind 共存,覆盖边框与背景 */
.drag-zone.drag-active {
border-color: #2563eb; /* blue-600 */
background-color: #eff6ff; /* blue-50 */
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
}
/* 文件列表样式微调 */
#fileList li {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.75rem;
padding: 0.75rem 1rem;
align-items: center;
}
#fileList .file-name {
font-size: 0.95rem;
color: #111827; /* gray-900 */
word-break: break-all;
}
#fileList .file-meta {
font-size: 0.78rem;
color: #6b7280; /* gray-500 */
}
#fileList .remove-btn {
color: #ef4444; /* red-500 */
}
#fileList .remove-btn:hover {
color: #b91c1c; /* red-700 */
}
/* 隐藏但保留可访问性的类配合HTML中的 sr-only*/
.sr-only {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0,0,0,0) !important;
white-space: nowrap !important;
border: 0 !important;
}

View File

@ -5,10 +5,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AiRagKnowledge - By Smile</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify/dist/purify.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/highlight.js/highlight.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js/styles/github.min.css">
<script src="js/marked.min.js"></script>
<script src="js/purify.min.js"></script>
<script src="js/highlight.min.js"></script>
<link rel="stylesheet" href="css/github.min.css">
<link rel="stylesheet" href="css/index.css">
</head>
<body class="h-screen flex flex-col bg-gray-50">
@ -33,9 +33,28 @@
<option value="openai" model="gpt-4o">gpt-4o</option>
</select>
<select id="ragSelect" class="px-3 py-2 border rounded-lg flex-1 max-w-xs">
<div class="flex items-center gap-2">
<select id="ragSelect" class="px-3 py-2 border rounded-lg max-w-xs">
<option value="">选择一个知识库</option>
</select>
<!-- 手动刷新 RAG 列表 -->
<button id="refreshRagBtn"
class="px-2 py-2 rounded-lg border hover:bg-gray-100"
title="刷新知识库列表"
aria-label="刷新知识库列表">
</button>
<!-- 删除当前选中的 RAG不可逆 -->
<button id="deleteRagBtn"
class="px-3 py-2 rounded-lg border text-red-600 hover:bg-red-50 disabled:opacity-50"
title="删除所选知识库(不可恢复)"
aria-label="删除所选知识库"
disabled>
🗑 删除
</button>
</div>
</div>
<div class="flex items-center gap-2">

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,3 +1,4 @@
// ===== index.js (Redis 存储版) =====
const chatArea = document.getElementById('chatArea');
const messageInput = document.getElementById('messageInput');
const submitBtn = document.getElementById('submitBtn');
@ -6,182 +7,212 @@ const chatList = document.getElementById('chatList');
const welcomeMessage = document.getElementById('welcomeMessage');
const toggleSidebarBtn = document.getElementById('toggleSidebar');
const sidebar = document.getElementById('sidebar');
let currentEventSource = null;
let currentChatId = null;
// 获取知识库列表
document.addEventListener('DOMContentLoaded', function() {
// 获取知识库列表
const loadRagOptions = () => {
// RAG 相关 DOM
const ragSelect = document.getElementById('ragSelect');
const deleteRagBtn = document.getElementById('deleteRagBtn');
const refreshRagBtn = document.getElementById('refreshRagBtn');
fetch('http://localhost:8095/api/v1/rag/query_rag_tag_list')
.then(response => response.json())
.then(data => {
if (data.code === '0000' && data.data) {
// 清空现有选项(保留第一个默认选项)
while (ragSelect.options.length > 1) {
ragSelect.remove(1);
let currentEventSource = null;
let currentChatId = localStorage.getItem('currentChatId') || null;
// ==================== Redis 存储相关函数 ====================
/**
* 获取所有聊天会话及其标题
*/
async function getAllChats() {
try {
const response = await fetch('http://localhost:8095/api/v1/chat');
const chatIds = await response.json();
return Promise.all(chatIds.map(async chatId => {
const nameResponse = await fetch(`http://localhost:8095/api/v1/chat/${chatId}/name`);
const name = await nameResponse.text();
return { chatId, name };
}));
} catch (error) {
console.error('获取聊天列表失败:', error);
return [];
}
}
// 添加新选项
data.data.forEach(tag => {
const option = new Option(`Rag${tag}`, tag);
ragSelect.add(option);
});
}
})
.catch(error => {
console.error('获取知识库列表失败:', error);
});
};
// 初始化加载
loadRagOptions();
});
function createNewChat() {
/**
* 创建新聊天会话
*/
async function createNewChat() {
const chatId = Date.now().toString();
try {
// 设置默认名称
await fetch(`http://localhost:8095/api/v1/chat/${chatId}/rename`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: '新聊天' })
});
currentChatId = chatId;
localStorage.setItem('currentChatId', chatId);
// 修改数据结构为包含name和messages的对象
localStorage.setItem(`chat_${chatId}`, JSON.stringify({
name: '新聊天',
messages: []
}));
updateChatList();
clearChatArea();
}
function deleteChat(chatId) {
if (confirm('确定要删除这个聊天记录吗?')) {
localStorage.removeItem(`chat_${chatId}`); // Remove the chat from localStorage
if (currentChatId === chatId) { // If the current chat is being deleted
createNewChat(); // Create a new chat
}
updateChatList(); // Update the chat list to reflect changes
return chatId;
} catch (error) {
console.error('创建聊天失败:', error);
throw error;
}
}
function updateChatList() {
chatList.innerHTML = '';
const chats = Object.keys(localStorage)
.filter(key => key.startsWith('chat_'));
/**
* 删除聊天会话
*/
async function deleteChat(chatId) {
if (!confirm('确定要删除这个聊天记录吗?')) return;
const currentChatIndex = chats.findIndex(key => key.split('_')[1] === currentChatId);
if (currentChatIndex!== -1) {
const currentChat = chats[currentChatIndex];
chats.splice(currentChatIndex, 1);
chats.unshift(currentChat);
try {
const response = await fetch(`http://localhost:8095/api/v1/chat/${chatId}`, {
method: 'DELETE'
});
if (response.ok) {
if (currentChatId === chatId) {
await createNewChat();
}
updateChatList();
} else {
throw new Error(`HTTP ${response.status}`);
}
} catch (error) {
console.error('删除聊天失败:', error);
alert('删除失败: ' + error.message);
}
}
chats.forEach(chatKey => {
let chatData = JSON.parse(localStorage.getItem(chatKey));
const chatId = chatKey.split('_')[1];
/**
* 重命名聊天会话
*/
async function renameChat(chatId) {
const currentName = await (await fetch(`http://localhost:8095/api/v1/chat/${chatId}/name`)).text();
const newName = prompt('请输入新的聊天名称', currentName);
// 数据迁移:将旧数组格式转换为新对象格式
if (Array.isArray(chatData)) {
chatData = {
name: `聊天 ${new Date(parseInt(chatId)).toLocaleDateString()}`,
messages: chatData
if (newName) {
try {
await fetch(`http://localhost:8095/api/v1/chat/${chatId}/rename`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName })
});
updateChatList();
} catch (error) {
console.error('重命名失败:', error);
alert('重命名失败: ' + error.message);
}
}
}
/**
* 加载聊天历史
*/
async function loadChat(chatId) {
try {
const response = await fetch(`http://localhost:8095/chat/${chatId}`);
const messages = await response.json();
currentChatId = chatId;
localStorage.setItem('currentChatId', chatId);
clearChatArea();
messages.forEach(msg => {
appendMessage(msg.content, msg.role === 'assistant', false);
});
} catch (error) {
console.error('加载聊天失败:', error);
alert('加载聊天失败: ' + error.message);
}
}
/**
* 保存消息到 Redis
*/
async function saveMessage(chatId, content, isAssistant) {
const msg = {
role: isAssistant ? "assistant" : "user",
content: content,
ts: Date.now()
};
localStorage.setItem(chatKey, JSON.stringify(chatData));
try {
await fetch(`http://localhost:8095/api/v1/chat/${chatId}/message`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(msg)
});
} catch (error) {
console.error('保存消息失败:', error);
}
}
/**
* 更新聊天列表 UI
*/
async function updateChatList() {
chatList.innerHTML = '';
const chats = await getAllChats();
// 当前聊天置顶
chats.sort((a, b) => {
if (a.chatId === currentChatId) return -1;
if (b.chatId === currentChatId) return 1;
return parseInt(b.chatId) - parseInt(a.chatId); // 按时间倒序
});
chats.forEach(chat => {
const li = document.createElement('li');
li.className = `chat-item flex items-center justify-between p-2 hover:bg-gray-100 rounded-lg cursor-pointer transition-colors ${chatId === currentChatId? 'bg-blue-50' : ''}`;
li.className = `chat-item flex items-center justify-between p-2 hover:bg-gray-100 rounded-lg cursor-pointer transition-colors ${
chat.chatId === currentChatId ? 'bg-blue-50' : ''
}`;
const date = new Date(parseInt(chat.chatId)).toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
li.innerHTML = `
<div class="flex-1">
<div class="text-sm font-medium">${chatData.name}</div>
<div class="text-xs text-gray-400">${new Date(parseInt(chatId)).toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' })}</div>
<div class="text-sm font-medium">${chat.name}</div>
<div class="text-xs text-gray-400">${date}</div>
</div>
<div class="chat-actions flex items-center gap-1 opacity-0 transition-opacity duration-200">
<button class="p-1 hover:bg-gray-200 rounded text-gray-500" onclick="renameChat('${chatId}')">重命名</button>
<button class="p-1 hover:bg-red-200 rounded text-red-500" onclick="deleteChat('${chatId}')">删除</button>
<button class="p-1 hover:bg-gray-200 rounded text-gray-500">重命名</button>
<button class="p-1 hover:bg-red-200 rounded text-red-500">删除</button>
</div>
`;
li.addEventListener('click', (e) => {
if (!e.target.closest('.chat-actions')) {
loadChat(chatId);
}
// 事件绑定
li.querySelectorAll('button')[0].addEventListener('click', (e) => {
e.stopPropagation();
renameChat(chat.chatId);
});
li.querySelectorAll('button')[1].addEventListener('click', (e) => {
e.stopPropagation();
deleteChat(chat.chatId);
});
li.addEventListener('click', () => loadChat(chat.chatId));
li.addEventListener('mouseenter', () => {
li.querySelector('.chat-actions').classList.remove('opacity-0');
});
li.addEventListener('mouseleave', () => {
li.querySelector('.chat-actions').classList.add('opacity-0');
});
chatList.appendChild(li);
});
}
let currentContextMenu = null;
// 优化后的上下文菜单
function showChatContextMenu(event, chatId) {
event.stopPropagation();
closeContextMenu();
const buttonRect = event.target.closest('button').getBoundingClientRect();
const menu = document.createElement('div');
menu.className = 'context-menu';
menu.style.position = 'fixed';
menu.style.left = `${buttonRect.left}px`;
menu.style.top = `${buttonRect.bottom + 4}px`;
menu.innerHTML = `
<div class="context-menu-item" onclick="renameChat('${chatId}')">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
重命名
</div>
<div class="context-menu-item text-red-500" onclick="deleteChat('${chatId}')">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
删除
</div>
`;
document.body.appendChild(menu);
currentContextMenu = menu;
// 点击外部关闭菜单
setTimeout(() => {
document.addEventListener('click', closeContextMenu, { once: true });
});
}
function closeContextMenu() {
if (currentContextMenu) {
currentContextMenu.remove();
currentContextMenu = null;
}
}
function renameChat(chatId) {
const chatKey = `chat_${chatId}`;
const chatData = JSON.parse(localStorage.getItem(chatKey));
const currentName = chatData.name || `聊天 ${new Date(parseInt(chatId)).toLocaleString()}`;
const newName = prompt('请输入新的聊天名称', currentName);
if (newName) {
chatData.name = newName;
localStorage.setItem(chatKey, JSON.stringify(chatData));
updateChatList();
}
}
function loadChat(chatId) {
currentChatId = chatId;
localStorage.setItem('currentChatId', chatId);
clearChatArea();
const chatData = JSON.parse(localStorage.getItem(`chat_${chatId}`) || { messages: [] });
chatData.messages.forEach(msg => {
appendMessage(msg.content, msg.isAssistant, false);
});
updateChatList()
}
// ==================== 消息显示相关函数 ====================
function clearChatArea() {
chatArea.innerHTML = '';
@ -191,19 +222,20 @@ function clearChatArea() {
function appendMessage(content, isAssistant = false, saveToStorage = true) {
welcomeMessage.style.display = 'none';
const messageDiv = document.createElement('div');
messageDiv.className = `max-w-4xl mx-auto mb-4 p-4 rounded-lg ${isAssistant ? 'bg-gray-100' : 'bg-white border'} markdown-body relative`;
messageDiv.className = `max-w-4xl mx-auto mb-4 p-4 rounded-lg ${
isAssistant ? 'bg-gray-100' : 'bg-white border'
} markdown-body relative`;
const renderedContent = DOMPurify.sanitize(marked.parse(content));
messageDiv.innerHTML = renderedContent;
// 添加复制按钮
const copyBtn = document.createElement('button');
copyBtn.className = 'absolute top-2 right-2 p-1 bg-gray-200 rounded-md text-xs';
copyBtn.textContent = '复制';
copyBtn.onclick = () => {
navigator.clipboard.writeText(content).then(() => {
copyBtn.textContent = '已复制';
setTimeout(() => copyBtn.textContent = '复制', 2000);
setTimeout(() => (copyBtn.textContent = '复制'), 2000);
});
};
messageDiv.appendChild(copyBtn);
@ -211,34 +243,29 @@ function appendMessage(content, isAssistant = false, saveToStorage = true) {
chatArea.appendChild(messageDiv);
chatArea.scrollTop = chatArea.scrollHeight;
// 仅在需要时保存到本地存储
if (saveToStorage && currentChatId) {
// 确保读取和保存完整的数据结构
const chatData = JSON.parse(localStorage.getItem(`chat_${currentChatId}`) || '{"name": "新聊天", "messages": []}');
chatData.messages.push({ content, isAssistant });
localStorage.setItem(`chat_${currentChatId}`, JSON.stringify(chatData));
saveMessage(currentChatId, content, isAssistant);
}
}
// ==================== 事件流处理 ====================
function startEventStream(message) {
if (currentEventSource) {
currentEventSource.close();
}
if (currentEventSource) currentEventSource.close();
// 选项值,
// 组装01http://localhost:8095/api/v1/ollama/generate_stream?message=Hello&model=deepseek-r1:1.5b
// 组装02http://localhost:8095/api/v1/openai/generate_stream?message=Hello&model=gpt-4o
const ragTag = document.getElementById('ragSelect').value;
const ragTag = ragSelect.value;
const aiModelSelect = document.getElementById('aiModel');
const aiModelValue = aiModelSelect.value; // 获取选中的 aiModel 的 value
const aiModelModel = aiModelSelect.options[aiModelSelect.selectedIndex].getAttribute('model'); // 获取选中的 aiModel 的 model 属性
const aiModelValue = aiModelSelect.value;
const aiModelModel = aiModelSelect.options[aiModelSelect.selectedIndex].getAttribute('model');
let url;
const base = `http://localhost:8095/api/v1/${aiModelValue}`;
const params = new URLSearchParams({ message, model: aiModelModel });
if (ragTag) {
url = `http://localhost:8095/api/v1/${aiModelValue}/generate_stream_rag?message=${encodeURIComponent(message)}&ragTag=${encodeURIComponent(ragTag)}&model=${encodeURIComponent(aiModelModel)}`;
params.append('ragTag', ragTag);
url = `${base}/generate_stream_rag?${params.toString()}`;
} else {
url = `http://localhost:8095/api/v1/${aiModelValue}/generate_stream?message=${encodeURIComponent(message)}&model=${encodeURIComponent(aiModelModel)}`;
url = `${base}/generate_stream?${params.toString()}`;
}
currentEventSource = new EventSource(url);
@ -248,12 +275,11 @@ function appendMessage(content, isAssistant = false, saveToStorage = true) {
currentEventSource.onmessage = function(event) {
try {
const data = JSON.parse(event.data);
const content = data.result?.output?.content || data.content;
if (data.result?.output?.content) {
const newContent = data.result.output.content;
accumulatedContent += newContent;
if (content) {
accumulatedContent += content;
// 首次创建临时消息容器
if (!tempMessageDiv) {
tempMessageDiv = document.createElement('div');
tempMessageDiv.className = 'max-w-4xl mx-auto mb-4 p-4 rounded-lg bg-gray-100 markdown-body relative';
@ -261,7 +287,6 @@ function appendMessage(content, isAssistant = false, saveToStorage = true) {
welcomeMessage.style.display = 'none';
}
// 直接更新文本内容先不解析Markdown
tempMessageDiv.textContent = accumulatedContent;
chatArea.scrollTop = chatArea.scrollHeight;
}
@ -269,47 +294,44 @@ function appendMessage(content, isAssistant = false, saveToStorage = true) {
if (data.result?.output?.properties?.finishReason === 'STOP') {
currentEventSource.close();
// 流式传输完成后进行最终渲染
const finalContent = accumulatedContent;
tempMessageDiv.innerHTML = DOMPurify.sanitize(marked.parse(finalContent));
if (tempMessageDiv) {
tempMessageDiv.innerHTML = DOMPurify.sanitize(marked.parse(accumulatedContent));
// 添加复制按钮
const copyBtn = document.createElement('button');
copyBtn.className = 'absolute top-2 right-2 p-1 bg-gray-200 rounded-md text-xs';
copyBtn.textContent = '复制';
copyBtn.onclick = () => {
navigator.clipboard.writeText(finalContent).then(() => {
navigator.clipboard.writeText(accumulatedContent).then(() => {
copyBtn.textContent = '已复制';
setTimeout(() => copyBtn.textContent = '复制', 2000);
setTimeout(() => (copyBtn.textContent = '复制'), 2000);
});
};
tempMessageDiv.appendChild(copyBtn);
}
// 保存到本地存储
if (currentChatId) {
// 正确的数据结构应该是对象包含messages数组
const chatData = JSON.parse(localStorage.getItem(`chat_${currentChatId}`) || '{"name": "新聊天", "messages": []}');
chatData.messages.push({ content: finalContent, isAssistant: true });
localStorage.setItem(`chat_${currentChatId}`, JSON.stringify(chatData));
saveMessage(currentChatId, accumulatedContent, true);
}
}
} catch (e) {
console.error('Error parsing event data:', e);
console.error('解析事件数据失败:', e);
}
};
currentEventSource.onerror = function(error) {
console.error('EventSource error:', error);
console.error('EventSource 错误:', error);
currentEventSource.close();
};
}
submitBtn.addEventListener('click', () => {
// ==================== 事件绑定 ====================
submitBtn.addEventListener('click', async () => {
const message = messageInput.value.trim();
if (!message) return;
if (!currentChatId) {
createNewChat();
await createNewChat();
}
appendMessage(message, false);
@ -340,14 +362,17 @@ function updateSidebarIcon() {
}
}
// Initialize
// ==================== 初始化 ====================
// 初始化聊天列表
updateChatList();
const savedChatId = localStorage.getItem('currentChatId');
if (savedChatId) {
loadChat(savedChatId);
// 加载当前聊天
if (currentChatId) {
loadChat(currentChatId);
}
// Handle window resize for responsive design
// 响应式处理
window.addEventListener('resize', () => {
if (window.innerWidth > 768) {
sidebar.classList.remove('-translate-x-full');
@ -356,33 +381,70 @@ window.addEventListener('resize', () => {
}
});
// Initial check for mobile devices
if (window.innerWidth <= 768) {
sidebar.classList.add('-translate-x-full');
}
updateSidebarIcon();
// 上传知识下拉菜单控制
/** -------------------------
* 上传知识下拉菜单保持你现有 HTML 结构
* - 点击按钮展开/收起菜单
* - 点击外部区域/按下 Esc 关闭
* - 点击菜单项后自动关闭
* ------------------------- */
(function initUploadMenu() {
const uploadMenuButton = document.getElementById('uploadMenuButton');
const uploadMenu = document.getElementById('uploadMenu');
if (!uploadMenuButton || !uploadMenu) return;
// 切换菜单显示
// 切换菜单显示/隐藏
const toggleMenu = () => {
uploadMenu.classList.toggle('hidden');
};
// 显示菜单
const openMenu = () => {
uploadMenu.classList.remove('hidden');
};
// 隐藏菜单
const closeMenu = () => {
uploadMenu.classList.add('hidden');
};
// 点击按钮展开/收起
uploadMenuButton.addEventListener('click', (e) => {
e.stopPropagation();
uploadMenu.classList.toggle('hidden');
toggleMenu();
});
// 点击外部区域关闭菜单
document.addEventListener('click', (e) => {
if (!uploadMenu.contains(e.target) && e.target !== uploadMenuButton) {
uploadMenu.classList.add('hidden');
// 键盘辅助Enter/Space 展开Esc 关闭
uploadMenuButton.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openMenu();
} else if (e.key === 'Escape') {
closeMenu();
}
});
// 菜单项点击后关闭菜单
document.querySelectorAll('#uploadMenu a').forEach(item => {
item.addEventListener('click', () => {
uploadMenu.classList.add('hidden');
// 点击菜单项后自动关闭
uploadMenu.querySelectorAll('a').forEach(a => {
a.addEventListener('click', () => closeMenu());
});
// 点击外部任意区域关闭
document.addEventListener('click', (e) => {
if (!uploadMenu.classList.contains('hidden')) {
if (!uploadMenu.contains(e.target) && e.target !== uploadMenuButton) {
closeMenu();
}
}
});
// Esc 关闭(全局)
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeMenu();
});
})();

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,383 @@
// ====== 配置 ======
const ENDPOINT = "http://localhost:8095/api/v1/rag/file/upload";
const ALLOWED_EXTS = [".pdf", ".csv", ".txt", ".md", ".sql", ".java"];
const MAX_SCAN_DEPTH = 20; // 防止极端深层目录卡住(可自行调整)
// ====== 工具函数 ======
const $ = (sel) => document.querySelector(sel);
function formatBytes(bytes) {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}
function extOfName(name) {
return (name.match(/\.[^.]+$/) || [""])[0].toLowerCase();
}
function isAllowedName(name) {
return ALLOWED_EXTS.includes(extOfName(name));
}
// 用于渲染与去重时的“相对路径”,优先使用浏览器提供的相对路径
function displayPath(file) {
return file.webkitRelativePath || file._relativePath || file.name;
}
// 以路径+大小+mtime 作为去重 key避免不同子目录下同名文件冲突
function uniqueKey(file) {
return `${displayPath(file)}__${file.size}__${file.lastModified}`;
}
// 将 FileList + 新增 files 合并为去重后的 DataTransfer.files
function mergeFiles(existingList, newFiles) {
const map = new Map();
Array.from(existingList || []).forEach((f) => map.set(uniqueKey(f), f));
Array.from(newFiles || []).forEach((f) => map.set(uniqueKey(f), f));
const dt = new DataTransfer();
for (const f of map.values()) dt.items.add(f);
return dt.files;
}
// ====== DOM 引用 ======
const form = $("#uploadForm");
const dropZone = $("#dropZone");
const fileInput = $("#fileInput");
const dirInput = $("#dirInput");
const chooseDirBtn = $("#chooseDirBtn");
const fileListWrap = $("#fileList");
const fileListUl = $("#fileList ul");
const clearAllBtn = $("#clearAllBtn");
const loadingOverlay = $("#loadingOverlay");
const submitBtn = $("#submitBtn");
const progressWrap = $("#progressWrap");
const progressBar = $("#progressBar");
const progressText = $("#progressText");
// ====== 阻止默认拖拽打开新窗口(关键) ======
["dragenter", "dragover", "dragleave", "drop"].forEach((eventName) => {
document.addEventListener(eventName, (e) => {
e.preventDefault();
e.stopPropagation();
});
});
// ====== 支持:点击选择文件 / 选择目录 ======
dropZone.addEventListener("click", () => fileInput.click());
dropZone.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
fileInput.click();
}
});
chooseDirBtn?.addEventListener("click", () => dirInput?.click());
// ====== 拖拽区交互状态 ======
dropZone.addEventListener("dragenter", () => dropZone.classList.add("drag-active"));
dropZone.addEventListener("dragover", () => dropZone.classList.add("drag-active"));
dropZone.addEventListener("dragleave", (e) => {
if (!dropZone.contains(e.relatedTarget)) dropZone.classList.remove("drag-active");
});
// ====== 拖拽接收(支持文件夹递归) ======
dropZone.addEventListener("drop", async (e) => {
dropZone.classList.remove("drag-active");
const items = e.dataTransfer?.items;
const plainFiles = e.dataTransfer?.files;
let collected = [];
try {
if (items && items.length) {
collected = await collectFromDataTransferItems(items);
} else if (plainFiles && plainFiles.length) {
collected = Array.from(plainFiles);
}
} catch (err) {
console.warn("解析拖拽内容失败,回退到 files", err);
collected = Array.from(plainFiles || []);
}
const { accepted, rejectedCount } = filterAllowed(collected);
if (rejectedCount > 0) {
alert(`已忽略 ${rejectedCount} 个不支持的文件类型。\n支持:${ALLOWED_EXTS.join(" ")}`);
}
if (accepted.length) {
fileInput.files = mergeFiles(fileInput.files, accepted);
renderFileList();
}
});
// ====== 目录选择webkitdirectory ======
dirInput?.addEventListener("change", () => {
const all = Array.from(dirInput.files || []); // 目录选择会包含 webkitRelativePath
const { accepted, rejectedCount } = filterAllowed(all, (f) => f.webkitRelativePath || f.name);
if (rejectedCount > 0) {
alert(`已忽略 ${rejectedCount} 个不支持的文件类型。\n支持:${ALLOWED_EXTS.join(" ")}`);
}
if (accepted.length) {
fileInput.files = mergeFiles(fileInput.files, accepted);
renderFileList();
}
// 允许连续两次选择同一文件夹
dirInput.value = "";
});
// ====== 文件选择变化(普通文件选择) ======
fileInput.addEventListener("change", renderFileList);
clearAllBtn.addEventListener("click", () => {
fileInput.value = "";
renderFileList();
});
// ====== 渲染文件列表(显示相对路径) ======
function renderFileList() {
const files = Array.from(fileInput.files || []);
fileListUl.innerHTML = "";
if (files.length === 0) {
fileListWrap.classList.add("hidden");
return;
}
fileListWrap.classList.remove("hidden");
files.forEach((file, idx) => {
const li = document.createElement("li");
const leftWrap = document.createElement("div");
const nameEl = document.createElement("div");
const metaEl = document.createElement("div");
nameEl.className = "file-name";
metaEl.className = "file-meta";
const rel = displayPath(file);
nameEl.textContent = rel;
metaEl.textContent = `${file.type || "未知类型"} · ${formatBytes(file.size)}`;
leftWrap.appendChild(nameEl);
leftWrap.appendChild(metaEl);
const removeBtn = document.createElement("button");
removeBtn.type = "button";
removeBtn.className = "remove-btn text-sm";
removeBtn.textContent = "删除";
removeBtn.addEventListener("click", () => removeFile(idx));
li.appendChild(leftWrap);
li.appendChild(removeBtn);
fileListUl.appendChild(li);
});
}
// ====== 删除单个文件 ======
function removeFile(index) {
const files = Array.from(fileInput.files);
files.splice(index, 1);
const dt = new DataTransfer();
files.forEach((f) => dt.items.add(f));
fileInput.files = dt.files;
renderFileList();
}
// ====== 过滤后缀白名单 ======
function filterAllowed(files, pathGetter) {
let rejectedCount = 0;
const accepted = [];
for (const f of files) {
const p = pathGetter ? pathGetter(f) : displayPath(f) || f.name;
if (isAllowedName(p)) {
accepted.push(f);
} else {
rejectedCount++;
}
}
return { accepted, rejectedCount };
}
// ====== 拖拽目录解析(两套方案 + 回退) ======
async function collectFromDataTransferItems(items) {
const supportsFSH =
typeof DataTransferItem !== "undefined" &&
DataTransferItem.prototype &&
"getAsFileSystemHandle" in DataTransferItem.prototype;
const collected = [];
// 方案 1File System Access APIChromium 新方案)
if (supportsFSH) {
const jobs = [];
for (const item of items) {
jobs.push(handleItemWithFSH(item, collected));
}
await Promise.all(jobs);
return collected;
}
// 方案 2webkitGetAsEntrySafari/老版 Chromium
const supportsWebkitEntry =
items[0] && typeof items[0].webkitGetAsEntry === "function";
if (supportsWebkitEntry) {
const jobs = [];
for (const item of items) {
const entry = item.webkitGetAsEntry && item.webkitGetAsEntry();
if (!entry) continue;
jobs.push(walkWebkitEntry(entry, collected, "", 0));
}
await Promise.all(jobs);
return collected;
}
// 回退:只能拿到平铺的 files无法识别目录
for (const item of items) {
const f = item.getAsFile && item.getAsFile();
if (f) collected.push(f);
}
return collected;
}
// ------ 方案 1FS Access API ------
async function handleItemWithFSH(item, out) {
const handle = await item.getAsFileSystemHandle();
if (!handle) return;
if (handle.kind === "file") {
const file = await handle.getFile();
// 无法直接获取相对路径,使用文件名作为相对路径
file._relativePath = file.name;
out.push(file);
} else if (handle.kind === "directory") {
await walkDirectoryHandle(handle, out, `${handle.name}/`, 0);
}
}
async function walkDirectoryHandle(dirHandle, out, prefix = "", depth = 0) {
if (depth > MAX_SCAN_DEPTH) return;
for await (const [name, handle] of dirHandle.entries()) {
if (handle.kind === "file") {
const file = await handle.getFile();
file._relativePath = `${prefix}${name}`;
out.push(file);
} else if (handle.kind === "directory") {
await walkDirectoryHandle(handle, out, `${prefix}${name}/`, depth + 1);
}
}
}
// ------ 方案 2webkitGetAsEntry ------
function readEntriesAsync(dirReader) {
return new Promise((resolve, reject) => {
dirReader.readEntries(resolve, reject);
});
}
async function walkWebkitEntry(entry, out, prefix = "", depth = 0) {
if (depth > MAX_SCAN_DEPTH) return;
if (entry.isFile) {
await new Promise((resolve) => {
entry.file((file) => {
file._relativePath = `${prefix}${entry.name}`;
resolve(out.push(file));
}, () => resolve());
});
} else if (entry.isDirectory) {
const dirReader = entry.createReader();
let entries = [];
do {
entries = await readEntriesAsync(dirReader);
for (const ent of entries) {
await walkWebkitEntry(ent, out, `${prefix}${entry.name}/`, depth + 1);
}
} while (entries.length > 0);
}
}
// ====== 表单提交 ======
form.addEventListener("submit", async (e) => {
e.preventDefault();
const title = $("#title").value.trim();
const files = Array.from(fileInput.files || []);
if (!title) {
alert("请先填写知识库名称");
return;
}
if (files.length === 0) {
alert("请先选择至少一个文件或文件夹");
return;
}
const fd = new FormData();
fd.append("ragTag", title);
// 将文件按顺序追加
// 可选:若你想把“相对路径”也传给后端,请同时 append 一个 filePath顺序与 file 对应)
files.forEach((f) => {
fd.append("file", f);
fd.append("filePath", displayPath(f)); // 需要后端额外接收
});
// UI 状态
loadingOverlay.classList.remove("hidden");
progressWrap.classList.remove("hidden");
submitBtn.disabled = true;
try {
const res = await axios.post(ENDPOINT, fd, {
onUploadProgress: (evt) => {
if (!evt.total) return;
const pct = Math.round((evt.loaded / evt.total) * 100);
progressBar.style.width = `${pct}%`;
progressText.textContent = `${pct}%`;
},
});
if (res?.data?.code === "0000") {
// === 通知 index.html 刷新 Rag 列表(两种方式都发,保证健壮性) ===
const ragTagJustCreated = title; // 你提交的 ragTag 标题
try {
// 1) postMessage同源且 opener 存在时生效
if (window.opener && !window.opener.closed) {
window.opener.postMessage(
{ type: "RAG_LIST_REFRESH", ragTag: ragTagJustCreated },
window.location.origin
);
}
} catch (_) {}
try {
// 2) BroadcastChannel同源标签页广播
if ("BroadcastChannel" in window) {
const bc = new BroadcastChannel("rag-updates");
bc.postMessage({ type: "rag:updated", ragTag: ragTagJustCreated });
bc.close();
}
} catch (_) {}
alert("上传成功,窗口即将关闭");
setTimeout(() => window.close(), 500);
} else {
throw new Error(res?.data?.info || "上传失败");
}
} catch (err) {
alert(err?.message || "上传过程中出现错误");
} finally {
loadingOverlay.classList.add("hidden");
submitBtn.disabled = false;
progressWrap.classList.add("hidden");
progressBar.style.width = "0%";
progressText.textContent = "0%";
renderFileList();
}
});

View File

@ -1,30 +1,149 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>文件上传</title>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<style>
/* 加载动画 */
.loader {
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
</head>
<body class="flex justify-center items-center min-h-screen bg-gray-100">
<!-- 上传文件模态框 -->
<div class="bg-white p-6 rounded-lg shadow-lg w-96 relative">
<!-- Tailwind CDN可保留也可改为自己构建的CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- 自定义样式 -->
<link rel="stylesheet" href="css/upload.css" />
<!-- AxiosCDN 版本;也可改回你本地的 js/axios.min.js -->
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js" defer></script>
<!-- 交互逻辑 -->
<script src="js/upload.js" defer></script>
</head>
<body class="min-h-screen min-h-[100dvh] bg-gradient-to-br from-gray-50 to-gray-100 grid place-items-center">
<div class="container mx-auto px-4 py-10">
<div class="mx-auto max-w-lg relative">
<!-- 卡片 -->
<div class="bg-white/90 backdrop-blur p-6 rounded-2xl shadow-xl ring-1 ring-gray-100">
<h2 class="text-2xl font-semibold text-center mb-2">添加知识</h2>
<p class="text-center text-gray-500 mb-6">支持拖拽或点击上传多个文件</p>
<!-- 表单 -->
<form id="uploadForm" enctype="multipart/form-data" class="space-y-5">
<!-- 标题 -->
<div>
<label for="title" class="block text-sm font-medium text-gray-700"
>知识库名称</label>
<input
type="text"
id="title"
name="title"
class="mt-1 block w-full rounded-lg border-gray-300 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="输入知识库名称"
required
aria-describedby="titleHelp"
/>
</div>
<!-- 拖拽/点击区域 -->
<div>
<label class="block text-sm font-medium text-gray-700">上传文件</label>
<div
id="dropZone"
class="mt-2 drag-zone group border-2 border-dashed border-gray-300 rounded-xl p-6 text-center text-gray-500 transition-all"
role="button"
tabindex="0"
aria-label="将文件或文件夹拖拽到此处,或点击选择文件"
>
<!-- 隐藏但可访问的原生文件输入(选择文件) -->
<input
type="file"
id="fileInput"
class="sr-only"
accept=".pdf,.csv,.txt,.md,.sql,.java"
multiple
/>
<div class="flex flex-col items-center justify-center gap-2 pointer-events-none">
<svg
class="h-10 w-10 opacity-80"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M12 16V4m0 0l-4 4m4-4l4 4M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<div class="text-base">
<strong>文件或文件夹</strong>拖到此处 <span class="text-gray-400"></span>
<span class="text-blue-600 underline decoration-dotted">点击选择文件</span>
</div>
<div class="text-xs text-gray-400">
支持文件后缀:.pdf .csv .txt .md .sql .java可多选
</div>
</div>
</div>
<!-- 选择文件夹按钮 + 隐藏目录输入 -->
<div class="mt-2 text-center">
<button type="button" id="chooseDirBtn" class="text-sm text-blue-600 hover:text-blue-700 underline">
或选择一个文件夹上传
</button>
<!-- 选择目录Chrome/Edge/Safari 支持Firefox 暂不支持) -->
<input type="file" id="dirInput" class="sr-only" webkitdirectory directory multiple />
</div>
</div>
<!-- 文件列表 -->
<div id="fileList" class="hidden">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-gray-700">待上传文件</span>
<button
type="button"
id="clearAllBtn"
class="text-sm text-gray-500 hover:text-red-600"
>
清空
</button>
</div>
<ul class="list-none divide-y divide-gray-100 rounded-lg border border-gray-100" aria-live="polite"></ul>
</div>
<!-- 进度条 -->
<div id="progressWrap" class="hidden">
<div class="flex justify-between text-xs text-gray-500 mb-1">
<span>上传进度</span><span id="progressText">0%</span>
</div>
<div class="w-full h-2 bg-gray-100 rounded-full overflow-hidden">
<div id="progressBar" class="h-full bg-blue-600 transition-all" style="width: 0%"></div>
</div>
</div>
<!-- 提交 -->
<div class="flex justify-center pt-2">
<button
type="submit"
class="bg-blue-600 text-white py-2 px-5 rounded-lg hover:bg-blue-700 active:scale-[0.99] disabled:opacity-60"
id="submitBtn"
>
提交
</button>
</div>
</form>
</div>
<!-- 加载遮罩层 -->
<div id="loadingOverlay" class="hidden absolute inset-0 bg-white bg-opacity-90 flex flex-col items-center justify-center rounded-lg">
<div class="loader mb-4">
<svg class="h-8 w-8 text-blue-600" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<div
id="loadingOverlay"
class="hidden absolute inset-0 bg-white/80 backdrop-blur flex flex-col items-center justify-center rounded-2xl"
aria-hidden="true"
>
<div class="loader mb-3">
<svg class="h-8 w-8 text-blue-600" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 2V6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M12 18V22" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M4.93 4.93L7.76 7.76" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
@ -35,124 +154,9 @@
<path d="M16.24 7.76L19.07 4.93" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</div>
<p class="text-gray-600">文件上传中,请稍候...</p>
</div>
<h2 class="text-xl font-semibold text-center mb-4">添加知识</h2>
<form id="uploadForm" enctype="multipart/form-data">
<!-- 知识标题输入 -->
<div class="mb-4">
<label for="title" class="block text-sm font-medium text-gray-700">知识标题</label>
<input type="text" id="title" name="title" class="mt-1 block w-full p-2 border border-gray-300 rounded-md" placeholder="输入知识标题" required />
</div>
<!-- 上传文件区域 -->
<div class="mb-4">
<label for="file" class="block text-sm font-medium text-gray-700">上传文件</label>
<div class="mt-2 border-dashed border-2 border-gray-300 p-4 text-center text-gray-500">
<input type="file" id="file" name="file" accept=".pdf,.csv,.txt,.md,.sql,.java" class="hidden" multiple />
<label for="file" class="cursor-pointer">
<div>将文件拖到此处或点击上传</div>
<div class="mt-2 text-sm text-gray-400">支持的文件类型:.pdf, .csv, .txt, .md, .sql, .java</div>
</label>
<p class="text-gray-600 text-sm">文件上传中,请稍候...</p>
</div>
</div>
<!-- 待上传文件列表 -->
<div class="mb-4" id="fileList">
<ul class="list-disc pl-5 text-gray-700"></ul>
</div>
<!-- 提交按钮 -->
<div class="flex justify-center">
<button type="submit" class="bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700">
提交
</button>
</div>
</form>
</div>
<script>
const fileListElement = document.querySelector('#fileList ul');
// 文件选择变更处理
document.getElementById('file').addEventListener('change', function (e) {
const files = Array.from(e.target.files);
fileListElement.innerHTML = ''; // 清空列表
files.forEach((file, index) => {
const listItem = document.createElement('li');
listItem.className = 'flex justify-between items-center';
listItem.innerHTML = `
<span>${file.name}</span>
<button type="button" class="text-red-500 hover:text-red-700" onclick="removeFile(${index})">删除</button>
`;
fileListElement.appendChild(listItem);
});
});
// 移除文件
function removeFile(index) {
const input = document.getElementById('file');
let files = Array.from(input.files);
files.splice(index, 1);
// 创建一个新的DataTransfer对象
const dataTransfer = new DataTransfer();
files.forEach(file => dataTransfer.items.add(file));
// 更新文件输入对象的文件列表
input.files = dataTransfer.files;
// 更新文件列表UI
const fileListItems = fileListElement.children;
fileListItems[index].remove();
}
// 提交事件处理
document.getElementById('uploadForm').addEventListener('submit', function (e) {
e.preventDefault();
const loadingOverlay = document.getElementById('loadingOverlay');
const input = document.getElementById('file');
const files = Array.from(input.files);
if (files.length === 0) {
alert('请先选择一个文件');
return;
}
// 显示加载状态
loadingOverlay.classList.remove('hidden');
const formData = new FormData();
formData.append('ragTag', document.getElementById('title').value);
files.forEach(file => formData.append('file', file));
axios.post('http://localhost:8095/api/v1/rag/file/upload', formData)
.then(response => {
if (response.data.code === '0000') {
// 成功提示并关闭窗口
setTimeout(() => {
alert('上传成功,窗口即将关闭');
window.close();
}, 500);
} else {
throw new Error(response.data.info || '上传失败');
}
})
.catch(error => {
alert(error.message);
})
.finally(() => {
// 隐藏加载状态
loadingOverlay.classList.add('hidden');
// 清空表单(无论成功与否)
input.value = '';
document.getElementById('title').value = '';
fileListElement.innerHTML = '';
});
});
</script>
</body>
</html>

View File

@ -1 +1,9 @@
curl http://localhost:8095/api/v1/rag/query_rag_tag_list
# A. 测试 embeddingsnomic-embed-text
curl -s http://localhost:11434/api/embeddings \
-H "Content-Type: application/json" \
-d '{"model":"nomic-embed-text","input":"hello"}'
# B. 测试 generatedeepseek-r1:1.5b
curl -s http://localhost:11434/api/generate \
-H "Content-Type: application/json" \
-d '{"model":"deepseek-r1:1.5b","prompt":"say hi"}'

View File

@ -83,6 +83,7 @@
<!-- 侧边栏结构 -->
<aside id="sidebar" class="w-64 bg-white border-r overflow-y-auto transition-all duration-300 ease-in-out">
<div class="p-4">
<h2 class="font-bold mb-2 text-lg">聊天列表</h2>
<ul id="chatList" class="space-y-1">
<!-- 聊天列表项结构修改 -->

View File

@ -1,5 +1,4 @@
// ===== index.js (with delete RAG support & auto-refresh + upload menu toggle) =====
// ===== index.js (Redis 存储版) =====
const chatArea = document.getElementById('chatArea');
const messageInput = document.getElementById('messageInput');
const submitBtn = document.getElementById('submitBtn');
@ -9,345 +8,227 @@ const welcomeMessage = document.getElementById('welcomeMessage');
const toggleSidebarBtn = document.getElementById('toggleSidebar');
const sidebar = document.getElementById('sidebar');
// 新增:与 RAG 操作相关 DOM
// RAG 相关 DOM
const ragSelect = document.getElementById('ragSelect');
const deleteRagBtn = document.getElementById('deleteRagBtn');
const refreshRagBtn = document.getElementById('refreshRagBtn');
let currentEventSource = null;
let currentChatId = null;
let currentChatId = localStorage.getItem('currentChatId') || null;
// 在文件顶部添加变量
let shouldAutoRename = false;
/** -------------------------
* RAG 列表加载可复用
* 支持可选预选值 preselectTag
* ------------------------- */
function loadRagOptions(preselectTag) {
return fetch('/api/v1/rag/query_rag_tag_list')
.then(response => response.json())
.then(data => {
if (data.code === '0000' && data.data) {
// 清空现有选项(保留第一个默认选项)
while (ragSelect.options.length > 1) {
ragSelect.remove(1);
// ==================== Redis 存储相关函数 ====================
/**
* 获取所有聊天会话及其标题
*/
async function getAllChats() {
try {
const response = await fetch('/api/v1/chat');
const chatIds = await response.json();
return Promise.all(chatIds.map(async chatId => {
const nameResponse = await fetch(`/api/v1/chat/${chatId}/name`);
const name = await nameResponse.text();
return { chatId, name };
}));
} catch (error) {
console.error('获取聊天列表失败:', error);
return [];
}
// 添加新选项
data.data.forEach(tag => {
const option = new Option(`Rag${tag}`, tag);
ragSelect.add(option);
}
/**
* 创建新聊天会话
*/
async function createNewChat() {
const chatId = Date.now().toString();
shouldAutoRename = true; // 设置需要自动重命名的标志
try {
// 设置初始名称为"新聊天"
await fetch(`/api/v1/chat/${chatId}/rename`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: '新聊天' })
});
// 可选:预选刚创建/删除后保留的 tag
if (preselectTag !== undefined) {
const exists = Array.from(ragSelect.options).some(o => o.value === preselectTag);
if (exists || preselectTag === '') {
ragSelect.value = preselectTag;
currentChatId = chatId;
localStorage.setItem('currentChatId', chatId);
updateChatList();
clearChatArea();
return chatId;
} catch (error) {
console.error('创建聊天失败:', error);
throw error;
}
}
}
})
.catch(error => {
console.error('获取知识库列表失败:', error);
})
.finally(() => {
// 根据当前是否选中某个 RAG决定删除按钮是否可点
if (deleteRagBtn) {
deleteRagBtn.disabled = !ragSelect.value;
}
});
}
/** -------------------------
* 广播 RAG 更新删除/新增后用于同步其它页面
* ------------------------- */
function broadcastRagUpdate(optionalTag) {
try {
// A) postMessage同源且 opener 存在时生效(比如从 index 打开的 upload 页)
if (window.opener && !window.opener.closed) {
window.opener.postMessage(
{ type: 'RAG_LIST_REFRESH', ragTag: optionalTag },
window.location.origin
);
}
} catch (_) {}
/**
* 删除聊天会话
*/
async function deleteChat(chatId) {
if (!confirm('确定要删除这个聊天记录吗?')) return;
try {
// B) BroadcastChannel同源标签页广播
if ('BroadcastChannel' in window) {
const bc = new BroadcastChannel('rag-updates');
bc.postMessage({ type: 'rag:updated', ragTag: optionalTag });
bc.close();
}
} catch (_) {}
}
/** -------------------------
* 删除当前选择的 RAG
* 调用后端 DELETE /api/v1/rag/knowledge/{ragTag}
* ------------------------- */
async function deleteCurrentRag() {
const tag = ragSelect.value;
if (!tag) {
alert('请先从下拉框选择一个知识库。');
return;
}
const ok = confirm(
`确认删除知识库「${tag}」吗?\n此操作不可恢复,将删除向量库中该知识库的所有数据。`
);
if (!ok) return;
setDeleteBtnBusy(true);
try {
const resp = await fetch(`/api/v1/rag/knowledge/${encodeURIComponent(tag)}`, {
const response = await fetch(`/api/v1/chat/${chatId}`, {
method: 'DELETE'
});
const data = await resp.json().catch(() => ({}));
if (resp.ok && data.code === '0000') {
alert('删除成功');
// 删除后刷新下拉框,并清空选择
await loadRagOptions(''); // 传空字符串,强制清空选择
// 广播给其它页面(比如已打开的 upload.html 或其他 index
broadcastRagUpdate();
if (response.ok) {
if (currentChatId === chatId) {
await createNewChat();
}
updateChatList();
} else {
throw new Error(data.info || `HTTP ${resp.status}`);
throw new Error(`HTTP ${response.status}`);
}
} catch (e) {
alert('删除失败:' + (e?.message || '未知错误'));
} finally {
setDeleteBtnBusy(false);
} catch (error) {
console.error('删除聊天失败:', error);
alert('删除失败: ' + error.message);
}
}
/** -------------------------
* 设置删除按钮忙碌状态
* ------------------------- */
function setDeleteBtnBusy(busy) {
if (!deleteRagBtn) return;
if (busy) {
deleteRagBtn.disabled = true;
deleteRagBtn.dataset.originalText = deleteRagBtn.textContent;
deleteRagBtn.textContent = '删除中…';
} else {
deleteRagBtn.textContent =
deleteRagBtn.dataset.originalText || '🗑 删除';
deleteRagBtn.disabled = !ragSelect.value;
}
}
/**
* 重命名聊天会话
*/
async function renameChat(chatId) {
const currentName = await (await fetch(`/api/v1/chat/${chatId}/name`)).text();
const newName = prompt('请输入新的聊天名称', currentName);
/** -------------------------
* 事件绑定刷新/删除/选择变化
* ------------------------- */
if (refreshRagBtn) {
refreshRagBtn.addEventListener('click', () => {
// 保留现有选择刷新若后端删除了该tag刷新后它自然消失
const keep = ragSelect.value || undefined;
loadRagOptions(keep);
if (newName) {
try {
await fetch(`/api/v1/chat/${chatId}/rename`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName })
});
updateChatList();
} catch (error) {
console.error('重命名失败:', error);
alert('重命名失败: ' + error.message);
}
}
}
if (deleteRagBtn) {
deleteRagBtn.addEventListener('click', deleteCurrentRag);
}
/**
* 加载聊天历史
*/
async function loadChat(chatId) {
try {
const response = await fetch(`/api/v1/chat/${chatId}`);
const messages = await response.json();
if (ragSelect) {
ragSelect.addEventListener('change', () => {
if (deleteRagBtn) {
deleteRagBtn.disabled = !ragSelect.value;
}
});
}
/** -------------------------
* 首屏加载 RAG 列表
* ------------------------- */
document.addEventListener('DOMContentLoaded', function () {
loadRagOptions();
});
/** -------------------------
* 接收来自其它页面的 RAG 刷新通知
* ApostMessage
* BBroadcastChannel
* C页面激活时兜底刷新
* ------------------------- */
// A. postMessage当 upload.html 有 opener 时)
window.addEventListener('message', (event) => {
if (event.origin !== window.location.origin) return;
const msg = event.data;
if (msg && msg.type === 'RAG_LIST_REFRESH') {
loadRagOptions(msg.ragTag);
}
});
// B. BroadcastChannel不依赖 opener
if ('BroadcastChannel' in window) {
const bc = new BroadcastChannel('rag-updates');
bc.addEventListener('message', (event) => {
const msg = event.data;
if (msg && msg.type === 'rag:updated') {
loadRagOptions(msg.ragTag);
}
});
}
// C. 兜底:页面重新获得焦点/可见时刷新一次
window.addEventListener('focus', () => loadRagOptions(ragSelect.value || undefined));
document.addEventListener('visibilitychange', () => {
if (!document.hidden) loadRagOptions(ragSelect.value || undefined);
});
/** -------------------------
* 聊天逻辑原有内容保留
* ------------------------- */
function createNewChat() {
const chatId = Date.now().toString();
currentChatId = chatId;
localStorage.setItem('currentChatId', chatId);
localStorage.setItem(`chat_${chatId}`, JSON.stringify({
name: '新聊天',
messages: []
}));
updateChatList();
clearChatArea();
}
function deleteChat(chatId) {
if (confirm('确定要删除这个聊天记录吗?')) {
localStorage.removeItem(`chat_${chatId}`);
if (currentChatId === chatId) {
createNewChat();
}
updateChatList();
messages.forEach(msg => {
appendMessage(msg.content, msg.role === 'assistant', false);
});
} catch (error) {
console.error('加载聊天失败:', error);
alert('加载聊天失败: ' + error.message);
}
}
function updateChatList() {
chatList.innerHTML = '';
const chats = Object.keys(localStorage).filter(key => key.startsWith('chat_'));
const currentChatIndex = chats.findIndex(key => key.split('_')[1] === currentChatId);
if (currentChatIndex !== -1) {
const currentChat = chats[currentChatIndex];
chats.splice(currentChatIndex, 1);
chats.unshift(currentChat);
}
chats.forEach(chatKey => {
let chatData = JSON.parse(localStorage.getItem(chatKey));
const chatId = chatKey.split('_')[1];
if (Array.isArray(chatData)) {
chatData = {
name: `聊天 ${new Date(parseInt(chatId)).toLocaleDateString()}`,
messages: chatData
/**
* 保存消息到 Redis
*/
async function saveMessage(chatId, content, isAssistant) {
const msg = {
role: isAssistant ? "assistant" : "user",
content: content,
ts: Date.now()
};
localStorage.setItem(chatKey, JSON.stringify(chatData));
try {
await fetch(`/api/v1/chat/${chatId}/message`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(msg)
});
} catch (error) {
console.error('保存消息失败:', error);
}
}
/**
* 更新聊天列表 UI
*/
async function updateChatList() {
chatList.innerHTML = '';
const chats = await getAllChats();
// 当前聊天置顶
chats.sort((a, b) => {
if (a.chatId === currentChatId) return -1;
if (b.chatId === currentChatId) return 1;
return parseInt(b.chatId) - parseInt(a.chatId); // 按时间倒序
});
chats.forEach(chat => {
const li = document.createElement('li');
li.className =
`chat-item flex items-center justify-between p-2 hover:bg-gray-100 rounded-lg cursor-pointer transition-colors ${chatId === currentChatId ? 'bg-blue-50' : ''}`;
li.className = `chat-item flex items-center justify-between p-2 hover:bg-gray-100 rounded-lg cursor-pointer transition-colors ${
chat.chatId === currentChatId ? 'bg-blue-50' : ''
}`;
const date = new Date(parseInt(chat.chatId)).toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
li.innerHTML = `
<div class="flex-1">
<div class="text-sm font-medium">${chatData.name}</div>
<div class="text-xs text-gray-400">
${new Date(parseInt(chatId)).toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' })}
</div>
<div class="text-sm font-medium">${chat.name}</div>
<div class="text-xs text-gray-400">${date}</div>
</div>
<div class="chat-actions flex items-center gap-1 opacity-0 transition-opacity duration-200">
<button class="p-1 hover:bg-gray-200 rounded text-gray-500" onclick="renameChat('${chatId}')">重命名</button>
<button class="p-1 hover:bg-red-200 rounded text-red-500" onclick="deleteChat('${chatId}')">删除</button>
<button class="p-1 hover:bg-gray-200 rounded text-gray-500">重命名</button>
<button class="p-1 hover:bg-red-200 rounded text-red-500">删除</button>
</div>
`;
li.addEventListener('click', (e) => {
if (!e.target.closest('.chat-actions')) {
loadChat(chatId);
}
// 事件绑定
li.querySelectorAll('button')[0].addEventListener('click', (e) => {
e.stopPropagation();
renameChat(chat.chatId);
});
li.querySelectorAll('button')[1].addEventListener('click', (e) => {
e.stopPropagation();
deleteChat(chat.chatId);
});
li.addEventListener('click', () => loadChat(chat.chatId));
li.addEventListener('mouseenter', () => {
li.querySelector('.chat-actions').classList.remove('opacity-0');
});
li.addEventListener('mouseleave', () => {
li.querySelector('.chat-actions').classList.add('opacity-0');
});
chatList.appendChild(li);
});
}
let currentContextMenu = null;
function showChatContextMenu(event, chatId) {
event.stopPropagation();
closeContextMenu();
// ==================== 消息显示相关函数 ====================
const buttonRect = event.target.closest('button').getBoundingClientRect();
const menu = document.createElement('div');
menu.className = 'context-menu';
menu.style.position = 'fixed';
menu.style.left = `${buttonRect.left}px`;
menu.style.top = `${buttonRect.bottom + 4}px`;
menu.innerHTML = `
<div class="context-menu-item" onclick="renameChat('${chatId}')">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
重命名
</div>
<div class="context-menu-item text-red-500" onclick="deleteChat('${chatId}')">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
删除
</div>
`;
document.body.appendChild(menu);
currentContextMenu = menu;
setTimeout(() => {
document.addEventListener('click', closeContextMenu, { once: true });
});
}
function closeContextMenu() {
if (currentContextMenu) {
currentContextMenu.remove();
currentContextMenu = null;
}
}
function renameChat(chatId) {
const chatKey = `chat_${chatId}`;
const chatData = JSON.parse(localStorage.getItem(chatKey));
const currentName = chatData.name || `聊天 ${new Date(parseInt(chatId)).toLocaleString()}`;
const newName = prompt('请输入新的聊天名称', currentName);
if (newName) {
chatData.name = newName;
localStorage.setItem(chatKey, JSON.stringify(chatData));
updateChatList();
}
}
function loadChat(chatId) {
currentChatId = chatId;
localStorage.setItem('currentChatId', chatId);
clearChatArea();
const chatData = JSON.parse(localStorage.getItem(`chat_${chatId}`) || '{ "messages": [] }');
(chatData.messages || []).forEach(msg => {
appendMessage(msg.content, msg.isAssistant, false);
});
updateChatList();
}
function clearChatArea() {
chatArea.innerHTML = '';
welcomeMessage.style.display = 'flex';
}
function appendMessage(content, isAssistant = false, saveToStorage = true) {
welcomeMessage.style.display = 'none';
const messageDiv = document.createElement('div');
messageDiv.className =
`max-w-4xl mx-auto mb-4 p-4 rounded-lg ${isAssistant ? 'bg-gray-100' : 'bg-white border'} markdown-body relative`;
messageDiv.className = `max-w-4xl mx-auto mb-4 p-4 rounded-lg ${
isAssistant ? 'bg-gray-100' : 'bg-white border'
} markdown-body relative`;
const renderedContent = DOMPurify.sanitize(marked.parse(content));
messageDiv.innerHTML = renderedContent;
@ -367,14 +248,12 @@ function appendMessage(content, isAssistant = false, saveToStorage = true) {
chatArea.scrollTop = chatArea.scrollHeight;
if (saveToStorage && currentChatId) {
const chatData = JSON.parse(
localStorage.getItem(`chat_${currentChatId}`) || '{"name": "新聊天", "messages": []}'
);
chatData.messages.push({ content, isAssistant });
localStorage.setItem(`chat_${currentChatId}`, JSON.stringify(chatData));
saveMessage(currentChatId, content, isAssistant);
}
}
// ==================== 事件流处理 ====================
function startEventStream(message) {
if (currentEventSource) {
currentEventSource.close();
@ -382,7 +261,7 @@ function startEventStream(message) {
const ragTag = ragSelect.value;
const aiModelSelect = document.getElementById('aiModel');
const aiModelValue = aiModelSelect.value; // openai / ollama
const aiModelValue = aiModelSelect.value;
const aiModelModel = aiModelSelect.options[aiModelSelect.selectedIndex].getAttribute('model');
let url;
@ -395,6 +274,8 @@ function startEventStream(message) {
url = `${base}/generate_stream?${params.toString()}`;
}
console.log('[SSE] Connecting to:', url);
currentEventSource = new EventSource(url);
let accumulatedContent = '';
let tempMessageDiv = null;
@ -402,64 +283,133 @@ function startEventStream(message) {
currentEventSource.onmessage = function (event) {
try {
const data = JSON.parse(event.data);
const content = data.result?.output?.text ||
data.output?.text ||
data.text ||
data.response;
if (data.result?.output?.content) {
const newContent = data.result.output.content;
accumulatedContent += newContent;
if (content !== undefined && content !== '') {
accumulatedContent += content;
if (!tempMessageDiv) {
tempMessageDiv = document.createElement('div');
tempMessageDiv.className = 'max-w-4xl mx-auto mb-4 p-4 rounded-lg bg-gray-100 markdown-body relative';
chatArea.appendChild(tempMessageDiv);
welcomeMessage.style.display = 'none';
// 如果是新聊天且是第一次收到AI回复则重命名
if (shouldAutoRename && currentChatId) {
renameChatToDefault(currentChatId);
shouldAutoRename = false; // 重置标志
}
}
tempMessageDiv.textContent = accumulatedContent;
chatArea.scrollTop = chatArea.scrollHeight;
}
if (data.result?.output?.properties?.finishReason === 'STOP') {
currentEventSource.close();
const isFinished = (data.result?.metadata?.finishReason || '').toLowerCase() === 'stop' ||
data.done === true ||
(data.metadata?.usage?.completionTokens &&
data.metadata?.usage?.totalTokens);
const finalContent = accumulatedContent;
tempMessageDiv.innerHTML = DOMPurify.sanitize(marked.parse(finalContent));
if (isFinished) {
currentEventSource.close();
console.log('[SSE] Stream completed');
if (tempMessageDiv) {
tempMessageDiv.innerHTML = DOMPurify.sanitize(marked.parse(accumulatedContent));
const copyBtn = document.createElement('button');
copyBtn.className = 'absolute top-2 right-2 p-1 bg-gray-200 rounded-md text-xs';
copyBtn.textContent = '复制';
copyBtn.onclick = () => {
navigator.clipboard.writeText(finalContent).then(() => {
navigator.clipboard.writeText(accumulatedContent).then(() => {
copyBtn.textContent = '已复制';
setTimeout(() => (copyBtn.textContent = '复制'), 2000);
});
};
tempMessageDiv.appendChild(copyBtn);
}
if (currentChatId) {
const chatData = JSON.parse(
localStorage.getItem(`chat_${currentChatId}`) || '{"name": "新聊天", "messages": []}'
);
chatData.messages.push({ content: finalContent, isAssistant: true });
localStorage.setItem(`chat_${currentChatId}`, JSON.stringify(chatData));
if (currentChatId && accumulatedContent) {
saveMessage(currentChatId, accumulatedContent, true);
}
}
} catch (e) {
console.error('Error parsing event data:', e);
console.error('[SSE] Parse error:', e, 'Raw data:', event.data);
}
};
currentEventSource.onerror = function (error) {
console.error('EventSource error:', error);
console.error('[SSE] Error:', error);
currentEventSource.close();
if (tempMessageDiv && !tempMessageDiv.textContent.trim()) {
tempMessageDiv.textContent = "⚠️ 连接中断,请重试";
}
};
}
submitBtn.addEventListener('click', () => {
/**
* 自动重命名聊天为默认名称基于第一条问题和时间
*/
/**
* 自动重命名聊天为默认名称基于第一条问题和美式时间格式
*/
async function renameChatToDefault(chatId) {
try {
// 获取聊天历史
const response = await fetch(`/api/v1/chat/${chatId}`);
const messages = await response.json();
// 查找第一条用户消息
const firstUserMessage = messages.find(msg => msg.role === 'user');
let chatName = 'Chat';
if (firstUserMessage && firstUserMessage.content) {
// 截取前20个字符作为标题部分过滤换行符
const shortContent = firstUserMessage.content
.replace(/\n/g, ' ') // 替换换行为空格
.substring(0, 20)
.trim();
chatName = shortContent + (firstUserMessage.content.length > 20 ? '...' : '');
}
// 使用美式时间格式MMM DD, YYYY, h:mm:ss AM/PM
const now = new Date();
const timeStr = now.toLocaleString('en-US', {
month: 'short', // Jul
day: 'numeric', // 29
year: 'numeric', // 2025
hour: 'numeric', // 2 (12小时制)
minute: '2-digit', // 37
second: '2-digit', // 44
hour12: true // AM/PM
});
const defaultName = `${chatName} (${timeStr})`;
await fetch(`/api/v1/chat/${chatId}/rename`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: defaultName })
});
updateChatList();
} catch (error) {
console.error('自动重命名失败:', error);
}
}
// ==================== 事件绑定 ====================
submitBtn.addEventListener('click', async () => {
const message = messageInput.value.trim();
if (!message) return;
if (!currentChatId) {
createNewChat();
await createNewChat();
}
appendMessage(message, false);
@ -490,14 +440,17 @@ function updateSidebarIcon() {
}
}
// Initialize
// ==================== 初始化 ====================
// 初始化聊天列表
updateChatList();
const savedChatId = localStorage.getItem('currentChatId');
if (savedChatId) {
loadChat(savedChatId);
// 加载当前聊天
if (currentChatId) {
loadChat(currentChatId);
}
// Responsive
// 响应式处理
window.addEventListener('resize', () => {
if (window.innerWidth > 768) {
sidebar.classList.remove('-translate-x-full');

View File

@ -109,11 +109,12 @@
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.0</version>
<version>3.8.1</version>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
<encoding>${project.build.sourceEncoding}</encoding>
<parameters>true</parameters>
</configuration>
</plugin>
<plugin>