8. AI 对话与流式响应

5 min

1. 后端流式设计

1.1 对话流程

对话流程时序图

一次完整的对话流程如下:

  1. 用户发送消息(前端 -> 后端)
  2. 将用户消息保存到数据库
  3. 从数据库查询该话题的历史消息(作为 AI 上下文)
  4. 将历史消息 + 新消息组装为 Prompt,调用 ChatClient.stream()
  5. AI 流式返回内容,通过 SSE 推送给前端
  6. 流结束后,将 AI 的完整回复保存到数据库
  7. 前端逐字渲染 AI 回复

1.2 DTO

src/main/java/com/albertstack/aichat/dto/ChatRequest.java

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

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

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 事件是一个 ChatChunk JSON 对象,type"thinking""content",前端据此分别渲染
  • 数据隔离:先验证 topicId 属于当前用户
  • 消息持久化:用户消息在 AI 调用前保存,AI 回复(含思考过程)在流结束后保存
  • 上下文窗口CONTEXT_WINDOW = 20,只取最近 20 条消息作为上下文。这是因为模型的上下文长度有限,太多历史消息会导致调用失败或费用过高

1.5 ChatController

src/main/java/com/albertstack/aichat/controller/ChatController.java

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

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

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

typescript
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

typescript
// 聊天接口使用 SSE 流式请求,不走 axios
// 具体调用在 ChatView 中通过 useSSE 实现
export const CHAT_URL = '/api/chat'

2.3 安装 Markdown 渲染依赖

AI 的回复通常包含 Markdown 格式(代码块、列表、加粗等),需要安装 markedhighlight.js 来渲染并高亮代码:

bash
cd ai-chat-frontend
npm install marked marked-highlight highlight.js
  • marked:将 Markdown 文本解析为 HTML
  • marked-highlightmarked 的代码高亮扩展插件(v5+ 不再内置 highlight 选项)
  • highlight.js:语法高亮引擎,支持 Java、JavaScript、SQL 等上百种语言

2.4 完整的 ChatView

更新 src/views/ChatView.vue,加入聊天功能:

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. 测试流程

  1. 打开 http://localhost:5173,注册/登录后进入聊天界面
  2. 点击「+ 新对话」创建话题
  3. 不开启深度思考:输入 "介绍一下你自己",发送,观察打字机效果
  4. 开启深度思考:点击输入框旁的 [深度思考] 按钮使其高亮,输入 "介绍一下你自己",发送
  5. 观察思考过程以半透明小字实时展示(默认展开),思考完成后回复内容逐字呈现
  6. 点击思考过程区域的「深度思考过程(点击展开)」可以折叠/展开
  7. 切换到其他话题再切回来,验证历史消息中的思考过程仍然可以展开查看
  8. 刷新页面,验证消息和思考过程都没有丢失
对话界面

4. 小结

完成了 AI 对话的完整链路:

新增内容
后端 DTO ChatRequest(含 thinking 开关)、ChatChunk(SSE 传输协议)
后端 Service ChatService(双模型切换、思考流提取、上下文构建、消息+思考持久化)
后端 Controller ChatController(SSE 端点,返回 Flux<ChatChunk>
后端测试 ChatServiceTestChatControllerTest
前端工具 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 流式) 需要

接下来介绍图像生成与分析,为对话系统接入图片生成和多模态图像理解能力。