GLM 多技术栈集成完整教程

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

  1. 访问 https://bigmodel.cn
  2. 注册/登录账号
  3. 进入"控制台" → "API Keys" → 创建新 Key
  4. 复制保存,后续配置使用

为什么需要 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),独立目录便于:

  1. 每个后端独立的依赖管理(requirements.txt vs pom.xml vs package.json)
  2. 每个后端独立的虚拟环境/构建工具
  3. 团队成员可以单独负责某个后端的开发

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),默认 true
  • max_tokens:最大生成 token 数,GLM-4.7-Flash 最大支持 65536
  • temperature:采样温度,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 文件?

  1. 避免硬编码:API Key 不应提交到 Git
  2. 多环境切换:开发/测试/生产用不同 Key
  3. 统一管理:所有后端共享同一份配置,避免复制粘贴

复制 .env.example.env 并填入真实 API Key:

bash 复制代码
cp .env.example .env
# 编辑 .env,将 GLM_API_KEY 改为你的真实 Key

各后端加载 .env 的方式

  • Python 后端(FastAPI/DeepAgents):使用 python-dotenvload_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 后端最核心的代码。面临两个挑战:

  1. GLM 流式响应是同步迭代器 :zai-sdk 的 client.chat.completions.create(stream=True) 返回一个同步对象,只能用 for chunk in response 遍历
  2. 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 入口,负责:

  1. 加载环境变量load_dotenv() 在应用启动时读取项目根目录 .env
  2. CORS 跨域:前端 5173 端口跨域调用 8001 端口的 API
  3. 请求体校验 :用 Pydantic BaseModel 自动校验请求格式
  4. SSE 端点 :用 StreamingResponse + 异步生成器返回 text/event-stream
  5. WebSocket 端点 :用 @app.websocket 装饰器处理双向通信
  6. 健康检查/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 的基础上增加了:

  1. 任务规划工具(write_todos / read_todos):让 LLM 将复杂任务拆解为有序步骤
  2. 虚拟文件系统(write_file / read_file / ls):让 LLM 读写结构化数据
  3. 子代理委派(task):让 LLM 调用隔离的子代理处理子任务
  4. 自动摘要机制:当对话历史过长时自动压缩上下文

这些能力让 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-openaiChatOpenAI 调用 GLM 会导致两个严重问题

  1. reasoning_content 被丢弃ChatOpenAI._parse_chat_completion_chunk 只解析标准 OpenAI 字段(contentroletool_calls),GLM 的 reasoning_content 字段被静默忽略,前端拿不到思考过程
  2. 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 包含三个关键概念:

  1. DEFAULT_SYSTEM_PROMPT :智能体的"人设"。DeepAgents 会自动注入内置提示词,这里追加我们的中文指令,要求 LLM 优先用 write_todos 规划步骤
  2. CUSTOM_TOOLS:自定义工具列表。DeepAgents 会将其与内置工具(write_todos、write_file、task 等)合并
  3. get_agent 函数 :每次请求调用 create_deep_agent 创建新实例。不要用 lru_cache 缓存------LangGraph 内部会 pickle 智能体以传递给子进程,缓存的实例包含 OpenAI 客户端(带 RLock)无法序列化

stream_agent 的事件类型

通过 agent.astream_events(version="v2") 可以订阅智能体执行的每个事件。关键事件:

事件类型 含义 映射帧
on_chat_model_stream LLM 流式输出 chunk reasoning / content
on_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 需要:

  1. Maven 项目结构(pom.xml + 标准目录布局)
  2. 9 个 Java 类分散到 config/controller/model/service 包
  3. 绕过 Spring AI 限制 :因为 Spring AI 2.0 的 OpenAiChatOptions 不支持自定义 thinking 字段,必须降级用 WebClient 直连 GLM
  4. 正确加载 .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 标准字段(modeltemperaturemax_tokenstools 等),没有 extra_bodythinking 这种自定义字段入口。

即使强行设置 OpenAiChatOptions.builder().withExtraBody(Map.of("thinking", Map.of("type", "enabled"))) 也行不通------该字段在反序列化时会被 Jackson 忽略。

解决方案:直接用 WebClient

WebClient 是 Spring WebFlux 的非阻塞 HTTP 客户端,用法:

java 复制代码
webClient.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(全局样式)                          │
└─────────────────────────────────────────────────────────┘

这种分层的好处:

  1. 关注点分离:UI 渲染(Components) vs 业务逻辑(Composables) vs 类型契约(Types)
  2. 可测试性:Composables 是纯逻辑,可在不渲染组件的情况下测试
  3. 可复用性:换 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)
marked Markdown 解析器,将 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。

核心状态

状态 类型 用途
messages ChatMessage[] 已固化的对话历史
thinkingContent string 当前思考内容(流式累积)
currentReply string 当前正式回复(流式累积)
loading boolean 是否正在生成
auxInfo AuxInfo[] DeepAgents 工具调用/任务规划
errorMsg string 错误信息

关键设计:监听后端切换自动中断

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 验证要点

  1. 思考模式:勾选"思考"复选框后发送消息,左侧思考面板应实时显示 GLM 的推理过程
  2. 流式输出:正式回复应逐字/逐句流式出现,带有闪烁光标
  3. 模式切换:切换后端或通信模式时,当前请求应自动中断并清理状态
  4. WebSocket 状态:切换到 WebSocket 模式后,头部应显示连接状态(open/closed/error)
  5. DeepAgents 特色:选择 DeepAgents 后端时,底部辅助信息区会显示工具调用和任务规划过程

关键技术要点总结

为什么需要自定义 GLMChat(DeepAgents)?

langchain-openaiChatOpenAI 在解析流式 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 最新

总结

通过本教程,你应该已经掌握了:

  1. ✅ 创建 4 个独立技术栈项目(Vue3、FastAPI、DeepAgents、SpringBoot)
  2. ✅ 使用虚拟环境隔离 Python 依赖
  3. ✅ 调用 GLM-4.7-Flash 并启用 thinking 模式
  4. ✅ 实现 SSE 和 WebSocket 两种流式传输
  5. ✅ 解决兼容性陷阱(自定义 GLMChat、绕道 WebClient)
  6. ✅ 构建支持 Markdown 渲染和消息编辑的前端聊天 UI

下一步学习方向

  • DeepAgents 高级用法:子代理(task 工具)、虚拟文件系统
  • LangGraph 状态机原理
  • Spring AI 新版本特性跟进
  • Vite 8 Rolldown 打包优化

祝你开发顺利!🎉

相关推荐
浩哥学JavaAI14 小时前
2026年最新AI agent面试(10)_通信与行业动态
人工智能·面试·职场和发展
新知图书14 小时前
7.4 测试与发布(一键生成PPT智能体开发)
人工智能·agent·ai agent·智能体·扣子
糖果店的幽灵14 小时前
【langgraph 从入门到精通graphApi 篇】Checkpoint 持久化与状态管理
人工智能·langgraph
JoyCong199815 小时前
打破远程协助的安全信任困局,ToDesk AI审计功能自动操作留痕
网络·人工智能·科技·安全·电脑·远程工作
罗西的思考15 小时前
【Agentic RL / 强化学习 / OPD】OpenClaw-RL 源码阅读笔记 --- (9)--- Reward Judging
人工智能·算法·机器学习
小保CPP15 小时前
OCR C++ Tesseract按行识别字符
c++·人工智能·ocr·模式识别·光学字符识别
小弥儿16 小时前
GitHub今日热榜 | 2026-07-19
人工智能·学习·github·知识图谱
犀利豆16 小时前
写 Mermaid 总在查语法?我做了个用一句话生成图的小工具 - text2mermaid
人工智能
墨舟的AI笔记16 小时前
端侧推理后端:ONNX Runtime 与跨平台执行提供方
人工智能