1. 后端流式设计
1.1 对话流程
一次完整的对话流程如下:
- 用户发送消息(前端 -> 后端)
- 将用户消息保存到数据库
- 从数据库查询该话题的历史消息(作为 AI 上下文)
- 将历史消息 + 新消息组装为 Prompt,调用 ChatClient.stream()
- AI 流式返回内容,通过 SSE 推送给前端
- 流结束后,将 AI 的完整回复保存到数据库
- 前端逐字渲染 AI 回复
1.2 DTO
src/main/java/com/albertstack/aichat/dto/ChatRequest.java
package com.albertstack.aichat.dto;
public record ChatRequest(Long topicId, String content, boolean thinking) {}thinking 字段控制是否启用深度思考模式。开启时使用带思考的模型,关闭时使用 instruct 模型(前面已经配置了双模型)。
1.3 ChatChunk
SSE 流需要区分思考内容和回复内容,定义一个传输对象:
src/main/java/com/albertstack/aichat/dto/ChatChunk.java
package com.albertstack.aichat.dto;
public record ChatChunk(String type, String text) {
public static ChatChunk thinking(String text) {
return new ChatChunk("thinking", text);
}
public static ChatChunk content(String text) {
return new ChatChunk("content", text);
}
}前端通过 type 字段区分当前 chunk 是思考过程还是回复内容。
1.4 ChatService
src/main/java/com/albertstack/aichat/service/ChatService.java
package com.albertstack.aichat.service;
import com.albertstack.aichat.dto.ChatChunk;
import com.albertstack.aichat.entity.Message;
import com.albertstack.aichat.repository.MessageRepository;
import com.albertstack.aichat.repository.TopicRepository;
import com.albertstack.aichat.util.SecurityUtils;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.Generation;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.ArrayList;
import java.util.List;
@Service
public class ChatService {
private final ChatClient chatClient;
private final ChatClient noThinkChatClient;
private final TopicRepository topicRepository;
private final MessageRepository messageRepository;
// 上下文窗口大小:最多携带最近 20 条历史消息
private static final int CONTEXT_WINDOW = 20;
public ChatService(ChatClient chatClient,
@Qualifier("noThinkChatClient") ChatClient noThinkChatClient,
TopicRepository topicRepository,
MessageRepository messageRepository) {
this.chatClient = chatClient;
this.noThinkChatClient = noThinkChatClient;
this.topicRepository = topicRepository;
this.messageRepository = messageRepository;
}
public Flux<ChatChunk> chat(Long topicId, String userContent, boolean thinking) {
return SecurityUtils.getCurrentUserId()
// 验证话题归属
.flatMap(userId -> topicRepository.findByIdAndUserId(topicId, userId))
.switchIfEmpty(Mono.error(new RuntimeException("话题不存在")))
.flatMapMany(topic ->
// 保存用户消息
messageRepository.save(Message.create(topicId, "user", userContent))
// 查询历史消息
.thenMany(messageRepository.findByTopicIdOrderByCreatedAtAsc(topicId))
.collectList()
.flatMapMany(history -> {
List<org.springframework.ai.chat.messages.Message> aiMessages =
buildAiMessages(history);
// 根据思考模式选择模型
ChatClient client = thinking ? chatClient : noThinkChatClient;
StringBuilder fullContent = new StringBuilder();
StringBuilder fullThinking = new StringBuilder();
// 使用 .stream().chatResponse() 获取完整的 ChatResponse(含 thinking 元数据)
return client.prompt()
.messages(aiMessages)
.stream()
.chatResponse()
.mapNotNull(chunk -> {
Generation result = chunk.getResult();
if (result == null || result.getOutput() == null)
return null;
// 提取思考过程
Object thinkingObj = result.getMetadata().get("thinking");
if (thinkingObj != null && !thinkingObj.toString().isEmpty()) {
String t = thinkingObj.toString();
fullThinking.append(t);
return ChatChunk.thinking(t);
}
// 提取回复内容
String text = result.getOutput().getText();
if (text != null && !text.isEmpty()) {
fullContent.append(text);
return ChatChunk.content(text);
}
return null;
})
.doOnComplete(() -> {
// 流结束后保存 AI 回复(含思考过程)
String thinkingText = fullThinking.isEmpty()
? null : fullThinking.toString();
messageRepository.save(
Message.create(topicId, "assistant",
fullContent.toString(), thinkingText)
).subscribe();
});
})
);
}
// 将数据库消息转换为 Spring AI 的 Message 类型
private List<org.springframework.ai.chat.messages.Message> buildAiMessages(
List<Message> history) {
// 只取最近的 N 条消息,避免超出模型上下文限制
List<Message> recent = history.size() > CONTEXT_WINDOW
? history.subList(history.size() - CONTEXT_WINDOW, history.size())
: history;
List<org.springframework.ai.chat.messages.Message> aiMessages = new ArrayList<>();
for (Message msg : recent) {
switch (msg.role()) {
case "user" -> aiMessages.add(new UserMessage(msg.content()));
case "assistant" -> aiMessages.add(new AssistantMessage(msg.content()));
}
}
return aiMessages;
}
}核心逻辑解读:
- 双模型切换:根据
thinking参数选择chatClient(带思考)或noThinkChatClient(无思考),两个 Bean 在AiConfig中已注册 .stream().chatResponse():不同于之前的.stream().content()只拿文本,这里获取完整的ChatResponse,从中提取思考过程(result.getMetadata().get("thinking"))和回复内容(result.getOutput().getText())- ChatChunk 协议:每个 SSE 事件是一个
ChatChunkJSON 对象,type为"thinking"或"content",前端据此分别渲染 - 数据隔离:先验证
topicId属于当前用户 - 消息持久化:用户消息在 AI 调用前保存,AI 回复(含思考过程)在流结束后保存
- 上下文窗口:
CONTEXT_WINDOW = 20,只取最近 20 条消息作为上下文。这是因为模型的上下文长度有限,太多历史消息会导致调用失败或费用过高
1.5 ChatController
src/main/java/com/albertstack/aichat/controller/ChatController.java
package com.albertstack.aichat.controller;
import com.albertstack.aichat.dto.ChatChunk;
import com.albertstack.aichat.dto.ChatRequest;
import com.albertstack.aichat.service.ChatService;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
@RestController
@RequestMapping("/api/chat")
public class ChatController {
private final ChatService chatService;
public ChatController(ChatService chatService) {
this.chatService = chatService;
}
@PostMapping(produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ChatChunk> chat(@RequestBody ChatRequest request) {
return chatService.chat(request.topicId(), request.content(), request.thinking());
}
}关键代码: produces = MediaType.TEXT_EVENT_STREAM_VALUE,它告诉 Spring 这个端点返回 SSE(Server-Sent Events)流。
响应头会包含 Content-Type: text/event-stream,浏览器收到后知道这是一个持续推送的事件流。
1.6 SSE 简介
SSE(Server-Sent Events)是 HTTP 协议原生支持的服务器推送机制,比 WebSocket 更简单:
- 基于普通的 HTTP 连接,不需要额外的协议升级
- 服务器可以持续向客户端推送数据
- 数据格式为
data: <内容>\n\n - 连接断开后浏览器会自动重连
在我们的场景中,AI 每生成一个 token(几个字),服务器就推送一次 SSE 事件,前端收到后立即渲染,形成打字机效果。
1.7 后端测试
src/test/java/com/albertstack/aichat/service/ChatServiceTest.java
package com.albertstack.aichat.service;
import com.albertstack.aichat.dto.ChatChunk;
import com.albertstack.aichat.entity.Message;
import com.albertstack.aichat.entity.Topic;
import com.albertstack.aichat.entity.User;
import com.albertstack.aichat.repository.MessageRepository;
import com.albertstack.aichat.repository.TopicRepository;
import com.albertstack.aichat.repository.UserRepository;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.r2dbc.core.DatabaseClient;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
import reactor.core.publisher.Flux;
import reactor.test.StepVerifier;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
@Slf4j
@SpringBootTest
class ChatServiceTest {
@Autowired
private ChatService chatService;
@Autowired
private UserRepository userRepository;
@Autowired
private TopicRepository topicRepository;
@Autowired
private MessageRepository messageRepository;
@Autowired
private DatabaseClient databaseClient;
private Long userId;
private Long topicId;
@BeforeEach
void setUp() {
databaseClient.sql("SET REFERENTIAL_INTEGRITY FALSE").then().block();
databaseClient.sql("TRUNCATE TABLE MESSAGES RESTART IDENTITY").then().block();
databaseClient.sql("TRUNCATE TABLE TOPICS RESTART IDENTITY").then().block();
databaseClient.sql("TRUNCATE TABLE USERS RESTART IDENTITY").then().block();
databaseClient.sql("SET REFERENTIAL_INTEGRITY TRUE").then().block();
User user = userRepository.save(User.create("Albert", "password")).block();
userId = user.id();
log.info("setUp => userId={}", userId);
Topic topic = topicRepository.save(Topic.create(userId, "测试话题")).block();
topicId = topic.id();
log.info("setUp => topicId={}", topicId);
}
@Test
void chat_withThinking_shouldReturnThinkingAndContent() {
var auth = new UsernamePasswordAuthenticationToken(userId, null, List.of());
Flux<ChatChunk> responseStream = chatService.chat(topicId, "1+1等于几?", true)
.contextWrite(ReactiveSecurityContextHolder.withAuthentication(auth));
StringBuilder thinkingText = new StringBuilder();
StringBuilder contentText = new StringBuilder();
StepVerifier.create(responseStream)
.thenConsumeWhile(chunk -> {
if ("thinking".equals(chunk.type())) {
thinkingText.append(chunk.text());
} else if ("content".equals(chunk.type())) {
contentText.append(chunk.text());
}
return true;
})
.verifyComplete();
log.info("思考过程: {}...(共 {} 字符)",
thinkingText.substring(0, Math.min(100, thinkingText.length())), thinkingText.length());
log.info("回复内容: {}", contentText);
assertFalse(thinkingText.isEmpty(), "开启思考模式应返回思考过程");
assertFalse(contentText.isEmpty(), "应返回回复内容");
log.info(" ✅ 思考模式:思考过程 + 回复内容均已返回");
// 等待异步保存完成
try { Thread.sleep(1000); } catch (InterruptedException ignored) {}
List<Message> messages = messageRepository
.findByTopicIdOrderByCreatedAtAsc(topicId)
.collectList().block();
assertEquals(2, messages.size());
Message aiMsg = messages.get(1);
assertEquals("assistant", aiMsg.role());
assertNotNull(aiMsg.thinking(), "思考过程应已持久化");
log.info(" ✅ 思考过程已保存到数据库({} 字符)", aiMsg.thinking().length());
}
@Test
void chat_withoutThinking_shouldReturnContentOnly() {
var auth = new UsernamePasswordAuthenticationToken(userId, null, List.of());
Flux<ChatChunk> responseStream = chatService.chat(topicId, "你好,请说'测试成功'", false)
.contextWrite(ReactiveSecurityContextHolder.withAuthentication(auth));
StringBuilder contentText = new StringBuilder();
boolean[] hasThinking = {false};
StepVerifier.create(responseStream)
.thenConsumeWhile(chunk -> {
if ("thinking".equals(chunk.type())) hasThinking[0] = true;
if ("content".equals(chunk.type())) contentText.append(chunk.text());
return true;
})
.verifyComplete();
log.info("回复内容: {}", contentText);
assertFalse(contentText.isEmpty(), "应返回回复内容");
assertFalse(hasThinking[0], "关闭思考模式不应返回思考过程");
log.info(" ✅ 非思考模式:仅返回回复内容");
}
@Test
void chat_withInvalidTopic_shouldError() {
var auth = new UsernamePasswordAuthenticationToken(userId, null, List.of());
Flux<ChatChunk> responseStream = chatService.chat(99999L, "你好", false)
.contextWrite(ReactiveSecurityContextHolder.withAuthentication(auth));
StepVerifier.create(responseStream)
.expectError(RuntimeException.class)
.verify();
log.info(" ✅ 无效 topicId 正确抛出异常");
}
}src/test/java/com/albertstack/aichat/controller/ChatControllerTest.java
package com.albertstack.aichat.controller;
import com.albertstack.aichat.dto.ChatRequest;
import com.albertstack.aichat.dto.RegisterRequest;
import com.albertstack.aichat.dto.TopicRequest;
import tools.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webtestclient.autoconfigure.AutoConfigureWebTestClient;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.r2dbc.core.DatabaseClient;
import org.springframework.test.web.reactive.server.WebTestClient;
import static com.albertstack.aichat.util.ApiTest.*;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureWebTestClient(timeout = "60000") // AI 响应可能较慢
class ChatControllerTest {
private static final Logger log = LoggerFactory.getLogger("ApiTest");
@Autowired
private WebTestClient webTestClient;
@Autowired
private DatabaseClient databaseClient;
private final ObjectMapper objectMapper = new ObjectMapper();
private String token;
private Long topicId;
@BeforeEach
void setUp() throws Exception {
databaseClient.sql("SET REFERENTIAL_INTEGRITY FALSE").then().block();
databaseClient.sql("TRUNCATE TABLE MESSAGES RESTART IDENTITY").then().block();
databaseClient.sql("TRUNCATE TABLE TOPICS RESTART IDENTITY").then().block();
databaseClient.sql("TRUNCATE TABLE USERS RESTART IDENTITY").then().block();
databaseClient.sql("SET REFERENTIAL_INTEGRITY TRUE").then().block();
// 注册用户,提取 token
byte[] authBody = webTestClient.post()
.uri("/api/auth/register")
.bodyValue(new RegisterRequest("Albert", "ps123456"))
.exchange()
.expectBody(byte[].class).returnResult().getResponseBody();
token = objectMapper.readTree(authBody).get("data").get("token").asString();
// 创建话题,提取 topicId
byte[] topicBody = webTestClient.post()
.uri("/api/topics")
.header("Authorization", "Bearer " + token)
.bodyValue(new TopicRequest("聊天测试话题"))
.exchange()
.expectBody(byte[].class).returnResult().getResponseBody();
topicId = objectMapper.readTree(topicBody).get("data").get("id").asLong();
}
@Test
void chat_shouldReturnSSEStream() {
// SSE 流式端点不走 ApiResponse 格式,需要单独处理
log.info("---------- POST /api/chat(SSE 流式) ----------");
webTestClient.post()
.uri("/api/chat")
.header("Authorization", "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(new ChatRequest(topicId, "你好,用一句话回答", false))
.exchange()
.expectStatus().isOk()
.expectHeader().contentTypeCompatibleWith(MediaType.TEXT_EVENT_STREAM)
.expectBody(String.class)
.consumeWith(result -> {
String body = result.getResponseBody();
log.info("SSE 响应内容: {}", body);
if (body != null && !body.isBlank()) {
log.info(" ✅ AI 返回了有效内容(长度: {} 字符)", body.length());
} else {
log.error(" ❌ AI 未返回任何内容");
fail("AI 应该返回有效内容");
}
});
}
@Test
void chat_withoutToken_shouldReturn401() {
of(webTestClient).post("/api/chat", new ChatRequest(topicId, "你好", false))
.expectStatus(401)
.verify();
}
}2. 前端聊天界面
2.1 SSE 请求封装
浏览器原生的 EventSource API 只支持 GET 请求,而我们的聊天接口是 POST。所以用 fetch + ReadableStream 手动处理 SSE 流。
src/composables/useSSE.ts
interface ChatChunk {
type: 'thinking' | 'content'
text: string
}
export function useSSE() {
async function fetchSSE(
url: string,
body: object,
onThinking: (text: string) => void,
onContent: (text: string) => void,
onDone?: () => void,
onError?: (error: Error) => void,
) {
const token = localStorage.getItem('token')
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(body),
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const reader = response.body?.getReader()
if (!reader) throw new Error('无法读取响应流')
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
const text = decoder.decode(value, { stream: true })
// 解析 SSE 格式:data:{"type":"thinking"|"content","text":"..."}\n\n
const lines = text.split('\n')
for (const line of lines) {
if (line.startsWith('data:')) {
const data = line.slice(5).trim()
if (!data) continue
try {
const chunk: ChatChunk = JSON.parse(data)
if (chunk.type === 'thinking') {
onThinking(chunk.text)
} else if (chunk.type === 'content') {
onContent(chunk.text)
}
} catch {
// 非 JSON 格式的 data,作为普通内容处理
onContent(data)
}
}
}
}
onDone?.()
} catch (error) {
onError?.(error as Error)
}
}
return { fetchSSE }
}2.2 Chat API 模块
src/api/chat.ts
// 聊天接口使用 SSE 流式请求,不走 axios
// 具体调用在 ChatView 中通过 useSSE 实现
export const CHAT_URL = '/api/chat'2.3 安装 Markdown 渲染依赖
AI 的回复通常包含 Markdown 格式(代码块、列表、加粗等),需要安装 marked 和 highlight.js 来渲染并高亮代码:
cd ai-chat-frontend
npm install marked marked-highlight highlight.jsmarked:将 Markdown 文本解析为 HTMLmarked-highlight:marked的代码高亮扩展插件(v5+ 不再内置 highlight 选项)highlight.js:语法高亮引擎,支持 Java、JavaScript、SQL 等上百种语言
2.4 完整的 ChatView
更新 src/views/ChatView.vue,加入聊天功能:
<template>
<div class="h-screen flex bg-base-200">
<!-- 左侧:话题列表 -->
<aside class="w-64 bg-base-100 flex flex-col border-r border-base-300">
<div class="p-3">
<button class="btn btn-primary btn-sm w-full" @click="createTopic">
+ 新对话
</button>
</div>
<div class="flex-1 overflow-y-auto">
<ul class="menu p-2">
<li v-for="topic in topics" :key="topic.id">
<a
:class="{ 'active': currentTopicId === topic.id }"
class="flex justify-between items-center"
@click="selectTopic(topic)"
>
<input
v-if="editingTopicId === topic.id"
v-model="editingTitle"
class="input input-xs input-bordered flex-1 mr-1"
@click.stop
@keyup.enter="confirmEdit(topic)"
@keyup.escape="cancelEdit"
@blur="confirmEdit(topic)"
/>
<span v-else class="truncate flex-1" @dblclick.stop="startEdit(topic)">
{{ topic.title }}
</span>
<button
class="btn btn-ghost btn-xs"
@click.stop="deleteTopic(topic.id)"
>
✕
</button>
</a>
</li>
</ul>
<div v-if="topics.length === 0" class="text-center text-base-content/50 p-4 text-sm">
暂无对话,点击上方按钮创建
</div>
</div>
<div class="p-3 border-t border-base-300 flex justify-between items-center">
<span class="text-sm">{{ authStore.username }}</span>
<button class="btn btn-ghost btn-xs" @click="handleLogout">退出</button>
</div>
</aside>
<!-- 右侧:聊天区域 -->
<main class="flex-1 flex flex-col">
<header class="h-14 flex items-center px-4 border-b border-base-300 bg-base-100">
<h2 class="text-lg font-medium">
{{ currentTopic?.title || '选择或创建一个对话' }}
</h2>
</header>
<!-- 消息列表 -->
<div ref="messageListRef" class="flex-1 overflow-y-auto p-4 space-y-4">
<div v-if="!currentTopicId" class="h-full flex items-center justify-center text-base-content/50">
← 选择一个话题开始对话
</div>
<div
v-for="msg in messages"
:key="msg.id || msg.tempId"
class="chat"
:class="msg.role === 'user' ? 'chat-end' : 'chat-start'"
>
<!-- AI 消息 -->
<div v-if="msg.role === 'assistant'" class="chat-bubble bg-base-100 text-base-content border border-base-300">
<details v-if="msg.thinking" class="mb-2">
<summary class="cursor-pointer text-xs opacity-50 select-none">
🧠 深度思考过程(点击展开)
</summary>
<div class="mt-1 p-2 rounded bg-base-200 text-xs whitespace-pre-wrap opacity-60">
{{ msg.thinking }}
</div>
</details>
<div class="prose prose-sm max-w-none" v-html="renderMarkdown(msg.content)"></div>
</div>
<!-- 用户消息 -->
<div v-else class="chat-bubble bg-primary text-primary-content">
<div class="whitespace-pre-wrap">{{ msg.content }}</div>
</div>
</div>
<!-- 流式响应中的 AI 消息 -->
<div v-if="streamingThinking || streamingContent" class="chat chat-start">
<div class="chat-bubble bg-base-100 text-base-content border border-base-300">
<details v-if="streamingThinking" class="mb-2" open>
<summary class="cursor-pointer text-xs opacity-50 select-none">
🧠 正在思考...
</summary>
<div class="mt-1 p-2 rounded bg-base-200 text-xs whitespace-pre-wrap opacity-60">
{{ streamingThinking }}<span class="animate-pulse">▊</span>
</div>
</details>
<div v-if="streamingContent" class="prose prose-sm max-w-none" v-html="renderMarkdown(streamingContent)">
</div>
<span v-if="streamingContent" class="animate-pulse">▊</span>
</div>
</div>
</div>
<!-- 输入区域 -->
<div class="p-4 border-t border-base-300 bg-base-100">
<form @submit.prevent="sendMessage" class="card border border-base-300 bg-base-100">
<textarea
v-model="inputText"
placeholder="输入消息..."
rows="3"
class="w-full p-3 resize-none bg-transparent outline-none leading-normal"
:disabled="!currentTopicId || isSending"
@keydown.enter.exact.prevent="sendMessage"
></textarea>
<div class="flex justify-between items-center px-3 py-2 border-t border-base-200">
<button
type="button"
class="btn btn-sm"
:class="thinkingEnabled ? 'btn-accent' : 'btn-ghost'"
:disabled="!currentTopicId"
@click="thinkingEnabled = !thinkingEnabled"
>
深度思考
</button>
<button
type="submit"
class="btn btn-sm btn-primary"
:disabled="!currentTopicId || !inputText.trim() || isSending"
>
<span v-if="isSending" class="loading loading-spinner loading-xs"></span>
<span v-else>发送</span>
</button>
</div>
</form>
</div>
</main>
</div>
</template>
<script setup lang="ts">
import { ref, nextTick, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { marked } from 'marked'
import { markedHighlight } from 'marked-highlight'
import hljs from 'highlight.js'
import 'highlight.js/styles/github.css'
import { useAuthStore } from '../stores/auth'
import { useSSE } from '../composables/useSSE'
import {
listTopicsApi,
createTopicApi,
updateTopicApi,
deleteTopicApi,
listMessagesApi,
type Topic,
} from '../api/topic'
import { CHAT_URL } from '../api/chat'
const router = useRouter()
const authStore = useAuthStore()
const { fetchSSE } = useSSE()
// 话题相关
const topics = ref<Topic[]>([])
const currentTopicId = ref<number | null>(null)
const currentTopic = ref<Topic | null>(null)
const editingTopicId = ref<number | null>(null)
const editingTitle = ref('')
// 消息相关
interface DisplayMessage {
id?: number
tempId?: string
role: 'user' | 'assistant'
content: string
thinking?: string | null
}
const messages = ref<DisplayMessage[]>([])
const inputText = ref('')
const isSending = ref(false)
const thinkingEnabled = ref(false)
const streamingContent = ref('')
const streamingThinking = ref('')
const messageListRef = ref<HTMLElement>()
// --- 话题操作 ---
async function loadTopics() {
const { data } = await listTopicsApi()
topics.value = data
}
async function createTopic() {
const { data } = await createTopicApi('新对话')
topics.value.unshift(data)
selectTopic(data)
}
async function selectTopic(topic: Topic) {
currentTopicId.value = topic.id
currentTopic.value = topic
const { data } = await listMessagesApi(topic.id)
messages.value = data.map((m) => ({
id: m.id,
role: m.role,
content: m.content,
thinking: m.thinking,
}))
await nextTick()
scrollToBottom()
}
function startEdit(topic: Topic) {
editingTopicId.value = topic.id
editingTitle.value = topic.title
}
async function confirmEdit(topic: Topic) {
if (editingTopicId.value === null) return
editingTopicId.value = null
const newTitle = editingTitle.value.trim()
if (!newTitle || newTitle === topic.title) return
const { data } = await updateTopicApi(topic.id, newTitle)
const index = topics.value.findIndex((t) => t.id === topic.id)
if (index !== -1) topics.value[index] = data
if (currentTopicId.value === topic.id) currentTopic.value = data
}
function cancelEdit() {
editingTopicId.value = null
}
async function deleteTopic(topicId: number) {
await deleteTopicApi(topicId)
topics.value = topics.value.filter((t) => t.id !== topicId)
if (currentTopicId.value === topicId) {
currentTopicId.value = null
currentTopic.value = null
messages.value = []
}
}
// --- 聊天操作 ---
async function sendMessage() {
const content = inputText.value.trim()
if (!content || !currentTopicId.value || isSending.value) return
inputText.value = ''
isSending.value = true
messages.value.push({
tempId: Date.now().toString(),
role: 'user',
content,
})
await nextTick()
scrollToBottom()
streamingContent.value = ''
streamingThinking.value = ''
await fetchSSE(
CHAT_URL,
{ topicId: currentTopicId.value, content, thinking: thinkingEnabled.value },
// 收到思考 chunk
(text) => {
streamingThinking.value += text
scrollToBottom()
},
// 收到内容 chunk
(text) => {
streamingContent.value += text
scrollToBottom()
},
// 流结束
() => {
messages.value.push({
tempId: Date.now().toString(),
role: 'assistant',
content: streamingContent.value,
thinking: streamingThinking.value || null,
})
streamingContent.value = ''
streamingThinking.value = ''
isSending.value = false
},
// 出错
(error) => {
console.error('聊天出错:', error)
streamingContent.value = ''
streamingThinking.value = ''
isSending.value = false
},
)
}
// 配置 marked 使用 highlight.js 高亮代码块
marked.use(markedHighlight({
langPrefix: 'hljs language-',
highlight(code, lang) {
if (lang && hljs.getLanguage(lang)) {
return hljs.highlight(code, { language: lang }).value
}
return hljs.highlightAuto(code).value
},
}))
function renderMarkdown(content: string): string {
return marked.parse(content) as string
}
function scrollToBottom() {
nextTick(() => {
if (messageListRef.value) {
messageListRef.value.scrollTop = messageListRef.value.scrollHeight
}
})
}
function handleLogout() {
authStore.logout()
router.push('/login')
}
onMounted(() => {
loadTopics()
})
</script>3. 测试流程
- 打开
http://localhost:5173,注册/登录后进入聊天界面 - 点击「+ 新对话」创建话题
- 不开启深度思考:输入 "介绍一下你自己",发送,观察打字机效果
- 开启深度思考:点击输入框旁的
[深度思考]按钮使其高亮,输入 "介绍一下你自己",发送 - 观察思考过程以半透明小字实时展示(默认展开),思考完成后回复内容逐字呈现
- 点击思考过程区域的「深度思考过程(点击展开)」可以折叠/展开
- 切换到其他话题再切回来,验证历史消息中的思考过程仍然可以展开查看
- 刷新页面,验证消息和思考过程都没有丢失
4. 小结
完成了 AI 对话的完整链路:
| 层 | 新增内容 |
|---|---|
| 后端 DTO | ChatRequest(含 thinking 开关)、ChatChunk(SSE 传输协议) |
| 后端 Service | ChatService(双模型切换、思考流提取、上下文构建、消息+思考持久化) |
| 后端 Controller | ChatController(SSE 端点,返回 Flux<ChatChunk>) |
| 后端测试 | ChatServiceTest、ChatControllerTest |
| 前端工具 | useSSE(fetch + ReadableStream,区分 thinking/content 事件) |
| 前端页面 | ChatView(深度思考开关 + 可折叠思考区域 + 消息列表 + 流式渲染) |
后端 API 总览(全部接口):
| 方法 | 路径 | 说明 | 认证 |
|---|---|---|---|
| GET | /api/ping |
心跳检测 | 不需要 |
| POST | /api/auth/register |
用户注册 | 不需要 |
| POST | /api/auth/login |
用户登录 | 不需要 |
| GET | /api/topics |
获取话题列表 | 需要 |
| POST | /api/topics |
创建话题 | 需要 |
| PUT | /api/topics/{id} |
更新话题标题 | 需要 |
| DELETE | /api/topics/{id} |
删除话题 | 需要 |
| GET | /api/topics/{id}/messages |
获取消息列表 | 需要 |
| POST | /api/chat |
AI 对话(SSE 流式) | 需要 |
接下来介绍图像生成与分析,为对话系统接入图片生成和多模态图像理解能力。