Initial commit
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
chat-web/node_modules/
|
||||
chat-web/dist/
|
||||
chat-server/target/
|
||||
.idea/
|
||||
*.iml
|
||||
*.zip
|
||||
*.db
|
||||
@@ -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 数据库存储聊天记录
|
||||
@@ -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()
|
||||
@@ -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.
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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 {
|
||||
// ========== 阶段1:DB 操作(快速完成,释放连接) ==========
|
||||
|
||||
// 如果没有会话 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()));
|
||||
}
|
||||
|
||||
// ========== 阶段2:AI 流式调用(不占用 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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Generated
+1012
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* 应用入口 — 初始化样式和应用
|
||||
*/
|
||||
import './style.css';
|
||||
import { App } from './app.js';
|
||||
|
||||
// DOM 加载完成后初始化应用
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const app = new App();
|
||||
app.init();
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user