7.29 redis存储历史聊天记录,替代本地浏览器缓存
This commit is contained in:
parent
37b696e61b
commit
8914ef7393
@ -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) {}
|
||||
}
|
@ -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
|
||||
|
41
docs/rag-dev-ops/nginx/conf/nginx.conf
Normal file
41
docs/rag-dev-ops/nginx/conf/nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
10
docs/rag-dev-ops/nginx/html/css/github.min.css
vendored
Normal file
10
docs/rag-dev-ops/nginx/html/css/github.min.css
vendored
Normal 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}
|
52
docs/rag-dev-ops/nginx/html/css/upload.css
Normal file
52
docs/rag-dev-ops/nginx/html/css/upload.css
Normal 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;
|
||||
}
|
@ -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">
|
||||
<option value="">选择一个知识库</option>
|
||||
</select>
|
||||
<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">
|
||||
|
3
docs/rag-dev-ops/nginx/html/js/axios.min.js
vendored
Normal file
3
docs/rag-dev-ops/nginx/html/js/axios.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
3
docs/rag-dev-ops/nginx/html/js/highlight.min.js
vendored
Normal file
3
docs/rag-dev-ops/nginx/html/js/highlight.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -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');
|
||||
|
||||
// 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;
|
||||
|
||||
// 获取知识库列表
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 获取知识库列表
|
||||
const loadRagOptions = () => {
|
||||
const ragSelect = document.getElementById('ragSelect');
|
||||
// ==================== Redis 存储相关函数 ====================
|
||||
|
||||
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);
|
||||
}
|
||||
/**
|
||||
* 获取所有聊天会话及其标题
|
||||
*/
|
||||
async function getAllChats() {
|
||||
try {
|
||||
const response = await fetch('http://localhost:8095/api/v1/chat');
|
||||
const chatIds = await response.json();
|
||||
|
||||
// 添加新选项
|
||||
data.data.forEach(tag => {
|
||||
const option = new Option(`Rag:${tag}`, tag);
|
||||
ragSelect.add(option);
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('获取知识库列表失败:', error);
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新聊天会话
|
||||
*/
|
||||
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);
|
||||
|
||||
updateChatList();
|
||||
clearChatArea();
|
||||
return chatId;
|
||||
} catch (error) {
|
||||
console.error('创建聊天失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除聊天会话
|
||||
*/
|
||||
async function deleteChat(chatId) {
|
||||
if (!confirm('确定要删除这个聊天记录吗?')) return;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重命名聊天会话
|
||||
*/
|
||||
async function renameChat(chatId) {
|
||||
const currentName = await (await fetch(`http://localhost:8095/api/v1/chat/${chatId}/name`)).text();
|
||||
const newName = prompt('请输入新的聊天名称', currentName);
|
||||
|
||||
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()
|
||||
};
|
||||
|
||||
// 初始化加载
|
||||
loadRagOptions();
|
||||
});
|
||||
|
||||
function createNewChat() {
|
||||
const chatId = Date.now().toString();
|
||||
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
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
function updateChatList() {
|
||||
/**
|
||||
* 更新聊天列表 UI
|
||||
*/
|
||||
async function updateChatList() {
|
||||
chatList.innerHTML = '';
|
||||
const chats = Object.keys(localStorage)
|
||||
.filter(key => key.startsWith('chat_'));
|
||||
const chats = await getAllChats();
|
||||
|
||||
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
|
||||
};
|
||||
localStorage.setItem(chatKey, JSON.stringify(chatData));
|
||||
}
|
||||
// 当前聊天置顶
|
||||
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();
|
||||
}
|
||||
// ==================== 事件流处理 ====================
|
||||
|
||||
// 选项值,
|
||||
// 组装01;http://localhost:8095/api/v1/ollama/generate_stream?message=Hello&model=deepseek-r1:1.5b
|
||||
// 组装02;http://localhost:8095/api/v1/openai/generate_stream?message=Hello&model=gpt-4o
|
||||
const ragTag = document.getElementById('ragSelect').value;
|
||||
function startEventStream(message) {
|
||||
if (currentEventSource) currentEventSource.close();
|
||||
|
||||
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(() => {
|
||||
copyBtn.textContent = '已复制';
|
||||
setTimeout(() => copyBtn.textContent = '复制', 2000);
|
||||
});
|
||||
};
|
||||
tempMessageDiv.appendChild(copyBtn);
|
||||
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(accumulatedContent).then(() => {
|
||||
copyBtn.textContent = '已复制';
|
||||
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();
|
||||
|
||||
// 上传知识下拉菜单控制
|
||||
const uploadMenuButton = document.getElementById('uploadMenuButton');
|
||||
const uploadMenu = document.getElementById('uploadMenu');
|
||||
/** -------------------------
|
||||
* 上传知识下拉菜单(保持你现有 HTML 结构)
|
||||
* - 点击按钮展开/收起菜单
|
||||
* - 点击外部区域/按下 Esc 关闭
|
||||
* - 点击菜单项后自动关闭
|
||||
* ------------------------- */
|
||||
(function initUploadMenu() {
|
||||
const uploadMenuButton = document.getElementById('uploadMenuButton');
|
||||
const uploadMenu = document.getElementById('uploadMenu');
|
||||
if (!uploadMenuButton || !uploadMenu) return;
|
||||
|
||||
// 切换菜单显示
|
||||
uploadMenuButton.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
uploadMenu.classList.toggle('hidden');
|
||||
});
|
||||
// 切换菜单显示/隐藏
|
||||
const toggleMenu = () => {
|
||||
uploadMenu.classList.toggle('hidden');
|
||||
};
|
||||
|
||||
// 点击外部区域关闭菜单
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!uploadMenu.contains(e.target) && e.target !== uploadMenuButton) {
|
||||
// 显示菜单
|
||||
const openMenu = () => {
|
||||
uploadMenu.classList.remove('hidden');
|
||||
};
|
||||
|
||||
// 隐藏菜单
|
||||
const closeMenu = () => {
|
||||
uploadMenu.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 菜单项点击后关闭菜单
|
||||
document.querySelectorAll('#uploadMenu a').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
uploadMenu.classList.add('hidden');
|
||||
// 点击按钮展开/收起
|
||||
uploadMenuButton.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
toggleMenu();
|
||||
});
|
||||
});
|
||||
|
||||
// 键盘辅助:Enter/Space 展开,Esc 关闭
|
||||
uploadMenuButton.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
openMenu();
|
||||
} else if (e.key === 'Escape') {
|
||||
closeMenu();
|
||||
}
|
||||
});
|
||||
|
||||
// 点击菜单项后自动关闭
|
||||
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();
|
||||
});
|
||||
})();
|
||||
|
6
docs/rag-dev-ops/nginx/html/js/marked.min.js
vendored
Normal file
6
docs/rag-dev-ops/nginx/html/js/marked.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
3
docs/rag-dev-ops/nginx/html/js/purify.min.js
vendored
Normal file
3
docs/rag-dev-ops/nginx/html/js/purify.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
383
docs/rag-dev-ops/nginx/html/js/upload.js
Normal file
383
docs/rag-dev-ops/nginx/html/js/upload.js
Normal 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 = [];
|
||||
|
||||
// 方案 1:File System Access API(Chromium 新方案)
|
||||
if (supportsFSH) {
|
||||
const jobs = [];
|
||||
for (const item of items) {
|
||||
jobs.push(handleItemWithFSH(item, collected));
|
||||
}
|
||||
await Promise.all(jobs);
|
||||
return collected;
|
||||
}
|
||||
|
||||
// 方案 2:webkitGetAsEntry(Safari/老版 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;
|
||||
}
|
||||
|
||||
// ------ 方案 1:FS 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------ 方案 2:webkitGetAsEntry ------
|
||||
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();
|
||||
}
|
||||
});
|
@ -1,158 +1,162 @@
|
||||
<!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>
|
||||
|
||||
<!-- Tailwind CDN(可保留,也可改为自己构建的CSS) -->
|
||||
<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>
|
||||
|
||||
<!-- 自定义样式 -->
|
||||
<link rel="stylesheet" href="css/upload.css" />
|
||||
|
||||
<!-- Axios(CDN 版本;也可改回你本地的 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="flex justify-center items-center min-h-screen bg-gray-100">
|
||||
|
||||
<!-- 上传文件模态框 -->
|
||||
<div class="bg-white p-6 rounded-lg shadow-lg w-96 relative">
|
||||
<!-- 加载遮罩层 -->
|
||||
<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">
|
||||
<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"/>
|
||||
<path d="M16.24 16.24L19.07 19.07" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M2 12H6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M18 12H22" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M4.93 19.07L7.76 16.24" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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 />
|
||||
<!-- 表单 -->
|
||||
<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 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>
|
||||
<!-- 加载遮罩层 -->
|
||||
<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"/>
|
||||
<path d="M16.24 16.24L19.07 19.07" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M2 12H6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M18 12H22" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M4.93 19.07L7.76 16.24" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M16.24 7.76L19.07 4.93" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-gray-600 text-sm">文件上传中,请稍候...</p>
|
||||
</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>
|
||||
</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>
|
||||
|
@ -1 +1,9 @@
|
||||
curl http://localhost:8095/api/v1/rag/query_rag_tag_list
|
||||
# A. 测试 embeddings(nomic-embed-text)
|
||||
curl -s http://localhost:11434/api/embeddings \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"nomic-embed-text","input":"hello"}'
|
||||
|
||||
# B. 测试 generate(deepseek-r1:1.5b)
|
||||
curl -s http://localhost:11434/api/generate \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"deepseek-r1:1.5b","prompt":"say hi"}'
|
||||
|
@ -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">
|
||||
<!-- 聊天列表项结构修改 -->
|
||||
|
@ -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);
|
||||
}
|
||||
// 添加新选项
|
||||
data.data.forEach(tag => {
|
||||
const option = new Option(`Rag:${tag}`, tag);
|
||||
ragSelect.add(option);
|
||||
});
|
||||
// ==================== Redis 存储相关函数 ====================
|
||||
|
||||
// 可选:预选刚创建/删除后保留的 tag
|
||||
if (preselectTag !== undefined) {
|
||||
const exists = Array.from(ragSelect.options).some(o => o.value === preselectTag);
|
||||
if (exists || preselectTag === '') {
|
||||
ragSelect.value = preselectTag;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('获取知识库列表失败:', error);
|
||||
})
|
||||
.finally(() => {
|
||||
// 根据当前是否选中某个 RAG,决定删除按钮是否可点
|
||||
if (deleteRagBtn) {
|
||||
deleteRagBtn.disabled = !ragSelect.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** -------------------------
|
||||
* 广播 RAG 更新(删除/新增后用于同步其它页面)
|
||||
* ------------------------- */
|
||||
function broadcastRagUpdate(optionalTag) {
|
||||
/**
|
||||
* 获取所有聊天会话及其标题
|
||||
*/
|
||||
async function getAllChats() {
|
||||
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 (_) {}
|
||||
const response = await fetch('/api/v1/chat');
|
||||
const chatIds = await response.json();
|
||||
|
||||
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;
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
|
||||
const ok = confirm(
|
||||
`确认删除知识库「${tag}」吗?\n此操作不可恢复,将删除向量库中该知识库的所有数据。`
|
||||
);
|
||||
if (!ok) return;
|
||||
|
||||
setDeleteBtnBusy(true);
|
||||
/**
|
||||
* 创建新聊天会话
|
||||
*/
|
||||
async function createNewChat() {
|
||||
const chatId = Date.now().toString();
|
||||
shouldAutoRename = true; // 设置需要自动重命名的标志
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/v1/rag/knowledge/${encodeURIComponent(tag)}`, {
|
||||
// 设置初始名称为"新聊天"
|
||||
await fetch(`/api/v1/chat/${chatId}/rename`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: '新聊天' })
|
||||
});
|
||||
|
||||
currentChatId = chatId;
|
||||
localStorage.setItem('currentChatId', chatId);
|
||||
|
||||
updateChatList();
|
||||
clearChatArea();
|
||||
return chatId;
|
||||
} catch (error) {
|
||||
console.error('创建聊天失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 删除聊天会话
|
||||
*/
|
||||
async function deleteChat(chatId) {
|
||||
if (!confirm('确定要删除这个聊天记录吗?')) return;
|
||||
|
||||
try {
|
||||
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();
|
||||
} else {
|
||||
throw new Error(data.info || `HTTP ${resp.status}`);
|
||||
}
|
||||
} catch (e) {
|
||||
alert('删除失败:' + (e?.message || '未知错误'));
|
||||
} finally {
|
||||
setDeleteBtnBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
/** -------------------------
|
||||
* 设置删除按钮忙碌状态
|
||||
* ------------------------- */
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/** -------------------------
|
||||
* 事件绑定:刷新/删除/选择变化
|
||||
* ------------------------- */
|
||||
if (refreshRagBtn) {
|
||||
refreshRagBtn.addEventListener('click', () => {
|
||||
// 保留现有选择刷新(若后端删除了该tag,刷新后它自然消失)
|
||||
const keep = ragSelect.value || undefined;
|
||||
loadRagOptions(keep);
|
||||
});
|
||||
}
|
||||
|
||||
if (deleteRagBtn) {
|
||||
deleteRagBtn.addEventListener('click', deleteCurrentRag);
|
||||
}
|
||||
|
||||
if (ragSelect) {
|
||||
ragSelect.addEventListener('change', () => {
|
||||
if (deleteRagBtn) {
|
||||
deleteRagBtn.disabled = !ragSelect.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** -------------------------
|
||||
* 首屏加载 RAG 列表
|
||||
* ------------------------- */
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
loadRagOptions();
|
||||
});
|
||||
|
||||
/** -------------------------
|
||||
* 接收来自其它页面的 RAG 刷新通知
|
||||
* A:postMessage
|
||||
* B:BroadcastChannel
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
localStorage.setItem(chatKey, JSON.stringify(chatData));
|
||||
}
|
||||
|
||||
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.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>
|
||||
<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>
|
||||
</div>
|
||||
`;
|
||||
li.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.chat-actions')) {
|
||||
loadChat(chatId);
|
||||
if (response.ok) {
|
||||
if (currentChatId === chatId) {
|
||||
await createNewChat();
|
||||
}
|
||||
updateChatList();
|
||||
} else {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除聊天失败:', error);
|
||||
alert('删除失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重命名聊天会话
|
||||
*/
|
||||
async function renameChat(chatId) {
|
||||
const currentName = await (await fetch(`/api/v1/chat/${chatId}/name`)).text();
|
||||
const newName = prompt('请输入新的聊天名称', currentName);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载聊天历史
|
||||
*/
|
||||
async function loadChat(chatId) {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/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()
|
||||
};
|
||||
|
||||
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 ${
|
||||
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">${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">重命名</button>
|
||||
<button class="p-1 hover:bg-red-200 rounded text-red-500">删除</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 事件绑定
|
||||
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') {
|
||||
const isFinished = (data.result?.metadata?.finishReason || '').toLowerCase() === 'stop' ||
|
||||
data.done === true ||
|
||||
(data.metadata?.usage?.completionTokens &&
|
||||
data.metadata?.usage?.totalTokens);
|
||||
|
||||
if (isFinished) {
|
||||
currentEventSource.close();
|
||||
console.log('[SSE] Stream completed');
|
||||
|
||||
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(() => {
|
||||
copyBtn.textContent = '已复制';
|
||||
setTimeout(() => (copyBtn.textContent = '复制'), 2000);
|
||||
});
|
||||
};
|
||||
tempMessageDiv.appendChild(copyBtn);
|
||||
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(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');
|
||||
|
3
pom.xml
3
pom.xml
@ -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>
|
||||
|
Loading…
x
Reference in New Issue
Block a user