Initial commit

This commit is contained in:
wangjing
2026-04-23 15:01:58 +08:00
commit f3debe7158
48 changed files with 4761 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
chat-web/node_modules/
chat-web/dist/
chat-server/target/
.idea/
*.iml
*.zip
*.db
+85
View File
@@ -0,0 +1,85 @@
# AI Chat — 智能聊天助手
基于大语言模型的前后端分离智能对话系统。
## 技术架构
| 模块 | 技术 | 说明 |
|------|------|------|
| 前端 | Vite + HTML/CSS/JS | 现代化聊天界面,深色玻璃态主题 |
| 后端 | Spring Boot 3.2 + JDK 17 | RESTful API + SSE 流式响应 |
| 数据库 | SQLite | 嵌入式数据库,零配置 |
| AI 接口 | OpenAI 兼容格式 | 支持 OpenAI / DeepSeek / 通义千问 |
## 项目结构
```
chat/
├── chat-server/ # 后端 Spring Boot 项目
│ ├── pom.xml
│ └── src/main/java/com/chat/
│ ├── ChatApplication.java
│ ├── config/ # 配置类(CORS、AI 模型)
│ ├── controller/ # API 控制器
│ ├── service/ # 业务服务
│ ├── model/ # 实体和 DTO
│ └── repository/ # 数据访问
└── chat-web/ # 前端 Vite 项目
├── index.html
└── src/
├── components/ # UI 组件
├── services/ # API 调用
└── utils/ # 工具函数
```
## 快速开始
### 1. 配置 AI 模型
编辑 `chat-server/src/main/resources/application.yml`,修改 AI 配置:
```yaml
ai:
base-url: https://api.deepseek.com # API 地址
api-key: your-api-key-here # 你的 API Key
model: deepseek-chat # 模型名称
```
**支持的模型:**
- OpenAI: `base-url=https://api.openai.com`, `model=gpt-4o`
- DeepSeek: `base-url=https://api.deepseek.com`, `model=deepseek-chat`
- 通义千问: `base-url=https://dashscope.aliyuncs.com/compatible-mode`, `model=qwen-plus`
### 2. 启动后端
```bash
cd chat-server
mvn spring-boot:run
```
后端默认运行在 `http://localhost:8080`
### 3. 启动前端
```bash
cd chat-web
npm install
npm run dev
```
前端默认运行在 `http://localhost:5173`
### 4. 开始使用
打开浏览器访问 `http://localhost:5173`,即可开始与 AI 对话。
## 功能特性
-**流式输出** — SSE 实时推送,打字机效果
- 💬 **会话管理** — 创建、切换、重命名、删除对话
- 📝 **Markdown 渲染** — 支持代码高亮、表格、列表等
- 📋 **一键复制** — 代码块一键复制到剪贴板
- 🌙 **深色主题** — 精美的玻璃态暗色界面
- 📱 **响应式布局** — 适配桌面和移动端
- 💾 **对话持久化** — SQLite 数据库存储聊天记录
+45
View File
@@ -0,0 +1,45 @@
import paramiko
import os
import time
host = "64.90.11.73"
port = 22
username = "root"
password = "qemzCSBQ8251"
local_zip = "chat-deploy.zip"
remote_zip = "/root/chat-deploy.zip"
print(f"Connecting to {host}...")
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
ssh.connect(host, port, username, password, look_for_keys=False, allow_agent=False)
print("Uploading zip file...")
sftp = ssh.open_sftp()
sftp.put(local_zip, remote_zip)
sftp.close()
commands = """
mkdir -p /root/chat-deploy
unzip -o /root/chat-deploy.zip -d /root/chat-deploy
cd /root/chat-deploy
docker build -t chat-server:latest .
docker stop chat-server || true
docker rm chat-server || true
docker run -d --name chat-server -p 38080:8080 chat-server:latest
"""
print("Executing deployment commands on server...")
stdin, stdout, stderr = ssh.exec_command(commands)
# 实时打印输出
for line in iter(stdout.readline, ""):
print(line, end="")
for line in iter(stderr.readline, ""):
print("ERROR:", line, end="")
print("Deployment script execution completed.")
finally:
ssh.close()
+46
View File
@@ -0,0 +1,46 @@
import paramiko
import os
import tarfile
HOST = '64.90.11.73'
USER = 'root'
PASSWORD = 'qemzCSBQ8251'
REMOTE_DIR = '/root/chat-web'
print("正在连接到服务器...")
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(HOST, username=USER, password=PASSWORD, look_for_keys=False, allow_agent=False)
print("正在清理并创建远程目录...")
ssh.exec_command(f'mkdir -p {REMOTE_DIR}/dist')
ssh.exec_command(f'rm -rf {REMOTE_DIR}/dist/*')
print("正在打包本地 dist 文件夹...")
with tarfile.open("dist.tar.gz", "w:gz") as tar:
tar.add("chat-web/dist", arcname="dist")
tar.add("chat-web/nginx.conf", arcname="nginx.conf")
sftp = ssh.open_sftp()
print("正在上传文件...")
sftp.put("dist.tar.gz", f"{REMOTE_DIR}/dist.tar.gz")
sftp.close()
print("正在远程解压...")
ssh.exec_command(f'cd {REMOTE_DIR} && tar -xzf dist.tar.gz && cp -r dist/nginx.conf .')
print("正在启动 Nginx 容器...")
cmd = (
f'docker stop chat-web-nginx; '
f'docker rm chat-web-nginx; '
f'docker run -d --name chat-web-nginx -p 38080:80 '
f'-v {REMOTE_DIR}/dist/dist:/usr/share/nginx/html '
f'-v {REMOTE_DIR}/nginx.conf:/etc/nginx/conf.d/default.conf nginx'
)
stdin, stdout, stderr = ssh.exec_command(cmd)
print("启动结果:", stdout.read().decode())
print("错误输出:", stderr.read().decode())
ssh.close()
os.remove("dist.tar.gz")
print("前端部署完成!可以访问 http://64.90.11.73:38080")
Binary file not shown.
+15
View File
@@ -0,0 +1,15 @@
# 第一阶段:使用 Maven 镜像编译代码
FROM maven:3.9.6-eclipse-temurin-17 AS builder
WORKDIR /build
COPY pom.xml .
COPY src ./src
# 编译并打包
RUN mvn clean package -DskipTests
# 第二阶段:运行
FROM openjdk:17-jdk-slim
WORKDIR /app
COPY --from=builder /build/target/*.jar app.jar
ENV TZ=Asia/Shanghai
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
+6
View File
@@ -0,0 +1,6 @@
FROM openjdk:17-jdk-slim
WORKDIR /app
COPY chat-server-1.0.0.jar app.jar
ENV TZ=Asia/Shanghai
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
+82
View File
@@ -0,0 +1,82 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
<relativePath/>
</parent>
<groupId>com.chat</groupId>
<artifactId>chat-server</artifactId>
<version>1.0.0</version>
<name>AI Chat Server</name>
<description>AI 智能聊天后端服务</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- MyBatis-Plus(适配 Spring Boot 3 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.7</version>
</dependency>
<!-- MySQL JDBC 驱动 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Jackson JSON 处理 -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Spring Boot Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,17 @@
package com.chat;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* AI 智能聊天应用启动类
*/
@SpringBootApplication
@MapperScan("com.chat.mapper")
public class ChatApplication {
public static void main(String[] args) {
SpringApplication.run(ChatApplication.class, args);
}
}
@@ -0,0 +1,44 @@
package com.chat.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
/**
* AI 模型配置 — 支持 OpenAI 兼容格式的大模型接口
*/
@Data
@Configuration
@ConfigurationProperties(prefix = "ai")
public class AiConfig {
/**
* API 基础地址(如 https://api.openai.com、https://api.deepseek.com
*/
private String baseUrl = "https://api.openai.com";
/**
* API 密钥
*/
private String apiKey = "";
/**
* 模型名称(如 gpt-4o、deepseek-chat、qwen-plus
*/
private String model = "gpt-3.5-turbo";
/**
* 生成温度(0-2,越高越有创造性)
*/
private Double temperature = 0.7;
/**
* 最大输出 token 数
*/
private Integer maxTokens = 4096;
/**
* 系统提示词
*/
private String systemPrompt = "你是一个有帮助的 AI 助手,请用中文回答问题。";
}
@@ -0,0 +1,28 @@
package com.chat.config;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
/**
* MyBatis-Plus 自动填充处理器
* 负责在 insert/update 时自动填充 createdAt、updatedAt 字段
*/
@Slf4j
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
this.strictInsertFill(metaObject, "createdAt", LocalDateTime::now, LocalDateTime.class);
this.strictInsertFill(metaObject, "updatedAt", LocalDateTime::now, LocalDateTime.class);
}
@Override
public void updateFill(MetaObject metaObject) {
this.strictUpdateFill(metaObject, "updatedAt", LocalDateTime::now, LocalDateTime.class);
}
}
@@ -0,0 +1,22 @@
package com.chat.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* 跨域配置 — 允许前端开发服务器访问后端 API
*/
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
@@ -0,0 +1,81 @@
package com.chat.controller;
import com.chat.model.ChatMessage;
import com.chat.model.ChatSession;
import com.chat.model.dto.ChatRequest;
import com.chat.service.ChatService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.List;
import java.util.Map;
/**
* 聊天 API 控制器
*/
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class ChatController {
private final ChatService chatService;
// ==================== 聊天接口 ====================
/**
* 发送消息(SSE 流式响应)
*/
@PostMapping(value = "/chat/send", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter sendMessage(@RequestBody ChatRequest request) {
return chatService.sendMessage(request);
}
// ==================== 会话管理接口 ====================
/**
* 获取所有会话
*/
@GetMapping("/sessions")
public ResponseEntity<List<ChatSession>> getSessions() {
return ResponseEntity.ok(chatService.getAllSessions());
}
/**
* 创建新会话
*/
@PostMapping("/sessions")
public ResponseEntity<ChatSession> createSession(@RequestBody(required = false) Map<String, String> body) {
String title = body != null ? body.get("title") : null;
return ResponseEntity.ok(chatService.createSession(title));
}
/**
* 更新会话标题
*/
@PutMapping("/sessions/{id}")
public ResponseEntity<ChatSession> updateSession(
@PathVariable Long id,
@RequestBody Map<String, String> body) {
return ResponseEntity.ok(chatService.updateSessionTitle(id, body.get("title")));
}
/**
* 删除会话
*/
@DeleteMapping("/sessions/{id}")
public ResponseEntity<Void> deleteSession(@PathVariable Long id) {
chatService.deleteSession(id);
return ResponseEntity.ok().build();
}
/**
* 获取会话的消息历史
*/
@GetMapping("/sessions/{id}/messages")
public ResponseEntity<List<ChatMessage>> getMessages(@PathVariable Long id) {
return ResponseEntity.ok(chatService.getSessionMessages(id));
}
}
@@ -0,0 +1,12 @@
package com.chat.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.chat.model.ChatMessage;
import org.apache.ibatis.annotations.Mapper;
/**
* 消息数据访问 Mapper
*/
@Mapper
public interface ChatMessageMapper extends BaseMapper<ChatMessage> {
}
@@ -0,0 +1,12 @@
package com.chat.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.chat.model.ChatSession;
import org.apache.ibatis.annotations.Mapper;
/**
* 会话数据访问 Mapper
*/
@Mapper
public interface ChatSessionMapper extends BaseMapper<ChatSession> {
}
@@ -0,0 +1,41 @@
package com.chat.model;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import java.time.LocalDateTime;
/**
* 聊天消息实体
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@TableName("chat_message")
public class ChatMessage {
@TableId(type = IdType.AUTO)
private Long id;
/**
* 所属会话 ID
*/
private Long sessionId;
/**
* 消息角色:user / assistant / system
*/
private String role;
/**
* 消息内容
*/
private String content;
/**
* 创建时间
*/
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createdAt;
}
@@ -0,0 +1,37 @@
package com.chat.model;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import java.time.LocalDateTime;
/**
* 聊天会话实体
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@TableName("chat_session")
public class ChatSession {
@TableId(type = IdType.AUTO)
private Long id;
/**
* 会话标题
*/
private String title;
/**
* 创建时间
*/
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createdAt;
/**
* 更新时间
*/
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updatedAt;
}
@@ -0,0 +1,35 @@
package com.chat.model.dto;
import lombok.Data;
import java.util.List;
/**
* 聊天请求 DTO
*/
@Data
public class ChatRequest {
/**
* 会话 ID(为空则创建新会话)
*/
private Long sessionId;
/**
* 用户消息内容
*/
private String message;
/**
* 模型覆盖(可选,不传则使用默认配置)
*/
private String model;
/**
* 消息历史记录(供 AI 上下文使用)
*/
@Data
public static class MessageItem {
private String role;
private String content;
}
}
@@ -0,0 +1,29 @@
package com.chat.model.dto;
import lombok.Data;
import lombok.AllArgsConstructor;
import lombok.NoArgsConstructor;
/**
* 聊天响应 DTO
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class ChatResponse {
/**
* 会话 ID
*/
private Long sessionId;
/**
* AI 回复的完整内容
*/
private String content;
/**
* 消息 ID
*/
private Long messageId;
}
@@ -0,0 +1,171 @@
package com.chat.service;
import com.chat.config.AiConfig;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
/**
* AI 模型调用服务 — 支持 OpenAI 兼容格式的流式调用
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AiService {
private final AiConfig aiConfig;
private final ObjectMapper objectMapper = new ObjectMapper();
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(30))
.build();
/**
* 流式调用 AI 模型
*
* @param messages 消息历史
* @param onContent 每次收到内容片段时的回调
* @param onError 发生错误时的回调
* @param onComplete 生成完成时的回调
*/
public void streamChat(List<Map<String, String>> messages,
Consumer<String> onContent,
Consumer<String> onError,
Runnable onComplete) {
try {
// 构建请求体
String requestBody = buildRequestBody(messages);
log.debug("发送 AI 请求: model={}, messages={}", aiConfig.getModel(), messages.size());
// 构建 HTTP 请求
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(aiConfig.getBaseUrl() + "/chat/completions"))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + aiConfig.getApiKey())
.POST(HttpRequest.BodyPublishers.ofString(requestBody))
.timeout(Duration.ofMinutes(5))
.build();
// 发送请求并流式读取响应
HttpResponse<java.io.InputStream> response = httpClient.send(
request, HttpResponse.BodyHandlers.ofInputStream());
if (response.statusCode() != 200) {
String errorBody = new String(response.body().readAllBytes(), StandardCharsets.UTF_8);
log.error("AI API 返回错误: status={}, body={}", response.statusCode(), errorBody);
onError.accept("AI 服务返回错误 (" + response.statusCode() + "): " + extractErrorMessage(errorBody));
return;
}
// 逐行读取 SSE 数据
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(response.body(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
if (line.startsWith("data: ")) {
String data = line.substring(6).trim();
if ("[DONE]".equals(data)) {
break;
}
// 解析 JSON 提取 content
String content = extractContent(data);
if (content != null && !content.isEmpty()) {
onContent.accept(content);
}
}
}
}
onComplete.run();
log.debug("AI 响应完成");
} catch (Exception e) {
log.error("调用 AI 服务失败", e);
onError.accept("调用 AI 服务失败: " + e.getMessage());
}
}
/**
* 构建 OpenAI 兼容格式的请求体
*/
private String buildRequestBody(List<Map<String, String>> messages) {
try {
ObjectNode root = objectMapper.createObjectNode();
root.put("model", aiConfig.getModel());
root.put("stream", true);
root.put("temperature", aiConfig.getTemperature());
root.put("max_tokens", aiConfig.getMaxTokens());
ArrayNode messagesNode = root.putArray("messages");
// 添加系统提示词
ObjectNode systemMsg = messagesNode.addObject();
systemMsg.put("role", "system");
systemMsg.put("content", aiConfig.getSystemPrompt());
// 添加历史消息
for (Map<String, String> msg : messages) {
ObjectNode msgNode = messagesNode.addObject();
msgNode.put("role", msg.get("role"));
msgNode.put("content", msg.get("content"));
}
return objectMapper.writeValueAsString(root);
} catch (Exception e) {
throw new RuntimeException("构建请求体失败", e);
}
}
/**
* 从 SSE 数据中提取 content 字段
*/
private String extractContent(String jsonData) {
try {
JsonNode root = objectMapper.readTree(jsonData);
JsonNode choices = root.get("choices");
if (choices != null && choices.isArray() && !choices.isEmpty()) {
JsonNode delta = choices.get(0).get("delta");
if (delta != null && delta.has("content")) {
return delta.get("content").asText();
}
}
return null;
} catch (Exception e) {
log.warn("解析 SSE 数据失败: {}", jsonData);
return null;
}
}
/**
* 提取错误响应中的消息
*/
private String extractErrorMessage(String errorBody) {
try {
JsonNode root = objectMapper.readTree(errorBody);
if (root.has("error")) {
JsonNode error = root.get("error");
if (error.has("message")) {
return error.get("message").asText();
}
}
return errorBody;
} catch (Exception e) {
return errorBody;
}
}
}
@@ -0,0 +1,224 @@
package com.chat.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.chat.model.ChatMessage;
import com.chat.model.ChatSession;
import com.chat.model.dto.ChatRequest;
import com.chat.mapper.ChatMessageMapper;
import com.chat.mapper.ChatSessionMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.*;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* 聊天业务逻辑服务
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ChatService {
private final AiService aiService;
private final ChatSessionMapper sessionMapper;
private final ChatMessageMapper messageMapper;
// 用于异步执行 AI 调用的线程池
private final ExecutorService executor = Executors.newCachedThreadPool();
/**
* 获取所有会话(按更新时间倒序)
*/
public List<ChatSession> getAllSessions() {
return sessionMapper.selectList(
new LambdaQueryWrapper<ChatSession>()
.orderByDesc(ChatSession::getUpdatedAt));
}
/**
* 创建新会话
*/
public ChatSession createSession(String title) {
ChatSession session = new ChatSession();
session.setTitle(title != null && !title.isBlank() ? title : "新对话");
sessionMapper.insert(session);
return session;
}
/**
* 更新会话标题
*/
public ChatSession updateSessionTitle(Long sessionId, String title) {
ChatSession session = sessionMapper.selectById(sessionId);
if (session == null) {
throw new RuntimeException("会话不存在: " + sessionId);
}
session.setTitle(title);
sessionMapper.updateById(session);
return session;
}
/**
* 删除会话及其所有消息
*/
@Transactional
public void deleteSession(Long sessionId) {
messageMapper.delete(
new LambdaQueryWrapper<ChatMessage>()
.eq(ChatMessage::getSessionId, sessionId));
sessionMapper.deleteById(sessionId);
log.info("删除会话: id={}", sessionId);
}
/**
* 获取指定会话的消息历史
*/
public List<ChatMessage> getSessionMessages(Long sessionId) {
return messageMapper.selectList(
new LambdaQueryWrapper<ChatMessage>()
.eq(ChatMessage::getSessionId, sessionId)
.orderByAsc(ChatMessage::getCreatedAt));
}
/**
* 发送消息并获取 AI 流式回复
*/
public SseEmitter sendMessage(ChatRequest request) {
// SSE 超时时间 5 分钟
SseEmitter emitter = new SseEmitter(300_000L);
executor.execute(() -> {
try {
// ========== 阶段1DB 操作(快速完成,释放连接) ==========
// 如果没有会话 ID,创建新会话
Long sessionId = request.getSessionId();
if (sessionId == null) {
ChatSession session = createSession(
truncate(request.getMessage(), 20));
sessionId = session.getId();
}
// 发送会话 ID 给前端
final Long finalSessionId = sessionId;
emitter.send(SseEmitter.event()
.name("session")
.data("{\"sessionId\":" + finalSessionId + "}"));
// 保存用户消息
ChatMessage userMsg = new ChatMessage();
userMsg.setSessionId(finalSessionId);
userMsg.setRole("user");
userMsg.setContent(request.getMessage());
messageMapper.insert(userMsg);
// 构建消息历史(供 AI 上下文),查完立即释放连接
List<ChatMessage> history = messageMapper.selectList(
new LambdaQueryWrapper<ChatMessage>()
.eq(ChatMessage::getSessionId, finalSessionId)
.orderByAsc(ChatMessage::getCreatedAt));
List<Map<String, String>> messages = new ArrayList<>();
for (ChatMessage msg : history) {
messages.add(Map.of(
"role", msg.getRole(),
"content", msg.getContent()));
}
// ========== 阶段2AI 流式调用(不占用 DB 连接) ==========
// 用于收集 AI 回复的完整内容
StringBuilder fullContent = new StringBuilder();
// 调用 AI 服务(流式)
aiService.streamChat(
messages,
// onContent: 每次收到内容片段
content -> {
try {
fullContent.append(content);
emitter.send(SseEmitter.event()
.name("message")
.data(content, org.springframework.http.MediaType.TEXT_PLAIN));
} catch (IOException e) {
log.error("发送 SSE 数据失败", e);
}
},
// onError: 发生错误
error -> {
try {
emitter.send(SseEmitter.event()
.name("error")
.data(error));
emitter.complete();
} catch (IOException e) {
emitter.completeWithError(e);
}
},
// onComplete: 生成完成 — 此时再短暂获取连接保存结果
() -> {
try {
// 保存 AI 回复消息
ChatMessage assistantMsg = new ChatMessage();
assistantMsg.setSessionId(finalSessionId);
assistantMsg.setRole("assistant");
assistantMsg.setContent(fullContent.toString());
messageMapper.insert(assistantMsg);
// 更新会话时间
ChatSession session = sessionMapper.selectById(finalSessionId);
if (session != null) {
sessionMapper.updateById(session);
}
// 发送完成信号
emitter.send(SseEmitter.event()
.name("done")
.data(""));
emitter.complete();
} catch (Exception e) {
log.error("保存 AI 回复失败", e);
try {
emitter.send(SseEmitter.event()
.name("done")
.data(""));
emitter.complete();
} catch (IOException ex) {
emitter.completeWithError(ex);
}
}
});
} catch (Exception e) {
log.error("处理聊天请求失败", e);
try {
emitter.send(SseEmitter.event()
.name("error")
.data("服务器内部错误: " + e.getMessage()));
emitter.complete();
} catch (IOException ex) {
emitter.completeWithError(ex);
}
}
});
// 超时和错误回调
emitter.onTimeout(() -> log.warn("SSE 连接超时"));
emitter.onError(e -> log.error("SSE 连接错误", e));
return emitter;
}
/**
* 截断字符串
*/
private String truncate(String str, int maxLen) {
if (str == null) return "新对话";
return str.length() > maxLen ? str.substring(0, maxLen) + "..." : str;
}
}
@@ -0,0 +1,47 @@
server:
port: 8080
spring:
datasource:
url: jdbc:mysql://64.90.11.73:33306/ai_chat?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
driver-class-name: com.mysql.cj.jdbc.Driver
username: root
password: qemzCSBQ8251
hikari:
maximum-pool-size: 10
connection-timeout: 30000
idle-timeout: 300000
mybatis-plus:
configuration:
# 开启驼峰命名自动映射(例如 session_id → sessionId
map-underscore-to-camel-case: true
# 开发阶段可开启 SQL 日志
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
# 主键自增策略
id-type: auto
# AI 模型配置 — 修改此处对接不同的大模型
ai:
# API 基础地址(需包含版本号路径)
# 智谱AI: https://open.bigmodel.cn/api/paas/v4
# OpenAI: https://api.openai.com/v1
# DeepSeek: https://api.deepseek.com/v1
# 通义千问: https://dashscope.aliyuncs.com/compatible-mode/v1
base-url: https://open.bigmodel.cn/api/paas/v4
# 你的 API Key(在 https://open.bigmodel.cn 获取)
api-key: a966a7f5cd2b4764b9d1799928617443.0b0T97kwwrqmhLUN
# 模型名称
# 智谱AI: glm-4-flash(免费)/ glm-4 / glm-4-plus
# OpenAI: gpt-4o / gpt-3.5-turbo
# DeepSeek: deepseek-chat
# 通义千问: qwen-plus / qwen-turbo
model: glm-4-flash
temperature: 0.7
max-tokens: 4096
system-prompt: "你是一个有帮助的 AI 助手,请用中文回答问题。回答时可以使用 Markdown 格式。"
logging:
level:
com.chat: DEBUG
+18
View File
@@ -0,0 +1,18 @@
-- AI Chat 数据库初始化脚本
-- 如果之前用 JPA 已自动建表,可跳过此脚本
CREATE TABLE IF NOT EXISTS chat_session (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '会话ID',
title VARCHAR(255) NOT NULL COMMENT '会话标题',
created_at DATETIME NOT NULL COMMENT '创建时间',
updated_at DATETIME NOT NULL COMMENT '更新时间'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='聊天会话表';
CREATE TABLE IF NOT EXISTS chat_message (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '消息ID',
session_id BIGINT NOT NULL COMMENT '所属会话ID',
role VARCHAR(32) NOT NULL COMMENT '角色:user/assistant/system',
content TEXT NOT NULL COMMENT '消息内容',
created_at DATETIME NOT NULL COMMENT '创建时间',
INDEX idx_session_id (session_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='聊天消息表';
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#667eea;stop-opacity:1" />
<stop offset="100%" style="stop-color:#764ba2;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="100" height="100" rx="20" fill="url(#grad)"/>
<text x="50" y="68" font-family="Arial" font-size="50" font-weight="bold" fill="white" text-anchor="middle">AI</text>
</svg>

After

Width:  |  Height:  |  Size: 499 B

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="AI 智能聊天助手 — 基于大语言模型的智能对话平台" />
<title>AI Chat — 智能聊天助手</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<script type="module" crossorigin src="/assets/index-B9xW_h2o.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-QWsKLjsn.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
+17
View File
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="AI 智能聊天助手 — 基于大语言模型的智能对话平台" />
<title>AI Chat — 智能聊天助手</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
server {
listen 80;
server_name localhost;
# 静态资源处理
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html; # 支持单页应用前端路由
}
# 代理后端 API 请求
location /api/ {
# 注意:这里需要替换为你后端服务的实际地址。如果是同一台服务器上的后端,通常可以用服务名(如果在同一docker网络)或者服务器内网/公网IP
proxy_pass http://64.90.11.73:8080/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# SSE 流式响应支持配置
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
}
+1012
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
{
"name": "chat-web",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"highlight.js": "^11.9.0",
"marked": "^12.0.2"
},
"devDependencies": {
"vite": "^5.2.0"
}
}
+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#667eea;stop-opacity:1" />
<stop offset="100%" style="stop-color:#764ba2;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="100" height="100" rx="20" fill="url(#grad)"/>
<text x="50" y="68" font-family="Arial" font-size="50" font-weight="bold" fill="white" text-anchor="middle">AI</text>
</svg>

After

Width:  |  Height:  |  Size: 499 B

+221
View File
@@ -0,0 +1,221 @@
/**
* 应用主逻辑 — 组合所有组件,协调交互流程
*/
import { Sidebar } from './components/sidebar.js';
import { ChatArea } from './components/chatArea.js';
import { InputBar } from './components/inputBar.js';
import { sendMessageStream } from './services/api.js';
import { setupCodeCopy } from './utils/markdown.js';
export class App {
constructor() {
this.sidebar = null;
this.chatArea = null;
this.inputBar = null;
this.currentSessionId = null;
this.currentController = null; // 用于取消正在进行的请求
this.streamingContent = '';
}
/**
* 初始化应用
*/
init() {
const appEl = document.getElementById('app');
// 注册全局代码复制功能
setupCodeCopy();
// 创建侧边栏
this.sidebar = new Sidebar({
onSelectSession: (sessionId) => this.handleSelectSession(sessionId),
onNewChat: () => this.handleNewChat(),
});
// 创建聊天区域
this.chatArea = new ChatArea();
// 创建输入栏
this.inputBar = new InputBar({
onSend: (message) => this.handleSendMessage(message),
onStop: () => this.handleStopGeneration(),
});
// 组装界面
appEl.appendChild(this.sidebar.render());
// 移动端遮罩层
const overlay = document.createElement('div');
overlay.className = 'sidebar-overlay';
overlay.id = 'sidebar-overlay';
overlay.addEventListener('click', () => this.sidebar.close());
appEl.appendChild(overlay);
// 聊天主区域
const chatMain = this.chatArea.render();
chatMain.appendChild(this.inputBar.render());
appEl.appendChild(chatMain);
// 设置建议卡片点击回调
this.chatArea.setSuggestionCallback((suggestion) => {
this.handleSendMessage(suggestion);
});
// 移动端菜单按钮
document.getElementById('btn-menu')?.addEventListener('click', () => {
this.sidebar.open();
});
// 输入框自动聚焦
this.inputBar.focus();
}
/**
* 选择会话
*/
async handleSelectSession(sessionId) {
// 如果正在生成,先停止
if (this.currentController) {
this.handleStopGeneration();
}
this.currentSessionId = sessionId;
// 找到会话标题
const session = this.sidebar.sessions.find((s) => s.id === sessionId);
if (session) {
this.chatArea.updateTitle(session.title);
}
// 加载消息历史
await this.chatArea.loadSessionMessages(sessionId);
// 重新绑定建议卡片回调
this.chatArea.setSuggestionCallback((suggestion) => {
this.handleSendMessage(suggestion);
});
}
/**
* 新建对话
*/
handleNewChat() {
// 如果正在生成,先停止
if (this.currentController) {
this.handleStopGeneration();
}
this.currentSessionId = null;
this.sidebar.setActive(null);
this.chatArea.showWelcome();
// 重新绑定建议卡片回调
this.chatArea.setSuggestionCallback((suggestion) => {
this.handleSendMessage(suggestion);
});
this.inputBar.focus();
}
/**
* 发送消息
*/
handleSendMessage(message) {
if (!message.trim()) return;
// 显示用户消息
this.chatArea.addUserMessage(message);
// 显示加载动画
this.chatArea.addLoadingMessage();
// 进入生成状态
this.inputBar.setGenerating(true);
this.streamingContent = '';
// 发送请求(SSE 流式)
this.currentController = sendMessageStream({
sessionId: this.currentSessionId,
message: message,
// 收到会话信息
onSession: (data) => {
this.currentSessionId = data.sessionId;
// 更新侧边栏
this.sidebar.addSession({
id: data.sessionId,
title: message.length > 20 ? message.substring(0, 20) + '...' : message,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
},
// 收到消息片段
onMessage: (content) => {
// 第一次收到内容时,替换加载动画为流式消息
if (this.streamingContent === '') {
this.chatArea.startStreamingMessage();
}
this.streamingContent += content;
this.chatArea.updateStreamingContent(this.streamingContent);
},
// 发生错误
onError: (error) => {
console.error('聊天错误:', error);
this.chatArea.removeStreamingMessage();
// 显示错误消息
const errorMsg = { role: 'assistant', content: `⚠️ **错误**: ${error}` };
const el = document.createElement('div');
el.className = 'message assistant';
el.innerHTML = `
<div class="message-avatar">⚠️</div>
<div class="message-body">
<div class="message-role">系统提示</div>
<div class="message-content" style="color: #ff6b6b;">${error}</div>
</div>
`;
this.chatArea.messagesContainer.appendChild(el);
this.chatArea.scrollToBottom();
this.inputBar.setGenerating(false);
this.currentController = null;
},
// 完成
onDone: () => {
if (this.streamingContent) {
this.chatArea.finishStreaming(this.streamingContent);
}
this.inputBar.setGenerating(false);
this.currentController = null;
// 刷新侧边栏会话列表
this.sidebar.loadSessions();
},
});
}
/**
* 停止生成
*/
handleStopGeneration() {
if (this.currentController) {
this.currentController.abort();
this.currentController = null;
}
// 如果有流式内容,保留当前已生成的内容
if (this.streamingContent) {
this.chatArea.finishStreaming(this.streamingContent + '\n\n*(已停止生成)*');
} else {
this.chatArea.removeStreamingMessage();
}
this.inputBar.setGenerating(false);
}
}
+229
View File
@@ -0,0 +1,229 @@
/**
* 聊天区域组件 — 消息列表和欢迎页面
*/
import { getMessages } from '../services/api.js';
import { createMessageElement, createLoadingMessage, updateMessageContent } from './messageItem.js';
export class ChatArea {
constructor() {
this.el = null;
this.messagesContainer = null;
this.currentMessages = [];
}
/**
* 渲染聊天区域
*/
render() {
const main = document.createElement('main');
main.className = 'chat-main';
main.innerHTML = `
<header class="chat-header">
<div style="display: flex; align-items: center; gap: 12px;">
<button class="btn-menu" id="btn-menu">☰</button>
<h1 class="chat-header-title" id="chat-title">新对话</h1>
</div>
<span class="chat-header-model" id="chat-model">AI Chat</span>
</header>
<div class="messages-container" id="messages-container">
${this.renderWelcome()}
</div>
`;
this.el = main;
this.messagesContainer = main.querySelector('#messages-container');
return main;
}
/**
* 渲染欢迎页面
*/
renderWelcome() {
return `
<div class="welcome-screen" id="welcome-screen">
<div class="welcome-icon">✨</div>
<h2 class="welcome-title">你好,有什么可以帮你的?</h2>
<p class="welcome-subtitle">我是你的 AI 智能助手,可以帮你解答问题、编写代码、翻译文档等</p>
<div class="welcome-suggestions">
<div class="suggestion-card" data-suggestion="帮我解释一下什么是机器学习">
<div class="suggestion-card-icon">🧠</div>
<div class="suggestion-card-text">帮我解释一下什么是机器学习</div>
</div>
<div class="suggestion-card" data-suggestion="用 Java 写一个快速排序算法">
<div class="suggestion-card-icon">💻</div>
<div class="suggestion-card-text">用 Java 写一个快速排序算法</div>
</div>
<div class="suggestion-card" data-suggestion="帮我翻译以下内容为英文">
<div class="suggestion-card-icon">🌍</div>
<div class="suggestion-card-text">帮我翻译以下内容为英文</div>
</div>
<div class="suggestion-card" data-suggestion="推荐一些提高工作效率的方法">
<div class="suggestion-card-icon">🚀</div>
<div class="suggestion-card-text">推荐一些提高工作效率的方法</div>
</div>
</div>
</div>
`;
}
/**
* 显示欢迎页面
*/
showWelcome() {
this.messagesContainer.innerHTML = this.renderWelcome();
this.currentMessages = [];
this.updateTitle('新对话');
// 绑定建议卡片点击事件
this.bindSuggestionEvents();
}
/**
* 绑定建议卡片事件
*/
bindSuggestionEvents() {
this.messagesContainer.querySelectorAll('.suggestion-card').forEach((card) => {
card.addEventListener('click', () => {
const suggestion = card.dataset.suggestion;
if (this.onSuggestionClick) {
this.onSuggestionClick(suggestion);
}
});
});
}
/**
* 设置建议卡片点击回调
*/
setSuggestionCallback(callback) {
this.onSuggestionClick = callback;
this.bindSuggestionEvents();
}
/**
* 加载会话的历史消息
*/
async loadSessionMessages(sessionId) {
try {
const messages = await getMessages(sessionId);
this.currentMessages = messages;
// 清空消息容器
this.messagesContainer.innerHTML = '';
if (messages.length === 0) {
this.messagesContainer.innerHTML = this.renderWelcome();
this.bindSuggestionEvents();
return;
}
// 渲染每条消息
messages.forEach((msg) => {
const el = createMessageElement(msg);
this.messagesContainer.appendChild(el);
});
this.scrollToBottom();
} catch (e) {
console.error('加载消息历史失败:', e);
}
}
/**
* 添加用户消息
*/
addUserMessage(content) {
// 移除欢迎页面
const welcome = this.messagesContainer.querySelector('#welcome-screen');
if (welcome) {
welcome.remove();
}
const msg = { role: 'user', content };
const el = createMessageElement(msg);
this.messagesContainer.appendChild(el);
this.scrollToBottom();
}
/**
* 添加 AI 加载中占位
*/
addLoadingMessage() {
const el = createLoadingMessage();
el.id = 'loading-message';
this.messagesContainer.appendChild(el);
this.scrollToBottom();
return el;
}
/**
* 移除加载占位,开始显示流式内容
*/
startStreamingMessage() {
const loading = this.messagesContainer.querySelector('#loading-message');
if (loading) {
loading.remove();
}
const msg = { role: 'assistant', content: '', isStreaming: true };
const el = createMessageElement(msg);
el.id = 'streaming-message';
this.messagesContainer.appendChild(el);
this.scrollToBottom();
return el;
}
/**
* 更新流式输出内容
*/
updateStreamingContent(content) {
const el = this.messagesContainer.querySelector('#streaming-message');
if (el) {
updateMessageContent(el, content, true);
this.scrollToBottom();
}
}
/**
* 完成流式输出
*/
finishStreaming(content) {
const el = this.messagesContainer.querySelector('#streaming-message');
if (el) {
el.id = '';
updateMessageContent(el, content, false);
}
}
/**
* 移除流式消息(如取消时)
*/
removeStreamingMessage() {
const loading = this.messagesContainer.querySelector('#loading-message');
if (loading) loading.remove();
const streaming = this.messagesContainer.querySelector('#streaming-message');
if (streaming) streaming.remove();
}
/**
* 更新标题
*/
updateTitle(title) {
const titleEl = document.getElementById('chat-title');
if (titleEl) {
titleEl.textContent = title;
}
}
/**
* 滚动到底部
*/
scrollToBottom() {
requestAnimationFrame(() => {
this.messagesContainer.scrollTop = this.messagesContainer.scrollHeight;
});
}
}
+130
View File
@@ -0,0 +1,130 @@
/**
* 输入栏组件 — 消息输入和发送控制
*/
export class InputBar {
constructor({ onSend, onStop }) {
this.onSend = onSend;
this.onStop = onStop;
this.el = null;
this.textarea = null;
this.sendBtn = null;
this.isGenerating = false;
}
/**
* 渲染输入栏
*/
render() {
const wrapper = document.createElement('div');
wrapper.className = 'input-area';
wrapper.innerHTML = `
<div class="input-wrapper" id="input-wrapper">
<textarea
class="input-textarea"
id="input-textarea"
placeholder="输入你的问题..."
rows="1"
></textarea>
<button class="btn-send" id="btn-send" title="发送消息">
<span id="send-icon">➤</span>
</button>
</div>
<div class="input-hint">
按 Enter 发送,Shift + Enter 换行
</div>
`;
this.el = wrapper;
this.textarea = wrapper.querySelector('#input-textarea');
this.sendBtn = wrapper.querySelector('#btn-send');
this.bindEvents();
return wrapper;
}
/**
* 绑定事件
*/
bindEvents() {
// 自动调整高度
this.textarea.addEventListener('input', () => {
this.autoResize();
});
// 键盘快捷键
this.textarea.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (this.isGenerating) return;
this.handleSend();
}
});
// 发送/停止按钮
this.sendBtn.addEventListener('click', () => {
if (this.isGenerating) {
this.onStop && this.onStop();
} else {
this.handleSend();
}
});
}
/**
* 处理发送
*/
handleSend() {
const message = this.textarea.value.trim();
if (!message) return;
this.onSend && this.onSend(message);
this.textarea.value = '';
this.autoResize();
}
/**
* 自动调整输入框高度
*/
autoResize() {
this.textarea.style.height = 'auto';
this.textarea.style.height = Math.min(this.textarea.scrollHeight, 150) + 'px';
}
/**
* 设置为生成中状态
*/
setGenerating(isGenerating) {
this.isGenerating = isGenerating;
const icon = this.sendBtn.querySelector('#send-icon');
if (isGenerating) {
this.sendBtn.classList.add('btn-stop');
icon.textContent = '⏹';
this.textarea.disabled = true;
this.textarea.placeholder = 'AI 正在思考中...';
} else {
this.sendBtn.classList.remove('btn-stop');
icon.textContent = '➤';
this.textarea.disabled = false;
this.textarea.placeholder = '输入你的问题...';
this.textarea.focus();
}
}
/**
* 设置输入内容
*/
setValue(text) {
this.textarea.value = text;
this.autoResize();
}
/**
* 聚焦输入框
*/
focus() {
this.textarea.focus();
}
}
+82
View File
@@ -0,0 +1,82 @@
/**
* 消息气泡组件 — 渲染单条消息
*/
import { renderMarkdown } from '../utils/markdown.js';
/**
* 创建消息元素
*
* @param {Object} msg - 消息对象
* @param {string} msg.role - 消息角色 (user/assistant)
* @param {string} msg.content - 消息内容
* @param {boolean} msg.isStreaming - 是否正在流式输出
* @returns {HTMLElement} 消息 DOM 元素
*/
export function createMessageElement(msg) {
const div = document.createElement('div');
div.className = `message ${msg.role}`;
const avatar = msg.role === 'user' ? '👤' : '✨';
const roleLabel = msg.role === 'user' ? '你' : 'AI 助手';
div.innerHTML = `
<div class="message-avatar">${avatar}</div>
<div class="message-body">
<div class="message-role">${roleLabel}</div>
<div class="message-content ${msg.isStreaming ? 'typing-cursor' : ''}">
${msg.role === 'assistant' ? renderMarkdown(msg.content) : escapeHtml(msg.content)}
</div>
</div>
`;
return div;
}
/**
* 更新消息内容(用于流式输出)
*
* @param {HTMLElement} el - 消息 DOM 元素
* @param {string} content - 最新的完整内容
* @param {boolean} isStreaming - 是否仍在流式输出
*/
export function updateMessageContent(el, content, isStreaming) {
const contentEl = el.querySelector('.message-content');
if (!contentEl) return;
contentEl.innerHTML = renderMarkdown(content);
if (isStreaming) {
contentEl.classList.add('typing-cursor');
} else {
contentEl.classList.remove('typing-cursor');
}
}
/**
* 创建加载中的消息占位元素
*/
export function createLoadingMessage() {
const div = document.createElement('div');
div.className = 'message assistant';
div.innerHTML = `
<div class="message-avatar">✨</div>
<div class="message-body">
<div class="message-role">AI 助手</div>
<div class="message-content">
<div class="loading-dots">
<span></span><span></span><span></span>
</div>
</div>
</div>
`;
return div;
}
/**
* HTML 转义
*/
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
+228
View File
@@ -0,0 +1,228 @@
/**
* 侧边栏组件 — 会话列表管理
*/
import { getSessions, createSession, deleteSession, updateSession } from '../services/api.js';
export class Sidebar {
constructor({ onSelectSession, onNewChat }) {
this.sessions = [];
this.activeSessionId = null;
this.onSelectSession = onSelectSession;
this.onNewChat = onNewChat;
this.el = null;
}
/**
* 渲染侧边栏
*/
render() {
const sidebar = document.createElement('aside');
sidebar.className = 'sidebar';
sidebar.id = 'sidebar';
sidebar.innerHTML = `
<div class="sidebar-header">
<div class="sidebar-logo">
<div class="sidebar-logo-icon">AI</div>
<span>AI Chat</span>
</div>
</div>
<button class="btn-new-chat" id="btn-new-chat">
<span>✨</span> 新建对话
</button>
<div class="session-list" id="session-list"></div>
<div class="sidebar-footer">
<div class="sidebar-footer-info">
基于大语言模型的智能对话
</div>
</div>
`;
this.el = sidebar;
// 绑定新建对话按钮事件
sidebar.querySelector('#btn-new-chat').addEventListener('click', () => {
this.onNewChat && this.onNewChat();
});
// 加载会话列表
this.loadSessions();
return sidebar;
}
/**
* 加载会话列表
*/
async loadSessions() {
try {
this.sessions = await getSessions();
this.renderSessionList();
} catch (e) {
console.error('加载会话列表失败:', e);
}
}
/**
* 渲染会话列表
*/
renderSessionList() {
const listEl = this.el.querySelector('#session-list');
if (!listEl) return;
if (this.sessions.length === 0) {
listEl.innerHTML = `
<div style="padding: 40px 16px; text-align: center; color: var(--text-tertiary); font-size: 13px;">
暂无对话记录<br>点击上方按钮开始
</div>
`;
return;
}
listEl.innerHTML = this.sessions
.map(
(session) => `
<div class="session-item ${session.id === this.activeSessionId ? 'active' : ''}"
data-session-id="${session.id}">
<span class="session-item-icon">💬</span>
<span class="session-item-title">${this.escapeHtml(session.title)}</span>
<div class="session-item-actions">
<button class="session-item-btn edit" data-action="edit" data-id="${session.id}" title="重命名">✏️</button>
<button class="session-item-btn delete" data-action="delete" data-id="${session.id}" title="删除">🗑️</button>
</div>
</div>
`
)
.join('');
// 绑定事件
listEl.querySelectorAll('.session-item').forEach((item) => {
item.addEventListener('click', (e) => {
// 如果点击了操作按钮,不触发选择
if (e.target.closest('.session-item-btn')) return;
const sessionId = parseInt(item.dataset.sessionId);
this.selectSession(sessionId);
});
});
listEl.querySelectorAll('.session-item-btn').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const action = btn.dataset.action;
const id = parseInt(btn.dataset.id);
if (action === 'delete') {
this.handleDelete(id);
} else if (action === 'edit') {
this.handleEdit(id);
}
});
});
}
/**
* 选择会话
*/
selectSession(sessionId) {
this.activeSessionId = sessionId;
this.renderSessionList();
this.onSelectSession && this.onSelectSession(sessionId);
// 移动端关闭侧边栏
if (window.innerWidth <= 768) {
this.close();
}
}
/**
* 处理删除会话
*/
async handleDelete(sessionId) {
if (!confirm('确定要删除这个对话吗?')) return;
try {
await deleteSession(sessionId);
this.sessions = this.sessions.filter((s) => s.id !== sessionId);
// 如果删除的是当前活动会话,清空聊天区域
if (this.activeSessionId === sessionId) {
this.activeSessionId = null;
this.onNewChat && this.onNewChat();
}
this.renderSessionList();
} catch (e) {
console.error('删除会话失败:', e);
alert('删除失败: ' + e.message);
}
}
/**
* 处理重命名会话
*/
async handleEdit(sessionId) {
const session = this.sessions.find((s) => s.id === sessionId);
if (!session) return;
const newTitle = prompt('请输入新的对话名称:', session.title);
if (!newTitle || newTitle === session.title) return;
try {
await updateSession(sessionId, newTitle);
session.title = newTitle;
this.renderSessionList();
} catch (e) {
console.error('重命名会话失败:', e);
alert('重命名失败: ' + e.message);
}
}
/**
* 添加会话到列表顶部
*/
addSession(session) {
// 检查是否已存在
const existingIndex = this.sessions.findIndex((s) => s.id === session.id);
if (existingIndex >= 0) {
this.sessions[existingIndex] = session;
} else {
this.sessions.unshift(session);
}
this.activeSessionId = session.id;
this.renderSessionList();
}
/**
* 设置当前活动会话
*/
setActive(sessionId) {
this.activeSessionId = sessionId;
this.renderSessionList();
}
/**
* 移动端打开侧边栏
*/
open() {
this.el.classList.add('open');
document.getElementById('sidebar-overlay')?.classList.add('show');
}
/**
* 移动端关闭侧边栏
*/
close() {
this.el.classList.remove('open');
document.getElementById('sidebar-overlay')?.classList.remove('show');
}
/**
* HTML 转义
*/
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
+11
View File
@@ -0,0 +1,11 @@
/**
* 应用入口 — 初始化样式和应用
*/
import './style.css';
import { App } from './app.js';
// DOM 加载完成后初始化应用
document.addEventListener('DOMContentLoaded', () => {
const app = new App();
app.init();
});
+269
View File
@@ -0,0 +1,269 @@
/**
* API 调用封装 — 与后端通信的统一接口
*/
const BASE_URL = '/api';
/**
* 通用 fetch 请求封装
*/
async function request(url, options = {}) {
const response = await fetch(BASE_URL + url, {
headers: {
'Content-Type': 'application/json',
},
...options,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`请求失败 (${response.status}): ${errorText}`);
}
return response.json();
}
/**
* 获取所有会话列表
*/
export async function getSessions() {
return request('/sessions');
}
/**
* 创建新会话
*/
export async function createSession(title) {
return request('/sessions', {
method: 'POST',
body: JSON.stringify({ title }),
});
}
/**
* 更新会话标题
*/
export async function updateSession(id, title) {
return request(`/sessions/${id}`, {
method: 'PUT',
body: JSON.stringify({ title }),
});
}
/**
* 删除会话
*/
export async function deleteSession(id) {
return request(`/sessions/${id}`, {
method: 'DELETE',
});
}
/**
* 获取会话消息历史
*/
export async function getMessages(sessionId) {
return request(`/sessions/${sessionId}/messages`);
}
/**
* 发送聊天消息(SSE 流式接收)
*
* @param {Object} params - 请求参数
* @param {number|null} params.sessionId - 会话 ID
* @param {string} params.message - 用户消息
* @param {function} params.onSession - 收到会话信息时的回调
* @param {function} params.onMessage - 收到消息片段时的回调
* @param {function} params.onError - 发生错误时的回调
* @param {function} params.onDone - 完成时的回调
* @returns {AbortController} 用于取消请求的控制器
*/
export function sendMessage({ sessionId, message, onSession, onMessage, onError, onDone }) {
const controller = new AbortController();
fetch(BASE_URL + '/chat/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
},
body: JSON.stringify({ sessionId, message }),
signal: controller.signal,
})
.then(async (response) => {
if (!response.ok) {
const errorText = await response.text();
onError(`请求失败 (${response.status}): ${errorText}`);
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
// 保留最后一行(可能不完整)
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('event:')) {
// 解析事件类型
const eventType = line.substring(6).trim();
continue;
}
if (line.startsWith('data:')) {
const data = line.substring(5).trim();
// 需要结合上一个 event 类型来处理
// 但 SseEmitter 发送的格式是 event:xxx\ndata:xxx\n\n
// 所以我们需要追踪 event 类型
}
}
// 使用更简单的方式解析 SSE:直接按双换行分割事件
// 实际上让我重新实现解析逻辑
}
onDone();
})
.catch((err) => {
if (err.name !== 'AbortError') {
onError(err.message);
}
});
return controller;
}
/**
* 发送聊天消息(SSE 流式接收 — 改进版)
* 使用 EventSource 兼容的手动解析
*/
export function sendMessageStream({ sessionId, message, onSession, onMessage, onError, onDone }) {
const controller = new AbortController();
fetch(BASE_URL + '/chat/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
},
body: JSON.stringify({ sessionId, message }),
signal: controller.signal,
})
.then(async (response) => {
if (!response.ok) {
const errorText = await response.text();
onError(`请求失败 (${response.status}): ${errorText}`);
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
// 当前事件的类型和数据行(SSE 规范:同一事件的多个 data: 行用 \n 连接)
let currentEvent = '';
let dataLines = [];
/**
* 分发一个完整的 SSE 事件
* SSE 规范:多个 data: 行之间用 \n 连接成完整数据
*/
const dispatchEvent = () => {
if (dataLines.length === 0) {
currentEvent = '';
return;
}
// 按 SSE 规范,多行 data 用换行符连接
const data = dataLines.join('\n');
dataLines = [];
switch (currentEvent) {
case 'session':
try {
const sessionData = JSON.parse(data);
onSession && onSession(sessionData);
} catch (e) {
console.error('解析 session 数据失败:', e);
}
break;
case 'message':
onMessage && onMessage(data);
break;
case 'error':
onError && onError(data);
break;
case 'done':
onDone && onDone();
break;
default:
// 未知事件类型的 data,尝试作为消息处理
if (data.trim()) {
onMessage && onMessage(data);
}
break;
}
currentEvent = '';
};
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// 按行处理 SSE 数据
while (buffer.includes('\n')) {
const newlineIndex = buffer.indexOf('\n');
const line = buffer.substring(0, newlineIndex).replace(/\r$/, '');
buffer = buffer.substring(newlineIndex + 1);
if (line === '') {
// 空行表示当前事件结束,分发事件
dispatchEvent();
continue;
}
if (line.startsWith('event:')) {
// 如果上一个事件有未分发的数据,先分发
if (dataLines.length > 0) {
dispatchEvent();
}
currentEvent = line.substring(6).trim();
continue;
}
if (line.startsWith('data:')) {
// 收集 data 行(去掉 "data:" 前缀,保留后面的内容)
dataLines.push(line.substring(5));
}
}
}
// 处理最后可能残留的事件
if (dataLines.length > 0) {
dispatchEvent();
}
// 处理可能的剩余数据
onDone && onDone();
})
.catch((err) => {
if (err.name !== 'AbortError') {
onError && onError(err.message);
}
});
return controller;
}
+888
View File
@@ -0,0 +1,888 @@
/* ==========================================
* AI Chat — 全局样式系统
* 深色玻璃态主题 + 紫蓝渐变品牌色
* ========================================== */
/* ---- CSS 变量 ---- */
:root {
/* 品牌色 */
--primary: #667eea;
--primary-light: #7c93ff;
--primary-dark: #5063c9;
--accent: #764ba2;
--gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--gradient-subtle: linear-gradient(135deg, rgba(102, 126, 234, 0.15) 0%, rgba(118, 75, 162, 0.15) 100%);
/* 背景色 */
--bg-primary: #0a0a1a;
--bg-secondary: #111127;
--bg-tertiary: #1a1a35;
--bg-surface: rgba(255, 255, 255, 0.04);
--bg-surface-hover: rgba(255, 255, 255, 0.08);
--bg-glass: rgba(255, 255, 255, 0.06);
--bg-glass-strong: rgba(255, 255, 255, 0.1);
/* 文本色 */
--text-primary: #f0f0f5;
--text-secondary: #a0a0b8;
--text-tertiary: #6b6b80;
--text-inverse: #0a0a1a;
/* 边框 */
--border-color: rgba(255, 255, 255, 0.08);
--border-color-hover: rgba(255, 255, 255, 0.16);
/* 阴影 */
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.5);
--shadow-glow: 0 0 20px rgba(102, 126, 234, 0.3);
/* 圆角 */
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 20px;
--radius-full: 9999px;
/* 动画 */
--transition-fast: 0.15s cubic-bezier(0.4, 0, 0.2, 1);
--transition-normal: 0.25s cubic-bezier(0.4, 0, 0.2, 1);
--transition-slow: 0.4s cubic-bezier(0.4, 0, 0.2, 1);
/* 字体 */
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
/* 侧边栏宽度 */
--sidebar-width: 280px;
}
/* ---- 全局重置 ---- */
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: var(--font-family);
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
overflow: hidden;
height: 100vh;
width: 100vw;
}
#app {
display: flex;
height: 100vh;
width: 100vw;
overflow: hidden;
}
/* ---- 滚动条美化 ---- */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: var(--radius-full);
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.25);
}
/* ---- 侧边栏 ---- */
.sidebar {
width: var(--sidebar-width);
min-width: var(--sidebar-width);
height: 100vh;
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
transition: transform var(--transition-normal);
z-index: 100;
position: relative;
}
.sidebar::after {
content: '';
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 1px;
background: linear-gradient(180deg, transparent, rgba(102, 126, 234, 0.3), transparent);
pointer-events: none;
}
/* 侧边栏头部 */
.sidebar-header {
padding: 20px 16px 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.sidebar-logo {
display: flex;
align-items: center;
gap: 10px;
font-size: 18px;
font-weight: 700;
background: var(--gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.sidebar-logo-icon {
width: 32px;
height: 32px;
border-radius: var(--radius-sm);
background: var(--gradient);
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
-webkit-text-fill-color: white;
box-shadow: var(--shadow-glow);
}
/* 新建会话按钮 */
.btn-new-chat {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 12px 16px;
margin: 0 16px 8px;
width: calc(100% - 32px);
background: var(--gradient);
color: white;
border: none;
border-radius: var(--radius-md);
font-family: var(--font-family);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all var(--transition-fast);
box-shadow: var(--shadow-sm);
}
.btn-new-chat:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-glow), var(--shadow-md);
}
.btn-new-chat:active {
transform: translateY(0);
}
/* 会话列表 */
.session-list {
flex: 1;
overflow-y: auto;
padding: 4px 12px;
}
.session-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
margin-bottom: 2px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition-fast);
position: relative;
group: true;
}
.session-item:hover {
background: var(--bg-surface-hover);
}
.session-item.active {
background: var(--gradient-subtle);
border: 1px solid rgba(102, 126, 234, 0.2);
}
.session-item-icon {
font-size: 16px;
opacity: 0.6;
flex-shrink: 0;
}
.session-item-title {
flex: 1;
font-size: 13px;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: color var(--transition-fast);
}
.session-item.active .session-item-title {
color: var(--text-primary);
}
.session-item-actions {
display: flex;
gap: 4px;
opacity: 0;
transition: opacity var(--transition-fast);
}
.session-item:hover .session-item-actions {
opacity: 1;
}
.session-item-btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
border-radius: var(--radius-sm);
color: var(--text-tertiary);
cursor: pointer;
font-size: 14px;
transition: all var(--transition-fast);
}
.session-item-btn:hover {
background: var(--bg-surface-hover);
color: var(--text-primary);
}
.session-item-btn.delete:hover {
color: #ff6b6b;
}
/* 侧边栏底部 */
.sidebar-footer {
padding: 12px 16px;
border-top: 1px solid var(--border-color);
}
.sidebar-footer-info {
font-size: 11px;
color: var(--text-tertiary);
text-align: center;
}
/* ---- 主聊天区域 ---- */
.chat-main {
flex: 1;
display: flex;
flex-direction: column;
height: 100vh;
background: var(--bg-primary);
position: relative;
overflow: hidden;
}
/* 背景装饰 */
.chat-main::before {
content: '';
position: absolute;
top: -200px;
right: -200px;
width: 500px;
height: 500px;
background: radial-gradient(circle, rgba(102, 126, 234, 0.06) 0%, transparent 70%);
pointer-events: none;
}
.chat-main::after {
content: '';
position: absolute;
bottom: -200px;
left: -200px;
width: 500px;
height: 500px;
background: radial-gradient(circle, rgba(118, 75, 162, 0.06) 0%, transparent 70%);
pointer-events: none;
}
/* 聊天头部 */
.chat-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
background: rgba(17, 17, 39, 0.8);
backdrop-filter: blur(20px);
border-bottom: 1px solid var(--border-color);
z-index: 10;
}
.chat-header-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.chat-header-model {
font-size: 12px;
color: var(--text-tertiary);
background: var(--bg-surface);
padding: 4px 10px;
border-radius: var(--radius-full);
border: 1px solid var(--border-color);
}
/* 消息容器 */
.messages-container {
flex: 1;
overflow-y: auto;
padding: 24px 0;
scroll-behavior: smooth;
position: relative;
z-index: 1;
}
/* 欢迎页面 */
.welcome-screen {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 40px 20px;
text-align: center;
animation: fadeInUp 0.6s ease-out;
}
.welcome-icon {
width: 80px;
height: 80px;
border-radius: var(--radius-xl);
background: var(--gradient);
display: flex;
align-items: center;
justify-content: center;
font-size: 36px;
margin-bottom: 24px;
box-shadow: var(--shadow-glow);
animation: float 3s ease-in-out infinite;
}
.welcome-title {
font-size: 28px;
font-weight: 700;
margin-bottom: 8px;
background: var(--gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.welcome-subtitle {
font-size: 15px;
color: var(--text-secondary);
margin-bottom: 40px;
max-width: 400px;
}
.welcome-suggestions {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
max-width: 560px;
width: 100%;
}
.suggestion-card {
padding: 16px;
background: var(--bg-glass);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
cursor: pointer;
text-align: left;
transition: all var(--transition-fast);
}
.suggestion-card:hover {
background: var(--bg-glass-strong);
border-color: var(--border-color-hover);
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.suggestion-card-icon {
font-size: 20px;
margin-bottom: 8px;
}
.suggestion-card-text {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.4;
}
/* ---- 消息样式 ---- */
.message {
display: flex;
gap: 16px;
padding: 8px 24px;
max-width: 900px;
margin: 0 auto;
width: 100%;
animation: fadeInUp 0.3s ease-out;
}
.message + .message {
margin-top: 8px;
}
.message-avatar {
width: 36px;
height: 36px;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
flex-shrink: 0;
margin-top: 2px;
}
.message.user .message-avatar {
background: var(--gradient);
color: white;
box-shadow: var(--shadow-sm);
}
.message.assistant .message-avatar {
background: var(--bg-glass-strong);
border: 1px solid var(--border-color);
font-size: 18px;
}
.message-body {
flex: 1;
min-width: 0;
}
.message-role {
font-size: 12px;
font-weight: 600;
color: var(--text-tertiary);
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.message-content {
font-size: 14px;
line-height: 1.7;
color: var(--text-primary);
word-break: break-word;
}
/* 用户消息内容样式 */
.message.user .message-content {
background: var(--bg-glass);
padding: 12px 16px;
border-radius: var(--radius-md);
border: 1px solid var(--border-color);
}
/* AI 消息 Markdown 样式 */
.message.assistant .message-content p {
margin-bottom: 12px;
}
.message.assistant .message-content p:last-child {
margin-bottom: 0;
}
.message.assistant .message-content h1,
.message.assistant .message-content h2,
.message.assistant .message-content h3,
.message.assistant .message-content h4 {
margin: 20px 0 8px;
font-weight: 600;
color: var(--text-primary);
}
.message.assistant .message-content h1 { font-size: 20px; }
.message.assistant .message-content h2 { font-size: 18px; }
.message.assistant .message-content h3 { font-size: 16px; }
.message.assistant .message-content ul,
.message.assistant .message-content ol {
margin: 8px 0;
padding-left: 24px;
}
.message.assistant .message-content li {
margin-bottom: 4px;
}
.message.assistant .message-content strong {
color: var(--primary-light);
}
.message.assistant .message-content a {
color: var(--primary);
text-decoration: none;
}
.message.assistant .message-content a:hover {
text-decoration: underline;
}
.message.assistant .message-content blockquote {
border-left: 3px solid var(--primary);
margin: 12px 0;
padding: 8px 16px;
background: var(--bg-surface);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
color: var(--text-secondary);
}
.message.assistant .message-content table {
width: 100%;
border-collapse: collapse;
margin: 12px 0;
font-size: 13px;
}
.message.assistant .message-content th,
.message.assistant .message-content td {
padding: 8px 12px;
border: 1px solid var(--border-color);
text-align: left;
}
.message.assistant .message-content th {
background: var(--bg-surface);
font-weight: 600;
}
/* 代码块样式 */
.message.assistant .message-content code {
font-family: var(--font-mono);
font-size: 13px;
background: var(--bg-surface);
padding: 2px 6px;
border-radius: 4px;
color: var(--primary-light);
}
.message.assistant .message-content pre {
position: relative;
margin: 12px 0;
border-radius: var(--radius-md);
overflow: hidden;
border: 1px solid var(--border-color);
}
.message.assistant .message-content pre code {
display: block;
padding: 16px;
background: var(--bg-tertiary);
overflow-x: auto;
color: var(--text-primary);
line-height: 1.5;
}
/* 代码块复制按钮 */
.code-block-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: rgba(255, 255, 255, 0.04);
border-bottom: 1px solid var(--border-color);
font-size: 12px;
color: var(--text-tertiary);
}
.code-copy-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: transparent;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-tertiary);
cursor: pointer;
font-family: var(--font-family);
font-size: 11px;
transition: all var(--transition-fast);
}
.code-copy-btn:hover {
background: var(--bg-surface-hover);
color: var(--text-primary);
}
.code-copy-btn.copied {
color: #4ade80;
border-color: #4ade80;
}
/* 打字机光标动画 */
.typing-cursor::after {
content: '▊';
animation: blink 1s step-end infinite;
color: var(--primary);
margin-left: 2px;
}
/* ---- 输入区域 ---- */
.input-area {
padding: 16px 24px 24px;
background: linear-gradient(180deg, transparent 0%, var(--bg-primary) 30%);
position: relative;
z-index: 10;
}
.input-wrapper {
max-width: 900px;
margin: 0 auto;
display: flex;
align-items: flex-end;
gap: 12px;
background: var(--bg-glass-strong);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 12px 16px;
transition: all var(--transition-fast);
backdrop-filter: blur(10px);
}
.input-wrapper:focus-within {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.15);
}
.input-textarea {
flex: 1;
background: transparent;
border: none;
outline: none;
resize: none;
color: var(--text-primary);
font-family: var(--font-family);
font-size: 14px;
line-height: 1.5;
max-height: 150px;
min-height: 24px;
}
.input-textarea::placeholder {
color: var(--text-tertiary);
}
.btn-send {
width: 40px;
height: 40px;
border-radius: var(--radius-md);
background: var(--gradient);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-fast);
flex-shrink: 0;
color: white;
font-size: 18px;
}
.btn-send:hover {
transform: scale(1.05);
box-shadow: var(--shadow-glow);
}
.btn-send:active {
transform: scale(0.95);
}
.btn-send:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.btn-stop {
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a24 100%);
}
.input-hint {
text-align: center;
font-size: 11px;
color: var(--text-tertiary);
margin-top: 8px;
max-width: 900px;
margin-left: auto;
margin-right: auto;
}
/* ---- 加载动画 ---- */
.loading-dots {
display: inline-flex;
gap: 4px;
align-items: center;
padding: 8px 0;
}
.loading-dots span {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--primary);
animation: loadingDot 1.4s ease-in-out infinite;
}
.loading-dots span:nth-child(2) {
animation-delay: 0.2s;
}
.loading-dots span:nth-child(3) {
animation-delay: 0.4s;
}
/* ---- 动画关键帧 ---- */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
@keyframes loadingDot {
0%, 80%, 100% {
opacity: 0.3;
transform: scale(0.8);
}
40% {
opacity: 1;
transform: scale(1.2);
}
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* ---- 响应式适配 ---- */
@media (max-width: 768px) {
:root {
--sidebar-width: 260px;
}
.sidebar {
position: fixed;
left: 0;
top: 0;
transform: translateX(-100%);
}
.sidebar.open {
transform: translateX(0);
box-shadow: var(--shadow-lg);
}
.sidebar-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 99;
}
.sidebar-overlay.show {
display: block;
}
.message {
padding: 8px 16px;
}
.input-area {
padding: 12px 16px 16px;
}
.welcome-suggestions {
grid-template-columns: 1fr;
}
}
/* ---- 移动端菜单按钮 ---- */
.btn-menu {
display: none;
width: 36px;
height: 36px;
border: none;
background: var(--bg-surface);
border-radius: var(--radius-sm);
color: var(--text-secondary);
cursor: pointer;
align-items: center;
justify-content: center;
font-size: 18px;
transition: all var(--transition-fast);
}
.btn-menu:hover {
background: var(--bg-surface-hover);
color: var(--text-primary);
}
@media (max-width: 768px) {
.btn-menu {
display: flex;
}
}
+105
View File
@@ -0,0 +1,105 @@
/**
* Markdown 渲染工具 — 使用 marked + highlight.js
*/
import { marked } from 'marked';
import hljs from 'highlight.js';
import 'highlight.js/styles/github-dark-dimmed.css';
// 配置 marked
marked.setOptions({
highlight: function (code, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(code, { language: lang }).value;
} catch (e) {
console.error('代码高亮失败:', e);
}
}
return hljs.highlightAuto(code).value;
},
breaks: true,
gfm: true,
});
// 自定义渲染器 — 为代码块添加复制按钮和语言标识
const renderer = new marked.Renderer();
renderer.code = function (code, language) {
// marked v12 中 code 参数可能是对象
let codeText = typeof code === 'object' ? code.text : code;
let lang = typeof code === 'object' ? code.lang : language;
lang = lang || 'text';
let highlighted;
try {
if (hljs.getLanguage(lang)) {
highlighted = hljs.highlight(codeText, { language: lang }).value;
} else {
highlighted = hljs.highlightAuto(codeText).value;
}
} catch (e) {
highlighted = escapeHtml(codeText);
}
const id = 'code-' + Math.random().toString(36).substring(2, 9);
return `<pre>
<div class="code-block-header">
<span>${lang}</span>
<button class="code-copy-btn" data-code-id="${id}" onclick="window.__copyCode('${id}')">
<span>📋</span> 复制
</button>
</div>
<code id="${id}" class="hljs language-${lang}">${highlighted}</code>
</pre>`;
};
marked.use({ renderer });
/**
* 将 Markdown 文本渲染为 HTML
*/
export function renderMarkdown(text) {
if (!text) return '';
try {
return marked.parse(text);
} catch (e) {
console.error('Markdown 渲染失败:', e);
return escapeHtml(text);
}
}
/**
* HTML 转义
*/
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* 注册代码块复制功能到全局
*/
export function setupCodeCopy() {
window.__copyCode = function (codeId) {
const codeEl = document.getElementById(codeId);
if (!codeEl) return;
const text = codeEl.textContent;
navigator.clipboard.writeText(text).then(() => {
// 找到对应的复制按钮
const btn = document.querySelector(`[data-code-id="${codeId}"]`);
if (btn) {
btn.classList.add('copied');
btn.innerHTML = '<span>✅</span> 已复制';
setTimeout(() => {
btn.classList.remove('copied');
btn.innerHTML = '<span>📋</span> 复制';
}, 2000);
}
}).catch((err) => {
console.error('复制失败:', err);
});
};
}
+40
View File
@@ -0,0 +1,40 @@
/**
* 本地存储工具
*/
const STORAGE_PREFIX = 'ai_chat_';
/**
* 保存数据到 localStorage
*/
export function setItem(key, value) {
try {
localStorage.setItem(STORAGE_PREFIX + key, JSON.stringify(value));
} catch (e) {
console.error('保存到 localStorage 失败:', e);
}
}
/**
* 从 localStorage 读取数据
*/
export function getItem(key, defaultValue = null) {
try {
const value = localStorage.getItem(STORAGE_PREFIX + key);
return value ? JSON.parse(value) : defaultValue;
} catch (e) {
console.error('从 localStorage 读取失败:', e);
return defaultValue;
}
}
/**
* 删除 localStorage 中的数据
*/
export function removeItem(key) {
try {
localStorage.removeItem(STORAGE_PREFIX + key);
} catch (e) {
console.error('从 localStorage 删除失败:', e);
}
}
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from 'vite';
export default defineConfig({
server: {
host: '0.0.0.0',
port: 3000,
// 代理后端 API 请求,解决开发时的跨域问题
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
},
});
+7
View File
@@ -0,0 +1,7 @@
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('64.90.11.73', username='root', password='qemzCSBQ8251', look_for_keys=False, allow_agent=False)
stdin, stdout, stderr = ssh.exec_command('docker ps')
print(stdout.read().decode())
ssh.close()
+44
View File
@@ -0,0 +1,44 @@
# 前端单独部署脚本 - Nginx
$serverIp = "64.90.11.73"
$serverUser = "root"
$remoteDir = "/root/chat-web"
Write-Host "=============================================" -ForegroundColor Cyan
Write-Host " 开始部署前端 (Nginx) 到 $serverIp" -ForegroundColor Cyan
Write-Host "=============================================" -ForegroundColor Cyan
# 1. 编译前端
Write-Host "`n[1/3] 开始构建前端应用..." -ForegroundColor Yellow
Set-Location -Path "chat-web"
if (!(Test-Path "node_modules")) {
npm install
}
npm run build
# 2. 上传文件到服务器
Write-Host "`n[2/3] 正在创建服务器目录并上传前端产物..." -ForegroundColor Yellow
Write-Host "可能会提示输入服务器密码:" -ForegroundColor Magenta
# 创建远程目录
ssh ${serverUser}@${serverIp} "mkdir -p $remoteDir/dist"
# 上传 Nginx 配置
scp nginx.conf ${serverUser}@${serverIp}:${remoteDir}/nginx.conf
# 上传构建好的前端文件 (将 dist 下的所有文件拷贝到服务器的 dist 目录中)
scp -r dist/* ${serverUser}@${serverIp}:${remoteDir}/dist/
# 3. 部署或更新 Nginx 容器
Write-Host "`n[3/3] 正在启动/更新 Nginx 容器..." -ForegroundColor Yellow
Write-Host "可能会再次提示输入服务器密码:" -ForegroundColor Magenta
$deployCmd = "docker stop chat-web-nginx; docker rm chat-web-nginx; docker run -d --name chat-web-nginx -p 38080:80 -v ${remoteDir}/dist:/usr/share/nginx/html -v ${remoteDir}/nginx.conf:/etc/nginx/conf.d/default.conf nginx"
ssh ${serverUser}@${serverIp} $deployCmd
Set-Location -Path ".."
Write-Host "`n=============================================" -ForegroundColor Green
Write-Host " 前端 Nginx 部署/更新完成!" -ForegroundColor Green
Write-Host " 请访问浏览器:http://${serverIp}:38080" -ForegroundColor Green
Write-Host "日后仅更新前端包:只需运行此脚本即可自动打包上传并重启。" -ForegroundColor Green
Write-Host "=============================================" -ForegroundColor Green
+47
View File
@@ -0,0 +1,47 @@
# 部署脚本 - 自动打包并部署到远程服务器
$serverIp = "64.90.11.73"
$serverUser = "root"
$port = 38080
Write-Host "=============================================" -ForegroundColor Cyan
Write-Host " 开始部署 AI Chat 应用到 $serverIp" -ForegroundColor Cyan
Write-Host "=============================================" -ForegroundColor Cyan
# 1. 编译前端
Write-Host "`n[1/4] 开始构建前端应用 (chat-web)..." -ForegroundColor Yellow
Set-Location -Path "chat-web"
# 检查 node_modules,如果没有则安装依赖
if (!(Test-Path "node_modules")) {
npm install
}
npm run build
Set-Location -Path ".."
# 2. 拷贝前端资源到后端
Write-Host "`n[2/4] 将前端构建产物拷贝到后端静态资源目录..." -ForegroundColor Yellow
$staticPath = "chat-server\src\main\resources\static"
if (!(Test-Path $staticPath)) {
New-Item -ItemType Directory -Force -Path $staticPath | Out-Null
}
Remove-Item "$staticPath\*" -Recurse -Force -ErrorAction SilentlyContinue
Copy-Item -Path "chat-web\dist\*" -Destination $staticPath -Recurse -Force
# 3. 准备远端构建目录并上传源码
Write-Host "`n[3/4] 准备远端目录并上传源码和 Dockerfile..." -ForegroundColor Yellow
ssh ${serverUser}@${serverIp} "mkdir -p /root/chat-deploy"
Write-Host "正在上传后端源码和构建文件,如果出现提示请输入服务器 $serverIp 的 root 密码:" -ForegroundColor Magenta
# 使用原始包含多阶段构建的 Dockerfile
scp "chat-server\Dockerfile" "${serverUser}@${serverIp}:/root/chat-deploy/"
scp "chat-server\pom.xml" "${serverUser}@${serverIp}:/root/chat-deploy/"
# 上传 src 目录(包含刚刚拷贝进去的前端静态资源)
scp -r "chat-server\src" "${serverUser}@${serverIp}:/root/chat-deploy/"
# 4. 远程构建和部署
Write-Host "`n[4/4] 正在远程 Docker 中编译并构建镜像,请再次输入服务器密码:" -ForegroundColor Magenta
ssh ${serverUser}@${serverIp} "cd /root/chat-deploy ; docker build -t chat-server:latest . ; docker stop chat-server ; docker rm chat-server ; docker run -d --name chat-server -p ${port}:8080 chat-server:latest"
Write-Host "`n=============================================" -ForegroundColor Green
Write-Host " 部署完成!" -ForegroundColor Green
Write-Host " 请访问浏览器:http://${serverIp}:${port}" -ForegroundColor Green
Write-Host "=============================================" -ForegroundColor Green
+9
View File
@@ -0,0 +1,9 @@
$serverIp = "64.90.11.73"
$serverUser = "root"
$port = 38080
ssh ${serverUser}@${serverIp} "mkdir -p /root/chat-deploy"
scp "chat-server\Dockerfile" "${serverUser}@${serverIp}:/root/chat-deploy/"
scp "chat-server\pom.xml" "${serverUser}@${serverIp}:/root/chat-deploy/"
scp -r "chat-server\src" "${serverUser}@${serverIp}:/root/chat-deploy/"
ssh ${serverUser}@${serverIp} "cd /root/chat-deploy ; docker build -t chat-server:latest . ; docker stop chat-server ; docker rm chat-server ; docker run -d --name chat-server -p ${port}:8080 chat-server:latest"
+32
View File
@@ -0,0 +1,32 @@
import paramiko
host = "64.90.11.73"
port = 22
username = "root"
password = "qemzCSBQ8251"
print("Connecting to server...")
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
ssh.connect(host, port, username, password, look_for_keys=False, allow_agent=False)
commands = """
mkdir -p /root/gitea
docker stop gitea || true
docker rm gitea || true
docker run -d --name=gitea -p 3010:3000 -p 222:22 -v /root/gitea:/data gitea/gitea:latest
"""
print("Deploying Gitea via Docker...")
stdin, stdout, stderr = ssh.exec_command(commands)
for line in iter(stdout.readline, ""):
print(line, end="")
for line in iter(stderr.readline, ""):
print("ERROR:", line, end="")
print("Gitea deployed successfully.")
finally:
ssh.close()