GLM 多技术栈集成完整教程
本教程演示如何使用 4 种技术栈(Vue3、Python FastAPI、DeepAgents、SpringBoot+SpringAI)集成智谱 AI 的 GLM-4.7-Flash 模型,实现 SSE 与 WebSocket 双模式流式对话。
项目概览
| 模块 | 技术栈 | 端口 | 功能 |
|---|---|---|---|
| 前端 | Vue3 + Vite + TypeScript | 5173 | 聊天 UI,支持 3 后端 × 2 模式切换,Markdown 渲染,消息复制/编辑 |
| 后端 1 | Python 3.14 + FastAPI + zai-sdk | 8001 | GLM 流式调用,SSE + WebSocket |
| 后端 2 | DeepAgents + LangGraph + GLMChat | 8002 | 智能体服务,任务规划 + 工具调用 |
| 后端 3 | SpringBoot 4.1.0 + Spring AI 2.0.0 | 8003 | Java 后端,WebClient 直连 GLM |


前置准备
1. 获取 GLM API Key
- 访问 https://bigmodel.cn
- 注册/登录账号
- 进入"控制台" → "API Keys" → 创建新 Key
- 复制保存,后续配置使用
为什么需要 API Key?
所有 3 个后端都要调用智谱 AI 的 GLM-4.7-Flash 模型,这是一个在线 LLM 服务,需要通过 API Key 进行身份认证和计费。本教程使用免费额度的 glm-4.7-flash(30B 级 SOTA 模型)。
2. 环境要求
| 工具 | 版本 | 用途 |
|---|---|---|
| Python | 3.14+ | FastAPI、DeepAgents 后端(使用虚拟环境隔离依赖) |
| Java | 25+ | SpringBoot 4.1.0 运行时 |
| Maven | 3.9+ | SpringBoot 项目构建与依赖管理 |
| Node.js | 24+ | Vue3 前端构建工具 Vite 8 |
| npm | 12+ | 前端依赖安装 |
为什么 Python 要用虚拟环境?
Python 全局包安装会污染系统环境,导致不同项目间依赖版本冲突。本教程每个 Python 服务(FastAPI、DeepAgents)都有独立的
venv目录,全局只保留pip,所有依赖都在虚拟环境中隔离安装。
3. 技术选型说明
| 模块 | 选型 | 替代方案 | 选择理由 |
|---|---|---|---|
| Python Web 框架 | FastAPI | Flask、Django | 原生异步支持、SSE/WebSocket 一等公民、自动 OpenAPI 文档 |
| Python AI SDK | zai-sdk | openai 原生 SDK | 智谱官方 SDK,对 GLM 扩展字段支持更完善 |
| Agent 框架 | DeepAgents | LangChain Agent、AutoGPT | 基于 LangGraph 的状态机、内置任务规划(write_todos)、生产可用 |
| Java AI 库 | Spring AI 2.0 + WebClient | Spring AI ChatClient 单独使用 | Spring AI 不支持 thinking 自定义字段,需绕道 WebClient |
| 前端框架 | Vue3 + Composition API | React | Vue3 的 <script setup> + composables 模式适合流式状态管理 |
第一部分:统一 API 契约定义
1.1 创建项目目录结构
bash
# 创建项目根目录
mkdir glm-4.7
cd glm-4.7
# 创建 4 个模块目录
mkdir frontend
mkdir python-fastapi
mkdir deepagents-server
mkdir springboot-ai
为什么用 4 个独立目录?
三个后端使用完全不同的技术栈(Python、Python+LangGraph、Java),独立目录便于:
- 每个后端独立的依赖管理(requirements.txt vs pom.xml vs package.json)
- 每个后端独立的虚拟环境/构建工具
- 团队成员可以单独负责某个后端的开发
1.2 定义 API 契约(API_CONTRACT.md)
创建 API_CONTRACT.md,定义所有后端遵循的统一接口规范。这是整个项目的核心------前端只对接这个契约,不关心后端实现细节。
端口分配:
- Vue3 前端:5173
- Python FastAPI:8001
- DeepAgents:8002
- SpringBoot:8003
SSE 端点:
- FastAPI:
POST /chat/sse - DeepAgents:
POST /agent/sse - SpringBoot:
POST /chat/sse
WebSocket 端点:
- FastAPI:
WS /chat/ws - DeepAgents:
WS /agent/ws - SpringBoot:
WS /chat/ws
请求体格式(统一):
json
{
"messages": [
{"role": "user", "content": "用户输入内容"}
],
"thinking": true,
"max_tokens": 65536,
"temperature": 1.0
}
字段含义:
messages:OpenAI 风格消息列表,必填thinking:是否启用 GLM 思考模式(reasoning_content),默认 truemax_tokens:最大生成 token 数,GLM-4.7-Flash 最大支持 65536temperature:采样温度,0.0 确定性 → 2.0 创造性
SSE 响应格式 (text/event-stream):
data: {"type": "reasoning", "content": "思考内容片段"}
data: {"type": "content", "content": "回复内容片段"}
data: {"type": "done"}
data: {"type": "error", "content": "错误信息描述"}
帧类型说明:
| type | 含义 | 来源后端 |
|---|---|---|
reasoning |
GLM 思考过程的流式片段(reasoning_content 字段) | 所有 |
content |
正式回复的流式片段 | 所有 |
tool_start |
工具调用开始 | 仅 DeepAgents |
tool_end |
工具调用结束 | 仅 DeepAgents |
plan |
write_todos 任务规划 | 仅 DeepAgents |
done |
流正常结束 | 所有 |
error |
异常信息 | 所有 |
WebSocket 消息帧格式:
- 客户端发送:
{"type": "chat", "messages": [...], "thinking": true, ...} - 服务端推送:
{"type": "reasoning|content|done|error", "content": "..."}
为什么 SSE 和 WebSocket 用不同的请求格式?
- SSE 是 HTTP POST 请求,请求体就是聊天参数
- WebSocket 是双向通信,客户端消息需要包一层
{"type": "chat", ...}来区分消息类型(如 chat、cancel、ping),服务端才能正确路由
1.3 创建环境变量模板(.env.example)
bash
# GLM API Key(从 https://bigmodel.cn 获取)
GLM_API_KEY=your-api-key-here
# 服务端口
VITE_FRONTEND_PORT=5173
FASTAPI_PORT=8001
DEEPAGENTS_PORT=8002
SPRINGBOOT_PORT=8003
# GLM 模型配置
GLM_MODEL=glm-4.7-flash
GLM_BASE_URL=https://open.bigmodel.cn/api/paas/v4
GLM_MAX_TOKENS=65536
GLM_TEMPERATURE=1.0
为什么要用 .env 文件?
- 避免硬编码:API Key 不应提交到 Git
- 多环境切换:开发/测试/生产用不同 Key
- 统一管理:所有后端共享同一份配置,避免复制粘贴
复制 .env.example 为 .env 并填入真实 API Key:
bash
cp .env.example .env
# 编辑 .env,将 GLM_API_KEY 改为你的真实 Key
各后端加载 .env 的方式:
- Python 后端(FastAPI/DeepAgents):使用
python-dotenv的load_dotenv()自动加载项目根目录的.env - SpringBoot 后端:在
Application.java启动前手动读取.env并注入 System Properties(详见 4.4 节) - Vue3 前端:Vite 自动加载
VITE_开头的环境变量
1.4 创建 README 占位文件
在每个服务目录下创建 README.md,简单描述该模块的作用。便于后人理解项目结构。
第二部分:Python FastAPI 后端(8001)
2.1 创建项目目录
bash
cd python-fastapi
mkdir -p src
本项目结构简单(2 个 .py 文件),不需要
src/子目录,所有代码直接放在python-fastapi/下即可。
2.2 创建依赖文件(requirements.txt)
txt
fastapi
uvicorn[standard]
zai-sdk
websockets
python-dotenv
每个依赖的作用:
| 依赖 | 作用 | 版本要求 |
|---|---|---|
fastapi |
Web 框架,提供 @app.get/post、WebSocket 装饰器 |
≥ 0.110 |
uvicorn[standard] |
ASGI 服务器,方括号表示包含 websockets/watchfiles 等可选依赖 | ≥ 0.27 |
zai-sdk |
智谱 AI 官方 Python SDK,封装 GLM API 调用 | 最新版即可 |
websockets |
提供 WebSocket 底层实现(uvicorn 依赖) | 通常作为 uvicorn 传递依赖 |
python-dotenv |
加载 .env 文件到 os.environ |
≥ 1.0 |
为什么不固定版本号?
用户要求
requirements.txt只保留依赖库名称,不指定版本号。安装时默认拉取最新稳定版。如果遇到兼容性问题,可以临时在虚拟环境中pip freeze > requirements-lock.txt生成锁定文件。
2.3 创建虚拟环境并安装依赖
bash
# 创建虚拟环境(python -m venv venv 创建独立的 Python 解释器副本)
python -m venv venv
# 激活虚拟环境(Windows PowerShell)
.\venv\Scripts\activate
# 激活后命令行前缀会显示 (venv),表示当前在虚拟环境中
# 安装依赖(只影响 venv,不影响全局 Python)
pip install -r requirements.txt
# 验证安装
pip list
# 应看到 fastapi、uvicorn、zai-sdk、websockets、python-dotenv
为什么每个服务一个 venv?
FastAPI 和 DeepAgents 共享一些依赖(如 fastapi、uvicorn、python-dotenv),但 DeepAgents 额外依赖 langchain、langgraph、deepagents。如果共用一个 venv,升级一个服务的依赖可能破坏另一个服务。独立 venv 是最佳实践。
2.4 创建 GLM 客户端封装(glm_client.py)
设计思路
这是整个 FastAPI 后端最核心的代码。面临两个挑战:
- GLM 流式响应是同步迭代器 :zai-sdk 的
client.chat.completions.create(stream=True)返回一个同步对象,只能用for chunk in response遍历- FastAPI 是异步的:如果在 SSE 端点直接调用同步流式迭代,会阻塞整个事件循环,导致其他请求无法处理
解决方案:asyncio.Queue + 后台线程 的生产者-消费者模式
主线程(async) 后台线程(sync) ───────────── ───────────── consumer = async for ... producer: for chunk in response: item = await queue.get() loop.call_soon_threadsafe( if item: yield item queue.put_nowait(item)同步迭代在后台线程运行,通过线程安全的
queue.put_nowait将结果投递给异步事件循环;异步消费者从队列中await queue.get()拿到片段并 yield 给 SSE 端点。这种模式也支持客户端断开时的优雅取消:消费者停止从队列取数据时,调用
cancel_event.set()通知生产者停止读取上游 GLM 流,避免资源泄漏。
python
"""GLM-4.7-Flash 客户端封装。
使用 zai-sdk 的 ZhipuAiClient 进行流式调用。由于 zai-sdk 的流式迭代是同步的
(`for chunk in response`),这里通过 asyncio.Queue + 后台线程的方式将其桥接为
异步生成器,避免阻塞 FastAPI 的事件循环。
"""
import os
import asyncio
import threading
from typing import AsyncGenerator, Optional
from zai import ZhipuAiClient
# 模型名称
MODEL = "glm-4.7-flash"
def _get_client() -> ZhipuAiClient:
"""根据环境变量 GLM_API_KEY 创建客户端实例。"""
api_key = os.environ.get("GLM_API_KEY")
if not api_key:
raise RuntimeError("环境变量 GLM_API_KEY 未设置,请先在 .env 中配置")
return ZhipuAiClient(api_key=api_key)
async def stream_chat(
messages: list,
thinking: bool = True,
max_tokens: int = 65536,
temperature: float = 1.0,
cancel_event: Optional[threading.Event] = None,
) -> AsyncGenerator[dict, None]:
"""流式调用 GLM-4.7-Flash,逐块 yield 字典。
yield 的字典结构:
{"type": "reasoning", "content": "..."} 思考内容片段
{"type": "content", "content": "..."} 正式回复片段
参数:
messages: OpenAI 风格的消息列表 [{"role": "user", "content": "..."}]
thinking: 是否启用思考模式
max_tokens: 最大生成 token 数
temperature: 采样温度
cancel_event: 可选的外部取消事件,set() 后会尽快停止读取
"""
client = _get_client()
# 组装调用参数
kwargs = {
"model": MODEL,
"messages": messages,
"stream": True,
"max_tokens": max_tokens,
"temperature": temperature,
}
if thinking:
kwargs["thinking"] = {"type": "enabled"}
loop = asyncio.get_running_loop()
queue: asyncio.Queue = asyncio.Queue()
# 哨兵对象,标识流正常结束
SENTINEL = object()
# 若调用方未提供取消事件,则内部创建一个(用于消费者提前退出时通知生产者)
if cancel_event is None:
cancel_event = threading.Event()
def _sync_producer() -> None:
"""在后台线程中执行同步流式迭代,将结果投递到队列。"""
try:
response = client.chat.completions.create(**kwargs)
for chunk in response:
# 检查取消信号,尽快退出
if cancel_event.is_set():
break
# 部分末尾 chunk 可能没有 choices
choices = getattr(chunk, "choices", None) or []
if not choices:
continue
delta = getattr(choices[0], "delta", None)
if delta is None:
continue
reasoning = getattr(delta, "reasoning_content", None)
content = getattr(delta, "content", None)
if reasoning:
loop.call_soon_threadsafe(
queue.put_nowait,
{"type": "reasoning", "content": reasoning},
)
if content:
loop.call_soon_threadsafe(
queue.put_nowait,
{"type": "content", "content": content},
)
except Exception as e: # noqa: BLE001
# 把异常投递给消费者
loop.call_soon_threadsafe(queue.put_nowait, e)
return
# 正常结束投递哨兵
loop.call_soon_threadsafe(queue.put_nowait, SENTINEL)
# 在后台线程运行同步生产者
producer_task = asyncio.create_task(asyncio.to_thread(_sync_producer))
try:
while True:
item = await queue.get()
if item is SENTINEL:
break
if isinstance(item, BaseException):
raise item
yield item
finally:
# 消费者提前退出(如客户端断开)时,通知生产者停止
cancel_event.set()
# 等待生产者线程结束,最多等 5 秒,避免无限挂起
try:
await asyncio.wait_for(producer_task, timeout=5.0)
except Exception: # noqa: BLE001
pass
2.5 创建 FastAPI 应用(main.py)
设计思路
main.py 是 HTTP/WebSocket 入口,负责:
- 加载环境变量 :
load_dotenv()在应用启动时读取项目根目录.env- CORS 跨域:前端 5173 端口跨域调用 8001 端口的 API
- 请求体校验 :用 Pydantic
BaseModel自动校验请求格式- SSE 端点 :用
StreamingResponse+ 异步生成器返回text/event-stream- WebSocket 端点 :用
@app.websocket装饰器处理双向通信- 健康检查 :
/health端点用于监控服务存活SSE 响应格式的关键细节:
media_type="text/event-stream":告诉浏览器这是 SSE 协议Cache-Control: no-cache:禁用代理缓存,确保实时性X-Accel-Buffering: no:禁用 Nginx 等反向代理的响应缓冲- 每个事件以
data: <json>\n\n结尾(两个换行表示事件结束)
python
"""FastAPI 应用入口,端口 8001。
提供 SSE 与 WebSocket 两种流式端点,调用 GLM-4.7-Flash 模型。
"""
import os
from contextlib import asynccontextmanager
from typing import Optional
from dotenv import load_dotenv
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from glm_client import stream_chat
# 加载项目根目录 .env
load_dotenv()
# 请求体模型
class ChatRequest(BaseModel):
messages: list[dict]
thinking: bool = True
max_tokens: int = 65536
temperature: float = 1.0
# 应用生命周期
@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用启动/关闭时执行。"""
print("FastAPI 应用启动")
yield
print("FastAPI 应用关闭")
# 创建 FastAPI 应用
app = FastAPI(
title="GLM-4.7-Flash FastAPI 后端",
description="Python 3.14 + FastAPI + zai-sdk 实现 GLM 流式调用",
version="1.0.0",
lifespan=lifespan,
)
# CORS 配置:允许前端 5173 端口跨域
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 健康检查端点
@app.get("/health")
async def health():
"""健康检查,返回应用状态。"""
return {"status": "ok", "service": "fastapi", "port": 8001}
# SSE 端点
@app.post("/chat/sse")
async def chat_sse(request: ChatRequest):
"""SSE 流式聊天端点。
接收聊天请求,使用 StreamingResponse 逐块推送 GLM 流式响应。
响应格式:data: {"type": "reasoning|content|done|error", "content": "..."}
"""
# 检查 API Key
api_key = os.environ.get("GLM_API_KEY")
if not api_key:
return StreamingResponse(
iter([b'data: {"type": "error", "content": "GLM_API_KEY 未配置"}\n\n']),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
# 创建异步生成器,逐块推送 SSE 事件
async def event_generator():
try:
async for frame in stream_chat(
messages=request.messages,
thinking=request.thinking,
max_tokens=request.max_tokens,
temperature=request.temperature,
):
# 序列化为 SSE data 行
import json
data = json.dumps(frame, ensure_ascii=False)
yield f"data: {data}\n\n"
# 流正常结束,推送 done 帧
yield 'data: {"type": "done"}\n\n'
except Exception as e:
# 异常时推送 error 帧
import json
error_frame = {"type": "error", "content": str(e)}
yield f"data: {json.dumps(error_frame, ensure_ascii=False)}\n\n"
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
# WebSocket 端点
@app.websocket("/chat/ws")
async def chat_ws(websocket: WebSocket):
"""WebSocket 聊天端点。
接收客户端消息,逐帧推送 GLM 流式响应。
客户端发送:{"type": "chat", "messages": [...], "thinking": true, ...}
服务端推送:{"type": "reasoning|content|done|error", "content": "..."}
"""
await websocket.accept()
print(f"[WS] 连接建立:{websocket.client}")
try:
while True:
# 接收客户端消息
data = await websocket.receive_json()
message_type = data.get("type")
if message_type != "chat":
await websocket.send_json({"type": "error", "content": "未知的消息类型"})
continue
# 检查 API Key
api_key = os.environ.get("GLM_API_KEY")
if not api_key:
await websocket.send_json({"type": "error", "content": "GLM_API_KEY 未配置"})
continue
# 流式调用 GLM,逐帧推送
try:
async for frame in stream_chat(
messages=data.get("messages", []),
thinking=data.get("thinking", True),
max_tokens=data.get("max_tokens", 65536),
temperature=data.get("temperature", 1.0),
):
await websocket.send_json(frame)
# 流正常结束
await websocket.send_json({"type": "done"})
except Exception as e:
await websocket.send_json({"type": "error", "content": str(e)})
except WebSocketDisconnect:
print(f"[WS] 连接断开:{websocket.client}")
except Exception as e:
print(f"[WS] 异常:{e}")
try:
await websocket.send_json({"type": "error", "content": str(e)})
except Exception:
pass
# 启动入口
if __name__ == "__main__":
import uvicorn
port = int(os.environ.get("FASTAPI_PORT", 8001))
uvicorn.run(app, host="0.0.0.0", port=port, reload=True)
2.6 运行 FastAPI 后端
bash
# 设置环境变量(PowerShell)
$env:GLM_API_KEY="your-api-key-here"
# 启动服务(确保在虚拟环境中)
python main.py
# 或使用 uvicorn 直接启动(更详细的日志)
uvicorn main:app --host 0.0.0.0 --port 8001 --reload
访问 http://localhost:8001/docs 查看 Swagger 文档。
2.7 手动验证 FastAPI 端点
启动后用 curl 命令验证两个端点:
验证 SSE 端点:
bash
curl -N -X POST http://localhost:8001/chat/sse ^
-H "Content-Type: application/json" ^
-d "{\"messages\":[{\"role\":\"user\",\"content\":\"你好,请自我介绍\"}],\"thinking\":true,\"max_tokens\":1024,\"temperature\":1.0}"
预期输出(实时流式):
data: {"type": "reasoning", "content": "用户让我自我介绍"}
data: {"type": "content", "content": "你好"}
data: {"type": "content", "content": "!我是 GLM"}
data: {"type": "content", "content": "-4.7"}
data: {"type": "done"}
curl 参数说明:
-N/--no-buffer:禁用 curl 缓冲,实时显示 SSE 流- PowerShell 用
^作为换行符,Bash 用\
验证 WebSocket 端点(需 Python 脚本或 wscat):
bash
# 安装 wscat(Node.js 工具)
npm install -g wscat
# 连接 WebSocket
wscat -c ws://localhost:8001/chat/ws
# 连接后输入:
> {"type":"chat","messages":[{"role":"user","content":"你好"}],"thinking":true,"max_tokens":512,"temperature":1.0}
验证健康检查:
bash
curl http://localhost:8001/health
# {"status":"ok","service":"fastapi","port":8001}
第三部分:DeepAgents 智能体服务(8002)
什么是 DeepAgents?
DeepAgents 是 LangChain 官方开源的智能体开发框架,构建在 LangGraph 状态机之上。它在 ReAct Agent 的基础上增加了:
- 任务规划工具(write_todos / read_todos):让 LLM 将复杂任务拆解为有序步骤
- 虚拟文件系统(write_file / read_file / ls):让 LLM 读写结构化数据
- 子代理委派(task):让 LLM 调用隔离的子代理处理子任务
- 自动摘要机制:当对话历史过长时自动压缩上下文
这些能力让 DeepAgents 适合做"长链路、需要规划的复杂任务",例如多步数据分析、研究报告生成、自动化运维等。
3.1 创建项目目录
bash
cd deepagents-server
3.2 创建依赖文件(requirements.txt)
txt
fastapi
uvicorn[standard]
deepagents
langchain-openai
langgraph
python-dotenv
openai
每个依赖的作用:
| 依赖 | 作用 |
|---|---|
fastapi |
Web 框架 |
uvicorn[standard] |
ASGI 服务器 |
deepagents |
DeepAgents 框架(提供 create_deep_agent) |
langchain-openai |
LangChain 的 OpenAI 兼容 ChatModel(备用) |
langgraph |
DeepAgents 底层依赖,提供状态机编排 |
python-dotenv |
加载 .env 文件 |
openai |
OpenAI Python SDK,本项目用其原生客户端实现 GLMChat |
为什么需要
openai?自定义的
GLMChat类继承 LangChain 的BaseChatModel,但内部使用openai原生客户端调用 GLM(OpenAI 兼容协议)。这样可以完全控制请求体(如thinking字段)和响应解析(如reasoning_content字段)。
3.3 创建虚拟环境并安装依赖
bash
# 创建虚拟环境
python -m venv venv
# 激活虚拟环境(Windows)
.\venv\Scripts\activate
# 安装依赖(deepagents 会自动拉取 langchain、langgraph、langchain-core 等)
pip install -r requirements.txt
# 验证关键依赖
pip show deepagents langgraph
3.4 创建 GLM 自定义 Chat Model(glm_chat.py)
为什么需要自定义 Chat Model?
这是整个项目最关键的兼容性修复点。直接用
langchain-openai的ChatOpenAI调用 GLM 会导致两个严重问题:
- reasoning_content 被丢弃 :
ChatOpenAI._parse_chat_completion_chunk只解析标准 OpenAI 字段(content、role、tool_calls),GLM 的reasoning_content字段被静默忽略,前端拿不到思考过程- thinking 模式无法启用 :
ChatOpenAI通过OpenAIChatOptions传参,没有原生方法塞入非标准的thinking: {"type": "enabled"}字段解决方案:继承 LangChain 的
BaseChatModel基类 ,内部用openai原生客户端(OpenAI(...))调用 GLM。这样可以:
- 完全控制请求体(通过
extra_body={"thinking": {...}})- 完全控制响应解析(手动提取
reasoning_content并写入chunk.additional_kwargs)- 实现
bind_tools以兼容 DeepAgents 的工具调用流程
python
"""GLM 自定义 LangChain Chat Model。
为什么需要这个类?
====================
GLM-4.7-Flash 是 OpenAI 兼容接口,但有几个非标准字段:
1. 请求体中的 `thinking: {"type": "enabled"}` 启用思考模式
2. 响应流式 chunk 中有 `reasoning_content` 字段(思考内容片段)
而 `langchain-openai` 的 `ChatOpenAI` 在解析流式 chunk 时会丢弃这些非标准字段,
导致 reasoning_content 无法在前端展示。
本类继承 LangChain 的 `BaseChatModel`,内部使用 `openai` 原生客户端直接调用 GLM:
- 透传 thinking 启用思考模式
- 将流式响应中的 reasoning_content 写入 chunk.additional_kwargs
- 实现 bind_tools 以兼容 LangGraph/DeepAgents 的工具调用流程
"""
from __future__ import annotations
import asyncio
import json
import os
import uuid
from typing import Any, AsyncIterator, Dict, Iterator, List, Mapping, Optional, Sequence
from langchain_core.callbacks import CallbackManagerForLLMRun
from langchain_core.language_models import BaseChatModel
from langchain_core.messages import AIMessage, AIMessageChunk, BaseMessage, ToolCall
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult
from langchain_core.runnables import RunnableBinding
from langchain_core.tools import BaseTool
from openai import OpenAI
from openai.types.chat import ChatCompletionMessageToolCall
from pydantic import Field
def _build_openai_client() -> OpenAI:
"""根据环境变量创建 openai 原生客户端,连接到 GLM OpenAI 兼容端点。"""
api_key = os.getenv("GLM_API_KEY")
if not api_key:
raise RuntimeError("环境变量 GLM_API_KEY 未设置,请先在 .env 中配置")
base_url = os.getenv("GLM_BASE_URL", "https://open.bigmodel.cn/api/paas/v4")
return OpenAI(api_key=api_key, base_url=base_url)
def _convert_message_to_dict(message: BaseMessage) -> dict:
"""将 LangChain 的 BaseMessage 转为 OpenAI 风格字典(含 tool_calls/tool 角色)。"""
role = getattr(message, "role", None) or message.type
role_map = {"human": "user", "ai": "assistant", "system": "system", "tool": "tool"}
role = role_map.get(role, role)
content = message.content
if isinstance(content, str):
text = content
elif isinstance(content, list):
parts = []
for item in content:
if isinstance(item, dict):
if item.get("type") in ("text", "output_text", "reasoning", "thinking"):
parts.append(str(item.get("text", "")))
elif isinstance(item, str):
parts.append(item)
text = "".join(parts)
else:
text = "" if content is None else str(content)
msg: Dict[str, Any] = {"role": role, "content": text}
# 处理 tool 消息(tool 响应需要 tool_call_id)
tool_call_id = getattr(message, "tool_call_id", None)
if tool_call_id:
msg["tool_call_id"] = tool_call_id
# 处理 assistant 消息的 tool_calls
if role == "assistant":
tool_calls = getattr(message, "tool_calls", None)
if tool_calls:
msg["tool_calls"] = [
{
"id": tc.get("id") or tc.get("id", f"call_{uuid.uuid4().hex[:8]}"),
"type": "function",
"function": {
"name": tc.get("name"),
"arguments": json.dumps(tc.get("args", {}), ensure_ascii=False)
if not isinstance(tc.get("args"), str)
else tc.get("args"),
},
}
for tc in tool_calls
]
return msg
def _convert_tool_to_dict(tool: BaseTool) -> dict:
"""将 LangChain BaseTool 转 OpenAI 风格 function tool 字典。"""
try:
schema = tool.args_schema.model_jsonschema() if tool.args_schema else {}
except Exception: # noqa: BLE001
schema = {}
# 清理 OpenAI 不支持的 schema 字段
for k in ("title", "$schema"):
schema.pop(k, None)
return {
"type": "function",
"function": {
"name": tool.name,
"description": tool.description or "",
"parameters": schema or {"type": "object", "properties": {}},
},
}
class GLMChat(BaseChatModel):
"""GLM-4.7-Flash 自定义 Chat Model。"""
model_name: str = Field(default="glm-4.7-flash")
temperature: float = Field(default=1.0)
max_tokens: int = Field(default=65536)
thinking: bool = Field(default=True)
client: Optional[OpenAI] = Field(default=None, exclude=True)
# 通过 bind_tools 注入的工具列表(OpenAI 风格 dict 列表)
bound_tools: Optional[List[dict]] = Field(default=None, exclude=True)
def __init__(self, **data: Any) -> None:
super().__init__(**data)
if self.client is None:
object.__setattr__(self, "client", _build_openai_client())
@property
def _llm_type(self) -> str:
return "glm-chat"
@property
def _identifying_params(self) -> dict:
return {
"model": self.model_name,
"temperature": self.temperature,
"max_tokens": self.max_tokens,
"thinking": self.thinking,
}
def bind_tools(
self,
tools: Sequence[BaseTool | dict],
**kwargs: Any,
):
"""绑定工具列表,返回新的 GLMChat 实例。
注意:使用 model_copy(update=...) 而不是 deep=True,因为
client (OpenAI 实例) 内部包含 RLock 等不可 deepcopy 的对象。
"""
tool_dicts: List[dict] = []
for t in tools:
if isinstance(t, dict):
# 已是 OpenAI 风格,直接使用
tool_dicts.append(t)
elif isinstance(t, BaseTool):
tool_dicts.append(_convert_tool_to_dict(t))
# 用 update 创建新实例,client 共享不深拷贝
new_instance = self.model_copy(update={"bound_tools": tool_dicts})
return new_instance
def _build_messages(self, messages: List[BaseMessage]) -> List[dict]:
return [_convert_message_to_dict(m) for m in messages]
def _build_kwargs(self) -> dict:
kwargs: Dict[str, Any] = {
"model": self.model_name,
"stream": True,
"temperature": self.temperature,
"max_tokens": self.max_tokens,
}
if self.thinking:
kwargs["extra_body"] = {"thinking": {"type": "enabled"}}
if self.bound_tools:
kwargs["tools"] = self.bound_tools
return kwargs
def _parse_tool_calls_from_delta(self, delta: Any, accumulator: Dict[str, Dict[str, Any]]) -> None:
"""累积 OpenAI 流式 tool_calls delta 到 accumulator。
每个 tool_call 由 id/name(首段)和后续 arguments delta 组成。
"""
tool_calls = getattr(delta, "tool_calls", None)
if not tool_calls:
return
for tc in tool_calls:
tc_index = getattr(tc, "index", 0)
if tc_index not in accumulator:
accumulator[tc_index] = {
"id": "",
"name": "",
"args": "",
}
entry = accumulator[tc_index]
if getattr(tc, "id", None):
entry["id"] = tc.id
fn = getattr(tc, "function", None)
if fn is not None:
if getattr(fn, "name", None):
entry["name"] = fn.name
if getattr(fn, "arguments", None):
entry["args"] += fn.arguments
def _stream(
self,
messages: List[BaseMessage],
stop: Optional[List[str]] = None,
run_manager: Optional[CallbackManagerForLLMRun] = None,
**kwargs: Any,
) -> Iterator[ChatGenerationChunk]:
"""同步流式调用,逐 yield AIMessageChunk。"""
request_kwargs = self._build_kwargs()
request_kwargs["messages"] = self._build_messages(messages)
try:
response = self.client.chat.completions.create(**request_kwargs)
except Exception as e: # noqa: BLE001
raise RuntimeError(f"GLM 调用失败:{e}") from e
tool_calls_acc: Dict[int, Dict[str, Any]] = {}
for chunk in response:
if not chunk.choices:
continue
delta = chunk.choices[0].delta
reasoning = getattr(delta, "reasoning_content", None) or ""
content = getattr(delta, "content", None) or ""
# 累积 tool_calls delta
self._parse_tool_calls_from_delta(delta, tool_calls_acc)
additional_kwargs: Dict[str, Any] = {}
if reasoning:
additional_kwargs["reasoning_content"] = reasoning
tool_call_chunks = []
for idx, tc in tool_calls_acc.items():
if tc.get("id") or tc.get("name") or tc.get("args"):
tool_call_chunks.append({
"id": tc.get("id") or f"call_{uuid.uuid4().hex[:8]}",
"name": tc.get("name") or "",
"args": tc.get("args") or "",
"index": idx,
})
structured_content: List[Any] = []
if reasoning:
structured_content.append({"type": "reasoning", "text": reasoning})
if content:
structured_content.append({"type": "text", "text": content})
msg_chunk = AIMessageChunk(
content=structured_content if structured_content else "",
additional_kwargs=additional_kwargs,
tool_call_chunks=[
{
"name": tcc["name"],
"args": tcc["args"],
"id": tcc["id"],
"index": tcc["index"],
}
for tcc in tool_call_chunks
] if tool_call_chunks else [],
)
yield ChatGenerationChunk(message=msg_chunk)
async def _astream(
self,
messages: List[BaseMessage],
stop: Optional[List[str]] = None,
run_manager: Optional[CallbackManagerForLLMRun] = None,
**kwargs: Any,
) -> AsyncIterator[ChatGenerationChunk]:
"""异步流式:同步生成器通过 queue + to_thread 桥接。"""
loop = asyncio.get_running_loop()
sync_gen = self._stream(messages, stop, run_manager, **kwargs)
queue: asyncio.Queue = asyncio.Queue()
SENTINEL = object()
def _producer():
try:
for item in sync_gen:
loop.call_soon_threadsafe(queue.put_nowait, item)
except Exception as e: # noqa: BLE001
loop.call_soon_threadsafe(queue.put_nowait, e)
finally:
loop.call_soon_threadsafe(queue.put_nowait, SENTINEL)
producer_task = asyncio.create_task(asyncio.to_thread(_producer))
try:
while True:
item = await queue.get()
if item is SENTINEL:
break
if isinstance(item, BaseException):
raise item
yield item
finally:
try:
await asyncio.wait_for(producer_task, timeout=5.0)
except Exception: # noqa: BLE001
pass
def _generate(
self,
messages: List[BaseMessage],
stop: Optional[List[str]] = None,
run_manager: Optional[CallbackManagerForLLMRun] = None,
**kwargs: Any,
) -> ChatResult:
"""非流式调用:聚合所有 chunk 为完整 AIMessage。"""
all_reasoning = ""
all_content = ""
all_tool_calls: List[ToolCall] = []
# 工具调用聚合
tool_acc: Dict[int, Dict[str, Any]] = {}
for gen_chunk in self._stream(messages, stop, run_manager, **kwargs):
inner = gen_chunk.message
rk = inner.additional_kwargs.get("reasoning_content") if inner.additional_kwargs else None
if rk:
all_reasoning += rk
for part in (inner.content if isinstance(inner.content, list) else []):
if isinstance(part, dict) and part.get("type") == "text":
all_content += str(part.get("text", ""))
for tcc in (inner.tool_call_chunks or []):
idx = tcc.get("index", 0)
if idx not in tool_acc:
tool_acc[idx] = {"id": tcc.get("id", ""), "name": tcc.get("name", ""), "args": ""}
tool_acc[idx]["args"] += tcc.get("args", "")
if tcc.get("name"):
tool_acc[idx]["name"] = tcc["name"]
if tcc.get("id"):
tool_acc[idx]["id"] = tcc["id"]
for idx in sorted(tool_acc.keys()):
tc = tool_acc[idx]
args_str = tc["args"]
try:
args = json.loads(args_str) if args_str else {}
except Exception: # noqa: BLE001
args = {"_raw": args_str}
all_tool_calls.append(ToolCall(id=tc["id"] or f"call_{uuid.uuid4().hex[:8]}", name=tc["name"], args=args))
structured: List[Any] = []
if all_reasoning:
structured.append({"type": "reasoning", "text": all_reasoning})
if all_content:
structured.append({"type": "text", "text": all_content})
msg = AIMessage(
content=structured if structured else all_content,
additional_kwargs={"reasoning_content": all_reasoning} if all_reasoning else {},
tool_calls=all_tool_calls,
)
return ChatResult(generations=[ChatGeneration(message=msg)])
3.5 创建智能体配置(agent.py)
设计思路
agent.py包含三个关键概念:
- DEFAULT_SYSTEM_PROMPT :智能体的"人设"。DeepAgents 会自动注入内置提示词,这里追加我们的中文指令,要求 LLM 优先用
write_todos规划步骤- CUSTOM_TOOLS:自定义工具列表。DeepAgents 会将其与内置工具(write_todos、write_file、task 等)合并
get_agent函数 :每次请求调用create_deep_agent创建新实例。不要用lru_cache缓存------LangGraph 内部会 pickle 智能体以传递给子进程,缓存的实例包含 OpenAI 客户端(带 RLock)无法序列化
stream_agent的事件类型:通过
agent.astream_events(version="v2")可以订阅智能体执行的每个事件。关键事件:
事件类型 含义 映射帧 on_chat_model_streamLLM 流式输出 chunk reasoning/contenton_tool_start工具调用开始 tool_start/plan(write_todos)on_tool_end工具调用结束 tool_end/plan(read_todos)
python
"""DeepAgents 智能体创建与配置。
基于 LangGraph + DeepAgents 框架,使用 GLM-4.7-Flash(OpenAI 兼容接口)驱动,
具备任务规划(write_todos/read_todos)、文件系统、子代理等内置工具能力,
并提供一个自定义工具(获取当前时间)以展示工具调用能力。
"""
import json
import os
from datetime import datetime
from typing import Any, AsyncGenerator, Dict, List, Optional
from dotenv import load_dotenv
from langchain_core.tools import tool
from deepagents import create_deep_agent
from glm_chat import GLMChat
# 加载项目根目录 .env
load_dotenv()
# GLM 模型配置常量
GLM_BASE_URL = os.getenv("GLM_BASE_URL", "https://open.bigmodel.cn/api/paas/v4")
GLM_MODEL = os.getenv("GLM_MODEL", "glm-4.7-flash")
# 智能体系统提示词
DEFAULT_SYSTEM_PROMPT = (
"你是一个有用的 AI 助手,能够规划任务并使用工具完成复杂工作。"
"在处理复杂任务时,请先使用 write_todos 规划步骤,再逐步执行。"
"回答请使用中文。"
)
@tool
def get_current_time() -> str:
"""获取当前时间,返回 ISO 8601 格式的时间字符串。"""
return datetime.now().isoformat()
# 自定义工具列表(DeepAgents 会在此基础上叠加内置工具)
CUSTOM_TOOLS = [get_current_time]
def build_model(
thinking: bool = True,
temperature: float = 1.0,
max_tokens: int = 65536,
):
"""构建连接 GLM-4.7-Flash 的自定义 Chat Model 实例(GLMChat)。
使用 GLMChat 而非 langchain-openai 的 ChatOpenAI 是因为:
- ChatOpenAI 解析流式 chunk 时会丢弃 GLM 扩展的 reasoning_content 字段
- GLMChat 内部使用 openai 原生客户端,透传 thinking 并保留 reasoning_content
"""
return GLMChat(
model_name=GLM_MODEL,
temperature=temperature,
max_tokens=max_tokens,
thinking=thinking,
)
def get_agent(
thinking: bool = True,
temperature: float = 1.0,
max_tokens: int = 65536,
):
"""根据参数构建 DeepAgents 智能体。
不使用 lru_cache 缓存智能体,因为 LangGraph 的 astream_events 内部会
pickle 智能体(含 RLock 等不可序列化对象)以传递给子进程,缓存会导致
pickling 错误。每次请求重新构建(构建开销可接受)。
"""
model = build_model(thinking, temperature, max_tokens)
agent = create_deep_agent(
model=model,
system_prompt=DEFAULT_SYSTEM_PROMPT,
tools=CUSTOM_TOOLS,
)
return agent
def _stringify(value: Any) -> str:
"""将任意值转为可读字符串,优先用 JSON 序列化。"""
if value is None:
return ""
if isinstance(value, str):
return value
try:
return json.dumps(value, ensure_ascii=False)
except Exception:
return str(value)
def _extract_reasoning(chunk: Any) -> Optional[str]:
"""从 AIMessageChunk 中提取 GLM 的 reasoning_content(思考内容)。
注意:GLM 的 reasoning_content 是 OpenAI 标准之外的额外字段。
LangChain 的 ChatOpenAI 不一定直接暴露该字段,可能落在以下位置:
1. chunk.additional_kwargs["reasoning_content"]
2. chunk.response_metadata["reasoning_content"]
3. content 为结构化列表时,其中 type=reasoning/thinking 的块
本函数做多处兼容性探测;若均无法获取则返回 None(此时仅推送 content 与工具事件)。
"""
# 1) additional_kwargs
if getattr(chunk, "additional_kwargs", None):
reasoning = chunk.additional_kwargs.get("reasoning_content")
if reasoning:
return reasoning if isinstance(reasoning, str) else _stringify(reasoning)
# 2) response_metadata
if getattr(chunk, "response_metadata", None):
reasoning = chunk.response_metadata.get("reasoning_content")
if reasoning:
return reasoning if isinstance(reasoning, str) else _stringify(reasoning)
# 3) content 为结构化列表时,包含 reasoning/thinking 类型块
content = getattr(chunk, "content", None)
if isinstance(content, list):
parts = []
for item in content:
if isinstance(item, dict) and item.get("type") in ("reasoning", "thinking"):
parts.append(str(item.get("text", "")))
if parts:
return "".join(parts)
return None
def _extract_content(chunk: Any) -> Optional[str]:
"""从 AIMessageChunk 中提取正式回复内容。"""
content = getattr(chunk, "content", None)
if content is None:
return None
if isinstance(content, str):
return content if content else None
if isinstance(content, list):
parts = []
for item in content:
if isinstance(item, dict):
if item.get("type") in ("text", "output_text"):
parts.append(str(item.get("text", "")))
elif isinstance(item, str):
parts.append(item)
text = "".join(parts)
return text if text else None
return str(content) if content else None
async def stream_agent(
messages: List[Dict[str, Any]],
thinking: bool = True,
temperature: float = 1.0,
max_tokens: int = 65536,
) -> AsyncGenerator[Dict[str, Any], None]:
"""流式运行 DeepAgents 智能体,yield 标准化帧字典。
帧类型:
- {"type": "reasoning", "content": "..."} 思考内容片段
- {"type": "content", "content": "..."} 正式回复片段
- {"type": "tool_start", "content": "..."} 工具调用开始
- {"type": "tool_end", "content": "..."} 工具调用结束
- {"type": "plan", "content": "..."} 任务规划(write_todos/read_todos)
- {"type": "done"} 流正常结束
说明:若运行过程中抛出异常,异常会向上传播,由调用方(main.py)捕获后
推送 {"type": "error", "content": "..."} 帧。
"""
agent = get_agent(thinking, temperature, max_tokens)
inputs = {"messages": messages}
async for event in agent.astream_events(inputs, version="v2"):
etype = event.get("event")
data = event.get("data") or {}
if etype == "on_chat_model_stream":
chunk = data.get("chunk")
if chunk is None:
continue
# 先推送思考内容,再推送正式回复
reasoning = _extract_reasoning(chunk)
if reasoning:
yield {"type": "reasoning", "content": reasoning}
content = _extract_content(chunk)
if content:
yield {"type": "content", "content": content}
elif etype == "on_tool_start":
name = event.get("name", "")
tool_input = data.get("input")
# write_todos 的输入即任务规划,映射为 plan 帧
if name == "write_todos":
yield {"type": "plan", "content": _stringify(tool_input)}
else:
yield {
"type": "tool_start",
"content": f"调用工具 {name}:{_stringify(tool_input)}",
}
elif etype == "on_tool_end":
name = event.get("name", "")
output = data.get("output")
# read_todos 的输出即当前任务规划,映射为 plan 帧
if name == "read_todos":
yield {"type": "plan", "content": _stringify(output)}
elif name == "write_todos":
# 已在 tool_start 推送 plan,此处不重复
continue
else:
yield {
"type": "tool_end",
"content": f"工具 {name} 完成:{_stringify(output)}",
}
yield {"type": "done"}
3.6 创建 FastAPI 应用(main.py)
python
"""DeepAgents 智能体服务 FastAPI 应用入口,端口 8002。
提供 SSE 与 WebSocket 两种流式端点,调用 DeepAgents 智能体。
"""
import os
from contextlib import asynccontextmanager
from dotenv import load_dotenv
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from agent import stream_agent
# 加载项目根目录 .env
load_dotenv()
# 请求体模型
class ChatRequest(BaseModel):
messages: list[dict]
thinking: bool = True
max_tokens: int = 65536
temperature: float = 1.0
# 应用生命周期
@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用启动/关闭时执行。"""
print("DeepAgents 服务启动")
yield
print("DeepAgents 服务关闭")
# 创建 FastAPI 应用
app = FastAPI(
title="GLM-4.7-Flash DeepAgents 智能体服务",
description="DeepAgents + LangGraph + GLMChat 实现智能体流式调用",
version="1.0.0",
lifespan=lifespan,
)
# CORS 配置:允许前端 5173 端口跨域
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 健康检查端点
@app.get("/health")
async def health():
"""健康检查,返回应用状态。"""
return {"status": "ok", "service": "deepagents", "port": 8002}
# SSE 端点
@app.post("/agent/sse")
async def agent_sse(request: ChatRequest):
"""SSE 流式智能体端点。
接收聊天请求,使用 StreamingResponse 逐块推送智能体执行过程。
响应格式:data: {"type": "reasoning|content|tool_start|tool_end|plan|done|error", "content": "..."}
"""
# 检查 API Key
api_key = os.environ.get("GLM_API_KEY")
if not api_key:
return StreamingResponse(
iter([b'data: {"type": "error", "content": "GLM_API_KEY 未配置"}\n\n']),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
# 创建异步生成器,逐块推送 SSE 事件
async def event_generator():
try:
async for frame in stream_agent(
messages=request.messages,
thinking=request.thinking,
max_tokens=request.max_tokens,
temperature=request.temperature,
):
# 序列化为 SSE data 行
import json
data = json.dumps(frame, ensure_ascii=False)
yield f"data: {data}\n\n"
except Exception as e:
# 异常时推送 error 帧
import json
error_frame = {"type": "error", "content": str(e)}
yield f"data: {json.dumps(error_frame, ensure_ascii=False)}\n\n"
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
# WebSocket 端点
@app.websocket("/agent/ws")
async def agent_ws(websocket: WebSocket):
"""WebSocket 智能体端点。
接收客户端消息,逐帧推送智能体执行过程。
客户端发送:{"type": "chat", "messages": [...], "thinking": true, ...}
服务端推送:{"type": "reasoning|content|tool_start|tool_end|plan|done|error", "content": "..."}
"""
await websocket.accept()
print(f"[WS] 连接建立:{websocket.client}")
try:
while True:
# 接收客户端消息
data = await websocket.receive_json()
message_type = data.get("type")
if message_type != "chat":
await websocket.send_json({"type": "error", "content": "未知的消息类型"})
continue
# 检查 API Key
api_key = os.environ.get("GLM_API_KEY")
if not api_key:
await websocket.send_json({"type": "error", "content": "GLM_API_KEY 未配置"})
continue
# 流式调用智能体,逐帧推送
try:
async for frame in stream_agent(
messages=data.get("messages", []),
thinking=data.get("thinking", True),
max_tokens=data.get("max_tokens", 65536),
temperature=data.get("temperature", 1.0),
):
await websocket.send_json(frame)
except Exception as e:
await websocket.send_json({"type": "error", "content": str(e)})
except WebSocketDisconnect:
print(f"[WS] 连接断开:{websocket.client}")
except Exception as e:
print(f"[WS] 异常:{e}")
try:
await websocket.send_json({"type": "error", "content": str(e)})
except Exception:
pass
# 启动入口
if __name__ == "__main__":
import uvicorn
port = int(os.environ.get("DEEPAGENTS_PORT", 8002))
uvicorn.run(app, host="0.0.0.0", port=port, reload=True)
3.7 运行 DeepAgents 服务
bash
# 设置环境变量
export GLM_API_KEY="your-api-key-here"
# 启动服务
python main.py
# 或使用 uvicorn 直接启动
uvicorn main:app --port 8002 --reload
第四部分:SpringBoot + Spring AI 后端(8003)
为什么 SpringBoot 部分最复杂?
相比 Python 后端只需 2 个 .py 文件,SpringBoot 需要:
- Maven 项目结构(
pom.xml+ 标准目录布局)- 9 个 Java 类分散到 config/controller/model/service 包
- 绕过 Spring AI 限制 :因为 Spring AI 2.0 的
OpenAiChatOptions不支持自定义thinking字段,必须降级用 WebClient 直连 GLM- 正确加载 .env :SpringBoot 默认不读项目根目录 .env,需要在
Application.java启动前手动注入 System Properties
4.1 创建项目目录
bash
cd springboot-ai
mkdir -p src/main/java/com/glm/demo/config
mkdir -p src/main/java/com/glm/demo/controller
mkdir -p src/main/java/com/glm/demo/model
mkdir -p src/main/java/com/glm/demo/service
mkdir -p src/main/resources
Maven 标准目录布局说明:
| 目录 | 作用 |
|---|---|
src/main/java |
Java 源代码(按包路径组织) |
src/main/resources |
配置文件(application.yml 等) |
src/test/java |
单元测试(可选) |
target/ |
Maven 编译输出(自动生成) |
包路径 com.glm.demo 与目录 src/main/java/com/glm/demo/ 一一对应。
4.2 创建 Maven 配置(pom.xml)
pom.xml 关键元素说明
<parent>:继承spring-boot-starter-parent,自动获得 SpringBoot 4.1.0 的依赖管理(BOM)<dependencyManagement>+spring-ai-bom:统一管理 Spring AI 2.0 系列依赖版本,子模块无需指定版本号<dependency>:每个 starter 提供一组相关功能<repositories>:Spring AI 2.0 仍处于早期版本,需配置里程碑仓库
xml
<?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>4.1.0</version>
<relativePath/>
</parent>
<groupId>com.glm</groupId>
<artifactId>springboot-ai</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>springboot-ai</name>
<description>SpringBoot 4.1.0 + Spring AI 2.0.0 调用 GLM-4.7-Flash 后端</description>
<properties>
<java.version>17</java.version>
<spring-ai.version>2.0.0</spring-ai.version>
</properties>
<dependencyManagement>
<dependencies>
<!-- Spring AI BOM 统一管理 Spring AI 依赖版本 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- Spring MVC(Servlet 栈,提供 @RestController 等) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- WebFlux:提供 Flux/Reactive 类型与流式 SSE 支持 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<!-- WebSocket 支持 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- Spring AI OpenAI 兼容 starter(2.0.0 起的命名) -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
<!-- Lombok(可选,便于简化模型代码) -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
<repositories>
<!-- Spring Boot 4.x 与 Spring AI 2.0.x 的里程碑仓库 -->
<repository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>https://repo.spring.io/milestone</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
</repositories>
</project>
4.3 创建应用配置(application.yml)
yaml
server:
port: 8003
spring:
ai:
openai:
api-key: ${GLM_API_KEY}
base-url: https://open.bigmodel.cn/api/paas/v4
chat:
options:
model: glm-4.7-flash
max-tokens: 65536
temperature: 1.0
4.4 创建主启动类(Application.java)
java
package com.glm.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* SpringBoot 主启动类。
* 启用 Spring AI 自动配置,提供 GLM-4.7-Flash 调用能力。
*/
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
4.5 创建 Jackson 配置(JacksonConfig.java)
java
package com.glm.demo.config;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
/**
* Jackson ObjectMapper Bean 配置。
* 由于同时引入 spring-boot-starter-web 与 spring-boot-starter-webflux,
* Spring Boot 4.x 的自动配置可能不会自动注册 ObjectMapper Bean,
* 因此这里显式声明,确保 ChatWebSocketHandler 等组件能够正常注入。
*
* 同时启用 FAIL_ON_UNKNOWN_PROPERTIES = false,忽略 JSON 中 ChatRequest
* 未声明的字段(如 WebSocket 客户端发送的 type=chat),避免反序列化失败。
*/
@Configuration
public class JacksonConfig {
@Bean
@Primary
public ObjectMapper objectMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
return mapper;
}
}
4.6 创建 WebSocket 配置(WebSocketConfig.java)
java
package com.glm.demo.config;
import com.glm.demo.controller.ChatWebSocketHandler;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
/**
* WebSocket 配置:注册 /chat/ws 路径,绑定到 ChatWebSocketHandler。
*/
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
private final ChatWebSocketHandler chatWebSocketHandler;
public WebSocketConfig(ChatWebSocketHandler chatWebSocketHandler) {
this.chatWebSocketHandler = chatWebSocketHandler;
}
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
// 注册 WebSocket 端点 /chat/ws,仅允许前端 5173 跨域
registry.addHandler(chatWebSocketHandler, "/chat/ws")
.setAllowedOrigins("http://localhost:5173");
}
}
4.7 创建 CORS 配置(CorsConfig.java)
java
package com.glm.demo.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* CORS 跨域配置:允许前端 5173 端口访问后端 API。
*/
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("http://localhost:5173")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
4.8 创建请求体模型(ChatRequest.java)
java
package com.glm.demo.model;
import java.util.List;
/**
* 统一聊天请求体,对应 API 契约中的请求 JSON。
*
* <pre>
* {
* "messages": [{"role": "user", "content": "..."}],
* "thinking": true,
* "max_tokens": 65536,
* "temperature": 1.0
* }
* </pre>
*/
public class ChatRequest {
private List<Message> messages;
private Boolean thinking;
private Integer maxTokens;
private Double temperature;
public List<Message> getMessages() {
return messages;
}
public void setMessages(List<Message> messages) {
this.messages = messages;
}
public Boolean getThinking() {
return thinking;
}
public void setThinking(Boolean thinking) {
this.thinking = thinking;
}
public Integer getMaxTokens() {
return maxTokens;
}
public void setMaxTokens(Integer maxTokens) {
this.maxTokens = maxTokens;
}
public Double getTemperature() {
return temperature;
}
public void setTemperature(Double temperature) {
this.temperature = temperature;
}
/** 单条消息:role + content。 */
public static class Message {
private String role;
private String content;
public String getRole() {
return role;
}
public void setRole(String role) {
this.role = role;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
}
}
4.9 创建 GLM 调用服务(GlmService.java)
设计思路:为什么绕过 Spring AI?
Spring AI 2.0 的
OpenAiChatOptions是一个强类型的 Builder,只暴露 OpenAI 标准字段(model、temperature、max_tokens、tools等),没有extra_body或thinking这种自定义字段入口。即使强行设置
OpenAiChatOptions.builder().withExtraBody(Map.of("thinking", Map.of("type", "enabled")))也行不通------该字段在反序列化时会被 Jackson 忽略。解决方案:直接用 WebClient
WebClient 是 Spring WebFlux 的非阻塞 HTTP 客户端,用法:
javawebClient.post() .uri("/chat/completions") .bodyValue(Map.of( "model", "glm-4.7-flash", "stream", true, "thinking", Map.of("type", "enabled"), "messages", messages)) .retrieve() .bodyToFlux(DataBuffer.class) // 关键:流式读取 DataBuffer .map(buf -> new String(buf.readableByteBytes(), UTF_8))关键技巧:
bodyToFlux(DataBuffer.class)如果用
bodyToFlux(String.class),WebClient 会等整个响应下载完 才返回,导致失去流式效果。必须用DataBuffer才能逐块接收 GLM 的 SSE 事件。
java
package com.glm.demo.service;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.glm.demo.model.ChatRequest;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;
import java.time.Duration;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* GLM-4.7-Flash 调用服务。
*
* 实现说明:
* 由于 Spring AI 2.0.0 的 OpenAiChatOptions 不支持传递自定义请求体字段,
* 无法启用 GLM 的 thinking 模式(reasoning_content 字段)。
*
* 本类直接使用 WebClient 调用 GLM 的 OpenAI 兼容接口,绕过 Spring AI 限制:
* - thinking 模式通过请求体中的 `thinking: {"type": "enabled"}` 启用
* - 流式响应(chunk)中提取 reasoning_content 和 content 字段
* - 返回统一的 StreamFrame 流(reasoning / content / done / error)
*/
@Service
public class GlmService {
private static final Logger log = LoggerFactory.getLogger(GlmService.class);
private final ObjectMapper objectMapper;
@Value("${spring.ai.openai.base-url:https://open.bigmodel.cn/api/paas/v4}")
private String baseUrl;
@Value("${spring.ai.openai.api-key:}")
private String apiKey;
@Value("${spring.ai.openai.chat.options.model:glm-4.7-flash}")
private String modelName;
private WebClient webClient;
public GlmService(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@PostConstruct
public void init() {
if (apiKey == null || apiKey.isBlank()) {
log.warn("GLM_API_KEY 未配置,流式调用将返回 error 帧");
}
this.webClient = WebClient.builder()
.baseUrl(baseUrl)
.defaultHeader("Authorization", "Bearer " + (apiKey == null ? "" : apiKey))
.defaultHeader("Content-Type", "application/json")
.build();
log.info("GLM WebClient 已初始化:baseUrl={}, model={}", baseUrl, modelName);
}
/**
* 流式帧。NON_NULL 保证 done 帧序列化为 {"type":"done"}(无 content 字段)。
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public record StreamFrame(String type, String content) {
}
/**
* 流式调用 GLM-4.7-Flash。
* 帧顺序:零到多个 reasoning → 零到多个 content → 一个 done(或 error)。
*
* @param request 聊天请求
* @return StreamFrame 流
*/
public Flux<StreamFrame> streamChat(ChatRequest request) {
if (apiKey == null || apiKey.isBlank()) {
return Flux.just(new StreamFrame("error", "GLM_API_KEY 未配置"));
}
try {
// 构造请求体
Map<String, Object> body = buildRequestBody(request);
// 调用 GLM 流式接口
// 注意:必须用 DataBuffer 流式读取,按 UTF-8 解码为字符串,然后按行切分 SSE 事件
Flux<String> lines = webClient.post()
.uri("/chat/completions")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.TEXT_EVENT_STREAM)
.bodyValue(body)
.retrieve()
.bodyToFlux(DataBuffer.class)
.timeout(Duration.ofSeconds(120))
.map(buffer -> {
byte[] bytes = new byte[buffer.readableByteCount()];
buffer.read(bytes);
DataBufferUtils.release(buffer);
return new String(bytes, java.nio.charset.StandardCharsets.UTF_8);
});
return parseSseLines(lines)
.concatWith(Flux.just(new StreamFrame("done", null)))
.onErrorResume(e -> Flux.just(new StreamFrame("error", safeMessage(e))));
} catch (Exception e) {
log.error("GLM 流式调用异常", e);
return Flux.just(new StreamFrame("error", safeMessage(e)));
}
}
/**
* 构建 GLM 请求体。
* 关键:thinking=true 时添加 `thinking: {"type": "enabled"}` 字段。
*/
private Map<String, Object> buildRequestBody(ChatRequest request) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("model", modelName);
body.put("stream", true);
body.put("max_tokens", request.getMaxTokens() != null ? request.getMaxTokens() : 65536);
body.put("temperature", request.getTemperature() != null ? request.getTemperature() : 1.0);
if (Boolean.TRUE.equals(request.getThinking())) {
Map<String, String> thinking = new HashMap<>();
thinking.put("type", "enabled");
body.put("thinking", thinking);
}
// messages
List<Map<String, Object>> msgs = new ArrayList<>();
if (request.getMessages() != null) {
for (ChatRequest.Message m : request.getMessages()) {
if (m == null) continue;
Map<String, Object> msg = new LinkedHashMap<>();
msg.put("role", m.getRole() == null ? "user" : m.getRole());
msg.put("content", m.getContent() == null ? "" : m.getContent());
msgs.add(msg);
}
}
body.put("messages", msgs);
return body;
}
/**
* 解析 SSE 行(每个 data: 行为一个完整 JSON chunk)。
*/
private Flux<StreamFrame> parseSseLines(Flux<String> lines) {
return lines
.filter(line -> line != null && line.startsWith("data:"))
.map(line -> line.substring(5).trim())
.filter(data -> !data.isEmpty() && !"[DONE]".equals(data))
.flatMapIterable(this::parseChunk);
}
/**
* 解析单个 chunk JSON 为 0~2 个 StreamFrame(reasoning + content)。
*/
private List<StreamFrame> parseChunk(String jsonStr) {
List<StreamFrame> frames = new ArrayList<>();
try {
JsonNode root = objectMapper.readTree(jsonStr);
JsonNode choices = root.get("choices");
if (choices == null || !choices.isArray() || choices.isEmpty()) {
return frames;
}
JsonNode delta = choices.get(0).get("delta");
if (delta == null) {
return frames;
}
// reasoning_content(GLM 扩展字段)
JsonNode reasoning = delta.get("reasoning_content");
if (reasoning != null && reasoning.isTextual()) {
String text = reasoning.asText();
if (!text.isEmpty()) {
frames.add(new StreamFrame("reasoning", text));
}
}
// content
JsonNode content = delta.get("content");
if (content != null && content.isTextual()) {
String text = content.asText();
if (!text.isEmpty()) {
frames.add(new StreamFrame("content", text));
}
}
} catch (Exception e) {
log.warn("解析 GLM chunk 失败:{}, data={}", e.getMessage(), jsonStr.substring(0, Math.min(200, jsonStr.length())));
}
return frames;
}
private static String safeMessage(Throwable e) {
String msg = e.getMessage();
return (msg == null || msg.isBlank()) ? e.getClass().getSimpleName() : msg;
}
}
4.10 创建 SSE 控制器(ChatSseController.java)
java
package com.glm.demo.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.glm.demo.model.ChatRequest;
import com.glm.demo.service.GlmService;
import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerSentEvent;
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;
/**
* SSE 聊天端点:POST /chat/sse
* 返回 text/event-stream,每条事件 data 为一个 JSON 帧字符串。
*/
@RestController
@RequestMapping("/chat")
public class ChatSseController {
private final GlmService glmService;
private final ObjectMapper objectMapper;
public ChatSseController(GlmService glmService, ObjectMapper objectMapper) {
this.glmService = glmService;
this.objectMapper = objectMapper;
}
@PostMapping(value = "/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> chat(@RequestBody ChatRequest request) {
// 全局 CORS(CorsConfig)已允许 http://localhost:5173 跨域
return glmService.streamChat(request)
.map(frame -> ServerSentEvent.<String>builder()
.data(toJson(frame))
.build());
}
/** 将 StreamFrame 序列化为 JSON 字符串,作为 SSE 的 data。 */
private String toJson(GlmService.StreamFrame frame) {
try {
return objectMapper.writeValueAsString(frame);
} catch (Exception e) {
return "{\"type\":\"error\",\"content\":\"序列化失败\"}";
}
}
}
4.11 创建 WebSocket 处理器(ChatWebSocketHandler.java)
java
package com.glm.demo.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.glm.demo.model.ChatRequest;
import com.glm.demo.service.GlmService;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import reactor.core.Disposable;
import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* WebSocket 聊天处理器,端点 /chat/ws。
* 客户端发送 {"type":"chat","messages":[...],"thinking":...,"max_tokens":...,"temperature":...}
* 服务端逐帧推送 {"type":"reasoning|content|done|error","content":"..."}。
*/
@Component
public class ChatWebSocketHandler extends TextWebSocketHandler {
private final GlmService glmService;
private final ObjectMapper objectMapper;
/** 记录每个会话正在进行的流式调用,便于连接关闭时取消。 */
private final Map<String, Disposable> activeStreams = new ConcurrentHashMap<>();
public ChatWebSocketHandler(GlmService glmService, ObjectMapper objectMapper) {
this.glmService = glmService;
this.objectMapper = objectMapper;
}
@Override
public void afterConnectionEstablished(WebSocketSession session) {
// 连接建立记录
System.out.println("[WS] 连接建立:" + session.getId());
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) {
ChatRequest request;
try {
// 解析客户端 JSON 消息(结构兼容标准请求体)
request = objectMapper.readValue(message.getPayload(), ChatRequest.class);
} catch (Exception e) {
sendFrame(session, new GlmService.StreamFrame("error", "请求解析失败:" + e.getMessage()));
return;
}
// 若该会话已有进行中的流式调用,先取消,避免并发写入
Disposable existing = activeStreams.remove(session.getId());
if (existing != null && !existing.isDisposed()) {
existing.dispose();
}
// 订阅流式响应,逐帧推送给客户端(done/error 帧由 GlmService 统一追加)
Disposable disposable = glmService.streamChat(request)
.doOnNext(frame -> sendFrame(session, frame))
.doOnError(e -> sendFrame(session,
new GlmService.StreamFrame("error", e.getMessage() == null
? e.getClass().getSimpleName() : e.getMessage())))
.subscribe(
null,
error -> activeStreams.remove(session.getId()),
() -> activeStreams.remove(session.getId()));
activeStreams.put(session.getId(), disposable);
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
System.out.println("[WS] 连接关闭:" + session.getId() + " status=" + status);
Disposable disposable = activeStreams.remove(session.getId());
if (disposable != null && !disposable.isDisposed()) {
disposable.dispose();
}
}
/** 线程安全地推送一帧 JSON 文本消息。 */
private void sendFrame(WebSocketSession session, GlmService.StreamFrame frame) {
try {
if (!session.isOpen()) {
return;
}
String json = objectMapper.writeValueAsString(frame);
// 同步避免并发发送导致底层缓冲区异常
synchronized (session) {
if (session.isOpen()) {
session.sendMessage(new TextMessage(json));
}
}
} catch (IOException e) {
System.err.println("[WS] 发送消息失败: " + e.getMessage());
}
}
}
4.12 运行 SpringBoot 后端
bash
# 设置环境变量(Windows PowerShell)
$env:GLM_API_KEY="your-api-key-here"
# 构建并启动(首次会下载依赖,可能较慢)
mvn spring-boot:run
# 或者先打包再运行
mvn clean package -DskipTests
java -jar target/springboot-ai-0.0.1-SNAPSHOT.jar
启动成功后访问 http://localhost:8003 确认服务已启动。
4.13 手动验证 SpringBoot 端点
验证 SSE 端点:
bash
curl -N -X POST http://localhost:8003/chat/sse ^
-H "Content-Type: application/json" ^
-d "{\"messages\":[{\"role\":\"user\",\"content\":\"你好\"}],\"thinking\":true,\"max_tokens\":512,\"temperature\":1.0}"
验证 WebSocket 端点:
使用浏览器开发者工具的 Console 面板:
javascript
const ws = new WebSocket('ws://localhost:8003/chat/ws')
ws.onopen = () => {
ws.send(JSON.stringify({
type: 'chat',
messages: [{ role: 'user', content: '你好' }],
thinking: true,
max_tokens: 512,
temperature: 1.0
}))
}
ws.onmessage = (e) => console.log('收到:', e.data)
第五部分:Vue3 前端(5173)
前端架构设计
Vue3 前端采用经典的"组合式 API + Composable + 组件"三层架构:
┌─────────────────────────────────────────────────────────┐ │ App.vue(根组件,管理顶层状态) │ ├─────────────────────────────────────────────────────────┤ │ Components(UI 组件) │ │ ├─ ModeSwitcher.vue(后端/通信模式切换) │ │ ├─ ThinkingPanel.vue(思考内容面板) │ │ ├─ ChatWindow.vue(对话窗口) │ │ └─ MessageInput.vue(消息输入框) │ ├─────────────────────────────────────────────────────────┤ │ Composables(可复用逻辑) │ │ ├─ useSse.ts(SSE 客户端封装) │ │ ├─ useWebSocket.ts(WebSocket 客户端封装) │ │ └─ useChat.ts(聊天核心逻辑,组合上述两个) │ ├─────────────────────────────────────────────────────────┤ │ Types & Utils │ │ ├─ types/index.ts(TypeScript 类型定义) │ │ ├─ utils/markdown.ts(Markdown 渲染与剥离) │ │ └─ styles/main.css(全局样式) │ └─────────────────────────────────────────────────────────┘这种分层的好处:
- 关注点分离:UI 渲染(Components) vs 业务逻辑(Composables) vs 类型契约(Types)
- 可测试性:Composables 是纯逻辑,可在不渲染组件的情况下测试
- 可复用性:换 React/Svelte 也只需替换 Components 层
5.1 创建项目目录
bash
cd frontend
mkdir -p src/components
mkdir -p src/composables
mkdir -p src/types
mkdir -p src/styles
5.2 创建 package.json
json
{
"name": "glm-47-flash-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"@microsoft/fetch-event-source": "^2.0.1",
"marked": "^18.0.6",
"vue": "^3.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.7",
"typescript": "^5.5.0",
"vite": "^8.1.4",
"vue-tsc": "^3.0.0"
}
}
package.json 关键字段说明:
字段 作用 "type": "module"告诉 Node.js 当前项目使用 ES Module 语法(Vite 需要) "dev": "vite"开发服务器,默认端口 5173 "build": "vue-tsc --noEmit && vite build"先用 vue-tsc 检查类型,再用 Vite 打包 @microsoft/fetch-event-source支持 POST 方式的 SSE(原生 EventSource只支持 GET)markedMarkdown 解析器,将 AI 回复渲染为 HTML vite下一代前端构建工具,基于原生 ES Module,无需打包即可启动
说明:
@microsoft/fetch-event-source支持 POST 方式的 SSE 请求(原生EventSource仅支持 GET)。marked用于将 AI 回复中的 Markdown 语法渲染为 HTML。
5.3 安装依赖
bash
npm install
# 或使用更快的 pnpm
npm install -g pnpm
pnpm install
安装完成后会生成 package-lock.json(锁定依赖版本)和 node_modules/ 目录。
5.4 创建 Vite 配置(vite.config.ts)
typescript
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// Vite 配置:开发端口 5173
// 默认依赖各后端开启 CORS;如需通过代理规避 CORS,可取消下方注释,
// 并将 src/types 中 BACKENDS 的 baseUrl 改为对应的相对路径前缀。
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
// proxy: {
// '/fastapi': { target: 'http://localhost:8001', changeOrigin: true, rewrite: (p) => p.replace(/^\/fastapi/, '') },
// '/deepagents': { target: 'http://localhost:8002', changeOrigin: true, rewrite: (p) => p.replace(/^\/deepagents/, '') },
// '/springboot': { target: 'http://localhost:8003', changeOrigin: true, rewrite: (p) => p.replace(/^\/springboot/, '') },
// },
},
})
5.5 创建 TypeScript 配置(tsconfig.json)
json
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue", "vite.config.ts"]
}
5.6 创建 HTML 入口(index.html)
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GLM-4.7-Flash 对话</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
5.7 创建类型定义(src/types/index.ts)
typescript
// 后端来源
export type BackendSource = 'fastapi' | 'deepagents' | 'springboot'
// 通信模式
export type TransportMode = 'sse' | 'websocket'
// 对话消息
export interface ChatMessage {
role: string
content: string
}
// 流式帧(SSE 与 WebSocket 通用)
export interface StreamFrame {
type: 'reasoning' | 'content' | 'done' | 'error' | 'tool_start' | 'tool_end' | 'plan'
content?: string
}
// 后端配置
export interface BackendConfig {
source: BackendSource
baseUrl: string
ssePath: string
wsPath: string
}
// 三个后端的统一配置表
export const BACKENDS: Record<BackendSource, BackendConfig> = {
fastapi: { source: 'fastapi', baseUrl: 'http://localhost:8001', ssePath: '/chat/sse', wsPath: '/chat/ws' },
deepagents: { source: 'deepagents', baseUrl: 'http://localhost:8002', ssePath: '/agent/sse', wsPath: '/agent/ws' },
springboot: { source: 'springboot', baseUrl: 'http://localhost:8003', ssePath: '/chat/sse', wsPath: '/chat/ws' },
}
5.8 创建应用入口(src/main.ts)
typescript
import { createApp } from 'vue'
import App from './App.vue'
import './styles/main.css'
// 创建并挂载 Vue 应用
createApp(App).mount('#app')
5.9 创建 SSE 客户端(src/composables/useSse.ts)
typescript
import { fetchEventSource } from '@microsoft/fetch-event-source'
import type { BackendConfig, ChatMessage, StreamFrame } from '../types'
// SSE 回调集合
export interface SseCallbacks {
onReasoning: (content: string) => void
onContent: (content: string) => void
onDone: () => void
onError: (content: string) => void
onToolStart: (content: string) => void
onToolEnd: (content: string) => void
onPlan: (content: string) => void
}
/**
* SSE 客户端 composable
* 使用 @microsoft/fetch-event-source 以支持 POST 请求
*/
export function useSse(callbacks: SseCallbacks) {
let controller: AbortController | null = null
// 解析一帧并分发到对应回调
function dispatch(frame: StreamFrame) {
switch (frame.type) {
case 'reasoning': callbacks.onReasoning(frame.content || ''); break
case 'content': callbacks.onContent(frame.content || ''); break
case 'done': callbacks.onDone(); break
case 'error': callbacks.onError(frame.content || '未知错误'); break
case 'tool_start': callbacks.onToolStart(frame.content || ''); break
case 'tool_end': callbacks.onToolEnd(frame.content || ''); break
case 'plan': callbacks.onPlan(frame.content || ''); break
}
}
// 发起 SSE 请求,返回取消函数
function sendMessage(backend: BackendConfig, messages: ChatMessage[], thinking: boolean): () => void {
abort()
controller = new AbortController()
const url = backend.baseUrl + backend.ssePath
fetchEventSource(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages, thinking, max_tokens: 65536, temperature: 1.0 }),
signal: controller.signal,
openWhenHidden: true,
onopen: async (response) => {
if (!response.ok) throw new Error(`SSE 连接失败:${response.status}`)
},
onmessage: (ev) => {
if (!ev.data) return
try {
dispatch(JSON.parse(ev.data) as StreamFrame)
} catch (e) {
console.warn('SSE 帧解析失败:', ev.data, e)
}
},
onerror: (err) => {
callbacks.onError(err instanceof Error ? err.message : 'SSE 连接异常')
throw err // 阻止自动重试
},
}).catch((e) => {
if (e instanceof Error && e.name === 'AbortError') return
callbacks.onError(e instanceof Error ? e.message : 'SSE 请求失败')
})
return abort
}
function abort() {
if (controller) { controller.abort(); controller = null }
}
return { sendMessage, abort }
}
5.10 创建 WebSocket 客户端(src/composables/useWebSocket.ts)
typescript
import { ref } from 'vue'
import type { BackendConfig, ChatMessage, StreamFrame } from '../types'
export type WsStatus = 'idle' | 'connecting' | 'open' | 'closed' | 'error'
export interface WsCallbacks {
onReasoning: (content: string) => void
onContent: (content: string) => void
onDone: () => void
onError: (content: string) => void
onToolStart: (content: string) => void
onToolEnd: (content: string) => void
onPlan: (content: string) => void
}
const MAX_RECONNECT = 3
/**
* WebSocket 客户端 composable
* 提供 connect / ensureConnected / sendMessage / disconnect 能力
* 并在异常断开时自动重连(最多 MAX_RECONNECT 次)
*/
export function useWebSocket(callbacks: WsCallbacks) {
const status = ref<WsStatus>('idle')
let ws: WebSocket | null = null
let currentBackend: BackendConfig | null = null
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
let reconnectAttempts = 0
let manualClose = false
function toWsUrl(backend: BackendConfig): string {
return backend.baseUrl.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:') + backend.wsPath
}
function dispatch(frame: StreamFrame) {
switch (frame.type) {
case 'reasoning': callbacks.onReasoning(frame.content || ''); break
case 'content': callbacks.onContent(frame.content || ''); break
case 'done': callbacks.onDone(); break
case 'error': callbacks.onError(frame.content || '未知错误'); break
case 'tool_start': callbacks.onToolStart(frame.content || ''); break
case 'tool_end': callbacks.onToolEnd(frame.content || ''); break
case 'plan': callbacks.onPlan(frame.content || ''); break
}
}
function teardownOld() {
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null }
if (ws) {
ws.onopen = null; ws.onmessage = null; ws.onerror = null; ws.onclose = null
try { ws.close() } catch { /* 忽略 */ }
ws = null
}
}
function doConnect() {
if (!currentBackend) return
status.value = 'connecting'
try { ws = new WebSocket(toWsUrl(currentBackend)) } catch (e) {
status.value = 'error'
callbacks.onError('WebSocket 创建失败')
return
}
ws.onopen = () => { reconnectAttempts = 0; status.value = 'open' }
ws.onmessage = (ev) => {
try { dispatch(JSON.parse(ev.data) as StreamFrame) } catch (e) {
console.warn('WebSocket 帧解析失败:', ev.data, e)
}
}
ws.onerror = () => { status.value = 'error' }
ws.onclose = () => {
ws = null
if (manualClose) { status.value = 'idle'; return }
status.value = 'closed'
if (reconnectAttempts < MAX_RECONNECT && currentBackend) {
reconnectAttempts++
reconnectTimer = setTimeout(() => doConnect(), 1500)
} else { status.value = 'error' }
}
}
function connect(backend: BackendConfig) {
manualClose = false; teardownOld(); currentBackend = backend; doConnect()
}
function ensureConnected(backend: BackendConfig): Promise<void> {
return new Promise((resolve) => {
if (ws?.readyState === WebSocket.OPEN && currentBackend?.source === backend.source) {
resolve(); return
}
connect(backend)
const start = Date.now()
const timer = setInterval(() => {
if (ws?.readyState === WebSocket.OPEN) { clearInterval(timer); resolve() }
else if (Date.now() - start > 8000) { clearInterval(timer); resolve() }
}, 80)
})
}
function sendMessage(messages: ChatMessage[], thinking: boolean) {
if (!ws || ws.readyState !== WebSocket.OPEN) {
callbacks.onError('WebSocket 未连接'); return
}
ws.send(JSON.stringify({ type: 'chat', messages, thinking, max_tokens: 65536, temperature: 1.0 }))
}
function disconnect() {
manualClose = true; reconnectAttempts = 0; teardownOld(); status.value = 'idle'
}
return { status, connect, ensureConnected, sendMessage, disconnect }
}
5.11 创建 Markdown 工具函数(src/utils/markdown.ts)
typescript
// ===================== Markdown 工具函数 =====================
// 使用 marked 库将 Markdown 文本渲染为 HTML,或剥离 Markdown 语法得到纯文本
import { marked } from 'marked'
// 配置 marked:启用 GFM(GitHub Flavored Markdown)和换行符转换
marked.setOptions({
breaks: true, // 将单个换行符转换为 <br>
gfm: true, // 启用 GitHub 风格 Markdown(表格、删除线等)
})
/**
* 将 Markdown 文本渲染为 HTML 字符串。
* 用于在 v-html 中显示格式化的 Markdown 内容。
*/
export function renderMarkdown(text: string): string {
try {
return marked.parse(text, { async: false }) as string
} catch {
return text // 解析失败返回原始文本
}
}
/**
* 剥离 Markdown 语法,返回纯文本。
* 实现方式:先用 marked 渲染为 HTML,再用 DOM 提取 textContent。
* 例如 "# Hello **world**" → "Hello world"
*/
export function stripMarkdown(text: string): string {
try {
const html = marked.parse(text, { async: false }) as string
const div = document.createElement('div') // 创建临时 DOM 元素
div.innerHTML = html // 注入 HTML
return div.textContent || div.innerText || text // 提取纯文本
} catch {
return text
}
}
说明:
renderMarkdown用于在v-html中渲染 AI 回复的 Markdown 内容(标题、列表、表格、代码块等)。stripMarkdown用于复制消息时剥离 Markdown 语法,得到纯文本。
5.12 创建聊天逻辑(src/composables/useChat.ts)
useChat 是整个前端的"大脑"
它整合了 SSE 和 WebSocket 两种客户端,对外暴露统一的
sendMessage / abort / editMessage接口。组件只调用这些接口,不关心底层是 SSE 还是 WebSocket。核心状态:
状态 类型 用途 messagesChatMessage[]已固化的对话历史 thinkingContentstring当前思考内容(流式累积) currentReplystring当前正式回复(流式累积) loadingboolean是否正在生成 auxInfoAuxInfo[]DeepAgents 工具调用/任务规划 errorMsgstring错误信息 关键设计:监听后端切换自动中断
用
watch([backendSource, transportMode], ...)监听用户切换后端或通信模式,自动中断当前请求,避免"切换后还在显示旧后端的回复"。
typescript
// ===================== 聊天逻辑 Composable =====================
// 整合 SSE 与 WebSocket 两种客户端,提供统一的聊天接口
import { ref, watch, type Ref } from 'vue' // Vue 响应式 API
import type { BackendSource, TransportMode, ChatMessage, BackendConfig } from '../types'
import { BACKENDS } from '../types' // 后端配置表
import { useSse } from './useSse' // SSE 客户端
import { useWebSocket } from './useWebSocket' // WebSocket 客户端
/** 辅助信息项 ------ DeepAgents 工具调用 / 任务规划帧 */
export interface AuxInfo {
type: 'tool_start' | 'tool_end' | 'plan' // 信息类型
content: string // 信息内容
time: number // 时间戳(ms)
}
/**
* 聊天核心逻辑 composable。
*
* 职责:
* 1. 维护消息历史列表(messages)
* 2. 管理当前流式回复(currentReply)和思考内容(thinkingContent)
* 3. 管理 loading 状态和错误信息
* 4. 根据 transportMode 自动切换 SSE / WebSocket 客户端
* 5. 监听后端来源 / 通信模式切换,自动中断旧请求
* 6. 支持编辑历史消息并重新发送
*/
export function useChat(backendSource: Ref<BackendSource>, transportMode: Ref<TransportMode>) {
// ============== 响应式状态 ==============
const messages = ref<ChatMessage[]>([]) // 对话历史
const thinkingContent = ref('') // 当前思考内容(流式累积)
const currentReply = ref('') // 当前正式回复(流式累积)
const loading = ref(false) // 是否正在生成
const auxInfo = ref<AuxInfo[]>([]) // DeepAgents 辅助信息列表
const errorMsg = ref('') // 错误信息
// ============== 帧回调:流式片段追加到对应状态 ==============
const callbacks = {
onReasoning: (c: string) => { // 思考内容回调
thinkingContent.value += c // 累积追加
},
onContent: (c: string) => { // 正式回复回调
currentReply.value += c // 累积追加
},
onDone: () => { // 流结束回调
finishTurn() // 将流式回复固化为一条消息
},
onError: (c: string) => { // 错误回调
errorMsg.value = c
loading.value = false
},
onToolStart: (c: string) => { // 工具调用开始
auxInfo.value.push({ type: 'tool_start', content: c, time: Date.now() })
},
onToolEnd: (c: string) => { // 工具调用结束
auxInfo.value.push({ type: 'tool_end', content: c, time: Date.now() })
},
onPlan: (c: string) => { // 任务规划
auxInfo.value.push({ type: 'plan', content: c, time: Date.now() })
},
}
// ============== 实例化 SSE / WebSocket 客户端 ==============
const sse = useSse(callbacks) // SSE 客户端
const ws = useWebSocket(callbacks) // WebSocket 客户端
// ============== 内部方法 ==============
/** 一轮对话结束:将流式回复固化为一条助手消息添加到历史中。 */
function finishTurn() {
if (currentReply.value.trim()) { // 有实质内容
messages.value.push({ role: 'assistant', content: currentReply.value })
}
currentReply.value = '' // 清空流式回复
loading.value = false // 结束加载
}
/** 重置本轮临时状态(清空思考、回复、错误、辅助信息)。 */
function resetTurn() {
thinkingContent.value = ''
currentReply.value = ''
errorMsg.value = ''
auxInfo.value = []
}
// ============== 公共方法 ==============
/**
* 发送一条用户消息。
*
* 流程:
* 1. 中断上一轮(如有)
* 2. 清空临时状态
* 3. 将用户消息加入历史
* 4. 根据 transportMode 调用 SSE.sendMessage 或 ws.sendMessage
*
* @param content 用户输入文本
* @param thinking 是否启用思考模式
*/
async function sendMessage(content: string, thinking: boolean) {
if (loading.value || !content.trim()) return // 正在生成或空内容则忽略
abort() // 中断上一轮
resetTurn() // 重置临时状态
messages.value.push({ role: 'user', content }) // 将用户消息加入历史
loading.value = true // 开始加载
const backend: BackendConfig = BACKENDS[backendSource.value] // 获取当前后端配置
if (transportMode.value === 'sse') { // SSE 模式
sse.sendMessage(backend, messages.value, thinking) // 直接发起 POST SSE 请求
} else { // WebSocket 模式
await ws.ensureConnected(backend) // 先确保连接就绪
ws.sendMessage(messages.value, thinking) // 再发送 WS 消息
}
}
/**
* 中断当前请求(停止生成)。
*
* SSE 模式:调用 AbortController.abort() 取消请求
* WebSocket 模式:断开连接以中断流(下次发送会自动重连)
* 已有部分回复内容会被保留为一条助手消息。
*/
function abort() {
if (transportMode.value === 'sse') { // SSE
sse.abort() // 取消请求
} else { // WebSocket
ws.disconnect() // 断开连接
}
if (loading.value) { // 正在生成中
if (currentReply.value.trim()) { // 已有部分回复
messages.value.push({ role: 'assistant', content: currentReply.value }) // 固化为消息
}
currentReply.value = '' // 清空
}
loading.value = false // 结束加载
}
/**
* 编辑历史消息:截断到指定索引,返回被编辑消息的内容。
*
* 用户点击编辑图标后:
* 1. 获取该索引处的消息内容
* 2. 将消息列表截断到该索引之前(移除该消息及其后的所有回复)
* 3. 重置临时状态
* 4. 返回原始内容,供 MessageInput 预填充
*
* @param index 要编辑的消息索引
* @returns 被编辑消息的原始内容
*/
function editMessage(index: number): string {
const msg = messages.value[index] // 获取指定索引的消息
if (!msg) return '' // 索引无效返回空
const content = msg.content // 保存原始内容
messages.value = messages.value.slice(0, index) // 截断消息列表
resetTurn() // 重置临时状态
return content // 返回原始内容供预填充
}
// ============== 切换监听 ==============
/**
* 监听后端来源和通信模式的变化。
* 切换时中断当前请求、断开 WebSocket、重置临时状态。
*/
watch(
[() => backendSource.value, () => transportMode.value],
() => {
if (loading.value) abort() // 有进行中的请求则中断
ws.disconnect() // 断开 WebSocket(释放资源)
resetTurn() // 重置 UI 状态
},
)
// ============== 导出 ==============
return {
messages, // 对话历史
thinkingContent, // 思考内容
currentReply, // 当前流式回复
loading, // 加载状态
auxInfo, // DeepAgents 辅助信息
errorMsg, // 错误信息
wsStatus: ws.status, // WebSocket 连接状态
sendMessage, // 发送消息
abort, // 中断生成
editMessage, // 编辑历史消息
}
}
5.13 创建模式切换组件(src/components/ModeSwitcher.vue)
vue
<script setup lang="ts">
import type { BackendSource, TransportMode } from '../types'
defineProps<{ source: BackendSource; transport: TransportMode }>()
const emit = defineEmits<{
(e: 'update:source', value: BackendSource): void
(e: 'update:transport', value: TransportMode): void
}>()
const backendOptions = [
{ value: 'fastapi' as BackendSource, label: 'FastAPI', desc: ':8001' },
{ value: 'deepagents' as BackendSource, label: 'DeepAgents', desc: ':8002' },
{ value: 'springboot' as BackendSource, label: 'SpringBoot', desc: ':8003' },
]
const transportOptions = [
{ value: 'sse' as TransportMode, label: 'SSE' },
{ value: 'websocket' as TransportMode, label: 'WebSocket' },
]
</script>
<template>
<div class="mode-switcher">
<div class="switch-group">
<span class="switch-label">后端</span>
<div class="seg">
<button v-for="opt in backendOptions" :key="opt.value" class="seg-btn"
:class="{ active: source === opt.value }" @click="emit('update:source', opt.value)">
{{ opt.label }}<small>{{ opt.desc }}</small>
</button>
</div>
</div>
<div class="switch-group">
<span class="switch-label">通信</span>
<div class="seg">
<button v-for="opt in transportOptions" :key="opt.value" class="seg-btn"
:class="{ active: transport === opt.value }" @click="emit('update:transport', opt.value)">
{{ opt.label }}
</button>
</div>
</div>
</div>
</template>
5.14 创建思考面板组件(src/components/ThinkingPanel.vue)
vue
<script setup lang="ts">
import { ref, watch } from 'vue'
const props = defineProps<{ content: string; loading: boolean }>()
const collapsed = ref(false)
// 新一轮思考内容开始产生时,自动展开面板
watch(() => props.content, (v) => { if (v && collapsed.value) collapsed.value = false })
</script>
<template>
<div class="thinking-panel">
<div class="thinking-header" @click="collapsed = !collapsed">
<span class="thinking-title">
<span class="dot" :class="{ pulse: loading }"></span>
思考过程
</span>
<span class="toggle">{{ collapsed ? '展开' : '收起' }}</span>
</div>
<div v-show="!collapsed" class="thinking-body">
<p v-if="!content && !loading" class="empty">尚未产生思考内容</p>
<pre v-else class="thinking-text">{{ content }}<span v-if="loading" class="cursor">▍</span></pre>
</div>
</div>
</template>
5.15 创建聊天窗口组件(src/components/ChatWindow.vue)
vue
<script setup lang="ts">
// ===================== ChatWindow 组件 =====================
// 右侧主面板:对话气泡列表 + 流式回复实时渲染 + 辅助信息区(DeepAgents)
// 支持消息悬停快捷操作(复制、编辑)和 Markdown 渲染
import { computed, ref, watch, nextTick } from 'vue'
import type { ChatMessage } from '../types'
import type { AuxInfo } from '../composables/useChat'
import { renderMarkdown, stripMarkdown } from '../utils/markdown'
// ---- Props ----
const props = defineProps<{
messages: ChatMessage[] // 已固化的历史消息列表
streamingReply: string // 正在流式生成的回复文本
loading: boolean // 是否正在加载
auxInfo: AuxInfo[] // DeepAgents 辅助信息(工具调用/任务规划)
showAux: boolean // 是否显示辅助信息区(仅 DeepAgents 模式为 true)
error: string // 错误信息文本
markdownMode: boolean // 是否启用 Markdown 渲染
}>()
// ---- Emits ----
const emit = defineEmits<{
(e: 'edit-message', index: number): void // 编辑消息事件(传递消息索引)
}>()
const scrollRef = ref<HTMLDivElement | null>(null) // 消息列表 DOM 引用(用于自动滚动)
// ---- 悬停和复制下拉状态 ----
const hoveredIndex = ref<number | null>(null) // 当前悬停的消息索引
const copyDropdownIndex = ref<number | null>(null) // 复制下拉菜单打开的消息索引
const copiedIndex = ref<number | null>(null) // 刚复制完成的消息索引(用于显示 ✓ 反馈)
/**
* 合并历史消息与流式回复。
* 流式生成中时,将 currentReply 作为最后一条 assistant 消息实时追加。
*/
const displayMessages = computed<ChatMessage[]>(() => {
const list = [...props.messages]
if (props.loading) {
list.push({ role: 'assistant', content: props.streamingReply })
}
return list
})
/** 将消息列表滚动到底部。 */
function scrollToBottom() {
nextTick(() => {
const el = scrollRef.value
if (el) el.scrollTop = el.scrollHeight
})
}
// 监听消息数量、流式回复、辅助信息变化 → 自动滚动
watch(
() => [props.messages.length, props.streamingReply, props.auxInfo.length],
scrollToBottom,
)
/**
* 判断某条消息是否可以显示快捷操作图标。
* 正在流式生成的最后一条消息不显示(因为内容还在变化)。
*/
function canShowActions(i: number): boolean {
if (props.loading && i === displayMessages.value.length - 1) return false
return true
}
/**
* 复制纯文本(剥离 Markdown 语法)。
* 对用户消息直接复制原始内容;对 AI 消息剥离 Markdown 语法。
*/
async function copyPlainText(content: string, index: number) {
const text = stripMarkdown(content) // 剥离 Markdown 语法
await navigator.clipboard.writeText(text) // 写入剪贴板
showCopiedFeedback(index) // 显示已复制反馈
}
/**
* 复制 Markdown 源码(保留 Markdown 语法标记)。
*/
async function copyMarkdownSource(content: string, index: number) {
await navigator.clipboard.writeText(content) // 直接复制原始内容
showCopiedFeedback(index) // 显示已复制反馈
}
/** 显示"已复制"反馈,2 秒后自动消失。 */
function showCopiedFeedback(index: number) {
copiedIndex.value = index
copyDropdownIndex.value = null // 关闭下拉菜单
setTimeout(() => {
if (copiedIndex.value === index) {
copiedIndex.value = null // 2 秒后清除反馈
}
}, 2000)
}
/** 切换复制下拉菜单的显示状态。 */
function toggleCopyDropdown(index: number) {
if (copyDropdownIndex.value === index) {
copyDropdownIndex.value = null // 已打开则关闭
} else {
copyDropdownIndex.value = index // 否则打开
}
}
/** 关闭复制下拉菜单(点击遮罩时调用)。 */
function closeCopyDropdown() {
copyDropdownIndex.value = null
}
/** 点击编辑按钮:向父组件发送编辑事件。 */
function handleEdit(index: number) {
emit('edit-message', index)
}
/**
* 辅助信息类型 → 显示标签。
*/
function auxLabel(type: string) {
switch (type) {
case 'tool_start': return '🔧 工具调用'
case 'tool_end': return '✅ 工具结果'
case 'plan': return '📋 任务规划'
default: return type
}
}
</script>
<template>
<div class="chat-window" @click="closeCopyDropdown">
<div ref="scrollRef" class="message-list">
<div v-if="displayMessages.length === 0" class="empty-tip">
GLM-4.7-Flash 已就绪,请选择后端与通信模式后开始对话。
</div>
<div
v-for="(msg, i) in displayMessages"
:key="i"
class="message"
:class="msg.role === 'user' ? 'user' : 'assistant'"
@mouseenter="hoveredIndex = i"
@mouseleave="hoveredIndex = null"
>
<div class="avatar">{{ msg.role === 'user' ? '我' : 'AI' }}</div>
<div class="bubble-wrapper">
<div class="bubble">
<template v-if="msg.content">
<!-- Markdown 渲染模式(仅 AI 消息) -->
<div
v-if="markdownMode && msg.role !== 'user'"
class="markdown-body"
v-html="renderMarkdown(msg.content)"
></div>
<!-- 纯文本模式 -->
<pre v-else class="bubble-text">{{ msg.content }}</pre>
<span
v-if="loading && i === displayMessages.length - 1 && msg.role !== 'user'"
class="cursor"
>▍</span>
</template>
<span v-else class="typing">
<span class="dot1"></span><span class="dot2"></span><span class="dot3"></span>
</span>
</div>
<!-- 消息下方快捷操作图标 -->
<div v-if="hoveredIndex === i && msg.content && canShowActions(i)" class="msg-actions">
<!-- 用户消息:复制 + 编辑 -->
<template v-if="msg.role === 'user'">
<button class="msg-icon-btn" @click.stop="copyPlainText(msg.content, i)" title="复制">
{{ copiedIndex === i ? '✓' : '⧉' }}
</button>
<button class="msg-icon-btn" @click.stop="handleEdit(i)" title="编辑并重新发送">
✎
</button>
</template>
<!-- AI 消息:复制下拉菜单 -->
<template v-else>
<button class="msg-icon-btn" @click.stop="toggleCopyDropdown(i)" title="复制">
{{ copiedIndex === i ? '✓' : '⧉' }}
</button>
<div v-if="copyDropdownIndex === i" class="copy-dropdown" @click.stop>
<button class="copy-option" @click="copyPlainText(msg.content, i)">
复制
</button>
<button class="copy-option" @click="copyMarkdownSource(msg.content, i)">
复制 Markdown
</button>
</div>
</template>
</div>
</div>
</div>
</div>
<div v-if="showAux && auxInfo.length" class="aux-area">
<div class="aux-title">智能体执行过程</div>
<div v-for="(item, i) in auxInfo" :key="i" class="aux-item" :class="item.type">
<span class="aux-tag">{{ auxLabel(item.type) }}</span>
<pre class="aux-content">{{ item.content }}</pre>
</div>
</div>
<div v-if="error" class="error-bar">⚠ {{ error }}</div>
</div>
</template>
5.16 创建消息输入组件(src/components/MessageInput.vue)
vue
<script setup lang="ts">
// ===================== MessageInput 组件 =====================
// 底部输入区域:文本输入框 + 思考模式开关 + Markdown 开关 + 发送/停止按钮
// 支持编辑预填充:当 editContent prop 变化时自动填入输入框并聚焦
import { ref, nextTick, watch } from 'vue'
// ---- Props ----
const props = defineProps<{
loading: boolean // 是否正在生成中(决定显示"发送"还是"停止"按钮)
editContent?: string // 编辑预填充内容(父组件设置后自动填入输入框)
}>()
// ---- Emits ----
const emit = defineEmits<{
(e: 'send', payload: { content: string; thinking: boolean }): void // 发送消息事件
(e: 'abort'): void // 停止生成事件
(e: 'edit-consumed'): void // 编辑内容已被消费(通知父组件清除)
}>()
// ---- 本地状态 ----
const text = ref('') // 输入文本
const thinking = ref(true) // 思考模式开关(默认开启)
const textareaRef = ref<HTMLTextAreaElement | null>(null) // textarea DOM 引用
/**
* 监听 editContent 变化:当父组件传入编辑内容时,
* 自动填入输入框、调整高度、聚焦,并通知父组件已消费。
*/
watch(
() => props.editContent,
(v) => {
if (v !== undefined && v !== '') { // 有实际内容
text.value = v // 填入输入框
emit('edit-consumed') // 通知父组件清除 editContent
nextTick(() => {
autoResize() // 调整 textarea 高度
textareaRef.value?.focus() // 聚焦输入框
})
}
},
)
/**
* textarea 自适应高度。
*/
function autoResize() {
const el = textareaRef.value
if (!el) return
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 160) + 'px'
}
/** 输入事件:触发自适应高度。 */
function handleInput() {
autoResize()
}
/** 发送消息:如果内容非空,emit send 事件并清空输入。 */
function doSend() {
const content = text.value.trim()
if (!content) return
emit('send', { content, thinking: thinking.value })
text.value = ''
nextTick(autoResize)
}
/**
* 键盘事件处理。
* Enter 发送消息,Shift+Enter 换行。
*/
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
doSend()
}
}
</script>
<template>
<div class="message-input">
<div class="input-row">
<label class="thinking-toggle" :title="thinking ? '已开启思考模式' : '已关闭思考模式'">
<input type="checkbox" v-model="thinking" />
<span>思考</span>
</label>
<textarea
ref="textareaRef"
v-model="text"
class="input-area"
rows="1"
placeholder="输入消息,Enter 发送,Shift+Enter 换行"
@input="handleInput"
@keydown="onKeydown"
></textarea>
<button v-if="!loading" class="send-btn" :disabled="!text.trim()" @click="doSend">
发送
</button>
<button v-else class="stop-btn" @click="emit('abort')">停止</button>
</div>
</div>
</template>
5.17 创建主应用组件(src/App.vue)
vue
<script setup lang="ts">
// ===================== App.vue 根组件 =====================
// 整个应用的入口组件:组装 Header(顶端栏)、
// ThinkingPanel(左侧思考面板)、ChatWindow(右侧对话面板)、
// MessageInput(底部输入区)
// 新增:Markdown 渲染开关、消息编辑功能
import { ref } from 'vue'
import type { BackendSource, TransportMode } from './types'
import { useChat } from './composables/useChat' // 聊天逻辑 composable
import ModeSwitcher from './components/ModeSwitcher.vue' // 后端/通信模式切换器
import ThinkingPanel from './components/ThinkingPanel.vue' // 思考面板
import ChatWindow from './components/ChatWindow.vue' // 对话窗口
import MessageInput from './components/MessageInput.vue' // 输入区域
// ---- 顶层状态 ----
const backendSource = ref<BackendSource>('fastapi') // 当前选中的后端(默认 FastAPI)
const transportMode = ref<TransportMode>('sse') // 当前通信模式(默认 SSE)
const markdownMode = ref(false) // Markdown 渲染开关(默认关闭)
const editContent = ref('') // 编辑预填充内容(传给 MessageInput)
// ---- 聊天逻辑(useChat 封装所有状态管理) ----
const {
messages, // 对话历史列表
thinkingContent, // 思考内容
currentReply, // 当前流式回复
loading, // 加载状态
auxInfo, // DeepAgents 辅助信息
errorMsg, // 错误信息
wsStatus, // WebSocket 连接状态
sendMessage, // 发送消息方法
abort, // 中断方法
editMessage, // 编辑历史消息方法
} = useChat(backendSource, transportMode)
/** 处理输入框发送事件。 */
function onSend(payload: { content: string; thinking: boolean }) {
sendMessage(payload.content, payload.thinking) // 委派给 useChat
}
/** 处理 ChatWindow 的编辑事件:截断消息历史并将原始内容填入输入框。 */
function onEdit(index: number) {
editContent.value = editMessage(index) // 获取原始内容并截断历史
}
/** MessageInput 消费了 editContent 后通知父组件清除。 */
function onEditConsumed() {
editContent.value = '' // 清除 editContent
}
</script>
<template>
<div class="app">
<header class="app-header">
<div class="brand">
<span class="logo">✦</span>
<div>
<h1>GLM-4.7-Flash</h1>
<p>多后端 · 多通道对话演示</p>
</div>
</div>
<ModeSwitcher
:source="backendSource"
:transport="transportMode"
@update:source="backendSource = $event"
@update:transport="transportMode = $event"
/>
<!-- Markdown 渲染开关 -->
<label class="md-toggle" :title="markdownMode ? '已开启 Markdown 渲染' : '已关闭 Markdown 渲染'">
<input type="checkbox" v-model="markdownMode" />
<span>Markdown</span>
</label>
<div v-if="transportMode === 'websocket'" class="ws-status">
WS:<span :class="'st-' + wsStatus">{{ wsStatus }}</span>
</div>
</header>
<main class="app-main">
<aside class="left-pane">
<ThinkingPanel :content="thinkingContent" :loading="loading" />
</aside>
<section class="right-pane">
<ChatWindow
:messages="messages"
:streaming-reply="currentReply"
:loading="loading"
:aux-info="auxInfo"
:show-aux="backendSource === 'deepagents'"
:error="errorMsg"
:markdown-mode="markdownMode"
@edit-message="onEdit"
/>
<MessageInput
:loading="loading"
:edit-content="editContent"
@send="onSend"
@abort="abort"
@edit-consumed="onEditConsumed"
/>
</section>
</main>
</div>
</template>
5.18 创建全局样式(src/styles/main.css)
css
/* ===== 全局重置与基础 ===== */
* {
box-sizing: border-box;
}
:root {
/* 主题色 */
--accent: #4f46e5;
--accent-soft: #eef2ff;
--accent-deep: #3730a3;
/* 中性色 */
--bg: #f5f6f8;
--surface: #ffffff;
--border: #e5e7eb;
--text: #1f2430;
--text-muted: #6b7280;
/* 思考区配色(暖灰,与正式回复区分) */
--thinking-bg: #fbf7ec;
--thinking-border: #ecdcb4;
--thinking-text: #8a6d3b;
/* 用户气泡 */
--user-bg: #4f46e5;
--user-text: #ffffff;
/* 助手气泡 */
--assistant-bg: #ffffff;
--assistant-text: #1f2430;
/* 状态色 */
--ok: #10b981;
--warn: #f59e0b;
--err: #ef4444;
/* 布局 */
--radius: 12px;
--header-h: 64px;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
}
html,
body,
#app {
height: 100%;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
background: var(--bg);
color: var(--text);
font-size: 14px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
button {
font-family: inherit;
cursor: pointer;
}
/* ===== 应用布局 ===== */
.app {
display: flex;
flex-direction: column;
height: 100%;
}
.app-header {
height: var(--header-h);
flex-shrink: 0;
display: flex;
align-items: center;
gap: 20px;
padding: 0 24px;
background: var(--surface);
border-bottom: 1px solid var(--border);
box-shadow: var(--shadow);
}
.brand {
display: flex;
align-items: center;
gap: 12px;
}
.brand .logo {
font-size: 26px;
color: var(--accent);
}
.brand h1 {
font-size: 16px;
margin: 0;
font-weight: 700;
letter-spacing: 0.2px;
}
.brand p {
margin: 0;
font-size: 12px;
color: var(--text-muted);
}
.app-main {
flex: 1;
display: flex;
min-height: 0;
}
.left-pane {
width: 320px;
flex-shrink: 0;
border-right: 1px solid var(--border);
background: var(--surface);
padding: 16px;
overflow: auto;
}
.right-pane {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
background: var(--bg);
}
/* ===== 模式切换器 ===== */
.mode-switcher {
display: flex;
align-items: center;
gap: 18px;
margin-left: auto;
}
.switch-group {
display: flex;
align-items: center;
gap: 8px;
}
.switch-label {
font-size: 12px;
color: var(--text-muted);
font-weight: 600;
}
.seg {
display: inline-flex;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 2px;
}
.seg-btn {
border: none;
background: transparent;
color: var(--text-muted);
padding: 6px 12px;
border-radius: 6px;
font-size: 13px;
display: inline-flex;
align-items: center;
gap: 4px;
transition: all 0.15s ease;
}
.seg-btn small {
font-size: 10px;
opacity: 0.7;
}
.seg-btn.active {
background: var(--accent);
color: #fff;
}
.seg-btn:not(.active):hover {
color: var(--text);
background: rgba(0, 0, 0, 0.04);
}
.ws-status {
font-size: 12px;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 4px;
}
.st-open {
color: var(--ok);
font-weight: 600;
}
.st-connecting {
color: var(--warn);
}
.st-closed,
.st-error {
color: var(--err);
}
.st-idle {
color: var(--text-muted);
}
/* ===== 思考面板 ===== */
.thinking-panel {
background: var(--thinking-bg);
border: 1px solid var(--thinking-border);
border-radius: var(--radius);
overflow: hidden;
}
.thinking-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
cursor: pointer;
user-select: none;
border-bottom: 1px solid var(--thinking-border);
}
.thinking-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
color: var(--thinking-text);
font-size: 13px;
}
.thinking-title .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--thinking-text);
opacity: 0.6;
}
.thinking-title .dot.pulse {
animation: pulse 1.2s infinite;
opacity: 1;
}
@keyframes pulse {
0%,
100% {
opacity: 0.4;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.3);
}
}
.toggle {
font-size: 12px;
color: var(--thinking-text);
opacity: 0.8;
}
.thinking-body {
padding: 12px 14px;
max-height: 60vh;
overflow: auto;
}
.thinking-text {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-style: italic;
color: var(--thinking-text);
font-size: 13px;
font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
}
.thinking-panel .empty {
margin: 0;
color: var(--thinking-text);
opacity: 0.6;
font-style: italic;
font-size: 12px;
}
/* 闪烁光标 */
.cursor {
display: inline-block;
animation: blink 1s steps(2, start) infinite;
color: var(--accent);
}
@keyframes blink {
to {
visibility: hidden;
}
}
/* ===== 聊天窗口 ===== */
.chat-window {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.message-list {
flex: 1;
overflow-y: auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.empty-tip {
margin: auto;
color: var(--text-muted);
font-size: 14px;
text-align: center;
padding: 40px 20px;
}
.message {
display: flex;
gap: 10px;
max-width: 80%;
align-items: flex-start;
}
.message.user {
align-self: flex-end;
flex-direction: row-reverse;
}
.message.assistant {
align-self: flex-start;
}
.avatar {
width: 30px;
height: 30px;
flex-shrink: 0;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
color: #fff;
background: var(--accent);
}
.message.user .avatar {
background: #6b7280;
}
.bubble {
padding: 10px 14px;
border-radius: var(--radius);
box-shadow: var(--shadow);
min-width: 40px;
}
.message.user .bubble {
background: var(--user-bg);
color: var(--user-text);
border-bottom-right-radius: 4px;
}
.message.assistant .bubble {
background: var(--assistant-bg);
color: var(--assistant-text);
border: 1px solid var(--border);
border-bottom-left-radius: 4px;
}
.bubble-text {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-family: inherit;
font-size: 14px;
}
/* 打字三点动画 */
.typing {
display: inline-flex;
gap: 4px;
padding: 2px 0;
}
.typing span {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--text-muted);
animation: typing 1.2s infinite ease-in-out;
}
.typing .dot2 {
animation-delay: 0.2s;
}
.typing .dot3 {
animation-delay: 0.4s;
}
@keyframes typing {
0%,
60%,
100% {
transform: translateY(0);
opacity: 0.4;
}
30% {
transform: translateY(-5px);
opacity: 1;
}
}
/* ===== 辅助信息区(DeepAgents) ===== */
.aux-area {
flex-shrink: 0;
border-top: 1px solid var(--border);
background: #f8fafc;
padding: 12px 24px;
max-height: 180px;
overflow-y: auto;
}
.aux-title {
font-size: 12px;
font-weight: 700;
color: var(--text-muted);
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.aux-item {
display: flex;
gap: 8px;
margin-bottom: 8px;
font-size: 12px;
align-items: flex-start;
}
.aux-tag {
flex-shrink: 0;
padding: 2px 8px;
border-radius: 6px;
font-weight: 600;
background: var(--accent-soft);
color: var(--accent-deep);
}
.aux-item.tool_start .aux-tag {
background: #fef3c7;
color: #92400e;
}
.aux-item.tool_end .aux-tag {
background: #d1fae5;
color: #065f46;
}
.aux-item.plan .aux-tag {
background: #ede9fe;
color: #5b21b6;
}
.aux-content {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
color: var(--text);
font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
font-size: 12px;
}
.error-bar {
flex-shrink: 0;
background: #fef2f2;
color: #991b1b;
padding: 8px 24px;
font-size: 13px;
border-top: 1px solid #fecaca;
}
/* ===== 输入区 ===== */
.message-input {
flex-shrink: 0;
border-top: 1px solid var(--border);
background: var(--surface);
padding: 14px 24px;
}
.input-row {
display: flex;
align-items: flex-end;
gap: 10px;
}
.thinking-toggle {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text-muted);
cursor: pointer;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 8px;
user-select: none;
background: var(--bg);
}
.thinking-toggle input {
accent-color: var(--accent);
cursor: pointer;
}
.input-area {
flex: 1;
resize: none;
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px 14px;
font-size: 14px;
font-family: inherit;
line-height: 1.5;
max-height: 160px;
outline: none;
transition: border-color 0.15s ease;
background: var(--surface);
color: var(--text);
}
.input-area:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.send-btn,
.stop-btn {
border: none;
padding: 10px 20px;
border-radius: 10px;
font-size: 14px;
font-weight: 600;
transition: all 0.15s ease;
}
.send-btn {
background: var(--accent);
color: #fff;
}
.send-btn:hover:not(:disabled) {
background: var(--accent-deep);
}
.send-btn:disabled {
background: #c7d2fe;
cursor: not-allowed;
}
.stop-btn {
background: var(--err);
color: #fff;
}
.stop-btn:hover {
background: #dc2626;
}
/* ===== Markdown 渲染开关 ===== */
.md-toggle {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text-muted);
cursor: pointer;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 8px;
user-select: none;
background: var(--bg);
}
.md-toggle input {
accent-color: var(--accent);
cursor: pointer;
}
/* ===== 消息下方快捷操作 ===== */
.bubble-wrapper {
position: relative;
display: flex;
flex-direction: column;
}
.msg-actions {
display: flex;
gap: 4px;
margin-top: 4px;
z-index: 10;
}
.message.user .msg-actions {
justify-content: flex-end;
}
.msg-icon-btn {
width: 26px;
height: 26px;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--surface);
color: var(--text-muted);
font-size: 13px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.15s ease;
box-shadow: var(--shadow);
}
.msg-icon-btn:hover {
background: var(--accent-soft);
color: var(--accent);
border-color: var(--accent);
}
/* ===== 复制下拉菜单 ===== */
.copy-dropdown {
position: absolute;
top: 100%;
left: 0;
margin-top: 4px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
overflow: hidden;
z-index: 20;
min-width: 120px;
}
.copy-option {
display: block;
width: 100%;
border: none;
background: transparent;
padding: 8px 14px;
font-size: 13px;
text-align: left;
cursor: pointer;
color: var(--text);
transition: background 0.15s ease;
}
.copy-option:hover {
background: var(--accent-soft);
color: var(--accent);
}
.copy-option:not(:last-child) {
border-bottom: 1px solid var(--border);
}
/* ===== Markdown 渲染样式 ===== */
.markdown-body {
font-size: 14px;
line-height: 1.7;
word-break: break-word;
}
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4,
.markdown-body h5,
.markdown-body h6 {
margin: 12px 0 8px;
font-weight: 700;
line-height: 1.3;
}
.markdown-body h1 { font-size: 1.5em; }
.markdown-body h2 { font-size: 1.3em; }
.markdown-body h3 { font-size: 1.15em; }
.markdown-body h4 { font-size: 1em; }
.markdown-body h5 { font-size: 0.9em; }
.markdown-body h6 { font-size: 0.85em; color: var(--text-muted); }
.markdown-body p {
margin: 8px 0;
}
.markdown-body ul,
.markdown-body ol {
margin: 8px 0;
padding-left: 24px;
}
.markdown-body li {
margin: 4px 0;
}
.markdown-body ul li {
list-style: disc;
}
.markdown-body ol li {
list-style: decimal;
}
.markdown-body code {
font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
font-size: 0.88em;
background: #f1f5f9;
padding: 2px 6px;
border-radius: 4px;
color: #be185d;
}
.markdown-body pre {
background: #1e293b;
color: #e2e8f0;
padding: 12px 16px;
border-radius: 8px;
overflow-x: auto;
margin: 8px 0;
font-size: 13px;
line-height: 1.5;
}
.markdown-body pre code {
background: transparent;
color: inherit;
padding: 0;
border-radius: 0;
}
.markdown-body blockquote {
margin: 8px 0;
padding: 8px 12px;
border-left: 3px solid var(--accent);
background: var(--accent-soft);
color: var(--text);
border-radius: 0 6px 6px 0;
}
.markdown-body blockquote p {
margin: 4px 0;
}
.markdown-body table {
border-collapse: collapse;
margin: 8px 0;
width: 100%;
font-size: 13px;
}
.markdown-body th,
.markdown-body td {
border: 1px solid var(--border);
padding: 6px 10px;
text-align: left;
}
.markdown-body th {
background: var(--accent-soft);
font-weight: 600;
}
.markdown-body tr:nth-child(even) {
background: #f8fafc;
}
.markdown-body a {
color: var(--accent);
text-decoration: none;
}
.markdown-body a:hover {
text-decoration: underline;
}
.markdown-body hr {
border: none;
border-top: 1px solid var(--border);
margin: 12px 0;
}
.markdown-body strong {
font-weight: 700;
}
.markdown-body em {
font-style: italic;
}
.markdown-body del {
text-decoration: line-through;
}
/* ===== 响应式 ===== */
@media (max-width: 860px) {
.app-main {
flex-direction: column;
}
.left-pane {
width: 100%;
height: 180px;
border-right: none;
border-bottom: 1px solid var(--border);
}
.mode-switcher {
gap: 10px;
}
.brand p {
display: none;
}
.message {
max-width: 95%;
}
}
5.19 运行前端
bash
npm run dev
浏览器访问 http://localhost:5173 即可看到聊天界面。
第六部分:运行与验证
6.1 配置环境变量
在项目根目录 glm-4.7/ 创建 .env 文件:
bash
GLM_API_KEY=your-api-key-here
6.2 启动所有服务
按以下顺序在 4 个终端窗口中分别启动:
bash
# 终端 1:FastAPI 后端(8001)- 使用虚拟环境
cd python-fastapi
.\venv\Scripts\activate
python main.py
# 终端 2:DeepAgents 服务(8002)- 使用虚拟环境
cd deepagents-server
.\venv\Scripts\activate
python main.py
# 终端 3:SpringBoot 后端(8003)
cd springboot-ai
mvn spring-boot:run
# 终端 4:Vue3 前端(5173)
cd frontend
npm run dev
6.3 验证 6 种组合
在浏览器中打开 http://localhost:5173,通过顶部的切换器依次测试:
| 后端 | SSE | WebSocket |
|---|---|---|
| FastAPI (8001) | 发送消息,验证思考内容 + 正式回复流式输出 | 发送消息,验证双向通信正常 |
| DeepAgents (8002) | 发送消息,验证思考内容 + 工具调用/任务规划 | 发送消息,验证智能体完整流程 |
| SpringBoot (8003) | 发送消息,验证思考内容 + 正式回复流式输出 | 发送消息,验证双向通信正常 |
6.4 验证要点
- 思考模式:勾选"思考"复选框后发送消息,左侧思考面板应实时显示 GLM 的推理过程
- 流式输出:正式回复应逐字/逐句流式出现,带有闪烁光标
- 模式切换:切换后端或通信模式时,当前请求应自动中断并清理状态
- WebSocket 状态:切换到 WebSocket 模式后,头部应显示连接状态(open/closed/error)
- DeepAgents 特色:选择 DeepAgents 后端时,底部辅助信息区会显示工具调用和任务规划过程
关键技术要点总结
为什么需要自定义 GLMChat(DeepAgents)?
langchain-openai 的 ChatOpenAI 在解析流式 chunk 时会丢弃 GLM 扩展的 reasoning_content 字段。GLMChat 继承 BaseChatModel,内部使用 openai 原生客户端直接调用 GLM,透传 thinking 并保留 reasoning_content。
为什么 SpringBoot 使用 WebClient 而非 Spring AI ChatClient?
Spring AI 2.0.0 的 OpenAiChatOptions 不支持传递自定义请求体字段(如 thinking: {"type": "enabled"}),无法启用 GLM 的思考模式。因此直接使用 WebClient 调用 GLM 的 OpenAI 兼容接口,手动构造请求体。
为什么 FastAPI 需要 asyncio.Queue + to_thread?
zai-sdk 的流式迭代是同步的(for chunk in response),而 FastAPI 运行在异步事件循环上。通过 asyncio.Queue + asyncio.to_thread 将同步流式迭代桥接为异步生成器,避免阻塞事件循环。
DeepAgents 的 write_todos 是什么?
DeepAgents 内置的任务规划工具,将 LLM 的内部思考外化为结构化任务清单。当智能体处理复杂任务时,会调用 write_todos 规划步骤,前端将其映射为 plan 帧显示在辅助信息区。
第七部分:常见问题排查与扩展建议
7.1 端口冲突处理
启动服务时如果遇到 Address already in use 错误:
Windows PowerShell 查找并杀死占用端口的进程:
powershell
# 查找占用 8001 端口的进程
netstat -ano | findstr :8001
# 输出示例:TCP 0.0.0.0:8001 0.0.0.0:0 LISTENING 12345
# 最后一个数字 12345 是 PID
# 杀死进程
taskkill /F /PID 12345
# 一键杀死所有 Python/Java/Node 占用 8001-8003、5173 的进程
Get-NetTCPConnection -LocalPort 8001,8002,8003,5173 -State Listen |
ForEach-Object { taskkill /F /PID $_.OwningProcess }
Linux/macOS:
bash
# 查找占用端口的进程
lsof -i :8001
# 杀死进程
kill -9 <PID>
# 一键清理
for port in 5173 8001 8002 8003; do
lsof -ti:$port | xargs -r kill -9
done
7.2 常见错误及解决方案
| 错误现象 | 可能原因 | 解决方案 |
|---|---|---|
RuntimeError: 环境变量 GLM_API_KEY 未设置 |
.env 未创建或路径错误 |
在项目根目录创建 .env 并填入 API Key |
cannot pickle '_thread.RLock' object |
DeepAgents 缓存了智能体实例 | 移除 get_agent 函数上的 @lru_cache 装饰器 |
NotImplementedError: bind_tools |
自定义 ChatModel 未实现 bind_tools |
在 GLMChat 中实现 bind_tools 方法 |
Unrecognized field "type" |
SpringBoot Jackson 不允许未知字段 | 在 JacksonConfig 中设置 FAIL_ON_UNKNOWN_PROPERTIES = false |
WebSocket disconnected before connection established |
后端未启动或 CORS 未配置 | 检查后端日志、确认 CORS 配置允许 localhost:5173 |
| SSE 连接成功但无内容返回 | GLM API Key 无效或额度耗尽 | 访问 https://bigmodel.cn 控制台检查 Key 状态 |
ModuleNotFoundError: No module named 'zai' |
未激活虚拟环境 | 先 .\venv\Scripts\activate 再启动 |
7.3 调试技巧
开启详细日志:
python
# FastAPI 启动时加 --log-level debug
uvicorn main:app --port 8001 --log-level debug
查看 SpringBoot 完整日志:
bash
mvn spring-boot:run -Dspring-boot.run.jvmArguments="-Dlogging.level.com.glm=DEBUG"
浏览器开发者工具:
- Network 面板:查看 SSE/WebSocket 连接、消息帧内容
- Console 面板:查看前端日志
- Application → Storage:检查 localStorage / cookie
抓包工具:
- Chrome DevTools Network 面板可直接看到 SSE 的 EventStream 数据
- WebSocket 帧可在 Network → WS 标签查看
7.4 扩展建议
1. 添加消息持久化
将对话历史保存到 SQLite(Python)或 H2(Java):
python
# FastAPI 示例:用 SQLAlchemy 持久化消息
from sqlalchemy import create_engine, Column, String, DateTime
from sqlalchemy.orm import sessionmaker, declarative_base
Base = declarative_base()
class Message(Base):
__tablename__ = 'messages'
id = Column(String, primary_key=True)
role = Column(String)
content = Column(String)
created_at = Column(DateTime)
2. 添加多会话管理
每个会话独立的 messages[],用 localStorage 或后端存储会话列表。
3. 接入更多工具
DeepAgents 的 CUSTOM_TOOLS 列表可加入:
- 网络搜索(search_web)
- 数据库查询(query_db)
- 代码执行(run_python)
4. 部署到生产环境
- 前端:
npm run build生成dist/,用 Nginx 托管 - FastAPI:
gunicorn -k uvicorn.workers.UvicornWorker main:app - DeepAgents:同上
- SpringBoot:
java -jar target/*.jar --spring.profiles.active=prod
5. 添加用户认证
接入 OAuth2 / JWT,在前端请求头携带 Authorization: Bearer <token>,后端中间件验证。
7.5 性能优化清单
| 优化点 | 做法 |
|---|---|
| 前端首屏 | Vite 自动代码分割;可加 <Suspense> + 异步组件懒加载 |
| 后端冷启动 | FastAPI 用 lifespan 预热 GLM 客户端;SpringBoot 用 Lazy Init |
| 流式响应延迟 | SSE 比 WebSocket 略快(无握手升级),但功能更弱 |
| 大量历史消息 | 只向后端发送最近 N 条消息,减少 token 消耗 |
| 并发能力 | uvicorn 用 --workers 4 多进程;SpringBoot 默认 Tomcat 200 线程 |
附录 A:项目目录结构总览
glm-4.7/
├── .env # 环境变量(含 API Key)
├── .env.example # 环境变量模板
├── API_CONTRACT.md # 统一 API 契约
├── tutorial.md # 本教程文档
│
├── python-fastapi/ # 后端 1:FastAPI
│ ├── venv/ # Python 虚拟环境
│ ├── glm_client.py # GLM 同步→异步桥接
│ ├── main.py # FastAPI 应用入口
│ ├── requirements.txt
│ └── README.md
│
├── deepagents-server/ # 后端 2:DeepAgents
│ ├── venv/ # Python 虚拟环境
│ ├── glm_chat.py # 自定义 LangChain ChatModel
│ ├── agent.py # 智能体配置 + 流式事件分发
│ ├── main.py # FastAPI 应用入口
│ ├── requirements.txt
│ └── README.md
│
├── springboot-ai/ # 后端 3:SpringBoot
│ ├── src/main/
│ │ ├── java/com/glm/demo/
│ │ │ ├── Application.java
│ │ │ ├── config/
│ │ │ │ ├── AiConfig.java
│ │ │ │ ├── CorsConfig.java
│ │ │ │ ├── JacksonConfig.java
│ │ │ │ └── WebSocketConfig.java
│ │ │ ├── controller/
│ │ │ │ ├── ChatSseController.java
│ │ │ │ └── ChatWebSocketHandler.java
│ │ │ ├── model/
│ │ │ │ └── ChatRequest.java
│ │ │ └── service/
│ │ │ └── GlmService.java
│ │ └── resources/
│ │ └── application.yml
│ ├── pom.xml
│ └── README.md
│
└── frontend/ # 前端:Vue3
├── src/
│ ├── components/
│ │ ├── ChatWindow.vue
│ │ ├── MessageInput.vue
│ │ ├── ModeSwitcher.vue
│ │ └── ThinkingPanel.vue
│ ├── composables/
│ │ ├── useChat.ts
│ │ ├── useSse.ts
│ │ └── useWebSocket.ts
│ ├── styles/
│ │ └── main.css
│ ├── types/
│ │ └── index.ts
│ ├── utils/
│ │ └── markdown.ts
│ ├── App.vue
│ ├── main.ts
│ └── vite-env.d.ts
├── index.html
├── package.json
├── tsconfig.json
└── vite.config.ts
附录 B:依赖版本参考
| 依赖 | 最新版本 | 本项目使用 |
|---|---|---|
| Python | 3.14 | ✅ 3.14.6 |
| FastAPI | 0.139+ | 最新 |
| uvicorn | 0.51+ | 最新 |
| zai-sdk | 0.2+ | 最新 |
| deepagents | 0.6+ | 最新 |
| langgraph | 1.2+ | 最新 |
| langchain | 1.0+ | 最新 |
| openai | 2.0+ | 最新 |
| Java | 25+ | 25 |
| SpringBoot | 4.1.x | 4.1.0 |
| Spring AI | 2.0.x | 2.0.0 |
| Node.js | 24+ | 24 LTS |
| Vue | 3.5+ | 最新 |
| Vite | 8.x | 8.1.4 |
| TypeScript | 5.5+ | 最新 |
| marked | 18.x | 最新 |
总结
通过本教程,你应该已经掌握了:
- ✅ 创建 4 个独立技术栈项目(Vue3、FastAPI、DeepAgents、SpringBoot)
- ✅ 使用虚拟环境隔离 Python 依赖
- ✅ 调用 GLM-4.7-Flash 并启用 thinking 模式
- ✅ 实现 SSE 和 WebSocket 两种流式传输
- ✅ 解决兼容性陷阱(自定义 GLMChat、绕道 WebClient)
- ✅ 构建支持 Markdown 渲染和消息编辑的前端聊天 UI
下一步学习方向:
- DeepAgents 高级用法:子代理(task 工具)、虚拟文件系统
- LangGraph 状态机原理
- Spring AI 新版本特性跟进
- Vite 8 Rolldown 打包优化
祝你开发顺利!🎉